Compare commits

...
Author SHA1 Message Date
advplyr 7014e04e6f iOS version bump 0.14.2 2026-09-27 17:50:38 -05:00
advplyr a00032beda Version bump v0.14.2-beta 2026-09-27 17:23:31 -05:00
advplyr 02acaf1159 Add Arabic, Hebrew, Korean and Slovak language options 2026-09-27 17:17:45 -05:00
advplyr 6f923ef1d8 Merge pull request #2017 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2026-09-27 17:06:19 -05:00
advplyr 679ebb16f8 Merge pull request #2027 from nichwall/download-clear-invalid-queue
Download clear invalid queue
2026-09-27 17:06:02 -05:00
advplyr ed356a8d99 Add try/catch around android cleanupExpired downloads call 2026-09-27 17:03:25 -05:00
Amirali Dehestani 33e187678d Translated using Weblate (Persian)
Currently translated at 14.6% (56 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fa/
2026-09-26 23:52:38 +02:00
Mateusz Lesiak 5fc952a9e5 Translated using Weblate (Polish)
Currently translated at 100.0% (381 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/pl/
2026-09-24 17:51:37 +00:00
oddhap a408c55cff Translated using Weblate (Norwegian Bokmål)
Currently translated at 100.0% (381 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/nb_NO/
2026-09-24 17:51:34 +00:00
Juan Benites a3d482e3f7 Translated using Weblate (Spanish)
Currently translated at 100.0% (381 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/es/
2026-09-24 17:51:30 +00:00
Mediokris 2f8c1731e9 Translated using Weblate (Norwegian Bokmål)
Currently translated at 99.7% (380 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/nb_NO/
2026-09-22 09:51:35 +00:00
Danol b5269f52c4 Translated using Weblate (Czech)
Currently translated at 100.0% (381 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/cs/
2026-09-22 09:51:33 +00:00
Daniel Nylander 9ebc7da984 Translated using Weblate (Swedish)
Currently translated at 100.0% (381 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sv/
2026-09-18 23:29:28 +02:00
Martynas c8c4b8b059 Translated using Weblate (Lithuanian)
Currently translated at 59.8% (228 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/lt/
2026-09-17 19:51:30 +00:00
Martynas c1f3e236f8 Translated using Weblate (Lithuanian)
Currently translated at 56.4% (215 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/lt/
2026-09-15 18:51:58 +00:00
Kim Josefsen 128e23fabf Translated using Weblate (Danish)
Currently translated at 98.4% (375 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/da/
2026-09-15 18:51:52 +00:00
Nicholas Wallace 989cf66715 Revert "Debug: add forced bad download queue entry"
This reverts commit 108f34bfb7.
2026-09-13 15:03:15 -07:00
Nicholas Wallace ce6883331e Fix: download queue null check before restoring queue 2026-09-13 15:03:04 -07:00
Nicholas Wallace 108f34bfb7 Debug: add forced bad download queue entry 2026-09-13 14:48:55 -07:00
J. Lavoie 869873acfd Translated using Weblate (Greek)
Currently translated at 72.1% (275 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/el/
2026-09-11 21:19:03 +00:00
samia shanta 96a44bcce7 Translated using Weblate (Bengali)
Currently translated at 100.0% (381 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/bn/
2026-09-11 21:19:03 +00:00
J. Lavoie ca2c023486 Translated using Weblate (Italian)
Currently translated at 99.7% (380 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/it/
2026-09-11 21:19:02 +00:00
J. Lavoie 46850159de Translated using Weblate (French)
Currently translated at 100.0% (381 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fr/
2026-09-11 21:19:02 +00:00
J. Lavoie bd4c3be210 Translated using Weblate (German)
Currently translated at 100.0% (381 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2026-09-11 21:19:01 +00:00
advplyr da4745217c Merge pull request #2022 from nichwall/weblate-workflow
Add weblate translation credit workflow
2026-09-11 16:18:48 -05:00
advplyr 3048237a4b Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2026-09-11 16:14:01 -05:00
advplyr e473155646 Update gh issue templates with 0.14.1 2026-09-11 16:13:56 -05:00
Nicholas Wallace 643603c073 Add weblate translation credit workflow 2026-09-11 14:09:09 -07:00
advplyr 2ac4de4d58 Bump iOS build number 2026-09-11 16:06:49 -05:00
advplyr 68931febf6 Fix crash on cleanExpiredLogs for iOS 2026-09-11 15:57:59 -05:00
advplyr 8561ada1d2 Merge pull request #2019 from nichwall/ios-workflow
Ios debug build workflow
2026-09-11 15:00:22 -05:00
Nicholas Wallace 06f4ba2e82 Delete iOS release workflow for separate PR 2026-09-11 06:43:42 -07:00
Nicholas Wallace 353e2f02df Reapply "Fix package-lock for macOS runner"
This reverts commit 94db1f72a7.
2026-09-11 06:38:58 -07:00
advplyr 2927ab92ae Bump iOS build number 2026-09-11 06:22:42 -05:00
Nicholas Wallace 94db1f72a7 Revert "Fix package-lock for macOS runner"
This reverts commit 607e5fc670.
2026-09-10 17:18:10 -07:00
Nicholas Wallace bf92d18b48 Change workflows to exclude optional dependencies 2026-09-10 17:16:19 -07:00
Nicholas Wallace c4aed2587f Initial IPA and Testflight push workflow 2026-09-10 16:36:23 -07:00
Nicholas Wallace 7a5e4f559c Add quiet flag to reduce amount of logs printed 2026-09-10 16:31:20 -07:00
Nicholas Wallace fe14ec0f45 Delete temporary workflow 2026-09-10 16:15:51 -07:00
Nicholas Wallace 607e5fc670 Fix package-lock for macOS runner 2026-09-10 16:15:22 -07:00
Nicholas Wallace a34a1bb2b9 Dummy workflow to get macOS npm dependencies 2026-09-10 16:13:23 -07:00
Nicholas Wallace e48f2f0da2 Initial iOS build workflow 2026-09-10 16:02:27 -07:00
Nicholas Wallace 32a8212653 Add inverted iOS icons for debug builds 2026-09-10 16:01:50 -07:00
advplyr 9a2f0c3e4a Add one time wipe of logs for iOS 2026-09-10 17:03:20 -05:00
advplyr 7292e367d2 iOS version bump 0.14.1 2026-09-08 17:35:53 -05:00
advplyr fe8f5de891 Update android target sdk to 36 2026-09-08 17:35:36 -05:00
advplyr c6d0f0e8b5 Version bump v0.14.1-beta 2026-09-08 17:22:02 -05:00
advplyr 14885467a4 Add padding on report bugs message on account page 2026-09-08 17:13:49 -05:00
advplyr 87f1c83cbf Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2026-09-08 17:11:14 -05:00
advplyr b706337852 Fix iOS expired logs never clearing 2026-09-08 17:10:30 -05:00
advplyr 34f2165ef6 Merge pull request #1956 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2026-09-08 16:36:14 -05:00
Pavel Miniutka 88ab456ce5 Translated using Weblate (Belarusian)
Currently translated at 100.0% (381 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/be/
2026-09-08 23:28:15 +02:00
J. Lavoie 022a9c5875 Translated using Weblate (Greek)
Currently translated at 71.9% (274 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/el/
2026-09-08 23:28:15 +02:00
J. Lavoie 4cb4183a12 Translated using Weblate (Finnish)
Currently translated at 98.9% (377 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fi/
2026-09-08 23:28:15 +02:00
J. Lavoie 62e80cb469 Translated using Weblate (Italian)
Currently translated at 99.7% (380 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/it/
2026-09-08 23:28:15 +02:00
J. Lavoie 37b19e9a7b Translated using Weblate (French)
Currently translated at 100.0% (381 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fr/
2026-09-08 23:28:15 +02:00
J. Lavoie 961e66b863 Translated using Weblate (German)
Currently translated at 100.0% (381 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2026-09-08 23:28:15 +02:00
samia shanta 6c56003c26 Translated using Weblate (Bengali)
Currently translated at 87.6% (334 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/bn/
2026-09-08 23:28:15 +02:00
Martynas d44dbd770a Translated using Weblate (Lithuanian)
Currently translated at 55.9% (213 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/lt/
2026-09-08 23:28:15 +02:00
kuci-JK ca04bb7a3e Translated using Weblate (Czech)
Currently translated at 99.2% (378 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/cs/
2026-09-08 23:28:15 +02:00
ugyes c832c86ca4 Translated using Weblate (Hungarian)
Currently translated at 100.0% (381 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hu/
2026-09-08 23:28:15 +02:00
Jan Paulzen 64cbb41b0b Translated using Weblate (German)
Currently translated at 100.0% (381 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2026-09-08 23:28:15 +02:00
advplyr d11d9860b6 iOS update refresh token failure to only logout when request returns 401/403 2026-09-08 16:28:01 -05:00
advplyr c1a0b96ae4 iOS version bump 0.14.0 2026-09-08 16:27:27 -05:00
epipenRX a09326b5b2 Pause instead of close playback on KEYCODE_MEDIA_STOP (#1913)
Car head units send KEYCODE_MEDIA_STOP when powering off, which was
calling closePlayback() and destroying the session entirely. This removed
the media notification from the shade and required manually reopening the
app to resume listening.

Change to pause() so the session and notification stay alive, matching
the behavior of YouTube, Plex, and Jellyfin. When the car powers back on
and sends KEYCODE_MEDIA_PLAY, playback resumes in the same session.
2026-09-07 17:41:37 -05:00
advplyr 1a290b4d07 Merge pull request #2000 from JorenVerbaandert/fix/playback-session-bug
Fix playback session bug
2026-09-07 17:32:42 -05:00
advplyr 8d8147ffea Merge pull request #2003 from ljlongwing/fix-1711-1712-library-switch
Leave playlist/collection detail pages when the library changes (#1711, #1712)
2026-09-07 17:20:56 -05:00
advplyr 17fcec4401 Merge pull request #1972 from Michael-128/fix/ipados-resize
Fix Stage Manager resize issue on iPadOS (#1563)
2026-09-07 17:18:05 -05:00
advplyr 79a925088f Merge pull request #2008 from huggenknubbel/cast-sleep-timer-volume
Let the sleep timer fade out on a cast device too
2026-09-07 17:13:49 -05:00
advplyr 2fc8735643 Merge pull request #2009 from huggenknubbel/cast-availability
Report receiver availability from the framework as well
2026-09-07 17:13:21 -05:00
advplyr 0b1dfa4c34 Merge pull request #2002 from nichwall/android-download-followup
Android download followup
2026-09-06 18:22:08 -05:00
advplyr 30c4fa7e16 Add try catch around scan download item 2026-09-06 18:11:49 -05:00
advplyr 71bd354bc7 Refactor token refresh to better handle types of failures and retries during download 2026-09-06 17:53:46 -05:00
advplyr e06ce4a868 Only clear refresh token and logout when refresh request explicitly rejects, prevents transient request errors from causing a logout 2026-09-06 17:16:36 -05:00
advplyr 90f8d99287 Merge pull request #2001 from JorenVerbaandert/docs/update-bug-report-template
Add 0.14 to bug report template, remove 0.12
2026-09-06 16:46:10 -05:00
huggenknubbel 1cdf0a59b1 cast: report receiver availability from the framework as well
ChromecastListener has always implemented CastStateListener, but nothing
ever registered it. Whether a receiver was reported as available hung
entirely on the media router callback, which does not always fire - and
then the cast button never appears even though devices are there.

The framework's own signal is registered now and the current state is
reported straight away. It is not taken at face value in one direction:
the framework drops back to NO_DEVICES_AVAILABLE when a session ends,
because it stops discovering at that point, which would hide the button
right after someone stops casting. The devices the media router still
knows decide that case.

The listener is dropped again when the activity goes.
2026-09-03 08:15:20 +02:00
Nicholas Wallace 6560038495 Use actual file size from server it size does not match metadata 2026-09-02 13:30:04 -07:00
Nicholas Wallace 4b8abe7deb Refresh auth token on download 401 2026-09-02 10:39:15 -07:00
Nicholas Wallace d230b16d5e Restore fullname check 2026-09-02 10:37:59 -07:00
huggenknubbel 0e71eb0308 cast: let the sleep timer fade out on a cast device too
The sleep timer fades playback down to 10% over its last minute by setting the
player's volume. CastPlayer.setVolume was an empty stub and getVolume returned
zero, so while casting the fade did nothing at all.

Both now go through the media stream volume the Cast SDK offers. That is the
receiver's volume for this stream only: the device volume stays where the user
put it, and on a cast group the balance between its members is untouched,
because the receiver applies the stream volume to the group's output.
2026-08-30 23:38:27 +02:00
LJ 4a1cf0ea4a Leave playlist/collection detail pages when the library changes (#1711, #1712)
The item detail page already listens for the app-wide `library-changed`
event and navigates away; the playlist and collection detail pages did
not, so switching libraries left them showing stale content with no way
back to the list.

- playlist/_id.vue: route to /bookshelf/playlists on library change
- collection/_id.vue: route to /bookshelf/collections unless the new
  library is the collection's own library
2026-08-28 13:03:59 -06:00
Nicholas Wallace d2196ce035 Add download complete to logs 2026-08-28 07:36:53 -07:00
Nicholas Wallace a100226a85 Add AbsLogger to download paths 2026-08-27 20:38:48 -07:00
Nicholas Wallace ba66869502 Remove unnecessary changes 2026-08-27 15:31:32 -07:00
Nicholas Wallace 02e3f3541f Simplify and revert Capacitor changes 2026-08-27 15:12:32 -07:00
Nicholas Wallace f25ff6344e Inline download resume policy 2026-08-27 15:12:04 -07:00
Nicholas Wallace c800ed727a Fix concurrent podcast download cover issue 2026-08-27 14:52:14 -07:00
Joren Verbaandert 4eb848ae5f Update the feature request template 2026-08-27 22:53:48 +02:00
Joren Verbaandert 9dfa68ea8b Add 0.14 to bug report template, remove 0.12 2026-08-27 22:35:29 +02:00
Nicholas Wallace 7989607313 Retry from beginning of file on failure and restore 2026-08-27 13:26:51 -07:00
Nicholas Wallace 61c314a531 Initial download service startup fixes and range request fix 2026-08-27 13:08:46 -07:00
Joren Verbaandert a709e994a0 Fix playback session bug 2026-08-27 21:26:12 +02:00
Michael-128 96fc9adbbf Fix Stage Manager resize issue on iPadOS (#1563) 2026-08-24 18:12:06 +02:00
79 changed files with 1647 additions and 466 deletions

No files matched your search

+4 -4
View File
@@ -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
+4 -4
View File
@@ -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
+1 -1
View File
@@ -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
+68
View File
@@ -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
+1 -1
View File
@@ -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
+25
View File
@@ -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 }}"
+2 -2
View File
@@ -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"
}
+2 -2
View File
@@ -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'
+4 -4
View File
@@ -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 = "";
+5 -1
View File
@@ -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
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 399 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 672 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 845 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

+2
View File
@@ -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
+2 -2
View File
@@ -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
+6 -7
View File
@@ -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
+37 -49
View File
@@ -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))
}
}
}
+63 -6
View File
@@ -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",
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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">
+13 -1
View File
@@ -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>
+1 -2
View File
@@ -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>
+7
View File
@@ -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)
}
+5 -1
View File
@@ -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' },
+7
View File
@@ -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": "Не ўдалося стварыць закладку",
+58 -2
View File
@@ -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} সেকেন্ড"
}
+11 -4
View File
@@ -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",
+35 -34
View File
@@ -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",
+11 -4
View File
@@ -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"
}
+12 -7
View File
@@ -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"
}
+7
View File
@@ -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",
+2 -1
View File
@@ -53,5 +53,6 @@
"ButtonStream": "پخش آنلاین",
"ButtonSubmit": "ثبت",
"ButtonSwitchServerUser": "تعویض سرور/کاربر",
"ButtonUnmaskServerAddress": "نمایش آدرس سرور"
"ButtonUnmaskServerAddress": "نمایش آدرس سرور",
"HeaderLocalFolders": "پوشه‌های محلی"
}
+4
View File
@@ -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",
+11 -4
View File
@@ -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"
}
+8 -1
View File
@@ -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",
+7 -1
View File
@@ -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",
+39
View File
@@ -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ų",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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",