Compare commits

...
257 Commits
Author SHA1 Message Date
advplyr edb1f7efc9 Version bump v2.37.2 2026-10-23 14:36:19 -05:00
advplyr 92517f473b Update devcontainer.json node variant to 24 2026-10-23 14:19:39 -05:00
advplyr 9715c7a8f7 Merge pull request #5668 from nichwall/fix/issue-5637-copy-fallback
Add EPERM copy fallback
2026-10-22 17:36:02 -05:00
advplyr 95cb5bad27 Remove fs-extra sync updates 2026-10-22 17:34:09 -05:00
Nicholas Wallace 5cb461f9b5 Add EPERM move fallback 2026-10-20 21:55:23 -07:00
Nicholas Wallace 16aa80ed5c Add EPERM copy fallback 2026-10-20 21:39:26 -07:00
advplyr 743d915220 Normalize RouterBasePath env variable 2026-10-05 17:45:14 -05:00
advplyr 34715558df Use transaction and check root on server init 2026-10-04 20:03:56 -05:00
advplyr 2a0507ffc6 Update linuxpackager to only set 0775 on required files #5629 2026-10-02 17:19:38 -05:00
advplyr cff295fdc9 Merge pull request #5418 from SharonZhu0122/fix-5306-feed-cover-cache
Fix RSS feed cover images not updating after cover change (#5306)
2026-10-01 18:13:31 -05:00
advplyr 6b60beff6e Extract out rss feed image URL construction into a separate function 2026-10-01 18:13:09 -05:00
advplyr db433ce076 Merge pull request #5430 from boqiaok/fix/issue-5176-audible-products
Fix Audible search when products are null
2026-10-01 17:56:26 -05:00
advplyr 5edd20dfa3 Merge pull request #5437 from rtainaan/codex/handle-repeated-upload-files
Fix upload crash with repeated file fields
2026-10-01 17:52:59 -05:00
advplyr 3563d49424 Version bump v2.37.1 2026-09-29 17:30:00 -05:00
advplyr 06935b78b5 Merge pull request #5615 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2026-09-29 17:24:44 -05:00
Gabriel 4aee64da07 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (1167 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/pt_BR/
2026-09-30 00:18:46 +02:00
Martynas 02cf2da4fa Translated using Weblate (Lithuanian)
Currently translated at 60.1% (702 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/lt/
2026-09-30 00:18:46 +02:00
Indrek Ankur 366320bf94 Translated using Weblate (Estonian)
Currently translated at 64.2% (750 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/et/
2026-09-30 00:18:45 +02:00
advplyr a2cba8dd05 OIDC extend cookie max age to 10 minutes and redirect to login page when no session #5614 2026-09-29 17:18:38 -05:00
advplyr 86bc18487b Merge pull request #5606 from SharonZhu0122/fix-5604-oidc-session-cleanup
Expire abandoned OIDC mobile login sessions
2026-09-29 16:51:30 -05:00
advplyr 9e18cb41e9 Fix EACCES error for Debian install #5613
- Update systemd service file to define HOME path and CacheDirectory to support Node24 change
2026-09-29 16:33:13 -05:00
advplyr 7d10b8fc4a Version bump v2.37.0 2026-09-28 16:46:32 -05:00
advplyr 14bec248f1 Merge pull request #5577 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2026-09-28 16:38:13 -05:00
Juan Benites ff57368f37 Translated using Weblate (Spanish)
Currently translated at 100.0% (1167 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/
2026-09-27 01:06:47 +02:00
Mediokris f589397008 Translated using Weblate (Norwegian Bokmål)
Currently translated at 100.0% (1167 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/nb_NO/
2026-09-27 01:06:47 +02:00
Danol fa4f6b58f3 Translated using Weblate (Czech)
Currently translated at 99.9% (1166 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/cs/
2026-09-27 01:06:47 +02:00
Daniel Nylander 1acfd2ae54 Translated using Weblate (Swedish)
Currently translated at 100.0% (1167 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/
2026-09-27 01:06:47 +02:00
advplyr 91717e3b96 Merge pull request #4974 from Vito0912/feat/addAPISocket
Add support that API-Keys authenticate sockets
2026-09-26 18:06:38 -05:00
advplyr 282d8b8bf6 Update TokenManager test 2026-09-26 18:03:39 -05:00
advplyr fb4c3ba984 Merge master 2026-09-26 18:00:58 -05:00
advplyr 72a1c656f9 Update jwt verify for sockets to ignore expiration 2026-09-26 17:52:08 -05:00
zsh 273693e8ad Expire abandoned OIDC mobile login sessions 2026-09-25 17:38:37 +12:00
advplyr d22c468215 Merge pull request #5575 from SharonZhu0122/fix-5535-explicit-flag-from-provider
Set explicit flag from Audible match results
2026-09-23 17:41:50 -05:00
advplyr c654de4630 Fix server crash decoding invalid filter groups 2026-09-22 18:00:08 -05:00
advplyr aef33df8d3 Update get series for library endpoint to ensure series belongs to library 2026-09-22 17:49:53 -05:00
advplyr 63a883e595 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2026-09-21 17:02:46 -05:00
advplyr b8b069eca6 Fix server crash when collapse sub series and sort by author name #4993 & add whitelist for collapse subseries sort 2026-09-21 17:02:34 -05:00
advplyr 0fd0483021 Merge commit from fork
Add UUID check for cache manager
2026-09-21 16:10:29 -05:00
advplyr 8463b8426a Update library item cover PATCH endpoint to require the cover path to be in the library item libraryFiles array 2026-09-21 16:07:41 -05:00
advplyr e874d31e64 Merge pull request #5578 from nichwall/upgrade-to-node24
Upgrade to node24
2026-09-20 16:49:09 -05:00
advplyr 557f56ff21 Add npm run lint to unit-tests gh workflow 2026-09-20 16:45:33 -05:00
advplyr 7f296a5135 Update @types/node to 24, update readme node version 2026-09-20 16:38:40 -05:00
advplyr b5134a3287 Cleanup prober.js and fix redeclare 2026-09-20 15:36:15 -05:00
advplyr 8fb7a856d5 Fix LibraryItem dupe class member hasAudioFiles 2026-09-20 15:32:20 -05:00
advplyr ad8b235058 Add eslint for server 2026-09-20 15:25:30 -05:00
advplyr 186733f6af Remove duplicate podcastEpisodeModel get in Database 2026-09-20 14:44:21 -05:00
advplyr 1e5443f054 Clean up invalid where objects in LibraryScanner 2026-09-20 14:43:39 -05:00
advplyr 18e32410ad Merge pull request #5586 from binyaminyblatt/missing_declaration
fix: resolve ReferenceErrors in scandir.js under strict mode
2026-09-19 16:59:43 -05:00
advplyr 5077f87e0c Fix undefined variable in podcastUtils 2026-09-19 16:59:02 -05:00
advplyr 608d3ddde8 Fix undefined binaryPath in BinaryManager 2026-09-19 16:57:01 -05:00
advplyr 4c27a7b68e Merge branch 'missing_declaration' of https://github.com/binyaminyblatt/audiobookshelf into missing_declaration 2026-09-19 16:48:50 -05:00
advplyr a3b150d049 Fix undefined var in nodeFfprobe 2026-09-19 16:48:45 -05:00
advplyr 088c4f542f Cleanup scandir variables 2026-09-19 16:47:40 -05:00
Binyamin Blatt 6acb4b9005 fix no-undef in BackupManager 2026-09-20 00:45:24 +03:00
Binyamin Blatt 8781930ced Fix undeclared variables 2026-09-20 00:01:56 +03:00
Finn Dittmar 4236eef5c4 Add UUID check for cache manager 2026-09-19 17:23:40 +02:00
Nicholas Wallace b14224a6d8 Update remaining workflows to node 24 2026-09-18 14:52:42 -07:00
Nicholas Wallace a6128a6c98 Remove socket warning from web client 2026-09-18 14:38:01 -07:00
Nicholas Wallace 8a2a45751e Update dockerfile to use node 24 2026-09-18 11:09:13 -07:00
Nicholas Wallace 83e9a82f8f Update build scripts to target node 24 2026-09-18 10:32:21 -07:00
zsh bf4f74cb93 Set explicit flag from Audible match results 2026-09-19 01:54:54 +12:00
advplyr 1e88ff01e7 Update Dockerfile fix tsc not work on arm64 2026-09-17 17:23:34 -05:00
advplyr 5ec4a8a46e Merge pull request #5510 from nichwall/typescript-migration-begin
Typescript initial migration framework
2026-09-17 17:11:58 -05:00
advplyr 99e2c45482 Remove buildServer and update docker and workflows 2026-09-17 17:09:00 -05:00
advplyr 3c5d191c33 Fix MigrationManager undefined var 2026-09-17 16:57:58 -05:00
advplyr b67cbffbf9 Update tsconfig to use allowJs to skip buildServer.js step 2026-09-17 16:54:31 -05:00
advplyr eeaa408a2d Merge pull request #5526 from mikiher/fix-logging-error
AudioMetadataManager: Fix logging error
2026-09-17 15:20:18 -05:00
advplyr 4b67c170ce Version bump v2.36.1 2026-09-16 16:51:44 -05:00
advplyr d21aec237c Merge pull request #5570 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2026-09-16 16:41:36 -05:00
Marcin 632ccd5cbf Translated using Weblate (Polish)
Currently translated at 100.0% (1167 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/pl/
2026-09-16 23:26:39 +02:00
Martynas 62efd5c583 Translated using Weblate (Lithuanian)
Currently translated at 60.1% (702 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/lt/
2026-09-16 23:26:39 +02:00
Kim Josefsen 9244f9ea5a Translated using Weblate (Danish)
Currently translated at 99.0% (1156 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/da/
2026-09-16 23:26:39 +02:00
advplyr 7e76eed8bb Update library item updateMedia endpoint to no longer accept ebookFile, chapters and audioFiles in req body 2026-09-16 16:26:26 -05:00
advplyrandFinn Dittmar e4e212cca7 Update comic book extractor file path sanitization
Co-authored-by: Finn Dittmar <finn@dittmar-ldk.de>
2026-09-16 16:07:33 -05:00
advplyr fb43db4f75 Fix library narrator update/delete endpoint to be library scoped 2026-09-15 16:21:22 -05:00
advplyr 3b4895b1b0 Update author middleware check user can access library 2026-09-15 16:12:26 -05:00
advplyr a06639886b Fix share endpoints not sending 404 status 2026-09-14 17:31:47 -05:00
advplyr 37d7ee20ed Add try/catch on ensureDir in upload endpoint 2026-09-14 17:25:37 -05:00
advplyr eb8ed7f5b6 Add html sanitize to authLoginCustomMessage on auth-settings PATCH 2026-09-14 17:23:15 -05:00
advplyr 3d62a103ae Update settings PATCH endpoint to only accept a fixed set of general settings (auth settings must go through the auth-settings endpoint) 2026-09-14 17:21:40 -05:00
advplyr 42bf9512b0 Update cover endpoints to only allow webp, jpeg and png as optional format param 2026-09-14 17:05:07 -05:00
advplyr 5cb75a85ec Merge pull request #5558 from nichwall/weblate-credits-workflow
Add workflow to parse weblate commits
2026-09-11 16:02:19 -05:00
Nicholas Wallace 3c1bbc7f8e Update workflow to cehck for new languages 2026-09-11 13:45:30 -07:00
Nicholas Wallace b204395b8e Update name to translate-credits 2026-09-11 12:50:14 -07:00
Nicholas Wallace e4c69fe68b Update to latest version 2026-09-11 12:47:46 -07:00
Nicholas Wallace def8fca33a initial translatior credit workflow 2026-09-11 09:52:22 -07:00
advplyr e8ed987034 Merge pull request #5411 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2026-09-11 06:37:14 -05:00
J. Lavoie d5be05b11e Translated using Weblate (Greek)
Currently translated at 29.5% (345 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/el/
2026-09-10 22:51:44 +02:00
J. Lavoie 580f9fb649 Translated using Weblate (Italian)
Currently translated at 100.0% (1167 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/it/
2026-09-10 22:51:41 +02:00
J. Lavoie 7aecf129ac Translated using Weblate (French)
Currently translated at 100.0% (1167 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/
2026-09-10 22:51:38 +02:00
J. Lavoie 4056f37390 Translated using Weblate (German)
Currently translated at 100.0% (1167 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/
2026-09-10 22:51:36 +02:00
samia shanta 6bc2a89827 Translated using Weblate (Bengali)
Currently translated at 100.0% (1167 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/bn/
2026-09-10 22:51:32 +02:00
mikiher eba1909c9c AudioMetadataManager: Fix logging error 2026-09-03 09:45:15 +03:00
Lorenz Schuster 010a73a16a Translated using Weblate (Italian)
Currently translated at 99.8% (1165 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/it/
2026-08-28 23:10:44 +00:00
J. Lavoie ed6663054f Translated using Weblate (Greek)
Currently translated at 29.3% (342 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/el/
2026-08-28 23:10:43 +00:00
Mario 01263e9379 Translated using Weblate (Finnish)
Currently translated at 100.0% (1167 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fi/
2026-08-28 23:10:43 +00:00
Mario 1ab6120596 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 69.0% (806 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hant/
2026-08-28 23:10:41 +00:00
J. Lavoie c097655c5c Translated using Weblate (French)
Currently translated at 100.0% (1167 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/
2026-08-28 23:10:41 +00:00
Gabriel 9f541063db Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (1167 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/pt_BR/
2026-08-28 23:10:40 +00:00
Martynas 3f2da7febc Translated using Weblate (Lithuanian)
Currently translated at 60.1% (702 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/lt/
2026-08-28 23:10:40 +00:00
kuci-JK 24c68507e7 Translated using Weblate (Czech)
Currently translated at 99.8% (1165 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/cs/
2026-08-28 23:10:39 +00:00
PovilasID b325d0ee1d Translated using Weblate (Lithuanian)
Currently translated at 59.8% (699 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/lt/
2026-08-28 23:10:39 +00:00
ugyes 5f6fa4adbf Translated using Weblate (Hungarian)
Currently translated at 100.0% (1167 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hu/
2026-08-28 23:10:38 +00:00
Jan Paulzen 78130a7823 Translated using Weblate (German)
Currently translated at 100.0% (1167 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/
2026-08-28 23:10:38 +00:00
violawang0401-cloud e4556b0307 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 69.0% (806 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hant/
2026-08-28 23:10:37 +00:00
violawang0401-cloud 6f57182da0 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 65.1% (760 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hant/
2026-08-28 23:10:36 +00:00
Vladimir Makeev 1aef21169c Translated using Weblate (Russian)
Currently translated at 100.0% (1167 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ru/
2026-08-28 23:10:35 +00:00
Mateusz Lesiak e02c859437 Translated using Weblate (Polish)
Currently translated at 100.0% (1167 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/pl/
2026-08-28 23:10:34 +00:00
Pavel Miniutka 9f475ef3d5 Translated using Weblate (Belarusian)
Currently translated at 100.0% (1167 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/be/
2026-08-28 23:10:34 +00:00
FiendFEARing 5ad5768308 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (1167 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/
2026-08-28 23:10:33 +00:00
Kwinten Cockhuyt d38bf9e868 Translated using Weblate (Dutch)
Currently translated at 100.0% (1167 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/nl/
2026-08-28 23:10:33 +00:00
Igor Dobrača 1027b85e68 Translated using Weblate (Croatian)
Currently translated at 100.0% (1167 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/
2026-08-28 23:10:33 +00:00
Charlie 67992540c0 Translated using Weblate (French)
Currently translated at 100.0% (1167 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/
2026-08-28 23:10:32 +00:00
Zarakkas 733c795989 Translated using Weblate (German)
Currently translated at 100.0% (1167 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/
2026-08-28 23:10:32 +00:00
advplyr 0a797ab8be Merge pull request #5507 from mikiher/next-client-base-path
Restore the mount prefix when handing requests to Next
2026-08-28 18:10:14 -05:00
nichwall fbfb24c264 Fix: new packaging warnings 2026-08-27 23:53:57 -07:00
nichwall 2440fb37c4 Ensure client is included in packaging after directory change 2026-08-27 23:42:06 -07:00
nichwall 65c075283c Fix appRoot directory 2026-08-27 23:30:22 -07:00
nichwall 198b2c27b8 Remove test files from docker image 2026-08-27 22:46:04 -07:00
nichwall d6398e54dd Fix: docker build 2026-08-27 21:40:09 -07:00
mikiher c0b9110e4e Make sure Next has basePath before remounting baseUrl 2026-08-27 12:17:10 +03:00
mikiherandCursor 13432209ee Restore the mount prefix when handing requests to Next
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 11:19:39 +03:00
nichwall 0571747fab Remove deprecated npm ci flags 2026-08-23 21:56:29 -07:00
nichwall ec2738cb0f Fix: linux packager building client as typescript 2026-08-23 15:49:15 -07:00
nichwall a7ebb3e1af Add: typescript compile step and update packaging 2026-08-23 15:34:08 -07:00
advplyr 513a0679f5 Merge pull request #5280 from schummar/fix/collapsed-series-with-filters
fix: collapsed series with filters
2026-08-16 17:13:48 -05:00
Raymond (Kai) Tainaan 3f9afe57bc Fix upload crash with repeated file fields
The multipart middleware returns an array when a field is repeated, but handleUpload treats every value as one file. Flatten the values before moving them so repeated and unique field names both work.

Signed-off-by: Raymond (Kai) Tainaan <282025710+rtainaan@users.noreply.github.com>
2026-08-06 23:36:07 -07:00
Asher Jin ac63b0c7fc Fix Audible search when products are null 2026-08-04 14:10:32 +12:00
advplyr 1b46d68027 Merge pull request #5429 from Vito0912/fix/npmv12
add npmv12 compatibility
2026-08-03 17:14:55 -05:00
Finn Dittmar d511612f21 add npmv12 compatibility 2026-08-03 21:08:18 +02:00
zsh 94400acdcd fix: add cache-busting timestamp to RSS feed cover image URLs
Feed cover image URLs were built only from the item's slug and file
extension, so the URL never changed when the cover image itself
changed. Podcast clients that cache by URL would keep showing the old
cover after an update.

Add a '?ts=' query parameter using the already-computed
entityUpdatedAt value, matching the same cache-busting convention
already used elsewhere in the app (see the updatedAt comment in
LibraryItemController.updateCover).

Applies to all three feed types: single item, collection, and series.

Fixes #5306
2026-07-31 10:28:21 +12:00
advplyr 6caa1aa2da Merge pull request #5409 from mikiher/podcast-scanner-stale-episodes
Fix podcast rescan emitting stale episodes on item_updated
2026-07-28 18:15:19 -04:00
mikiher 32fae4a7c6 Fix podcast rescan emitting stale episodes on item_updated 2026-07-28 12:26:32 +03:00
advplyr 96d4021a3c Version bump v2.36.0 2026-07-27 17:31:12 -05:00
advplyr 49826f5163 Merge pull request #5406 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2026-07-27 18:26:57 -04:00
FiendFEARing 76e33a2a4e Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (1167 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/
2026-07-27 11:54:06 +02:00
FiendFEARing 1a440811c5 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (1166 of 1166 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/
2026-07-27 11:54:06 +02:00
advplyr 529a16b718 Merge pull request #5407 from nichwall/readme-update
Readme update
2026-07-27 05:53:49 -04:00
Nicholas Wallace 032f0b6b51 Fix installation link 2026-07-26 21:41:51 -07:00
Nicholas Wallace f5bfa1edd6 Add note about frontend rewrite 2026-07-26 21:40:59 -07:00
advplyr 4dfad46722 Merge pull request #5405 from advplyr/auth_sessions_enhancements
Delete auth session endpoint & paginate sessions
2026-07-26 18:49:25 -04:00
advplyr c86f336536 Update delete session to return status 200 instead of json 2026-07-26 17:40:14 -05:00
advplyr 1876bfbfe7 Add delete auth session endpoint to logout individual session 2026-07-26 17:36:36 -05:00
advplyr a7b380cd7a Add pagination to /me/sessions endpoint & sessions account page table 2026-07-26 17:11:03 -05:00
advplyr 71c5d27ea5 Merge pull request #5269 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2026-07-26 08:45:00 -04:00
FiendFEARing 83707e08bf Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (1164 of 1164 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/
2026-07-25 22:44:45 +00:00
Igor Dobrača e8d1b58bd2 Translated using Weblate (Croatian)
Currently translated at 100.0% (1164 of 1164 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/
2026-07-25 22:44:44 +00:00
FiendFEARing aff79b5579 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (1164 of 1164 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/
2026-07-25 22:44:43 +00:00
Mona Lisa 321d8bfda1 Translated using Weblate (Swedish)
Currently translated at 100.0% (1163 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/
2026-07-25 22:44:42 +00:00
Axel Björnsson a3ff41f90e Translated using Weblate (Icelandic)
Currently translated at 2.5% (30 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/is/
2026-07-25 22:44:40 +00:00
Mona Lisa 60f9ac93f7 Translated using Weblate (Swedish)
Currently translated at 100.0% (1163 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/
2026-07-25 22:44:38 +00:00
Mona Lisa bd61a43458 Translated using Weblate (Swedish)
Currently translated at 100.0% (1163 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/
2026-07-25 22:44:36 +00:00
FiendFEARing df6ec07f18 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (1163 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/
2026-07-25 22:44:35 +00:00
FiendFEARing 6d02aa3aa0 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (1163 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/
2026-07-25 22:44:33 +00:00
FiendFEARing 144fe743c6 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (1163 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/
2026-07-25 22:44:31 +00:00
FiendFEARing 0851b346d0 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (1163 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/
2026-07-25 22:44:29 +00:00
FiendFEARing 53e904314e Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (1163 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/
2026-07-25 22:44:25 +00:00
FiendFEARing 84f80f1903 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (1163 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/
2026-07-25 22:44:20 +00:00
FiendFEARing 98b1f4abad Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (1163 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/
2026-07-25 22:44:18 +00:00
FiendFEARing ec0d147d6d Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (1163 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/
2026-07-25 22:44:16 +00:00
FiendFEARing a7825bb461 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (1163 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/
2026-07-25 22:44:15 +00:00
தமிழ்நேரம் 09be7799f6 Translated using Weblate (Tamil)
Currently translated at 100.0% (1163 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ta/
2026-07-25 22:44:14 +00:00
தமிழ்நேரம் dc6a8a7292 Translated using Weblate (Tamil)
Currently translated at 16.7% (195 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ta/
2026-07-25 22:44:12 +00:00
Gabriel 0c075674a0 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (1163 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/pt_BR/
2026-07-25 22:44:11 +00:00
advplyr 5b13820cb4 Added translation using Weblate (Tamil) 2026-07-25 22:44:10 +00:00
Hans Henrik Juhl d5b4d57fe3 Translated using Weblate (Danish)
Currently translated at 99.3% (1156 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/da/
2026-07-25 22:44:07 +00:00
Mikkel Dupont Olesen f12b64495a Translated using Weblate (Danish)
Currently translated at 99.7% (1160 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/da/
2026-07-25 22:44:05 +00:00
Hans Henrik Juhl 43f3150a52 Translated using Weblate (Danish)
Currently translated at 99.7% (1160 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/da/
2026-07-25 22:44:01 +00:00
Mikkel Dupont Olesen 65d3a668e3 Translated using Weblate (Danish)
Currently translated at 99.7% (1160 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/da/
2026-07-25 22:43:58 +00:00
Mikkel Dupont Olesen 2b5297bee8 Translated using Weblate (Danish)
Currently translated at 99.7% (1160 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/da/
2026-07-25 22:43:57 +00:00
no1ta 08caeb5975 Translated using Weblate (Greek)
Currently translated at 29.2% (340 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/el/
2026-07-25 22:43:55 +00:00
Igor Dobrača 6c170a5064 Translated using Weblate (Croatian)
Currently translated at 100.0% (1163 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/
2026-07-25 22:43:52 +00:00
advplyr 4b62f90964 Added translation using Weblate (Uzbek) 2026-07-25 22:43:50 +00:00
advplyr e9ea511c90 Merge pull request #5400 from advplyr/account_sessions_table
Account sessions table
2026-07-25 18:43:26 -04:00
advplyr c6afeb7dc9 Fix logout button logging out all devices 2026-07-25 17:20:32 -05:00
advplyr 35a2c5e87c Add /me/sessions endpoint and show sessions table on account page 2026-07-25 17:18:38 -05:00
advplyr 4c71a076e7 Merge pull request #5395 from advplyr/logout_all_devices
Add logout all devices button and extend auth logout endpoint
2026-07-24 18:51:35 -04:00
advplyr e56c1099e6 Add logout all devices button and extend auth logout endpoint 2026-07-24 17:44:03 -05:00
advplyr dc0b24a03f Merge pull request #5393 from advplyr/pw_change_invalidates_sessions
User password changes invalidate sessions
2026-07-23 18:40:30 -04:00
advplyr c900676be7 User password changes invalidate sessions 2026-07-23 17:21:25 -05:00
advplyr a6cde926ee Merge pull request #5336 from francisrath/fix-broken-help-links
Update outdated in-app help links to new docs site structure
2026-07-22 18:07:58 -04:00
advplyr e8db14fe33 Update collection help docs link 2026-07-22 17:07:32 -05:00
advplyr e9b533637f Merge pull request #5376 from DanielAshley/contrib/refresh-grace-period
Make refresh-token grace period configurable (default 10 min)
2026-07-22 18:01:32 -04:00
advplyr 8b97da500d Remove TokenManager test 2026-07-22 16:58:03 -05:00
advplyr 37b42601ef Merge pull request #5363 from Vito0912/feat/newEndpoints
add progress and bookmarks specific endpoints
2026-07-22 17:10:51 -04:00
advplyr 5da4ec51ce Remove MeController test 2026-07-22 16:05:59 -05:00
advplyr 4c65a87d5c Update bookmarks endpoints to use json object as root 2026-07-22 16:05:37 -05:00
Finn Dittmarandadvplyr 0e5618dcd8 Apply suggestions from code review
Co-authored-by: advplyr <dev@advplyr.com>
2026-07-22 10:20:41 +02:00
advplyr d063d851e1 Merge pull request #5388 from mikiher/fix/bulk-download-permissions
Enforce per-item access checks on bulk library download
2026-07-21 18:34:13 -04:00
advplyr 6458e6d3d2 Merge pull request #5387 from mikiher/fix/refresh-token-auth
Reject refresh tokens on API and WebSocket authentication
2026-07-21 18:20:57 -04:00
advplyr 70953d8c82 Slim down TokenManager test 2026-07-21 17:18:58 -05:00
advplyr 2d9f63963e Merge pull request #5370 from mikiher/fix/root-user-delete-protection
Prevent admin users from deleting the root account
2026-07-20 18:35:32 -04:00
advplyr f12abca3fc Simplify user delete checks 2026-07-20 17:33:15 -05:00
advplyr bced308462 Trim down UserController test 2026-07-20 17:31:03 -05:00
advplyr 528d03fef3 Merge pull request #5386 from mikiher/fix/oidc-url-validation
Fix oidc url validation
2026-07-20 18:08:51 -04:00
advplyr b93c3133a9 Update x-forwarded-proto to check comma separated value 2026-07-20 17:06:21 -05:00
mikiher 8d9db8d103 fix OidcAuthStrategy test module load order 2026-07-20 18:43:15 +03:00
advplyr 08bbf9cd9d Update server settings to include host timezone for use in cron scheduler next run dates 2026-07-19 17:31:00 -05:00
Daniel 95af4812cd Make the refresh-token grace period configurable (default 10 min)
When /auth/refresh rotates the refresh token, the previous token is kept
valid for a short grace period so a client that never received the
rotation response (e.g. a dropped or suspended mobile request) can retry
with the old token and get back the already-rotated current token
instead of a 401.

That window was hardcoded to 60 seconds, which is too short for real
mobile conditions: iOS backgrounding, VPN/proxy timeouts, or network
handoff can delay the retry past a minute, permanently locking the
session out (#5281).

Make it configurable via REFRESH_TOKEN_GRACE_PERIOD (seconds), following
the existing REFRESH_TOKEN_EXPIRY / ACCESS_TOKEN_EXPIRY pattern, and
raise the default to 10 minutes. Adds unit tests for the new config.

Fixes #5281
2026-07-18 21:42:06 -04:00
mikiher 8ec547e302 fix(auth): refresh tokens authentication in API and WebSocket requests 2026-07-16 18:58:44 +03:00
mikiher 32e4da8c92 fix(permissions): enforce per-item access on bulk library download 2026-07-16 18:11:19 +03:00
mikiher 0ce37004af fix(auth): harden OIDC web callback URL validation 2026-07-16 17:11:11 +03:00
mikiher 527745b54e refactor(server): centralize request HTTPS and origin detection 2026-07-16 17:10:42 +03:00
mikiher 3c016314e6 fix(auth): prevent admin users from deleting the root account 2026-07-15 20:34:02 +03:00
advplyr 59c7d0275c Merge pull request #5354 from mikiher/scan-author-events
Emit author socket events when book scans create or link authors
2026-07-13 19:27:31 -04:00
Finn Dittmar 4883d0f60c add progress and bookmarks specific endpoints 2026-07-13 11:41:49 +02:00
mikiher 9d9ab49664 Batch author numBooks updates at scan end via authors_num_books_updated 2026-07-10 17:37:31 +03:00
advplyr 82aec5f60c Merge pull request #5341 from mikiher/expanded-minified-superset
Add all minified fields to expanded library item JSON
2026-07-09 16:56:45 -05:00
mikiher 7ee5e1bdf6 Emit author socket events when book scans create or link authors 2026-07-09 10:35:18 +03:00
mikiher 3bb53d939d Add all minified fields to expanded library item JSON so socket updates remain a strict superset of shelf payloads 2026-07-01 22:30:21 +03:00
advplyr 6f03467f35 Update links in the README 2026-06-29 16:46:33 -05:00
advplyr b2f593f1c4 Update readme organizing docs link & remove api docs link 2026-06-29 16:43:24 -05:00
advplyr 58dcda36a5 Cleanup readme and link to contributing docs 2026-06-29 16:37:54 -05:00
advplyr 354e0bf793 Update readme remove reverse proxy set up and link to docs page 2026-06-29 16:32:41 -05:00
Francis Rath 0784373b61 Update outdated in-app help links to new docs site structure
The help (?) links throughout the client pointed to the old
www.audiobookshelf.org/guides/* pages, which no longer exist and
resolve to a 404. Update all of them to the current
audiobookshelf.org/docs/documentation/* locations.
2026-06-29 03:02:19 +02:00
advplyr 2f219ea3cc Merge pull request #5325 from mikiher/file-upload-internal-api
Let next.js handle file uploads through internal-api routes
2026-06-25 17:03:34 -05:00
mikiher 56e60b8420 Server.js: Let next.js handle file uploads through internal-api routes 2026-06-23 14:08:16 +03:00
advplyr 9b92b5de34 Merge pull request #5318 from mikiher/match-update-episode-enclosure
Enhance PodcastController to handle enclosure updates
2026-06-20 17:06:32 -05:00
mikiher 3417c0c721 Enhance PodcastController to handle enclosure updates, allowing for null values and object structure validation for enclosure properties. 2026-06-18 12:39:50 +03:00
advplyr cbda0360aa Merge pull request #5291 from mikiher/allowed-dev-origins
Read AllowedDevOrigins from dev.js into ALLOWED_DEV_ORIGINS env var
2026-06-04 15:13:34 -05:00
mikiher 036bc081f0 index.js: Read AllowedDevOrigins from dev,js into ALLOWED_DEV_ORIGINS env var 2026-06-04 14:03:59 +03:00
Marco Schumacher 42947e2e64 Revert "fix: series book count with active filters"
This reverts commit c4e7172c2f.
2026-06-01 10:44:35 +02:00
Marco Schumacher c4e7172c2f fix: series book count with active filters 2026-06-01 10:26:45 +02:00
Marco Schumacher 4502d37d4f fix: collapsed series query with active filters 2026-06-01 10:26:29 +02:00
advplyr e70e4b9d40 Fix typo on onTest notification body 2026-05-30 15:43:50 -05:00
advplyr aacdcc47ec Version bump v2.35.1 2026-05-28 15:22:55 -05:00
advplyr 499b52b4dd Update Sequelize where query for User username/email case insensitive 2026-05-28 14:49:49 -05:00
advplyr 1bad2d9072 Cleanup abmetadata file parsing & fix server crash #5268 #4287 #5142 2026-05-27 17:33:14 -05:00
advplyr c009db9f28 Merge pull request #5256 from nichwall/fix-bookauthor-collision-on-rename
Fix duplicate bookAuthor creation when renaming authors
2026-05-22 15:43:13 -05:00
advplyr 325469c5a5 Merge pull request #5255 from nichwall/refresh-token-uniqueness
Add unique UUID to access and refresh tokens
2026-05-22 15:39:01 -05:00
Nicholas Wallace c97b36e11c Add ignoreDuplicates for bookAuthor when renaming to respect unique index 2026-05-21 21:06:17 -07:00
Nicholas Wallace e944b2a2f5 Add unique UUID to access and refresh tokens 2026-05-21 17:08:39 -07:00
advplyr 2d0a5462d2 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2026-05-17 14:31:45 -05:00
advplyr 72dc75482f Version bump v2.35.0 2026-05-17 14:31:41 -05:00
advplyr cac74f3477 Merge pull request #5004 from nichwall/token_refresh_race_condition
Access token refresh grace period
2026-05-17 14:16:58 -05:00
advplyr 1ad11b2b9e Merge pull request #5216 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2026-05-17 14:16:24 -05:00
Pavel Miniutka 50eeca2e0f Translated using Weblate (Belarusian)
Currently translated at 100.0% (1163 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/be/
2026-05-15 18:13:27 +00:00
EteranlK 4f21fc023c Translated using Weblate (Arabic)
Currently translated at 96.3% (1120 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ar/
2026-05-15 18:13:26 +00:00
advplyr 52a485d135 Added translation using Weblate (Latvian) 2026-05-15 18:13:25 +00:00
d0nizam 3b025076e8 Translated using Weblate (Bulgarian)
Currently translated at 100.0% (1163 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/bg/
2026-05-15 18:13:23 +00:00
Mateusz Lesiak 6d5d89429d Translated using Weblate (Polish)
Currently translated at 99.8% (1161 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/pl/
2026-05-15 18:13:22 +00:00
advplyr c010f0e1eb Fix android device sdkVersion not handling it using number type, causing android session device names to show as iOS 2026-05-15 13:13:14 -05:00
advplyr eee377e081 Cleanup TokenManager logs 2026-05-13 16:23:26 -05:00
advplyr b0aaa24660 Update socket events to check client is admin & validate log level 2026-05-12 16:57:28 -05:00
advplyr 47ea6b5092 Update book/podcast scanner to sanitize description pulled from metadata 2026-05-05 17:18:49 -05:00
advplyr 4b060febc2 Merge pull request #5221 from brandonfhall/fix/rss-feed-m4b-content-type
Fix: RSS feed serves m4b files with correct Content-Type: audio/mp4
2026-05-03 14:40:43 -05:00
Brandon 40869bcf39 fix: set correct Content-Type for RSS feed audio files
Express's mime package does not recognize .m4b, causing it to fall back
to application/octet-stream. This reuses the existing
getAudioMimeTypeFromExtname utility (already applied to the download
endpoint) to set the correct audio/mp4 header before sendFile.

Fixes #5041
2026-05-02 22:13:35 -04:00
advplyr 3942805129 Cleanup rotateTokensForSession 2026-04-30 16:25:43 -05:00
advplyr dc446862c1 Rename migration to v2.35.0 & merge master 2026-04-30 16:08:24 -05:00
advplyr 379f6c716a Merge branch 'master' into token_refresh_race_condition 2026-04-30 15:59:22 -05:00
Nicholas Wallace cfeb6bd502 Fix: grace period enable statement 2026-01-24 18:57:40 -07:00
Nicholas Wallace 077b523bd6 Fix JS Doc deletion 2026-01-24 18:42:50 -07:00
Nicholas Wallace b8a2d113f0 Allow rotation without grace period for invalidating all user sessions 2026-01-24 18:26:11 -07:00
Nicholas Wallace e1ae4f2d31 Fix: race condition in rotation 2026-01-24 18:10:38 -07:00
Nicholas Wallace 7aa2f84daa Revert default token expiry 2026-01-24 17:00:07 -07:00
Nicholas Wallace da0a64daed Add: 10 second grace period to access token cycle 2026-01-24 16:57:25 -07:00
Finn Dittmar dfce15a74c Add support that API-Keys authenticate sockets 2026-01-09 20:45:48 +01:00
132 changed files with 6047 additions and 2501 deletions

No files matched your search

+28 -37
View File
@@ -1,40 +1,31 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node
{
"name": "Audiobookshelf",
"build": {
"dockerfile": "Dockerfile",
// Update 'VARIANT' to pick a Node version: 18, 16, 14.
// Append -bullseye or -buster to pin to an OS version.
// Use -bullseye variants on local arm64/Apple Silicon.
"args": {
"VARIANT": "20"
}
},
"mounts": [
"source=abs-server-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume",
"source=abs-client-node_modules,target=${containerWorkspaceFolder}/client/node_modules,type=volume"
],
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [
3000,
3333
],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "sh .devcontainer/post-create.sh",
// Configure tool-specific properties.
"customizations": {
// Configure properties specific to VS Code.
"vscode": {
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"dbaeumer.vscode-eslint",
"octref.vetur"
]
}
}
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}
"name": "Audiobookshelf",
"build": {
"dockerfile": "Dockerfile",
// Update 'VARIANT' to pick a Node version: 18, 16, 14.
// Append -bullseye or -buster to pin to an OS version.
// Use -bullseye variants on local arm64/Apple Silicon.
"args": {
"VARIANT": "24"
}
},
"mounts": ["source=abs-server-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume", "source=abs-client-node_modules,target=${containerWorkspaceFolder}/client/node_modules,type=volume"],
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [3000, 3333],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "sh .devcontainer/post-create.sh",
// Configure tool-specific properties.
"customizations": {
// Configure properties specific to VS Code.
"vscode": {
// Add the IDs of extensions you want installed when the container is created.
"extensions": ["dbaeumer.vscode-eslint", "octref.vetur"]
}
}
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 24
cache: 'npm'
- name: Install dependencies
+2
View File
@@ -19,6 +19,8 @@ on:
- server/**
- index.js
- package.json
- tsconfig.server.json
- Dockerfile
jobs:
build:
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
- name: Set up node
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 24
cache: 'npm'
# The only argument is the `directory`, which is where the i18n files are
+6 -5
View File
@@ -12,6 +12,7 @@ on:
- test/**
- index.js
- package.json
- tsconfig.server.json
jobs:
build:
@@ -23,10 +24,10 @@ jobs:
- name: setup node
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 24
cache: 'npm'
- name: install pkg (using yao-pkg fork for targeting node20)
- name: install pkg (using yao-pkg fork for targeting node24)
run: npm install -g @yao-pkg/pkg
- name: get client dependencies
@@ -37,11 +38,11 @@ jobs:
working-directory: client
run: npm run generate
- name: get server dependencies
run: npm ci --only=production
- name: get server dependencies and compile server
run: npm ci && npm run build:server && npm prune --omit=dev
- name: build binary
run: pkg -t node20-linux-x64 -o audiobookshelf .
run: pkg --config package.json -t node24-linux-x64 -o audiobookshelf dist-server/index.js
- name: run audiobookshelf
run: |
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
- name: Set up node
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 24
cache: 'npm'
# Install Redocly CLI
+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 }}"
+4 -1
View File
@@ -28,11 +28,14 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 24
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run lint
run: npm run lint
- name: Run tests
run: npm test
+1
View File
@@ -11,6 +11,7 @@
/client/.nuxt/
/client/dist/
/dist/
/dist-server/
/deploy/
/coverage/
/.nyc_output/
+16 -7
View File
@@ -2,15 +2,23 @@ ARG NUSQLITE3_DIR="/usr/local/lib/nusqlite3"
ARG NUSQLITE3_PATH="${NUSQLITE3_DIR}/libnusqlite3.so"
### STAGE 0: Build client ###
FROM node:20-alpine AS build-client
FROM node:24-alpine AS build-client
WORKDIR /client
COPY /client /client
RUN npm ci && npm cache clean --force
RUN npm run generate
### STAGE 1: Build server ###
FROM node:20-alpine AS build-server
### STAGE 1: Compile server on the builder CPU (avoid QEMU SIGILL from tsc on arm64) ###
FROM --platform=$BUILDPLATFORM node:24-alpine AS compile-server
WORKDIR /server
COPY index.js package* tsconfig.server.json /server
COPY /server /server/server
RUN npm ci --include=dev --ignore-scripts && npm run build:server
### STAGE 2: Install native server deps for the target arch ###
FROM node:24-alpine AS build-server
ARG NUSQLITE3_DIR
ARG TARGETPLATFORM
@@ -27,6 +35,7 @@ RUN apk add --no-cache --update \
WORKDIR /server
COPY index.js package* /server
COPY /server /server/server
COPY --from=compile-server /server/dist-server /server/dist-server
RUN case "$TARGETPLATFORM" in \
"linux/amd64") \
@@ -38,10 +47,10 @@ RUN case "$TARGETPLATFORM" in \
unzip /tmp/library.zip -d $NUSQLITE3_DIR && \
rm /tmp/library.zip
RUN npm ci --only=production
RUN npm ci --omit=dev
### STAGE 2: Create minimal runtime image ###
FROM node:20-alpine
### STAGE 3: Create minimal runtime image ###
FROM node:24-alpine
ARG NUSQLITE3_DIR
ARG NUSQLITE3_PATH
@@ -70,4 +79,4 @@ ENV NUSQLITE3_DIR=${NUSQLITE3_DIR}
ENV NUSQLITE3_PATH=${NUSQLITE3_PATH}
ENTRYPOINT ["tini", "--"]
CMD ["node", "index.js"]
CMD ["node", "dist-server/index.js"]
@@ -11,6 +11,8 @@ ExecReload=/bin/kill -HUP $MAINPID
Restart=always
User=audiobookshelf
Group=audiobookshelf
Environment=HOME=/var/cache/audiobookshelf
CacheDirectory=audiobookshelf
[Install]
WantedBy=multi-user.target
+10 -4
View File
@@ -16,7 +16,7 @@ echo "--------------------"
cd client
rm -rf node_modules
npm ci --unsafe-perm=true --allow-root
npm ci
npm run generate
cd ..
@@ -24,7 +24,8 @@ echo ">>> Building Server"
echo "--------------------"
rm -rf node_modules
npm ci --unsafe-perm=true --allow-root
npm ci
npm run build:server
echo ">>> Packaging"
echo "--------------------"
@@ -34,7 +35,6 @@ echo "--------------------"
mkdir -p dist
rm -rf dist/debian
cp -R build/debian dist/debian
chmod -R 775 dist/debian
controlfile="Package: audiobookshelf
Version: $VERSION
@@ -47,8 +47,14 @@ Description: $DESCRIPTION"
echo "$controlfile" > dist/debian/DEBIAN/control;
# Package contents: directories 0755, files 0644 (including the systemd unit).
# Maintainer scripts must stay executable so dpkg can run them.
find dist/debian -type d -exec chmod 755 {} +
find dist/debian -type f -exec chmod 644 {} +
chmod 755 dist/debian/DEBIAN/preinst dist/debian/DEBIAN/postinst dist/debian/DEBIAN/prerm
# Package debian
pkg -t node20-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf .
pkg --config package.json -t node24-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf dist-server/index.js
fakeroot dpkg-deb -Zxz --build dist/debian
+1 -1
View File
@@ -22,7 +22,7 @@
<div v-if="entityName === 'collections' || entityName === 'playlists'" class="flex justify-center mt-4">
{{ emptyMessageHelp }}
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides/collections" target="_blank" class="inline-flex">
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/playlists" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
</a>
</ui-tooltip>
+1 -1
View File
@@ -8,7 +8,7 @@
<div class="px-6 py-8 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<div class="absolute top-0 right-0 p-4">
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides/media-item-shares" target="_blank" class="inline-flex">
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/public-shares#media-item-shares" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
</a>
</ui-tooltip>
@@ -25,7 +25,7 @@
<div class="text-sm flex items-center justify-center text-gray-200">
<p>{{ $strings.MessageBookshelfNoCollectionsHelp }}</p>
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides/collections" target="_blank" class="inline-flex">
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/playlists" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
</a>
</ui-tooltip>
@@ -8,7 +8,7 @@
<div class="flex items-center justify-between md:justify-start mb-4">
<p class="text-sm text-gray-300 pr-2">{{ $strings.LabelMetadataOrderOfPrecedenceDescription }}</p>
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex">
<a href="https://www.audiobookshelf.org/guides/book-scanner" target="_blank" class="inline-flex">
<a href="https://audiobookshelf.org/docs/documentation/libraries/book-library/book-metadata" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5">help_outline</span>
</a>
</ui-tooltip>
@@ -25,7 +25,7 @@
<div class="text-sm flex items-center justify-center text-gray-200">
<p>{{ $strings.MessageNoUserPlaylistsHelp }}</p>
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides/collections" target="_blank" class="inline-flex">
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/playlists" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
</a>
</ui-tooltip>
+1
View File
@@ -60,6 +60,7 @@ module.exports = {
proxy,
io: {
server: false,
sockets: [
{
name: 'dev',
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "audiobookshelf-client",
"version": "2.34.0",
"version": "2.37.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf-client",
"version": "2.34.0",
"version": "2.37.2",
"license": "ISC",
"dependencies": {
"@nuxtjs/axios": "^5.13.6",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "2.34.0",
"version": "2.37.2",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast client",
"main": "index.js",
+142 -5
View File
@@ -1,6 +1,6 @@
<template>
<div id="page-wrapper" class="page p-6 overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
<div class="w-full max-w-xl mx-auto">
<div class="w-full max-w-2xl mx-auto">
<h1 class="text-2xl">{{ $strings.HeaderAccount }}</h1>
<div class="my-4">
@@ -69,8 +69,52 @@
</app-settings-content>
</div>
<div class="py-4 mt-8 flex">
<ui-btn color="bg-primary flex items-center text-lg" @click="logout"><span class="material-symbols mr-4 icon-text">logout</span>{{ $strings.ButtonLogout }}</ui-btn>
<div v-if="!isGuest">
<div class="w-full h-px bg-white/10 my-4" />
<app-settings-content :header-text="$strings.HeaderSessions">
<table v-if="authSessions.length" class="tracksTable mt-4 table-fixed w-full">
<tr>
<th class="text-left">{{ $strings.LabelDeviceInfo }}</th>
<th class="text-left hidden sm:table-cell w-36">{{ $strings.LabelIpAddress }}</th>
<th class="text-left w-30 sm:w-40">{{ $strings.LabelLastUpdate }}</th>
<th class="w-12"></th>
</tr>
<tr v-for="session in authSessions" :key="session.id">
<td class="max-w-0">
<div class="flex items-center gap-0.5 min-w-0">
<span class="text-sm text-gray-100 truncate min-w-0" :title="session.userAgent">{{ getSessionDeviceLabel(session) }}</span>
<ui-tooltip v-if="session.current" direction="top" :text="$strings.LabelCurrent" class="inline-flex shrink-0">
<span class="material-symbols text-success text-sm leading-none">check</span>
</ui-tooltip>
</div>
</td>
<td class="hidden sm:table-cell w-36">
<p class="text-sm text-gray-100 truncate" :title="session.ipAddress">{{ session.ipAddress || '-' }}</p>
</td>
<td class="w-30 sm:w-40">
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)">
<p class="text-xs sm:text-sm text-gray-100">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
</ui-tooltip>
</td>
<td class="w-12">
<div class="flex justify-end items-center h-10">
<ui-icon-btn icon="delete" borderless :size="8" icon-font-size="1.1rem" :disabled="deletingSessionId === session.id || loadingAuthSessions" @click="deleteAuthSessionClick(session)" />
</div>
</td>
</tr>
</table>
<div v-if="authSessionsNumPages > 1" class="flex items-center justify-end py-1">
<ui-icon-btn icon="arrow_back_ios_new" :size="7" icon-font-size="1rem" class="mx-1" :disabled="loadingAuthSessions || authSessionsPage === 0" @click="prevAuthSessionsPage" />
<p class="text-sm mx-1">{{ $getString('LabelPaginationPageXOfY', [authSessionsPage + 1, authSessionsNumPages]) }}</p>
<ui-icon-btn icon="arrow_forward_ios" :size="7" icon-font-size="1rem" class="mx-1" :disabled="loadingAuthSessions || authSessionsPage >= authSessionsNumPages - 1" @click="nextAuthSessionsPage" />
</div>
</app-settings-content>
</div>
<div class="py-4 mt-8 flex flex-wrap gap-2">
<ui-btn v-if="!isGuest" color="bg-primary flex items-center text-lg" :disabled="loggingOut" @click="logout(true)"> <span class="material-symbols mr-4 icon-text">devices</span>{{ $strings.ButtonLogoutAllDevices }} </ui-btn>
<ui-btn color="bg-primary flex items-center text-lg" :disabled="loggingOut" @click="logout(false)"><span class="material-symbols mr-4 icon-text">logout</span>{{ $strings.ButtonLogout }}</ui-btn>
</div>
<modals-emails-user-e-reader-device-modal v-model="showEReaderDeviceModal" :existing-devices="revisedEreaderDevices" :ereader-device="selectedEReaderDevice" @update="ereaderDevicesUpdated" />
@@ -87,6 +131,13 @@ export default {
newPassword: null,
confirmPassword: null,
changingPassword: false,
loggingOut: false,
authSessions: [],
authSessionsPage: 0,
authSessionsNumPages: 0,
authSessionsItemsPerPage: 10,
loadingAuthSessions: false,
deletingSessionId: null,
selectedLanguage: '',
newEReaderDevice: {
name: '',
@@ -129,13 +180,19 @@ export default {
},
revisedEreaderDevices() {
return this.ereaderDevices.filter((device) => device.users?.length === 1)
},
dateFormat() {
return this.$store.getters['getServerSetting']('dateFormat')
},
timeFormat() {
return this.$store.getters['getServerSetting']('timeFormat')
}
},
methods: {
updateLocalLanguage(lang) {
this.$setLanguageCode(lang)
},
logout() {
logout(allDevices = false) {
// Disconnect from socket
if (this.$root.socket) {
console.log('Disconnecting from socket', this.$root.socket.id)
@@ -149,8 +206,10 @@ export default {
this.$store.commit('libraries/setUserPlaylists', [])
this.$store.commit('libraries/setCollections', [])
this.loggingOut = true
const url = allDevices ? '/logout?allDevices=1' : '/logout'
this.$axios
.$post('/logout')
.$post(url)
.then((logoutPayload) => {
const redirect_url = logoutPayload.redirect_url
@@ -163,6 +222,9 @@ export default {
.catch((error) => {
console.error(error)
})
.finally(() => {
this.loggingOut = false
})
},
resetForm() {
this.password = null
@@ -238,11 +300,86 @@ export default {
},
ereaderDevicesUpdated(ereaderDevices) {
this.ereaderDevices = ereaderDevices
},
loadAuthSessions(page = 0) {
if (this.loadingAuthSessions) return
if (page < 0) return
if (this.authSessionsNumPages > 0 && page > this.authSessionsNumPages - 1) return
this.loadingAuthSessions = true
this.$axios
.$get(`/api/me/sessions?page=${page}&itemsPerPage=${this.authSessionsItemsPerPage}`)
.then((data) => {
this.authSessions = data.sessions || []
this.authSessionsPage = data.page ?? page
this.authSessionsNumPages = data.numPages ?? 0
})
.catch((error) => {
console.error('Failed to load sessions', error)
})
.finally(() => {
this.loadingAuthSessions = false
})
},
prevAuthSessionsPage() {
if (this.authSessionsPage <= 0) return
this.loadAuthSessions(this.authSessionsPage - 1)
},
nextAuthSessionsPage() {
if (this.authSessionsPage >= this.authSessionsNumPages - 1) return
this.loadAuthSessions(this.authSessionsPage + 1)
},
deleteAuthSessionClick(session) {
this.$store.commit('globals/setConfirmPrompt', {
message: this.$getString('MessageConfirmLogoutDevice', [this.getSessionDeviceLabel(session)]),
callback: (confirmed) => {
if (confirmed) this.deleteAuthSession(session)
},
type: 'yesNo'
})
},
deleteAuthSession(session) {
// Call logout instead for current session
if (session.current) {
this.logout(false)
return
}
this.deletingSessionId = session.id
this.$axios
.$delete(`/api/me/sessions/${session.id}`)
.then(() => {
if (this.authSessions.length === 1 && this.authSessionsPage > 0) {
this.loadAuthSessions(this.authSessionsPage - 1)
} else {
this.loadAuthSessions(this.authSessionsPage)
}
})
.catch((error) => {
console.error('Failed to delete session', error)
this.$toast.error(this.$strings.ToastFailedToDelete)
})
.finally(() => {
this.deletingSessionId = null
})
},
getSessionDeviceLabel(session) {
const deviceInfo = session.deviceInfo
if (!deviceInfo) return session.userAgent || '-'
const parts = []
if (deviceInfo.model) parts.push(deviceInfo.model)
if (deviceInfo.osName) parts.push(`${deviceInfo.osName}${deviceInfo.osVersion ? ` ${deviceInfo.osVersion}` : ''}`)
if (deviceInfo.browserName) parts.push(deviceInfo.browserName)
return parts.join(' / ') || session.userAgent || '-'
}
},
mounted() {
this.selectedLanguage = this.$languageCodes.current
this.ereaderDevices = this.$store.state.libraries.ereaderDevices || []
if (!this.isGuest) {
this.loadAuthSessions()
}
}
}
</script>
+1 -1
View File
@@ -7,7 +7,7 @@
</div>
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides/api-keys" target="_blank" class="inline-flex">
<a href="https://audiobookshelf.org/docs/documentation/server-management/api-keys" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
</a>
</ui-tooltip>
+1 -1
View File
@@ -24,7 +24,7 @@
<ui-checkbox v-model="enableOpenIDAuth" checkbox-bg="bg" />
<p class="text-lg pl-4">{{ $strings.HeaderOpenIDConnectAuthentication }}</p>
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides/oidc_authentication" target="_blank" class="inline-flex">
<a href="https://audiobookshelf.org/docs/documentation/server-management/oidc-authentication" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
</a>
</ui-tooltip>
+1 -1
View File
@@ -3,7 +3,7 @@
<app-settings-content :header-text="$strings.HeaderEmailSettings" :description="''">
<template #header-items>
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides/send_to_ereader" target="_blank" class="inline-flex">
<a href="https://audiobookshelf.org/docs/documentation/libraries/book-library/ebooks" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
</a>
</ui-tooltip>
@@ -8,7 +8,7 @@
</template>
<template #header-items>
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides/custom-metadata-providers" target="_blank" class="inline-flex">
<a href="https://audiobookshelf.org/docs/documentation/community/community-providers" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
</a>
</ui-tooltip>
+1 -1
View File
@@ -3,7 +3,7 @@
<app-settings-content :header-text="$strings.HeaderLibraries">
<template #header-items>
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides/library_creation" target="_blank" class="inline-flex">
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/overview" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
</a>
</ui-tooltip>
+1 -1
View File
@@ -3,7 +3,7 @@
<app-settings-content :header-text="$strings.HeaderLogs" :description="$strings.MessageLogsDescription">
<template #header-items>
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides/server_logs" target="_blank" class="inline-flex">
<a href="https://audiobookshelf.org/docs/documentation/server-management/server-logs" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
</a>
</ui-tooltip>
+1 -1
View File
@@ -3,7 +3,7 @@
<app-settings-content :header-text="$strings.HeaderRSSFeeds">
<template #header-items>
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides/rss_feeds" target="_blank" class="inline-flex">
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/public-shares#rss-feed" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
</a>
</ui-tooltip>
+1 -1
View File
@@ -7,7 +7,7 @@
</div>
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides/users" target="_blank" class="inline-flex">
<a href="https://audiobookshelf.org/docs/documentation/server-management/user-management" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
</a>
</ui-tooltip>
+6
View File
@@ -244,6 +244,8 @@
"LabelAlreadyInYourLibrary": "موجود بالفعل في مكتبتك",
"LabelApiKeyCreated": "تم إنشاء مفتاح API \"{0}\" بنجاح.",
"LabelApiKeyCreatedDescription": "تأكد من نسخ مفتاح API الآن، لن تتمكن من رؤيته مرة أخرى.",
"LabelApiKeyUser": "التصرف بالنيابة عن مستخدم",
"LabelApiKeyUserDescription": "مفتاح API سيمتلك نفس صلاحيات المستخدم الذي ينوب عنه ، سيظهر بالسجلات وكأن المستخدم قام بالطلب.",
"LabelApiToken": "رمز API",
"LabelAppend": "إلحاق",
"LabelAudioBitrate": "معدل بت الصوت (على سبيل المثال 128 كيلو بايت)",
@@ -293,6 +295,7 @@
"LabelContinueListening": "استمرار الاستماع",
"LabelContinueReading": "استمرار القراءة",
"LabelContinueSeries": "استمرار المسلسلات",
"LabelCorsAllowed": "CORS Origins مسموح",
"LabelCover": "الغلاف",
"LabelCoverImageURL": "رابط صورة الغلاف",
"LabelCoverProvider": "مزود الغلاف",
@@ -426,6 +429,9 @@
"LabelLibraryFilterSublistEmpty": "لا يوجد {0}",
"LabelLibraryItem": "عنصر المكتبة",
"LabelLibraryName": "اسم المكتبة",
"LabelLibrarySortByProgress": "المرحلة: الأحدث",
"LabelLibrarySortByProgressFinished": "المرحلة: تم الانتهاء",
"LabelLibrarySortByProgressStarted": "المرحلة: تم البدء",
"LabelLimit": "حد",
"LabelLineSpacing": "تباعد الأسطر",
"LabelListenAgain": "الاستماع مجدداً",
+6 -2
View File
@@ -46,6 +46,7 @@
"ButtonLatest": "Апошняе",
"ButtonLibrary": "Бібліятэка",
"ButtonLogout": "Выйсці",
"ButtonLogoutAllDevices": "Выйсці з усіх прылад",
"ButtonLookup": "Пошук",
"ButtonManageTracks": "Кіраванне трэкамі",
"ButtonMapChapterTitles": "Супаставіць загалоўкі раздзелаў",
@@ -88,7 +89,7 @@
"ButtonResetToDefault": "Скінуць да прадвызначаных",
"ButtonRestore": "Аднавіць",
"ButtonSave": "Захаваць",
"ButtonSaveAndClose": "Захаваць і зачыніць",
"ButtonSaveAndClose": "Захаваць і закрыць",
"ButtonSaveTracklist": "Захаваць спіс трэкаў",
"ButtonScan": "Сканаваць",
"ButtonScanLibrary": "Сканіраваць бібліятэку",
@@ -194,6 +195,7 @@
"HeaderScheduleEpisodeDownloads": "Расклад аўтаматычных спампоўванняў выпускаў",
"HeaderScheduleLibraryScans": "Расклад аўтаматычнага сканавання бібліятэкі",
"HeaderSession": "Сеанс",
"HeaderSessions": "Сеансы",
"HeaderSetBackupSchedule": "Наладзіць расклад рэзервовага капіравання",
"HeaderSettings": "Налады",
"HeaderSettingsDisplay": "Выгляд",
@@ -284,7 +286,7 @@
"LabelChaptersFound": "раздзелаў знойдзена",
"LabelClickForMoreInfo": "Націсніце для больш падрабязнай інфармацыі",
"LabelClickToUseCurrentValue": "Націсніце, каб выкарыстоўваць бягучае значэнне",
"LabelClosePlayer": "Зачыніць прайгравальнік",
"LabelClosePlayer": "Закрыць прайгравальнік",
"LabelCodec": "Кодэк",
"LabelCollapseSeries": "Згарнуць серыі",
"LabelCollapseSubSeries": "Згарнуць падсерыі",
@@ -415,6 +417,7 @@
"LabelIntervalEveryHour": "Кожную гадзіну",
"LabelIntervalEveryMinute": "Кожную хвіліну",
"LabelInvert": "Інвертаваць",
"LabelIpAddress": "IP-адрас",
"LabelItem": "Элемент",
"LabelJumpBackwardAmount": "Час пераходу назад",
"LabelJumpForwardAmount": "Час пераходу наперад",
@@ -774,6 +777,7 @@
"MessageConfirmDeleteSession": "Вы ўпэўнены, што хочаце выдаліць гэты сеанс?",
"MessageConfirmEmbedMetadataInAudioFiles": "Вы ўпэўнены, што хочаце ўбудаваць метаданыя ў {0} аўдыяфайлаў?",
"MessageConfirmForceReScan": "Вы ўпэўнены, што хочаце прымусова паўторна сканіраваць?",
"MessageConfirmLogoutDevice": "Вы ўпэўнены, што хочаце выйсці з \"{0}\"?",
"MessageConfirmMarkAllEpisodesFinished": "Вы ўпэўнены, што хочаце пазначыць усе выпускі як завершаныя?",
"MessageConfirmMarkAllEpisodesNotFinished": "Вы ўпэўнены, што хочаце пазначыць усе выпускі як незавершаныя?",
"MessageConfirmMarkItemFinished": "Вы ўпэўнены, што хочаце пазначыць \"{0}\" як завершаны?",
+103 -2
View File
@@ -752,7 +752,7 @@
"MessageBookshelfNoRSSFeeds": "Няма отворени RSS feed-ове",
"MessageBookshelfNoResultsForFilter": "Няма резултат за филтер \"{0}: {1}\"",
"MessageBookshelfNoResultsForQuery": "Няма резултати от заявката",
"MessageBookshelfNoSeries": "Нямаш сеЗЙ",
"MessageBookshelfNoSeries": "Нямате поредица",
"MessageBulkChapterPattern": "Колко глави искате да добавите, използвайки тази схема за номериране?",
"MessageChapterEndIsAfter": "Краят на главата е след края на вашата аудиокнига",
"MessageChapterErrorFirstNotZero": "Първата глава трябва да започва от 0",
@@ -1018,18 +1018,50 @@
"ToastChapterStartTimeAdjusted": "Начално време на главате е настоено с {0} секунди",
"ToastChaptersAllLocked": "Всички глави са заключени. Оключете някой глави за да преместите техните времена.",
"ToastChaptersHaveErrors": "Главите имат грешки",
"ToastChaptersInvalidShiftAmountLast": "Невалидно време за преместване. Началният час на последната глава ще превиши общата продължителност на аудиокнигата.",
"ToastChaptersInvalidShiftAmountStart": "Невалидно време за преместване. Първата глава ще има нулева или отрицателна дължина и ще бъде презаписана от втората глава. Увеличете началното време на втората глава.",
"ToastChaptersMustHaveTitles": "Главите трябва да имат заглавия",
"ToastChaptersRemoved": "Главите са премахнати",
"ToastChaptersUpdated": "Главите са актуализирани",
"ToastCollectionItemsAddFailed": "Неуспешно добавяне на елемент(и) към колекцията",
"ToastCollectionRemoveSuccess": "Колекцията е премахната",
"ToastCollectionUpdateSuccess": "Колекцията е обновена",
"ToastConnectionNotAvailable": "Няма връзка. Моля, опитайте отново по-късно",
"ToastCoverSearchFailed": "Търсенето на корица е неуспешно",
"ToastCoverUpdateFailed": "Обновяването на корицата е неуспешно",
"ToastDateTimeInvalidOrIncomplete": "Датата и часът са невалидни или непълни",
"ToastDeleteFileFailed": "Неуспешно изтриване на файла",
"ToastDeleteFileSuccess": "Успешно изтриване на файла",
"ToastDeviceAddFailed": "Неуспешно добавяне на устройство",
"ToastDeviceNameAlreadyExists": "Вече съществува четец с това име",
"ToastDeviceTestEmailFailed": "Неуспешно изпращане на тестов имейл",
"ToastDeviceTestEmailSuccess": "Тестовият имейл е изпратен",
"ToastEmailSettingsUpdateSuccess": "Имейл настройките са актуализирани",
"ToastEncodeCancelFailed": "Неуспешно отменяне на кодирането",
"ToastEncodeCancelSucces": "Кодирането е отменено",
"ToastEpisodeDownloadQueueClearFailed": "Неуспешно изчистване на опашката",
"ToastEpisodeDownloadQueueClearSuccess": "Опашката за изтегляне на епизоди е изчистена",
"ToastEpisodeUpdateSuccess": "{0} епизода са актуализирани",
"ToastErrorCannotShare": "Не може да се споделя директно от това устройство",
"ToastFailedToCreate": "Неуспешно създаване",
"ToastFailedToDelete": "Неуспешно изтриване",
"ToastFailedToLoadData": "Неуспешно зареждане на данни",
"ToastFailedToMatch": "Неуспешно съвпадение",
"ToastFailedToShare": "Неуспешно споделяне",
"ToastFailedToUpdate": "Неуспешно актуализиране",
"ToastInvalidImageUrl": "Невалиден URL адрес на изображение",
"ToastInvalidMaxEpisodesToDownload": "Невалиден максимален брой епизоди за изтегляне",
"ToastInvalidUrl": "Невалиден URL адрес",
"ToastInvalidUrls": "Един или повече URL адреси са невалидни",
"ToastItemCoverUpdateSuccess": "Корицата на елемента е обновена",
"ToastItemDeletedFailed": "Неуспешно изтриване на елемента",
"ToastItemDeletedSuccess": "Елементът е изтрит",
"ToastItemDetailsUpdateSuccess": "Детайлите на елемента са обновени",
"ToastItemMarkedAsFinishedFailed": "Неуспешно маркиране като Завършено",
"ToastItemMarkedAsFinishedSuccess": "Елементът е маркиран като завършен",
"ToastItemMarkedAsNotFinishedFailed": "Неуспешно маркиране като Незавършено",
"ToastItemMarkedAsNotFinishedSuccess": "Елементът е маркиран като незавършен",
"ToastItemUpdateSuccess": "Елементът е актуализиран",
"ToastLibraryCreateFailed": "Неуспешно създаване на библиотека",
"ToastLibraryCreateSuccess": "Библиотеката \"{0}\" е създадена",
"ToastLibraryDeleteFailed": "Неуспешно изтриване на библиотека",
@@ -1037,28 +1069,97 @@
"ToastLibraryScanFailedToStart": "Неуспешно стартиране на сканиране",
"ToastLibraryScanStarted": "Сканирането на библиотеката е стартирано",
"ToastLibraryUpdateSuccess": "Библиотеката \"{0}\" е обновена",
"ToastMatchAllAuthorsFailed": "Неуспешно съвпадение на всички автори",
"ToastMetadataFilesRemovedError": "Грешка при премахване на metadata.{0} файлове",
"ToastMetadataFilesRemovedNoneFound": "Не са намерени metadata.{0} файлове в библиотеката",
"ToastMetadataFilesRemovedNoneRemoved": "Не са премахнати metadata.{0} файлове",
"ToastMetadataFilesRemovedSuccess": "Премахнати са {0} файла metadata.{1}",
"ToastMustHaveAtLeastOnePath": "Трябва да има поне един път",
"ToastNameEmailRequired": "Изискват се име и имейл",
"ToastNameRequired": "Изисква се име",
"ToastNewApiKeyUserError": "Трябва да изберете потребител",
"ToastNewEpisodesFound": "Намерени са {0} нови епизода",
"ToastNewUserCreatedFailed": "Неуспешно създаване на акаунт: „{0}“",
"ToastNewUserCreatedSuccess": "Създаден е нов акаунт",
"ToastNewUserLibraryError": "Трябва да изберете поне една библиотека",
"ToastNewUserPasswordError": "Трябва да има парола; само root потребителят може да бъде с празна парола",
"ToastNewUserTagError": "Трябва да изберете поне един етикет",
"ToastNewUserUsernameError": "Въведете потребителско име",
"ToastNoNewEpisodesFound": "Не са намерени нови епизоди",
"ToastNoRSSFeed": "Подкастът няма RSS емисия",
"ToastNoUpdatesNecessary": "Не са необходими актуализации",
"ToastNotificationCreateFailed": "Неуспешно създаване на известие",
"ToastNotificationDeleteFailed": "Неуспешно изтриване на известието",
"ToastNotificationFailedMaximum": "Максималният брой неуспешни опити трябва да бъде >= 0",
"ToastNotificationQueueMaximum": "Максималната опашка за известия трябва да бъде >= 0",
"ToastNotificationSettingsUpdateSuccess": "Настройките за известия са актуализирани",
"ToastNotificationTestTriggerFailed": "Неуспешно задействане на тестово известие",
"ToastNotificationTestTriggerSuccess": "Тестовото известие е задействано",
"ToastNotificationUpdateSuccess": "Известието е актуализирано",
"ToastPlaylistCreateFailed": "Неуспешно създаване на плейлист",
"ToastPlaylistCreateSuccess": "Плейлистът е създаден",
"ToastPlaylistRemoveSuccess": "Плейлистът е премахнат",
"ToastPlaylistUpdateSuccess": "Плейлистът е обновен",
"ToastPodcastCreateFailed": "Неуспешно създаване на подкаст",
"ToastPodcastCreateSuccess": "Подкаст успешно създаден",
"ToastPodcastEpisodeUpdated": "Епизодът е актуализиран",
"ToastPodcastGetFeedFailed": "Неуспешно извличане на емисията на подкаста",
"ToastPodcastNoEpisodesInFeed": "Не са намерени епизоди в RSS емисията",
"ToastPodcastNoRssFeed": "Подкастът няма RSS емисия",
"ToastProgressIsNotBeingSynced": "Напредъкът не се синхронизира, рестартирайте възпроизвеждането",
"ToastProviderCreatedFailed": "Неуспешно добавяне на доставчик",
"ToastProviderCreatedSuccess": "Добавен е нов доставчик",
"ToastProviderNameAndUrlRequired": "Изискват се име и URL адрес",
"ToastProviderRemoveSuccess": "Доставчикът е премахнат",
"ToastRSSFeedCloseFailed": "Неуспешно затваряне на RSS емисията",
"ToastRSSFeedCloseSuccess": "RSS емисията е затворена",
"ToastRemoveFailed": "Неуспешно премахване",
"ToastRemoveItemFromCollectionFailed": "Неуспешно премахване на елемент от колекция",
"ToastRemoveItemFromCollectionSuccess": "Елементът е премахнат от колекция",
"ToastRemoveItemsWithIssuesFailed": "Неуспешно премахване на елементите от библиотеката с проблеми",
"ToastRemoveItemsWithIssuesSuccess": "Елементите от библиотеката с проблеми са премахнати",
"ToastRenameFailed": "Неуспешно преименуване",
"ToastRescanFailed": "Повторното сканиране е неуспешно за {0}",
"ToastRescanRemoved": "Повторното сканиране завърши: елементът е премахнат",
"ToastRescanUpToDate": "Повторното сканиране завърши: елементът вече е актуален",
"ToastRescanUpdated": "Повторното сканиране завърши: елементът е актуализиран",
"ToastScanFailed": "Неуспешно сканиране на елемент от библиотеката",
"ToastSelectAtLeastOneUser": "Изберете поне един потребител",
"ToastSendEbookToDeviceFailed": "Неуспешно изпращане на електронна книга до устройство",
"ToastSendEbookToDeviceSuccess": "Електронната книга е изпратена до устройство \"{0}\"",
"ToastSeriesSubmitFailedSameName": "Не могат да бъдат добавени два сериала с едно и също име",
"ToastSeriesUpdateFailed": "Неуспешно обновяване на серия",
"ToastSeriesUpdateSuccess": "Серията е обновена",
"ToastServerSettingsUpdateSuccess": "Настройките на сървъра са актуализирани",
"ToastSessionCloseFailed": "Неуспешно затваряне на сесията",
"ToastSessionDeleteFailed": "Неуспешно изтриване на сесия",
"ToastSessionDeleteSuccess": "Сесията е изтрита",
"ToastSleepTimerDone": "Таймерът за заспиване приключи... zZzzZz",
"ToastSlugMustChange": "Краткият URL (slug) съдържа невалидни символи",
"ToastSlugRequired": "Изисква се кратък URL (slug)",
"ToastSocketConnected": "Свързан сокет",
"ToastSocketDisconnected": "Сокетът е прекъснат",
"ToastSocketFailedToConnect": "Неуспешно свързване на сокет",
"ToastSortingPrefixesEmptyError": "Трябва да има поне 1 префикс за сортиране",
"ToastSortingPrefixesUpdateSuccess": "Префиксите за сортиране са актуализирани ({0} елемента)",
"ToastTitleRequired": "Изисква се заглавие",
"ToastUnknownError": "Неизвестна грешка",
"ToastUnlinkOpenIdFailed": "Неуспешно прекъсване на връзката на потребителя с OpenID",
"ToastUnlinkOpenIdSuccess": "Връзката на потребителя с OpenID е прекъсната",
"ToastUploaderFilepathExistsError": "Файловият път „{0}“ вече съществува на сървъра",
"ToastUploaderItemExistsInSubdirectoryError": "Елементът „{0}“ използва поддиректория на пътя за качване.",
"ToastUserDeleteFailed": "Неуспешно изтриване на потребител",
"ToastUserDeleteSuccess": "Потребителят е изтрит"
"ToastUserDeleteSuccess": "Потребителят е изтрит",
"ToastUserPasswordChangeSuccess": "Паролата е променена успешно",
"ToastUserPasswordMismatch": "Паролите не съвпадат",
"ToastUserPasswordMustChange": "Новата парола не може да бъде същата като старата",
"ToastUserRootRequireName": "Трябва да въведете root потребителско име",
"TooltipAddChapters": "Добавяне на глава(и)",
"TooltipAddOneSecond": "Добавяне на 1 секунда",
"TooltipAdjustChapterStart": "Кликнете за коригиране на началния час",
"TooltipLockAllChapters": "Заключване на всички глави",
"TooltipLockChapter": "Заключване на глава (Shift+клик за диапазон)",
"TooltipSubtractOneSecond": "Изваждане на 1 секунда",
"TooltipUnlockAllChapters": "Отключване на всички глави",
"TooltipUnlockChapter": "Отключване на глава (Shift+клик за диапазон)"
}
+94 -9
View File
@@ -46,6 +46,7 @@
"ButtonLatest": "সর্বশেষ",
"ButtonLibrary": "লাইব্রেরি",
"ButtonLogout": "লগআউট",
"ButtonLogoutAllDevices": "সব ডিভাইস থেকে লগআউট করুন",
"ButtonLookup": "সন্ধান",
"ButtonManageTracks": "ট্র্যাকগুলি পরিচালনা করুন",
"ButtonMapChapterTitles": "অধ্যায়ের শিরোনাম ম্যাপ করুন",
@@ -76,7 +77,7 @@
"ButtonReScan": "পুনরায় স্ক্যান",
"ButtonRead": "পড়ুন",
"ButtonReadLess": "সংক্ষিপ্ত",
"ButtonReadMore": "বিস্তারিত পড়ুন",
"ButtonReadMore": "আরও পড়ুন",
"ButtonRefresh": "রিফ্রেশ",
"ButtonRemove": "মুছে ফেলুন",
"ButtonRemoveAll": "সব মুছে ফেলুন",
@@ -194,6 +195,7 @@
"HeaderScheduleEpisodeDownloads": "স্বয়ংক্রিয় পর্ব ডাউনলোডের সময়সূচী নির্ধারন করুন",
"HeaderScheduleLibraryScans": "স্বয়ংক্রিয় লাইব্রেরি স্ক্যানের সময়সূচী",
"HeaderSession": "সেশন",
"HeaderSessions": "সেশনসমূহ",
"HeaderSetBackupSchedule": "ব্যাকআপ সময়সূচী সেট করুন",
"HeaderSettings": "সেটিংস",
"HeaderSettingsDisplay": "প্রদর্শন",
@@ -212,6 +214,7 @@
"HeaderTableOfContents": "বিষয়বস্তুর সারণী",
"HeaderTools": "টুলস",
"HeaderUpdateAccount": "অ্যাকাউন্ট আপডেট করুন",
"HeaderUpdateApiKey": "API কী আপডেট করুন",
"HeaderUpdateAuthor": "লেখক আপডেট করুন",
"HeaderUpdateDetails": "বিশদ আপডেট করুন",
"HeaderUpdateLibrary": "লাইব্রেরি আপডেট করুন",
@@ -226,6 +229,7 @@
"LabelAccountTypeAdmin": "প্রশাসন",
"LabelAccountTypeGuest": "অতিথি",
"LabelAccountTypeUser": "ব্যবহারকারী",
"LabelActivities": "কার্যকলাপ",
"LabelActivity": "ক্রিয়াকলাপ",
"LabelAddToCollection": "সংগ্রহে যোগ করুন",
"LabelAddToCollectionBatch": "সংগ্রহে {0}টি বই যোগ করুন",
@@ -235,10 +239,15 @@
"LabelAddedDate": "যোগ করা হয়েছে {0}",
"LabelAdminUsersOnly": "শুধু অ্যাডমিন ব্যবহারকারী",
"LabelAll": "সব",
"LabelAllEpisodesDownloaded": "সব পর্ব ডাউনলোড করা হয়েছে",
"LabelAllUsers": "সমস্ত ব্যবহারকারী",
"LabelAllUsersExcludingGuests": "অতিথি ব্যতীত সকল ব্যবহারকারী",
"LabelAllUsersIncludingGuests": "অতিথি সহ সকল ব্যবহারকারী",
"LabelAlreadyInYourLibrary": "ইতিমধ্যেই আপনার লাইব্রেরিতে রয়েছে",
"LabelApiKeyCreated": "API কী \"{0}\" সফলভাবে তৈরি করা হয়েছে।",
"LabelApiKeyCreatedDescription": "API কীটি এখনই কপি করে রাখুন, কারণ এটি পরে আর দেখতে পারবেন না।",
"LabelApiKeyUser": "ব্যবহারকারীর পক্ষ থেকে কাজ করুন",
"LabelApiKeyUserDescription": "এই API কীটি যে ব্যবহারকারীর পক্ষ থেকে কাজ করবে, সেই ব্যবহারকারীর মতোই একই অনুমতি পাবে। লগে এটি এমনভাবে দেখাবে যেন ব্যবহারকারী নিজেই অনুরোধটি করেছেন।",
"LabelApiToken": "API টোকেন",
"LabelAppend": "সংযোজন",
"LabelAudioBitrate": "অডিও বিটরেট (যেমন- 128k)",
@@ -268,7 +277,7 @@
"LabelBonus": "উপরিলাভ",
"LabelBooks": "বইগুলো",
"LabelButtonText": "ঘর পাঠ্য",
"LabelByAuthor": "দ্বারা {0}",
"LabelByAuthor": "{0} অবশ্যই একদম অপরিবর্তিত রাখবে",
"LabelChangePassword": "পাসওয়ার্ড পরিবর্তন করুন",
"LabelChannels": "চ্যানেল",
"LabelChapterCount": "{0} অধ্যায়",
@@ -288,8 +297,10 @@
"LabelContinueListening": "শোনা চালিয়ে যান",
"LabelContinueReading": "পড়া চালিয়ে যান",
"LabelContinueSeries": "সিরিজ চালিয়ে যান",
"LabelCorsAllowed": "অনুমোদিত CORS অরিজিনসমূহ",
"LabelCover": "কভার",
"LabelCoverImageURL": "ছবির কভারের URL",
"LabelCoverProvider": "কভার প্রদানকারী",
"LabelCreatedAt": "তৈরি করা হয়েছে",
"LabelCronExpression": "Cron এক্সপ্রেশন",
"LabelCurrent": "বর্তমান",
@@ -300,6 +311,7 @@
"LabelDeleteFromFileSystemCheckbox": "ফাইল সিস্টেম থেকে মুছে ফেলুন (শুধু ডাটাবেস থেকে সরাতে টিক চিহ্ন মুক্ত করুন)",
"LabelDescription": "বিবরণ",
"LabelDeselectAll": "সমস্ত অনির্বাচিত করুন",
"LabelDetectedPattern": "শনাক্ত করা প্যাটার্ন:",
"LabelDevice": "ডিভাইস",
"LabelDeviceInfo": "ডিভাইস তথ্য",
"LabelDeviceIsAvailableTo": "ডিভাইস এর জন্য উপলব্ধ...",
@@ -349,7 +361,11 @@
"LabelExample": "উদাহরণ",
"LabelExpandSeries": "সিরিজ প্রসারিত করুন",
"LabelExpandSubSeries": "সাব সিরিজ প্রসারিত করুন",
"LabelExplicit": "বিশদ",
"LabelExpired": "মেয়াদ শেষ হয়েছে",
"LabelExpiresAt": "মেয়াদ শেষ হওয়ার সময়",
"LabelExpiresInSeconds": "মেয়াদ শেষ হবে (সেকেন্ডে)",
"LabelExpiresNever": "কখনোই নয়",
"LabelExplicit": "অশ্লীল ভাষা",
"LabelExplicitChecked": "সুস্পষ্ট (পরীক্ষিত)",
"LabelExplicitUnchecked": "অস্পষ্ট (অপরিক্ষীত)",
"LabelExportOPML": "OPML এক্সপোর্ট করুন",
@@ -364,11 +380,12 @@
"LabelFilterByUser": "ব্যবহারকারী দ্বারা ফিল্টারকৃত",
"LabelFindEpisodes": "পর্বগুলো খুঁজুন",
"LabelFinished": "সমাপ্ত",
"LabelFinishedDate": "{0} সম্পন্ন হয়েছে",
"LabelFolder": "ফোল্ডার",
"LabelFolders": "ফোল্ডারগুলো",
"LabelFontBold": "বোল্ড",
"LabelFontBoldness": "হরফ বোল্ডনেস",
"LabelFontFamily": "হরফ পরিবার",
"LabelFontFamily": "ফন্টের ধরন",
"LabelFontItalic": "ইটালিক",
"LabelFontScale": "ফন্ট স্কেল",
"LabelFontStrikethrough": "অবচ্ছেদন রেখা",
@@ -398,7 +415,9 @@
"LabelIntervalEvery6Hours": "প্রতি ৬ ঘন্টায়",
"LabelIntervalEveryDay": "প্রতিদিন",
"LabelIntervalEveryHour": "প্রতি ঘন্টা",
"LabelIntervalEveryMinute": "প্রতি মিনিটে",
"LabelInvert": "উল্টানো",
"LabelIpAddress": "IP ঠিকানা",
"LabelItem": "আইটেম",
"LabelJumpBackwardAmount": "পিছন দিকে ঝাঁপের পরিমাণ",
"LabelJumpForwardAmount": "সামনের দিকে ঝাঁপের পরিমাণ",
@@ -407,6 +426,7 @@
"LabelLanguages": "ভাষাসমূহ",
"LabelLastBookAdded": "শেষ বই যোগ করা হয়েছে",
"LabelLastBookUpdated": "শেষ বই আপডেট করা হয়েছে",
"LabelLastProgressDate": "সর্বশেষ অগ্রগতি: {0}",
"LabelLastSeen": "শেষ দেখা",
"LabelLastTime": "শেষ বার",
"LabelLastUpdate": "শেষ আপডেট",
@@ -419,6 +439,9 @@
"LabelLibraryFilterSublistEmpty": "না {0}",
"LabelLibraryItem": "লাইব্রেরি আইটেম",
"LabelLibraryName": "লাইব্রেরির নাম",
"LabelLibrarySortByProgress": "অগ্রগতি: সর্বশেষ আপডেট",
"LabelLibrarySortByProgressFinished": "অগ্রগতি: সম্পন্ন",
"LabelLibrarySortByProgressStarted": "অগ্রগতি: শুরু হয়েছে",
"LabelLimit": "সীমা",
"LabelLineSpacing": "লাইন স্পেসিং",
"LabelListenAgain": "আবার শুনুন",
@@ -427,6 +450,7 @@
"LabelLogLevelWarn": "সতর্ক",
"LabelLookForNewEpisodesAfterDate": "এই তারিখের পরে নতুন পর্বগুলি সন্ধান করুন",
"LabelLowestPriority": "সর্বনিম্ন অগ্রাধিকার",
"LabelMatchConfidence": "নিশ্চয়তার মাত্রা",
"LabelMatchExistingUsersBy": "বিদ্যমান ব্যবহারকারীদের দ্বারা মিলিত করুন",
"LabelMatchExistingUsersByDescription": "বিদ্যমান ব্যবহারকারীদের সংযোগ করার জন্য ব্যবহৃত হয়। একবার সংযুক্ত হলে, ব্যবহারকারীদের আপনার SSO প্রদানকারীর থেকে একটি অনন্য আইডি দ্বারা মিলিত হবে",
"LabelMaxEpisodesToDownload": "সর্বাধিক # টি পর্ব ডাউনলোড করা হবে। অসীমের জন্য 0 ব্যবহার করুন।",
@@ -441,7 +465,7 @@
"LabelMetadataProvider": "মেটাডেটা প্রদানকারী",
"LabelMinute": "মিনিট",
"LabelMinutes": "মিনিটস",
"LabelMissing": "নিখোঁজ",
"LabelMissing": "অনুপস্থিত",
"LabelMissingEbook": "কোনও ই-বই নেই",
"LabelMissingSupplementaryEbook": "কোনও সম্পূরক ই-বই নেই",
"LabelMobileRedirectURIs": "অনুমোদিত মোবাইল রিডাইরেক্ট URIs",
@@ -456,7 +480,9 @@
"LabelNewestAuthors": "নতুন লেখক",
"LabelNewestEpisodes": "নতুনতম পর্ব",
"LabelNextBackupDate": "পরবর্তী ব্যাকআপ তারিখ",
"LabelNextChapters": "পরবর্তী অধ্যায়গুলো হবে:",
"LabelNextScheduledRun": "পরবর্তী নির্ধারিত দৌড়",
"LabelNoApiKeys": "কোনো API কী নেই",
"LabelNoCustomMetadataProviders": "কোনো কাস্টম মেটাডেটা প্রদানকারী নেই",
"LabelNoEpisodesSelected": "কোন পর্ব নির্বাচন করা হয়নি",
"LabelNotFinished": "সমাপ্ত হয়নি",
@@ -472,7 +498,8 @@
"LabelNotificationsMaxQueueSize": "বিজ্ঞপ্তি ইভেন্টের জন্য সর্বোচ্চ সারির আকার",
"LabelNotificationsMaxQueueSizeHelp": "ইভেন্টগুলি প্রতি সেকেন্ডে ১ বার ইন্ধন করার মধ্যে সীমাবদ্ধ। সারি সর্বাধিক আকারে থাকলে ইভেন্টগুলি উপেক্ষা করা হবে। এটি বিজ্ঞপ্তি স্প্যামিং প্রতিরোধ করে।",
"LabelNumberOfBooks": "বইয়ের সংখ্যা",
"LabelNumberOfEpisodes": "# টি পর্ব",
"LabelNumberOfChapters": "অধ্যায়ের সংখ্যা:",
"LabelNumberOfEpisodes": "পর্বের সংখ্যা",
"LabelOpenIDAdvancedPermsClaimDescription": "ওপেনআইডি দাবির নাম যাতে অ্যাপ্লিকেশনের মধ্যে ব্যবহারকারীর ক্রিয়াকলাপের জন্য উন্নত অনুমতি রয়েছে যা অ-প্রশাসক ভূমিকাগুলিতে প্রযোজ্য হবে (<b>যদি কনফিগার করা হয়</b>)। প্রতিক্রিয়া থেকে দাবিটি অনুপস্থিত থাকলে, অ্যাক্সেস করুন ABS-তে অস্বীকার করা হবে। যদি একটি একক বিকল্প অনুপস্থিত থাকে, তাহলে এটিকে <code>false</code> হিসাবে গণ্য করা হবে। নিশ্চিত করুন যে পরিচয় প্রদানকারীর দাবি প্রত্যাশিত কাঠামোর সাথে মেলে:",
"LabelOpenIDClaims": "অ্যাডভান্সড গ্রুপ এবং পারমিশন অ্যাসাইনমেন্ট নিষ্ক্রিয় করতে নিম্নলিখিত বিকল্পগুলিকে খালি ছেড়ে দিন, তারপর স্বয়ংক্রিয়ভাবে 'ব্যবহারকারী' গ্রুপকে বরাদ্দ করা হবে।",
"LabelOpenIDGroupClaimDescription": "ওপেনআইডি দাবির নাম যাতে ব্যবহারকারীর গোষ্ঠীর একটি তালিকা থাকে। সাধারণত <code>গ্রুপ</code> হিসাবে উল্লেখ করা হয়। <b>কনফিগার করা থাকলে</b>, অ্যাপ্লিকেশনটি স্বয়ংক্রিয়ভাবে এর উপর ভিত্তি করে ব্যবহারকারীর গোষ্ঠীর সদস্যপদ নির্ধারণ করবে, শর্ত এই যে এই গোষ্ঠীগুলি কেস-অসংবেদনশীলভাবে দাবিতে 'অ্যাডমিন', 'ব্যবহারকারী' বা 'অতিথি' নাম দেওয়া হয়৷ দাবিতে একটি তালিকা থাকা উচিত এবং যদি একজন ব্যবহারকারী একাধিক গোষ্ঠীর অন্তর্গত হয় তবে অ্যাপ্লিকেশনটি বরাদ্দ করবে সর্বোচ্চ স্তরের অ্যাক্সেসের সাথে সঙ্গতিপূর্ণ ভূমিকা৷ যদি কোনও গোষ্ঠীর সাথে মেলে না, তবে অ্যাক্সেস অস্বীকার করা হবে।",
@@ -493,6 +520,7 @@
"LabelPersonalYearReview": "আপনার বছরের পর্যালোচনা ({0})",
"LabelPhotoPathURL": "ছবি পথ/ইউআরএল",
"LabelPlayMethod": "প্লে পদ্ধতি",
"LabelPlaybackRateIncrementDecrement": "প্লেব্যাকের গতি বাড়ানো/কমানোর পরিমাণ",
"LabelPlayerChapterNumberMarker": "{1} এর মধ্যে {0}",
"LabelPlaylists": "প্লেলিস্ট",
"LabelPodcast": "পডকাস্ট",
@@ -515,7 +543,7 @@
"LabelPublishers": "প্রকাশকরা",
"LabelRSSFeedCustomOwnerEmail": "কাস্টম মালিকের ইমেইল",
"LabelRSSFeedCustomOwnerName": "কাস্টম মালিকের নাম",
"LabelRSSFeedOpen": "আরএসএস ফিড খুলুন",
"LabelRSSFeedOpen": "RSS ফিড খোলা আছে",
"LabelRSSFeedPreventIndexing": "সূচীকরণ প্রতিরোধ করুন",
"LabelRSSFeedSlug": "আরএসএস ফিড স্লাগ",
"LabelRSSFeedURL": "আরএসএস ফিড ইউআরএল",
@@ -532,6 +560,7 @@
"LabelReleaseDate": "উন্মোচনের তারিখ",
"LabelRemoveAllMetadataAbs": "সমস্ত metadata.abs ফাইল সরান",
"LabelRemoveAllMetadataJson": "সমস্ত metadata.json ফাইল সরান",
"LabelRemoveAudibleBranding": "অধ্যায়গুলো থেকে Audible-এর ইন্ট্রো ও আউট্রো সরান",
"LabelRemoveCover": "কভার সরান",
"LabelRemoveMetadataFile": "লাইব্রেরি আইটেম ফোল্ডারে মেটাডেটা ফাইল সরান",
"LabelRemoveMetadataFileHelp": "আপনার {0} ফোল্ডারের সমস্ত metadata.json এবং metadata.abs ফাইলগুলি সরান।",
@@ -544,6 +573,7 @@
"LabelSelectAll": "সব নির্বাচন করুন",
"LabelSelectAllEpisodes": "সমস্ত পর্ব নির্বাচন করুন",
"LabelSelectEpisodesShowing": "দেখানো {0}টি পর্ব নির্বাচন করুন",
"LabelSelectUser": "ব্যবহারকারী নির্বাচন করুন",
"LabelSelectUsers": "ব্যবহারকারী নির্বাচন করুন",
"LabelSendEbookToDevice": "ই-বই পাঠান...",
"LabelSequence": "ক্রম",
@@ -561,6 +591,8 @@
"LabelSettingsBookshelfViewHelp": "কাঠের তাক সহ স্কুমরফিক ডিজাইন",
"LabelSettingsChromecastSupport": "ক্রোমকাস্ট সমর্থন",
"LabelSettingsDateFormat": "তারিখ বিন্যাস",
"LabelSettingsEnableWatcher": "পরিবর্তনের জন্য লাইব্রেরিগুলো স্বয়ংক্রিয়ভাবে পর্যবেক্ষণ করুন",
"LabelSettingsEnableWatcherForLibrary": "লাইব্রেরির জন্য ফোল্ডার প্রহরী সক্ষম করুন",
"LabelSettingsEnableWatcherHelp": "ফাইলের পরিবর্তন শনাক্ত হলে আইটেমগুলির স্বয়ংক্রিয় যোগ/আপডেট সক্ষম করবে। *সার্ভার পুনরায় চালু করতে হবে",
"LabelSettingsEpubsAllowScriptedContent": "ইপাবে স্ক্রিপ্ট করা বিষয়বস্তুর অনুমতি দিন",
"LabelSettingsEpubsAllowScriptedContentHelp": "ইপাব ফাইলগুলিকে স্ক্রিপ্ট চালানোর অনুমতি দিন। আপনি ইপাব ফাইলগুলির উৎসকে বিশ্বাস না করলে এই সেটিংটি নিষ্ক্রিয় রাখার সুপারিশ করা হলো।",
@@ -578,7 +610,7 @@
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "কন্টিনিউ সিরিজে আগের বইগুলো এড়িয়ে যান",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "কন্টিনিউ সিরিজের নীড় পেজ শেল্ফ দেখায় যে সিরিজে শুরু হয়নি এমন প্রথম বই যার অন্তত একটি বই শেষ হয়েছে এবং কোনো বই চলছে না। এই সেটিংটি সক্ষম করলে শুরু না হওয়া প্রথম বইটির পরিবর্তে সবচেয়ে দূরের সম্পূর্ণ বই থেকে সিরিজ চলতে থাকবে।",
"LabelSettingsParseSubtitles": "সাবটাইটেল পার্স করুন",
"LabelSettingsParseSubtitlesHelp": "অডিওবুক ফোল্ডারের নাম থেকে সাবটাইটেল বের করুন৷<br>সাবটাইটেল অবশ্যই \" - \"<br>অর্থাৎ \"বুকের শিরোনাম - এখানে একটি সাবটাইটেল\" এর সাবটাইটেল আছে \"এখানে একটি সাবটাইটেল\"",
"LabelSettingsParseSubtitlesHelp": "অডিওবুক ফোল্ডারের নাম থেকে সাবটাইটেল শনাক্ত করুন।<br>সাবটাইটেল অবশ্যই \" - \" দিয়ে আলাদা করা থাকতে হবে।<br>যেমন, \"বইয়ের নাম - একটি সাবটাইটেল\" হলে সাবটাইটেল হবে \"একটি সাবটাইটেল\"",
"LabelSettingsPreferMatchedMetadata": "মিলিত মেটাডেটা পছন্দ করুন",
"LabelSettingsPreferMatchedMetadataHelp": "দ্রুত ম্যাচ ব্যবহার করার সময় মিলে যাওয়া ডেটা আইটেমের বিবরণকে ওভাররাইড করবে। ডিফল্টরূপে দ্রুত ম্যাচ শুধুমাত্র অনুপস্থিত বিশদগুলি পূরণ করবে।",
"LabelSettingsSkipMatchingBooksWithASIN": "এমন বইগুলি এড়িয়ে যান যেগুলির মধ্যে ইতিমধ্যে একটি ASIN আছে",
@@ -604,10 +636,12 @@
"LabelSlug": "স্লাগ",
"LabelSortAscending": "আরোহী",
"LabelSortDescending": "অবরোহী",
"LabelSortPubDate": "প্রকাশের তারিখ অনুযায়ী সাজান",
"LabelStart": "শুরু",
"LabelStartTime": "শুরুর সময়",
"LabelStarted": "শুরু হয়েছে",
"LabelStartedAt": "এতে শুরু হয়েছে",
"LabelStartedDate": "{0} শুরু হয়েছে",
"LabelStatsAudioTracks": "অডিও ট্র্যাক",
"LabelStatsAuthors": "লেখক",
"LabelStatsBestDay": "সেরা দিন",
@@ -637,6 +671,7 @@
"LabelTheme": "থিম",
"LabelThemeDark": "অন্ধকার",
"LabelThemeLight": "আলো",
"LabelThemeSepia": "সেপিয়া",
"LabelTimeBase": "সময় বেস",
"LabelTimeDurationXHours": "{0} ঘণ্টা",
"LabelTimeDurationXMinutes": "{0} মিনিট",
@@ -704,23 +739,33 @@
"LabelYourProgress": "আপনার অগ্রগতি",
"MessageAddToPlayerQueue": "প্লেয়ার সারিতে যোগ করুন",
"MessageAppriseDescription": "এই বৈশিষ্ট্যটি ব্যবহার করার জন্য আপনাকে <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> চালানোর একটি উদাহরণ বা একটি এপিআই পরিচালনা করতে হবে যে একই অনুরোধ পরিচালনা করবে। <br />অ্যাপ্রাইজ এপিআই ইউআরএলটি বিজ্ঞপ্তি পাঠানোর জন্য সম্পূর্ণ ইউআরএল পথ হওয়া উচিত, যেমন, যদি আপনার API ইনস্ট্যান্স <code>http://192.168.1.1:8337</code> এ পরিবেশিত হয় তাহলে আপনি <code> রাখবেন >http://192.168.1.1:8337/notify</code>।",
"MessageAsinCheck": "নিশ্চিত করুন যে আপনি Amazon-এর নয়, সঠিক Audible অঞ্চলের ASIN ব্যবহার করছেন।",
"MessageAuthenticationLegacyTokenWarning": "পুরোনো API টোকেনগুলো ভবিষ্যতে সরিয়ে দেওয়া হবে। এর পরিবর্তে <a href=\"/config/api-keys\">API কী</a> ব্যবহার করুন।",
"MessageAuthenticationOIDCChangesRestart": "OIDC-এর পরিবর্তনগুলো কার্যকর করতে সেভ করার পর সার্ভারটি পুনরায় চালু করুন।",
"MessageAuthenticationSecurityMessage": "নিরাপত্তার জন্য অথেনটিকেশন ব্যবস্থা উন্নত করা হয়েছে। সকল ব্যবহারকারীকে আবার লগইন করতে হবে।",
"MessageBackupsDescription": "ব্যাকআপের মধ্যে রয়েছে ব্যবহারকারী, ব্যবহারকারীর অগ্রগতি, লাইব্রেরি আইটেমের বিবরণ, সার্ভার সেটিংস এবং <code>/metadata/items</code> & <code>/metadata/authors</code>-এ সংরক্ষিত ছবি। ব্যাকআপগুলি <strong> আপনার লাইব্রেরি ফোল্ডারে সঞ্চিত কোনো ফাইল >অন্তর্ভুক্ত করবেন না</strong>।",
"MessageBackupsLocationEditNote": "দ্রষ্টব্য: ব্যাকআপ অবস্থান আপডেট করলে বিদ্যমান ব্যাকআপগুলি সরানো বা সংশোধন করা হবে না",
"MessageBackupsLocationNoEditNote": "দ্রষ্টব্য: ব্যাকআপ অবস্থান একটি পরিবেশ পরিবর্তনশীল মাধ্যমে স্থির করা হয়েছে এবং এখানে পরিবর্তন করা যাবে না।",
"MessageBackupsLocationPathEmpty": "ব্যাকআপ অবস্থানের পথ খালি থাকতে পারবে না",
"MessageBatchEditPopulateMapDetailsAllHelp": "সক্রিয় ফিল্ডগুলো সব আইটেমের তথ্য দিয়ে পূরণ করুন। একাধিক মান থাকা ফিল্ডগুলোর মান একত্রিত করা হবে",
"MessageBatchEditPopulateMapDetailsItemHelp": "সক্রিয় ম্যাপের বিস্তারিত ফিল্ডগুলো এই আইটেমের তথ্য দিয়ে পূরণ করুন",
"MessageBatchQuickMatchDescription": "কুইক ম্যাচ নির্বাচিত আইটেমগুলির জন্য অনুপস্থিত কভার এবং মেটাডেটা যোগ করার চেষ্টা করবে। বিদ্যমান কভার এবং/অথবা মেটাডেটা ওভাররাইট করার জন্য দ্রুত ম্যাচকে অনুমতি দিতে নীচের বিকল্পগুলি সক্ষম করুন।",
"MessageBookshelfNoCollections": "আপনি এখনও কোনো সংগ্রহ করেননি",
"MessageBookshelfNoCollectionsHelp": "কালেকশনগুলো সবার জন্য উন্মুক্ত। লাইব্রেরিতে অ্যাক্সেস থাকা সকল ব্যবহারকারী এগুলো দেখতে পারবেন।",
"MessageBookshelfNoRSSFeeds": "কোনও RSS ফিড খোলা নেই",
"MessageBookshelfNoResultsForFilter": "ফিল্টার \"{0}: {1}\" এর জন্য কোন ফলাফল নেই",
"MessageBookshelfNoResultsForQuery": "প্রশ্নের জন্য কোন ফলাফল নেই",
"MessageBookshelfNoSeries": "আপনার কোনো সিরিজ নেই",
"MessageBulkChapterPattern": "এই নম্বর দেওয়ার ধরন অনুযায়ী আপনি কতটি অধ্যায় যোগ করতে চান?",
"MessageChapterEndIsAfter": "অধ্যায়ের সমাপ্তি আপনার অডিওবুকের শেষে",
"MessageChapterErrorFirstNotZero": "প্রথম অধ্যায় 0 এ শুরু হতে হবে",
"MessageChapterErrorStartGteDuration": "অবৈধ শুরুর সময় অবশ্যই অডিওবুকের সময়কালের কম হতে হবে",
"MessageChapterErrorStartLtPrev": "অবৈধ শুরুর সময় অবশ্যই আগের অধ্যায় শুরুর সময়ের চেয়ে বেশি বা সমান হতে হবে",
"MessageChapterStartIsAfter": "আপনার অডিওবুক শেষ হওয়ার পরে অধ্যায় শুরু হয়",
"MessageChaptersNotFound": "কোনো অধ্যায় পাওয়া যায়নি",
"MessageCheckingCron": "ক্রন পরীক্ষা করা হচ্ছে...",
"MessageConfirmCloseFeed": "আপনি কি নিশ্চিত যে আপনি এই ফিডটি বন্ধ করতে চান?",
"MessageConfirmDeleteApiKey": "আপনি কি নিশ্চিত যে API কী \"{0}\" মুছে ফেলতে চান?",
"MessageConfirmDeleteBackup": "আপনি কি নিশ্চিত যে আপনি {0} এর ব্যাকআপ মুছে ফেলতে চান?",
"MessageConfirmDeleteDevice": "আপনি কি নিশ্চিতভাবে ই-রিডার ডিভাইস \"{0}\" মুছতে চান?",
"MessageConfirmDeleteFile": "এটি আপনার ফাইল সিস্টেম থেকে ফাইলটি মুছে দেবে। আপনি কি নিশ্চিত?",
@@ -732,6 +777,7 @@
"MessageConfirmDeleteSession": "আপনি কি নিশ্চিত আপনি এই অধিবেশন মুছে দিতে চান?",
"MessageConfirmEmbedMetadataInAudioFiles": "আপনি কি {0}টি অডিও ফাইলে মেটাডেটা এম্বেড করার বিষয়ে নিশ্চিত?",
"MessageConfirmForceReScan": "আপনি কি নিশ্চিত যে আপনি জোর করে পুনরায় স্ক্যান করতে চান?",
"MessageConfirmLogoutDevice": "আপনি কি নিশ্চিত যে \"{0}\" থেকে লগআউট করতে চান?",
"MessageConfirmMarkAllEpisodesFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্ব সমাপ্ত হিসাবে চিহ্নিত করতে চান?",
"MessageConfirmMarkAllEpisodesNotFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্বকে শেষ হয়নি বলে চিহ্নিত করতে চান?",
"MessageConfirmMarkItemFinished": "আপনি কি \"{0}\" কে সমাপ্ত হিসাবে চিহ্নিত করার বিষয়ে নিশ্চিত?",
@@ -748,6 +794,7 @@
"MessageConfirmRemoveAuthor": "আপনি কি নিশ্চিত যে আপনি লেখক \"{0}\" অপসারণ করতে চান?",
"MessageConfirmRemoveCollection": "আপনি কি নিশ্চিত যে আপনি সংগ্রহ \"{0}\" সরাতে চান?",
"MessageConfirmRemoveEpisode": "আপনি কি নিশ্চিত আপনি \"{0}\" পর্বটি সরাতে চান?",
"MessageConfirmRemoveEpisodeNote": "দ্রষ্টব্য: \"Hard delete file\" চালু না করলে অডিও ফাইলটি মুছে ফেলা হবে না",
"MessageConfirmRemoveEpisodes": "আপনি কি নিশ্চিত যে আপনি {0}টি পর্ব সরাতে চান?",
"MessageConfirmRemoveListeningSessions": "আপনি কি নিশ্চিত যে আপনি {0}টি শোনার সেশন সরাতে চান?",
"MessageConfirmRemoveMetadataFiles": "আপনি কি আপনার লাইব্রেরি আইটেম ফোল্ডারে থাকা সমস্ত মেটাডেটা {0} ফাইল মুছে ফেলার বিষয়ে নিশ্চিত?",
@@ -762,6 +809,7 @@
"MessageConfirmResetProgress": "আপনি কি আপনার অগ্রগতি রিসেট করার বিষয়ে নিশ্চিত?",
"MessageConfirmSendEbookToDevice": "আপনি কি নিশ্চিত যে আপনি \"{2}\" ডিভাইসে {0} ইবুক \"{1}\" পাঠাতে চান?",
"MessageConfirmUnlinkOpenId": "আপনি কি এই ব্যবহারকারীকে ওপেনআইডি থেকে লিঙ্কমুক্ত করার বিষয়ে নিশ্চিত?",
"MessageDaysListenedInTheLastYear": "গত এক বছরে {0} দিন শোনা হয়েছে",
"MessageDownloadingEpisode": "ডাউনলোডিং পর্ব",
"MessageDragFilesIntoTrackOrder": "সঠিক ট্র্যাক অর্ডারে ফাইল টেনে আনুন",
"MessageEmbedFailed": "এম্বেড ব্যর্থ হয়েছে!",
@@ -772,8 +820,11 @@
"MessageFeedURLWillBe": "ফিড URL হবে {0}",
"MessageFetching": "আনয় হচ্ছে.।",
"MessageForceReScanDescription": "সকল ফাইল আবার নতুন স্ক্যানের মত স্ক্যান করবে। অডিও ফাইল ID3 ট্যাগ, OPF ফাইল, এবং টেক্সট ফাইলগুলি নতুন হিসাবে স্ক্যান করা হবে।",
"MessageHeatmapListeningTimeTooltip": "{1}-এ <strong>{0} জন শুনছেন</strong>",
"MessageHeatmapNoListeningSessions": "{0}-এ কোনো শোনার সেশন নেই",
"MessageImportantNotice": "গুরুত্বপূর্ণ বিজ্ঞপ্তি!",
"MessageInsertChapterBelow": "নীচে অধ্যায় ঢোকান",
"MessageInvalidAsin": "অবৈধ ASIN",
"MessageItemsSelected": "{0}টি আইটেম নির্বাচিত",
"MessageItemsUpdated": "{0}টি আইটেম আপডেট করা হয়েছে",
"MessageJoinUsOn": "আমাদের সাথে যোগ দিন",
@@ -819,6 +870,7 @@
"MessageNoTasksRunning": "কোন টাস্ক চলছে না",
"MessageNoUpdatesWereNecessary": "কোন আপডেটের প্রয়োজন ছিল না",
"MessageNoUserPlaylists": "আপনার কোনো প্লেলিস্ট নেই",
"MessageNoUserPlaylistsHelp": "প্লেলিস্টগুলো ব্যক্তিগত। শুধুমাত্র যে ব্যবহারকারী এগুলো তৈরি করেছেন তিনিই সেগুলো দেখতে পারবেন।",
"MessageNotYetImplemented": "এখনও বাস্তবায়িত হয়নি",
"MessageOpmlPreviewNote": "দ্রষ্টব্য: এটি পার্স করা OPML ফাইলের একটি পূর্বরূপ। প্রকৃত পডকাস্ট শিরোনাম RSS ফিড থেকে নেওয়া হবে।",
"MessageOr": "বা",
@@ -840,8 +892,11 @@
"MessageResetChaptersConfirm": "আপনি কি নিশ্চিত যে আপনি অধ্যায়গুলি পুনরায় সেট করতে চান এবং আপনার করা পরিবর্তনগুলি পূর্বাবস্থায় ফেরাতে চান?",
"MessageRestoreBackupConfirm": "আপনি কি নিশ্চিত যে আপনি তৈরি করা ব্যাকআপ পুনরুদ্ধার করতে চান",
"MessageRestoreBackupWarning": "একটি ব্যাকআপ পুনরুদ্ধার করা হলে তা /config-এ অবস্থিত সমগ্র ডাটাবেস ওভাররাইট করবে এবং /metadata/items & /metadata/authors-এ থাকা ছবিগুলিকে কভার করবে৷<br /><br />ব্যাকআপগুলি আপনার লাইব্রেরি ফোল্ডারে কোনো ফাইল পরিবর্তন করে না৷ আপনি যদি আপনার লাইব্রেরি ফোল্ডারে কভার আর্ট এবং মেটাডেটা সংরক্ষণ করতে সার্ভার সেটিংস সক্ষম করে থাকেন তবে সেগুলি ব্যাক আপ বা ওভাররাইট করা হয় না৷<br /><br />আপনার সার্ভার ব্যবহারকারী সমস্ত ক্লায়েন্ট স্বয়ংক্রিয়ভাবে রিফ্রেশ হবে৷",
"MessageScheduleLibraryScanNote": "বেশিরভাগ ব্যবহারকারীর জন্য এই ফিচারটি বন্ধ রেখে \"Automatically watch library for changes\" সেটিংটি চালু রাখার পরামর্শ দেওয়া হয়—এটি আপনার লাইব্রেরি ফোল্ডারের পরিবর্তনগুলো স্বয়ংক্রিয়ভাবে শনাক্ত করবে। আপনার ফাইল সিস্টেমে (যেমন NFS) \"Automatically watch library for changes\" কাজ না করলে এই ফিচারটি চালু করুন।",
"MessageScheduleRunEveryWeekdayAtTime": "প্রতি {0} অন্তর {1}-এ চালান",
"MessageSearchResultsFor": "এর জন্য অনুসন্ধান ফলাফল",
"MessageSelected": "{0}টি নির্বাচিত",
"MessageSeriesSequenceCannotContainSpaces": "সিরিজের ক্রমে স্পেস থাকতে পারবে না",
"MessageServerCouldNotBeReached": "সার্ভারে পৌঁছানো যায়নি",
"MessageSetChaptersFromTracksDescription": "প্রতিটি অডিও ফাইলকে অধ্যায় হিসেবে ব্যবহার করে অধ্যায় সেট করুন এবং অডিও ফাইলের নাম হিসেবে অধ্যায়ের শিরোনাম করুন",
"MessageShareExpirationWillBe": "মেয়াদ শেষ হবে <strong>{0}</strong>",
@@ -903,7 +958,10 @@
"NotificationOnBackupCompletedDescription": "ব্যাকআপ সম্পূর্ণ হলে ট্রিগার হবে",
"NotificationOnBackupFailedDescription": "ব্যাকআপ ব্যর্থ হলে ট্রিগার হবে",
"NotificationOnEpisodeDownloadedDescription": "একটি পডকাস্ট পর্ব স্বয়ংক্রিয়ভাবে ডাউনলোড হলে ট্রিগার হবে",
"NotificationOnRSSFeedDisabledDescription": "অনেকবার ডাউনলোডের চেষ্টা ব্যর্থ হওয়ার কারণে স্বয়ংক্রিয় পর্ব ডাউনলোড বন্ধ হয়ে গেলে এটি ট্রিগার হয়",
"NotificationOnRSSFeedFailedDescription": "স্বয়ংক্রিয় পর্ব ডাউনলোডের জন্য RSS ফিডের অনুরোধ ব্যর্থ হলে এটি ট্রিগার হয়",
"NotificationOnTestDescription": "বিজ্ঞপ্তি সিস্টেম পরীক্ষার জন্য ইভেন্ট",
"PlaceholderBulkChapterInput": "অধ্যায়ের শিরোনাম লিখুন অথবা নম্বর ব্যবহার করুন (যেমন, 'পর্ব 1', 'অধ্যায় 10', '1.')",
"PlaceholderNewCollection": "নতুন সংগ্রহের নাম",
"PlaceholderNewFolderPath": "নতুন ফোল্ডার পথ",
"PlaceholderNewPlaylist": "নতুন প্লেলিস্টের নাম",
@@ -947,6 +1005,7 @@
"ToastBackupRestoreFailed": "ব্যাকআপ পুনরুদ্ধার করতে ব্যর্থ",
"ToastBackupUploadFailed": "ব্যাকআপ আপলোড করতে ব্যর্থ",
"ToastBackupUploadSuccess": "ব্যাকআপ আপলোড হয়েছে",
"ToastBatchApplyDetailsToItemsSuccess": "আইটেমগুলোতে বিস্তারিত তথ্য প্রয়োগ করা হয়েছে",
"ToastBatchDeleteFailed": "ব্যাচ মুছে ফেলতে ব্যর্থ হয়েছে",
"ToastBatchDeleteSuccess": "ব্যাচ মুছে ফেলা সফল হয়েছে",
"ToastBatchQuickMatchFailed": "ব্যাচ কুইক ম্যাচ ব্যর্থ!",
@@ -956,16 +1015,25 @@
"ToastBookmarkCreateFailed": "বুকমার্ক তৈরি করতে ব্যর্থ",
"ToastBookmarkCreateSuccess": "বুকমার্ক যোগ করা হয়েছে",
"ToastBookmarkRemoveSuccess": "বুকমার্ক সরানো হয়েছে",
"ToastBulkChapterInvalidCount": "১ থেকে ১৫০-এর মধ্যে একটি সংখ্যা লিখুন",
"ToastCachePurgeFailed": "ক্যাশে পরিষ্কার করতে ব্যর্থ হয়েছে",
"ToastCachePurgeSuccess": "ক্যাশে সফলভাবে পরিষ্কার করা হয়েছে",
"ToastChapterLocked": "অধ্যায়টি লক করা আছে।",
"ToastChapterStartTimeAdjusted": "অধ্যায়ের শুরুর সময় {0} সেকেন্ড সমন্বয় করা হয়েছে",
"ToastChaptersAllLocked": "সব অধ্যায় লক করা আছে। সময় পরিবর্তন করতে কিছু অধ্যায় আনলক করুন।",
"ToastChaptersHaveErrors": "অধ্যায়ে ত্রুটি আছে",
"ToastChaptersInvalidShiftAmountLast": "সময় সরানোর পরিমাণ সঠিক নয়। এতে শেষ অধ্যায়ের শুরুর সময় এই অডিওবুকের মোট সময়সীমা ছাড়িয়ে যাবে।",
"ToastChaptersInvalidShiftAmountStart": "সময় সরানোর পরিমাণ সঠিক নয়। প্রথম অধ্যায়ের দৈর্ঘ্য শূন্য বা ঋণাত্মক হয়ে যাবে এবং দ্বিতীয় অধ্যায়টি এটিকে ওভাররাইট করবে। দ্বিতীয় অধ্যায়ের শুরুর সময় বাড়ান।",
"ToastChaptersMustHaveTitles": "অধ্যায়ের শিরোনাম থাকতে হবে",
"ToastChaptersRemoved": "অধ্যায়গুলো মুছে ফেলা হয়েছে",
"ToastChaptersUpdated": "অধ্যায় আপডেট করা হয়েছে",
"ToastCollectionItemsAddFailed": "আইটেম(গুলি) সংগ্রহে যোগ করা ব্যর্থ হয়েছে",
"ToastCollectionRemoveSuccess": "সংগ্রহ সরানো হয়েছে",
"ToastCollectionUpdateSuccess": "সংগ্রহ আপডেট করা হয়েছে",
"ToastConnectionNotAvailable": "সংযোগ পাওয়া যাচ্ছে না। অনুগ্রহ করে পরে আবার চেষ্টা করুন",
"ToastCoverSearchFailed": "কভার অনুসন্ধান ব্যর্থ হয়েছে",
"ToastCoverUpdateFailed": "কভার আপডেট ব্যর্থ হয়েছে",
"ToastDateTimeInvalidOrIncomplete": "তারিখ ও সময় সঠিক নয় অথবা অসম্পূর্ণ",
"ToastDeleteFileFailed": "ফাইল মুছে ফেলতে ব্যর্থ হয়েছে",
"ToastDeleteFileSuccess": "ফাইল মুছে ফেলা হয়েছে",
"ToastDeviceAddFailed": "ডিভাইস যোগ করতে ব্যর্থ হয়েছে",
@@ -979,6 +1047,8 @@
"ToastEpisodeDownloadQueueClearSuccess": "পর্ব ডাউনলোড সারি পরিষ্কার করা হয়েছে",
"ToastEpisodeUpdateSuccess": "{0}টি পর্ব আপডেট করা হয়েছে",
"ToastErrorCannotShare": "এই ডিভাইসে স্থানীয়ভাবে শেয়ার করা যাবে না",
"ToastFailedToCreate": "তৈরি করা যায়নি",
"ToastFailedToDelete": "মুছে ফেলা যায়নি",
"ToastFailedToLoadData": "ডেটা লোড করা যায়নি",
"ToastFailedToMatch": "মেলাতে ব্যর্থ হয়েছে",
"ToastFailedToShare": "শেয়ার করতে ব্যর্থ",
@@ -986,6 +1056,7 @@
"ToastInvalidImageUrl": "অকার্যকর ছবির ইউআরএল",
"ToastInvalidMaxEpisodesToDownload": "ডাউনলোড করার জন্য অবৈধ সর্বোচ্চ পর্ব",
"ToastInvalidUrl": "অকার্যকর ইউআরএল",
"ToastInvalidUrls": "এক বা একাধিক URL সঠিক নয়",
"ToastItemCoverUpdateSuccess": "আইটেম কভার আপডেট করা হয়েছে",
"ToastItemDeletedFailed": "আইটেম মুছে ফেলতে ব্যর্থ",
"ToastItemDeletedSuccess": "মুছে ফেলা আইটেম",
@@ -1010,6 +1081,7 @@
"ToastMustHaveAtLeastOnePath": "অন্তত একটি পথ থাকতে হবে",
"ToastNameEmailRequired": "নাম এবং ইমেইল আবশ্যক",
"ToastNameRequired": "নাম আবশ্যক",
"ToastNewApiKeyUserError": "একজন ব্যবহারকারী নির্বাচন করতে হবে",
"ToastNewEpisodesFound": "{0}টি নতুন পর্ব পাওয়া গেছে",
"ToastNewUserCreatedFailed": "অ্যাকাউন্ট তৈরি করতে ব্যর্থ: \"{0}\"",
"ToastNewUserCreatedSuccess": "নতুন একাউন্ট তৈরি হয়েছে",
@@ -1018,6 +1090,7 @@
"ToastNewUserTagError": "অন্তত একটি ট্যাগ নির্বাচন করতে হবে",
"ToastNewUserUsernameError": "একটি ব্যবহারকারীর নাম লিখুন",
"ToastNoNewEpisodesFound": "কোন নতুন পর্ব পাওয়া যায়নি",
"ToastNoRSSFeed": "ডকাস্টটির কোনো RSS ফিড নেই",
"ToastNoUpdatesNecessary": "কোন আপডেটের প্রয়োজন নেই",
"ToastNotificationCreateFailed": "বিজ্ঞপ্তি তৈরি করতে ব্যর্থ",
"ToastNotificationDeleteFailed": "বিজ্ঞপ্তি মুছে ফেলতে ব্যর্থ",
@@ -1033,6 +1106,7 @@
"ToastPlaylistUpdateSuccess": "প্লেলিস্ট আপডেট করা হয়েছে",
"ToastPodcastCreateFailed": "পডকাস্ট তৈরি করতে ব্যর্থ",
"ToastPodcastCreateSuccess": "পডকাস্ট সফলভাবে তৈরি করা হয়েছে",
"ToastPodcastEpisodeUpdated": "পর্ব আপডেট করা হয়েছে",
"ToastPodcastGetFeedFailed": "পডকাস্ট ফিড পেতে ব্যর্থ হয়েছে",
"ToastPodcastNoEpisodesInFeed": "আরএসএস ফিডে কোনো পর্ব পাওয়া যায়নি",
"ToastPodcastNoRssFeed": "পডকাস্টের কোন আরএসএস ফিড নেই",
@@ -1057,6 +1131,7 @@
"ToastSelectAtLeastOneUser": "অন্তত একজন ব্যবহারকারী নির্বাচন করুন",
"ToastSendEbookToDeviceFailed": "ডিভাইসে ইবুক পাঠাতে ব্যর্থ",
"ToastSendEbookToDeviceSuccess": "ইবুক \"{0}\" ডিভাইসে পাঠানো হয়েছে",
"ToastSeriesSubmitFailedSameName": "একই নামে দুটি সিরিজ যোগ করা যাবে না",
"ToastSeriesUpdateFailed": "সিরিজ আপডেট ব্যর্থ হয়েছে",
"ToastSeriesUpdateSuccess": "সিরিজ আপডেট সাফল্য",
"ToastServerSettingsUpdateSuccess": "সার্ভার সেটিংস আপডেট করা হয়েছে",
@@ -1075,10 +1150,20 @@
"ToastUnknownError": "অজানা ত্রুটি",
"ToastUnlinkOpenIdFailed": "OpenID থেকে ব্যবহারকারীকে আনলিঙ্ক করতে ব্যর্থ হয়েছে",
"ToastUnlinkOpenIdSuccess": "OpenID থেকে ব্যবহারকারীকে লিঙ্কমুক্ত করা হয়েছে",
"ToastUploaderFilepathExistsError": "ফাইল পাথ \"{0}\" ইতিমধ্যে সার্ভারে বিদ্যমান",
"ToastUploaderItemExistsInSubdirectoryError": "আইটেম \"{0}\" আপলোড পাথের একটি সাবডিরেক্টরি ব্যবহার করছে।",
"ToastUserDeleteFailed": "ব্যবহারকারী মুছতে ব্যর্থ",
"ToastUserDeleteSuccess": "ব্যবহারকারী মুছে ফেলা হয়েছে",
"ToastUserPasswordChangeSuccess": "পাসওয়ার্ড সফলভাবে পরিবর্তন করা হয়েছে",
"ToastUserPasswordMismatch": "পাসওয়ার্ড মিলছে না",
"ToastUserPasswordMustChange": "নতুন পাসওয়ার্ড পুরানো পাসওয়ার্ডের সাথে মিলতে পারবে না",
"ToastUserRootRequireName": "একটি রুট ব্যবহারকারীর নাম লিখতে হবে"
"ToastUserRootRequireName": "একটি রুট ব্যবহারকারীর নাম লিখতে হবে",
"TooltipAddChapters": "অধ্যায় যোগ করুন",
"TooltipAddOneSecond": "১ সেকেন্ড যোগ করুন",
"TooltipAdjustChapterStart": "শুরুর সময় সমন্বয় করতে ক্লিক করুন",
"TooltipLockAllChapters": "সব অধ্যায় লক করুন",
"TooltipLockChapter": "অধ্যায় লক করুন (একাধিক অধ্যায়ের পরিসর নির্বাচন করতে Shift+ক্লিক করুন)",
"TooltipSubtractOneSecond": "১ সেকেন্ড কমান",
"TooltipUnlockAllChapters": "সব অধ্যায় আনলক করুন",
"TooltipUnlockChapter": "অধ্যায় আনলক করুন (একাধিক অধ্যায়ের পরিসর নির্বাচন করতে Shift+ক্লিক করুন)"
}
+8 -4
View File
@@ -46,6 +46,7 @@
"ButtonLatest": "Nejnovější",
"ButtonLibrary": "Knihovna",
"ButtonLogout": "Odhlásit",
"ButtonLogoutAllDevices": "Odhlásit ze všech zařízení",
"ButtonLookup": "Vyhledat",
"ButtonManageTracks": "Správa stop",
"ButtonMapChapterTitles": "Mapovat názvy kapitol",
@@ -194,6 +195,7 @@
"HeaderScheduleEpisodeDownloads": "Naplánovat automatické stahování epizod",
"HeaderScheduleLibraryScans": "Naplánovat automatické prohledávání knihoven",
"HeaderSession": "Relace",
"HeaderSessions": "Relace",
"HeaderSetBackupSchedule": "Nastavit plán zálohování",
"HeaderSettings": "Nastavení",
"HeaderSettingsDisplay": "Zobrazit",
@@ -206,7 +208,7 @@
"HeaderStatsLargestItems": "Největší položky",
"HeaderStatsLongestItems": "Nejdelší položky (hod.)",
"HeaderStatsMinutesListeningChart": "Poslech za posledních 7 dní (min)",
"HeaderStatsRecentSessions": "Poslední relace",
"HeaderStatsRecentSessions": "Nedávné relace",
"HeaderStatsTop10Authors": "Top 10 autorů",
"HeaderStatsTop5Genres": "Top 5 žánrů",
"HeaderTableOfContents": "Obsah",
@@ -415,6 +417,7 @@
"LabelIntervalEveryHour": "Každou hodinu",
"LabelIntervalEveryMinute": "Každou minutu",
"LabelInvert": "Invertovat",
"LabelIpAddress": "IP Adresa",
"LabelItem": "Položka",
"LabelJumpBackwardAmount": "Přeskočit zpět o",
"LabelJumpForwardAmount": "Přeskočit dopředu o",
@@ -774,6 +777,7 @@
"MessageConfirmDeleteSession": "Opravdu chcete smazat tuto relaci?",
"MessageConfirmEmbedMetadataInAudioFiles": "Jste si jisti, že chcete vložit metadata do {0} zvukových souborů?",
"MessageConfirmForceReScan": "Opravdu chcete vynutit opětovné prohledání?",
"MessageConfirmLogoutDevice": "Opravdu se chcete odhlásit z \"{0}\"?",
"MessageConfirmMarkAllEpisodesFinished": "Opravdu chcete označit všechny epizody jako dokončené?",
"MessageConfirmMarkAllEpisodesNotFinished": "Opravdu chcete označit všechny epizody jako nedokončené?",
"MessageConfirmMarkItemFinished": "Opravdu chcete označit \"{0}\" jako dokončené?",
@@ -970,7 +974,7 @@
"StatsBooksFinishedThisYear": "Některé knihy dokončené tento rok…",
"StatsBooksListenedTo": "knih poslechnuto",
"StatsCollectionGrewTo": "Vaše kolekce knih se rozrostla na…",
"StatsSessions": "sezóna",
"StatsSessions": "relace",
"StatsSpentListening": "stráveno posloucháním",
"StatsTopAuthor": "TOP AUTOR",
"StatsTopAuthors": "TOP AUTOŘI",
@@ -1131,12 +1135,12 @@
"ToastSeriesUpdateFailed": "Aktualizace série se nezdařila",
"ToastSeriesUpdateSuccess": "Aktualizace série byla úspěšná",
"ToastServerSettingsUpdateSuccess": "Nastavení serveru aktualizováno",
"ToastSessionCloseFailed": "Chyba při ukončení",
"ToastSessionCloseFailed": "Nepodařilo se ukončit relaci",
"ToastSessionDeleteFailed": "Nepodařilo se smazat relaci",
"ToastSessionDeleteSuccess": "Relace smazána",
"ToastSleepTimerDone": "Uspání knížky ... zZzzZz",
"ToastSlugMustChange": "Slug obsahuje chybné znaky",
"ToastSlugRequired": "Slug (URL) je vyžadována",
"ToastSlugRequired": "Slug (URL) je vyžadován",
"ToastSocketConnected": "Socket připojen",
"ToastSocketDisconnected": "Socket odpojen",
"ToastSocketFailedToConnect": "Socket se nepodařilo připojit",
+251 -251
View File
@@ -3,7 +3,7 @@
"ButtonAddApiKey": "Tilføj API-nøgle",
"ButtonAddChapters": "Tilføj kapitler",
"ButtonAddDevice": "Tilføj enhed",
"ButtonAddLibrary": "Tilføj Bibliotek",
"ButtonAddLibrary": "Tilføj bibliotek",
"ButtonAddPodcasts": "Tilføj podcasts",
"ButtonAddUser": "Tilføj bruger",
"ButtonAddYourFirstLibrary": "Tilføj dit første bibliotek",
@@ -11,21 +11,21 @@
"ButtonApplyChapters": "Anvend kapitler",
"ButtonAuthors": "Forfattere",
"ButtonBack": "Tilbage",
"ButtonBatchEditPopulateFromExisting": "Opret fra eksisterende",
"ButtonBatchEditPopulateMapDetails": "Opret fra kortlægnings detaljer",
"ButtonBatchEditPopulateFromExisting": "Kopiér fra eksisterende",
"ButtonBatchEditPopulateMapDetails": "Kopiér til masseredigering",
"ButtonBrowseForFolder": "Gennemse mappe",
"ButtonCancel": "Annuller",
"ButtonCancelEncode": "Annuller kodning",
"ButtonChangeRootPassword": "Ændr rodadgangskode",
"ButtonCheckAndDownloadNewEpisodes": "Tjek og download nye episoder",
"ButtonCheckAndDownloadNewEpisodes": "Tjek og hent nye afsnit",
"ButtonChooseAFolder": "Vælg en mappe",
"ButtonChooseFiles": "Vælg filer",
"ButtonClearFilter": "Ryd filter",
"ButtonClose": "Luk",
"ButtonCloseFeed": "Luk feed",
"ButtonCloseSession": "Luk Åben Session",
"ButtonCloseSession": "Luk åben session",
"ButtonCollections": "Samlinger",
"ButtonConfigureScanner": "Konfigurer scanner",
"ButtonConfigureScanner": "Konfigurer skanner",
"ButtonCreate": "Opret",
"ButtonCreateBackup": "Opret sikkerhedskopi",
"ButtonDelete": "Slet",
@@ -33,10 +33,10 @@
"ButtonEdit": "Rediger",
"ButtonEditChapters": "Rediger kapitler",
"ButtonEditPodcast": "Rediger podcast",
"ButtonEnable": "Aktiver",
"ButtonEnable": "Aktivér",
"ButtonFireAndFail": "Affyring Og Fejl",
"ButtonFireOnTest": "Affyring vedTest begivenhed",
"ButtonForceReScan": "Tving genindlæsning",
"ButtonFireOnTest": "Udløs onTest-hændelse",
"ButtonForceReScan": "Tving genskanning",
"ButtonFullPath": "Fuld sti",
"ButtonHide": "Skjul",
"ButtonHome": "Hjem",
@@ -53,321 +53,321 @@
"ButtonMatchBooks": "Match bøger",
"ButtonNevermind": "Glem det",
"ButtonNext": "Næste",
"ButtonNextChapter": "Næste Kapitel",
"ButtonNextItemInQueue": "Næste Element i Køen",
"ButtonNextChapter": "Næste kapitel",
"ButtonNextItemInQueue": "Næste element i køen",
"ButtonOk": "Ok",
"ButtonOpenFeed": "Åbn feed",
"ButtonOpenManager": "Åbn manager",
"ButtonPause": "Pause",
"ButtonPlay": "Afspil",
"ButtonPlayAll": "Afspil Alle",
"ButtonPlayAll": "Afspil alle",
"ButtonPlaying": "Afspiller",
"ButtonPlaylists": "Afspilningslister",
"ButtonPrevious": "Sidste",
"ButtonPreviousChapter": "Sidste Kapitel",
"ButtonProbeAudioFile": "Undersøg Lydfil",
"ButtonPurgeAllCache": "Ryd al cache",
"ButtonPurgeItemsCache": "Ryd elementcache",
"ButtonQueueAddItem": "Tilføj til kø",
"ButtonPlaylists": "Playlister",
"ButtonPrevious": "Forrige",
"ButtonPreviousChapter": "Forrige kapitel",
"ButtonProbeAudioFile": "Undersøg lydfil",
"ButtonPurgeAllCache": "Ryd alle mellemlagre",
"ButtonPurgeItemsCache": "Ryd mediemellemlager",
"ButtonQueueAddItem": "Sæt i kø",
"ButtonQueueRemoveItem": "Fjern fra kø",
"ButtonQuickEmbed": "Hurtig Indlejring",
"ButtonQuickEmbedMetadata": "Hurtig Indlejring af Metadata",
"ButtonQuickEmbed": "Hurtig indlejring",
"ButtonQuickEmbedMetadata": "Hurtig indlejring af metadata",
"ButtonQuickMatch": "Hurtig Match",
"ButtonReScan": "Gen-scan",
"ButtonReScan": "Skan igen",
"ButtonRead": "Læs",
"ButtonReadLess": "Se mindre",
"ButtonReadMore": "Se mere",
"ButtonRefresh": "Genindlæs",
"ButtonRemove": "Fjern",
"ButtonRemoveAll": "Fjern Alle",
"ButtonRemoveAllLibraryItems": "Fjern Alle Bibliotekselementer",
"ButtonRemoveFromContinueListening": "Fjern fra Fortsæt Lytning",
"ButtonRemoveFromContinueReading": "Fjern fra Fortsæt Læsning",
"ButtonRemoveSeriesFromContinueSeries": "Fjern Serie fra Fortsæt Serie",
"ButtonRemoveAll": "Fjern alle",
"ButtonRemoveAllLibraryItems": "Fjern alle bibliotekselementer",
"ButtonRemoveFromContinueListening": "Fjern fra »Fortsæt med at lytte«",
"ButtonRemoveFromContinueReading": "Fjern fra »Fortsæt med at læse«",
"ButtonRemoveSeriesFromContinueSeries": "Fjern Serie fra »Fortsæt serie«",
"ButtonReset": "Nulstil",
"ButtonResetToDefault": "Nulstil til standard",
"ButtonRestore": "Gendan",
"ButtonSave": "Gem",
"ButtonSaveAndClose": "Gem & Luk",
"ButtonSaveTracklist": "Gem Sporliste",
"ButtonScan": "Scan",
"ButtonScanLibrary": "Scan Bibliotek",
"ButtonScrollLeft": "Rul til Venstre",
"ButtonScrollRight": "Rul til Højre",
"ButtonSaveAndClose": "Gem og luk",
"ButtonSaveTracklist": "Gem sporliste",
"ButtonScan": "Skan",
"ButtonScanLibrary": "Skan bibliotek",
"ButtonScrollLeft": "Rul til venstre",
"ButtonScrollRight": "Rul til højre",
"ButtonSearch": "Søg",
"ButtonSelectFolderPath": "Vælg Mappe Sti",
"ButtonSelectFolderPath": "Vælg mappesti",
"ButtonSeries": "Serier",
"ButtonSetChaptersFromTracks": "Sæt kapitler fra spor",
"ButtonShare": "Del",
"ButtonShiftTimes": "Skift Tider",
"ButtonShiftTimes": "Forskyd tider",
"ButtonShow": "Vis",
"ButtonStartM4BEncode": "Start M4B Kode",
"ButtonStartMetadataEmbed": "Start Metadata Indlejring",
"ButtonStartM4BEncode": "Start konvertering til M4B",
"ButtonStartMetadataEmbed": "Start metadataindlejring",
"ButtonStats": "Statistik",
"ButtonSubmit": "Send",
"ButtonTest": "Test",
"ButtonUnlinkOpenId": "Afkobl OpenID",
"ButtonUpload": "Upload",
"ButtonUploadBackup": "Upload Backup",
"ButtonUploadCover": "Upload Omslag",
"ButtonUploadOPMLFile": "Upload OPML Fil",
"ButtonUpload": "Overfør",
"ButtonUploadBackup": "Overfør sikkerhedskopi",
"ButtonUploadCover": "Overfør omslag",
"ButtonUploadOPMLFile": "Overfør OPML-fil",
"ButtonUserDelete": "Slet bruger {0}",
"ButtonUserEdit": "Rediger bruger {0}",
"ButtonViewAll": "Vis Alle",
"ButtonViewAll": "Vis alle",
"ButtonYes": "Ja",
"ErrorUploadFetchMetadataAPI": "Fejl henter metadata",
"ErrorUploadFetchMetadataNoResults": "Kunne ikke hente metadata - prøv at uploade title og/eller forfatter",
"ErrorUploadLacksTitle": "Skal have en title",
"ErrorUploadFetchMetadataAPI": "Fejl ved hentning af metadata",
"ErrorUploadFetchMetadataNoResults": "Kunne ikke hente metadata - prøv at ændre titel og/eller forfatter",
"ErrorUploadLacksTitle": "Titel kræves",
"HeaderAccount": "Konto",
"HeaderAddCustomMetadataProvider": "Tilføj Brugerdefineret Metadataudbyder",
"HeaderAddCustomMetadataProvider": "Tilføj brugerdefineret metadataudbyder",
"HeaderAdvanced": "Avanceret",
"HeaderApiKeys": "API-nøgler",
"HeaderAppriseNotificationSettings": "Apprise Notifikationsindstillinger",
"HeaderAppriseNotificationSettings": "Apprise-notifikationsindstillinger",
"HeaderAudioTracks": "Lydspor",
"HeaderAudiobookTools": "Audiobog Filhåndteringsværktøjer",
"HeaderAuthentication": "Autentificering",
"HeaderAudiobookTools": "Håndtering af lydbogsfiler",
"HeaderAuthentication": "Loginindstillinger",
"HeaderBackups": "Sikkerhedskopier",
"HeaderBulkChapterModal": "Tilføj flere kapitler",
"HeaderChangePassword": "Skift Adgangskode",
"HeaderChangePassword": "Skift adgangskode",
"HeaderChapters": "Kapitler",
"HeaderChooseAFolder": "Vælg en Mappe",
"HeaderChooseAFolder": "Vælg en mappe",
"HeaderCollection": "Samling",
"HeaderCollectionItems": "Samlingselementer",
"HeaderCollectionItems": "Bøger i samlingen",
"HeaderCover": "Omslag",
"HeaderCurrentDownloads": "Nuværende Downloads",
"HeaderCustomMessageOnLogin": "Brugerdefineret Besked ved Login",
"HeaderCustomMetadataProviders": "Brugerdefineret Metadataudbyder",
"HeaderCurrentDownloads": "Aktuelle downloads",
"HeaderCustomMessageOnLogin": "Brugerdefineret besked ved logind",
"HeaderCustomMetadataProviders": "Brugerdefineret metadataudbyder",
"HeaderDetails": "Detaljer",
"HeaderDownloadQueue": "Download Kø",
"HeaderDownloadQueue": "Downloadkø",
"HeaderEbookFiles": "E-bogsfiler",
"HeaderEmail": "Email",
"HeaderEmailSettings": "Email Indstillinger",
"HeaderEpisodes": "Episoder",
"HeaderEreaderDevices": "E-læser Enheder",
"HeaderEreaderSettings": "E-læser Indstillinger",
"HeaderEmailSettings": "Emailindstillinger",
"HeaderEpisodes": "Afsnit",
"HeaderEreaderDevices": "E-bogslæsere",
"HeaderEreaderSettings": "Indstillinger for e-bogslæsere",
"HeaderFiles": "Filer",
"HeaderFindChapters": "Find Kapitler",
"HeaderIgnoredFiles": "Ignorerede Filer",
"HeaderItemFiles": "Emnefiler",
"HeaderItemMetadataUtils": "Emne Metadata Værktøjer",
"HeaderLastListeningSession": "Seneste Lyttesession",
"HeaderLatestEpisodes": "Seneste episoder",
"HeaderFindChapters": "Find kapitler",
"HeaderIgnoredFiles": "Ignorerede filer",
"HeaderItemFiles": "Filer",
"HeaderItemMetadataUtils": "Metadata-værktøjer",
"HeaderLastListeningSession": "Seneste lyttesession",
"HeaderLatestEpisodes": "Nyeste afsnit",
"HeaderLibraries": "Biblioteker",
"HeaderLibraryFiles": "Biblioteksfiler",
"HeaderLibraryStats": "Biblioteksstatistik",
"HeaderListeningSessions": "Lyttesessioner",
"HeaderListeningStats": "Lyttestatistik",
"HeaderLogin": "Log ind",
"HeaderLogs": "Logs",
"HeaderManageGenres": "Administrer Genrer",
"HeaderManageTags": "Administrer Tags",
"HeaderMapDetails": "Kort Detaljer",
"HeaderMatch": "Match",
"HeaderLogs": "Logfiler",
"HeaderManageGenres": "Administrer genrer",
"HeaderManageTags": "Administrer emneord",
"HeaderMapDetails": "Masseredigering",
"HeaderMatch": "Find match",
"HeaderMetadataOrderOfPrecedence": "Metadata-prioritet",
"HeaderMetadataToEmbed": "Metadata til indlejring",
"HeaderNewAccount": "Ny Konto",
"HeaderNewAccount": "Ny konto",
"HeaderNewApiKey": "Ny API-nøgle",
"HeaderNewLibrary": "Nyt Bibliotek",
"HeaderNotificationCreate": "Opret Notifikation",
"HeaderNewLibrary": "Nyt bibliotek",
"HeaderNotificationCreate": "Opret notifikation",
"HeaderNotificationUpdate": "Updater Notifikation",
"HeaderNotifications": "Meddelelser",
"HeaderOpenIDConnectAuthentication": "OpenID Connect-autentificering",
"HeaderOpenIDConnectAuthentication": "Log ind med OpenID Connect",
"HeaderOpenListeningSessions": "Åbne lyttesessioner",
"HeaderOpenRSSFeed": "Åbn RSS Feed",
"HeaderOtherFiles": "Andre Filer",
"HeaderPasswordAuthentication": "Adgangskodeautentificering",
"HeaderOpenRSSFeed": "Åbn RSS-feed",
"HeaderOtherFiles": "Andre filer",
"HeaderPasswordAuthentication": "Log ind med adgangskode",
"HeaderPermissions": "Tilladelser",
"HeaderPlayerQueue": "Afspilningskø",
"HeaderPlayerSettings": "Afspiller Indstillinger",
"HeaderPlaylist": "Afspilningsliste",
"HeaderPlaylistItems": "Afspilningsliste Elementer",
"HeaderPodcastsToAdd": "Podcasts til Tilføjelse",
"HeaderPlayerSettings": "Afspilningsindstillinger",
"HeaderPlaylist": "Playliste",
"HeaderPlaylistItems": "Elementer i playlisten",
"HeaderPodcastsToAdd": "Podcasts som skal tilføjes",
"HeaderPresets": "Forudindstillinger",
"HeaderPreviewCover": "Forhåndsvis Omslag",
"HeaderRSSFeedGeneral": "RSS Detaljer",
"HeaderRSSFeedIsOpen": "RSS Feed er Åben",
"HeaderRSSFeeds": "RSS-Feeds",
"HeaderRemoveEpisode": "Fjern Episode",
"HeaderRemoveEpisodes": "Fjern {0} Episoder",
"HeaderSavedMediaProgress": "Gemt Medieforløb",
"HeaderPreviewCover": "Forhåndsvis omslag",
"HeaderRSSFeedGeneral": "RSS-detaljer",
"HeaderRSSFeedIsOpen": "RSS-feedet er åbent",
"HeaderRSSFeeds": "RSS-feeds",
"HeaderRemoveEpisode": "Fjern afsnit",
"HeaderRemoveEpisodes": "Fjern {0} afsnit",
"HeaderSavedMediaProgress": "Gemt lyttefremgang",
"HeaderSchedule": "Planlæg",
"HeaderScheduleEpisodeDownloads": "Planlæg Automatisk Episode-Download",
"HeaderScheduleLibraryScans": "Planlæg Automatiske Biblioteksscanninger",
"HeaderScheduleEpisodeDownloads": "Planlæg automatisk hentning af afsnit",
"HeaderScheduleLibraryScans": "Planlæg automatisk biblioteksskanning",
"HeaderSession": "Session",
"HeaderSetBackupSchedule": "Indstil Sikkerhedskopieringsplan",
"HeaderSetBackupSchedule": "Indstil tidsplan for sikkerhedskopiering",
"HeaderSettings": "Indstillinger",
"HeaderSettingsDisplay": "Skærm",
"HeaderSettingsExperimental": "Eksperimentelle Funktioner",
"HeaderSettingsDisplay": "Visning",
"HeaderSettingsExperimental": "Eksperimentelle funktioner",
"HeaderSettingsGeneral": "Generelt",
"HeaderSettingsScanner": "Scanner",
"HeaderSettingsScanner": "Skanner",
"HeaderSettingsSecurity": "Sikkerhed",
"HeaderSettingsWebClient": "Webklient",
"HeaderSleepTimer": "Søvntimer",
"HeaderStatsLargestItems": "Største Elementer",
"HeaderStatsLongestItems": "Længste Elementer (timer)",
"HeaderStatsMinutesListeningChart": "Minutter Lyttet (sidste 7 dage)",
"HeaderStatsRecentSessions": "Seneste Sessions",
"HeaderStatsTop10Authors": "Top 10 Forfattere",
"HeaderStatsTop5Genres": "Top 5 Genrer",
"HeaderSleepTimer": "Sovefunktion",
"HeaderStatsLargestItems": "Største bøger",
"HeaderStatsLongestItems": "Længste bøger (timer)",
"HeaderStatsMinutesListeningChart": "Minutter lyttet (sidste 7 dage)",
"HeaderStatsRecentSessions": "Seneste sessioner",
"HeaderStatsTop10Authors": "Top 10 forfattere",
"HeaderStatsTop5Genres": "Top 5 genrer",
"HeaderTableOfContents": "Indholdsfortegnelse",
"HeaderTools": "Værktøjer",
"HeaderUpdateAccount": "Opdater Konto",
"HeaderUpdateAccount": "Opdater konto",
"HeaderUpdateApiKey": "Opdater API-nøgle",
"HeaderUpdateAuthor": "Opdater Forfatter",
"HeaderUpdateDetails": "Opdater Detaljer",
"HeaderUpdateLibrary": "Opdater Bibliotek",
"HeaderUpdateAuthor": "Opdater forfatter",
"HeaderUpdateDetails": "Opdater detaljer",
"HeaderUpdateLibrary": "Opdater bibliotek",
"HeaderUsers": "Brugere",
"HeaderYearReview": "Gennemgang af År {0}",
"HeaderYourStats": "Dine Statistikker",
"LabelAbridged": "Forkortet",
"LabelAbridgedChecked": "Forkortet (kontrolleret)",
"LabelAbridgedUnchecked": "Uforkortet (ikke kontrolleret)",
"LabelAccessibleBy": "Tilgængelig af",
"LabelAccountType": "Brugertype",
"HeaderYearReview": "Året {0} opsummeret",
"HeaderYourStats": "Din statistik",
"LabelAbridged": "Forkortet udgave",
"LabelAbridgedChecked": "Forkortet udgave (bekræftet)",
"LabelAbridgedUnchecked": "Uforkortet udgave (ikke bekræftet)",
"LabelAccessibleBy": "Tilgængelig for",
"LabelAccountType": "Kontotype",
"LabelAccountTypeAdmin": "Administrator",
"LabelAccountTypeGuest": "Gæst",
"LabelAccountTypeUser": "Bruger",
"LabelActivities": "Aktiviteter",
"LabelActivity": "Aktivitet",
"LabelAddToCollection": "Tilføj til Samling",
"LabelAddToCollectionBatch": "Tilføj {0} Bøger til Samling",
"LabelAddToPlaylist": "Tilføj til Afspilningsliste",
"LabelAddToPlaylistBatch": "Tilføj {0} Elementer til Afspilningsliste",
"LabelAddToCollection": "Tilføj til samling",
"LabelAddToCollectionBatch": "Tilføj {0} bøger til samling",
"LabelAddToPlaylist": "Tilføj til playliste",
"LabelAddToPlaylistBatch": "Tilføj {0} elementer til afspilningsliste",
"LabelAddedAt": "Tilføjet",
"LabelAddedDate": "Tilføjet {0}",
"LabelAdminUsersOnly": "Kun Administratorer",
"LabelAddedDate": "Tilføjede {0}",
"LabelAdminUsersOnly": "Kun administratorer",
"LabelAll": "Alle",
"LabelAllEpisodesDownloaded": "Alle episoder hentet",
"LabelAllUsers": "Alle Brugere",
"LabelAllUsersExcludingGuests": "Alle bruger eksklusiv gæster",
"LabelAllUsersIncludingGuests": "Alle bruger inklusiv gæster",
"LabelAllEpisodesDownloaded": "Alle afsnit hentet",
"LabelAllUsers": "Alle brugere",
"LabelAllUsersExcludingGuests": "Alle brugere, bortset fra gæster",
"LabelAllUsersIncludingGuests": "Alle brugere, også gæster",
"LabelAlreadyInYourLibrary": "Allerede i dit bibliotek",
"LabelApiKeyCreated": "API-nøgle\"{0}\" oprettet succesfuldt.",
"LabelApiKeyCreated": "API-nøglen \"{0}\" blev oprettet.",
"LabelApiKeyCreatedDescription": "Sørg for at kopiere API-nøglen nu, da du ikke vil kunne se den igen.",
"LabelApiKeyUser": "Ret på vegne af brugeren",
"LabelApiKeyUserDescription": "Denne API-nøgle vil have de samme tilladelser som den bruger, den handler på vegne af. Dette vil fremgå på samme måde i logfiler, som hvis brugeren foretog anmodningen.",
"LabelApiToken": "API Token",
"LabelAppend": "Tilføj",
"LabelApiKeyUser": "Handl på vegne af bruger",
"LabelApiKeyUserDescription": "Denne API-nøgle vil have de samme tilladelser som den bruger, den handler på vegne af. Dette vil fremgå på samme måde i logfiler, som hvis brugeren selv foretog anmodningen.",
"LabelApiToken": "API-nøgle",
"LabelAppend": "Vedhæft",
"LabelAudioBitrate": "Lydbitrate (f.eks. 128k)",
"LabelAudioChannels": "Lydkanaler (1 eller 2)",
"LabelAudioCodec": "Lydkodek",
"LabelAudioCodec": "Lyd-codec",
"LabelAuthor": "Forfatter",
"LabelAuthorFirstLast": "Forfatter (Fornavn Efternavn)",
"LabelAuthorLastFirst": "Forfatter (Efternavn, Fornavn)",
"LabelAuthors": "Forfattere",
"LabelAutoDownloadEpisodes": "Auto Download Episoder",
"LabelAutoFetchMetadata": "Automatisk Hent Metadata",
"LabelAutoFetchMetadataHelp": "Henter metadata for titler, forfatter og serier for at strømligne uploading. Ekstra metadata har måske brug for at blive matchet efter upload.",
"LabelAutoLaunch": "Åben Automatisk",
"LabelAutoDownloadEpisodes": "Hent automatisk afsnit",
"LabelAutoFetchMetadata": "Hent automatisk metadata",
"LabelAutoFetchMetadataHelp": "Henter metadata for titler, forfatter og serie for at strømline uploading. Ekstra metadata har måske brug for at blive matchet efter upload.",
"LabelAutoLaunch": "Automatisk start",
"LabelAutoLaunchDescription": "Viderestil automatisk til login-udbyderen ved navigation til login-siden (manuel overstyring via <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Registrer Automatisk",
"LabelAutoRegister": "Registrer automatisk",
"LabelAutoRegisterDescription": "Automatisk oprettelse af nye brugere efter login",
"LabelBackToUser": "Tilbage til Bruger",
"LabelBackupAudioFiles": "Sikkerhedskopier lydfiler",
"LabelBackupLocation": "Backup Placering",
"LabelBackToUser": "Tilbage til bruger",
"LabelBackupAudioFiles": "Sikkerhedskopiér lydfiler",
"LabelBackupLocation": "Placering af sikkerhedskopi",
"LabelBackupsEnableAutomaticBackups": "Automatisk sikkerhedskopiering",
"LabelBackupsEnableAutomaticBackupsHelp": "Sikkerhedskopier gemt i /metadata/backups",
"LabelBackupsMaxBackupSize": "Maksimal sikkerhedskopistørrelse (i GB) (0 for ubegrænset)",
"LabelBackupsMaxBackupSizeHelp": "Som en beskyttelse mod fejlkonfiguration fejler sikkerhedskopier, hvis de overstiger den konfigurerede størrelse.",
"LabelBackupsNumberToKeep": "Antal sikkerhedskopier at beholde",
"LabelBackupsNumberToKeepHelp": "Kun 1 sikkerhedskopi fjernes ad gangen, så hvis du allerede har flere sikkerhedskopier end dette, skal du fjerne dem manuelt.",
"LabelBackupsNumberToKeep": "Antal sikkerhedskopier som skal beholdes",
"LabelBackupsNumberToKeepHelp": "Kun én sikkerhedskopi fjernes ad gangen, så hvis du allerede har flere sikkerhedskopier end dette, skal du fjerne dem manuelt.",
"LabelBitrate": "Bitrate",
"LabelBonus": "Bonus",
"LabelBooks": "Bøger",
"LabelButtonText": "Knap tekst",
"LabelByAuthor": "Efter Forfatter",
"LabelChangePassword": "Ændre Adgangskode",
"LabelButtonText": "Tekst på knap",
"LabelByAuthor": "af {0}",
"LabelChangePassword": "Ændr adgangskode",
"LabelChannels": "Kanaler",
"LabelChapterCount": "{0} Kapitler",
"LabelChapterTitle": "Kapitel Titel",
"LabelChapterCount": "{0} kapitler",
"LabelChapterTitle": "Kapiteltitel",
"LabelChapters": "Kapitler",
"LabelChaptersFound": "fundne kapitler",
"LabelChaptersFound": "kapitler fundet",
"LabelClickForMoreInfo": "Klik for mere info",
"LabelClickToUseCurrentValue": "Klik for at bruge nuværende værdi",
"LabelClosePlayer": "Luk afspiller",
"LabelCodec": "Kodeks",
"LabelCollapseSeries": "Fold Serier Sammen",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Fold serier sammen",
"LabelCollapseSubSeries": "Fold underserie sammen",
"LabelCollection": "Samling",
"LabelCollections": "Samlinger",
"LabelComplete": "Fuldfør",
"LabelConfirmPassword": "Bekræft Adgangskode",
"LabelComplete": "Afsluttet",
"LabelConfirmPassword": "Bekræft adgangskode",
"LabelContinueListening": "Fortsæt med at lytte",
"LabelContinueReading": "Fortsæt med at læse",
"LabelContinueSeries": "Fortsæt Serien",
"LabelContinueSeries": "Fortsæt serien",
"LabelCorsAllowed": "Tilladte CORS-oprindelser",
"LabelCover": "Omslag",
"LabelCoverImageURL": "Omslagsbillede URL",
"LabelCoverProvider": "Cover billede udbyder",
"LabelCreatedAt": "Oprettet d.",
"LabelCronExpression": "Cron Udtryk",
"LabelCoverImageURL": "Omslagsbilledes-URL",
"LabelCoverProvider": "Kilde til omslagsbillede",
"LabelCreatedAt": "Oprettet",
"LabelCronExpression": "Cron-kommando",
"LabelCurrent": "Aktuel",
"LabelCurrently": "Aktuelt:",
"LabelCustomCronExpression": "Brugerdefineret Cron Udtryk:",
"LabelDatetime": "Dato og Tid",
"LabelCurrently": "Nuværende:",
"LabelCustomCronExpression": "Brugerdefineret Cron-kommando:",
"LabelDatetime": "Dato og tid",
"LabelDays": "Dage",
"LabelDeleteFromFileSystemCheckbox": "Slet fra filsystem (afmarker kun for at fjerne fra databasen)",
"LabelDeleteFromFileSystemCheckbox": "Slet fra filsystem (afmarkér for udelukkende at fjerne fra databasen)",
"LabelDescription": "Beskrivelse",
"LabelDeselectAll": "Fravælg Alle",
"LabelDeselectAll": "Fravælg alle",
"LabelDetectedPattern": "Identificeret mønster:",
"LabelDevice": "Enheds",
"LabelDevice": "Enhed",
"LabelDeviceInfo": "Enhedsinformation",
"LabelDeviceIsAvailableTo": "Enhed er tilgængelig for...",
"LabelDeviceIsAvailableTo": "Enheden er tilgængelig for...",
"LabelDirectory": "Mappe",
"LabelDiscFromFilename": "Disk fra Filnavn",
"LabelDiscFromMetadata": "Disk fra Metadata",
"LabelDiscFromFilename": "Disk fra filnavn",
"LabelDiscFromMetadata": "Disk fra metadata",
"LabelDiscover": "Opdag",
"LabelDownload": "Download",
"LabelDownloadNEpisodes": "Download {0} episoder",
"LabelDownloadable": "Downloadbar",
"LabelDownload": "Hent",
"LabelDownloadNEpisodes": "Hent {0} afsnit",
"LabelDownloadable": "Kan hentes",
"LabelDuration": "Varighed",
"LabelDurationComparisonExactMatch": "(præcis match)",
"LabelDurationComparisonExactMatch": "(præcist match)",
"LabelDurationComparisonLonger": "({0} længere)",
"LabelDurationComparisonShorter": "({0} kortere)",
"LabelDurationFound": "Fundet varighed:",
"LabelEbook": "E-bog",
"LabelEbooks": "E-bøger",
"LabelEdit": "Rediger",
"LabelEmail": "E-mail",
"LabelEmailSettingsFromAddress": "Fra Adresse",
"LabelEmailSettingsRejectUnauthorized": "Afvis uautoriserede certifikater",
"LabelEmailSettingsRejectUnauthorizedHelp": "Deaktivering af SSL certifikat validering kan udsætte din forbindelse for sikkerhedsrisici, eksempelvis man-in-the-middle angreb. Deaktiver kun denne indstilling hvis du forstår de potentielle implikationer og stoler på den mailserver du forbinder til.",
"LabelEmailSettingsSecure": "Sikker",
"LabelEmailSettingsSecureHelp": "Hvis sandt, vil forbindelsen bruge TLS ved tilslutning til serveren. Hvis falsk, bruges TLS, hvis serveren understøtter STARTTLS-udvidelsen. I de fleste tilfælde skal denne værdi sættes til sandt, hvis du tilslutter til port 465. Til port 587 eller 25 skal du holde det falsk. (fra nodemailer.com/smtp/#authentication)",
"LabelEmailSettingsTestAddress": "Test Adresse",
"LabelEmbeddedCover": "Indlejret Omslag",
"LabelEmail": "Email",
"LabelEmailSettingsFromAddress": "Afsenderadresse",
"LabelEmailSettingsRejectUnauthorized": "Afvis ukendte sikkerhedscertifikater",
"LabelEmailSettingsRejectUnauthorizedHelp": "Hvis du slår SSL-certifikatkontrollen fra, risikerer du at uvedkommende kan opsnappe dine data. Slå kun dette fra, hvis du er helt tryg ved situationen og har fuld tillid til den mailserver, du forbinder til.",
"LabelEmailSettingsSecure": "Sikker forbindelse",
"LabelEmailSettingsSecureHelp": "Når denne er slået til, bruger forbindelsen altid fuld kryptering (TLS). Hvis den er slået fra, bruges kryptering kun, hvis serveren tillader det (via STARTTLS). Lad dette være slået til, hvis du bruger port 465. Slå det fra, hvis du bruger port 587 eller 25",
"LabelEmailSettingsTestAddress": "Modtageradresse til test",
"LabelEmbeddedCover": "Indlejret omslag",
"LabelEnable": "Aktivér",
"LabelEncodingBackupLocation": "En sikkerhedskopi af dine originale lydfiler vil blive gemt under:",
"LabelEncodingChaptersNotEmbedded": "Kapitler er ikke indlejret i multi spors lydbøger.",
"LabelEncodingClearItemCache": "Sørg for periodisk at rense indholdscachen.",
"LabelEncodingFinishedM4B": "Færdiggjort M4B som vil blive placeret i din lydbogsmappe ved:",
"LabelEncodingInfoEmbedded": "Metadata vil blive indlejret i lydfiler i lydbogsmappen.",
"LabelEncodingStartedNavigation": "Når opgaven er startet kan du navigere væk fra denne side.",
"LabelEncodingTimeWarning": "Indkodning kan tage op til 30 minutter.",
"LabelEncodingWarningAdvancedSettings": "Advarsel: Opdater ikke disse indstillinger med mindre du kender til ffmpeg indkodningsindstillinger.",
"LabelEncodingWatcherDisabled": "Hvis du har watcheren deaktiveret skal du gen-scanne denne lydbog bagefter.",
"LabelEncodingBackupLocation": "En sikkerhedskopi af dine originale lydfiler vil blive gemt i mappen:",
"LabelEncodingChaptersNotEmbedded": "Kapitler bliver ikke indlejret i lydbøger som består af flere lydfiler.",
"LabelEncodingClearItemCache": "Sørg for jævnligt at rense mellemlageret.",
"LabelEncodingFinishedM4B": "Den færdige M4B-fil vil blive placeret i lydbogsmappen:",
"LabelEncodingInfoEmbedded": "Metadata vil blive indlejret i lydfilerne i lydbogsmappen.",
"LabelEncodingStartedNavigation": "Når opgaven er sat i gang kan du navigere væk fra denne side.",
"LabelEncodingTimeWarning": "Omkodningen kan tage op til 30 minutter.",
"LabelEncodingWarningAdvancedSettings": "Advarsel: Ændr ikke disse indstillinger med mindre du kender til ffmpeg-indkodningsindstillinger.",
"LabelEncodingWatcherDisabled": "Hvis du har slået automatisk biblioteksovervågning fra skal du genskanne denne lydbog bagefter.",
"LabelEnd": "Slut",
"LabelEndOfChapter": "Slutningen af kapitel",
"LabelEpisode": "Afsnit",
"LabelEpisodeNotLinkedToRssFeed": "Afsnit er ikke koblet til RSS feed",
"LabelEpisodeNotLinkedToRssFeed": "Afsnit er ikke koblet til RSS-feed",
"LabelEpisodeNumber": "Afsnit #{0}",
"LabelEpisodeTitle": "Episodetitel",
"LabelEpisodeType": "Episodetype",
"LabelEpisodeUrlFromRssFeed": "Afsnit URL fra RSS feed",
"LabelEpisodeTitle": "Afsnitstitel",
"LabelEpisodeType": "Afsnitstype",
"LabelEpisodeUrlFromRssFeed": "Afsnits-URL fra RSS-feed",
"LabelEpisodes": "Afsnit",
"LabelEpisodic": "Afsnit",
"LabelEpisodic": "Episodisk",
"LabelExample": "Eksempel",
"LabelExpandSeries": "Udfold serie",
"LabelExpandSubSeries": "Udfold underserie",
"LabelExpandSeries": "Fold serie ud",
"LabelExpandSubSeries": "Fold underserie ud",
"LabelExpired": "Udløbet",
"LabelExpiresAt": "Udløbsdato",
"LabelExpiresInSeconds": "Udløber om (seconds)",
"LabelExpiresNever": "Aldrig",
"LabelExplicit": "Eksplisit",
"LabelExplicitChecked": "Eksplicit (markeret)",
"LabelExplicitUnchecked": "Ikke eksplicit (ikke markeret)",
"LabelExplicit": "Voksenindhold",
"LabelExplicitChecked": "Voksenindhold (markeret)",
"LabelExplicitUnchecked": "Ikke voksenindhold (ikke markeret)",
"LabelExportOPML": "Eksport OPML",
"LabelFeedURL": "Feed URL",
"LabelFeedURL": "Feed-URL",
"LabelFetchingMetadata": "Henter metadata",
"LabelFile": "Fil",
"LabelFileBirthtime": "Oprettelsestidspunkt for fil",
@@ -391,7 +391,7 @@
"LabelFull": "Fuld",
"LabelGenre": "Genre",
"LabelGenres": "Genrer",
"LabelHardDeleteFile": "Permanent slet fil",
"LabelHardDeleteFile": "Slet fil permanent",
"LabelHasEbook": "Har e-bog",
"LabelHasSupplementaryEbook": "Har supplerende e-bog",
"LabelHideSubtitles": "Skjul undertitler",
@@ -400,7 +400,7 @@
"LabelHour": "Time",
"LabelHours": "Timer",
"LabelIcon": "Ikon",
"LabelImageURLFromTheWeb": "Billede URL fra nettet",
"LabelImageURLFromTheWeb": "Billede-URL fra nettet",
"LabelInProgress": "I gang",
"LabelIncludeInTracklist": "Inkluder i afspilningsliste",
"LabelIncomplete": "Ufuldstændig",
@@ -414,22 +414,22 @@
"LabelIntervalEveryDay": "Hver dag",
"LabelIntervalEveryHour": "Hver time",
"LabelIntervalEveryMinute": "Hvert minut",
"LabelInvert": "Inverter",
"LabelInvert": "Omvendt begrænsning",
"LabelItem": "Element",
"LabelJumpBackwardAmount": "Spring bagud mængde",
"LabelJumpForwardAmount": "Spring fremad mængde",
"LabelJumpBackwardAmount": "Tilbagespolingstid",
"LabelJumpForwardAmount": "Fremadspolingstid",
"LabelLanguage": "Sprog",
"LabelLanguageDefaultServer": "Standard server sprog",
"LabelLanguageDefaultServer": "Serverens standardsprog",
"LabelLanguages": "Sprog",
"LabelLastBookAdded": "Senest tilføjede bog",
"LabelLastBookUpdated": "Senest opdaterede bog",
"LabelLastProgressDate": "Sidste fremgang: {0}",
"LabelLastProgressDate": "Seneste fremgang: {0}",
"LabelLastSeen": "Sidst set",
"LabelLastTime": "Sidste gang",
"LabelLastUpdate": "Seneste opdatering",
"LabelLayout": "Layout",
"LabelLastTime": "Sidst lyttet",
"LabelLastUpdate": "Sidst ændret",
"LabelLayout": "Udseende",
"LabelLayoutSinglePage": "Enkeltside",
"LabelLayoutSplitPage": "Opdelt side",
"LabelLayoutSplitPage": "Opdelte sider",
"LabelLess": "Mindre",
"LabelLibrariesAccessibleToUser": "Biblioteker tilgængelige for bruger",
"LabelLibrary": "Bibliotek",
@@ -445,14 +445,14 @@
"LabelLogLevelDebug": "Fejlsøgning",
"LabelLogLevelInfo": "Information",
"LabelLogLevelWarn": "Advarsel",
"LabelLookForNewEpisodesAfterDate": "Søg efter nye episoder efter denne dato",
"LabelLookForNewEpisodesAfterDate": "Søg efter nye afsnit efter denne dato",
"LabelLowestPriority": "Laveste prioritet",
"LabelMatchConfidence": "Confidens",
"LabelMatchExistingUsersBy": "Match eksisterende brugere ved",
"LabelMatchExistingUsersByDescription": "Anvendt for at forbinde brugere. Når forbundet, brugere vil blive matchet ved unikt id fra din SSO udbyder",
"LabelMaxEpisodesToDownload": "Max # afsnit for at downloade. Anvend 0 for ubegrænset.",
"LabelMaxEpisodesToDownloadPerCheck": "Max # afsnit til at downloade per check",
"LabelMaxEpisodesToKeep": "Max # afsnit at beholde",
"LabelMatchConfidence": "Nøjagtighed",
"LabelMatchExistingUsersBy": "Forbind eksisterende brugere med",
"LabelMatchExistingUsersByDescription": "Anvendes til at tilslutte eksisterende brugere. Ved tilslutningen kobles brugeren til et unikt ID fra din SSO-udbyder",
"LabelMaxEpisodesToDownload": "Maksimalt antal afsnit som skal hentes. Anvend 0 for ubegrænset.",
"LabelMaxEpisodesToDownloadPerCheck": "Maksimalt antal afsnit som skal hentes per tjek",
"LabelMaxEpisodesToKeep": "Maksimalt antal afsnit som skal beholdes",
"LabelMaxEpisodesToKeepHelp": "Værdi af 0 sætter intet maks begrænsning. After et nyt afsnit er automatisk downloaded vil det ældste afsnit blive slettet hvis du har mere end X afsnit. Dette vil kun slette 1 afsnit for hvert nye download.",
"LabelMediaPlayer": "Medieafspiller",
"LabelMediaType": "Medietype",
@@ -463,20 +463,20 @@
"LabelMinute": "Minut",
"LabelMinutes": "Minutter",
"LabelMissing": "Mangler",
"LabelMissingEbook": "Har ingen ebog",
"LabelMissingSupplementaryEbook": "Har ingen tillægsbog",
"LabelMissingEbook": "Har ingen e-bog",
"LabelMissingSupplementaryEbook": "Har ingen supplerende e-bog",
"LabelMobileRedirectURIs": "Godkendte mobil redirect URI'er",
"LabelMobileRedirectURIsDescription": "Dete vil whiteliste en gyldig omdirigerings URL for mobile apps. Den standarde er <code>audiobookshelf://oauth</code> som du kan fjerne eller supplere med flere URI'er for tredjeparts app integration. Anvend en stjerne (<code>*</code>) som den eneste indstilling for at tilade en hvilkensomhelst URI.",
"LabelMore": "Mere",
"LabelMoreInfo": "Mere info",
"LabelName": "Navn",
"LabelNarrator": "Fortæller",
"LabelNarrators": "Fortællere",
"LabelNarrator": "Oplæser",
"LabelNarrators": "Oplæsere",
"LabelNew": "Ny",
"LabelNewPassword": "Ny adgangskode",
"LabelNewestAuthors": "Nyeste forfattere",
"LabelNewestEpisodes": "Nyeste episoder",
"LabelNextBackupDate": "Næste sikkerhedskopi dato",
"LabelNewestEpisodes": "Nyeste afsnit",
"LabelNextBackupDate": "Dato for næste sikkerhedskopi",
"LabelNextChapters": "Næste kapitler vil være:",
"LabelNextScheduledRun": "Næste planlagte kørsel",
"LabelNoApiKeys": "Ingen API-nøgler",
@@ -526,28 +526,28 @@
"LabelPodcasts": "Podcast",
"LabelPort": "Port",
"LabelPrefixesToIgnore": "Præfikser der skal ignoreres (skal ikke skelne mellem store og små bogstaver)",
"LabelPreventIndexing": "Forhindrer, at dit feed bliver indekseret af iTunes og Google podcastkataloger",
"LabelPreventIndexing": "Forhindrer at dit feed bliver indekseret af iTunes- og Google-podcastkataloger",
"LabelPrimaryEbook": "Primær e-bog",
"LabelProgress": "Fremskridt",
"LabelProvider": "Udbyder",
"LabelProviderAuthorizationValue": "Authorization Header værdi",
"LabelPubDate": "Udgivelsesdato",
"LabelPublishYear": "Udgivelsesår",
"LabelPublishedDate": "Publiceret {0}",
"LabelPublishedDecade": "Publiceret årti",
"LabelPublishedDecades": "Publiceret årtier",
"LabelPublishedDate": "Udgivet {0}",
"LabelPublishedDecade": "Udgivelsesårti",
"LabelPublishedDecades": "Udgivelsesårtier",
"LabelPublisher": "Forlag",
"LabelPublishers": "Forlag",
"LabelRSSFeedCustomOwnerEmail": "Brugerdefineret ejerens e-mail",
"LabelRSSFeedCustomOwnerName": "Brugerdefineret ejerens navn",
"LabelRSSFeedOpen": "RSS-feed åbent",
"LabelRSSFeedPreventIndexing": "Forhindrer indeksering",
"LabelRSSFeedPreventIndexing": "Forhindr indeksering",
"LabelRSSFeedSlug": "RSS-feed-slug",
"LabelRSSFeedURL": "RSS-feed-URL",
"LabelRandomly": "Tilfældigt",
"LabelReAddSeriesToContinueListening": "Gentilføj serier til Fortsæt Lytning",
"LabelReAddSeriesToContinueListening": "Gentilføj serier til »Fortsæt med at lytte«",
"LabelRead": "Læst",
"LabelReadAgain": "Læs Igen",
"LabelReadAgain": "Læs igen",
"LabelReadEbookWithoutProgress": "Læs e-bog uden at følge fremskridt",
"LabelRecentSeries": "Seneste serier",
"LabelRecentlyAdded": "Senest tilføjet",
@@ -555,9 +555,9 @@
"LabelRedo": "Gøre igen",
"LabelRegion": "Region",
"LabelReleaseDate": "Udgivelsesdato",
"LabelRemoveAllMetadataAbs": "Fjern alle metadata.abs filer",
"LabelRemoveAllMetadataJson": "Fjern alle metadata.json filer",
"LabelRemoveAudibleBranding": "Fjern Audible intro og outro fra kapitler",
"LabelRemoveAllMetadataAbs": "Fjern alle \"metadata.abs\"-filer",
"LabelRemoveAllMetadataJson": "Fjern alle \"metadata.json\"-filer",
"LabelRemoveAudibleBranding": "Fjern Audible-intro og -outro fra kapitler",
"LabelRemoveCover": "Fjern omslag",
"LabelRemoveMetadataFile": "Fjern alle metadata filer i biblioteksmapper",
"LabelRemoveMetadataFileHelp": "Fjern alle metadata.json og metadata.abs filer i dine {0} mapper.",
@@ -571,7 +571,7 @@
"LabelSelectAllEpisodes": "Vælg alle episoder",
"LabelSelectEpisodesShowing": "Vælg {0} episoder vist",
"LabelSelectUser": "Vælg bruger",
"LabelSelectUsers": "Valgte brugere",
"LabelSelectUsers": "Vælg brugere",
"LabelSendEbookToDevice": "Send e-bog til...",
"LabelSequence": "Sekvens",
"LabelSerial": "Seriel",
@@ -582,21 +582,21 @@
"LabelServerYearReview": "Server år i review ({0})",
"LabelSetEbookAsPrimary": "Indstil som primær",
"LabelSetEbookAsSupplementary": "Indstil som supplerende",
"LabelSettingsAllowIframe": "Tillad embedding i en iframe",
"LabelSettingsAllowIframe": "Tillad indlejring i en iframe",
"LabelSettingsAudiobooksOnly": "Kun lydbøger",
"LabelSettingsAudiobooksOnlyHelp": "Aktivering af denne indstilling vil ignorere e-bogsfiler, medmindre de er inde i en lydbogmappe, hvor de vil blive indstillet som supplerende e-bøger",
"LabelSettingsBookshelfViewHelp": "Skeumorfisk design med træhylder",
"LabelSettingsChromecastSupport": "Chromecast-understøttelse",
"LabelSettingsDateFormat": "Datoformat",
"LabelSettingsEnableWatcher": "Automatisk biblioteksovervåger",
"LabelSettingsEnableWatcher": "Automatisk biblioteksovervågning",
"LabelSettingsEnableWatcherForLibrary": "Automatisk biblioteksovervåger",
"LabelSettingsEnableWatcherHelp": "Aktiverer automatisk tilføjelse/opdatering af elementer, når filændringer registreres. *Kræver servergenstart",
"LabelSettingsEnableWatcherHelp": "Aktiverer automatisk opdatering af kataloget, når filændringer registreres. *Kræver genstart af serveren",
"LabelSettingsEpubsAllowScriptedContent": "Tillad scriptet indhold i epub",
"LabelSettingsEpubsAllowScriptedContentHelp": "Tillad epub filer at køre scripts. Det anbefales at holde denne indstilling deaktiveret med mindre du stoler på kilderne af epub filerne.",
"LabelSettingsExperimentalFeatures": "Eksperimentelle funktioner",
"LabelSettingsExperimentalFeaturesHelp": "Funktioner under udvikling, der kunne bruge din feedback og hjælp til test. Klik for at åbne Github-diskussionen.",
"LabelSettingsFindCovers": "Find omslag",
"LabelSettingsFindCoversHelp": "Hvis din lydbog ikke har et indlejret omslag eller et omslagsbillede i mappen, vil skanneren forsøge at finde et omslag.<br>Bemærk: Dette vil forlænge scanntiden",
"LabelSettingsFindCoversHelp": "Hvis din lydbog ikke har et indlejret omslag eller et omslagsbillede i mappen, vil skanneren forsøge at finde et omslag.<br>Bemærk: Dette vil forlænge skanningens varighed",
"LabelSettingsHideSingleBookSeries": "Skjul enkeltbogsserier",
"LabelSettingsHideSingleBookSeriesHelp": "Serier med en enkelt bog vil blive skjult fra serie-siden og hjemmesidehylder.",
"LabelSettingsHomePageBookshelfView": "Brug bogreolvisning på startside",
@@ -607,7 +607,7 @@
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Spring tidligere bøger i Fortsæt serie over",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Fortsæt Serien siden viser den første bog som ikke er startet i serier med mindst en bog som ikke er startet og hvor ingen bøger i gang. Aktivering af denne indstilling vil fortsætte serien fra den sidst gennemførte bog i stedet for fra den første ikke startede bog.",
"LabelSettingsParseSubtitles": "Fortolk undertitler",
"LabelSettingsParseSubtitlesHelp": "Udtræk undertekster fra lydbogsmappenavne.<br>Undertitler skal adskilles af \" - \"<br>f.eks. \"Bogtitel - En undertitel her\" har undertitlen \"En undertitel her\"",
"LabelSettingsParseSubtitlesHelp": "Udtræk undertitler fra lydbogsmappenavne.<br>Undertitler skal adskilles af \" - \"<br>f.eks. \"Bogtitel - En undertitel her\" har undertitlen \"En undertitel her\"",
"LabelSettingsPreferMatchedMetadata": "Foretræk matchede metadata",
"LabelSettingsPreferMatchedMetadataHelp": "Matchede data vil tilsidesætte elementdetaljer ved brug af Hurtig Match. Som standard udfylder Hurtig Match kun manglende detaljer.",
"LabelSettingsSkipMatchingBooksWithASIN": "Ignorer matchende bøger, der allerede har en ASIN",
@@ -647,14 +647,14 @@
"LabelStatsDaysListened": "Dage hørt",
"LabelStatsHours": "Timer",
"LabelStatsInARow": "i træk",
"LabelStatsItemsFinished": "Elementer færdige",
"LabelStatsItemsInLibrary": "Elementer i biblioteket",
"LabelStatsItemsFinished": "færdiglæste bøger",
"LabelStatsItemsInLibrary": "bøger i biblioteket",
"LabelStatsMinutes": "minutter",
"LabelStatsMinutesListening": "Minutter hørt",
"LabelStatsOverallDays": "Samlede dage",
"LabelStatsOverallHours": "Samlede timer",
"LabelStatsWeekListening": "Ugens lytning",
"LabelSubtitle": "Undertekst",
"LabelSubtitle": "Undertitel",
"LabelSupportedFileTypes": "Understøttede filtyper",
"LabelTag": "Mærke",
"LabelTags": "Mærker",
@@ -663,7 +663,7 @@
"LabelTasks": "Kører opgaver",
"LabelTextEditorBulletedList": "Punktopstilling",
"LabelTextEditorLink": "Link",
"LabelTextEditorNumberedList": "Nummeropstilling",
"LabelTextEditorNumberedList": "Nummereret liste",
"LabelTextEditorUnlink": "Aflink",
"LabelTheme": "Tema",
"LabelThemeDark": "Mørk",
@@ -735,17 +735,17 @@
"LabelYourPlaylists": "Dine spillelister",
"LabelYourProgress": "Din fremgang",
"MessageAddToPlayerQueue": "Tilføj til afspilningskø",
"MessageAppriseDescription": "For at bruge denne funktion skal du have en instans af <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> kørende eller en API, der håndterer de samme anmodninger. <br /> Apprise API-webadressen skal være den fulde URL-sti for at sende underretningen, f.eks. hvis din API-instans er tilgængelig på <code>http://192.168.1.1:8337</code>, så skal du bruge <code>http://192.168.1.1:8337/notify</code>.",
"MessageAppriseDescription": "For at bruge denne funktion skal du have en instans af <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> kørende eller en API, der kan håndtere de samme anmodninger. <br />Apprise API-webadressen skal være den fulde URL-sti for at sende underretningen, f.eks. hvis din API-instans er tilgængelig på <code>http://192.168.1.1:8337</code>, så skal du bruge <code>http://192.168.1.1:8337/notify</code>.",
"MessageAsinCheck": "Sikr dig at du bruger ASIN fra den korrekte Audible region, ikke Amazon.",
"MessageAuthenticationLegacyTokenWarning": "Ældre API tokens vil blive fjernet i fremtiden. Brug <a href=\"/config/api-keys\">API-nøgler</a> i stedet.",
"MessageAuthenticationOIDCChangesRestart": "Genstart sin server efter du har gemt for at bekræfte OIDC ændringer.",
"MessageAuthenticationSecurityMessage": "Autentificeringen er blevet forbedret af sikkerhedsmæssige årsager. Alle brugere skal logge ind igen.",
"MessageAuthenticationSecurityMessage": "Brugerlogin er blevet forbedret af sikkerhedsmæssige årsager. Alle brugere skal logge ind igen.",
"MessageBackupsDescription": "Backups inkluderer brugere, brugerfremskridt, biblioteksvareoplysninger, serverindstillinger og billeder gemt i <code>/metadata/items</code> og <code>/metadata/authors</code>. Backups inkluderer <strong>ikke</strong> nogen filer gemt i dine biblioteksmapper.",
"MessageBackupsLocationEditNote": "Note: Opdatering af backup sti vil ikke fjerne eller modificere eksisterende backups",
"MessageBackupsLocationNoEditNote": "Note: Backup sti er sat igennem miljøvariabel og kan ikke ændres her.",
"MessageBackupsLocationPathEmpty": "Backup sti kan ikke være tom",
"MessageBatchEditPopulateMapDetailsAllHelp": "Opret felter slået til med data fra alle genstande. Felter med flere værdier vil blive sammenflettet",
"MessageBatchEditPopulateMapDetailsItemHelp": "Opret kort med værdier der er slået til fra felter med data fra denne genstand",
"MessageBatchEditPopulateMapDetailsAllHelp": "Udfyld valgte felter med data fra alle bøger. Felter med flere værdier vil blive sammenflettet",
"MessageBatchEditPopulateMapDetailsItemHelp": "Udfyld de felter, som er tilvalgt i masseredigering med data fra denne bog",
"MessageBatchQuickMatchDescription": "Quick Match vil forsøge at tilføje manglende omslag og metadata til de valgte elementer. Aktivér indstillingerne nedenfor for at tillade Quick Match at overskrive eksisterende omslag og/eller metadata.",
"MessageBookshelfNoCollections": "Du har ikke oprettet nogen samlinger endnu",
"MessageBookshelfNoCollectionsHelp": "Samlinger er offentlige. Alle brugere med adgang til biblioteket kan se dem.",
@@ -888,7 +888,7 @@
"MessageResetChaptersConfirm": "Er du sikker på, at du vil nulstille kapitler og annullere ændringerne, du har foretaget?",
"MessageRestoreBackupConfirm": "Er du sikker på, at du vil gendanne sikkerhedskopien oprettet den",
"MessageRestoreBackupWarning": "Gendannelse af en sikkerhedskopi vil overskrive hele databasen, som er placeret på /config, og omslagsbilleder i /metadata/items & /metadata/authors.<br /><br />Sikkerhedskopier ændrer ikke nogen filer i dine biblioteksmapper. Hvis du har aktiveret serverindstillinger for at gemme omslagskunst og metadata i dine biblioteksmapper, sikkerhedskopieres eller overskrives disse ikke.<br /><br />Alle klienter, der bruger din server, opdateres automatisk.",
"MessageScheduleLibraryScanNote": "For de fleste brugere er det anbefalet, at efterlade denne funktion deaktiveret, og lade biblioteksovervågeren være aktiveret - den vil automatisk opdage ændringer i dine biblioteksmapper. Aktiver denne funktion, hvis biblioteksovervågeren ikke virker med dit filsystem (f. eks. NFS).",
"MessageScheduleLibraryScanNote": "For de fleste brugere er det anbefalet, at lade denne funktion være deaktiveret, og lade biblioteksovervågning være aktiveret - den vil automatisk opdage ændringer i dine biblioteksmapper. Aktiver denne funktion, hvis biblioteksovervågning ikke virker med dit filsystem (f. eks. NFS).",
"MessageScheduleRunEveryWeekdayAtTime": "Kør hvert {0} af {1}",
"MessageSearchResultsFor": "Søgeresultater for",
"MessageSelected": "{0} valgt",
@@ -915,7 +915,7 @@
"MessageTaskFailedToWriteMetadataFile": "Fejlede i at skrive metadata fil",
"MessageTaskMatchingBooksInLibrary": "Matchede bøger i bibliotek \"{0}\"",
"MessageTaskNoFilesToScan": "Ingen filer at scanne",
"MessageTaskOpmlImport": "OPML import",
"MessageTaskOpmlImport": "OPML-import",
"MessageTaskOpmlImportDescription": "Oprettelse af podcasts fra {0} RSS feeds",
"MessageTaskOpmlImportFeed": "OPML importering fejlede",
"MessageTaskOpmlImportFeedDescription": "Importering af RSS feed \"{0}\"",
@@ -939,13 +939,13 @@
"MessageUploaderItemSuccess": "Uploadet med succes!",
"MessageUploading": "Uploader...",
"MessageValidCronExpression": "Gyldigt cron-udtryk",
"MessageWatcherIsDisabledGlobally": "Watcher er deaktiveret globalt i serverindstillinger",
"MessageWatcherIsDisabledGlobally": "Automatisk biblioteksovervågning er deaktiveret globalt i serverens indstillinger",
"MessageXLibraryIsEmpty": "{0} bibliotek er tomt!",
"MessageYourAudiobookDurationIsLonger": "Din lydbogsvarighed er længere end den fundne varighed",
"MessageYourAudiobookDurationIsShorter": "Din lydbogsvarighed er kortere end den fundne varighed",
"NoteChangeRootPassword": "Root-brugeren er den eneste bruger, der kan have en tom adgangskode",
"NoteChapterEditorTimes": "Bemærk: Første kapitel starttidspunkt skal forblive kl. 0:00, og det sidste kapitel starttidspunkt må ikke overstige denne lydbogs varighed.",
"NoteFolderPicker": "Bemærk: Mapper, der allerede er mappet, vises ikke",
"NoteFolderPicker": "Bemærk: Allerede kortlagte mapper vises ikke",
"NoteRSSFeedPodcastAppsHttps": "Advarsel: De fleste podcast-apps kræver, at RSS-feedets URL bruger HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Advarsel: En eller flere af dine episoder har ikke en Pub Date. Nogle podcast-apps kræver dette.",
"NoteUploaderFoldersWithMediaFiles": "Mapper med mediefiler håndteres som separate bibliotekselementer.",
@@ -1018,8 +1018,8 @@
"ToastChapterStartTimeAdjusted": "Kapitelstarttid justeret med {0} sekunder",
"ToastChaptersAllLocked": "Alle kapitler er låst. Lås op for nogle kapitler for at ændre deres tider.",
"ToastChaptersHaveErrors": "Kapitler har fejl",
"ToastChaptersInvalidShiftAmountLast": "Ugyldig ændring. Det sidste kapitels starttid ville fortsætte længere end varigheden på denne lydbog.",
"ToastChaptersInvalidShiftAmountStart": "Ugyldig ændring. Første kapitel ville have en længde på nul eller negativt og ville blive overskrevet af andet kapitel. Udvid startvarigheden på andet kapitel.",
"ToastChaptersInvalidShiftAmountLast": "Forskydningen er ikke mulig. Det sidste kapitels starttidspunkt ville strække sig ud over denne lydbogs varighed.",
"ToastChaptersInvalidShiftAmountStart": "Forskydningen er ikke mulig. Det første kapitel ville have en længde på nul eller negativ længde og ville blive overskrevet af det andet kapitel. Øg startvarigheden af det andet kapitel.",
"ToastChaptersMustHaveTitles": "Kapitler skal have titler",
"ToastChaptersRemoved": "Kapitler fjernet",
"ToastChaptersUpdated": "Kapitler opdateret",
@@ -1044,7 +1044,7 @@
"ToastEpisodeUpdateSuccess": "{0} afsnit opdateret",
"ToastErrorCannotShare": "Kan ikke dele på denne enhed",
"ToastFailedToCreate": "Oprettelsen mislykkedes",
"ToastFailedToDelete": "Sletning fejlede",
"ToastFailedToDelete": "Kunne ikke slette",
"ToastFailedToLoadData": "Indlæsning af data fejlede",
"ToastFailedToMatch": "Fejlet match",
"ToastFailedToShare": "Deling fejlede",
@@ -1109,7 +1109,7 @@
"ToastProgressIsNotBeingSynced": "Fremskridt ikke synkroniseret, genstart afspilning",
"ToastProviderCreatedFailed": "Fejlede at tilføje udbyder",
"ToastProviderCreatedSuccess": "Ny udbyder tilføjet",
"ToastProviderNameAndUrlRequired": "Navn og URL påkrævet",
"ToastProviderNameAndUrlRequired": "Navn og URL kræves",
"ToastProviderRemoveSuccess": "Udbyder fjernet",
"ToastRSSFeedCloseFailed": "Mislykkedes lukning af RSS-feed",
"ToastRSSFeedCloseSuccess": "RSS-feed lukket",
+6 -2
View File
@@ -46,6 +46,7 @@
"ButtonLatest": "Neueste",
"ButtonLibrary": "Bibliothek",
"ButtonLogout": "Abmelden",
"ButtonLogoutAllDevices": "Von allen Geräte abmelden",
"ButtonLookup": "Online-Suche",
"ButtonManageTracks": "Tracks verwalten",
"ButtonMapChapterTitles": "Kapitelüberschriften zuordnen",
@@ -139,12 +140,12 @@
"HeaderCustomMetadataProviders": "Benutzerdefinierte Metadatenanbieter",
"HeaderDetails": "Details",
"HeaderDownloadQueue": "Download-Warteschlange",
"HeaderEbookFiles": "E-Book-Dateien",
"HeaderEbookFiles": "Buch-Dateien",
"HeaderEmail": "E-Mail",
"HeaderEmailSettings": "E-Mail-Einstellungen",
"HeaderEpisodes": "Episoden",
"HeaderEreaderDevices": "E-Reader Geräte",
"HeaderEreaderSettings": "E-Reader-Einstellungen",
"HeaderEreaderSettings": "E-Reader Einstellungen",
"HeaderFiles": "Dateien",
"HeaderFindChapters": "Kapitel suchen",
"HeaderIgnoredFiles": "Ignorierte Dateien",
@@ -194,6 +195,7 @@
"HeaderScheduleEpisodeDownloads": "Automatische Episoden-Downloads planen",
"HeaderScheduleLibraryScans": "Automatische Bibliotheksscans",
"HeaderSession": "Sitzung",
"HeaderSessions": "Sitzungen",
"HeaderSetBackupSchedule": "Zeitplan für die Datensicherung festlegen",
"HeaderSettings": "Einstellungen",
"HeaderSettingsDisplay": "Anzeige",
@@ -415,6 +417,7 @@
"LabelIntervalEveryHour": "Jede Stunde",
"LabelIntervalEveryMinute": "Jede Minute",
"LabelInvert": "Umkehren",
"LabelIpAddress": "IP-Adresse",
"LabelItem": "Medium",
"LabelJumpBackwardAmount": "Zurückspringen Zeit",
"LabelJumpForwardAmount": "Vorwärtsspringen Zeit",
@@ -774,6 +777,7 @@
"MessageConfirmDeleteSession": "Sitzung wird gelöscht! Bist du dir sicher?",
"MessageConfirmEmbedMetadataInAudioFiles": "Bist du dir sicher, dass die Metadaten in {0} Audiodateien eingebettet werden sollen?",
"MessageConfirmForceReScan": "Scanvorgang erzwingen! Bist du dir sicher?",
"MessageConfirmLogoutDevice": "Bist du sicher, dass du dich von \"{0}\" abmelden willst?",
"MessageConfirmMarkAllEpisodesFinished": "Alle Episoden werden als abgeschlossen markiert! Bist du dir sicher?",
"MessageConfirmMarkAllEpisodesNotFinished": "Alle Episoden werden als nicht abgeschlossen markiert! Bist du dir sicher?",
"MessageConfirmMarkItemFinished": "Möchtest du \"{0}\" wirklich als fertig markieren?",
+35 -11
View File
@@ -135,9 +135,9 @@
"HeaderCurrentDownloads": "Τρέχουσες Λήψεις",
"HeaderDetails": "Λεπτομέρειες",
"HeaderDownloadQueue": "Ουρά Λήψης",
"HeaderEbookFiles": "Αρχεία Ebook",
"HeaderEmail": "Ηλεκτρονικό Ταχυδρομίο",
"HeaderEmailSettings": "Ρυθμίσεις Ηλεκτρονικού Ταχυδρομίου",
"HeaderEbookFiles": "Αρχεία του βιβλίου",
"HeaderEmail": "Ηλεκτρονικό ταχυδρομείο",
"HeaderEmailSettings": "Ρυθμίσεις ηλεκτρονικού ταχυδρομείου",
"HeaderEpisodes": "Επεισόδια",
"HeaderEreaderSettings": "Ρυθμίσεις Ereader",
"HeaderFiles": "Αρχεία",
@@ -150,6 +150,7 @@
"HeaderLibraryStats": "Στατιστικά Βιβλιοθήκης",
"HeaderListeningSessions": "Συνεδρίες Ακρόασης",
"HeaderListeningStats": "Στατιστικά Ακρόασης",
"HeaderLogin": "Σύνδεση",
"HeaderMatch": "Ταύτιση",
"HeaderNewAccount": "Νέος Λογαριασμός",
"HeaderNewApiKey": "Νέο Κλειδί API",
@@ -242,35 +243,51 @@
"LabelDescription": "Περιγραφή",
"LabelDevice": "Συσκευή",
"LabelDeviceInfo": "Πληροφορίες Συσκευής",
"LabelDiscover": "Ανακάλυψη",
"LabelDownload": "Λήψη",
"LabelDownloadNEpisodes": "Λήψη {0} επεισοδίων",
"LabelDuration": "Διάρκεια",
"LabelDurationComparisonExactMatch": "(ακριβής ταύτιση)",
"LabelEbook": "Ebook",
"LabelEbooks": "Ebooks",
"LabelEbook": "Ηλεκτρονικό βιβλίο",
"LabelEbooks": "Ηλεκτρονικά βιβλία",
"LabelEdit": "Επεξεργασία",
"LabelEmail": "Ηλεκτρονικό Ταχυδρομίο",
"LabelEmailSettingsFromAddress": "Από Διεύθυνση",
"LabelEmail": "Ηλεκτρονικό ταχυδρομείο",
"LabelEmailSettingsFromAddress": "Από διεύθυνση",
"LabelEmailSettingsSecure": "Ασφαλές",
"LabelEmailSettingsTestAddress": "Δοκιμή Διεύθυνσης",
"LabelEmailSettingsTestAddress": "Δοκιμή διεύθυνσης",
"LabelEmbeddedCover": "Ενσωματωμένο Εξώφυλλο",
"LabelEnable": "Ενεργοποίηση",
"LabelEnd": "Τέλος",
"LabelEndOfChapter": "Τέλος Κεφαλαίου",
"LabelEpisode": "Επεισόδιο",
"LabelExplicit": "Άσεμνο περιεχόμενο",
"LabelFeedURL": "URL ροής",
"LabelFile": "Αρχείο",
"LabelFileBirthtime": "Ώρα δημιουργίας αρχείου",
"LabelFileModified": "Τροποποιημένο αρχείο",
"LabelFilename": "Όνομα Αρχείου",
"LabelFinished": "Ολοκληρώθηκε",
"LabelFolder": "Φάκελος",
"LabelFontBoldness": "Ένταση γραμματοσειράς",
"LabelFontFamily": "Οικογένεια Γραμματοσειράς",
"LabelGenre": "Είδος",
"LabelGenres": "Είδη",
"LabelFontScale": "Μέγεθος γραμματοσειράς",
"LabelGenre": "Λογοτεχνικό Είδος",
"LabelGenres": "Λογοτεχνικά Είδη",
"LabelHasEbook": "Έχει βιβλίο",
"LabelHasSupplementaryEbook": "Διαθέτει συμπληρωματικό ηλεκτρονικό βιβλίο",
"LabelHost": "Διακομιστής",
"LabelInProgress": "Σε Εξέλιξη",
"LabelIncomplete": "Ελειπής",
"LabelLanguage": "Γλώσσα",
"LabelLayout": "Διάταξη",
"LabelLayoutSinglePage": "Μονή Σελίδα",
"LabelLibrarySortByProgress": "Πρόοδος: Τελευταία ενημέρωση",
"LabelLibrarySortByProgressFinished": "Πρόοδος: Ολοκληρώθηκε",
"LabelLibrarySortByProgressStarted": "Πρόοδος: Έναρξη",
"LabelLineSpacing": "Διάστημα γραμμών",
"LabelListenAgain": "Επανάληψη Ακρόασης",
"LabelMediaType": "Τύπος Πολυμέσων",
"LabelMissing": "Λείπει η ετικέτα",
"LabelMore": "Περισσότερα",
"LabelMoreInfo": "Περισσότερες Πληροφορίες",
"LabelName": "Όνομα",
@@ -278,11 +295,15 @@
"LabelNarrators": "Αφηγητές",
"LabelNewestAuthors": "Πρόσφατοι Συγγραφείς",
"LabelNewestEpisodes": "Πρόσφατα Επεισόδια",
"LabelNotFinished": "Δεν έχει ολοκληρωθεί",
"LabelNotStarted": "Δεν Έχει Ξεκινήσει",
"LabelNumberOfEpisodes": "# Επεισοδίων",
"LabelPassword": "Κωδικός Πρόσβασης",
"LabelPath": "Διαδρομή",
"LabelPodcast": "Πόντκαστ",
"LabelPodcasts": "Πόντκαστ",
"LabelProgress": "Πρόοδος",
"LabelPubDate": "Ημερομηνία έκδοσης",
"LabelPublishYear": "Χρονολογία Έκδοσης",
"LabelPublishedDate": "Εκδόθηκε {0}",
"LabelRandomly": "Τυχαία",
@@ -317,8 +338,11 @@
"LabelUsername": "Όνομα Χρήστη",
"LabelYourProgress": "Η Πρόοδος Σας",
"MessageDownloadingEpisode": "Λήψη επεισοδίου",
"MessageEreaderDevices": "Για να διασφαλίσετε την παράδοση των ηλεκτρονικών βιβλίων, ίσως χρειαστεί να προσθέσετε την παραπάνω διεύθυνση ηλεκτρονικού ταχυδρομείου ως έγκυρο αποστολέα για κάθε συσκευή που αναγράφεται παρακάτω.",
"MessageLoading": "Φόρτωση...",
"MessageMarkAsFinished": "Σήμανση ως Ολοκληρωμένο",
"MessageNoItemsFound": "Δεν βρέθηκαν αντικείμενα",
"MessageNoUserPlaylists": "Δεν έχετε λίστες αναπαραγωγής"
"MessageNoUserPlaylists": "Δεν έχετε λίστες αναπαραγωγής",
"ToastEmailSettingsUpdateSuccess": "Ενημερώθηκαν οι ρυθμίσεις ηλεκτρονικού ταχυδρομείου",
"ToastNameEmailRequired": "Το όνομα και η διεύθυνση ηλεκτρονικού ταχυδρομείου είναι υποχρεωτικά"
}
+4
View File
@@ -46,6 +46,7 @@
"ButtonLatest": "Latest",
"ButtonLibrary": "Library",
"ButtonLogout": "Logout",
"ButtonLogoutAllDevices": "Logout All Devices",
"ButtonLookup": "Lookup",
"ButtonManageTracks": "Manage Tracks",
"ButtonMapChapterTitles": "Map Chapter Titles",
@@ -194,6 +195,7 @@
"HeaderScheduleEpisodeDownloads": "Schedule Automatic Episode Downloads",
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
"HeaderSession": "Session",
"HeaderSessions": "Sessions",
"HeaderSetBackupSchedule": "Set Backup Schedule",
"HeaderSettings": "Settings",
"HeaderSettingsDisplay": "Display",
@@ -415,6 +417,7 @@
"LabelIntervalEveryHour": "Every hour",
"LabelIntervalEveryMinute": "Every minute",
"LabelInvert": "Invert",
"LabelIpAddress": "IP Address",
"LabelItem": "Item",
"LabelJumpBackwardAmount": "Jump backward amount",
"LabelJumpForwardAmount": "Jump forward amount",
@@ -774,6 +777,7 @@
"MessageConfirmDeleteSession": "Are you sure you want to delete this session?",
"MessageConfirmEmbedMetadataInAudioFiles": "Are you sure you want to embed metadata in {0} audio files?",
"MessageConfirmForceReScan": "Are you sure you want to force re-scan?",
"MessageConfirmLogoutDevice": "Are you sure you want to log out of \"{0}\"?",
"MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?",
"MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?",
"MessageConfirmMarkItemFinished": "Are you sure you want to mark \"{0}\" as finished?",
+4
View File
@@ -46,6 +46,7 @@
"ButtonLatest": "Más recientes",
"ButtonLibrary": "Biblioteca",
"ButtonLogout": "Cerrar Sesión",
"ButtonLogoutAllDevices": "Cerrar sesión en todos los dispositivos",
"ButtonLookup": "Averiguar",
"ButtonManageTracks": "Gestionar pistas",
"ButtonMapChapterTitles": "Asignar Títulos a Capítulos",
@@ -194,6 +195,7 @@
"HeaderScheduleEpisodeDownloads": "Planificador de auto‐descargas de episodios",
"HeaderScheduleLibraryScans": "Planificar Auto‐Escaneo de Biblioteca",
"HeaderSession": "Sesión",
"HeaderSessions": "Sesiones",
"HeaderSetBackupSchedule": "Establecer Planificación de Respaldo",
"HeaderSettings": "Ajustes",
"HeaderSettingsDisplay": "Interfaz",
@@ -415,6 +417,7 @@
"LabelIntervalEveryHour": "Cada hora",
"LabelIntervalEveryMinute": "Cada minuto",
"LabelInvert": "Invertir",
"LabelIpAddress": "Dirección IP",
"LabelItem": "Elemento",
"LabelJumpBackwardAmount": "Cantidad de saltos hacia atrás",
"LabelJumpForwardAmount": "Cantidad de saltos hacia adelante",
@@ -774,6 +777,7 @@
"MessageConfirmDeleteSession": "¿Confirma que quiere eliminar esta sesión?",
"MessageConfirmEmbedMetadataInAudioFiles": "¿Confirma que quiere incrustar metadatos en {0} archivos de audio?",
"MessageConfirmForceReScan": "¿Confirma que quiere forzar un reescaneo?",
"MessageConfirmLogoutDevice": "¿Seguro que quieres cerrar la sesión de «{0}»?",
"MessageConfirmMarkAllEpisodesFinished": "¿Confirma que quiere marcar todos los episodios como terminados?",
"MessageConfirmMarkAllEpisodesNotFinished": "¿Confirma que quiere marcar todos los episodios como no terminados?",
"MessageConfirmMarkItemFinished": "¿Confirma que quiere marcar «{0}» como terminado?",
+12 -9
View File
@@ -1,5 +1,6 @@
{
"ButtonAdd": "Lisa",
"ButtonAddApiKey": "Lisa API võti",
"ButtonAddChapters": "Lisa peatükid",
"ButtonAddDevice": "Lisa seade",
"ButtonAddLibrary": "Lisa raamatukogu",
@@ -53,7 +54,7 @@
"ButtonOpenFeed": "Ava voog",
"ButtonOpenManager": "Ava haldur",
"ButtonPause": "Paus",
"ButtonPlay": "Play",
"ButtonPlay": "Mängi",
"ButtonPlayAll": "Mängi kõik",
"ButtonPlaying": "Mängib",
"ButtonPlaylists": "Playlist",
@@ -159,7 +160,7 @@
"HeaderPlaylistItems": "Playlisti esemed",
"HeaderPodcastsToAdd": "Lisatavad podcastid",
"HeaderPreviewCover": "Eelvaate kaas",
"HeaderRSSFeedGeneral": "RSS-i üksikasjad",
"HeaderRSSFeedGeneral": "RSS detailid",
"HeaderRSSFeedIsOpen": "RSS-voog on avatud",
"HeaderRSSFeeds": "RSS-vooged",
"HeaderRemoveEpisode": "Eemalda episood",
@@ -177,7 +178,7 @@
"HeaderSleepTimer": "Unetaimer",
"HeaderStatsLargestItems": "Suurimad esemed",
"HeaderStatsLongestItems": "Kõige pikemad esemed (tunnid)",
"HeaderStatsMinutesListeningChart": "Kuulamise minutid (viimased 7 päeva)",
"HeaderStatsMinutesListeningChart": "Kuulatud minutid (viimased 7 päeva)",
"HeaderStatsRecentSessions": "Hiljutised sessioonid",
"HeaderStatsTop10Authors": "Top 10 autorit",
"HeaderStatsTop5Genres": "Top 5 žanrit",
@@ -245,7 +246,7 @@
"LabelConfirmPassword": "Kinnita parool",
"LabelContinueListening": "Jätka kuulamist",
"LabelContinueReading": "Jätka lugemist",
"LabelContinueSeries": "Jätka seeriat",
"LabelContinueSeries": "Jäta sarja",
"LabelCover": "Ümbris",
"LabelCoverImageURL": "Ümbrise pildi URL",
"LabelCreatedAt": "Loodud",
@@ -325,9 +326,10 @@
"LabelIntervalEveryDay": "Iga päev",
"LabelIntervalEveryHour": "Iga tunni tagant",
"LabelInvert": "Pööra ümber",
"LabelIpAddress": "IP-aadress",
"LabelItem": "Kirje",
"LabelLanguage": "Keel",
"LabelLanguageDefaultServer": "Vaikeserveri keel",
"LabelLanguageDefaultServer": "Serveri vaikekeel",
"LabelLastBookAdded": "Viimati lisatud raamat",
"LabelLastBookUpdated": "Viimati uuendatud raamat",
"LabelLastSeen": "Viimati nähtud",
@@ -368,8 +370,8 @@
"LabelNarrators": "Jutustajad",
"LabelNew": "Uus",
"LabelNewPassword": "Uus parool",
"LabelNewestAuthors": "Uuemad autorid",
"LabelNewestEpisodes": "Uuemad episoodid",
"LabelNewestAuthors": "Uusimad autorid",
"LabelNewestEpisodes": "Uusimad episoodid",
"LabelNextBackupDate": "Järgmine varukoopia kuupäev",
"LabelNextScheduledRun": "Järgmine ajakava järgmine",
"LabelNoEpisodesSelected": "Episoodid pole valitud",
@@ -458,8 +460,8 @@
"LabelSettingsFindCoversHelp": "Kui teie heliraamatul pole sisseehitatud ümbrist ega ümbrise pilti kaustas, proovib skanner leida ümbrist.<br>Märkus: see pikendab skaneerimisaega",
"LabelSettingsHideSingleBookSeries": "Peida üksikute raamatute seeriad",
"LabelSettingsHideSingleBookSeriesHelp": "Ühe raamatuga seeriaid peidetakse seeria lehelt ja avalehe riiulitelt.",
"LabelSettingsHomePageBookshelfView": "Avaleht kasutage raamatukoguvaadet",
"LabelSettingsLibraryBookshelfView": "Raamatukogu kasutamiseks kasutage raamatukoguvaadet",
"LabelSettingsHomePageBookshelfView": "Avalehel kasuta raamaturiiulivaadet",
"LabelSettingsLibraryBookshelfView": "Raamatukogus kasuta raamaturiiulivaadet",
"LabelSettingsParseSubtitles": "Lugege subtiitreid",
"LabelSettingsParseSubtitlesHelp": "Eraldage subtiitrid heliraamatu kaustade nimedest.<br>Subtiitrid peavad olema eraldatud kasutades \" - \".<br>Näiteks: \"Raamatu pealkiri - Siin on alapealkiri\" alapealkiri on \"Siin on alapealkiri\"",
"LabelSettingsPreferMatchedMetadata": "Eelista sobitatud metaandmeid",
@@ -565,6 +567,7 @@
"MessageBookshelfNoCollectionsHelp": "Kollektsioonid on avalikud. Kõik kasutajad kellel on olemas ligipääs raamatukogule saavad neid näha.",
"MessageBookshelfNoRSSFeeds": "Ühtegi RSS-i voogu pole avatud",
"MessageBookshelfNoResultsForFilter": "Filtrile \"{0}: {1}\" pole tulemusi",
"MessageBookshelfNoResultsForQuery": "Päringul ei ole tulemusi",
"MessageBookshelfNoSeries": "Teil pole ühtegi seeriat",
"MessageChapterEndIsAfter": "Peatüki lõpp on pärast teie heliraamatu lõppu",
"MessageChapterErrorFirstNotZero": "Esimene peatükk peab algama 0-st",
+4
View File
@@ -46,6 +46,7 @@
"ButtonLatest": "Viimeisimmät",
"ButtonLibrary": "Kirjasto",
"ButtonLogout": "Kirjaudu ulos",
"ButtonLogoutAllDevices": "Kirjaudu ulos kaikista laitteista",
"ButtonLookup": "Hae",
"ButtonManageTracks": "Hallitse raitoja",
"ButtonMapChapterTitles": "Kartoita lukujen nimet",
@@ -194,6 +195,7 @@
"HeaderScheduleEpisodeDownloads": "Ajoita automaattiset jaksolataukset",
"HeaderScheduleLibraryScans": "Ajoita automaattiset kirjastoskannaukset",
"HeaderSession": "Istunto",
"HeaderSessions": "Istunnot",
"HeaderSetBackupSchedule": "Aseta varmuuskopiointiaikataulu",
"HeaderSettings": "Asetukset",
"HeaderSettingsDisplay": "Näyttö",
@@ -415,6 +417,7 @@
"LabelIntervalEveryHour": "Joka tunti",
"LabelIntervalEveryMinute": "Joka minuutti",
"LabelInvert": "Saa käänteiseksi",
"LabelIpAddress": "IP-osoite",
"LabelItem": "Kohde",
"LabelJumpBackwardAmount": "Taaksepäin-hyppyjen määrä",
"LabelJumpForwardAmount": "Eteenpäin-hyppyjen määrä",
@@ -774,6 +777,7 @@
"MessageConfirmDeleteSession": "Oletko varma, että haluat poistaa tämän istunnon?",
"MessageConfirmEmbedMetadataInAudioFiles": "Oletko varma, että haluat upottaa metatiedot {0} äänitiedostoihin?",
"MessageConfirmForceReScan": "Oletko varma, että haluat pakottaa uudelleenskannauksen?",
"MessageConfirmLogoutDevice": "Haluatko varmasti kirjautua ulos palvelusta \"{0}\"?",
"MessageConfirmMarkAllEpisodesFinished": "Oletko varma, että haluat merkitä kaikki jaksot päättyneiksi?",
"MessageConfirmMarkAllEpisodesNotFinished": "Oletko varma, että haluat merkitä kaikki jaksot ei-valmiiksi?",
"MessageConfirmMarkItemFinished": "Oletko varma, että haluat merkitä \"{0}\":n valmiiksi?",
+6 -2
View File
@@ -46,6 +46,7 @@
"ButtonLatest": "Dernière version",
"ButtonLibrary": "Bibliothèque",
"ButtonLogout": "Déconnexion",
"ButtonLogoutAllDevices": "Se déconnecter de tous les appareils",
"ButtonLookup": "Chercher",
"ButtonManageTracks": "Gérer les pistes",
"ButtonMapChapterTitles": "Correspondance des titres de chapitres",
@@ -139,7 +140,7 @@
"HeaderCustomMetadataProviders": "Fournisseurs de métadonnées personnalisés",
"HeaderDetails": "Détails",
"HeaderDownloadQueue": "File d’attente de téléchargements",
"HeaderEbookFiles": "Fichiers des livres numériques",
"HeaderEbookFiles": "Fichiers du livre",
"HeaderEmail": "Courriel",
"HeaderEmailSettings": "Configuration de l’envoie des courriels",
"HeaderEpisodes": "Épisodes",
@@ -194,6 +195,7 @@
"HeaderScheduleEpisodeDownloads": "Programmer des téléchargements automatiques d'épisodes",
"HeaderScheduleLibraryScans": "Analyse automatique de la bibliothèque",
"HeaderSession": "Session",
"HeaderSessions": "Sessions",
"HeaderSetBackupSchedule": "Activer la sauvegarde automatique",
"HeaderSettings": "Paramètres",
"HeaderSettingsDisplay": "Affichage",
@@ -415,6 +417,7 @@
"LabelIntervalEveryHour": "Toutes les heures",
"LabelIntervalEveryMinute": "Toutes les minutes",
"LabelInvert": "Inverser",
"LabelIpAddress": "Adresse IP",
"LabelItem": "Élément",
"LabelJumpBackwardAmount": "Dans le lecteur, reculer de",
"LabelJumpForwardAmount": "Dans le lecteur, avancer de",
@@ -538,7 +541,7 @@
"LabelPublishedDecades": "Décennies de publication",
"LabelPublisher": "Éditeur",
"LabelPublishers": "Éditeurs",
"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",
@@ -774,6 +777,7 @@
"MessageConfirmDeleteSession": "Êtes-vous sûr·e de vouloir supprimer cette session ?",
"MessageConfirmEmbedMetadataInAudioFiles": "Êtes-vous sûr·e de vouloir intégrer des métadonnées dans {0} fichiers audio ?",
"MessageConfirmForceReScan": "Êtes-vous sûr·e de vouloir lancer une analyse forcée ?",
"MessageConfirmLogoutDevice": "Êtes-vous sûr·e de vouloir vous déconnecter de « {0} » ?",
"MessageConfirmMarkAllEpisodesFinished": "Êtes-vous sûr·e de marquer tous les épisodes comme terminés ?",
"MessageConfirmMarkAllEpisodesNotFinished": "Êtes-vous sûr·e de vouloir marquer tous les épisodes comme non terminés ?",
"MessageConfirmMarkItemFinished": "Êtes-vous sûr·e de vouloir marquer {0} comme terminé ?",
+5 -1
View File
@@ -46,6 +46,7 @@
"ButtonLatest": "Najnovije",
"ButtonLibrary": "Knjižnica",
"ButtonLogout": "Odjavi se",
"ButtonLogoutAllDevices": "Odjava sa svih uređaja",
"ButtonLookup": "Potraži",
"ButtonManageTracks": "Upravljanje zvučnim zapisima",
"ButtonMapChapterTitles": "Mapiraj naslove poglavlja",
@@ -154,7 +155,7 @@
"HeaderLatestEpisodes": "Najnoviji nastavci",
"HeaderLibraries": "Knjižnice",
"HeaderLibraryFiles": "Datoteke knjižnice",
"HeaderLibraryStats": "Statistika knjižnice",
"HeaderLibraryStats": "Knjižnična statistika",
"HeaderListeningSessions": "Sesije slušanja",
"HeaderListeningStats": "Statistika slušanja",
"HeaderLogin": "Prijava",
@@ -194,6 +195,7 @@
"HeaderScheduleEpisodeDownloads": "Zakazivanje automatskog preuzimanja nastavaka",
"HeaderScheduleLibraryScans": "Zakaži automatsko skeniranje knjižnice",
"HeaderSession": "Sesija",
"HeaderSessions": "Sesije",
"HeaderSetBackupSchedule": "Zakazivanje sigurnosne pohrane",
"HeaderSettings": "Postavke",
"HeaderSettingsDisplay": "Prikaz",
@@ -415,6 +417,7 @@
"LabelIntervalEveryHour": "Svaki sat",
"LabelIntervalEveryMinute": "Svaku minutu",
"LabelInvert": "Obrni",
"LabelIpAddress": "IP adresa",
"LabelItem": "Stavka",
"LabelJumpBackwardAmount": "Dužina skoka unatrag",
"LabelJumpForwardAmount": "Dužina skoka unaprijed",
@@ -774,6 +777,7 @@
"MessageConfirmDeleteSession": "Sigurno želite izbrisati ovu sesiju?",
"MessageConfirmEmbedMetadataInAudioFiles": "Sigurno želite ugraditi meta-podatke u {0} zvučnih datoteka?",
"MessageConfirmForceReScan": "Sigurno želite ponovno pokrenuti skeniranje?",
"MessageConfirmLogoutDevice": "Sigurno se želiš odjaviti iz \"{0}\"?",
"MessageConfirmMarkAllEpisodesFinished": "Sigurno želite označiti sve nastavke kao završene?",
"MessageConfirmMarkAllEpisodesNotFinished": "Sigurno želite označiti sve nastavke kao nezavršene?",
"MessageConfirmMarkItemFinished": "Sigurno želite označiti stavku \"{0}\" kao završenu?",
+4
View File
@@ -46,6 +46,7 @@
"ButtonLatest": "Legújabb",
"ButtonLibrary": "Könyvtár",
"ButtonLogout": "Kijelentkezés",
"ButtonLogoutAllDevices": "Kijelentkezés az összes eszközről",
"ButtonLookup": "Keresés",
"ButtonManageTracks": "Sávok kezelése",
"ButtonMapChapterTitles": "Fejezetcímek hozzárendelése",
@@ -194,6 +195,7 @@
"HeaderScheduleEpisodeDownloads": "Automatikus epizódletöltés ütemezése",
"HeaderScheduleLibraryScans": "Könyvtárak automatikus szkennelésének ütemezése",
"HeaderSession": "Munkamenet",
"HeaderSessions": "Munkamenet",
"HeaderSetBackupSchedule": "Biztonsági másolatok ütemezésének beállítása",
"HeaderSettings": "Beállítások",
"HeaderSettingsDisplay": "Kijelző",
@@ -415,6 +417,7 @@
"LabelIntervalEveryHour": "Minden órában",
"LabelIntervalEveryMinute": "Minden percben",
"LabelInvert": "Inverz",
"LabelIpAddress": "IP-cím",
"LabelItem": "Elem",
"LabelJumpBackwardAmount": "Visszafelé ugrás mennyisége",
"LabelJumpForwardAmount": "Előre ugrás mennyisége",
@@ -774,6 +777,7 @@
"MessageConfirmDeleteSession": "Biztosan törölni szeretné ezt a munkamenetet?",
"MessageConfirmEmbedMetadataInAudioFiles": "Biztos, hogy metaadatokat szeretne beágyazni {0} hangfájlba?",
"MessageConfirmForceReScan": "Biztosan kényszeríteni szeretné az újraszkennelést?",
"MessageConfirmLogoutDevice": "Biztosan ki szeretne jelentkezni a „{0}”-ból?",
"MessageConfirmMarkAllEpisodesFinished": "Biztosan meg szeretné jelölni az összes epizódot befejezettnek?",
"MessageConfirmMarkAllEpisodesNotFinished": "Biztosan meg szeretné jelölni az összes epizódot nem befejezettnek?",
"MessageConfirmMarkItemFinished": "Biztos, hogy a „{0}”-t befejezettnek akarja jelölni?",
+32 -1
View File
@@ -1 +1,32 @@
{}
{
"ButtonAdd": "Bæta við",
"ButtonAddApiKey": "Bæta við API-lykli",
"ButtonAddChapters": "Bæta við köflum",
"ButtonAddDevice": "Bæta við tæki",
"ButtonAddLibrary": "Bæta við bókasafni",
"ButtonAddPodcasts": "Bæta við hlaðvörpum",
"ButtonAddUser": "Bæta við notanda",
"ButtonAddYourFirstLibrary": "Bættu fyrsta bókasafninu þínu við",
"ButtonApply": "Nota",
"ButtonApplyChapters": "Skrá kafla",
"ButtonAuthors": "Rithöfundar",
"ButtonBack": "Bakka",
"ButtonBatchEditPopulateFromExisting": "Afrita frá núverandi",
"ButtonBatchEditPopulateMapDetails": "Bæta við upplýsingum",
"ButtonBrowseForFolder": "Skoða möppu",
"ButtonCancel": "Hætta við",
"ButtonCancelEncode": "Hætta við Encoding",
"ButtonChangeRootPassword": "Breyta Root lykilorði",
"ButtonCheckAndDownloadNewEpisodes": "Skoða og sækja nýja þætti",
"ButtonChooseAFolder": "Velja möppu",
"ButtonChooseFiles": "Velja skrár",
"ButtonClearFilter": "Hreinsa síu",
"ButtonClose": "Loka",
"ButtonCloseSession": "Loka opnu sessioni",
"ButtonCollections": "Söfn",
"ButtonConfigureScanner": "Stilla skannara",
"ButtonCreate": "Stofna",
"ButtonCreateBackup": "Búa til backup",
"ButtonDelete": "Eyða",
"ButtonDownloadQueue": "Röð"
}
+6 -2
View File
@@ -46,6 +46,7 @@
"ButtonLatest": "Ultimi",
"ButtonLibrary": "Biblioteca",
"ButtonLogout": "Disconnetti",
"ButtonLogoutAllDevices": "Disconnetti su tutti i dispositivi",
"ButtonLookup": "Consulta",
"ButtonManageTracks": "Gestisci le Tracce",
"ButtonMapChapterTitles": "Titoli dei Capitoli",
@@ -139,7 +140,7 @@
"HeaderCustomMetadataProviders": "Metadata Providers Personalizzato",
"HeaderDetails": "Dettagli",
"HeaderDownloadQueue": "Download coda",
"HeaderEbookFiles": "File dei libri",
"HeaderEbookFiles": "File del libro",
"HeaderEmail": "E-mail",
"HeaderEmailSettings": "Impostazioni e-mail",
"HeaderEpisodes": "Episodi",
@@ -194,6 +195,7 @@
"HeaderScheduleEpisodeDownloads": "Imposta il download automatico degli episodi",
"HeaderScheduleLibraryScans": "Schedula la scansione della libreria",
"HeaderSession": "Sessione",
"HeaderSessions": "Sessioni",
"HeaderSetBackupSchedule": "Imposta programmazione Backup",
"HeaderSettings": "Impostazioni",
"HeaderSettingsDisplay": "Visualizzazione",
@@ -415,6 +417,7 @@
"LabelIntervalEveryHour": "Ogni ora",
"LabelIntervalEveryMinute": "Ogni minuto",
"LabelInvert": "Inverti",
"LabelIpAddress": "Indirizzo Ip",
"LabelItem": "Oggetti",
"LabelJumpBackwardAmount": "secondi di avvolgimento",
"LabelJumpForwardAmount": "Secondi di Avvolgimento",
@@ -774,6 +777,7 @@
"MessageConfirmDeleteSession": "Sei sicuro di voler eliminare questa sessione?",
"MessageConfirmEmbedMetadataInAudioFiles": "Sei sicuro di voler incorporare i metadati nei file audio {0}?",
"MessageConfirmForceReScan": "Sei sicuro di voler forzare una nuova scansione?",
"MessageConfirmLogoutDevice": "Sei sicuro/a di volerti disconnettere da «{0}»?",
"MessageConfirmMarkAllEpisodesFinished": "Sei sicuro di voler contrassegnare tutti gli episodi come finiti?",
"MessageConfirmMarkAllEpisodesNotFinished": "Sei sicuro di voler contrassegnare tutti gli episodi come non completati?",
"MessageConfirmMarkItemFinished": "Sei sicuro/sicura di voler segnare {0} come finito?",
@@ -1075,7 +1079,7 @@
"ToastMetadataFilesRemovedNoneRemoved": "Nessun metadato. {0} file rimossi",
"ToastMetadataFilesRemovedSuccess": "{0} metadati.{1} file rimossi",
"ToastMustHaveAtLeastOnePath": "Deve avere almeno un percorso",
"ToastNameEmailRequired": "Nome ed email sono obbligatori",
"ToastNameEmailRequired": "Nome ed e-mail sono obbligatori",
"ToastNameRequired": "Il nome è obbligatorio",
"ToastNewApiKeyUserError": "Deve selezionare un utente",
"ToastNewEpisodesFound": "{0} nuovi episodi trovati",
+9 -4
View File
@@ -11,6 +11,7 @@
"ButtonApplyChapters": "Taikyti skyrius",
"ButtonAuthors": "Autoriai",
"ButtonBack": "Atgal",
"ButtonBatchEditPopulateFromExisting": "Užpildyti iš esamų",
"ButtonBrowseForFolder": "Naršyti aplanko",
"ButtonCancel": "Atšaukti",
"ButtonCancelEncode": "Atšaukti kodavimą",
@@ -42,6 +43,7 @@
"ButtonLatest": "Naujausias",
"ButtonLibrary": "Biblioteka",
"ButtonLogout": "Atsijungti",
"ButtonLogoutAllDevices": "Atsijungti visuose įrenginiuose",
"ButtonLookup": "Ieškoti",
"ButtonManageTracks": "Tvarkyti takelius",
"ButtonMapChapterTitles": "Suderinti skyrių pavadinimus",
@@ -69,7 +71,7 @@
"ButtonReScan": "Iš naujo nuskaityti",
"ButtonRead": "Skaityti",
"ButtonReadLess": "Mažiau",
"ButtonReadMore": "Daugiau",
"ButtonReadMore": "Skaityti daugiau",
"ButtonRefresh": "Atnaujinti",
"ButtonRemove": "Pašalinti",
"ButtonRemoveAll": "Pašalinti viską",
@@ -230,8 +232,8 @@
"LabelCollections": "Kolekcijos",
"LabelComplete": "Baigta",
"LabelConfirmPassword": "Patvirtinkite slaptažodį",
"LabelContinueListening": "Tęsti klausymąsi",
"LabelContinueReading": "Tęsti skaitymą",
"LabelContinueListening": "Klausytis toliau",
"LabelContinueReading": "Skaityti toliau",
"LabelContinueSeries": "Tęsti seriją",
"LabelCover": "Viršelis",
"LabelCoverImageURL": "Viršelio paveikslėlio URL",
@@ -248,6 +250,7 @@
"LabelDirectory": "Katalogas",
"LabelDiscFromFilename": "Diskas pagal failo pavadinimą",
"LabelDiscFromMetadata": "Diskas pagal metaduomenis",
"LabelDiscover": "Atrask",
"LabelDownload": "Atsisiųsti",
"LabelDownloadNEpisodes": "Atsisiųsti {0} epizodų",
"LabelDuration": "Trukmė",
@@ -393,7 +396,7 @@
"LabelReadAgain": "Skaityti dar kartą",
"LabelReadEbookWithoutProgress": "Skaityti e-knygą be pažangos saugojimo",
"LabelRecentSeries": "Naujausios serijos",
"LabelRecentlyAdded": "Neseniai pridėta",
"LabelRecentlyAdded": "Naujausiai įkelta",
"LabelRecommended": "Rekomenduojama",
"LabelRegion": "Regionas",
"LabelReleaseDate": "Išleidimo data",
@@ -511,6 +514,8 @@
"LabelViewQueue": "Peržiūrėti grotuvo eilę",
"LabelVolume": "Garsumas",
"LabelWeekdaysToRun": "Dienos, kuriomis vykdyti",
"LabelYearReviewHide": "Slėpti metų apžvalgą",
"LabelYearReviewShow": "Pamatyti metų apžvalgą",
"LabelYourAudiobookDuration": "Jūsų garso knygos trukmė",
"LabelYourBookmarks": "Jūsų skirtukai",
"LabelYourPlaylists": "Jūsų grojaraščiai",
+1
View File
@@ -0,0 +1 @@
{}
+4
View File
@@ -46,6 +46,7 @@
"ButtonLatest": "Meest recent",
"ButtonLibrary": "Bibliotheek",
"ButtonLogout": "Uitloggen",
"ButtonLogoutAllDevices": "Uitloggen op alle apparaten",
"ButtonLookup": "Zoeken",
"ButtonManageTracks": "Tracks beheren",
"ButtonMapChapterTitles": "Hoofdstuktitels mappen",
@@ -194,6 +195,7 @@
"HeaderScheduleEpisodeDownloads": "Automatische afleveringsdownloads plannen",
"HeaderScheduleLibraryScans": "Schema automatische bibliotheekscans",
"HeaderSession": "Sessie",
"HeaderSessions": "Sessies",
"HeaderSetBackupSchedule": "Kies schema voor back-up",
"HeaderSettings": "Instellingen",
"HeaderSettingsDisplay": "Toon",
@@ -415,6 +417,7 @@
"LabelIntervalEveryHour": "Ieder uur",
"LabelIntervalEveryMinute": "Elke minuut",
"LabelInvert": "Omdraaien",
"LabelIpAddress": "IP-adres",
"LabelItem": "Onderdeel",
"LabelJumpBackwardAmount": "Terugspoelen hoeveelheid",
"LabelJumpForwardAmount": "Vooruitspoelen hoeveelheid",
@@ -774,6 +777,7 @@
"MessageConfirmDeleteSession": "Weet je zeker dat je deze sessie wil verwijderen?",
"MessageConfirmEmbedMetadataInAudioFiles": "Weet u zeker dat u metagegevens wilt insluiten in {0} audiobestanden?",
"MessageConfirmForceReScan": "Weet je zeker dat je geforceerd opnieuw wil scannen?",
"MessageConfirmLogoutDevice": "Weet je zeker dat je wil uitloggen op \"{0}\"?",
"MessageConfirmMarkAllEpisodesFinished": "Weet je zeker dat je alle afleveringen als voltooid wil markeren?",
"MessageConfirmMarkAllEpisodesNotFinished": "Weet je zeker dat je alle afleveringen als niet-voltooid wil markeren?",
"MessageConfirmMarkItemFinished": "Weet u zeker dat u \"{0}\" als voltooid wilt markeren?",
+4
View File
@@ -46,6 +46,7 @@
"ButtonLatest": "Siste",
"ButtonLibrary": "Bibliotek",
"ButtonLogout": "Logg ut",
"ButtonLogoutAllDevices": "Logg ut av alle enheter",
"ButtonLookup": "Slå opp",
"ButtonManageTracks": "Administrer spor",
"ButtonMapChapterTitles": "Kartlegg kapittel titler",
@@ -194,6 +195,7 @@
"HeaderScheduleEpisodeDownloads": "Planlegg automatisk nedlasting av episoder",
"HeaderScheduleLibraryScans": "Planlegg automatisk bibliotek skann",
"HeaderSession": "Sesjon",
"HeaderSessions": "Økter",
"HeaderSetBackupSchedule": "Sett timeplan for sikkerhetskopi",
"HeaderSettings": "Innstillinger",
"HeaderSettingsDisplay": "Vis",
@@ -415,6 +417,7 @@
"LabelIntervalEveryHour": "Hver time",
"LabelIntervalEveryMinute": "Hvert minutt",
"LabelInvert": "Inverter",
"LabelIpAddress": "IP Adresse",
"LabelItem": "Enhet",
"LabelJumpBackwardAmount": "Hopp bakover med",
"LabelJumpForwardAmount": "Hopp forover med",
@@ -774,6 +777,7 @@
"MessageConfirmDeleteSession": "Er du sikker på at du vil slette denne sesjonen?",
"MessageConfirmEmbedMetadataInAudioFiles": "Er du sikker på at du vil legge til metadata i {0} lyd-filer?",
"MessageConfirmForceReScan": "Er du sikker på at du vil tvinge en ny skann?",
"MessageConfirmLogoutDevice": "Er du sikker på at du vil logge ut av \"{0}\"?",
"MessageConfirmMarkAllEpisodesFinished": "Er du sikker på at du vil markere alle episodene som fullført?",
"MessageConfirmMarkAllEpisodesNotFinished": "Er du sikker på at du vil markere alle episodene som ikke fullført?",
"MessageConfirmMarkItemFinished": "Er du sikker på at du vil markere {0} som ferdig?",
+54 -2
View File
@@ -46,6 +46,7 @@
"ButtonLatest": "Aktualna wersja",
"ButtonLibrary": "Biblioteka",
"ButtonLogout": "Wyloguj",
"ButtonLogoutAllDevices": "Wyloguj z wszystkich urządzeń",
"ButtonLookup": "Importuj",
"ButtonManageTracks": "Zarządzaj ścieżkami",
"ButtonMapChapterTitles": "Mapuj nazwy rozdziałów",
@@ -194,6 +195,7 @@
"HeaderScheduleEpisodeDownloads": "Planowanie automatycznego ściągania odcinków",
"HeaderScheduleLibraryScans": "Zaplanuj automatyczne skanowanie biblioteki",
"HeaderSession": "Sesja",
"HeaderSessions": "Sesje",
"HeaderSetBackupSchedule": "Ustaw harmonogram tworzenia kopii zapasowej",
"HeaderSettings": "Ustawienia",
"HeaderSettingsDisplay": "Wyświetlanie",
@@ -344,7 +346,7 @@
"LabelEncodingInfoEmbedded": "Metadane zostaną osadzone w ścieżkach audio w folderze z audiobookiem.",
"LabelEncodingStartedNavigation": "Po uruchomieniu zadania możesz opuścić tę stronę.",
"LabelEncodingTimeWarning": "Kodowanie może potrwać do 30 minut.",
"LabelEncodingWarningAdvancedSettings": "Ostrzeżenie: Nie aktualizuj tych ustawień, jeśli nie jesteś zaznajomiony ze sposobem działania ffmpeg i opcji konwersji.",
"LabelEncodingWarningAdvancedSettings": "Ostrzeżenie: Nie aktualizuj tych ustawień, jeśli nie znasz opcji kodowania ffmpeg.",
"LabelEncodingWatcherDisabled": "Jeśli monitorowanie folderów jest wyłączone, należy ponownie przeskanować audiobooka.",
"LabelEnd": "Zakończ",
"LabelEndOfChapter": "Koniec rozdziału",
@@ -415,6 +417,7 @@
"LabelIntervalEveryHour": "Każdej godziny",
"LabelIntervalEveryMinute": "Co minutę",
"LabelInvert": "Inversja",
"LabelIpAddress": "Adres IP",
"LabelItem": "Pozycja",
"LabelJumpBackwardAmount": "Przeskocz do tyłu o:",
"LabelJumpForwardAmount": "Przeskocz do przodu o:",
@@ -774,6 +777,7 @@
"MessageConfirmDeleteSession": "Czy na pewno chcesz usunąć tę sesję?",
"MessageConfirmEmbedMetadataInAudioFiles": "Czy na pewno chcesz osadzić metadane w {0} plikach audio?",
"MessageConfirmForceReScan": "Czy na pewno chcesz wymusić ponowne skanowanie?",
"MessageConfirmLogoutDevice": "Czy na pewno chcesz się wylogować z \"{0}\"?",
"MessageConfirmMarkAllEpisodesFinished": "Czy na pewno chcesz oznaczyć wszystkie odcinki jako ukończone?",
"MessageConfirmMarkAllEpisodesNotFinished": "Czy na pewno chcesz oznaczyć wszystkie odcinki jako nieukończone?",
"MessageConfirmMarkItemFinished": "Czy na pewno chcesz oznaczyć \"{0}\" jako zakończone?",
@@ -888,7 +892,7 @@
"MessageResetChaptersConfirm": "Czy na pewno chcesz zresetować rozdziały i cofnąć wprowadzone zmiany?",
"MessageRestoreBackupConfirm": "Czy na pewno chcesz przywrócić kopię zapasową utworzoną w dniu",
"MessageRestoreBackupWarning": "Przywrócenie kopii zapasowej spowoduje nadpisanie bazy danych w folderze /config oraz okładek w folderze /metadata/items & /metadata/authors.<br /><br />Kopie zapasowe nie modyfikują żadnego pliku w folderach z plikami audio. Jeśli włączyłeś ustawienia serwera, aby przechowywać okładki i metadane w folderach biblioteki, to nie są one zapisywane w kopii zapasowej lub nadpisywane<br /><br />Wszyscy klienci korzystający z Twojego serwera będą automatycznie odświeżani.",
"MessageScheduleLibraryScanNote": "W przypadku większości użytkowników zaleca się pozostawienie tej funkcji wyłączonej i włączenie opcji monitorowania folderów. Monitor folderów automatycznie wykrywa zmiany w folderach biblioteki. Monitor folderów nie działa w przypadku wszystkich systemów plików (np. NFS), dlatego zamiast niego można używać zaplanowanych skanowań biblioteki.",
"MessageScheduleLibraryScanNote": "Dla większości użytkowników zaleca się pozostawienie tej funkcji wyłączonej i włączenie ustawienia \"Automatycznie obserwuj bibliotekę pod kątem zmian\" – będzie ono automatycznie wykrywać zmiany w folderach biblioteki. Włącz tę funkcję, jeśli \"Automatycznie obserwuj bibliotekę pod kątem zmian\" nie działa w Twoim systemie plików (np. NFS).",
"MessageScheduleRunEveryWeekdayAtTime": "Uruchom w każdy {0} o {1}",
"MessageSearchResultsFor": "Wyniki wyszukiwania dla",
"MessageSelected": "{0} wybranych",
@@ -951,6 +955,11 @@
"NoteUploaderFoldersWithMediaFiles": "Foldery z plikami multimedialnymi będą traktowane jako osobne elementy w bibliotece.",
"NoteUploaderOnlyAudioFiles": "Jeśli przesyłasz tylko pliki audio, każdy plik audio będzie traktowany jako osobny audiobook.",
"NoteUploaderUnsupportedFiles": "Nieobsługiwane pliki są ignorowane. Podczas dodawania folderu, inne pliki, które nie znajdują się w folderze elementu, są ignorowane.",
"NotificationOnBackupCompletedDescription": "Wyzwalane po zakończeniu tworzenia kopii zapasowej",
"NotificationOnBackupFailedDescription": "Wyzwalane w przypadku gdy stworzenie kopii zapasowej rzuci błąd",
"NotificationOnEpisodeDownloadedDescription": "Wyzwalane, gdy odcinek podcastu zostanie automatycznie pobrany",
"NotificationOnRSSFeedDisabledDescription": "Wyzwalane, gdy automatyczne pobieranie odcinków jest wyłączone z powodu zbyt wielu nieudanych prób",
"NotificationOnRSSFeedFailedDescription": "Wyzwalane, gdy żądanie kanału RSS dotyczące automatycznego pobrania odcinka nie powiedzie się",
"NotificationOnTestDescription": "Zdarzenie używane do testowania systemu powiadomień",
"PlaceholderBulkChapterInput": "Wpisz tytuł rozdziału lub użyj numeracji (np. „Odcinek 1”, „Rozdział 10”, „1.”)",
"PlaceholderNewCollection": "Nowa nazwa kolekcji",
@@ -960,6 +969,7 @@
"PlaceholderSearchEpisode": "Szukanie odcinka..",
"StatsAuthorsAdded": "dodano autorów",
"StatsBooksAdded": "dodano książki",
"StatsBooksAdditional": "Niektóre dodatki obejmują…",
"StatsBooksFinished": "ukończone książki",
"StatsBooksFinishedThisYear": "Wybrane książki ukończone w tym roku…",
"StatsBooksListenedTo": "książki wysłuchane",
@@ -976,6 +986,7 @@
"StatsTotalDuration": "O sumarycznej długości…",
"StatsYearInReview": "PRZEGLĄD ROKU",
"ToastAccountUpdateSuccess": "Zaktualizowano konto",
"ToastAppriseUrlRequired": "Należy wprowadzić adres URL Apprise",
"ToastAsinRequired": "ASIN jest wymagany",
"ToastAuthorImageRemoveSuccess": "Zdjęcie autora usunięte",
"ToastAuthorNotFound": "Autor \"{0}\" nie został znaleziony",
@@ -994,8 +1005,11 @@
"ToastBackupRestoreFailed": "Nie udało się przywrócić kopii zapasowej",
"ToastBackupUploadFailed": "Nie udało się przesłać kopii zapasowej",
"ToastBackupUploadSuccess": "Kopia zapasowa została przesłana",
"ToastBatchApplyDetailsToItemsSuccess": "Szczegóły zastosowane do elementów",
"ToastBatchDeleteFailed": "Usuwanie zbiorcze nie powiodło się",
"ToastBatchDeleteSuccess": "Usuwanie zbiorcze powiodło się",
"ToastBatchQuickMatchFailed": "Szybkie dopasowanie partii nie powiodło się!",
"ToastBatchQuickMatchStarted": "Rozpoczęto partię szybkiego dopasowania {0} książek!",
"ToastBatchUpdateFailed": "Aktualizacja zbiorcza nie powiodła się",
"ToastBatchUpdateSuccess": "Aktualizacja zbiorcza powiodła się",
"ToastBookmarkCreateFailed": "Nie udało się utworzyć zakładki",
@@ -1033,7 +1047,14 @@
"ToastEpisodeDownloadQueueClearSuccess": "Wyczyszczono kolejkę epizodów do ściągnięcia",
"ToastEpisodeUpdateSuccess": "Zaktualizowano {0} odcinków",
"ToastErrorCannotShare": "Nie można udostępniać natywnie na tym urządzeniu.",
"ToastFailedToCreate": "Nie udało się utworzyć",
"ToastFailedToDelete": "Nie udało się usunąć",
"ToastFailedToLoadData": "Nie udało się załadować danych",
"ToastFailedToMatch": "Nie udało się dopasować",
"ToastFailedToShare": "Nie udało się udostępnić",
"ToastFailedToUpdate": "Nie udało się zaktualizować",
"ToastInvalidImageUrl": "Nieprawidłowy URL obrazu",
"ToastInvalidMaxEpisodesToDownload": "Nieprawidłowa maksymalna liczba odcinków do pobrania",
"ToastInvalidUrl": "Nieprawidłowy URL",
"ToastInvalidUrls": "Jeden lub więcej URL-i są nieprawidłowe",
"ToastItemCoverUpdateSuccess": "Zaktualizowano okładkę",
@@ -1044,6 +1065,7 @@
"ToastItemMarkedAsFinishedSuccess": "Pozycja oznaczona jako ukończona",
"ToastItemMarkedAsNotFinishedFailed": "Oznaczenie pozycji jako ukończonej nie powiodło się",
"ToastItemMarkedAsNotFinishedSuccess": "Pozycja oznaczona jako nieukończona",
"ToastItemUpdateSuccess": "Element zaktualizowany",
"ToastLibraryCreateFailed": "Nie udało się utworzyć biblioteki",
"ToastLibraryCreateSuccess": "Biblioteka \"{0}\" stworzona",
"ToastLibraryDeleteFailed": "Nie udało się usunąć biblioteki",
@@ -1052,6 +1074,10 @@
"ToastLibraryScanStarted": "Rozpoczęto skanowanie biblioteki",
"ToastLibraryUpdateSuccess": "Zaktualizowano \"{0}\" pozycji",
"ToastMatchAllAuthorsFailed": "Nie udało się dopasować wszystkich autorów",
"ToastMetadataFilesRemovedError": "Błąd podczas usuwania metadata.{0} plików",
"ToastMetadataFilesRemovedNoneFound": "Nie znaleziono metadata.{0} plików w bibliotece",
"ToastMetadataFilesRemovedNoneRemoved": "Nie usunięto żadnego metadata.{0} pliku",
"ToastMetadataFilesRemovedSuccess": "{0} metadata.{0} plików usunięto",
"ToastMustHaveAtLeastOnePath": "Musi mieć przynajmniej jedną ścieżkę",
"ToastNameEmailRequired": "Nazwa i email są wymagane",
"ToastNameRequired": "Imię jest wymagane",
@@ -1065,7 +1091,15 @@
"ToastNewUserUsernameError": "Wprowadź nazwę użytkownika",
"ToastNoNewEpisodesFound": "Nie znaleziono nowych odcinków",
"ToastNoRSSFeed": "Podcast nie posiada RSS Feed",
"ToastNoUpdatesNecessary": "Brak konieczności aktualizacji",
"ToastNotificationCreateFailed": "Nie udało się utworzyć powiadomienia",
"ToastNotificationDeleteFailed": "Nie udało się usunąć powiadomienia",
"ToastNotificationFailedMaximum": "Maks. ilość nieudanych prób musi być >= 0",
"ToastNotificationQueueMaximum": "Maksymalna liczba powiadomień w kolejce musi być >= 0",
"ToastNotificationSettingsUpdateSuccess": "Zaktualizowano ustawienia powiadomień",
"ToastNotificationTestTriggerFailed": "Nie udało się wywołać powiadomienia testowego",
"ToastNotificationTestTriggerSuccess": "Wyzwolono powiadomienie testowe",
"ToastNotificationUpdateSuccess": "Powiadomienie zaktualizowane",
"ToastPlaylistCreateFailed": "Nie udało się utworzyć playlisty",
"ToastPlaylistCreateSuccess": "Playlista utworzona",
"ToastPlaylistRemoveSuccess": "Playlista usunięta",
@@ -1073,8 +1107,17 @@
"ToastPodcastCreateFailed": "Nie udało się utworzyć podcastu",
"ToastPodcastCreateSuccess": "Podcast został pomyślnie utworzony",
"ToastPodcastEpisodeUpdated": "Zaktualizowano odcinki",
"ToastPodcastGetFeedFailed": "Nie udało się pobrać kanału podcastu",
"ToastPodcastNoEpisodesInFeed": "Nie znaleziono żadnych odcinków w kanale RSS",
"ToastPodcastNoRssFeed": "Podcast nie ma kanału RSS",
"ToastProgressIsNotBeingSynced": "Postęp nie jest synchronizowany, uruchom ponownie odtwarzanie",
"ToastProviderCreatedFailed": "Nie udało się dodać dostawcy",
"ToastProviderCreatedSuccess": "Dodano nowego dostawcę",
"ToastProviderNameAndUrlRequired": "Wymagane jest podanie nazwy i adresu URL",
"ToastProviderRemoveSuccess": "Dostawca usunięty",
"ToastRSSFeedCloseFailed": "Zamknięcie kanału RSS nie powiodło się",
"ToastRSSFeedCloseSuccess": "Zamknięcie kanału RSS powiodło się",
"ToastRemoveFailed": "Nie udało się usunąć",
"ToastRemoveItemFromCollectionFailed": "Nie udało się usunąć elementu z kolekcji",
"ToastRemoveItemFromCollectionSuccess": "Pozycja usunięta z kolekcji",
"ToastRemoveItemsWithIssuesFailed": "Nie udało się usunąć wadliwych elementów z biblioteki",
@@ -1096,16 +1139,25 @@
"ToastSessionDeleteFailed": "Nie udało się usunąć sesji",
"ToastSessionDeleteSuccess": "Sesja usunięta",
"ToastSleepTimerDone": "Słodkich snów... zZzzZz",
"ToastSlugMustChange": "Slug zawiera nieprawidłowe znaki",
"ToastSlugRequired": "Slug jest wymagany",
"ToastSocketConnected": "Nawiązano połączenie z serwerem",
"ToastSocketDisconnected": "Połączenie z serwerem zostało zamknięte",
"ToastSocketFailedToConnect": "Poączenie z serwerem nie powiodło się",
"ToastSortingPrefixesEmptyError": "Musi mieć co najmniej 1 prefiks sortowania",
"ToastSortingPrefixesUpdateSuccess": "Zaktualizowano prefiksy sortowania ({0} elementów)",
"ToastTitleRequired": "Tytuł jest wymagany",
"ToastUnknownError": "Nieznany błąd",
"ToastUnlinkOpenIdFailed": "Nie udało się odpiąć użytkownika z OpenID",
"ToastUnlinkOpenIdSuccess": "Użytkownik odpięty z OpenID",
"ToastUploaderFilepathExistsError": "Ścieżka \"{0}\" już istnieje na serwerze",
"ToastUploaderItemExistsInSubdirectoryError": "Element \"{0}\" używa podkatalogu ścieżki przesyłania.",
"ToastUserDeleteFailed": "Nie udało się usunąć użytkownika",
"ToastUserDeleteSuccess": "Użytkownik usunięty",
"ToastUserPasswordChangeSuccess": "Hasło zostało pomyślnie zmienione",
"ToastUserPasswordMismatch": "Hasła nie są zgodne",
"ToastUserPasswordMustChange": "Nowe hasło nie może być takie samo jak stare hasło",
"ToastUserRootRequireName": "Należy wprowadzić nazwę użytkownika root",
"TooltipAddChapters": "Dodaj rozdział(y)",
"TooltipAddOneSecond": "Dodaj sekundę",
"TooltipAdjustChapterStart": "Kliknij, aby skorygować czas początkowy",
+7 -3
View File
@@ -46,6 +46,7 @@
"ButtonLatest": "Mais Recentes",
"ButtonLibrary": "Biblioteca",
"ButtonLogout": "Logout",
"ButtonLogoutAllDevices": "Desconectar de todos os dispositivos",
"ButtonLookup": "Procurar",
"ButtonManageTracks": "Gerenciar Faixas",
"ButtonMapChapterTitles": "Designar Títulos de Capítulos",
@@ -194,6 +195,7 @@
"HeaderScheduleEpisodeDownloads": "Programar Download Automático de Episódios",
"HeaderScheduleLibraryScans": "Programar Verificação Automática da Biblioteca",
"HeaderSession": "Sessão",
"HeaderSessions": "Sessões",
"HeaderSetBackupSchedule": "Definir Programação de Backup",
"HeaderSettings": "Configurações",
"HeaderSettingsDisplay": "Exibição",
@@ -325,8 +327,8 @@
"LabelDurationComparisonLonger": "({0} maior)",
"LabelDurationComparisonShorter": "({0} menor)",
"LabelDurationFound": "Duração comprovada:",
"LabelEbook": "Ebook",
"LabelEbooks": "Ebooks",
"LabelEbook": "E-book",
"LabelEbooks": "E-books",
"LabelEdit": "Editar",
"LabelEmail": "Email",
"LabelEmailSettingsFromAddress": "Remetente",
@@ -415,6 +417,7 @@
"LabelIntervalEveryHour": "Toda hora",
"LabelIntervalEveryMinute": "A cada minuto",
"LabelInvert": "Inverter",
"LabelIpAddress": "Endereço IP",
"LabelItem": "Item",
"LabelJumpBackwardAmount": "Tempo de retrocesso",
"LabelJumpForwardAmount": "Tempo de avanço",
@@ -774,6 +777,7 @@
"MessageConfirmDeleteSession": "Tem certeza de que deseja apagar essa sessão?",
"MessageConfirmEmbedMetadataInAudioFiles": "Tem certeza de que deseja incorporar metadados em arquivos de áudio {0}?",
"MessageConfirmForceReScan": "Tem certeza de que deseja forçar a nova verificação?",
"MessageConfirmLogoutDevice": "Tem certeza de que deseja sair de \"{0}\"?",
"MessageConfirmMarkAllEpisodesFinished": "Tem certeza de que deseja marcar todos os episódios como concluídos?",
"MessageConfirmMarkAllEpisodesNotFinished": "Tem certeza de que deseja marcar todos os episódios como não concluídos?",
"MessageConfirmMarkItemFinished": "Tem certeza de que deseja marcar \"{0}\" como concluído?",
@@ -1109,7 +1113,7 @@
"ToastProgressIsNotBeingSynced": "O progresso não está sendo sincronizado, reinicie a reprodução",
"ToastProviderCreatedFailed": "Falha ao adicionar o provedor",
"ToastProviderCreatedSuccess": "Novo provedor adicionado",
"ToastProviderNameAndUrlRequired": "Nome e URL obrigatórios",
"ToastProviderNameAndUrlRequired": "Nome e Url obrigatórios",
"ToastProviderRemoveSuccess": "Fornecedor removido",
"ToastRSSFeedCloseFailed": "Falha ao fechar feed RSS",
"ToastRSSFeedCloseSuccess": "Feed RSS fechado",
+4
View File
@@ -46,6 +46,7 @@
"ButtonLatest": "Последнее",
"ButtonLibrary": "Библиотека",
"ButtonLogout": "Выход",
"ButtonLogoutAllDevices": "Выйти на всех устройствах",
"ButtonLookup": "Найти",
"ButtonManageTracks": "Управление треками",
"ButtonMapChapterTitles": "Найти названия глав",
@@ -194,6 +195,7 @@
"HeaderScheduleEpisodeDownloads": "Запланировать автоматическое скачивание выпусков",
"HeaderScheduleLibraryScans": "Планировщик автоматического сканирования библиотеки",
"HeaderSession": "Сеансы",
"HeaderSessions": "Сессии",
"HeaderSetBackupSchedule": "Установить планировщик бэкапов",
"HeaderSettings": "Настройки",
"HeaderSettingsDisplay": "Дисплей",
@@ -415,6 +417,7 @@
"LabelIntervalEveryHour": "Каждый час",
"LabelIntervalEveryMinute": "Каждую минуту",
"LabelInvert": "Инвертировать",
"LabelIpAddress": "IP-адрес",
"LabelItem": "Элемент",
"LabelJumpBackwardAmount": "Прыжок назад на величину",
"LabelJumpForwardAmount": "Прыжок вперед на величину",
@@ -774,6 +777,7 @@
"MessageConfirmDeleteSession": "Вы уверены, что хотите удалить этот сеанс?",
"MessageConfirmEmbedMetadataInAudioFiles": "Вы уверены, что хотите вставить метаданные в {0} аудиофайлов?",
"MessageConfirmForceReScan": "Вы уверены, что хотите принудительно выполнить повторное сканирование?",
"MessageConfirmLogoutDevice": "Вы уверены, что хотите выйти из «{0}»?",
"MessageConfirmMarkAllEpisodesFinished": "Вы уверены, что хотите отметить все выпуски как прослушанные?",
"MessageConfirmMarkAllEpisodesNotFinished": "Вы уверены, что хотите отметить все выпуски как непрослушанные?",
"MessageConfirmMarkItemFinished": "Вы уверены, что хотите отметить «{0}» как завершенную?",
+538 -534
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
{}
+261 -257
View File
File diff suppressed because it is too large. Load diff
+62 -8
View File
@@ -1,5 +1,6 @@
{
"ButtonAdd": "添加",
"ButtonAddApiKey": "新增API金鑰",
"ButtonAddChapters": "新增章節",
"ButtonAddDevice": "新增設備",
"ButtonAddLibrary": "新增庫",
@@ -10,6 +11,7 @@
"ButtonApplyChapters": "應用到章節",
"ButtonAuthors": "作者",
"ButtonBack": "返回",
"ButtonBatchEditPopulateFromExisting": "從現有資料帶入",
"ButtonBrowseForFolder": "瀏覽資料夾",
"ButtonCancel": "取消",
"ButtonCancelEncode": "取消編碼",
@@ -48,11 +50,12 @@
"ButtonNevermind": "沒關係",
"ButtonNext": "下個",
"ButtonNextChapter": "下個章節",
"ButtonOk": "確定",
"ButtonOk": "好",
"ButtonOpenFeed": "打開源",
"ButtonOpenManager": "打開管理器",
"ButtonPause": "暫停",
"ButtonPlay": "播放",
"ButtonPlayAll": "全部播放",
"ButtonPlaying": "正在播放",
"ButtonPlaylists": "播放列表",
"ButtonPrevious": "上一個",
@@ -64,6 +67,8 @@
"ButtonQuickMatch": "快速匹配",
"ButtonReScan": "重新掃描",
"ButtonRead": "讀取",
"ButtonReadLess": "顯示較少",
"ButtonReadMore": "顯示更多",
"ButtonRefresh": "重整",
"ButtonRemove": "移除",
"ButtonRemoveAll": "移除所有",
@@ -83,12 +88,15 @@
"ButtonSelectFolderPath": "選擇資料夾路徑",
"ButtonSeries": "系列",
"ButtonSetChaptersFromTracks": "將音軌設定為章節",
"ButtonShare": "分享",
"ButtonShiftTimes": "快速調整時間",
"ButtonShow": "顯示",
"ButtonStartM4BEncode": "開始 M4B 編碼",
"ButtonStartMetadataEmbed": "開始嵌入元數據",
"ButtonStats": "統計資料",
"ButtonSubmit": "提交",
"ButtonTest": "測試",
"ButtonUnlinkOpenId": "解除連結 OpenID",
"ButtonUpload": "上傳",
"ButtonUploadBackup": "上傳備份",
"ButtonUploadCover": "上傳封面",
@@ -145,6 +153,7 @@
"HeaderMetadataOrderOfPrecedence": "元數據優先級",
"HeaderMetadataToEmbed": "嵌入元數據",
"HeaderNewAccount": "新建帳號",
"HeaderNewApiKey": "新API權杖",
"HeaderNewLibrary": "新建媒體庫",
"HeaderNotifications": "通知",
"HeaderOpenIDConnectAuthentication": "OpenID 連接身份驗證",
@@ -164,6 +173,7 @@
"HeaderRemoveEpisodes": "移除 {0} 劇集",
"HeaderSavedMediaProgress": "保存媒體進度",
"HeaderSchedule": "計劃任務",
"HeaderScheduleEpisodeDownloads": "設定自動下載集數",
"HeaderScheduleLibraryScans": "自動掃描媒體庫",
"HeaderSession": "會話",
"HeaderSetBackupSchedule": "設定備份計劃任務",
@@ -182,6 +192,7 @@
"HeaderTableOfContents": "目錄",
"HeaderTools": "工具",
"HeaderUpdateAccount": "更新帳號",
"HeaderUpdateApiKey": "更新API權杖",
"HeaderUpdateAuthor": "更新作者",
"HeaderUpdateDetails": "更新詳情",
"HeaderUpdateLibrary": "更新媒體庫",
@@ -202,12 +213,14 @@
"LabelAddToPlaylist": "新增到播放列表",
"LabelAddToPlaylistBatch": "新增 {0} 個項目到播放列表",
"LabelAddedAt": "新增於",
"LabelAddedDate": "新增{0}",
"LabelAdminUsersOnly": "僅限管理員使用者",
"LabelAll": "全部",
"LabelAllUsers": "所有使用者",
"LabelAllUsersExcludingGuests": "除訪客外的所有使用者",
"LabelAllUsersIncludingGuests": "包括訪客的所有使用者",
"LabelAlreadyInYourLibrary": "已存在你的庫中",
"LabelApiToken": "API 權杖",
"LabelAppend": "附加",
"LabelAuthor": "作者",
"LabelAuthorFirstLast": "作者 (姓 名)",
@@ -229,8 +242,10 @@
"LabelBackupsNumberToKeep": "要保留的備份個數",
"LabelBackupsNumberToKeepHelp": "一次只能刪除一個備份, 因此如果你已經有超過此數量的備份, 則應手動刪除它們.",
"LabelBitrate": "位元率",
"LabelBonus": "附贈內容",
"LabelBooks": "圖書",
"LabelButtonText": "按鈕文本",
"LabelByAuthor": "由{0}",
"LabelChangePassword": "修改密碼",
"LabelChannels": "聲道",
"LabelChapterTitle": "章節標題",
@@ -255,6 +270,7 @@
"LabelCurrently": "當前:",
"LabelCustomCronExpression": "自定義計劃任務表達式:",
"LabelDatetime": "日期時間",
"LabelDays": "天",
"LabelDeleteFromFileSystemCheckbox": "從檔案系統刪除 (取消選中僅從資料庫中刪除)",
"LabelDescription": "描述",
"LabelDeselectAll": "全部取消選擇",
@@ -289,8 +305,10 @@
"LabelEpisode": "劇集",
"LabelEpisodeTitle": "劇集標題",
"LabelEpisodeType": "劇集類型",
"LabelEpisodes": "集數",
"LabelEpisodic": "單集式",
"LabelExample": "示例",
"LabelExplicit": "信息準確",
"LabelExplicit": "限制級",
"LabelFeedURL": "源鏈接",
"LabelFetchingMetadata": "正在獲取元數據",
"LabelFile": "文件",
@@ -302,12 +320,14 @@
"LabelFinished": "已聽完",
"LabelFolder": "資料夾",
"LabelFolders": "資料夾",
"LabelFontBold": "粗體",
"LabelFontBoldness": "字體粗細",
"LabelFontFamily": "字體系列",
"LabelFontFamily": "字體家族",
"LabelFontItalic": "斜體",
"LabelFontScale": "字體比例",
"LabelFontStrikethrough": "刪除線",
"LabelFormat": "編碼格式",
"LabelFull": "完整",
"LabelGenre": "流派",
"LabelGenres": "流派",
"LabelHardDeleteFile": "完全刪除檔案",
@@ -316,6 +336,7 @@
"LabelHighestPriority": "最高優先級",
"LabelHost": "主機",
"LabelHour": "小時",
"LabelHours": "小時",
"LabelIcon": "圖標",
"LabelImageURLFromTheWeb": "來自 Web 圖像的 URL",
"LabelInProgress": "正在聽",
@@ -334,6 +355,7 @@
"LabelItem": "項目",
"LabelLanguage": "語言",
"LabelLanguageDefaultServer": "預設伺服器語言",
"LabelLanguages": "語言",
"LabelLastBookAdded": "最後新增的書",
"LabelLastBookUpdated": "最後更新的書",
"LabelLastSeen": "上次查看時間",
@@ -347,6 +369,9 @@
"LabelLibrary": "媒體庫",
"LabelLibraryItem": "媒體庫項目",
"LabelLibraryName": "媒體庫名稱",
"LabelLibrarySortByProgress": "進度:最後更新",
"LabelLibrarySortByProgressFinished": "進度:已完成",
"LabelLibrarySortByProgressStarted": "進度:已開始",
"LabelLimit": "限制",
"LabelLineSpacing": "行間距",
"LabelListenAgain": "再次收聽",
@@ -364,7 +389,8 @@
"LabelMetadataOrderOfPrecedenceDescription": "較高優先級的元數據源將覆蓋較低優先級的元數據源",
"LabelMetadataProvider": "元數據提供者",
"LabelMinute": "分鐘",
"LabelMissing": "丟失",
"LabelMinutes": "分鐘",
"LabelMissing": "缺失",
"LabelMobileRedirectURIs": "允許移動應用重定向 URI",
"LabelMobileRedirectURIsDescription": "這是移動應用程序的有效重定向 URI 白名單. 預設值為 <code>audiobookshelf://oauth</code>,您可以刪除它或加入其他 URI 以進行第三方應用集成. 使用星號 (<code>*</code>) 作為唯一條目允許任何 URI.",
"LabelMore": "更多",
@@ -392,11 +418,12 @@
"LabelNotificationsMaxQueueSize": "通知事件的最大佇列大小",
"LabelNotificationsMaxQueueSizeHelp": "通知事件被限制為每秒觸發 1 個. 如果佇列處於最大大小, 則將忽略事件. 這可以防止通知垃圾郵件.",
"LabelNumberOfBooks": "圖書數量",
"LabelNumberOfEpisodes": "# 集",
"LabelNumberOfEpisodes": "集數",
"LabelOpenRSSFeed": "打開 RSS 源",
"LabelOverwrite": "覆蓋",
"LabelPassword": "密碼",
"LabelPath": "路徑",
"LabelPermanent": "永久",
"LabelPermissionsAccessAllLibraries": "可以存取所有媒體庫",
"LabelPermissionsAccessAllTags": "可以存取所有標籤",
"LabelPermissionsAccessExplicitContent": "可以存取顯式內容",
@@ -420,10 +447,12 @@
"LabelProvider": "供應商",
"LabelPubDate": "出版日期",
"LabelPublishYear": "發布年份",
"LabelPublishedDate": "已發布{0}",
"LabelPublisher": "出版商",
"LabelPublishers": "出版商",
"LabelRSSFeedCustomOwnerEmail": "自定義所有者電子郵件",
"LabelRSSFeedCustomOwnerName": "自定義所有者名稱",
"LabelRSSFeedOpen": "打開 RSS 源",
"LabelRSSFeedOpen": "開啟RSS訂閱",
"LabelRSSFeedPreventIndexing": "防止索引",
"LabelRSSFeedSlug": "RSS 源段",
"LabelRSSFeedURL": "RSS 源 URL",
@@ -447,6 +476,7 @@
"LabelSelectEpisodesShowing": "選擇正在播放的 {0} 劇集",
"LabelSendEbookToDevice": "發送電子書到...",
"LabelSequence": "序列",
"LabelSerial": "連續劇式",
"LabelSeries": "系列",
"LabelSeriesName": "系列名稱",
"LabelSeriesProgress": "系列進度",
@@ -482,9 +512,12 @@
"LabelSettingsStoreMetadataWithItem": "存儲項目元數據",
"LabelSettingsStoreMetadataWithItemHelp": "預設情況下元數據檔案存儲在/metadata/items資料夾中, 啟用此設定將存儲元數據在你媒體項目資料夾中",
"LabelSettingsTimeFormat": "時間格式",
"LabelShare": "分享",
"LabelShowAll": "全部顯示",
"LabelShowSeconds": "顯示秒數",
"LabelSize": "檔案大小",
"LabelSleepTimer": "睡眠定時",
"LabelSlug": "網址代稱",
"LabelStart": "開始",
"LabelStartTime": "開始時間",
"LabelStarted": "開始於",
@@ -512,6 +545,7 @@
"LabelTagsNotAccessibleToUser": "使用者無法存取標籤",
"LabelTasks": "正在運行的任務",
"LabelTextEditorBulletedList": "項目符號列表",
"LabelTextEditorLink": "連結",
"LabelTextEditorNumberedList": "編號列表",
"LabelTextEditorUnlink": "取消連結",
"LabelTheme": "主題",
@@ -539,6 +573,7 @@
"LabelTracksSingleTrack": "單軌",
"LabelType": "類型",
"LabelUnabridged": "未刪節",
"LabelUndo": "復原",
"LabelUnknown": "未知",
"LabelUpdateCover": "更新封面",
"LabelUpdateCoverHelp": "找到匹配項時允許覆蓋所選書籍存在的封面",
@@ -546,6 +581,7 @@
"LabelUpdateDetailsHelp": "找到匹配項時允許覆蓋所選書籍存在的詳細信息",
"LabelUpdatedAt": "更新時間",
"LabelUploaderDragAndDrop": "拖放檔案或資料夾",
"LabelUploaderDragAndDropFilesOnly": "拖曳檔案至此",
"LabelUploaderDropFiles": "刪除檔案",
"LabelUploaderItemFetchMetadataHelp": "自動獲取標題, 作者和系列",
"LabelUseChapterTrack": "使用章節音軌",
@@ -635,7 +671,7 @@
"MessageNoBackups": "沒有備份",
"MessageNoBookmarks": "沒有書籤",
"MessageNoChapters": "沒有章節",
"MessageNoCollections": "沒有收藏",
"MessageNoCollections": "沒有合集",
"MessageNoCoversFound": "沒有找到封面",
"MessageNoDescription": "沒有描述",
"MessageNoDownloadsInProgress": "當前沒有正在進行的下載",
@@ -665,6 +701,7 @@
"MessagePlayChapter": "開始章節播放",
"MessagePlaylistCreateFromCollection": "從收藏中創建播放列表",
"MessagePodcastHasNoRSSFeedForMatching": "播客沒有可用於匹配 RSS 源的 url",
"MessagePodcastSearchField": "輸入搜尋關鍵字或 RSS 訂閱源網址",
"MessageQuickMatchDescription": "使用來自 '{0}' 的第一個匹配結果填充空白詳細信息和封面. 除非啟用 '首選匹配元數據' 伺服器設定, 否則不會覆蓋詳細信息.",
"MessageRemoveChapter": "移除章節",
"MessageRemoveEpisodes": "移除 {0} 劇集",
@@ -678,7 +715,11 @@
"MessageSelected": "{0} 被選取",
"MessageServerCouldNotBeReached": "無法連接伺服器",
"MessageSetChaptersFromTracksDescription": "把每個音頻檔設定為章節並將章節標題設定為音頻檔名",
"MessageShareExpirationWillBe": "有效期限為 <strong>{0}</strong>",
"MessageStartPlaybackAtTime": "開始播放 \"{0}\" 在 {1}?",
"MessageTaskEmbeddingMetadata": "正在嵌入詮釋資料",
"MessageTaskEncodingM4b": "正在編碼 M4B",
"MessageTaskFailed": "失敗",
"MessageThinking": "正在查找...",
"MessageUploaderItemFailed": "上傳失敗",
"MessageUploaderItemSuccess": "上傳成功!",
@@ -701,8 +742,11 @@
"PlaceholderNewPlaylist": "輸入播放列表名稱",
"PlaceholderSearch": "查找..",
"PlaceholderSearchEpisode": "搜尋劇集..",
"StatsSessions": "收聽時段",
"ToastAccountUpdateSuccess": "帳號已更新",
"ToastAsinRequired": "需要 ASIN",
"ToastAuthorImageRemoveSuccess": "作者圖像已刪除",
"ToastAuthorSearchNotFound": "找不到作者",
"ToastAuthorUpdateMerged": "作者已合併",
"ToastAuthorUpdateSuccess": "作者已更新",
"ToastAuthorUpdateSuccessNoImageFound": "作者已更新 (未找到圖像)",
@@ -713,6 +757,8 @@
"ToastBackupRestoreFailed": "備份還原失敗",
"ToastBackupUploadFailed": "上傳備份失敗",
"ToastBackupUploadSuccess": "備份已上傳",
"ToastBatchDeleteFailed": "批次刪除失敗",
"ToastBatchDeleteSuccess": "批次刪除成功",
"ToastBatchUpdateFailed": "批量更新失敗",
"ToastBatchUpdateSuccess": "批量更新成功",
"ToastBookmarkCreateFailed": "創建書簽失敗",
@@ -720,8 +766,12 @@
"ToastBookmarkRemoveSuccess": "書籤已刪除",
"ToastChaptersHaveErrors": "章節有錯誤",
"ToastChaptersMustHaveTitles": "章節必須有標題",
"ToastChaptersUpdated": "章節已更新",
"ToastCollectionRemoveSuccess": "收藏夾已刪除",
"ToastCollectionUpdateSuccess": "收藏夾已更新",
"ToastCoverUpdateFailed": "封面更新失敗",
"ToastFailedToMatch": "比對失敗",
"ToastFailedToUpdate": "更新失敗",
"ToastItemCoverUpdateSuccess": "項目封面已更新",
"ToastItemDetailsUpdateSuccess": "項目詳細信息已更新",
"ToastItemMarkedAsFinishedFailed": "標記為聽完失敗",
@@ -735,6 +785,7 @@
"ToastLibraryScanFailedToStart": "無法啟動掃描",
"ToastLibraryScanStarted": "媒體庫掃描已啟動",
"ToastLibraryUpdateSuccess": "媒體庫 \"{0}\" 已更新",
"ToastNewEpisodesFound": "找到{0}個新單集",
"ToastPlaylistCreateFailed": "創建播放列表失敗",
"ToastPlaylistCreateSuccess": "已成功創建播放列表",
"ToastPlaylistRemoveSuccess": "播放列表已刪除",
@@ -745,6 +796,7 @@
"ToastRSSFeedCloseSuccess": "RSS 源已關閉",
"ToastRemoveItemFromCollectionFailed": "從收藏中刪除項目失敗",
"ToastRemoveItemFromCollectionSuccess": "項目已從收藏中刪除",
"ToastRenameFailed": "重新命名失敗",
"ToastSendEbookToDeviceFailed": "發送電子書到設備失敗",
"ToastSendEbookToDeviceSuccess": "電子書已經發送到設備 \"{0}\"",
"ToastSeriesUpdateFailed": "更新系列失敗",
@@ -754,6 +806,8 @@
"ToastSocketConnected": "網路已連接",
"ToastSocketDisconnected": "網路已斷開",
"ToastSocketFailedToConnect": "網路連接失敗",
"ToastUnlinkOpenIdSuccess": "已解除使用者與 OpenID 的連結",
"ToastUserDeleteFailed": "刪除使用者失敗",
"ToastUserDeleteSuccess": "使用者已刪除"
"ToastUserDeleteSuccess": "使用者已刪除",
"ToastUserPasswordMismatch": "密碼不符"
}
+59
View File
@@ -0,0 +1,59 @@
const nodeGlobals = {
AbortController: 'readonly',
Buffer: 'readonly',
TextDecoder: 'readonly',
URL: 'readonly',
URLSearchParams: 'readonly',
__dirname: 'readonly',
clearInterval: 'readonly',
clearTimeout: 'readonly',
console: 'readonly',
process: 'readonly',
setInterval: 'readonly',
setTimeout: 'readonly',
structuredClone: 'readonly'
}
const mochaGlobals = {
after: 'readonly',
afterEach: 'readonly',
before: 'readonly',
beforeEach: 'readonly',
describe: 'readonly',
it: 'readonly'
}
const rules = {
'no-undef': 'error',
'no-redeclare': 'error',
'no-dupe-keys': 'error',
'no-dupe-class-members': 'error',
'no-global-assign': 'error'
}
export default [
{
ignores: ['**/node_modules/**', 'client/**', 'dist/**', 'dist-server/**', 'coverage/**', 'server/libs/**']
},
{
files: ['index.js', 'prod.js', 'dev.js', 'server/**/*.js'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'commonjs',
globals: nodeGlobals
},
rules
},
{
files: ['test/**/*.js'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'commonjs',
globals: {
...nodeGlobals,
...mochaGlobals
}
},
rules
}
]
+4 -1
View File
@@ -16,7 +16,7 @@ const Path = require('path')
process.env.NODE_ENV = options.dev ? 'development' : process.env.NODE_ENV || 'production'
const server = require('./server/Server')
global.appRoot = __dirname
global.appRoot = Path.basename(__dirname) === 'dist-server' ? Path.resolve(__dirname, '..') : __dirname
const isDev = process.env.NODE_ENV !== 'production'
if (isDev || options['prod-with-dev-env']) {
@@ -31,6 +31,9 @@ if (isDev || options['prod-with-dev-env']) {
if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1'
if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath
if (devEnv.ReactClientPath) process.env.REACT_CLIENT_PATH = devEnv.ReactClientPath
if (devEnv.AllowedDevOrigins) {
process.env.ALLOWED_DEV_ORIGINS = Array.isArray(devEnv.AllowedDevOrigins) ? devEnv.AllowedDevOrigins.join(',') : String(devEnv.AllowedDevOrigins)
}
process.env.SOURCE = 'local'
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath ?? '/audiobookshelf'
}
+944 -22
View File
File diff suppressed because it is too large. Load diff
+23 -15
View File
@@ -1,35 +1,37 @@
{
"name": "audiobookshelf",
"version": "2.34.0",
"version": "2.37.2",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",
"main": "dist-server/index.js",
"scripts": {
"dev": "nodemon --watch server index.js -- --dev",
"start": "node index.js",
"start-dev": "node index.js --prod-with-dev-env",
"build:server": "tsc -p tsconfig.server.json",
"dev": "nodemon --watch server --watch index.js --watch test --watch dev.js --ext js,ts --exec \"npm run build:server && node dist-server/index.js\" -- --dev",
"start": "node dist-server/index.js",
"start-dev": "node dist-server/index.js --prod-with-dev-env",
"client": "cd client && npm ci && npm run generate",
"prod": "npm run client && npm ci && node index.js",
"build-win": "npm run client && pkg -t node20-win-x64 -o ./dist/win/audiobookshelf -C GZip .",
"build-win-no-compress": "npm run client && pkg -t node20-win-x64 -o ./dist/win/audiobookshelf .",
"prod": "npm run client && npm ci && npm run build:server && npm run start",
"build-win": "npm run client && npm run build:server && pkg --config package.json -t node24-win-x64 -o ./dist/win/audiobookshelf -C GZip dist-server/index.js",
"build-win-no-compress": "npm run client && npm run build:server && pkg --config package.json -t node24-win-x64 -o ./dist/win/audiobookshelf dist-server/index.js",
"build-linux": "build/linuxpackager",
"docker": "docker buildx build --platform linux/amd64,linux/arm64 --push . -t advplyr/audiobookshelf",
"docker-amd64-local": "docker buildx build --platform linux/amd64 --load . -t advplyr/audiobookshelf-amd64-local",
"docker-arm64-local": "docker buildx build --platform linux/arm64 --load . -t advplyr/audiobookshelf-arm64-local",
"deploy-linux": "node deploy/linux",
"test": "mocha",
"coverage": "nyc mocha"
"test": "npm run build:server && mocha dist-server/test",
"coverage": "npm run build:server && nyc mocha dist-server/test",
"lint": "eslint ."
},
"bin": "index.js",
"bin": "dist-server/index.js",
"pkg": {
"assets": [
"client/dist/**/*",
"node_modules/sqlite3/lib/binding/**/*.node",
"server/migrations/*.js"
"dist-server/server/migrations/*.js"
],
"scripts": [
"index.js",
"server/**/*.js"
"dist-server/index.js",
"dist-server/server/**/*.js"
]
},
"mocha": {
@@ -60,10 +62,16 @@
"xml2js": "^0.5.0"
},
"devDependencies": {
"@types/node": "^24.13.6",
"chai": "^4.3.10",
"eslint": "^10.11.0",
"mocha": "^10.2.0",
"nodemon": "^2.0.20",
"nyc": "^15.1.0",
"sinon": "^17.0.1"
"sinon": "^17.0.1",
"typescript": "^5.7.3"
},
"allowScripts": {
"sqlite3@5.1.7": true
}
}
+18 -276
View File
@@ -6,12 +6,14 @@
<br />
<a href="https://audiobookshelf.org/docs">Documentation</a>
·
<a href="https://audiobookshelf.org/guides">User Guides</a>
·
<a href="https://audiobookshelf.org/support">Support</a>
·
<a href="https://audiobooks.dev/">Demo</a>
</p>
</div>
## ⚠️ Frontend pull requests are not being reviewed or merged for the existing Vue frontend. The frontend is currently being rewritten and migrated to React and should be available soon.
# About
Audiobookshelf is a self-hosted audiobook and podcast server.
@@ -57,27 +59,23 @@ Try it out on the [Google Play Store](https://play.google.com/store/apps/details
Using Test Flight: https://testflight.apple.com/join/wiic7QIW **_(beta is full)_**
### Build your own tools & clients
Check out the [API documentation](https://api.audiobookshelf.org/)
<br />
<img alt="Library Screenshot" src="https://github.com/advplyr/audiobookshelf/raw/master/images/DemoLibrary.png" />
<br />
# Organizing your audiobooks
# Organizing your media
#### Directory structure and folder names are important to Audiobookshelf!
See [documentation](https://audiobookshelf.org/docs#book-directory-structure) for supported directory structure, folder naming conventions, and audio file metadata usage.
See [library docs](https://audiobookshelf.org/docs/category/libraries) for supported directory structures, folder naming conventions, and audio file metadata usage.
<br />
# Installation
See [install docs](https://www.audiobookshelf.org/docs)
See [install docs](https://audiobookshelf.org/docs/category/installation)
<br />
@@ -87,276 +85,24 @@ See [install docs](https://www.audiobookshelf.org/docs)
#### Note: Using a subfolder is supported with no additional changes but the path must be `/audiobookshelf` (this is not changeable). See [discussion](https://github.com/advplyr/audiobookshelf/discussions/3535)
### NGINX Proxy Manager
See [reverse proxy docs](https://audiobookshelf.org/docs/category/reverse-proxy)
Toggle websockets support.
<img alt="NGINX Web socket" src="https://user-images.githubusercontent.com/67830747/153679106-b2a7f5b9-0702-48c6-9740-b26b401986e9.png" />
### NGINX Reverse Proxy
Add this to the site config file on your nginx server after you have changed the relevant parts in the <> brackets, and inserted your certificate paths.
```bash
server {
listen 443 ssl;
server_name <sub>.<domain>.<tld>;
access_log /var/log/nginx/audiobookshelf.access.log;
error_log /var/log/nginx/audiobookshelf.error.log;
ssl_certificate /path/to/certificate;
ssl_certificate_key /path/to/key;
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_http_version 1.1;
proxy_pass http://<URL_to_forward_to>;
proxy_redirect http:// https://;
# Prevent 413 Request Entity Too Large error
# by increasing the maximum allowed size of the client request body
# For example, set it to 10 GiB
client_max_body_size 10240M;
}
}
```
### Apache Reverse Proxy
Add this to the site config file on your Apache server after you have changed the relevant parts in the <> brackets, and inserted your certificate paths.
For this to work you must enable at least the following mods using `a2enmod`:
- `ssl`
- `proxy`
- `proxy_http`
- `proxy_balancer`
- `proxy_wstunnel`
- `rewrite`
```bash
<IfModule mod_ssl.c>
<VirtualHost *:443>
ServerName <sub>.<domain>.<tld>
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
ProxyPreserveHost On
ProxyPass / http://localhost:<audiobookshelf_port>/
RewriteEngine on
RewriteCond %{HTTP:Upgrade} websocket [NC]
RewriteCond %{HTTP:Connection} upgrade [NC]
RewriteRule ^/?(.*) "ws://localhost:<audiobookshelf_port>/$1" [P,L]
# unless you're doing something special this should be generated by a
# tool like certbot by let's encrypt
SSLCertificateFile /path/to/cert/file
SSLCertificateKeyFile /path/to/key/file
</VirtualHost>
</IfModule>
```
If using Apache >= 2.4.47 you can use the following, without having to use any of the `RewriteEngine`, `RewriteCond`, or `RewriteRule` directives. For example:
```xml
<Location /audiobookshelf>
ProxyPreserveHost on
ProxyPass http://localhost:<audiobookshelf_port>/audiobookshelf upgrade=websocket
ProxyPassReverse http://localhost:<audiobookshelf_port>/audiobookshelf
</Location>
```
Some SSL certificates like those signed by Let's Encrypt require ACME validation. To allow Let's Encrypt to write and confirm the ACME challenge, edit your VirtualHost definition to prevent proxying traffic that queries `/.well-known` and instead serve that directly:
```bash
<VirtualHost *:443>
# ...
# create the directory structure /.well-known/acme-challenges
# within DocumentRoot and give the HTTP user recursive write
# access to it.
DocumentRoot /path/to/local/directory
ProxyPreserveHost On
ProxyPass /.well-known !
ProxyPass / http://localhost:<audiobookshelf_port>/
# ...
</VirtualHost>
```
### SWAG Reverse Proxy
[See LinuxServer.io config sample](https://github.com/linuxserver/reverse-proxy-confs/blob/master/audiobookshelf.subdomain.conf.sample)
### Synology NAS Reverse Proxy Setup (DSM 7+/Quickconnect)
1. **Open Control Panel**
- Navigate to `Login Portal > Advanced`.
2. **General Tab**
- Click `Reverse Proxy` > `Create`.
| Setting | Value |
| ------------------ | -------------- |
| Reverse Proxy Name | audiobookshelf |
3. **Source Configuration**
| Setting | Value |
| ---------------------- | ---------------------------------------- |
| Protocol | HTTPS |
| Hostname | `<sub>.<quickconnectdomain>.synology.me` |
| Port | 443 |
| Access Control Profile | Leave as is |
- Example Hostname: `audiobookshelf.mydomain.synology.me`
4. **Destination Configuration**
| Setting | Value |
| -------- | ----------- |
| Protocol | HTTP |
| Hostname | Your NAS IP |
| Port | 13378 |
5. **Custom Header Tab**
- Go to `Create > Websocket`.
- Configure Headers (leave as is):
| Header Name | Value |
| ----------- | --------------------- |
| Upgrade | `$http_upgrade` |
| Connection | `$connection_upgrade` |
6. **Advanced Settings Tab**
- Leave as is.
### [Traefik Reverse Proxy](https://doc.traefik.io/traefik/)
Middleware relating to CORS will cause the app to report Unknown Error when logging in. To prevent this don't apply any of the following headers to the router for this site:
<ul>
<li>accessControlAllowMethods</li>
<li>accessControlAllowOriginList</li>
<li>accessControlMaxAge</li>
</ul>
From [@Dondochaka](https://discord.com/channels/942908292873723984/942914154254176257/945074590374318170) and [@BeastleeUK](https://discord.com/channels/942908292873723984/942914154254176257/970366039294611506) <br />
### Example Caddyfile - [Caddy Reverse Proxy](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy)
```
subdomain.domain.com {
encode gzip zstd
reverse_proxy <LOCAL_IP>:<PORT>
}
```
### HAProxy
Below is a generic HAProxy config, using `audiobookshelf.YOUR_DOMAIN.COM`.
To use `http2`, `ssl` is needed.
```make
global
# ... (your global settings go here)
defaults
mode http
# ... (your default settings go here)
frontend my_frontend
# Bind to port 443, enable SSL, and specify the certificate list file
bind :443 name :443 ssl crt-list /path/to/cert.crt_list alpn h2,http/1.1
mode http
# Define an ACL for subdomains starting with "audiobookshelf"
acl is_audiobookshelf hdr_beg(host) -i audiobookshelf
# Use the ACL to route traffic to audiobookshelf_backend if the condition is met,
# otherwise, use the default_backend
use_backend audiobookshelf_backend if is_audiobookshelf
default_backend default_backend
backend audiobookshelf_backend
mode http
# ... (backend settings for audiobookshelf go here)
# Define the server for the audiobookshelf backend
server audiobookshelf_server 127.0.0.99:13378
backend default_backend
mode http
# ... (default backend settings go here)
# Define the server for the default backend
server default_server 127.0.0.123:8081
```
### pfSense and HAProxy
For pfSense the inputs are graphical, and `Health checking` is enabled.
#### Frontend, Default backend, access control lists and actions
##### Access Control lists
| Name | Expression | CS | Not | Value |
| :------------: | :---------------: | :-: | :-: | :-------------: |
| audiobookshelf | Host starts with: | | | audiobookshelf. |
##### Actions
The `condition acl names` needs to match the name above `audiobookshelf`.
| Action | Parameters | Condition acl names |
| :-----------: | :------------: | :-----------------: |
| `Use Backend` | audiobookshelf | audiobookshelf |
#### Backend
The `Name` needs to match the `Parameters` above `audiobookshelf`.
| Name | audiobookshelf |
| ---- | -------------- |
##### Server list:
| Name | Expression | CS | Not | Value |
| :------------: | :---------------: | :-: | :-: | :-------------: |
| audiobookshelf | Host starts with: | | | audiobookshelf. |
##### Health checking:
Health checking is enabled by default. `Http check method` of `OPTIONS` is not supported on Audiobookshelf. If Health check fails, data will not be forwared. Need to do one of following:
- To disable: Change `Health check method` to `none`.
- To make Health checking function: Change `Http check method` to `HEAD` or `GET`.
# Run from source
<br />
# Contributing
This application is built using [NodeJs](https://nodejs.org/).
See [contributing docs](https://audiobookshelf.org/docs/contributing/general/)
### Localization
Thank you to [Weblate](https://hosted.weblate.org/engage/audiobookshelf/) for hosting our localization infrastructure pro-bono. If you want to see Audiobookshelf in your language, please help us localize. Additional information on helping with the translations [here](https://www.audiobookshelf.org/faq#how-do-i-help-with-translations). <a href="https://hosted.weblate.org/engage/audiobookshelf/"> <img src="https://hosted.weblate.org/widget/audiobookshelf/abs-web-client/multi-auto.svg" alt="Translation status" /> </a>
<br />
# Run from source
This application is built using [NodeJs](https://nodejs.org/).
### Dev Container Setup
The easiest way to begin developing this project is to use a dev container. An introduction to dev containers in VSCode can be found [here](https://code.visualstudio.com/docs/devcontainers/containers).
@@ -419,7 +165,7 @@ You are now ready to start development!
### Manual Environment Setup
If you don't want to use the dev container, you can still develop this project. First, you will need to install [NodeJs](https://nodejs.org/) (version 20) and [FFmpeg](https://ffmpeg.org/).
If you don't want to use the dev container, you can still develop this project. First, you will need to install [NodeJs](https://nodejs.org/) (version 24) and [FFmpeg](https://ffmpeg.org/).
Next you will need to create a `dev.js` file in the project's root directory. This contains configuration information and paths unique to your development environment. You can find an example of this file in `.devcontainer/dev.js`.
@@ -437,7 +183,7 @@ cd ..
After setting up your development environment, either using the dev container or using your own custom environment, the following commands will help you run the server and client.
To run the server, you can use the command `npm run dev`. This will use the client that was built when you ran `npm run generate` in the client directory or when you started the dev container. If you make changes to the server, you will need to restart the server. If you make changes to the client, you will need to run the command `(cd client; npm run generate)` and then restart the server. By default the client runs at `localhost:3333`, though the port can be configured in `dev.js`.
To run the server, you can use the command `npm run dev`. This will compile the server and use the client that was built when you ran `npm run generate` in the client directory or when you started the dev container. Server changes are compiled and restarted automatically. If you make changes to the client, you will need to run the command `(cd client; npm run generate)` and then restart the server. By default the client runs at `localhost:3333`, though the port can be configured in `dev.js`.
You can also build a version of the client that supports live reloading. To do this, start the server, then run the command `(cd client; npm run dev)`. This will run a separate instance of the client at `localhost:3000` that will be automatically updated as you make changes to the client.
@@ -446,7 +192,3 @@ If you are using VSCode, this project includes a couple of pre-defined targets t
- `Debug server`—Run the server.
- `Debug client (nuxt)`—Run the client with live reload.
- `Debug server and client (nuxt)`—Runs both the preceding two debug targets.
# How to Support
[See the incomplete "How to Support" page](https://www.audiobookshelf.org/support)
+22 -9
View File
@@ -81,7 +81,7 @@ class Auth {
* @param {import('./models/User')} user
* @param {Request} req
* @param {Response} res
* @returns {Promise<string>} accessToken only if user is current user and refresh token is valid
* @returns {Promise<{ accessToken:string, refreshToken:string }|null>} new tokens for the current session if kept alive
*/
async invalidateJwtSessionsForUser(user, req, res) {
return this.tokenManager.invalidateJwtSessionsForUser(user, req, res)
@@ -216,14 +216,15 @@ class Auth {
* @returns {Object|null} - Returns error object if validation fails, null if successful
*/
paramsToCookies(req, res, authMethod = 'local') {
const TWO_MINUTES = 120000 // 2 minutes in milliseconds
// Cookie max age in milliseconds
const TEN_MINUTES = 10 * 60 * 1000
const callback = req.query.redirect_uri || req.query.callback
// Additional handling for non-API based authMethod
if (!this.isAuthMethodAPIBased(authMethod)) {
// Store 'auth_state' if present in the request
if (req.query.state) {
res.cookie('auth_state', req.query.state, { maxAge: TWO_MINUTES, httpOnly: true })
res.cookie('auth_state', req.query.state, { maxAge: TEN_MINUTES, httpOnly: true })
}
// Validate and store the callback URL
@@ -239,7 +240,7 @@ class Auth {
return { error: 'Invalid callback URL - must be same-origin' }
}
res.cookie('auth_cb', callback, { maxAge: TWO_MINUTES, httpOnly: true })
res.cookie('auth_cb', callback, { maxAge: TEN_MINUTES, httpOnly: true })
}
// Store the authentication method for long
@@ -273,7 +274,8 @@ class Auth {
// TODO: Temporarily continue sending the old token as setToken
res.redirect(302, `${req.cookies.auth_cb}?setToken=${userResponse.user.token}&accessToken=${userResponse.user.accessToken}${stateQuery}`)
} else {
res.status(400).send('No callback or already expired')
Logger.error('[Auth] No callback or already expired')
res.redirect(`/login?error=${encodeURIComponent('No callback or already expired')}&autoLaunch=0`)
}
}
}
@@ -385,7 +387,13 @@ class Auth {
const sessionKey = this.oidcAuthStrategy.getStrategy()._key
if (!req.session[sessionKey]) {
return res.status(400).send('No session')
// Mobile clients send code_verifier and expect a status, not the web login page
const isMobile = req.cookies.auth_method === 'openid-mobile' || !!req.query.code_verifier
Logger.error('[Auth] /auth/openid/callback route: No session')
if (isMobile) {
return res.status(400).send('No session')
}
return res.redirect(`/login?error=${encodeURIComponent('No sign-in session')}&autoLaunch=0`)
}
// If the client sends us a code_verifier, we will tell passport to use this to send this in the token request
@@ -471,18 +479,23 @@ class Auth {
res.json(openIdIssuerConfig)
})
// Logout route
/**
* Logout route
* Use ?allDevices=1 to destroy every session for this user instead of just the current one
*/
router.post('/logout', async (req, res) => {
// Refresh token be alternatively be sent in the header
const refreshToken = req.cookies.refresh_token || req.headers['x-refresh-token']
const allDevices = req.query.allDevices === '1'
// Clear refresh token cookie
res.clearCookie('refresh_token', {
path: '/'
})
// Invalidate the session in database using refresh token
if (refreshToken) {
if (allDevices) {
await this.tokenManager.invalidateAllSessionsForRefreshToken(refreshToken)
} else if (refreshToken) {
await this.tokenManager.invalidateRefreshToken(refreshToken)
} else {
Logger.info(`[Auth] logout: No refresh token on request`)
+23 -18
View File
@@ -102,11 +102,6 @@ class Database {
return this.models.libraryItem
}
/** @type {typeof import('./models/PodcastEpisode')} */
get podcastEpisodeModel() {
return this.models.podcastEpisode
}
/** @type {typeof import('./models/MediaProgress')} */
get mediaProgressModel() {
return this.models.mediaProgress
@@ -425,9 +420,21 @@ class Database {
*/
async createRootUser(username, pash, auth) {
if (!this.sequelize) return false
await this.userModel.createRootUser(username, pash, auth)
this.hasRootUser = true
return true
const transaction = await this.sequelize.transaction()
try {
const rootCount = await this.userModel.count({ where: { type: 'root' }, transaction })
if (rootCount > 0) {
await transaction.rollback()
return false
}
await this.userModel.createRootUser(username, pash, auth, transaction)
await transaction.commit()
this.hasRootUser = true
return true
} catch (error) {
await transaction.rollback()
throw error
}
}
updateServerSettings() {
@@ -514,19 +521,17 @@ class Database {
})
}
replaceNarratorInFilterData(oldNarrator, newNarrator) {
for (const libraryId in this.libraryFilterData) {
const indexOf = this.libraryFilterData[libraryId].narrators.findIndex((n) => n === oldNarrator)
if (indexOf >= 0) {
this.libraryFilterData[libraryId].narrators.splice(indexOf, 1, newNarrator)
}
replaceNarratorInFilterData(libraryId, oldNarrator, newNarrator) {
if (!this.libraryFilterData[libraryId]) return
const indexOf = this.libraryFilterData[libraryId].narrators.findIndex((n) => n === oldNarrator)
if (indexOf >= 0) {
this.libraryFilterData[libraryId].narrators.splice(indexOf, 1, newNarrator)
}
}
removeNarratorFromFilterData(narrator) {
for (const libraryId in this.libraryFilterData) {
this.libraryFilterData[libraryId].narrators = this.libraryFilterData[libraryId].narrators.filter((n) => n !== narrator)
}
removeNarratorFromFilterData(libraryId, narrator) {
if (!this.libraryFilterData[libraryId]) return
this.libraryFilterData[libraryId].narrators = this.libraryFilterData[libraryId].narrators.filter((n) => n !== narrator)
}
addNarratorsToFilterData(libraryId, narrators) {
+64 -6
View File
@@ -14,6 +14,7 @@ const { version } = require('../package.json')
const is = require('./libs/requestIp/isJs')
const fileUtils = require('./utils/fileUtils')
const { toNumber } = require('./utils/index')
const { getRequestOrigin } = require('./utils/requestUtils')
const Logger = require('./Logger')
const Auth = require('./Auth')
@@ -45,6 +46,34 @@ const passport = require('passport')
const expressSession = require('express-session')
const MemoryStore = require('./libs/memorystore')
/**
* Normalize to '' (root) or '/segment' with no trailing slash.
* Matches audiobookshelf-client-react `src/lib/basePath.ts`.
* @param {string|null|undefined} basePath
* @returns {string}
*/
function normalizeBasePath(basePath) {
if (!basePath) return ''
const trimmed = basePath.trim()
if (!trimmed || trimmed === '/') return ''
const withLeadingSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
return withLeadingSlash.endsWith('/') ? withLeadingSlash.slice(0, -1) : withLeadingSlash
}
/**
* `next()` for a custom server is a NextCustomServer wrapper. After prepare(),
* `.server` is another NextServer wrapper; the Node server with `nextConfig` is
* one more `.server` down. Older react clients have no basePath and must keep
* Express-stripped URLs.
*/
function getPreparedNextBasePath(nextApp) {
const nodeServer = nextApp.server?.server
const basePath = nodeServer?.nextConfig?.basePath
return typeof basePath === 'string' ? basePath : ''
}
class Server {
constructor(SOURCE, PORT, HOST, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) {
this.Port = PORT
@@ -53,7 +82,7 @@ class Server {
global.isWin = process.platform === 'win32'
global.ConfigPath = fileUtils.filePathToPOSIX(Path.normalize(CONFIG_PATH))
global.MetadataPath = fileUtils.filePathToPOSIX(Path.normalize(METADATA_PATH))
global.RouterBasePath = ROUTER_BASE_PATH
global.RouterBasePath = normalizeBasePath(ROUTER_BASE_PATH)
global.XAccel = process.env.USE_X_ACCEL
global.AllowCors = process.env.ALLOW_CORS === '1'
@@ -157,6 +186,12 @@ class Server {
}
await Database.init(false)
if (typeof global.RouterBasePath !== 'string') {
throw new Error('[Server] RouterBasePath must be a string before serving requests')
}
Logger.info(`[Server] Serving from base path "${global.RouterBasePath || '/'}"`)
// Create or set JWT secret in token manager
await this.auth.tokenManager.initTokenSecret()
@@ -288,10 +323,9 @@ class Server {
// if RouterBasePath is set, modify all requests to include the base path
app.use((req, res, next) => {
const urlStartsWithRouterBasePath = req.url.startsWith(global.RouterBasePath)
const host = req.get('host')
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
const { origin } = getRequestOrigin(req)
const prefix = urlStartsWithRouterBasePath ? global.RouterBasePath : ''
req.originalHostPrefix = `${protocol}://${host}${prefix}`
req.originalHostPrefix = `${origin}${prefix}`
if (!urlStartsWithRouterBasePath) {
req.url = `${global.RouterBasePath}${req.url}`
}
@@ -302,7 +336,9 @@ class Server {
this.server = http.createServer(app)
// Skip file upload parsing for internal-api routes (Next.js proxies read multipart bodies).
router.use(
/^(?!\/internal-api).*/,
fileUpload({
defCharset: 'utf8',
defParamCharset: 'utf8',
@@ -407,7 +443,25 @@ class Server {
const nextApp = next({ dev: Logger.isDev, dir: ReactClientPath })
const handle = nextApp.getRequestHandler()
await nextApp.prepare()
router.all('*', (req, res) => handle(req, res))
const nextBasePath = getPreparedNextBasePath(nextApp)
if (nextBasePath) {
Logger.info(`[Server] Next client basePath is "${nextBasePath}"`)
} else {
Logger.info(`[Server] Next client has no basePath; leaving Express-stripped URLs as-is`)
}
router.all('*', (req, res) => {
// Next is configured with the same base path as this router, but Express strips the mount
// prefix from req.url, so put it back. The bare mount path is passed through without a
// trailing slash, which Next would otherwise redirect away from on every request.
// Skip when Next has no basePath (older client on master).
if (nextBasePath && req.baseUrl) {
const queryIndex = req.url.indexOf('?')
const pathname = queryIndex === -1 ? req.url : req.url.slice(0, queryIndex)
const search = queryIndex === -1 ? '' : req.url.slice(queryIndex)
req.url = `${req.baseUrl}${pathname === '/' ? '' : pathname}${search}`
}
handle(req, res)
})
}
const unixSocketPrefix = 'unix/'
@@ -434,7 +488,11 @@ class Server {
const rootUsername = newRoot.username || 'root'
const rootPash = newRoot.password ? await this.auth.localAuthStrategy.hashPassword(newRoot.password) : ''
if (!rootPash) Logger.warn(`[Server] Creating root user with no password`)
await Database.createRootUser(rootUsername, rootPash, this.auth)
const created = await Database.createRootUser(rootUsername, rootPash, this.auth)
if (!created) {
Logger.error(`[Server] attempt to init server when server already has a root user`)
return res.sendStatus(500)
}
res.sendStatus(200)
}
+66 -10
View File
@@ -3,6 +3,7 @@ const Logger = require('./Logger')
const Database = require('./Database')
const TokenManager = require('./auth/TokenManager')
const CoverSearchManager = require('./managers/CoverSearchManager')
const { LogLevel } = require('./utils/constants')
/**
* @typedef SocketClient
@@ -85,6 +86,14 @@ class SocketAuthority {
}
}
requireAdminSocket(socket, eventName) {
const client = this.clients[socket.id]
if (client?.user?.isAdminOrUp) return true
Logger.warn(`[SocketAuthority] Unauthorized ${eventName} socket event from socket ${socket.id}`)
return false
}
/**
* Emits event with library item to all clients that can access the library item
* Note: Emits toOldJSONExpanded()
@@ -179,14 +188,25 @@ class SocketAuthority {
socket.on('auth', (token) => this.authenticateSocket(socket, token))
// Scanning
socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId))
socket.on('cancel_scan', (libraryId) => {
if (!this.requireAdminSocket(socket, 'cancel_scan')) return
this.cancelScan(libraryId)
})
// Cover search streaming
socket.on('search_covers', (payload) => this.handleCoverSearch(socket, payload))
socket.on('cancel_cover_search', (requestId) => this.handleCancelCoverSearch(socket, requestId))
// Logs
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
socket.on('set_log_listener', (level) => {
if (!this.requireAdminSocket(socket, 'set_log_listener')) return
if (!Number.isInteger(level) || !Object.values(LogLevel).includes(level)) {
Logger.warn(`[SocketAuthority] Invalid set_log_listener level from socket ${socket.id}`)
return
}
Logger.addSocketListener(socket, level)
})
socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))
// Sent automatically from socket.io clients
@@ -249,22 +269,58 @@ class SocketAuthority {
async authenticateSocket(socket, token) {
// we don't use passport to authenticate the jwt we get over the socket connection.
// it's easier to directly verify/decode it.
// TODO: Support API keys for web socket connections
const token_data = TokenManager.validateAccessToken(token)
if (!token_data?.userId) {
if (!token_data) {
// Token invalid
Logger.error('Cannot validate socket - invalid token')
return socket.emit('auth_failed', { message: 'Invalid token' })
}
// get the user via the id from the decoded jwt.
const user = await Database.userModel.getUserByIdOrOldId(token_data.userId)
if (!user) {
// user not found
Logger.error('Cannot validate socket - invalid token')
return socket.emit('auth_failed', { message: 'Invalid token' })
let user = null
if (token_data.type === 'api') {
// Api key based authentication
const apiKey = await Database.apiKeyModel.getById(token_data.keyId)
if (!apiKey?.isActive) {
Logger.error('Cannot validate socket - API key not found or inactive')
return socket.emit('auth_failed', { message: 'Invalid API key' })
}
if (token_data.exp && token_data.exp < Date.now() / 1000) {
apiKey.isActive = false
await apiKey.save()
Logger.info(`[SocketAuthority] API key ${apiKey.id} is expired - deactivated`)
return socket.emit('auth_failed', { message: 'API key expired' })
}
user = await Database.userModel.getUserById(apiKey.userId)
if (!user) {
Logger.error('Cannot validate socket - user not found for API key')
return socket.emit('auth_failed', { message: 'Invalid API key' })
}
} else {
// JWT based authentication
if (!TokenManager.isBearerAccessTokenPayload(token_data)) {
Logger.error('Cannot validate socket - invalid token')
return socket.emit('auth_failed', { message: 'Invalid token' })
}
if (token_data.exp && token_data.exp < Date.now() / 1000) {
Logger.error('Cannot validate socket - token expired')
return socket.emit('auth_failed', { message: 'Token expired' })
}
// get the user via the id from the decoded jwt.
user = await Database.userModel.getUserByIdOrOldId(token_data.userId)
if (!user) {
// user not found
Logger.error('Cannot validate socket - invalid token')
return socket.emit('auth_failed', { message: 'Invalid token' })
}
}
if (!user.isActive) {
Logger.error('Cannot validate socket - user is not active')
return socket.emit('auth_failed', { message: 'Invalid user' })
+33 -38
View File
@@ -1,9 +1,11 @@
const { LRUCache } = require('lru-cache')
const { Request, Response } = require('express')
const passport = require('passport')
const OpenIDClient = require('openid-client')
const axios = require('axios')
const Database = require('../Database')
const Logger = require('../Logger')
const { getRequestOrigin } = require('../utils/requestUtils')
/**
* OpenID Connect authentication strategy
@@ -14,7 +16,10 @@ class OidcAuthStrategy {
this.strategy = null
this.client = null
// Map of openId sessions indexed by oauth2 state-variable
this.openIdAuthSession = new Map()
this.openIdAuthSession = new LRUCache({
ttl: 10 * 60 * 1000,
ttlAutopurge: true
})
}
/**
@@ -289,8 +294,8 @@ class OidcAuthStrategy {
const sessionKey = strategy._key
try {
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
const hostUrl = new URL(`${protocol}://${req.get('host')}`)
const { origin } = getRequestOrigin(req)
const hostUrl = new URL(origin)
const isMobileFlow = req.query.response_type === 'code' || req.query.redirect_uri || req.query.code_challenge
// Only allow code flow (for mobile clients)
@@ -394,11 +399,10 @@ class OidcAuthStrategy {
let postLogoutRedirectUri = null
if (authMethod === 'openid') {
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
const host = req.get('host')
const { origin } = getRequestOrigin(req)
// TODO: ABS does currently not support subfolders for installation
// If we want to support it we need to include a config for the serverurl
postLogoutRedirectUri = `${protocol}://${host}${global.RouterBasePath}/login`
postLogoutRedirectUri = `${origin}${global.RouterBasePath}/login`
}
// else for openid-mobile we keep postLogoutRedirectUri on null
// nice would be to redirect to the app here, but for example Authentik does not implement
@@ -515,42 +519,33 @@ class OidcAuthStrategy {
if (!callbackUrl) return false
try {
// Handle relative URLs - these are always safe if they start with router base path
if (callbackUrl.startsWith('/')) {
// Only allow relative paths that start with the router base path
if (callbackUrl.startsWith(global.RouterBasePath + '/')) {
return true
}
// Reject protocol-relative (//host) and backslash-prefixed (/\host) values,
// which browsers resolve to a cross-origin absolute URL.
if (callbackUrl.startsWith('//') || callbackUrl.startsWith('/\\')) {
Logger.warn(`[OidcAuth] Rejected protocol-relative callback URL: ${callbackUrl}`)
return false
}
const { origin: serverOrigin } = getRequestOrigin(req)
const resolvedUrl = callbackUrl.startsWith('/') ? new URL(callbackUrl, serverOrigin) : new URL(callbackUrl)
if (resolvedUrl.origin !== serverOrigin) {
Logger.warn(`[OidcAuth] Rejected callback URL to different origin: ${callbackUrl} (expected ${serverOrigin})`)
return false
}
const pathname = decodeURIComponent(resolvedUrl.pathname)
if (pathname.startsWith('//') || pathname.startsWith('/\\')) {
Logger.warn(`[OidcAuth] Rejected protocol-relative callback URL path: ${callbackUrl}`)
return false
}
if (!resolvedUrl.pathname.startsWith(global.RouterBasePath + '/')) {
Logger.warn(`[OidcAuth] Rejected callback URL outside router base path: ${callbackUrl}`)
return false
}
// For absolute URLs, ensure they point to the same origin
const callbackUrlObj = new URL(callbackUrl)
// NPM appends both http and https in x-forwarded-proto sometimes, so we need to check for both
const xfp = (req.get('x-forwarded-proto') || '').toLowerCase()
const currentProtocol =
req.secure ||
xfp
.split(',')
.map((s) => s.trim())
.includes('https')
? 'https'
: 'http'
const currentHost = req.get('host')
// Check if protocol and host match exactly
if (callbackUrlObj.protocol === currentProtocol + ':' && callbackUrlObj.host === currentHost) {
// Additional check: ensure path starts with router base path
if (callbackUrlObj.pathname.startsWith(global.RouterBasePath + '/')) {
return true
}
Logger.warn(`[OidcAuth] Rejected same-origin callback URL outside router base path: ${callbackUrl}`)
return false
}
Logger.warn(`[OidcAuth] Rejected callback URL to different origin: ${callbackUrl} (expected ${currentProtocol}://${currentHost})`)
return false
return true
} catch (error) {
Logger.error(`[OidcAuth] Invalid callback URL format: ${callbackUrl}`, error)
return false
+152 -29
View File
@@ -1,10 +1,12 @@
const { Op } = require('sequelize')
const uuid = require('uuid')
const Database = require('../Database')
const Logger = require('../Logger')
const requestIp = require('../libs/requestIp')
const jwt = require('../libs/jsonwebtoken')
const { isRequestSecure } = require('../utils/requestUtils')
class TokenManager {
/** @type {string} JWT secret key */
@@ -15,6 +17,8 @@ class TokenManager {
this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 30 * 24 * 60 * 60 // 30 days
/** @type {number} Access token expiry in seconds */
this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 1 * 60 * 60 // 1 hour
/** @type {number} Grace period in seconds during which a rotated (old) refresh token is still accepted */
this.RefreshTokenGracePeriod = parseInt(process.env.REFRESH_TOKEN_GRACE_PERIOD) || 10 * 60 // 10 minutes
if (parseInt(process.env.REFRESH_TOKEN_EXPIRY) > 0) {
Logger.info(`[TokenManager] Refresh token expiry set from ENV variable to ${this.RefreshTokenExpiry} seconds`)
@@ -22,6 +26,9 @@ class TokenManager {
if (parseInt(process.env.ACCESS_TOKEN_EXPIRY) > 0) {
Logger.info(`[TokenManager] Access token expiry set from ENV variable to ${this.AccessTokenExpiry} seconds`)
}
if (parseInt(process.env.REFRESH_TOKEN_GRACE_PERIOD) > 0) {
Logger.info(`[TokenManager] Refresh token grace period set from ENV variable to ${this.RefreshTokenGracePeriod} seconds`)
}
}
get TokenSecret() {
@@ -58,24 +65,36 @@ class TokenManager {
setRefreshTokenCookie(req, res, refreshToken) {
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: req.secure || req.get('x-forwarded-proto') === 'https',
secure: isRequestSecure(req),
sameSite: 'lax',
maxAge: this.RefreshTokenExpiry * 1000,
path: '/'
})
}
/**
* Whether a decoded JWT payload may authenticate API/socket requests (not refresh-only credentials).
*
* @param {Object} decoded
* @returns {boolean}
*/
static isBearerAccessTokenPayload(decoded) {
if (!decoded?.userId) return false
if (decoded.type === 'refresh') return false
return true
}
/**
* Function to validate a jwt token for a given user
* Used to authenticate socket connections
* TODO: Support API keys for web socket connections
*
* @param {string} token
* @returns {Object} tokens data
*/
static validateAccessToken(token) {
try {
return jwt.verify(token, TokenManager.TokenSecret)
// Expiration is handled by the caller so expired API keys can be deactivated
return jwt.verify(token, TokenManager.TokenSecret, { ignoreExpiration: true })
} catch (err) {
return null
}
@@ -115,6 +134,7 @@ class TokenManager {
const payload = {
userId: user.id,
username: user.username,
jti: uuid.v4(),
type: 'access'
}
const options = {
@@ -138,6 +158,7 @@ class TokenManager {
const payload = {
userId: user.id,
username: user.username,
jti: uuid.v4(),
type: 'refresh'
}
const options = {
@@ -183,20 +204,60 @@ class TokenManager {
* @param {import('../models/User')} user
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {boolean} gracePeriod - whether to use the grace period
* @returns {Promise<{ accessToken:string, refreshToken:string }>}
*/
async rotateTokensForSession(session, user, req, res) {
// Generate new tokens
async rotateTokensForSession(session, user, req, res, gracePeriod = true) {
const previousRefreshToken = session.refreshToken
const newAccessToken = this.generateTempAccessToken(user)
const newRefreshToken = this.generateRefreshToken(user)
// Calculate new expiration time
let newRefreshToken = this.generateRefreshToken(user)
const newExpiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000)
// Update the session with the new refresh token and expiration
session.refreshToken = newRefreshToken
session.expiresAt = newExpiresAt
await session.save()
let lastRefreshToken = null
let lastRefreshTokenExpiresAt = null
if (gracePeriod) {
// Set grace period of old refresh token in case of race condition in token rotation.
// During this window a retry with the old refresh token returns the already-rotated
// current token instead of failing, so a client that never received the rotation
// response (e.g. dropped/suspended mobile request) can still recover the session.
// Configurable via REFRESH_TOKEN_GRACE_PERIOD; may need to be longer if fetching the
// user data takes longer due to large progress objects.
lastRefreshToken = previousRefreshToken
lastRefreshTokenExpiresAt = new Date(Date.now() + this.RefreshTokenGracePeriod * 1000)
}
// Only update if this session row still has the refresh token we read
const [numUpdated] = await Database.sessionModel.update(
{
refreshToken: newRefreshToken,
expiresAt: newExpiresAt,
lastRefreshToken,
lastRefreshTokenExpiresAt
},
{
where: {
id: session.id,
refreshToken: previousRefreshToken
}
}
)
if (numUpdated === 0) {
Logger.debug(`[TokenManager] Race condition in rotateTokensForSession for user ${user.id}, getting new token`)
const updatedSession = await Database.sessionModel.findOne({ where: { id: session.id } })
newRefreshToken = updatedSession.refreshToken
session.refreshToken = updatedSession.refreshToken
session.expiresAt = updatedSession.expiresAt
session.lastRefreshToken = updatedSession.lastRefreshToken
session.lastRefreshTokenExpiresAt = updatedSession.lastRefreshTokenExpiresAt
} else {
session.refreshToken = newRefreshToken
session.expiresAt = newExpiresAt
session.lastRefreshToken = lastRefreshToken
session.lastRefreshTokenExpiresAt = lastRefreshTokenExpiresAt
}
// Set new refresh token cookie
this.setRefreshTokenCookie(req, res, newRefreshToken)
@@ -243,7 +304,11 @@ class TokenManager {
done(null, user)
} else {
// JWT based authentication
// JWT based authentication — refresh tokens are only valid at POST /auth/refresh
if (!TokenManager.isBearerAccessTokenPayload(jwt_payload)) {
done(null, null)
return
}
// Check if the jwt is expired
if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) {
@@ -294,23 +359,40 @@ class TokenManager {
}
}
const session = await Database.sessionModel.findOne({
where: { refreshToken: refreshToken }
let session = await Database.sessionModel.findOne({
where: {
[Op.or]: [{ refreshToken: refreshToken }, { lastRefreshToken: refreshToken }]
}
})
if (!session) {
Logger.error(`[TokenManager] Failed to refresh token. Session not found for refresh token: ${refreshToken}`)
Logger.error(`[TokenManager] Failed to refresh token. Session not found`)
return {
error: 'Invalid refresh token'
}
}
// Check if session is expired in database
if (session.expiresAt < new Date()) {
Logger.info(`[TokenManager] Session expired in database, cleaning up`)
await session.destroy()
return {
error: 'Refresh token expired'
let isGracePeriod = false
if (session.refreshToken !== refreshToken) {
// Token matched lastRefreshToken
if (session.lastRefreshTokenExpiresAt && session.lastRefreshTokenExpiresAt > new Date()) {
isGracePeriod = true
Logger.debug(`[TokenManager] Grace period hit for user ${session.userId}`)
} else {
Logger.debug(`[TokenManager] Grace period expired for user ${session.userId}`)
return {
error: 'Invalid refresh token'
}
}
} else {
// Token matched current refreshToken
// Check if session is expired in database
if (session.expiresAt < new Date()) {
Logger.info(`[TokenManager] Session expired in database, cleaning up`)
await session.destroy()
return {
error: 'Refresh token expired'
}
}
}
@@ -322,6 +404,20 @@ class TokenManager {
}
}
if (isGracePeriod) {
// Return the already rotated refresh token store in the database,
// and generate a new access token without changing the refresh token
// again
const accessToken = this.generateTempAccessToken(user)
this.setRefreshTokenCookie(req, res, session.refreshToken)
return {
accessToken,
refreshToken: session.refreshToken,
user
}
}
const newTokens = await this.rotateTokensForSession(session, user, req, res)
return {
accessToken: newTokens.accessToken,
@@ -366,16 +462,21 @@ class TokenManager {
* @param {import('../models/User')} user
* @param {import('express').Request} req
* @param {import('express').Response} res
* @returns {Promise<string>} accessToken only if user is current user and refresh token is valid
* @returns {Promise<{ accessToken:string, refreshToken:string }|null>} new tokens for the current session if kept alive
*/
async invalidateJwtSessionsForUser(user, req, res) {
const currentRefreshToken = req.cookies.refresh_token
const currentRefreshToken = req.cookies.refresh_token || req.headers['x-refresh-token']
if (req.user.id === user.id && currentRefreshToken) {
// Current user is the same as the user to invalidate sessions for
// So rotate token for current session
const currentSession = await Database.sessionModel.findOne({ where: { refreshToken: currentRefreshToken } })
const currentSession = await Database.sessionModel.findOne({
where: {
userId: user.id,
[Op.or]: [{ refreshToken: currentRefreshToken }, { lastRefreshToken: currentRefreshToken }]
}
})
if (currentSession) {
const newTokens = await this.rotateTokensForSession(currentSession, user, req, res)
const newTokens = await this.rotateTokensForSession(currentSession, user, req, res, false)
// Invalidate all sessions for the user except the current one
await Database.sessionModel.destroy({
@@ -387,9 +488,12 @@ class TokenManager {
}
})
return newTokens.accessToken
return {
accessToken: newTokens.accessToken,
refreshToken: newTokens.refreshToken
}
} else {
Logger.error(`[TokenManager] No session found to rotate tokens for refresh token ${currentRefreshToken}`)
Logger.error(`[TokenManager] No session found to rotate tokens`)
}
}
@@ -399,6 +503,25 @@ class TokenManager {
return null
}
/**
* Destroy all JWT sessions for the user that owns this refresh token
*
* @param {string} refreshToken
*/
async invalidateAllSessionsForRefreshToken(refreshToken) {
if (!refreshToken) return
const session = await Database.sessionModel.findOne({
where: {
[Op.or]: [{ refreshToken: refreshToken }, { lastRefreshToken: refreshToken }]
}
})
if (!session) return
const numDeleted = await Database.sessionModel.destroy({ where: { userId: session.userId } })
Logger.info(`[TokenManager] Invalidated all JWT sessions for user ${session.userId}, ${numDeleted} deleted`)
}
/**
* Invalidate a refresh token - used for logout
*
@@ -413,7 +536,7 @@ class TokenManager {
try {
const numDeleted = await Database.sessionModel.destroy({ where: { refreshToken: refreshToken } })
Logger.info(`[TokenManager] Refresh token ${refreshToken} invalidated, ${numDeleted} sessions deleted`)
Logger.info(`[TokenManager] Refresh token invalidated, ${numDeleted} sessions deleted`)
return true
} catch (error) {
Logger.error(`[TokenManager] Error invalidating refresh token: ${error.message}`)
+2 -1
View File
@@ -149,7 +149,7 @@ class AuthorController {
})
if (libraryItems.length) {
await Database.bookAuthorModel.removeByIds(req.author.id) // Remove all old BookAuthor
await Database.bookAuthorModel.bulkCreate(bookAuthorsToCreate) // Create all new BookAuthor
await Database.bookAuthorModel.bulkCreate(bookAuthorsToCreate, { ignoreDuplicates: true }) // Create all new unique BookAuthor
for (const libraryItem of libraryItems) {
await libraryItem.saveMetadataFile()
}
@@ -427,6 +427,7 @@ class AuthorController {
async middleware(req, res, next) {
const author = await Database.authorModel.findByPk(req.params.id)
if (!author) return res.sendStatus(404)
if (!req.user.checkCanAccessLibrary(author.libraryId)) return res.sendStatus(404)
if (req.method == 'DELETE' && !req.user.canDelete) {
Logger.warn(`[AuthorController] User "${req.user.username}" attempted to delete without permission`)
+14 -11
View File
@@ -782,7 +782,7 @@ class LibraryController {
.filter((v) => !!v)
const series = await Database.seriesModel.findByPk(req.params.seriesId)
if (!series) return res.sendStatus(404)
if (!series || series.libraryId !== req.library.id) return res.sendStatus(404)
const libraryItemsInSeries = await libraryItemsBookFilters.getLibraryItemsForSeries(series, req.user)
@@ -1167,11 +1167,11 @@ class LibraryController {
}
// Update filter data
Database.replaceNarratorInFilterData(narratorName, updatedName)
Database.replaceNarratorInFilterData(req.library.id, narratorName, updatedName)
const itemsUpdated = []
const itemsWithNarrator = await libraryItemFilters.getAllLibraryItemsWithNarrators([narratorName])
const itemsWithNarrator = await libraryItemFilters.getAllLibraryItemsWithNarrators([narratorName], req.library.id)
for (const libraryItem of itemsWithNarrator) {
libraryItem.media.narrators = libraryItem.media.narrators.filter((n) => n !== narratorName)
@@ -1211,11 +1211,11 @@ class LibraryController {
const narratorName = libraryFilters.decode(req.params.narratorId)
// Update filter data
Database.removeNarratorFromFilterData(narratorName)
Database.removeNarratorFromFilterData(req.library.id, narratorName)
const itemsUpdated = []
const itemsWithNarrator = await libraryItemFilters.getAllLibraryItemsWithNarrators([narratorName])
const itemsWithNarrator = await libraryItemFilters.getAllLibraryItemsWithNarrators([narratorName], req.library.id)
for (const libraryItem of itemsWithNarrator) {
libraryItem.media.narrators = libraryItem.media.narrators.filter((n) => n !== narratorName)
@@ -1432,14 +1432,17 @@ class LibraryController {
const itemIds = req.query.ids.split(',')
const libraryItems = await Database.libraryItemModel.findAll({
attributes: ['id', 'libraryId', 'path', 'isFile'],
where: {
id: itemIds,
libraryId: req.library.id
}
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
id: itemIds,
libraryId: req.library.id
})
for (const libraryItem of libraryItems) {
if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
return res.sendStatus(403)
}
}
if (libraryItems.length < itemIds.length) {
Logger.warn(`[LibraryController] User "${req.user.username}" requested ${itemIds.length} items but only ${libraryItems.length} are in library "${req.library.id}"`)
}
@@ -335,6 +335,9 @@ class LibraryItemController {
/**
* PATCH: /api/items/:id/cover
*
* This is used when updating the cover to a local image library file for the library item
* TODO: In the next API this should be using an inode or similar instead of a file path
*
* @param {LibraryItemControllerRequest} req
* @param {Response} res
*/
@@ -343,6 +346,7 @@ class LibraryItemController {
return res.status(400).send('Invalid request no cover path')
}
// Must be a cover file path inside the libraryFiles array
const validationResult = await CoverManager.validateCoverPath(req.body.cover, req.libraryItem)
if (validationResult.error) {
return res.status(500).send(validationResult.error)
+141 -1
View File
@@ -1,10 +1,12 @@
const { Request, Response } = require('express')
const { Op } = require('sequelize')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const { sort } = require('../libs/fastSort')
const { toNumber, isNullOrNaN } = require('../utils/index')
const { toNumber, isNullOrNaN, isUUID } = require('../utils/index')
const userStats = require('../utils/queries/userStats')
const parseUserAgent = require('../utils/parsers/parseUserAgent')
/**
* @typedef RequestUserObject
@@ -26,6 +28,124 @@ class MeController {
res.json(req.user.toOldJSONForBrowser())
}
/**
* GET: /api/me/sessions
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async getSessions(req, res) {
const page = Math.max(0, toNumber(req.query.page, 0))
const itemsPerPage = Math.max(1, toNumber(req.query.itemsPerPage, 10))
if (req.user.isGuest) {
return res.json({ sessions: [], total: 0, numPages: 0, page, itemsPerPage })
}
const refreshToken = req.cookies.refresh_token || req.headers['x-refresh-token']
const { rows, count } = await Database.sessionModel.findAndCountAll({
where: {
userId: req.user.id,
expiresAt: { [Op.gt]: new Date() }
},
order: [['updatedAt', 'DESC']],
limit: itemsPerPage,
offset: itemsPerPage * page
})
res.json({
total: count,
numPages: Math.ceil(count / itemsPerPage),
page,
itemsPerPage,
sessions: rows.map((session) => ({
id: session.id,
ipAddress: session.ipAddress,
userAgent: session.userAgent,
// For display convenience
deviceInfo: parseUserAgent(session.userAgent),
createdAt: session.createdAt?.valueOf() ?? null,
updatedAt: session.updatedAt?.valueOf() ?? null,
current: !!refreshToken && (session.refreshToken === refreshToken || session.lastRefreshToken === refreshToken)
}))
})
}
/**
* DELETE: /api/me/sessions/:id
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async deleteSession(req, res) {
if (req.user.isGuest) {
return res.sendStatus(403)
}
if (!isUUID(req.params.id)) {
return res.sendStatus(400)
}
const session = await Database.sessionModel.findOne({
where: {
id: req.params.id,
userId: req.user.id
}
})
if (!session) {
return res.sendStatus(404)
}
await Database.sessionModel.destroy({ where: { id: session.id } })
Logger.info(`[MeController] User ${req.user.username} deleted auth session ${session.id}`)
res.sendStatus(200)
}
/**
* GET: /api/me/progress
*
* @param {RequestWithUser} req
* @param {Response} res
*/
getAllMediaProgress(req, res) {
const mediaProgress = req.user.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || []
res.json({ mediaProgress })
}
/**
* GET: /api/me/bookmarks
*
* @param {RequestWithUser} req
* @param {Response} res
*/
getAllBookmarks(req, res) {
const bookmarks = req.user.bookmarks?.map((bookmark) => ({ ...bookmark })) || []
res.json({ bookmarks })
}
/**
* GET: /api/me/bookmarks/:libraryItemId
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async getBookmarksForLibraryItem(req, res) {
const libraryItem = await Database.libraryItemModel.getExpandedById(req.params.libraryItemId)
if (!libraryItem) {
return res.sendStatus(404)
}
if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
Logger.error(`[MeController] User "${req.user.username}" attempted to access bookmarks for library item "${req.params.libraryItemId}" without access`)
return res.sendStatus(403)
}
const bookmarks = req.user.bookmarks?.filter((bookmark) => bookmark.libraryItemId === libraryItem.id).map((bookmark) => ({ ...bookmark })) || []
res.json({ bookmarks })
}
/**
* GET: /api/me/listening-sessions
*
@@ -308,6 +428,8 @@ class MeController {
* User change password. Requires current password.
* Guest users cannot change password.
*
* Invalidates all other JWT sessions for the user. If using x-refresh-token, returns new tokens for the current session.
*
* @this import('../routers/ApiRouter')
*
* @param {RequestWithUser} req
@@ -330,6 +452,24 @@ class MeController {
return res.status(400).send(result.error)
}
const shouldReturnTokens = !!req.headers['x-refresh-token']
const newTokens = await this.auth.invalidateJwtSessionsForUser(req.user, req, res)
if (newTokens?.accessToken) {
Logger.info(`[MeController] Invalidated other JWT sessions for user ${req.user.username} after password change`)
if (shouldReturnTokens) {
return res.json({
success: true,
user: {
accessToken: newTokens.accessToken,
refreshToken: newTokens.refreshToken
}
})
}
} else {
Logger.info(`[MeController] Invalidated all JWT sessions for user ${req.user.username} after password change`)
}
res.sendStatus(200)
}
+20 -5
View File
@@ -10,7 +10,9 @@ const Watcher = require('../Watcher')
const libraryItemFilters = require('../utils/queries/libraryItemFilters')
const cron = require('../libs/nodeCron')
const { isObject, getTitleIgnorePrefix } = require('../utils/index')
const ServerSettings = require('../objects/settings/ServerSettings')
const { sanitizeFilename } = require('../utils/fileUtils')
const { sanitize } = require('../utils/htmlSanitizer')
const TaskManager = require('../managers/TaskManager')
const adminStats = require('../utils/queries/adminStats')
@@ -42,7 +44,7 @@ class MiscController {
return res.sendStatus(400)
}
const files = Object.values(req.files)
const files = Object.values(req.files).flat()
let { title, author, series, folder: folderId, library: libraryId } = req.body
// Validate request body
if (!libraryId || !folderId || typeof libraryId !== 'string' || typeof folderId !== 'string' || !title || typeof title !== 'string') {
@@ -78,7 +80,12 @@ class MiscController {
const cleanedOutputDirectoryParts = outputDirectoryParts.filter(Boolean).map((part) => sanitizeFilename(part))
const outputDirectory = Path.join(...[folder.path, ...cleanedOutputDirectoryParts])
await fs.ensureDir(outputDirectory)
try {
await fs.ensureDir(outputDirectory)
} catch (error) {
Logger.error('[MiscController] Failed to create upload directory', outputDirectory, error)
return res.sendStatus(500)
}
Logger.info(`Uploading ${files.length} files to`, outputDirectory)
@@ -142,16 +149,23 @@ class MiscController {
Logger.warn('Cannot disable iframe when ALLOW_IFRAME is enabled in environment')
return res.status(400).send('Cannot disable iframe when ALLOW_IFRAME is enabled in environment')
}
if (settingsUpdate.allowedOrigins && !Array.isArray(settingsUpdate.allowedOrigins)) {
const filteredUpdate = {}
for (const key in settingsUpdate) {
if (ServerSettings.patchableSettingsKeys.has(key)) {
filteredUpdate[key] = settingsUpdate[key]
}
}
if (filteredUpdate.allowedOrigins && !Array.isArray(filteredUpdate.allowedOrigins)) {
return res.status(400).send('allowedOrigins must be an array')
}
const madeUpdates = Database.serverSettings.update(settingsUpdate)
const madeUpdates = Database.serverSettings.update(filteredUpdate)
if (madeUpdates) {
await Database.updateServerSettings()
// If backup schedule is updated - update backup manager
if (settingsUpdate.backupSchedule !== undefined) {
if (filteredUpdate.backupSchedule !== undefined) {
this.backupManager.updateCronSchedule()
}
}
@@ -698,6 +712,7 @@ class MiscController {
}
let updatedValue = settingsUpdate[key]
if (updatedValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') updatedValue = null
if (key === 'authLoginCustomMessage' && updatedValue) updatedValue = sanitize(updatedValue) || null
let currentValue = currentAuthenticationSettings[key]
if (currentValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') currentValue = null
+11
View File
@@ -437,6 +437,17 @@ class PodcastController {
}
updatePayload[key] = req.body[key]
} else if (key === 'enclosure') {
const enclosure = req.body.enclosure
if (enclosure === null) {
updatePayload.enclosureURL = null
updatePayload.enclosureSize = null
updatePayload.enclosureType = null
} else if (typeof enclosure === 'object' && typeof enclosure.url === 'string') {
updatePayload.enclosureURL = enclosure.url
updatePayload.enclosureType = typeof enclosure.type === 'string' ? enclosure.type : null
updatePayload.enclosureSize = enclosure.length !== undefined && enclosure.length !== null ? enclosure.length : null
}
} else if (key === 'chapters' && Array.isArray(req.body[key]) && req.body[key].every((ch) => typeof ch === 'object' && ch.title && ch.start)) {
updatePayload[key] = req.body[key]
} else if (key === 'publishedAt' && typeof req.body[key] === 'number') {
+1 -1
View File
@@ -57,7 +57,7 @@ class RSSFeedController {
}
// Check item has audio tracks
if (!itemExpanded.hasAudioTracks()) {
if (!itemExpanded.hasAudioTracks) {
Logger.error(`[RSSFeedController] Cannot open RSS feed for item "${itemExpanded.media.title}" because it has no audio tracks`)
return res.status(400).send('Item has no audio tracks')
}
+4 -4
View File
@@ -146,7 +146,7 @@ class ShareController {
const mediaItemShare = ShareManager.findBySlug(slug)
if (!mediaItemShare) {
return res.status(404)
return res.sendStatus(404)
}
const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id)
@@ -186,7 +186,7 @@ class ShareController {
const mediaItemShare = ShareManager.findBySlug(slug)
if (!mediaItemShare) {
return res.status(404)
return res.sendStatus(404)
}
const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id)
@@ -231,7 +231,7 @@ class ShareController {
const { slug } = req.params
const mediaItemShare = ShareManager.findBySlug(slug)
if (!mediaItemShare) {
return res.status(404)
return res.sendStatus(404)
}
if (!mediaItemShare.isDownloadable) {
return res.status(403).send('Download is not allowed for this item')
@@ -295,7 +295,7 @@ class ShareController {
const mediaItemShare = ShareManager.findBySlug(slug)
if (!mediaItemShare) {
return res.status(404)
return res.sendStatus(404)
}
const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id)
+11 -12
View File
@@ -253,6 +253,7 @@ class UserController {
// Updating password
if (updatePayload.password) {
user.pash = await this.auth.localAuthStrategy.hashPassword(updatePayload.password)
shouldInvalidateJwtSessions = true
hasUpdates = true
}
@@ -331,14 +332,11 @@ class UserController {
Logger.info(`[UserController] User ${user.username} has generated a new api token`)
}
// Handle JWT session invalidation for username changes
// Handle JWT session invalidation for username/password changes
if (shouldInvalidateJwtSessions) {
const newAccessToken = await this.auth.invalidateJwtSessionsForUser(user, req, res)
if (newAccessToken) {
user.accessToken = newAccessToken
// Refresh tokens are only returned for mobile clients
// Mobile apps currently do not use this API endpoint so always set to null
user.refreshToken = null
const newTokens = await this.auth.invalidateJwtSessionsForUser(user, req, res)
if (newTokens) {
// Note: for admin users changing their own password they should use MeController.updatePassword instead. This endpoint does not return tokens
Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username} and rotated tokens for current session`)
} else {
Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username}`)
@@ -363,15 +361,16 @@ class UserController {
* @param {Response} res
*/
async delete(req, res) {
if (req.params.id === 'root') {
Logger.error('[UserController] Attempt to delete root user. Root user cannot be deleted')
return res.sendStatus(400)
}
const user = req.reqUser
if (req.user.id === req.params.id) {
Logger.error(`[UserController] User ${req.user.username} is attempting to delete self`)
return res.sendStatus(400)
}
const user = req.reqUser
if (user.isRoot) {
Logger.error(`[UserController] Admin user "${req.user.username}" attempted to delete root user`)
return res.sendStatus(403)
}
// Todo: check if user is logged in and cancel streams
@@ -7,7 +7,7 @@ const { ObjectDefineProperties, ObjectDefineProperty, ObjectSetPrototypeOf } = r
const stream = require('../../stream')
const { getDefaultEncoding } = require('../crypto/util')
const getDefaultEncoding = () => 'buffer'
module.exports = LazyTransform
+12 -1
View File
@@ -2,6 +2,7 @@
const fs = require('graceful-fs')
const path = require('path')
const { pipeline } = require('stream')
const mkdirs = require('../mkdirs').mkdirs
const pathExists = require('../path-exists').pathExists
const utimesMillis = require('../util/utimes').utimesMillis
@@ -98,10 +99,20 @@ function mayCopyFile (srcStat, src, dest, opts, cb) {
}
function copyFile (srcStat, src, dest, opts, cb) {
fs.copyFile(src, dest, err => {
const finish = err => {
if (err) return cb(err)
if (opts.preserveTimestamps) return handleTimestampsAndMode(srcStat.mode, src, dest, cb)
return setDestMode(dest, srcStat.mode, cb)
}
fs.copyFile(src, dest, err => {
if (err?.code !== 'EPERM' || !srcStat.isFile()) return finish(err)
// Stream the fallback so database copies do not require a full-file buffer.
pipeline(fs.createReadStream(src, { highWaterMark: 1024 * 1024 }), fs.createWriteStream(dest, { highWaterMark: 1024 * 1024 }), streamError => {
if (streamError) return fs.unlink(dest, () => cb(streamError))
finish()
})
})
}
+1 -1
View File
@@ -56,7 +56,7 @@ function doRename (src, dest, overwrite, isChangingCase, cb) {
function rename (src, dest, overwrite, cb) {
fs.rename(src, dest, err => {
if (!err) return cb()
if (err.code !== 'EXDEV') return cb(err)
if (err.code !== 'EXDEV' && err.code !== 'EPERM') return cb(err)
return moveAcrossDevice(src, dest, overwrite, cb)
})
}
+10 -7
View File
@@ -15,20 +15,23 @@ module.exports = (function () {
proc.stdout.setEncoding('utf8')
proc.stderr.setEncoding('utf8')
proc.stdout.on('data', function (data) { probeData.push(data) })
proc.stderr.on('data', function (data) { errData.push(data) })
proc.stdout.on('data', function (data) {
probeData.push(data)
})
proc.stderr.on('data', function (data) {
errData.push(data)
})
proc.on('exit', code => { exitCode = code })
proc.on('error', err => reject(err))
proc.on('error', (err) => reject(err))
proc.on('close', () => {
try {
resolve(JSON.parse(probeData.join('')))
resolve(JSON.parse(probeData.join('')))
} catch (err) {
reject(err);
reject(err)
}
})
})
}
return doProbe
})()
})()
+2 -2
View File
@@ -5,8 +5,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.WatcherStats = exports.Stats = void 0;
const ripstat_1 = require("ripstat");
const ripstat_1 = require("./ripstat");
Object.defineProperty(exports, "Stats", { enumerable: true, get: function () { return ripstat_1.Stats; } });
const watcher_stats_1 = __importDefault(require("./watcher_stats"));
exports.WatcherStats = watcher_stats_1.default;
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidHlwZXMuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi9zcmMvdHlwZXMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IjtBQUNBLFlBQVk7Ozs7OztBQUdaLHFDQUE4QjtBQWlGbUUsc0ZBakZ6RixlQUFLLE9BaUZ5RjtBQTlFdEcsb0VBQTJDO0FBOEUwSyx1QkE5RTlNLHVCQUFZLENBOEU4TSJ9
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidHlwZXMuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi9zcmMvdHlwZXMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IjtBQUNBLFlBQVk7Ozs7OztBQUdaLHFDQUE4QjtBQWlGbUUsc0ZBakZ6RixlQUFLLE9BaUZ5RjtBQTlFdEcsb0VBQTJDO0FBOEUwSyx1QkE5RTlNLHVCQUFZLENBOEU4TSJ9
+1 -1
View File
@@ -272,7 +272,7 @@ class AudioMetadataMangaer {
this.tasksRunning = this.tasksRunning.filter((t) => t.id !== task.id)
if (this.tasksRunning.length < this.MAX_CONCURRENT_TASKS && this.tasksQueued.length) {
Logger.info(`[AudioMetadataManager] Task finished and dequeueing next task. ${this.tasksQueued} tasks queued.`)
Logger.info(`[AudioMetadataManager] Task finished and dequeueing next task. ${this.tasksQueued.length} tasks queued.`)
const nextTask = this.tasksQueued.shift()
SocketAuthority.emitter('metadata_embed_queue_update', {
libraryItemId: nextTask.data.libraryItemId,
+1 -1
View File
@@ -112,7 +112,7 @@ class BackupManager {
.mv(tempPath)
.then(() => true)
.catch((error) => {
Logger.error('[BackupManager] Failed to move backup file', path, error)
Logger.error('[BackupManager] Failed to move backup file', tempPath, error)
return false
})
if (!success) {
+1 -1
View File
@@ -369,8 +369,8 @@ class BinaryManager {
* @param {Binary} binary
*/
async removeBinary(destination, binary) {
const binaryPath = path.join(destination, binary.fileName)
try {
const binaryPath = path.join(destination, binary.fileName)
if (await fs.pathExists(binaryPath)) {
Logger.debug(`[BinaryManager] Removing binary: ${binaryPath}`)
await fs.remove(binaryPath)
+11
View File
@@ -4,8 +4,13 @@ const stream = require('stream')
const Logger = require('../Logger')
const { resizeImage } = require('../utils/ffmpegHelpers')
const { encodeUriPath } = require('../utils/fileUtils')
const { isUUID } = require('../utils/index')
const Database = require('../Database')
function isValidCacheDimension(dimension) {
return Number.isSafeInteger(dimension) && dimension > 0
}
class CacheManager {
constructor() {
this.CachePath = null
@@ -40,6 +45,9 @@ class CacheManager {
const width = options.width || 400
const height = options.height || null
if (!['webp', 'jpeg', 'png'].includes(format) || !isUUID(libraryItemId) || !isValidCacheDimension(width) || (height !== null && !isValidCacheDimension(height))) {
return res.sendStatus(400)
}
res.type(`image/${format}`)
const cachePath = Path.join(this.CoverCachePath, `${libraryItemId}_${width}${height ? `x${height}` : ''}`) + '.' + format
@@ -149,6 +157,9 @@ class CacheManager {
const width = options.width || 400
const height = options.height || null
if (!['webp', 'jpeg', 'png'].includes(format) || !isUUID(authorId) || !isValidCacheDimension(width) || (height !== null && !isValidCacheDimension(height))) {
return res.sendStatus(400)
}
res.type(`image/${format}`)
var cachePath = Path.join(this.ImageCachePath, `${authorId}_${width}${height ? `x${height}` : ''}`) + '.' + format
+10
View File
@@ -138,6 +138,16 @@ class CoverManager {
}
}
coverPath = filePathToPOSIX(coverPath)
// Must already have been scanned in for the library item
const isLibraryItemFile = libraryItem.libraryFiles?.some((lf) => filePathToPOSIX(lf.metadata?.path) === coverPath)
if (!isLibraryItemFile) {
Logger.error(`[CoverManager] validate cover path is not a library item file "${coverPath}"`)
return {
error: 'Invalid cover path'
}
}
// Cover path already set on media
if (libraryItem.media.coverPath == coverPath) {
Logger.debug(`[CoverManager] validate cover path already set "${coverPath}"`)
+6
View File
@@ -2,6 +2,7 @@ const { Request, Response } = require('express')
const Path = require('path')
const Logger = require('../Logger')
const { getAudioMimeTypeFromExtname } = require('../utils/fileUtils')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
@@ -216,6 +217,11 @@ class RssFeedManager {
res.sendStatus(404)
return
}
// Express does not set the correct mimetype for m4b files so use our defined mimetypes if available
const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(episodePath))
if (audioMimeType) {
res.setHeader('Content-Type', audioMimeType)
}
res.sendFile(episodePath)
}
@@ -0,0 +1,84 @@
/**
* @typedef MigrationContext
* @property {import('sequelize').QueryInterface} queryInterface - a Sequelize QueryInterface object.
* @property {import('../Logger')} logger - a Logger object.
*
* @typedef MigrationOptions
* @property {MigrationContext} context - an object containing the migration context.
*/
const migrationVersion = '2.35.0'
const migrationName = `${migrationVersion}-add-last-refresh-token`
const loggerPrefix = `[${migrationVersion} migration]`
/**
* This migration script adds lastRefreshToken and lastRefreshTokenExpiresAt columns to the sessions table.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function up({ context: { queryInterface, logger } }) {
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
if (await queryInterface.tableExists('sessions')) {
const tableDescription = await queryInterface.describeTable('sessions')
if (!tableDescription.lastRefreshToken) {
logger.info(`${loggerPrefix} Adding lastRefreshToken column to sessions table`)
await queryInterface.addColumn('sessions', 'lastRefreshToken', {
type: queryInterface.sequelize.Sequelize.DataTypes.STRING,
allowNull: true
})
} else {
logger.info(`${loggerPrefix} lastRefreshToken column already exists in sessions table`)
}
if (!tableDescription.lastRefreshTokenExpiresAt) {
logger.info(`${loggerPrefix} Adding lastRefreshTokenExpiresAt column to sessions table`)
await queryInterface.addColumn('sessions', 'lastRefreshTokenExpiresAt', {
type: queryInterface.sequelize.Sequelize.DataTypes.DATE,
allowNull: true
})
} else {
logger.info(`${loggerPrefix} lastRefreshTokenExpiresAt column already exists in sessions table`)
}
} else {
logger.info(`${loggerPrefix} sessions table does not exist`)
}
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
}
/**
* This migration script removes the lastRefreshToken and lastRefreshTokenExpiresAt columns from the sessions table.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function down({ context: { queryInterface, logger } }) {
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
if (await queryInterface.tableExists('sessions')) {
const tableDescription = await queryInterface.describeTable('sessions')
if (tableDescription.lastRefreshToken) {
logger.info(`${loggerPrefix} Removing lastRefreshToken column from sessions table`)
await queryInterface.removeColumn('sessions', 'lastRefreshToken')
} else {
logger.info(`${loggerPrefix} lastRefreshToken column does not exist in sessions table`)
}
if (tableDescription.lastRefreshTokenExpiresAt) {
logger.info(`${loggerPrefix} Removing lastRefreshTokenExpiresAt column from sessions table`)
await queryInterface.removeColumn('sessions', 'lastRefreshTokenExpiresAt')
} else {
logger.info(`${loggerPrefix} lastRefreshTokenExpiresAt column does not exist in sessions table`)
}
} else {
logger.info(`${loggerPrefix} sessions table does not exist`)
}
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
}
module.exports = { up, down }
+13 -21
View File
@@ -422,21 +422,6 @@ class Book extends Model {
hasUpdates = true
}
// TODO: Remove support for updating audioFiles, chapters and ebookFile here
const arrayOfObjectsKeys = ['audioFiles', 'chapters']
arrayOfObjectsKeys.forEach((key) => {
if (Array.isArray(payload[key]) && !payload[key].some((item) => typeof item !== 'object') && JSON.stringify(this[key]) !== JSON.stringify(payload[key])) {
this[key] = payload[key]
this.changed(key, true)
hasUpdates = true
}
})
if (payload.ebookFile && JSON.stringify(this.ebookFile) !== JSON.stringify(payload.ebookFile)) {
this.ebookFile = payload.ebookFile
this.changed('ebookFile', true)
hasUpdates = true
}
if (hasUpdates) {
Logger.debug(`[Book] "${this.title}" changed keys:`, this.changed())
await this.save()
@@ -647,6 +632,11 @@ class Book extends Model {
}
}
/**
* Minified book JSON for list/shelf endpoints.
* `toOldJSONExpanded()` must be a strict superset: every key here must exist in expanded
* with the same value semantics. Only additive changes to expanded; never remove or rename keys.
*/
toOldJSONMinified() {
if (!this.authors) {
throw new Error(`[Book] Cannot convert to old JSON because authors are not loaded`)
@@ -669,6 +659,12 @@ class Book extends Model {
}
}
/**
* Expanded book JSON for item detail and socket events.
* Must be a strict superset of `toOldJSONMinified()` — built by spreading minified, then adding expanded-only fields.
*
* @param {string} libraryItemId
*/
toOldJSONExpanded(libraryItemId) {
if (!libraryItemId) {
throw new Error(`[Book] Cannot convert to old JSON because libraryItemId is not provided`)
@@ -681,16 +677,12 @@ class Book extends Model {
}
return {
id: this.id,
libraryItemId: libraryItemId,
...this.toOldJSONMinified(),
libraryItemId,
metadata: this.oldMetadataToJSONExpanded(),
coverPath: this.coverPath,
tags: [...(this.tags || [])],
audioFiles: structuredClone(this.audioFiles),
chapters: structuredClone(this.chapters),
ebookFile: structuredClone(this.ebookFile),
duration: this.duration,
size: this.size,
tracks: this.getTracklist(libraryItemId)
}
}
+27 -1
View File
@@ -1,4 +1,4 @@
const { DataTypes, Model } = require('sequelize')
const { DataTypes, Model, fn, col } = require('sequelize')
class BookAuthor extends Model {
constructor(values, options) {
@@ -37,6 +37,32 @@ class BookAuthor extends Model {
})
}
/**
* Get number of books for each author
*
* @param {string[]} authorIds
* @returns {Promise<Record<string, number>>}
*/
static async getCountsForAuthors(authorIds) {
if (!authorIds.length) return {}
const rows = await this.findAll({
attributes: ['authorId', [fn('COUNT', col('id')), 'count']],
where: {
authorId: authorIds
},
group: ['authorId'],
raw: true
})
/** @type {Record<string, number>} */
const counts = {}
for (const row of rows) {
counts[row.authorId] = Number(row.count)
}
return counts
}
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
+15 -3
View File
@@ -87,6 +87,18 @@ class Feed extends Model {
)
}
/**
* @param {string} slug
* @param {string|null|undefined} coverPath
* @param {Date|null|undefined} entityUpdatedAt
* @returns {string}
*/
static getFeedImageURL(slug, coverPath, entityUpdatedAt) {
if (!coverPath) return '/Logo.png'
const cacheBuster = entityUpdatedAt != null ? `?ts=${entityUpdatedAt.valueOf()}` : ''
return `/feed/${slug}/cover${Path.extname(coverPath)}${cacheBuster}`
}
/**
*
* @param {string} userId
@@ -119,7 +131,7 @@ class Feed extends Model {
entityUpdatedAt,
serverAddress,
feedURL: `/feed/${slug}`,
imageURL: media.coverPath ? `/feed/${slug}/cover${Path.extname(media.coverPath)}` : `/Logo.png`,
imageURL: Feed.getFeedImageURL(slug, media.coverPath, entityUpdatedAt),
siteURL: `/item/${libraryItem.id}`,
title: media.title,
description: media.description,
@@ -212,7 +224,7 @@ class Feed extends Model {
entityUpdatedAt,
serverAddress,
feedURL: `/feed/${slug}`,
imageURL: firstBookWithCover?.coverPath ? `/feed/${slug}/cover${Path.extname(firstBookWithCover.coverPath)}` : `/Logo.png`,
imageURL: Feed.getFeedImageURL(slug, firstBookWithCover?.coverPath, entityUpdatedAt),
siteURL: `/collection/${collectionExpanded.id}`,
title: collectionExpanded.name,
description: collectionExpanded.description || '',
@@ -301,7 +313,7 @@ class Feed extends Model {
entityUpdatedAt,
serverAddress,
feedURL: `/feed/${slug}`,
imageURL: firstBookWithCover?.coverPath ? `/feed/${slug}/cover${Path.extname(firstBookWithCover.coverPath)}` : `/Logo.png`,
imageURL: Feed.getFeedImageURL(slug, firstBookWithCover?.coverPath, entityUpdatedAt),
siteURL: `/library/${booksWithTracks[0].libraryItem.libraryId}/series/${seriesExpanded.id}`,
title: seriesExpanded.name,
description: seriesExpanded.description || '',
+23 -42
View File
@@ -491,11 +491,7 @@ class LibraryItem extends Model {
}
Logger.debug(`Loaded ${newestAuthorsPayload.authors.length} of ${newestAuthorsPayload.count} authors for "Newest Authors" in ${newestAuthorsResult.elapsedSeconds}s`)
} else if (library.isPodcast) {
const [newestEpisodesResult, mostRecentResult, mediaFinishedResult] = await Promise.all([
timed(() => libraryFilters.getNewestPodcastEpisodes(library, user, limit)),
timed(() => libraryFilters.getLibraryItemsMostRecentlyAdded(library, user, include, limit)),
timed(() => libraryFilters.getMediaFinished(library, user, include, limit))
])
const [newestEpisodesResult, mostRecentResult, mediaFinishedResult] = await Promise.all([timed(() => libraryFilters.getNewestPodcastEpisodes(library, user, limit)), timed(() => libraryFilters.getLibraryItemsMostRecentlyAdded(library, user, include, limit)), timed(() => libraryFilters.getMediaFinished(library, user, include, limit))])
const newestEpisodesPayload = newestEpisodesResult.payload
// "Newest Episodes" shelf
@@ -852,8 +848,18 @@ class LibraryItem extends Model {
get isPodcast() {
return this.mediaType === 'podcast'
}
/**
* Check if book or podcast library item has audio tracks.
* Requires expanded library item (media loaded).
*
* @returns {boolean}
*/
get hasAudioTracks() {
return this.media.hasAudioTracks()
if (!this.media) {
Logger.error(`[LibraryItem] hasAudioTracks: Library item "${this.id}" does not have media`)
return false
}
return this.media.hasAudioTracks
}
/**
@@ -904,24 +910,6 @@ class LibraryItem extends Model {
}
}
/**
* Check if book or podcast library item has audio tracks
* Requires expanded library item
*
* @returns {boolean}
*/
hasAudioTracks() {
if (!this.media) {
Logger.error(`[LibraryItem] hasAudioTracks: Library item "${this.id}" does not have media`)
return false
}
if (this.isBook) {
return this.media.audioFiles?.length > 0
} else {
return this.media.podcastEpisodes?.length > 0
}
}
/**
*
* @param {string} ino
@@ -1004,6 +992,11 @@ class LibraryItem extends Model {
}
}
/**
* Minified library item JSON for list/shelf endpoints.
* `toOldJSONExpanded()` must be a strict superset: every key here must exist in expanded
* with the same value semantics. Only additive changes to expanded; never remove or rename keys.
*/
toOldJSONMinified() {
if (!this.media) {
throw new Error(`[LibraryItem] Cannot convert to old JSON without media for library item "${this.id}"`)
@@ -1032,30 +1025,18 @@ class LibraryItem extends Model {
}
}
/**
* Expanded library item JSON for item detail and socket events.
* Must be a strict superset of `toOldJSONMinified()` — built by spreading minified, then adding expanded-only fields.
*/
toOldJSONExpanded() {
return {
id: this.id,
ino: this.ino,
oldLibraryItemId: this.extraData?.oldLibraryItemId || null,
libraryId: this.libraryId,
folderId: this.libraryFolderId,
path: this.path,
relPath: this.relPath,
isFile: this.isFile,
mtimeMs: this.mtime?.valueOf(),
ctimeMs: this.ctime?.valueOf(),
birthtimeMs: this.birthtime?.valueOf(),
addedAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf(),
...this.toOldJSONMinified(),
lastScan: this.lastScan?.valueOf(),
scanVersion: this.lastScanVersion,
isMissing: !!this.isMissing,
isInvalid: !!this.isInvalid,
mediaType: this.mediaType,
media: this.media.toOldJSONExpanded(this.id),
// LibraryFile JSON includes a fileType property that may not be saved in libraryFiles column in the database
libraryFiles: this.getLibraryFilesJson(),
size: this.size
libraryFiles: this.getLibraryFilesJson()
}
}
}
+14 -12
View File
@@ -451,6 +451,11 @@ class Podcast extends Model {
}
}
/**
* Minified podcast JSON for list/shelf endpoints.
* `toOldJSONExpanded()` must be a strict superset: every key here must exist in expanded
* with the same value semantics. Only additive changes to expanded; never remove or rename keys.
*/
toOldJSONMinified() {
return {
id: this.id,
@@ -468,6 +473,12 @@ class Podcast extends Model {
}
}
/**
* Expanded podcast JSON for item detail and socket events.
* Must be a strict superset of `toOldJSONMinified()` — built by spreading minified, then adding expanded-only fields.
*
* @param {string} libraryItemId
*/
toOldJSONExpanded(libraryItemId) {
if (!libraryItemId) {
throw new Error(`[Podcast] Cannot convert to old JSON because libraryItemId is not provided`)
@@ -477,18 +488,9 @@ class Podcast extends Model {
}
return {
id: this.id,
libraryItemId: libraryItemId,
metadata: this.oldMetadataToJSONExpanded(),
coverPath: this.coverPath,
tags: [...(this.tags || [])],
episodes: this.podcastEpisodes.map((e) => e.toOldJSONExpanded(libraryItemId)),
autoDownloadEpisodes: this.autoDownloadEpisodes,
autoDownloadSchedule: this.autoDownloadSchedule,
lastEpisodeCheck: this.lastEpisodeCheck?.valueOf() || null,
maxEpisodesToKeep: this.maxEpisodesToKeep,
maxNewEpisodesToDownload: this.maxNewEpisodesToDownload,
size: this.size
...this.toOldJSONMinified(),
libraryItemId,
episodes: this.podcastEpisodes.map((e) => e.toOldJSONExpanded(libraryItemId))
}
}
}
+12
View File
@@ -18,6 +18,10 @@ class Session extends Model {
this.userId
/** @type {Date} */
this.expiresAt
/** @type {string} */
this.lastRefreshToken
/** @type {Date} */
this.lastRefreshTokenExpiresAt
// Expanded properties
@@ -66,6 +70,14 @@ class Session extends Model {
expiresAt: {
type: DataTypes.DATE,
allowNull: false
},
lastRefreshToken: {
type: DataTypes.STRING,
allowNull: true
},
lastRefreshTokenExpiresAt: {
type: DataTypes.DATE,
allowNull: true
}
},
{
+12 -13
View File
@@ -189,7 +189,7 @@ class User extends Model {
* @param {import('../Auth')} auth
* @returns {Promise<User>}
*/
static async createRootUser(username, pash, auth) {
static async createRootUser(username, pash, auth, transaction = null) {
const userId = uuidv4()
const token = auth.generateAccessToken({ id: userId, username })
@@ -207,7 +207,7 @@ class User extends Model {
seriesHideFromContinueListening: []
}
}
return this.create(newUser)
return this.create(newUser, { transaction })
}
/**
@@ -352,11 +352,7 @@ class User extends Model {
if (cachedUser) return cachedUser
const user = await this.findOne({
where: {
username: {
[sequelize.Op.like]: username
}
},
where: sequelize.where(sequelize.fn('lower', sequelize.col('username')), username.toLowerCase()),
include: this.sequelize.models.mediaProgress
})
@@ -377,11 +373,7 @@ class User extends Model {
if (cachedUser) return cachedUser
const user = await this.findOne({
where: {
email: {
[sequelize.Op.like]: email
}
},
where: sequelize.where(sequelize.fn('lower', sequelize.col('email')), email.toLowerCase()),
include: this.sequelize.models.mediaProgress
})
@@ -538,7 +530,14 @@ class User extends Model {
},
{
sequelize,
modelName: 'user'
modelName: 'user',
hooks: {
beforeDestroy(user) {
if (user.type === 'root') {
throw new Error('Root user cannot be deleted')
}
}
}
}
)
}
+6 -1
View File
@@ -96,7 +96,12 @@ class DeviceInfo {
this.clientVersion = stripAllTags(clientDeviceInfo?.clientVersion) || serverVersion
this.manufacturer = stripAllTags(clientDeviceInfo?.manufacturer) || null
this.model = stripAllTags(clientDeviceInfo?.model) || null
this.sdkVersion = stripAllTags(clientDeviceInfo?.sdkVersion) || null
if (typeof clientDeviceInfo?.sdkVersion === 'number') {
this.sdkVersion = clientDeviceInfo.sdkVersion.toString()
} else {
this.sdkVersion = stripAllTags(clientDeviceInfo?.sdkVersion) || null
}
this.clientName = stripAllTags(clientDeviceInfo?.clientName) || null
if (this.sdkVersion) {
+40 -18
View File
@@ -5,7 +5,31 @@ const Logger = require('../../Logger')
const User = require('../../models/User')
const { sanitize } = require('../../utils/htmlSanitizer')
const PATCHABLE_SETTINGS_KEYS = new Set([
'scannerParseSubtitle',
'scannerFindCovers',
'scannerCoverProvider',
'scannerPreferMatchedMetadata',
'scannerDisableWatcher',
'storeCoverWithItem',
'storeMetadataWithItem',
'allowIframe',
'allowedOrigins',
'backupSchedule',
'backupsToKeep',
'maxBackupSize',
'logLevel',
'homeBookshelfView',
'bookshelfView',
'dateFormat',
'timeFormat',
'language',
'chromecastEnabled',
'sortingIgnorePrefix'
])
class ServerSettings {
static patchableSettingsKeys = PATCHABLE_SETTINGS_KEYS
constructor(settings) {
this.id = 'server-settings'
/** @type {string} JWT secret key ONLY used when JWT_SECRET_KEY is not set in ENV */
@@ -260,6 +284,18 @@ class ServerSettings {
}
}
/**
* Host timezone used by cron schedulers (not persisted in settings)
* @returns {string} IANA timezone name, e.g. "America/New_York"
*/
static getHostTimeZone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
} catch {
return 'UTC'
}
}
toJSONForBrowser() {
const json = this.toJSON()
delete json.tokenSecret
@@ -268,6 +304,7 @@ class ServerSettings {
delete json.authOpenIDMobileRedirectURIs
delete json.authOpenIDGroupClaim
delete json.authOpenIDAdvancedPermsClaim
json.timeZone = ServerSettings.getHostTimeZone()
return json
}
@@ -328,24 +365,9 @@ class ServerSettings {
update(payload) {
let hasUpdates = false
for (const key in payload) {
if (key === 'authLoginCustomMessage') {
payload[key] = sanitize(payload[key])
}
if (key === 'sortingPrefixes') {
// Sorting prefixes are updated with the /api/sorting-prefixes endpoint
continue
} else if (key === 'authActiveAuthMethods') {
if (!payload[key]?.length) {
Logger.error(`[ServerSettings] Invalid authActiveAuthMethods`, payload[key])
continue
}
this.authActiveAuthMethods.sort()
payload[key].sort()
if (payload[key].join() !== this.authActiveAuthMethods.join()) {
this.authActiveAuthMethods = payload[key]
hasUpdates = true
}
} else if (this[key] !== payload[key]) {
if (!PATCHABLE_SETTINGS_KEYS.has(key)) continue
if (this[key] !== payload[key]) {
if (key === 'logLevel') {
Logger.setLogLevel(payload[key])
}
+3 -2
View File
@@ -83,7 +83,8 @@ class Audible {
duration: runtimeLengthMin && !isNaN(runtimeLengthMin) ? Number(runtimeLengthMin) : 0,
region: item.region || null,
rating: item.rating || null,
abridged: formatType === 'abridged'
abridged: formatType === 'abridged',
explicit: !!item.isAdult
}
}
@@ -159,7 +160,7 @@ class Audible {
timeout
})
.then((res) => {
if (!res?.data?.products) return null
if (!res?.data?.products) return []
return Promise.all(res.data.products.map((result) => this.asinSearch(result.asin, region, timeout)))
})
.catch((error) => {
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff. Show more