Compare 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
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
Finn Dittmar dfce15a74c Add support that API-Keys authenticate sockets 2026-01-09 20:45:48 +01:00
95 changed files with 4510 additions and 1410 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
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.35.1",
"version": "2.37.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf-client",
"version": "2.35.1",
"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.35.1",
"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>
+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}\" як завершаны?",
+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",
+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?",
+6 -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",
+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
}
]
+2 -4
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']) {
@@ -32,9 +32,7 @@ if (isDev || options['prod-with-dev-env']) {
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.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.35.1",
"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
}
}
+6 -6
View File
@@ -12,6 +12,8 @@
</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.
@@ -73,7 +75,7 @@ See [library docs](https://audiobookshelf.org/docs/category/libraries) for suppo
# Installation
See [install docs](https://www.audiobookshelf.org/docs)
See [install docs](https://audiobookshelf.org/docs/category/installation)
<br />
@@ -93,8 +95,7 @@ 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>
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 />
@@ -164,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`.
@@ -182,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.
@@ -191,4 +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.
+21 -8
View File
@@ -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) {
+59 -3
View File
@@ -46,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
@@ -54,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'
@@ -158,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()
@@ -409,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/'
@@ -436,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)
}
+44 -8
View File
@@ -269,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' })
+5 -1
View File
@@ -1,3 +1,4 @@
const { LRUCache } = require('lru-cache')
const { Request, Response } = require('express')
const passport = require('passport')
const OpenIDClient = require('openid-client')
@@ -15,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
})
}
/**
+21 -4
View File
@@ -87,16 +87,14 @@ class TokenManager {
/**
* 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 {
const decoded = jwt.verify(token, TokenManager.TokenSecret)
if (!TokenManager.isBearerAccessTokenPayload(decoded)) return null
return decoded
// 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
}
@@ -505,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
*
+1
View File
@@ -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`)
+5 -5
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)
@@ -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)
+78 -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,81 @@ 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
*
+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
+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)
@@ -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}"`)
-15
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()
+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 || '',
+12 -24
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
+2 -2
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 })
}
/**
+27 -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 */
@@ -341,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) => {
+2
View File
@@ -171,6 +171,8 @@ class ApiRouter {
// Current User Routes (Me)
//
this.router.get('/me', MeController.getCurrentUser.bind(this))
this.router.get('/me/sessions', MeController.getSessions.bind(this))
this.router.delete('/me/sessions/:id', MeController.deleteSession.bind(this))
this.router.get('/me/progress', MeController.getAllMediaProgress.bind(this))
this.router.get('/me/bookmarks', MeController.getAllBookmarks.bind(this))
this.router.get('/me/bookmarks/:libraryItemId', MeController.getBookmarksForLibraryItem.bind(this))
+2 -8
View File
@@ -515,10 +515,7 @@ class LibraryScanner {
attributes: ['id', 'path'],
where: {
path: {
[sequelize.Op.not]: fullPath
},
path: {
[sequelize.Op.startsWith]: fullPath
[sequelize.Op.startsWith]: fullPath + '/'
}
}
})
@@ -531,10 +528,7 @@ class LibraryScanner {
attributes: ['id', 'path'],
where: {
path: {
[sequelize.Op.not]: altFullPath
},
path: {
[sequelize.Op.startsWith]: altFullPath
[sequelize.Op.startsWith]: altFullPath + '/'
}
}
})
+12 -2
View File
@@ -58,8 +58,12 @@ class PodcastScanner {
/** @type {AudioFile[]} */
let newAudioFiles = []
// Track episode add/remove/update so item_updated includes the current episode list
let hasEpisodeChanges = false
if (libraryItemData.hasAudioFileChanges || libraryItemData.audioLibraryFiles.length !== existingPodcastEpisodes.length) {
// Filter out and destroy episodes that were removed
// Filter out and destroy episodes that were removed.
// filter() returns a new array — reassign to media.podcastEpisodes before emit.
const episodesToRemove = []
existingPodcastEpisodes = existingPodcastEpisodes.filter((ep) => {
if (libraryItemData.checkAudioFileRemoved(ep.audioFile)) {
@@ -70,6 +74,7 @@ class PodcastScanner {
})
if (episodesToRemove.length) {
hasEpisodeChanges = true
// Remove episodes from playlists and media progress
const episodeIds = episodesToRemove.map((ep) => ep.id)
await Database.playlistModel.removeMediaItemsFromPlaylists(episodeIds)
@@ -116,6 +121,7 @@ class PodcastScanner {
AudioFileScanner.setPodcastEpisodeMetadataFromAudioMetaTags(podcastEpisode, libraryScan)
libraryScan.addLog(LogLevel.INFO, `Podcast episode "${podcastEpisode.title}" keys changed [${podcastEpisode.changed()?.join(', ')}]`)
await podcastEpisode.save()
hasEpisodeChanges = true
}
}
@@ -155,9 +161,13 @@ class PodcastScanner {
libraryScan.addLog(LogLevel.INFO, `New Podcast episode "${newPodcastEpisode.title}" added`)
await newPodcastEpisode.save()
existingPodcastEpisodes.push(newPodcastEpisode)
hasEpisodeChanges = true
}
}
// Keep association in sync for toOldJSONExpanded() / item_updated socket payload
media.podcastEpisodes = existingPodcastEpisodes
let hasMediaChanges = false
if (existingPodcastEpisodes.length !== media.numEpisodes) {
media.numEpisodes = existingPodcastEpisodes.length
@@ -250,7 +260,7 @@ class PodcastScanner {
return {
libraryItem: existingLibraryItem,
wasUpdated: hasMediaChanges || libraryItemUpdated
wasUpdated: hasMediaChanges || libraryItemUpdated || hasEpisodeChanges
}
}
+32 -5
View File
@@ -6,6 +6,21 @@ const fs = require('../libs/fsExtra')
const StreamZip = require('../libs/nodeStreamZip')
const Archive = require('../libs/libarchive/archive')
const { isWritable } = require('./fileUtils')
const { sanitizePath } = require('../libs/archiver/archiverUtils')
/**
* Sanitize a path from an archive
*
* @param {string} filename
* @returns {string}
*/
function sanitizeArchivePath(filename) {
const sanitizedPath = Path.normalize(sanitizePath(filename))
if (!sanitizedPath || sanitizedPath === '.' || sanitizedPath === '..' || sanitizedPath.startsWith(`..${Path.sep}`) || Path.isAbsolute(sanitizedPath)) {
throw new Error(`[CbrComicBookExtractor] Unsafe archive path "${filename}"`)
}
return sanitizedPath
}
class AbstractComicBookExtractor {
constructor(comicPath) {
@@ -57,7 +72,11 @@ class CbrComicBookExtractor extends AbstractComicBookExtractor {
this.tmpDir = global.MetadataPath ? Path.join(global.MetadataPath, 'tmp') : os.tmpdir()
await fs.ensureDir(this.tmpDir)
if (!(await isWritable(this.tmpDir))) throw new Error(`[CbrComicBookExtractor] Temp directory "${this.tmpDir}" is not writable`)
this.archive = await unrar.createExtractorFromFile({ filepath: this.comicPath, targetPath: this.tmpDir })
this.archive = await unrar.createExtractorFromFile({
filepath: this.comicPath,
targetPath: this.tmpDir,
filenameTransform: sanitizeArchivePath
})
Logger.debug(`[CbrComicBookExtractor] Opened comic book "${this.comicPath}". Using temp directory "${this.tmpDir}" for extraction.`)
}
@@ -81,14 +100,22 @@ class CbrComicBookExtractor extends AbstractComicBookExtractor {
}
}
getExtractedFilePath(file) {
const sanitizedFile = sanitizeArchivePath(file)
return {
filePath: Path.join(this.tmpDir, sanitizedFile),
relativePath: sanitizedFile
}
}
async extractToBuffer(file) {
if (!this.archive) return null
const extracted = this.archive.extract({ files: [file] })
const files = [...extracted.files]
const filePath = Path.join(this.tmpDir, files[0].fileHeader.name)
const { filePath, relativePath } = this.getExtractedFilePath(files[0].fileHeader.name)
const fileData = await fs.readFile(filePath)
await fs.remove(filePath)
await this.removeEmptyParentDirs(files[0].fileHeader.name)
await this.removeEmptyParentDirs(relativePath)
Logger.debug(`[CbrComicBookExtractor] Extracted file "${file}" from comic book "${this.comicPath}" to buffer, size: ${fileData.length}`)
return fileData
}
@@ -97,9 +124,9 @@ class CbrComicBookExtractor extends AbstractComicBookExtractor {
if (!this.archive) return false
const extracted = this.archive.extract({ files: [file] })
const files = [...extracted.files]
const extractedFilePath = Path.join(this.tmpDir, files[0].fileHeader.name)
const { filePath: extractedFilePath, relativePath } = this.getExtractedFilePath(files[0].fileHeader.name)
await fs.move(extractedFilePath, outputFilePath, { overwrite: true })
await this.removeEmptyParentDirs(files[0].fileHeader.name)
await this.removeEmptyParentDirs(relativePath)
Logger.debug(`[CbrComicBookExtractor] Extracted file "${file}" from comic book "${this.comicPath}" to "${outputFilePath}"`)
return true
}
+11 -1
View File
@@ -1,10 +1,13 @@
const { createNewSortInstance } = require('../libs/fastSort')
const Database = require('../Database')
const Logger = require('../Logger')
const { getTitlePrefixAtEnd, isNullOrNaN, getTitleIgnorePrefix } = require('../utils/index')
const naturalSort = createNewSortInstance({
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
})
const VALID_COLLAPSE_SUBSERIES_SORTS = new Set(['addedAt', 'size', 'birthtimeMs', 'mtimeMs', 'media.duration', 'media.metadata.publishedYear', 'media.metadata.authorName', 'media.metadata.authorNameLF', 'media.metadata.title', 'sequence', 'progress', 'progress.createdAt', 'progress.finishedAt', 'random'])
module.exports = {
/**
*
@@ -100,6 +103,11 @@ module.exports = {
* @returns {Object[]}
*/
async handleCollapseSubseries(payload, seriesId, user, library) {
if (payload.sortBy && !VALID_COLLAPSE_SUBSERIES_SORTS.has(payload.sortBy)) {
Logger.warn(`[libraryHelpers] Invalid "sort" query string "${payload.sortBy}"`)
payload.sortBy = undefined
}
const seriesWithBooks = await Database.seriesModel.findByPk(seriesId, {
include: {
model: Database.bookModel,
@@ -196,7 +204,9 @@ module.exports = {
return li.collapsedSeries?.name || li.media.title
}
} else {
return payload.sortBy.split('.').reduce((a, b) => a[b], li)
if (payload.sortBy === 'media.metadata.authorName') return li.authorNamesFirstLast ?? ''
if (payload.sortBy === 'media.metadata.authorNameLF') return li.authorNamesLastFirst ?? ''
return payload.sortBy.split('.').reduce((a, b) => a?.[b], li) ?? ''
}
}
})
+28
View File
@@ -0,0 +1,28 @@
const uaParserJs = require('../../libs/uaParser')
/**
* @param {string|null|undefined} userAgent
* @returns {{ browserName?:string, browserVersion?:string, osName?:string, osVersion?:string, deviceType?:string, model?:string, vendor?:string }|null}
*/
function parseUserAgent(userAgent) {
if (!userAgent) return null
const ua = uaParserJs(userAgent)
const deviceInfo = {
browserName: ua?.browser?.name || undefined,
browserVersion: ua?.browser?.version || undefined,
osName: ua?.os?.name || undefined,
osVersion: ua?.os?.version || undefined,
deviceType: ua?.device?.type || undefined,
model: ua?.device?.model || undefined,
vendor: ua?.device?.vendor || undefined
}
for (const key in deviceInfo) {
if (deviceInfo[key] === undefined) delete deviceInfo[key]
}
return Object.keys(deviceInfo).length ? deviceInfo : null
}
module.exports = parseUserAgent
+1 -1
View File
@@ -69,7 +69,7 @@ function extractStringOrStringify(json) {
return json[Object.keys(json)[0]][0]
}
// Handles case where html was included without being wrapped in CDATA
return JSON.stringify(value)
return JSON.stringify(json)
} catch {
return ''
}
+14 -14
View File
@@ -12,22 +12,22 @@ function tryGrabBitRate(stream, all_streams, total_bit_rate) {
}
// Attempt to get bitrate from bps tags
var bps = stream.tags.BPS || stream.tags['BPS-eng'] || stream.tags['BPS_eng']
const bps = stream.tags.BPS || stream.tags['BPS-eng'] || stream.tags['BPS_eng']
if (bps && !isNaN(bps)) {
return Number(bps)
}
var tagDuration = stream.tags.DURATION || stream.tags['DURATION-eng'] || stream.tags['DURATION_eng']
var tagBytes = stream.tags.NUMBER_OF_BYTES || stream.tags['NUMBER_OF_BYTES-eng'] || stream.tags['NUMBER_OF_BYTES_eng']
const tagDuration = stream.tags.DURATION || stream.tags['DURATION-eng'] || stream.tags['DURATION_eng']
const tagBytes = stream.tags.NUMBER_OF_BYTES || stream.tags['NUMBER_OF_BYTES-eng'] || stream.tags['NUMBER_OF_BYTES_eng']
if (tagDuration && tagBytes && !isNaN(tagDuration) && !isNaN(tagBytes)) {
var bps = Math.floor((Number(tagBytes) * 8) / Number(tagDuration))
if (bps && !isNaN(bps)) {
return bps
const calculatedBps = Math.floor((Number(tagBytes) * 8) / Number(tagDuration))
if (calculatedBps && !isNaN(calculatedBps)) {
return calculatedBps
}
}
if (total_bit_rate && stream.codec_type === 'video') {
var estimated_bit_rate = total_bit_rate
let estimated_bit_rate = total_bit_rate
all_streams.forEach((stream) => {
if (stream.bit_rate && !isNaN(stream.bit_rate)) {
estimated_bit_rate -= Number(stream.bit_rate)
@@ -46,9 +46,9 @@ function tryGrabBitRate(stream, all_streams, total_bit_rate) {
}
function tryGrabFrameRate(stream) {
var avgFrameRate = stream.avg_frame_rate || stream.r_frame_rate
let avgFrameRate = stream.avg_frame_rate || stream.r_frame_rate
if (!avgFrameRate) return null
var parts = avgFrameRate.split('/')
const parts = avgFrameRate.split('/')
if (parts.length === 2) {
avgFrameRate = Number(parts[0]) / Number(parts[1])
} else {
@@ -59,13 +59,13 @@ function tryGrabFrameRate(stream) {
}
function tryGrabSampleRate(stream) {
var sample_rate = stream.sample_rate
const sample_rate = stream.sample_rate
if (!isNaN(sample_rate)) return Number(sample_rate)
return null
}
function tryGrabChannelLayout(stream) {
var layout = stream.channel_layout
const layout = stream.channel_layout
if (!layout) return null
return String(layout).split('(').shift()
}
@@ -81,7 +81,7 @@ function tryGrabTags(stream, ...tags) {
}
function parseMediaStreamInfo(stream, all_streams, total_bit_rate) {
var info = {
const info = {
index: stream.index,
type: stream.codec_type,
codec: stream.codec_name || null,
@@ -95,7 +95,7 @@ function parseMediaStreamInfo(stream, all_streams, total_bit_rate) {
if (stream.tags) info.tags = stream.tags
if (info.type === 'audio' || info.type === 'subtitle') {
var disposition = stream.disposition || {}
const disposition = stream.disposition || {}
info.is_default = disposition.default === 1 || disposition.default === '1'
}
@@ -230,7 +230,7 @@ function parseTags(format, verbose) {
function getDefaultAudioStream(audioStreams) {
if (!audioStreams || !audioStreams.length) return null
if (audioStreams.length === 1) return audioStreams[0]
var defaultStream = audioStreams.find((a) => a.is_default)
const defaultStream = audioStreams.find((a) => a.is_default)
if (!defaultStream) return audioStreams[0]
return defaultStream
}
+6 -1
View File
@@ -11,7 +11,12 @@ const naturalSort = createNewSortInstance({
module.exports = {
decode(text) {
return Buffer.from(decodeURIComponent(text), 'base64').toString()
try {
return Buffer.from(decodeURIComponent(text), 'base64').toString()
} catch (error) {
Logger.warn(`[libraryFilters] Failed to decode filter value "${text}": ${error.message}`)
return null
}
},
/**
+4 -2
View File
@@ -133,9 +133,10 @@ module.exports = {
/**
* Get all library items that have narrators
* @param {string[]} narrators
* @param {string} libraryId
* @returns {Promise<import('../../models/LibraryItem')[]>}
*/
async getAllLibraryItemsWithNarrators(narrators) {
async getAllLibraryItemsWithNarrators(narrators, libraryId) {
const libraryItems = []
const booksWithGenre = await Database.bookModel.findAll({
where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(narrators) WHERE json_valid(narrators) AND json_each.value IN (:narrators))`), {
@@ -146,7 +147,8 @@ module.exports = {
},
include: [
{
model: Database.libraryItemModel
model: Database.libraryItemModel,
where: { libraryId }
},
{
model: Database.authorModel,
@@ -309,11 +309,12 @@ module.exports = {
* @param {Sequelize.WhereOptions} seriesWhere
* @returns {object} { booksToExclude, bookSeriesToInclude }
*/
async getCollapseSeriesBooksToExclude(bookFindOptions, seriesWhere) {
async getCollapseSeriesBooksToExclude(bookFindOptions, seriesWhere, replacements = {}) {
const allSeries = await Database.seriesModel.findAll({
attributes: ['id', 'name', [Sequelize.literal('(SELECT count(*) FROM bookSeries bs WHERE bs.seriesId = series.id)'), 'numBooks']],
distinct: true,
subQuery: false,
replacements,
where: seriesWhere,
include: [
{
@@ -581,7 +582,7 @@ module.exports = {
...bookIncludes
]
}
const { booksToExclude, bookSeriesToInclude } = await this.getCollapseSeriesBooksToExclude(bookFindOptions, seriesWhere)
const { booksToExclude, bookSeriesToInclude } = await this.getCollapseSeriesBooksToExclude(bookFindOptions, seriesWhere, replacements)
if (booksToExclude.length) {
bookWhere.push({
id: {
+6 -1
View File
@@ -5,7 +5,12 @@ const libraryItemsBookFilters = require('./libraryItemsBookFilters')
module.exports = {
decode(text) {
return Buffer.from(decodeURIComponent(text), 'base64').toString()
try {
return Buffer.from(decodeURIComponent(text), 'base64').toString()
} catch (error) {
Logger.warn(`[seriesFilters] Failed to decode filter value "${text}": ${error.message}`)
return null
}
},
/**
+19 -19
View File
@@ -149,16 +149,16 @@ module.exports.buildLibraryFile = buildLibraryFile
function getBookDataFromDir(relPath, parseSubtitle = false) {
const splitDir = relPath.split('/')
var folder = splitDir.pop() // Audio files will always be in the directory named for the title
series = splitDir.length > 1 ? splitDir.pop() : null // If there are at least 2 more directories, next furthest will be the series
author = splitDir.length > 0 ? splitDir.pop() : null // There could be many more directories, but only the top 3 are used for naming /author/series/title/
const titleFolder = splitDir.pop() // Audio files will always be in the directory named for the title
const series = splitDir.length > 1 ? splitDir.pop() : null // If there are at least 2 more directories, next furthest will be the series
const author = splitDir.length > 0 ? splitDir.pop() : null // There could be many more directories, but only the top 3 are used for naming /author/series/title/
// The may contain various other pieces of metadata, these functions extract it.
var [folder, asin] = getASIN(folder)
var [folder, narrators] = getNarrator(folder)
var [folder, sequence] = series ? getSequence(folder) : [folder, null]
var [folder, publishedYear] = getPublishedYear(folder)
var [title, subtitle] = parseSubtitle ? getSubtitle(folder) : [folder, null]
// Each extractor strips one piece of metadata and returns the remaining folder name.
const [afterAsin, asin] = getASIN(titleFolder)
const [afterNarrators, narrators] = getNarrator(afterAsin)
const [afterSequence, sequence] = series ? getSequence(afterNarrators) : [afterNarrators, null]
const [afterYear, publishedYear] = getPublishedYear(afterSequence)
const [title, subtitle] = parseSubtitle ? getSubtitle(afterYear) : [afterYear, null]
return {
title,
@@ -180,8 +180,8 @@ module.exports.getBookDataFromDir = getBookDataFromDir
* @returns {[string, string]} [folder, narrator]
*/
function getNarrator(folder) {
let pattern = /^(?<title>.*) \{(?<narrators>.*)\}$/
let match = folder.match(pattern)
const pattern = /^(?<title>.*) \{(?<narrators>.*)\}$/
const match = folder.match(pattern)
return match ? [match.groups.title, match.groups.narrators] : [folder, null]
}
@@ -204,12 +204,12 @@ function getNarrator(folder) {
*/
function getSequence(folder) {
// Matches a valid volume string. Also matches a book whose title starts with a 1 to 3 digit number. Will handle that later.
let pattern = /^(?<volumeLabel>vol\.? |volume |book )?(?<sequence>\d{0,3}(?:\.\d{1,2})?)(?<trailingDot>\.?)(?: (?<suffix>.*))?$/i
const pattern = /^(?<volumeLabel>vol\.? |volume |book )?(?<sequence>\d{0,3}(?:\.\d{1,2})?)(?<trailingDot>\.?)(?: (?<suffix>.*))?$/i
let volumeNumber = null
let parts = folder.split(' - ')
const parts = folder.split(' - ')
for (let i = 0; i < parts.length; i++) {
let match = parts[i].match(pattern)
const match = parts[i].match(pattern)
// This excludes '101 Dalmations' but includes '101. Dalmations'
if (match && !(match.groups.suffix && !(match.groups.volumeLabel || match.groups.trailingDot))) {
volumeNumber = isNaN(match.groups.sequence) ? match.groups.sequence : Number(match.groups.sequence).toString()
@@ -232,10 +232,10 @@ function getSequence(folder) {
* @returns {[string, string]} [folder, publishedYear]
*/
function getPublishedYear(folder) {
var publishedYear = null
let publishedYear = null
pattern = /^ *\(?([0-9]{4})\)? * - *(.+)/ //Matches #### - title or (####) - title
var match = folder.match(pattern)
const pattern = /^ *\(?([0-9]{4})\)? * - *(.+)/ //Matches #### - title or (####) - title
const match = folder.match(pattern)
if (match) {
publishedYear = match[1]
folder = match[2]
@@ -252,7 +252,7 @@ function getPublishedYear(folder) {
*/
function getSubtitle(folder) {
// Subtitle is everything after " - "
var splitTitle = folder.split(' - ')
const splitTitle = folder.split(' - ')
return [splitTitle.shift(), splitTitle.join(' - ')]
}
@@ -265,7 +265,7 @@ function getSubtitle(folder) {
function getASIN(folder) {
let asin = null
let pattern = /(?: |^)\[([A-Z0-9]{10})](?= |$)/ // Matches "[B0015T963C]"
const pattern = /(?: |^)\[([A-Z0-9]{10})](?= |$)/ // Matches "[B0015T963C]"
const match = folder.match(pattern)
if (match) {
asin = match[1]
+23
View File
@@ -75,3 +75,26 @@ describe('OidcAuthStrategy - isValidWebCallbackUrl', () => {
expect(strategy.isValidWebCallbackUrl('http://books.example.com/login', req)).to.equal(false)
})
})
describe('OidcAuthStrategy - openIdAuthSession', () => {
/** @type {OidcAuthStrategy} */
let strategy
beforeEach(() => {
global.RouterBasePath = ''
strategy = new OidcAuthStrategy()
})
it('configures a 10 minute TTL with autopurge so abandoned mobile logins are cleaned up', () => {
expect(strategy.openIdAuthSession.ttl).to.equal(10 * 60 * 1000)
expect(strategy.openIdAuthSession.ttlAutopurge).to.equal(true)
})
it('still supports the same set/has/get/delete usage as a plain Map', () => {
strategy.openIdAuthSession.set('some-state', { mobile_redirect_uri: 'myapp://callback' })
expect(strategy.openIdAuthSession.has('some-state')).to.equal(true)
expect(strategy.openIdAuthSession.get('some-state')).to.deep.equal({ mobile_redirect_uri: 'myapp://callback' })
strategy.openIdAuthSession.delete('some-state')
expect(strategy.openIdAuthSession.has('some-state')).to.equal(false)
})
})
-5
View File
@@ -28,11 +28,6 @@ describe('TokenManager', () => {
})
describe('validateAccessToken', () => {
it('rejects refresh tokens', () => {
const refreshToken = jwt.sign({ userId, type: 'refresh' }, secret, { expiresIn: 3600 })
expect(TokenManager.validateAccessToken(refreshToken)).to.equal(null)
})
it('accepts access tokens', () => {
const accessToken = jwt.sign({ userId, type: 'access' }, secret, { expiresIn: 3600 })
const decoded = TokenManager.validateAccessToken(accessToken)
+96
View File
@@ -0,0 +1,96 @@
const { expect } = require('chai')
const sinon = require('sinon')
const fs = require('graceful-fs')
const os = require('os')
const path = require('path')
const fsExtra = require('../../server/libs/fsExtra')
describe('fsExtra copy fallback', () => {
let dir
let source
let dest
let contents
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'abs-copy-'))
source = path.join(dir, 'source')
dest = path.join(dir, 'dest')
contents = Buffer.alloc(2 * 1024 * 1024 + 17, 0x61)
fs.writeFileSync(source, contents)
})
afterEach(() => {
sinon.restore()
fs.rmSync(dir, { recursive: true, force: true })
})
it('copies through streams when asynchronous copyFile is denied', async () => {
sinon.stub(fs, 'copyFile').callsFake((src, target, callback) => {
callback(Object.assign(new Error('copyfile denied'), { code: 'EPERM' }))
})
await fsExtra.copy(source, dest)
expect(fs.readFileSync(dest).equals(contents)).to.be.true
})
it('moves across devices when asynchronous copyFile is denied', async () => {
sinon.stub(fs, 'rename').callsFake((src, target, callback) => {
callback(Object.assign(new Error('cross-device link'), { code: 'EXDEV' }))
})
sinon.stub(fs, 'copyFile').callsFake((src, target, callback) => {
callback(Object.assign(new Error('copyfile denied'), { code: 'EPERM' }))
})
await fsExtra.move(source, dest)
expect(fs.existsSync(source)).to.be.false
expect(fs.readFileSync(dest).equals(contents)).to.be.true
})
it('moves over an existing file when asynchronous rename and copyFile are denied', async () => {
fs.writeFileSync(dest, 'old database')
sinon.stub(fs, 'rename').callsFake((src, target, callback) => {
callback(Object.assign(new Error('rename denied'), { code: 'EPERM' }))
})
sinon.stub(fs, 'copyFile').callsFake((src, target, callback) => {
callback(Object.assign(new Error('copyfile denied'), { code: 'EPERM' }))
})
await fsExtra.move(source, dest, { overwrite: true })
expect(fs.existsSync(source)).to.be.false
expect(fs.readFileSync(dest).equals(contents)).to.be.true
})
it('keeps the source when a move cannot copy it', async () => {
sinon.stub(fs, 'rename').callsFake((src, target, callback) => {
callback(Object.assign(new Error('rename denied'), { code: 'EPERM' }))
})
sinon.stub(fs, 'copyFile').callsFake((src, target, callback) => {
callback(Object.assign(new Error('read failed'), { code: 'EIO' }))
})
try {
await fsExtra.move(source, dest)
expect.fail('Expected move to fail')
} catch (error) {
expect(error.code).to.equal('EIO')
}
expect(fs.readFileSync(source).equals(contents)).to.be.true
})
it('propagates unrelated copy errors', async () => {
sinon.stub(fs, 'copyFile').callsFake((src, target, callback) => {
callback(Object.assign(new Error('file missing'), { code: 'ENOENT' }))
})
try {
await fsExtra.copy(source, dest)
expect.fail('Expected copy to fail')
} catch (error) {
expect(error.code).to.equal('ENOENT')
}
expect(fs.existsSync(dest)).to.be.false
})
})
@@ -17,6 +17,7 @@ describe('MigrationManager', () => {
let fsMoveStub
let fsRemoveStub
let fsEnsureDirStub
let fsPathExistsStub
let processExitStub
let configPath = '/path/to/config'
+22
View File
@@ -45,4 +45,26 @@ describe('Audible', () => {
expect(result).to.equal('.5')
})
})
describe('cleanResult', () => {
const baseItem = {
title: 'Some Book',
formatType: 'unabridged'
}
it('should set explicit to true when isAdult is true', () => {
const result = audible.cleanResult({ ...baseItem, isAdult: true })
expect(result.explicit).to.equal(true)
})
it('should set explicit to false when isAdult is false', () => {
const result = audible.cleanResult({ ...baseItem, isAdult: false })
expect(result.explicit).to.equal(false)
})
it('should set explicit to false when isAdult is not present', () => {
const result = audible.cleanResult({ ...baseItem })
expect(result.explicit).to.equal(false)
})
})
})
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"moduleResolution": "node",
"rootDir": ".",
"outDir": "dist-server",
"allowJs": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"noEmitOnError": true,
"incremental": true,
"tsBuildInfoFile": "dist-server/.tsbuildinfo",
"forceConsistentCasingInFileNames": true
},
"include": ["index.js", "dev.js", "server/**/*.js", "server/**/*.ts", "test/**/*.js", "test/**/*.ts"],
"exclude": ["node_modules", "dist-server", "client", "**/*.d.ts"]
}