Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7014e04e6f | ||
|
|
a00032beda | ||
|
|
02acaf1159 | ||
|
|
6f923ef1d8 | ||
|
|
679ebb16f8 | ||
|
|
ed356a8d99 | ||
|
|
33e187678d | ||
|
|
5fc952a9e5 | ||
|
|
a408c55cff | ||
|
|
a3d482e3f7 | ||
|
|
2f8c1731e9 | ||
|
|
b5269f52c4 | ||
|
|
9ebc7da984 | ||
|
|
c8c4b8b059 | ||
|
|
c1f3e236f8 | ||
|
|
128e23fabf | ||
|
|
989cf66715 | ||
|
|
ce6883331e | ||
|
|
108f34bfb7 | ||
|
|
869873acfd | ||
|
|
96a44bcce7 | ||
|
|
ca2c023486 | ||
|
|
46850159de | ||
|
|
bd4c3be210 | ||
|
|
da4745217c | ||
|
|
3048237a4b | ||
|
|
e473155646 | ||
|
|
643603c073 | ||
|
|
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.13.0'
|
||||
- 'iOS App - 0.13.0'
|
||||
- 'Android App - 0.12.0'
|
||||
- 'iOS App - 0.12.0'
|
||||
- 'Android App - 0.14.1'
|
||||
- 'iOS App - 0.14.1'
|
||||
- 'Android App - 0.14.0'
|
||||
- 'iOS App - 0.14.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.13.0'
|
||||
- 'iOS App - 0.13.0'
|
||||
- 'Android App - 0.12.0'
|
||||
- 'iOS App - 0.12.0'
|
||||
- 'Android App - 0.14.1'
|
||||
- 'iOS App - 0.14.1'
|
||||
- 'Android App - 0.14.0'
|
||||
- 'iOS App - 0.14.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
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Prepare translation credits
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
release-notes:
|
||||
if: github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, 'weblate')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- id: translator-credits
|
||||
uses: audiobookshelf/get-translator-credits@v1.1.0
|
||||
|
||||
- name: Show translator credits
|
||||
run: |
|
||||
printf '%s\n' "${{ steps.translator-credits.outputs.credits }}"
|
||||
@@ -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 121
|
||||
versionName "0.14.2-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()
|
||||
|
||||
@@ -121,6 +121,86 @@ class DbManager {
|
||||
return downloadItems
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate persisted download queue entries before restoring them.
|
||||
*
|
||||
* Queue entries can become incompatible or malformed, for example after an app update. If any
|
||||
* entry cannot be read or fails the checks required for queue restoration, the entire persisted
|
||||
* queue is cleared so startup can continue without restoring invalid data.
|
||||
*
|
||||
* @return true if the queue passes validation or was successfully cleared; false if an invalid
|
||||
* queue could not be cleared
|
||||
*/
|
||||
fun ensureValidDownloadQueue(): Boolean {
|
||||
val downloadBook = Paper.book("downloadItems")
|
||||
val queueIsValid =
|
||||
try {
|
||||
downloadBook.allKeys
|
||||
.map { downloadItemId ->
|
||||
val item =
|
||||
downloadBook.read<DownloadItem>(downloadItemId)
|
||||
?: throw IllegalStateException(
|
||||
"Download queue entry $downloadItemId could not be read"
|
||||
)
|
||||
if (item.id != downloadItemId) {
|
||||
throw IllegalStateException(
|
||||
"Download queue entry $downloadItemId has a mismatched item ID"
|
||||
)
|
||||
}
|
||||
item
|
||||
}
|
||||
.all { item ->
|
||||
listOf<String?>(item.id, item.libraryItemId, item.localFolder.id).all {
|
||||
!it.isNullOrBlank()
|
||||
} &&
|
||||
item.downloadItemParts.isNotEmpty() &&
|
||||
item.downloadItemParts.all { part ->
|
||||
listOf<String?>(
|
||||
part.id,
|
||||
part.downloadItemId,
|
||||
part.filename,
|
||||
part.destinationPath,
|
||||
part.finalDestinationPath,
|
||||
part.serverPath,
|
||||
part.localFolderId
|
||||
)
|
||||
.all { !it.isNullOrBlank() } &&
|
||||
part.downloadItemId == item.id
|
||||
}
|
||||
}
|
||||
} catch (readError: Exception) {
|
||||
AbsLogger.error(
|
||||
tag,
|
||||
"Could not read persisted download queue; clearing queue (${readError.message})"
|
||||
)
|
||||
false
|
||||
}
|
||||
|
||||
if (queueIsValid) return true
|
||||
|
||||
AbsLogger.error(tag, "Persisted download queue contains invalid data; clearing queue")
|
||||
return try {
|
||||
downloadBook.destroy()
|
||||
val remainingKeys = downloadBook.allKeys
|
||||
if (remainingKeys.isEmpty()) {
|
||||
AbsLogger.info(tag, "Cleared invalid persisted download queue")
|
||||
true
|
||||
} else {
|
||||
AbsLogger.error(
|
||||
tag,
|
||||
"Invalid persisted download queue still contains ${remainingKeys.size} entries after clearing"
|
||||
)
|
||||
false
|
||||
}
|
||||
} catch (clearError: Exception) {
|
||||
AbsLogger.error(
|
||||
tag,
|
||||
"Could not clear invalid persisted download queue (${clearError.message})"
|
||||
)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun saveLocalMediaProgress(mediaProgress: LocalMediaProgress) {
|
||||
Paper.book("localMediaProgress").write(mediaProgress.id, mediaProgress)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,15 +2,25 @@ package com.audiobookshelf.app.services
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
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 +31,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 +43,22 @@ object DownloadServiceHost {
|
||||
val appContext = context.applicationContext
|
||||
DbManager.initialize(appContext)
|
||||
manager = DownloadItemManager(FolderScanner(appContext), appContext, ForwardingEmitter)
|
||||
manager!!.restoreQueue()
|
||||
restoreJob = scope.launch {
|
||||
try {
|
||||
if (DeviceManager.dbManager.ensureValidDownloadQueue()) {
|
||||
IncompleteDownloadCleanup.cleanupExpired(appContext)
|
||||
manager!!.restoreQueue()
|
||||
} else {
|
||||
AbsLogger.error(TAG, "Skipping invalid download queue because it could not be cleared")
|
||||
}
|
||||
onRestoreComplete(appContext)
|
||||
} catch (restoreError: Exception) {
|
||||
AbsLogger.error(
|
||||
TAG,
|
||||
"Could not restore download queue (${restoreError.javaClass.simpleName}: ${restoreError.message})"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return manager!!
|
||||
}
|
||||
@@ -41,14 +68,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 +82,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 +151,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 +161,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 +221,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 = 48;
|
||||
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.2;
|
||||
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 = 48;
|
||||
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.2;
|
||||
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.2-beta",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-app",
|
||||
"version": "0.14.0-beta",
|
||||
"version": "0.14.2-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.2-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)
|
||||
}
|
||||
|
||||
@@ -7,9 +7,10 @@ const defaultCode = 'en-us'
|
||||
let $localStore = null
|
||||
|
||||
const languageCodeMap = {
|
||||
ar: { label: 'عربي', dateFnsLocale: 'ar' },
|
||||
be: { label: 'Беларуская', dateFnsLocale: 'be' },
|
||||
bn: { label: 'বাংলা', dateFnsLocale: 'bn' },
|
||||
bg: { label: 'Български', dateFnsLocale: 'bg' },
|
||||
bn: { label: 'বাংলা', dateFnsLocale: 'bn' },
|
||||
ca: { label: 'Català', dateFnsLocale: 'ca' },
|
||||
cs: { label: 'Čeština', dateFnsLocale: 'cs' },
|
||||
da: { label: 'Dansk', dateFnsLocale: 'da' },
|
||||
@@ -18,15 +19,18 @@ const languageCodeMap = {
|
||||
es: { label: 'Español', dateFnsLocale: 'es' },
|
||||
fi: { label: 'Suomi', dateFnsLocale: 'fi' },
|
||||
fr: { label: 'Français', dateFnsLocale: 'fr' },
|
||||
he: { label: 'עברית', dateFnsLocale: 'he' },
|
||||
hr: { label: 'Hrvatski', dateFnsLocale: 'hr' },
|
||||
it: { label: 'Italiano', dateFnsLocale: 'it' },
|
||||
lt: { label: 'Lietuvių', dateFnsLocale: 'lt' },
|
||||
hu: { label: 'Magyar', dateFnsLocale: 'hu' },
|
||||
ko: { label: '한국어', dateFnsLocale: 'ko' },
|
||||
nl: { label: 'Nederlands', dateFnsLocale: 'nl' },
|
||||
no: { label: 'Norsk', dateFnsLocale: 'no' },
|
||||
pl: { label: 'Polski', dateFnsLocale: 'pl' },
|
||||
'pt-br': { label: 'Português (Brasil)', dateFnsLocale: 'ptBR' },
|
||||
ru: { label: 'Русский', dateFnsLocale: 'ru' },
|
||||
sk: { label: 'Slovenčina', dateFnsLocale: 'sk' },
|
||||
sl: { label: 'Slovenščina', dateFnsLocale: 'sl' },
|
||||
sv: { label: 'Svenska', dateFnsLocale: 'sv' },
|
||||
tr: { label: 'Türkçe', dateFnsLocale: 'tr' },
|
||||
|
||||
@@ -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": "এতে যোগ করা হয়েছে",
|
||||
@@ -91,6 +101,9 @@
|
||||
"LabelAll": "সব",
|
||||
"LabelAllowSeekingOnMediaControls": "মিডিয়া বিজ্ঞপ্তি নিয়ন্ত্রণে অবস্থান খোঁজার অনুমতি দিন",
|
||||
"LabelAlways": "সর্বদা",
|
||||
"LabelAndroidAutoBrowseLimitForGrouping": "বর্ণানুক্রমিক তালিকার সীমা",
|
||||
"LabelAndroidAutoBrowseLimitForGroupingHelp": "প্রদর্শনের জন্য আইটেমের সংখ্যা এর চেয়ে কম হলে বর্ণানুক্রমিক তালিকা ব্যবহার করবেন না",
|
||||
"LabelAndroidAutoBrowseSeriesSequenceOrder": "সিরিজের বইগুলোর ক্রম",
|
||||
"LabelAskConfirmation": "নিশ্চিতকরণের জন্য জিজ্ঞাসা করুন",
|
||||
"LabelAuthor": "লেখক",
|
||||
"LabelAuthorFirstLast": "লেখক (প্রথম শেষ)",
|
||||
@@ -103,6 +116,7 @@
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "অটো স্লিপ টাইমার শেষে আইটেমটি আবার চালানো হলে স্বয়ংক্রিয়ভাবে আপনার অবস্থান রিওয়াইন্ড করবে।",
|
||||
"LabelAutoSleepTimerHelp": "নির্দিষ্ট শুরু এবং শেষ সময়ের মধ্যে মিডিয়া চালানোর সময় একটি স্লিপ টাইমার স্বয়ংক্রিয়ভাবে শুরু হবে।",
|
||||
"LabelBooks": "বইগুলো",
|
||||
"LabelByAuthor": "{0} অবশ্যই একদম অপরিবর্তিত রাখবে",
|
||||
"LabelChapterTrack": "অধ্যায় ট্র্যাক",
|
||||
"LabelChapters": "অধ্যায়",
|
||||
"LabelClosePlayer": "প্লেয়ার বন্ধ করুন",
|
||||
@@ -131,11 +145,12 @@
|
||||
"LabelEbooks": "ই-বইগুলো",
|
||||
"LabelEnable": "সক্ষম করুন",
|
||||
"LabelEnableMp3IndexSeeking": "এমপি৩ সূচী খোঁজা সক্ষম করুন",
|
||||
"LabelEnableMp3IndexSeekingHelp": "এই সেটিংটি শুধুমাত্র তখনই সক্রিয় করা উচিত যদি আপনার কাছে MP3 ফাইল থাকে যা সঠিকভাবে খুঁজছে না। ভেরিয়েবল বিটরেট (VBR) MP3 ফাইলের কারণে ভুল চাওয়া হয়। এই সেটিংটি সূচী খুঁজতে বাধ্য করবে, যেখানে ফাইলটি পড়ার সাথে-সাথে একটি টাইম-টু-বাইট ম্যাপিং তৈরি করা হয়। বড় MP3 ফাইলের সাথে কিছু ক্ষেত্রে ফাইলের শেষের দিকে খোঁজার সময় বিলম্ব হবে।",
|
||||
"LabelEnableMp3IndexSeekingHelp": "এই সেটিংটি শুধু তখনই চালু করুন, যখন আপনার MP3 ফাইলে ঠিকভাবে নির্দিষ্ট স্থানে যাওয়া (seeking) কাজ করছে না। সাধারণত ভ্যারিয়েবল বিটরেট (VBR) MP3 ফাইলের কারণে এমন সমস্যা হয়। এই সেটিংটি চালু করলে ইনডেক্স ব্যবহার করে seeking করা হবে এবং ফাইল পড়ার সময় সময়ের সঙ্গে ফাইলের বাইট অবস্থানের একটি ম্যাপ তৈরি হবে। বড় MP3 ফাইলের ক্ষেত্রে ফাইলের শেষের দিকে যেতে কিছুটা দেরি হতে পারে।",
|
||||
"LabelEnd": "সমাপ্ত",
|
||||
"LabelEndOfChapter": "অধ্যায়ের সমাপ্তি",
|
||||
"LabelEndTime": "শেষ সময়",
|
||||
"LabelEpisode": "পর্ব",
|
||||
"LabelExplicit": "অশ্লীল ভাষা",
|
||||
"LabelFeedURL": "ফিড ইউআরএল",
|
||||
"LabelFile": "ফাইল",
|
||||
"LabelFileBirthtime": "ফাইল জন্মের সময়",
|
||||
@@ -144,6 +159,9 @@
|
||||
"LabelFinished": "সমাপ্ত",
|
||||
"LabelFolder": "ফোল্ডার",
|
||||
"LabelFontBoldness": "হরফ বোল্ডনেস",
|
||||
"LabelFontFamily": "ফন্টের ধরন",
|
||||
"LabelFontFamilySans": "স্যান্স",
|
||||
"LabelFontFamilySerif": "সেরিফ",
|
||||
"LabelFontScale": "ফন্ট স্কেল",
|
||||
"LabelGenre": "ঘরানা",
|
||||
"LabelGenres": "ঘরানাগুলো",
|
||||
@@ -158,10 +176,14 @@
|
||||
"LabelInternalAppStorage": "অভ্যন্তরীণ অ্যাপ স্টোরেজ",
|
||||
"LabelJumpBackwardsTime": "পিছনের সময়ে ঝাঁপ",
|
||||
"LabelJumpForwardsTime": "সামনের সময়ে ঝাঁপ",
|
||||
"LabelKeepScreenAwake": "স্ক্রিন চালু রাখুন",
|
||||
"LabelLanguage": "ভাষা",
|
||||
"LabelLayout": "লেআউট",
|
||||
"LabelLayoutAuto": "স্বয়ংক্রিয়",
|
||||
"LabelLayoutSinglePage": "একক পৃষ্ঠা",
|
||||
"LabelLibrarySortByProgress": "অগ্রগতি: সর্বশেষ আপডেট",
|
||||
"LabelLibrarySortByProgressFinished": "অগ্রগতি: সম্পন্ন",
|
||||
"LabelLibrarySortByProgressStarted": "অগ্রগতি: শুরু হয়েছে",
|
||||
"LabelLight": "আলো",
|
||||
"LabelLineSpacing": "লাইন স্পেসিং",
|
||||
"LabelListenAgain": "আবার শুনুন",
|
||||
@@ -172,6 +194,7 @@
|
||||
"LabelLow": "নিচু",
|
||||
"LabelMediaType": "মিডিয়ার ধরন",
|
||||
"LabelMedium": "মাঝারি",
|
||||
"LabelMissing": "অনুপস্থিত",
|
||||
"LabelMore": "আরো",
|
||||
"LabelMoreInfo": "আরো তথ্য",
|
||||
"LabelName": "নাম",
|
||||
@@ -189,6 +212,8 @@
|
||||
"LabelNotFinished": "সমাপ্ত হয়নি",
|
||||
"LabelNotStarted": "শুরু হয়নি",
|
||||
"LabelNumEpisodes": "{0}টি পর্ব",
|
||||
"LabelNumEpisodesIncomplete": "{0}টি পর্ব, {1}টি অসম্পূর্ণ",
|
||||
"LabelNumberOfEpisodes": "পর্বের সংখ্যা",
|
||||
"LabelOff": "বন্ধ",
|
||||
"LabelOn": "চালু",
|
||||
"LabelPassword": "পাসওয়ার্ড",
|
||||
@@ -206,6 +231,7 @@
|
||||
"LabelPublishedDate": "প্রকাশিত {0}",
|
||||
"LabelRSSFeedCustomOwnerEmail": "কাস্টম মালিকের ইমেইল",
|
||||
"LabelRSSFeedCustomOwnerName": "কাস্টম মালিকের নাম",
|
||||
"LabelRSSFeedOpen": "RSS ফিড খোলা আছে",
|
||||
"LabelRSSFeedPreventIndexing": "সূচীকরণ প্রতিরোধ করুন",
|
||||
"LabelRSSFeedSlug": "আরএসএস ফিড স্লাগ",
|
||||
"LabelRandomly": "এলোমেলোভাবে",
|
||||
@@ -217,6 +243,9 @@
|
||||
"LabelScaleElapsedTimeBySpeed": "অতিবাহিত সময় গতি দ্বারা স্কেল করুন",
|
||||
"LabelSeason": "সেশন",
|
||||
"LabelSelectADevice": "একটি ডিভাইস নির্বাচন করুন",
|
||||
"LabelSelectMediaType": "মিডিয়ার ধরন নির্বাচন করুন",
|
||||
"LabelSequenceAscending": "ক্রম ঊর্ধ্বমুখী",
|
||||
"LabelSequenceDescending": "ক্রম নিম্নমুখী",
|
||||
"LabelSeries": "সিরিজ",
|
||||
"LabelServerAddress": "সার্ভার ঠিকানা",
|
||||
"LabelSetEbookAsPrimary": "প্রাথমিক হিসাবে সেট করুন",
|
||||
@@ -225,6 +254,8 @@
|
||||
"LabelShowAll": "সব দেখান",
|
||||
"LabelSize": "আকার",
|
||||
"LabelSleepTimer": "স্লিপ টাইমার",
|
||||
"LabelSleepTimerAlmostDoneChime": "প্রায় শেষ হলে একটি সংকেতের শব্দ বাজান",
|
||||
"LabelSleepTimerAlmostDoneChimeHelp": "স্লিপ টাইমারে ৩০ সেকেন্ড বাকি থাকলে একটি সংকেতের শব্দ বাজান",
|
||||
"LabelStart": "শুরু",
|
||||
"LabelStartTime": "শুরুর সময়",
|
||||
"LabelStatsBestDay": "সেরা দিন",
|
||||
@@ -240,6 +271,7 @@
|
||||
"LabelTag": "ট্যাগ",
|
||||
"LabelTags": "ট্যাগগুলো",
|
||||
"LabelTheme": "থিম",
|
||||
"LabelThemeBlack": "কালো",
|
||||
"LabelThemeDark": "অন্ধকার",
|
||||
"LabelThemeLight": "আলো",
|
||||
"LabelTimeRemaining": "{0}টি অবশিষ্ট",
|
||||
@@ -264,24 +296,34 @@
|
||||
"MessageAudiobookshelfServerNotConnected": "অডিওবুকশেল্ফ সার্ভার সংযুক্ত করা যায়নি",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>গুরুত্বপূর্ণ!</strong> এই অ্যাপটি এমন একটি অডিওবুকশেল্ফ সার্ভারের সাথে কাজ করার জন্য ডিজাইন করা হয়েছে যা আপনি বা আপনার পরিচিত কেউ হোস্ট করছেন৷ এই অ্যাপ্লিকেশনটি নিজে থেকে কোন বিষয়বস্তু প্রদান করে না।",
|
||||
"MessageBookshelfEmpty": "বুকশেলফ খালি",
|
||||
"MessageConfirmAppExit": "আপনি কি অ্যাপ থেকে বের হতে চান?",
|
||||
"MessageConfirmDeleteEpisodeDownloadQueue": "আপনি কি নিশ্চিত যে পর্ব ডাউনলোডের সারি মুছে ফেলতে চান?",
|
||||
"MessageConfirmDeleteLocalEpisode": "আপনার ডিভাইস থেকে স্থানীয় পর্ব \"{0}\" সরাবেন? সার্ভারে ফাইলটি প্রভাবিত হবে না।",
|
||||
"MessageConfirmDeleteLocalFiles": "আপনার ডিভাইস থেকে এই আইটেমটির স্থানীয় ফাইলগুলি সরাবেন? সার্ভারে থাকা ফাইল এবং আপনার অগ্রগতি প্রভাবিত হবে না।",
|
||||
"MessageConfirmDeleteServerConfig": "এই সার্ভার কনফিগারেশনটি সরিয়ে ফেলতে চান?",
|
||||
"MessageConfirmDeleteServerEpisode": "আপনি কি নিশ্চিত যে সার্ভার থেকে \"{0}\" পর্বটি মুছে ফেলতে চান?\nসতর্কতা: এটি অডিও ফাইলটিও মুছে ফেলবে।",
|
||||
"MessageConfirmDisableAutoTimer": "আপনি কি নিশ্চিত যে আজকের বাকি সময়ের জন্য অটো টাইমার বন্ধ করতে চান? এই অটো-স্লিপ টাইমারের সময়সীমা শেষ হলে অথবা আপনি অ্যাপটি পুনরায় চালু করলে টাইমারটি আবার চালু হবে।",
|
||||
"MessageConfirmDiscardProgress": "আপনি কি নিশ্চিত আপনার অগ্রগতি পুনরায় সেট করতে চান?",
|
||||
"MessageConfirmDownloadUsingCellular": "আপনি সেলুলার ডেটা ব্যবহার করে ডাউনলোড করতে চলেছেন৷ এর মধ্যে ক্যারিয়ার ডেটা চার্জ অন্তর্ভুক্ত থাকতে পারে। আপনি কি চালিয়ে যেতে চান?",
|
||||
"MessageConfirmMarkAsFinished": "আপনি কি এই আইটেমটিকে সমাপ্ত হিসাবে চিহ্নিত করার বিষয়ে নিশ্চিত?",
|
||||
"MessageConfirmPlaybackTime": "\"{0}\" এর প্লেব্যাক {1} থেকে শুরু করবেন?",
|
||||
"MessageConfirmRemoveBookmark": "আপনি কি বুকমার্ক সরানোর বিষয়ে নিশ্চিত?",
|
||||
"MessageConfirmStreamingUsingCellular": "আপনি সেলুলার ডেটা ব্যবহার করে স্ট্রিম করতে চলেছেন৷ এর মধ্যে ক্যারিয়ার ডেটা চার্জ অন্তর্ভুক্ত থাকতে পারে। আপনি কি চালিয়ে যেতে চান?",
|
||||
"MessageDiscardProgress": "অগ্রগতি বাতিল করুন",
|
||||
"MessageDownloadCompleteProcessing": "ডাউনলোড সম্পূর্ণ। প্রক্রিয়া করা হচ্ছে..।",
|
||||
"MessageDownloading": "ডাউনলোড হচ্ছে..।",
|
||||
"MessageDownloadingEpisode": "ডাউনলোডিং পর্ব",
|
||||
"MessageDownloadingFile": "{0} ডাউনলোড করা হচ্ছে",
|
||||
"MessageEpisodesQueuedForDownload": "{0} পর্ব(গুলি) ডাউনলোডের জন্য সারিবদ্ধ",
|
||||
"MessageFailedToRefreshToken": "টোকেন রিফ্রেশ করা যায়নি, আবার লগইন করতে হবে",
|
||||
"MessageFeedURLWillBe": "ফিড URL হবে {0}",
|
||||
"MessageFetching": "আনয় হচ্ছে.।",
|
||||
"MessageFollowTheProjectOnGithub": "গিটহাবে প্রকল্পটিকে অনুসরণ করুন",
|
||||
"MessageItemDownloadCompleteFailedToCreate": "আইটেম ডাউনলোড সম্পূর্ণ হয়েছে কিন্তু লাইব্রেরি আইটেম তৈরি করতে ব্যর্থ হয়েছে",
|
||||
"MessageItemMissing": "আইটেমটি খুঁজে পাওয়া যাচ্ছে না এবং এটি সার্ভারে ঠিক করতে হবে। সাধারণত ফাইলের পাথ অ্যাক্সেস করা না গেলে কোনো আইটেমকে অনুপস্থিত হিসেবে চিহ্নিত করা হয়।",
|
||||
"MessageLoading": "লোড হচ্ছে.।",
|
||||
"MessageLoadingServerData": "সার্ভার ডেটা লোড হচ্ছে..।",
|
||||
"MessageLocalFolderDescription": "‘Internal App Storage’ শুধু এই অ্যাপ থেকেই অ্যাক্সেস করা যায়। এই অ্যাপটি শুধুমাত্র অ্যাপের মাধ্যমে সরাসরি ডাউনলোড করা মিডিয়া সমর্থন করে। শেয়ার্ড স্টোরেজ ফোল্ডার ব্যবহার করলে এই অ্যাপের মাধ্যমে ডাউনলোড করা মিডিয়া অন্য অ্যাপ থেকেও অ্যাক্সেস করা যাবে।",
|
||||
"MessageMarkAsFinished": "সমাপ্ত হিসাবে চিহ্নিত করুন",
|
||||
"MessageMediaLinkedToADifferentServer": "মিডিয়া একটি ভিন্ন ঠিকানায় একটি অডিওবুকশেল্ফ সার্ভারের সাথে লিঙ্ক করা হয়েছে ({0})৷ এই সার্ভার ঠিকানার সাথে সংযুক্ত হলে অগ্রগতি সিঙ্ক করা হবে।",
|
||||
"MessageMediaLinkedToADifferentUser": "মিডিয়া এই সার্ভারের সাথে লিঙ্ক করা হয়েছে কিন্তু অন্য ব্যবহারকারী দ্বারা ডাউনলোড করা হয়েছে৷ অগ্রগতি শুধুমাত্র সেই ব্যবহারকারীর সাথে সিঙ্ক করা হবে যে এটি ডাউনলোড করেছে।",
|
||||
@@ -294,13 +336,21 @@
|
||||
"MessageNoItems": "কোন আইটেম নেই",
|
||||
"MessageNoItemsFound": "কোন আইটেম পাওয়া যায়নি",
|
||||
"MessageNoListeningSessions": "কোনও শোনার সেশন নেই",
|
||||
"MessageNoLogs": "কোনো লগ নেই",
|
||||
"MessageNoMediaFolders": "কোনো মিডিয়া ফোল্ডার নেই",
|
||||
"MessageNoNetworkConnection": "নেটওয়ার্ক সংযোগ নেই",
|
||||
"MessageNoPodcastsFound": "কোন পডকাস্ট পাওয়া যায়নি",
|
||||
"MessageNoSeries": "কোনো সিরিজ নেই",
|
||||
"MessageNoUpdatesWereNecessary": "কোন আপডেটের প্রয়োজন ছিল না",
|
||||
"MessageNoUserPlaylists": "আপনার কোনো প্লেলিস্ট নেই",
|
||||
"MessageOldServerAuthReLoginRequired": "নিরাপত্তার জন্য সার্ভার v2.26.0-এ অথেনটিকেশন ব্যবস্থা উন্নত করা হয়েছে। সকল ব্যবহারকারীকে আবার লগইন করতে হবে।",
|
||||
"MessageOldServerAuthWarning": "সার্ভারটি পুরোনো অথেনটিকেশন পদ্ধতি ব্যবহার করছে",
|
||||
"MessageOldServerAuthWarningHelp": "এই সার্ভারটি v2.26.0-এর আগের একটি সংস্করণে চলছে। v2.26.0-এ আরও নিরাপদ অথেনটিকেশন ব্যবস্থা যোগ করা হয়েছে। সার্ভারটি সর্বশেষ সংস্করণে আপডেট করার জন্য জোরালোভাবে পরামর্শ দেওয়া হচ্ছে। আপনি যদি ইতিমধ্যে সার্ভারটি আপডেট করে থাকেন, তাহলে নতুন অথেনটিকেশন ব্যবস্থা ব্যবহার করতে আবার লগইন করুন।",
|
||||
"MessageOldServerConnectionWarning": "সার্ভার সংযোগের কনফিগারেশনে একটি পুরোনো ইউজার আইডি ব্যবহার করা হচ্ছে। অনুগ্রহ করে এই সার্ভার সংযোগটি মুছে আবার যোগ করুন।",
|
||||
"MessageOldServerConnectionWarningHelp": "আপনি মূলত জুন ২০২৩-এ প্রকাশিত 2.3.0 সংস্করণের ডেটাবেস মাইগ্রেশনের আগে এই সার্ভারের সাথে সংযোগ সেটআপ করেছিলেন। ভবিষ্যতের কোনো সার্ভার আপডেটে এই পুরোনো সংযোগ ব্যবহার করে সাইন ইন করার সুবিধা সরিয়ে দেওয়া হবে। অনুগ্রহ করে বর্তমান সার্ভার সংযোগটি মুছে ফেলুন এবং একই সার্ভার ঠিকানা ও লগইন তথ্য ব্যবহার করে আবার সংযোগ করুন। এই ডিভাইসে কোনো মিডিয়া ডাউনলোড করা থাকলে, সার্ভারের সাথে সিঙ্ক করার জন্য সেগুলো আবার ডাউনলোড করতে হবে।",
|
||||
"MessagePodcastSearchField": "অনুসন্ধান শব্দ বা RSS ফিড URL লিখুন",
|
||||
"MessagePreparingDownloads": "ডাউনলোড প্রস্তুত করা হচ্ছে",
|
||||
"MessageProgressSyncFailed": "সার্ভারে আপনার শোনার অগ্রগতি জানানোর সর্বশেষ প্রচেষ্টা ব্যর্থ হয়েছে। মিডিয়া চলাকালীন প্রতি ১৫ সেকেন্ড থেকে ১ মিনিট পরপর অগ্রগতি সিঙ্ক করার চেষ্টা চলতে থাকবে।",
|
||||
"MessageReportBugsAndContribute": "বাগ রিপোর্ট করুন, বৈশিষ্ট্যের অনুরোধ করুন এবং এতে অবদান রাখুন",
|
||||
"MessageSeriesAlreadyDownloaded": "আপনি ইতিমধ্যে এই সিরিজের সব বই ডাউনলোড করেছেন।",
|
||||
"MessageSeriesDownloadConfirm": "{1} ফাইল(গুলো) সহ অনুপস্থিত {0} বই(গুলো) ডাউনলোড করুন, মোট {2}টি ফোল্ডার {3} এ?",
|
||||
@@ -310,6 +360,10 @@
|
||||
"MessageSocketConnectedOverUnmeteredCellular": "মিটারবিহীন সেলুলারের দ্বারা সকেট সংযোগ করা হয়েছে",
|
||||
"MessageSocketConnectedOverUnmeteredWifi": "মিটারবিহীন ওয়াইফাইয়ের মাধ্যমে সকেট সংযুক্ত",
|
||||
"MessageSocketNotConnected": "সকেট সংযুক্ত নয়",
|
||||
"MessageStorageAccessDenied": "অ্যাক্সেস প্রত্যাখ্যান করা হয়েছে",
|
||||
"MessageStoragePermissionDenied": "অনুমতি প্রত্যাখ্যান করা হয়েছে",
|
||||
"MessageStorageWriteAccessRequired": "এই ফোল্ডারে আপনার লেখার অনুমতি নেই। আপনি কি অ্যাক্সেস দিতে চান?",
|
||||
"MessageWaitingForAvailableStorage": "স্টোরেজে জায়গা খালি হওয়ার অপেক্ষা করা হচ্ছে",
|
||||
"NoteRSSFeedPodcastAppsHttps": "সতর্কতা: বেশিরভাগ পডকাস্ট অ্যাপের জন্য প্রয়োজন হবে RSS ফিড URL যেটি HTTPS ব্যবহার করছে",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "সতর্কতা: আপনার 1 বা তার বেশি পর্বের একটি পাব তারিখ নেই। কিছু পডকাস্ট অ্যাপের এটি প্রয়োজন।",
|
||||
"ToastBookmarkCreateFailed": "বুকমার্ক তৈরি করতে ব্যর্থ",
|
||||
@@ -323,5 +377,7 @@
|
||||
"ToastPodcastCreateSuccess": "পডকাস্ট সফলভাবে তৈরি করা হয়েছে",
|
||||
"ToastRSSFeedCloseFailed": "RSS ফিড বন্ধ করতে ব্যর্থ",
|
||||
"ToastRSSFeedCloseSuccess": "RSS ফিড বন্ধ",
|
||||
"ToastStreamingNotAllowedOnCellular": "সেলুলার ডেটাতে স্ট্রিমিং অনুমোদিত নয়"
|
||||
"ToastStreamingNotAllowedOnCellular": "সেলুলার ডেটাতে স্ট্রিমিং অনুমোদিত নয়",
|
||||
"UnitMinutesShort": "{0} মিনিট",
|
||||
"UnitSecondsShort": "{0} সেকেন্ড"
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"ButtonAdd": "Přidat",
|
||||
"ButtonAddNewServer": "Přidat nový server",
|
||||
"ButtonAllow": "Povolit",
|
||||
"ButtonAuthors": "Autoři",
|
||||
"ButtonBack": "Zpět",
|
||||
"ButtonCancel": "Zrušit",
|
||||
@@ -89,7 +90,7 @@
|
||||
"HeaderSleepTimer": "Časovač spánku",
|
||||
"HeaderSleepTimerSettings": "Nastavení časovače spánku",
|
||||
"HeaderStatsMinutesListeningChart": "Poslech za posledních 7 dní (min)",
|
||||
"HeaderStatsRecentSessions": "Poslední sezení",
|
||||
"HeaderStatsRecentSessions": "Nedávné relace",
|
||||
"HeaderTableOfContents": "Obsah",
|
||||
"HeaderUserInterfaceSettings": "Nastavení uživatelského rozhraní",
|
||||
"HeaderWelcome": "Vítejte, <strong>{0}</strong>",
|
||||
@@ -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?",
|
||||
@@ -305,13 +306,14 @@
|
||||
"MessageConfirmDiscardProgress": "Opravdu chcete zahodit svůj pokrok?",
|
||||
"MessageConfirmDownloadUsingCellular": "Chystáte se stahovat přes mobilní data. Toto může zahrnovat poplatky za mobilní data. Chcete pokračovat?",
|
||||
"MessageConfirmMarkAsFinished": "Opravdu chcete tuto položku označit jako dokončenou?",
|
||||
"MessageConfirmPlaybackTime": "Spustit přehrávání pro \"{0}\" v {1}?",
|
||||
"MessageConfirmPlaybackTime": "Spustit přehrávání \"{0}\" od {1}?",
|
||||
"MessageConfirmRemoveBookmark": "Opravdu chcete odebrat záložku?",
|
||||
"MessageConfirmStreamingUsingCellular": "Chystáte se přehrávat přes mobilní data. Toto může zahrnovat poplatky za mobilní data. Chcete pokračovat?",
|
||||
"MessageDiscardProgress": "Zahodit pokrok",
|
||||
"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": "Přístup zamítnut",
|
||||
"MessageStorageWriteAccessRequired": "Aplikace nemá oprávnění k zápisu do této složky. Chcete ho udělit?",
|
||||
"MessageWaitingForAvailableStorage": "Čekání 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,24 +1,25 @@
|
||||
{
|
||||
"ButtonAdd": "Tilføj",
|
||||
"ButtonAddNewServer": "Tilføj ny server",
|
||||
"ButtonAllow": "Tillad",
|
||||
"ButtonAuthors": "Forfattere",
|
||||
"ButtonBack": "Tilbage",
|
||||
"ButtonCancel": "Annuller",
|
||||
"ButtonCancelTimer": "Stop timer",
|
||||
"ButtonClearFilter": "Ryd filter",
|
||||
"ButtonClearLogs": "Rens Logfiler",
|
||||
"ButtonClearLogs": "Ryd Logfiler",
|
||||
"ButtonCloseFeed": "Luk feed",
|
||||
"ButtonCollections": "Samlinger",
|
||||
"ButtonConnect": "Forbind",
|
||||
"ButtonConnectToServer": "Forbind til Server",
|
||||
"ButtonConnectToServer": "Forbind til server",
|
||||
"ButtonCreate": "Opret",
|
||||
"ButtonCreateBookmark": "Opret Bogmærker",
|
||||
"ButtonCreateNewPlaylist": "Opret Ny Playlist",
|
||||
"ButtonCreateBookmark": "Opret Bogmærke",
|
||||
"ButtonCreateNewPlaylist": "Opret ny playliste",
|
||||
"ButtonDelete": "Slet",
|
||||
"ButtonDeleteLocalEpisode": "Slet lokalt afsnit",
|
||||
"ButtonDeleteLocalFile": "Slet lokal fil",
|
||||
"ButtonDeleteLocalItem": "Slet lokalt element",
|
||||
"ButtonDisableAutoTimer": "Afbryd Auto Timer",
|
||||
"ButtonDisableAutoTimer": "Afbryd auto timer",
|
||||
"ButtonDisconnect": "Luk forbindelse",
|
||||
"ButtonGoToWebClient": "Gå til webklient",
|
||||
"ButtonHistory": "Historik",
|
||||
@@ -26,83 +27,83 @@
|
||||
"ButtonIssues": "Problemer",
|
||||
"ButtonLatest": "Seneste",
|
||||
"ButtonLibrary": "Bibliotek",
|
||||
"ButtonLocalMedia": "Lokalt medie",
|
||||
"ButtonLocalMedia": "Lokale medier",
|
||||
"ButtonLogs": "Logfiler",
|
||||
"ButtonManageLocalFiles": "Administrér Lokale Filer",
|
||||
"ButtonManageLocalFiles": "Administrér lokale filer",
|
||||
"ButtonMaskServerAddress": "Maskér server adresse",
|
||||
"ButtonNewFolder": "Ny Folder",
|
||||
"ButtonNextEpisode": "Næste Afsnit",
|
||||
"ButtonNewFolder": "Ny mappe",
|
||||
"ButtonNextEpisode": "Næste afsnit",
|
||||
"ButtonOk": "Ok",
|
||||
"ButtonOpenFeed": "Åbn feed",
|
||||
"ButtonOverride": "Overskriv",
|
||||
"ButtonPause": "Pause",
|
||||
"ButtonPlay": "Afspil",
|
||||
"ButtonPlayEpisode": "Afspil Afsnit",
|
||||
"ButtonPlaylists": "Afspilningslister",
|
||||
"ButtonPlayEpisode": "Afspil afsnit",
|
||||
"ButtonPlaylists": "Playlister",
|
||||
"ButtonRead": "Læs",
|
||||
"ButtonReadLess": "Se mindre",
|
||||
"ButtonReadMore": "Se mere",
|
||||
"ButtonRemove": "Fjern",
|
||||
"ButtonRemoveFromServer": "Fjern fra Server",
|
||||
"ButtonRemoveFromServer": "Fjern fra server",
|
||||
"ButtonSave": "Gem",
|
||||
"ButtonSaveOrder": "Gem Rækkefølge",
|
||||
"ButtonSaveOrder": "Gem rækkefølge",
|
||||
"ButtonSearch": "Søg",
|
||||
"ButtonSendEbookToDevice": "Send E-bog til Enhed",
|
||||
"ButtonSendEbookToDevice": "Send e-bog til enhed",
|
||||
"ButtonSeries": "Serier",
|
||||
"ButtonSetTimer": "Indstil timer",
|
||||
"ButtonStream": "Stream",
|
||||
"ButtonSubmit": "Send",
|
||||
"ButtonSwitchServerUser": "Skift Server/Bruger",
|
||||
"ButtonSwitchServerUser": "Skift server/bruger",
|
||||
"ButtonUnmaskServerAddress": "Afmaskér server adresse",
|
||||
"ButtonUserStats": "Bruger statitstik",
|
||||
"ButtonUserStats": "Brugerstatistik",
|
||||
"ButtonYes": "Ja",
|
||||
"HeaderAccount": "Konto",
|
||||
"HeaderAdvanced": "Avanceret",
|
||||
"HeaderAndroidAutoSettings": "Android Auto Indstillinger",
|
||||
"HeaderAndroidAutoSettings": "Android Auto indstillinger",
|
||||
"HeaderAudioTracks": "Lydspor",
|
||||
"HeaderChapters": "Kapitler",
|
||||
"HeaderCollection": "Samling",
|
||||
"HeaderCollectionItems": "Bøger i samlingen",
|
||||
"HeaderConfirm": "Bekræft",
|
||||
"HeaderConnectionStatus": "Forbindelsesstatus",
|
||||
"HeaderDataSettings": "Data indstillinger",
|
||||
"HeaderDataSettings": "Dataindstillinger",
|
||||
"HeaderDetails": "Detaljer",
|
||||
"HeaderDownloads": "Downloads",
|
||||
"HeaderDownloads": "Overførsler",
|
||||
"HeaderEbookFiles": "E-bogsfiler",
|
||||
"HeaderEpisodes": "Afsnit",
|
||||
"HeaderEreaderSettings": "Indstillinger for e-bogslæsere",
|
||||
"HeaderLatestEpisodes": "Nyeste afsnit",
|
||||
"HeaderLibraries": "Biblioteker",
|
||||
"HeaderLocalFolders": "Lokale mapper",
|
||||
"HeaderLocalLibraryItems": "Lokale biblioteks elementer",
|
||||
"HeaderNewPlaylist": "Ny afspilningsliste",
|
||||
"HeaderLocalLibraryItems": "Lokale bibliotekselementer",
|
||||
"HeaderNewPlaylist": "Ny playliste",
|
||||
"HeaderOpenRSSFeed": "Åbn RSS-feed",
|
||||
"HeaderPlaybackSettings": "Afspilnings indstillinger",
|
||||
"HeaderPlaylist": "Afspilningsliste",
|
||||
"HeaderPlaylistItems": "Elementer i afspilningslisten",
|
||||
"HeaderProgressSyncFailed": "Fremskridt synkronisering fejlede",
|
||||
"HeaderPlaybackSettings": "Afspilningsindstillinger",
|
||||
"HeaderPlaylist": "Playliste",
|
||||
"HeaderPlaylistItems": "Elementer i playlisten",
|
||||
"HeaderProgressSyncFailed": "Fremskridtssynkronisering fejlede",
|
||||
"HeaderRSSFeed": "RSS Feed",
|
||||
"HeaderRSSFeedGeneral": "RSS-detaljer",
|
||||
"HeaderRSSFeedIsOpen": "RSS-feedet er åbent",
|
||||
"HeaderSelectDownloadLocation": "Vælg Download Placering",
|
||||
"HeaderSelectDownloadLocation": "Vælg overførselslokation",
|
||||
"HeaderSettings": "Indstillinger",
|
||||
"HeaderSleepTimer": "Sovefunktion",
|
||||
"HeaderSleepTimerSettings": "Sleep Timer instillinger",
|
||||
"HeaderSleepTimerSettings": "Sovefunktionsinstillinger",
|
||||
"HeaderStatsMinutesListeningChart": "Minutter lyttet (sidste 7 dage)",
|
||||
"HeaderStatsRecentSessions": "Seneste sessioner",
|
||||
"HeaderTableOfContents": "Indholdsfortegnelse",
|
||||
"HeaderUserInterfaceSettings": "Indstillinger for brugergrænseflade",
|
||||
"HeaderWelcome": "Velkommen, <strong>{0}</strong>",
|
||||
"HeaderYourStats": "Din statistik",
|
||||
"LabelAddToPlaylist": "Tilføj til afspilningsliste",
|
||||
"LabelAddToPlaylist": "Tilføj til playliste",
|
||||
"LabelAddedAt": "Tilføjet",
|
||||
"LabelAddedDate": "Tilføjede {0}",
|
||||
"LabelAll": "Alle",
|
||||
"LabelAllowSeekingOnMediaControls": "Tillad positionssøgning på mediemeddelelseskontroller",
|
||||
"LabelAlways": "Altid",
|
||||
"LabelAndroidAutoBrowseLimitForGrouping": "Alfabetisk Rullemenu Begrænsning",
|
||||
"LabelAndroidAutoBrowseLimitForGrouping": "Alfabetisk rullemenusbegrænsning",
|
||||
"LabelAndroidAutoBrowseLimitForGroupingHelp": "Brug ikke alfabetisk rullemenu når der er færre end dette antal elementer at vise",
|
||||
"LabelAndroidAutoBrowseSeriesSequenceOrder": "Bogserie Rækkefølge",
|
||||
"LabelAndroidAutoBrowseSeriesSequenceOrder": "Bogseriens rækkefølge",
|
||||
"LabelAskConfirmation": "Bed om bekræftelse",
|
||||
"LabelAuthor": "Forfatter",
|
||||
"LabelAuthorFirstLast": "Forfatter (Fornavn Efternavn)",
|
||||
@@ -110,10 +111,10 @@
|
||||
"LabelAuthors": "Forfattere",
|
||||
"LabelAutoDownloadEpisodes": "Hent automatisk afsnit",
|
||||
"LabelAutoRewindTime": "Automatisk tilbagespolingstid",
|
||||
"LabelAutoSleepTimer": "Auto søvn-timer",
|
||||
"LabelAutoSleepTimerAutoRewind": "Automatisk sleep timer automatisk tilbagespoling",
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "Når den automatiske sleep-timer er færdig, vil genafspilning af elementet automatisk spole din position tilbage.",
|
||||
"LabelAutoSleepTimerHelp": "Når der afspilles medie mellem de angivne start- og sluttidspunkter, starter en sleep-timer automatisk.",
|
||||
"LabelAutoSleepTimer": "Auto søvnfunktion",
|
||||
"LabelAutoSleepTimerAutoRewind": "automatisk tilbagespoling for automatisk søvnfunktion",
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "Når den automatiske søvnfunktion er færdig, vil genafspilning af elementet automatisk spole din position tilbage.",
|
||||
"LabelAutoSleepTimerHelp": "Når der afspilles medie mellem de angivne start- og sluttidspunkter, starter en søvnfunktion automatisk.",
|
||||
"LabelBooks": "Bøger",
|
||||
"LabelByAuthor": "af {0}",
|
||||
"LabelChapterTrack": "Kapitel spor",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"ButtonAdd": "Hinzufügen",
|
||||
"ButtonAddNewServer": "Neuen Server hinzufügen",
|
||||
"ButtonAllow": "Erlauben",
|
||||
"ButtonAuthors": "Autoren",
|
||||
"ButtonBack": "Zurück",
|
||||
"ButtonCancel": "Abbrechen",
|
||||
@@ -68,9 +69,9 @@
|
||||
"HeaderDataSettings": "Daten-Einstellungen",
|
||||
"HeaderDetails": "Details",
|
||||
"HeaderDownloads": "Downloads",
|
||||
"HeaderEbookFiles": "E-Book-Dateien",
|
||||
"HeaderEbookFiles": "Buch-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"
|
||||
}
|
||||
@@ -47,7 +47,7 @@
|
||||
"ButtonSave": "Αποθήκευση",
|
||||
"ButtonSaveOrder": "Αποθήκευση Ταξινόμησης",
|
||||
"ButtonSearch": "Αναζήτηση",
|
||||
"ButtonSendEbookToDevice": "Αποστολή Ebook σε Συσκευή",
|
||||
"ButtonSendEbookToDevice": "Αποστολή του βιβλίου σε συσκευή",
|
||||
"ButtonSeries": "Σειρά",
|
||||
"ButtonSetTimer": "Ορισμός Χρονοδιακόπτη",
|
||||
"ButtonStream": "Μετάδοση",
|
||||
@@ -68,7 +68,7 @@
|
||||
"HeaderDataSettings": "Ρυθμίσεις Δεδομένων",
|
||||
"HeaderDetails": "Λεπτομέρειες",
|
||||
"HeaderDownloads": "Λήψεις",
|
||||
"HeaderEbookFiles": "Αρχεία Ebook",
|
||||
"HeaderEbookFiles": "Αρχεία του βιβλίου",
|
||||
"HeaderEpisodes": "Επεισόδια",
|
||||
"HeaderEreaderSettings": "Ρυθμίσεις Ereader",
|
||||
"HeaderLatestEpisodes": "Τελευταία Επεισόδια",
|
||||
@@ -121,7 +121,7 @@
|
||||
"LabelClosePlayer": "Κλείσιμο αναπαραγωγής",
|
||||
"LabelCollapseSeries": "Σύμπτυξη Σειράς",
|
||||
"LabelComplete": "Ολοκλήρωση",
|
||||
"LabelContinueBooks": "Συνέχεια Βιβλίων",
|
||||
"LabelContinueBooks": "Συνέχεια βιβλίων",
|
||||
"LabelContinueEpisodes": "Συνέχεια Επεισοδίων",
|
||||
"LabelContinueListening": "Συνέχεια Ακρόασης",
|
||||
"LabelContinueReading": "Συνέχεια Ανάγνωσης",
|
||||
@@ -140,8 +140,8 @@
|
||||
"LabelDownloadUsingCellular": "Λήψη με χρήση δεδομένων",
|
||||
"LabelDownloaded": "Ληφθέντα",
|
||||
"LabelDuration": "Διάρκεια",
|
||||
"LabelEbook": "Ηλεκτρονικό Βιβλίο/ Ebook",
|
||||
"LabelEbooks": "Ηλεκτρονικά Βιβλία / Ebooks",
|
||||
"LabelEbook": "Ηλεκτρονικό βιβλίο",
|
||||
"LabelEbooks": "Ηλεκτρονικά βιβλία",
|
||||
"LabelEnable": "Ενεργοποίηση",
|
||||
"LabelEnableMp3IndexSeeking": "Ενεργοποίηση αναζήτησης σε mp3 ευρετήριο",
|
||||
"LabelEnableMp3IndexSeekingHelp": "Αυτή η ρύθμιση θα πρέπει να είναι ενεργοποιημένη μόνο εάν έχετε αρχεία mp3 που δεν έχει γίνει σωστή αναζήτηση. Η ανακριβής αναζήτηση πιθανότατα οφείλεται σε αρχεία MP3 με μεταβλητό ρυθμό μετάδοσης bit (VBR). Αυτή η ρύθμιση θα επιβάλει την αναζήτηση ευρετηρίου, στην οποία δημιουργείται μια αντιστοίχιση χρόνου-byte καθώς διαβάζεται το αρχείο. Σε ορισμένες περιπτώσεις, με μεγάλα αρχεία MP3, θα υπάρχει καθυστέρηση κατά την αναζήτηση προς το τέλος του αρχείου.",
|
||||
@@ -163,7 +163,7 @@
|
||||
"LabelGenre": "Λογοτεχνικό Είδος",
|
||||
"LabelGenres": "Λογοτεχνικά Είδη",
|
||||
"LabelHapticFeedback": "Απτική ανατροφοδότηση",
|
||||
"LabelHasEbook": "Έχει ebook",
|
||||
"LabelHasEbook": "Έχει βιβλίο",
|
||||
"LabelHasSupplementaryEbook": "Διαθέτει συμπληρωματικό ηλεκτρονικό βιβλίο",
|
||||
"LabelHeavy": "Έντονη",
|
||||
"LabelHigh": "Υψηλή",
|
||||
@@ -218,6 +218,8 @@
|
||||
"LabelPlaybackDirect": "Άμεση αναπαραγωγή",
|
||||
"LabelPlaybackLocal": "Αναπαραγωγή τοπικού αρχείου",
|
||||
"LabelPlaybackSpeed": "Ταχύτητα Αναπαραγωγής",
|
||||
"LabelPodcast": "Πόντκαστ",
|
||||
"LabelPodcasts": "Πόντκαστ",
|
||||
"LabelProgress": "Πρόοδος",
|
||||
"LabelPubDate": "Ημερομηνία έκδοσης",
|
||||
"LabelPublishYear": "Χρονολογία Έκδοσης",
|
||||
@@ -261,6 +263,7 @@
|
||||
"LabelVeryHigh": "Πολύ Ψηλά",
|
||||
"LabelVeryLow": "Πολύ Χαμηλά",
|
||||
"LabelYourProgress": "Η Πρόοδος Σας",
|
||||
"MessageBookshelfEmpty": "Το ράφι είναι άδειο",
|
||||
"MessageDownloadCompleteProcessing": "Η λήψη ολοκληρώθηκε. Επεξεργασία...",
|
||||
"MessageDownloading": "Λήψη...",
|
||||
"MessageDownloadingEpisode": "Λήψη επεισοδίου",
|
||||
@@ -268,5 +271,7 @@
|
||||
"MessageMarkAsFinished": "Σήμανση ως Ολοκληρωμένο",
|
||||
"MessageNoItemsFound": "Δεν βρέθηκαν αντικείμενα",
|
||||
"MessageNoNetworkConnection": "Δεν υπάρχει σύνδεση δικτύου",
|
||||
"MessageNoUserPlaylists": "Δεν έχετε λίστες αναπαραγωγής"
|
||||
"MessageNoUserPlaylists": "Δεν έχετε λίστες αναπαραγωγής",
|
||||
"UnitMinutesShort": "{0} min",
|
||||
"UnitSecondsShort": "{0} s"
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"ButtonAdd": "Añadir",
|
||||
"ButtonAddNewServer": "Añadir servidor nuevo",
|
||||
"ButtonAllow": "Permitir",
|
||||
"ButtonAuthors": "Autores",
|
||||
"ButtonBack": "Atrás",
|
||||
"ButtonCancel": "Cancelar",
|
||||
@@ -312,6 +313,7 @@
|
||||
"MessageDownloadCompleteProcessing": "Descarga completada. Procesando...",
|
||||
"MessageDownloading": "Descargando...",
|
||||
"MessageDownloadingEpisode": "Descargando episodio",
|
||||
"MessageDownloadingFile": "Descargando",
|
||||
"MessageEpisodesQueuedForDownload": "{0} episodio(s) en cola para descargar",
|
||||
"MessageFailedToRefreshToken": "No se pudo actualizar el token, se requiere reiniciar la sesión",
|
||||
"MessageFeedURLWillBe": "El URL del suministro será {0}",
|
||||
@@ -347,6 +349,7 @@
|
||||
"MessageOldServerConnectionWarning": "La configuración de la conexión al servidor utiliza un identificador de usuario antiguo. Elimine y vuelva a añadir esta conexión al servidor.",
|
||||
"MessageOldServerConnectionWarningHelp": "Usted configuró originalmente la conexión a este servidor antes de la migración de la base de datos en la versión 2.3.0, publicada en junio de 2023. Una futura actualización del servidor eliminará la posibilidad de iniciar sesión con esta conexión antigua. Por favor, elimine la conexión existente al servidor y conéctese de nuevo (utilizando la misma dirección del servidor y las mismas credenciales). Si tiene algún medio descargado en este dispositivo, será necesario descargarlo de nuevo para sincronizarlo con el servidor.",
|
||||
"MessagePodcastSearchField": "Introduzca el término de búsqueda o el URL del suministro RSS",
|
||||
"MessagePreparingDownloads": "Preparando descargas",
|
||||
"MessageProgressSyncFailed": "El último intento de informar al servidor sobre el progreso de la escucha ha fallado. Las solicitudes de sincronización de progreso seguirán intentándose cada 15 segundos a 1 minuto mientras se reproduce el contenido multimedia.",
|
||||
"MessageReportBugsAndContribute": "Informe de defectos, solicite funciones y contribuya en",
|
||||
"MessageSeriesAlreadyDownloaded": "Ya ha descargado todos los libros de esta serie.",
|
||||
@@ -357,6 +360,10 @@
|
||||
"MessageSocketConnectedOverUnmeteredCellular": "Socket conectado a través de red celular sin tarificación por consumo",
|
||||
"MessageSocketConnectedOverUnmeteredWifi": "Socket conectado a través de una red Wi-Fi sin tarificación por consumo",
|
||||
"MessageSocketNotConnected": "Socket no conectado",
|
||||
"MessageStorageAccessDenied": "Acceso denegado",
|
||||
"MessageStoragePermissionDenied": "Permiso denegado",
|
||||
"MessageStorageWriteAccessRequired": "No tienes permisos de escritura en esta carpeta. ¿Quieres conceder acceso?",
|
||||
"MessageWaitingForAvailableStorage": "Esperando almacenamiento disponible",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Atención: la mayoría de las aplicaciones de pódcast requieren que el URL del suministro RSS use HTTPS",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Advertencia: 1 o más de sus episodios no tienen fecha de publicación. Algunas aplicaciones de podcast lo requieren.",
|
||||
"ToastBookmarkCreateFailed": "No se pudo crear el marcador",
|
||||
|
||||
@@ -53,5 +53,6 @@
|
||||
"ButtonStream": "پخش آنلاین",
|
||||
"ButtonSubmit": "ثبت",
|
||||
"ButtonSwitchServerUser": "تعویض سرور/کاربر",
|
||||
"ButtonUnmaskServerAddress": "نمایش آدرس سرور"
|
||||
"ButtonUnmaskServerAddress": "نمایش آدرس سرور",
|
||||
"HeaderLocalFolders": "پوشههای محلی"
|
||||
}
|
||||
@@ -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",
|
||||
@@ -68,7 +69,7 @@
|
||||
"HeaderDataSettings": "Paramètres de données",
|
||||
"HeaderDetails": "Détails",
|
||||
"HeaderDownloads": "Téléchargements",
|
||||
"HeaderEbookFiles": "Fichiers des livres numériques",
|
||||
"HeaderEbookFiles": "Fichiers du livre",
|
||||
"HeaderEpisodes": "Épisodes",
|
||||
"HeaderEreaderSettings": "Paramètres de la liseuse",
|
||||
"HeaderLatestEpisodes": "Dernier épisodes",
|
||||
@@ -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",
|
||||
@@ -68,7 +69,7 @@
|
||||
"HeaderDataSettings": "Impostazioni dei dati",
|
||||
"HeaderDetails": "Dettagli",
|
||||
"HeaderDownloads": "Scaricamenti",
|
||||
"HeaderEbookFiles": "File dei libri",
|
||||
"HeaderEbookFiles": "File del libro",
|
||||
"HeaderEpisodes": "Episodi",
|
||||
"HeaderEreaderSettings": "Impostazioni lettore",
|
||||
"HeaderLatestEpisodes": "Ultimi Episodi",
|
||||
@@ -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,16 +18,20 @@
|
||||
"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",
|
||||
"ButtonMaskServerAddress": "Paslėpti serverio adresą",
|
||||
"ButtonNewFolder": "Naujas aplankas",
|
||||
"ButtonNextEpisode": "Kitas epizodas",
|
||||
"ButtonOpenFeed": "Atidaryti srautą",
|
||||
"ButtonOverride": "Perrašyti",
|
||||
"ButtonPause": "Pauzė",
|
||||
"ButtonPlay": "Groti",
|
||||
"ButtonPlayEpisode": "Groti episodą",
|
||||
@@ -43,29 +48,44 @@
|
||||
"ButtonSetTimer": "Nustatyti laikmatį",
|
||||
"ButtonStream": "Srautas",
|
||||
"ButtonSubmit": "Pateikti",
|
||||
"ButtonSwitchServerUser": "Keisti serverį/vartotoją",
|
||||
"ButtonUnmaskServerAddress": "Rodyti serverio adresą",
|
||||
"ButtonUserStats": "Vartotojo statistika",
|
||||
"ButtonYes": "Taip",
|
||||
"HeaderAccount": "Paskyra",
|
||||
"HeaderAdvanced": "Papildomi",
|
||||
"HeaderAndroidAutoSettings": "Android Auto nustatymai",
|
||||
"HeaderAudioTracks": "Garso takeliai",
|
||||
"HeaderChapters": "Skyriai",
|
||||
"HeaderCollection": "Kolekcija",
|
||||
"HeaderCollectionItems": "Kolekcijos elementai",
|
||||
"HeaderConfirm": "Patvirtinti",
|
||||
"HeaderConnectionStatus": "Ryšio statusas",
|
||||
"HeaderDataSettings": "Duomenų nustatymai",
|
||||
"HeaderDetails": "Detalės",
|
||||
"HeaderDownloads": "Atsisiuntimai",
|
||||
"HeaderEbookFiles": "Eknygos failai",
|
||||
"HeaderEpisodes": "Epizodai",
|
||||
"HeaderEreaderSettings": "Elektroninių skaitytuvų nustatymai",
|
||||
"HeaderLatestEpisodes": "Naujausi epizodai",
|
||||
"HeaderLibraries": "Bibliotekos",
|
||||
"HeaderLocalFolders": "Vietiniai aplankai",
|
||||
"HeaderNewPlaylist": "Naujas grojaraštis",
|
||||
"HeaderOpenRSSFeed": "Atidaryti RSS srautą",
|
||||
"HeaderPlaybackSettings": "Grojimo nustatymai",
|
||||
"HeaderPlaylist": "Grojaraštis",
|
||||
"HeaderPlaylistItems": "Grojaraščio elementai",
|
||||
"HeaderProgressSyncFailed": "Progreso sinchronizacija nepavyko",
|
||||
"HeaderRSSFeed": "RSS sklaidos kanalas",
|
||||
"HeaderRSSFeedGeneral": "RSS informacija",
|
||||
"HeaderRSSFeedIsOpen": "RSS srautas yra atidarytas",
|
||||
"HeaderSelectDownloadLocation": "Pasirinkti atsisiuntimo vietą",
|
||||
"HeaderSettings": "Nustatymai",
|
||||
"HeaderSleepTimer": "Miego laikmatis",
|
||||
"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 +102,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 +138,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 +159,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 +189,18 @@
|
||||
"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",
|
||||
"MessageConfirmDiscardProgress": "Ar tikrai norite ištrinti savo progresą?",
|
||||
"MessageDiscardProgress": "Ištrinti progresą",
|
||||
"MessageDownloadingEpisode": "Epizodas atsisiunčiamas",
|
||||
"MessageEpisodesQueuedForDownload": "{0} epizodai laukia atsisiuntimo",
|
||||
"MessageFeedURLWillBe": "Srauto URL bus {0}",
|
||||
@@ -175,6 +213,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ų",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"ButtonAdd": "Legg til",
|
||||
"ButtonAddNewServer": "Legg til ny tjener",
|
||||
"ButtonAllow": "Tillat",
|
||||
"ButtonAuthors": "Forfattere",
|
||||
"ButtonBack": "Tilbake",
|
||||
"ButtonCancel": "Avbryt",
|
||||
@@ -312,6 +313,7 @@
|
||||
"MessageDownloadCompleteProcessing": "Nedlasting fullført. Behandler …",
|
||||
"MessageDownloading": "Laster ned …",
|
||||
"MessageDownloadingEpisode": "Laster ned episode",
|
||||
"MessageDownloadingFile": "Laster ned {0}",
|
||||
"MessageEpisodesQueuedForDownload": "{0} Episode(r) lagt til i kø for nedlasting",
|
||||
"MessageFailedToRefreshToken": "Klarte ikke å oppdatere token, ny innlogging kreves",
|
||||
"MessageFeedURLWillBe": "Feed URL vil bli {0}",
|
||||
@@ -347,6 +349,7 @@
|
||||
"MessageOldServerConnectionWarning": "Konfigurasjonen til tjener-tilkoblingen bruker en gammel bruker ID. Vennligst slett og legg til igjen denne tjener-tilkoblingen.",
|
||||
"MessageOldServerConnectionWarningHelp": "Du opprettet opprinnelig tilkoblingen til denne tjeneren før database-migreringen i versjon 2.3.0, som ble lansert i juni 2023. En fremtidig oppdatering av tjeneren vil fjerne muligheten for å logge inn med denne gamle tilkoblingen. Vennligst slett den eksisterende tjenertilkoblingen og koble til på nytt (bruk samme tjeneradresse og påloggingsinformasjon). Hvis du har nedlastede medier på denne enheten, må disse lastes ned på nytt for å synkronisere med tjeneren.",
|
||||
"MessagePodcastSearchField": "Skriv inn søkeord eller URL til en RSS-strøm",
|
||||
"MessagePreparingDownloads": "Forbereder nedlastinger",
|
||||
"MessageProgressSyncFailed": "Det siste forsøket på å rapportere lyttefremdriften til tjeneren mislyktes. Forsøk på å synkronisere fremdriften vil fortsette hvert 15. sekund til hvert minutt så lenge mediet spilles av.",
|
||||
"MessageReportBugsAndContribute": "Rapporter feil, forespør funksjoner og tillegg og bidra på",
|
||||
"MessageSeriesAlreadyDownloaded": "Du har allerede lastet ned alle bøkene i denne serien.",
|
||||
@@ -357,6 +360,10 @@
|
||||
"MessageSocketConnectedOverUnmeteredCellular": "Tilkobling via ubegrenset mobildata",
|
||||
"MessageSocketConnectedOverUnmeteredWifi": "Tilkoblet over ubegrenset Wi-Fi",
|
||||
"MessageSocketNotConnected": "Socket ikke tilkoblet",
|
||||
"MessageStorageAccessDenied": "Ingen adgang",
|
||||
"MessageStoragePermissionDenied": "Ingen tilgang",
|
||||
"MessageStorageWriteAccessRequired": "Du har ikke skrivetilgang til denne mappen. Vil du tillate tilgangen?",
|
||||
"MessageWaitingForAvailableStorage": "Venter på tilgjengelig lagring",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Advarsel! De fleste podcast applikasjoner trenger RSS feed URL som bruker HTTPS",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Advarsel! 1 eller flere av episodene har ikke publikasjonsdato. Noen podcast applikasjoner trenger dette.",
|
||||
"ToastBookmarkCreateFailed": "Misslykkes å opprette bokmerke",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"ButtonAdd": "Dodaj",
|
||||
"ButtonAddNewServer": "Dodaj nowy serwer",
|
||||
"ButtonAllow": "Zezwól",
|
||||
"ButtonAuthors": "Autorzy",
|
||||
"ButtonBack": "Wstecz",
|
||||
"ButtonCancel": "Anuluj",
|
||||
@@ -312,6 +313,7 @@
|
||||
"MessageDownloadCompleteProcessing": "Pobieranie ukończone. Przetwarzanie...",
|
||||
"MessageDownloading": "Pobieranie...",
|
||||
"MessageDownloadingEpisode": "Pobieranie odcinka",
|
||||
"MessageDownloadingFile": "Pobieranie {0}",
|
||||
"MessageEpisodesQueuedForDownload": "{0} odcinki w kolejce do pobrania",
|
||||
"MessageFailedToRefreshToken": "Odświeżenie tokenu nie powiodło się. Wymagane jest ponowne zalogowanie",
|
||||
"MessageFeedURLWillBe": "URL kanału: {0}",
|
||||
@@ -347,6 +349,7 @@
|
||||
"MessageOldServerConnectionWarning": "Konfiguracja połączenia z serwerem używa starego identyfikatora użytkownika. Usuń i dodaj ponownie to połączenie z serwerem.",
|
||||
"MessageOldServerConnectionWarningHelp": "Połączenie z tym serwerem zostało pierwotnie skonfigurowane przed migracją bazy danych w wersji 2.3.0, wydanej w czerwcu 2023 r. Przyszła aktualizacja serwera usunie możliwość logowania się przy użyciu tego starego połączenia. Usuń istniejące połączenie z serwerem i połącz się ponownie (używając tego samego adresu serwera i danych logowania). Jeśli na tym urządzeniu znajdują się pobrane pliki multimedialne, konieczne będzie ich ponowne pobranie w celu synchronizacji z serwerem.",
|
||||
"MessagePodcastSearchField": "Wprowadź wyszukiwane hasło lub adres URL kanału RSS",
|
||||
"MessagePreparingDownloads": "Przygotowanie do pobierania",
|
||||
"MessageProgressSyncFailed": "Ostatnia próba zgłoszenia postępu słuchania do serwera nie powiodła się. Żądania synchronizacji postępu będą nadal podejmowane co 15 sekund do 1 minuty podczas odtwarzania multimediów.",
|
||||
"MessageReportBugsAndContribute": "Zgłoś błędy, pomysły i pomóż rozwijać aplikację na",
|
||||
"MessageSeriesAlreadyDownloaded": "Seria książek już pobrana.",
|
||||
@@ -357,6 +360,10 @@
|
||||
"MessageSocketConnectedOverUnmeteredCellular": "Gniazdo podłączone przez sieć komórkową bez limitu danych",
|
||||
"MessageSocketConnectedOverUnmeteredWifi": "Gniazdo podłączone przez Wi-Fi bez limitu danych",
|
||||
"MessageSocketNotConnected": "Gniazdo nie jest podłączone",
|
||||
"MessageStorageAccessDenied": "Brak dostępu",
|
||||
"MessageStoragePermissionDenied": "Brak uprawnień",
|
||||
"MessageStorageWriteAccessRequired": "Nie masz uprawnień zapisu do tego folderu. Czy chciałbyś nadać dostęp?",
|
||||
"MessageWaitingForAvailableStorage": "Oczekiwanie na wolną przestrzeń",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Ostrzeżenie: Większość aplikacji do obsługi podcastów wymaga, aby adres URL kanału RSS korzystał z protokołu HTTPS",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Ostrzeżenie: 1 lub więcej odcinków nie ma daty publikacji. Niektóre aplikacje do słuchania podcastów tego wymagają.",
|
||||
"ToastBookmarkCreateFailed": "Nie udało się utworzyć zakładki",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"ButtonAdd": "Lägg till",
|
||||
"ButtonAddNewServer": "Lägg till ny server",
|
||||
"ButtonAllow": "Tillåt",
|
||||
"ButtonAuthors": "Författare",
|
||||
"ButtonBack": "Tillbaka",
|
||||
"ButtonCancel": "Avbryt",
|
||||
@@ -312,6 +313,7 @@
|
||||
"MessageDownloadCompleteProcessing": "Hämtningen är klar. Bearbetar…",
|
||||
"MessageDownloading": "Hämtar…",
|
||||
"MessageDownloadingEpisode": "Hämtar avsnitt",
|
||||
"MessageDownloadingFile": "Hämtar {0}",
|
||||
"MessageEpisodesQueuedForDownload": "{0} avsnitt har lagts i hämtningskön",
|
||||
"MessageFailedToRefreshToken": "Det gick inte att förnya token. Du måste logga in igen.",
|
||||
"MessageFeedURLWillBe": "Flödets URL blir {0}",
|
||||
@@ -347,6 +349,7 @@
|
||||
"MessageOldServerConnectionWarning": "Serveranslutningen använder ett äldre användar-ID. Ta bort anslutningen och lägg till den igen.",
|
||||
"MessageOldServerConnectionWarningHelp": "Anslutningen till servern skapades före databasflytten i version 2.3.0, som gavs ut i juni 2023. En framtida serveruppdatering tar bort möjligheten att logga in med den äldre anslutningen. Ta bort serveranslutningen och anslut igen med samma serveradress och inloggningsuppgifter. Om du har hämtade medier på enheten måste de hämtas igen för att kunna synkroniseras med servern.",
|
||||
"MessagePodcastSearchField": "Ange en sökfras eller RSS-flödets URL",
|
||||
"MessagePreparingDownloads": "Förbereder hämtningar",
|
||||
"MessageProgressSyncFailed": "Det senaste försöket att skicka uppspelningspositionen till servern misslyckades. Appen fortsätter att försöka synkronisera positionen med 15 sekunders till en minuts mellanrum medan mediet spelas upp.",
|
||||
"MessageReportBugsAndContribute": "Rapportera fel, föreslå funktioner och bidra på",
|
||||
"MessageSeriesAlreadyDownloaded": "Du har redan hämtat alla böcker i serien.",
|
||||
@@ -357,6 +360,10 @@
|
||||
"MessageSocketConnectedOverUnmeteredCellular": "Socketanslutning via mobilnät utan datagräns",
|
||||
"MessageSocketConnectedOverUnmeteredWifi": "Socketanslutning via Wi-Fi utan datagräns",
|
||||
"MessageSocketNotConnected": "Socketanslutning saknas",
|
||||
"MessageStorageAccessDenied": "Åtkomst nekad",
|
||||
"MessageStoragePermissionDenied": "Behörighet nekad",
|
||||
"MessageStorageWriteAccessRequired": "Du har inte skrivåtkomst till denna mapp. Vill du bevilja åtkomst?",
|
||||
"MessageWaitingForAvailableStorage": "Väntar på tillgängligt lagringsutrymme",
|
||||
"NoteRSSFeedPodcastAppsHttps": "VARNING: De flesta poddappar kräver att RSS-flödets URL använder HTTPS",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "VARNING: Ett eller flera avsnitt saknar publiceringsdatum. Vissa poddappar kräver ett sådant datum.",
|
||||
"ToastBookmarkCreateFailed": "Det gick inte att skapa bokmärket",
|
||||
|
||||