From d8cdb7073e93262e7fe1e1e7c8f3c8a0c9fc7d5e Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 1 Jul 2025 11:33:51 -0500 Subject: [PATCH 01/80] Update auth to handle refresh tokens --- .../app/managers/SecureStorage.kt | 124 +++++++++ .../audiobookshelf/app/plugins/AbsDatabase.kt | 61 +++- .../audiobookshelf/app/server/ApiHandler.kt | 263 ++++++++++++++++++ components/connection/ServerConnectForm.vue | 16 +- layouts/default.vue | 26 +- nuxt.config.js | 2 +- plugins/axios.js | 6 +- plugins/capacitor/AbsAudioPlayer.js | 2 +- plugins/capacitor/AbsDatabase.js | 192 +++++++------ plugins/capacitor/index.js | 3 +- plugins/db.js | 20 +- plugins/nativeHttp.js | 221 ++++++++++++++- store/user.js | 33 ++- 13 files changed, 828 insertions(+), 141 deletions(-) create mode 100644 android/app/src/main/java/com/audiobookshelf/app/managers/SecureStorage.kt diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/SecureStorage.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/SecureStorage.kt new file mode 100644 index 00000000..c294f52e --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/SecureStorage.kt @@ -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() + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt index bcf0e34d..39a0b650 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt @@ -8,6 +8,7 @@ import com.audiobookshelf.app.data.* import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.media.MediaEventManager import com.audiobookshelf.app.server.ApiHandler +import com.audiobookshelf.app.managers.SecureStorage import com.fasterxml.jackson.core.json.JsonReadFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue @@ -24,15 +25,19 @@ class AbsDatabase : Plugin() { lateinit var mainActivity: MainActivity lateinit var apiHandler: ApiHandler + lateinit var secureStorage: SecureStorage data class LocalMediaProgressPayload(val value:List) data class LocalLibraryItemsPayload(val value:List) data class LocalFoldersPayload(val value:List) - 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?) + data class ServerConnConfigPayload(val id:String?, val index:Int, val name:String?, val userId:String, val username:String, val token:String, val refreshToken:String?, val address:String?, val customHeaders:Map?) override fun load() { mainActivity = (activity as MainActivity) apiHandler = ApiHandler(mainActivity) + ApiHandler.absDatabaseNotifyListeners = ::notifyListeners + + secureStorage = SecureStorage(mainActivity) DeviceManager.dbManager.cleanLocalMediaProgress() DeviceManager.dbManager.cleanLocalLibraryItems() @@ -120,7 +125,8 @@ class AbsDatabase : Plugin() { val userId = serverConfigPayload.userId val username = serverConfigPayload.username - val token = serverConfigPayload.token + val accessToken = serverConfigPayload.token // New token + val refreshToken = serverConfigPayload.refreshToken // Refresh only sent on first connection GlobalScope.launch(Dispatchers.IO) { if (serverConnectionConfig == null) { // New Server Connection @@ -129,7 +135,16 @@ class AbsDatabase : Plugin() { // Create new server connection config val sscId = DeviceManager.getBase64Id("$serverAddress@$username") 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, userId, username, accessToken, serverConfigPayload.customHeaders) // Add and save DeviceManager.deviceData.serverConnectionConfigs.add(serverConnectionConfig!!) @@ -137,14 +152,20 @@ class AbsDatabase : Plugin() { DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData) } else { var shouldSave = false - if (serverConnectionConfig?.username != username || serverConnectionConfig?.token != token) { + if (serverConnectionConfig?.username != username || serverConnectionConfig?.token != accessToken) { serverConnectionConfig?.userId = userId serverConnectionConfig?.username = username serverConnectionConfig?.name = "${serverConnectionConfig?.address} (${serverConnectionConfig?.username})" - serverConnectionConfig?.token = token + serverConnectionConfig?.token = accessToken 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 if (DeviceManager.deviceData.lastServerConnectionConfigId != serverConfigPayload.id) { DeviceManager.deviceData.lastServerConnectionConfigId = serverConfigPayload.id @@ -163,6 +184,10 @@ class AbsDatabase : Plugin() { fun removeServerConnectionConfig(call:PluginCall) { GlobalScope.launch(Dispatchers.IO) { 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 if (DeviceManager.deviceData.lastServerConnectionConfigId == serverConnectionConfigId) { DeviceManager.deviceData.lastServerConnectionConfigId = null @@ -175,6 +200,32 @@ 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 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 fun logout(call:PluginCall) { GlobalScope.launch(Dispatchers.IO) { diff --git a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt index 4086d6b7..64841c0b 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt @@ -14,6 +14,7 @@ import com.audiobookshelf.app.media.SyncResult import com.audiobookshelf.app.models.User import com.audiobookshelf.app.BuildConfig import com.audiobookshelf.app.plugins.AbsLogger +import com.audiobookshelf.app.managers.SecureStorage import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.core.json.JsonReadFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper @@ -33,9 +34,19 @@ import java.util.concurrent.TimeUnit class ApiHandler(var ctx:Context) { 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 pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build() private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) + private var secureStorage = SecureStorage(ctx) data class LocalSessionsSyncRequestPayload(val sessions:List, val deviceInfo:DeviceInfo) @JsonIgnoreProperties(ignoreUnknown = true) @@ -110,6 +121,13 @@ class ApiHandler(var ctx:Context) { override fun onResponse(call: Call, response: Response) { response.use { + if (it.code == 401) { + // Handle 401 Unauthorized by attempting token refresh + Log.d(tag, "Received 401, attempting token refresh") + handleTokenRefresh(request, httpClient, cb) + return + } + if (!it.isSuccessful) { val jsobj = JSObject() jsobj.put("error", "Unexpected code $response") @@ -142,6 +160,251 @@ 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 { + Log.d(tag, "handleTokenRefresh: Starting token refresh process") + + // Get current server connection config ID + val serverConnectionConfigId = DeviceManager.serverConnectionConfigId + if (serverConnectionConfigId.isEmpty()) { + Log.e(tag, "handleTokenRefresh: No server connection config ID available") + 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()) { + Log.e(tag, "handleTokenRefresh: No refresh token available for server $serverConnectionConfigId") + 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("Authorization", "Bearer $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) + handleRefreshFailure(callback) + } + + override fun onResponse(call: Call, response: Response) { + response.use { + if (!it.isSuccessful) { + Log.e(tag, "handleTokenRefresh: Refresh request failed with status ${it.code}") + handleRefreshFailure(callback) + return + } + + val bodyString = it.body!!.string() + try { + val responseJson = JSONObject(bodyString) + val userObj = responseJson.optJSONObject("user") + + if (userObj == null) { + Log.e(tag, "handleTokenRefresh: No user object in refresh response") + handleRefreshFailure(callback) + return + } + + val newAccessToken = userObj.optString("accessToken") + val newRefreshToken = userObj.optString("refreshToken") + + if (newAccessToken.isEmpty()) { + Log.e(tag, "handleTokenRefresh: No access token in refresh response") + 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) + 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") + } + } catch (e: Exception) { + Log.e(tag, "updateTokens: Failed to update tokens", e) + } + } + + /** + * 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) + 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}") + 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) + 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.isNullOrEmpty()) { + secureStorage.removeRefreshToken(serverConnectionConfigId) + } + + val errorObj = JSObject() + errorObj.put("error", "Authentication failed - please login again") + callback(errorObj) + + } 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) { getRequest("/api/me", null, null) { if (it.has("error")) { diff --git a/components/connection/ServerConnectForm.vue b/components/connection/ServerConnectForm.vue index 0c35b158..1408797f 100644 --- a/components/connection/ServerConnectForm.vue +++ b/components/connection/ServerConnectForm.vue @@ -436,7 +436,7 @@ export default { } this.error = null - var payload = await this.authenticateToken() + const payload = await this.authenticateToken() if (payload) { this.setUserAndConnection(payload) @@ -597,7 +597,7 @@ export default { }) }, requestServerLogin() { - return this.postRequest(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password || '' }, this.serverConfig.customHeaders, 20000) + return this.postRequest(`${this.serverConfig.address}/login?return_tokens=true`, { username: this.serverConfig.username, password: this.password || '' }, this.serverConfig.customHeaders, 20000) .then((data) => { if (!data.user) { console.error(data.error) @@ -806,7 +806,7 @@ export default { this.error = null this.processing = true - var payload = await this.requestServerLogin() + const payload = await this.requestServerLogin() this.processing = false if (payload) { this.setUserAndConnection(payload) @@ -830,8 +830,13 @@ export default { } this.serverConfig.userId = user.id - this.serverConfig.token = user.token this.serverConfig.username = user.username + // Tokens only returned from /login endpoint + if (user.accessToken) { + this.serverConfig.token = user.accessToken + this.serverConfig.refreshToken = user.refreshToken + } + delete this.serverConfig.version var serverConnectionConfig = await this.$db.setServerConnectionConfig(this.serverConfig) @@ -850,6 +855,7 @@ export default { } this.$store.commit('user/setUser', user) + this.$store.commit('user/setAccessToken', serverConnectionConfig.token) this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig) this.$socket.connect(this.serverConfig.address, this.serverConfig.token) @@ -865,6 +871,7 @@ export default { this.error = null this.processing = true + // TODO: Handle refresh token const authRes = await this.postRequest(`${this.serverConfig.address}/api/authorize`, null, { Authorization: `Bearer ${this.serverConfig.token}` }).catch((error) => { console.error('[ServerConnectForm] Server auth failed', error) const errorMsg = error.message || error @@ -882,6 +889,7 @@ export default { }, init() { if (this.lastServerConnectionConfig) { + console.log('[ServerConnectForm] init with lastServerConnectionConfig', this.lastServerConnectionConfig) this.connectToServer(this.lastServerConnectionConfig) } else { this.showForm = !this.serverConnectionConfigs.length diff --git a/layouts/default.vue b/layouts/default.vue index 09a9355b..23eed383 100644 --- a/layouts/default.vue +++ b/layouts/default.vue @@ -99,21 +99,6 @@ export default { 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() { console.warn('[default] attemptConnection') if (!this.networkConnected) { @@ -145,10 +130,18 @@ export default { 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})` }) return false }) + if (!authRes) { this.attemptingConnection = false return @@ -168,6 +161,7 @@ export default { const serverConnectionConfig = await this.$db.setServerConnectionConfig(serverConfig) this.$store.commit('user/setUser', user) + this.$store.commit('user/setAccessToken', serverConnectionConfig.token) this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig) this.$socket.connect(serverConnectionConfig.address, serverConnectionConfig.token) diff --git a/nuxt.config.js b/nuxt.config.js index 60b108a5..09cab389 100644 --- a/nuxt.config.js +++ b/nuxt.config.js @@ -30,7 +30,7 @@ export default { 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, diff --git a/plugins/axios.js b/plugins/axios.js index 73b97238..5974874f 100644 --- a/plugins/axios.js +++ b/plugins/axios.js @@ -1,5 +1,5 @@ export default function ({ $axios, store }) { - $axios.onRequest(config => { + $axios.onRequest((config) => { console.log('[Axios] Making request to ' + config.url) if (config.url.startsWith('http:') || config.url.startsWith('https:') || config.url.startsWith('capacitor:')) { return @@ -26,7 +26,7 @@ export default function ({ $axios, store }) { console.log('[Axios] Request out', config.url) }) - $axios.onError(error => { + $axios.onError((error) => { console.error('Axios error code', error) }) -} \ No newline at end of file +} diff --git a/plugins/capacitor/AbsAudioPlayer.js b/plugins/capacitor/AbsAudioPlayer.js index 43326165..b00dde3f 100644 --- a/plugins/capacitor/AbsAudioPlayer.js +++ b/plugins/capacitor/AbsAudioPlayer.js @@ -245,7 +245,7 @@ class AbsAudioPlayerWeb extends WebPlugin { this.trackStartTime = Math.max(0, this.startTime - (this.currentTrack.startOffset || 0)) const serverAddressUrl = new URL(vuexStore.getters['user/getServerAddress']) const serverHost = `${serverAddressUrl.protocol}//${serverAddressUrl.host}` - this.player.src = `${serverHost}${this.currentTrack.contentUrl}?token=${vuexStore.getters['user/getToken']}` + this.player.src = `${serverHost}${this.currentTrack.contentUrl}` console.log(`[AbsAudioPlayer] Loading track src ${this.player.src}`) this.player.load() this.player.playbackRate = this.playbackRate diff --git a/plugins/capacitor/AbsDatabase.js b/plugins/capacitor/AbsDatabase.js index fc34a6fa..971f7ee0 100644 --- a/plugins/capacitor/AbsDatabase.js +++ b/plugins/capacitor/AbsDatabase.js @@ -1,4 +1,4 @@ -import { registerPlugin, Capacitor, WebPlugin } from '@capacitor/core'; +import { registerPlugin, Capacitor, WebPlugin } from '@capacitor/core' class AbsDatabaseWeb extends WebPlugin { constructor() { @@ -22,7 +22,7 @@ class AbsDatabaseWeb extends WebPlugin { async setCurrentServerConnectionConfig(serverConnectionConfig) { 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) { deviceData.lastServerConnectionConfigId = ssc.id ssc.name = `${ssc.address} (${serverConnectionConfig.username})` @@ -30,6 +30,13 @@ class AbsDatabaseWeb extends WebPlugin { ssc.userId = serverConnectionConfig.userId ssc.username = serverConnectionConfig.username 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)) } else { ssc = { @@ -42,6 +49,13 @@ class AbsDatabaseWeb extends WebPlugin { token: serverConnectionConfig.token, 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.lastServerConnectionConfigId = ssc.id localStorage.setItem('device', JSON.stringify(deviceData)) @@ -49,10 +63,16 @@ class AbsDatabaseWeb extends WebPlugin { return ssc } + async getRefreshToken({ serverConnectionConfigId }) { + console.log('[AbsDatabase] Getting refresh token...', serverConnectionConfigId) + const refreshToken = localStorage.getItem(`refresh_token_${serverConnectionConfigId}`) + return refreshToken ? { refreshToken } : null + } + async removeServerConnectionConfig(serverConnectionConfigCallObject) { var serverConnectionConfigId = serverConnectionConfigCallObject.serverConnectionConfigId 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)) } @@ -85,79 +105,81 @@ class AbsDatabaseWeb extends WebPlugin { } async getLocalLibraryItems(payload) { return { - value: [{ - id: 'local_test', - libraryItemId: 'test34', - serverAddress: 'https://abs.test.com', - serverUserId: 'test56', - folderId: 'test1', - absolutePath: 'a', - contentUrl: 'c', - isInvalid: false, - mediaType: 'book', - media: { - metadata: { - title: 'Test Book', - authorName: 'Test Author Name' + value: [ + { + id: 'local_test', + libraryItemId: 'test34', + serverAddress: 'https://abs.test.com', + serverUserId: 'test56', + folderId: 'test1', + absolutePath: 'a', + contentUrl: 'c', + isInvalid: false, + mediaType: 'book', + media: { + metadata: { + 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, - tags: [], - audioFiles: [], - chapters: [], - tracks: [ + localFiles: [ { - index: 1, - startOffset: 0, - duration: 10000, - title: 'Track Title 1', + id: 'lf1', + filename: 'lf1.mp3', contentUrl: 'test', + absolutePath: 'test', + simplePath: '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: {} + size: 39048290 } - ] - }, - localFiles: [ - { - id: 'lf1', - filename: 'lf1.mp3', - contentUrl: 'test', - absolutePath: 'test', - simplePath: 'test', - mimeType: 'audio/mpeg', - size: 39048290 - } - ], - coverContentUrl: null, - coverAbsolutePath: null, - isLocal: true - }] + ], + coverContentUrl: null, + coverAbsolutePath: null, + isLocal: true + } + ] } } async getLocalLibraryItemsInFolder({ folderId }) { @@ -167,7 +189,7 @@ class AbsDatabaseWeb extends WebPlugin { return this.getLocalLibraryItems().then((data) => data.value[0]) } 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() { return { @@ -182,7 +204,7 @@ class AbsDatabaseWeb extends WebPlugin { isFinished: false, lastUpdate: 394089090, startedAt: 239048209, - finishedAt: null, + finishedAt: null // For local lib items from server to support server sync // var serverConnectionConfigId:String?, // var serverAddress:String?, @@ -240,7 +262,7 @@ class AbsDatabaseWeb extends WebPlugin { serverSyncAttempted: true, serverSyncSuccess: true, 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', @@ -250,7 +272,7 @@ class AbsDatabaseWeb extends WebPlugin { serverSyncAttempted: false, serverSyncSuccess: null, serverSyncMessage: null, - timestamp: Date.now() - (1000 * 60 * 22) // 22 mins ago + timestamp: Date.now() - 1000 * 60 * 22 // 22 mins ago }, { name: 'Pause', @@ -260,7 +282,7 @@ class AbsDatabaseWeb extends WebPlugin { serverSyncAttempted: true, serverSyncSuccess: false, 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', @@ -270,7 +292,7 @@ class AbsDatabaseWeb extends WebPlugin { serverSyncAttempted: true, serverSyncSuccess: true, 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', @@ -280,7 +302,7 @@ class AbsDatabaseWeb extends WebPlugin { serverSyncAttempted: true, serverSyncSuccess: true, 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', @@ -290,7 +312,7 @@ class AbsDatabaseWeb extends WebPlugin { serverSyncAttempted: true, serverSyncSuccess: true, 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', @@ -300,7 +322,7 @@ class AbsDatabaseWeb extends WebPlugin { serverSyncAttempted: false, serverSyncSuccess: null, serverSyncMessage: null, - timestamp: Date.now() - (1000 * 60 * 60) // 1 hour ago + timestamp: Date.now() - 1000 * 60 * 60 // 1 hour ago }, { name: 'Stop', @@ -310,7 +332,7 @@ class AbsDatabaseWeb extends WebPlugin { serverSyncAttempted: true, serverSyncSuccess: true, 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', @@ -320,7 +342,7 @@ class AbsDatabaseWeb extends WebPlugin { serverSyncAttempted: true, serverSyncSuccess: true, 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', @@ -330,7 +352,7 @@ class AbsDatabaseWeb extends WebPlugin { serverSyncAttempted: false, serverSyncSuccess: 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', @@ -340,7 +362,7 @@ class AbsDatabaseWeb extends WebPlugin { serverSyncAttempted: false, serverSyncSuccess: 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 +373,4 @@ const AbsDatabase = registerPlugin('AbsDatabase', { web: () => new AbsDatabaseWeb() }) -export { AbsDatabase } \ No newline at end of file +export { AbsDatabase } diff --git a/plugins/capacitor/index.js b/plugins/capacitor/index.js index 29b4ae87..1a0bf03f 100644 --- a/plugins/capacitor/index.js +++ b/plugins/capacitor/index.js @@ -2,9 +2,10 @@ import Vue from 'vue' import { AbsAudioPlayer } from './AbsAudioPlayer' import { AbsDownloader } from './AbsDownloader' import { AbsFileSystem } from './AbsFileSystem' +import { AbsDatabase } from './AbsDatabase' import { AbsLogger } from './AbsLogger' import { Capacitor } from '@capacitor/core' Vue.prototype.$platform = Capacitor.getPlatform() -export { AbsAudioPlayer, AbsDownloader, AbsFileSystem, AbsLogger } +export { AbsAudioPlayer, AbsDownloader, AbsFileSystem, AbsLogger, AbsDatabase } diff --git a/plugins/db.js b/plugins/db.js index 8819434f..747e6e56 100644 --- a/plugins/db.js +++ b/plugins/db.js @@ -1,7 +1,7 @@ import { AbsDatabase } from './capacitor/AbsDatabase' class DbService { - constructor() { } + constructor() {} getDeviceData() { return AbsDatabase.getDeviceData().then((data) => { @@ -29,10 +29,12 @@ class DbService { } getLocalFolders() { - return AbsDatabase.getLocalFolders().then((data) => data.value).catch((error) => { - console.error('Failed to load', error) - return null - }) + return AbsDatabase.getLocalFolders() + .then((data) => data.value) + .catch((error) => { + console.error('Failed to load', error) + return null + }) } getLocalFolder(folderId) { @@ -103,4 +105,10 @@ class DbService { export default ({ app, store }, inject) => { inject('db', new DbService()) -} \ No newline at end of file + + // Listen for token refresh events from native app + AbsDatabase.addListener('onTokenRefresh', (data) => { + console.log('[db] onTokenRefresh', data) + store.commit('user/setAccessToken', data.accessToken) + }) +} diff --git a/plugins/nativeHttp.js b/plugins/nativeHttp.js index f2586dc2..2d4bc908 100644 --- a/plugins/nativeHttp.js +++ b/plugins/nativeHttp.js @@ -1,8 +1,13 @@ import { CapacitorHttp } from '@capacitor/core' +import { AbsDatabase } from '@/plugins/capacitor' export default function ({ store }, inject) { 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 const headers = {} if (!url.startsWith('http') && !url.startsWith('capacitor')) { @@ -12,9 +17,8 @@ export default function ({ store }, inject) { } else { console.warn('[nativeHttp] No Bearer Token for request') } - const serverUrl = store.getters['user/getServerAddress'] - if (serverUrl) { - url = `${serverUrl}${url}` + if (serverConnectionConfig?.address) { + url = `${serverConnectionConfig.address}${url}` } } if (data) { @@ -27,7 +31,12 @@ export default function ({ store }, inject) { data, headers, ...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) { console.error(`[nativeHttp] ${res.status} status for url "${url}"`) throw new Error(res.data) @@ -35,6 +44,206 @@ export default function ({ store }, inject) { 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 }} 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 refreshTokenData = await this.getRefreshToken(serverConnectionConfig.id) + if (!refreshTokenData || !refreshTokenData.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(refreshTokenData.refreshToken, serverConnectionConfig.address) + if (!newTokens || !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 newHeaders = options?.headers ? { ...options.headers } : { ...headers } + newHeaders['Authorization'] = `Bearer ${newTokens.accessToken}` + + const retryResponse = await CapacitorHttp.request({ + method, + url, + data, + ...options, + headers: newHeaders + }) + + 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() + throw error + } + }, + + /** + * Retrieves refresh token from secure storage + * @param {string} serverConnectionConfigId - Server connection config ID + * @returns {Promise} - Promise that resolves with refresh token data or null + */ + async getRefreshToken(serverConnectionConfigId) { + try { + console.log('[nativeHttp] Getting refresh token...') + return await AbsDatabase.getRefreshToken({ serverConnectionConfigId }) + } catch (error) { + console.error('[nativeHttp] Failed to get refresh token:', error) + return null + } + }, + + /** + * Refreshes the access token using the refresh token + * @param {string} refreshToken - The refresh token + * @param {string} serverAddress - The server address + * @returns {Promise} - 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', + Authorization: `Bearer ${refreshToken}`, + 'X-Return-Tokens': 'true' + }, + 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, + refreshToken: userResponseData.user.refreshToken || refreshToken // Use new refresh token if provided, otherwise keep the old one + } + } 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 }} 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 + const savedConfig = await AbsDatabase.setCurrentServerConnectionConfig(updatedConfig) + + // Update the store + store.commit('user/setAccessToken', tokens.accessToken) + + 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 + * @returns {Promise} - Promise that resolves when logout is complete + */ + async handleRefreshFailure() { + try { + console.log('[nativeHttp] Handling refresh failure - logging out user') + + // Clear the store + store.commit('user/setUser', null) + store.commit('user/setAccessToken', null) + store.commit('user/setServerConnectionConfig', null) + + // Logout from database + await AbsDatabase.logout() + + // Redirect to login page + if (window.location.pathname !== '/connect') { + window.location.href = '/connect' + } + } catch (error) { + console.error('[nativeHttp] Failed to handle refresh failure:', error) + } + }, + + /** + * Gets device data from the database + * @returns {Promise} - Promise that resolves with device data + */ + async getDeviceData() { + try { + return await AbsDatabase.getDeviceData() + } catch (error) { + console.error('[nativeHttp] Failed to get device data:', error) + return { serverConnectionConfigs: [] } + } + }, + get(url, options = {}) { return this.request('GET', url, undefined, options) }, @@ -49,4 +258,4 @@ export default function ({ store }, inject) { } } inject('nativeHttp', nativeHttp) -} \ No newline at end of file +} diff --git a/store/user.js b/store/user.js index 78c2fcfe..120702ad 100644 --- a/store/user.js +++ b/store/user.js @@ -2,6 +2,7 @@ import { Browser } from '@capacitor/browser' export const state = () => ({ user: null, + accessToken: null, serverConnectionConfig: null, settings: { mobileOrderBy: 'addedAt', @@ -17,7 +18,7 @@ export const getters = { getIsRoot: (state) => state.user && state.user.type === 'root', getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'), getToken: (state) => { - return state.user?.token || null + return state.accessToken || null }, getServerConnectionConfigId: (state) => { return state.serverConnectionConfig?.id || null @@ -31,16 +32,18 @@ export const getters = { getCustomHeaders: (state) => { return state.serverConnectionConfig?.customHeaders || null }, - getUserMediaProgress: (state) => (libraryItemId, episodeId = null) => { - if (!state.user?.mediaProgress) return null - return state.user.mediaProgress.find(li => { - if (episodeId && li.episodeId !== episodeId) return false - return li.libraryItemId == libraryItemId - }) - }, + getUserMediaProgress: + (state) => + (libraryItemId, episodeId = null) => { + if (!state.user?.mediaProgress) return null + return state.user.mediaProgress.find((li) => { + if (episodeId && li.episodeId !== episodeId) return false + return li.libraryItemId == libraryItemId + }) + }, getUserBookmarksForItem: (state) => (libraryItemId) => { 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) => { return state.settings?.[key] || null @@ -143,13 +146,17 @@ export const mutations = { setUser(state, user) { state.user = user }, + setAccessToken(state, accessToken) { + console.log('[user] setAccessToken', accessToken) + state.accessToken = accessToken + }, removeMediaProgress(state, id) { 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) { 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) { state.user.mediaProgress.splice(mediaProgressIndex, 1, data) } else { @@ -174,9 +181,9 @@ export const mutations = { }, deleteBookmark(state, { libraryItemId, time }) { 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 return true }) } -} \ No newline at end of file +} From 467fedbfe7051fd216b462b36e150d4f357b01c3 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 4 Jul 2025 17:41:19 -0500 Subject: [PATCH 02/80] Update to use x-refresh-token header, update logout to clear refresh token, add AbsLogger logs for android refresh --- .../audiobookshelf/app/plugins/AbsDatabase.kt | 10 +++ .../audiobookshelf/app/server/ApiHandler.kt | 25 +++--- components/app/SideDrawer.vue | 16 +--- components/readers/Reader.vue | 1 + pages/account.vue | 12 +-- plugins/capacitor/AbsAudioPlayer.js | 17 +++- plugins/capacitor/AbsDatabase.js | 6 ++ plugins/db.js | 20 +++++ plugins/nativeHttp.js | 77 ++++++------------- store/user.js | 29 +++++++ 10 files changed, 123 insertions(+), 90 deletions(-) diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt index 39a0b650..861ae692 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt @@ -216,6 +216,16 @@ class AbsDatabase : Plugin() { } } + @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() diff --git a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt index 64841c0b..a3a9b550 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt @@ -123,7 +123,7 @@ class ApiHandler(var ctx:Context) { response.use { if (it.code == 401) { // Handle 401 Unauthorized by attempting token refresh - Log.d(tag, "Received 401, attempting token refresh") + AbsLogger.info(tag, "makeRequest: 401 Unauthorized for request to \"${request.url}\" - attempt token refresh") handleTokenRefresh(request, httpClient, cb) return } @@ -175,12 +175,12 @@ class ApiHandler(var ctx:Context) { */ private fun handleTokenRefresh(originalRequest: Request, httpClient: OkHttpClient?, callback: (JSObject) -> Unit) { try { - Log.d(tag, "handleTokenRefresh: Starting token refresh process") + 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()) { - Log.e(tag, "handleTokenRefresh: No server connection config ID available") + 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) @@ -190,7 +190,7 @@ class ApiHandler(var ctx:Context) { // Get refresh token from secure storage val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId) if (refreshToken.isNullOrEmpty()) { - Log.e(tag, "handleTokenRefresh: No refresh token available for server $serverConnectionConfigId") + 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) @@ -203,7 +203,7 @@ class ApiHandler(var ctx:Context) { val refreshEndpoint = "${DeviceManager.serverAddress}/auth/refresh" val refreshRequest = Request.Builder() .url(refreshEndpoint) - .addHeader("Authorization", "Bearer $refreshToken") + .addHeader("x-refresh-token", refreshToken) .addHeader("Content-Type", "application/json") .post(EMPTY_REQUEST) .build() @@ -213,13 +213,14 @@ class ApiHandler(var ctx:Context) { 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) { - Log.e(tag, "handleTokenRefresh: Refresh request failed with status ${it.code}") + AbsLogger.error(tag, "handleTokenRefresh: Refresh request failed with status ${it.code} for server ${DeviceManager.serverConnectionConfigString}") handleRefreshFailure(callback) return } @@ -230,7 +231,7 @@ class ApiHandler(var ctx:Context) { val userObj = responseJson.optJSONObject("user") if (userObj == null) { - Log.e(tag, "handleTokenRefresh: No user object in refresh response") + AbsLogger.error(tag, "handleTokenRefresh: No user object in refresh response for server ${DeviceManager.serverConnectionConfigString}") handleRefreshFailure(callback) return } @@ -239,7 +240,7 @@ class ApiHandler(var ctx:Context) { val newRefreshToken = userObj.optString("refreshToken") if (newAccessToken.isEmpty()) { - Log.e(tag, "handleTokenRefresh: No access token in refresh response") + AbsLogger.error(tag, "handleTokenRefresh: No access token in refresh response for server ${DeviceManager.serverConnectionConfigString}") handleRefreshFailure(callback) return } @@ -255,6 +256,7 @@ class ApiHandler(var ctx:Context) { } 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) } } @@ -297,8 +299,10 @@ class ApiHandler(var ctx:Context) { // 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})") } } @@ -325,6 +329,7 @@ class ApiHandler(var ctx:Context) { 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) @@ -334,6 +339,7 @@ class ApiHandler(var ctx:Context) { 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) @@ -366,6 +372,7 @@ class ApiHandler(var ctx:Context) { } 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) @@ -389,7 +396,7 @@ class ApiHandler(var ctx:Context) { // Remove refresh token from secure storage val serverConnectionConfigId = DeviceManager.serverConnectionConfigId - if (!serverConnectionConfigId.isNullOrEmpty()) { + if (serverConnectionConfigId.isNotEmpty()) { secureStorage.removeRefreshToken(serverConnectionConfigId) } diff --git a/components/app/SideDrawer.vue b/components/app/SideDrawer.vue index ee788317..7173f790 100644 --- a/components/app/SideDrawer.vue +++ b/components/app/SideDrawer.vue @@ -179,21 +179,7 @@ export default { this.show = false }, async logout() { - if (this.user) { - 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) + await this.$store.dispatch('user/logout', {}) }, async disconnect() { await this.$hapticsImpact() diff --git a/components/readers/Reader.vue b/components/readers/Reader.vue index 137457a4..b1318617 100644 --- a/components/readers/Reader.vue +++ b/components/readers/Reader.vue @@ -260,6 +260,7 @@ export default { return null }, ebookFile() { + if (!this.media) return null // ebook file id is passed when reading a supplementary ebook if (this.ebookFileId) { return this.selectedLibraryItem.libraryFiles.find((lf) => lf.ino === this.ebookFileId) diff --git a/pages/account.vue b/pages/account.vue index eb686e8e..78c16b8b 100644 --- a/pages/account.vue +++ b/pages/account.vue @@ -48,17 +48,7 @@ export default { methods: { async logout() { await this.$hapticsImpact() - if (this.user) { - 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) + await this.$store.dispatch('user/logout', {}) this.$router.push('/connect') } }, diff --git a/plugins/capacitor/AbsAudioPlayer.js b/plugins/capacitor/AbsAudioPlayer.js index b00dde3f..f16b882b 100644 --- a/plugins/capacitor/AbsAudioPlayer.js +++ b/plugins/capacitor/AbsAudioPlayer.js @@ -70,7 +70,12 @@ class AbsAudioPlayerWeb extends WebPlugin { const deviceInfo = { 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 (startTime !== undefined && startTime !== null) playbackSession.currentTime = startTime this.setAudioPlayer(playbackSession, playWhenReady) @@ -245,7 +250,15 @@ class AbsAudioPlayerWeb extends WebPlugin { this.trackStartTime = Math.max(0, this.startTime - (this.currentTrack.startOffset || 0)) const serverAddressUrl = new URL(vuexStore.getters['user/getServerAddress']) const serverHost = `${serverAddressUrl.protocol}//${serverAddressUrl.host}` - this.player.src = `${serverHost}${this.currentTrack.contentUrl}` + + 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}`) this.player.load() this.player.playbackRate = this.playbackRate diff --git a/plugins/capacitor/AbsDatabase.js b/plugins/capacitor/AbsDatabase.js index 971f7ee0..059abe30 100644 --- a/plugins/capacitor/AbsDatabase.js +++ b/plugins/capacitor/AbsDatabase.js @@ -69,6 +69,11 @@ class AbsDatabaseWeb extends WebPlugin { return refreshToken ? { refreshToken } : null } + async clearRefreshToken({ serverConnectionConfigId }) { + console.log('[AbsDatabase] Clearing refresh token...', serverConnectionConfigId) + localStorage.removeItem(`refresh_token_${serverConnectionConfigId}`) + } + async removeServerConnectionConfig(serverConnectionConfigCallObject) { var serverConnectionConfigId = serverConnectionConfigCallObject.serverConnectionConfigId var deviceData = await this.getDeviceData() @@ -77,6 +82,7 @@ class AbsDatabaseWeb extends WebPlugin { } async logout() { + console.log('[AbsDatabase] Logging out...') var deviceData = await this.getDeviceData() deviceData.lastServerConnectionConfigId = null localStorage.setItem('device', JSON.stringify(deviceData)) diff --git a/plugins/db.js b/plugins/db.js index 747e6e56..7b7740e1 100644 --- a/plugins/db.js +++ b/plugins/db.js @@ -10,6 +10,26 @@ class DbService { }) } + /** + * Retrieves refresh token from secure storage + * @param {string} serverConnectionConfigId + * @return {Promise} + */ + async getRefreshToken(serverConnectionConfigId) { + const refreshTokenData = await AbsDatabase.getRefreshToken({ serverConnectionConfigId }) + return refreshTokenData?.refreshToken + } + + /** + * Clears refresh token from secure storage + * @param {string} serverConnectionConfigId + * @returns {Promise} + */ + async clearRefreshToken(serverConnectionConfigId) { + const result = await AbsDatabase.clearRefreshToken({ serverConnectionConfigId }) + return !!result?.success + } + setServerConnectionConfig(serverConnectionConfig) { return AbsDatabase.setCurrentServerConnectionConfig(serverConnectionConfig).then((data) => { console.log('Set server connection config', JSON.stringify(data)) diff --git a/plugins/nativeHttp.js b/plugins/nativeHttp.js index 2d4bc908..68b8bc6c 100644 --- a/plugins/nativeHttp.js +++ b/plugins/nativeHttp.js @@ -1,7 +1,6 @@ import { CapacitorHttp } from '@capacitor/core' -import { AbsDatabase } from '@/plugins/capacitor' -export default function ({ store }, inject) { +export default function ({ store, $db }, inject) { const nativeHttp = { async request(method, _url, data, options = {}) { // When authorizing before a config is set, server config gets passed in as an option @@ -9,7 +8,7 @@ export default function ({ store }, inject) { delete options.serverConnectionConfig let url = _url - const headers = {} + let headers = {} if (!url.startsWith('http') && !url.startsWith('capacitor')) { const bearerToken = store.getters['user/getToken'] if (bearerToken) { @@ -24,6 +23,10 @@ export default function ({ store }, inject) { if (data) { headers['Content-Type'] = 'application/json' } + if (options.headers) { + headers = { ...headers, ...options.headers } + delete options.headers + } console.log(`[nativeHttp] Making ${method} request to ${url}`) return CapacitorHttp.request({ method, @@ -65,15 +68,15 @@ export default function ({ store }, inject) { } // Get refresh token from secure storage - const refreshTokenData = await this.getRefreshToken(serverConnectionConfig.id) - if (!refreshTokenData || !refreshTokenData.refreshToken) { + 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(refreshTokenData.refreshToken, serverConnectionConfig.address) - if (!newTokens || !newTokens.accessToken) { + 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') } @@ -83,15 +86,15 @@ export default function ({ store }, inject) { // Retry the original request with the new token console.log('[nativeHttp] Retrying original request with new token...') - const newHeaders = options?.headers ? { ...options.headers } : { ...headers } - newHeaders['Authorization'] = `Bearer ${newTokens.accessToken}` - const retryResponse = await CapacitorHttp.request({ method, url, data, - ...options, - headers: newHeaders + headers: { + ...headers, + Authorization: `Bearer ${newTokens.accessToken}` + }, + ...options }) if (retryResponse.status >= 400) { @@ -104,26 +107,11 @@ export default function ({ store }, inject) { console.error('[nativeHttp] Token refresh failed:', error) // If refresh fails, redirect to login - await this.handleRefreshFailure() + await this.handleRefreshFailure(serverConnectionConfig?.id) throw error } }, - /** - * Retrieves refresh token from secure storage - * @param {string} serverConnectionConfigId - Server connection config ID - * @returns {Promise} - Promise that resolves with refresh token data or null - */ - async getRefreshToken(serverConnectionConfigId) { - try { - console.log('[nativeHttp] Getting refresh token...') - return await AbsDatabase.getRefreshToken({ serverConnectionConfigId }) - } catch (error) { - console.error('[nativeHttp] Failed to get refresh token:', error) - return null - } - }, - /** * Refreshes the access token using the refresh token * @param {string} refreshToken - The refresh token @@ -142,8 +130,7 @@ export default function ({ store }, inject) { url: `${serverAddress}/auth/refresh`, headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${refreshToken}`, - 'X-Return-Tokens': 'true' + 'x-refresh-token': refreshToken }, data: {} }) @@ -162,7 +149,8 @@ export default function ({ store }, inject) { console.log('[nativeHttp] Successfully refreshed access token') return { accessToken: userResponseData.user.accessToken, - refreshToken: userResponseData.user.refreshToken || refreshToken // Use new refresh token if provided, otherwise keep the old one + // 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) @@ -190,7 +178,7 @@ export default function ({ store }, inject) { } // Save updated config to secure storage - const savedConfig = await AbsDatabase.setCurrentServerConnectionConfig(updatedConfig) + const savedConfig = await $db.setServerConnectionConfig(updatedConfig) // Update the store store.commit('user/setAccessToken', tokens.accessToken) @@ -208,19 +196,15 @@ export default function ({ store }, inject) { /** * Handles the case when token refresh fails + * @param {string} [serverConnectionConfigId] * @returns {Promise} - Promise that resolves when logout is complete */ - async handleRefreshFailure() { + async handleRefreshFailure(serverConnectionConfigId) { try { console.log('[nativeHttp] Handling refresh failure - logging out user') - // Clear the store - store.commit('user/setUser', null) - store.commit('user/setAccessToken', null) - store.commit('user/setServerConnectionConfig', null) - - // Logout from database - await AbsDatabase.logout() + // Logout from server and clear store + await store.dispatch('user/logout', { serverConnectionConfigId }) // Redirect to login page if (window.location.pathname !== '/connect') { @@ -231,19 +215,6 @@ export default function ({ store }, inject) { } }, - /** - * Gets device data from the database - * @returns {Promise} - Promise that resolves with device data - */ - async getDeviceData() { - try { - return await AbsDatabase.getDeviceData() - } catch (error) { - console.error('[nativeHttp] Failed to get device data:', error) - return { serverConnectionConfigs: [] } - } - }, - get(url, options = {}) { return this.request('GET', url, undefined, options) }, diff --git a/store/user.js b/store/user.js index 120702ad..4b013157 100644 --- a/store/user.js +++ b/store/user.js @@ -1,4 +1,5 @@ import { Browser } from '@capacitor/browser' +import { AbsLogger } from '@/plugins/capacitor' export const state = () => ({ user: null, @@ -135,12 +136,40 @@ export const actions = { } catch (error) { console.error('Error opening browser', error) } + }, + async logout({ state, commit }, { serverConnectionConfigId }) { + if (state.serverConnectionConfig) { + 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.clearRefreshToken(state.serverConnectionConfig.id) + } else if (serverConnectionConfigId) { + // When refresh fails before a server connection config is set, clear refresh token for server connection config + await this.$db.clearRefreshToken(serverConnectionConfigId) + } + + 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'}` }) } } export const mutations = { logout(state) { state.user = null + state.accessToken = null state.serverConnectionConfig = null }, setUser(state, user) { From 44613e12f14d4eda93193a10b4b9e5762a765106 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 5 Jul 2025 09:28:40 -0500 Subject: [PATCH 03/80] Update serverConnectionConfig to include server version, update server track URL and server cover image URL based on server version --- .../com/audiobookshelf/app/data/AudioTrack.kt | 1 + .../audiobookshelf/app/data/DeviceClasses.kt | 3 ++ .../audiobookshelf/app/data/LibraryItem.kt | 5 +++ .../app/data/PlaybackSession.kt | 36 +++++++++++++++---- .../app/device/DeviceManager.kt | 36 +++++++++++++++++++ .../audiobookshelf/app/plugins/AbsDatabase.kt | 14 ++++---- components/connection/ServerConnectForm.vue | 2 +- layouts/default.vue | 1 + plugins/capacitor/AbsDatabase.js | 20 +++++++++++ plugins/nativeHttp.js | 4 +-- 10 files changed, 106 insertions(+), 16 deletions(-) diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/AudioTrack.kt b/android/app/src/main/java/com/audiobookshelf/app/data/AudioTrack.kt index f77adbf6..086175d3 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/AudioTrack.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/AudioTrack.kt @@ -14,6 +14,7 @@ data class AudioTrack( var metadata: FileMetadata?, var isLocal: Boolean, var localFileId: String?, + // TODO: This should no longer be necessary var serverIndex: Int? // Need to know if server track index is different ) { diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt index 6358ea2c..76b8f4b4 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt @@ -32,11 +32,14 @@ enum class AndroidAutoBrowseSeriesSequenceOrderSetting { ASC, DESC } +@JsonIgnoreProperties(ignoreUnknown = true) data class ServerConnectionConfig( var id:String, var index:Int, var name:String, var address:String, + // version added after 0.9.81-beta + var version:String?, var userId:String, var username:String, var token:String, diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt index 7b2dcf70..0b08f1aa 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt @@ -53,6 +53,11 @@ class LibraryItem( 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}") } diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt b/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt index cde9318e..46ea3cdc 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt @@ -149,6 +149,16 @@ class PlaybackSession( 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 fun getCoverUri(ctx: Context): Uri { if (localLibraryItem?.coverContentUrl != null) { @@ -168,12 +178,22 @@ class PlaybackSession( if (coverPath == null) 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}") } @JsonIgnore fun getContentUri(audioTrack: AudioTrack): Uri { 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}") } @@ -264,14 +284,16 @@ class PlaybackSession( 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 { - castMetadata.addImage( - WebImage( - Uri.parse( - "$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}" - ) - ) - ) + castMetadata.addImage(WebImage(coverUri)) } castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_TITLE, displayTitle ?: "") diff --git a/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt b/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt index d31891ff..de931ca1 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt @@ -41,6 +41,7 @@ object DeviceManager { get() = serverConnectionConfig?.userId ?: "" val token get() = serverConnectionConfig?.token ?: "" + val serverVersion get() = serverConnectionConfig?.version ?: "" val isConnectedToServer get() = serverConnectionConfig != null @@ -111,6 +112,41 @@ object DeviceManager { 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. * @param ctx The context to use for checking connectivity. diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt index 861ae692..4c376dfc 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt @@ -23,14 +23,14 @@ class AbsDatabase : Plugin() { val tag = "AbsDatabase" private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) - lateinit var mainActivity: MainActivity - lateinit var apiHandler: ApiHandler - lateinit var secureStorage: SecureStorage + private lateinit var mainActivity: MainActivity + private lateinit var apiHandler: ApiHandler + private lateinit var secureStorage: SecureStorage data class LocalMediaProgressPayload(val value:List) data class LocalLibraryItemsPayload(val value:List) data class LocalFoldersPayload(val value:List) - data class ServerConnConfigPayload(val id:String?, val index:Int, val name:String?, val userId:String, val username:String, val token:String, val refreshToken:String?, val address:String?, val customHeaders:Map?) + 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?) override fun load() { mainActivity = (activity as MainActivity) @@ -125,6 +125,7 @@ class AbsDatabase : Plugin() { val userId = serverConfigPayload.userId val username = serverConfigPayload.username + val serverVersion = serverConfigPayload.version val accessToken = serverConfigPayload.token // New token val refreshToken = serverConfigPayload.refreshToken // Refresh only sent on first connection @@ -144,7 +145,7 @@ class AbsDatabase : Plugin() { } Log.d(tag, "Refresh token secured = $hasRefreshToken") - serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, userId, username, accessToken, serverConfigPayload.customHeaders) + serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, serverVersion, userId, username, accessToken, serverConfigPayload.customHeaders) // Add and save DeviceManager.deviceData.serverConnectionConfigs.add(serverConnectionConfig!!) @@ -152,10 +153,11 @@ class AbsDatabase : Plugin() { DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData) } else { var shouldSave = false - if (serverConnectionConfig?.username != username || serverConnectionConfig?.token != accessToken) { + if (serverConnectionConfig?.username != username || serverConnectionConfig?.token != accessToken || serverConnectionConfig?.version != serverVersion) { serverConnectionConfig?.userId = userId serverConnectionConfig?.username = username serverConnectionConfig?.name = "${serverConnectionConfig?.address} (${serverConnectionConfig?.username})" + serverConnectionConfig?.version = serverVersion serverConnectionConfig?.token = accessToken shouldSave = true } diff --git a/components/connection/ServerConnectForm.vue b/components/connection/ServerConnectForm.vue index 1408797f..35f8e680 100644 --- a/components/connection/ServerConnectForm.vue +++ b/components/connection/ServerConnectForm.vue @@ -837,7 +837,7 @@ export default { this.serverConfig.refreshToken = user.refreshToken } - delete this.serverConfig.version + this.serverConfig.version = serverSettings.version var serverConnectionConfig = await this.$db.setServerConnectionConfig(this.serverConfig) diff --git a/layouts/default.vue b/layouts/default.vue index 23eed383..e895d81e 100644 --- a/layouts/default.vue +++ b/layouts/default.vue @@ -158,6 +158,7 @@ export default { } else if (userDefaultLibraryId) { this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId) } + serverConfig.version = serverSettings.version const serverConnectionConfig = await this.$db.setServerConnectionConfig(serverConfig) this.$store.commit('user/setUser', user) diff --git a/plugins/capacitor/AbsDatabase.js b/plugins/capacitor/AbsDatabase.js index 059abe30..1c499251 100644 --- a/plugins/capacitor/AbsDatabase.js +++ b/plugins/capacitor/AbsDatabase.js @@ -1,5 +1,18 @@ 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 { constructor() { super() @@ -19,6 +32,11 @@ class AbsDatabaseWeb extends WebPlugin { return deviceData } + /** + * + * @param {ServerConnectionConfig} serverConnectionConfig + * @returns {Promise} + */ async setCurrentServerConnectionConfig(serverConnectionConfig) { var deviceData = await this.getDeviceData() @@ -29,6 +47,7 @@ class AbsDatabaseWeb extends WebPlugin { ssc.token = serverConnectionConfig.token ssc.userId = serverConnectionConfig.userId ssc.username = serverConnectionConfig.username + ssc.version = serverConnectionConfig.version ssc.customHeaders = serverConnectionConfig.customHeaders || {} if (serverConnectionConfig.refreshToken) { @@ -47,6 +66,7 @@ class AbsDatabaseWeb extends WebPlugin { username: serverConnectionConfig.username, address: serverConnectionConfig.address, token: serverConnectionConfig.token, + version: serverConnectionConfig.version, customHeaders: serverConnectionConfig.customHeaders || {} } diff --git a/plugins/nativeHttp.js b/plugins/nativeHttp.js index 68b8bc6c..f00b5837 100644 --- a/plugins/nativeHttp.js +++ b/plugins/nativeHttp.js @@ -55,7 +55,7 @@ export default function ({ store, $db }, inject) { * @param {*} data - Request data * @param {Object} headers - Request headers * @param {Object} options - Additional options - * @param {{ id: string, address: string }} serverConnectionConfig + * @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) { @@ -161,7 +161,7 @@ export default function ({ store, $db }, inject) { /** * Updates the store and secure storage with new tokens * @param {Object} tokens - Object containing accessToken and refreshToken - * @param {{ id: string, address: string }} serverConnectionConfig + * @param {{ id: string, address: string, version: string }} serverConnectionConfig * @returns {Promise} - Promise that resolves when tokens are updated */ async updateTokens(tokens, serverConnectionConfig) { From f6e2e4010f866a07eafeeb5c6f0a23feb1f04d09 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 5 Jul 2025 11:21:20 -0500 Subject: [PATCH 04/80] Update to not logout from server when switching servers, force users to re-login if using old auth --- components/app/SideDrawer.vue | 2 +- components/connection/ServerConnectForm.vue | 84 ++++++++++++--------- layouts/default.vue | 16 ++++ pages/account.vue | 2 +- plugins/init.client.js | 25 ++++++ plugins/nativeHttp.js | 10 ++- store/user.js | 11 ++- 7 files changed, 105 insertions(+), 45 deletions(-) diff --git a/components/app/SideDrawer.vue b/components/app/SideDrawer.vue index 7173f790..f0dfed36 100644 --- a/components/app/SideDrawer.vue +++ b/components/app/SideDrawer.vue @@ -179,7 +179,7 @@ export default { this.show = false }, async logout() { - await this.$store.dispatch('user/logout', {}) + await this.$store.dispatch('user/logout') }, async disconnect() { await this.$hapticsImpact() diff --git a/components/connection/ServerConnectForm.vue b/components/connection/ServerConnectForm.vue index 35f8e680..7f1a461b 100644 --- a/components/connection/ServerConnectForm.vue +++ b/components/connection/ServerConnectForm.vue @@ -439,6 +439,7 @@ export default { const payload = await this.authenticateToken() if (payload) { + // Will NOT include access token and refresh token this.setUserAndConnection(payload) } else { this.showAuth = true @@ -770,26 +771,6 @@ export default { prependProtocolIfNeeded(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() { if (!this.networkConnected) return if (!this.serverConfig.username) { @@ -809,6 +790,7 @@ export default { const payload = await this.requestServerLogin() this.processing = false if (payload) { + // Will include access token and refresh token this.setUserAndConnection(payload) } }, @@ -821,20 +803,27 @@ export default { this.$store.commit('libraries/setEReaderDevices', ereaderDevices) this.$setServerLanguageCode(serverSettings.language) - // Set library - Use last library if set and available fallback to default user library - var 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.serverConfig.userId = user.id this.serverConfig.username = user.username - // Tokens only returned from /login endpoint - if (user.accessToken) { - this.serverConfig.token = user.accessToken - this.serverConfig.refreshToken = user.refreshToken + + if (this.$isValidVersion(serverSettings.version, '2.26.0')) { + // Tokens only returned from /login endpoint + 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) { + 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.version = serverSettings.version @@ -854,6 +843,14 @@ 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/setAccessToken', serverConnectionConfig.token) this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig) @@ -871,8 +868,13 @@ export default { this.error = null this.processing = true - // TODO: Handle refresh token - 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) const errorMsg = error.message || error this.error = 'Failed to authorize' @@ -881,13 +883,25 @@ export default { } return false }) - console.log('[ServerConnectForm] authRes=', authRes) this.processing = false return authRes }, + setForceReloginForNewAuth() { + this.error = 'A new authentication system was added in server v2.26.0. Re-login is required for this server connection.' + this.showAuth = true + }, 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) { console.log('[ServerConnectForm] init with lastServerConnectionConfig', this.lastServerConnectionConfig) this.connectToServer(this.lastServerConnectionConfig) diff --git a/layouts/default.vue b/layouts/default.vue index e895d81e..990da2c5 100644 --- a/layouts/default.vue +++ b/layouts/default.vue @@ -151,6 +151,22 @@ export default { this.$store.commit('setServerSettings', serverSettings) 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) { + this.attemptingConnection = false + AbsLogger.info({ tag: 'default', message: `attemptConnection: Server is using new JWT auth but 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 const lastLibraryId = await this.$localStore.getLastLibraryId() if (lastLibraryId && (!user.librariesAccessible.length || user.librariesAccessible.includes(lastLibraryId))) { diff --git a/pages/account.vue b/pages/account.vue index 78c16b8b..ceb65842 100644 --- a/pages/account.vue +++ b/pages/account.vue @@ -48,7 +48,7 @@ export default { methods: { async logout() { await this.$hapticsImpact() - await this.$store.dispatch('user/logout', {}) + await this.$store.dispatch('user/logout') this.$router.push('/connect') } }, diff --git a/plugins/init.client.js b/plugins/init.client.js index aa90dd4e..e54f85b2 100644 --- a/plugins/init.client.js +++ b/plugins/init.client.js @@ -245,10 +245,35 @@ Vue.prototype.$sanitizeSlug = (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) => { const eventBus = new Vue() inject('eventBus', eventBus) + inject('isValidVersion', isValidVersion) + // Set theme app.$localStore?.getTheme()?.then((theme) => { if (theme) { diff --git a/plugins/nativeHttp.js b/plugins/nativeHttp.js index f00b5837..49d3f3f1 100644 --- a/plugins/nativeHttp.js +++ b/plugins/nativeHttp.js @@ -28,6 +28,7 @@ export default function ({ store, $db }, inject) { delete options.headers } console.log(`[nativeHttp] Making ${method} request to ${url}`) + return CapacitorHttp.request({ method, url, @@ -177,7 +178,7 @@ export default function ({ store, $db }, inject) { refreshToken: tokens.refreshToken } - // Save updated config to secure storage + // Save updated config to secure storage, persists refresh token in secure storage const savedConfig = await $db.setServerConnectionConfig(updatedConfig) // Update the store @@ -204,7 +205,12 @@ export default function ({ store, $db }, inject) { console.log('[nativeHttp] Handling refresh failure - logging out user') // Logout from server and clear store - await store.dispatch('user/logout', { serverConnectionConfigId }) + 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') { diff --git a/store/user.js b/store/user.js index fcb7a83b..63161bb0 100644 --- a/store/user.js +++ b/store/user.js @@ -140,8 +140,11 @@ export const actions = { console.error('Error opening browser', error) } }, - async logout({ state, commit }, { serverConnectionConfigId }) { - if (state.serverConnectionConfig) { + 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) { @@ -154,10 +157,6 @@ export const actions = { await this.$nativeHttp.post('/logout', null, options).catch((error) => { console.error('Failed to logout', error) }) - await this.$db.clearRefreshToken(state.serverConnectionConfig.id) - } else if (serverConnectionConfigId) { - // When refresh fails before a server connection config is set, clear refresh token for server connection config - await this.$db.clearRefreshToken(serverConnectionConfigId) } await this.$db.logout() From 52f86cbce9d6e8bbebdca2f070f7901452681ff5 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 5 Jul 2025 12:20:27 -0500 Subject: [PATCH 05/80] Fix plugin listener handlers, add message for configs using old server auth, show server version on account page --- components/app/Appbar.vue | 6 ++-- components/app/AudioPlayer.vue | 34 ++++++------------- components/app/AudioPlayerContainer.vue | 18 +++++----- components/connection/ServerConnectForm.vue | 20 +++++++++-- components/readers/ComicReader.vue | 1 + components/readers/MobiReader.vue | 1 + .../widgets/DownloadProgressIndicator.vue | 14 ++++---- pages/account.vue | 8 +++++ pages/media/_id/history.vue | 2 +- strings/en-us.json | 3 ++ 10 files changed, 62 insertions(+), 45 deletions(-) diff --git a/components/app/Appbar.vue b/components/app/Appbar.vue index 4f8bfb97..fd67744f 100644 --- a/components/app/Appbar.vue +++ b/components/app/Appbar.vue @@ -100,14 +100,14 @@ export default { this.isCastAvailable = data && data.value } }, - mounted() { + async mounted() { AbsAudioPlayer.getIsCastAvailable().then((data) => { this.isCastAvailable = data && data.value }) - this.onCastAvailableUpdateListener = AbsAudioPlayer.addListener('onCastAvailableUpdate', this.onCastAvailableUpdate) + this.onCastAvailableUpdateListener = await AbsAudioPlayer.addListener('onCastAvailableUpdate', this.onCastAvailableUpdate) }, beforeDestroy() { - if (this.onCastAvailableUpdateListener) this.onCastAvailableUpdateListener.remove() + this.onCastAvailableUpdateListener?.remove() } } diff --git a/components/app/AudioPlayer.vue b/components/app/AudioPlayer.vue index 7684fb90..ec129319 100644 --- a/components/app/AudioPlayer.vue +++ b/components/app/AudioPlayer.vue @@ -140,13 +140,6 @@ export default { readyTrackWidth: 0, seekedTime: 0, seekLoading: false, - onPlaybackSessionListener: null, - onPlaybackClosedListener: null, - onPlayingUpdateListener: null, - onMetadataListener: null, - onProgressSyncFailing: null, - onProgressSyncSuccess: null, - onPlaybackSpeedChangedListener: null, touchStartY: 0, touchStartTime: 0, playerSettings: { @@ -883,14 +876,14 @@ export default { async init() { await this.loadPlayerSettings() - this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession) - this.onPlaybackClosedListener = AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed) - this.onPlaybackFailedListener = AbsAudioPlayer.addListener('onPlaybackFailed', this.onPlaybackFailed) - this.onPlayingUpdateListener = AbsAudioPlayer.addListener('onPlayingUpdate', this.onPlayingUpdate) - this.onMetadataListener = AbsAudioPlayer.addListener('onMetadata', this.onMetadata) - this.onProgressSyncFailing = AbsAudioPlayer.addListener('onProgressSyncFailing', this.showProgressSyncIsFailing) - this.onProgressSyncSuccess = AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess) - this.onPlaybackSpeedChangedListener = AbsAudioPlayer.addListener('onPlaybackSpeedChanged', this.onPlaybackSpeedChanged) + AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession) + AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed) + AbsAudioPlayer.addListener('onPlaybackFailed', this.onPlaybackFailed) + AbsAudioPlayer.addListener('onPlayingUpdate', this.onPlayingUpdate) + AbsAudioPlayer.addListener('onMetadata', this.onMetadata) + AbsAudioPlayer.addListener('onProgressSyncFailing', this.showProgressSyncIsFailing) + AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess) + AbsAudioPlayer.addListener('onPlaybackSpeedChanged', this.onPlaybackSpeedChanged) }, async screenOrientationChange() { if (this.isRefreshingUI) return @@ -984,14 +977,9 @@ export default { document.body.removeEventListener('touchend', this.touchend) document.body.removeEventListener('touchmove', this.touchmove) - if (this.onPlayingUpdateListener) this.onPlayingUpdateListener.remove() - if (this.onMetadataListener) this.onMetadataListener.remove() - 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() + if (AbsAudioPlayer.removeAllListeners) { + AbsAudioPlayer.removeAllListeners() + } clearInterval(this.playInterval) } } diff --git a/components/app/AudioPlayerContainer.vue b/components/app/AudioPlayerContainer.vue index 958fc968..1040e61b 100644 --- a/components/app/AudioPlayerContainer.vue +++ b/components/app/AudioPlayerContainer.vue @@ -351,11 +351,11 @@ export default { } } }, - mounted() { - this.onLocalMediaProgressUpdateListener = AbsAudioPlayer.addListener('onLocalMediaProgressUpdate', this.onLocalMediaProgressUpdate) - this.onSleepTimerEndedListener = AbsAudioPlayer.addListener('onSleepTimerEnded', this.onSleepTimerEnded) - this.onSleepTimerSetListener = AbsAudioPlayer.addListener('onSleepTimerSet', this.onSleepTimerSet) - this.onMediaPlayerChangedListener = AbsAudioPlayer.addListener('onMediaPlayerChanged', this.onMediaPlayerChanged) + async mounted() { + this.onLocalMediaProgressUpdateListener = await AbsAudioPlayer.addListener('onLocalMediaProgressUpdate', this.onLocalMediaProgressUpdate) + this.onSleepTimerEndedListener = await AbsAudioPlayer.addListener('onSleepTimerEnded', this.onSleepTimerEnded) + this.onSleepTimerSetListener = await AbsAudioPlayer.addListener('onSleepTimerSet', this.onSleepTimerSet) + this.onMediaPlayerChangedListener = await AbsAudioPlayer.addListener('onMediaPlayerChanged', this.onMediaPlayerChanged) this.playbackSpeed = this.$store.getters['user/getUserSetting']('playbackRate') console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`) @@ -370,10 +370,10 @@ export default { this.$eventBus.$on('device-focus-update', this.deviceFocused) }, beforeDestroy() { - if (this.onLocalMediaProgressUpdateListener) this.onLocalMediaProgressUpdateListener.remove() - if (this.onSleepTimerEndedListener) this.onSleepTimerEndedListener.remove() - if (this.onSleepTimerSetListener) this.onSleepTimerSetListener.remove() - if (this.onMediaPlayerChangedListener) this.onMediaPlayerChangedListener.remove() + this.onLocalMediaProgressUpdateListener?.remove() + this.onSleepTimerEndedListener?.remove() + this.onSleepTimerSetListener?.remove() + this.onMediaPlayerChangedListener?.remove() this.$eventBus.$off('abs-ui-ready', this.onReady) this.$eventBus.$off('play-item', this.playLibraryItem) diff --git a/components/connection/ServerConnectForm.vue b/components/connection/ServerConnectForm.vue index 7f1a461b..f3a3470c 100644 --- a/components/connection/ServerConnectForm.vue +++ b/components/connection/ServerConnectForm.vue @@ -17,6 +17,11 @@

{{ $strings.MessageOldServerConnectionWarning }}

{{ $strings.LabelMoreInfo }} + +
+

{{ $strings.MessageOldServerAuthWarning }}

+ {{ $strings.LabelMoreInfo }} +
{{ $strings.ButtonAddNewServer }} @@ -155,9 +160,20 @@ export default { cancelText: this.$strings.ButtonOk }) }, + showOldAuthWarningDialog() { + Dialog.alert({ + title: 'Old Server Auth Warning', + message: this.$strings.MessageOldServerAuthWarningHelp, + cancelText: this.$strings.ButtonOk + }) + }, 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) }, + checkIsUsingOldAuth(config) { + if (!config.version) return true + return !this.$isValidVersion(config.version, '2.26.0') + }, /** * Initiates the login process using OpenID via OAuth2.0. * 1. Verifying the server's address @@ -505,7 +521,7 @@ export default { try { var urlObject = new URL(url) if (protocolOverride) urlObject.protocol = protocolOverride - return urlObject.href + return urlObject.href.replace(/\/$/, '') // Remove trailing slash } catch (error) { console.error('Invalid URL', error) return null @@ -889,7 +905,7 @@ export default { return authRes }, setForceReloginForNewAuth() { - this.error = 'A new authentication system was added in server v2.26.0. Re-login is required for this server connection.' + this.error = this.$strings.MessageOldServerAuthReLoginRequired this.showAuth = true }, init() { diff --git a/components/readers/ComicReader.vue b/components/readers/ComicReader.vue index e18885e3..bd7ed4e1 100644 --- a/components/readers/ComicReader.vue +++ b/components/readers/ComicReader.vue @@ -245,6 +245,7 @@ export default { async extract() { this.loading = true + // TODO: Handle JWT auth refresh const buff = await this.$axios.$get(this.url, { responseType: 'blob', headers: { diff --git a/components/readers/MobiReader.vue b/components/readers/MobiReader.vue index 556c145b..a3d5c112 100644 --- a/components/readers/MobiReader.vue +++ b/components/readers/MobiReader.vue @@ -84,6 +84,7 @@ export default { }, async initMobi() { // Fetch mobi file as blob + // TODO: Handle JWT auth refresh var buff = await this.$axios.$get(this.url, { responseType: 'blob', headers: { diff --git a/components/widgets/DownloadProgressIndicator.vue b/components/widgets/DownloadProgressIndicator.vue index 28bde581..8e389c7d 100644 --- a/components/widgets/DownloadProgressIndicator.vue +++ b/components/widgets/DownloadProgressIndicator.vue @@ -78,15 +78,15 @@ export default { this.$store.commit('globals/updateDownloadItemPart', itemPart) } }, - mounted() { - this.downloadItemListener = AbsDownloader.addListener('onDownloadItem', (data) => this.onDownloadItem(data)) - this.itemPartUpdateListener = AbsDownloader.addListener('onDownloadItemPartUpdate', (data) => this.onDownloadItemPartUpdate(data)) - this.completeListener = AbsDownloader.addListener('onItemDownloadComplete', (data) => this.onItemDownloadComplete(data)) + async mounted() { + this.downloadItemListener = await AbsDownloader.addListener('onDownloadItem', (data) => this.onDownloadItem(data)) + this.itemPartUpdateListener = await AbsDownloader.addListener('onDownloadItemPartUpdate', (data) => this.onDownloadItemPartUpdate(data)) + this.completeListener = await AbsDownloader.addListener('onItemDownloadComplete', (data) => this.onItemDownloadComplete(data)) }, beforeDestroy() { - if (this.downloadItemListener) this.downloadItemListener.remove() - if (this.completeListener) this.completeListener.remove() - if (this.itemPartUpdateListener) this.itemPartUpdateListener.remove() + this.downloadItemListener?.remove() + this.completeListener?.remove() + this.itemPartUpdateListener?.remove() } } \ No newline at end of file diff --git a/pages/account.vue b/pages/account.vue index ceb65842..dbcfc50c 100644 --- a/pages/account.vue +++ b/pages/account.vue @@ -4,6 +4,10 @@ +
+

Server version: v{{ serverVersion }}

+
+ {{ $strings.ButtonSwitchServerUser }}logout
@@ -43,6 +47,10 @@ export default { }, serverAddress() { return this.serverConnectionConfig.address + }, + serverVersion() { + // Saved in server connection config after 0.9.81 + return this.serverConnectionConfig.version } }, methods: { diff --git a/pages/media/_id/history.vue b/pages/media/_id/history.vue index d63b7b66..f231aff5 100644 --- a/pages/media/_id/history.vue +++ b/pages/media/_id/history.vue @@ -200,7 +200,7 @@ export default { this.onMediaItemHistoryUpdatedListener = await AbsAudioPlayer.addListener('onMediaItemHistoryUpdated', this.onMediaItemHistoryUpdated) }, beforeDestroy() { - if (this.onMediaItemHistoryUpdatedListener) this.onMediaItemHistoryUpdatedListener.remove() + this.onMediaItemHistoryUpdatedListener?.remove() } } diff --git a/strings/en-us.json b/strings/en-us.json index 8ee7a956..e25c8e6c 100644 --- a/strings/en-us.json +++ b/strings/en-us.json @@ -325,6 +325,9 @@ "MessageNoSeries": "No series", "MessageNoUpdatesWereNecessary": "No updates were necessary", "MessageNoUserPlaylists": "You have no playlists", + "MessageOldServerAuthReLoginRequired": "A new authentication system was added in server v2.26.0. Re-login is required for this server connection.", + "MessageOldServerAuthWarning": "Server is using out-dated authentication", + "MessageOldServerAuthWarningHelp": "Authentication was updated in server v2.26.0 to use a more secure method. A future app update will require server version v2.26.0 or higher. You will need to re-login after updating the server.", "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.", "MessagePodcastSearchField": "Enter search term or RSS feed URL", From 5804c54656f82b9b27956ff54c094e96d3d640cc Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 5 Jul 2025 14:28:02 -0500 Subject: [PATCH 06/80] Add version to ServerConnectionConfig on iOS --- components/connection/ServerConnectForm.vue | 6 +++++- ios/App/App/AppDelegate.swift | 13 +++++++++---- ios/App/App/plugins/AbsDatabase.swift | 17 +++++++++++++++++ .../Shared/models/ServerConnectionConfig.swift | 2 ++ ios/App/Shared/util/Database.swift | 1 + layouts/default.vue | 4 ++-- strings/en-us.json | 2 +- 7 files changed, 37 insertions(+), 8 deletions(-) diff --git a/components/connection/ServerConnectForm.vue b/components/connection/ServerConnectForm.vue index f3a3470c..7e46a116 100644 --- a/components/connection/ServerConnectForm.vue +++ b/components/connection/ServerConnectForm.vue @@ -22,6 +22,9 @@

{{ $strings.MessageOldServerAuthWarning }}

{{ $strings.LabelMoreInfo }}
+
+

No server version set. Connect to update server config.

+
{{ $strings.ButtonAddNewServer }} @@ -829,7 +832,7 @@ export default { 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) { + if (this.serverConfig.token === user.token || user.isOldToken) { this.setForceReloginForNewAuth() return } @@ -907,6 +910,7 @@ export default { setForceReloginForNewAuth() { this.error = this.$strings.MessageOldServerAuthReLoginRequired this.showAuth = true + this.showForm = true }, init() { // Handle force re-login for servers using new JWT auth but still using an old token in the server config diff --git a/ios/App/App/AppDelegate.swift b/ios/App/App/AppDelegate.swift index bd270f50..da344016 100644 --- a/ios/App/App/AppDelegate.swift +++ b/ios/App/App/AppDelegate.swift @@ -14,7 +14,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Override point for customization after application launch. let configuration = Realm.Configuration( - schemaVersion: 19, + schemaVersion: 20, migrationBlock: { [weak self] migration, oldSchemaVersion in if (oldSchemaVersion < 1) { 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") migration.enumerateObjects(ofType: PlayerSettings.className()) { oldObject, newObject in 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 diff --git a/ios/App/App/plugins/AbsDatabase.swift b/ios/App/App/plugins/AbsDatabase.swift index 8208be78..ca0749b4 100644 --- a/ios/App/App/plugins/AbsDatabase.swift +++ b/ios/App/App/plugins/AbsDatabase.swift @@ -53,11 +53,17 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin { @objc func setCurrentServerConnectionConfig(_ call: CAPPluginCall) { var id = call.getString("id") let address = call.getString("address", "") + let version = call.getString("version", "") let userId = call.getString("userId", "") let username = call.getString("username", "") let token = call.getString("token", "") + let refreshToken = call.getString("refreshToken", "") // Refresh only sent after login or refresh let name = "\(address) (\(username))" + + if (refreshToken != "") { + // TODO: Implement secure storage + } if id == nil { id = "\(address)@\(username)".toBase64() @@ -68,6 +74,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin { config.index = 0 config.name = name config.address = address + config.version = version config.userId = userId config.username = username config.token = token @@ -82,6 +89,16 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin { call.resolve() } + @objc func getRefreshToken(_ call: CAPPluginCall) { + let serverConnectionConfigId = call.getString("serverConnectionConfigId", "") + // TODO: Implement secure storage + call.resolve() + } + @objc func clearRefreshToken(_ call: CAPPluginCall) { + let serverConnectionConfigId = call.getString("serverConnectionConfigId", "") + // TODO: Implement secure storage + call.resolve() + } @objc func logout(_ call: CAPPluginCall) { Store.serverConfig = nil call.resolve() diff --git a/ios/App/Shared/models/ServerConnectionConfig.swift b/ios/App/Shared/models/ServerConnectionConfig.swift index be841da1..fea82e4b 100644 --- a/ios/App/Shared/models/ServerConnectionConfig.swift +++ b/ios/App/Shared/models/ServerConnectionConfig.swift @@ -13,6 +13,7 @@ class ServerConnectionConfig: Object { @Persisted(indexed: true) var index: Int = 1 @Persisted var name: String = "" @Persisted var address: String = "" + @Persisted var version: String = "" @Persisted var userId: String = "" @Persisted var username: String = "" @Persisted var token: String = "" @@ -29,6 +30,7 @@ func convertServerConnectionConfigToJSON(config: ServerConnectionConfig) -> Dict "name": config.name, "index": config.index, "address": config.address, + "version": config.version, "userId": config.userId, "username": config.username, "token": config.token, diff --git a/ios/App/Shared/util/Database.swift b/ios/App/Shared/util/Database.swift index c20a63b0..f40a57fe 100644 --- a/ios/App/Shared/util/Database.swift +++ b/ios/App/Shared/util/Database.swift @@ -27,6 +27,7 @@ class Database { try existing.update { existing.name = config.name existing.address = config.address + existing.version = config.version existing.userId = config.userId existing.username = config.username existing.token = config.token diff --git a/layouts/default.vue b/layouts/default.vue index 990da2c5..aa621721 100644 --- a/layouts/default.vue +++ b/layouts/default.vue @@ -154,9 +154,9 @@ export default { 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) { + if (serverConfig.token === user.token || user.isOldToken) { this.attemptingConnection = false - AbsLogger.info({ tag: 'default', message: `attemptConnection: Server is using new JWT auth but is still using an old token (server version: ${serverSettings.version}) (${serverConfig.name})` }) + 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}`) diff --git a/strings/en-us.json b/strings/en-us.json index e25c8e6c..fbd8e24a 100644 --- a/strings/en-us.json +++ b/strings/en-us.json @@ -325,7 +325,7 @@ "MessageNoSeries": "No series", "MessageNoUpdatesWereNecessary": "No updates were necessary", "MessageNoUserPlaylists": "You have no playlists", - "MessageOldServerAuthReLoginRequired": "A new authentication system was added in server v2.26.0. Re-login is required for this server connection.", + "MessageOldServerAuthReLoginRequired": "A new authentication system was added in server v2.26.0. Please re-login to use the more secure authentication.", "MessageOldServerAuthWarning": "Server is using out-dated authentication", "MessageOldServerAuthWarningHelp": "Authentication was updated in server v2.26.0 to use a more secure method. A future app update will require server version v2.26.0 or higher. You will need to re-login after updating the server.", "MessageOldServerConnectionWarning": "Server connection config is using an old user ID. Please delete and re-add this server connection.", From bc927d4c355cfad9f72a776ab407773c74f3215b Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 5 Jul 2025 14:52:41 -0500 Subject: [PATCH 07/80] Add SecureStorage to iOS and implement refresh token methods --- ios/App/App.xcodeproj/project.pbxproj | 4 + ios/App/App/plugins/AbsDatabase.swift | 30 ++++-- ios/App/Shared/util/SecureStorage.swift | 116 ++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 6 deletions(-) create mode 100644 ios/App/Shared/util/SecureStorage.swift diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index c72a5f88..7fa9db20 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -25,6 +25,7 @@ 4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B957282EEA14008272D4 /* AbsFileSystem.swift */; }; 4D91EEC62A40F28D004807ED /* EBookFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D91EEC52A40F28D004807ED /* EBookFile.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 */; }; 4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DF74911287105C600AC7814 /* DeviceSettings.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 = ""; }; 4D91EEC52A40F28D004807ED /* EBookFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EBookFile.swift; sourceTree = ""; }; 4DABC04E2B0139CA000F6264 /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = ""; }; + 4DB441E02E19B8BF0056C8F1 /* SecureStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureStorage.swift; sourceTree = ""; }; 4DF6C7162DB58ABF004059F1 /* AbsLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsLogger.swift; sourceTree = ""; }; 4DF74911287105C600AC7814 /* DeviceSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceSettings.swift; sourceTree = ""; }; 4DFE2DA22D345C390000B204 /* MyViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyViewController.swift; sourceTree = ""; }; @@ -239,6 +241,7 @@ 3AFCB5E627EA23F700ECCC05 /* util */ = { isa = PBXGroup; children = ( + 4DB441E02E19B8BF0056C8F1 /* SecureStorage.swift */, E9FA07E228C82848005520B0 /* Logger.swift */, 3AFCB5E727EA240D00ECCC05 /* NowPlayingInfo.swift */, 3AD4FCEA280443DD006DB301 /* Database.swift */, @@ -550,6 +553,7 @@ E9D5505828AC1C1A00C746DD /* Library.swift in Sources */, 3AD4FCEB280443DD006DB301 /* Database.swift in Sources */, 3AD4FCE528043E50006DB301 /* AbsDatabase.swift in Sources */, + 4DB441E12E19B8BF0056C8F1 /* SecureStorage.swift in Sources */, 4DABC04F2B0139CA000F6264 /* User.swift in Sources */, E9D5506828AC1DC300C746DD /* LocalPodcastEpisode.swift in Sources */, EACB38162BCCA1500060DA4A /* DefaultedAudioPlayerRateManager.swift in Sources */, diff --git a/ios/App/App/plugins/AbsDatabase.swift b/ios/App/App/plugins/AbsDatabase.swift index ca0749b4..ac8d5457 100644 --- a/ios/App/App/plugins/AbsDatabase.swift +++ b/ios/App/App/plugins/AbsDatabase.swift @@ -33,6 +33,8 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin { public let pluginMethods: [CAPPluginMethod] = [ CAPPluginMethod(name: "setCurrentServerConnectionConfig", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "removeServerConnectionConfig", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "getRefreshToken", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "clearRefreshToken", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "logout", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "getDeviceData", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "getLocalLibraryItems", returnType: CAPPluginReturnPromise), @@ -49,6 +51,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin { ] private let logger = AppLogger(category: "AbsDatabase") + private let secureStorage = SecureStorage() @objc func setCurrentServerConnectionConfig(_ call: CAPPluginCall) { var id = call.getString("id") @@ -62,7 +65,9 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin { let name = "\(address) (\(username))" if (refreshToken != "") { - // TODO: Implement secure storage + // Store refresh token securely if provided + let hasRefreshToken = secureStorage.storeRefreshToken(serverConnectionConfigId: id ?? "", refreshToken: refreshToken) + logger.log("Refresh token secured = \(hasRefreshToken)") } if id == nil { @@ -83,22 +88,35 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin { let savedConfig = Store.serverConfig // Fetch the latest value call.resolve(convertServerConnectionConfigToJSON(config: savedConfig!)) } + @objc func removeServerConnectionConfig(_ call: CAPPluginCall) { let id = call.getString("serverConnectionConfigId", "") + + // Remove refresh token if it exists + secureStorage.removeRefreshToken(serverConnectionConfigId: id) + Database.shared.deleteServerConnectionConfig(id: id) - call.resolve() } + @objc func getRefreshToken(_ call: CAPPluginCall) { let serverConnectionConfigId = call.getString("serverConnectionConfigId", "") - // TODO: Implement secure storage - call.resolve() + + 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", "") - // TODO: Implement secure storage - call.resolve() + + let success = secureStorage.removeRefreshToken(serverConnectionConfigId: serverConnectionConfigId) + call.resolve(["success": success]) } + @objc func logout(_ call: CAPPluginCall) { Store.serverConfig = nil call.resolve() diff --git a/ios/App/Shared/util/SecureStorage.swift b/ios/App/Shared/util/SecureStorage.swift new file mode 100644 index 00000000..babad6f6 --- /dev/null +++ b/ios/App/Shared/util/SecureStorage.swift @@ -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 + } +} \ No newline at end of file From 5766c49f6186187cd921a8db40a411984aa4256a Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 5 Jul 2025 16:46:37 -0500 Subject: [PATCH 08/80] Update iOS ApiClient to handle token refresh --- .../audiobookshelf/app/server/ApiHandler.kt | 1 + ios/App/App/plugins/AbsDatabase.swift | 9 + ios/App/Shared/util/ApiClient.swift | 392 +++++++++++++++++- ios/App/Shared/util/Database.swift | 13 + 4 files changed, 393 insertions(+), 22 deletions(-) diff --git a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt index a3a9b550..c45a7e5a 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt @@ -404,6 +404,7 @@ class ApiHandler(var ctx:Context) { errorObj.put("error", "Authentication failed - please login again") callback(errorObj) + // TODO: Notify webview frontend } catch (e: Exception) { Log.e(tag, "handleRefreshFailure: Error during failure handling", e) val errorObj = JSObject() diff --git a/ios/App/App/plugins/AbsDatabase.swift b/ios/App/App/plugins/AbsDatabase.swift index ac8d5457..2ba5a1c1 100644 --- a/ios/App/App/plugins/AbsDatabase.swift +++ b/ios/App/App/plugins/AbsDatabase.swift @@ -53,6 +53,15 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin { 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) { var id = call.getString("id") let address = call.getString("address", "") diff --git a/ios/App/Shared/util/ApiClient.swift b/ios/App/Shared/util/ApiClient.swift index 37e831e1..9b1804e6 100644 --- a/ios/App/Shared/util/ApiClient.swift +++ b/ios/App/Shared/util/ApiClient.swift @@ -10,6 +10,7 @@ import Alamofire class ApiClient { private static let logger = AppLogger(category: "ApiClient") + private static let secureStorage = SecureStorage() public static func getData(from url: URL, completion: @escaping (UIImage?) -> Void) { URLSession.shared.dataTask(with: url, completionHandler: {(data, response, error) in @@ -146,6 +147,297 @@ 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(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(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 + } + + retryRequest.responseDecodable(of: decodable) { response in + switch response.result { + case .success(let obj): + callback?(obj) + case .failure(let error): + logger.error("retryOriginalRequest: Retry request failed: \(error)") + 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(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(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(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(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) { var endpoint = "api/items/\(libraryItemId)/play" if episodeId != nil { @@ -160,20 +452,28 @@ class ApiClient { } } - let parameters: [String: Any] = [ - "forceDirectPlay": !forceTranscode ? "1" : "", - "forceTranscode": forceTranscode ? "1" : "", - "mediaPlayer": "AVPlayer", - "deviceInfo": [ - "deviceId": UIDevice.current.identifierForVendor?.uuidString, - "manufacturer": "Apple", - "model": modelCode, - "clientVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String - ] - ] - ApiClient.postResource(endpoint: endpoint, parameters: parameters, decodable: PlaybackSession.self) { obj in - let session = obj + // Create an Encodable struct for the parameters + let parameters = PlaybackSessionRequest( + forceDirectPlay: !forceTranscode ? "1" : "", + forceTranscode: forceTranscode ? "1" : "", + mediaPlayer: "AVPlayer", + deviceInfo: DeviceInfo( + deviceId: UIDevice.current.identifierForVendor?.uuidString, + manufacturer: "Apple", + model: modelCode, + clientVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + ) + ) + + // 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.serverAddress = Store.serverConfig!.address @@ -182,15 +482,28 @@ class ApiClient { } 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 { - 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 { - 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 { @@ -257,7 +570,7 @@ class ApiClient { public static func updateMediaProgress(libraryItemId: String, episodeId: String?, payload: T, callback: @escaping () -> Void) { logger.log("updateMediaProgress \(libraryItemId) \(episodeId ?? "NIL") \(payload)") 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() } } @@ -265,21 +578,29 @@ class ApiClient { public static func getMediaProgress(libraryItemId: String, episodeId: String?) async -> MediaProgress? { logger.log("getMediaProgress \(libraryItemId) \(episodeId ?? "NIL")") 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? { 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" if episodeId != nil { endpoint += "&episodeId=\(episodeId!)" } - - ApiClient.getResource(endpoint: endpoint, decodable: LibraryItem.self) { obj in + + getResourceWithTokenRefresh(endpoint: endpoint, decodable: LibraryItem.self) { obj in callback(obj) } } @@ -338,3 +659,30 @@ struct Connectivity { 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? +} diff --git a/ios/App/Shared/util/Database.swift b/ios/App/Shared/util/Database.swift index f40a57fe..d87aac56 100644 --- a/ios/App/Shared/util/Database.swift +++ b/ios/App/Shared/util/Database.swift @@ -61,6 +61,19 @@ class Database { 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) { let realm = try! Realm() From b06274866da9831b4c4938335812fb36ed6e7f69 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 5 Jul 2025 17:31:25 -0500 Subject: [PATCH 09/80] iOS update to use new track endpoint and remove token from cover url depending on version --- ios/App/Shared/player/AudioPlayer.swift | 15 +++++++-- ios/App/Shared/util/NowPlayingInfo.swift | 12 +++++-- ios/App/Shared/util/Store.swift | 42 ++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/ios/App/Shared/player/AudioPlayer.swift b/ios/App/Shared/player/AudioPlayer.swift index e46b0da7..ae1e5db2 100644 --- a/ios/App/Shared/player/AudioPlayer.swift +++ b/ios/App/Shared/player/AudioPlayer.swift @@ -561,8 +561,15 @@ class AudioPlayer: NSObject { guard let playbackSession = self.getPlaybackSession() else { return nil } if (playbackSession.playMethod == PlayMethod.directplay.rawValue) { - let urlstr = "\(Store.serverConfig!.address)/api/items/\(itemId)/file/\(ino)?token=\(Store.serverConfig!.token)" - let url = URL(string: urlstr)! + // As of v2.22.0 tracks use a different endpoint + // 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) } else if (playbackSession.playMethod == PlayMethod.local.rawValue) { guard let localFile = track.getLocalFile() else { @@ -577,7 +584,9 @@ class AudioPlayer: NSObject { let headers: [String: String] = [ "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]) } } diff --git a/ios/App/Shared/util/NowPlayingInfo.swift b/ios/App/Shared/util/NowPlayingInfo.swift index 69443fbe..5369cbca 100644 --- a/ios/App/Shared/util/NowPlayingInfo.swift +++ b/ios/App/Shared/util/NowPlayingInfo.swift @@ -22,8 +22,16 @@ struct NowPlayingMetadata { return item.coverUrl } else { 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) } } } diff --git a/ios/App/Shared/util/Store.swift b/ios/App/Shared/util/Store.swift index 6073f2d7..b0b991d1 100644 --- a/ios/App/Shared/util/Store.swift +++ b/ios/App/Shared/util/Store.swift @@ -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.. compareVersionComponent { + return true // Server version is greater than compareVersion + } + } + + return true // versions are equal in major, minor, and patch + } } From fab94cd363af9c8551977562afd0cae97f058114 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 5 Jul 2025 17:40:57 -0500 Subject: [PATCH 10/80] Handle native app token refresh failure notification --- .../java/com/audiobookshelf/app/plugins/AbsDatabase.kt | 4 ++-- .../java/com/audiobookshelf/app/server/ApiHandler.kt | 9 ++++++++- plugins/db.js | 10 ++++++++++ plugins/nativeHttp.js | 2 +- 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt index 4c376dfc..bb96e554 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt @@ -126,8 +126,8 @@ class AbsDatabase : Plugin() { val userId = serverConfigPayload.userId val username = serverConfigPayload.username val serverVersion = serverConfigPayload.version - val accessToken = serverConfigPayload.token // New token - val refreshToken = serverConfigPayload.refreshToken // Refresh only sent on first connection + val accessToken = serverConfigPayload.token + val refreshToken = serverConfigPayload.refreshToken // Refresh only sent after login or refresh GlobalScope.launch(Dispatchers.IO) { if (serverConnectionConfig == null) { // New Server Connection diff --git a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt index c45a7e5a..c4caccb4 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt @@ -404,7 +404,14 @@ class ApiHandler(var ctx:Context) { errorObj.put("error", "Authentication failed - please login again") callback(errorObj) - // TODO: Notify webview frontend + 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() diff --git a/plugins/db.js b/plugins/db.js index 7b7740e1..f41be58f 100644 --- a/plugins/db.js +++ b/plugins/db.js @@ -131,4 +131,14 @@ export default ({ app, store }, inject) => { 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' + } + }) } diff --git a/plugins/nativeHttp.js b/plugins/nativeHttp.js index 49d3f3f1..815fb219 100644 --- a/plugins/nativeHttp.js +++ b/plugins/nativeHttp.js @@ -204,7 +204,7 @@ export default function ({ store, $db }, inject) { try { console.log('[nativeHttp] Handling refresh failure - logging out user') - // Logout from server and clear store + // Clear store await store.dispatch('user/logout') if (serverConnectionConfigId) { From 4534ffaead1721f28d0d210d0ea68594e8a126b4 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 6 Jul 2025 11:32:21 -0500 Subject: [PATCH 11/80] Handle re-authenticating socket --- plugins/nativeHttp.js | 9 ++++++++- plugins/server.js | 17 ++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/plugins/nativeHttp.js b/plugins/nativeHttp.js index 815fb219..f4089ebb 100644 --- a/plugins/nativeHttp.js +++ b/plugins/nativeHttp.js @@ -1,6 +1,6 @@ import { CapacitorHttp } from '@capacitor/core' -export default function ({ store, $db }, inject) { +export default function ({ store, $db, $socket }, inject) { const nativeHttp = { async request(method, _url, data, options = {}) { // When authorizing before a config is set, server config gets passed in as an option @@ -184,6 +184,13 @@ export default function ({ store, $db }, inject) { // 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) } diff --git a/plugins/server.js b/plugins/server.js index 409ebe2d..90f6f715 100644 --- a/plugins/server.js +++ b/plugins/server.js @@ -9,7 +9,7 @@ class ServerSocket extends EventEmitter { this.socket = null this.connected = false this.serverAddress = null - this.token = null + this.isAuthenticated = false this.lastReconnectAttemptTime = 0 } @@ -26,7 +26,6 @@ class ServerSocket extends EventEmitter { connect(serverAddress, token) { this.serverAddress = serverAddress - this.token = token const serverUrl = new URL(serverAddress) 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('disconnect', this.onDisconnect.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_item_progress_updated', this.onUserItemProgressUpdated.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)) } + sendAuthenticate() { + // Required to connect a socket to a user + this.socket.emit('auth', this.$store.getters['user/getToken']) + } + removeListeners() { if (!this.socket) return this.socket.removeAllListeners() @@ -74,7 +79,7 @@ class ServerSocket extends EventEmitter { this.connected = true this.$store.commit('setSocketConnected', true) this.emit('connection-update', true) - this.socket.emit('auth', this.token) // Required to connect a user with their socket + this.sendAuthenticate() } onReconnectAttempt(attemptNumber) { @@ -101,6 +106,12 @@ class ServerSocket extends EventEmitter { onInit(data) { console.log('[SOCKET] Initial socket data received', data) this.emit('initialized', true) + this.isAuthenticated = true + } + + onAuthFailed(data) { + console.log('[SOCKET] Auth failed', data) + this.isAuthenticated = false } onUserUpdated(data) { From be08efeca32365981f300c143a90b23fe9e8451a Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 7 Jul 2025 06:15:51 -0500 Subject: [PATCH 12/80] iOS update retry request handler to handle string bodies --- ios/App/App/plugins/AbsDatabase.swift | 2 +- ios/App/Shared/util/ApiClient.swift | 33 ++++++++++++++++++++++----- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/ios/App/App/plugins/AbsDatabase.swift b/ios/App/App/plugins/AbsDatabase.swift index 2ba5a1c1..aa9dfc43 100644 --- a/ios/App/App/plugins/AbsDatabase.swift +++ b/ios/App/App/plugins/AbsDatabase.swift @@ -102,7 +102,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin { let id = call.getString("serverConnectionConfigId", "") // Remove refresh token if it exists - secureStorage.removeRefreshToken(serverConnectionConfigId: id) + _ = secureStorage.removeRefreshToken(serverConnectionConfigId: id) Database.shared.deleteServerConnectionConfig(id: id) call.resolve() diff --git a/ios/App/Shared/util/ApiClient.swift b/ios/App/Shared/util/ApiClient.swift index 9b1804e6..ab368988 100644 --- a/ios/App/Shared/util/ApiClient.swift +++ b/ios/App/Shared/util/ApiClient.swift @@ -275,12 +275,33 @@ class ApiClient { return } - retryRequest.responseDecodable(of: decodable) { response in - switch response.result { - case .success(let obj): - callback?(obj) - case .failure(let error): - logger.error("retryOriginalRequest: Retry request failed: \(error)") + // 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) } } From beb5e1a56c0587c3481797cacd8435f5da5b42cb Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 7 Jul 2025 17:20:22 -0500 Subject: [PATCH 13/80] OIDC to support new access tokens --- components/connection/ServerConnectForm.vue | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/components/connection/ServerConnectForm.vue b/components/connection/ServerConnectForm.vue index 7e46a116..9ede75a3 100644 --- a/components/connection/ServerConnectForm.vue +++ b/components/connection/ServerConnectForm.vue @@ -393,15 +393,19 @@ export default { } catch (error) {} // No Error handling needed try { + // Returns the same user response payload as /login const response = await CapacitorHttp.get({ url: backendEndpoint }) - - if (!response.data || !response.data.user || !response.data.user.token) { - throw new Error('Token data is missing in the response.') + // v2.26.0+ returns accessToken and refreshToken on user object + if (!response.data?.user?.token && !response.data?.user?.accessToken) { + 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() if (!payload) { @@ -413,6 +417,12 @@ export default { 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) } catch (error) { console.error('[SSO] Error in exchangeCodeForToken: ', error) From 79d8ccbf52aef57852ae6a0886a308509eccf785 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 11 Jul 2025 16:00:48 -0500 Subject: [PATCH 14/80] Update login query param to x-return-tokens header --- components/connection/ServerConnectForm.vue | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/components/connection/ServerConnectForm.vue b/components/connection/ServerConnectForm.vue index 9ede75a3..8b799da2 100644 --- a/components/connection/ServerConnectForm.vue +++ b/components/connection/ServerConnectForm.vue @@ -627,7 +627,12 @@ export default { }) }, requestServerLogin() { - return this.postRequest(`${this.serverConfig.address}/login?return_tokens=true`, { 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) => { if (!data.user) { console.error(data.error) From f4e0a6121ff38f08e7e8d0806c640dd01bbc8f6e Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 15 Jul 2025 17:22:45 -0500 Subject: [PATCH 15/80] Update readers to handle token refresh --- components/readers/ComicReader.vue | 8 +- components/readers/EpubReader.vue | 20 +++-- components/readers/MobiReader.vue | 13 +--- components/readers/PdfReader.vue | 58 +++++++++++++- components/readers/Reader.vue | 6 +- plugins/axios.js | 121 ++++++++++++++++++++++++++--- store/user.js | 59 +++++++++++++- 7 files changed, 243 insertions(+), 42 deletions(-) diff --git a/components/readers/ComicReader.vue b/components/readers/ComicReader.vue index bd7ed4e1..f62ea87d 100644 --- a/components/readers/ComicReader.vue +++ b/components/readers/ComicReader.vue @@ -77,9 +77,6 @@ export default { } }, computed: { - userToken() { - return this.$store.getters['user/getToken'] - }, libraryItemId() { return this.libraryItem?.id }, @@ -247,10 +244,7 @@ export default { // TODO: Handle JWT auth refresh const buff = await this.$axios.$get(this.url, { - responseType: 'blob', - headers: { - Authorization: `Bearer ${this.userToken}` - } + responseType: 'blob' }) const archive = await Archive.open(buff) diff --git a/components/readers/EpubReader.vue b/components/readers/EpubReader.vue index f40cf07c..28857a5d 100644 --- a/components/readers/EpubReader.vue +++ b/components/readers/EpubReader.vue @@ -49,9 +49,6 @@ export default { } }, computed: { - userToken() { - return this.$store.getters['user/getToken'] - }, /** @returns {string} */ libraryItemId() { return this.libraryItem?.id @@ -298,15 +295,26 @@ export default { /** @type {EpubReader} */ 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) /** @type {ePub.Book} */ reader.book = new ePub(reader.url, { width: window.innerWidth, height: window.innerHeight - this.readerHeightOffset, openAs: 'epub', - requestHeaders: { - Authorization: `Bearer ${this.userToken}` - } + requestMethod: this.isLocal ? null : customRequest }) /** @type {ePub.Rendition} */ diff --git a/components/readers/MobiReader.vue b/components/readers/MobiReader.vue index a3d5c112..53de8ecd 100644 --- a/components/readers/MobiReader.vue +++ b/components/readers/MobiReader.vue @@ -22,11 +22,7 @@ export default { data() { return {} }, - computed: { - userToken() { - return this.$store.getters['user/getToken'] - } - }, + computed: {}, methods: { addHtmlCss() { let iframe = document.getElementsByTagName('iframe')[0] @@ -86,10 +82,7 @@ export default { // Fetch mobi file as blob // TODO: Handle JWT auth refresh var buff = await this.$axios.$get(this.url, { - responseType: 'blob', - headers: { - Authorization: `Bearer ${this.userToken}` - } + responseType: 'blob' }) var reader = new FileReader() reader.onload = async (event) => { @@ -134,4 +127,4 @@ export default { overflow-x: hidden; overflow-y: auto; } - \ No newline at end of file + diff --git a/components/readers/PdfReader.vue b/components/readers/PdfReader.vue index 8a163719..1446e396 100644 --- a/components/readers/PdfReader.vue +++ b/components/readers/PdfReader.vue @@ -49,7 +49,8 @@ export default { numPages: 0, windowWidth: 0, windowHeight: 0, - pdfDocInitParams: null + pdfDocInitParams: null, + isRefreshing: false } }, computed: { @@ -109,6 +110,10 @@ export default { }, isPlayerOpen() { return this.$store.getters['getIsPlayerOpen'] + }, + ebookUrl() { + const serverAddress = this.$store.getters['user/getServerAddress'] + return this.isLocal ? this.url : `${serverAddress}${this.url}` } }, methods: { @@ -164,7 +169,54 @@ export default { this.page++ 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) }, screenOrientationChange() { @@ -173,7 +225,7 @@ export default { }, init() { this.pdfDocInitParams = { - url: this.url, + url: this.ebookUrl, httpHeaders: { Authorization: `Bearer ${this.userToken}` } diff --git a/components/readers/Reader.vue b/components/readers/Reader.vue index dda49acb..aa76df67 100644 --- a/components/readers/Reader.vue +++ b/components/readers/Reader.vue @@ -305,11 +305,11 @@ export default { if (this.localContentUrl) { return Capacitor.convertFileSrc(this.localContentUrl) } - const serverAddress = this.$store.getters['user/getServerAddress'] + 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() { return this.$store.getters['getIsPlayerOpen'] diff --git a/plugins/axios.js b/plugins/axios.js index 5974874f..6424446d 100644 --- a/plugins/axios.js +++ b/plugins/axios.js @@ -1,17 +1,50 @@ -export default function ({ $axios, store }) { +export default function ({ $axios, store, $db }) { + // 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) if (config.url.startsWith('http:') || config.url.startsWith('https:') || config.url.startsWith('capacitor:')) { 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'] if (bearerToken) { config.headers.common['Authorization'] = `Bearer ${bearerToken}` @@ -26,7 +59,75 @@ export default function ({ $axios, store }) { console.log('[Axios] Request out', config.url) }) - $axios.onError((error) => { - console.error('Axios error code', error) + $axios.onError(async (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) }) } diff --git a/store/user.js b/store/user.js index 63161bb0..042eb55c 100644 --- a/store/user.js +++ b/store/user.js @@ -1,5 +1,6 @@ import { Browser } from '@capacitor/browser' import { AbsLogger } from '@/plugins/capacitor' +import { CapacitorHttp } from '@capacitor/core' export const state = () => ({ user: null, @@ -30,9 +31,6 @@ export const getters = { getServerConfigName: (state) => { return state.serverConnectionConfig?.name || null }, - getCustomHeaders: (state) => { - return state.serverConnectionConfig?.customHeaders || null - }, getUserMediaProgress: (state) => (libraryItemId, episodeId = null) => { @@ -165,6 +163,61 @@ export const actions = { 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 } } From 224d75fac509a7679ffa12b5311f845fbfa81ae3 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 15 Jul 2025 17:41:30 -0500 Subject: [PATCH 16/80] Update old auth alert messages, add link to github discussion --- components/connection/ServerConnectForm.vue | 9 ++++++--- strings/en-us.json | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/components/connection/ServerConnectForm.vue b/components/connection/ServerConnectForm.vue index 8b799da2..37c17485 100644 --- a/components/connection/ServerConnectForm.vue +++ b/components/connection/ServerConnectForm.vue @@ -163,12 +163,15 @@ export default { cancelText: this.$strings.ButtonOk }) }, - showOldAuthWarningDialog() { - Dialog.alert({ + async showOldAuthWarningDialog() { + const confirmResult = await Dialog.confirm({ title: 'Old Server Auth Warning', message: this.$strings.MessageOldServerAuthWarningHelp, - cancelText: this.$strings.ButtonOk + cancelButtonTitle: this.$strings.ButtonReadMore }) + if (!confirmResult.value) { + window.open('https://github.com/advplyr/audiobookshelf/discussions/4460', '_blank') + } }, 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) diff --git a/strings/en-us.json b/strings/en-us.json index fbd8e24a..5c38599b 100644 --- a/strings/en-us.json +++ b/strings/en-us.json @@ -325,9 +325,9 @@ "MessageNoSeries": "No series", "MessageNoUpdatesWereNecessary": "No updates were necessary", "MessageNoUserPlaylists": "You have no playlists", - "MessageOldServerAuthReLoginRequired": "A new authentication system was added in server v2.26.0. Please re-login to use the more secure authentication.", + "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": "Authentication was updated in server v2.26.0 to use a more secure method. A future app update will require server version v2.26.0 or higher. You will need to re-login after updating the server.", + "MessageOldServerAuthWarningHelp": "Authentication was updated in server v2.26.0 for security. It is strongly recommended to update the server to the latest version.", "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.", "MessagePodcastSearchField": "Enter search term or RSS feed URL", From 87614bc78af30d99fa4bc845e61aa18428f6c714 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 17 Jul 2025 17:37:41 -0500 Subject: [PATCH 17/80] Update auth message, update force re-login to pull auth methods to support oidc --- components/connection/ServerConnectForm.vue | 17 ++++++++++------- strings/en-us.json | 2 +- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/components/connection/ServerConnectForm.vue b/components/connection/ServerConnectForm.vue index 37c17485..404f993d 100644 --- a/components/connection/ServerConnectForm.vue +++ b/components/connection/ServerConnectForm.vue @@ -22,9 +22,6 @@

{{ $strings.MessageOldServerAuthWarning }}

{{ $strings.LabelMoreInfo }}
-
-

No server version set. Connect to update server config.

-
{{ $strings.ButtonAddNewServer }} @@ -680,9 +677,13 @@ export default { if (statusData.data.authFormData?.authOpenIDAutoLaunch) { this.clickLoginWithOpenId() } + return true + } else { + return false } } catch (error) { this.handleLoginFormError(error) + return false } finally { this.processing = false } @@ -925,10 +926,12 @@ export default { this.processing = false return authRes }, - setForceReloginForNewAuth() { - this.error = this.$strings.MessageOldServerAuthReLoginRequired - this.showAuth = true - this.showForm = true + 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() { // Handle force re-login for servers using new JWT auth but still using an old token in the server config diff --git a/strings/en-us.json b/strings/en-us.json index 5c38599b..ef8fb515 100644 --- a/strings/en-us.json +++ b/strings/en-us.json @@ -327,7 +327,7 @@ "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": "Authentication was updated in server v2.26.0 for security. It is strongly recommended to update the server to the latest version.", + "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.", "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", From 572fc8545f6b55b1e757d88af95c042f5d9626ed Mon Sep 17 00:00:00 2001 From: Kabika82 Date: Fri, 11 Jul 2025 11:14:24 +0200 Subject: [PATCH 18/80] 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/ --- strings/hu.json | 1 + 1 file changed, 1 insertion(+) diff --git a/strings/hu.json b/strings/hu.json index 49cb7162..d06af335 100644 --- a/strings/hu.json +++ b/strings/hu.json @@ -146,6 +146,7 @@ "LabelEndOfChapter": "Fejezet vége", "LabelEndTime": "Befejezés ideje", "LabelEpisode": "Epizód", + "LabelExplicit": "Szókimondó", "LabelFeedURL": "Hírcsatorna URL", "LabelFile": "Fájl", "LabelFileBirthtime": "Fájl létrehozásának ideje", From be99ef87adb52685b6a8e0e647382516bf6f04a9 Mon Sep 17 00:00:00 2001 From: FiendFEARing Date: Fri, 11 Jul 2025 04:00:45 +0200 Subject: [PATCH 19/80] 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/ --- strings/zh-cn.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/strings/zh-cn.json b/strings/zh-cn.json index e004f20f..ba8ed06f 100644 --- a/strings/zh-cn.json +++ b/strings/zh-cn.json @@ -29,7 +29,7 @@ "ButtonLocalMedia": "本地媒体", "ButtonLogs": "日志", "ButtonManageLocalFiles": "管理本地文件", - "ButtonMaskServerAddress": "屏蔽服务器地址", + "ButtonMaskServerAddress": "隐藏服务器地址", "ButtonNewFolder": "新建文件夹", "ButtonNextEpisode": "下一集", "ButtonOk": "确定", @@ -40,7 +40,7 @@ "ButtonPlayEpisode": "播放剧集", "ButtonPlaylists": "播放列表", "ButtonRead": "读取", - "ButtonReadLess": "阅读较少", + "ButtonReadLess": "收起", "ButtonReadMore": "阅读更多", "ButtonRemove": "移除", "ButtonRemoveFromServer": "从服务器中删除", @@ -53,7 +53,7 @@ "ButtonStream": "串流播放", "ButtonSubmit": "提交", "ButtonSwitchServerUser": "切换服务器/用户", - "ButtonUnmaskServerAddress": "取消屏蔽服务器地址", + "ButtonUnmaskServerAddress": "显示服务器地址", "ButtonUserStats": "用户统计信息", "ButtonYes": "确定", "HeaderAccount": "帐户", @@ -81,7 +81,7 @@ "HeaderPlaylistItems": "播放列表项目", "HeaderProgressSyncFailed": "进度同步失败", "HeaderRSSFeed": "RSS 源", - "HeaderRSSFeedGeneral": "RSS 源概述", + "HeaderRSSFeedGeneral": "RSS 详细信息", "HeaderRSSFeedIsOpen": "RSS 源已打开", "HeaderSelectDownloadLocation": "选择下载位置", "HeaderSettings": "设置", From afd3cd67164e901ddb5c41bac71b935bd63fe054 Mon Sep 17 00:00:00 2001 From: biuklija Date: Sun, 13 Jul 2025 08:55:37 +0200 Subject: [PATCH 20/80] 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/ --- strings/hr.json | 1 + 1 file changed, 1 insertion(+) diff --git a/strings/hr.json b/strings/hr.json index baa761be..7246a003 100644 --- a/strings/hr.json +++ b/strings/hr.json @@ -146,6 +146,7 @@ "LabelEndOfChapter": "Kraj poglavlja", "LabelEndTime": "Vrijeme završetka", "LabelEpisode": "Nastavak", + "LabelExplicit": "Eksplicitno", "LabelFeedURL": "URL izvora", "LabelFile": "Datoteka", "LabelFileBirthtime": "Datoteka stvorena", From 2f3b2613923c1c0167a3aeb0c06f79e35647207c Mon Sep 17 00:00:00 2001 From: Simple16 Date: Sun, 13 Jul 2025 06:48:02 +0200 Subject: [PATCH 21/80] 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/ --- strings/ru.json | 1 + 1 file changed, 1 insertion(+) diff --git a/strings/ru.json b/strings/ru.json index cdf1cabe..254d9de0 100644 --- a/strings/ru.json +++ b/strings/ru.json @@ -146,6 +146,7 @@ "LabelEndOfChapter": "Конец главы", "LabelEndTime": "Время конца", "LabelEpisode": "Эпизод", + "LabelExplicit": "18+", "LabelFeedURL": "URL канала", "LabelFile": "Файл", "LabelFileBirthtime": "Дата создания", From fa31fba586cf81dd9c36ba23191cb33b46b09511 Mon Sep 17 00:00:00 2001 From: FiendFEARing Date: Sun, 13 Jul 2025 11:39:43 +0200 Subject: [PATCH 22/80] 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/ --- strings/zh-cn.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/strings/zh-cn.json b/strings/zh-cn.json index ba8ed06f..4c5a5d85 100644 --- a/strings/zh-cn.json +++ b/strings/zh-cn.json @@ -249,7 +249,7 @@ "LabelStartTime": "开始时间", "LabelStatsBestDay": "单日最高", "LabelStatsDailyAverage": "每日平均值", - "LabelStatsDays": "连续收听", + "LabelStatsDays": "连续", "LabelStatsDaysListened": "收听天数", "LabelStatsInARow": "天", "LabelStatsItemsFinished": "已完成的项目", @@ -329,7 +329,7 @@ "MessageOldServerConnectionWarningHelp": "您最初在 2023 年 6 月发布的 2.3.0 版本中进行数据库迁移之前设置了与此服务器的连接. 未来的服务器更新将取消使用此旧连接登录的功能. 请删除现有的服务器连接并重新连接 (使用相同的服务器地址和凭据). 如果您在此设备上有任何下载的媒体, 则需要再次下载该媒体才能与服务器同步.", "MessagePodcastSearchField": "输入搜索词或 RSS 源 URL", "MessageProgressSyncFailed": "向服务器报告收听进度的最新尝试失败. 播放媒体时, 将继续每 15 秒至 1 分钟尝试一次进度同步请求.", - "MessageReportBugsAndContribute": "报告错误、请求功能和贡献在", + "MessageReportBugsAndContribute": "反馈问题、建议功能或参与贡献,请访问", "MessageSeriesAlreadyDownloaded": "你已下载此系列的所有书籍.", "MessageSeriesDownloadConfirm": "将缺失的 {0} 本图书和 {1} 个文件 (共 {2} 个) 下载至文件夹 {3}?", "MessageSeriesDownloadConfirmIos": "是否下载缺失的 {0} 本书和 {1} 个文件,总计 {2} 个?", From 571b3a2c86336ab6e9f1defb443bc06af18270b0 Mon Sep 17 00:00:00 2001 From: Jannik Date: Tue, 15 Jul 2025 00:29:48 +0200 Subject: [PATCH 23/80] 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/ --- strings/de.json | 1 + 1 file changed, 1 insertion(+) diff --git a/strings/de.json b/strings/de.json index bdb93841..dcc6e9cd 100644 --- a/strings/de.json +++ b/strings/de.json @@ -146,6 +146,7 @@ "LabelEndOfChapter": "Ende des Kapitels", "LabelEndTime": "Endzeit", "LabelEpisode": "Episode", + "LabelExplicit": "Explizit", "LabelFeedURL": "Feed-URL", "LabelFile": "Datei", "LabelFileBirthtime": "Datei erstellt", From 79c4ffa6c501d1268bef1a2dbe51d50e1f654135 Mon Sep 17 00:00:00 2001 From: Fredrik Lindqvist Date: Tue, 15 Jul 2025 11:31:13 +0200 Subject: [PATCH 24/80] 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/ --- strings/sv.json | 1 + 1 file changed, 1 insertion(+) diff --git a/strings/sv.json b/strings/sv.json index f8b5f3a3..1b4bcc0d 100644 --- a/strings/sv.json +++ b/strings/sv.json @@ -146,6 +146,7 @@ "LabelEndOfChapter": "Slut av kapitel", "LabelEndTime": "Slut tid", "LabelEpisode": "Avsnitt", + "LabelExplicit": "Bestämd", "LabelFeedURL": "URL-adress för flödet", "LabelFile": "Fil", "LabelFileBirthtime": "Tidpunkt, fil skapad", From 2d9604aa074587d209fac048e0036ae62c670066 Mon Sep 17 00:00:00 2001 From: Grzegorz Orlowski Date: Tue, 15 Jul 2025 23:14:43 +0200 Subject: [PATCH 25/80] 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/ --- strings/pl.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/strings/pl.json b/strings/pl.json index b451fb37..05ff64d0 100644 --- a/strings/pl.json +++ b/strings/pl.json @@ -141,7 +141,7 @@ "LabelEbooks": "Ebooki", "LabelEnable": "Włącz", "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", "LabelEndOfChapter": "Koniec rozdziału", "LabelEndTime": "Koniec czasu", @@ -322,7 +322,9 @@ "MessageNoSeries": "Brak serii", "MessageNoUpdatesWereNecessary": "Brak wymaganych aktualizacji", "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", + "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", "MessageSeriesAlreadyDownloaded": "Seria książek już pobrana.", "MessageSeriesDownloadConfirm": "Pobrać serię?", From decac596d00feaa3aa3fb5ca75bf130a4ee83234 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=BA=D1=81=D0=B8=D0=BC=20=D0=93=D0=BE=D1=80?= =?UTF-8?q?=D0=BF=D0=B8=D0=BD=D1=96=D1=87?= Date: Thu, 17 Jul 2025 06:41:38 +0200 Subject: [PATCH 26/80] 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/ --- strings/uk.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/strings/uk.json b/strings/uk.json index 010bce4a..e4166237 100644 --- a/strings/uk.json +++ b/strings/uk.json @@ -318,7 +318,7 @@ "MessageNoItems": "Елементи відсутні", "MessageNoItemsFound": "Елементів не знайдено", "MessageNoListeningSessions": "Сеанси прослуховування відсутні", - "MessageNoLogs": "Без журналу", + "MessageNoLogs": "Немає журналу", "MessageNoMediaFolders": "Теки медіа відсутні", "MessageNoNetworkConnection": "Немає підключення до мережі", "MessageNoPodcastsFound": "Подкастів не знайдено", From c31aa17ce2f2d21520d0cf2eba30395c2721beb3 Mon Sep 17 00:00:00 2001 From: SunSpring Date: Thu, 17 Jul 2025 01:32:05 +0200 Subject: [PATCH 27/80] 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/ --- strings/zh-cn.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/strings/zh-cn.json b/strings/zh-cn.json index 4c5a5d85..a4c1e4d7 100644 --- a/strings/zh-cn.json +++ b/strings/zh-cn.json @@ -251,7 +251,7 @@ "LabelStatsDailyAverage": "每日平均值", "LabelStatsDays": "连续", "LabelStatsDaysListened": "收听天数", - "LabelStatsInARow": "天", + "LabelStatsInARow": "在一行", "LabelStatsItemsFinished": "已完成的项目", "LabelStatsMinutes": "分钟", "LabelStatsMinutesListening": "收听分钟数", @@ -329,7 +329,7 @@ "MessageOldServerConnectionWarningHelp": "您最初在 2023 年 6 月发布的 2.3.0 版本中进行数据库迁移之前设置了与此服务器的连接. 未来的服务器更新将取消使用此旧连接登录的功能. 请删除现有的服务器连接并重新连接 (使用相同的服务器地址和凭据). 如果您在此设备上有任何下载的媒体, 则需要再次下载该媒体才能与服务器同步.", "MessagePodcastSearchField": "输入搜索词或 RSS 源 URL", "MessageProgressSyncFailed": "向服务器报告收听进度的最新尝试失败. 播放媒体时, 将继续每 15 秒至 1 分钟尝试一次进度同步请求.", - "MessageReportBugsAndContribute": "反馈问题、建议功能或参与贡献,请访问", + "MessageReportBugsAndContribute": "反馈问题, 建议功能或参与贡献, 请访问", "MessageSeriesAlreadyDownloaded": "你已下载此系列的所有书籍.", "MessageSeriesDownloadConfirm": "将缺失的 {0} 本图书和 {1} 个文件 (共 {2} 个) 下载至文件夹 {3}?", "MessageSeriesDownloadConfirmIos": "是否下载缺失的 {0} 本书和 {1} 个文件,总计 {2} 个?", From e44c5740680fa38d1621851c3b943cb9d115d56a Mon Sep 17 00:00:00 2001 From: Gernomaly Date: Fri, 18 Jul 2025 03:59:46 +0200 Subject: [PATCH 28/80] 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/ --- strings/de.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/strings/de.json b/strings/de.json index dcc6e9cd..e28edfcc 100644 --- a/strings/de.json +++ b/strings/de.json @@ -325,6 +325,8 @@ "MessageNoSeries": "Keine Serie", "MessageNoUpdatesWereNecessary": "Keine Aktualisierungen waren notwendig", "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.", "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", From 8be2e3af0cfa144d9c09e33f1f8589946c458445 Mon Sep 17 00:00:00 2001 From: biuklija Date: Fri, 18 Jul 2025 10:45:22 +0200 Subject: [PATCH 29/80] 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/ --- strings/hr.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/strings/hr.json b/strings/hr.json index 7246a003..7594e2de 100644 --- a/strings/hr.json +++ b/strings/hr.json @@ -325,6 +325,9 @@ "MessageNoSeries": "Nema serijala", "MessageNoUpdatesWereNecessary": "Ažuriranje nije bilo potrebno", "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.", "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", From ce62f3b2738c87f8062ef6335b0ce810eb5a0d52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=BA=D1=81=D0=B8=D0=BC=20=D0=93=D0=BE=D1=80?= =?UTF-8?q?=D0=BF=D0=B8=D0=BD=D1=96=D1=87?= Date: Fri, 18 Jul 2025 08:59:58 +0200 Subject: [PATCH 30/80] 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/ --- strings/uk.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/strings/uk.json b/strings/uk.json index e4166237..163356fc 100644 --- a/strings/uk.json +++ b/strings/uk.json @@ -318,13 +318,16 @@ "MessageNoItems": "Елементи відсутні", "MessageNoItemsFound": "Елементів не знайдено", "MessageNoListeningSessions": "Сеанси прослуховування відсутні", - "MessageNoLogs": "Немає журналу", + "MessageNoLogs": "Нема журналу", "MessageNoMediaFolders": "Теки медіа відсутні", "MessageNoNetworkConnection": "Немає підключення до мережі", "MessageNoPodcastsFound": "Подкастів не знайдено", "MessageNoSeries": "Серії відсутні", "MessageNoUpdatesWereNecessary": "Оновлення не потрібні", "MessageNoUserPlaylists": "У вас немає списків відтворення", + "MessageOldServerAuthReLoginRequired": "Автентифікацію покращено для безпеки на сервері версії 2.26.0. Усім користувачам потрібно повторно увійти в систему.", + "MessageOldServerAuthWarning": "Сервер використовує застарілу автентифікацію", + "MessageOldServerAuthWarningHelp": "Цей сервер використовує версію, старішу за v2.26.0. У версії 2.26.0 було додано безпечнішу систему автентифікації. Наполегливо рекомендується оновити сервер до останньої версії. Якщо ви вже оновили сервер, увійдіть знову, щоб використовувати нову автентифікацію.", "MessageOldServerConnectionWarning": "Конфігурація підключення до сервера використовує старий ідентифікатор користувача. Видаліть і додайте це підключення до сервера заново.", "MessageOldServerConnectionWarningHelp": "Ви спочатку налаштували з’єднання з цим сервером перед міграцією бази даних у версії 2.3.0, випущеній у червні 2023 року. Майбутнє оновлення сервера скасує можливість входу за допомогою цього старого з’єднання. Будь ласка, видаліть існуюче підключення до сервера та підключіться знову (використовуючи ту саму адресу сервера та облікові дані). Якщо на цьому пристрої є будь-які завантажені медіафайли, їх потрібно буде завантажити ще раз, щоб синхронізувати із сервером.", "MessagePodcastSearchField": "Введіть пошуковий запит або URL RSS-стрічки", From d8b4cf98ddeb58ccb8ea17340ce933a5aa1367de Mon Sep 17 00:00:00 2001 From: SunSpring Date: Fri, 18 Jul 2025 02:25:39 +0200 Subject: [PATCH 31/80] 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/ --- strings/zh-cn.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/strings/zh-cn.json b/strings/zh-cn.json index a4c1e4d7..565d8ab7 100644 --- a/strings/zh-cn.json +++ b/strings/zh-cn.json @@ -325,6 +325,9 @@ "MessageNoSeries": "没有系列", "MessageNoUpdatesWereNecessary": "无需更新", "MessageNoUserPlaylists": "你没有播放列表", + "MessageOldServerAuthReLoginRequired": "服务器 v2.26.0 中的身份验证已得到改进, 以提高安全性. 所有用户都需要重新登录.", + "MessageOldServerAuthWarning": "服务器正在使用过时的身份验证", + "MessageOldServerAuthWarningHelp": "此服务器运行的版本早于 v2.26.0. 在 v2.26.0 中添加了更安全的身份验证系统. 强烈建议将服务器更新到最新版本. 如果您已经更新了服务器, 请重新登录以使用新的身份验证.", "MessageOldServerConnectionWarning": "服务器连接配置正在使用旧的用户 ID. 请删除并重新添加此服务器连接.", "MessageOldServerConnectionWarningHelp": "您最初在 2023 年 6 月发布的 2.3.0 版本中进行数据库迁移之前设置了与此服务器的连接. 未来的服务器更新将取消使用此旧连接登录的功能. 请删除现有的服务器连接并重新连接 (使用相同的服务器地址和凭据). 如果您在此设备上有任何下载的媒体, 则需要再次下载该媒体才能与服务器同步.", "MessagePodcastSearchField": "输入搜索词或 RSS 源 URL", From 15fa42986aa55a97a2953f5742a0d0f2aaefb7ca Mon Sep 17 00:00:00 2001 From: FiendFEARing Date: Fri, 18 Jul 2025 10:11:49 +0200 Subject: [PATCH 32/80] 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/ --- strings/zh-cn.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/strings/zh-cn.json b/strings/zh-cn.json index 565d8ab7..e3e60cd2 100644 --- a/strings/zh-cn.json +++ b/strings/zh-cn.json @@ -251,7 +251,7 @@ "LabelStatsDailyAverage": "每日平均值", "LabelStatsDays": "连续", "LabelStatsDaysListened": "收听天数", - "LabelStatsInARow": "在一行", + "LabelStatsInARow": "天", "LabelStatsItemsFinished": "已完成的项目", "LabelStatsMinutes": "分钟", "LabelStatsMinutesListening": "收听分钟数", From 03e2feddb35baa773524ba48d7d25a55f6763387 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 19 Jul 2025 16:55:54 -0500 Subject: [PATCH 33/80] Version bump v0.10.0-beta --- android/app/build.gradle | 4 ++-- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index ea4b69d7..dce3b2e3 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -36,8 +36,8 @@ android { applicationId "com.audiobookshelf.app" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 112 - versionName "0.9.81-beta" + versionCode 113 + versionName "0.10.0-beta" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders = [ "appAuthRedirectScheme": "com.audiobookshelf.app" diff --git a/package-lock.json b/package-lock.json index f8576ebb..44156c0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-app", - "version": "0.9.81-beta", + "version": "0.10.0-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-app", - "version": "0.9.81-beta", + "version": "0.10.0-beta", "dependencies": { "@capacitor-community/keep-awake": "^7.0.0", "@capacitor-community/volume-buttons": "^7.0.0", diff --git a/package.json b/package.json index 15effb38..140df13c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-app", - "version": "0.9.81-beta", + "version": "0.10.0-beta", "author": "advplyr", "scripts": { "dev": "nuxt --hostname 0.0.0.0 --port 1337", From 04c70f54aaaf16db2687be50e4510b27818421a1 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 19 Jul 2025 17:17:27 -0500 Subject: [PATCH 34/80] iOS version bump 0.10.0 --- ios/App/App.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 7fa9db20..527e65be 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -736,12 +736,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = Icons; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 38; + CURRENT_PROJECT_VERSION = 39; DEVELOPMENT_TEAM = 7UFJ7D8V6A; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; 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\""; PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -760,12 +760,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = Icons; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 38; + CURRENT_PROJECT_VERSION = 39; DEVELOPMENT_TEAM = 7UFJ7D8V6A; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; 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_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; From def47fd5e230193e4a6f2a9b9ad295f581faa193 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 21 Jul 2025 08:57:56 -0500 Subject: [PATCH 35/80] Update gh issue template app versions --- .github/ISSUE_TEMPLATE/bug_report.yml | 6 ++---- .github/ISSUE_TEMPLATE/feature_request.yml | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 98842277..414ff717 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -72,12 +72,10 @@ body: description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.* multiple: true options: + - 'Android App - 0.10.0' + - 'iOS App - 0.10.0' - 'Android App - 0.9.81' - 'iOS App - 0.9.81' - - 'Android App - 0.9.80' - - 'iOS App - 0.9.80' - - 'Android App - 0.9.79' - - 'iOS App - 0.9.79' validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 4b38385a..fa6c9e9e 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -43,12 +43,10 @@ body: description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.* multiple: true options: + - 'Android App - 0.10.0' + - 'iOS App - 0.10.0' - 'Android App - 0.9.81' - 'iOS App - 0.9.81' - - 'Android App - 0.9.80' - - 'iOS App - 0.9.80' - - 'Android App - 0.9.79' - - 'iOS App - 0.9.79' validations: required: true - type: textarea From 82cddde15f4fd83fab7f92dcf03a19b32b26daa0 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 22 Jul 2025 14:16:35 -0500 Subject: [PATCH 36/80] Fix track URL used for transcodes on Android #1635 --- .../java/com/audiobookshelf/app/data/PlaybackSession.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt b/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt index 46ea3cdc..2a4b8b30 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt @@ -192,7 +192,12 @@ class PlaybackSession( // 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 if (isDirectPlay) { + Uri.parse("$serverAddress/public/session/$id/track/${audioTrack.index}") + } else { + // Transcode uses HlsRouter on server + Uri.parse("$serverAddress${audioTrack.contentUrl}") + } } return Uri.parse("$serverAddress${audioTrack.contentUrl}?token=${DeviceManager.token}") } From a53a96ecf5b2bcbdb2fff82bd6dfb3aac49111e7 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 22 Jul 2025 14:46:31 -0500 Subject: [PATCH 37/80] Update web player track index to fallback to 1 --- plugins/capacitor/AbsAudioPlayer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/capacitor/AbsAudioPlayer.js b/plugins/capacitor/AbsAudioPlayer.js index f16b882b..e43d09e3 100644 --- a/plugins/capacitor/AbsAudioPlayer.js +++ b/plugins/capacitor/AbsAudioPlayer.js @@ -255,7 +255,7 @@ class AbsAudioPlayerWeb extends WebPlugin { if (this.currentTrack.contentUrl?.startsWith('/hls')) { sessionTrackUrl = this.currentTrack.contentUrl } else { - sessionTrackUrl = `/public/session/${this.playbackSession.id}/track/${this.currentTrack.index}` + sessionTrackUrl = `/public/session/${this.playbackSession.id}/track/${this.currentTrack.index || 1}` } this.player.src = `${serverHost}${sessionTrackUrl}` From 69a54c5fa9d38f0e13e84a68a94ed70d60ba881e Mon Sep 17 00:00:00 2001 From: Pavel Vachek Date: Mon, 21 Jul 2025 19:49:50 +0200 Subject: [PATCH 38/80] Translated using Weblate (Czech) Currently translated at 99.1% (353 of 356 strings) Translation: Audiobookshelf/Abs Mobile App Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/cs/ --- strings/cs.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/strings/cs.json b/strings/cs.json index b7078c31..00b02f58 100644 --- a/strings/cs.json +++ b/strings/cs.json @@ -331,7 +331,7 @@ "MessageProgressSyncFailed": "Poslední pokus o nahlášení průběhu poslechu na server se nezdařil. Během přehrávání médií se budou požadavky na synchronizaci pokroku nadále pokoušet každých 15 až 60 sekund.", "MessageReportBugsAndContribute": "Nahlašte chyby, vyžádejte si funkce a přispěte na", "MessageSeriesAlreadyDownloaded": "Všechny knihy v této sérii jste již stáhli.", - "MessageSeriesDownloadConfirm": "Stáhnout chybějící {0} knihu(y) s {1} souborem(y), celkem {2} do složky {3}?", + "MessageSeriesDownloadConfirm": "Stáhnout chybějících {0} knih s {1} souborem/soubory, celkem {2}, do složky {3}?", "MessageSeriesDownloadConfirmIos": "Stáhnout chybějící {0} knihu(y) s {1} souborem(y), celkem {2}?", "MessageSocketConnectedOverMeteredCellular": "Socket připojen přes měřenou mobilní síť", "MessageSocketConnectedOverMeteredWifi": "Socket připojen přes měřenou Wi-Fi", From 8038a8ac6615d5f5b4918f36304e451c1c8f97ff Mon Sep 17 00:00:00 2001 From: kuci-JK Date: Mon, 21 Jul 2025 19:50:16 +0200 Subject: [PATCH 39/80] Translated using Weblate (Czech) Currently translated at 99.1% (353 of 356 strings) Translation: Audiobookshelf/Abs Mobile App Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/cs/ --- strings/cs.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/strings/cs.json b/strings/cs.json index 00b02f58..f00a8439 100644 --- a/strings/cs.json +++ b/strings/cs.json @@ -325,13 +325,16 @@ "MessageNoSeries": "Žádné série", "MessageNoUpdatesWereNecessary": "Nebyly nutné žádné aktualizace", "MessageNoUserPlaylists": "Nemáte žádné seznamy skladeb", + "MessageOldServerAuthReLoginRequired": "V rámci aktualizace serveru v2.26.0 bylo vylepšeno zabezpečení autentizace. Všichni uživatelé se musí znovu přihlásit.", + "MessageOldServerAuthWarning": "Server používá zastaralou autentizaci", + "MessageOldServerAuthWarningHelp": "Server běží na verzi starší než v2.26.0. Bezpečnější způsob autentizace byl přidán ve verzi v2.26.0. Důrazně se doporučuje aktualizovat server na nejnovější verzi. Pokud jste již server aktualizovali, znovu se přihlašte pro použití nové autentizace.", "MessageOldServerConnectionWarning": "Konfigurace připojení k serveru používá staré ID uživatele. Odstraňte a znovu přidejte toto připojení k serveru.", "MessageOldServerConnectionWarningHelp": "Připojení k tomuto serveru jste původně nastavili před migrací databáze ve verzi 2.3.0 vydané v červnu 2023. Budoucí aktualizace serveru odstraní možnost přihlášení pomocí tohoto starého připojení. Odstraňte prosím stávající připojení k serveru a připojte se znovu (pomocí stejné adresy serveru a přihlašovacích údajů). Pokud máte v tomto zařízení stažená média, bude nutné je pro synchronizaci se serverem stáhnout znovu.", "MessagePodcastSearchField": "Zadejte hledaný pojem pro RSS feed URL", "MessageProgressSyncFailed": "Poslední pokus o nahlášení průběhu poslechu na server se nezdařil. Během přehrávání médií se budou požadavky na synchronizaci pokroku nadále pokoušet každých 15 až 60 sekund.", "MessageReportBugsAndContribute": "Nahlašte chyby, vyžádejte si funkce a přispěte na", "MessageSeriesAlreadyDownloaded": "Všechny knihy v této sérii jste již stáhli.", - "MessageSeriesDownloadConfirm": "Stáhnout chybějících {0} knih s {1} souborem/soubory, celkem {2}, do složky {3}?", + "MessageSeriesDownloadConfirm": "Stáhnout {0} chybějících knih s {1} souborem/soubory, celkem {2}, do složky {3}?", "MessageSeriesDownloadConfirmIos": "Stáhnout chybějící {0} knihu(y) s {1} souborem(y), celkem {2}?", "MessageSocketConnectedOverMeteredCellular": "Socket připojen přes měřenou mobilní síť", "MessageSocketConnectedOverMeteredWifi": "Socket připojen přes měřenou Wi-Fi", From 8722e91c0f11be0402dcc73f1fa0401f6b126733 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Tue, 22 Jul 2025 00:53:31 +0200 Subject: [PATCH 40/80] Translated using Weblate (Russian) 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/ru/ --- strings/ru.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/strings/ru.json b/strings/ru.json index 254d9de0..22fe8ebf 100644 --- a/strings/ru.json +++ b/strings/ru.json @@ -325,6 +325,9 @@ "MessageNoSeries": "Нет серии", "MessageNoUpdatesWereNecessary": "Обновления не требовались", "MessageNoUserPlaylists": "У вас нет плейлистов", + "MessageOldServerAuthReLoginRequired": "В версии server 2.26.0 для обеспечения безопасности была улучшена аутентификация. Всем пользователям необходимо повторно войти в систему.", + "MessageOldServerAuthWarning": "Сервер использует устаревшую аутентификацию", + "MessageOldServerAuthWarningHelp": "Этот сервер работает под управлением версии старше чем 2.26.0. В версии 2.26.0 была добавлена более безопасная система аутентификации. Настоятельно рекомендуется обновить сервер до последней версии. Если вы уже обновили сервер, войдите в систему еще раз, чтобы использовать новую аутентификацию.", "MessageOldServerConnectionWarning": "Конфигурация подключения к серверу использует старый идентификатор пользователя. Удалите и повторно добавьте это подключение к серверу.", "MessageOldServerConnectionWarningHelp": "Первоначально вы настроили подключение к этому серверу до миграции базы данных в версии 2.3.0, выпущенной в июне 2023 года. Будущее обновление сервера уберет возможность входа с помощью этого старого подключения. Удалите существующее подключение к серверу и подключитесь снова (используя тот же адрес сервера и учетные данные). Если на этом устройстве есть загруженные медиафайлы, их необходимо будет загрузить снова для синхронизации с сервером.", "MessagePodcastSearchField": "Введите поисковый запрос или URL-адрес RSS-канала", From c34d14edb5b7f71d553dc167142e8a8d2ca03b77 Mon Sep 17 00:00:00 2001 From: Mikkel Dupont Olesen Date: Fri, 25 Jul 2025 23:24:35 +0200 Subject: [PATCH 41/80] Translated using Weblate (Danish) 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/da/ --- strings/da.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/strings/da.json b/strings/da.json index e1d8fe5e..418e520b 100644 --- a/strings/da.json +++ b/strings/da.json @@ -146,6 +146,7 @@ "LabelEndOfChapter": "Slutningen af kapitel", "LabelEndTime": "Sluttidspunkt", "LabelEpisode": "Afsnit", + "LabelExplicit": "Eksplisit", "LabelFeedURL": "Feed URL", "LabelFile": "Fil", "LabelFileBirthtime": "Oprettelsestidspunkt for fil", @@ -324,6 +325,9 @@ "MessageNoSeries": "Ingen serier", "MessageNoUpdatesWereNecessary": "Ingen opdateringer var nødvendige", "MessageNoUserPlaylists": "Du har ingen afspilningslister", + "MessageOldServerAuthReLoginRequired": "Autentificering er blevet forbedret af sikkerhedsmæssige årsager i server v2.26.0. Alle brugere skal logge ind igen.", + "MessageOldServerAuthWarning": "Serveren bruger forældet autentificering", + "MessageOldServerAuthWarningHelp": "Denne server kører en version ældre end v2.26.0. Et mere sikkert autentificeringssystem blev tilføjet i v2.26.0. Det anbefales kraftigt at opdatere serveren til den nyeste version. Hvis du allerede har opdateret serveren, skal du logge ind igen for at bruge den nye autentificering.", "MessageOldServerConnectionWarning": "Server forbindelseskonfiguration anvender et gammelt bruger ID. Fjern venligst og gentilføj denne server forbindelse.", "MessageOldServerConnectionWarningHelp": "Du har oprindeligt sat forbindelsen til denne server op før databasemigreringen i 2.3.0, udgivet i juni 2023. En senere serveropdatering vil fjerne muligheden for at logge ind med genne gamle metode. Sørg venligst for at slette den eksisterende serverforbindelse og opret forbindelse igen (ved anvendelse af den samme server adresse og login oplysninger). Hvis du har downloaded noget medie på denne enhed vil dette være nødsaget til at bive downloaded igen efter serversynkronisering.", "MessagePodcastSearchField": "Indtast søgeterm eller RSS URL", From a937800ef02efe770c078e6cc7f1a3b577bbe074 Mon Sep 17 00:00:00 2001 From: numerfolt Date: Tue, 29 Jul 2025 18:04:18 +0200 Subject: [PATCH 42/80] Translated using Weblate (German) Currently translated at 99.7% (355 of 356 strings) Translation: Audiobookshelf/Abs Mobile App Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/ --- strings/de.json | 1 + 1 file changed, 1 insertion(+) diff --git a/strings/de.json b/strings/de.json index e28edfcc..44df5807 100644 --- a/strings/de.json +++ b/strings/de.json @@ -327,6 +327,7 @@ "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", + "MessageOldServerAuthWarningHelp": "Die Version dieses Servers ist älter als v2.26.0. Ein sichereres Authentifikationssystem wurde mit v2.26.0 eingeführt. Es wird dringend empfohlen, den Server auf die neueste Version zu aktualisieren. Falls du den Server bereits aktualisiert hast, melde dich erneut an, um die neue Authentifizierung zu nutzen.", "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.", "MessagePodcastSearchField": "Suchbegriff oder RSS-Feed URL eingeben", From 51f2e5e7ec93ee9d548b14cd4e319aeefd8b1a18 Mon Sep 17 00:00:00 2001 From: B0rax Date: Tue, 29 Jul 2025 18:10:12 +0200 Subject: [PATCH 43/80] Translated using Weblate (German) Currently translated at 99.7% (355 of 356 strings) Translation: Audiobookshelf/Abs Mobile App Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/ --- strings/de.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/strings/de.json b/strings/de.json index 44df5807..b2afb0e7 100644 --- a/strings/de.json +++ b/strings/de.json @@ -12,10 +12,10 @@ "ButtonConnect": "Verbinden", "ButtonConnectToServer": "Mit Server verbinden", "ButtonCreate": "Erstellen", - "ButtonCreateBookmark": "Erstelle Lesezeichen", - "ButtonCreateNewPlaylist": "Erstelle neue Wiedergabeliste", + "ButtonCreateBookmark": "Lesezeichen erstellen", + "ButtonCreateNewPlaylist": "Neue Wiedergabeliste erstellen", "ButtonDelete": "Löschen", - "ButtonDeleteLocalEpisode": "Lösche lokale Episode", + "ButtonDeleteLocalEpisode": "Lokale Episode löschen", "ButtonDeleteLocalFile": "Lösche lokale Datei", "ButtonDeleteLocalItem": "Lokales Element löschen", "ButtonDisableAutoTimer": "Automatischen Timer deaktivieren", From f24dd3b28965738bbef86e95a8a40a200829fa11 Mon Sep 17 00:00:00 2001 From: ugyes Date: Mon, 28 Jul 2025 15:58:50 +0200 Subject: [PATCH 44/80] Translated using Weblate (Hungarian) 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/hu/ --- strings/hu.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/strings/hu.json b/strings/hu.json index d06af335..d6c8b484 100644 --- a/strings/hu.json +++ b/strings/hu.json @@ -325,6 +325,9 @@ "MessageNoSeries": "Nincsenek sorozatok", "MessageNoUpdatesWereNecessary": "Nem volt szükséges frissítés", "MessageNoUserPlaylists": "Nincsenek lejátszási listái", + "MessageOldServerAuthReLoginRequired": "A szerver v2.26.0 verziójában a biztonság érdekében továbbfejlesztettük a hitelesítést. Minden felhasználónak újra be kell jelentkeznie.", + "MessageOldServerAuthWarning": "A szerver elavult hitelesítést használ", + "MessageOldServerAuthWarningHelp": "Ez a szerver a v2.26.0-nál régebbi verziót futtat. A v2.26.0-ban egy biztonságosabb hitelesítési rendszer került bevezetésre. Erősen ajánlott a szervert a legújabb verzióra frissíteni. Ha már frissítette a szervert, jelentkezzen be újra, hogy az új hitelesítést használhassa.", "MessageOldServerConnectionWarning": "A kiszolgáló kapcsolat beállításai egy régi felhasználói azonosítót használnak. Kérjük, törölje és adja hozzá újra ezt a szerverkapcsolatot.", "MessageOldServerConnectionWarningHelp": "Eredetileg a 2023 júniusában megjelent 2.3.0-s verziójú adatbázis-migráció előtt állította be a kapcsolatot ehhez a kiszolgálóhoz. Egy jövőbeli kiszolgálófrissítéssel megszűnik a lehetőség, hogy ezzel a régi kapcsolattal jelentkezzen be. Kérjük, törölje a meglévő szerverkapcsolatot, és csatlakozzon újra (ugyanazzal a szervercímmel és hitelesítő adatokkal). Ha ezen az eszközön bármilyen letöltött média van, a szerverrel való szinkronizáláshoz a médiát újra le kell tölteni.", "MessagePodcastSearchField": "Adja meg a keresési kifejezést vagy az RSS hírcsatorna URL-címét", From 58281bb5ce6719fb5a4dba29f258a432217a0f74 Mon Sep 17 00:00:00 2001 From: thehijacker Date: Tue, 29 Jul 2025 06:59:33 +0200 Subject: [PATCH 45/80] Translated using Weblate (Slovenian) 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/sl/ --- strings/sl.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/strings/sl.json b/strings/sl.json index 56458ded..4cb08377 100644 --- a/strings/sl.json +++ b/strings/sl.json @@ -325,6 +325,9 @@ "MessageNoSeries": "Ni serij", "MessageNoUpdatesWereNecessary": "Posodobitve niso bile potrebne", "MessageNoUserPlaylists": "Nimate seznamov predvajanja", + "MessageOldServerAuthReLoginRequired": "Zaradi varnosti je bila v strežniku različice 2.26.0 izboljšana avtentikacija. Vsi uporabniki se morajo ponovno prijaviti.", + "MessageOldServerAuthWarning": "Strežnik uporablja zastarelo preverjanje avtentikacije", + "MessageOldServerAuthWarningHelp": "Ta strežnik uporablja različico, starejšo od v2.26.0. V različici v2.26.0 je bil dodan varnejši sistem preverjanja pristnosti. Zelo priporočljivo je, da strežnik posodobite na najnovejšo različico. Če ste strežnik že posodobili, se znova prijavite, da uporabite novo preverjanje pristnosti.", "MessageOldServerConnectionWarning": "Nastavitev povezave s strežnikom uporablja stari ID uporabnika. Izbrišite in znova dodajte to povezavo s strežnikom.", "MessageOldServerConnectionWarningHelp": "Povezavo s tem strežnikom ste prvotno nastavili pred selitvijo baze podatkov v različici 2.3.0, izdani junija 2023. Prihodnja posodobitev strežnika bo odstranila možnost prijave s to staro povezavo. Izbrišite obstoječo povezavo s strežnikom in se znova povežite (z istim naslovom strežnika in poverilnicami). Če imate v tej napravi prenesen medij, ga boste morali znova prenesti za sinhronizacijo s strežnikom.", "MessagePodcastSearchField": "Vnesite iskalni izraz ali URL vira RSS", From 7eeb9095e83ef93f439181b0cb02b59b739583ba Mon Sep 17 00:00:00 2001 From: "Troj@" Date: Tue, 29 Jul 2025 14:15:42 +0200 Subject: [PATCH 46/80] Translated using Weblate (Belarusian) 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/be/ --- strings/be.json | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/strings/be.json b/strings/be.json index d9ddbe4c..de19d7b2 100644 --- a/strings/be.json +++ b/strings/be.json @@ -6,6 +6,7 @@ "ButtonCancel": "Скасаваць", "ButtonCancelTimer": "Скасаваць таймер", "ButtonClearFilter": "Ачысціць фільтр", + "ButtonClearLogs": "Ачысціць журналы", "ButtonCloseFeed": "Закрыць стужку", "ButtonCollections": "Калекцыі", "ButtonConnect": "Падлучыцца", @@ -26,7 +27,9 @@ "ButtonLatest": "Апошняе", "ButtonLibrary": "Бібліятэка", "ButtonLocalMedia": "Лакальныя медыя", + "ButtonLogs": "Журналы", "ButtonManageLocalFiles": "Кіраванне лакальнымі файламі", + "ButtonMaskServerAddress": "Схаваць адрас сервера", "ButtonNewFolder": "Новая тэчка", "ButtonNextEpisode": "Наступны эпізод", "ButtonOk": "Добра", @@ -50,6 +53,7 @@ "ButtonStream": "Трансляцыя", "ButtonSubmit": "Адправіць", "ButtonSwitchServerUser": "Змяніць Сервер/Карыстальніка", + "ButtonUnmaskServerAddress": "Паказаць адрас сервера", "ButtonUserStats": "Статыстыка карыстальніка", "ButtonYes": "Так", "HeaderAccount": "Уліковы запіс", @@ -78,7 +82,7 @@ "HeaderProgressSyncFailed": "Не ўдалося сінхранізаваць прагрэс", "HeaderRSSFeed": "RSS-стужка", "HeaderRSSFeedGeneral": "Падрабязнасці RSS", - "HeaderRSSFeedIsOpen": "RSS-стужка адкрыта", + "HeaderRSSFeedIsOpen": "RSS-стужка адкрытая", "HeaderSelectDownloadLocation": "Абраць месцазнаходжання для спампоўкі", "HeaderSettings": "Налады", "HeaderSleepTimer": "Таймер сну", @@ -142,6 +146,7 @@ "LabelEndOfChapter": "Канец раздзела", "LabelEndTime": "Час заканчэння", "LabelEpisode": "Эпізод", + "LabelExplicit": "Відверты", "LabelFeedURL": "URL стужкі", "LabelFile": "Файл", "LabelFileBirthtime": "Час стварэння файла", @@ -216,6 +221,7 @@ "LabelPublishedDate": "Апублікавана {0}", "LabelRSSFeedCustomOwnerEmail": "Карыстальніцкая электронная пошта ўладальніка", "LabelRSSFeedCustomOwnerName": "Карыстальніцкае імя ўладальніка", + "LabelRSSFeedOpen": "RSS-стужка адкрыта", "LabelRSSFeedPreventIndexing": "Прадухіліць індэксацыю", "LabelRSSFeedSlug": "Ідэнтыфікатар RSS-стужкі", "LabelRandomly": "Выпадкова", @@ -237,6 +243,8 @@ "LabelShowAll": "Паказаць усё", "LabelSize": "Памер", "LabelSleepTimer": "Таймер сну", + "LabelSleepTimerAlmostDoneChime": "Праіграйце звонак, калі амаль скончана", + "LabelSleepTimerAlmostDoneChimeHelp": "Памякчыць звонак, калі на таймеры сну застанецца 30 секунд", "LabelStart": "Пачаць", "LabelStartTime": "Час пачатку", "LabelStatsBestDay": "Лепшы дзень", @@ -252,6 +260,7 @@ "LabelTag": "Метка", "LabelTags": "Меткі", "LabelTheme": "Тэма", + "LabelThemeBlack": "Чорны", "LabelThemeDark": "Цёмная", "LabelThemeLight": "Светлая", "LabelTimeRemaining": "Засталося {0}", @@ -309,12 +318,16 @@ "MessageNoItems": "Няма элементаў", "MessageNoItemsFound": "Элементы не знойдзены", "MessageNoListeningSessions": "Няма сеансаў праслухоўвання", + "MessageNoLogs": "Няма журналаў", "MessageNoMediaFolders": "Няма тэчак з медыя", "MessageNoNetworkConnection": "Няма падлучэння да сеткі", "MessageNoPodcastsFound": "Падкасты не знойдзены", "MessageNoSeries": "Няма серый", "MessageNoUpdatesWereNecessary": "Абнаўленні не патрабаваліся", "MessageNoUserPlaylists": "У вас няма спісаў прайгравання", + "MessageOldServerAuthReLoginRequired": "Аўтэнтыфікацыя была палепшана для бяспекі ў серверы v2.26.0. Усім карыстальнікам патрабуецца паўторна ўвайсці ў сістэму.", + "MessageOldServerAuthWarning": "Сервер выкарыстоўвае састарэлую аўтэнтыфікацыю", + "MessageOldServerAuthWarningHelp": "Гэты сервер працуе пад версіяй, старэйшай за 2.26.0. У версіі 2.26.0 была дададзена больш бяспечная сістэма аўтэнтыфікацыі. Настойліва рэкамендуецца абнавіць сервер да апошняй версіі. Калі вы ўжо абнавілі сервер, увайдзіце зноў, каб выкарыстоўваць новую аўтэнтыфікацыю.", "MessageOldServerConnectionWarning": "Канфігурацыя падлучэння да сервера выкарыстоўвае стары ідэнтыфікатар карыстальніка. Калі ласка, выдаліце і зноў дадайце гэта падлучэнне да сервера.", "MessageOldServerConnectionWarningHelp": "Вы першапачаткова наладзілі падключэнне да гэтага сервера да міграцыі базы даных у версіі 2.3.0, якая выйшла ў чэрвені 2023 года. У будучым абнаўленні сервера магчымасць уваходу праз гэтае старое падключэнне будзе выдалена. Калі ласка, выдаліце існуючае падключэнне да сервера і падключыцеся зноў (выкарыстоўваючы той жа адрас сервера і ўліковыя даныя). Калі на гэтай прыладзе ёсць спампаваныя медыяфайлы, іх трэба будзе спампаваць зноў для сінхранізацыі з серверам.", "MessagePodcastSearchField": "Увядзіце пошукавы запыт або URL RSS-стужкі", From 9da49d6116302fec1e7c6a2c86ff379de7451eec Mon Sep 17 00:00:00 2001 From: Remco Schrijver Date: Wed, 30 Jul 2025 22:35:56 +0200 Subject: [PATCH 47/80] Translated using Weblate (Dutch) 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/nl/ --- strings/nl.json | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/strings/nl.json b/strings/nl.json index 33da968b..0f5d599d 100644 --- a/strings/nl.json +++ b/strings/nl.json @@ -137,7 +137,7 @@ "LabelDownloadUsingCellular": "Download met mobiele data", "LabelDownloaded": "Gedownload", "LabelDuration": "Duur", - "LabelEbook": "Ebook", + "LabelEbook": "E-boek", "LabelEbooks": "Eboeken", "LabelEnable": "Inschakelen", "LabelEnableMp3IndexSeeking": "MP3-index zoeken inschakelen", @@ -146,6 +146,7 @@ "LabelEndOfChapter": "Einde van het Hoofdstuk", "LabelEndTime": "Eindtijd", "LabelEpisode": "Aflevering", + "LabelExplicit": "Expliciet", "LabelFeedURL": "Feed URL", "LabelFile": "Bestand", "LabelFileBirthtime": "Aanmaaktijd bestand", @@ -153,10 +154,10 @@ "LabelFilename": "Bestandsnaam", "LabelFinished": "Voltooid", "LabelFolder": "Map", - "LabelFontBoldness": "Font Boldness", + "LabelFontBoldness": "Lettertype Dikte", "LabelFontScale": "Lettertype schaal", "LabelGenre": "Genre", - "LabelGenres": "Genres", + "LabelGenres": "Categorieën", "LabelHapticFeedback": "Haptische feedback", "LabelHasEbook": "Heeft Ebook", "LabelHasSupplementaryEbook": "Heeft aanvullend Ebook", @@ -220,7 +221,7 @@ "LabelPublishedDate": "{0} gepubliceerd", "LabelRSSFeedCustomOwnerEmail": "Aangepast e-mailadres eigenaar", "LabelRSSFeedCustomOwnerName": "Aangepaste naam eigenaar", - "LabelRSSFeedOpen": "RSS Feed Open", + "LabelRSSFeedOpen": "RSS Feed open", "LabelRSSFeedPreventIndexing": "Voorkom indexering", "LabelRSSFeedSlug": "RSS-feed slug", "LabelRandomly": "Willekeurig", @@ -324,6 +325,9 @@ "MessageNoSeries": "Geen series", "MessageNoUpdatesWereNecessary": "Geen bijwerkingen waren noodzakelijk", "MessageNoUserPlaylists": "Je hebt geen afspeellijsten", + "MessageOldServerAuthReLoginRequired": "Authenticatie is verbeterd om de veiligheid te verhogen in server versie v2.26.0. Alle gebruikers moeten opnieuw inloggen.", + "MessageOldServerAuthWarning": "De server gebruikt een verouderde authenticatie versie", + "MessageOldServerAuthWarningHelp": "Deze server draait een versie ouder dan v2.26.0. Een veiliger authenticatie systeem is geïntroduceerd in v2.26.0. Het wordt met klem aangeraden om de server te updaten naar de laatste versie. Mocht de server al geüpdatet zijn, log dan opnieuw in om het nieuwe authenticatie mechanisme te gebruiken.", "MessageOldServerConnectionWarning": "Server connectie configuratie gebruikt een oud user ID. Gelieve deze server connectie te verwijderen en opnieuw toe te voegen.", "MessageOldServerConnectionWarningHelp": "U hebt de verbinding met deze server oorspronkelijk ingesteld vóór de databasemigratie in versie 2.3.0, uitgebracht in juni 2023. Een toekomstige serverupdate zal de mogelijkheid om in te loggen met deze oude verbinding verwijderen. Verwijder de bestaande serververbinding en maak opnieuw verbinding (met hetzelfde serveradres en dezelfde inloggegevens). Als u gedownloade media op dit apparaat hebt, moet u deze opnieuw downloaden om te synchroniseren met de server.", "MessagePodcastSearchField": "Zoekterm of RSS feed URL invullen", From 0d7f92129b3caca74d25cf756e565fceec22e4b8 Mon Sep 17 00:00:00 2001 From: Grzegorz Orlowski Date: Sat, 2 Aug 2025 00:01:29 +0200 Subject: [PATCH 48/80] Translated using Weblate (Polish) Currently translated at 98.0% (349 of 356 strings) Translation: Audiobookshelf/Abs Mobile App Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/pl/ --- strings/pl.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/strings/pl.json b/strings/pl.json index 05ff64d0..85861b68 100644 --- a/strings/pl.json +++ b/strings/pl.json @@ -218,8 +218,8 @@ "LabelPubDate": "Data publikacji", "LabelPublishYear": "Rok publikacji", "LabelPublishedDate": "Opublikowano {0}", - "LabelRSSFeedCustomOwnerEmail": "Własny email", - "LabelRSSFeedCustomOwnerName": "Własna nazwa", + "LabelRSSFeedCustomOwnerEmail": "Email właściciela", + "LabelRSSFeedCustomOwnerName": "Nazwa właściciela", "LabelRSSFeedOpen": "Otwarty Kanał RSS", "LabelRSSFeedPreventIndexing": "Zapobiegaj indeksowaniu", "LabelRSSFeedSlug": "Numer Kanału RSS", From 66372e974339322288b2fb04496551cc26b8ec85 Mon Sep 17 00:00:00 2001 From: Grzegorz Orlowski Date: Sun, 3 Aug 2025 15:05:56 +0200 Subject: [PATCH 49/80] Translated using Weblate (Polish) 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/pl/ --- strings/pl.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/strings/pl.json b/strings/pl.json index 85861b68..230ed996 100644 --- a/strings/pl.json +++ b/strings/pl.json @@ -316,12 +316,17 @@ "MessageNoItems": "Brak elementów", "MessageNoItemsFound": "Nie znaleziono żadnych elementów", "MessageNoListeningSessions": "Brak sesji słuchania", + "MessageNoLogs": "Brak logów", "MessageNoMediaFolders": "Brak folderów z mediami", "MessageNoNetworkConnection": "Brak połączenia sieciowego", "MessageNoPodcastsFound": "Nie znaleziono podcastów", "MessageNoSeries": "Brak serii", "MessageNoUpdatesWereNecessary": "Brak wymaganych aktualizacji", "MessageNoUserPlaylists": "Nie masz żadnych list odtwarzania", + "MessageOldServerAuthReLoginRequired": "Uwierzytelnianie zostało ulepszone pod kątem bezpieczeństwa w serwerze w wersji 2.26.0. Wszyscy użytkownicy muszą się ponownie zalogować.", + "MessageOldServerAuthWarning": "Serwer korzysta z przestarzałego uwierzytelniania", + "MessageOldServerAuthWarningHelp": "Ten serwer działa w wersji starszej niż 2.26.0. W wersji 2.26.0 dodano bezpieczniejszy system uwierzytelniania. Zdecydowanie zaleca się aktualizację serwera do najnowszej wersji. Jeżeli dokonałeś już aktualizacji serwera, zaloguj się ponownie, aby użyć nowego uwierzytelniania.", + "MessageOldServerConnectionWarning": "Konfiguracja połączenia z serwerem używa starego identyfikatora użytkownika. Usuń i dodaj ponownie to połączenie z serwerem.", "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", "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.", From 73599815ba38d03d07b2c595ca8e1f9f9698ceb3 Mon Sep 17 00:00:00 2001 From: Camille de Lune Date: Tue, 5 Aug 2025 19:18:54 +0200 Subject: [PATCH 50/80] Translated using Weblate (French) Currently translated at 99.7% (355 of 356 strings) Translation: Audiobookshelf/Abs Mobile App Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fr/ --- strings/fr.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/strings/fr.json b/strings/fr.json index 7f91953a..4ee4f463 100644 --- a/strings/fr.json +++ b/strings/fr.json @@ -146,6 +146,7 @@ "LabelEndOfChapter": "Fin du chapitre", "LabelEndTime": "Heure de fin", "LabelEpisode": "Épisode", + "LabelExplicit": "Contenu explicite", "LabelFeedURL": "URL du flux", "LabelFile": "Fichier", "LabelFileBirthtime": "Création du fichier", @@ -220,6 +221,7 @@ "LabelPublishedDate": "Publié le {0}", "LabelRSSFeedCustomOwnerEmail": "Courriel personnalisée du propriétaire", "LabelRSSFeedCustomOwnerName": "Nom propriétaire personnalisé", + "LabelRSSFeedOpen": "Flux RSS ouvert", "LabelRSSFeedPreventIndexing": "Empêcher l’indexation", "LabelRSSFeedSlug": "Identifiant d’URL du flux RSS", "LabelRandomly": "Au hasard", @@ -323,6 +325,9 @@ "MessageNoSeries": "Aucune série", "MessageNoUpdatesWereNecessary": "Aucune mise à jour n’était nécessaire", "MessageNoUserPlaylists": "Vous n’avez aucune liste de lecture", + "MessageOldServerAuthReLoginRequired": "L’authentification a été renforcée pour des raisons de sécurité dans la version 2.26.0 du serveur. Tous les utilisateurs doivent se reconnecter.", + "MessageOldServerAuthWarning": "Le serveur utilise un système d’authentification obsolète", + "MessageOldServerAuthWarningHelp": "Ce serveur utilise une version antérieure à la v2.26.0. Un système d’authentification plus sécurisé a été ajouté dans la version 2.26.0. Il est fortement recommandé de mettre à jour le serveur vers la dernière version. Si vous avez déjà effectué la mise à jour, reconnectez-vous pour utiliser le nouveau système d’authentification.", "MessageOldServerConnectionWarning": "La configuration de connexion du serveur utilise un ancien identifiant utilisateur. Veuillez supprimer et ajouter cette connexion serveur.", "MessageOldServerConnectionWarningHelp": "Vous avez initialement créé la connexion à ce serveur avant la migration de la base de données dans 2.3.0, publié en juin 2023. Une mise à jour future du serveur supprimera la possibilité de signer avec cette ancienne connexion. Veuillez supprimer la connexion existante du serveur et se connecter à nouveau (en utilisant la même adresse de serveur et les mêmes identifiants). Si vous avez des médias téléchargés sur cet appareil, les médias devront être téléchargés à nouveau pour synchroniser avec le serveur.", "MessagePodcastSearchField": "Saisir un terme de recherche ou l'URL d'un flux RSS", From 76d1f2b29b1d977f43b8366e3aee78ffee0a241a Mon Sep 17 00:00:00 2001 From: Ashish Wadekar Date: Tue, 5 Aug 2025 15:09:29 +0200 Subject: [PATCH 51/80] Translated using Weblate (Hindi) Currently translated at 13.2% (47 of 356 strings) Translation: Audiobookshelf/Abs Mobile App Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hi/ --- strings/hi.json | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/strings/hi.json b/strings/hi.json index c55a17a5..983e9af7 100644 --- a/strings/hi.json +++ b/strings/hi.json @@ -6,16 +6,34 @@ "ButtonCancel": "रद्द करें", "ButtonCancelTimer": "टाइमर रद्द करें", "ButtonClearFilter": "लागू फ़िल्टर साफ़ करें", + "ButtonClearLogs": "अभिलेख साफ़ करें", "ButtonCloseFeed": "फ़ीड बंद करें", "ButtonCollections": "संग्रह", "ButtonConnect": "जोड़ना", + "ButtonConnectToServer": "सर्वर से संबद्ध स्थापित करें", "ButtonCreate": "बनाएं", + "ButtonCreateBookmark": "बुकमार्क बनाएं", + "ButtonCreateNewPlaylist": "प्लेलिस्ट बनाएं", "ButtonDelete": "हटाएं", + "ButtonDeleteLocalEpisode": "स्थानीय प्रकरण हटाएँ", + "ButtonDeleteLocalFile": "स्थानीय फ़ाइल हटाएँ", + "ButtonDeleteLocalItem": "स्थानीय वस्तु हटाएँ", + "ButtonDisableAutoTimer": "स्वचालित घड़ी अक्षम करें", + "ButtonDisconnect": "सर्वर संबद्ध तोड़े", + "ButtonGoToWebClient": "वेब क्लाइंट पर जाएँ", + "ButtonHistory": "इतिहास", "ButtonHome": "घर", "ButtonIssues": "समस्याएं", "ButtonLatest": "नवीनतम", "ButtonLibrary": "पुस्तकालय", + "ButtonLogs": "अभिलेख", + "ButtonManageLocalFiles": "स्थानीय फ़ाइल प्रबंधित करें", + "ButtonMaskServerAddress": "सर्वर पता नकाबपोश करें", + "ButtonNewFolder": "नया फ़ोल्डर", + "ButtonNextEpisode": "अगला एपिसोड", + "ButtonOk": "ठीक है", "ButtonOpenFeed": "फ़ीड खोलें", + "ButtonOverride": "अवहेलना करें", "ButtonPlay": "चलाएँ", "ButtonPlaylists": "प्लेलिस्ट्स", "ButtonRead": "पढ़ लिया", From 796189ad4851f1496d4aa481fb4499c9687cce84 Mon Sep 17 00:00:00 2001 From: Kent Henriksen Date: Wed, 6 Aug 2025 20:04:56 +0200 Subject: [PATCH 52/80] =?UTF-8?q?Translated=20using=20Weblate=20(Norwegian?= =?UTF-8?q?=20Bokm=C3=A5l)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/nb_NO/ --- strings/no.json | 99 +++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 80 insertions(+), 19 deletions(-) diff --git a/strings/no.json b/strings/no.json index b42d7b10..24ea930c 100644 --- a/strings/no.json +++ b/strings/no.json @@ -15,7 +15,7 @@ "ButtonCreateBookmark": "Opprett bokmerke", "ButtonCreateNewPlaylist": "Opprett ny spilleliste", "ButtonDelete": "Slett", - "ButtonDeleteLocalEpisode": "Slett episode på enheten", + "ButtonDeleteLocalEpisode": "Slett nedlastet episode", "ButtonDeleteLocalFile": "Slett fil på enheten", "ButtonDeleteLocalItem": "Slett element på enheten", "ButtonDisableAutoTimer": "Skru av automatisk tidsur", @@ -29,7 +29,7 @@ "ButtonLocalMedia": "Lokal media", "ButtonLogs": "Logger", "ButtonManageLocalFiles": "Administrer Lokale Filer", - "ButtonMaskServerAddress": "Skjul serveradresse", + "ButtonMaskServerAddress": "Skjul tjeneradresse", "ButtonNewFolder": "Ny mappe", "ButtonNextEpisode": "Neste episode", "ButtonOk": "Ok", @@ -53,7 +53,7 @@ "ButtonStream": "Strøm", "ButtonSubmit": "Send inn", "ButtonSwitchServerUser": "Bytt tjener/bruker", - "ButtonUnmaskServerAddress": "Vis serveradresse", + "ButtonUnmaskServerAddress": "Vis tjeneradresse", "ButtonUserStats": "Brukerstatistikk", "ButtonYes": "Ja", "HeaderAccount": "Konto", @@ -73,19 +73,20 @@ "HeaderLatestEpisodes": "Siste episoder", "HeaderLibraries": "Biblioteker", "HeaderLocalFolders": "Lokale Mapper", - "HeaderLocalLibraryItems": "Elementer i bibliotek på enheten", + "HeaderLocalLibraryItems": "Nedlastede elementer", "HeaderNewPlaylist": "Ny spilleliste", "HeaderOpenRSSFeed": "Åpne RSS Feed", "HeaderPlaybackSettings": "Avspillingsinnstillinger", "HeaderPlaylist": "Spilleliste", "HeaderPlaylistItems": "Spillelisteelement", + "HeaderProgressSyncFailed": "Fremskritts-synkronisering feilet", "HeaderRSSFeed": "RSS-informasjonskanal", "HeaderRSSFeedGeneral": "RSS Detailer", "HeaderRSSFeedIsOpen": "RSS Feed er åpen", "HeaderSelectDownloadLocation": "Velg Nedlastningssted", "HeaderSettings": "Innstillinger", "HeaderSleepTimer": "Sove timer", - "HeaderSleepTimerSettings": "Innstillinger for Søvntidtaker", + "HeaderSleepTimerSettings": "Innstillinger for søvntidtaker", "HeaderStatsMinutesListeningChart": "Minutter lyttet (siste 7 dagene)", "HeaderStatsRecentSessions": "Siste sesjoner", "HeaderTableOfContents": "Innholdsfortegnelse", @@ -93,9 +94,13 @@ "HeaderYourStats": "Din statistikk", "LabelAddToPlaylist": "Legg til i spilleliste", "LabelAddedAt": "Lagt Til", + "LabelAddedDate": "La til {0}", "LabelAll": "Alle", "LabelAllowSeekingOnMediaControls": "Tillat posisjonsblafring i mediamerknadskontroller", "LabelAlways": "Alltid", + "LabelAndroidAutoBrowseLimitForGrouping": "Alfabetisk nedtreksmeny-grense", + "LabelAndroidAutoBrowseLimitForGroupingHelp": "Ikke bruk alfabetisk nedtrekksmeny når det er færre enn dette antallet elementer", + "LabelAndroidAutoBrowseSeriesSequenceOrder": "Rekkefølge på bokserie", "LabelAskConfirmation": "Spør om bekreftelse", "LabelAuthor": "Forfatter", "LabelAuthorFirstLast": "Forfatter (Fornavn Etternavn)", @@ -105,7 +110,7 @@ "LabelAutoRewindTime": "Automatisk tilbakespolingstid", "LabelAutoSleepTimer": "Automatisk søvntidsur", "LabelAutoSleepTimerAutoRewind": "Automatisk tilbakespoling for automatisk søvntidsur", - "LabelAutoSleepTimerAutoRewindHelp": "Når automatisk søvntidsur fullføres vil avspilling av elementet automatisk spole tilbake posisjonen din.", + "LabelAutoSleepTimerAutoRewindHelp": "Når automatisk søvntidsur fullføres, vil avspilling av elementet på nytt automatisk spole tilbake posisjonen din.", "LabelAutoSleepTimerHelp": "Ved avspilling av media mellom angitt start og slutt vil automatisk søvntidsur starte automatisk.", "LabelBooks": "Bøker", "LabelChapterTrack": "Kapittelspor", @@ -121,10 +126,10 @@ "LabelCustomTime": "Egendefinert tid", "LabelDescription": "Beskrivelse", "LabelDisableAudioFadeOut": "Skru av automatisk uttoning", - "LabelDisableAudioFadeOutHelp": "Lydstyrke vil minkes når det er mindre enn ett minutt igjen på søvntidsuret. Skru av dette for å ikke ha uttoning.", + "LabelDisableAudioFadeOutHelp": "Lydstyrke vil minkes når det er mindre enn ett minutt igjen på søvntidsuret. Aktiver denne innstillingen for å ikke ha uttoning.", "LabelDisableAutoRewind": "Skru av automatisk tilbakespoling", "LabelDisableShakeToReset": "Skru av risting for å tilbakestille", - "LabelDisableShakeToResetHelp": "Risting av enheten mens tidsuret kjører, eller opptil to minutter etter utløp av tidsuret tilbakestiller søvntidsuret. Skru på denne innstillinger for å skru av risting for å tilbakestille.", + "LabelDisableShakeToResetHelp": "Rister du enheten mens tidsuret kjører, eller opptil to minutter etter utløp av tidsuret, tilbakestillers søvntidsuret. Aktiver denne innstillinger for å skru av risting for å tilbakestille.", "LabelDisableVibrateOnReset": "Skru av vibrering ved tilbakestilling", "LabelDisableVibrateOnResetHelp": "Når søvntidsuret blir tilbakestilt vil enheten din vibrere. Skru på denne innstillingen for å skru av vibrering når tidsuret tilbakestilles.", "LabelDiscover": "Oppdagelse", @@ -140,6 +145,8 @@ "LabelEnd": "Slutt", "LabelEndOfChapter": "Slutt på kapittel", "LabelEndTime": "Slutt-tid", + "LabelEpisode": "Episode", + "LabelExplicit": "Eksplisitt", "LabelFeedURL": "Feed Adresse", "LabelFile": "Fil", "LabelFileBirthtime": "Fil Opprettelsesdato", @@ -151,63 +158,94 @@ "LabelFontScale": "Font størrelse", "LabelGenre": "Sjanger", "LabelGenres": "Sjangers", - "LabelHapticFeedback": "Føllighetsrespons", + "LabelHapticFeedback": "Følighetsrespons", "LabelHasEbook": "Har e-bok", - "LabelHasSupplementaryEbook": "Har komplimentær e-bok", + "LabelHasSupplementaryEbook": "Har komplementær e-bok", "LabelHeavy": "Tung", + "LabelHigh": "Høy", "LabelHost": "Tjener", "LabelInProgress": "I gang", "LabelIncomplete": "Ufullstendig", - "LabelInternalAppStorage": "Programlager på enheten", + "LabelInternalAppStorage": "Intern lagringsplass", "LabelJumpBackwardsTime": "Tilbakespolingstid", "LabelJumpForwardsTime": "Foroverspolingstid", + "LabelKeepScreenAwake": "Hold skjerm våken", "LabelLanguage": "Språk", + "LabelLayout": "Utseende", + "LabelLayoutAuto": "Automatisk", "LabelLayoutSinglePage": "Enkeltside", "LabelLight": "Lett", "LabelLineSpacing": "Linjemellomrom", "LabelListenAgain": "Lytt igjen", - "LabelLocalBooks": "Bøker på enheten", + "LabelLocalBooks": "Nedlastede bøker", + "LabelLocalPodcasts": "Nedlastede Podcaster", + "LabelLockOrientation": "Lås rotasjon", "LabelLockPlayer": "Lås avspiller", + "LabelLow": "Lav", "LabelMediaType": "Medie type", + "LabelMedium": "Middels", + "LabelMissing": "Mangler", "LabelMore": "Mer", "LabelMoreInfo": "Mer info", "LabelName": "Navn", "LabelNarrator": "Forteller", "LabelNarrators": "Fortellere", + "LabelNavigateWithVolume": "Naviger med volum-knapper", + "LabelNavigateWithVolumeMirrored": "Speilet", + "LabelNavigateWithVolumeWhilePlaying": "Navigere med volumknapper under avspilling", + "LabelNavigateWithVolumeWhilePlayingDisabled": "Av", + "LabelNavigateWithVolumeWhilePlayingEnabled": "På", "LabelNever": "Aldri", "LabelNewestAuthors": "Nyeste forfattere", "LabelNewestEpisodes": "Nyeste episoder", + "LabelNo": "Nei", "LabelNotFinished": "Ikke fullført", "LabelNotStarted": "Ikke startet", + "LabelNumEpisodes": "{0} episoder", + "LabelNumEpisodesIncomplete": "{0} episoder, {1} ufullstendige", + "LabelNumberOfEpisodes": "# episoder", "LabelOff": "Av", + "LabelOn": "På", "LabelPassword": "Passord", "LabelPath": "Sti", "LabelPlaybackDirect": "Direkte", + "LabelPlaybackLocal": "Lokal", "LabelPlaybackSpeed": "Avspillingshastighet", - "LabelPlaybackTranscode": "Transkoding", + "LabelPlaybackTranscode": "Konvertering", + "LabelPodcast": "Podcast", "LabelPodcasts": "Podcaster", "LabelPreventIndexing": "Forhindre at din feed fra å bli indeksert av iTunes og Google podcast kataloger", "LabelProgress": "Framgang", "LabelPubDate": "Publiseringsdato", "LabelPublishYear": "Publikasjonsår", + "LabelPublishedDate": "Publisert {0}", "LabelRSSFeedCustomOwnerEmail": "Tilpasset eier e-post", "LabelRSSFeedCustomOwnerName": "Tilpasset eier Navn", + "LabelRSSFeedOpen": "RSS-strøm åpen", "LabelRSSFeedPreventIndexing": "Forhindre indeksering", "LabelRSSFeedSlug": "RSS-informasjonskanalunderadresse", + "LabelRandomly": "Tilfeldighet", "LabelRead": "Les", "LabelReadAgain": "Les igjen", + "LabelRecentSeries": "Nylige serier", "LabelRecentlyAdded": "Nylig tillagt", "LabelRemoveFromPlaylist": "Fjern fra spilleliste", "LabelScaleElapsedTimeBySpeed": "Skaler forløpt tid etter hastighet", "LabelSeason": "Sesong", "LabelSelectADevice": "Velg en enhet", + "LabelSequenceAscending": "Stigende rekkefølge", + "LabelSequenceDescending": "Synkende rekkefølge", "LabelSeries": "Serier", + "LabelServerAddress": "Tjener-adresse", "LabelSetEbookAsPrimary": "Sett som primær", "LabelSetEbookAsSupplementary": "Sett som supplerende", "LabelShakeSensitivity": "Ristingsterskel", "LabelShowAll": "Vis alt", "LabelSize": "Størrelse", "LabelSleepTimer": "Sove-timer", + "LabelSleepTimerAlmostDoneChime": "Spill en lyd når nesten ferdig", + "LabelSleepTimerAlmostDoneChimeHelp": "Spill av en lyd når søvnklokken har 30 sekunder igjen", + "LabelStart": "Start", "LabelStartTime": "Start Tid", "LabelStatsBestDay": "Beste dag", "LabelStatsDailyAverage": "Daglig gjennomsnitt", @@ -219,8 +257,10 @@ "LabelStatsMinutesListening": "Minutter lyttet", "LabelStatsWeekListening": "Uker lyttet", "LabelStreamingUsingCellular": "Strømmer via mobildata", + "LabelTag": "Merke", "LabelTags": "Tagger", "LabelTheme": "Tema", + "LabelThemeBlack": "Svart", "LabelThemeDark": "Mørk", "LabelThemeLight": "Lys", "LabelTimeRemaining": "{0} gjennstående", @@ -228,12 +268,16 @@ "LabelTotalSize": "Total størrelse", "LabelTotalTrack": "Totalt antall spor", "LabelTracks": "Spor", + "LabelType": "Type", + "LabelUnknown": "Ukjent", "LabelUnlockPlayer": "Lås opp avspiller", "LabelUseBookshelfView": "Bruk bokhyllevisning", "LabelUser": "Bruker", "LabelUsername": "Brukernavn", "LabelVeryHigh": "Veldig høy", "LabelVeryLow": "Veldig lav", + "LabelYearReviewHide": "Skjul oppsummering av året", + "LabelYearReviewShow": "Vis oppsummering av året", "LabelYourBookmarks": "Dine bokmerker", "LabelYourProgress": "Din fremgang", "MessageAndroid10Downloads": "Android 10 og tidligere bruker lagringsplass på enheten for nedlastninger.", @@ -243,13 +287,14 @@ "MessageBookshelfEmpty": "Tom bokhylle", "MessageConfirmDeleteLocalEpisode": "Fjen episoden «{0}» fra enheten? Filen på tjeneren forblir som den er.", "MessageConfirmDeleteLocalFiles": "Fjern dette elementets filer fra enheten? Filene på tjeneren og framdriften forblir som den er.", - "MessageConfirmDiscardProgress": "Tilbakestill framgang?", + "MessageConfirmDisableAutoTimer": "Er du sikker på at du vil slå av den automatiske tidsuret for resten av dagen? Tidsuret vil bli reaktivert på slutten av perioden til dette automatiske søvn-tidsuret, eller hvis du starter appen på nytt.", + "MessageConfirmDiscardProgress": "Er du sikker på at du vil tilbakestille fremdriften din?", "MessageConfirmDownloadUsingCellular": "Last ned over mobildata selv om kostnader kan tilkomme?", - "MessageConfirmMarkAsFinished": "Merk element som fullført?", - "MessageConfirmRemoveBookmark": "Fjern bokmerke?", + "MessageConfirmMarkAsFinished": "Er du sikker på at du vil merke element som fullført?", + "MessageConfirmRemoveBookmark": "Er du sikker på at du vil fjerne bokmerket?", "MessageConfirmStreamingUsingCellular": "Strøm via mobildata selv om kostnader kan tilkomme?", "MessageDiscardProgress": "Forkast framdrift", - "MessageDownloadCompleteProcessing": "Behandler …", + "MessageDownloadCompleteProcessing": "Nedlasting fullført. Behandler …", "MessageDownloading": "Laster ned …", "MessageDownloadingEpisode": "Laster ned episode", "MessageEpisodesQueuedForDownload": "{0} Episode(r) lagt til i kø for nedlasting", @@ -257,28 +302,44 @@ "MessageFetching": "Henter...", "MessageFollowTheProjectOnGithub": "Følg prosjektet på GitHub", "MessageItemDownloadCompleteFailedToCreate": "Element nedlastet, men kunne ikke opprette bibliotekselement", + "MessageItemMissing": "Elementet mangler og må bli korrigert på tjeneren. Vanligvis blir et element markert som manglende hvis filens lokasjon ikke er tilgjengelig.", "MessageLoading": "Laster...", "MessageLoadingServerData": "Laster inn tjenerdata …", + "MessageLocalFolderDescription": "'Intern lagringsminne for app' er bare tilgjengelig for denne appen. Denne appen støtter bare medier lastet ned direkte gjennom appen. Mapper for delt lagringsminne kan bli brukt for å gi andre apper tilgang til media lastet ned av denne appen.", "MessageMarkAsFinished": "Marker som Fullført", - "MessageMediaLinkedToADifferentServer": "Media er lenket til en Audiobookshelf-tjener på en annen adresse ({0}). Framdriften vil bli synkronisert når tilkoblet denne tjeneradressen.", + "MessageMediaLinkedToADifferentServer": "Media er lenket til en Audiobookshelf-tjener på en annen adresse ({0}). Framdriften vil bli synkronisert når du blir tilkoblet denne tjeneradressen.", "MessageMediaLinkedToADifferentUser": "Media er lenket til denne tjeneren, men ble lastet ned av en annen bruker. Framdriften vil kun synkroniseres til brukeren som lastet den ned.", "MessageMediaLinkedToServer": "Lenket til tjener {0}", "MessageMediaLinkedToThisServer": "Nedlastet media er lenket til denne tjeneren", "MessageMediaNotLinkedToServer": "Media er ikke lenket til en Audiobookshelf-tjener. Ingen fremdrift vil bli synkronisert.", "MessageNoBookmarks": "Ingen bokmerker", "MessageNoChapters": "Ingen kapitler", + "MessageNoCollections": "Ingen samlinger", "MessageNoItems": "Ingen gjenstander", "MessageNoItemsFound": "Ingen gjenstander funnet", "MessageNoListeningSessions": "Ingen Lyttesesjoner", + "MessageNoLogs": "Ingen logger", "MessageNoMediaFolders": "Ingen mediamapper", "MessageNoNetworkConnection": "Ingen nettverkstilkobling", "MessageNoPodcastsFound": "Ingen podcaster funnet", + "MessageNoSeries": "Ingen serier", "MessageNoUpdatesWereNecessary": "Ingen oppdatering var nødvendig", "MessageNoUserPlaylists": "Du har ingen spillelister", + "MessageOldServerAuthReLoginRequired": "Autentisering har blitt forbedret for sikkerhet i tjener-versjon v2.26.0. Alle brukere må logge inn igjen.", + "MessageOldServerAuthWarning": "Tjeneren bruker utdatert autentisering", + "MessageOldServerAuthWarningHelp": "Denne tjeneren kjører en versjon som er eldre enn v2.26.0. Et mer sikkert autentiseringssystem ble lagt til i v.2.26.0. Det anbefales sterkt å oppdatere tjeneren til siste versjon. Hvis du allerede har oppdatert tjeneren, logg inn igjen for å bruke den nye autentiseringen.", + "MessageOldServerConnectionWarning": "Konfigurasjonen til tjener-tilkoblingen bruker en gammel bruker ID. Vennligst slett og legg til igjen denne tjener-tilkoblingen.", + "MessageOldServerConnectionWarningHelp": "Du opprettet opprinnelig tilkoblingen til denne tjeneren før database-migreringen i versjon 2.3.0, som ble lansert i juni 2023. En fremtidig oppdatering av tjeneren vil fjerne muligheten for å logge inn med denne gamle tilkoblingen. Vennligst slett den eksisterende tjenertilkoblingen og koble til på nytt (bruk samme tjeneradresse og påloggingsinformasjon). Hvis du har nedlastede medier på denne enheten, må disse lastes ned på nytt for å synkronisere med tjeneren.", + "MessagePodcastSearchField": "Skriv inn søkeord eller URL til en RSS-strøm", + "MessageProgressSyncFailed": "Det siste forsøket på å rapportere lyttefremdriften til tjeneren mislyktes. Forsøk på å synkronisere fremdriften vil fortsette hvert 15. sekund til hvert minutt så lenge mediet spilles av.", "MessageReportBugsAndContribute": "Rapporter feil, forespør funksjoner og tillegg og bidra på", + "MessageSeriesAlreadyDownloaded": "Du har allerede lastet ned alle bøkene i denne serien.", + "MessageSeriesDownloadConfirm": "Laste ned manglende {0} bok/bøker med {1} fil(er), totalt {2} til mappe {3}?", + "MessageSeriesDownloadConfirmIos": "Laste ned manglende {0} bok/bøker med {1} fil(er), totalt {2}?", "MessageSocketConnectedOverMeteredCellular": "Socket tilkoblet over kvotebasert mobildata", "MessageSocketConnectedOverMeteredWifi": "Socket tilkoblet over kvotebasert Wi-Fi", - "MessageSocketConnectedOverUnmeteredWifi": "Socket tilkoblet over ubegrenset Wi-Fi", + "MessageSocketConnectedOverUnmeteredCellular": "Tilkobling via ubegrenset mobildata", + "MessageSocketConnectedOverUnmeteredWifi": "Tilkoblet over ubegrenset Wi-Fi", "MessageSocketNotConnected": "Socket ikke tilkoblet", "NoteRSSFeedPodcastAppsHttps": "Advarsel! De fleste podcast applikasjoner trenger RSS feed URL som bruker HTTPS", "NoteRSSFeedPodcastAppsPubDate": "Advarsel! 1 eller flere av episodene har ikke publikasjonsdato. Noen podcast applikasjoner trenger dette.", From ecdbabfa171192625b99c24937bc931e48b557db Mon Sep 17 00:00:00 2001 From: "weblate.user.1274" Date: Wed, 6 Aug 2025 20:06:19 +0200 Subject: [PATCH 53/80] =?UTF-8?q?Translated=20using=20Weblate=20(Norwegian?= =?UTF-8?q?=20Bokm=C3=A5l)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/nb_NO/ --- strings/no.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/strings/no.json b/strings/no.json index 24ea930c..e9b07c6b 100644 --- a/strings/no.json +++ b/strings/no.json @@ -110,7 +110,7 @@ "LabelAutoRewindTime": "Automatisk tilbakespolingstid", "LabelAutoSleepTimer": "Automatisk søvntidsur", "LabelAutoSleepTimerAutoRewind": "Automatisk tilbakespoling for automatisk søvntidsur", - "LabelAutoSleepTimerAutoRewindHelp": "Når automatisk søvntidsur fullføres, vil avspilling av elementet på nytt automatisk spole tilbake posisjonen din.", + "LabelAutoSleepTimerAutoRewindHelp": "Når automatisk søvntidsur fullføres og du gjenopptar avspillingen vil posisjonen din automatisk spoles tilbake.", "LabelAutoSleepTimerHelp": "Ved avspilling av media mellom angitt start og slutt vil automatisk søvntidsur starte automatisk.", "LabelBooks": "Bøker", "LabelChapterTrack": "Kapittelspor", @@ -158,9 +158,9 @@ "LabelFontScale": "Font størrelse", "LabelGenre": "Sjanger", "LabelGenres": "Sjangers", - "LabelHapticFeedback": "Følighetsrespons", + "LabelHapticFeedback": "Taktil respons", "LabelHasEbook": "Har e-bok", - "LabelHasSupplementaryEbook": "Har komplementær e-bok", + "LabelHasSupplementaryEbook": "Har supplerende e-bok", "LabelHeavy": "Tung", "LabelHigh": "Høy", "LabelHost": "Tjener", From e367fa8a86b9d409d98bed2940aed294045b91b0 Mon Sep 17 00:00:00 2001 From: Aleksandr Zakirov Date: Wed, 6 Aug 2025 01:35:33 +0200 Subject: [PATCH 54/80] Translated using Weblate (Estonian) Currently translated at 49.1% (175 of 356 strings) Translation: Audiobookshelf/Abs Mobile App Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/et/ --- strings/et.json | 178 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 177 insertions(+), 1 deletion(-) diff --git a/strings/et.json b/strings/et.json index 0967ef42..8decd84b 100644 --- a/strings/et.json +++ b/strings/et.json @@ -1 +1,177 @@ -{} +{ + "ButtonAdd": "Lisa", + "ButtonAddNewServer": "Lisa uus server", + "ButtonAuthors": "Autorid", + "ButtonBack": "Tagasi", + "ButtonCancel": "Katkesta", + "ButtonCancelTimer": "Katkesta taimer", + "ButtonClearFilter": "Tühista filter", + "ButtonCollections": "Kollektsioonid", + "ButtonConnect": "Ühenda", + "ButtonConnectToServer": "Ühenda serveriga", + "ButtonCreate": "Loo uus", + "ButtonCreateBookmark": "Loo uus järjehoidja", + "ButtonCreateNewPlaylist": "Loo uus playlist", + "ButtonDelete": "Kustuta", + "ButtonDeleteLocalEpisode": "Kustuta lokaalne episood", + "ButtonDeleteLocalFile": "Kustuta lokaalne fail", + "ButtonDisableAutoTimer": "Lülita välja auto-taimer", + "ButtonDisconnect": "Ühenda lahti", + "ButtonHistory": "Ajalugu", + "ButtonIssues": "Probleemid", + "ButtonLatest": "Viimased", + "ButtonLibrary": "Raamatukogu", + "ButtonLocalMedia": "Lokaalne meedia", + "ButtonNewFolder": "Uus kaust", + "ButtonNextEpisode": "Järgmine episood", + "ButtonOk": "Ok", + "ButtonPause": "Paus", + "ButtonPlay": "Play", + "ButtonPlaylists": "Playlist", + "ButtonRead": "Loe", + "ButtonReadLess": "Loe vähem", + "ButtonReadMore": "Loe rohkem", + "ButtonRemove": "Kustuta", + "ButtonRemoveFromServer": "Kustuta serverist", + "ButtonSave": "Salvesta", + "ButtonSaveOrder": "Salvesta järjekord", + "ButtonSearch": "Otsi", + "ButtonSendEbookToDevice": "Saada e-raamat seadmele", + "ButtonSetTimer": "Säti taimer", + "ButtonSubmit": "Esita", + "ButtonUserStats": "Kasutaja statistika", + "ButtonYes": "Jah", + "HeaderAccount": "Konto", + "HeaderAndroidAutoSettings": "Android Auto sätted", + "HeaderChapters": "Peatükid", + "HeaderConnectionStatus": "Ühenduse staatus", + "HeaderDataSettings": "Andmete sätted", + "HeaderDetails": "Detailid", + "HeaderDownloads": "Allatõmmatud", + "HeaderEbookFiles": "E-raamatu failid", + "HeaderEpisodes": "Episoodid", + "HeaderEreaderSettings": "E-lugeja sätted", + "HeaderLatestEpisodes": "Viimased episoodid", + "HeaderLibraries": "Raamatukogud", + "HeaderLocalFolders": "Lokaalsed failid", + "HeaderLocalLibraryItems": "Lokaalse raamatukogu esemed", + "HeaderNewPlaylist": "Uus playlist", + "HeaderPlaylist": "Playlist", + "HeaderPlaylistItems": "Playlisti esemed", + "HeaderProgressSyncFailed": "Progressi sühnkronisatsioon ebaõnnestus", + "HeaderSelectDownloadLocation": "Vali allalaadimise kaust", + "HeaderSettings": "Seaded", + "HeaderSleepTimer": "Unetaimer", + "HeaderSleepTimerSettings": "Unetaimeri seaded", + "HeaderStatsRecentSessions": "Hiljutised sessioonid", + "HeaderTableOfContents": "Sisukord", + "HeaderUserInterfaceSettings": "Kasutajaliidese seaded", + "HeaderYourStats": "Sinu statistika", + "LabelAddToPlaylist": "Lisa playlisti", + "LabelAddedAt": "Lisatud", + "LabelAddedDate": "Lisatud {0}", + "LabelAll": "Kõik", + "LabelAllowSeekingOnMediaControls": "Luba positsiooniotsing meediateavituste juhtelementidel", + "LabelAlways": "Alati", + "LabelAndroidAutoBrowseLimitForGrouping": "Tähestikuline väljavõtmise limiit", + "LabelAndroidAutoBrowseLimitForGroupingHelp": "Ära kasuta tähestikulises järjekorras järjestamist juhul kui kuvatavaid esemeid on vähem kui see arv", + "LabelAskConfirmation": "Küsi kinnitust", + "LabelAuthor": "Autor", + "LabelAuthorFirstLast": "Autor (eesnimi perekonnanimi)", + "LabelAuthorLastFirst": "Autor (perekonnanimi, eesnimi)", + "LabelAuthors": "Autorid", + "LabelAutoDownloadEpisodes": "Episoodide automaatne allalaadimine", + "LabelAutoRewindTime": "Keri aeg automaatselt tagasi", + "LabelAutoSleepTimer": "Automaatne unetaimer", + "LabelBooks": "Raamatud", + "LabelChapterTrack": "Peatüki heliriba", + "LabelChapters": "Peatükid", + "LabelComplete": "Valmis", + "LabelContinueBooks": "Jätka raamatuid", + "LabelContinueEpisodes": "Jätka episoode", + "LabelContinueListening": "Jätka kuulamist", + "LabelContinueReading": "Jätka lugemist", + "LabelDescription": "Kirjeldus", + "LabelDownload": "Lae alla", + "LabelDownloadUsingCellular": "Lae alla kasutades andmesidet", + "LabelDownloaded": "Allalaaditud", + "LabelDuration": "Kestvus", + "LabelEbook": "E-raamat", + "LabelEbooks": "E-raamatud", + "LabelEnable": "Luba", + "LabelEnd": "Lõpp", + "LabelEndOfChapter": "Peatükki lõpp", + "LabelEpisode": "Episood", + "LabelFile": "Fail", + "LabelFilename": "Faili nimi", + "LabelFinished": "Lõpetatud", + "LabelFolder": "Kaust", + "LabelFontBoldness": "Fondi paksus", + "LabelFontScale": "Fondi suurus", + "LabelGenre": "Žanr", + "LabelGenres": "Žanrid", + "LabelHapticFeedback": "Haptiline tagasiside", + "LabelHasEbook": "E-raamat olemas", + "LabelIncomplete": "Lõpetamata", + "LabelKeepScreenAwake": "Hoia ekraan ärkvel", + "LabelLanguage": "Keel", + "LabelLayoutAuto": "Auto", + "LabelLight": "Hele", + "LabelListenAgain": "Kuula uuesti", + "LabelLocalBooks": "Lokaalsed raamatud", + "LabelLocalPodcasts": "Lokaalsed podcastid", + "LabelLow": "Madal", + "LabelMediaType": "Meedia tüüp", + "LabelMedium": "Keskmine", + "LabelMissing": "Puudub", + "LabelMore": "Rohkem", + "LabelMoreInfo": "Rohkem infot", + "LabelName": "Nimi", + "LabelNavigateWithVolumeMirrored": "Peegeldatud", + "LabelNavigateWithVolumeWhilePlayingDisabled": "Väljalülitatud", + "LabelNavigateWithVolumeWhilePlayingEnabled": "Sisselülitatud", + "LabelNever": "Mitte kunagi", + "LabelNewestAuthors": "Uuemad autorid", + "LabelNewestEpisodes": "Uuemad episoodid", + "LabelNo": "Ei", + "LabelNotFinished": "Lõpetamata", + "LabelNotStarted": "Pole alustatud", + "LabelNumEpisodes": "{0} episoode", + "LabelNumEpisodesIncomplete": "{0} episoodi, {1} lõpetamata", + "LabelNumberOfEpisodes": "# episoode", + "LabelOff": "Väljalülitatud", + "LabelOn": "Sisselülitatud", + "LabelPassword": "Parool", + "LabelPlaybackDirect": "Otse", + "LabelPlaybackLocal": "Lokaalne", + "LabelPlaybackTranscode": "Transkodeeri", + "LabelPodcast": "Podcast", + "LabelPodcasts": "Podcastid", + "LabelProgress": "Progress", + "LabelPubDate": "Publitseerimise kuupäev", + "LabelPublishYear": "Publitseerimise aasta", + "LabelPublishedDate": "Publitseeritud {0}", + "LabelRandomly": "Juhuslikus järjekorras", + "LabelRead": "Loetud läbi", + "LabelReadAgain": "Loe uuesti", + "LabelRecentlyAdded": "Hiljuti lisatud", + "LabelRemoveFromPlaylist": "Eemalda playlistist", + "LabelSeason": "Hooaeg", + "LabelSelectADevice": "Vali seade", + "LabelServerAddress": "Serveri aadres", + "LabelShowAll": "Näita kõik", + "LabelSize": "Suurus", + "LabelSleepTimer": "Unetaimer", + "LabelStart": "Alusta", + "LabelStatsBestDay": "Parim päev", + "LabelStatsDays": "Päevi", + "LabelStatsInARow": "järjest", + "LabelStatsMinutes": "minuteid", + "LabelThemeBlack": "Must", + "LabelThemeDark": "Pime", + "LabelThemeLight": "Hele", + "LabelTitle": "Pealkiri", + "LabelTotalSize": "Kogusuurus", + "LabelType": "Tüüp", + "LabelUnknown": "Teadmata" +} From bd8668f0bfc01b22ebed3638494bb03936582b33 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 8 Aug 2025 17:39:27 -0500 Subject: [PATCH 55/80] Fix rss feed modal not showing full URL once open #1652 --- components/modals/rssfeeds/RssFeedModal.vue | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/components/modals/rssfeeds/RssFeedModal.vue b/components/modals/rssfeeds/RssFeedModal.vue index 97b11a0f..9172076c 100644 --- a/components/modals/rssfeeds/RssFeedModal.vue +++ b/components/modals/rssfeeds/RssFeedModal.vue @@ -12,9 +12,9 @@

{{ $strings.HeaderRSSFeedIsOpen }}

- + - {{ linkCopied ? 'check' : 'content_copy' }} + {{ linkCopied ? 'check' : 'content_copy' }}
@@ -119,6 +119,10 @@ export default { demoFeedUrl() { return `${this.serverAddress}/feed/${this.newFeedSlug}` }, + fullFeedUrl() { + if (!this.currentFeed) return '' + return `${this.serverAddress}${this.currentFeed.feedUrl}` + }, isHttp() { return !!this.serverAddress?.startsWith('http://') } From 7aebcd92c3d4c894745d6f3e7505b5664127c17a Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 15 Aug 2025 17:41:23 -0500 Subject: [PATCH 56/80] Fix refresh token not persisted on new server connections #1634 --- ios/App/App/plugins/AbsDatabase.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ios/App/App/plugins/AbsDatabase.swift b/ios/App/App/plugins/AbsDatabase.swift index aa9dfc43..c942136b 100644 --- a/ios/App/App/plugins/AbsDatabase.swift +++ b/ios/App/App/plugins/AbsDatabase.swift @@ -73,16 +73,16 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin { let name = "\(address) (\(username))" + if id == nil { + id = "\(address)@\(username)".toBase64() + } + 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 { - id = "\(address)@\(username)".toBase64() - } - let config = ServerConnectionConfig() config.id = id ?? "" config.index = 0 From 4be1598ecaff6b6f154ced9e11623d955f398fea Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 16 Aug 2025 17:55:00 -0500 Subject: [PATCH 57/80] Fix oidc button not showing on re-login, fix oidc re-login showing config already exists #1638 #1634 --- .../audiobookshelf/app/server/ApiHandler.kt | 3 ++ components/connection/ServerConnectForm.vue | 50 ++++++++++++------- components/readers/PdfReader.vue | 8 +-- pages/connect.vue | 2 +- plugins/axios.js | 2 +- plugins/db.js | 2 +- plugins/nativeHttp.js | 2 +- strings/en-us.json | 1 + 8 files changed, 46 insertions(+), 24 deletions(-) diff --git a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt index c4caccb4..d25e7446 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt @@ -407,6 +407,9 @@ class ApiHandler(var ctx:Context) { if (checkAbsDatabaseNotifyListenersInitted()) { val tokenJsObject = JSObject() tokenJsObject.put("error", "Token refresh failed") + if (serverConnectionConfigId.isNotEmpty()) { + tokenJsObject.put("serverConnectionConfigId", serverConnectionConfigId) + } absDatabaseNotifyListeners("onTokenRefreshFailure", tokenJsObject) } else { // Can happen if Webview is never run diff --git a/components/connection/ServerConnectForm.vue b/components/connection/ServerConnectForm.vue index 404f993d..ebecabdf 100644 --- a/components/connection/ServerConnectForm.vue +++ b/components/connection/ServerConnectForm.vue @@ -30,7 +30,7 @@
-
+
arrow_back
@@ -412,7 +412,7 @@ export default { throw new Error('Authentication failed with the provided token.') } - const duplicateConfig = this.serverConnectionConfigs.find((scc) => scc.address === this.serverConfig.address && scc.username === payload.user.username) + const duplicateConfig = this.serverConnectionConfigs.find((scc) => scc.address === this.serverConfig.address && scc.username === payload.user.username && scc.id !== this.serverConfig.id) if (duplicateConfig) { throw new Error('Config already exists for this address and username.') } @@ -471,7 +471,11 @@ export default { // Will NOT include access token and refresh token this.setUserAndConnection(payload) } else { - this.showAuth = true + let error = this.error + if (await this.submit(true)) { + this.showForm = true + this.error = error + } } }, async removeServerConfigClick() { @@ -500,12 +504,14 @@ export default { this.showForm = !this.serverConnectionConfigs.length } }, - editServerConfig(serverConfig) { + async editServerConfig(serverConfig) { this.serverConfig = { ...serverConfig } - this.showForm = true - this.showAuth = true + + if (await this.submit(true)) { + this.showForm = true + } }, async newServerConfigClick() { await this.$hapticsImpact() @@ -651,9 +657,8 @@ export default { return false }) }, - async submit() { - if (!this.networkConnected) return - if (!this.serverConfig.address) return + async submit(preventAutoLogin = false) { + if (!this.networkConnected || !this.serverConfig.address) return false const initialAddress = this.serverConfig.address // Did the user specify a protocol? @@ -666,6 +671,7 @@ export default { this.authMethods = [] try { + console.log('[ServerConnectForm] submit tryServerUrl: ' + this.serverConfig.address) // Try the server URL. If it fails and the protocol was not provided, try with http instead of https const statusData = await this.tryServerUrl(this.serverConfig.address, !protocolProvided) if (this.validateLoginFormResponse(statusData, this.serverConfig.address, protocolProvided)) { @@ -674,11 +680,12 @@ export default { this.oauth.buttonText = statusData.data.authFormData?.authOpenIDButtonText || 'Login with OpenID' this.serverConfig.version = statusData.data.serverVersion - if (statusData.data.authFormData?.authOpenIDAutoLaunch) { + if (statusData.data.authFormData?.authOpenIDAutoLaunch && !preventAutoLogin) { this.clickLoginWithOpenId() } return true } else { + console.log('[ServerConnectForm] submit validateLoginFormResponse failed: ' + this.serverConfig.address) return false } } catch (error) { @@ -852,7 +859,7 @@ export default { } 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() + this.setForceRelogin('oldAuthToken') return } @@ -926,20 +933,29 @@ export default { this.processing = false return authRes }, - async setForceReloginForNewAuth() { + async setForceRelogin(error) { + console.log('[ServerConnectForm] setForceRelogin', error, this.serverConfig.address) // This calls /status on the server and sets the auth methods - const result = await this.submit() + const result = await this.submit(true) if (result) { - this.error = this.$strings.MessageOldServerAuthReLoginRequired + this.showForm = true + + if (error === 'oldAuthToken') { + this.error = this.$strings.MessageOldServerAuthReLoginRequired + } else if (error === 'refreshTokenFailed') { + this.error = this.$strings.MessageFailedToRefreshToken + } } }, 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) { + if (this.$route.query.serverConnectionConfigId) { + // Handle force re-login for servers using new JWT auth but still using an old token OR refresh token failed this.serverConfig = this.serverConnectionConfigs.find((scc) => scc.id === this.$route.query.serverConnectionConfigId) if (this.serverConfig) { - this.setForceReloginForNewAuth() + this.setForceRelogin(this.$route.query.error) return + } else { + console.error('[ServerConnectForm] init with serverConnectionConfigId but no serverConfig found', this.$route.query.serverConnectionConfigId) } } diff --git a/components/readers/PdfReader.vue b/components/readers/PdfReader.vue index 1446e396..5cefaa7d 100644 --- a/components/readers/PdfReader.vue +++ b/components/readers/PdfReader.vue @@ -173,16 +173,18 @@ export default { try { console.log('[PdfReader] Handling refresh failure - logging out user') + const serverConnectionConfigId = this.$store.getters['user/getServerConnectionConfigId'] + // Clear store await this.$store.dispatch('user/logout') - if (this.$store.getters['user/getServerConnectionConfigId']) { + if (serverConnectionConfigId) { // Clear refresh token for server connection config - await this.$db.clearRefreshToken(this.$store.getters['user/getServerConnectionConfigId']) + await this.$db.clearRefreshToken(serverConnectionConfigId) } if (window.location.pathname !== '/connect') { - window.location.href = '/connect' + window.location.href = '/connect?error=refreshTokenFailed&serverConnectionConfigId=' + serverConnectionConfigId } } catch (error) { console.error('[PdfReader] Failed to handle refresh failure:', error) diff --git a/pages/connect.vue b/pages/connect.vue index b20c5f69..439feae0 100644 --- a/pages/connect.vue +++ b/pages/connect.vue @@ -37,9 +37,9 @@ export default { computed: {}, methods: { async init() { + await this.$store.dispatch('setupNetworkListener') this.deviceData = await this.$db.getDeviceData() this.$store.commit('setDeviceData', this.deviceData) - await this.$store.dispatch('setupNetworkListener') } }, mounted() { diff --git a/plugins/axios.js b/plugins/axios.js index 6424446d..de50a8d9 100644 --- a/plugins/axios.js +++ b/plugins/axios.js @@ -32,7 +32,7 @@ export default function ({ $axios, store, $db }) { } if (window.location.pathname !== '/connect') { - window.location.href = '/connect' + window.location.href = '/connect?error=refreshTokenFailed&serverConnectionConfigId=' + serverConnectionConfigId } } catch (error) { console.error('[axios] Failed to handle refresh failure:', error) diff --git a/plugins/db.js b/plugins/db.js index f41be58f..faa5949e 100644 --- a/plugins/db.js +++ b/plugins/db.js @@ -138,7 +138,7 @@ export default ({ app, store }, inject) => { // Clear store and redirect to login page await store.dispatch('user/logout') if (window.location.pathname !== '/connect') { - window.location.href = '/connect' + window.location.href = '/connect?error=refreshTokenFailed&serverConnectionConfigId=' + data.serverConnectionConfigId || '' } }) } diff --git a/plugins/nativeHttp.js b/plugins/nativeHttp.js index f4089ebb..76aa0e05 100644 --- a/plugins/nativeHttp.js +++ b/plugins/nativeHttp.js @@ -221,7 +221,7 @@ export default function ({ store, $db, $socket }, inject) { // Redirect to login page if (window.location.pathname !== '/connect') { - window.location.href = '/connect' + window.location.href = '/connect?error=refreshTokenFailed&serverConnectionConfigId=' + serverConnectionConfigId } } catch (error) { console.error('[nativeHttp] Failed to handle refresh failure:', error) diff --git a/strings/en-us.json b/strings/en-us.json index ef8fb515..fdfd87f8 100644 --- a/strings/en-us.json +++ b/strings/en-us.json @@ -298,6 +298,7 @@ "MessageDownloading": "Downloading...", "MessageDownloadingEpisode": "Downloading episode", "MessageEpisodesQueuedForDownload": "{0} Episode(s) queued for download", + "MessageFailedToRefreshToken": "Failed to refresh token, re-login required", "MessageFeedURLWillBe": "Feed URL will be {0}", "MessageFetching": "Fetching...", "MessageFollowTheProjectOnGithub": "Follow the project on Github", From 13855a8682640b617b852094611347ac53d89570 Mon Sep 17 00:00:00 2001 From: "J. Lavoie" Date: Thu, 7 Aug 2025 23:44:33 +0200 Subject: [PATCH 58/80] Translated using Weblate (German) Currently translated at 99.7% (355 of 356 strings) Translation: Audiobookshelf/Abs Mobile App Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/ --- strings/de.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/strings/de.json b/strings/de.json index b2afb0e7..d3b48cf2 100644 --- a/strings/de.json +++ b/strings/de.json @@ -221,7 +221,7 @@ "LabelPublishedDate": "Veröffentlicht {0}", "LabelRSSFeedCustomOwnerEmail": "Benutzerdefinierte Eigentümer-E-Mail", "LabelRSSFeedCustomOwnerName": "Benutzerdefinierter Name des Eigentümers", - "LabelRSSFeedOpen": "RSS Feed offen", + "LabelRSSFeedOpen": "RSS-Feed offen", "LabelRSSFeedPreventIndexing": "Indizierung verhindern", "LabelRSSFeedSlug": "RSS-Feed-Schlagwort", "LabelRandomly": "Zufällig", From 4be28bc579b263b6ef7ac8d43abc9a9885a770a9 Mon Sep 17 00:00:00 2001 From: owlcollector Date: Fri, 8 Aug 2025 15:33:17 +0200 Subject: [PATCH 59/80] Translated using Weblate (Japanese) Currently translated at 7.8% (28 of 356 strings) Translation: Audiobookshelf/Abs Mobile App Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ja/ --- strings/ja.json | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/strings/ja.json b/strings/ja.json index 1457265a..593f6ef3 100644 --- a/strings/ja.json +++ b/strings/ja.json @@ -1,12 +1,28 @@ { "ButtonAdd": "追加", + "ButtonAddNewServer": "サーバーを追加", + "ButtonAuthors": "作者", + "ButtonBack": "戻る", "ButtonCancel": "キャンセル", - "ButtonCancelTimer": "キャンセルタイマー", + "ButtonCancelTimer": "タイマーをキャンセル", + "ButtonClearFilter": "絞り込みを解除", + "ButtonClearLogs": "ログを削除", + "ButtonCollections": "コレクション", + "ButtonCreate": "作成", + "ButtonDelete": "削除", + "ButtonHome": "ホーム", + "ButtonLibrary": "ライブラリー", "ButtonOk": "はい", "ButtonPlay": "プレイ", "ButtonRead": "野村", + "ButtonReadLess": "閉じる", + "ButtonReadMore": "もっと見る", + "ButtonRemove": "削除", + "ButtonSave": "保存", + "ButtonSearch": "検索", "ButtonYes": "はい", "LabelBooks": "ほん", + "LabelContinueListening": "続きから聞く", "LabelLanguage": "言語", "LabelName": "名", "LabelPassword": "パスワード", From e350b4970fa5a27d3eb447b02268e433f1589f93 Mon Sep 17 00:00:00 2001 From: Paolo Ricci Date: Tue, 12 Aug 2025 09:25:01 +0200 Subject: [PATCH 60/80] Translated using Weblate (Italian) 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/it/ --- strings/it.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/strings/it.json b/strings/it.json index d0185f91..44f5466c 100644 --- a/strings/it.json +++ b/strings/it.json @@ -146,6 +146,7 @@ "LabelEndOfChapter": "Fine Capitolo", "LabelEndTime": "Tempo alla fine", "LabelEpisode": "Episodio", + "LabelExplicit": "Esplicito", "LabelFeedURL": "URL del flusso", "LabelFile": "File", "LabelFileBirthtime": "Data di creazione", @@ -324,6 +325,9 @@ "MessageNoSeries": "Nessuna serie", "MessageNoUpdatesWereNecessary": "Nessun aggiornamento necessario", "MessageNoUserPlaylists": "Non hai nessuna Playlist", + "MessageOldServerAuthReLoginRequired": "L'autenticazione è stata migliorata per la sicurezza nel server v2.26.0. Tutti gli utenti sono tenuti a re-login.", + "MessageOldServerAuthWarning": "Server utilizza l'autenticazione obsoleta", + "MessageOldServerAuthWarningHelp": "Questo server esegue una versione precedente a v2.26.0. Un sistema di autenticazione più sicuro è stato aggiunto in v2.26.0. Si raccomanda vivamente di aggiornare il server alla versione più recente. Se hai già aggiornato il server, accedi di nuovo per utilizzare la nuova autenticazione.", "MessageOldServerConnectionWarning": "La configurazione della connessione al server utilizza un vecchio ID utente. Elimina e aggiungi nuovamente questa connessione al server.", "MessageOldServerConnectionWarningHelp": "Originariamente hai impostato la connessione a questo server prima della migrazione del database in 2.3.0, rilasciata a giugno 2023. Un futuro aggiornamento del server rimuoverà la possibilità di accedere con questa vecchia connessione. Elimina la connessione al server esistente e riconnettiti (utilizzando lo stesso indirizzo server e le stesse credenziali). Se hai scaricato dei contenuti multimediali su questo dispositivo, i contenuti multimediali dovranno essere scaricati di nuovo per la sincronizzazione con il server.", "MessagePodcastSearchField": "Inserisci il termine di ricerca o l'URL del feed RSS", From 8a8f7d4f271638ec1b7aba3e634e5354cdf5a8b4 Mon Sep 17 00:00:00 2001 From: Sneaky Date: Thu, 14 Aug 2025 19:48:03 +0200 Subject: [PATCH 61/80] Translated using Weblate (Swedish) 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/sv/ --- strings/sv.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/strings/sv.json b/strings/sv.json index 1b4bcc0d..1d470aef 100644 --- a/strings/sv.json +++ b/strings/sv.json @@ -221,6 +221,7 @@ "LabelPublishedDate": "Publicerad {0}", "LabelRSSFeedCustomOwnerEmail": "Anpassad ägarens e-post", "LabelRSSFeedCustomOwnerName": "Anpassat ägarnamn", + "LabelRSSFeedOpen": "RSS-flöde öppet", "LabelRSSFeedPreventIndexing": "Förhindra indexering", "LabelRSSFeedSlug": "RSS-flödesslag", "LabelRandomly": "Slumpartat", @@ -324,6 +325,9 @@ "MessageNoSeries": "Inga serier", "MessageNoUpdatesWereNecessary": "Inga uppdateringar var nödvändiga", "MessageNoUserPlaylists": "Du har inga spellistor", + "MessageOldServerAuthReLoginRequired": "Autentiseringen har förbättrats för ökad säkerhet i server v2.26.0. Alla användare måste logga in igen.", + "MessageOldServerAuthWarning": "Servern använder en gammal autentisering", + "MessageOldServerAuthWarningHelp": "Den här servern kör en version äldre än v2.26.0. Ett säkrare autentiseringssystem finns i v2.26.0. Det rekommenderas starkt att uppdatera servern till den senaste versionen. Om du redan har uppdaterat servern, logga in igen för att använda den nya autentiseringen.", "MessageOldServerConnectionWarning": "Serveranslutningskonfigurationen använder ett gammalt användar-ID. Vänligen ta bort och lägg till denna serveranslutning igen.", "MessageOldServerConnectionWarningHelp": "Du ställde ursprungligen in anslutningen till denna server före databasflytten i version 2.3.0, som släpptes i juni 2023. En framtida serveruppdatering kommer att ta bort möjligheten att logga in med denna gamla anslutning. Vänligen ta bort den befintliga serveranslutningen och anslut igen (med samma serveradress och inloggningsuppgifter). Om du har nedladdad media på den här enheten måste den laddas ner igen för att synkroniseras med servern.", "MessagePodcastSearchField": "Skriv sökfrågan eller URL-adressen för RSS-flödet", From 19a2703ea4993b388d123aa467f38c2ab8da34ba Mon Sep 17 00:00:00 2001 From: Hang Pham Date: Wed, 13 Aug 2025 16:11:29 +0200 Subject: [PATCH 62/80] Translated using Weblate (Vietnamese) Currently translated at 80.0% (285 of 356 strings) Translation: Audiobookshelf/Abs Mobile App Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/vi/ --- strings/vi-vn.json | 43 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/strings/vi-vn.json b/strings/vi-vn.json index f62be687..70f4e372 100644 --- a/strings/vi-vn.json +++ b/strings/vi-vn.json @@ -2,10 +2,11 @@ "ButtonAdd": "Thêm", "ButtonAddNewServer": "Thêm Máy Chủ Mới", "ButtonAuthors": "Tác Giả", - "ButtonBack": "Quay Lại", + "ButtonBack": "Quay lại", "ButtonCancel": "Hủy", "ButtonCancelTimer": "Hủy Bộ Đếm", "ButtonClearFilter": "Xóa Bộ Lọc", + "ButtonClearLogs": "Xoá logs", "ButtonCloseFeed": "Đóng Feed", "ButtonCollections": "Bộ Sưu Tập", "ButtonConnect": "Kết Nối", @@ -19,21 +20,28 @@ "ButtonDeleteLocalItem": "Xóa mục địa phương", "ButtonDisableAutoTimer": "Tắt Bộ Đếm Tự Động", "ButtonDisconnect": "Ngắt Kết Nối", + "ButtonGoToWebClient": "Đi tới trang web", "ButtonHistory": "Lịch Sử", "ButtonHome": "Trang Chủ", "ButtonIssues": "Vấn Đề", "ButtonLatest": "Mới Nhất", "ButtonLibrary": "Thư Viện", "ButtonLocalMedia": "Phương Tiện Địa Phương", + "ButtonLogs": "Logs", "ButtonManageLocalFiles": "Quản Lý Tập Tin Địa Phương", + "ButtonMaskServerAddress": "Ẩn địa chỉ máy chủ", "ButtonNewFolder": "Thư Mục Mới", "ButtonNextEpisode": "Tập Kế Tiếp", + "ButtonOk": "Chấp nhận", "ButtonOpenFeed": "Mở Feed", "ButtonOverride": "Bỏ Qua", "ButtonPause": "Tạm Dừng", "ButtonPlay": "Phát", + "ButtonPlayEpisode": "Phát", "ButtonPlaylists": "Danh Sách Phát", "ButtonRead": "Đọc", + "ButtonReadLess": "Thu gọn", + "ButtonReadMore": "Đọc thêm", "ButtonRemove": "Xóa", "ButtonRemoveFromServer": "Xóa khỏi Máy Chủ", "ButtonSave": "Lưu", @@ -45,15 +53,18 @@ "ButtonStream": "Phát Trực Tiếp", "ButtonSubmit": "Gửi", "ButtonSwitchServerUser": "Chuyển Đổi Máy Chủ/Người Dùng", + "ButtonUnmaskServerAddress": "Hiện địa chỉ máy chủ", "ButtonUserStats": "Thống Kê Người Dùng", "ButtonYes": "Có", "HeaderAccount": "Tài Khoản", "HeaderAdvanced": "Nâng Cao", + "HeaderAndroidAutoSettings": "Cài đặt Android Auto", "HeaderAudioTracks": "Danh Sách Âm Thanh", "HeaderChapters": "Chương", "HeaderCollection": "Bộ Sưu Tập", - "HeaderCollectionItems": "Mục Bộ Sưu Tập", + "HeaderCollectionItems": "Danh sách Bộ Sưu Tập", "HeaderConnectionStatus": "Trạng Thái Kết Nối", + "HeaderDataSettings": "Cài đặt dữ liệu", "HeaderDetails": "Chi Tiết", "HeaderDownloads": "Tải Xuống", "HeaderEbookFiles": "Tập Tin Ebook", @@ -68,6 +79,7 @@ "HeaderPlaybackSettings": "Cài Đặt Phát Lại", "HeaderPlaylist": "Danh Sách Phát", "HeaderPlaylistItems": "Các Mục Danh Sách Phát", + "HeaderProgressSyncFailed": "Lỗi đồng bộ hoá", "HeaderRSSFeedGeneral": "Thông Tin Chi Tiết về RSS", "HeaderRSSFeedIsOpen": "RSS Feed đã được Mở", "HeaderSelectDownloadLocation": "Chọn Vị Trí Tải Xuống", @@ -78,16 +90,20 @@ "HeaderStatsRecentSessions": "Các Phiên Gần Đây", "HeaderTableOfContents": "Mục Lục", "HeaderUserInterfaceSettings": "Cài Đặt Giao Diện Người Dùng", - "HeaderYourStats": "Thống Kê của Bạn", + "HeaderYourStats": "Thống Kê Của Bạn", "LabelAddToPlaylist": "Thêm vào Danh Sách Phát", "LabelAddedAt": "Đã Thêm Vào", + "LabelAddedDate": "Đã thêm {0}", "LabelAll": "Tất Cả", "LabelAllowSeekingOnMediaControls": "Cho phép tìm kiếm vị trí trên các điều khiển phương tiện thông báo", + "LabelAlways": "Luôn luôn", + "LabelAndroidAutoBrowseLimitForGroupingHelp": "Không dùng danh sách sổ xuống theo thứ tự chữ cái khi số mục hiển thị ít hơn số lượng này", + "LabelAskConfirmation": "Yêu cầu xác nhận", "LabelAuthor": "Tác Giả", "LabelAuthorFirstLast": "Tác Giả (Tên Đầu Tiên, Họ)", "LabelAuthorLastFirst": "Tác Giả (Họ, Tên Đầu Tiên)", "LabelAuthors": "Tác Giả", - "LabelAutoDownloadEpisodes": "Tự Động Tải Xuống Các Tập Phim", + "LabelAutoDownloadEpisodes": "Tự Động Tải Xuống Các Tập", "LabelAutoRewindTime": "Thời Gian Tự Động Lùi", "LabelAutoSleepTimer": "Bộ Đếm Thời Gian Ngủ Tự Động", "LabelAutoSleepTimerAutoRewind": "Lùi Tự Động Bộ Đếm Thời Gian Ngủ", @@ -115,8 +131,10 @@ "LabelDisableVibrateOnResetHelp": "Khi bộ đếm thời gian ngủ được đặt lại, thiết bị của bạn sẽ rung. Bật cài đặt này để không rung khi bộ đếm thời gian ngủ được đặt lại.", "LabelDiscover": "Khám Phá", "LabelDownload": "Tải Xuống", + "LabelDownloadUsingCellular": "Tải xuống với dữ liệu di động", "LabelDownloaded": "Đã Tải Xuống", "LabelDuration": "Thời Lượng", + "LabelEbooks": "", "LabelEnable": "Bật", "LabelEnableMp3IndexSeeking": "Bật Tìm Kiếm Chỉ Mục Mp3", "LabelEnableMp3IndexSeekingHelp": "Cài đặt này chỉ nên được bật nếu bạn có các tập tin mp3 không tìm kiếm đúng cách. Việc tìm kiếm không chính xác chủ yếu là do các tập tin MP3 biến đổi biến (VBR). Cài đặt này sẽ buộc tìm kiếm chỉ mục, trong đó một bản đồ thời gian-đến-byte được xây dựng khi tập tin được đọc. Trong một số trường hợp với các tập tin MP3 lớn, có thể có một sự trễ khi tìm kiếm về phía cuối của tập tin.", @@ -131,6 +149,7 @@ "LabelFilename": "Tên Tập Tin", "LabelFinished": "Hoàn Thành", "LabelFolder": "Thư Mục", + "LabelFontBoldness": "Chữ đậm", "LabelFontScale": "Tỷ Lệ Phông Chữ", "LabelGenre": "Thể Loại", "LabelGenres": "Thể Loại", @@ -145,6 +164,7 @@ "LabelInternalAppStorage": "Bộ Nhớ Ứng Dụng Nội Bộ", "LabelJumpBackwardsTime": "Nhảy Lùi Thời Gian", "LabelJumpForwardsTime": "Nhảy Chuyển Tiếp Thời Gian", + "LabelKeepScreenAwake": "Không khoá màn hình", "LabelLanguage": "Ngôn Ngữ", "LabelLayout": "Bố Cục", "LabelLayoutAuto": "Tự Động", @@ -159,27 +179,40 @@ "LabelLow": "Thấp", "LabelMediaType": "Loại Phương Tiện", "LabelMedium": "Trung Bình", + "LabelMissing": "", "LabelMore": "Thêm", "LabelMoreInfo": "Thông Tin Thêm", "LabelName": "Tên", "LabelNarrator": "Người Đọc", "LabelNarrators": "Người Đọc", + "LabelNavigateWithVolume": "Điều hướng bằng nút âm thanh", + "LabelNavigateWithVolumeWhilePlaying": "Cho phép sử dụng nút âm lượng để điều hướng", + "LabelNavigateWithVolumeWhilePlayingDisabled": "Không cho phép sử dụng nút âm lượng để điều hướng trong lúc phát", + "LabelNavigateWithVolumeWhilePlayingEnabled": "Cho phép sử dụng nút âm lượng để điều hướng trong lúc phát", + "LabelNever": "Không bao giờ", "LabelNewestAuthors": "Tác Giả Mới Nhất", "LabelNewestEpisodes": "Các Tập Phim Mới Nhất", "LabelNo": "Không", "LabelNotFinished": "Chưa Hoàn Thành", "LabelNotStarted": "Chưa Bắt Đầu", + "LabelNumEpisodes": "{0} tập", + "LabelNumEpisodesIncomplete": "{0} tập, {1} chưa hoàn thành", + "LabelNumberOfEpisodes": "Số lượng tập", "LabelOff": "Tắt", + "LabelOn": "Bật", "LabelPassword": "Mật Khẩu", "LabelPath": "Đường Dẫn", "LabelPlaybackDirect": "Trực Tiếp", "LabelPlaybackLocal": "Địa Phương", "LabelPlaybackSpeed": "Tốc Độ Phát Lại", "LabelPlaybackTranscode": "Chuyển Đổi", - "LabelPreventIndexing": "Ngăn Chặn Feed của bạn được lập chỉ mục bởi iTunes và Google podcast directories", + "LabelPodcast": "Podcast", + "LabelPodcasts": "Podcasts", + "LabelPreventIndexing": "Ngăn Chặn Feed của bạn bị truy xuất bởi thư mục iTunes và Google podcast", "LabelProgress": "Tiến Độ", "LabelPubDate": "Ngày Xuất Bản", "LabelPublishYear": "Năm Xuất Bản", + "LabelPublishedDate": "Xuất bản vào {0}", "LabelRSSFeedCustomOwnerEmail": "Email Chủ Sở Hữu Tùy Chỉnh", "LabelRSSFeedCustomOwnerName": "Tên Chủ Sở Hữu Tùy Chỉnh", "LabelRSSFeedPreventIndexing": "Ngăn Chặn Lập Chỉ Mục", From bc5505e12d947869197e425750330434a16f5549 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=BA=D1=81=D0=B8=D0=BC=20=D0=93=D0=BE=D1=80?= =?UTF-8?q?=D0=BF=D0=B8=D0=BD=D1=96=D1=87?= Date: Fri, 15 Aug 2025 06:32:26 +0200 Subject: [PATCH 63/80] 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/ --- strings/uk.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/strings/uk.json b/strings/uk.json index 163356fc..c652b4fb 100644 --- a/strings/uk.json +++ b/strings/uk.json @@ -121,7 +121,7 @@ "LabelContinueBooks": "Продовжити книги", "LabelContinueEpisodes": "Продовжити епізоди", "LabelContinueListening": "Слухати далі", - "LabelContinueReading": "Читати далі", + "LabelContinueReading": "Продовжити читання", "LabelContinueSeries": "Продовжити серії", "LabelCustomTime": "Власний час", "LabelDescription": "Опис", From e43f1e75c85c6f5e77f141e9b653fe5ce7c30713 Mon Sep 17 00:00:00 2001 From: Zhelyan Radoev Date: Sat, 16 Aug 2025 09:54:09 +0200 Subject: [PATCH 64/80] Translated using Weblate (Bulgarian) 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/bg/ --- strings/bg.json | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/strings/bg.json b/strings/bg.json index 5102fdbf..20435ab1 100644 --- a/strings/bg.json +++ b/strings/bg.json @@ -6,6 +6,7 @@ "ButtonCancel": "Отказ", "ButtonCancelTimer": "Отмени Таймер", "ButtonClearFilter": "Изчисти филтър", + "ButtonClearLogs": "Изчисти логове", "ButtonCloseFeed": "Затвори стената", "ButtonCollections": "Колекции", "ButtonConnect": "Свържи се", @@ -26,7 +27,9 @@ "ButtonLatest": "Последни", "ButtonLibrary": "Библиотека", "ButtonLocalMedia": "Локална медия", + "ButtonLogs": "Логове", "ButtonManageLocalFiles": "Управление на локални файлове", + "ButtonMaskServerAddress": "Маскирай адреса на сървърът", "ButtonNewFolder": "Създай нова папка", "ButtonNextEpisode": "Следващ епизод", "ButtonOk": "Приемам", @@ -50,6 +53,7 @@ "ButtonStream": "Стрийм", "ButtonSubmit": "Изпрати", "ButtonSwitchServerUser": "Смени сървър/потребител", + "ButtonUnmaskServerAddress": "Демаскирай адреса на съвърът", "ButtonUserStats": "Потребителски статистики", "ButtonYes": "Да", "HeaderAccount": "Профил", @@ -111,7 +115,7 @@ "LabelBooks": "Книги", "LabelChapterTrack": "Трак на глава", "LabelChapters": "Глави", - "LabelClosePlayer": "Затвори", + "LabelClosePlayer": "Затвори плейъра", "LabelCollapseSeries": "Скрий сериите", "LabelComplete": "Приключено", "LabelContinueBooks": "Продължи книги", @@ -142,6 +146,7 @@ "LabelEndOfChapter": "Край на глава", "LabelEndTime": "Оставащо време", "LabelEpisode": "Епизод", + "LabelExplicit": "Експлицитно", "LabelFeedURL": "URL на емисия", "LabelFile": "Файл", "LabelFileBirthtime": "Дата на създаване на файла", @@ -216,6 +221,7 @@ "LabelPublishedDate": "Публикувани {0}", "LabelRSSFeedCustomOwnerEmail": "Персонализиран имейл на собственика", "LabelRSSFeedCustomOwnerName": "Персонализирано име на собственика", + "LabelRSSFeedOpen": "RSS Feed е отворен", "LabelRSSFeedPreventIndexing": "Предотвратете индексиране", "LabelRSSFeedSlug": "идентификатор на RSS емисия", "LabelRandomly": "Случайно", @@ -237,6 +243,8 @@ "LabelShowAll": "Покажи всички", "LabelSize": "Размер", "LabelSleepTimer": "Таймер за изключване", + "LabelSleepTimerAlmostDoneChime": "Възпроизведи звуков сигнал малко преди да приключи", + "LabelSleepTimerAlmostDoneChimeHelp": "Възпроизведи звуков сигнал, 30 секунди преди изтичане на таймера за заспиване", "LabelStart": "Старт", "LabelStartTime": "Време на стартиране", "LabelStatsBestDay": "Най-добър ден", @@ -252,6 +260,7 @@ "LabelTag": "Таг", "LabelTags": "Тагове", "LabelTheme": "Тема", + "LabelThemeBlack": "Черен", "LabelThemeDark": "Тъмна", "LabelThemeLight": "Светла", "LabelTimeRemaining": "{0} оставащи", @@ -296,6 +305,7 @@ "MessageItemMissing": "Елементът липсва и трябва да бъде поправен на сървъра. Обикновено един елемент се маркира като липсващ, защото пътищата към файловете не са достъпни.", "MessageLoading": "Зарежда...", "MessageLoadingServerData": "Зарежда данни от сървъра...", + "MessageLocalFolderDescription": "'Вътрешното хранилище на приложението' е достъпно само през това приложение. Това приложение поддържа само медийни файлове, изтеглени директно през него. Споделените папки за съхранение могат да се използват, за да се позволи на други приложения да имат достъп до медийните файлове, изтеглени чрез това приложение.", "MessageMarkAsFinished": "Маркирай като завършено", "MessageMediaLinkedToADifferentServer": "Медията е свързана със сървър на Audiobookshelf на различен адрес ({0}). Напредъкът ще бъде синхронизиран, когато се свържете с този сървърен адрес.", "MessageMediaLinkedToADifferentUser": "Медията е свързана с този сървър, но е изтеглена от друг потребител. Напредъкът ще бъде синхронизиран само с потребителя, който я е изтеглил.", @@ -308,12 +318,16 @@ "MessageNoItems": "Няма елементи", "MessageNoItemsFound": "Няма намерени елементи", "MessageNoListeningSessions": "Няма сесии за слушане", + "MessageNoLogs": "Няма записи", "MessageNoMediaFolders": "Няма медии папки", "MessageNoNetworkConnection": "Няма връзка с интернет", "MessageNoPodcastsFound": "Няма намерени подкасти", "MessageNoSeries": "Няма серии", "MessageNoUpdatesWereNecessary": "Няма нужда от обновяване", "MessageNoUserPlaylists": "Нямате създадени плейлисти", + "MessageOldServerAuthReLoginRequired": "За осигуряването на по-добра сигурност, автентикация беше подобрена във v.2.26.0. Всеки потребител ще трябва да се автентикира наново.", + "MessageOldServerAuthWarning": "Сървърът използва остаряла форма на автентикация", + "MessageOldServerAuthWarningHelp": "Този сървър е на версия, която е по-стара от v2.26.0. Във версия v2.26.0 беше добавена по-сигурна автентикация. Силно е препоръчително да обновите сървъра до последната версия. Аковече сте обновили сървъра, влезде наново, за да ползвате новата автентикация.", "MessageOldServerConnectionWarning": "Конфигурацията за връзка със сървъра използва старо потребителско ID. Моля, изтрийте и добавете отново тази връзка със сървъра.", "MessageOldServerConnectionWarningHelp": "Първоначално сте настроили връзката към този сървър преди миграцията на базата данни във версия 2.3.0, пусната през юни 2023 г. Бъдеща актуализация на сървъра ще премахне възможността за влизане с тази стара връзка. Моля, изтрийте съществуващата връзка със сървъра и се свържете отново (използвайки същия сървърен адрес и идентификационни данни). Ако имате изтеглена медия на това устройство, тя ще трябва да бъде изтеглена отново, за да се синхронизира със сървъра.", "MessagePodcastSearchField": "Въведи какво да търся или RSS емисия адрес", From 3aad7e9e075c95f453775b6109cba869493a8d60 Mon Sep 17 00:00:00 2001 From: owlcollector Date: Sat, 16 Aug 2025 15:55:36 +0200 Subject: [PATCH 65/80] Translated using Weblate (Japanese) Currently translated at 16.5% (59 of 356 strings) Translation: Audiobookshelf/Abs Mobile App Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ja/ --- strings/ja.json | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/strings/ja.json b/strings/ja.json index 593f6ef3..bfdfb595 100644 --- a/strings/ja.json +++ b/strings/ja.json @@ -7,20 +7,51 @@ "ButtonCancelTimer": "タイマーをキャンセル", "ButtonClearFilter": "絞り込みを解除", "ButtonClearLogs": "ログを削除", + "ButtonCloseFeed": "フィードを閉じる", "ButtonCollections": "コレクション", "ButtonCreate": "作成", + "ButtonCreateBookmark": "ブックマークを作成", + "ButtonCreateNewPlaylist": "新しいプレイリストを作成", "ButtonDelete": "削除", + "ButtonHistory": "履歴", "ButtonHome": "ホーム", + "ButtonLatest": "最新", "ButtonLibrary": "ライブラリー", + "ButtonNewFolder": "新しいフォルダー", + "ButtonNextEpisode": "次のエピソード", "ButtonOk": "はい", - "ButtonPlay": "プレイ", - "ButtonRead": "野村", + "ButtonOpenFeed": "フィードを開く", + "ButtonOverride": "上書きする", + "ButtonPause": "一時停止", + "ButtonPlay": "再生", + "ButtonPlayEpisode": "エピソードを再生", + "ButtonPlaylists": "プレイリスト", + "ButtonRead": "読む", "ButtonReadLess": "閉じる", "ButtonReadMore": "もっと見る", "ButtonRemove": "削除", "ButtonSave": "保存", "ButtonSearch": "検索", + "ButtonSeries": "シリーズ", + "ButtonSetTimer": "タイマーを設定", + "ButtonUserStats": "ユーザーの記録", "ButtonYes": "はい", + "HeaderAccount": "アカウント", + "HeaderChapters": "チャプター", + "HeaderCollection": "コレクション", + "HeaderCollectionItems": "コレクションの項目", + "HeaderConnectionStatus": "接続状況", + "HeaderDataSettings": "データ設定", + "HeaderDetails": "詳細", + "HeaderDownloads": "ダウンロード", + "HeaderEbookFiles": "電子書籍ファイル", + "HeaderEpisodes": "エピソード", + "HeaderEreaderSettings": "電子書籍リーダーの設定", + "HeaderLatestEpisodes": "最新のエピソード", + "HeaderLibraries": "ライブラリー", + "HeaderLocalFolders": "ローカルフォルダー", + "HeaderLocalLibraryItems": "ローカルライブラリーの項目", + "HeaderNewPlaylist": "新しいプレイリスト", "LabelBooks": "ほん", "LabelContinueListening": "続きから聞く", "LabelLanguage": "言語", From ae2fc1fcfbdc473b38d2a4bb41431548b32e143a Mon Sep 17 00:00:00 2001 From: Charlie Date: Mon, 18 Aug 2025 08:45:06 +0200 Subject: [PATCH 66/80] Translated using Weblate (French) Currently translated at 99.7% (356 of 357 strings) Translation: Audiobookshelf/Abs Mobile App Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fr/ --- strings/fr.json | 1 + 1 file changed, 1 insertion(+) diff --git a/strings/fr.json b/strings/fr.json index 4ee4f463..c126ddb0 100644 --- a/strings/fr.json +++ b/strings/fr.json @@ -298,6 +298,7 @@ "MessageDownloading": "Téléchargement…", "MessageDownloadingEpisode": "Téléchargement de l’épisode", "MessageEpisodesQueuedForDownload": "{0} épisode(s) mis en file pour téléchargement", + "MessageFailedToRefreshToken": "Échec de l'actualisation du jeton, reconnexion requise", "MessageFeedURLWillBe": "l’URL du flux sera {0}", "MessageFetching": "Récupération…", "MessageFollowTheProjectOnGithub": "Suivre le projet sur GitHub", From 6c7f2d96f32b76bb8e90ecd4a4f97a923c7912f5 Mon Sep 17 00:00:00 2001 From: ugyes Date: Mon, 18 Aug 2025 17:01:41 +0200 Subject: [PATCH 67/80] Translated using Weblate (Hungarian) Currently translated at 100.0% (357 of 357 strings) Translation: Audiobookshelf/Abs Mobile App Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hu/ --- strings/hu.json | 1 + 1 file changed, 1 insertion(+) diff --git a/strings/hu.json b/strings/hu.json index d6c8b484..de8aac6d 100644 --- a/strings/hu.json +++ b/strings/hu.json @@ -298,6 +298,7 @@ "MessageDownloading": "Letöltés...", "MessageDownloadingEpisode": "Epizód letöltése", "MessageEpisodesQueuedForDownload": "{0} epizód letöltésre vár", + "MessageFailedToRefreshToken": "A token frissítése nem sikerült, ismételt bejelentkezés szükséges", "MessageFeedURLWillBe": "A hírcsatorna URL-je lesz: {0}", "MessageFetching": "Lekérdezés...", "MessageFollowTheProjectOnGithub": "Kövesse a projektet a GitHubon", From 976bf73388c019c960f9b5e0fe0ba0a85079d4b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=BA=D1=81=D0=B8=D0=BC=20=D0=93=D0=BE=D1=80?= =?UTF-8?q?=D0=BF=D0=B8=D0=BD=D1=96=D1=87?= Date: Sun, 17 Aug 2025 08:18:48 +0200 Subject: [PATCH 68/80] Translated using Weblate (Ukrainian) Currently translated at 100.0% (357 of 357 strings) Translation: Audiobookshelf/Abs Mobile App Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/uk/ --- strings/uk.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/strings/uk.json b/strings/uk.json index c652b4fb..d4f74fa2 100644 --- a/strings/uk.json +++ b/strings/uk.json @@ -298,6 +298,7 @@ "MessageDownloading": "Завантажується...", "MessageDownloadingEpisode": "Скачування епізоду", "MessageEpisodesQueuedForDownload": "{0} епізод(ів) у черзі на завантаження", + "MessageFailedToRefreshToken": "Не вдалося оновити токен, потрібен повторний увійти", "MessageFeedURLWillBe": "URL-адреса каналу буде {0}", "MessageFetching": "Отримання...", "MessageFollowTheProjectOnGithub": "Слідкуйте за проєктом на GitHub", @@ -318,7 +319,7 @@ "MessageNoItems": "Елементи відсутні", "MessageNoItemsFound": "Елементів не знайдено", "MessageNoListeningSessions": "Сеанси прослуховування відсутні", - "MessageNoLogs": "Нема журналу", + "MessageNoLogs": "Немає журналу", "MessageNoMediaFolders": "Теки медіа відсутні", "MessageNoNetworkConnection": "Немає підключення до мережі", "MessageNoPodcastsFound": "Подкастів не знайдено", From ceb9537b730103d3d6a01318bd81137ad4202a40 Mon Sep 17 00:00:00 2001 From: FiendFEARing Date: Sun, 17 Aug 2025 04:03:04 +0200 Subject: [PATCH 69/80] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (357 of 357 strings) Translation: Audiobookshelf/Abs Mobile App Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/ --- strings/zh-cn.json | 1 + 1 file changed, 1 insertion(+) diff --git a/strings/zh-cn.json b/strings/zh-cn.json index e3e60cd2..ce7bdd61 100644 --- a/strings/zh-cn.json +++ b/strings/zh-cn.json @@ -298,6 +298,7 @@ "MessageDownloading": "下载中...", "MessageDownloadingEpisode": "正在下载剧集", "MessageEpisodesQueuedForDownload": "{0} 个剧集排队等待下载", + "MessageFailedToRefreshToken": "无法刷新令牌,需要重新登录", "MessageFeedURLWillBe": "源 URL 将改为 {0}", "MessageFetching": "正在获取...", "MessageFollowTheProjectOnGithub": "在 GitHub 上关注此项目", From 6de45a3b8855040cc03f5045887ce28d7638fa13 Mon Sep 17 00:00:00 2001 From: Zhelyan Radoev Date: Mon, 18 Aug 2025 07:58:33 +0200 Subject: [PATCH 70/80] Translated using Weblate (Bulgarian) Currently translated at 99.7% (356 of 357 strings) Translation: Audiobookshelf/Abs Mobile App Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/bg/ --- strings/bg.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/strings/bg.json b/strings/bg.json index 20435ab1..b7ca9e45 100644 --- a/strings/bg.json +++ b/strings/bg.json @@ -318,7 +318,7 @@ "MessageNoItems": "Няма елементи", "MessageNoItemsFound": "Няма намерени елементи", "MessageNoListeningSessions": "Няма сесии за слушане", - "MessageNoLogs": "Няма записи", + "MessageNoLogs": "Няма логове", "MessageNoMediaFolders": "Няма медии папки", "MessageNoNetworkConnection": "Няма връзка с интернет", "MessageNoPodcastsFound": "Няма намерени подкасти", From ef180f08b0dce7f92c5a43f275c833f4334f8e2f Mon Sep 17 00:00:00 2001 From: Laurin Sorgend Date: Mon, 18 Aug 2025 19:12:44 +0200 Subject: [PATCH 71/80] Translated using Weblate (German) Currently translated at 99.7% (356 of 357 strings) Translation: Audiobookshelf/Abs Mobile App Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/ --- strings/de.json | 1 + 1 file changed, 1 insertion(+) diff --git a/strings/de.json b/strings/de.json index d3b48cf2..611c5b6a 100644 --- a/strings/de.json +++ b/strings/de.json @@ -298,6 +298,7 @@ "MessageDownloading": "Wird heruntergeladen …", "MessageDownloadingEpisode": "Episode herunterladen", "MessageEpisodesQueuedForDownload": "{0} Episode(n) in der Warteschlange zum Herunterladen", + "MessageFailedToRefreshToken": "Aktualisierung des Tokens fehlgeschlagen, erneute Anmeldung erforderlich", "MessageFeedURLWillBe": "Feed-URL wird {0} sein", "MessageFetching": "Wird abgerufen …", "MessageFollowTheProjectOnGithub": "Folge dem Projekt auf GitHub", From 1b38b92177967416dcaedd95601e412178890836 Mon Sep 17 00:00:00 2001 From: B0rax Date: Tue, 19 Aug 2025 11:51:09 +0200 Subject: [PATCH 72/80] Translated using Weblate (German) Currently translated at 99.7% (356 of 357 strings) Translation: Audiobookshelf/Abs Mobile App Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/ --- strings/de.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/strings/de.json b/strings/de.json index 611c5b6a..95f65ecc 100644 --- a/strings/de.json +++ b/strings/de.json @@ -16,7 +16,7 @@ "ButtonCreateNewPlaylist": "Neue Wiedergabeliste erstellen", "ButtonDelete": "Löschen", "ButtonDeleteLocalEpisode": "Lokale Episode löschen", - "ButtonDeleteLocalFile": "Lösche lokale Datei", + "ButtonDeleteLocalFile": "Lokale Datei löschen", "ButtonDeleteLocalItem": "Lokales Element löschen", "ButtonDisableAutoTimer": "Automatischen Timer deaktivieren", "ButtonDisconnect": "Trennen", From e7773c9e881cb4f846db2b0911aa4cb590c5af11 Mon Sep 17 00:00:00 2001 From: numerfolt Date: Tue, 19 Aug 2025 11:52:08 +0200 Subject: [PATCH 73/80] Translated using Weblate (German) Currently translated at 99.7% (356 of 357 strings) Translation: Audiobookshelf/Abs Mobile App Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/ --- strings/de.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/strings/de.json b/strings/de.json index 95f65ecc..b5bf9c47 100644 --- a/strings/de.json +++ b/strings/de.json @@ -328,7 +328,7 @@ "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", - "MessageOldServerAuthWarningHelp": "Die Version dieses Servers ist älter als v2.26.0. Ein sichereres Authentifikationssystem wurde mit v2.26.0 eingeführt. Es wird dringend empfohlen, den Server auf die neueste Version zu aktualisieren. Falls du den Server bereits aktualisiert hast, melde dich erneut an, um die neue Authentifizierung zu nutzen.", + "MessageOldServerAuthWarningHelp": "Die Version dieses Servers ist älter als v2.26.0. Ein sichereres Authentifizierungssystem wurde in v2.26.0 hinzugefügt. Es wird dringend empfohlen, den Server auf die neuste Version zu aktualisieren. Solltest du den Server bereits aktualisiert haben, melde dich neu an, um das neue Authentifizierungssystem zu verwenden.", "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.", "MessagePodcastSearchField": "Suchbegriff oder RSS-Feed URL eingeben", From 16a26130f2c9684feac8a5e9ad68e5e84f1b64af Mon Sep 17 00:00:00 2001 From: Ivan Smoliakov Date: Mon, 18 Aug 2025 23:52:30 +0200 Subject: [PATCH 74/80] Translated using Weblate (Russian) Currently translated at 100.0% (357 of 357 strings) Translation: Audiobookshelf/Abs Mobile App Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ru/ --- strings/ru.json | 1 + 1 file changed, 1 insertion(+) diff --git a/strings/ru.json b/strings/ru.json index 22fe8ebf..6dabd871 100644 --- a/strings/ru.json +++ b/strings/ru.json @@ -298,6 +298,7 @@ "MessageDownloading": "Загрузка...", "MessageDownloadingEpisode": "Эпизод скачивается", "MessageEpisodesQueuedForDownload": "{0} Эпизод(ов) запланировано для закачки", + "MessageFailedToRefreshToken": "Не удалось обновить токен, требуется повторный вход в систему", "MessageFeedURLWillBe": "URL канала будет {0}", "MessageFetching": "Завершается...", "MessageFollowTheProjectOnGithub": "Следите за проектом на GitHub", From 4ceb427d9cc13d547aca14f023503b2b706f7b5f Mon Sep 17 00:00:00 2001 From: Zhelyan Radoev Date: Mon, 18 Aug 2025 18:26:13 +0200 Subject: [PATCH 75/80] Translated using Weblate (Bulgarian) Currently translated at 100.0% (357 of 357 strings) Translation: Audiobookshelf/Abs Mobile App Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/bg/ --- strings/bg.json | 1 + 1 file changed, 1 insertion(+) diff --git a/strings/bg.json b/strings/bg.json index b7ca9e45..0ac2ef11 100644 --- a/strings/bg.json +++ b/strings/bg.json @@ -298,6 +298,7 @@ "MessageDownloading": "Сваляне...", "MessageDownloadingEpisode": "Сваля епизод", "MessageEpisodesQueuedForDownload": "{0} Епизод(и) са сложени за сваляне", + "MessageFailedToRefreshToken": "Неуспешно опресняване на токена, налага се да се впишете отново", "MessageFeedURLWillBe": "Адресът на емисията ще бъде {0}", "MessageFetching": "Извличане...", "MessageFollowTheProjectOnGithub": "Последвайте проекта в Github", From ad77209832ee840e58ad7fdce858bc47f29126d1 Mon Sep 17 00:00:00 2001 From: Yurt Page Date: Fri, 22 Aug 2025 07:49:12 +0200 Subject: [PATCH 76/80] Translated using Weblate (Russian) Currently translated at 100.0% (357 of 357 strings) Translation: Audiobookshelf/Abs Mobile App Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ru/ --- strings/ru.json | 56 ++++++++++++++++++++++++------------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/strings/ru.json b/strings/ru.json index 6dabd871..b9e50108 100644 --- a/strings/ru.json +++ b/strings/ru.json @@ -7,7 +7,7 @@ "ButtonCancelTimer": "Отмена таймера", "ButtonClearFilter": "Очистить фильтр", "ButtonClearLogs": "Очистить журнал", - "ButtonCloseFeed": "Закрыть канал", + "ButtonCloseFeed": "Закрыть ленту", "ButtonCollections": "Коллекции", "ButtonConnect": "Подключение", "ButtonConnectToServer": "Подключение к серверу", @@ -15,7 +15,7 @@ "ButtonCreateBookmark": "Создать закладку", "ButtonCreateNewPlaylist": "Создать новый плейлист", "ButtonDelete": "Удалить", - "ButtonDeleteLocalEpisode": "Удалить локальный эпизод", + "ButtonDeleteLocalEpisode": "Удалить выпуск локально", "ButtonDeleteLocalFile": "Удалить локальный файл", "ButtonDeleteLocalItem": "Удалить локальный элемент", "ButtonDisableAutoTimer": "Отключить автоматический таймер", @@ -31,13 +31,13 @@ "ButtonManageLocalFiles": "Управление локальными файлами", "ButtonMaskServerAddress": "Скрывать адрес сервера", "ButtonNewFolder": "Новая папка", - "ButtonNextEpisode": "Следующий эпизод", + "ButtonNextEpisode": "Следующий выпуск", "ButtonOk": "Ок", - "ButtonOpenFeed": "Открыть канал", + "ButtonOpenFeed": "Открыть ленту", "ButtonOverride": "Переопределить", "ButtonPause": "Пауза", "ButtonPlay": "Слушать", - "ButtonPlayEpisode": "Воспроизвести эпизод", + "ButtonPlayEpisode": "Воспроизвести выпуск", "ButtonPlaylists": "Плейлисты", "ButtonRead": "Читать", "ButtonReadLess": "Читать меньше", @@ -68,21 +68,21 @@ "HeaderDetails": "Подробности", "HeaderDownloads": "Загрузки", "HeaderEbookFiles": "Файлы e-книг", - "HeaderEpisodes": "Эпизоды", + "HeaderEpisodes": "Выпуски", "HeaderEreaderSettings": "Настройки E-ридера", - "HeaderLatestEpisodes": "Последние эпизоды", + "HeaderLatestEpisodes": "Последние выпуски", "HeaderLibraries": "Библиотеки", "HeaderLocalFolders": "Локальные папки", "HeaderLocalLibraryItems": "Элементы локальной библиотеки", "HeaderNewPlaylist": "Новый плейлист", - "HeaderOpenRSSFeed": "Открыть RSS-канал", + "HeaderOpenRSSFeed": "Открыть RSS-ленту", "HeaderPlaybackSettings": "Настройки воспроизведения", "HeaderPlaylist": "Плейлист", "HeaderPlaylistItems": "Элементы списка воспроизведения", "HeaderProgressSyncFailed": "Синхронизация прогресса не удалась", - "HeaderRSSFeed": "RSS-канал", + "HeaderRSSFeed": "RSS-лента", "HeaderRSSFeedGeneral": "Сведения о RSS", - "HeaderRSSFeedIsOpen": "RSS-канал открыт", + "HeaderRSSFeedIsOpen": "RSS-лента открыта", "HeaderSelectDownloadLocation": "Выберите место загрузки", "HeaderSettings": "Настройки", "HeaderSleepTimer": "Таймер сна", @@ -106,7 +106,7 @@ "LabelAuthorFirstLast": "Автор (Имя Фамилия)", "LabelAuthorLastFirst": "Автор (Фамилия, Имя)", "LabelAuthors": "Авторы", - "LabelAutoDownloadEpisodes": "Скачивать эпизоды автоматически", + "LabelAutoDownloadEpisodes": "Скачивать выпуски автоматически", "LabelAutoRewindTime": "Время автоматической перемотки", "LabelAutoSleepTimer": "Таймер сна", "LabelAutoSleepTimerAutoRewind": "Перемотка назад таймера сна", @@ -119,7 +119,7 @@ "LabelCollapseSeries": "Свернуть серии", "LabelComplete": "Завершить", "LabelContinueBooks": "Продолжить", - "LabelContinueEpisodes": "Продолжить эпизоды", + "LabelContinueEpisodes": "Продолжить выпуски", "LabelContinueListening": "Продолжить слушать", "LabelContinueReading": "Продолжить чтение", "LabelContinueSeries": "Продолжить серии", @@ -145,9 +145,9 @@ "LabelEnd": "Конец", "LabelEndOfChapter": "Конец главы", "LabelEndTime": "Время конца", - "LabelEpisode": "Эпизод", + "LabelEpisode": "Выпуск", "LabelExplicit": "18+", - "LabelFeedURL": "URL канала", + "LabelFeedURL": "URL-адрес ленты", "LabelFile": "Файл", "LabelFileBirthtime": "Дата создания", "LabelFileModified": "Дата модификации", @@ -197,13 +197,13 @@ "LabelNavigateWithVolumeWhilePlayingEnabled": "Вкл.", "LabelNever": "Никогда", "LabelNewestAuthors": "Новые авторы", - "LabelNewestEpisodes": "Новые эпизоды", + "LabelNewestEpisodes": "Новые выпуски", "LabelNo": "Нет", "LabelNotFinished": "Не завершено", "LabelNotStarted": "Не запущено", - "LabelNumEpisodes": "{0} эпизодов", - "LabelNumEpisodesIncomplete": "{0} эпизодов, {1} не завершено", - "LabelNumberOfEpisodes": "# из эпизодов", + "LabelNumEpisodes": "{0} выпусков", + "LabelNumEpisodesIncomplete": "{0} выпусков, {1} не завершено", + "LabelNumberOfEpisodes": "# из выпусков", "LabelOff": "Выключить", "LabelOn": "Включён", "LabelPassword": "Пароль", @@ -223,7 +223,7 @@ "LabelRSSFeedCustomOwnerName": "Пользовательское Имя владельца", "LabelRSSFeedOpen": "Открыть RSS-ленту", "LabelRSSFeedPreventIndexing": "Запретить индексирование", - "LabelRSSFeedSlug": "Встроить RSS-канал", + "LabelRSSFeedSlug": "Ключевое слово RSS-ленты", "LabelRandomly": "Случайно", "LabelRead": "Читать", "LabelReadAgain": "Читать снова", @@ -285,7 +285,7 @@ "MessageAudiobookshelfServerNotConnected": "Сервер Audiobookshelf не подключен", "MessageAudiobookshelfServerRequired": "Важно! Это приложение предназначено для работы с сервером Audiobookshelf, который размещаете вы или кто-то из ваших знакомых. Это приложение не предоставляет никакого контента.", "MessageBookshelfEmpty": "Книжная полка пуста", - "MessageConfirmDeleteLocalEpisode": "Удалить локальный эпизод \"{0}\" с Вашего устройства? Файл на сервере не будет затронут.", + "MessageConfirmDeleteLocalEpisode": "Удалить локальный выпуск \"{0}\" с вашего устройства? Файл на сервере не будет затронут.", "MessageConfirmDeleteLocalFiles": "Удалить локальные файлы этого элемента с вашего устройства? Это не повлияет на файлы на сервере и ваш прогресс.", "MessageConfirmDisableAutoTimer": "Вы уверены, что хотите отключить автоматический таймер до конца сегодняшнего дня? Таймер будет снова включен по истечении этого периода автоматического отключения или если вы перезапустите приложение.", "MessageConfirmDiscardProgress": "Вы уверены, что хотите сбросить свой прогресс?", @@ -296,10 +296,10 @@ "MessageDiscardProgress": "Отбросить прогресс", "MessageDownloadCompleteProcessing": "Загрузка завершена. Обработка...", "MessageDownloading": "Загрузка...", - "MessageDownloadingEpisode": "Эпизод скачивается", - "MessageEpisodesQueuedForDownload": "{0} Эпизод(ов) запланировано для закачки", + "MessageDownloadingEpisode": "Скачивание выпуска", + "MessageEpisodesQueuedForDownload": "{0} выпуск(ов) запланировано для скачивания", "MessageFailedToRefreshToken": "Не удалось обновить токен, требуется повторный вход в систему", - "MessageFeedURLWillBe": "URL канала будет {0}", + "MessageFeedURLWillBe": "URL-адрес ленты будет {0}", "MessageFetching": "Завершается...", "MessageFollowTheProjectOnGithub": "Следите за проектом на GitHub", "MessageItemDownloadCompleteFailedToCreate": "Загрузка элемента завершена, но не удалось создать элемент библиотеки", @@ -331,7 +331,7 @@ "MessageOldServerAuthWarningHelp": "Этот сервер работает под управлением версии старше чем 2.26.0. В версии 2.26.0 была добавлена более безопасная система аутентификации. Настоятельно рекомендуется обновить сервер до последней версии. Если вы уже обновили сервер, войдите в систему еще раз, чтобы использовать новую аутентификацию.", "MessageOldServerConnectionWarning": "Конфигурация подключения к серверу использует старый идентификатор пользователя. Удалите и повторно добавьте это подключение к серверу.", "MessageOldServerConnectionWarningHelp": "Первоначально вы настроили подключение к этому серверу до миграции базы данных в версии 2.3.0, выпущенной в июне 2023 года. Будущее обновление сервера уберет возможность входа с помощью этого старого подключения. Удалите существующее подключение к серверу и подключитесь снова (используя тот же адрес сервера и учетные данные). Если на этом устройстве есть загруженные медиафайлы, их необходимо будет загрузить снова для синхронизации с сервером.", - "MessagePodcastSearchField": "Введите поисковый запрос или URL-адрес RSS-канала", + "MessagePodcastSearchField": "Введите поисковый запрос или URL-адрес RSS-ленты", "MessageProgressSyncFailed": "Последняя попытка сообщить о вашем прогрессе прослушивания на сервер не удалась. Запросы на синхронизацию прогресса будут продолжаться каждые 15 секунд в течении 1 минуты во время воспроизведения медиа.", "MessageReportBugsAndContribute": "Сообщайте об ошибках, запрашивайте функции и вносите свой вклад на", "MessageSeriesAlreadyDownloaded": "Вы уже скачали все книги из этой серии.", @@ -342,8 +342,8 @@ "MessageSocketConnectedOverUnmeteredCellular": "Сокет подключен по безлимитной сотовой связи", "MessageSocketConnectedOverUnmeteredWifi": "Сокет подключен через безлимитный Wi-Fi", "MessageSocketNotConnected": "Сокет не подключен", - "NoteRSSFeedPodcastAppsHttps": "Предупреждение: Большинству приложений подкастов потребуется, чтобы URL-адрес RSS-канала использовал HTTPS", - "NoteRSSFeedPodcastAppsPubDate": "Предупреждение: 1 или более эпизодов не имеют даты публикации. Некоторые приложения для подкастов требуют этого.", + "NoteRSSFeedPodcastAppsHttps": "Предупреждение: Большинству приложений подкастов потребуется, чтобы URL-адрес RSS-ленты использовал HTTPS", + "NoteRSSFeedPodcastAppsPubDate": "Предупреждение: 1 или более выпусков не имеют даты публикации. Некоторые приложения для подкастов требуют этого.", "ToastBookmarkCreateFailed": "Не удалось создать закладку", "ToastBookmarkRemoveFailed": "Не удалось удалить закладку", "ToastBookmarkUpdateFailed": "Не удалось обновить закладку", @@ -353,7 +353,7 @@ "ToastPlaylistCreateFailed": "Не удалось создать плейлист", "ToastPodcastCreateFailed": "Не удалось создать подкаст", "ToastPodcastCreateSuccess": "Подкаст успешно создан", - "ToastRSSFeedCloseFailed": "Не удалось закрыть RSS-канал", - "ToastRSSFeedCloseSuccess": "RSS-канал закрыт", + "ToastRSSFeedCloseFailed": "Не удалось закрыть RSS-ленту", + "ToastRSSFeedCloseSuccess": "RSS-лента закрыта", "ToastStreamingNotAllowedOnCellular": "Потоковая передача данных по сотовой сети запрещена" } From e23e6417ee96a287a3ac7e219d7e6189c52fe8f4 Mon Sep 17 00:00:00 2001 From: thehijacker Date: Thu, 21 Aug 2025 18:07:21 +0200 Subject: [PATCH 77/80] Translated using Weblate (Slovenian) Currently translated at 100.0% (357 of 357 strings) Translation: Audiobookshelf/Abs Mobile App Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sl/ --- strings/sl.json | 1 + 1 file changed, 1 insertion(+) diff --git a/strings/sl.json b/strings/sl.json index 4cb08377..cf273c72 100644 --- a/strings/sl.json +++ b/strings/sl.json @@ -298,6 +298,7 @@ "MessageDownloading": "Prenašanje...", "MessageDownloadingEpisode": "Prenašam epizodo", "MessageEpisodesQueuedForDownload": "{0} epizod v čakalni vrsti za prenos", + "MessageFailedToRefreshToken": "Osvežitev žetona ni uspela, potrebna je ponovna prijava", "MessageFeedURLWillBe": "URL vira bo {0}", "MessageFetching": "Pridobivam...", "MessageFollowTheProjectOnGithub": "Sledite projektu na Githubu", From 361c55c5acb9fa357801bc447611aa2c418804c4 Mon Sep 17 00:00:00 2001 From: Lauris van Rijn Date: Fri, 29 Aug 2025 00:18:59 +0200 Subject: [PATCH 78/80] =?UTF-8?q?fix(media):=20remove=20busy=E2=80=91wait?= =?UTF-8?q?=20loop=20in=20library=20personalization=20loading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced the infinite `while(libraryPersonalizationsDone > 0){}` spin‑loop with an async counter callback. This prevents pegging the CPU if one personalization never completes, and allows completion to trigger via AtomicInteger decrement. Now the final callback fires only when all libraries have finished loading. --- .../audiobookshelf/app/media/MediaManager.kt | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt index 39b3c25f..db3ee4ad 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt @@ -15,6 +15,7 @@ import org.json.JSONException import org.json.JSONObject import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine +import java.util.concurrent.atomic.AtomicInteger class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { val tag = "MediaManager" @@ -175,21 +176,20 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { * Load personalized shelves from server for all libraries. * [cb] resolves when all libraries are processed */ - fun populatePersonalizedDataForAllLibraries(cb: () -> Unit ) { - serverLibraries.forEach { - libraryPersonalizationsDone++ - Log.d(tag, "Loading personalization for library ${it.name} - ${it.id} - ${it.mediaType}") - populatePersonalizedDataForLibrary(it.id) { - Log.d(tag, "Loaded personalization for library ${it.name} - ${it.id} - ${it.mediaType}") - libraryPersonalizationsDone-- + fun populatePersonalizedDataForAllLibraries(cb: () -> Unit) { + val remaining = AtomicInteger(serverLibraries.size) + + serverLibraries.forEach { lib -> + Log.d(tag, "Loading personalization for library ${lib.name}") + populatePersonalizedDataForLibrary(lib.id) { + Log.d(tag, "Loaded personalization for library ${lib.name}") + if (remaining.decrementAndGet() == 0) { + Log.d(tag, "Finished loading all library personalization data") + allLibraryPersonalizationsDone = true + cb() + } } } - - while (libraryPersonalizationsDone > 0) { } - - Log.d(tag, "Finished loading all library personalization data") - allLibraryPersonalizationsDone = true - cb() } /** From 719e517dda851f8feaf3470f21406648f745eae3 Mon Sep 17 00:00:00 2001 From: Lauris van Rijn Date: Fri, 29 Aug 2025 00:19:24 +0200 Subject: [PATCH 79/80] =?UTF-8?q?fix(androidauto):=20async=20handling=20of?= =?UTF-8?q?=20browseTree=20init=20instead=20of=20busy=E2=80=91loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed blocking `while (!browseTree.isInitialized){}` in `onLoadChildren`. Added `waitForBrowseTree` and `onBrowseTreeInitialized` helpers to queue pending results until browseTree is ready. All browseTree assignments now call `onBrowseTreeInitialized()`. This avoids ANRs and high CPU when Android Auto requests children before init. --- .../app/player/PlayerNotificationService.kt | 64 +++++++++++++++---- 1 file changed, 52 insertions(+), 12 deletions(-) diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt index e37e7147..5e3af81e 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt @@ -1087,6 +1087,26 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { private val DOWNLOADS_ROOT = "__DOWNLOADS__" private val CONTINUE_ROOT = "__CONTINUE__" private lateinit var browseTree: BrowseTree + private val browseTreeInitListeners = mutableListOf<() -> Unit>() + + private fun waitForBrowseTree(cb: () -> Unit) + { + if (this::browseTree.isInitialized) + { + cb() + } + else + { + browseTreeInitListeners += cb + } + } + + private fun onBrowseTreeInitialized() + { + // Called after browseTree is assigned for the first time + browseTreeInitListeners.forEach { it.invoke() } + browseTreeInitListeners.clear() + } // Only allowing android auto or similar to access media browser service // normal loading of audiobooks is handled in webview (not natively) @@ -1257,6 +1277,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { mediaManager.serverLibraries, mediaManager.allLibraryPersonalizationsDone ) + onBrowseTreeInitialized() val children = browseTree[parentMediaId]?.map { item -> Log.d(tag, "Found top menu item: ${item.description.title}") @@ -1291,6 +1312,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { mediaManager.serverLibraries, mediaManager.allLibraryPersonalizationsDone ) + onBrowseTreeInitialized() val children = browseTree[parentMediaId]?.map { item -> Log.d(tag, "Found top menu item: ${item.description.title}") @@ -1303,22 +1325,40 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { AbsLogger.info(tag, "onLoadChildren: Android auto data loaded") result.sendResult(children as MutableList?) } - } else if (parentMediaId == LIBRARIES_ROOT || parentMediaId == RECENTLY_ROOT) { + } else if (parentMediaId == LIBRARIES_ROOT || parentMediaId == RECENTLY_ROOT) + { Log.d(tag, "First load done: $firstLoadDone") - if (!firstLoadDone) { + if (!firstLoadDone) + { result.sendResult(null) return } - // Wait until top-menu is initialized - while (!this::browseTree.isInitialized) {} - val children = - browseTree[parentMediaId]?.map { item -> - Log.d(tag, "[MENU: $parentMediaId] Showing list item ${item.description.title}") - MediaBrowserCompat.MediaItem( - item.description, - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE - ) - } + + if (!this::browseTree.isInitialized) + { + // ✅ good: detach and wait for init + result.detach() + waitForBrowseTree { + val children = browseTree[parentMediaId]?.map { item -> + Log.d(tag, "[MENU: $parentMediaId] Showing list item ${item.description.title}") + MediaBrowserCompat.MediaItem( + item.description, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } + return + } + + // Already initialized: just return + val children = browseTree[parentMediaId]?.map { item -> + Log.d(tag, "[MENU: $parentMediaId] Showing list item ${item.description.title}") + MediaBrowserCompat.MediaItem( + item.description, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } result.sendResult(children as MutableList?) } else if (mediaManager.getIsLibrary(parentMediaId)) { // Load library items for library Log.d(tag, "Loading items for library $parentMediaId") From 95bf28d7c6176582063e560c61f18edbf3da6029 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 31 Aug 2025 16:41:13 -0500 Subject: [PATCH 80/80] Fix tab focus for appbar and sidenav, add tabindex to book card --- components/app/Appbar.vue | 18 +++++++++--------- components/app/SideDrawer.vue | 4 ++-- components/cards/LazyBookCard.vue | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/components/app/Appbar.vue b/components/app/Appbar.vue index fd67744f..903fa337 100644 --- a/components/app/Appbar.vue +++ b/components/app/Appbar.vue @@ -4,14 +4,14 @@ - + arrow_back
-
+
+
@@ -21,19 +21,19 @@ -
+
+ - + search -
- menu -
+
diff --git a/components/app/SideDrawer.vue b/components/app/SideDrawer.vue index f0dfed36..58ce9979 100644 --- a/components/app/SideDrawer.vue +++ b/components/app/SideDrawer.vue @@ -11,11 +11,11 @@