Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ac4de4d58 | ||
|
|
68931febf6 | ||
|
|
8561ada1d2 | ||
|
|
06f4ba2e82 | ||
|
|
353e2f02df | ||
|
|
2927ab92ae | ||
|
|
94db1f72a7 | ||
|
|
bf92d18b48 | ||
|
|
c4aed2587f | ||
|
|
7a5e4f559c | ||
|
|
fe14ec0f45 | ||
|
|
607e5fc670 | ||
|
|
a34a1bb2b9 | ||
|
|
e48f2f0da2 | ||
|
|
32a8212653 | ||
|
|
9a2f0c3e4a | ||
|
|
7292e367d2 | ||
|
|
fe8f5de891 | ||
|
|
c6d0f0e8b5 | ||
|
|
14885467a4 | ||
|
|
87f1c83cbf | ||
|
|
b706337852 | ||
|
|
34f2165ef6 | ||
|
|
88ab456ce5 | ||
|
|
022a9c5875 | ||
|
|
4cb4183a12 | ||
|
|
62e80cb469 | ||
|
|
37b19e9a7b | ||
|
|
961e66b863 | ||
|
|
6c56003c26 | ||
|
|
d44dbd770a | ||
|
|
ca04bb7a3e | ||
|
|
c832c86ca4 | ||
|
|
64cbb41b0b | ||
|
|
d11d9860b6 | ||
|
|
c1a0b96ae4 | ||
|
|
a09326b5b2 | ||
|
|
1a290b4d07 | ||
|
|
8d8147ffea | ||
|
|
17fcec4401 | ||
|
|
79a925088f | ||
|
|
2fc8735643 | ||
|
|
0b1dfa4c34 | ||
|
|
30c4fa7e16 | ||
|
|
71bd354bc7 | ||
|
|
e06ce4a868 | ||
|
|
90f8d99287 | ||
|
|
1cdf0a59b1 | ||
|
|
6560038495 | ||
|
|
4b8abe7deb | ||
|
|
d230b16d5e | ||
|
|
0e71eb0308 | ||
|
|
4a1cf0ea4a | ||
|
|
d2196ce035 | ||
|
|
a100226a85 | ||
|
|
ba66869502 | ||
|
|
02e3f3541f | ||
|
|
f25ff6344e | ||
|
|
c800ed727a | ||
|
|
4eb848ae5f | ||
|
|
9dfa68ea8b | ||
|
|
7989607313 | ||
|
|
61c314a531 | ||
|
|
a709e994a0 | ||
|
|
96fc9adbbf |
No files matched your search
@@ -72,10 +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.14.0'
|
||||
- 'iOS App - 0.14.0'
|
||||
- 'Android App - 0.13.0'
|
||||
- 'iOS App - 0.13.0'
|
||||
- 'Android App - 0.12.0'
|
||||
- 'iOS App - 0.12.0'
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
@@ -43,10 +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.14.0'
|
||||
- 'iOS App - 0.14.0'
|
||||
- 'Android App - 0.13.0'
|
||||
- 'iOS App - 0.13.0'
|
||||
- 'Android App - 0.12.0'
|
||||
- 'iOS App - 0.12.0'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
java-version: 21
|
||||
|
||||
- name: install dependencies
|
||||
run: npm ci
|
||||
run: npm ci --omit=optional
|
||||
|
||||
- name: build Nuxt project
|
||||
run: npm run generate
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
name: Build iOS
|
||||
|
||||
on:
|
||||
push:
|
||||
paths-ignore:
|
||||
- 'android/**'
|
||||
- '.github/workflows/build-apk.yml'
|
||||
- '.github/workflows/deploy-apk.yml'
|
||||
- 'readme.md'
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- 'android/**'
|
||||
- '.github/workflows/build-apk.yml'
|
||||
- '.github/workflows/deploy-apk.yml'
|
||||
- 'readme.md'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build iOS simulator app
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Check out sources
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
|
||||
- name: Install JavaScript dependencies
|
||||
run: npm ci --omit=optional
|
||||
|
||||
- name: Build shared web app
|
||||
run: npm run generate
|
||||
|
||||
- name: Sync Capacitor iOS project
|
||||
run: npx cap sync ios
|
||||
|
||||
- name: Install CocoaPods dependencies
|
||||
working-directory: ios/App
|
||||
run: pod install --deployment
|
||||
|
||||
- name: Build iOS simulator app
|
||||
working-directory: ios/App
|
||||
run: |
|
||||
xcodebuild -quiet \
|
||||
-workspace App.xcworkspace \
|
||||
-scheme App \
|
||||
-configuration Debug \
|
||||
-sdk iphonesimulator \
|
||||
-destination 'generic/platform=iOS Simulator' \
|
||||
-derivedDataPath build \
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME=IconsDebug \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
build
|
||||
|
||||
echo "iOS simulator build succeeded."
|
||||
|
||||
- name: Upload iOS simulator app
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: audiobookshelf-ios-simulator
|
||||
path: ios/App/build/Build/Products/Debug-iphonesimulator/Audiobookshelf.app
|
||||
if-no-files-found: error
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
java-version: 21
|
||||
|
||||
- name: install dependencies
|
||||
run: npm ci
|
||||
run: npm ci --omit=optional
|
||||
|
||||
- name: build Nuxt project
|
||||
run: npm run generate
|
||||
|
||||
@@ -36,8 +36,8 @@ android {
|
||||
applicationId "com.audiobookshelf.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 118
|
||||
versionName "0.14.0-beta"
|
||||
versionCode 120
|
||||
versionName "0.14.1-beta"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
manifestPlaceholders = [
|
||||
"appAuthRedirectScheme": "com.audiobookshelf.app"
|
||||
|
||||
@@ -228,6 +228,7 @@ class PlaybackSession(
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, totalDurationMs)
|
||||
.putString(
|
||||
MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI,
|
||||
coverUri.toString()
|
||||
|
||||
@@ -3,12 +3,14 @@ package com.audiobookshelf.app.managers
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.StatFs
|
||||
import android.util.Log
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.anggrayudi.storage.file.fullName
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.device.FolderScanner
|
||||
import com.audiobookshelf.app.models.DownloadItem
|
||||
import com.audiobookshelf.app.models.DownloadItemPart
|
||||
import com.audiobookshelf.app.plugins.AbsLogger
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.getcapacitor.JSObject
|
||||
@@ -22,7 +24,6 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.Call
|
||||
|
||||
/** Manages the process-owned queue for app-managed downloads. */
|
||||
class DownloadItemManager(
|
||||
@@ -32,10 +33,14 @@ class DownloadItemManager(
|
||||
) {
|
||||
private val tag = "DownloadItemManager"
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val activeCalls = ConcurrentHashMap<String, Call>()
|
||||
private val activeCalls = ConcurrentHashMap<String, InternalDownloadManager.DownloadHandle>()
|
||||
private val safFolderLocks = ConcurrentHashMap<String, Any>()
|
||||
private val scanLocks = ConcurrentHashMap<String, Any>()
|
||||
private val reservations = mutableMapOf<String, Long>()
|
||||
private val lastPersistTime = mutableMapOf<String, Long>()
|
||||
private val finalizingItems = mutableSetOf<String>()
|
||||
private val refreshingServerIds = mutableSetOf<String>()
|
||||
private val apiHandler = ApiHandler(context)
|
||||
private var watcherRunning = false
|
||||
private val jacksonMapper =
|
||||
jacksonObjectMapper()
|
||||
@@ -54,12 +59,10 @@ class DownloadItemManager(
|
||||
}
|
||||
|
||||
interface InternalProgressCallback {
|
||||
fun onSizeResolved(totalBytes: Long)
|
||||
fun onProgress(totalBytesWritten: Long, progress: Long)
|
||||
fun onComplete(failed: Boolean)
|
||||
}
|
||||
|
||||
init {
|
||||
IncompleteDownloadCleanup.cleanupExpired(context)
|
||||
fun onAuthError()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@@ -73,26 +76,37 @@ class DownloadItemManager(
|
||||
fun restoreQueue() {
|
||||
if (downloadItemQueue.isNotEmpty()) return
|
||||
DeviceManager.dbManager.getDownloadItems().forEach { item ->
|
||||
item.downloadItemParts.filter { it.moved }.forEach { part ->
|
||||
if (!finalizedFileExists(part)) {
|
||||
AbsLogger.error(tag, "Finalized file is missing; resetting ${part.filename}")
|
||||
part.moved = false
|
||||
part.completed = false
|
||||
part.completedDestinationUri = null
|
||||
part.downloadId = null
|
||||
part.reusedExistingFile = false
|
||||
}
|
||||
}
|
||||
if (item.isDownloadFinished) {
|
||||
downloadItemQueue.add(item)
|
||||
checkDownloadItemFinished(item)
|
||||
return@forEach
|
||||
}
|
||||
var resetFailed = false
|
||||
item.downloadItemParts.forEach { part ->
|
||||
if (part.moved) return@forEach
|
||||
if (item.terminalFailureAt != null && part.failed) return@forEach
|
||||
part.downloadId = null
|
||||
part.isMoving = false
|
||||
part.failed = false
|
||||
part.completed = false
|
||||
part.waitingForSpace = false
|
||||
part.bytesDownloaded = File(part.destinationPath).takeIf(File::exists)?.length() ?: 0L
|
||||
if (!resetPartForFreshDownload(part)) resetFailed = true
|
||||
}
|
||||
if (resetFailed) {
|
||||
item.terminalFailureAt = item.terminalFailureAt ?: System.currentTimeMillis()
|
||||
item.stagingCleanupAt = null
|
||||
}
|
||||
if (item.terminalFailureAt != null) {
|
||||
item.downloadItemParts.filter { !it.moved }.forEach { it.failed = true }
|
||||
}
|
||||
downloadItemQueue.add(item)
|
||||
if (item.terminalFailureAt != null) IncompleteDownloadCleanup.schedule(context, item)
|
||||
clientEventEmitter.onDownloadItem(item)
|
||||
}
|
||||
checkUpdateDownloadQueue()
|
||||
notifyQueueChanged()
|
||||
}
|
||||
|
||||
@@ -100,39 +114,68 @@ class DownloadItemManager(
|
||||
fun addDownloadItem(downloadItem: DownloadItem) {
|
||||
val existingItem = downloadItemQueue.find { it.id == downloadItem.id }
|
||||
if (existingItem != null) {
|
||||
if (existingItem.terminalFailureAt != null) {
|
||||
retryDownloadItem(existingItem)
|
||||
checkUpdateDownloadQueue()
|
||||
notifyQueueChanged()
|
||||
}
|
||||
return
|
||||
}
|
||||
persist(downloadItem, force = true)
|
||||
downloadItemQueue.add(downloadItem)
|
||||
clientEventEmitter.onDownloadItem(downloadItem)
|
||||
notifyQueueChanged()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun retryDownloadItem(downloadItemId: String): Boolean {
|
||||
val item = downloadItemQueue.find { it.id == downloadItemId } ?: return false
|
||||
if (item.downloadItemParts.any { it in currentDownloadItemParts }) return false
|
||||
if (item.isDownloadFinished) return false
|
||||
synchronized(IncompleteDownloadCleanup) {
|
||||
var resetFailed = false
|
||||
item.downloadItemParts.filter { !it.moved }.forEach { part ->
|
||||
if (!resetPartForFreshDownload(part)) resetFailed = true
|
||||
}
|
||||
if (resetFailed) {
|
||||
item.downloadItemParts.filter { !it.moved }.forEach { it.failed = true }
|
||||
persist(item, force = true)
|
||||
return false
|
||||
}
|
||||
item.terminalFailureAt = null
|
||||
item.stagingCleanupAt = null
|
||||
IncompleteDownloadCleanup.cancel(context, item.id)
|
||||
persist(item, force = true)
|
||||
}
|
||||
clientEventEmitter.onDownloadItem(item)
|
||||
notifyQueueChanged()
|
||||
return true
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun resumeWork() {
|
||||
checkUpdateDownloadQueue()
|
||||
notifyQueueChanged()
|
||||
}
|
||||
|
||||
private fun retryDownloadItem(item: DownloadItem) {
|
||||
item.terminalFailureAt = null
|
||||
IncompleteDownloadCleanup.cancel(context, item.id)
|
||||
item.downloadItemParts.filter { it.failed }.forEach { part ->
|
||||
part.failed = false
|
||||
part.completed = false
|
||||
part.isMoving = false
|
||||
part.downloadId = null
|
||||
part.retryCount = 0
|
||||
}
|
||||
persist(item, force = true)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun cancelAll() {
|
||||
activeCalls.values.forEach(Call::cancel)
|
||||
activeCalls.values.forEach(InternalDownloadManager.DownloadHandle::cancel)
|
||||
activeCalls.clear()
|
||||
downloadItemQueue.forEach { item ->
|
||||
item.downloadItemParts.forEach { part -> File(part.destinationPath).delete() }
|
||||
item.downloadItemParts.forEach { part ->
|
||||
File(part.destinationPath).delete()
|
||||
if (part.moved && !part.reusedExistingFile && part.isInternalStorage) {
|
||||
File(part.finalDestinationPath).delete()
|
||||
} else if (part.moved && !part.reusedExistingFile) {
|
||||
part.completedDestinationUri?.let { uri ->
|
||||
try {
|
||||
DocumentFile.fromSingleUri(context, Uri.parse(uri))?.delete()
|
||||
} catch (e: Exception) {
|
||||
AbsLogger.error(
|
||||
tag,
|
||||
"Could not delete cancelled SAF file ${part.filename}: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
IncompleteDownloadCleanup.cancel(context, item.id)
|
||||
DeviceManager.dbManager.removeDownloadItem(item.id)
|
||||
}
|
||||
currentDownloadItemParts.clear()
|
||||
@@ -143,16 +186,34 @@ class DownloadItemManager(
|
||||
|
||||
@Synchronized
|
||||
fun hasWork(): Boolean =
|
||||
downloadItemQueue.any { item ->
|
||||
item.downloadItemParts.any { part ->
|
||||
(!part.completed && !part.failed) || part.isMoving
|
||||
}
|
||||
}
|
||||
finalizingItems.isNotEmpty() ||
|
||||
downloadItemQueue.any { item ->
|
||||
item.downloadItemParts.any { part ->
|
||||
(!part.moved && !part.failed) || part.isMoving
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun checkUpdateDownloadQueue() {
|
||||
downloadItemQueue.toList().forEach { item ->
|
||||
val slots = MAX_SIMULTANEOUS_DOWNLOADS - currentDownloadItemParts.size
|
||||
var slots = MAX_SIMULTANEOUS_DOWNLOADS - currentDownloadItemParts.size
|
||||
if (slots <= 0) return@forEach
|
||||
item.downloadItemParts
|
||||
.filter { part ->
|
||||
part.completed &&
|
||||
!part.moved &&
|
||||
!part.failed &&
|
||||
!part.isMoving &&
|
||||
part !in currentDownloadItemParts &&
|
||||
File(part.destinationPath).exists() &&
|
||||
!hasActiveDestinationConflict(part)
|
||||
}
|
||||
.take(slots)
|
||||
.forEach { part ->
|
||||
currentDownloadItemParts.add(part)
|
||||
part.downloadId = APP_MANAGED_DOWNLOAD_ID
|
||||
}
|
||||
slots = MAX_SIMULTANEOUS_DOWNLOADS - currentDownloadItemParts.size
|
||||
if (slots <= 0) return@forEach
|
||||
item.getNextDownloadItemParts(slots).forEach { part ->
|
||||
val existingFile = findSharedStorageFile(part)
|
||||
@@ -160,9 +221,17 @@ class DownloadItemManager(
|
||||
part.bytesDownloaded = existingFile.length()
|
||||
part.progress = 100L
|
||||
part.completedDestinationUri = existingFile.uri.toString()
|
||||
part.reusedExistingFile = true
|
||||
File(part.destinationPath).delete()
|
||||
completePart(item, part)
|
||||
clientEventEmitter.onDownloadItemPartUpdate(part)
|
||||
return@forEach
|
||||
}
|
||||
if (completeFromExistingInternalCover(item, part)) return@forEach
|
||||
if (hasActiveDestinationConflict(part)) {
|
||||
leaveQueued(item, part)
|
||||
} else if (part.fileSize <= 0L && currentDownloadItemParts.any { it.fileSize <= 0L }) {
|
||||
leaveQueued(item, part)
|
||||
} else if (tryReserve(part)) startDownload(item, part)
|
||||
else {
|
||||
part.waitingForSpace = true
|
||||
@@ -183,17 +252,33 @@ class DownloadItemManager(
|
||||
part.lastUpdateTime = System.currentTimeMillis()
|
||||
currentDownloadItemParts.add(part)
|
||||
persist(item, force = true)
|
||||
AbsLogger.info(tag, "Starting download for ${part.filename}")
|
||||
val activeConfig = DeviceManager.serverConnectionConfig
|
||||
val token =
|
||||
if (activeConfig?.id == item.serverConnectionConfigId) activeConfig.token
|
||||
else
|
||||
DeviceManager.getServerConnectionConfig(item.serverConnectionConfigId)?.token
|
||||
?: DeviceManager.token
|
||||
activeCalls[part.id] =
|
||||
val handle =
|
||||
InternalDownloadManager(
|
||||
stagingFile,
|
||||
part.fileSize,
|
||||
object : InternalProgressCallback {
|
||||
override fun onSizeResolved(totalBytes: Long) {
|
||||
synchronized(this@DownloadItemManager) {
|
||||
if (part !in currentDownloadItemParts || totalBytes < 0L) return
|
||||
if (part.fileSize == totalBytes) return
|
||||
AbsLogger.info(
|
||||
tag,
|
||||
"Using server size $totalBytes instead of metadata size ${part.fileSize} for ${part.filename}"
|
||||
)
|
||||
part.fileSize = totalBytes
|
||||
part.lastUpdateTime = System.currentTimeMillis()
|
||||
persist(item, force = true)
|
||||
clientEventEmitter.onDownloadItemPartUpdate(part)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onProgress(totalBytesWritten: Long, progress: Long) {
|
||||
synchronized(this@DownloadItemManager) {
|
||||
if (part !in currentDownloadItemParts) return
|
||||
@@ -214,10 +299,20 @@ class DownloadItemManager(
|
||||
persist(item, force = true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAuthError() {
|
||||
synchronized(this@DownloadItemManager) {
|
||||
if (part !in currentDownloadItemParts) return
|
||||
handleAuthError(item, part)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ hasAvailableSpace(part) }
|
||||
)
|
||||
.download(serverUrl(item, part), token)
|
||||
if (part in currentDownloadItemParts && !part.completed && !part.failed) {
|
||||
activeCalls[part.id] = handle
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@@ -253,7 +348,7 @@ class DownloadItemManager(
|
||||
if (!part.completed && !part.failed) {
|
||||
val lastUpdate = part.lastUpdateTime ?: return
|
||||
if (System.currentTimeMillis() - lastUpdate > STALL_TIMEOUT_MS) {
|
||||
Log.w(tag, "Download stalled: ${part.filename}")
|
||||
AbsLogger.error(tag, "Download stalled: ${part.filename}")
|
||||
activeCalls.remove(part.id)?.cancel()
|
||||
failOrRetry(item, part, "Download stalled")
|
||||
}
|
||||
@@ -272,14 +367,7 @@ class DownloadItemManager(
|
||||
part.retryCount += 1
|
||||
reservations.remove(part.destinationPath)
|
||||
if (part.retryCount > MAX_RETRIES) {
|
||||
Log.e(tag, "$reason after $MAX_RETRIES retries: ${part.filename}")
|
||||
part.failed = true
|
||||
part.completed = false
|
||||
part.downloadId = null
|
||||
item.terminalFailureAt = item.terminalFailureAt ?: System.currentTimeMillis()
|
||||
persist(item, force = true)
|
||||
IncompleteDownloadCleanup.schedule(context, item)
|
||||
notifyQueueChanged()
|
||||
markTerminalFailure(item, part, "$reason after $MAX_RETRIES retries")
|
||||
return
|
||||
}
|
||||
part.failed = false
|
||||
@@ -287,6 +375,94 @@ class DownloadItemManager(
|
||||
part.downloadId = null
|
||||
part.isMoving = false
|
||||
persist(item, force = true)
|
||||
clientEventEmitter.onDownloadItemPartUpdate(part)
|
||||
}
|
||||
|
||||
/** A 401 refreshes the token for this queued item's server without consuming transfer retries. */
|
||||
@Synchronized
|
||||
private fun handleAuthError(item: DownloadItem, part: DownloadItemPart) {
|
||||
removeActivePart(part)
|
||||
reservations.remove(part.destinationPath)
|
||||
part.downloadId = null
|
||||
part.isMoving = false
|
||||
part.failed = false
|
||||
part.completed = false
|
||||
part.authRetryCount += 1
|
||||
part.lastUpdateTime = System.currentTimeMillis()
|
||||
if (part.authRetryCount > MAX_AUTH_RETRIES) {
|
||||
markTerminalFailure(item, part, "Unauthorized after $MAX_AUTH_RETRIES token refresh attempts")
|
||||
return
|
||||
}
|
||||
|
||||
AbsLogger.info(
|
||||
tag,
|
||||
"Refreshing token after 401 for ${part.filename} (attempt ${part.authRetryCount})"
|
||||
)
|
||||
persist(item, force = true)
|
||||
clientEventEmitter.onDownloadItemPartUpdate(part)
|
||||
refreshTokenThenResume(item.serverConnectionConfigId)
|
||||
}
|
||||
|
||||
private fun refreshTokenThenResume(serverConnectionConfigId: String) {
|
||||
if (!refreshingServerIds.add(serverConnectionConfigId)) return
|
||||
apiHandler.refreshAuthTokens(serverConnectionConfigId) { result ->
|
||||
synchronized(this@DownloadItemManager) {
|
||||
refreshingServerIds.remove(serverConnectionConfigId)
|
||||
when (result) {
|
||||
is ApiHandler.RefreshResult.Success -> {
|
||||
AbsLogger.info(
|
||||
tag,
|
||||
"Token refresh succeeded; resuming downloads for $serverConnectionConfigId"
|
||||
)
|
||||
checkUpdateDownloadQueue()
|
||||
}
|
||||
ApiHandler.RefreshResult.Rejected -> failParkedAuthParts(serverConnectionConfigId)
|
||||
// Parked parts are still queued, so MAX_AUTH_RETRIES bounds the reattempts.
|
||||
ApiHandler.RefreshResult.Transient -> {
|
||||
AbsLogger.info(
|
||||
tag,
|
||||
"Token refresh could not be completed; retrying downloads for $serverConnectionConfigId"
|
||||
)
|
||||
checkUpdateDownloadQueue()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun failParkedAuthParts(serverConnectionConfigId: String) {
|
||||
downloadItemQueue.toList().forEach { item ->
|
||||
if (item.serverConnectionConfigId != serverConnectionConfigId) return@forEach
|
||||
item.downloadItemParts
|
||||
.filter {
|
||||
it.authRetryCount > 0 &&
|
||||
!it.completed &&
|
||||
!it.failed &&
|
||||
it.downloadId == null &&
|
||||
it !in currentDownloadItemParts
|
||||
}
|
||||
.forEach { part ->
|
||||
markTerminalFailure(item, part, "Unable to refresh download authorization")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun markTerminalFailure(item: DownloadItem, part: DownloadItemPart, reason: String) {
|
||||
AbsLogger.error(tag, "$reason: ${part.filename}")
|
||||
removeActivePart(part)
|
||||
reservations.remove(part.destinationPath)
|
||||
part.failed = true
|
||||
part.completed = false
|
||||
part.downloadId = null
|
||||
part.isMoving = false
|
||||
item.terminalFailureAt = item.terminalFailureAt ?: System.currentTimeMillis()
|
||||
item.stagingCleanupAt = null
|
||||
persist(item, force = true)
|
||||
IncompleteDownloadCleanup.schedule(context, item)
|
||||
clientEventEmitter.onDownloadItemPartUpdate(part)
|
||||
notifyQueueChanged()
|
||||
}
|
||||
|
||||
private fun finalizeInternalFile(item: DownloadItem, part: DownloadItemPart) {
|
||||
@@ -305,6 +481,7 @@ class DownloadItemManager(
|
||||
throw IllegalStateException("Could not finalize internal staging file")
|
||||
}
|
||||
backup.delete()
|
||||
AbsLogger.info(tag, "Move completed for ${part.filename} to ${finalFile.absolutePath}")
|
||||
completePart(item, part)
|
||||
} catch (e: Exception) {
|
||||
part.isMoving = false
|
||||
@@ -341,18 +518,19 @@ class DownloadItemManager(
|
||||
}
|
||||
if (temporary.length() != staging.length())
|
||||
throw IllegalStateException("SAF copy size mismatch")
|
||||
val existing = folder.findFile(part.filename)
|
||||
val existing = findDocumentByFilename(folder, part)
|
||||
if (existing != null && !existing.delete())
|
||||
throw IllegalStateException("Could not replace existing file")
|
||||
if (!temporary.renameTo(part.filename))
|
||||
throw IllegalStateException("Could not finalize SAF temporary file")
|
||||
val destination =
|
||||
folder.findFile(part.filename)
|
||||
findDocumentByFilename(folder, part)
|
||||
?: throw IllegalStateException("Could not reopen finalized SAF file")
|
||||
if (destination.length() != staging.length())
|
||||
throw IllegalStateException("SAF final size mismatch")
|
||||
if (!staging.delete()) Log.w(tag, "Could not remove staging file ${staging.name}")
|
||||
if (!staging.delete()) AbsLogger.error(tag, "Could not remove staging file ${staging.name}")
|
||||
part.completedDestinationUri = destination.uri.toString()
|
||||
AbsLogger.info(tag, "Move completed for ${part.filename} to ${destination.uri}")
|
||||
completePart(item, part)
|
||||
} catch (e: Exception) {
|
||||
failFinalization(item, part, "SAF copy failed: ${e.message}")
|
||||
@@ -362,7 +540,7 @@ class DownloadItemManager(
|
||||
|
||||
@Synchronized
|
||||
private fun failFinalization(item: DownloadItem, part: DownloadItemPart, message: String) {
|
||||
Log.e(tag, message)
|
||||
AbsLogger.error(tag, message)
|
||||
part.isMoving = false
|
||||
part.failed = true
|
||||
failOrRetry(item, part, message)
|
||||
@@ -380,25 +558,39 @@ class DownloadItemManager(
|
||||
checkDownloadItemFinished(item)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun checkDownloadItemFinished(item: DownloadItem) {
|
||||
if (!item.isDownloadFinished) return
|
||||
if (!item.isDownloadFinished || !finalizingItems.add(item.id)) return
|
||||
IncompleteDownloadCleanup.cancel(context, item.id)
|
||||
scope.launch {
|
||||
folderScanner.scanDownloadItem(item) { scanResult ->
|
||||
val event =
|
||||
JSObject().apply {
|
||||
put("libraryItemId", item.id)
|
||||
put("localFolderId", item.localFolder.id)
|
||||
scanResult?.localLibraryItem?.let {
|
||||
put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(it)))
|
||||
}
|
||||
scanResult?.localMediaProgress?.let {
|
||||
put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(it)))
|
||||
}
|
||||
}
|
||||
clientEventEmitter.onDownloadItemComplete(event)
|
||||
try {
|
||||
val scanLock = scanLocks.computeIfAbsent(scanDestinationKey(item)) { Any() }
|
||||
synchronized(scanLock) {
|
||||
folderScanner.scanDownloadItem(item) { scanResult ->
|
||||
val event =
|
||||
JSObject().apply {
|
||||
put("libraryItemId", item.id)
|
||||
put("localFolderId", item.localFolder.id)
|
||||
scanResult?.localLibraryItem?.let {
|
||||
put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(it)))
|
||||
}
|
||||
scanResult?.localMediaProgress?.let {
|
||||
put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(it)))
|
||||
}
|
||||
}
|
||||
clientEventEmitter.onDownloadItemComplete(event)
|
||||
synchronized(this@DownloadItemManager) {
|
||||
downloadItemQueue.remove(item)
|
||||
DeviceManager.dbManager.removeDownloadItem(item.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// The files are already in place, so leave the item queued for restoreQueue to rescan.
|
||||
AbsLogger.error(tag, "Could not finalize download item ${item.id}: ${e.message}")
|
||||
} finally {
|
||||
synchronized(this@DownloadItemManager) {
|
||||
downloadItemQueue.remove(item)
|
||||
DeviceManager.dbManager.removeDownloadItem(item.id)
|
||||
finalizingItems.remove(item.id)
|
||||
notifyQueueChanged()
|
||||
}
|
||||
}
|
||||
@@ -406,7 +598,6 @@ class DownloadItemManager(
|
||||
}
|
||||
|
||||
private fun tryReserve(part: DownloadItemPart): Boolean {
|
||||
if (part.fileSize <= 0L && currentDownloadItemParts.any { it.fileSize <= 0L }) return false
|
||||
val staging = File(part.destinationPath)
|
||||
staging.parentFile?.mkdirs()
|
||||
val expectedSize = if (part.fileSize > 0L) part.fileSize else UNKNOWN_PART_RESERVATION_BYTES
|
||||
@@ -457,7 +648,7 @@ class DownloadItemManager(
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
activeCalls.values.forEach(Call::cancel)
|
||||
activeCalls.values.forEach(InternalDownloadManager.DownloadHandle::cancel)
|
||||
activeCalls.clear()
|
||||
scope.cancel()
|
||||
}
|
||||
@@ -479,13 +670,103 @@ class DownloadItemManager(
|
||||
if (segment == "." || segment == "..") return null
|
||||
folder = folder.findFile(segment) ?: return null
|
||||
}
|
||||
val file = folder.findFile(part.filename) ?: return null
|
||||
val file = findDocumentByFilename(folder, part) ?: return null
|
||||
if (!file.isFile) return null
|
||||
if (part.fileSize > 0L && file.length() != part.fileSize) return null
|
||||
if (part.fileSize <= 0L && file.length() <= 0L) return null
|
||||
return file
|
||||
}
|
||||
|
||||
private fun completeFromExistingInternalCover(
|
||||
item: DownloadItem,
|
||||
part: DownloadItemPart
|
||||
): Boolean {
|
||||
if (!part.isInternalStorage || !part.serverPath.endsWith("/cover")) return false
|
||||
val file = File(part.finalDestinationPath)
|
||||
if (!file.isFile || file.length() <= 0L) return false
|
||||
if (part.fileSize > 0L && file.length() != part.fileSize) return false
|
||||
part.bytesDownloaded = file.length()
|
||||
part.progress = 100L
|
||||
part.reusedExistingFile = true
|
||||
AbsLogger.info(tag, "Reusing existing cover ${part.filename}")
|
||||
File(part.destinationPath).delete()
|
||||
completePart(item, part)
|
||||
clientEventEmitter.onDownloadItemPartUpdate(part)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun hasActiveDestinationConflict(part: DownloadItemPart): Boolean =
|
||||
currentDownloadItemParts.any { activePart ->
|
||||
activePart !== part &&
|
||||
activePart.localFolderId == part.localFolderId &&
|
||||
activePart.finalDestinationPath == part.finalDestinationPath
|
||||
}
|
||||
|
||||
private fun leaveQueued(item: DownloadItem, part: DownloadItemPart) {
|
||||
if (!part.waitingForSpace) return
|
||||
part.waitingForSpace = false
|
||||
part.downloadId = null
|
||||
persist(item)
|
||||
clientEventEmitter.onDownloadItemPartUpdate(part)
|
||||
}
|
||||
|
||||
private fun scanDestinationKey(item: DownloadItem): String =
|
||||
"${item.localFolder.id}:${item.itemFolderPath}"
|
||||
|
||||
private fun finalizedFileExists(part: DownloadItemPart): Boolean {
|
||||
if (part.isInternalStorage) {
|
||||
val file = File(part.finalDestinationPath)
|
||||
return file.isFile &&
|
||||
if (part.fileSize > 0L) file.length() == part.fileSize else file.length() > 0L
|
||||
}
|
||||
part.completedDestinationUri?.let { uri ->
|
||||
try {
|
||||
val file = DocumentFile.fromSingleUri(context, Uri.parse(uri))
|
||||
if (file?.isFile == true && (part.fileSize <= 0L || file.length() == part.fileSize))
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
AbsLogger.error(tag, "Could not validate SAF file ${part.filename}: ${e.message}")
|
||||
}
|
||||
}
|
||||
return findSharedStorageFile(part) != null
|
||||
}
|
||||
|
||||
/** Resets an unmoved part when recovery crosses a service-session boundary. */
|
||||
private fun resetPartForFreshDownload(part: DownloadItemPart): Boolean {
|
||||
val stagingFile = File(part.destinationPath)
|
||||
if (stagingFile.exists() && !stagingFile.delete()) {
|
||||
AbsLogger.error(tag, "Could not delete staging file ${part.filename}")
|
||||
part.failed = true
|
||||
return false
|
||||
}
|
||||
part.completed = false
|
||||
part.bytesDownloaded = 0L
|
||||
part.progress = 0L
|
||||
part.failed = false
|
||||
part.isMoving = false
|
||||
part.downloadId = null
|
||||
part.retryCount = 0
|
||||
part.authRetryCount = 0
|
||||
part.waitingForSpace = false
|
||||
part.reusedExistingFile = false
|
||||
return true
|
||||
}
|
||||
|
||||
private fun findDocumentByFilename(folder: DocumentFile, part: DownloadItemPart): DocumentFile? {
|
||||
folder.findFile(part.filename)?.let {
|
||||
return it
|
||||
}
|
||||
val expectedBaseName = part.filename.substringBeforeLast('.')
|
||||
return folder.listFiles().firstOrNull { document ->
|
||||
document.name == part.filename ||
|
||||
document.fullName == part.filename ||
|
||||
(part.audioTrack != null &&
|
||||
document.isFile &&
|
||||
((document.name ?: "").substringBeforeLast('.') == expectedBaseName ||
|
||||
document.fullName.substringBeforeLast('.') == expectedBaseName))
|
||||
}
|
||||
}
|
||||
|
||||
private fun mimeTypeFor(part: DownloadItemPart): String =
|
||||
part.audioTrack?.mimeType
|
||||
?: when (part.ebookFile?.ebookFormat?.lowercase()) {
|
||||
@@ -505,6 +786,7 @@ class DownloadItemManager(
|
||||
const val WATCH_INTERVAL_MS = 1_000L
|
||||
const val STALL_TIMEOUT_MS = 60_000L
|
||||
const val MAX_RETRIES = 5
|
||||
const val MAX_AUTH_RETRIES = 2
|
||||
const val PERSIST_INTERVAL_MS = 2_000L
|
||||
const val MIN_FREE_SPACE_BYTES = 100L * 1024L * 1024L
|
||||
const val UNKNOWN_PART_RESERVATION_BYTES = 100L * 1024L * 1024L
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package com.audiobookshelf.app.managers
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
@@ -11,10 +8,11 @@ import androidx.work.Worker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.models.DownloadItem
|
||||
import com.audiobookshelf.app.plugins.AbsLogger
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** Removes terminally failed downloads after their retention window elapses. */
|
||||
/** Removes only staging data for terminally failed downloads after their retention window. */
|
||||
object IncompleteDownloadCleanup {
|
||||
private const val tag = "IncompleteDownloadCleanup"
|
||||
private const val RETENTION_MS = 24L * 60L * 60L * 1000L
|
||||
@@ -22,6 +20,7 @@ object IncompleteDownloadCleanup {
|
||||
|
||||
fun schedule(context: Context, item: DownloadItem) {
|
||||
val failedAt = item.terminalFailureAt ?: return
|
||||
if (item.stagingCleanupAt != null) return
|
||||
val delay = (failedAt + RETENTION_MS - System.currentTimeMillis()).coerceAtLeast(0L)
|
||||
val request = OneTimeWorkRequestBuilder<IncompleteDownloadCleanupWorker>()
|
||||
.setInitialDelay(delay, TimeUnit.MILLISECONDS)
|
||||
@@ -34,7 +33,8 @@ object IncompleteDownloadCleanup {
|
||||
WorkManager.getInstance(context).cancelUniqueWork(WORK_PREFIX + itemId)
|
||||
}
|
||||
|
||||
/** Removes failures retained longer than 24 hours when scheduled work did not run. */
|
||||
/** Cleans staging data retained longer than 24 hours when scheduled work did not run. */
|
||||
@Synchronized
|
||||
fun cleanupExpired(context: Context) {
|
||||
val now = System.currentTimeMillis()
|
||||
DeviceManager.dbManager.getDownloadItems()
|
||||
@@ -46,6 +46,7 @@ object IncompleteDownloadCleanup {
|
||||
|
||||
private fun isEligible(item: DownloadItem, now: Long): Boolean {
|
||||
val failedAt = item.terminalFailureAt ?: return false
|
||||
if (item.stagingCleanupAt != null) return false
|
||||
if (now - failedAt < RETENTION_MS) return false
|
||||
return item.downloadItemParts.all { part ->
|
||||
part.moved || (part.failed && !part.isMoving)
|
||||
@@ -55,21 +56,15 @@ object IncompleteDownloadCleanup {
|
||||
private fun deleteItem(context: Context, item: DownloadItem) {
|
||||
item.downloadItemParts.forEach { part ->
|
||||
deleteAppOwnedFile(context, File(part.destinationPath))
|
||||
if (part.isInternalStorage && part.moved) {
|
||||
deleteAppOwnedFile(context, File(part.finalDestinationPath))
|
||||
} else if (!part.isInternalStorage && part.moved) {
|
||||
part.completedDestinationUri?.let { uriString ->
|
||||
try {
|
||||
DocumentFile.fromSingleUri(context, Uri.parse(uriString))?.delete()
|
||||
} catch (e: Exception) {
|
||||
Log.w(tag, "Could not delete expired SAF document for ${part.filename}", e)
|
||||
}
|
||||
}
|
||||
if (!part.moved) {
|
||||
part.bytesDownloaded = 0L
|
||||
part.completed = false
|
||||
}
|
||||
}
|
||||
DeviceManager.dbManager.removeDownloadItem(item.id)
|
||||
item.stagingCleanupAt = System.currentTimeMillis()
|
||||
DeviceManager.dbManager.saveDownloadItem(item)
|
||||
cancel(context, item.id)
|
||||
Log.i(tag, "Deleted terminally failed download item ${item.id}")
|
||||
AbsLogger.info(tag, "Deleted staging files for terminally failed download item ${item.id}")
|
||||
}
|
||||
|
||||
private fun deleteAppOwnedFile(context: Context, file: File) {
|
||||
@@ -77,10 +72,10 @@ object IncompleteDownloadCleanup {
|
||||
val internal = context.filesDir.absolutePath
|
||||
val external = context.getExternalFilesDir(null)?.absolutePath
|
||||
if (path.startsWith(internal) || (external != null && path.startsWith(external))) {
|
||||
if (file.exists() && !file.delete()) Log.w(tag, "Could not delete expired staging file $path")
|
||||
if (file.exists() && !file.delete()) AbsLogger.error(tag, "Could not delete expired staging file $path")
|
||||
file.parentFile?.takeIf { it.isDirectory && it.list()?.isEmpty() == true }?.delete()
|
||||
} else {
|
||||
Log.w(tag, "Refusing to delete non-app-owned path $path")
|
||||
AbsLogger.error(tag, "Refusing to delete non-app-owned path $path")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.audiobookshelf.app.managers
|
||||
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.plugins.AbsLogger
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import java.util.concurrent.TimeUnit
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
@@ -19,16 +21,49 @@ class InternalDownloadManager(
|
||||
private val hasAvailableSpace: () -> Boolean
|
||||
) {
|
||||
private val tag = "InternalDownloadManager"
|
||||
|
||||
interface DownloadHandle {
|
||||
fun cancel()
|
||||
}
|
||||
|
||||
private class ActiveDownloadHandle : DownloadHandle {
|
||||
private val cancelled = AtomicBoolean(false)
|
||||
private val activeCall = AtomicReference<Call?>()
|
||||
|
||||
fun setCall(call: Call) {
|
||||
activeCall.set(call)
|
||||
if (cancelled.get()) call.cancel()
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
cancelled.set(true)
|
||||
activeCall.get()?.cancel()
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Starts or resumes a download.
|
||||
*
|
||||
* @param url download URL
|
||||
* @param token access token sent in the Authorization header
|
||||
* @return active call, used to cancel a stalled transfer
|
||||
* @return logical handle used to cancel the active request, including a restarted request
|
||||
*/
|
||||
fun download(url: String, token: String): Call {
|
||||
fun download(url: String, token: String): DownloadHandle {
|
||||
destinationFile.parentFile?.mkdirs()
|
||||
val existingBytes = destinationFile.takeIf { it.exists() }?.length() ?: 0L
|
||||
val handle = ActiveDownloadHandle()
|
||||
startRequest(url, token, handle, allowRestart = true)
|
||||
return handle
|
||||
}
|
||||
|
||||
private fun startRequest(
|
||||
url: String,
|
||||
token: String,
|
||||
handle: ActiveDownloadHandle,
|
||||
allowRestart: Boolean
|
||||
) {
|
||||
var existingBytes = destinationFile.takeIf { it.exists() }?.length() ?: 0L
|
||||
AbsLogger.info(
|
||||
tag,
|
||||
"Starting ${if (existingBytes > 0L) "resumed" else "new"} download for ${destinationFile.name} at byte $existingBytes")
|
||||
val request =
|
||||
Request.Builder()
|
||||
.url(url)
|
||||
@@ -37,20 +72,39 @@ class InternalDownloadManager(
|
||||
.apply { if (existingBytes > 0L) header("Range", "bytes=$existingBytes-") }
|
||||
.build()
|
||||
val call = client.newCall(request)
|
||||
handle.setCall(call)
|
||||
call.enqueue(
|
||||
object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e(tag, "Download URL failed", e)
|
||||
AbsLogger.error(tag, "Download request failed for ${destinationFile.name}: ${e.message}")
|
||||
progressCallback.onComplete(true)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
try {
|
||||
if (response.code == 416 && expectedSize > 0L && existingBytes == expectedSize
|
||||
) {
|
||||
progressCallback.onProgress(existingBytes, 100L)
|
||||
progressCallback.onComplete(false)
|
||||
if (response.code == 401) {
|
||||
AbsLogger.error(tag, "Download unauthorized (401) for ${destinationFile.name}")
|
||||
progressCallback.onAuthError()
|
||||
return
|
||||
}
|
||||
if (response.code == 416) {
|
||||
val serverSize =
|
||||
response.header("Content-Range")
|
||||
?.removePrefix("bytes */")
|
||||
?.toLongOrNull()
|
||||
if (serverSize != null) progressCallback.onSizeResolved(serverSize)
|
||||
if (serverSize != null && existingBytes == serverSize) {
|
||||
progressCallback.onProgress(existingBytes, 100L)
|
||||
AbsLogger.info(tag, "Download completed for ${destinationFile.name} ($existingBytes bytes)")
|
||||
progressCallback.onComplete(false)
|
||||
} else if (allowRestart && destinationFile.delete()) {
|
||||
AbsLogger.info(tag, "Restarting stale range from byte zero for ${destinationFile.name}")
|
||||
startRequest(url, token, handle, allowRestart = false)
|
||||
} else {
|
||||
AbsLogger.error(tag, "Could not recover invalid range for ${destinationFile.name} at byte $existingBytes")
|
||||
progressCallback.onComplete(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
val append =
|
||||
@@ -58,24 +112,27 @@ class InternalDownloadManager(
|
||||
response.code == 206 &&
|
||||
hasExpectedRange(response, existingBytes)
|
||||
if (existingBytes > 0L && !append && response.code != 200) {
|
||||
Log.e(
|
||||
AbsLogger.error(
|
||||
tag,
|
||||
"Invalid resume response ${response.code} for offset $existingBytes"
|
||||
"Invalid resume response ${response.code} for ${destinationFile.name} at byte $existingBytes"
|
||||
)
|
||||
progressCallback.onComplete(true)
|
||||
return
|
||||
}
|
||||
if (!response.isSuccessful || response.body == null) {
|
||||
Log.e(tag, "Download HTTP failure ${response.code}")
|
||||
AbsLogger.error(tag, "Download HTTP failure ${response.code} for ${destinationFile.name}")
|
||||
progressCallback.onComplete(true)
|
||||
return
|
||||
}
|
||||
|
||||
val startingBytes = if (append) existingBytes else 0L
|
||||
val responseLength = response.body!!.contentLength()
|
||||
val serverSize =
|
||||
if (append) contentRangeTotal(response)
|
||||
else responseLength.takeIf { it >= 0L }
|
||||
if (serverSize != null) progressCallback.onSizeResolved(serverSize)
|
||||
val totalLength =
|
||||
if (expectedSize > 0L) expectedSize
|
||||
else if (responseLength >= 0L) startingBytes + responseLength else 0L
|
||||
serverSize ?: if (expectedSize > 0L) expectedSize else 0L
|
||||
|
||||
FileOutputStream(destinationFile, append).use { output ->
|
||||
response.body!!.byteStream().use { input ->
|
||||
@@ -95,24 +152,28 @@ class InternalDownloadManager(
|
||||
}
|
||||
}
|
||||
|
||||
if (expectedSize > 0L && destinationFile.length() != expectedSize) {
|
||||
Log.e(
|
||||
val downloadedSize = destinationFile.length()
|
||||
if (serverSize != null && downloadedSize != serverSize) {
|
||||
AbsLogger.error(
|
||||
tag,
|
||||
"Downloaded size ${destinationFile.length()} did not match $expectedSize"
|
||||
"Downloaded size for ${destinationFile.name} was $downloadedSize, expected server size $serverSize"
|
||||
)
|
||||
progressCallback.onComplete(true)
|
||||
} else {
|
||||
AbsLogger.info(
|
||||
tag,
|
||||
"Download completed for ${destinationFile.name} ($downloadedSize bytes)"
|
||||
)
|
||||
progressCallback.onComplete(false)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e(tag, "Could not write staging file", e)
|
||||
AbsLogger.error(tag, "Could not write staging file ${destinationFile.name}: ${e.message}")
|
||||
progressCallback.onComplete(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
return call
|
||||
}
|
||||
|
||||
private fun hasExpectedRange(response: Response, offset: Long): Boolean {
|
||||
@@ -122,9 +183,16 @@ class InternalDownloadManager(
|
||||
match.groupValues[2].toLongOrNull()?.let { it >= offset } == true
|
||||
}
|
||||
|
||||
private fun contentRangeTotal(response: Response): Long? =
|
||||
CONTENT_RANGE.matchEntire(response.header("Content-Range") ?: "")
|
||||
?.groupValues
|
||||
?.get(3)
|
||||
?.takeUnless { it == "*" }
|
||||
?.toLongOrNull()
|
||||
|
||||
private companion object {
|
||||
const val CHUNK_SIZE = 512 * 1024 // 512 KB
|
||||
val CONTENT_RANGE = Regex("bytes (\\d+)-(\\d+)/(?:\\d+|\\*)")
|
||||
val CONTENT_RANGE = Regex("bytes (\\d+)-(\\d+)/(\\d+|\\*)")
|
||||
val client =
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
|
||||
@@ -20,7 +20,8 @@ data class DownloadItem(
|
||||
val itemSubfolder: String,
|
||||
val media: MediaType,
|
||||
val downloadItemParts: MutableList<DownloadItemPart>,
|
||||
@JsonIgnore var terminalFailureAt: Long? = null
|
||||
@JsonIgnore var terminalFailureAt: Long? = null,
|
||||
@JsonIgnore var stagingCleanupAt: Long? = null
|
||||
) {
|
||||
@get:JsonIgnore
|
||||
val isInternalStorage
|
||||
@@ -28,7 +29,9 @@ data class DownloadItem(
|
||||
|
||||
@get:JsonIgnore
|
||||
val isDownloadFinished
|
||||
get() = !downloadItemParts.any { !it.completed || it.isMoving || it.failed }
|
||||
get() = downloadItemParts.isNotEmpty() && downloadItemParts.all {
|
||||
it.completed && it.moved && !it.isMoving && !it.failed
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getNextDownloadItemParts(limit: Int): MutableList<DownloadItemPart> {
|
||||
|
||||
@@ -14,7 +14,7 @@ data class DownloadItemPart(
|
||||
val id: String,
|
||||
val downloadItemId: String,
|
||||
val filename: String,
|
||||
val fileSize: Long,
|
||||
var fileSize: Long,
|
||||
@JsonIgnore val destinationPath: String,
|
||||
val finalDestinationPath:String,
|
||||
val serverPath: String,
|
||||
@@ -38,7 +38,9 @@ data class DownloadItemPart(
|
||||
var progress: Long,
|
||||
var bytesDownloaded: Long,
|
||||
@JsonIgnore var retryCount: Int = 0,
|
||||
@JsonIgnore var waitingForSpace: Boolean = false
|
||||
@JsonIgnore var authRetryCount: Int = 0,
|
||||
@JsonIgnore var waitingForSpace: Boolean = false,
|
||||
@JsonIgnore var reusedExistingFile: Boolean = false
|
||||
) {
|
||||
companion object {
|
||||
fun make(downloadItemId:String, filename:String, fileSize: Long, destinationFile: File, finalDestinationFile: File, subfolder:String, serverPath:String, localFolder: LocalFolder, ebookFile: EBookFile?, audioTrack: AudioTrack?, episode: PodcastEpisode?) :DownloadItemPart {
|
||||
@@ -74,7 +76,8 @@ data class DownloadItemPart(
|
||||
downloadId = null,
|
||||
lastUpdateTime = null,
|
||||
progress = 0,
|
||||
bytesDownloaded = 0
|
||||
bytesDownloaded = 0,
|
||||
reusedExistingFile = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,31 @@ class CastManager constructor(val mainActivity:Activity) {
|
||||
|
||||
private var playerNotificationService:PlayerNotificationService? = null
|
||||
private var newConnectionListener: SessionListener? = null
|
||||
private var castStateListener: CastStateListener? = null
|
||||
|
||||
/**
|
||||
* The framework reports NO_DEVICES_AVAILABLE when a session ends, because it stops discovering -
|
||||
* taken at face value that hides the cast button. The media router is the second opinion.
|
||||
*/
|
||||
private fun isReceiverAvailable(castState: Int): Boolean {
|
||||
if (castState != CastState.NO_DEVICES_AVAILABLE) return true
|
||||
|
||||
// The framework's own selector, which carries the receiver id this app is configured with -
|
||||
// building one by hand here would ask about a different receiver than the one being cast to
|
||||
val selector = getContext().mergedSelector ?: return false
|
||||
|
||||
return getMediaRouter()?.isRouteAvailable(
|
||||
selector,
|
||||
MediaRouter.AVAILABILITY_FLAG_IGNORE_DEFAULT_ROUTE or
|
||||
MediaRouter.AVAILABILITY_FLAG_REQUIRE_MATCH
|
||||
) == true
|
||||
}
|
||||
|
||||
/** Drops what this manager registered for the activity, which does not outlive it. */
|
||||
fun detach() {
|
||||
castStateListener?.let { getContext().removeCastStateListener(it) }
|
||||
castStateListener = null
|
||||
}
|
||||
|
||||
private fun switchToPlayer(useCastPlayer:Boolean) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
@@ -123,6 +148,13 @@ class CastManager constructor(val mainActivity:Activity) {
|
||||
}
|
||||
|
||||
fun startRouteScan(connListener:ChromecastListener) {
|
||||
// ChromecastListener has always implemented CastStateListener, but nothing registered it, so
|
||||
// availability hung entirely on the media router callback below - which does not always fire
|
||||
val stateListener = CastStateListener { state -> connListener.onReceiverAvailableUpdate(isReceiverAvailable(state)) }
|
||||
castStateListener = stateListener
|
||||
getContext().addCastStateListener(stateListener)
|
||||
connListener.onReceiverAvailableUpdate(isReceiverAvailable(getContext().castState))
|
||||
|
||||
val callback = object : ScanCallback() {
|
||||
override fun onRouteUpdate(routes: List<MediaRouter.RouteInfo>?) {
|
||||
Log.d(tag, "CAST On ROUTE UPDATED ${routes?.size} | ${getContext().castState}")
|
||||
|
||||
@@ -815,12 +815,21 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
return AudioAttributes.DEFAULT
|
||||
}
|
||||
|
||||
/**
|
||||
* The volume of the media stream, which is what the sleep timer fades - not the volume of the
|
||||
* receiver. The device volume stays where the user put it, and on a cast group the balance
|
||||
* between its members is left alone, because the receiver applies this to the group's output.
|
||||
*/
|
||||
override fun setVolume(audioVolume: Float) {
|
||||
|
||||
try {
|
||||
remoteMediaClient?.setStreamVolume(audioVolume.toDouble().coerceIn(0.0, 1.0))
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Failed to set the cast stream volume", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getVolume(): Float {
|
||||
return 0F
|
||||
return remoteMediaClient?.mediaStatus?.streamVolume?.toFloat() ?: 1F
|
||||
}
|
||||
|
||||
override fun clearVideoSurface() {
|
||||
|
||||
@@ -234,7 +234,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
playerNotificationService.jumpBackward()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_STOP -> {
|
||||
playerNotificationService.closePlayback()
|
||||
playerNotificationService.pause()
|
||||
}
|
||||
else -> {
|
||||
Log.d(tag, "KeyCode:${keyEvent.keyCode}")
|
||||
|
||||
@@ -127,6 +127,11 @@ class AbsAudioPlayer : Plugin() {
|
||||
notifyListeners(evtName, ret)
|
||||
}
|
||||
|
||||
override fun handleOnDestroy() {
|
||||
castManager?.detach()
|
||||
super.handleOnDestroy()
|
||||
}
|
||||
|
||||
override fun handleOnPause() {
|
||||
super.handleOnPause()
|
||||
isInForeground = false
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.audiobookshelf.app.plugins
|
||||
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.MainActivity
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
@@ -84,57 +83,60 @@ class AbsDownloader : Plugin() {
|
||||
var episodeId = call.data.getString("episodeId").toString()
|
||||
if (episodeId == "null") episodeId = ""
|
||||
var localFolderId = call.data.getString("localFolderId", "").toString()
|
||||
Log.d(tag, "Download library item $libraryItemId to folder $localFolderId / episode: $episodeId")
|
||||
AbsLogger.info(tag, "Requested download for item $libraryItemId${if (episodeId.isEmpty()) "" else " / episode $episodeId"}")
|
||||
|
||||
val downloadId = if (episodeId.isEmpty()) libraryItemId else "$libraryItemId-$episodeId"
|
||||
if (downloadItemManager.downloadItemQueue.find { it.id == downloadId } != null) {
|
||||
Log.d(tag, "Download already started for this media entity $downloadId")
|
||||
return call.resolve(JSObject("{\"error\":\"Download already started for this media entity\"}"))
|
||||
}
|
||||
|
||||
apiHandler.getLibraryItemWithProgress(libraryItemId, episodeId) { libraryItem ->
|
||||
if (libraryItem == null) {
|
||||
call.resolve(JSObject("{\"error\":\"Server request failed\"}"))
|
||||
} else {
|
||||
Log.d(tag, "Got library item from server ${libraryItem.id}")
|
||||
|
||||
if (localFolderId == "") {
|
||||
localFolderId = "internal-${libraryItem.mediaType}"
|
||||
DownloadServiceHost.retryExisting(mainActivity, downloadId) { result ->
|
||||
when (result) {
|
||||
DownloadServiceHost.ExistingDownloadResult.RETRIED -> call.resolve()
|
||||
DownloadServiceHost.ExistingDownloadResult.ACTIVE -> {
|
||||
AbsLogger.info(tag, "Download already active for $downloadId")
|
||||
call.resolve(JSObject("{\"error\":\"Download already started for this media entity\"}"))
|
||||
}
|
||||
var localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
|
||||
|
||||
if (localFolder == null && localFolderId.startsWith("internal-")) {
|
||||
Log.d(tag, "Creating new App Storage internal LocalFolder $localFolderId")
|
||||
localFolder = LocalFolder(localFolderId, "Internal App Storage", "", "", "", "internal", libraryItem.mediaType)
|
||||
DeviceManager.dbManager.saveLocalFolder(localFolder)
|
||||
}
|
||||
|
||||
if (localFolder != null) {
|
||||
if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") {
|
||||
Log.e(tag, "Library item is not a podcast but episode was requested")
|
||||
call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
|
||||
} else if (episodeId.isNotEmpty()) {
|
||||
val podcast = libraryItem.media as Podcast
|
||||
val episode = podcast.episodes?.find { podcastEpisode ->
|
||||
podcastEpisode.id == episodeId
|
||||
}
|
||||
if (episode == null) {
|
||||
call.resolve(JSObject("{\"error\":\"Invalid podcast episode not found\"}"))
|
||||
DownloadServiceHost.ExistingDownloadResult.SERVICE_START_FAILED ->
|
||||
call.resolve(JSObject("{\"error\":\"Unable to start the Android download service\"}"))
|
||||
DownloadServiceHost.ExistingDownloadResult.NOT_FOUND -> {
|
||||
apiHandler.getLibraryItemWithProgress(libraryItemId, episodeId) { libraryItem ->
|
||||
if (libraryItem == null) {
|
||||
call.resolve(JSObject("{\"error\":\"Server request failed\"}"))
|
||||
} else {
|
||||
startLibraryItemDownload(libraryItem, localFolder, episode)
|
||||
call.resolve()
|
||||
AbsLogger.info(tag, "Preparing download for ${libraryItem.id}")
|
||||
|
||||
if (localFolderId == "") localFolderId = "internal-${libraryItem.mediaType}"
|
||||
var localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
|
||||
if (localFolder == null && localFolderId.startsWith("internal-")) {
|
||||
AbsLogger.info(tag, "Creating internal download folder $localFolderId")
|
||||
localFolder = LocalFolder(localFolderId, "Internal App Storage", "", "", "", "internal", libraryItem.mediaType)
|
||||
DeviceManager.dbManager.saveLocalFolder(localFolder)
|
||||
}
|
||||
|
||||
if (localFolder == null) {
|
||||
call.resolve(JSObject("{\"error\":\"Local Folder Not Found\"}"))
|
||||
} else if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") {
|
||||
call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
|
||||
} else if (episodeId.isNotEmpty()) {
|
||||
val podcast = libraryItem.media as Podcast
|
||||
val episode = podcast.episodes?.find { it.id == episodeId }
|
||||
if (episode == null) {
|
||||
call.resolve(JSObject("{\"error\":\"Invalid podcast episode not found\"}"))
|
||||
} else {
|
||||
startLibraryItemDownload(libraryItem, localFolder, episode) { error -> resolveDownloadCall(call, error) }
|
||||
}
|
||||
} else {
|
||||
startLibraryItemDownload(libraryItem, localFolder, null) { error -> resolveDownloadCall(call, error) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
startLibraryItemDownload(libraryItem, localFolder, null)
|
||||
call.resolve()
|
||||
}
|
||||
} else {
|
||||
call.resolve(JSObject("{\"error\":\"Local Folder Not Found\"}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveDownloadCall(call: PluginCall, error: String?) {
|
||||
if (error == null) call.resolve()
|
||||
else call.resolve(JSObject().put("error", error))
|
||||
}
|
||||
|
||||
// Item filenames could be the same if they are in sub-folders, this will make them unique
|
||||
private fun getFilenameFromRelPath(relPath: String): String {
|
||||
var cleanedRelPath = relPath.replace("\\", "_").replace("/", "_")
|
||||
@@ -155,7 +157,12 @@ class AbsDownloader : Plugin() {
|
||||
return newTitle
|
||||
}
|
||||
|
||||
private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) {
|
||||
private fun startLibraryItemDownload(
|
||||
libraryItem: LibraryItem,
|
||||
localFolder: LocalFolder,
|
||||
episode: PodcastEpisode?,
|
||||
callback: (String?) -> Unit
|
||||
) {
|
||||
val isInternal = localFolder.id.startsWith("internal-")
|
||||
|
||||
val finalInternalFolderPath = "${mainActivity.filesDir}/downloads/${libraryItem.id}"
|
||||
@@ -166,14 +173,13 @@ class AbsDownloader : Plugin() {
|
||||
"${mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: mainActivity.filesDir}/download-staging/${libraryItem.id}"
|
||||
}
|
||||
|
||||
Log.d(tag, "downloadCacheDirectory=$tempFolderPath")
|
||||
|
||||
if (libraryItem.mediaType == "book") {
|
||||
val bookTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
|
||||
val bookAuthor = cleanStringForFileSystem(libraryItem.media.metadata.getAuthorDisplayName())
|
||||
|
||||
val tracks = libraryItem.media.getAudioTracks()
|
||||
Log.d(tag, "Starting library item download with ${tracks.size} tracks")
|
||||
AbsLogger.info(tag, "Queueing library item download with ${tracks.size} files")
|
||||
val itemSubfolder = "$bookAuthor/$bookTitle"
|
||||
val itemFolderPath = if (isInternal) finalInternalFolderPath else "${localFolder.absolutePath}/$itemSubfolder"
|
||||
val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, itemSubfolder, libraryItem.media, mutableListOf())
|
||||
@@ -200,7 +206,6 @@ class AbsDownloader : Plugin() {
|
||||
|
||||
val serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download"
|
||||
val destinationFilename = getFilenameFromRelPath(audioTrack.relPath)
|
||||
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
|
||||
|
||||
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
val destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||
@@ -224,8 +229,8 @@ class AbsDownloader : Plugin() {
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
|
||||
DownloadServiceHost.enqueue(mainActivity, downloadItem)
|
||||
}
|
||||
DownloadServiceHost.enqueue(mainActivity, downloadItem, callback)
|
||||
} else callback("No downloadable files found")
|
||||
} else {
|
||||
// Podcast episode download
|
||||
val podcastTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
|
||||
@@ -234,14 +239,13 @@ class AbsDownloader : Plugin() {
|
||||
val audioFileIno = episode?.audioFile?.ino
|
||||
val fileSize = audioTrack?.metadata?.size ?: 0
|
||||
|
||||
Log.d(tag, "Starting podcast episode download")
|
||||
AbsLogger.info(tag, "Queueing podcast episode download")
|
||||
val itemFolderPath = if (isInternal) finalInternalFolderPath else "${localFolder.absolutePath}/$podcastTitle"
|
||||
val downloadItemId = "${libraryItem.id}-${episode?.id}"
|
||||
val downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, libraryItem.userMediaProgress, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, podcastTitle, libraryItem.media, mutableListOf())
|
||||
|
||||
var serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download"
|
||||
var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "")
|
||||
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack?.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
|
||||
|
||||
var destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||
var finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
@@ -262,7 +266,7 @@ class AbsDownloader : Plugin() {
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
|
||||
DownloadServiceHost.enqueue(mainActivity, downloadItem)
|
||||
DownloadServiceHost.enqueue(mainActivity, downloadItem, callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,105 +167,116 @@ class ApiHandler(var ctx:Context) {
|
||||
* 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
|
||||
* 5. If refresh fails, fail the request ([refreshAuthTokens] owns clearing the session)
|
||||
*
|
||||
* @param originalRequest The original request that failed with 401
|
||||
* @param httpClient The HTTP client to use for the request
|
||||
* @param callback The callback to return the response
|
||||
*/
|
||||
private fun handleTokenRefresh(originalRequest: Request, httpClient: OkHttpClient?, callback: (JSObject) -> Unit) {
|
||||
try {
|
||||
AbsLogger.info(tag, "handleTokenRefresh: Attempting to refresh auth tokens for server ${DeviceManager.serverConnectionConfigString}")
|
||||
val serverConnectionConfigId = DeviceManager.serverConnectionConfigId
|
||||
refreshAuthTokens(serverConnectionConfigId, httpClient) { result ->
|
||||
if (result is RefreshResult.Success) {
|
||||
retryOriginalRequest(originalRequest, result.accessToken, httpClient, callback)
|
||||
} else {
|
||||
callback(JSObject().put("error", "Authentication failed - login again"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get current server connection config ID
|
||||
val serverConnectionConfigId = DeviceManager.serverConnectionConfigId
|
||||
if (serverConnectionConfigId.isEmpty()) {
|
||||
AbsLogger.error(tag, "handleTokenRefresh: Unable to refresh auth tokens. No server connection config ID")
|
||||
val errorObj = JSObject()
|
||||
errorObj.put("error", "No server connection available")
|
||||
callback(errorObj)
|
||||
return
|
||||
sealed interface RefreshResult {
|
||||
data class Success(val accessToken: String) : RefreshResult
|
||||
|
||||
/** The server rejected the refresh token, so the session has already been cleared. */
|
||||
data object Rejected : RefreshResult
|
||||
|
||||
/** The refresh could not be completed. Credentials are untouched and the caller may retry. */
|
||||
data object Transient : RefreshResult
|
||||
}
|
||||
|
||||
/** Refreshes tokens for a specific saved server, including downloads queued while another server is active. */
|
||||
fun refreshAuthTokens(
|
||||
serverConnectionConfigId: String,
|
||||
httpClient: OkHttpClient? = null,
|
||||
onResult: (RefreshResult) -> Unit
|
||||
) {
|
||||
val config = DeviceManager.getServerConnectionConfig(serverConnectionConfigId)
|
||||
val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId)
|
||||
if (config == null || refreshToken.isNullOrEmpty()) {
|
||||
AbsLogger.error(tag, "No refresh token or server configuration for $serverConnectionConfigId")
|
||||
handleRefreshRejected(serverConnectionConfigId)
|
||||
onResult(RefreshResult.Rejected)
|
||||
return
|
||||
}
|
||||
val request = try {
|
||||
Request.Builder()
|
||||
.url("${config.address}/auth/refresh")
|
||||
.addHeader("x-refresh-token", refreshToken)
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post(EMPTY_REQUEST)
|
||||
.build()
|
||||
} catch (e: Exception) {
|
||||
AbsLogger.error(tag, "Could not create refresh request for ${config.name}: ${e.message}")
|
||||
onResult(RefreshResult.Transient)
|
||||
return
|
||||
}
|
||||
(httpClient ?: defaultClient).newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
AbsLogger.error(tag, "Token refresh failed for ${config.name}: ${e.message}")
|
||||
onResult(RefreshResult.Transient)
|
||||
}
|
||||
|
||||
// Get refresh token from secure storage
|
||||
val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId)
|
||||
if (refreshToken.isNullOrEmpty()) {
|
||||
AbsLogger.error(tag, "handleTokenRefresh: Unable to refresh auth tokens. No refresh token available for server ${DeviceManager.serverConnectionConfigString}")
|
||||
val errorObj = JSObject()
|
||||
errorObj.put("error", "No refresh token available")
|
||||
callback(errorObj)
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(tag, "handleTokenRefresh: Retrieved refresh token, attempting to refresh access token")
|
||||
|
||||
// Create refresh token request
|
||||
val refreshEndpoint = "${DeviceManager.serverAddress}/auth/refresh"
|
||||
val refreshRequest = Request.Builder()
|
||||
.url(refreshEndpoint)
|
||||
.addHeader("x-refresh-token", refreshToken)
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post(EMPTY_REQUEST)
|
||||
.build()
|
||||
|
||||
// Make the refresh request
|
||||
val client = httpClient ?: defaultClient
|
||||
client.newCall(refreshRequest).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e(tag, "handleTokenRefresh: Failed to connect to refresh endpoint", e)
|
||||
AbsLogger.error(tag, "handleTokenRefresh: Failed to connect to refresh endpoint for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})")
|
||||
handleRefreshFailure(callback)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (!it.isSuccessful) {
|
||||
AbsLogger.error(tag, "handleTokenRefresh: Refresh request failed with status ${it.code} for server ${DeviceManager.serverConnectionConfigString}")
|
||||
handleRefreshFailure(callback)
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (!it.isSuccessful) {
|
||||
AbsLogger.error(tag, "Token refresh returned ${it.code} for ${config.name}")
|
||||
if (it.code != 401 && it.code != 403) {
|
||||
onResult(RefreshResult.Transient)
|
||||
return
|
||||
}
|
||||
|
||||
val bodyString = it.body!!.string()
|
||||
try {
|
||||
val responseJson = JSONObject(bodyString)
|
||||
val userObj = responseJson.optJSONObject("user")
|
||||
|
||||
if (userObj == null) {
|
||||
AbsLogger.error(tag, "handleTokenRefresh: No user object in refresh response for server ${DeviceManager.serverConnectionConfigString}")
|
||||
handleRefreshFailure(callback)
|
||||
return
|
||||
}
|
||||
|
||||
val newAccessToken = userObj.optString("accessToken")
|
||||
val newRefreshToken = userObj.optString("refreshToken")
|
||||
|
||||
if (newAccessToken.isEmpty()) {
|
||||
AbsLogger.error(tag, "handleTokenRefresh: No access token in refresh response for server ${DeviceManager.serverConnectionConfigString}")
|
||||
handleRefreshFailure(callback)
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(tag, "handleTokenRefresh: Successfully obtained new access token")
|
||||
|
||||
// Update tokens in secure storage and device manager
|
||||
updateTokens(newAccessToken, newRefreshToken.ifEmpty { refreshToken }, serverConnectionConfigId)
|
||||
|
||||
// Retry the original request with the new access token
|
||||
Log.d(tag, "handleTokenRefresh: Retrying original request with new token")
|
||||
retryOriginalRequest(originalRequest, newAccessToken, httpClient, callback)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "handleTokenRefresh: Failed to parse refresh response", e)
|
||||
AbsLogger.error(tag, "handleTokenRefresh: Failed to parse refresh response for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})")
|
||||
handleRefreshFailure(callback)
|
||||
handleRefreshRejected(serverConnectionConfigId)
|
||||
onResult(RefreshResult.Rejected)
|
||||
return
|
||||
}
|
||||
try {
|
||||
val user = JSONObject(it.body!!.string()).optJSONObject("user")
|
||||
val accessToken = user?.optString("accessToken").orEmpty()
|
||||
if (accessToken.isEmpty()) {
|
||||
AbsLogger.error(tag, "Refresh response had no access token for ${config.name}")
|
||||
onResult(RefreshResult.Transient)
|
||||
return
|
||||
}
|
||||
updateTokens(accessToken, user?.optString("refreshToken").orEmpty().ifEmpty { refreshToken }, serverConnectionConfigId)
|
||||
onResult(RefreshResult.Success(accessToken))
|
||||
} catch (e: Exception) {
|
||||
AbsLogger.error(tag, "Could not parse refresh response for ${config.name}: ${e.message}")
|
||||
onResult(RefreshResult.Transient)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears only the server that rejected the refresh token; queued downloads can target a non-active server.
|
||||
*
|
||||
* Only call this when the server explicitly rejected the refresh token. Transient failures should not log the user out
|
||||
*/
|
||||
private fun handleRefreshRejected(serverConnectionConfigId: String) {
|
||||
// Must not throw: callers still have to report the refresh result to an in-flight request or download.
|
||||
try {
|
||||
secureStorage.removeRefreshToken(serverConnectionConfigId)
|
||||
if (DeviceManager.serverConnectionConfigId != serverConnectionConfigId) return
|
||||
DeviceManager.serverConnectionConfig = null
|
||||
DeviceManager.deviceData.lastServerConnectionConfigId = null
|
||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||
if (checkAbsDatabaseNotifyListenersInitted()) {
|
||||
absDatabaseNotifyListeners(
|
||||
"onTokenRefreshFailure",
|
||||
JSObject().put("error", "Token refresh failed").put("serverConnectionConfigId", serverConnectionConfigId))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "handleTokenRefresh: Unexpected error during token refresh", e)
|
||||
handleRefreshFailure(callback)
|
||||
AbsLogger.error(tag, "Could not clear session for $serverConnectionConfigId: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,15 +294,15 @@ class ApiHandler(var ctx:Context) {
|
||||
Log.d(tag, "updateTokens: Updated refresh token in secure storage")
|
||||
}
|
||||
|
||||
// Update the access token in the current server connection config
|
||||
DeviceManager.serverConnectionConfig?.let { config ->
|
||||
// The refreshed connection may be queued in the downloader rather than currently active.
|
||||
DeviceManager.getServerConnectionConfig(serverConnectionConfigId)?.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()) {
|
||||
if (DeviceManager.serverConnectionConfigId == serverConnectionConfigId && checkAbsDatabaseNotifyListenersInitted()) {
|
||||
val tokenJsObject = JSObject()
|
||||
tokenJsObject.put("accessToken", newAccessToken)
|
||||
absDatabaseNotifyListeners("onTokenRefresh", tokenJsObject)
|
||||
@@ -379,50 +390,6 @@ class ApiHandler(var ctx:Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the case when token refresh fails
|
||||
* This will clear the current session and notify the callback
|
||||
*
|
||||
* @param callback The callback to return the error
|
||||
*/
|
||||
private fun handleRefreshFailure(callback: (JSObject) -> Unit) {
|
||||
try {
|
||||
Log.d(tag, "handleRefreshFailure: Token refresh failed, clearing session")
|
||||
|
||||
// Clear the current server connection
|
||||
DeviceManager.serverConnectionConfig = null
|
||||
DeviceManager.deviceData.lastServerConnectionConfigId = null
|
||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||
|
||||
// Remove refresh token from secure storage
|
||||
val serverConnectionConfigId = DeviceManager.serverConnectionConfigId
|
||||
if (serverConnectionConfigId.isNotEmpty()) {
|
||||
secureStorage.removeRefreshToken(serverConnectionConfigId)
|
||||
}
|
||||
|
||||
val errorObj = JSObject()
|
||||
errorObj.put("error", "Authentication failed - please login again")
|
||||
callback(errorObj)
|
||||
|
||||
if (checkAbsDatabaseNotifyListenersInitted()) {
|
||||
val tokenJsObject = JSObject()
|
||||
tokenJsObject.put("error", "Token refresh failed")
|
||||
if (serverConnectionConfigId.isNotEmpty()) {
|
||||
tokenJsObject.put("serverConnectionConfigId", serverConnectionConfigId)
|
||||
}
|
||||
absDatabaseNotifyListeners("onTokenRefreshFailure", tokenJsObject)
|
||||
} else {
|
||||
// Can happen if Webview is never run
|
||||
Log.i(tag, "AbsDatabaseNotifyListeners is not initialized so cannot send token refresh failure notification")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "handleRefreshFailure: Error during failure handling", e)
|
||||
val errorObj = JSObject()
|
||||
errorObj.put("error", "Authentication failed")
|
||||
callback(errorObj)
|
||||
}
|
||||
}
|
||||
|
||||
fun getCurrentUser(cb: (User?) -> Unit) {
|
||||
getRequest("/api/me", null, null) {
|
||||
if (it.has("error")) {
|
||||
|
||||
@@ -28,7 +28,7 @@ class DownloadService : Service() {
|
||||
ACTION_CANCEL -> DownloadServiceHost.cancelAll(this)
|
||||
else -> {
|
||||
startForegroundWithType(DownloadServiceHost.notificationStrings(this).preparing)
|
||||
DownloadServiceHost.ensure(this)
|
||||
DownloadServiceHost.startWork(this)
|
||||
}
|
||||
}
|
||||
return START_STICKY
|
||||
|
||||
@@ -5,12 +5,21 @@ import androidx.core.content.ContextCompat
|
||||
import com.audiobookshelf.app.device.FolderScanner
|
||||
import com.audiobookshelf.app.managers.DbManager
|
||||
import com.audiobookshelf.app.managers.DownloadItemManager
|
||||
import com.audiobookshelf.app.managers.IncompleteDownloadCleanup
|
||||
import com.audiobookshelf.app.models.DownloadItem
|
||||
import com.audiobookshelf.app.plugins.AbsLogger
|
||||
import com.getcapacitor.JSObject
|
||||
import java.util.Collections
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Shared process owner used by the foreground service and the Capacitor bridge. */
|
||||
object DownloadServiceHost {
|
||||
enum class ExistingDownloadResult { NOT_FOUND, ACTIVE, RETRIED, SERVICE_START_FAILED }
|
||||
|
||||
data class NotificationStrings(
|
||||
val preparing: String,
|
||||
val downloadingFile: String,
|
||||
@@ -21,9 +30,11 @@ object DownloadServiceHost {
|
||||
|
||||
private var manager: DownloadItemManager? = null
|
||||
private var bridgeEmitter: DownloadItemManager.DownloadEventEmitter = NoopEmitter
|
||||
private var service: DownloadService? = null
|
||||
@Volatile private var service: DownloadService? = null
|
||||
@Volatile private var bridgeReady = false
|
||||
private val deferredCompletions = Collections.synchronizedList(mutableListOf<JSObject>())
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var restoreJob: Job? = null
|
||||
|
||||
@Synchronized
|
||||
fun ensure(context: Context): DownloadItemManager {
|
||||
@@ -31,7 +42,11 @@ object DownloadServiceHost {
|
||||
val appContext = context.applicationContext
|
||||
DbManager.initialize(appContext)
|
||||
manager = DownloadItemManager(FolderScanner(appContext), appContext, ForwardingEmitter)
|
||||
manager!!.restoreQueue()
|
||||
restoreJob = scope.launch {
|
||||
IncompleteDownloadCleanup.cleanupExpired(appContext)
|
||||
manager!!.restoreQueue()
|
||||
onRestoreComplete(appContext)
|
||||
}
|
||||
}
|
||||
return manager!!
|
||||
}
|
||||
@@ -41,14 +56,12 @@ object DownloadServiceHost {
|
||||
fun attachBridge(context: Context, emitter: DownloadItemManager.DownloadEventEmitter) {
|
||||
bridgeReady = false
|
||||
bridgeEmitter = emitter
|
||||
val queue = ensure(context)
|
||||
queue.setEventEmitter(ForwardingEmitter)
|
||||
ensure(context).setEventEmitter(ForwardingEmitter)
|
||||
bridgeReady = true
|
||||
val completions = synchronized(deferredCompletions) {
|
||||
deferredCompletions.toList().also { deferredCompletions.clear() }
|
||||
}
|
||||
completions.forEach(bridgeEmitter::onDownloadItemComplete)
|
||||
if (queue.hasWork()) startService(context)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@@ -57,14 +70,44 @@ object DownloadServiceHost {
|
||||
bridgeEmitter = NoopEmitter
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun enqueue(context: Context, item: DownloadItem) {
|
||||
ensure(context).addDownloadItem(item)
|
||||
startService(context)
|
||||
fun enqueue(context: Context, item: DownloadItem, callback: (String?) -> Unit) {
|
||||
val queue = ensure(context)
|
||||
scope.launch {
|
||||
restoreJob?.join()
|
||||
queue.addDownloadItem(item)
|
||||
if (startService(context)) callback(null)
|
||||
else callback("Unable to start the Android download service")
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun cancelAll(context: Context) { ensure(context).cancelAll() }
|
||||
fun retryExisting(
|
||||
context: Context,
|
||||
downloadItemId: String,
|
||||
callback: (ExistingDownloadResult) -> Unit
|
||||
) {
|
||||
val queue = ensure(context)
|
||||
scope.launch {
|
||||
restoreJob?.join()
|
||||
val existing = queue.downloadItemQueue.find { it.id == downloadItemId }
|
||||
if (existing == null) {
|
||||
callback(ExistingDownloadResult.NOT_FOUND)
|
||||
} else if (!queue.retryDownloadItem(downloadItemId)) {
|
||||
callback(ExistingDownloadResult.ACTIVE)
|
||||
} else if (startService(context)) {
|
||||
callback(ExistingDownloadResult.RETRIED)
|
||||
} else {
|
||||
callback(ExistingDownloadResult.SERVICE_START_FAILED)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelAll(context: Context) {
|
||||
val queue = ensure(context)
|
||||
scope.launch {
|
||||
restoreJob?.join()
|
||||
queue.cancelAll()
|
||||
}
|
||||
}
|
||||
|
||||
fun setNotificationStrings(
|
||||
context: Context,
|
||||
@@ -96,10 +139,9 @@ object DownloadServiceHost {
|
||||
preferences.getString(KEY_CANCEL, DEFAULT_CANCEL) ?: DEFAULT_CANCEL)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun attachService(downloadService: DownloadService) {
|
||||
service = downloadService
|
||||
service?.onQueueChanged(ensure(downloadService).hasWork())
|
||||
synchronized(this) { service = downloadService }
|
||||
startWork(downloadService)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@@ -107,8 +149,31 @@ object DownloadServiceHost {
|
||||
if (service === downloadService) service = null
|
||||
}
|
||||
|
||||
private fun startService(context: Context) {
|
||||
ContextCompat.startForegroundService(context, DownloadService.intent(context))
|
||||
fun startWork(context: Context) {
|
||||
val queue = ensure(context)
|
||||
scope.launch {
|
||||
restoreJob?.join()
|
||||
queue.resumeWork()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onRestoreComplete(context: Context) {
|
||||
val attachedService = synchronized(this) { service }
|
||||
if (attachedService != null) {
|
||||
manager?.resumeWork()
|
||||
} else if (bridgeReady && manager?.hasWork() == true) {
|
||||
startService(context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startService(context: Context): Boolean {
|
||||
return try {
|
||||
ContextCompat.startForegroundService(context, DownloadService.intent(context))
|
||||
true
|
||||
} catch (e: RuntimeException) {
|
||||
AbsLogger.error(TAG, "Could not start download foreground service: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private object ForwardingEmitter : DownloadItemManager.DownloadEventEmitter {
|
||||
@@ -144,4 +209,5 @@ object DownloadServiceHost {
|
||||
private const val DEFAULT_WAITING_FOR_STORAGE = "Waiting for available storage"
|
||||
private const val DEFAULT_DOWNLOADS = "Downloads"
|
||||
private const val DEFAULT_CANCEL = "Cancel"
|
||||
private const val TAG = "DownloadServiceHost"
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
ext {
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 35
|
||||
targetSdkVersion = 35
|
||||
compileSdkVersion = 36
|
||||
targetSdkVersion = 36
|
||||
androidxActivityVersion = '1.9.2'
|
||||
androidxAppCompatVersion = '1.7.0'
|
||||
androidxCoordinatorLayoutVersion = '1.3.0'
|
||||
|
||||
@@ -740,12 +740,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 43;
|
||||
CURRENT_PROJECT_VERSION = 47;
|
||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.13.0;
|
||||
MARKETING_VERSION = 0.14.1;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -764,12 +764,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 43;
|
||||
CURRENT_PROJECT_VERSION = 47;
|
||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.13.0;
|
||||
MARKETING_VERSION = 0.14.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
@@ -12,7 +12,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
// Override point for customization after application launch.
|
||||
|
||||
let configuration = Realm.Configuration(
|
||||
schemaVersion: 20,
|
||||
schemaVersion: 21,
|
||||
migrationBlock: { [weak self] migration, oldSchemaVersion in
|
||||
if (oldSchemaVersion < 1) {
|
||||
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)")
|
||||
@@ -71,6 +71,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
newObject?["version"] = ""
|
||||
}
|
||||
}
|
||||
if (oldSchemaVersion < 21) {
|
||||
// One-time wipe of accumulated logs that were freezing the app on load/clear
|
||||
migration.deleteData(forType: LogEntry.className())
|
||||
}
|
||||
}
|
||||
)
|
||||
Realm.Configuration.defaultConfiguration = configuration
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon-40.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-60.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-58.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-87.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-80.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-120.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-120.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-180.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-20.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-40.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-29.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-58.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-40.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-80.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-76.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-152.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-167.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "83.5x83.5"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-120.png",
|
||||
"idiom" : "car",
|
||||
"scale" : "2x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-180.png",
|
||||
"idiom" : "car",
|
||||
"scale" : "3x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-1024.png",
|
||||
"idiom" : "ios-marketing",
|
||||
"scale" : "1x",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-16.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-32.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-32.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-64.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-128.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-256.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-256.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-512.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-512.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-1024.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-48.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "notificationCenter",
|
||||
"scale" : "2x",
|
||||
"size" : "24x24",
|
||||
"subtype" : "38mm"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-55.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "notificationCenter",
|
||||
"scale" : "2x",
|
||||
"size" : "27.5x27.5",
|
||||
"subtype" : "42mm"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-58.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "companionSettings",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-87.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "companionSettings",
|
||||
"scale" : "3x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"role" : "notificationCenter",
|
||||
"scale" : "2x",
|
||||
"size" : "33x33",
|
||||
"subtype" : "45mm"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-80.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "appLauncher",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40",
|
||||
"subtype" : "38mm"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-88.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "appLauncher",
|
||||
"scale" : "2x",
|
||||
"size" : "44x44",
|
||||
"subtype" : "40mm"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"role" : "appLauncher",
|
||||
"scale" : "2x",
|
||||
"size" : "46x46",
|
||||
"subtype" : "41mm"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-100.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "appLauncher",
|
||||
"scale" : "2x",
|
||||
"size" : "50x50",
|
||||
"subtype" : "44mm"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"role" : "appLauncher",
|
||||
"scale" : "2x",
|
||||
"size" : "51x51",
|
||||
"subtype" : "45mm"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"role" : "appLauncher",
|
||||
"scale" : "2x",
|
||||
"size" : "54x54",
|
||||
"subtype" : "49mm"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-172.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "quickLook",
|
||||
"scale" : "2x",
|
||||
"size" : "86x86",
|
||||
"subtype" : "38mm"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-196.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "quickLook",
|
||||
"scale" : "2x",
|
||||
"size" : "98x98",
|
||||
"subtype" : "42mm"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-216.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "quickLook",
|
||||
"scale" : "2x",
|
||||
"size" : "108x108",
|
||||
"subtype" : "44mm"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"role" : "quickLook",
|
||||
"scale" : "2x",
|
||||
"size" : "117x117",
|
||||
"subtype" : "45mm"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"role" : "quickLook",
|
||||
"scale" : "2x",
|
||||
"size" : "129x129",
|
||||
"subtype" : "49mm"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-1024.png",
|
||||
"idiom" : "watch-marketing",
|
||||
"scale" : "1x",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 399 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 672 B |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 845 B |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
@@ -78,6 +78,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
||||
liveDownloadItemPart.progress = 100
|
||||
liveDownloadItemPart.completed = true
|
||||
}
|
||||
AbsLogger.info(message: "Download completed for \(liveDownloadItemPart.filename ?? partId)")
|
||||
|
||||
do {
|
||||
// Move the downloaded file into place
|
||||
@@ -89,6 +90,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
||||
try realm.write {
|
||||
liveDownloadItemPart.moved = true
|
||||
}
|
||||
AbsLogger.info(message: "Move completed for \(liveDownloadItemPart.filename ?? partId) to \(destinationUrl.path)")
|
||||
} catch {
|
||||
try realm.write {
|
||||
liveDownloadItemPart.failed = true
|
||||
|
||||
@@ -21,7 +21,7 @@ PODS:
|
||||
- Capacitor
|
||||
- CapacitorPreferences (7.0.1):
|
||||
- Capacitor
|
||||
- CapacitorStatusBar (7.0.1):
|
||||
- CapacitorStatusBar (7.0.6):
|
||||
- Capacitor
|
||||
- CordovaPlugins (6.2.1):
|
||||
- CapacitorCordova
|
||||
@@ -100,7 +100,7 @@ SPEC CHECKSUMS:
|
||||
CapacitorHaptics: 1f1e17041f435d8ead9ff2a34edd592c6aa6a8d6
|
||||
CapacitorNetwork: 15cb4385f0913a8ceb5e9a4d7af1ec554bdb8de8
|
||||
CapacitorPreferences: 6c98117d4d7508034a4af9db64d6b26fc75d7b94
|
||||
CapacitorStatusBar: 6e7af040d8fc4dd655999819625cae9c2d74c36f
|
||||
CapacitorStatusBar: 416e9e53fd6397e668d4a181cd2131617d949bd6
|
||||
CordovaPlugins: 2ecbba09775516c41764dbf78ade612427311b7e
|
||||
Realm: b1b3bc68162fa242132eb7eefbf91d7c40f36a85
|
||||
RealmSwift: 456cfd82a4f23dff8e3456980999331ab69bbf3e
|
||||
|
||||
@@ -183,6 +183,7 @@ class ApiClient {
|
||||
]
|
||||
|
||||
let refreshRequest = AF.request("\(serverConfig.address)/auth/refresh", method: .post, headers: refreshHeaders)
|
||||
.validate(statusCode: 200..<300)
|
||||
|
||||
refreshRequest.responseDecodable(of: RefreshResponse.self) { response in
|
||||
switch response.result {
|
||||
@@ -190,7 +191,6 @@ class ApiClient {
|
||||
guard let user = refreshResponse.user,
|
||||
!user.accessToken.isEmpty else {
|
||||
AbsLogger.error(message: "handleTokenRefresh: No access token in refresh response for server \(serverConfig.name)")
|
||||
handleRefreshFailure()
|
||||
callback?(nil)
|
||||
return
|
||||
}
|
||||
@@ -206,7 +206,8 @@ class ApiClient {
|
||||
|
||||
case .failure(let error):
|
||||
AbsLogger.error(message: "handleTokenRefresh: Refresh request failed for server \(serverConfig.name): \(error)")
|
||||
handleRefreshFailure()
|
||||
let status = response.response?.statusCode
|
||||
if status == 401 || status == 403 { handleRefreshFailure() }
|
||||
callback?(nil)
|
||||
}
|
||||
}
|
||||
@@ -313,12 +314,10 @@ class ApiClient {
|
||||
private static func handleRefreshFailure() {
|
||||
AbsLogger.info(message: "handleRefreshFailure: Token refresh failed, clearing session")
|
||||
|
||||
// Clear the current server connection
|
||||
let configId = Store.serverConfig?.id
|
||||
Store.serverConfig = nil
|
||||
|
||||
// Remove refresh token from secure storage
|
||||
if let serverConfig = Store.serverConfig {
|
||||
_ = secureStorage.removeRefreshToken(serverConnectionConfigId: serverConfig.id)
|
||||
if let configId = configId {
|
||||
_ = secureStorage.removeRefreshToken(serverConnectionConfigId: configId)
|
||||
}
|
||||
|
||||
// Notify webview frontend about token refresh failure
|
||||
|
||||
@@ -20,12 +20,12 @@ class Database {
|
||||
debugPrint(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public func setServerConnectionConfig(config: ServerConnectionConfig) {
|
||||
let config = config
|
||||
let realm = try! Realm()
|
||||
let existing: ServerConnectionConfig? = realm.object(ofType: ServerConnectionConfig.self, forPrimaryKey: config.id)
|
||||
|
||||
|
||||
if let existing = existing {
|
||||
do {
|
||||
try existing.update {
|
||||
@@ -40,19 +40,19 @@ class Database {
|
||||
AbsLogger.error("setServerConn", message: "failed to update server config")
|
||||
debugPrint(error)
|
||||
}
|
||||
|
||||
|
||||
setLastActiveConfigIndex(index: existing.index)
|
||||
} else {
|
||||
if config.index == 0 {
|
||||
let lastConfig: ServerConnectionConfig? = realm.objects(ServerConnectionConfig.self).last
|
||||
|
||||
|
||||
if lastConfig != nil {
|
||||
config.index = lastConfig!.index + 1
|
||||
} else {
|
||||
config.index = 1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
do {
|
||||
try realm.write {
|
||||
realm.add(config)
|
||||
@@ -61,7 +61,7 @@ class Database {
|
||||
AbsLogger.error(message: "failed to save server config")
|
||||
debugPrint(exception)
|
||||
}
|
||||
|
||||
|
||||
setLastActiveConfigIndex(index: config.index)
|
||||
}
|
||||
}
|
||||
@@ -78,11 +78,11 @@ class Database {
|
||||
debugPrint("Failed to update server connection config token: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public func deleteServerConnectionConfig(id: String) {
|
||||
let realm = try! Realm()
|
||||
let config = realm.object(ofType: ServerConnectionConfig.self, forPrimaryKey: id)
|
||||
|
||||
|
||||
do {
|
||||
try realm.write {
|
||||
if config != nil {
|
||||
@@ -94,27 +94,27 @@ class Database {
|
||||
debugPrint(exception)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public func getServerConnectionConfigs() -> [ServerConnectionConfig] {
|
||||
let realm = try! Realm()
|
||||
return Array(realm.objects(ServerConnectionConfig.self))
|
||||
}
|
||||
|
||||
|
||||
public func setLastActiveConfigIndexToNil() {
|
||||
setLastActiveConfigIndex(index: nil)
|
||||
}
|
||||
|
||||
|
||||
private func setLastActiveConfigIndex(index: Int?) {
|
||||
let realm = try! Realm()
|
||||
do {
|
||||
try realm.write {
|
||||
let existing = realm.objects(ServerConnectionConfigActiveIndex.self).last
|
||||
|
||||
|
||||
if ( existing?.index != index ) {
|
||||
if let existing = existing {
|
||||
realm.delete(existing)
|
||||
}
|
||||
|
||||
|
||||
let activeConfig = ServerConnectionConfigActiveIndex()
|
||||
activeConfig.index = index
|
||||
realm.add(activeConfig)
|
||||
@@ -125,12 +125,12 @@ class Database {
|
||||
debugPrint(exception)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public func getLastActiveConfigIndex() -> Int? {
|
||||
let realm = try! Realm()
|
||||
return realm.objects(ServerConnectionConfigActiveIndex.self).first?.index ?? nil
|
||||
}
|
||||
|
||||
|
||||
public func setDeviceSettings(deviceSettings: DeviceSettings) {
|
||||
let realm = try! Realm()
|
||||
let existing = realm.objects(DeviceSettings.self)
|
||||
@@ -144,7 +144,7 @@ class Database {
|
||||
AbsLogger.error(message: "failed to save device settings")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public func getLocalLibraryItems(mediaType: MediaType? = nil) -> [LocalLibraryItem] {
|
||||
do {
|
||||
let realm = try Realm()
|
||||
@@ -154,7 +154,7 @@ class Database {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public func getLocalLibraryItem(byServerLibraryItemId: String) -> LocalLibraryItem? {
|
||||
do {
|
||||
let realm = try Realm()
|
||||
@@ -164,7 +164,7 @@ class Database {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public func getLocalLibraryItem(localLibraryItemId: String) -> LocalLibraryItem? {
|
||||
do {
|
||||
let realm = try Realm()
|
||||
@@ -174,12 +174,12 @@ class Database {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public func saveLocalLibraryItem(localLibraryItem: LocalLibraryItem) throws {
|
||||
let realm = try Realm()
|
||||
try realm.write { realm.add(localLibraryItem, update: .modified) }
|
||||
}
|
||||
|
||||
|
||||
public func getLocalFile(localFileId: String) -> LocalFile? {
|
||||
do {
|
||||
let realm = try Realm()
|
||||
@@ -189,7 +189,7 @@ class Database {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public func getDownloadItem(downloadItemId: String) -> DownloadItem? {
|
||||
do {
|
||||
let realm = try Realm()
|
||||
@@ -199,7 +199,7 @@ class Database {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public func getDownloadItem(libraryItemId: String) -> DownloadItem? {
|
||||
do {
|
||||
let realm = try Realm()
|
||||
@@ -209,7 +209,7 @@ class Database {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public func getDownloadItem(downloadItemPartId: String) -> DownloadItem? {
|
||||
do {
|
||||
let realm = try Realm()
|
||||
@@ -219,17 +219,17 @@ class Database {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public func saveDownloadItem(_ downloadItem: DownloadItem) throws {
|
||||
let realm = try Realm()
|
||||
return try realm.write { realm.add(downloadItem, update: .modified) }
|
||||
}
|
||||
|
||||
|
||||
public func getDeviceSettings() -> DeviceSettings {
|
||||
let realm = try! Realm()
|
||||
return realm.objects(DeviceSettings.self).first ?? getDefaultDeviceSettings()
|
||||
}
|
||||
|
||||
|
||||
public func getAllLocalMediaProgress() -> [LocalMediaProgress] {
|
||||
do {
|
||||
let realm = try Realm()
|
||||
@@ -239,7 +239,7 @@ class Database {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// For books this will just be the localLibraryItemId for podcast episodes this will be "{localLibraryItemId}-{episodeId}"
|
||||
public func getLocalMediaProgress(localMediaProgressId: String) -> LocalMediaProgress? {
|
||||
do {
|
||||
@@ -250,7 +250,7 @@ class Database {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public func removeLocalMediaProgress(localMediaProgressId: String) throws {
|
||||
let realm = try Realm()
|
||||
try realm.write {
|
||||
@@ -258,7 +258,7 @@ class Database {
|
||||
realm.delete(progress!)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public func getAllPlaybackSessions() -> [PlaybackSession] {
|
||||
do {
|
||||
let realm = try Realm()
|
||||
@@ -268,7 +268,7 @@ class Database {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public func getPlaybackSession(id: String) -> PlaybackSession? {
|
||||
do {
|
||||
let realm = try Realm()
|
||||
@@ -279,12 +279,12 @@ class Database {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public func saveLog(_ log: LogEntry) throws {
|
||||
let realm = try Realm()
|
||||
return try realm.write { realm.add(log) }
|
||||
}
|
||||
|
||||
|
||||
public func getAllLogs() -> [LogEntry] {
|
||||
do {
|
||||
let realm = try Realm()
|
||||
@@ -294,7 +294,7 @@ class Database {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public func clearLogs() throws {
|
||||
do {
|
||||
let realm = try! Realm()
|
||||
@@ -308,25 +308,13 @@ class Database {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func cleanExpiredLogs() throws {
|
||||
let realm = try Realm()
|
||||
let numberOfHoursToKeep = 48
|
||||
let keepLogCutoff = Date().addingTimeInterval(TimeInterval(-1 * numberOfHoursToKeep * 3600))
|
||||
|
||||
let allLogs = getAllLogs()
|
||||
var logsRemoved = 0
|
||||
try? realm.write {
|
||||
allLogs.forEach { log in
|
||||
if log.timestamp < Int(keepLogCutoff.timeIntervalSince1970) {
|
||||
realm.delete(log)
|
||||
logsRemoved += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if logsRemoved > 0 {
|
||||
AbsLogger.info(message: "cleanLogs: Removed \(logsRemoved) logs older than \(numberOfHoursToKeep) hours")
|
||||
let keepLogCutoffMs = Int(Date().addingTimeInterval(TimeInterval(-1 * numberOfHoursToKeep * 3600)).timeIntervalSince1970 * 1000)
|
||||
try realm.write {
|
||||
realm.delete(realm.objects(LogEntry.self).filter("timestamp < %@", keepLogCutoffMs))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf-app",
|
||||
"version": "0.14.0-beta",
|
||||
"version": "0.14.1-beta",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-app",
|
||||
"version": "0.14.0-beta",
|
||||
"version": "0.14.1-beta",
|
||||
"dependencies": {
|
||||
"@capacitor-community/keep-awake": "^7.0.0",
|
||||
"@capacitor-community/volume-buttons": "^7.0.0",
|
||||
@@ -20,7 +20,7 @@
|
||||
"@capacitor/ios": "^7.0.0",
|
||||
"@capacitor/network": "^7.0.0",
|
||||
"@capacitor/preferences": "^7.0.0",
|
||||
"@capacitor/status-bar": "^7.0.0",
|
||||
"@capacitor/status-bar": "^7.0.6",
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
"@teckel/vue-pdf": "^4.3.5",
|
||||
"@webnativellc/capacitor-filesharer": "7.0.4",
|
||||
@@ -1883,9 +1883,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/status-bar": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-7.0.1.tgz",
|
||||
"integrity": "sha512-iDv3mXYo9CdxYRVwt3/pRyuk25p7Sn4GfaS/zMZyVIqTzsvKLCIIH3GdKK+ta+nsNcAVpCw/t5jFEBt1D18ctA==",
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-7.0.6.tgz",
|
||||
"integrity": "sha512-7AVqj46b26QikImQzWfsJmzG8NUJZBGkrVSuoeTo+SX/YH3hXH0MqwwFgMqMGHfa/BICDgSvTjM9miVFlq0+RQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=7.0.0"
|
||||
@@ -5102,6 +5102,16 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bluebird": {
|
||||
"version": "3.7.2",
|
||||
"license": "MIT"
|
||||
@@ -7582,6 +7592,13 @@
|
||||
"version": "2.0.5",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"license": "MIT",
|
||||
@@ -7813,6 +7830,20 @@
|
||||
"version": "1.0.0",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"license": "MIT",
|
||||
@@ -9992,6 +10023,13 @@
|
||||
"thenify-all": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.28.0",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz",
|
||||
"integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"funding": [
|
||||
@@ -15859,6 +15897,25 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/watchpack-chokidar2/node_modules/fsevents": {
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
|
||||
"integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
|
||||
"deprecated": "Upgrade to fsevents v2 to mitigate potential security issues",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"nan": "^2.12.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/watchpack-chokidar2/node_modules/glob-parent": {
|
||||
"version": "3.1.0",
|
||||
"license": "ISC",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-app",
|
||||
"version": "0.14.0-beta",
|
||||
"version": "0.14.1-beta",
|
||||
"author": "advplyr",
|
||||
"scripts": {
|
||||
"dev": "nuxt --hostname 0.0.0.0 --port 1337",
|
||||
@@ -27,7 +27,7 @@
|
||||
"@capacitor/ios": "^7.0.0",
|
||||
"@capacitor/network": "^7.0.0",
|
||||
"@capacitor/preferences": "^7.0.0",
|
||||
"@capacitor/status-bar": "^7.0.0",
|
||||
"@capacitor/status-bar": "^7.0.6",
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
"@teckel/vue-pdf": "^4.3.5",
|
||||
"buffer": "^6.0.3",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
<ui-btn color="primary flex items-center justify-between gap-2 ml-auto text-base mt-8" @click="logout">{{ $strings.ButtonSwitchServerUser }}<span class="material-symbols" style="font-size: 1.1rem">logout</span></ui-btn>
|
||||
|
||||
<div class="flex justify-center items-center my-4 left-0 right-0 bottom-0 absolute">
|
||||
<div class="flex justify-center items-center my-4 left-0 right-0 bottom-0 absolute p-4">
|
||||
<p class="text-sm text-fg">{{ $strings.MessageReportBugsAndContribute }} <a class="underline" href="https://github.com/advplyr/audiobookshelf-app" target="_blank">GitHub</a></p>
|
||||
<a href="https://github.com/advplyr/audiobookshelf-app" target="_blank" class="text-fg hover:scale-150 hover:rotate-6 transform duration-500 ml-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" width="24" height="24" viewBox="0 0 24 24">
|
||||
|
||||
@@ -135,8 +135,20 @@ export default {
|
||||
this.$eventBus.$emit('play-item', { libraryItemId: nextBookNotRead.id })
|
||||
}
|
||||
}
|
||||
},
|
||||
libraryChanged(libraryId) {
|
||||
// A collection belongs to a single library, so leave this page when a different library is
|
||||
// selected rather than showing a collection that is not in the current library
|
||||
if (!libraryId || libraryId !== this.collection.libraryId) {
|
||||
this.$router.replace('/bookshelf/collections')
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {
|
||||
this.$eventBus.$on('library-changed', this.libraryChanged)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off('library-changed', this.libraryChanged)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
<div v-if="!downloadItemParts.length" class="py-6 text-center text-lg">No download item parts</div>
|
||||
<template v-for="(itemPart, num) in downloadItemParts">
|
||||
<div :key="itemPart.id" class="w-full">
|
||||
<div :key="`${itemPart.downloadItemId}-${itemPart.id}`" class="w-full">
|
||||
<div class="flex">
|
||||
<div class="w-14">
|
||||
<span v-if="itemPart.completed" class="material-symbols text-success">check_circle</span>
|
||||
@@ -40,4 +40,3 @@ export default {
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -162,13 +162,20 @@ export default {
|
||||
if (this.playlist.id === playlist.id) {
|
||||
this.$router.replace('/bookshelf/playlists')
|
||||
}
|
||||
},
|
||||
libraryChanged() {
|
||||
// Playlist contents are shown in the context of the selected library, so leave this page
|
||||
// when the library changes rather than showing stale items
|
||||
this.$router.replace('/bookshelf/playlists')
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$eventBus.$on('library-changed', this.libraryChanged)
|
||||
this.$socket.$on('playlist_updated', this.playlistUpdated)
|
||||
this.$socket.$on('playlist_removed', this.playlistRemoved)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off('library-changed', this.libraryChanged)
|
||||
this.$socket.$off('playlist_updated', this.playlistUpdated)
|
||||
this.$socket.$off('playlist_removed', this.playlistRemoved)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"ButtonAdd": "Дадаць",
|
||||
"ButtonAddNewServer": "Дадаць новы сервер",
|
||||
"ButtonAllow": "Дазволіць",
|
||||
"ButtonAuthors": "Аўтары",
|
||||
"ButtonBack": "Назад",
|
||||
"ButtonCancel": "Скасаваць",
|
||||
@@ -312,6 +313,7 @@
|
||||
"MessageDownloadCompleteProcessing": "Спампоўванне завершана. Апрацоўка...",
|
||||
"MessageDownloading": "Спампоўванне...",
|
||||
"MessageDownloadingEpisode": "Спампоўванне выпуску",
|
||||
"MessageDownloadingFile": "Спампоўванне {0}",
|
||||
"MessageEpisodesQueuedForDownload": "{0} выпуск(-аў) у чарзе спампоўвання",
|
||||
"MessageFailedToRefreshToken": "Не ўдалося абнавіць токен, патрабуецца паўторны ўваход",
|
||||
"MessageFeedURLWillBe": "URL стужкі будзе {0}",
|
||||
@@ -347,6 +349,7 @@
|
||||
"MessageOldServerConnectionWarning": "Канфігурацыя падлучэння да сервера выкарыстоўвае стары ідэнтыфікатар карыстальніка. Калі ласка, выдаліце і зноў дадайце гэта падлучэнне да сервера.",
|
||||
"MessageOldServerConnectionWarningHelp": "Вы першапачаткова наладзілі падключэнне да гэтага сервера да міграцыі базы даных у версіі 2.3.0, якая выйшла ў чэрвені 2023 года. У будучым абнаўленні сервера магчымасць уваходу праз гэтае старое падключэнне будзе выдалена. Калі ласка, выдаліце існуючае падключэнне да сервера і падключыцеся зноў (выкарыстоўваючы той жа адрас сервера і ўліковыя даныя). Калі на гэтай прыладзе ёсць спампаваныя медыяфайлы, іх трэба будзе спампаваць зноў для сінхранізацыі з серверам.",
|
||||
"MessagePodcastSearchField": "Увядзіце пошукавы запыт або URL RSS-стужкі",
|
||||
"MessagePreparingDownloads": "Падрыхтоўка спампоўвання",
|
||||
"MessageProgressSyncFailed": "Апошняя спроба адправіць ваш прагрэс праслухоўвання на сервер не ўдалася. Спробы сінхранізаваць прагрэс будуць працягвацца кожныя 15 секунд на працягу 1 хвіліны падчас прайгравання медыяфайла.",
|
||||
"MessageReportBugsAndContribute": "Паведамляйце пра памылкі, прапануйце функцыі і ўносьце свой уклад на",
|
||||
"MessageSeriesAlreadyDownloaded": "Вы ўжо спампавалі ўсе кнігі з гэтай серыі.",
|
||||
@@ -357,6 +360,10 @@
|
||||
"MessageSocketConnectedOverUnmeteredCellular": "Сокет падлучаны праз мабільны інтэрнэт без абмежавання трафіку",
|
||||
"MessageSocketConnectedOverUnmeteredWifi": "Сокет падлучаны праз Wi-Fi без абмежавання трафіку",
|
||||
"MessageSocketNotConnected": "Сокет не падлучаны",
|
||||
"MessageStorageAccessDenied": "Доступ забаронены",
|
||||
"MessageStoragePermissionDenied": "Няма дазволу",
|
||||
"MessageStorageWriteAccessRequired": "У вас няма правоў на запіс у гэту папку. Хочаце даць доступ?",
|
||||
"MessageWaitingForAvailableStorage": "Чаканне даступнага сховішча",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Папярэджанне: большасць праграм для падкастаў патрабуюць, каб URL RSS-стужкі выкарыстоўваў HTTPS",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Папярэджанне: адзін ці больш выпускаў не маюць даты публікацыі. Некаторыя праграмы для падкастаў патрабуюць гэтага.",
|
||||
"ToastBookmarkCreateFailed": "Не ўдалося стварыць закладку",
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"ButtonAdd": "যোগ করুন",
|
||||
"ButtonAddNewServer": "নতুন সার্ভার যোগ করুন",
|
||||
"ButtonAllow": "অনুমতি দিন",
|
||||
"ButtonAuthors": "লেখকগণ",
|
||||
"ButtonBack": "পেছনে যান",
|
||||
"ButtonCancel": "বাতিল করুন",
|
||||
"ButtonCancelTimer": "টাইমার বাতিল করুন",
|
||||
"ButtonClearFilter": "ফিল্টার পরিষ্কার করুন",
|
||||
"ButtonClearLogs": "লগ মুছে ফেলুন",
|
||||
"ButtonCloseFeed": "ফিড বন্ধ করুন",
|
||||
"ButtonCollections": "সংগ্রহ",
|
||||
"ButtonConnect": "সংযোগ করুন",
|
||||
@@ -26,9 +28,12 @@
|
||||
"ButtonLatest": "সর্বশেষ",
|
||||
"ButtonLibrary": "লাইব্রেরি",
|
||||
"ButtonLocalMedia": "স্থানীয় মিডিয়া",
|
||||
"ButtonLogs": "লগসমূহ",
|
||||
"ButtonManageLocalFiles": "স্থানীয় ফাইলগুলো পরিচালনা করুন",
|
||||
"ButtonMaskServerAddress": "সার্ভারের ঠিকানা আড়াল করুন",
|
||||
"ButtonNewFolder": "নতুন ফোল্ডার",
|
||||
"ButtonNextEpisode": "পরবর্তী পর্ব",
|
||||
"ButtonOk": "ঠিক আছে",
|
||||
"ButtonOpenFeed": "ফিড খুলুন",
|
||||
"ButtonOverride": "ওভাররাইড",
|
||||
"ButtonPause": "বিরতি",
|
||||
@@ -49,14 +54,17 @@
|
||||
"ButtonStream": "স্ট্রীম",
|
||||
"ButtonSubmit": "জমা দিন",
|
||||
"ButtonSwitchServerUser": "সার্ভার/ব্যবহারকারী পরিবর্তন করুন",
|
||||
"ButtonUnmaskServerAddress": "সার্ভারের ঠিকানা প্রকাশ করুন",
|
||||
"ButtonUserStats": "ব্যবহারকারীর পরিসংখ্যান",
|
||||
"ButtonYes": "হ্যাঁ",
|
||||
"HeaderAccount": "অ্যাকাউন্ট",
|
||||
"HeaderAdvanced": "অ্যাডভান্সড",
|
||||
"HeaderAndroidAutoSettings": "অ্যান্ড্রয়েড অটো সেটিংস",
|
||||
"HeaderAudioTracks": "অডিও ট্র্যাকসগুলো",
|
||||
"HeaderChapters": "অধ্যায়",
|
||||
"HeaderCollection": "সংগ্রহ",
|
||||
"HeaderCollectionItems": "সংগ্রহ আইটেম",
|
||||
"HeaderConfirm": "নিশ্চিত করুন",
|
||||
"HeaderConnectionStatus": "সংযোগের অবস্থা",
|
||||
"HeaderDataSettings": "ডেটা সেটিংস",
|
||||
"HeaderDetails": "বিস্তারিত",
|
||||
@@ -73,6 +81,7 @@
|
||||
"HeaderPlaybackSettings": "প্লেব্যাক সেটিংস",
|
||||
"HeaderPlaylist": "প্লেলিস্ট",
|
||||
"HeaderPlaylistItems": "প্লেলিস্ট আইটেম",
|
||||
"HeaderProgressSyncFailed": "অগ্রগতি সিঙ্ক ব্যর্থ হয়েছে",
|
||||
"HeaderRSSFeed": "আরএসএস ফিডগুলো",
|
||||
"HeaderRSSFeedGeneral": "আরএসএস বিবরণ",
|
||||
"HeaderRSSFeedIsOpen": "আরএসএস ফিড খোলা আছে",
|
||||
@@ -84,6 +93,7 @@
|
||||
"HeaderStatsRecentSessions": "সাম্প্রতিক সেশন",
|
||||
"HeaderTableOfContents": "বিষয়বস্তুর সারণী",
|
||||
"HeaderUserInterfaceSettings": "ব্যবহারকারী ইন্টারফেস সেটিংস",
|
||||
"HeaderWelcome": "স্বাগতম, <strong>{0}</strong>",
|
||||
"HeaderYourStats": "আপনার পরিসংখ্যান",
|
||||
"LabelAddToPlaylist": "প্লেলিস্টে যোগ করুন",
|
||||
"LabelAddedAt": "এতে যোগ করা হয়েছে",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"ButtonAdd": "Přidat",
|
||||
"ButtonAddNewServer": "Přidat nový server",
|
||||
"ButtonAllow": "Povolit",
|
||||
"ButtonAuthors": "Autoři",
|
||||
"ButtonBack": "Zpět",
|
||||
"ButtonCancel": "Zrušit",
|
||||
@@ -295,8 +296,8 @@
|
||||
"MessageAudiobookshelfServerNotConnected": "Server Audiobookshelf není připojen",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>Důležité!</strong> Tato aplikace je navržena, aby pracovala se serverem Audiobookshelf, který hostuje buď vy, nebo někdo, koho znáte. Tato aplikace neposkytuje žádný obsah.",
|
||||
"MessageBookshelfEmpty": "Knihovna je prázdná",
|
||||
"MessageConfirmAppExit": "Opravdu chcete opustit aplikaci?",
|
||||
"MessageConfirmDeleteEpisodeDownloadQueue": "Přejete si skutečně vymazat frontu stahování epizod?",
|
||||
"MessageConfirmAppExit": "Opravdu chcete ukončit aplikaci?",
|
||||
"MessageConfirmDeleteEpisodeDownloadQueue": "Opravdu chcete vymazat frontu stahování epizod?",
|
||||
"MessageConfirmDeleteLocalEpisode": "Odebrat místní epizodu „{0}“ ze zařízení? Soubor na serveru zůstane nezměněný.",
|
||||
"MessageConfirmDeleteLocalFiles": "Odebrat místní soubory této položky ze zařízení? Soubory na serveru a váš pokrok nebudou ovlivněny.",
|
||||
"MessageConfirmDeleteServerConfig": "Odebrat tuto konfiguraci serveru?",
|
||||
@@ -312,6 +313,7 @@
|
||||
"MessageDownloadCompleteProcessing": "Stahování dokončeno. Zpracovává se...",
|
||||
"MessageDownloading": "Stahuje se...",
|
||||
"MessageDownloadingEpisode": "Stahuji epizodu",
|
||||
"MessageDownloadingFile": "Stahování {0}",
|
||||
"MessageEpisodesQueuedForDownload": "{0} Epizody zařazené do fronty ke stažení",
|
||||
"MessageFailedToRefreshToken": "Nepovedlo se obnovit token, je nutné se znovu přihlásit",
|
||||
"MessageFeedURLWillBe": "URL zdroje bude {0}",
|
||||
@@ -347,6 +349,7 @@
|
||||
"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",
|
||||
"MessagePreparingDownloads": "Příprava stahování",
|
||||
"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.",
|
||||
@@ -357,6 +360,10 @@
|
||||
"MessageSocketConnectedOverUnmeteredCellular": "Socket připojen přes neměřenou mobilní síť",
|
||||
"MessageSocketConnectedOverUnmeteredWifi": "Socket připojen přes neměřenou Wi-Fi",
|
||||
"MessageSocketNotConnected": "Socket není připojen",
|
||||
"MessageStorageAccessDenied": "Přístup zamítnut",
|
||||
"MessageStoragePermissionDenied": "Oprávnění neuděleno",
|
||||
"MessageStorageWriteAccessRequired": "Nemáte přístup k zápisu do této složky. Chcete udělit přístup?",
|
||||
"MessageWaitingForAvailableStorage": "Čekám na dostupnost úložiště",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Upozornění: Většina aplikací pro podcasty bude vyžadovat, aby adresa URL kanálu RSS používala protokol HTTPS",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Upozornění: 1 nebo více epizod nemá datum vydání. Některé podcastové aplikace to vyžadují.",
|
||||
"ToastBookmarkCreateFailed": "Vytvoření záložky se nezdařilo",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"ButtonAdd": "Hinzufügen",
|
||||
"ButtonAddNewServer": "Neuen Server hinzufügen",
|
||||
"ButtonAllow": "Erlauben",
|
||||
"ButtonAuthors": "Autoren",
|
||||
"ButtonBack": "Zurück",
|
||||
"ButtonCancel": "Abbrechen",
|
||||
@@ -70,7 +71,7 @@
|
||||
"HeaderDownloads": "Downloads",
|
||||
"HeaderEbookFiles": "E-Book-Dateien",
|
||||
"HeaderEpisodes": "Episoden",
|
||||
"HeaderEreaderSettings": "E-Reader-Einstellungen",
|
||||
"HeaderEreaderSettings": "E-Reader Einstellungen",
|
||||
"HeaderLatestEpisodes": "Neueste Episoden",
|
||||
"HeaderLibraries": "Bibliotheken",
|
||||
"HeaderLocalFolders": "Lokale Ordner",
|
||||
@@ -312,6 +313,7 @@
|
||||
"MessageDownloadCompleteProcessing": "Download abgeschlossen. Wird verarbeitet …",
|
||||
"MessageDownloading": "Wird heruntergeladen …",
|
||||
"MessageDownloadingEpisode": "Episode herunterladen",
|
||||
"MessageDownloadingFile": "Datei wird heruntergeladen {0}",
|
||||
"MessageEpisodesQueuedForDownload": "{0} Episode(n) in der Warteschlange zum Herunterladen",
|
||||
"MessageFailedToRefreshToken": "Aktualisierung des Tokens fehlgeschlagen, erneute Anmeldung erforderlich",
|
||||
"MessageFeedURLWillBe": "Feed-URL wird {0} sein",
|
||||
@@ -347,6 +349,7 @@
|
||||
"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",
|
||||
"MessagePreparingDownloads": "Downloads werden vorbereitet",
|
||||
"MessageProgressSyncFailed": "Der letzte Versuch den aktuellen Hörfortschritt an den Server zu melden ist fehlgeschlagen. Die Anfragen den Fortschritt zu synchronisieren wird alle 15 Sekunden bis 1 Minute versucht, während Medien abgespielt werden.",
|
||||
"MessageReportBugsAndContribute": "Fehler melden, Funktionen anfordern und mitwirken",
|
||||
"MessageSeriesAlreadyDownloaded": "Du hast bereits alle Bücher in dieser Serie heruntergeladen.",
|
||||
@@ -357,6 +360,10 @@
|
||||
"MessageSocketConnectedOverUnmeteredCellular": "Socket verbunden über ungetaktetes Mobilfunknetz",
|
||||
"MessageSocketConnectedOverUnmeteredWifi": "Socket verbunden über ungetaktetes Mobilfunknetz",
|
||||
"MessageSocketNotConnected": "Socket nicht verbunden",
|
||||
"MessageStorageAccessDenied": "Zugang verweigert",
|
||||
"MessageStoragePermissionDenied": "Erlaubnis verweigert",
|
||||
"MessageStorageWriteAccessRequired": "Du hast keine Schreibrechte für diesen Ordner. Möchtest du Zugriff gewähren?",
|
||||
"MessageWaitingForAvailableStorage": "Warten auf freien Speicherplatz",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Warnung: Die meisten Podcast-Apps verlangen, dass die URL des RSS-Feeds HTTPS verwendet",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Warnung: 1 oder mehrere deiner Episoden haben kein Veröffentlichungsdatum. Einige Podcast-Apps verlangen dies.",
|
||||
"ToastBookmarkCreateFailed": "Lesezeichen konnte nicht erstellt werden",
|
||||
@@ -371,6 +378,6 @@
|
||||
"ToastRSSFeedCloseFailed": "RSS-Feed konnte nicht geschlossen werden",
|
||||
"ToastRSSFeedCloseSuccess": "RSS-Feed geschlossen",
|
||||
"ToastStreamingNotAllowedOnCellular": "Das Streamen über mobile Daten ist nicht erlaubt",
|
||||
"UnitMinutesShort": "{0}m",
|
||||
"UnitSecondsShort": "{0}s"
|
||||
"UnitMinutesShort": "{0} min",
|
||||
"UnitSecondsShort": "{0} s"
|
||||
}
|
||||
@@ -218,6 +218,8 @@
|
||||
"LabelPlaybackDirect": "Άμεση αναπαραγωγή",
|
||||
"LabelPlaybackLocal": "Αναπαραγωγή τοπικού αρχείου",
|
||||
"LabelPlaybackSpeed": "Ταχύτητα Αναπαραγωγής",
|
||||
"LabelPodcast": "Πόντκαστ",
|
||||
"LabelPodcasts": "Πόντκαστ",
|
||||
"LabelProgress": "Πρόοδος",
|
||||
"LabelPubDate": "Ημερομηνία έκδοσης",
|
||||
"LabelPublishYear": "Χρονολογία Έκδοσης",
|
||||
@@ -268,5 +270,7 @@
|
||||
"MessageMarkAsFinished": "Σήμανση ως Ολοκληρωμένο",
|
||||
"MessageNoItemsFound": "Δεν βρέθηκαν αντικείμενα",
|
||||
"MessageNoNetworkConnection": "Δεν υπάρχει σύνδεση δικτύου",
|
||||
"MessageNoUserPlaylists": "Δεν έχετε λίστες αναπαραγωγής"
|
||||
"MessageNoUserPlaylists": "Δεν έχετε λίστες αναπαραγωγής",
|
||||
"UnitMinutesShort": "{0} min",
|
||||
"UnitSecondsShort": "{0} s"
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"ButtonAdd": "Lisää",
|
||||
"ButtonAddNewServer": "Lisää uusi palvelin",
|
||||
"ButtonAllow": "Salli",
|
||||
"ButtonAuthors": "Tekijät",
|
||||
"ButtonBack": "Takaisin",
|
||||
"ButtonCancel": "Peruuta",
|
||||
@@ -312,6 +313,7 @@
|
||||
"MessageDownloadCompleteProcessing": "Lataus valmis. Käsitellään...",
|
||||
"MessageDownloading": "Ladataan...",
|
||||
"MessageDownloadingEpisode": "Ladataan jaksoa",
|
||||
"MessageDownloadingFile": "Ladataan {0}",
|
||||
"MessageEpisodesQueuedForDownload": "{0} jaksoa on latausjonossa",
|
||||
"MessageFailedToRefreshToken": "Tunnuksen uusiminen epäonnistui, kirjaudu uudelleen sisään",
|
||||
"MessageFeedURLWillBe": "Syötteen URL tulee olemaan {0}",
|
||||
@@ -347,6 +349,7 @@
|
||||
"MessageOldServerConnectionWarning": "Palvelinyhteysmääritys käyttää vanhaa käyttäjätunnusta. Poista tämä palvelinyhteys ja lisää se uudelleen.",
|
||||
"MessageOldServerConnectionWarningHelp": "Määritit alun perin yhteyden tähän palvelimeen ennen tietokannan siirtoa versiossa 2.3.0, joka julkaistiin kesäkuussa 2023. Tuleva palvelinpäivitys poistaa mahdollisuuden kirjautua sisään tällä vanhalla yhteydellä. Poista olemassa oleva palvelinyhteys ja muodosta yhteys uudelleen (käyttäen samaa palvelinosoitetta ja tunnistetietoja). Jos sinulla on ladattua mediaa tällä laitteella, media on ladattava uudelleen synkronoitavaksi palvelimen kanssa.",
|
||||
"MessagePodcastSearchField": "Syötä hakutermi tai RSS-syötteen URL-osoite",
|
||||
"MessagePreparingDownloads": "Latausten valmistelu",
|
||||
"MessageProgressSyncFailed": "Viimeisin yritys raportoida kuuntelun edistymisestä palvelimelle epäonnistui. Edistymisen synkronointipyyntöjä yritetään edelleen 15 sekunnin tai minuutin välein median toiston aikana.",
|
||||
"MessageReportBugsAndContribute": "Ilmoita virheistä, toivo ominaisuuksia ja osallistu",
|
||||
"MessageSeriesAlreadyDownloaded": "Olet jo ladannut kaikki tämän sarjan kirjat.",
|
||||
@@ -357,6 +360,7 @@
|
||||
"MessageSocketConnectedOverUnmeteredCellular": "Kiinnitin yhdistetty ei mitatun mobiilin välityksellä",
|
||||
"MessageSocketConnectedOverUnmeteredWifi": "Kiinnitin yhdistetty ei mitatun Wi-Fi:n välityksellä",
|
||||
"MessageSocketNotConnected": "Kiinnitin ei yhdistetty",
|
||||
"MessageStoragePermissionDenied": "Käyttöoikeus evätty",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Varoitus: Useimmat podcast-sovellukset edellyttävät, että RSS-syötteen URL-osoite käyttää HTTPS:a",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Varoitus: yhdellä tai useammalla jaksollasi ei ole julkaisupäivämäärää. Jotkut podcast-sovellukset vaativat tämän.",
|
||||
"ToastBookmarkCreateFailed": "Kirjanmerkin luominen epäonnistui",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"ButtonAdd": "Ajouter",
|
||||
"ButtonAddNewServer": "Ajouter un serveur",
|
||||
"ButtonAllow": "Autoriser",
|
||||
"ButtonAuthors": "Auteurs",
|
||||
"ButtonBack": "Retour",
|
||||
"ButtonCancel": "Annuler",
|
||||
@@ -228,7 +229,7 @@
|
||||
"LabelPubDate": "Date de publication",
|
||||
"LabelPublishYear": "Année de publication",
|
||||
"LabelPublishedDate": "Publié le {0}",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Courriel personnalisée du propriétaire",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Courriel personnalisé du propriétaire",
|
||||
"LabelRSSFeedCustomOwnerName": "Nom propriétaire personnalisé",
|
||||
"LabelRSSFeedOpen": "Flux RSS ouvert",
|
||||
"LabelRSSFeedPreventIndexing": "Empêcher l’indexation",
|
||||
@@ -312,6 +313,7 @@
|
||||
"MessageDownloadCompleteProcessing": "Téléchargement terminé. Analyse…",
|
||||
"MessageDownloading": "Téléchargement…",
|
||||
"MessageDownloadingEpisode": "Téléchargement de l’épisode",
|
||||
"MessageDownloadingFile": "Téléchargement {0}",
|
||||
"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}",
|
||||
@@ -347,6 +349,7 @@
|
||||
"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",
|
||||
"MessagePreparingDownloads": "Préparation des téléchargements",
|
||||
"MessageProgressSyncFailed": "La dernière tentative de rapporter vos progrès d'écoute sur le serveur a échoué. Les demandes de synchronisation continueront d'être tentées toutes les 15 secondes à 1 minute pendant que les médias sont lus.",
|
||||
"MessageReportBugsAndContribute": "Signalez des anomalies, demandez des fonctionnalités et contribuez sur",
|
||||
"MessageSeriesAlreadyDownloaded": "Vous avez déjà téléchargé tous les livres de cette série.",
|
||||
@@ -357,6 +360,10 @@
|
||||
"MessageSocketConnectedOverUnmeteredCellular": "Socket connecté via données cellulaire",
|
||||
"MessageSocketConnectedOverUnmeteredWifi": "Socket connecté via Wi-Fi",
|
||||
"MessageSocketNotConnected": "Socket non connecté",
|
||||
"MessageStorageAccessDenied": "Accès refusé",
|
||||
"MessageStoragePermissionDenied": "Permission refusée",
|
||||
"MessageStorageWriteAccessRequired": "Vous n’avez pas le droit d’accès et d’écriture à ce dossier. Voulez-vous donner accès ?",
|
||||
"MessageWaitingForAvailableStorage": "En attente de stockage disponible",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Attention : la majorité des application de podcast nécessite une adresse de flux HTTPS",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Attention : un ou plusieurs de vos épisodes ne possèdent pas de date de publication. Certaines applications de podcast le requièrent.",
|
||||
"ToastBookmarkCreateFailed": "Échec de la création de marque-page",
|
||||
@@ -371,6 +378,6 @@
|
||||
"ToastRSSFeedCloseFailed": "Échec de la fermeture du flux RSS",
|
||||
"ToastRSSFeedCloseSuccess": "Flux RSS fermé",
|
||||
"ToastStreamingNotAllowedOnCellular": "La diffusion sur les données mobile n’est pas autorisée",
|
||||
"UnitMinutesShort": "{0}min",
|
||||
"UnitSecondsShort": "{0}sec"
|
||||
"UnitMinutesShort": "{0} min",
|
||||
"UnitSecondsShort": "{0} s"
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"ButtonAdd": "Hozzáadás",
|
||||
"ButtonAddNewServer": "Új szerver hozzáadása",
|
||||
"ButtonAllow": "Engedélyezés",
|
||||
"ButtonAuthors": "Szerzők",
|
||||
"ButtonBack": "Vissza",
|
||||
"ButtonCancel": "Mégsem",
|
||||
@@ -277,7 +278,7 @@
|
||||
"LabelTitle": "Cím",
|
||||
"LabelTotalSize": "Összes méret",
|
||||
"LabelTotalTrack": "Összes szám",
|
||||
"LabelTracks": "Számok",
|
||||
"LabelTracks": "Sávok",
|
||||
"LabelType": "Típus",
|
||||
"LabelUnknown": "Ismeretlen",
|
||||
"LabelUnlockPlayer": "Lejátszó feloldása",
|
||||
@@ -312,6 +313,7 @@
|
||||
"MessageDownloadCompleteProcessing": "Letöltés kész. Feldolgozás...",
|
||||
"MessageDownloading": "Letöltés...",
|
||||
"MessageDownloadingEpisode": "Epizód letöltése",
|
||||
"MessageDownloadingFile": "Letöltés alatt {0}",
|
||||
"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}",
|
||||
@@ -347,6 +349,7 @@
|
||||
"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",
|
||||
"MessagePreparingDownloads": "Letöltések előkészítése",
|
||||
"MessageProgressSyncFailed": "A legutóbbi kísérlet, hogy jelentse a hallgatás folyamatát a szervernek, meghiúsult. A folyamatban lévő szinkronizálási kérelmek továbbra is 15 másodperctől 1 percig terjednek, miközben a média lejátssza.",
|
||||
"MessageReportBugsAndContribute": "Hibák jelentése, funkciók kérése és hozzájárulás itt",
|
||||
"MessageSeriesAlreadyDownloaded": "Ön már letöltötte az összes könyvet a sorozatból.",
|
||||
@@ -357,6 +360,10 @@
|
||||
"MessageSocketConnectedOverUnmeteredCellular": "Csatlakoztatva korlátlan mobilhálózaton keresztül",
|
||||
"MessageSocketConnectedOverUnmeteredWifi": "Csatlakoztatva korlátlan Wi-Fi hálózaton keresztül",
|
||||
"MessageSocketNotConnected": "A socket nincs csatlakoztatva",
|
||||
"MessageStorageAccessDenied": "Hozzáférés megtagadva",
|
||||
"MessageStoragePermissionDenied": "Nincs jogosultság",
|
||||
"MessageStorageWriteAccessRequired": "Nincs írásjoga ehhez a könyvtárhoz. Szeretne hozzáférést biztosítani?",
|
||||
"MessageWaitingForAvailableStorage": "Szabad tárhelyre vár",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Figyelmeztetés: A legtöbb podcast alkalmazás megköveteli, hogy az RSS hírcsatorna URL-je HTTPS-t használjon",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Figyelmeztetés: Egy vagy több epizódnak nincs közzétételi dátuma. Néhány podcast alkalmazás ezt megköveteli.",
|
||||
"ToastBookmarkCreateFailed": "A könyvjelző létrehozása sikertelen",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"ButtonAdd": "Aggiungi",
|
||||
"ButtonAddNewServer": "Aggiungi un nuovo server",
|
||||
"ButtonAllow": "Autorizza",
|
||||
"ButtonAuthors": "Autori",
|
||||
"ButtonBack": "Indietro",
|
||||
"ButtonCancel": "Annulla",
|
||||
@@ -312,6 +313,7 @@
|
||||
"MessageDownloadCompleteProcessing": "Scaricamento completato. Elaborazione…",
|
||||
"MessageDownloading": "Scaricamento…",
|
||||
"MessageDownloadingEpisode": "Scaricamento dell’episodio in corso",
|
||||
"MessageDownloadingFile": "Scaricando {0}",
|
||||
"MessageEpisodesQueuedForDownload": "{0} episodio(i) in coda per lo scaricamento",
|
||||
"MessageFailedToRefreshToken": "Rinnovo del Token fallito, riautenticazione richiesta",
|
||||
"MessageFeedURLWillBe": "l’URL del flusso sarà {0}",
|
||||
@@ -347,6 +349,7 @@
|
||||
"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",
|
||||
"MessagePreparingDownloads": "Preparazione degli scaricamenti",
|
||||
"MessageProgressSyncFailed": "Il tentativo più recente di segnalare i tuoi progressi di ascolto al server è fallito. Le richieste di sincronizzazione dei progressi continueranno a essere tentate ogni 15 secondi - 1 minuto mentre il contenuto multimediale è in riproduzione.",
|
||||
"MessageReportBugsAndContribute": "Segnala errori, richiedi funzionalità e contribuisci",
|
||||
"MessageSeriesAlreadyDownloaded": "Hai già scaricato tutti i libri della serie.",
|
||||
@@ -357,6 +360,9 @@
|
||||
"MessageSocketConnectedOverUnmeteredCellular": "Collegato tramite rete cellulare",
|
||||
"MessageSocketConnectedOverUnmeteredWifi": "Collegato tramite rete Wi-Fi",
|
||||
"MessageSocketNotConnected": "Non connesso",
|
||||
"MessageStorageAccessDenied": "Accesso negato",
|
||||
"MessageStoragePermissionDenied": "Permesso negato",
|
||||
"MessageStorageWriteAccessRequired": "Non hai accesso di scrittura a questa cartella. Vuoi concedere l'accesso?",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Avviso: la maggior parte delle app di podcast richiede che l'URL del feed RSS utilizzi HTTPS",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Avviso: 1 o più delle tue puntate non hanno una data di pubblicazione. Alcune app di podcast lo richiedono.",
|
||||
"ToastBookmarkCreateFailed": "Creazione segnalibro fallita",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"ButtonAdd": "Pridėti",
|
||||
"ButtonAddNewServer": "Pridėti naują serverį",
|
||||
"ButtonAllow": "Leisti",
|
||||
"ButtonAuthors": "Autoriai",
|
||||
"ButtonBack": "Atgal",
|
||||
"ButtonCancel": "Atšaukti",
|
||||
@@ -17,11 +18,13 @@
|
||||
"ButtonDelete": "Ištrinti",
|
||||
"ButtonDisableAutoTimer": "Išjungti automatinį laikmatį",
|
||||
"ButtonDisconnect": "Atsijungti",
|
||||
"ButtonGoToWebClient": "Atidaryti naršyklės versiją",
|
||||
"ButtonHistory": "Istorija",
|
||||
"ButtonHome": "Pradžia",
|
||||
"ButtonIssues": "Problemos",
|
||||
"ButtonLatest": "Naujausias",
|
||||
"ButtonLibrary": "Biblioteka",
|
||||
"ButtonLocalMedia": "Vietiniai failai",
|
||||
"ButtonLogs": "Žurnalai",
|
||||
"ButtonManageLocalFiles": "Tvarkyti vietinius failus",
|
||||
"ButtonNewFolder": "Naujas aplankas",
|
||||
@@ -43,6 +46,8 @@
|
||||
"ButtonSetTimer": "Nustatyti laikmatį",
|
||||
"ButtonStream": "Srautas",
|
||||
"ButtonSubmit": "Pateikti",
|
||||
"ButtonSwitchServerUser": "Keisti serverį/vartotoją",
|
||||
"ButtonUserStats": "Vartotojo statistika",
|
||||
"ButtonYes": "Taip",
|
||||
"HeaderAccount": "Paskyra",
|
||||
"HeaderAdvanced": "Papildomi",
|
||||
@@ -56,6 +61,7 @@
|
||||
"HeaderEreaderSettings": "Elektroninių skaitytuvų nustatymai",
|
||||
"HeaderLatestEpisodes": "Naujausi epizodai",
|
||||
"HeaderLibraries": "Bibliotekos",
|
||||
"HeaderLocalFolders": "Vietiniai aplankai",
|
||||
"HeaderOpenRSSFeed": "Atidaryti RSS srautą",
|
||||
"HeaderPlaylist": "Grojaraštis",
|
||||
"HeaderPlaylistItems": "Grojaraščio elementai",
|
||||
@@ -66,6 +72,7 @@
|
||||
"HeaderStatsMinutesListeningChart": "Klausymo minutės (paskutinės 7 dienos)",
|
||||
"HeaderStatsRecentSessions": "Naujausios sesijos",
|
||||
"HeaderTableOfContents": "Turinys",
|
||||
"HeaderWelcome": "Sveiki, <strong>{0}</strong>",
|
||||
"HeaderYourStats": "Jūsų statistika",
|
||||
"LabelAddToPlaylist": "Pridėti į grojaraštį",
|
||||
"LabelAddedAt": "Pridėta {0}",
|
||||
@@ -82,7 +89,9 @@
|
||||
"LabelClosePlayer": "Uždaryti grotuvą",
|
||||
"LabelCollapseSeries": "Suskleisti seriją",
|
||||
"LabelComplete": "Baigta",
|
||||
"LabelContinueListening": "Klausytis toliau",
|
||||
"LabelDescription": "Aprašymas",
|
||||
"LabelDiscover": "Atrask",
|
||||
"LabelDownload": "Atsisiųsti",
|
||||
"LabelDownloaded": "Atsisiųsta",
|
||||
"LabelDuration": "Trukmė",
|
||||
@@ -116,10 +125,15 @@
|
||||
"LabelName": "Pavadinimas",
|
||||
"LabelNarrator": "Skaitytojas",
|
||||
"LabelNarrators": "Skaitytojai",
|
||||
"LabelNewestAuthors": "Naujausi autoriai",
|
||||
"LabelNotFinished": "Nebaigta",
|
||||
"LabelNotStarted": "Nepasileista",
|
||||
"LabelPassword": "Slaptažodis",
|
||||
"LabelPath": "Kelias",
|
||||
"LabelPlaybackDirect": "Tiesioginis",
|
||||
"LabelPlaybackLocal": "Vietinis",
|
||||
"LabelPlaybackSpeed": "Grojimo greitis",
|
||||
"LabelPlaybackTranscode": "Konvertuojama",
|
||||
"LabelPodcast": "Tinklalaidė",
|
||||
"LabelPodcasts": "Tinklalaidės",
|
||||
"LabelPreventIndexing": "Neleisti indeksuoti jūsų srauto „iTunes“ ir Google podcast kataloguose",
|
||||
@@ -132,7 +146,10 @@
|
||||
"LabelRSSFeedSlug": "RSS srauto identifikatorius",
|
||||
"LabelRead": "Skaityta",
|
||||
"LabelReadAgain": "Skaityti dar kartą",
|
||||
"LabelRecentSeries": "Naujausiais įkeltos serijos",
|
||||
"LabelRecentlyAdded": "Naujausiai įkelta",
|
||||
"LabelSeason": "Sezonas",
|
||||
"LabelSelectMediaType": "Pasirinkti failų tipą",
|
||||
"LabelSeries": "Serija",
|
||||
"LabelSetEbookAsPrimary": "Nustatyti kaip pagrindinę",
|
||||
"LabelSetEbookAsSupplementary": "Nustatyti kaip papildomą",
|
||||
@@ -159,10 +176,16 @@
|
||||
"LabelTitle": "Pavadinimas",
|
||||
"LabelTracks": "Takeliai",
|
||||
"LabelType": "Tipas",
|
||||
"LabelUseBookshelfView": "Naudoti lentynos vaizdą",
|
||||
"LabelUser": "Vartotojas",
|
||||
"LabelUsername": "Vartotojo vardas",
|
||||
"LabelYearReviewHide": "Slėpti metų apžvalgą",
|
||||
"LabelYearReviewShow": "Pamatyti metų apžvalgą",
|
||||
"LabelYourBookmarks": "Jūsų skirtukai",
|
||||
"LabelYourProgress": "Jūsų pažanga",
|
||||
"MessageAudiobookshelfServerNotConnected": "Audiobookshelf serveris neprijungtas",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>Important!</strong> Ši programa skirta naudoti su Audiobookshelf serveriu, kurį valdote jūs arba jūsų pažįstamas asmuo. Programa neteikia jokio turinio.",
|
||||
"MessageBookshelfEmpty": "Lentyna tuščia",
|
||||
"MessageDownloadingEpisode": "Epizodas atsisiunčiamas",
|
||||
"MessageEpisodesQueuedForDownload": "{0} epizodai laukia atsisiuntimo",
|
||||
"MessageFeedURLWillBe": "Srauto URL bus {0}",
|
||||
@@ -175,6 +198,7 @@
|
||||
"MessageNoItems": "Nėra elementų",
|
||||
"MessageNoItemsFound": "Elementų nerasta",
|
||||
"MessageNoListeningSessions": "Klausymo sesijų nėra",
|
||||
"MessageNoMediaFolders": "Nėra failų aplankų",
|
||||
"MessageNoPodcastsFound": "Tinklalaidžių nerasta",
|
||||
"MessageNoUpdatesWereNecessary": "Nereikalingi jokie atnaujinimai",
|
||||
"MessageNoUserPlaylists": "Neturite grojaraščių",
|
||||
|
||||