Compare commits

...

131 Commits

Author SHA1 Message Date
advplyr b816c0e7c4 Fix opening feed for series and collections 2025-01-20 14:18:22 -06:00
advplyr a8b92819d1 Update feed episode description to use CDATA 2025-01-20 14:04:18 -06:00
advplyr 54a4b09592 Update RSS feed to exclude empty tags, format duration, use CDATA 2025-01-20 13:57:56 -06:00
advplyr f13283b950 Merge pull request #3864 from mikiher/subdir-support-fix-missing-img
Fix missing texture image & epub ebook url for subdirectory support
2025-01-20 09:09:39 -06:00
advplyr 78994b3589 Update epub ebook url to include routerBasePath 2025-01-20 09:06:45 -06:00
advplyr 6745efc4d6 Revert case-insensitive cache manager update in #3780 2025-01-20 08:59:45 -06:00
mikiher 6c540ad789 Fix missing texture image for subdirectory support 2025-01-20 08:38:58 +02:00
advplyr 64992b3308 Version bump v2.18.0 2025-01-19 17:11:36 -06:00
advplyr ea9552e9a9 Merge pull request #3854 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2025-01-19 17:09:47 -06:00
J. Lavoie 60add37ba0 Translated using Weblate (Italian)
Currently translated at 98.6% (1067 of 1082 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/it/
2025-01-19 18:56:13 +01:00
J. Lavoie 6182764340 Translated using Weblate (French)
Currently translated at 98.8% (1070 of 1082 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/
2025-01-19 18:56:13 +01:00
J. Lavoie d8de61437c Translated using Weblate (German)
Currently translated at 100.0% (1082 of 1082 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/
2025-01-19 18:56:12 +01:00
J. Lavoie ca5c8a4d41 Translated using Weblate (French)
Currently translated at 98.8% (1070 of 1082 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/
2025-01-19 18:52:09 +01:00
thehijacker 152683ff9c Translated using Weblate (Slovenian)
Currently translated at 100.0% (1082 of 1082 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/
2025-01-19 14:44:53 +00:00
Jan-Eric Myhrgren 0ac92b6dc1 Translated using Weblate (Swedish)
Currently translated at 82.7% (895 of 1082 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/
2025-01-19 14:44:52 +00:00
Илья Червонный 831f9ab9e7 Translated using Weblate (Russian)
Currently translated at 100.0% (1082 of 1082 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ru/
2025-01-19 14:44:51 +00:00
Максим Горпиніч 3a33553aec Translated using Weblate (Ukrainian)
Currently translated at 100.0% (1082 of 1082 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/
2025-01-19 14:44:51 +00:00
Kieli Puoli 94df14f0cb Translated using Weblate (Finnish)
Currently translated at 50.9% (551 of 1082 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fi/
2025-01-19 14:44:50 +00:00
Максим Горпиніч 1d1bdb2f00 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (1082 of 1082 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/
2025-01-19 14:44:50 +00:00
Jan-Eric Myhrgren 3aa6b358b3 Translated using Weblate (Swedish)
Currently translated at 79.9% (865 of 1082 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/
2025-01-19 14:44:49 +00:00
Kieli Puoli 6052bb9fda Translated using Weblate (Finnish)
Currently translated at 44.5% (482 of 1082 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fi/
2025-01-19 14:44:48 +00:00
Kieli Puoli 76b270ddf6 Translated using Weblate (Finnish)
Currently translated at 44.4% (481 of 1082 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fi/
2025-01-19 14:44:48 +00:00
Jan-Eric Myhrgren 318e57170d Translated using Weblate (Swedish)
Currently translated at 78.1% (846 of 1082 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/
2025-01-19 14:44:47 +00:00
Kieli Puoli 5294335bca Translated using Weblate (Finnish)
Currently translated at 44.3% (480 of 1082 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fi/
2025-01-19 14:44:46 +00:00
Kieli Puoli 68af5933e5 Translated using Weblate (Finnish)
Currently translated at 44.2% (479 of 1082 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fi/
2025-01-19 14:44:46 +00:00
Kieli Puoli bc2d7ff14d Translated using Weblate (Finnish)
Currently translated at 44.1% (478 of 1082 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fi/
2025-01-19 14:44:45 +00:00
Jan-Eric Myhrgren 7d278ebc56 Translated using Weblate (Swedish)
Currently translated at 78.1% (846 of 1082 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/
2025-01-19 14:44:45 +00:00
Kieli Puoli 47247323cf Translated using Weblate (Finnish)
Currently translated at 44.0% (477 of 1082 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fi/
2025-01-19 14:44:44 +00:00
Jan-Eric Myhrgren 77ad9c8a16 Translated using Weblate (Swedish)
Currently translated at 78.1% (846 of 1082 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/
2025-01-19 14:44:44 +00:00
advplyr 58ca26436d Merge pull request #3810 from mikiher/enable-subdirectory
Enable subdirectory support by default
2025-01-19 08:44:33 -06:00
advplyr 4a3254d338 Fix create library with mark media as finished when setting #3856 2025-01-18 15:57:44 -06:00
advplyr ebaae98a12 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2025-01-17 17:21:39 -06:00
advplyr 4701b3ed0c Update audiobook rss feeds to increment pub dates in 1 minute intervals #3442 2025-01-17 17:21:35 -06:00
advplyr 4843be89e7 Merge pull request #3833 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2025-01-17 10:41:54 -06:00
advplyr 9a2fb49950 Merge branch 'master' into weblate-audiobookshelf-abs-web-client 2025-01-17 10:41:46 -06:00
advplyr ecbcc8470b Merge pull request #3847 from advplyr/bookmark-modal-updates
Bookmark modal updates
2025-01-16 17:18:02 -06:00
advplyr 32b886a0c3 Update bookmark modal to scale with playback rate #3728 2025-01-16 17:06:06 -06:00
advplyr 2463c62bbf Update bookmark modal scrollable with create always visible, make UI consistent, hide create when bookmark already exists 2025-01-16 16:56:56 -06:00
Jan-Eric Myhrgren d55faabb6d Translated using Weblate (Swedish)
Currently translated at 77.4% (839 of 1083 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/
2025-01-16 23:43:16 +01:00
Jan-Eric Myhrgren 222ce6ca00 Translated using Weblate (Swedish)
Currently translated at 74.5% (807 of 1083 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/
2025-01-15 20:48:18 +01:00
Jan-Eric Myhrgren be5dc6d2ec Translated using Weblate (Swedish)
Currently translated at 73.7% (799 of 1083 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/
2025-01-15 20:48:18 +01:00
ugyes 804b446dae Translated using Weblate (Hungarian)
Currently translated at 97.8% (1060 of 1083 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hu/
2025-01-15 20:48:17 +01:00
Milo Ivir 5897aee3b7 Translated using Weblate (Croatian)
Currently translated at 100.0% (1083 of 1083 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/
2025-01-15 20:48:16 +01:00
Rasmus Enevoldsen 1e5e507eb0 Translated using Weblate (Danish)
Currently translated at 68.6% (743 of 1083 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/da/
2025-01-15 20:48:15 +01:00
Milo Ivir 760af51c5d Translated using Weblate (Croatian)
Currently translated at 99.9% (1082 of 1083 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/
2025-01-15 20:48:14 +01:00
Rasmus Enevoldsen 24705ca06a Translated using Weblate (Danish)
Currently translated at 68.3% (740 of 1083 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/da/
2025-01-15 20:48:14 +01:00
thehijacker 56cba44154 Translated using Weblate (Slovenian)
Currently translated at 100.0% (1083 of 1083 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/
2025-01-15 20:48:13 +01:00
SunSpring 9360165f6b Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (1083 of 1083 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/
2025-01-15 20:48:12 +01:00
Marcus skoding adef6ede12 Translated using Weblate (Swedish)
Currently translated at 72.0% (780 of 1083 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/
2025-01-15 20:48:12 +01:00
Jan-Eric Myhrgren b8afcd1664 Translated using Weblate (Swedish)
Currently translated at 72.0% (780 of 1083 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/
2025-01-15 20:48:11 +01:00
Mathias Franco d8da793bca Translated using Weblate (Dutch)
Currently translated at 100.0% (1083 of 1083 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/nl/
2025-01-15 20:48:10 +01:00
David 1856d68299 Translated using Weblate (Spanish)
Currently translated at 99.9% (1082 of 1083 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/
2025-01-15 20:48:09 +01:00
Vito0912 89247f1786 Translated using Weblate (German)
Currently translated at 100.0% (1083 of 1083 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/
2025-01-15 20:48:08 +01:00
Stefan Ha 5995c52ab7 Translated using Weblate (German)
Currently translated at 100.0% (1083 of 1083 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/
2025-01-15 20:48:07 +01:00
D0ckW0rka 07264544ef Translated using Weblate (German)
Currently translated at 100.0% (1083 of 1083 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/
2025-01-15 20:48:07 +01:00
advplyr 6057930507 Merge pull request #3842 from mikiher/dynamic-episode-row-height
Dynamically calculate episode row height on LazyEpisodeTable init
2025-01-15 13:47:56 -06:00
advplyr 9bbb23b853 Merge pull request #3832 from daneroo/fix_rounding_elapsedPrettyExtended
Fixes #3817 Correct rounding and carry of minutes in client/plugins/utils.js::$elapsedPrettyExtended
2025-01-15 13:32:23 -06:00
mikiher e865241258 Dynamically calculate episode row height on init 2025-01-15 10:39:59 +02:00
advplyr 1a67f57551 Update podcast downloads to fallback to download without tagging due to inaccurate rss feed enclosures #3837 2025-01-14 15:48:06 -06:00
advplyr 9b5bdc1fdb Merge pull request #3822 from mikiher/episode-table-refresh-fix
Episode table refresh fixes
2025-01-13 16:12:38 -06:00
Daniel Lauzon acda776e3e Fixes #3817
Correct rounding and carry of minutes in Vue.prototype.$elapsedPrettyExtended

-Add cypress tests for Vue.prototype.$elapsedPrettyExtended function
2025-01-13 13:36:15 -05:00
advplyr 8c4a9280ab Merge pull request #3828 from mikiher/nginx-host-fix
recommend using $http_host for ngnix
2025-01-12 10:55:38 -06:00
mikiher 1812282946 recommend using $http_host for ngnix 2025-01-12 18:35:49 +02:00
advplyr 64e9ac9d8f Fix merging embedded chapters for multi-track audiobooks giving incorrect chapter ids #3361
- Also trim chapter titles on probe (remove carriage return)
2025-01-12 09:56:48 -06:00
advplyr 0da9a04d8e Merge pull request #3788 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2025-01-12 05:05:02 -06:00
Максим Горпиніч 11178f58bd Translated using Weblate (Ukrainian)
Currently translated at 100.0% (1083 of 1083 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/
2025-01-11 21:27:24 +01:00
Илья Червонный 08b2d07f65 Translated using Weblate (Russian)
Currently translated at 100.0% (1083 of 1083 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ru/
2025-01-11 21:27:23 +01:00
Øystein S. Hegnander 3c210170b2 Translated using Weblate (Norwegian Bokmål)
Currently translated at 94.3% (1022 of 1083 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/nb_NO/
2025-01-11 21:27:22 +01:00
thehijacker 03d35421b4 Translated using Weblate (Slovenian)
Currently translated at 100.0% (1082 of 1082 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/
2025-01-11 21:27:22 +01:00
Максим Горпиніч a176ba53e0 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (1082 of 1082 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/
2025-01-11 21:27:21 +01:00
Stefan Ha e34dff8f30 Translated using Weblate (German)
Currently translated at 100.0% (1081 of 1081 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/
2025-01-11 21:27:21 +01:00
thehijacker 0881ab4bfb Translated using Weblate (Slovenian)
Currently translated at 100.0% (1081 of 1081 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/
2025-01-11 21:27:20 +01:00
Максим Горпиніч 20c32efd62 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (1081 of 1081 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/
2025-01-11 21:27:19 +01:00
ugyes e2b8127a5b Translated using Weblate (Hungarian)
Currently translated at 97.7% (1057 of 1081 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hu/
2025-01-11 21:27:19 +01:00
kuci-JK 90f32cefca Translated using Weblate (Czech)
Currently translated at 88.7% (958 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/cs/
2025-01-11 21:27:18 +01:00
Jaroslav Lichtblau ab2e661e22 Translated using Weblate (Czech)
Currently translated at 88.7% (958 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/cs/
2025-01-11 21:27:18 +01:00
Troja a073aedca2 Translated using Weblate (Belarusian)
Currently translated at 10.7% (116 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/be/
2025-01-11 21:27:17 +01:00
Troja b440a22ec9 Translated using Weblate (Belarusian)
Currently translated at 2.8% (31 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/be/
2025-01-11 21:27:16 +01:00
Perttu Niskanen ec695e5f48 Translated using Weblate (Finnish)
Currently translated at 44.1% (477 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fi/
2025-01-11 21:27:16 +01:00
Perttu Niskanen 69ad0bf113 Translated using Weblate (Finnish)
Currently translated at 42.0% (454 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fi/
2025-01-11 21:27:15 +01:00
David 88f464398a Translated using Weblate (Catalan)
Currently translated at 93.7% (1013 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ca/
2025-01-11 21:27:15 +01:00
thehijacker 6fce501389 Translated using Weblate (Slovenian)
Currently translated at 100.0% (1080 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/
2025-01-11 21:27:14 +01:00
Perttu Niskanen 559fab0d90 Translated using Weblate (Finnish)
Currently translated at 41.7% (451 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fi/
2025-01-11 21:27:13 +01:00
Максим Горпиніч 69c428802b Translated using Weblate (Ukrainian)
Currently translated at 100.0% (1080 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/
2025-01-11 21:27:13 +01:00
Fredrik Drugge 6da631fa4f Translated using Weblate (Swedish)
Currently translated at 69.1% (747 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/
2025-01-11 21:27:12 +01:00
Dawid Kuźnicki f83b081791 Translated using Weblate (Polish)
Currently translated at 75.0% (811 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/pl/
2025-01-11 21:27:11 +01:00
ugyes a6ce5fdd98 Translated using Weblate (Hungarian)
Currently translated at 97.8% (1057 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hu/
2025-01-11 21:27:11 +01:00
biuklija 0a2e725bd3 Translated using Weblate (Croatian)
Currently translated at 100.0% (1080 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/
2025-01-11 21:27:10 +01:00
DiamondtipDR c07c4a3341 Translated using Weblate (Spanish)
Currently translated at 100.0% (1080 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/
2025-01-11 21:27:10 +01:00
David 422773e745 Translated using Weblate (Spanish)
Currently translated at 100.0% (1080 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/
2025-01-11 21:27:09 +01:00
Vito0912 7a298aa6f5 Translated using Weblate (German)
Currently translated at 100.0% (1080 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/
2025-01-11 21:27:08 +01:00
advplyr 41daf557aa Update get all libraries endpoint to support include stats for android auto to detect audio libraries 2025-01-11 14:26:57 -06:00
mikiher de5bc63d88 Remove deleted episode from returned libraryItem object 2025-01-11 22:26:36 +02:00
mikiher 5e2282ef76 Fix LazyEpisodeTable.init to respect non-zero scrollTop 2025-01-11 22:25:30 +02:00
advplyr c819afc53b Merge pull request #3816 from mikiher/fix-trix-issues
Fix Trix to use paragraphs and break on return
2025-01-10 16:37:24 -06:00
advplyr 37221a0446 Fix missing translation string 2025-01-10 15:48:44 -06:00
advplyr 0f20ed101e Update podcast RSS parser to handle HTML not wrapped in CDATA #3778 2025-01-10 15:42:52 -06:00
mikiher b0dbccd283 Fix Trix to use paragraphs and break on return 2025-01-10 08:03:41 +02:00
advplyr 7001adb4dd Add message to schedule library scan tab #3734 2025-01-09 16:25:41 -06:00
mikiher 9668b49df9 Enable subdirectory support 2025-01-09 07:41:09 +02:00
advplyr 02ecf7ccfe Fix catch exception on failed to parse comic metadata #3804 2025-01-08 16:53:56 -06:00
advplyr 05ff5f1956 Merge pull request #3771 from sbyrx/master
Adds a configuration for podcast feed and episode download timeout
2025-01-08 14:10:20 -06:00
advplyr 1649fb40db Merge pull request #3808 from mikiher/merge-prod-js-index-js
Merge prod.js into index.js
2025-01-08 14:04:48 -06:00
mikiher 052e0059ff Restore prod.js 2025-01-08 07:23:08 +02:00
advplyr 5edd799b3e Update media player volume tooltip to be below the volume icon 2025-01-07 16:44:13 -06:00
advplyr 1632d8edee Update episode list item to fallback to using description if subtitle is not set, matching latest page 2025-01-07 15:21:11 -06:00
advplyr e6181196a7 Merge pull request #3805 from nichwall/text_input_date_validation
Text input date validation
2025-01-07 14:46:34 -06:00
advplyr bea9d6aff4 Update date time input validation, add red border for invalid datetime 2025-01-07 14:08:57 -06:00
mikiher d410b13c9b Merge prod.js into index.js 2025-01-07 17:41:09 +02:00
advplyr 8286aad7a4 Fix updating cover from match requests #3807 2025-01-07 09:05:53 -06:00
advplyr ed5960825b Fix podcast episode continue and listen again home page shelves 2025-01-07 08:37:05 -06:00
Nicholas Wallace 7fd8178dde Add: datetime check for new episode modal 2025-01-06 20:30:27 -07:00
Nicholas Wallace db17a5c88b Change: toast date error to be generic 2025-01-06 20:22:47 -07:00
Nicholas Wallace 2ec84edb5e Add: episode pubdate validation before saving 2025-01-06 20:00:42 -07:00
advplyr 0eed38b771 Fix playback sessions num days listened in last year to be accurate for smaller screen sizes 2025-01-06 14:32:10 -06:00
advplyr 977bdbf0bb Fix podcast episode AudioTrack object 2025-01-06 13:30:31 -06:00
advplyr a1ec10bd0d Fix sync request responding with 500 status code 2025-01-06 11:39:55 -06:00
advplyr 57d742b862 Merge pull request #3800 from advplyr/migrate-library-item-in-scanner
Migrate to new library item in scanner
2025-01-05 14:31:42 -06:00
advplyr 108eaba022 Migrate tools and collapse series. fix continue shelves. remove old objects 2025-01-05 14:09:03 -06:00
advplyr ac159bea72 Update unit test stub function 2025-01-05 12:12:20 -06:00
advplyr d5ce7b4939 Migrate to new library item in scanner 2025-01-05 12:05:01 -06:00
sbyrx e64302f1d4 Merge branch 'advplyr:master' into master 2025-01-04 20:15:59 -05:00
advplyr fdbca4feb6 Merge pull request #3776 from mikiher/fix-ffmpeg-concat-file
Fix ffmpeg concat file escaping
2025-01-04 16:04:18 -06:00
advplyr f366dfa909 Merge pull request #3780 from nichwall/api_cache_case_insensitive
API Cache Manager route uses case-insensitive match
2025-01-04 16:03:14 -06:00
advplyr 5d1a17ffa8 Merge pull request #3794 from mikiher/fix-stream-ffmpeg-add-option
Fix ffmpeg.addOption for transcoding
2025-01-04 16:01:56 -06:00
advplyr 0ed4ea9138 Merge pull request #3798 from advplyr/migrate-new-library-items
Migrate controllers to use new toOldJSON functions
2025-01-04 16:01:17 -06:00
mikiher 43d8d9b286 Fix ffmpeg.addOption for transcoding 2025-01-04 20:16:48 +02:00
Nicholas Wallace f70f21455f Req URL is lowercase in ApiCacheManager 2025-01-02 20:13:38 -07:00
Nicholas Wallace a6fd0c95b2 API cache manager case-insensitive match 2025-01-02 20:07:21 -07:00
mikiher fe2ba083be Fix ffmpeg concat file escaping 2025-01-02 13:34:25 +02:00
sbyrx 0d8d0a650b Adds a configuration for podcast feed and episode download timeout 2025-01-01 19:41:19 +00:00
103 changed files with 1971 additions and 2628 deletions
+5
View File
@@ -46,5 +46,10 @@ RUN apk del make python3 g++
EXPOSE 80 EXPOSE 80
ENV PORT=80
ENV CONFIG_PATH="/config"
ENV METADATA_PATH="/metadata"
ENV SOURCE="docker"
ENTRYPOINT ["tini", "--"] ENTRYPOINT ["tini", "--"]
CMD ["node", "index.js"] CMD ["node", "index.js"]
+2 -2
View File
@@ -5,7 +5,7 @@
@import './absicons.css'; @import './absicons.css';
:root { :root {
--bookshelf-texture-img: url(/textures/wood_default.jpg); --bookshelf-texture-img: url(~static/textures/wood_default.jpg);
--bookshelf-divider-bg: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); --bookshelf-divider-bg: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%);
} }
@@ -247,4 +247,4 @@ Bookshelf Label
.abs-btn:disabled::before { .abs-btn:disabled::before {
background-color: rgba(0, 0, 0, 0.2); background-color: rgba(0, 0, 0, 0.2);
} }
@@ -55,7 +55,7 @@
@showPlayerQueueItems="showPlayerQueueItemsModal = true" @showPlayerQueueItems="showPlayerQueueItemsModal = true"
/> />
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :library-item-id="libraryItemId" @select="selectBookmark" /> <modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :playback-rate="currentPlaybackRate" :library-item-id="libraryItemId" @select="selectBookmark" />
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-type="sleepTimerType" :remaining="sleepTimerRemaining" :has-chapters="!!chapters.length" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" /> <modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-type="sleepTimerType" :remaining="sleepTimerRemaining" :has-chapters="!!chapters.length" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
+16 -25
View File
@@ -5,24 +5,26 @@
<p class="text-3xl text-white truncate">{{ $strings.LabelYourBookmarks }}</p> <p class="text-3xl text-white truncate">{{ $strings.LabelYourBookmarks }}</p>
</div> </div>
</template> </template>
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh"> <div v-if="show" class="w-full rounded-lg bg-bg box-shadow-md relative" style="max-height: 80vh">
<div v-if="show" class="w-full h-full"> <div v-if="bookmarks.length" class="h-full max-h-[calc(80vh-60px)] w-full relative overflow-y-auto overflow-x-hidden">
<template v-for="bookmark in bookmarks"> <template v-for="bookmark in bookmarks">
<modals-bookmarks-bookmark-item :key="bookmark.id" :highlight="currentTime === bookmark.time" :bookmark="bookmark" @click="clickBookmark" @update="submitUpdateBookmark" @delete="deleteBookmark" /> <modals-bookmarks-bookmark-item :key="bookmark.id" :highlight="currentTime === bookmark.time" :bookmark="bookmark" :playback-rate="playbackRate" @click="clickBookmark" @delete="deleteBookmark" />
</template> </template>
<div v-if="!bookmarks.length" class="flex h-32 items-center justify-center"> </div>
<p class="text-xl">{{ $strings.MessageNoBookmarks }}</p> <div v-else class="flex h-32 items-center justify-center">
</div> <p class="text-xl">{{ $strings.MessageNoBookmarks }}</p>
<div v-if="!hideCreate" class="w-full h-px bg-white bg-opacity-10" /> </div>
<form v-if="!hideCreate" @submit.prevent="submitCreateBookmark">
<div v-show="canCreateBookmark" class="flex px-4 py-2 items-center text-center border-b border-white border-opacity-10 text-white text-opacity-80"> <div v-if="canCreateBookmark && !hideCreate" class="w-full border-t border-white/10">
<form @submit.prevent="submitCreateBookmark">
<div class="flex px-4 py-2 items-center text-center border-b border-white border-opacity-10 text-white text-opacity-80">
<div class="w-16 max-w-16 text-center"> <div class="w-16 max-w-16 text-center">
<p class="text-sm font-mono text-gray-400"> <p class="text-sm font-mono text-gray-400">
{{ this.$secondsToTimestamp(currentTime) }} {{ this.$secondsToTimestamp(currentTime / playbackRate) }}
</p> </p>
</div> </div>
<div class="flex-grow px-2"> <div class="flex-grow px-2">
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full" /> <ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full h-10" />
</div> </div>
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-symbols text-2xl -mt-px">add</span></ui-btn> <ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-symbols text-2xl -mt-px">add</span></ui-btn>
</div> </div>
@@ -45,6 +47,7 @@ export default {
default: 0 default: 0
}, },
libraryItemId: String, libraryItemId: String,
playbackRate: Number,
hideCreate: Boolean hideCreate: Boolean
}, },
data() { data() {
@@ -57,6 +60,7 @@ export default {
watch: { watch: {
show(newVal) { show(newVal) {
if (newVal) { if (newVal) {
this.selectedBookmark = null
this.showBookmarkTitleInput = false this.showBookmarkTitleInput = false
this.newBookmarkTitle = '' this.newBookmarkTitle = ''
} }
@@ -72,7 +76,7 @@ export default {
} }
}, },
canCreateBookmark() { canCreateBookmark() {
return !this.bookmarks.find((bm) => bm.time === this.currentTime) return !this.bookmarks.find((bm) => Math.abs(this.currentTime - bm.time) < 1)
}, },
dateFormat() { dateFormat() {
return this.$store.state.serverSettings.dateFormat return this.$store.state.serverSettings.dateFormat
@@ -102,19 +106,6 @@ export default {
clickBookmark(bm) { clickBookmark(bm) {
this.$emit('select', bm) this.$emit('select', bm)
}, },
submitUpdateBookmark(updatedBookmark) {
var bookmark = { ...updatedBookmark }
this.$axios
.$patch(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
.then(() => {
this.$toast.success(this.$strings.ToastBookmarkUpdateSuccess)
})
.catch((error) => {
this.$toast.error(this.$strings.ToastFailedToUpdate)
console.error(error)
})
this.show = false
},
submitCreateBookmark() { submitCreateBookmark() {
if (!this.newBookmarkTitle) { if (!this.newBookmarkTitle) {
this.newBookmarkTitle = this.$formatDatetime(Date.now(), this.dateFormat, this.timeFormat) this.newBookmarkTitle = this.$formatDatetime(Date.now(), this.dateFormat, this.timeFormat)
@@ -1,8 +1,8 @@
<template> <template>
<div class="flex items-center px-4 py-4 justify-start relative bg-primary hover:bg-opacity-25" :class="wrapperClass" @click.stop="click" @mouseover="mouseover" @mouseleave="mouseleave"> <div class="flex items-center px-4 py-4 justify-start relative hover:bg-primary/10" :class="wrapperClass" @click.stop="click" @mouseover="mouseover" @mouseleave="mouseleave">
<div class="w-16 max-w-16 text-center"> <div class="w-16 max-w-16 text-center">
<p class="text-sm font-mono text-gray-400"> <p class="text-sm font-mono text-gray-400">
{{ this.$secondsToTimestamp(bookmark.time) }} {{ this.$secondsToTimestamp(bookmark.time / playbackRate) }}
</p> </p>
</div> </div>
<div class="flex-grow overflow-hidden px-2"> <div class="flex-grow overflow-hidden px-2">
@@ -10,7 +10,7 @@
<form @submit.prevent="submitUpdate"> <form @submit.prevent="submitUpdate">
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-grow pr-2"> <div class="flex-grow pr-2">
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full" /> <ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full h-10" />
</div> </div>
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-symbols text-2xl -mt-px">forward</span></ui-btn> <ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-symbols text-2xl -mt-px">forward</span></ui-btn>
<div class="pl-2 flex items-center"> <div class="pl-2 flex items-center">
@@ -35,7 +35,8 @@ export default {
type: Object, type: Object,
default: () => {} default: () => {}
}, },
highlight: Boolean highlight: Boolean,
playbackRate: Number
}, },
data() { data() {
return { return {
@@ -83,11 +84,19 @@ export default {
if (this.newBookmarkTitle === this.bookmark.title) { if (this.newBookmarkTitle === this.bookmark.title) {
return this.cancelEditing() return this.cancelEditing()
} }
var bookmark = { ...this.bookmark } const bookmark = { ...this.bookmark }
bookmark.title = this.newBookmarkTitle bookmark.title = this.newBookmarkTitle
this.$emit('update', bookmark)
this.$axios
.$patch(`/api/me/item/${bookmark.libraryItemId}/bookmark`, bookmark)
.then(() => {
this.isEditing = false
})
.catch((error) => {
this.$toast.error(this.$strings.ToastFailedToUpdate)
console.error(error)
})
} }
}, }
mounted() {}
} }
</script> </script>
@@ -113,6 +113,10 @@ export default {
return false return false
}) })
console.log('updateResult', updateResult) console.log('updateResult', updateResult)
} else if (!lastEpisodeCheck) {
this.$toast.error(this.$strings.ToastDateTimeInvalidOrIncomplete)
this.checkingNewEpisodes = false
return false
} }
this.$axios this.$axios
@@ -5,6 +5,9 @@
<ui-checkbox v-model="enableAutoScan" @input="toggleEnableAutoScan" :label="$strings.LabelEnable" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" /> <ui-checkbox v-model="enableAutoScan" @input="toggleEnableAutoScan" :label="$strings.LabelEnable" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" />
</div> </div>
<widgets-cron-expression-builder ref="cronExpressionBuilder" v-if="enableAutoScan" v-model="cronExpression" @input="updatedCron" /> <widgets-cron-expression-builder ref="cronExpressionBuilder" v-if="enableAutoScan" v-model="cronExpression" @input="updatedCron" />
<div v-else>
<p class="text-yellow-400 text-base">{{ $strings.MessageScheduleLibraryScanNote }}</p>
</div>
</div> </div>
</template> </template>
@@ -11,7 +11,7 @@
<ui-dropdown v-model="newEpisode.episodeType" :label="$strings.LabelEpisodeType" :items="episodeTypes" small /> <ui-dropdown v-model="newEpisode.episodeType" :label="$strings.LabelEpisodeType" :items="episodeTypes" small />
</div> </div>
<div class="w-2/5 p-1"> <div class="w-2/5 p-1">
<ui-text-input-with-label v-model="pubDateInput" @input="updatePubDate" type="datetime-local" :label="$strings.LabelPubDate" /> <ui-text-input-with-label v-model="pubDateInput" ref="pubdate" type="datetime-local" :label="$strings.LabelPubDate" @input="updatePubDate" />
</div> </div>
<div class="w-full p-1"> <div class="w-full p-1">
<ui-text-input-with-label v-model="newEpisode.title" :label="$strings.LabelTitle" /> <ui-text-input-with-label v-model="newEpisode.title" :label="$strings.LabelTitle" />
@@ -145,11 +145,18 @@ export default {
return null return null
} }
// Check pubdate is valid if it is being updated. Cannot be set to null in the web client
if (this.newEpisode.pubDate === null && this.$refs.pubdate?.$refs?.input?.isInvalidDate) {
this.$toast.error(this.$strings.ToastDateTimeInvalidOrIncomplete)
return null
}
const updatedDetails = this.getUpdatePayload() const updatedDetails = this.getUpdatePayload()
if (!Object.keys(updatedDetails).length) { if (!Object.keys(updatedDetails).length) {
this.$toast.info(this.$strings.ToastNoUpdatesNecessary) this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
return false return false
} }
return this.updateDetails(updatedDetails) return this.updateDetails(updatedDetails)
}, },
async updateDetails(updatedDetails) { async updateDetails(updatedDetails) {
+1 -1
View File
@@ -4,7 +4,7 @@
<div class="absolute -top-10 lg:top-0 right-0 lg:right-2 flex items-center h-full"> <div class="absolute -top-10 lg:top-0 right-0 lg:right-2 flex items-center h-full">
<controls-playback-speed-control v-model="playbackRate" @input="setPlaybackRate" @change="playbackRateChanged" class="mx-2 block" /> <controls-playback-speed-control v-model="playbackRate" @input="setPlaybackRate" @change="playbackRateChanged" class="mx-2 block" />
<ui-tooltip direction="left" :text="$strings.LabelVolume"> <ui-tooltip direction="bottom" :text="$strings.LabelVolume">
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" class="mx-2 hidden sm:block" /> <controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" class="mx-2 hidden sm:block" />
</ui-tooltip> </ui-tooltip>
+2 -2
View File
@@ -97,9 +97,9 @@ export default {
}, },
ebookUrl() { ebookUrl() {
if (this.fileId) { if (this.fileId) {
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}` return `${this.$config.routerBasePath}/api/items/${this.libraryItemId}/ebook/${this.fileId}`
} }
return `/api/items/${this.libraryItemId}/ebook` return `${this.$config.routerBasePath}/api/items/${this.libraryItemId}/ebook`
}, },
themeRules() { themeRules() {
const isDark = this.ereaderSettings.theme === 'dark' const isDark = this.ereaderSettings.theme === 'dark'
+17 -8
View File
@@ -63,9 +63,6 @@ export default {
dayOfWeekToday() { dayOfWeekToday() {
return new Date().getDay() return new Date().getDay()
}, },
firstWeekStart() {
return this.$addDaysToToday(-this.daysToShow)
},
dayLabels() { dayLabels() {
return [ return [
{ {
@@ -198,12 +195,25 @@ export default {
let minValue = 0 let minValue = 0
const dates = [] const dates = []
for (let i = 0; i < this.daysToShow + 1; i++) {
const date = i === 0 ? this.firstWeekStart : this.$addDaysToDate(this.firstWeekStart, i) const numDaysInTheLastYear = 52 * 7 + this.dayOfWeekToday
const firstDay = this.$addDaysToToday(-numDaysInTheLastYear)
for (let i = 0; i < numDaysInTheLastYear + 1; i++) {
const date = i === 0 ? firstDay : this.$addDaysToDate(firstDay, i)
const dateString = this.$formatJsDate(date, 'yyyy-MM-dd') const dateString = this.$formatJsDate(date, 'yyyy-MM-dd')
if (this.daysListening[dateString] > 0) {
this.daysListenedInTheLastYear++
}
const visibleDayIndex = i - (numDaysInTheLastYear - this.daysToShow)
if (visibleDayIndex < 0) {
continue
}
const dateObj = { const dateObj = {
col: Math.floor(i / 7), col: Math.floor(visibleDayIndex / 7),
row: i % 7, row: visibleDayIndex % 7,
date, date,
dateString, dateString,
datePretty: this.$formatJsDate(date, 'MMM d, yyyy'), datePretty: this.$formatJsDate(date, 'MMM d, yyyy'),
@@ -215,7 +225,6 @@ export default {
dates.push(dateObj) dates.push(dateObj)
if (dateObj.value > 0) { if (dateObj.value > 0) {
this.daysListenedInTheLastYear++
if (dateObj.value > maxValue) maxValue = dateObj.value if (dateObj.value > maxValue) maxValue = dateObj.value
if (!minValue || dateObj.value < minValue) minValue = dateObj.value if (!minValue || dateObj.value < minValue) minValue = dateObj.value
} }
@@ -96,7 +96,7 @@ export default {
return this.episode?.title || '' return this.episode?.title || ''
}, },
episodeSubtitle() { episodeSubtitle() {
return this.episode?.subtitle || '' return this.episode?.subtitle || this.episode?.description || ''
}, },
episodeType() { episodeType() {
return this.episode?.episodeType || '' return this.episode?.episodeType || ''
@@ -30,7 +30,7 @@
<ui-text-input v-model="search" @input="inputUpdate" type="search" :placeholder="$strings.PlaceholderSearchEpisode" class="flex-grow mr-2 text-sm md:text-base" /> <ui-text-input v-model="search" @input="inputUpdate" type="search" :placeholder="$strings.PlaceholderSearchEpisode" class="flex-grow mr-2 text-sm md:text-base" />
</form> </form>
</div> </div>
<div class="relative min-h-[176px]"> <div class="relative min-h-44">
<template v-for="episode in totalEpisodes"> <template v-for="episode in totalEpisodes">
<div :key="episode" :id="`episode-${episode - 1}`" class="w-full h-44 px-2 py-3 overflow-hidden relative border-b border-white/10"> <div :key="episode" :id="`episode-${episode - 1}`" class="w-full h-44 px-2 py-3 overflow-hidden relative border-b border-white/10">
<!-- episode is mounted here --> <!-- episode is mounted here -->
@@ -39,7 +39,7 @@
<div v-if="isSearching" class="w-full h-full absolute inset-0 flex justify-center py-12" :class="{ 'bg-black/50': totalEpisodes }"> <div v-if="isSearching" class="w-full h-full absolute inset-0 flex justify-center py-12" :class="{ 'bg-black/50': totalEpisodes }">
<ui-loading-indicator /> <ui-loading-indicator />
</div> </div>
<div v-else-if="!totalEpisodes" class="h-44 flex items-center justify-center"> <div v-else-if="!totalEpisodes" id="no-episodes" class="h-44 flex items-center justify-center">
<p class="text-lg">{{ $strings.MessageNoEpisodes }}</p> <p class="text-lg">{{ $strings.MessageNoEpisodes }}</p>
</div> </div>
</div> </div>
@@ -80,7 +80,8 @@ export default {
episodeComponentRefs: {}, episodeComponentRefs: {},
windowHeight: 0, windowHeight: 0,
episodesTableOffsetTop: 0, episodesTableOffsetTop: 0,
episodeRowHeight: 176 episodeRowHeight: 44 * 4, // h-44,
currScrollTop: 0
} }
}, },
watch: { watch: {
@@ -484,9 +485,8 @@ export default {
} }
} }
}, },
scroll(evt) { handleScroll() {
if (!evt?.target?.scrollTop) return const scrollTop = this.currScrollTop
const scrollTop = Math.max(evt.target.scrollTop - this.episodesTableOffsetTop, 0)
let firstEpisodeIndex = Math.floor(scrollTop / this.episodeRowHeight) let firstEpisodeIndex = Math.floor(scrollTop / this.episodeRowHeight)
let lastEpisodeIndex = Math.ceil((scrollTop + this.windowHeight) / this.episodeRowHeight) let lastEpisodeIndex = Math.ceil((scrollTop + this.windowHeight) / this.episodeRowHeight)
lastEpisodeIndex = Math.min(this.totalEpisodes - 1, lastEpisodeIndex) lastEpisodeIndex = Math.min(this.totalEpisodes - 1, lastEpisodeIndex)
@@ -501,6 +501,12 @@ export default {
}) })
this.mountEpisodes(firstEpisodeIndex, lastEpisodeIndex + 1) this.mountEpisodes(firstEpisodeIndex, lastEpisodeIndex + 1)
}, },
scroll(evt) {
if (!evt?.target?.scrollTop) return
const scrollTop = Math.max(evt.target.scrollTop - this.episodesTableOffsetTop, 0)
this.currScrollTop = scrollTop
this.handleScroll()
},
initListeners() { initListeners() {
const itemPageWrapper = document.getElementById('item-page-wrapper') const itemPageWrapper = document.getElementById('item-page-wrapper')
if (itemPageWrapper) { if (itemPageWrapper) {
@@ -532,11 +538,24 @@ export default {
this.episodesTableOffsetTop = (lazyEpisodesTableEl?.offsetTop || 0) + 64 this.episodesTableOffsetTop = (lazyEpisodesTableEl?.offsetTop || 0) + 64
this.windowHeight = window.innerHeight this.windowHeight = window.innerHeight
this.episodesPerPage = Math.ceil(this.windowHeight / this.episodeRowHeight)
this.$nextTick(() => { this.$nextTick(() => {
this.mountEpisodes(0, Math.min(this.episodesPerPage, this.totalEpisodes)) this.recalcEpisodeRowHeight()
this.episodesPerPage = Math.ceil(this.windowHeight / this.episodeRowHeight)
// Maybe update currScrollTop if items were removed
const itemPageWrapper = document.getElementById('item-page-wrapper')
const { scrollHeight, clientHeight } = itemPageWrapper
const maxScrollTop = scrollHeight - clientHeight
this.currScrollTop = Math.min(this.currScrollTop, maxScrollTop)
this.handleScroll()
}) })
},
recalcEpisodeRowHeight() {
const episodeRowEl = document.getElementById('episode-0') || document.getElementById('no-episodes')
if (episodeRowEl) {
const height = getComputedStyle(episodeRowEl).height
this.episodeRowHeight = parseInt(height) || this.episodeRowHeight
}
} }
}, },
mounted() { mounted() {
+15 -20
View File
@@ -1,24 +1,6 @@
<template> <template>
<div ref="wrapper" class="relative"> <div ref="wrapper" class="relative">
<input <input :id="inputId" :name="inputName" ref="input" v-model="inputValue" :type="actualType" :step="step" :min="min" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" dir="auto" class="rounded bg-primary text-gray-200 focus:bg-bg focus:outline-none border h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
:id="inputId"
:name="inputName"
ref="input"
v-model="inputValue"
:type="actualType"
:step="step"
:min="min"
:readonly="readonly"
:disabled="disabled"
:placeholder="placeholder"
dir="auto"
class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full"
:class="classList"
@keyup="keyup"
@change="change"
@focus="focused"
@blur="blurred"
/>
<div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center"> <div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center">
<span class="material-symbols text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span> <span class="material-symbols text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
</div> </div>
@@ -65,7 +47,8 @@ export default {
showPassword: false, showPassword: false,
isHovering: false, isHovering: false,
isFocused: false, isFocused: false,
hasCopied: false hasCopied: false,
isInvalidDate: false
} }
}, },
computed: { computed: {
@@ -84,6 +67,10 @@ export default {
if (this.noSpinner) _list.push('no-spinner') if (this.noSpinner) _list.push('no-spinner')
if (this.textCenter) _list.push('text-center') if (this.textCenter) _list.push('text-center')
if (this.customInputClass) _list.push(this.customInputClass) if (this.customInputClass) _list.push(this.customInputClass)
if (this.isInvalidDate) _list.push('border-error')
else _list.push('focus:border-gray-300 border-gray-600')
return _list.join(' ') return _list.join(' ')
}, },
actualType() { actualType() {
@@ -118,6 +105,14 @@ export default {
}, },
keyup(e) { keyup(e) {
this.$emit('keyup', e) this.$emit('keyup', e)
if (this.type === 'datetime-local') {
if (e.target.validity?.badInput) {
this.isInvalidDate = true
} else {
this.isInvalidDate = false
}
}
}, },
blur() { blur() {
if (this.$refs.input) this.$refs.input.blur() if (this.$refs.input) this.$refs.input.blur()
+5 -4
View File
@@ -1,9 +1,10 @@
<template> <template>
<div class="w-full"> <div class="w-full">
<slot> <slot>
<label :for="identifier" class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }" <label :for="identifier" class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }">
>{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em></label {{ label }}
> <em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
</label>
</slot> </slot>
<ui-text-input :placeholder="placeholder || label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" :class="inputClass" @blur="inputBlurred" /> <ui-text-input :placeholder="placeholder || label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" :class="inputClass" @blur="inputBlurred" />
</div> </div>
@@ -57,4 +58,4 @@ export default {
}, },
mounted() {} mounted() {}
} }
</script> </script>
+23 -1
View File
@@ -249,11 +249,33 @@ export default {
} }
} }
return target return target
},
enableBreakParagraphOnReturn() {
// Trix works with divs by default, we want paragraphs instead
Trix.config.blockAttributes.default.tagName = 'p'
// Enable break paragraph on Enter (Shift + Enter will still create a line break)
Trix.config.blockAttributes.default.breakOnReturn = true
// Hack to fix buggy paragraph breaks
// Copied from https://github.com/basecamp/trix/issues/680#issuecomment-735742942
Trix.Block.prototype.breaksOnReturn = function () {
const attr = this.getLastAttribute()
const config = Trix.getBlockConfig(attr ? attr : 'default')
return config ? config.breakOnReturn : false
}
Trix.LineBreakInsertion.prototype.shouldInsertBlockBreak = function () {
if (this.block.hasAttributes() && this.block.isListItem() && !this.block.isEmpty()) {
return this.startLocation.offset > 0
} else {
return !this.shouldBreakFormattedBlock() ? this.breaksOnReturn : false
}
}
} }
}, },
mounted() { mounted() {
/** Override editor configuration */ /** Override editor configuration */
this.overrideConfig(this.config) this.overrideConfig(this.config)
this.enableBreakParagraphOnReturn()
/** Check if editor read-only mode is required */ /** Check if editor read-only mode is required */
this.decorateDisabledEditor(this.disabledEditor) this.decorateDisabledEditor(this.disabledEditor)
this.$nextTick(() => { this.$nextTick(() => {
@@ -283,4 +305,4 @@ export default {
.trix_container .trix-content { .trix_container .trix-content {
background-color: white; background-color: white;
} }
</style> </style>
@@ -0,0 +1,188 @@
import Vue from 'vue'
import '@/plugins/utils'
// This is the actual function that is being tested
const elapsedPrettyExtended = Vue.prototype.$elapsedPrettyExtended
// Helper function to convert days, hours, minutes, seconds to total seconds
function DHMStoSeconds(days, hours, minutes, seconds) {
return seconds + minutes * 60 + hours * 3600 + days * 86400
}
describe('$elapsedPrettyExtended', () => {
describe('function is on the Vue Prototype', () => {
it('exists as a function on Vue.prototype', () => {
expect(Vue.prototype.$elapsedPrettyExtended).to.exist
expect(Vue.prototype.$elapsedPrettyExtended).to.be.a('function')
})
})
describe('param default values', () => {
const testSeconds = DHMStoSeconds(0, 25, 1, 5) // 25h 1m 5s = 90065 seconds
it('uses useDays=true showSeconds=true by default', () => {
expect(elapsedPrettyExtended(testSeconds)).to.equal('1d 1h 1m 5s')
})
it('only useDays=false overrides useDays but keeps showSeconds=true', () => {
expect(elapsedPrettyExtended(testSeconds, false)).to.equal('25h 1m 5s')
})
it('explicit useDays=false showSeconds=false overrides both', () => {
expect(elapsedPrettyExtended(testSeconds, false, false)).to.equal('25h 1m')
})
})
describe('useDays=false showSeconds=true', () => {
const useDaysFalse = false
const showSecondsTrue = true
const testCases = [
[[0, 0, 0, 0], '', '0s -> ""'],
[[0, 1, 0, 1], '1h 1s', '1h 1s -> 1h 1s'],
[[0, 25, 0, 1], '25h 1s', '25h 1s -> 25h 1s']
]
testCases.forEach(([dhms, expected, description]) => {
it(description, () => {
expect(elapsedPrettyExtended(DHMStoSeconds(...dhms), useDaysFalse, showSecondsTrue)).to.equal(expected)
})
})
})
describe('useDays=true showSeconds=true', () => {
const useDaysTrue = true
const showSecondsTrue = true
const testCases = [
[[0, 0, 0, 0], '', '0s -> ""'],
[[0, 1, 0, 1], '1h 1s', '1h 1s -> 1h 1s'],
[[0, 25, 0, 1], '1d 1h 1s', '25h 1s -> 1d 1h 1s']
]
testCases.forEach(([dhms, expected, description]) => {
it(description, () => {
expect(elapsedPrettyExtended(DHMStoSeconds(...dhms), useDaysTrue, showSecondsTrue)).to.equal(expected)
})
})
})
describe('useDays=true showSeconds=false', () => {
const useDaysTrue = true
const showSecondsFalse = false
const testCases = [
[[0, 0, 0, 0], '', '0s -> ""'],
[[0, 1, 0, 0], '1h', '1h -> 1h'],
[[0, 1, 0, 1], '1h', '1h 1s -> 1h'],
[[0, 1, 1, 0], '1h 1m', '1h 1m -> 1h 1m'],
[[0, 25, 0, 0], '1d 1h', '25h -> 1d 1h'],
[[0, 25, 0, 1], '1d 1h', '25h 1s -> 1d 1h'],
[[2, 0, 0, 0], '2d', '2d -> 2d']
]
testCases.forEach(([dhms, expected, description]) => {
it(description, () => {
expect(elapsedPrettyExtended(DHMStoSeconds(...dhms), useDaysTrue, showSecondsFalse)).to.equal(expected)
})
})
})
describe('rounding useDays=true showSeconds=true', () => {
const useDaysTrue = true
const showSecondsTrue = true
const testCases = [
// Seconds rounding
[[0, 0, 0, 1], '1s', '1s -> 1s'],
[[0, 0, 0, 29.9], '30s', '29.9s -> 30s'],
[[0, 0, 0, 30], '30s', '30s -> 30s'],
[[0, 0, 0, 30.1], '30s', '30.1s -> 30s'],
[[0, 0, 0, 59.4], '59s', '59.4s -> 59s'],
[[0, 0, 0, 59.5], '1m', '59.5s -> 1m'],
// Minutes rounding
[[0, 0, 59, 29], '59m 29s', '59m 29s -> 59m 29s'],
[[0, 0, 59, 30], '59m 30s', '59m 30s -> 59m 30s'],
[[0, 0, 59, 59.5], '1h', '59m 59.5s -> 1h'],
// Hours rounding
[[0, 23, 59, 29], '23h 59m 29s', '23h 59m 29s -> 23h 59m 29s'],
[[0, 23, 59, 30], '23h 59m 30s', '23h 59m 30s -> 23h 59m 30s'],
[[0, 23, 59, 59.5], '1d', '23h 59m 59.5s -> 1d'],
// The actual bug case
[[44, 23, 59, 30], '44d 23h 59m 30s', '44d 23h 59m 30s -> 44d 23h 59m 30s']
]
testCases.forEach(([dhms, expected, description]) => {
it(description, () => {
expect(elapsedPrettyExtended(DHMStoSeconds(...dhms), useDaysTrue, showSecondsTrue)).to.equal(expected)
})
})
})
describe('rounding useDays=true showSeconds=false', () => {
const useDaysTrue = true
const showSecondsFalse = false
const testCases = [
// Seconds rounding - these cases changed behavior from original
[[0, 0, 0, 1], '', '1s -> ""'],
[[0, 0, 0, 29.9], '', '29.9s -> ""'],
[[0, 0, 0, 30], '', '30s -> ""'],
[[0, 0, 0, 30.1], '', '30.1s -> ""'],
[[0, 0, 0, 59.4], '', '59.4s -> ""'],
[[0, 0, 0, 59.5], '1m', '59.5s -> 1m'],
// This is unexpected behavior, but it's consistent with the original behavior
// We preserved the test case, to document the current behavior
// - with showSeconds=false,
// one might expect: 1m 29.5s --round(1.4901m)-> 1m
// actual implementation: 1h 29.5s --roundSeconds-> 1h 30s --roundMinutes-> 2m
// So because of the separate rounding of seconds, and then minutes, it returns 2m
[[0, 0, 1, 29.5], '2m', '1m 29.5s -> 2m'],
// Minutes carry - actual bug fixes below
[[0, 0, 59, 29], '59m', '59m 29s -> 59m'],
[[0, 0, 59, 30], '1h', '59m 30s -> 1h'], // This was an actual bug, used to return 60m
[[0, 0, 59, 59.5], '1h', '59m 59.5s -> 1h'],
// Hours carry
[[0, 23, 59, 29], '23h 59m', '23h 59m 29s -> 23h 59m'],
[[0, 23, 59, 30], '1d', '23h 59m 30s -> 1d'], // This was an actual bug, used to return 23h 60m
[[0, 23, 59, 59.5], '1d', '23h 59m 59.5s -> 1d'],
// The actual bug case
[[44, 23, 59, 30], '45d', '44d 23h 59m 30s -> 45d'] // This was an actual bug, used to return 44d 23h 60m
]
testCases.forEach(([dhms, expected, description]) => {
it(description, () => {
expect(elapsedPrettyExtended(DHMStoSeconds(...dhms), useDaysTrue, showSecondsFalse)).to.equal(expected)
})
})
})
describe('empty values', () => {
const paramCombos = [
// useDays, showSeconds, description
[true, true, 'with days and seconds'],
[true, false, 'with days, no seconds'],
[false, true, 'no days, with seconds'],
[false, false, 'no days, no seconds']
]
const emptyInputs = [
// input, description
[null, 'null input'],
[undefined, 'undefined input'],
[0, 'zero'],
[0.49, 'rounds to zero'] // Just under rounding threshold
]
paramCombos.forEach(([useDays, showSeconds, paramDesc]) => {
describe(paramDesc, () => {
emptyInputs.forEach(([input, desc]) => {
it(desc, () => {
expect(elapsedPrettyExtended(input, useDays, showSeconds)).to.equal('')
})
})
})
})
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
const pkg = require('./package.json') const pkg = require('./package.json')
const routerBasePath = process.env.ROUTER_BASE_PATH || '' const routerBasePath = process.env.ROUTER_BASE_PATH || '/audiobookshelf'
const serverHostUrl = process.env.NODE_ENV === 'production' ? '' : 'http://localhost:3333' const serverHostUrl = process.env.NODE_ENV === 'production' ? '' : 'http://localhost:3333'
const serverPaths = ['api/', 'public/', 'hls/', 'auth/', 'feed/', 'status', 'login', 'logout', 'init'] const serverPaths = ['api/', 'public/', 'hls/', 'auth/', 'feed/', 'status', 'login', 'logout', 'init']
const proxy = Object.fromEntries(serverPaths.map((path) => [`${routerBasePath}/${path}`, { target: process.env.NODE_ENV !== 'production' ? serverHostUrl : '/' }])) const proxy = Object.fromEntries(serverPaths.map((path) => [`${routerBasePath}/${path}`, { target: process.env.NODE_ENV !== 'production' ? serverHostUrl : '/' }]))
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.17.7", "version": "2.18.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.17.7", "version": "2.18.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@nuxtjs/axios": "^5.13.6", "@nuxtjs/axios": "^5.13.6",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.17.7", "version": "2.18.0",
"buildNumber": 1, "buildNumber": 1,
"description": "Self-hosted audiobook and podcast client", "description": "Self-hosted audiobook and podcast client",
"main": "index.js", "main": "index.js",
+1 -1
View File
@@ -141,7 +141,7 @@
</div> </div>
<modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" /> <modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" />
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :library-item-id="libraryItemId" hide-create @select="selectBookmark" /> <modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :playback-rate="1" :library-item-id="libraryItemId" hide-create @select="selectBookmark" />
</div> </div>
</template> </template>
+10 -5
View File
@@ -69,17 +69,22 @@ Vue.prototype.$elapsedPrettyExtended = (seconds, useDays = true, showSeconds = t
let hours = Math.floor(minutes / 60) let hours = Math.floor(minutes / 60)
minutes -= hours * 60 minutes -= hours * 60
// Handle rollovers before days calculation
if (minutes && seconds && !showSeconds) {
if (seconds >= 30) minutes++
if (minutes >= 60) {
hours++ // Increment hours if minutes roll over
minutes -= 60 // adjust minutes
}
}
// Now calculate days with the final hours value
let days = 0 let days = 0
if (useDays || Math.floor(hours / 24) >= 100) { if (useDays || Math.floor(hours / 24) >= 100) {
days = Math.floor(hours / 24) days = Math.floor(hours / 24)
hours -= days * 24 hours -= days * 24
} }
// If not showing seconds then round minutes up
if (minutes && seconds && !showSeconds) {
if (seconds >= 30) minutes++
}
const strs = [] const strs = []
if (days) strs.push(`${days}d`) if (days) strs.push(`${days}d`)
if (hours) strs.push(`${hours}h`) if (hours) strs.push(`${hours}h`)
+117 -1
View File
@@ -1 +1,117 @@
{} {
"ButtonAdd": "Дадаць",
"ButtonAddChapters": "Дадаць раздзелы",
"ButtonAddDevice": "Дадаць прыладу",
"ButtonAddLibrary": "Дадаць бібліятэку",
"ButtonAddPodcasts": "Дадаць падкасты",
"ButtonAddUser": "Дадаць карыстальніка",
"ButtonAddYourFirstLibrary": "Дадайце сваю першую бібліятэку",
"ButtonApply": "Ужыць",
"ButtonApplyChapters": "Ужыць раздзелы",
"ButtonAuthors": "Аўтары",
"ButtonBack": "Назад",
"ButtonBrowseForFolder": "Знайсці тэчку",
"ButtonCancel": "Адмяніць",
"ButtonCancelEncode": "Адмяніць кадзіраванне",
"ButtonChangeRootPassword": "Зменіце Root пароль",
"ButtonCheckAndDownloadNewEpisodes": "Праверыць і спампаваць новыя эпізоды",
"ButtonChooseAFolder": "Выбраць тэчку",
"ButtonChooseFiles": "Выбраць файлы",
"ButtonClearFilter": "Ачысціць фільтр",
"ButtonCloseFeed": "Закрыць стужку",
"ButtonCloseSession": "Закрыць адкрыты сеанс",
"ButtonCollections": "Калекцыі",
"ButtonConfigureScanner": "Наладзіць сканер",
"ButtonCreate": "Ствараць",
"ButtonCreateBackup": "Стварыць рэзервовую копію",
"ButtonDelete": "Выдаліць",
"ButtonDownloadQueue": "Чарга",
"ButtonEdit": "Рэдагаваць",
"ButtonEditChapters": "Рэдагаваць раздзелы",
"ButtonEditPodcast": "Рэдагаваць падкаст",
"ButtonEnable": "Уключыць",
"ButtonFireAndFail": "Агонь і няўдача",
"ButtonFireOnTest": "Тэст на вогнеўстойлівасць",
"ButtonForceReScan": "Прымусовае паўторнае сканаванне",
"ButtonFullPath": "Поўны шлях",
"ButtonHide": "Схаваць",
"ButtonIssues": "Праблемы",
"ButtonJumpBackward": "Перайсці назад",
"ButtonJumpForward": "Перайсці наперад",
"ButtonLibrary": "Бібліятэка",
"ButtonLogout": "Выйсці",
"ButtonLookup": "",
"ButtonMapChapterTitles": "Супаставіць назвы раздзелаў",
"ButtonMatchAllAuthors": "Супадзенне ўсіх аўтараў",
"ButtonNevermind": "Няважна",
"ButtonNext": "Далей",
"ButtonNextChapter": "Наступны раздзел",
"ButtonNextItemInQueue": "Наступны элемент у чарзе",
"ButtonOk": "Добра",
"ButtonOpenFeed": "Адкрыць стужку",
"ButtonOpenManager": "Адкрыць менеджар",
"ButtonPause": "Паўза",
"ButtonPlay": "Прайграць",
"ButtonPlayAll": "Прайграць усё",
"ButtonPlaying": "Прайграваецца",
"ButtonPlaylists": "Плэйлісты",
"ButtonPrevious": "Папярэдні",
"ButtonPreviousChapter": "Папярэдні раздзел",
"ButtonProbeAudioFile": "Праверыць аўдыяфайл",
"ButtonPurgeAllCache": "Ачысціць увесь кэш",
"ButtonPurgeItemsCache": "Ачысціць кэш элементаў",
"ButtonQueueAddItem": "Дадаць у чаргу",
"ButtonQueueRemoveItem": "Выдаліць з чаргі",
"ButtonQuickEmbed": "Хуткае ўбудаванне",
"ButtonQuickEmbedMetadata": "Хуткае ўбудаванне метаданых",
"ButtonQuickMatch": "Хуткі пошук",
"ButtonReScan": "Паўторнае сканаванне",
"ButtonRead": "Чытаць",
"ButtonRefresh": "Абнавіць",
"ButtonRemove": "Выдаліць",
"ButtonRemoveAll": "Выдаліць усе",
"ButtonRemoveAllLibraryItems": "Выдаліць усе элементы бібліятэкі",
"ButtonReset": "Скінуць",
"ButtonResetToDefault": "Скінуць па змаўчанні",
"ButtonRestore": "Аднавіць",
"ButtonSave": "Захаваць",
"ButtonSaveAndClose": "Захаваць і зачыніць",
"ButtonSaveTracklist": "Захаваць спіс трэкаў",
"ButtonScan": "Сканаваць",
"ButtonScanLibrary": "Сканіраваць бібліятэку",
"ButtonScrollLeft": "Пракруціць улева",
"ButtonScrollRight": "Пракруціць направа",
"ButtonSearch": "Пошук",
"ButtonSelectFolderPath": "Выбраць шлях да тэчкі",
"ButtonSeries": "Серыі",
"ButtonSetChaptersFromTracks": "Усталяваць раздзелы з трэкаў",
"ButtonShare": "Падзяліцца",
"ButtonStartM4BEncode": "Пачаць кадзіраванне ў M4B",
"ButtonStartMetadataEmbed": "Пачаць убудаванне метаданых",
"ButtonStats": "Статыстыка",
"ButtonSubmit": "Адправіць",
"ButtonTest": "Тэст",
"ButtonUnlinkOpenId": "Адвязаць OpenID",
"ButtonUpload": "Загрузіць",
"ButtonUploadBackup": "Загрузіць рэзервовую копію",
"ButtonUploadCover": "Загрузіць вокладку",
"ButtonUploadOPMLFile": "Загрузіць OPML файл",
"ButtonUserDelete": "Выдаліць карыстальніка {0}",
"ButtonUserEdit": "Рэдагаваць карыстальніка {0}",
"ButtonViewAll": "Прагледзець усе",
"ButtonYes": "Так",
"HeaderAccount": "Уліковы запіс",
"HeaderAddCustomMetadataProvider": "Дадаць карыстальніцкага пастаўшчыка метаданных",
"HeaderAppriseNotificationSettings": "Налады апавяшчэнняў Apprise",
"HeaderAudiobookTools": "Сродкі кіравання файламі аўдыякніг",
"HeaderAuthentication": "Аўтэнтыфікацыя",
"HeaderBackups": "Рэзервовыя копіі",
"HeaderChangePassword": "Змяніць пароль",
"HeaderChapters": "Раздзелы",
"HeaderChooseAFolder": "Выбраць тэчку",
"HeaderCollection": "Калекцыя",
"HeaderCollectionItems": "Элементы калекцыі",
"HeaderCover": "Вокладка",
"HeaderCurrentDownloads": "Бягучыя загрузкі",
"HeaderCustomMessageOnLogin": "Карыстальніцкае паведамленне пры ўваходзе"
}
-1
View File
@@ -725,7 +725,6 @@
"ToastBookmarkCreateFailed": "Неуспешно създаване на отметка", "ToastBookmarkCreateFailed": "Неуспешно създаване на отметка",
"ToastBookmarkCreateSuccess": "Отметката е създадена", "ToastBookmarkCreateSuccess": "Отметката е създадена",
"ToastBookmarkRemoveSuccess": "Отметката е премахната", "ToastBookmarkRemoveSuccess": "Отметката е премахната",
"ToastBookmarkUpdateSuccess": "Отметката е обновена",
"ToastChaptersHaveErrors": "Главите имат грешки", "ToastChaptersHaveErrors": "Главите имат грешки",
"ToastChaptersMustHaveTitles": "Главите трябва да имат заглавия", "ToastChaptersMustHaveTitles": "Главите трябва да имат заглавия",
"ToastCollectionRemoveSuccess": "Колекцията е премахната", "ToastCollectionRemoveSuccess": "Колекцията е премахната",
-1
View File
@@ -952,7 +952,6 @@
"ToastBookmarkCreateFailed": "বুকমার্ক তৈরি করতে ব্যর্থ", "ToastBookmarkCreateFailed": "বুকমার্ক তৈরি করতে ব্যর্থ",
"ToastBookmarkCreateSuccess": "বুকমার্ক যোগ করা হয়েছে", "ToastBookmarkCreateSuccess": "বুকমার্ক যোগ করা হয়েছে",
"ToastBookmarkRemoveSuccess": "বুকমার্ক সরানো হয়েছে", "ToastBookmarkRemoveSuccess": "বুকমার্ক সরানো হয়েছে",
"ToastBookmarkUpdateSuccess": "বুকমার্ক আপডেট করা হয়েছে",
"ToastCachePurgeFailed": "ক্যাশে পরিষ্কার করতে ব্যর্থ হয়েছে", "ToastCachePurgeFailed": "ক্যাশে পরিষ্কার করতে ব্যর্থ হয়েছে",
"ToastCachePurgeSuccess": "ক্যাশে সফলভাবে পরিষ্কার করা হয়েছে", "ToastCachePurgeSuccess": "ক্যাশে সফলভাবে পরিষ্কার করা হয়েছে",
"ToastChaptersHaveErrors": "অধ্যায়ে ত্রুটি আছে", "ToastChaptersHaveErrors": "অধ্যায়ে ত্রুটি আছে",
+2 -1
View File
@@ -88,6 +88,8 @@
"ButtonSaveTracklist": "Desa Pistes", "ButtonSaveTracklist": "Desa Pistes",
"ButtonScan": "Escaneja", "ButtonScan": "Escaneja",
"ButtonScanLibrary": "Escaneja Biblioteca", "ButtonScanLibrary": "Escaneja Biblioteca",
"ButtonScrollLeft": "Mou a l'esquerra",
"ButtonScrollRight": "Mou a la dreta",
"ButtonSearch": "Cerca", "ButtonSearch": "Cerca",
"ButtonSelectFolderPath": "Selecciona Ruta de Carpeta", "ButtonSelectFolderPath": "Selecciona Ruta de Carpeta",
"ButtonSeries": "Sèries", "ButtonSeries": "Sèries",
@@ -896,7 +898,6 @@
"ToastBookmarkCreateFailed": "Error en crear marcador", "ToastBookmarkCreateFailed": "Error en crear marcador",
"ToastBookmarkCreateSuccess": "Marcador afegit", "ToastBookmarkCreateSuccess": "Marcador afegit",
"ToastBookmarkRemoveSuccess": "Marcador eliminat", "ToastBookmarkRemoveSuccess": "Marcador eliminat",
"ToastBookmarkUpdateSuccess": "Marcador actualitzat",
"ToastCachePurgeFailed": "Error en purgar la memòria cau", "ToastCachePurgeFailed": "Error en purgar la memòria cau",
"ToastCachePurgeSuccess": "Memòria cau purgada amb èxit", "ToastCachePurgeSuccess": "Memòria cau purgada amb èxit",
"ToastChaptersHaveErrors": "Els capítols tenen errors", "ToastChaptersHaveErrors": "Els capítols tenen errors",
+12 -2
View File
@@ -572,7 +572,7 @@
"LabelSettingsLibraryMarkAsFinishedWhen": "Označit položku médií jako dokončenou, když", "LabelSettingsLibraryMarkAsFinishedWhen": "Označit položku médií jako dokončenou, když",
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Přeskočit předchozí knihy v pokračování série", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Přeskočit předchozí knihy v pokračování série",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Polička Pokračovat v sérii na domovské stránce zobrazuje první nezačatou knihu v sériích, které mají alespoň jednu knihu dokončenou a žádnou rozečtenou. Povolením tohoto nastavení budou série pokračovat od poslední dokončené knihy namísto první nezačaté knihy.", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Polička Pokračovat v sérii na domovské stránce zobrazuje první nezačatou knihu v sériích, které mají alespoň jednu knihu dokončenou a žádnou rozečtenou. Povolením tohoto nastavení budou série pokračovat od poslední dokončené knihy namísto první nezačaté knihy.",
"LabelSettingsParseSubtitles": "Analzyovat podtitul", "LabelSettingsParseSubtitles": "Analyzovat podtitul",
"LabelSettingsParseSubtitlesHelp": "Rozparsovat podtitul z názvů složek audioknih.<br>Podtiul musí být oddělen znakem \" - \"<br>tj. \"Název knihy - Zde Podtitul\" má podtitul \"Zde podtitul\"", "LabelSettingsParseSubtitlesHelp": "Rozparsovat podtitul z názvů složek audioknih.<br>Podtiul musí být oddělen znakem \" - \"<br>tj. \"Název knihy - Zde Podtitul\" má podtitul \"Zde podtitul\"",
"LabelSettingsPreferMatchedMetadata": "Preferovat spárovaná metadata", "LabelSettingsPreferMatchedMetadata": "Preferovat spárovaná metadata",
"LabelSettingsPreferMatchedMetadataHelp": "Spárovaná data budou mít při použití funkce Rychlé párování přednost před údaji o položce. Ve výchozím nastavení funkce Rychlé párování pouze doplní chybějící údaje.", "LabelSettingsPreferMatchedMetadataHelp": "Spárovaná data budou mít při použití funkce Rychlé párování přednost před údaji o položce. Ve výchozím nastavení funkce Rychlé párování pouze doplní chybějící údaje.",
@@ -756,6 +756,7 @@
"MessageConfirmResetProgress": "Opravdu chcete zahodit svůj pokrok?", "MessageConfirmResetProgress": "Opravdu chcete zahodit svůj pokrok?",
"MessageConfirmSendEbookToDevice": "Opravdu chcete odeslat e-knihu {0} {1}\" do zařízení \"{2}\"?", "MessageConfirmSendEbookToDevice": "Opravdu chcete odeslat e-knihu {0} {1}\" do zařízení \"{2}\"?",
"MessageConfirmUnlinkOpenId": "Opravdu chcete odpojit tohoto uživatele z OpenID?", "MessageConfirmUnlinkOpenId": "Opravdu chcete odpojit tohoto uživatele z OpenID?",
"MessageDaysListenedInTheLastYear": "{0} poslechových dní v minulém roce",
"MessageDownloadingEpisode": "Stahuji epizodu", "MessageDownloadingEpisode": "Stahuji epizodu",
"MessageDragFilesIntoTrackOrder": "Přetáhněte soubory do správného pořadí stop", "MessageDragFilesIntoTrackOrder": "Přetáhněte soubory do správného pořadí stop",
"MessageEmbedFailed": "Vložení selhalo!", "MessageEmbedFailed": "Vložení selhalo!",
@@ -866,6 +867,8 @@
"MessageTaskOpmlImportFeedPodcastExists": "Podcast se stejnou cestou již existuje", "MessageTaskOpmlImportFeedPodcastExists": "Podcast se stejnou cestou již existuje",
"MessageTaskOpmlImportFeedPodcastFailed": "Vytváření podcastu selhalo", "MessageTaskOpmlImportFeedPodcastFailed": "Vytváření podcastu selhalo",
"MessageTaskOpmlImportFinished": "Přidáno {0} podcastů", "MessageTaskOpmlImportFinished": "Přidáno {0} podcastů",
"MessageTaskOpmlParseFailed": "Selhalo parsování OPML souboru",
"MessageTaskOpmlParseFastFail": "Neplatný OPML soubor <opml> tag nenalezen NEBO <outline> tag nenalezen",
"MessageTaskScanItemsAdded": "{0} přidáno", "MessageTaskScanItemsAdded": "{0} přidáno",
"MessageTaskScanItemsMissing": "{0} chybí", "MessageTaskScanItemsMissing": "{0} chybí",
"MessageTaskScanItemsUpdated": "{0} aktualizováno", "MessageTaskScanItemsUpdated": "{0} aktualizováno",
@@ -890,6 +893,10 @@
"NoteUploaderFoldersWithMediaFiles": "Se složkami s multimediálními soubory bude zacházeno jako se samostatnými položkami knihovny.", "NoteUploaderFoldersWithMediaFiles": "Se složkami s multimediálními soubory bude zacházeno jako se samostatnými položkami knihovny.",
"NoteUploaderOnlyAudioFiles": "Pokud nahráváte pouze zvukové soubory, bude s každým zvukovým souborem zacházeno jako se samostatnou audioknihou.", "NoteUploaderOnlyAudioFiles": "Pokud nahráváte pouze zvukové soubory, bude s každým zvukovým souborem zacházeno jako se samostatnou audioknihou.",
"NoteUploaderUnsupportedFiles": "Nepodporované soubory jsou ignorovány. Při výběru nebo přetažení složky jsou ostatní soubory, které nejsou ve složce položek, ignorovány.", "NoteUploaderUnsupportedFiles": "Nepodporované soubory jsou ignorovány. Při výběru nebo přetažení složky jsou ostatní soubory, které nejsou ve složce položek, ignorovány.",
"NotificationOnBackupCompletedDescription": "Spuštěno po dokončení zálohování",
"NotificationOnBackupFailedDescription": "Spuštěno pokud zálohování selže",
"NotificationOnEpisodeDownloadedDescription": "Spuštěno při automatickém stažení epizody podcastu",
"NotificationOnTestDescription": "Akce pro otestování upozorňovacího systému",
"PlaceholderNewCollection": "Nový název kolekce", "PlaceholderNewCollection": "Nový název kolekce",
"PlaceholderNewFolderPath": "Nová cesta ke složce", "PlaceholderNewFolderPath": "Nová cesta ke složce",
"PlaceholderNewPlaylist": "Nový název seznamu přehrávání", "PlaceholderNewPlaylist": "Nový název seznamu přehrávání",
@@ -900,6 +907,7 @@
"StatsBooksAdditional": "Některé další zahrnují…", "StatsBooksAdditional": "Některé další zahrnují…",
"StatsBooksFinished": "dokončené knihy", "StatsBooksFinished": "dokončené knihy",
"StatsBooksFinishedThisYear": "Některé knihy dokončené tento rok…", "StatsBooksFinishedThisYear": "Některé knihy dokončené tento rok…",
"StatsBooksListenedTo": "knih poslechnuto",
"StatsCollectionGrewTo": "Vaše kolekce knih se rozrostla na…", "StatsCollectionGrewTo": "Vaše kolekce knih se rozrostla na…",
"StatsSessions": "sezení", "StatsSessions": "sezení",
"StatsSpentListening": "stráveno posloucháním", "StatsSpentListening": "stráveno posloucháním",
@@ -908,10 +916,13 @@
"StatsTopGenre": "TOP ŽÁNR", "StatsTopGenre": "TOP ŽÁNR",
"StatsTopGenres": "TOP ŽÁNRY", "StatsTopGenres": "TOP ŽÁNRY",
"StatsTopMonth": "TOP MĚSÍC", "StatsTopMonth": "TOP MĚSÍC",
"StatsTopNarrator": "NEJLEPŠÍ VYPRAVĚČ",
"StatsTopNarrators": "NEJLEPŠÍ VYPRAVĚČI",
"StatsTotalDuration": "S celkovou dobou…", "StatsTotalDuration": "S celkovou dobou…",
"StatsYearInReview": "ROK V PŘEHLEDU", "StatsYearInReview": "ROK V PŘEHLEDU",
"ToastAccountUpdateSuccess": "Účet aktualizován", "ToastAccountUpdateSuccess": "Účet aktualizován",
"ToastAppriseUrlRequired": "Je nutné zadat Apprise URL", "ToastAppriseUrlRequired": "Je nutné zadat Apprise URL",
"ToastAsinRequired": "ASIN vyžadován",
"ToastAuthorImageRemoveSuccess": "Obrázek autora odstraněn", "ToastAuthorImageRemoveSuccess": "Obrázek autora odstraněn",
"ToastAuthorNotFound": "Author \"{0}\" nenalezen", "ToastAuthorNotFound": "Author \"{0}\" nenalezen",
"ToastAuthorRemoveSuccess": "Autor odstraněn", "ToastAuthorRemoveSuccess": "Autor odstraněn",
@@ -936,7 +947,6 @@
"ToastBookmarkCreateFailed": "Vytvoření záložky se nezdařilo", "ToastBookmarkCreateFailed": "Vytvoření záložky se nezdařilo",
"ToastBookmarkCreateSuccess": "Přidána záložka", "ToastBookmarkCreateSuccess": "Přidána záložka",
"ToastBookmarkRemoveSuccess": "Záložka odstraněna", "ToastBookmarkRemoveSuccess": "Záložka odstraněna",
"ToastBookmarkUpdateSuccess": "Záložka aktualizována",
"ToastCachePurgeFailed": "Nepodařilo se vyčistit mezipaměť", "ToastCachePurgeFailed": "Nepodařilo se vyčistit mezipaměť",
"ToastCachePurgeSuccess": "Vyrovnávací paměť úspěšně vyčištěna", "ToastCachePurgeSuccess": "Vyrovnávací paměť úspěšně vyčištěna",
"ToastChaptersHaveErrors": "Kapitoly obsahují chyby", "ToastChaptersHaveErrors": "Kapitoly obsahují chyby",
+61 -1
View File
@@ -37,6 +37,8 @@
"ButtonHide": "Skjul", "ButtonHide": "Skjul",
"ButtonHome": "Hjem", "ButtonHome": "Hjem",
"ButtonIssues": "Problemer", "ButtonIssues": "Problemer",
"ButtonJumpBackward": "Hop Tilbage",
"ButtonJumpForward": "Hop Fremad",
"ButtonLatest": "Seneste", "ButtonLatest": "Seneste",
"ButtonLibrary": "Bibliotek", "ButtonLibrary": "Bibliotek",
"ButtonLogout": "Log ud", "ButtonLogout": "Log ud",
@@ -46,20 +48,30 @@
"ButtonMatchAllAuthors": "Match alle forfattere", "ButtonMatchAllAuthors": "Match alle forfattere",
"ButtonMatchBooks": "Match bøger", "ButtonMatchBooks": "Match bøger",
"ButtonNevermind": "Glem det", "ButtonNevermind": "Glem det",
"ButtonNext": "Næste",
"ButtonNextChapter": "Næste Kapitel",
"ButtonNextItemInQueue": "Næste Element i Køen",
"ButtonOk": "OK", "ButtonOk": "OK",
"ButtonOpenFeed": "Åbn feed", "ButtonOpenFeed": "Åbn feed",
"ButtonOpenManager": "Åbn manager", "ButtonOpenManager": "Åbn manager",
"ButtonPause": "Pause", "ButtonPause": "Pause",
"ButtonPlay": "Afspil", "ButtonPlay": "Afspil",
"ButtonPlayAll": "Afspil Alle",
"ButtonPlaying": "Afspiller", "ButtonPlaying": "Afspiller",
"ButtonPlaylists": "Afspilningslister", "ButtonPlaylists": "Afspilningslister",
"ButtonPrevious": "Sidste",
"ButtonPreviousChapter": "Sidste Kapitel",
"ButtonProbeAudioFile": "Undersøg Lydfil",
"ButtonPurgeAllCache": "Ryd al cache", "ButtonPurgeAllCache": "Ryd al cache",
"ButtonPurgeItemsCache": "Ryd elementcache", "ButtonPurgeItemsCache": "Ryd elementcache",
"ButtonQueueAddItem": "Tilføj til kø", "ButtonQueueAddItem": "Tilføj til kø",
"ButtonQueueRemoveItem": "Fjern fra kø", "ButtonQueueRemoveItem": "Fjern fra kø",
"ButtonQuickEmbed": "Hurtig Indlejring",
"ButtonQuickEmbedMetadata": "Hurtig Indlejring af Metadata",
"ButtonQuickMatch": "Hurtig Match", "ButtonQuickMatch": "Hurtig Match",
"ButtonReScan": "Gen-scan", "ButtonReScan": "Gen-scan",
"ButtonRead": "Læs", "ButtonRead": "Læs",
"ButtonRefresh": "Genindlæs",
"ButtonRemove": "Fjern", "ButtonRemove": "Fjern",
"ButtonRemoveAll": "Fjern Alle", "ButtonRemoveAll": "Fjern Alle",
"ButtonRemoveAllLibraryItems": "Fjern Alle Bibliotekselementer", "ButtonRemoveAllLibraryItems": "Fjern Alle Bibliotekselementer",
@@ -67,31 +79,46 @@
"ButtonRemoveFromContinueReading": "Fjern fra Fortsæt Læsning", "ButtonRemoveFromContinueReading": "Fjern fra Fortsæt Læsning",
"ButtonRemoveSeriesFromContinueSeries": "Fjern Serie fra Fortsæt Serie", "ButtonRemoveSeriesFromContinueSeries": "Fjern Serie fra Fortsæt Serie",
"ButtonReset": "Nulstil", "ButtonReset": "Nulstil",
"ButtonResetToDefault": "Nulstil til standard",
"ButtonRestore": "Gendan", "ButtonRestore": "Gendan",
"ButtonSave": "Gem", "ButtonSave": "Gem",
"ButtonSaveAndClose": "Gem & Luk", "ButtonSaveAndClose": "Gem & Luk",
"ButtonSaveTracklist": "Gem Sporliste", "ButtonSaveTracklist": "Gem Sporliste",
"ButtonScan": "Scan",
"ButtonScanLibrary": "Scan Bibliotek", "ButtonScanLibrary": "Scan Bibliotek",
"ButtonScrollLeft": "Rul til Venstre",
"ButtonScrollRight": "Rul til Højre",
"ButtonSearch": "Søg", "ButtonSearch": "Søg",
"ButtonSelectFolderPath": "Vælg Mappen Sti", "ButtonSelectFolderPath": "Vælg Mappen Sti",
"ButtonSeries": "Serier", "ButtonSeries": "Serier",
"ButtonSetChaptersFromTracks": "Sæt kapitler fra spor", "ButtonSetChaptersFromTracks": "Sæt kapitler fra spor",
"ButtonShare": "Del",
"ButtonShiftTimes": "Skift Tider", "ButtonShiftTimes": "Skift Tider",
"ButtonShow": "Vis", "ButtonShow": "Vis",
"ButtonStartM4BEncode": "Start M4B Kode", "ButtonStartM4BEncode": "Start M4B Kode",
"ButtonStartMetadataEmbed": "Start Metadata Indlejring", "ButtonStartMetadataEmbed": "Start Metadata Indlejring",
"ButtonStats": "Statistik",
"ButtonSubmit": "Send", "ButtonSubmit": "Send",
"ButtonTest": "Test",
"ButtonUnlinkOpenId": "Afkobl OpenID",
"ButtonUpload": "Upload",
"ButtonUploadBackup": "Upload Backup",
"ButtonUploadCover": "Upload Omslag", "ButtonUploadCover": "Upload Omslag",
"ButtonUploadOPMLFile": "Upload OPML Fil", "ButtonUploadOPMLFile": "Upload OPML Fil",
"ButtonUserDelete": "Slet bruger {0}", "ButtonUserDelete": "Slet bruger {0}",
"ButtonUserEdit": "Rediger bruger {0}", "ButtonUserEdit": "Rediger bruger {0}",
"ButtonViewAll": "Vis Alle", "ButtonViewAll": "Vis Alle",
"ButtonYes": "Ja", "ButtonYes": "Ja",
"ErrorUploadFetchMetadataAPI": "Fejl henter metadata",
"ErrorUploadFetchMetadataNoResults": "Kunne ikke hente metadata - prøv at uploade title og/eller forfatter",
"ErrorUploadLacksTitle": "Skal have en title",
"HeaderAccount": "Konto", "HeaderAccount": "Konto",
"HeaderAddCustomMetadataProvider": "Tilføj Brugerdefineret Metadataudbyder",
"HeaderAdvanced": "Avanceret", "HeaderAdvanced": "Avanceret",
"HeaderAppriseNotificationSettings": "Apprise Notifikationsindstillinger", "HeaderAppriseNotificationSettings": "Apprise Notifikationsindstillinger",
"HeaderAudioTracks": "Lydspor", "HeaderAudioTracks": "Lydspor",
"HeaderAudiobookTools": "Audiobog Filhåndteringsværktøjer", "HeaderAudiobookTools": "Audiobog Filhåndteringsværktøjer",
"HeaderAuthentication": "Autentificering",
"HeaderBackups": "Sikkerhedskopier", "HeaderBackups": "Sikkerhedskopier",
"HeaderChangePassword": "Skift Adgangskode", "HeaderChangePassword": "Skift Adgangskode",
"HeaderChapters": "Kapitler", "HeaderChapters": "Kapitler",
@@ -100,9 +127,12 @@
"HeaderCollectionItems": "Samlingselementer", "HeaderCollectionItems": "Samlingselementer",
"HeaderCover": "Omslag", "HeaderCover": "Omslag",
"HeaderCurrentDownloads": "Nuværende Downloads", "HeaderCurrentDownloads": "Nuværende Downloads",
"HeaderCustomMessageOnLogin": "Brugerdefineret Besked ved Login",
"HeaderCustomMetadataProviders": "Brugerdefineret Metadataudbyder",
"HeaderDetails": "Detaljer", "HeaderDetails": "Detaljer",
"HeaderDownloadQueue": "Download Kø", "HeaderDownloadQueue": "Download Kø",
"HeaderEbookFiles": "E-bogsfiler", "HeaderEbookFiles": "E-bogsfiler",
"HeaderEmail": "Email",
"HeaderEmailSettings": "Email Indstillinger", "HeaderEmailSettings": "Email Indstillinger",
"HeaderEpisodes": "Episoder", "HeaderEpisodes": "Episoder",
"HeaderEreaderDevices": "E-læser Enheder", "HeaderEreaderDevices": "E-læser Enheder",
@@ -120,33 +150,47 @@
"HeaderListeningSessions": "Lyttesessioner", "HeaderListeningSessions": "Lyttesessioner",
"HeaderListeningStats": "Lyttestatistik", "HeaderListeningStats": "Lyttestatistik",
"HeaderLogin": "Log ind", "HeaderLogin": "Log ind",
"HeaderLogs": "Logs",
"HeaderManageGenres": "Administrer Genrer", "HeaderManageGenres": "Administrer Genrer",
"HeaderManageTags": "Administrer Tags", "HeaderManageTags": "Administrer Tags",
"HeaderMapDetails": "Kort Detaljer", "HeaderMapDetails": "Kort Detaljer",
"HeaderMatch": "Match",
"HeaderMetadataOrderOfPrecedence": "Metadata-prioritet",
"HeaderMetadataToEmbed": "Metadata til indlejring", "HeaderMetadataToEmbed": "Metadata til indlejring",
"HeaderNewAccount": "Ny Konto", "HeaderNewAccount": "Ny Konto",
"HeaderNewLibrary": "Nyt Bibliotek", "HeaderNewLibrary": "Nyt Bibliotek",
"HeaderNotificationCreate": "Opret Notifikation",
"HeaderNotificationUpdate": "Updater Notifikation",
"HeaderNotifications": "Meddelelser", "HeaderNotifications": "Meddelelser",
"HeaderOpenIDConnectAuthentication": "OpenID Connect-autentificering",
"HeaderOpenListeningSessions": "Åbne lyttesessioner",
"HeaderOpenRSSFeed": "Åbn RSS Feed", "HeaderOpenRSSFeed": "Åbn RSS Feed",
"HeaderOtherFiles": "Andre Filer", "HeaderOtherFiles": "Andre Filer",
"HeaderPasswordAuthentication": "Adgangskodeautentificering",
"HeaderPermissions": "Tilladelser", "HeaderPermissions": "Tilladelser",
"HeaderPlayerQueue": "Afspilningskø", "HeaderPlayerQueue": "Afspilningskø",
"HeaderPlayerSettings": "Afspiller Indstillinger",
"HeaderPlaylist": "Afspilningsliste", "HeaderPlaylist": "Afspilningsliste",
"HeaderPlaylistItems": "Afspilningsliste Elementer", "HeaderPlaylistItems": "Afspilningsliste Elementer",
"HeaderPodcastsToAdd": "Podcasts til Tilføjelse", "HeaderPodcastsToAdd": "Podcasts til Tilføjelse",
"HeaderPreviewCover": "Forhåndsvis Omslag", "HeaderPreviewCover": "Forhåndsvis Omslag",
"HeaderRSSFeedGeneral": "RSS Detaljer", "HeaderRSSFeedGeneral": "RSS Detaljer",
"HeaderRSSFeedIsOpen": "RSS Feed er Åben", "HeaderRSSFeedIsOpen": "RSS Feed er Åben",
"HeaderRSSFeeds": "RSS-Feeds",
"HeaderRemoveEpisode": "Fjern Episode", "HeaderRemoveEpisode": "Fjern Episode",
"HeaderRemoveEpisodes": "Fjern {0} Episoder", "HeaderRemoveEpisodes": "Fjern {0} Episoder",
"HeaderSavedMediaProgress": "Gemt Medieforløb", "HeaderSavedMediaProgress": "Gemt Medieforløb",
"HeaderSchedule": "Planlæg", "HeaderSchedule": "Planlæg",
"HeaderScheduleEpisodeDownloads": "Planlæg Automatisk Episode-Download",
"HeaderScheduleLibraryScans": "Planlæg Automatiske Biblioteksscanninger", "HeaderScheduleLibraryScans": "Planlæg Automatiske Biblioteksscanninger",
"HeaderSession": "Session",
"HeaderSetBackupSchedule": "Indstil Sikkerhedskopieringsplan", "HeaderSetBackupSchedule": "Indstil Sikkerhedskopieringsplan",
"HeaderSettings": "Indstillinger", "HeaderSettings": "Indstillinger",
"HeaderSettingsDisplay": "Skærm", "HeaderSettingsDisplay": "Skærm",
"HeaderSettingsExperimental": "Eksperimentelle Funktioner", "HeaderSettingsExperimental": "Eksperimentelle Funktioner",
"HeaderSettingsGeneral": "Generelt", "HeaderSettingsGeneral": "Generelt",
"HeaderSettingsScanner": "Scanner",
"HeaderSettingsWebClient": "Webklient",
"HeaderSleepTimer": "Søvntimer", "HeaderSleepTimer": "Søvntimer",
"HeaderStatsLargestItems": "Største Elementer", "HeaderStatsLargestItems": "Største Elementer",
"HeaderStatsLongestItems": "Længste Elementer (timer)", "HeaderStatsLongestItems": "Længste Elementer (timer)",
@@ -161,7 +205,12 @@
"HeaderUpdateDetails": "Opdater Detaljer", "HeaderUpdateDetails": "Opdater Detaljer",
"HeaderUpdateLibrary": "Opdater Bibliotek", "HeaderUpdateLibrary": "Opdater Bibliotek",
"HeaderUsers": "Brugere", "HeaderUsers": "Brugere",
"HeaderYearReview": "Gennemgang af År {0}",
"HeaderYourStats": "Dine Statistikker", "HeaderYourStats": "Dine Statistikker",
"LabelAbridged": "Forkortet",
"LabelAbridgedChecked": "Forkortet (kontrolleret)",
"LabelAbridgedUnchecked": "Uforkortet (ikke kontrolleret)",
"LabelAccessibleBy": "Tilgængelig af",
"LabelAccountType": "Kontotype", "LabelAccountType": "Kontotype",
"LabelAccountTypeAdmin": "Administrator", "LabelAccountTypeAdmin": "Administrator",
"LabelAccountTypeGuest": "Gæst", "LabelAccountTypeGuest": "Gæst",
@@ -172,15 +221,26 @@
"LabelAddToPlaylist": "Tilføj til Afspilningsliste", "LabelAddToPlaylist": "Tilføj til Afspilningsliste",
"LabelAddToPlaylistBatch": "Tilføj {0} Elementer til Afspilningsliste", "LabelAddToPlaylistBatch": "Tilføj {0} Elementer til Afspilningsliste",
"LabelAddedAt": "Tilføjet Kl.", "LabelAddedAt": "Tilføjet Kl.",
"LabelAdminUsersOnly": "Kun Administratorbrugere",
"LabelAll": "Alle", "LabelAll": "Alle",
"LabelAllUsers": "Alle Brugere", "LabelAllUsers": "Alle Brugere",
"LabelAllUsersExcludingGuests": "Alle bruger eksklusiv gæster",
"LabelAllUsersIncludingGuests": "Alle bruger inklusiv gæster",
"LabelAlreadyInYourLibrary": "Allerede i dit bibliotek", "LabelAlreadyInYourLibrary": "Allerede i dit bibliotek",
"LabelApiToken": "API Token",
"LabelAppend": "Tilføj", "LabelAppend": "Tilføj",
"LabelAudioBitrate": "Lydbitrate (f.eks. 128k)",
"LabelAudioChannels": "Lydkanaler (1 eller 2)",
"LabelAudioCodec": "Lydkodek",
"LabelAuthor": "Forfatter", "LabelAuthor": "Forfatter",
"LabelAuthorFirstLast": "Forfatter (Fornavn Efternavn)", "LabelAuthorFirstLast": "Forfatter (Fornavn Efternavn)",
"LabelAuthorLastFirst": "Forfatter (Efternavn, Fornavn)", "LabelAuthorLastFirst": "Forfatter (Efternavn, Fornavn)",
"LabelAuthors": "Forfattere", "LabelAuthors": "Forfattere",
"LabelAutoDownloadEpisodes": "Auto Download Episoder", "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",
"LabelAutoLaunchDescription": "Viderestil automatisk til login-udbyderen ved navigation til login-siden (manuel overstyring via <code>/login?autoLaunch=0</code>)",
"LabelBackToUser": "Tilbage til Bruger", "LabelBackToUser": "Tilbage til Bruger",
"LabelBackupLocation": "Backup Placering", "LabelBackupLocation": "Backup Placering",
"LabelBackupsEnableAutomaticBackups": "Aktivér automatisk sikkerhedskopiering", "LabelBackupsEnableAutomaticBackups": "Aktivér automatisk sikkerhedskopiering",
@@ -190,6 +250,7 @@
"LabelBackupsNumberToKeep": "Antal sikkerhedskopier at beholde", "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.", "LabelBackupsNumberToKeepHelp": "Kun 1 sikkerhedskopi fjernes ad gangen, så hvis du allerede har flere sikkerhedskopier end dette, skal du fjerne dem manuelt.",
"LabelBooks": "Bøger", "LabelBooks": "Bøger",
"LabelByAuthor": "af {0}",
"LabelChangePassword": "Ændre Adgangskode", "LabelChangePassword": "Ændre Adgangskode",
"LabelChannels": "Kanaler", "LabelChannels": "Kanaler",
"LabelChapterTitle": "Kapitel Titel", "LabelChapterTitle": "Kapitel Titel",
@@ -636,7 +697,6 @@
"ToastBookmarkCreateFailed": "Mislykkedes oprettelse af bogmærke", "ToastBookmarkCreateFailed": "Mislykkedes oprettelse af bogmærke",
"ToastBookmarkCreateSuccess": "Bogmærke tilføjet", "ToastBookmarkCreateSuccess": "Bogmærke tilføjet",
"ToastBookmarkRemoveSuccess": "Bogmærke fjernet", "ToastBookmarkRemoveSuccess": "Bogmærke fjernet",
"ToastBookmarkUpdateSuccess": "Bogmærke opdateret",
"ToastChaptersHaveErrors": "Kapitler har fejl", "ToastChaptersHaveErrors": "Kapitler har fejl",
"ToastChaptersMustHaveTitles": "Kapitler skal have titler", "ToastChaptersMustHaveTitles": "Kapitler skal have titler",
"ToastCollectionRemoveSuccess": "Samling fjernet", "ToastCollectionRemoveSuccess": "Samling fjernet",
+8 -3
View File
@@ -51,7 +51,7 @@
"ButtonNext": "Vor", "ButtonNext": "Vor",
"ButtonNextChapter": "Nächstes Kapitel", "ButtonNextChapter": "Nächstes Kapitel",
"ButtonNextItemInQueue": "Das nächste Element in der Warteschlange", "ButtonNextItemInQueue": "Das nächste Element in der Warteschlange",
"ButtonOk": "OK", "ButtonOk": "Einverstanden",
"ButtonOpenFeed": "Feed öffnen", "ButtonOpenFeed": "Feed öffnen",
"ButtonOpenManager": "Manager öffnen", "ButtonOpenManager": "Manager öffnen",
"ButtonPause": "Pausieren", "ButtonPause": "Pausieren",
@@ -300,6 +300,7 @@
"LabelDiscover": "Entdecken", "LabelDiscover": "Entdecken",
"LabelDownload": "Herunterladen", "LabelDownload": "Herunterladen",
"LabelDownloadNEpisodes": "Download {0} Episoden", "LabelDownloadNEpisodes": "Download {0} Episoden",
"LabelDownloadable": "Herunterladbar",
"LabelDuration": "Laufzeit", "LabelDuration": "Laufzeit",
"LabelDurationComparisonExactMatch": "(genauer Treffer)", "LabelDurationComparisonExactMatch": "(genauer Treffer)",
"LabelDurationComparisonLonger": "({0} länger)", "LabelDurationComparisonLonger": "({0} länger)",
@@ -588,6 +589,7 @@
"LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet", "LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet",
"LabelSettingsTimeFormat": "Zeitformat", "LabelSettingsTimeFormat": "Zeitformat",
"LabelShare": "Freigeben", "LabelShare": "Freigeben",
"LabelShareDownloadableHelp": "Erlaubt es einem Nutzer, mit dem Link, die Dateien des Mediums als ZIP herunterzuladen.",
"LabelShareOpen": "Freigeben", "LabelShareOpen": "Freigeben",
"LabelShareURL": "Freigabe URL", "LabelShareURL": "Freigabe URL",
"LabelShowAll": "Alles anzeigen", "LabelShowAll": "Alles anzeigen",
@@ -756,6 +758,7 @@
"MessageConfirmResetProgress": "Möchtest du Ihren Fortschritt wirklich zurücksetzen?", "MessageConfirmResetProgress": "Möchtest du Ihren Fortschritt wirklich zurücksetzen?",
"MessageConfirmSendEbookToDevice": "{0} E-Buch „{1}“ wird auf das Gerät „{2}“ gesendet! Bist du dir sicher?", "MessageConfirmSendEbookToDevice": "{0} E-Buch „{1}“ wird auf das Gerät „{2}“ gesendet! Bist du dir sicher?",
"MessageConfirmUnlinkOpenId": "Möchtest du die Verknüpfung dieses Benutzers mit OpenID wirklich löschen?", "MessageConfirmUnlinkOpenId": "Möchtest du die Verknüpfung dieses Benutzers mit OpenID wirklich löschen?",
"MessageDaysListenedInTheLastYear": "{0} Tage in dem letzten Jahr gehört",
"MessageDownloadingEpisode": "Episode wird heruntergeladen", "MessageDownloadingEpisode": "Episode wird heruntergeladen",
"MessageDragFilesIntoTrackOrder": "Verschiebe die Dateien in die richtige Reihenfolge", "MessageDragFilesIntoTrackOrder": "Verschiebe die Dateien in die richtige Reihenfolge",
"MessageEmbedFailed": "Einbetten fehlgeschlagen!", "MessageEmbedFailed": "Einbetten fehlgeschlagen!",
@@ -834,6 +837,7 @@
"MessageResetChaptersConfirm": "Kapitel und vorgenommenen Änderungen werden zurückgesetzt und rückgängig gemacht! Bist du dir sicher?", "MessageResetChaptersConfirm": "Kapitel und vorgenommenen Änderungen werden zurückgesetzt und rückgängig gemacht! Bist du dir sicher?",
"MessageRestoreBackupConfirm": "Bist du dir sicher, dass du die Sicherung wiederherstellen willst, welche am", "MessageRestoreBackupConfirm": "Bist du dir sicher, dass du die Sicherung wiederherstellen willst, welche am",
"MessageRestoreBackupWarning": "Bei der Wiederherstellung einer Sicherung wird die gesamte Datenbank unter /config und die Titelbilder in /metadata/items und /metadata/authors überschrieben.<br /><br />Bei der Sicherung werden keine Dateien in deinen Bibliotheksordnern verändert. Wenn du die Servereinstellungen aktiviert hast, um Cover und Metadaten in deinen Bibliotheksordnern zu speichern, werden diese nicht gesichert oder überschrieben.<br /><br />Alle Clients, die Ihren Server nutzen, werden automatisch aktualisiert.", "MessageRestoreBackupWarning": "Bei der Wiederherstellung einer Sicherung wird die gesamte Datenbank unter /config und die Titelbilder in /metadata/items und /metadata/authors überschrieben.<br /><br />Bei der Sicherung werden keine Dateien in deinen Bibliotheksordnern verändert. Wenn du die Servereinstellungen aktiviert hast, um Cover und Metadaten in deinen Bibliotheksordnern zu speichern, werden diese nicht gesichert oder überschrieben.<br /><br />Alle Clients, die Ihren Server nutzen, werden automatisch aktualisiert.",
"MessageScheduleLibraryScanNote": "Für die meisten Nutzer wird empfohlen, diese Funktion deaktiviert zu lassen und stattdessen die Ordnerüberwachung aktiviert zu lassen. Die Ordnerüberwachung erkennt automatisch Änderungen in deinen Bibliotheksordnern. Da die Ordnerüberwachung jedoch nicht mit jedem Dateisystem (z.B. NFS) funktioniert, können alternativ hier geplante Bibliotheks-Scans aktiviert werden.",
"MessageSearchResultsFor": "Suchergebnisse für", "MessageSearchResultsFor": "Suchergebnisse für",
"MessageSelected": "{0} ausgewählt", "MessageSelected": "{0} ausgewählt",
"MessageServerCouldNotBeReached": "Server kann nicht erreicht werden", "MessageServerCouldNotBeReached": "Server kann nicht erreicht werden",
@@ -909,7 +913,7 @@
"StatsBooksFinished": "Beendete Bücher", "StatsBooksFinished": "Beendete Bücher",
"StatsBooksFinishedThisYear": "Einige Bücher, die dieses Jahr beendet wurden…", "StatsBooksFinishedThisYear": "Einige Bücher, die dieses Jahr beendet wurden…",
"StatsBooksListenedTo": "gehörte Bücher", "StatsBooksListenedTo": "gehörte Bücher",
"StatsCollectionGrewTo": "Deine Bückersammlung ist gewachsen auf…", "StatsCollectionGrewTo": "Deine Büchersammlung ist gewachsen auf…",
"StatsSessions": "Sitzungen", "StatsSessions": "Sitzungen",
"StatsSpentListening": "zugehört", "StatsSpentListening": "zugehört",
"StatsTopAuthor": "TOP AUTOR", "StatsTopAuthor": "TOP AUTOR",
@@ -950,7 +954,6 @@
"ToastBookmarkCreateFailed": "Lesezeichen konnte nicht erstellt werden", "ToastBookmarkCreateFailed": "Lesezeichen konnte nicht erstellt werden",
"ToastBookmarkCreateSuccess": "Lesezeichen hinzugefügt", "ToastBookmarkCreateSuccess": "Lesezeichen hinzugefügt",
"ToastBookmarkRemoveSuccess": "Lesezeichen entfernt", "ToastBookmarkRemoveSuccess": "Lesezeichen entfernt",
"ToastBookmarkUpdateSuccess": "Lesezeichen aktualisiert",
"ToastCachePurgeFailed": "Cache leeren fehlgeschlagen", "ToastCachePurgeFailed": "Cache leeren fehlgeschlagen",
"ToastCachePurgeSuccess": "Cache geleert", "ToastCachePurgeSuccess": "Cache geleert",
"ToastChaptersHaveErrors": "Kapitel sind fehlerhaft", "ToastChaptersHaveErrors": "Kapitel sind fehlerhaft",
@@ -961,6 +964,7 @@
"ToastCollectionRemoveSuccess": "Sammlung entfernt", "ToastCollectionRemoveSuccess": "Sammlung entfernt",
"ToastCollectionUpdateSuccess": "Sammlung aktualisiert", "ToastCollectionUpdateSuccess": "Sammlung aktualisiert",
"ToastCoverUpdateFailed": "Cover-Update fehlgeschlagen", "ToastCoverUpdateFailed": "Cover-Update fehlgeschlagen",
"ToastDateTimeInvalidOrIncomplete": "Datum und Zeit ist ungültig oder unvollständig",
"ToastDeleteFileFailed": "Die Datei konnte nicht gelöscht werden", "ToastDeleteFileFailed": "Die Datei konnte nicht gelöscht werden",
"ToastDeleteFileSuccess": "Datei gelöscht", "ToastDeleteFileSuccess": "Datei gelöscht",
"ToastDeviceAddFailed": "Gerät konnte nicht hinzugefügt werden", "ToastDeviceAddFailed": "Gerät konnte nicht hinzugefügt werden",
@@ -1013,6 +1017,7 @@
"ToastNewUserTagError": "Mindestens ein Tag muss ausgewählt sein", "ToastNewUserTagError": "Mindestens ein Tag muss ausgewählt sein",
"ToastNewUserUsernameError": "Nutzername eingeben", "ToastNewUserUsernameError": "Nutzername eingeben",
"ToastNoNewEpisodesFound": "Keine neuen Episoden gefunden", "ToastNoNewEpisodesFound": "Keine neuen Episoden gefunden",
"ToastNoRSSFeed": "Podcast hat keinen RSS Feed",
"ToastNoUpdatesNecessary": "Keine Änderungen nötig", "ToastNoUpdatesNecessary": "Keine Änderungen nötig",
"ToastNotificationCreateFailed": "Fehler beim erstellen der Benachrichtig", "ToastNotificationCreateFailed": "Fehler beim erstellen der Benachrichtig",
"ToastNotificationDeleteFailed": "Fehler beim löschen der Benachrichtigung", "ToastNotificationDeleteFailed": "Fehler beim löschen der Benachrichtigung",
+3 -1
View File
@@ -837,6 +837,7 @@
"MessageResetChaptersConfirm": "Are you sure you want to reset chapters and undo the changes you made?", "MessageResetChaptersConfirm": "Are you sure you want to reset chapters and undo the changes you made?",
"MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on", "MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on",
"MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.", "MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.",
"MessageScheduleLibraryScanNote": "For most users, it is recommended to leave this feature disabled and keep the folder watcher setting enabled. The folder watcher will automatically detect changes in your library folders. The folder watcher doesn't work for every file system (like NFS) so scheduled library scans can be used instead.",
"MessageSearchResultsFor": "Search results for", "MessageSearchResultsFor": "Search results for",
"MessageSelected": "{0} selected", "MessageSelected": "{0} selected",
"MessageServerCouldNotBeReached": "Server could not be reached", "MessageServerCouldNotBeReached": "Server could not be reached",
@@ -953,7 +954,6 @@
"ToastBookmarkCreateFailed": "Failed to create bookmark", "ToastBookmarkCreateFailed": "Failed to create bookmark",
"ToastBookmarkCreateSuccess": "Bookmark added", "ToastBookmarkCreateSuccess": "Bookmark added",
"ToastBookmarkRemoveSuccess": "Bookmark removed", "ToastBookmarkRemoveSuccess": "Bookmark removed",
"ToastBookmarkUpdateSuccess": "Bookmark updated",
"ToastCachePurgeFailed": "Failed to purge cache", "ToastCachePurgeFailed": "Failed to purge cache",
"ToastCachePurgeSuccess": "Cache purged successfully", "ToastCachePurgeSuccess": "Cache purged successfully",
"ToastChaptersHaveErrors": "Chapters have errors", "ToastChaptersHaveErrors": "Chapters have errors",
@@ -964,6 +964,7 @@
"ToastCollectionRemoveSuccess": "Collection removed", "ToastCollectionRemoveSuccess": "Collection removed",
"ToastCollectionUpdateSuccess": "Collection updated", "ToastCollectionUpdateSuccess": "Collection updated",
"ToastCoverUpdateFailed": "Cover update failed", "ToastCoverUpdateFailed": "Cover update failed",
"ToastDateTimeInvalidOrIncomplete": "Date and time is invalid or incomplete",
"ToastDeleteFileFailed": "Failed to delete file", "ToastDeleteFileFailed": "Failed to delete file",
"ToastDeleteFileSuccess": "File deleted", "ToastDeleteFileSuccess": "File deleted",
"ToastDeviceAddFailed": "Failed to add device", "ToastDeviceAddFailed": "Failed to add device",
@@ -1016,6 +1017,7 @@
"ToastNewUserTagError": "Must select at least one tag", "ToastNewUserTagError": "Must select at least one tag",
"ToastNewUserUsernameError": "Enter a username", "ToastNewUserUsernameError": "Enter a username",
"ToastNoNewEpisodesFound": "No new episodes found", "ToastNoNewEpisodesFound": "No new episodes found",
"ToastNoRSSFeed": "Podcast does not have an RSS Feed",
"ToastNoUpdatesNecessary": "No updates necessary", "ToastNoUpdatesNecessary": "No updates necessary",
"ToastNotificationCreateFailed": "Failed to create notification", "ToastNotificationCreateFailed": "Failed to create notification",
"ToastNotificationDeleteFailed": "Failed to delete notification", "ToastNotificationDeleteFailed": "Failed to delete notification",
+6 -2
View File
@@ -300,6 +300,7 @@
"LabelDiscover": "Descubrir", "LabelDiscover": "Descubrir",
"LabelDownload": "Descargar", "LabelDownload": "Descargar",
"LabelDownloadNEpisodes": "Descargar {0} episodios", "LabelDownloadNEpisodes": "Descargar {0} episodios",
"LabelDownloadable": "Descarregable",
"LabelDuration": "Duración", "LabelDuration": "Duración",
"LabelDurationComparisonExactMatch": "(coincidencia exacta)", "LabelDurationComparisonExactMatch": "(coincidencia exacta)",
"LabelDurationComparisonLonger": "({0} más largo)", "LabelDurationComparisonLonger": "({0} más largo)",
@@ -588,6 +589,7 @@
"LabelSettingsStoreMetadataWithItemHelp": "Por defecto, los archivos de metadatos se almacenan en /metadata/items. Si habilita esta opción, los archivos de metadatos se guardarán en la carpeta de elementos de su biblioteca", "LabelSettingsStoreMetadataWithItemHelp": "Por defecto, los archivos de metadatos se almacenan en /metadata/items. Si habilita esta opción, los archivos de metadatos se guardarán en la carpeta de elementos de su biblioteca",
"LabelSettingsTimeFormat": "Formato de Tiempo", "LabelSettingsTimeFormat": "Formato de Tiempo",
"LabelShare": "Compartir", "LabelShare": "Compartir",
"LabelShareDownloadableHelp": "Permet als usuaris amb l'enllaç compartit descarregar un arxiu zip amb l'item de la llibreria.",
"LabelShareOpen": "abrir un recurso compartido", "LabelShareOpen": "abrir un recurso compartido",
"LabelShareURL": "Compartir la URL", "LabelShareURL": "Compartir la URL",
"LabelShowAll": "Mostrar Todos", "LabelShowAll": "Mostrar Todos",
@@ -756,6 +758,7 @@
"MessageConfirmResetProgress": "¿Estás seguro de que quieres reiniciar tu progreso?", "MessageConfirmResetProgress": "¿Estás seguro de que quieres reiniciar tu progreso?",
"MessageConfirmSendEbookToDevice": "¿Está seguro de que enviar {0} ebook(s) \"{1}\" al dispositivo \"{2}\"?", "MessageConfirmSendEbookToDevice": "¿Está seguro de que enviar {0} ebook(s) \"{1}\" al dispositivo \"{2}\"?",
"MessageConfirmUnlinkOpenId": "¿Estás seguro de que deseas desvincular este usuario de OpenID?", "MessageConfirmUnlinkOpenId": "¿Estás seguro de que deseas desvincular este usuario de OpenID?",
"MessageDaysListenedInTheLastYear": "{0} dies escoltats en l'últim any",
"MessageDownloadingEpisode": "Descargando Capitulo", "MessageDownloadingEpisode": "Descargando Capitulo",
"MessageDragFilesIntoTrackOrder": "Arrastra los archivos al orden correcto de las pistas", "MessageDragFilesIntoTrackOrder": "Arrastra los archivos al orden correcto de las pistas",
"MessageEmbedFailed": "¡Error al insertar!", "MessageEmbedFailed": "¡Error al insertar!",
@@ -950,7 +953,6 @@
"ToastBookmarkCreateFailed": "Error al crear marcador", "ToastBookmarkCreateFailed": "Error al crear marcador",
"ToastBookmarkCreateSuccess": "Marcador Agregado", "ToastBookmarkCreateSuccess": "Marcador Agregado",
"ToastBookmarkRemoveSuccess": "Marcador eliminado", "ToastBookmarkRemoveSuccess": "Marcador eliminado",
"ToastBookmarkUpdateSuccess": "Marcador actualizado",
"ToastCachePurgeFailed": "Error al purgar el caché", "ToastCachePurgeFailed": "Error al purgar el caché",
"ToastCachePurgeSuccess": "Caché purgado de manera exitosa", "ToastCachePurgeSuccess": "Caché purgado de manera exitosa",
"ToastChaptersHaveErrors": "Los capítulos tienen errores", "ToastChaptersHaveErrors": "Los capítulos tienen errores",
@@ -961,6 +963,7 @@
"ToastCollectionRemoveSuccess": "Colección removida", "ToastCollectionRemoveSuccess": "Colección removida",
"ToastCollectionUpdateSuccess": "Colección actualizada", "ToastCollectionUpdateSuccess": "Colección actualizada",
"ToastCoverUpdateFailed": "Error al actualizar la cubierta", "ToastCoverUpdateFailed": "Error al actualizar la cubierta",
"ToastDateTimeInvalidOrIncomplete": "Fecha y hora inválidas o incompletas",
"ToastDeleteFileFailed": "Error el eliminar archivo", "ToastDeleteFileFailed": "Error el eliminar archivo",
"ToastDeleteFileSuccess": "Archivo eliminado", "ToastDeleteFileSuccess": "Archivo eliminado",
"ToastDeviceAddFailed": "Error al añadir dispositivo", "ToastDeviceAddFailed": "Error al añadir dispositivo",
@@ -997,7 +1000,7 @@
"ToastLibraryScanFailedToStart": "Error al iniciar el escaneo", "ToastLibraryScanFailedToStart": "Error al iniciar el escaneo",
"ToastLibraryScanStarted": "Se inició el escaneo de la biblioteca", "ToastLibraryScanStarted": "Se inició el escaneo de la biblioteca",
"ToastLibraryUpdateSuccess": "Biblioteca \"{0}\" actualizada", "ToastLibraryUpdateSuccess": "Biblioteca \"{0}\" actualizada",
"ToastMatchAllAuthorsFailed": "No coincide con todos los autores", "ToastMatchAllAuthorsFailed": "No se pudo encontrar a todos los autores",
"ToastMetadataFilesRemovedError": "Error al eliminar metadatos de {0} archivo(s)", "ToastMetadataFilesRemovedError": "Error al eliminar metadatos de {0} archivo(s)",
"ToastMetadataFilesRemovedNoneFound": "No hay metadatos.{0} archivo(s) encontrado(s) en la biblioteca", "ToastMetadataFilesRemovedNoneFound": "No hay metadatos.{0} archivo(s) encontrado(s) en la biblioteca",
"ToastMetadataFilesRemovedNoneRemoved": "Sin metadatos.{0} archivo(s) eliminado(s)", "ToastMetadataFilesRemovedNoneRemoved": "Sin metadatos.{0} archivo(s) eliminado(s)",
@@ -1013,6 +1016,7 @@
"ToastNewUserTagError": "Debes seleccionar al menos una etiqueta", "ToastNewUserTagError": "Debes seleccionar al menos una etiqueta",
"ToastNewUserUsernameError": "Introduce un nombre de usuario", "ToastNewUserUsernameError": "Introduce un nombre de usuario",
"ToastNoNewEpisodesFound": "No se encontraron nuevos episodios", "ToastNoNewEpisodesFound": "No se encontraron nuevos episodios",
"ToastNoRSSFeed": "El Podcast no tiene una fuente RSS",
"ToastNoUpdatesNecessary": "No es necesario actualizar", "ToastNoUpdatesNecessary": "No es necesario actualizar",
"ToastNotificationCreateFailed": "Error al crear notificación", "ToastNotificationCreateFailed": "Error al crear notificación",
"ToastNotificationDeleteFailed": "Error al borrar la notificación", "ToastNotificationDeleteFailed": "Error al borrar la notificación",
-1
View File
@@ -709,7 +709,6 @@
"ToastBookmarkCreateFailed": "Järjehoidja loomine ebaõnnestus", "ToastBookmarkCreateFailed": "Järjehoidja loomine ebaõnnestus",
"ToastBookmarkCreateSuccess": "Järjehoidja lisatud", "ToastBookmarkCreateSuccess": "Järjehoidja lisatud",
"ToastBookmarkRemoveSuccess": "Järjehoidja eemaldatud", "ToastBookmarkRemoveSuccess": "Järjehoidja eemaldatud",
"ToastBookmarkUpdateSuccess": "Järjehoidja värskendatud",
"ToastChaptersHaveErrors": "Peatükkidel on vigu", "ToastChaptersHaveErrors": "Peatükkidel on vigu",
"ToastChaptersMustHaveTitles": "Peatükkidel peab olema pealkiri", "ToastChaptersMustHaveTitles": "Peatükkidel peab olema pealkiri",
"ToastCollectionRemoveSuccess": "Kogum eemaldatud", "ToastCollectionRemoveSuccess": "Kogum eemaldatud",
+130 -3
View File
@@ -65,11 +65,13 @@
"ButtonPurgeItemsCache": "Tyhjennä kohteiden välimuisti", "ButtonPurgeItemsCache": "Tyhjennä kohteiden välimuisti",
"ButtonQueueAddItem": "Lisää jonoon", "ButtonQueueAddItem": "Lisää jonoon",
"ButtonQueueRemoveItem": "Poista jonosta", "ButtonQueueRemoveItem": "Poista jonosta",
"ButtonQuickEmbed": "Pikaupota",
"ButtonQuickEmbedMetadata": "Upota kuvailutiedot nopeasti",
"ButtonQuickMatch": "Pikatäsmää", "ButtonQuickMatch": "Pikatäsmää",
"ButtonReScan": "Uudelleenskannaa", "ButtonReScan": "Uudelleenskannaa",
"ButtonRead": "Lue", "ButtonRead": "Lue",
"ButtonReadLess": "Näytä vähemmän", "ButtonReadLess": "Lue vähemmän",
"ButtonReadMore": "Näytä enemmän", "ButtonReadMore": "Lue enemmän",
"ButtonRefresh": "Päivitä", "ButtonRefresh": "Päivitä",
"ButtonRemove": "Poista", "ButtonRemove": "Poista",
"ButtonRemoveAll": "Poista kaikki", "ButtonRemoveAll": "Poista kaikki",
@@ -85,6 +87,8 @@
"ButtonSaveTracklist": "Tallenna raitalista", "ButtonSaveTracklist": "Tallenna raitalista",
"ButtonScan": "Skannaa", "ButtonScan": "Skannaa",
"ButtonScanLibrary": "Skannaa kirjasto", "ButtonScanLibrary": "Skannaa kirjasto",
"ButtonScrollLeft": "Vieritä vasemmalle",
"ButtonScrollRight": "Vieritä oikealle",
"ButtonSearch": "Etsi", "ButtonSearch": "Etsi",
"ButtonSelectFolderPath": "Valitse kansiopolku", "ButtonSelectFolderPath": "Valitse kansiopolku",
"ButtonSeries": "Sarjat", "ButtonSeries": "Sarjat",
@@ -148,6 +152,7 @@
"HeaderLogs": "Lokit", "HeaderLogs": "Lokit",
"HeaderManageGenres": "Hallitse lajityyppejä", "HeaderManageGenres": "Hallitse lajityyppejä",
"HeaderManageTags": "Hallitse tageja", "HeaderManageTags": "Hallitse tageja",
"HeaderMetadataOrderOfPrecedence": "Metadatan tärkeysjärjestys",
"HeaderMetadataToEmbed": "Sisällytettävä metadata", "HeaderMetadataToEmbed": "Sisällytettävä metadata",
"HeaderNewAccount": "Uusi tili", "HeaderNewAccount": "Uusi tili",
"HeaderNewLibrary": "Uusi kirjasto", "HeaderNewLibrary": "Uusi kirjasto",
@@ -156,6 +161,7 @@
"HeaderNotifications": "Ilmoitukset", "HeaderNotifications": "Ilmoitukset",
"HeaderOpenRSSFeed": "Avaa RSS-syöte", "HeaderOpenRSSFeed": "Avaa RSS-syöte",
"HeaderOtherFiles": "Muut tiedostot", "HeaderOtherFiles": "Muut tiedostot",
"HeaderPasswordAuthentication": "Salasanan todentaminen",
"HeaderPermissions": "Käyttöoikeudet", "HeaderPermissions": "Käyttöoikeudet",
"HeaderPlayerQueue": "Soittimen jono", "HeaderPlayerQueue": "Soittimen jono",
"HeaderPlayerSettings": "Soittimen asetukset", "HeaderPlayerSettings": "Soittimen asetukset",
@@ -169,24 +175,34 @@
"HeaderRemoveEpisode": "Poista jakso", "HeaderRemoveEpisode": "Poista jakso",
"HeaderRemoveEpisodes": "Poista {0} jaksoa", "HeaderRemoveEpisodes": "Poista {0} jaksoa",
"HeaderSchedule": "Ajoita", "HeaderSchedule": "Ajoita",
"HeaderScheduleEpisodeDownloads": "Ajoita automaattiset jaksolataukset",
"HeaderScheduleLibraryScans": "Ajoita automaattiset kirjastoskannaukset", "HeaderScheduleLibraryScans": "Ajoita automaattiset kirjastoskannaukset",
"HeaderSession": "Istunto", "HeaderSession": "Istunto",
"HeaderSetBackupSchedule": "Aseta varmuuskopiointiaikataulu", "HeaderSetBackupSchedule": "Aseta varmuuskopiointiaikataulu",
"HeaderSettings": "Asetukset", "HeaderSettings": "Asetukset",
"HeaderSettingsDisplay": "Näyttö",
"HeaderSettingsExperimental": "Kokeelliset ominaisuudet", "HeaderSettingsExperimental": "Kokeelliset ominaisuudet",
"HeaderSettingsGeneral": "Yleiset", "HeaderSettingsGeneral": "Yleiset",
"HeaderSettingsScanner": "Skannaaja",
"HeaderSleepTimer": "Uniajastin", "HeaderSleepTimer": "Uniajastin",
"HeaderStatsLargestItems": "Suurimmat kohteet",
"HeaderStatsLongestItems": "Pisimmät kohteet (h)",
"HeaderStatsMinutesListeningChart": "Kuunteluminuutit (viim. 7 pv)", "HeaderStatsMinutesListeningChart": "Kuunteluminuutit (viim. 7 pv)",
"HeaderStatsRecentSessions": "Viimeaikaiset istunnot", "HeaderStatsRecentSessions": "Viimeaikaiset istunnot",
"HeaderStatsTop5Genres": "Top 5 lajityypit", "HeaderStatsTop10Authors": "Suosituimmat 10 kirjailijaa",
"HeaderStatsTop5Genres": "Suosituimmat 5 lajityyppiä",
"HeaderTableOfContents": "Sisällysluettelo", "HeaderTableOfContents": "Sisällysluettelo",
"HeaderTools": "Työkalut", "HeaderTools": "Työkalut",
"HeaderUpdateAccount": "Päivitä tili", "HeaderUpdateAccount": "Päivitä tili",
"HeaderUpdateAuthor": "Päivitä kirjailija", "HeaderUpdateAuthor": "Päivitä kirjailija",
"HeaderUpdateDetails": "Päivitä yksityiskohdat",
"HeaderUpdateLibrary": "Päivitä kirjasto", "HeaderUpdateLibrary": "Päivitä kirjasto",
"HeaderUsers": "Käyttäjät", "HeaderUsers": "Käyttäjät",
"HeaderYearReview": "Vuosi {0} tarkasteltuna",
"HeaderYourStats": "Tilastosi", "HeaderYourStats": "Tilastosi",
"LabelAbridged": "Lyhennetty", "LabelAbridged": "Lyhennetty",
"LabelAbridgedChecked": "Lyhennetty (tarkistettu)",
"LabelAbridgedUnchecked": "Lyhentämätön (tarkistamaton)",
"LabelAccountType": "Tilin tyyppi", "LabelAccountType": "Tilin tyyppi",
"LabelAccountTypeAdmin": "Järjestelmänvalvoja", "LabelAccountTypeAdmin": "Järjestelmänvalvoja",
"LabelAccountTypeGuest": "Vieras", "LabelAccountTypeGuest": "Vieras",
@@ -204,24 +220,40 @@
"LabelAllUsersExcludingGuests": "Kaikki käyttäjät vieraita lukuun ottamatta", "LabelAllUsersExcludingGuests": "Kaikki käyttäjät vieraita lukuun ottamatta",
"LabelAllUsersIncludingGuests": "Kaikki käyttäjät mukaan lukien vieraat", "LabelAllUsersIncludingGuests": "Kaikki käyttäjät mukaan lukien vieraat",
"LabelAlreadyInYourLibrary": "Jo kirjastossasi", "LabelAlreadyInYourLibrary": "Jo kirjastossasi",
"LabelApiToken": "Sovellusliittymätunnus",
"LabelAudioBitrate": "Äänen bittinopeus (esim. 128k)",
"LabelAudioChannels": "Äänikanavat (1 tai 2)",
"LabelAudioCodec": "Äänikoodekki",
"LabelAuthor": "Tekijä", "LabelAuthor": "Tekijä",
"LabelAuthorFirstLast": "Tekijä (Etunimi Sukunimi)", "LabelAuthorFirstLast": "Tekijä (Etunimi Sukunimi)",
"LabelAuthorLastFirst": "Tekijä (Sukunimi, Etunimi)", "LabelAuthorLastFirst": "Tekijä (Sukunimi, Etunimi)",
"LabelAuthors": "Tekijät", "LabelAuthors": "Tekijät",
"LabelAutoDownloadEpisodes": "Lataa jaksot automaattisesti", "LabelAutoDownloadEpisodes": "Lataa jaksot automaattisesti",
"LabelAutoFetchMetadata": "Etsi metadata automaattisesti",
"LabelAutoLaunch": "Automaattinen käynnistys",
"LabelAutoRegister": "Automaattinen rekisteröinti",
"LabelAutoRegisterDescription": "Luo automaattisesti uusia käyttäjiä kirjautumisen jälkeen",
"LabelBackToUser": "Takaisin käyttäjään", "LabelBackToUser": "Takaisin käyttäjään",
"LabelBackupAudioFiles": "Varmuuskopioi äänitiedostot",
"LabelBackupLocation": "Varmuuskopiointipaikka", "LabelBackupLocation": "Varmuuskopiointipaikka",
"LabelBackupsEnableAutomaticBackups": "Ota automaattinen varmuuskopiointi käyttöön", "LabelBackupsEnableAutomaticBackups": "Ota automaattinen varmuuskopiointi käyttöön",
"LabelBackupsEnableAutomaticBackupsHelp": "Varmuuskopiot tallennettu kansioon /metadata/backups", "LabelBackupsEnableAutomaticBackupsHelp": "Varmuuskopiot tallennettu kansioon /metadata/backups",
"LabelBackupsMaxBackupSize": "Varmuuskopion enimmäiskoko (Gt) (0 rajaton)", "LabelBackupsMaxBackupSize": "Varmuuskopion enimmäiskoko (Gt) (0 rajaton)",
"LabelBackupsMaxBackupSizeHelp": "Virheellisten asetusten estämiseksi varmuuskopiot epäonnistuvat, jos ne ovat asetettua kokoa suurempia.",
"LabelBackupsNumberToKeep": "Säilytettävien varmuuskopioiden määrä", "LabelBackupsNumberToKeep": "Säilytettävien varmuuskopioiden määrä",
"LabelBackupsNumberToKeepHelp": "Varmuuskopiot poistetaan yksi kerrallaan, joten jos niitä on enemmän kuin yksi, ne on poistettava manuaalisesti.",
"LabelBitrate": "Bittinopeus", "LabelBitrate": "Bittinopeus",
"LabelBonus": "Bonus",
"LabelBooks": "Kirjat", "LabelBooks": "Kirjat",
"LabelButtonText": "Painikkeen teksti", "LabelButtonText": "Painikkeen teksti",
"LabelChangePassword": "Vaihda salasana", "LabelChangePassword": "Vaihda salasana",
"LabelChannels": "Kanavat", "LabelChannels": "Kanavat",
"LabelChapterCount": "{0} lukua",
"LabelChapterTitle": "Luvun nimi",
"LabelChapters": "Luvut", "LabelChapters": "Luvut",
"LabelChaptersFound": "lukua löydetty",
"LabelClickForMoreInfo": "Napsauta saadaksesi lisätietoja", "LabelClickForMoreInfo": "Napsauta saadaksesi lisätietoja",
"LabelClickToUseCurrentValue": "Käytä nykyistä arvoa napsauttamalla",
"LabelClosePlayer": "Sulje soitin", "LabelClosePlayer": "Sulje soitin",
"LabelCodec": "Koodekki", "LabelCodec": "Koodekki",
"LabelCollapseSeries": "Pienennä sarja", "LabelCollapseSeries": "Pienennä sarja",
@@ -236,45 +268,85 @@
"LabelCoverImageURL": "Kansikuvan URL-osoite", "LabelCoverImageURL": "Kansikuvan URL-osoite",
"LabelCreatedAt": "Luotu", "LabelCreatedAt": "Luotu",
"LabelCurrent": "Nykyinen", "LabelCurrent": "Nykyinen",
"LabelCurrently": "Nyt:",
"LabelDays": "Päivää", "LabelDays": "Päivää",
"LabelDeleteFromFileSystemCheckbox": "Poista tiedostojärjestelmästä (poista merkintä, jos haluat poistaa vain tietokannasta)",
"LabelDescription": "Kuvaus", "LabelDescription": "Kuvaus",
"LabelDeselectAll": "Poista valinta kaikista",
"LabelDevice": "Laite", "LabelDevice": "Laite",
"LabelDeviceInfo": "Laitteen tiedot", "LabelDeviceInfo": "Laitteen tiedot",
"LabelDeviceIsAvailableTo": "Laite on saatavilla...",
"LabelDirectory": "Kansio",
"LabelDiscover": "Löydä", "LabelDiscover": "Löydä",
"LabelDownload": "Lataa", "LabelDownload": "Lataa",
"LabelDownloadNEpisodes": "Lataa {0} jaksoa", "LabelDownloadNEpisodes": "Lataa {0} jaksoa",
"LabelDownloadable": "Ladattavissa",
"LabelDuration": "Kesto", "LabelDuration": "Kesto",
"LabelDurationComparisonExactMatch": "(tarkka vastaavuus)",
"LabelDurationComparisonLonger": "({0} pidempi)", "LabelDurationComparisonLonger": "({0} pidempi)",
"LabelDurationComparisonShorter": "({0} lyhyempi)", "LabelDurationComparisonShorter": "({0} lyhyempi)",
"LabelDurationFound": "Kesto löydetty:",
"LabelEbook": "E-kirja", "LabelEbook": "E-kirja",
"LabelEbooks": "E-kirjat", "LabelEbooks": "E-kirjat",
"LabelEdit": "Muokkaa", "LabelEdit": "Muokkaa",
"LabelEmail": "Sähköposti", "LabelEmail": "Sähköposti",
"LabelEmailSettingsFromAddress": "Osoitteesta",
"LabelEmailSettingsRejectUnauthorized": "Hylkää luvattomat sertifikaatit",
"LabelEmailSettingsRejectUnauthorizedHelp": "SSL-sertifikaatin varmentamisen käytöstä poistaminen saattaa vaarantaa yhteytesti turvallisuusriskeihin, kuten man-in-the-middle hyökkäyksiin. Poista käytöstä vain jos ymmärrät vaaran ja luotat yhdistämääsi sähköpostipalvelimeen.",
"LabelEmailSettingsSecure": "Turvallinen",
"LabelEmailSettingsTestAddress": "Testiosoite", "LabelEmailSettingsTestAddress": "Testiosoite",
"LabelEmbeddedCover": "Upotettu kansikuva", "LabelEmbeddedCover": "Upotettu kansikuva",
"LabelEnable": "Ota käyttöön", "LabelEnable": "Ota käyttöön",
"LabelEncodingBackupLocation": "Alkuperäisistä audiotiedostoistasi tallennetaan varmuuskopio osoitteessa:",
"LabelEncodingChaptersNotEmbedded": "Lukuja ei upoteta moniraitaisiin äänikirjoihin.",
"LabelEncodingInfoEmbedded": "Kuvailutiedot upotetaan äänikirjakansion ääniraitoihin.",
"LabelEncodingStartedNavigation": "Voit poistua sivulta kun tehtävä on aloitettu.",
"LabelEncodingTimeWarning": "Koodaus saattaa kestää 30 minuuttiin asti.",
"LabelEncodingWarningAdvancedSettings": "Varoitus: Älä päivitä näitä asetuksia ellet ymmärrä ffmpeg-koodausasetuksia.",
"LabelEnd": "Loppu", "LabelEnd": "Loppu",
"LabelEndOfChapter": "Luvun loppu", "LabelEndOfChapter": "Luvun loppu",
"LabelEpisode": "Jakso", "LabelEpisode": "Jakso",
"LabelEpisodeNotLinkedToRssFeed": "Jakso ei yhdistetty RSS-syötteeseen",
"LabelEpisodeNumber": "Jakso #{0}",
"LabelEpisodeTitle": "Jakson nimi",
"LabelEpisodeType": "Jakson tyyppi",
"LabelEpisodeUrlFromRssFeed": "Jakson URL RSS-syötteestä",
"LabelEpisodes": "Jaksot", "LabelEpisodes": "Jaksot",
"LabelExample": "Esimerkki", "LabelExample": "Esimerkki",
"LabelExpandSeries": "Laajenna sarja",
"LabelExpandSubSeries": "Laajenna alisarja",
"LabelExportOPML": "Vie OPML",
"LabelFeedURL": "Syötteen URL", "LabelFeedURL": "Syötteen URL",
"LabelFetchingMetadata": "Noudetaan kuvailutietoja",
"LabelFile": "Tiedosto", "LabelFile": "Tiedosto",
"LabelFileBirthtime": "Tiedoston syntymäaika", "LabelFileBirthtime": "Tiedoston syntymäaika",
"LabelFileBornDate": "Syntynyt {0}", "LabelFileBornDate": "Syntynyt {0}",
"LabelFileModified": "Muutettu tiedosto", "LabelFileModified": "Muutettu tiedosto",
"LabelFileModifiedDate": "Muokattu {0}", "LabelFileModifiedDate": "Muokattu {0}",
"LabelFilename": "Tiedostonimi", "LabelFilename": "Tiedostonimi",
"LabelFilterByUser": "Suodata käyttäjien perusteella",
"LabelFindEpisodes": "Etsi jaksoja", "LabelFindEpisodes": "Etsi jaksoja",
"LabelFinished": "Valmis", "LabelFinished": "Valmis",
"LabelFolder": "Kansio", "LabelFolder": "Kansio",
"LabelFolders": "Kansiot", "LabelFolders": "Kansiot",
"LabelFontBold": "Lihavoitu",
"LabelFontBoldness": "Kirjasintyyppien lihavointi",
"LabelFontFamily": "Kirjasinperhe",
"LabelFontItalic": "Kursiivi",
"LabelFontScale": "Kirjasintyyppien skaalautuminen",
"LabelFontStrikethrough": "Yliviivattu",
"LabelFull": "Täynnä",
"LabelGenre": "Lajityyppi", "LabelGenre": "Lajityyppi",
"LabelGenres": "Lajityypit", "LabelGenres": "Lajityypit",
"LabelHighestPriority": "Tärkein",
"LabelHost": "Isäntä", "LabelHost": "Isäntä",
"LabelHours": "Tunnit", "LabelHours": "Tunnit",
"LabelIcon": "Kuvake",
"LabelImageURLFromTheWeb": "Kuvan verkko-osoite",
"LabelInProgress": "Kesken", "LabelInProgress": "Kesken",
"LabelIncomplete": "Keskeneräinen", "LabelIncomplete": "Keskeneräinen",
"LabelInterval": "Väli",
"LabelIntervalCustomDailyWeekly": "Mukautettu päivittäinen/viikoittainen",
"LabelIntervalEvery12Hours": "12 tunnin välein", "LabelIntervalEvery12Hours": "12 tunnin välein",
"LabelIntervalEvery15Minutes": "15 minuutin välein", "LabelIntervalEvery15Minutes": "15 minuutin välein",
"LabelIntervalEvery2Hours": "2 tunnin välein", "LabelIntervalEvery2Hours": "2 tunnin välein",
@@ -287,12 +359,36 @@
"LabelLanguageDefaultServer": "Palvelimen oletuskieli", "LabelLanguageDefaultServer": "Palvelimen oletuskieli",
"LabelLanguages": "Kielet", "LabelLanguages": "Kielet",
"LabelLastBookAdded": "Viimeisin lisätty kirja", "LabelLastBookAdded": "Viimeisin lisätty kirja",
"LabelLastBookUpdated": "Viimeisin päivitetty kirja",
"LabelLastSeen": "Nähty viimeksi",
"LabelLastUpdate": "Viimeisin päivitys",
"LabelLayout": "Asettelu",
"LabelLayoutSinglePage": "Yksi sivu",
"LabelLayoutSplitPage": "Jaa sivu osiin",
"LabelLess": "Vähemmän",
"LabelLibrariesAccessibleToUser": "Käyttäjälle saatavilla olevat kirjastot",
"LabelLibrary": "Kirjasto", "LabelLibrary": "Kirjasto",
"LabelLibraryName": "Kirjaston nimi",
"LabelLimit": "Raja",
"LabelLineSpacing": "Riviväli", "LabelLineSpacing": "Riviväli",
"LabelListenAgain": "Kuuntele uudelleen", "LabelListenAgain": "Kuuntele uudelleen",
"LabelLogLevelInfo": "Tiedot",
"LabelLogLevelWarn": "Varoita",
"LabelLookForNewEpisodesAfterDate": "Etsi uusia jaksoja tämän päivämäärän jälkeen",
"LabelLowestPriority": "Vähiten tärkeä",
"LabelMaxEpisodesToDownload": "Jaksojen maksimilatausmäärä. 0 poistaa rajoituksen.",
"LabelMaxEpisodesToKeep": "Säilytettävien jaksojen enimmäismäärä",
"LabelMaxEpisodesToKeepHelp": "Jos arvona on 0, enimmäisrajaa ei ole. Kun uusi jakso ladataan automaattisesti, vanhin jakso poistetaan, jos jaksoja on yli X. Tämä poistaa vain yhden jakson uutta latauskertaa kohden.",
"LabelMediaPlayer": "Mediasoitin",
"LabelMediaType": "Mediatyyppi", "LabelMediaType": "Mediatyyppi",
"LabelMetaTag": "Metatunniste",
"LabelMetaTags": "Metatunnisteet",
"LabelMetadataOrderOfPrecedenceDescription": "Tärkeämmät kuvailutietojen lähteet ohittavat vähemmän tärkeät lähteet",
"LabelMetadataProvider": "Kuvailutietojen toimittaja",
"LabelMinute": "Minuutti", "LabelMinute": "Minuutti",
"LabelMinutes": "Minuutit", "LabelMinutes": "Minuutit",
"LabelMissing": "Puuttuu",
"LabelMissingEbook": "Ei e-kirjaa",
"LabelMore": "Lisää", "LabelMore": "Lisää",
"LabelMoreInfo": "Lisätietoja", "LabelMoreInfo": "Lisätietoja",
"LabelName": "Nimi", "LabelName": "Nimi",
@@ -302,31 +398,62 @@
"LabelNewPassword": "Uusi salasana", "LabelNewPassword": "Uusi salasana",
"LabelNewestAuthors": "Uusimmat kirjailijat", "LabelNewestAuthors": "Uusimmat kirjailijat",
"LabelNewestEpisodes": "Uusimmat jaksot", "LabelNewestEpisodes": "Uusimmat jaksot",
"LabelNextBackupDate": "Seuraava varmuuskopiointipäivämäärä",
"LabelNextScheduledRun": "Seuraava ajastettu suorittaminen",
"LabelNoCustomMetadataProviders": "Ei mukautettuja kuvailutietojen toimittajia",
"LabelNoEpisodesSelected": "Jaksoja ei ole valittu",
"LabelNotFinished": "Ei valmis",
"LabelNotStarted": "Ei aloitettu", "LabelNotStarted": "Ei aloitettu",
"LabelNotes": "Muistiinpanoja",
"LabelNotificationAvailableVariables": "Käytettävissä olevat muuttujat",
"LabelNotificationEvent": "Ilmoitustapahtuma",
"LabelNotificationsMaxFailedAttempts": "Epäonnistuneiden yritysten enimmäismäärä",
"LabelNotificationsMaxFailedAttemptsHelp": "Ilmoitukset poistetaan käytöstä, jos niiden lähettäminen epäonnistuu näin monta kertaa",
"LabelNotificationsMaxQueueSize": "Ilmoitustapahtumajonon enimmäispituus",
"LabelNumberOfBooks": "Kirjojen määrä",
"LabelNumberOfEpisodes": "Jaksojen määrä",
"LabelOverwrite": "Korvaa",
"LabelPaginationPageXOfY": "Sivu {0}/{1}",
"LabelPassword": "Salasana", "LabelPassword": "Salasana",
"LabelPath": "Polku", "LabelPath": "Polku",
"LabelPermanent": "Pysyvä", "LabelPermanent": "Pysyvä",
"LabelPermissionsAccessAllLibraries": "Käyttöoikeudet kaikkiin kirjastoihin", "LabelPermissionsAccessAllLibraries": "Käyttöoikeudet kaikkiin kirjastoihin",
"LabelPermissionsAccessAllTags": "Saa käyttää kaikkia tunnisteita",
"LabelPermissionsAccessExplicitContent": "Saa käyttää aikuisille tarkoitettua sisältöä",
"LabelPermissionsDelete": "Voi poistaa", "LabelPermissionsDelete": "Voi poistaa",
"LabelPermissionsDownload": "Voi ladata", "LabelPermissionsDownload": "Voi ladata",
"LabelPermissionsUpdate": "Voi päivittää", "LabelPermissionsUpdate": "Voi päivittää",
"LabelPermissionsUpload": "Voi lähettää", "LabelPermissionsUpload": "Voi lähettää",
"LabelPlayMethod": "Toistotapa",
"LabelPlayerChapterNumberMarker": "{0}/{1}",
"LabelPlaylists": "Soittolistat", "LabelPlaylists": "Soittolistat",
"LabelPodcast": "Podcast", "LabelPodcast": "Podcast",
"LabelPodcastSearchRegion": "Podcastien hakualue",
"LabelPodcastType": "Podcastien tyyppi",
"LabelPodcasts": "Podcastit", "LabelPodcasts": "Podcastit",
"LabelPort": "Portti", "LabelPort": "Portti",
"LabelPrimaryEbook": "Ensisijainen e-kirja",
"LabelProgress": "Edistyminen",
"LabelProvider": "Toimittaja",
"LabelPubDate": "Julkaisupäivä",
"LabelPublishYear": "Julkaisuvuosi", "LabelPublishYear": "Julkaisuvuosi",
"LabelPublishedDate": "Julkaistu {0}",
"LabelPublisher": "Julkaisija", "LabelPublisher": "Julkaisija",
"LabelPublishers": "Julkaisijat", "LabelPublishers": "Julkaisijat",
"LabelRSSFeedPreventIndexing": "Estä indeksointi", "LabelRSSFeedPreventIndexing": "Estä indeksointi",
"LabelRandomly": "Satunnaisesti", "LabelRandomly": "Satunnaisesti",
"LabelRead": "Lue", "LabelRead": "Lue",
"LabelReadAgain": "Lue uudelleen", "LabelReadAgain": "Lue uudelleen",
"LabelReadEbookWithoutProgress": "Lue e-kirja tallentamatta edistymistietoja",
"LabelRecentSeries": "Viimeisimmät sarjat", "LabelRecentSeries": "Viimeisimmät sarjat",
"LabelRecentlyAdded": "Viimeeksi lisätyt", "LabelRecentlyAdded": "Viimeeksi lisätyt",
"LabelRecommended": "Suositeltu", "LabelRecommended": "Suositeltu",
"LabelRedo": "Tee uudelleen",
"LabelRegion": "Alue", "LabelRegion": "Alue",
"LabelReleaseDate": "Julkaisupäivä",
"LabelRemoveCover": "Poista kansikuva", "LabelRemoveCover": "Poista kansikuva",
"LabelRowsPerPage": "Rivejä sivulla",
"LabelSearchTerm": "Hakusana",
"LabelSeason": "Kausi", "LabelSeason": "Kausi",
"LabelSelectAll": "Valitse kaikki", "LabelSelectAll": "Valitse kaikki",
"LabelSelectUsers": "Valitse käyttäjät", "LabelSelectUsers": "Valitse käyttäjät",
+2 -3
View File
@@ -51,7 +51,7 @@
"ButtonNext": "Suivant", "ButtonNext": "Suivant",
"ButtonNextChapter": "Chapitre suivant", "ButtonNextChapter": "Chapitre suivant",
"ButtonNextItemInQueue": "Élément suivant dans la file dattente", "ButtonNextItemInQueue": "Élément suivant dans la file dattente",
"ButtonOk": "Daccord", "ButtonOk": "D'accord",
"ButtonOpenFeed": "Ouvrir le flux", "ButtonOpenFeed": "Ouvrir le flux",
"ButtonOpenManager": "Ouvrir le gestionnaire", "ButtonOpenManager": "Ouvrir le gestionnaire",
"ButtonPause": "Pause", "ButtonPause": "Pause",
@@ -459,7 +459,7 @@
"LabelNotificationsMaxQueueSize": "Nombres de notifications maximum à mettre en attente", "LabelNotificationsMaxQueueSize": "Nombres de notifications maximum à mettre en attente",
"LabelNotificationsMaxQueueSizeHelp": "La limite de notification est de un évènement par seconde. Les notifications seront ignorées si la file dattente est à son maximum. Cela empêche un flot trop important.", "LabelNotificationsMaxQueueSizeHelp": "La limite de notification est de un évènement par seconde. Les notifications seront ignorées si la file dattente est à son maximum. Cela empêche un flot trop important.",
"LabelNumberOfBooks": "Nombre de livres", "LabelNumberOfBooks": "Nombre de livres",
"LabelNumberOfEpisodes": "Nombre dépisodes", "LabelNumberOfEpisodes": "Nombre d'épisodes",
"LabelOpenIDAdvancedPermsClaimDescription": "Nom de la demande OpenID qui contient des autorisations avancées pour les actions de lutilisateur dans lapplication, qui sappliqueront à des rôles autres que celui dadministrateur (<b>sil est configuré</b>). Si la demande est absente de la réponse, laccès à ABS sera refusé. Si une seule option est manquante, elle sera considérée comme <code>false</code>. Assurez-vous que la demande du fournisseur didentité correspond à la structure attendue :", "LabelOpenIDAdvancedPermsClaimDescription": "Nom de la demande OpenID qui contient des autorisations avancées pour les actions de lutilisateur dans lapplication, qui sappliqueront à des rôles autres que celui dadministrateur (<b>sil est configuré</b>). Si la demande est absente de la réponse, laccès à ABS sera refusé. Si une seule option est manquante, elle sera considérée comme <code>false</code>. Assurez-vous que la demande du fournisseur didentité correspond à la structure attendue :",
"LabelOpenIDClaims": "Laissez les options suivantes vides pour désactiver lattribution avancée de groupes et dautorisations, en attribuant alors automatiquement le groupe « Utilisateur ».", "LabelOpenIDClaims": "Laissez les options suivantes vides pour désactiver lattribution avancée de groupes et dautorisations, en attribuant alors automatiquement le groupe « Utilisateur ».",
"LabelOpenIDGroupClaimDescription": "Nom de la demande OpenID qui contient une liste des groupes de lutilisateur. Communément appelé <code>groups</code>. <b>Si elle est configurée</b>, lapplication attribuera automatiquement des rôles en fonction de lappartenance de lutilisateur à un groupe, à condition que ces groupes soient nommés -sensible à la casse- tel que « admin », « user » ou « guest » dans la demande. Elle doit contenir une liste, et si un utilisateur appartient à plusieurs groupes, lapplication attribuera le rôle correspondant au niveau daccès le plus élevé. Si aucun groupe ne correspond, laccès sera refusé.", "LabelOpenIDGroupClaimDescription": "Nom de la demande OpenID qui contient une liste des groupes de lutilisateur. Communément appelé <code>groups</code>. <b>Si elle est configurée</b>, lapplication attribuera automatiquement des rôles en fonction de lappartenance de lutilisateur à un groupe, à condition que ces groupes soient nommés -sensible à la casse- tel que « admin », « user » ou « guest » dans la demande. Elle doit contenir une liste, et si un utilisateur appartient à plusieurs groupes, lapplication attribuera le rôle correspondant au niveau daccès le plus élevé. Si aucun groupe ne correspond, laccès sera refusé.",
@@ -944,7 +944,6 @@
"ToastBookmarkCreateFailed": "Échec de la création de signet", "ToastBookmarkCreateFailed": "Échec de la création de signet",
"ToastBookmarkCreateSuccess": "Signet ajouté", "ToastBookmarkCreateSuccess": "Signet ajouté",
"ToastBookmarkRemoveSuccess": "Signet supprimé", "ToastBookmarkRemoveSuccess": "Signet supprimé",
"ToastBookmarkUpdateSuccess": "Signet mis à jour",
"ToastCachePurgeFailed": "Échec de la purge du cache", "ToastCachePurgeFailed": "Échec de la purge du cache",
"ToastCachePurgeSuccess": "Cache purgé avec succès", "ToastCachePurgeSuccess": "Cache purgé avec succès",
"ToastChaptersHaveErrors": "Les chapitres contiennent des erreurs", "ToastChaptersHaveErrors": "Les chapitres contiennent des erreurs",
-1
View File
@@ -740,7 +740,6 @@
"ToastBookmarkCreateFailed": "יצירת סימניה נכשלה", "ToastBookmarkCreateFailed": "יצירת סימניה נכשלה",
"ToastBookmarkCreateSuccess": "הסימניה נוספה בהצלחה", "ToastBookmarkCreateSuccess": "הסימניה נוספה בהצלחה",
"ToastBookmarkRemoveSuccess": "הסימניה הוסרה בהצלחה", "ToastBookmarkRemoveSuccess": "הסימניה הוסרה בהצלחה",
"ToastBookmarkUpdateSuccess": "הסימניה עודכנה בהצלחה",
"ToastChaptersHaveErrors": "פרקים מכילים שגיאות", "ToastChaptersHaveErrors": "פרקים מכילים שגיאות",
"ToastChaptersMustHaveTitles": "פרקים חייבים לכלול כותרות", "ToastChaptersMustHaveTitles": "פרקים חייבים לכלול כותרות",
"ToastCollectionRemoveSuccess": "האוסף הוסר בהצלחה", "ToastCollectionRemoveSuccess": "האוסף הוסר בהצלחה",
+20 -15
View File
@@ -300,6 +300,7 @@
"LabelDiscover": "Otkrij", "LabelDiscover": "Otkrij",
"LabelDownload": "Preuzmi", "LabelDownload": "Preuzmi",
"LabelDownloadNEpisodes": "Preuzmi {0} nastavak/a", "LabelDownloadNEpisodes": "Preuzmi {0} nastavak/a",
"LabelDownloadable": "Moguće preuzimanje",
"LabelDuration": "Trajanje", "LabelDuration": "Trajanje",
"LabelDurationComparisonExactMatch": "(točno podudaranje)", "LabelDurationComparisonExactMatch": "(točno podudaranje)",
"LabelDurationComparisonLonger": "({0} duže)", "LabelDurationComparisonLonger": "({0} duže)",
@@ -366,7 +367,7 @@
"LabelFull": "Cijeli", "LabelFull": "Cijeli",
"LabelGenre": "Žanr", "LabelGenre": "Žanr",
"LabelGenres": "Žanrovi", "LabelGenres": "Žanrovi",
"LabelHardDeleteFile": "Obriši datoteku zauvijek", "LabelHardDeleteFile": "Izbriši datoteku zauvijek",
"LabelHasEbook": "Ima e-knjigu", "LabelHasEbook": "Ima e-knjigu",
"LabelHasSupplementaryEbook": "Ima dopunsku e-knjigu", "LabelHasSupplementaryEbook": "Ima dopunsku e-knjigu",
"LabelHideSubtitles": "Skrij podnaslove", "LabelHideSubtitles": "Skrij podnaslove",
@@ -588,6 +589,7 @@
"LabelSettingsStoreMetadataWithItemHelp": "Meta-podatci se obično spremaju u /metadata/items; ako uključite ovu postavku meta-podatci će se čuvati u mapama knjižničkih stavki", "LabelSettingsStoreMetadataWithItemHelp": "Meta-podatci se obično spremaju u /metadata/items; ako uključite ovu postavku meta-podatci će se čuvati u mapama knjižničkih stavki",
"LabelSettingsTimeFormat": "Format vremena", "LabelSettingsTimeFormat": "Format vremena",
"LabelShare": "Podijeli", "LabelShare": "Podijeli",
"LabelShareDownloadableHelp": "Korisnicima s poveznicom za dijeljenje omogućuje preuzimanje stavke.",
"LabelShareOpen": "Dijeljenje otvoreno", "LabelShareOpen": "Dijeljenje otvoreno",
"LabelShareURL": "URL za dijeljenje", "LabelShareURL": "URL za dijeljenje",
"LabelShowAll": "Prikaži sve", "LabelShowAll": "Prikaži sve",
@@ -715,15 +717,15 @@
"MessageChapterStartIsAfter": "Početak poglavlja je nakon kraja zvučne knjige", "MessageChapterStartIsAfter": "Početak poglavlja je nakon kraja zvučne knjige",
"MessageCheckingCron": "Provjeravam cron...", "MessageCheckingCron": "Provjeravam cron...",
"MessageConfirmCloseFeed": "Sigurno želite zatvoriti ovaj izvor?", "MessageConfirmCloseFeed": "Sigurno želite zatvoriti ovaj izvor?",
"MessageConfirmDeleteBackup": "Jeste li sigurni da želite obrisati backup za {0}?", "MessageConfirmDeleteBackup": "Sigurno želite izbrisati sigurnosnu kopiju za {0}?",
"MessageConfirmDeleteDevice": "Sigurno želite izbrisati e-čitač \"{0}\"?", "MessageConfirmDeleteDevice": "Sigurno želite izbrisati e-čitač \"{0}\"?",
"MessageConfirmDeleteFile": "Ovo će izbrisati datoteke s datotečnog sustava. Jeste li sigurni?", "MessageConfirmDeleteFile": "Ovo će izbrisati datoteke s datotečnog sustava. Jeste li sigurni?",
"MessageConfirmDeleteLibrary": "Sigurno želite trajno obrisati knjižnicu \"{0}\"?", "MessageConfirmDeleteLibrary": "Sigurno želite trajno izbrisati knjižnicu \"{0}\"?",
"MessageConfirmDeleteLibraryItem": "Ovo će izbrisati knjižničku stavku iz datoteke i vašeg datotečnog sustava. Jeste li sigurni?", "MessageConfirmDeleteLibraryItem": "Ovo će izbrisati knjižničku stavku iz datoteke i vašeg datotečnog sustava. Jeste li sigurni?",
"MessageConfirmDeleteLibraryItems": "Ovo će izbrisati {0} knjižničkih stavki iz baze podataka i datotečnog sustava. Jeste li sigurni?", "MessageConfirmDeleteLibraryItems": "Ovo će izbrisati {0} knjižničkih stavki iz baze podataka i datotečnog sustava. Jeste li sigurni?",
"MessageConfirmDeleteMetadataProvider": "Sigurno želite izbrisati prilagođenog pružatelja meta-podataka \"{0}\"?", "MessageConfirmDeleteMetadataProvider": "Sigurno želite izbrisati prilagođenog pružatelja meta-podataka \"{0}\"?",
"MessageConfirmDeleteNotification": "Sigurno želite izbrisati ovu obavijest?", "MessageConfirmDeleteNotification": "Sigurno želite izbrisati ovu obavijest?",
"MessageConfirmDeleteSession": "Sigurno želite obrisati ovu sesiju?", "MessageConfirmDeleteSession": "Sigurno želite izbrisati ovu sesiju?",
"MessageConfirmEmbedMetadataInAudioFiles": "Sigurno želite ugraditi meta-podatke u {0} zvučnih datoteka?", "MessageConfirmEmbedMetadataInAudioFiles": "Sigurno želite ugraditi meta-podatke u {0} zvučnih datoteka?",
"MessageConfirmForceReScan": "Sigurno želite ponovno pokrenuti skeniranje?", "MessageConfirmForceReScan": "Sigurno želite ponovno pokrenuti skeniranje?",
"MessageConfirmMarkAllEpisodesFinished": "Sigurno želite označiti sve nastavke dovršenima?", "MessageConfirmMarkAllEpisodesFinished": "Sigurno želite označiti sve nastavke dovršenima?",
@@ -754,8 +756,9 @@
"MessageConfirmRenameTagMergeNote": "Napomena: Ova oznaka već postoji, stoga će biti pripojena.", "MessageConfirmRenameTagMergeNote": "Napomena: Ova oznaka već postoji, stoga će biti pripojena.",
"MessageConfirmRenameTagWarning": "Pažnja! Slična oznaka s drugačijim velikim i malim slovima već postoji \"{0}\".", "MessageConfirmRenameTagWarning": "Pažnja! Slična oznaka s drugačijim velikim i malim slovima već postoji \"{0}\".",
"MessageConfirmResetProgress": "Sigurno želite resetirati napredak?", "MessageConfirmResetProgress": "Sigurno želite resetirati napredak?",
"MessageConfirmSendEbookToDevice": "Sigurno želite poslati {0} e-knjiga/u \"{1}\" na uređaj \"{2}\"?", "MessageConfirmSendEbookToDevice": "Sigurno želite poslati {0} e-knjigu \"{1}\" na uređaj \"{2}\"?",
"MessageConfirmUnlinkOpenId": "Sigurno želite odspojiti ovog korisnika s OpenID-ja?", "MessageConfirmUnlinkOpenId": "Sigurno želite odspojiti ovog korisnika s OpenID-ja?",
"MessageDaysListenedInTheLastYear": "{0} dana slušanja u posljednjih godinu dana",
"MessageDownloadingEpisode": "Preuzimam nastavak", "MessageDownloadingEpisode": "Preuzimam nastavak",
"MessageDragFilesIntoTrackOrder": "Prevlačenjem datoteka složite pravilan redoslijed", "MessageDragFilesIntoTrackOrder": "Prevlačenjem datoteka složite pravilan redoslijed",
"MessageEmbedFailed": "Ugrađivanje nije uspjelo!", "MessageEmbedFailed": "Ugrađivanje nije uspjelo!",
@@ -829,11 +832,12 @@
"MessageRemoveChapter": "Ukloni poglavlje", "MessageRemoveChapter": "Ukloni poglavlje",
"MessageRemoveEpisodes": "Ukloni {0} nastavaka", "MessageRemoveEpisodes": "Ukloni {0} nastavaka",
"MessageRemoveFromPlayerQueue": "Ukloni iz redoslijeda izvođenja", "MessageRemoveFromPlayerQueue": "Ukloni iz redoslijeda izvođenja",
"MessageRemoveUserWarning": "Sigurno želite trajno obrisati korisnika \"{0}\"?", "MessageRemoveUserWarning": "Sigurno želite trajno izbrisati korisnika \"{0}\"?",
"MessageReportBugsAndContribute": "Prijavite pogreške, zatražite funkcionalnosti i doprinesite na", "MessageReportBugsAndContribute": "Prijavite pogreške, zatražite funkcionalnosti i doprinesite na",
"MessageResetChaptersConfirm": "Sigurno želite vratiti poglavlja na prethodno stanje i poništiti učinjene promjene?", "MessageResetChaptersConfirm": "Sigurno želite vratiti poglavlja na prethodno stanje i poništiti učinjene promjene?",
"MessageRestoreBackupConfirm": "Sigurno želite vratiti sigurnosnu kopiju izrađenu", "MessageRestoreBackupConfirm": "Sigurno želite vratiti sigurnosnu kopiju izrađenu",
"MessageRestoreBackupWarning": "Vraćanjem sigurnosne kopije prepisat ćete cijelu bazu podataka koja se nalazi u /config i slike naslovnice u /metadata/items i /metadata/authors.<br /><br />Sigurnosne kopije ne mijenjaju datoteke koje se nalaze u mapama vaših knjižnica. Ako ste u postavkama poslužitelja uključili mogućnost spremanja naslovnica i meta-podataka u mape knjižnice, te se datoteke neće niti sigurnosno pohraniti niti prepisati. <br /><br />Svi klijenti koji se spajaju na vaš poslužitelj automatski će se osvježiti.", "MessageRestoreBackupWarning": "Vraćanjem sigurnosne kopije prepisat ćete cijelu bazu podataka koja se nalazi u /config i slike naslovnice u /metadata/items i /metadata/authors.<br /><br />Sigurnosne kopije ne mijenjaju datoteke koje se nalaze u mapama vaših knjižnica. Ako ste u postavkama poslužitelja uključili mogućnost spremanja naslovnica i meta-podataka u mape knjižnice, te se datoteke neće niti sigurnosno pohraniti niti prepisati. <br /><br />Svi klijenti koji se spajaju na vaš poslužitelj automatski će se osvježiti.",
"MessageScheduleLibraryScanNote": "Za većinu korisnika se preporučuje ostaviti ovu funkciju deaktiviranom i ostaviti postavku promatrača mape aktiviranom. Promatrač mapa će automatski otkriti promjene u mapama vaše knjižnice. Promatrač mapa ne radi na svakom datotečnom sustavu (kao što je NFS) pa se umjesto njega mogu koristiti planirana pretraživanja knjižnice.",
"MessageSearchResultsFor": "Rezultati pretrage za", "MessageSearchResultsFor": "Rezultati pretrage za",
"MessageSelected": "{0} odabrano", "MessageSelected": "{0} odabrano",
"MessageServerCouldNotBeReached": "Nije moguće pristupiti poslužitelju", "MessageServerCouldNotBeReached": "Nije moguće pristupiti poslužitelju",
@@ -909,7 +913,7 @@
"StatsBooksFinished": "knjiga dovršeno", "StatsBooksFinished": "knjiga dovršeno",
"StatsBooksFinishedThisYear": "Neke knjige dovršene ove godine…", "StatsBooksFinishedThisYear": "Neke knjige dovršene ove godine…",
"StatsBooksListenedTo": "knjiga slušano", "StatsBooksListenedTo": "knjiga slušano",
"StatsCollectionGrewTo": "Vaša zbirka knjiga narasla je na…", "StatsCollectionGrewTo": "Vaša je zbirka knjiga narasla na…",
"StatsSessions": "sesija", "StatsSessions": "sesija",
"StatsSpentListening": "provedeno u slušanju", "StatsSpentListening": "provedeno u slušanju",
"StatsTopAuthor": "NAJPOPULARNIJI AUTOR", "StatsTopAuthor": "NAJPOPULARNIJI AUTOR",
@@ -932,7 +936,7 @@
"ToastAuthorUpdateSuccess": "Autor ažuriran", "ToastAuthorUpdateSuccess": "Autor ažuriran",
"ToastAuthorUpdateSuccessNoImageFound": "Autor ažuriran (slika nije pronađena)", "ToastAuthorUpdateSuccessNoImageFound": "Autor ažuriran (slika nije pronađena)",
"ToastBackupAppliedSuccess": "Sigurnosna kopija vraćena", "ToastBackupAppliedSuccess": "Sigurnosna kopija vraćena",
"ToastBackupCreateFailed": "Neuspješno kreiranje backupa", "ToastBackupCreateFailed": "Izrada sigurnosne kopije nije uspjela",
"ToastBackupCreateSuccess": "Izrađena sigurnosna kopija", "ToastBackupCreateSuccess": "Izrađena sigurnosna kopija",
"ToastBackupDeleteFailed": "Brisanje sigurnosne kopije nije uspjelo", "ToastBackupDeleteFailed": "Brisanje sigurnosne kopije nije uspjelo",
"ToastBackupDeleteSuccess": "Sigurnosna kopija izbrisana", "ToastBackupDeleteSuccess": "Sigurnosna kopija izbrisana",
@@ -942,7 +946,7 @@
"ToastBackupUploadFailed": "Učitavanje sigurnosne kopije nije uspjelo", "ToastBackupUploadFailed": "Učitavanje sigurnosne kopije nije uspjelo",
"ToastBackupUploadSuccess": "Sigurnosna kopija učitana", "ToastBackupUploadSuccess": "Sigurnosna kopija učitana",
"ToastBatchDeleteFailed": "Grupno brisanje nije uspjelo", "ToastBatchDeleteFailed": "Grupno brisanje nije uspjelo",
"ToastBatchDeleteSuccess": "Grupno brisanje je uspješno dovršeno", "ToastBatchDeleteSuccess": "Grupno brisanje je uspjelo",
"ToastBatchQuickMatchFailed": "Grupno brzo prepoznavanje nije uspjelo!", "ToastBatchQuickMatchFailed": "Grupno brzo prepoznavanje nije uspjelo!",
"ToastBatchQuickMatchStarted": "Započelo je brzo prepoznavanje {0} knjiga!", "ToastBatchQuickMatchStarted": "Započelo je brzo prepoznavanje {0} knjiga!",
"ToastBatchUpdateFailed": "Skupno ažuriranje nije uspjelo", "ToastBatchUpdateFailed": "Skupno ažuriranje nije uspjelo",
@@ -950,7 +954,6 @@
"ToastBookmarkCreateFailed": "Izrada knjižne oznake nije uspjela", "ToastBookmarkCreateFailed": "Izrada knjižne oznake nije uspjela",
"ToastBookmarkCreateSuccess": "Knjižna oznaka dodana", "ToastBookmarkCreateSuccess": "Knjižna oznaka dodana",
"ToastBookmarkRemoveSuccess": "Knjižna oznaka uklonjena", "ToastBookmarkRemoveSuccess": "Knjižna oznaka uklonjena",
"ToastBookmarkUpdateSuccess": "Knjižna oznaka ažurirana",
"ToastCachePurgeFailed": "Čišćenje predmemorije nije uspjelo", "ToastCachePurgeFailed": "Čišćenje predmemorije nije uspjelo",
"ToastCachePurgeSuccess": "Predmemorija uspješno očišćena", "ToastCachePurgeSuccess": "Predmemorija uspješno očišćena",
"ToastChaptersHaveErrors": "Poglavlja imaju pogreške", "ToastChaptersHaveErrors": "Poglavlja imaju pogreške",
@@ -961,6 +964,7 @@
"ToastCollectionRemoveSuccess": "Zbirka izbrisana", "ToastCollectionRemoveSuccess": "Zbirka izbrisana",
"ToastCollectionUpdateSuccess": "Zbirka ažurirana", "ToastCollectionUpdateSuccess": "Zbirka ažurirana",
"ToastCoverUpdateFailed": "Ažuriranje naslovnice nije uspjelo", "ToastCoverUpdateFailed": "Ažuriranje naslovnice nije uspjelo",
"ToastDateTimeInvalidOrIncomplete": "Datum i vrijeme su neispravni ili nepotpuni",
"ToastDeleteFileFailed": "Brisanje datoteke nije uspjelo", "ToastDeleteFileFailed": "Brisanje datoteke nije uspjelo",
"ToastDeleteFileSuccess": "Datoteka izbrisana", "ToastDeleteFileSuccess": "Datoteka izbrisana",
"ToastDeviceAddFailed": "Dodavanje uređaja nije uspjelo", "ToastDeviceAddFailed": "Dodavanje uređaja nije uspjelo",
@@ -1013,6 +1017,7 @@
"ToastNewUserTagError": "Potrebno je odabrati najmanje jednu oznaku", "ToastNewUserTagError": "Potrebno je odabrati najmanje jednu oznaku",
"ToastNewUserUsernameError": "Upišite korisničko ime", "ToastNewUserUsernameError": "Upišite korisničko ime",
"ToastNoNewEpisodesFound": "Nisu pronađeni novi nastavci", "ToastNoNewEpisodesFound": "Nisu pronađeni novi nastavci",
"ToastNoRSSFeed": "Podcast nema RSS izvor",
"ToastNoUpdatesNecessary": "Ažuriranja nisu potrebna", "ToastNoUpdatesNecessary": "Ažuriranja nisu potrebna",
"ToastNotificationCreateFailed": "Stvaranje obavijesti nije uspjelo", "ToastNotificationCreateFailed": "Stvaranje obavijesti nije uspjelo",
"ToastNotificationDeleteFailed": "Brisanje obavijesti nije uspjelo", "ToastNotificationDeleteFailed": "Brisanje obavijesti nije uspjelo",
@@ -1039,7 +1044,7 @@
"ToastRSSFeedCloseFailed": "RSS izvor nije uspješno zatvoren", "ToastRSSFeedCloseFailed": "RSS izvor nije uspješno zatvoren",
"ToastRSSFeedCloseSuccess": "RSS izvor zatvoren", "ToastRSSFeedCloseSuccess": "RSS izvor zatvoren",
"ToastRemoveFailed": "Uklanjanje nije uspjelo", "ToastRemoveFailed": "Uklanjanje nije uspjelo",
"ToastRemoveItemFromCollectionFailed": "Neuspješno uklanjanje stavke iz zbirke", "ToastRemoveItemFromCollectionFailed": "Uklanjanje stavke iz zbirke nije uspjelo",
"ToastRemoveItemFromCollectionSuccess": "Stavka uklonjena iz zbirke", "ToastRemoveItemFromCollectionSuccess": "Stavka uklonjena iz zbirke",
"ToastRemoveItemsWithIssuesFailed": "Uklanjanje knjižničkih stavki s problemima nije uspjelo", "ToastRemoveItemsWithIssuesFailed": "Uklanjanje knjižničkih stavki s problemima nije uspjelo",
"ToastRemoveItemsWithIssuesSuccess": "Uspješno uklonjene knjižničke stavke s problemima", "ToastRemoveItemsWithIssuesSuccess": "Uspješno uklonjene knjižničke stavke s problemima",
@@ -1056,8 +1061,8 @@
"ToastSeriesUpdateSuccess": "Serijal uspješno ažuriran", "ToastSeriesUpdateSuccess": "Serijal uspješno ažuriran",
"ToastServerSettingsUpdateSuccess": "Postavke poslužitelja ažurirane", "ToastServerSettingsUpdateSuccess": "Postavke poslužitelja ažurirane",
"ToastSessionCloseFailed": "Zatvaranje sesije nije uspjelo", "ToastSessionCloseFailed": "Zatvaranje sesije nije uspjelo",
"ToastSessionDeleteFailed": "Neuspješno brisanje serije", "ToastSessionDeleteFailed": "Brisanje sesije nije uspjelo",
"ToastSessionDeleteSuccess": "Sesija obrisana", "ToastSessionDeleteSuccess": "Sesija izbrisana",
"ToastSleepTimerDone": "Timer za spavanje istječe... zZzzZz", "ToastSleepTimerDone": "Timer za spavanje istječe... zZzzZz",
"ToastSlugMustChange": "Slug sadrži nedozvoljene znakove", "ToastSlugMustChange": "Slug sadrži nedozvoljene znakove",
"ToastSlugRequired": "Slug je obavezan", "ToastSlugRequired": "Slug je obavezan",
@@ -1070,8 +1075,8 @@
"ToastUnknownError": "Nepoznata pogreška", "ToastUnknownError": "Nepoznata pogreška",
"ToastUnlinkOpenIdFailed": "Uklanjanje OpenID veze korisnika nije uspjelo", "ToastUnlinkOpenIdFailed": "Uklanjanje OpenID veze korisnika nije uspjelo",
"ToastUnlinkOpenIdSuccess": "Korisnik odspojen od OpenID-ja", "ToastUnlinkOpenIdSuccess": "Korisnik odspojen od OpenID-ja",
"ToastUserDeleteFailed": "Neuspješno brisanje korisnika", "ToastUserDeleteFailed": "Brisanje korisnika nije uspjelo",
"ToastUserDeleteSuccess": "Korisnik obrisan", "ToastUserDeleteSuccess": "Korisnik izbrisan",
"ToastUserPasswordChangeSuccess": "Zaporka je uspješno promijenjena", "ToastUserPasswordChangeSuccess": "Zaporka je uspješno promijenjena",
"ToastUserPasswordMismatch": "Zaporke se ne podudaraju", "ToastUserPasswordMismatch": "Zaporke se ne podudaraju",
"ToastUserPasswordMustChange": "Nova zaporka ne smije biti jednaka staroj", "ToastUserPasswordMustChange": "Nova zaporka ne smije biti jednaka staroj",
+50 -15
View File
@@ -100,7 +100,7 @@
"ButtonStartM4BEncode": "M4B kódolás indítása", "ButtonStartM4BEncode": "M4B kódolás indítása",
"ButtonStartMetadataEmbed": "Metaadatok beágyazásának indítása", "ButtonStartMetadataEmbed": "Metaadatok beágyazásának indítása",
"ButtonStats": "Statisztikák", "ButtonStats": "Statisztikák",
"ButtonSubmit": "Beküldés", "ButtonSubmit": "Küldés",
"ButtonTest": "Teszt", "ButtonTest": "Teszt",
"ButtonUnlinkOpenId": "OpenID szétkapcsolása", "ButtonUnlinkOpenId": "OpenID szétkapcsolása",
"ButtonUpload": "Feltöltés", "ButtonUpload": "Feltöltés",
@@ -143,7 +143,7 @@
"HeaderFindChapters": "Fejezetek keresése", "HeaderFindChapters": "Fejezetek keresése",
"HeaderIgnoredFiles": "Figyelmen kívül hagyott fájlok", "HeaderIgnoredFiles": "Figyelmen kívül hagyott fájlok",
"HeaderItemFiles": "Elemfájlok", "HeaderItemFiles": "Elemfájlok",
"HeaderItemMetadataUtils": "Elem metaadat eszközök", "HeaderItemMetadataUtils": "Metaadatok eszközei",
"HeaderLastListeningSession": "Utolsó hallgatási munkamenet", "HeaderLastListeningSession": "Utolsó hallgatási munkamenet",
"HeaderLatestEpisodes": "Legújabb epizódok", "HeaderLatestEpisodes": "Legújabb epizódok",
"HeaderLibraries": "Könyvtárak", "HeaderLibraries": "Könyvtárak",
@@ -165,6 +165,7 @@
"HeaderNotificationUpdate": "Értesítés frissítése", "HeaderNotificationUpdate": "Értesítés frissítése",
"HeaderNotifications": "Értesítések", "HeaderNotifications": "Értesítések",
"HeaderOpenIDConnectAuthentication": "OpenID Connect hitelesítés", "HeaderOpenIDConnectAuthentication": "OpenID Connect hitelesítés",
"HeaderOpenListeningSessions": "Hallgatási menetek megnyitása",
"HeaderOpenRSSFeed": "RSS hírcsatorna megnyitása", "HeaderOpenRSSFeed": "RSS hírcsatorna megnyitása",
"HeaderOtherFiles": "Egyéb fájlok", "HeaderOtherFiles": "Egyéb fájlok",
"HeaderPasswordAuthentication": "Jelszó hitelesítés", "HeaderPasswordAuthentication": "Jelszó hitelesítés",
@@ -194,7 +195,7 @@
"HeaderSettingsWebClient": "Webkliens", "HeaderSettingsWebClient": "Webkliens",
"HeaderSleepTimer": "Alvásidőzítő", "HeaderSleepTimer": "Alvásidőzítő",
"HeaderStatsLargestItems": "Legnagyobb elemek", "HeaderStatsLargestItems": "Legnagyobb elemek",
"HeaderStatsLongestItems": "Leghosszabb elemek (órákban)", "HeaderStatsLongestItems": "Leghosszabb elemek (órában)",
"HeaderStatsMinutesListeningChart": "Hallgatási grafikon percekben (az elmúlt 7 napból)", "HeaderStatsMinutesListeningChart": "Hallgatási grafikon percekben (az elmúlt 7 napból)",
"HeaderStatsRecentSessions": "Legutóbbi munkamenetek", "HeaderStatsRecentSessions": "Legutóbbi munkamenetek",
"HeaderStatsTop10Authors": "Top 10 szerző", "HeaderStatsTop10Authors": "Top 10 szerző",
@@ -206,7 +207,7 @@
"HeaderUpdateDetails": "Részletek frissítése", "HeaderUpdateDetails": "Részletek frissítése",
"HeaderUpdateLibrary": "Könyvtár frissítése", "HeaderUpdateLibrary": "Könyvtár frissítése",
"HeaderUsers": "Felhasználók", "HeaderUsers": "Felhasználók",
"HeaderYearReview": "{0} év visszatekintése", "HeaderYearReview": "Visszatekintés {0} -ra/re",
"HeaderYourStats": "Saját statisztikák", "HeaderYourStats": "Saját statisztikák",
"LabelAbridged": "Tömörített", "LabelAbridged": "Tömörített",
"LabelAbridgedChecked": "Rövidített (ellenőrizve)", "LabelAbridgedChecked": "Rövidített (ellenőrizve)",
@@ -237,7 +238,7 @@
"LabelAuthor": "Szerző", "LabelAuthor": "Szerző",
"LabelAuthorFirstLast": "Szerző (Keresztnév Vezetéknév)", "LabelAuthorFirstLast": "Szerző (Keresztnév Vezetéknév)",
"LabelAuthorLastFirst": "Szerző (Vezetéknév, Keresztnév)", "LabelAuthorLastFirst": "Szerző (Vezetéknév, Keresztnév)",
"LabelAuthors": "Szerzők", "LabelAuthors": "Szerző",
"LabelAutoDownloadEpisodes": "Epizódok automatikus letöltése", "LabelAutoDownloadEpisodes": "Epizódok automatikus letöltése",
"LabelAutoFetchMetadata": "Metaadatok automatikus lekérése", "LabelAutoFetchMetadata": "Metaadatok automatikus lekérése",
"LabelAutoFetchMetadataHelp": "Cím, szerző és sorozat metaadatok automatikus lekérése a feltöltés megkönnyítése érdekében. További metaadatok egyeztetése szükséges lehet a feltöltés után.", "LabelAutoFetchMetadataHelp": "Cím, szerző és sorozat metaadatok automatikus lekérése a feltöltés megkönnyítése érdekében. További metaadatok egyeztetése szükséges lehet a feltöltés után.",
@@ -272,7 +273,7 @@
"LabelCollapseSeries": "Sorozat összecsukása", "LabelCollapseSeries": "Sorozat összecsukása",
"LabelCollapseSubSeries": "Alszéria összecsukása", "LabelCollapseSubSeries": "Alszéria összecsukása",
"LabelCollection": "Gyűjtemény", "LabelCollection": "Gyűjtemény",
"LabelCollections": "Gyűjtemények", "LabelCollections": "Gyűjtemény",
"LabelComplete": "Kész", "LabelComplete": "Kész",
"LabelConfirmPassword": "Jelszó megerősítése", "LabelConfirmPassword": "Jelszó megerősítése",
"LabelContinueListening": "Hallgatás folytatása", "LabelContinueListening": "Hallgatás folytatása",
@@ -299,6 +300,7 @@
"LabelDiscover": "Felfedezés", "LabelDiscover": "Felfedezés",
"LabelDownload": "Letöltés", "LabelDownload": "Letöltés",
"LabelDownloadNEpisodes": "{0} epizód letöltése", "LabelDownloadNEpisodes": "{0} epizód letöltése",
"LabelDownloadable": "Letölthető",
"LabelDuration": "Időtartam", "LabelDuration": "Időtartam",
"LabelDurationComparisonExactMatch": "(pontos egyezés)", "LabelDurationComparisonExactMatch": "(pontos egyezés)",
"LabelDurationComparisonLonger": "({0}-val hosszabb)", "LabelDurationComparisonLonger": "({0}-val hosszabb)",
@@ -320,6 +322,7 @@
"LabelEncodingChaptersNotEmbedded": "A fejezetek nincsenek beágyazva a többsávos hangoskönyvekbe.", "LabelEncodingChaptersNotEmbedded": "A fejezetek nincsenek beágyazva a többsávos hangoskönyvekbe.",
"LabelEncodingClearItemCache": "Győződjön meg róla, hogy rendszeresen tisztítja az elemek gyorsítótárát.", "LabelEncodingClearItemCache": "Győződjön meg róla, hogy rendszeresen tisztítja az elemek gyorsítótárát.",
"LabelEncodingFinishedM4B": "A kész M4B a hangoskönyv mappádba kerül:", "LabelEncodingFinishedM4B": "A kész M4B a hangoskönyv mappádba kerül:",
"LabelEncodingInfoEmbedded": "A metaadatok beépülnek a hangsávokba a hangoskönyv mappáján belül.",
"LabelEncodingStartedNavigation": "Ha a feladat elindult, el lehet navigálni erről az oldalról.", "LabelEncodingStartedNavigation": "Ha a feladat elindult, el lehet navigálni erről az oldalról.",
"LabelEncodingTimeWarning": "A kódolás akár 30 percet is igénybe vehet.", "LabelEncodingTimeWarning": "A kódolás akár 30 percet is igénybe vehet.",
"LabelEncodingWarningAdvancedSettings": "Figyelmeztetés: Ne frissítse ezeket a beállításokat, hacsak nem ismeri az ffmpeg kódolási beállításait.", "LabelEncodingWarningAdvancedSettings": "Figyelmeztetés: Ne frissítse ezeket a beállításokat, hacsak nem ismeri az ffmpeg kódolási beállításait.",
@@ -441,7 +444,7 @@
"LabelNarrators": "Előadók", "LabelNarrators": "Előadók",
"LabelNew": "Új", "LabelNew": "Új",
"LabelNewPassword": "Új jelszó", "LabelNewPassword": "Új jelszó",
"LabelNewestAuthors": "Legújabb szerzők", "LabelNewestAuthors": "A legújabb szerzők",
"LabelNewestEpisodes": "Legújabb epizódok", "LabelNewestEpisodes": "Legújabb epizódok",
"LabelNextBackupDate": "Következő biztonsági másolat dátuma", "LabelNextBackupDate": "Következő biztonsági másolat dátuma",
"LabelNextScheduledRun": "Következő ütemezett futtatás", "LabelNextScheduledRun": "Következő ütemezett futtatás",
@@ -478,7 +481,7 @@
"LabelPermissionsDownload": "Letölthet", "LabelPermissionsDownload": "Letölthet",
"LabelPermissionsUpdate": "Frissíthet", "LabelPermissionsUpdate": "Frissíthet",
"LabelPermissionsUpload": "Feltölthet", "LabelPermissionsUpload": "Feltölthet",
"LabelPersonalYearReview": "Az évvisszatekintésed ({0})", "LabelPersonalYearReview": "Az éved összefoglalása ({0})",
"LabelPhotoPathURL": "Fénykép útvonal/URL", "LabelPhotoPathURL": "Fénykép útvonal/URL",
"LabelPlayMethod": "Lejátszási módszer", "LabelPlayMethod": "Lejátszási módszer",
"LabelPlayerChapterNumberMarker": "{0} a {1} -ből", "LabelPlayerChapterNumberMarker": "{0} a {1} -ből",
@@ -535,11 +538,12 @@
"LabelSelectUsers": "Felhasználók kiválasztása", "LabelSelectUsers": "Felhasználók kiválasztása",
"LabelSendEbookToDevice": "E-könyv küldése...", "LabelSendEbookToDevice": "E-könyv küldése...",
"LabelSequence": "Sorozat", "LabelSequence": "Sorozat",
"LabelSerial": "Sorozat",
"LabelSeries": "Sorozat", "LabelSeries": "Sorozat",
"LabelSeriesName": "Sorozat neve", "LabelSeriesName": "Sorozat neve",
"LabelSeriesProgress": "Sorozat haladása", "LabelSeriesProgress": "Sorozat haladása",
"LabelServerLogLevel": "Kiszolgáló naplózási szint", "LabelServerLogLevel": "Kiszolgáló naplózási szint",
"LabelServerYearReview": "Szerver évvisszatekintés ({0})", "LabelServerYearReview": "Szerver éves visszatekintése ({0})",
"LabelSetEbookAsPrimary": "Beállítás elsődlegesként", "LabelSetEbookAsPrimary": "Beállítás elsődlegesként",
"LabelSetEbookAsSupplementary": "Beállítás kiegészítőként", "LabelSetEbookAsSupplementary": "Beállítás kiegészítőként",
"LabelSettingsAllowIframe": "A beágyazás engedélyezése egy iframe-be", "LabelSettingsAllowIframe": "A beágyazás engedélyezése egy iframe-be",
@@ -585,7 +589,11 @@
"LabelSettingsStoreMetadataWithItemHelp": "Alapértelmezés szerint a metaadatfájlok a /metadata/items mappában vannak tárolva, ennek a beállításnak az engedélyezése a metaadatfájlokat a könyvtári elem mappáiban tárolja", "LabelSettingsStoreMetadataWithItemHelp": "Alapértelmezés szerint a metaadatfájlok a /metadata/items mappában vannak tárolva, ennek a beállításnak az engedélyezése a metaadatfájlokat a könyvtári elem mappáiban tárolja",
"LabelSettingsTimeFormat": "Időformátum", "LabelSettingsTimeFormat": "Időformátum",
"LabelShare": "Megosztás", "LabelShare": "Megosztás",
"LabelShareDownloadableHelp": "Lehetővé teszi a megosztási linket birtokló felhasználók számára, hogy letöltsék a könyvtári elem zip-fájlját.",
"LabelShareOpen": "Megosztás megnyitása",
"LabelShareURL": "URL megosztása",
"LabelShowAll": "Mindent mutat", "LabelShowAll": "Mindent mutat",
"LabelShowSeconds": "Másodperc megjelenítése",
"LabelShowSubtitles": "Felirat megjelenítése", "LabelShowSubtitles": "Felirat megjelenítése",
"LabelSize": "Méret", "LabelSize": "Méret",
"LabelSleepTimer": "Alvásidőzítő", "LabelSleepTimer": "Alvásidőzítő",
@@ -596,8 +604,8 @@
"LabelStartTime": "Kezdési idő", "LabelStartTime": "Kezdési idő",
"LabelStarted": "Elkezdődött", "LabelStarted": "Elkezdődött",
"LabelStartedAt": "Kezdés ideje", "LabelStartedAt": "Kezdés ideje",
"LabelStatsAudioTracks": "Audiósávok", "LabelStatsAudioTracks": "Audiósáv",
"LabelStatsAuthors": "Szerzők", "LabelStatsAuthors": "Szerző",
"LabelStatsBestDay": "Legjobb nap", "LabelStatsBestDay": "Legjobb nap",
"LabelStatsDailyAverage": "Napi átlag", "LabelStatsDailyAverage": "Napi átlag",
"LabelStatsDays": "Napok", "LabelStatsDays": "Napok",
@@ -605,7 +613,7 @@
"LabelStatsHours": "Órák", "LabelStatsHours": "Órák",
"LabelStatsInARow": "egymás után", "LabelStatsInARow": "egymás után",
"LabelStatsItemsFinished": "Befejezett elem", "LabelStatsItemsFinished": "Befejezett elem",
"LabelStatsItemsInLibrary": "Elemek a könyvtárban", "LabelStatsItemsInLibrary": "Elem a könyvtárban",
"LabelStatsMinutes": "perc", "LabelStatsMinutes": "perc",
"LabelStatsMinutesListening": "Hallgatási perc", "LabelStatsMinutesListening": "Hallgatási perc",
"LabelStatsOverallDays": "Összes nap", "LabelStatsOverallDays": "Összes nap",
@@ -684,8 +692,8 @@
"LabelWeekdaysToRun": "Futás napjai", "LabelWeekdaysToRun": "Futás napjai",
"LabelXBooks": "{0} könyv", "LabelXBooks": "{0} könyv",
"LabelXItems": "{0} elem", "LabelXItems": "{0} elem",
"LabelYearReviewHide": "Az évvisszatekintés elrejtése", "LabelYearReviewHide": "Visszatekintés az évre elrejtése",
"LabelYearReviewShow": "Évvisszatekintés megtekintése", "LabelYearReviewShow": "Visszatekintés az évre megtekintése",
"LabelYourAudiobookDuration": "Hangoskönyv időtartama", "LabelYourAudiobookDuration": "Hangoskönyv időtartama",
"LabelYourBookmarks": "Könyvjelzőid", "LabelYourBookmarks": "Könyvjelzőid",
"LabelYourPlaylists": "Lejátszási listáid", "LabelYourPlaylists": "Lejátszási listáid",
@@ -750,10 +758,12 @@
"MessageConfirmResetProgress": "Biztos, hogy vissza akarja állítani a haladási folyamatát?", "MessageConfirmResetProgress": "Biztos, hogy vissza akarja állítani a haladási folyamatát?",
"MessageConfirmSendEbookToDevice": "Biztosan el szeretné küldeni a(z) {0} e-könyvet a(z) \"{1}\" eszközre?", "MessageConfirmSendEbookToDevice": "Biztosan el szeretné küldeni a(z) {0} e-könyvet a(z) \"{1}\" eszközre?",
"MessageConfirmUnlinkOpenId": "Biztos, hogy el akarja távolítani ezt a felhasználót az OpenID-ból?", "MessageConfirmUnlinkOpenId": "Biztos, hogy el akarja távolítani ezt a felhasználót az OpenID-ból?",
"MessageDaysListenedInTheLastYear": "{0} napot hallgatott az elmúlt évben",
"MessageDownloadingEpisode": "Epizód letöltése", "MessageDownloadingEpisode": "Epizód letöltése",
"MessageDragFilesIntoTrackOrder": "Húzza a fájlokat a helyes sávrendbe", "MessageDragFilesIntoTrackOrder": "Húzza a fájlokat a helyes sávrendbe",
"MessageEmbedFailed": "A beágyazás sikertelen!", "MessageEmbedFailed": "A beágyazás sikertelen!",
"MessageEmbedFinished": "Beágyazás befejeződött!", "MessageEmbedFinished": "Beágyazás befejeződött!",
"MessageEmbedQueue": "Metaadatok beágyazására várakozik ({0} a sorban)",
"MessageEpisodesQueuedForDownload": "{0} epizód letöltésre vár", "MessageEpisodesQueuedForDownload": "{0} epizód letöltésre vár",
"MessageEreaderDevices": "Az e-könyvek kézbesítésének biztosítása érdekében a fenti e-mail címet az alább felsorolt minden egyes eszközhöz, mint érvényes feladót kell hozzáadnia.", "MessageEreaderDevices": "Az e-könyvek kézbesítésének biztosítása érdekében a fenti e-mail címet az alább felsorolt minden egyes eszközhöz, mint érvényes feladót kell hozzáadnia.",
"MessageFeedURLWillBe": "A hírcsatorna URL-je {0} lesz", "MessageFeedURLWillBe": "A hírcsatorna URL-je {0} lesz",
@@ -816,6 +826,7 @@
"MessagePodcastHasNoRSSFeedForMatching": "A podcastnak nincs RSS hírcsatorna URL-je az egyeztetéshez", "MessagePodcastHasNoRSSFeedForMatching": "A podcastnak nincs RSS hírcsatorna URL-je az egyeztetéshez",
"MessagePodcastSearchField": "Adja meg a keresési kifejezést vagy az RSS hírcsatorna URL-címét", "MessagePodcastSearchField": "Adja meg a keresési kifejezést vagy az RSS hírcsatorna URL-címét",
"MessageQuickEmbedInProgress": "Gyors beágyazás folyamatban", "MessageQuickEmbedInProgress": "Gyors beágyazás folyamatban",
"MessageQuickEmbedQueue": "Gyors beágyazásra várakozik ({0} a sorban)",
"MessageQuickMatchAllEpisodes": "Minden epizód gyors egyeztetése", "MessageQuickMatchAllEpisodes": "Minden epizód gyors egyeztetése",
"MessageQuickMatchDescription": "Üres elem részletek és borító feltöltése az első találati eredménnyel a(z) '{0}'-ból. Nem írja felül a részleteket, kivéve, ha a 'Preferált egyeztetett metaadatok' szerverbeállítás engedélyezve van.", "MessageQuickMatchDescription": "Üres elem részletek és borító feltöltése az első találati eredménnyel a(z) '{0}'-ból. Nem írja felül a részleteket, kivéve, ha a 'Preferált egyeztetett metaadatok' szerverbeállítás engedélyezve van.",
"MessageRemoveChapter": "Fejezet eltávolítása", "MessageRemoveChapter": "Fejezet eltávolítása",
@@ -826,12 +837,14 @@
"MessageResetChaptersConfirm": "Biztosan alaphelyzetbe szeretné állítani a fejezeteket és visszavonni a módosításokat?", "MessageResetChaptersConfirm": "Biztosan alaphelyzetbe szeretné állítani a fejezeteket és visszavonni a módosításokat?",
"MessageRestoreBackupConfirm": "Biztosan vissza szeretné állítani a biztonsági másolatot, amely ekkor készült:", "MessageRestoreBackupConfirm": "Biztosan vissza szeretné állítani a biztonsági másolatot, amely ekkor készült:",
"MessageRestoreBackupWarning": "A biztonsági mentés visszaállítása felülírja az egész adatbázist, amely a /config mappában található, valamint a borítóképeket a /metadata/items és /metadata/authors mappákban.<br /><br />A biztonsági mentések nem módosítják a könyvtár mappáiban található fájlokat. Ha engedélyezte a szerverbeállításokat a borítóképek és a metaadatok könyvtármappákban való tárolására, akkor ezek nem kerülnek biztonsági mentésre vagy felülírásra.<br /><br />A szerver használó összes kliens automatikusan frissül.", "MessageRestoreBackupWarning": "A biztonsági mentés visszaállítása felülírja az egész adatbázist, amely a /config mappában található, valamint a borítóképeket a /metadata/items és /metadata/authors mappákban.<br /><br />A biztonsági mentések nem módosítják a könyvtár mappáiban található fájlokat. Ha engedélyezte a szerverbeállításokat a borítóképek és a metaadatok könyvtármappákban való tárolására, akkor ezek nem kerülnek biztonsági mentésre vagy felülírásra.<br /><br />A szerver használó összes kliens automatikusan frissül.",
"MessageScheduleLibraryScanNote": "A legtöbb felhasználó számára ajánlott ezt a funkciót kikapcsolva hagyni, és engedélyezni a mappafigyelő beállítást. A mappafigyelő automatikusan észleli a könyvtári mappák változásait. A mappafigyelő nem működik minden fájlrendszernél (mint például az NFS), ezért helyette ütemezett könyvtárellenőrzéseket lehet használni.",
"MessageSearchResultsFor": "Keresési eredmények", "MessageSearchResultsFor": "Keresési eredmények",
"MessageSelected": "{0} kiválasztva", "MessageSelected": "{0} kiválasztva",
"MessageServerCouldNotBeReached": "A szervert nem lehet elérni", "MessageServerCouldNotBeReached": "A szervert nem lehet elérni",
"MessageSetChaptersFromTracksDescription": "Fejezetek beállítása minden egyes hangfájlt egy fejezetként használva, és a fejezet címét a hangfájl neveként", "MessageSetChaptersFromTracksDescription": "Fejezetek beállítása minden egyes hangfájlt egy fejezetként használva, és a fejezet címét a hangfájl neveként",
"MessageShareExpirationWillBe": "A lejárat: <strong>{0}</strong>", "MessageShareExpirationWillBe": "A lejárat: <strong>{0}</strong>",
"MessageShareExpiresIn": "{0} múlva jár le", "MessageShareExpiresIn": "{0} múlva jár le",
"MessageShareURLWillBe": "A megosztási URL <strong>{0}</strong> lesz",
"MessageStartPlaybackAtTime": "\"{0}\" lejátszásának kezdése {1} -tól?", "MessageStartPlaybackAtTime": "\"{0}\" lejátszásának kezdése {1} -tól?",
"MessageTaskAudioFileNotWritable": "A/Az „{0}” hangfájl nem írható", "MessageTaskAudioFileNotWritable": "A/Az „{0}” hangfájl nem írható",
"MessageTaskCanceledByUser": "Felhasználó törölte a feladatot", "MessageTaskCanceledByUser": "Felhasználó törölte a feladatot",
@@ -937,7 +950,6 @@
"ToastBookmarkCreateFailed": "Könyvjelző létrehozása sikertelen", "ToastBookmarkCreateFailed": "Könyvjelző létrehozása sikertelen",
"ToastBookmarkCreateSuccess": "Könyvjelző hozzáadva", "ToastBookmarkCreateSuccess": "Könyvjelző hozzáadva",
"ToastBookmarkRemoveSuccess": "Könyvjelző eltávolítva", "ToastBookmarkRemoveSuccess": "Könyvjelző eltávolítva",
"ToastBookmarkUpdateSuccess": "Könyvjelző frissítve",
"ToastCachePurgeFailed": "A gyorsítótár törlése sikertelen", "ToastCachePurgeFailed": "A gyorsítótár törlése sikertelen",
"ToastCachePurgeSuccess": "A gyorsítótár sikeresen törölve", "ToastCachePurgeSuccess": "A gyorsítótár sikeresen törölve",
"ToastChaptersHaveErrors": "A fejezetek hibákat tartalmaznak", "ToastChaptersHaveErrors": "A fejezetek hibákat tartalmaznak",
@@ -947,6 +959,7 @@
"ToastCollectionRemoveSuccess": "Gyűjtemény eltávolítva", "ToastCollectionRemoveSuccess": "Gyűjtemény eltávolítva",
"ToastCollectionUpdateSuccess": "Gyűjtemény frissítve", "ToastCollectionUpdateSuccess": "Gyűjtemény frissítve",
"ToastCoverUpdateFailed": "A borító frissítése nem sikerült", "ToastCoverUpdateFailed": "A borító frissítése nem sikerült",
"ToastDateTimeInvalidOrIncomplete": "A dátum és az időpont érvénytelen vagy hiányos",
"ToastDeleteFileFailed": "Nem sikerült törölni a fájlt", "ToastDeleteFileFailed": "Nem sikerült törölni a fájlt",
"ToastDeleteFileSuccess": "Fájl törölve", "ToastDeleteFileSuccess": "Fájl törölve",
"ToastDeviceAddFailed": "Nem sikerült eszközt hozzáadni", "ToastDeviceAddFailed": "Nem sikerült eszközt hozzáadni",
@@ -954,9 +967,11 @@
"ToastDeviceTestEmailFailed": "Teszt email küldése sikertelen", "ToastDeviceTestEmailFailed": "Teszt email küldése sikertelen",
"ToastDeviceTestEmailSuccess": "Teszt email elküldve", "ToastDeviceTestEmailSuccess": "Teszt email elküldve",
"ToastEmailSettingsUpdateSuccess": "Email beállítások frissítve", "ToastEmailSettingsUpdateSuccess": "Email beállítások frissítve",
"ToastEncodeCancelFailed": "A kódolás törlése sikertelen volt",
"ToastEncodeCancelSucces": "Kódolás törölve", "ToastEncodeCancelSucces": "Kódolás törölve",
"ToastEpisodeDownloadQueueClearFailed": "Nem sikerült törölni a várólistát", "ToastEpisodeDownloadQueueClearFailed": "Nem sikerült törölni a várólistát",
"ToastEpisodeUpdateSuccess": "{0} epizód frissítve", "ToastEpisodeUpdateSuccess": "{0} epizód frissítve",
"ToastErrorCannotShare": "Ezen az eszközön nem lehet natívan megosztani",
"ToastFailedToLoadData": "Sikertelen adatbetöltés", "ToastFailedToLoadData": "Sikertelen adatbetöltés",
"ToastFailedToMatch": "Nem sikerült egyezőséget találni", "ToastFailedToMatch": "Nem sikerült egyezőséget találni",
"ToastFailedToShare": "Nem sikerült megosztani", "ToastFailedToShare": "Nem sikerült megosztani",
@@ -992,10 +1007,15 @@
"ToastNewUserCreatedSuccess": "Új fiók létrehozva", "ToastNewUserCreatedSuccess": "Új fiók létrehozva",
"ToastNewUserLibraryError": "Legalább egy könyvtárat ki kell választani", "ToastNewUserLibraryError": "Legalább egy könyvtárat ki kell választani",
"ToastNewUserPasswordError": "Kötelező a jelszó, csak a root felhasználónak lehet üres jelszava", "ToastNewUserPasswordError": "Kötelező a jelszó, csak a root felhasználónak lehet üres jelszava",
"ToastNewUserTagError": "Legalább egy címkét ki kell választania",
"ToastNewUserUsernameError": "Adjon meg egy felhasználónevet", "ToastNewUserUsernameError": "Adjon meg egy felhasználónevet",
"ToastNoNewEpisodesFound": "Nincs új epizód", "ToastNoNewEpisodesFound": "Nincs új epizód",
"ToastNoRSSFeed": "A podcastnak nincs RSS hírcsatornája",
"ToastNoUpdatesNecessary": "Nincs szükség frissítésre", "ToastNoUpdatesNecessary": "Nincs szükség frissítésre",
"ToastNotificationCreateFailed": "Értesítés létrehozása sikertelen",
"ToastNotificationDeleteFailed": "Értesítés törlése sikertelen",
"ToastNotificationSettingsUpdateSuccess": "Értesítési beállítások frissítve", "ToastNotificationSettingsUpdateSuccess": "Értesítési beállítások frissítve",
"ToastNotificationTestTriggerFailed": "Nem sikerült a tesztértesítést elindítani",
"ToastNotificationUpdateSuccess": "Értesítés frissítve", "ToastNotificationUpdateSuccess": "Értesítés frissítve",
"ToastPlaylistCreateFailed": "Lejátszási lista létrehozása sikertelen", "ToastPlaylistCreateFailed": "Lejátszási lista létrehozása sikertelen",
"ToastPlaylistCreateSuccess": "Lejátszási lista létrehozva", "ToastPlaylistCreateSuccess": "Lejátszási lista létrehozva",
@@ -1005,22 +1025,37 @@
"ToastPodcastCreateSuccess": "A podcast sikeresen létrehozva", "ToastPodcastCreateSuccess": "A podcast sikeresen létrehozva",
"ToastPodcastNoEpisodesInFeed": "Nincsenek epizódok az RSS hírcsatornában", "ToastPodcastNoEpisodesInFeed": "Nincsenek epizódok az RSS hírcsatornában",
"ToastPodcastNoRssFeed": "A podcastnak nincs RSS-hírcsatornája", "ToastPodcastNoRssFeed": "A podcastnak nincs RSS-hírcsatornája",
"ToastProgressIsNotBeingSynced": "Az előrehaladás nem szinkronizálódik, a lejátszás újraindul",
"ToastProviderCreatedFailed": "Hiba a szolgáltató hozzáadásakor",
"ToastProviderCreatedSuccess": "Új szolgáltató hozzáadva",
"ToastProviderNameAndUrlRequired": "Név és Url kötelező",
"ToastProviderRemoveSuccess": "Szolgáltató eltávolítva",
"ToastRSSFeedCloseFailed": "Az RSS hírcsatorna bezárása sikertelen", "ToastRSSFeedCloseFailed": "Az RSS hírcsatorna bezárása sikertelen",
"ToastRSSFeedCloseSuccess": "RSS hírfolyam leállítva", "ToastRSSFeedCloseSuccess": "RSS hírfolyam leállítva",
"ToastRemoveFailed": "Sikertelen eltávolítás",
"ToastRemoveItemFromCollectionFailed": "Tétel eltávolítása a gyűjteményből sikertelen", "ToastRemoveItemFromCollectionFailed": "Tétel eltávolítása a gyűjteményből sikertelen",
"ToastRemoveItemFromCollectionSuccess": "Tétel eltávolítva a gyűjteményből", "ToastRemoveItemFromCollectionSuccess": "Tétel eltávolítva a gyűjteményből",
"ToastRenameFailed": "Sikertelen átnevezés",
"ToastSelectAtLeastOneUser": "Válasszon legalább egy felhasználót",
"ToastSendEbookToDeviceFailed": "E-könyv küldése az eszközre sikertelen", "ToastSendEbookToDeviceFailed": "E-könyv küldése az eszközre sikertelen",
"ToastSendEbookToDeviceSuccess": "E-könyv elküldve az eszközre \"{0}\"", "ToastSendEbookToDeviceSuccess": "E-könyv elküldve az eszközre \"{0}\"",
"ToastSeriesUpdateFailed": "Sorozat frissítése sikertelen", "ToastSeriesUpdateFailed": "Sorozat frissítése sikertelen",
"ToastSeriesUpdateSuccess": "Sorozat frissítése sikeres", "ToastSeriesUpdateSuccess": "Sorozat frissítése sikeres",
"ToastServerSettingsUpdateSuccess": "Szerver beállítások frissítve",
"ToastSessionCloseFailed": "A munkamenet bezárása sikertelen",
"ToastSessionDeleteFailed": "Munkamenet törlése sikertelen", "ToastSessionDeleteFailed": "Munkamenet törlése sikertelen",
"ToastSessionDeleteSuccess": "Munkamenet törölve", "ToastSessionDeleteSuccess": "Munkamenet törölve",
"ToastSleepTimerDone": "Alvásidőzítő kész... zZzzZZz",
"ToastSocketConnected": "Socket csatlakoztatva", "ToastSocketConnected": "Socket csatlakoztatva",
"ToastSocketDisconnected": "Socket lecsatlakoztatva", "ToastSocketDisconnected": "Socket lecsatlakoztatva",
"ToastSocketFailedToConnect": "A Socket csatlakoztatása sikertelen", "ToastSocketFailedToConnect": "A Socket csatlakoztatása sikertelen",
"ToastSortingPrefixesEmptyError": "Legalább 1 rendezési előtaggal kell rendelkeznie",
"ToastSortingPrefixesUpdateSuccess": "Rendezési előtagok frissítése ({0} elem)",
"ToastTitleRequired": "A cím kötelező",
"ToastUnknownError": "Ismeretlen hiba", "ToastUnknownError": "Ismeretlen hiba",
"ToastUserDeleteFailed": "Felhasználó törlése sikertelen", "ToastUserDeleteFailed": "Felhasználó törlése sikertelen",
"ToastUserDeleteSuccess": "Felhasználó törölve", "ToastUserDeleteSuccess": "Felhasználó törölve",
"ToastUserPasswordChangeSuccess": "Jelszó sikeresen megváltoztatva", "ToastUserPasswordChangeSuccess": "Jelszó sikeresen megváltoztatva",
"ToastUserPasswordMustChange": "Az új jelszó nem egyezik a régi jelszóval",
"ToastUserRootRequireName": "Egy root felhasználónevet kell megadnia" "ToastUserRootRequireName": "Egy root felhasználónevet kell megadnia"
} }
+1 -2
View File
@@ -51,7 +51,7 @@
"ButtonNext": "Prossimo", "ButtonNext": "Prossimo",
"ButtonNextChapter": "Prossimo Capitolo", "ButtonNextChapter": "Prossimo Capitolo",
"ButtonNextItemInQueue": "Elemento successivo in coda", "ButtonNextItemInQueue": "Elemento successivo in coda",
"ButtonOk": "Daccordo", "ButtonOk": "D'accordo",
"ButtonOpenFeed": "Apri il flusso", "ButtonOpenFeed": "Apri il flusso",
"ButtonOpenManager": "Apri Manager", "ButtonOpenManager": "Apri Manager",
"ButtonPause": "Pausa", "ButtonPause": "Pausa",
@@ -941,7 +941,6 @@
"ToastBookmarkCreateFailed": "Creazione segnalibro fallita", "ToastBookmarkCreateFailed": "Creazione segnalibro fallita",
"ToastBookmarkCreateSuccess": "Segnalibro creato", "ToastBookmarkCreateSuccess": "Segnalibro creato",
"ToastBookmarkRemoveSuccess": "Segnalibro Rimosso", "ToastBookmarkRemoveSuccess": "Segnalibro Rimosso",
"ToastBookmarkUpdateSuccess": "Segnalibro aggiornato",
"ToastCachePurgeFailed": "Impossibile eliminare la cache", "ToastCachePurgeFailed": "Impossibile eliminare la cache",
"ToastCachePurgeSuccess": "Cache eliminata correttamente", "ToastCachePurgeSuccess": "Cache eliminata correttamente",
"ToastChaptersHaveErrors": "I capitoli contengono errori", "ToastChaptersHaveErrors": "I capitoli contengono errori",
-1
View File
@@ -660,7 +660,6 @@
"ToastBookmarkCreateFailed": "Žymos sukurti nepavyko", "ToastBookmarkCreateFailed": "Žymos sukurti nepavyko",
"ToastBookmarkCreateSuccess": "Žyma pridėta", "ToastBookmarkCreateSuccess": "Žyma pridėta",
"ToastBookmarkRemoveSuccess": "Žyma pašalinta", "ToastBookmarkRemoveSuccess": "Žyma pašalinta",
"ToastBookmarkUpdateSuccess": "Žyma atnaujinta",
"ToastChaptersHaveErrors": "Skyriai turi klaidų", "ToastChaptersHaveErrors": "Skyriai turi klaidų",
"ToastChaptersMustHaveTitles": "Skyriai turi turėti pavadinimus", "ToastChaptersMustHaveTitles": "Skyriai turi turėti pavadinimus",
"ToastChaptersRemoved": "Skyriai pašalinti", "ToastChaptersRemoved": "Skyriai pašalinti",
+20 -2
View File
@@ -88,6 +88,8 @@
"ButtonSaveTracklist": "Afspeellijst opslaan", "ButtonSaveTracklist": "Afspeellijst opslaan",
"ButtonScan": "Scannen", "ButtonScan": "Scannen",
"ButtonScanLibrary": "Scan bibliotheek", "ButtonScanLibrary": "Scan bibliotheek",
"ButtonScrollLeft": "Scroll Links",
"ButtonScrollRight": "Scroll Rechts",
"ButtonSearch": "Zoeken", "ButtonSearch": "Zoeken",
"ButtonSelectFolderPath": "Maplocatie selecteren", "ButtonSelectFolderPath": "Maplocatie selecteren",
"ButtonSeries": "Series", "ButtonSeries": "Series",
@@ -153,7 +155,7 @@
"HeaderLogs": "Logboek", "HeaderLogs": "Logboek",
"HeaderManageGenres": "Genres beheren", "HeaderManageGenres": "Genres beheren",
"HeaderManageTags": "Tags beheren", "HeaderManageTags": "Tags beheren",
"HeaderMapDetails": "Map details", "HeaderMapDetails": "Details map",
"HeaderMatch": "Vergelijken", "HeaderMatch": "Vergelijken",
"HeaderMetadataOrderOfPrecedence": "Metadata volgorde", "HeaderMetadataOrderOfPrecedence": "Metadata volgorde",
"HeaderMetadataToEmbed": "In te sluiten metadata", "HeaderMetadataToEmbed": "In te sluiten metadata",
@@ -190,6 +192,7 @@
"HeaderSettingsExperimental": "Experimentele functies", "HeaderSettingsExperimental": "Experimentele functies",
"HeaderSettingsGeneral": "Algemeen", "HeaderSettingsGeneral": "Algemeen",
"HeaderSettingsScanner": "Scanner", "HeaderSettingsScanner": "Scanner",
"HeaderSettingsWebClient": "Web Client",
"HeaderSleepTimer": "Slaaptimer", "HeaderSleepTimer": "Slaaptimer",
"HeaderStatsLargestItems": "Grootste items", "HeaderStatsLargestItems": "Grootste items",
"HeaderStatsLongestItems": "Langste items (uren)", "HeaderStatsLongestItems": "Langste items (uren)",
@@ -297,6 +300,7 @@
"LabelDiscover": "Ontdekken", "LabelDiscover": "Ontdekken",
"LabelDownload": "Download", "LabelDownload": "Download",
"LabelDownloadNEpisodes": "Download {0} afleveringen", "LabelDownloadNEpisodes": "Download {0} afleveringen",
"LabelDownloadable": "Downloadbaar",
"LabelDuration": "Duur", "LabelDuration": "Duur",
"LabelDurationComparisonExactMatch": "(exacte overeenkomst)", "LabelDurationComparisonExactMatch": "(exacte overeenkomst)",
"LabelDurationComparisonLonger": "({0} langer)", "LabelDurationComparisonLonger": "({0} langer)",
@@ -472,6 +476,7 @@
"LabelPermissionsAccessAllLibraries": "Heeft toegang tot all bibliotheken", "LabelPermissionsAccessAllLibraries": "Heeft toegang tot all bibliotheken",
"LabelPermissionsAccessAllTags": "Heeft toegang tot alle tags", "LabelPermissionsAccessAllTags": "Heeft toegang tot alle tags",
"LabelPermissionsAccessExplicitContent": "Heeft toegang tot expliciete inhoud", "LabelPermissionsAccessExplicitContent": "Heeft toegang tot expliciete inhoud",
"LabelPermissionsCreateEreader": "Kan Ereader Aanmaken",
"LabelPermissionsDelete": "Kan verwijderen", "LabelPermissionsDelete": "Kan verwijderen",
"LabelPermissionsDownload": "Kan downloaden", "LabelPermissionsDownload": "Kan downloaden",
"LabelPermissionsUpdate": "Kan bijwerken", "LabelPermissionsUpdate": "Kan bijwerken",
@@ -541,6 +546,7 @@
"LabelServerYearReview": "Server Jaar in Review ({0})", "LabelServerYearReview": "Server Jaar in Review ({0})",
"LabelSetEbookAsPrimary": "Stel in als primair", "LabelSetEbookAsPrimary": "Stel in als primair",
"LabelSetEbookAsSupplementary": "Stel in als supplementair", "LabelSetEbookAsSupplementary": "Stel in als supplementair",
"LabelSettingsAllowIframe": "Insluiten in iframe toestaan",
"LabelSettingsAudiobooksOnly": "Alleen audiobooks", "LabelSettingsAudiobooksOnly": "Alleen audiobooks",
"LabelSettingsAudiobooksOnlyHelp": "Deze instelling inschakelen zorgt ervoor dat ebook-bestanden genegeerd worden tenzij ze in een audiobook-map staan, in welk geval ze worden ingesteld als supplementaire ebooks", "LabelSettingsAudiobooksOnlyHelp": "Deze instelling inschakelen zorgt ervoor dat ebook-bestanden genegeerd worden tenzij ze in een audiobook-map staan, in welk geval ze worden ingesteld als supplementaire ebooks",
"LabelSettingsBookshelfViewHelp": "Skeumorphisch design met houten planken", "LabelSettingsBookshelfViewHelp": "Skeumorphisch design met houten planken",
@@ -562,6 +568,9 @@
"LabelSettingsHideSingleBookSeriesHelp": "Series die slechts een enkel boek bevatten worden verborgen op de seriespagina en de homepagina-planken.", "LabelSettingsHideSingleBookSeriesHelp": "Series die slechts een enkel boek bevatten worden verborgen op de seriespagina en de homepagina-planken.",
"LabelSettingsHomePageBookshelfView": "Boekenplank-view voor homepagina", "LabelSettingsHomePageBookshelfView": "Boekenplank-view voor homepagina",
"LabelSettingsLibraryBookshelfView": "Boekenplank-view voor bibliotheek", "LabelSettingsLibraryBookshelfView": "Boekenplank-view voor bibliotheek",
"LabelSettingsLibraryMarkAsFinishedPercentComplete": "Voltooid percentage is groter dan",
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Resterende tijd is kleiner dan (seconden)",
"LabelSettingsLibraryMarkAsFinishedWhen": "Markeer media item wanneer voltooid",
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Sla eedere boeken in Serie Verderzetten over", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Sla eedere boeken in Serie Verderzetten over",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "De Continue Series home page shelf toont het eerste boek dat nog niet is begonnen in series waarvan er minstens één is voltooid en er geen boeken in uitvoering zijn. Als u deze instelling inschakelt, wordt de serie voortgezet vanaf het boek dat het verst is voltooid in plaats van het eerste boek dat nog niet is begonnen.", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "De Continue Series home page shelf toont het eerste boek dat nog niet is begonnen in series waarvan er minstens één is voltooid en er geen boeken in uitvoering zijn. Als u deze instelling inschakelt, wordt de serie voortgezet vanaf het boek dat het verst is voltooid in plaats van het eerste boek dat nog niet is begonnen.",
"LabelSettingsParseSubtitles": "Parseer subtitel", "LabelSettingsParseSubtitles": "Parseer subtitel",
@@ -580,6 +589,7 @@
"LabelSettingsStoreMetadataWithItemHelp": "Standaard worden metadata-bestanden bewaard in /metadata/items, door deze instelling in te schakelen zullen metadata bestanden in de map van je bibliotheekonderdeel bewaard worden", "LabelSettingsStoreMetadataWithItemHelp": "Standaard worden metadata-bestanden bewaard in /metadata/items, door deze instelling in te schakelen zullen metadata bestanden in de map van je bibliotheekonderdeel bewaard worden",
"LabelSettingsTimeFormat": "Tijdformat", "LabelSettingsTimeFormat": "Tijdformat",
"LabelShare": "Delen", "LabelShare": "Delen",
"LabelShareDownloadableHelp": "Gebruikers toestaan met share link om zip bestand te downloaden van het bibliotheek item.",
"LabelShareOpen": "Delen Open", "LabelShareOpen": "Delen Open",
"LabelShareURL": "URL Delen", "LabelShareURL": "URL Delen",
"LabelShowAll": "Toon alle", "LabelShowAll": "Toon alle",
@@ -588,6 +598,8 @@
"LabelSize": "Grootte", "LabelSize": "Grootte",
"LabelSleepTimer": "Slaaptimer", "LabelSleepTimer": "Slaaptimer",
"LabelSlug": "Slak", "LabelSlug": "Slak",
"LabelSortAscending": "Oplopend",
"LabelSortDescending": "Aflopend",
"LabelStart": "Start", "LabelStart": "Start",
"LabelStartTime": "Starttijd", "LabelStartTime": "Starttijd",
"LabelStarted": "Gestart", "LabelStarted": "Gestart",
@@ -659,6 +671,7 @@
"LabelUpdateDetailsHelp": "Sta overschrijven van bestaande details toe voor de geselecteerde boeken wanneer een match is gevonden", "LabelUpdateDetailsHelp": "Sta overschrijven van bestaande details toe voor de geselecteerde boeken wanneer een match is gevonden",
"LabelUpdatedAt": "Bijgewerkt op", "LabelUpdatedAt": "Bijgewerkt op",
"LabelUploaderDragAndDrop": "Slepen & neerzeten van bestanden of mappen", "LabelUploaderDragAndDrop": "Slepen & neerzeten van bestanden of mappen",
"LabelUploaderDragAndDropFilesOnly": "Drag & drop bestanden",
"LabelUploaderDropFiles": "Bestanden neerzetten", "LabelUploaderDropFiles": "Bestanden neerzetten",
"LabelUploaderItemFetchMetadataHelp": "Automatisch titel, auteur en serie ophalen", "LabelUploaderItemFetchMetadataHelp": "Automatisch titel, auteur en serie ophalen",
"LabelUseAdvancedOptions": "Gebruik Geavanceerde Instellingen", "LabelUseAdvancedOptions": "Gebruik Geavanceerde Instellingen",
@@ -674,6 +687,8 @@
"LabelViewPlayerSettings": "Laat spelerinstellingen zien", "LabelViewPlayerSettings": "Laat spelerinstellingen zien",
"LabelViewQueue": "Bekijk afspeelwachtrij", "LabelViewQueue": "Bekijk afspeelwachtrij",
"LabelVolume": "Volume", "LabelVolume": "Volume",
"LabelWebRedirectURLsDescription": "Autoriseer deze URL's in uw OAuth-provider om na het inloggen omleiding terug naar de web-app toe te staan:",
"LabelWebRedirectURLsSubfolder": "Subfolder voor Redirect URLs",
"LabelWeekdaysToRun": "Weekdagen om te draaien", "LabelWeekdaysToRun": "Weekdagen om te draaien",
"LabelXBooks": "{0} boeken", "LabelXBooks": "{0} boeken",
"LabelXItems": "{0} items", "LabelXItems": "{0} items",
@@ -743,6 +758,7 @@
"MessageConfirmResetProgress": "Bet u zeker dat u uw voortgang wil resetten?", "MessageConfirmResetProgress": "Bet u zeker dat u uw voortgang wil resetten?",
"MessageConfirmSendEbookToDevice": "Weet je zeker dat je {0} ebook \"{1}\" naar apparaat \"{2}\" wil sturen?", "MessageConfirmSendEbookToDevice": "Weet je zeker dat je {0} ebook \"{1}\" naar apparaat \"{2}\" wil sturen?",
"MessageConfirmUnlinkOpenId": "Bent u zeker dat u deze gebruiker wil ontkoppelen van OpenID?", "MessageConfirmUnlinkOpenId": "Bent u zeker dat u deze gebruiker wil ontkoppelen van OpenID?",
"MessageDaysListenedInTheLastYear": "{0} dagen geluisterd in het voorbije jaar",
"MessageDownloadingEpisode": "Aflevering aan het dowloaden", "MessageDownloadingEpisode": "Aflevering aan het dowloaden",
"MessageDragFilesIntoTrackOrder": "Sleep bestanden in de juiste trackvolgorde", "MessageDragFilesIntoTrackOrder": "Sleep bestanden in de juiste trackvolgorde",
"MessageEmbedFailed": "Insluiten Mislukt!", "MessageEmbedFailed": "Insluiten Mislukt!",
@@ -821,6 +837,7 @@
"MessageResetChaptersConfirm": "Weet je zeker dat je de hoofdstukken wil resetten en de wijzigingen die je gemaakt hebt ongedaan wil maken?", "MessageResetChaptersConfirm": "Weet je zeker dat je de hoofdstukken wil resetten en de wijzigingen die je gemaakt hebt ongedaan wil maken?",
"MessageRestoreBackupConfirm": "Weet je zeker dat je wil herstellen met behulp van de back-up gemaakt op", "MessageRestoreBackupConfirm": "Weet je zeker dat je wil herstellen met behulp van de back-up gemaakt op",
"MessageRestoreBackupWarning": "Herstellen met een back-up zal de volledige database in /config en de covers in /metadata/items & /metadata/authors overschrijven.<br /><br />Back-ups wijzigen geen bestanden in je bibliotheekmappen. Als je de serverinstelling gebruikt om covers en metadata in je bibliotheekmappen te bewaren dan worden deze niet geback-upt of overschreven.<br /><br />Alle clients die van je server gebruik maken zullen automatisch worden ververst.", "MessageRestoreBackupWarning": "Herstellen met een back-up zal de volledige database in /config en de covers in /metadata/items & /metadata/authors overschrijven.<br /><br />Back-ups wijzigen geen bestanden in je bibliotheekmappen. Als je de serverinstelling gebruikt om covers en metadata in je bibliotheekmappen te bewaren dan worden deze niet geback-upt of overschreven.<br /><br />Alle clients die van je server gebruik maken zullen automatisch worden ververst.",
"MessageScheduleLibraryScanNote": "Voor de meeste gebruikers is het raadzaam om deze functie uitgeschakeld te laten en de folder watcher-instelling ingeschakeld te houden. De folder watcher detecteert automatisch wijzigingen in uw bibliotheekmappen. De folder watcher werkt niet voor elk bestandssysteem (zoals NFS), dus geplande bibliotheekscans kunnen in plaats daarvan worden gebruikt.",
"MessageSearchResultsFor": "Zoekresultaten voor", "MessageSearchResultsFor": "Zoekresultaten voor",
"MessageSelected": "{0} geselecteerd", "MessageSelected": "{0} geselecteerd",
"MessageServerCouldNotBeReached": "Server niet bereikbaar", "MessageServerCouldNotBeReached": "Server niet bereikbaar",
@@ -937,7 +954,6 @@
"ToastBookmarkCreateFailed": "Aanmaken boekwijzer mislukt", "ToastBookmarkCreateFailed": "Aanmaken boekwijzer mislukt",
"ToastBookmarkCreateSuccess": "boekwijzer toegevoegd", "ToastBookmarkCreateSuccess": "boekwijzer toegevoegd",
"ToastBookmarkRemoveSuccess": "Boekwijzer verwijderd", "ToastBookmarkRemoveSuccess": "Boekwijzer verwijderd",
"ToastBookmarkUpdateSuccess": "Boekwijzer bijgewerkt",
"ToastCachePurgeFailed": "Cache wissen is mislukt", "ToastCachePurgeFailed": "Cache wissen is mislukt",
"ToastCachePurgeSuccess": "Cache succesvol verwijderd", "ToastCachePurgeSuccess": "Cache succesvol verwijderd",
"ToastChaptersHaveErrors": "Hoofdstukken bevatten fouten", "ToastChaptersHaveErrors": "Hoofdstukken bevatten fouten",
@@ -948,6 +964,7 @@
"ToastCollectionRemoveSuccess": "Collectie verwijderd", "ToastCollectionRemoveSuccess": "Collectie verwijderd",
"ToastCollectionUpdateSuccess": "Collectie bijgewerkt", "ToastCollectionUpdateSuccess": "Collectie bijgewerkt",
"ToastCoverUpdateFailed": "Cover update mislukt", "ToastCoverUpdateFailed": "Cover update mislukt",
"ToastDateTimeInvalidOrIncomplete": "Datum en tijd ongeldig of onvolledig",
"ToastDeleteFileFailed": "Bestand verwijderen mislukt", "ToastDeleteFileFailed": "Bestand verwijderen mislukt",
"ToastDeleteFileSuccess": "Bestand verwijderd", "ToastDeleteFileSuccess": "Bestand verwijderd",
"ToastDeviceAddFailed": "Apparaat toevoegen mislukt", "ToastDeviceAddFailed": "Apparaat toevoegen mislukt",
@@ -1000,6 +1017,7 @@
"ToastNewUserTagError": "Moet ten minste een tag selecteren", "ToastNewUserTagError": "Moet ten minste een tag selecteren",
"ToastNewUserUsernameError": "Voer een gebruikersnaam in", "ToastNewUserUsernameError": "Voer een gebruikersnaam in",
"ToastNoNewEpisodesFound": "Geen nieuwe afleveringen gevonden", "ToastNoNewEpisodesFound": "Geen nieuwe afleveringen gevonden",
"ToastNoRSSFeed": "Podcast heeft geen RSS Feed",
"ToastNoUpdatesNecessary": "Geen updates nodig", "ToastNoUpdatesNecessary": "Geen updates nodig",
"ToastNotificationCreateFailed": "Nieuwe melding aanmaken mislukt", "ToastNotificationCreateFailed": "Nieuwe melding aanmaken mislukt",
"ToastNotificationDeleteFailed": "Melding verwijderen mislukt", "ToastNotificationDeleteFailed": "Melding verwijderen mislukt",
+43 -13
View File
@@ -12,7 +12,7 @@
"ButtonBack": "Tilbake", "ButtonBack": "Tilbake",
"ButtonBrowseForFolder": "Bla gjennom mappe", "ButtonBrowseForFolder": "Bla gjennom mappe",
"ButtonCancel": "Avbryt", "ButtonCancel": "Avbryt",
"ButtonCancelEncode": "Avbryt Encode", "ButtonCancelEncode": "Avbryt konvertering",
"ButtonChangeRootPassword": "Bytt Root-bruker passord", "ButtonChangeRootPassword": "Bytt Root-bruker passord",
"ButtonCheckAndDownloadNewEpisodes": "Sjekk og last ned nye episoder", "ButtonCheckAndDownloadNewEpisodes": "Sjekk og last ned nye episoder",
"ButtonChooseAFolder": "Velg mappe", "ButtonChooseAFolder": "Velg mappe",
@@ -97,10 +97,10 @@
"ButtonShare": "Del", "ButtonShare": "Del",
"ButtonShiftTimes": "Forskyv tider", "ButtonShiftTimes": "Forskyv tider",
"ButtonShow": "Vis", "ButtonShow": "Vis",
"ButtonStartM4BEncode": "Start M4B Koding", "ButtonStartM4BEncode": "Start konvertering til M4B",
"ButtonStartMetadataEmbed": "Start Metadata innbaking", "ButtonStartMetadataEmbed": "Start Metadata innbaking",
"ButtonStats": "Statistikk", "ButtonStats": "Statistikk",
"ButtonSubmit": "Send inn", "ButtonSubmit": "Lagre",
"ButtonTest": "Test", "ButtonTest": "Test",
"ButtonUnlinkOpenId": "Koble fra OpenID", "ButtonUnlinkOpenId": "Koble fra OpenID",
"ButtonUpload": "Last opp", "ButtonUpload": "Last opp",
@@ -143,12 +143,12 @@
"HeaderFindChapters": "Finn Kapittel", "HeaderFindChapters": "Finn Kapittel",
"HeaderIgnoredFiles": "Ignorerte filer", "HeaderIgnoredFiles": "Ignorerte filer",
"HeaderItemFiles": "Elementfiler", "HeaderItemFiles": "Elementfiler",
"HeaderItemMetadataUtils": "Enhet Metadata verktøy", "HeaderItemMetadataUtils": "Element Metadata verktøy",
"HeaderLastListeningSession": "Siste lyttesesjon", "HeaderLastListeningSession": "Siste lyttesesjon",
"HeaderLatestEpisodes": "Siste episoder", "HeaderLatestEpisodes": "Siste episoder",
"HeaderLibraries": "Biblioteker", "HeaderLibraries": "Biblioteker",
"HeaderLibraryFiles": "Bibliotek filer", "HeaderLibraryFiles": "Bibliotek filer",
"HeaderLibraryStats": "Bibliotek statistikk", "HeaderLibraryStats": "Bibliotekstatistikk",
"HeaderListeningSessions": "Lyttesesjoner", "HeaderListeningSessions": "Lyttesesjoner",
"HeaderListeningStats": "Lyttestatistikk", "HeaderListeningStats": "Lyttestatistikk",
"HeaderLogin": "Logg inn", "HeaderLogin": "Logg inn",
@@ -300,6 +300,7 @@
"LabelDiscover": "Oppdag", "LabelDiscover": "Oppdag",
"LabelDownload": "Last ned", "LabelDownload": "Last ned",
"LabelDownloadNEpisodes": "Last ned {0} episoder", "LabelDownloadNEpisodes": "Last ned {0} episoder",
"LabelDownloadable": "Nedlastbar",
"LabelDuration": "Varighet", "LabelDuration": "Varighet",
"LabelDurationComparisonExactMatch": "(nøyaktig treff)", "LabelDurationComparisonExactMatch": "(nøyaktig treff)",
"LabelDurationComparisonLonger": "({0} lenger)", "LabelDurationComparisonLonger": "({0} lenger)",
@@ -365,11 +366,11 @@
"LabelFormat": "Format", "LabelFormat": "Format",
"LabelFull": "Full", "LabelFull": "Full",
"LabelGenre": "Sjanger", "LabelGenre": "Sjanger",
"LabelGenres": "Sjangers", "LabelGenres": "Sjangre",
"LabelHardDeleteFile": "Tving sletting av fil", "LabelHardDeleteFile": "Tving sletting av fil",
"LabelHasEbook": "Har e-bok", "LabelHasEbook": "Har e-bok",
"LabelHasSupplementaryEbook": "Har komplimentær e-bok", "LabelHasSupplementaryEbook": "Har komplimentær e-bok",
"LabelHideSubtitles": "Skjul undertekster", "LabelHideSubtitles": "Skjul undertitler",
"LabelHighestPriority": "Høyeste prioritet", "LabelHighestPriority": "Høyeste prioritet",
"LabelHost": "Tjener", "LabelHost": "Tjener",
"LabelHour": "Time", "LabelHour": "Time",
@@ -406,7 +407,7 @@
"LabelLess": "Mindre", "LabelLess": "Mindre",
"LabelLibrariesAccessibleToUser": "Biblioteker tilgjengelig for bruker", "LabelLibrariesAccessibleToUser": "Biblioteker tilgjengelig for bruker",
"LabelLibrary": "Bibliotek", "LabelLibrary": "Bibliotek",
"LabelLibraryFilterSublistEmpty": "", "LabelLibraryFilterSublistEmpty": "Ingen {0}",
"LabelLibraryItem": "Bibliotek enhet", "LabelLibraryItem": "Bibliotek enhet",
"LabelLibraryName": "Bibliotek navn", "LabelLibraryName": "Bibliotek navn",
"LabelLimit": "Begrensning", "LabelLimit": "Begrensning",
@@ -570,7 +571,7 @@
"LabelSettingsLibraryMarkAsFinishedWhen": "Marker som ferdig når", "LabelSettingsLibraryMarkAsFinishedWhen": "Marker som ferdig når",
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Hopp over tidligere bøker i \"Fortsett serien\"", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Hopp over tidligere bøker i \"Fortsett serien\"",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "\"Fortsett serie\"-siden viser første bok som ikke er påbegynt i serier der en bok er lest og ingen bøker leses nå. Ved å slå på denne innstillingen så vil man fortsette på serien etter siste leste bok, fremfor første bok som ikke er startet på i en serie.", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "\"Fortsett serie\"-siden viser første bok som ikke er påbegynt i serier der en bok er lest og ingen bøker leses nå. Ved å slå på denne innstillingen så vil man fortsette på serien etter siste leste bok, fremfor første bok som ikke er startet på i en serie.",
"LabelSettingsParseSubtitles": "Analyser undertekster", "LabelSettingsParseSubtitles": "Analyser undertitler",
"LabelSettingsParseSubtitlesHelp": "Hent undertittel fra lydbokens mappenavn.<br>Undertittel må være separert med \" - \"<br>f.eks. \"Boktittel - En lengre tittel\" har undertittel \"En lengre tittel\".", "LabelSettingsParseSubtitlesHelp": "Hent undertittel fra lydbokens mappenavn.<br>Undertittel må være separert med \" - \"<br>f.eks. \"Boktittel - En lengre tittel\" har undertittel \"En lengre tittel\".",
"LabelSettingsPreferMatchedMetadata": "Foretrekk funnet metadata", "LabelSettingsPreferMatchedMetadata": "Foretrekk funnet metadata",
"LabelSettingsPreferMatchedMetadataHelp": "Funnet data vil overskrive enhetens detaljene når man bruker Kjapt søk. Som standard vil Kjapt søk kun fylle inn manglende detaljer.", "LabelSettingsPreferMatchedMetadataHelp": "Funnet data vil overskrive enhetens detaljene når man bruker Kjapt søk. Som standard vil Kjapt søk kun fylle inn manglende detaljer.",
@@ -586,6 +587,7 @@
"LabelSettingsStoreMetadataWithItemHelp": "Som standard vil metadata bli lagret under /metadata/items, aktiveres dette valget vil metadata bli lagret i samme mappe som gjenstanden", "LabelSettingsStoreMetadataWithItemHelp": "Som standard vil metadata bli lagret under /metadata/items, aktiveres dette valget vil metadata bli lagret i samme mappe som gjenstanden",
"LabelSettingsTimeFormat": "Tid format", "LabelSettingsTimeFormat": "Tid format",
"LabelShare": "Dele", "LabelShare": "Dele",
"LabelShareDownloadableHelp": "Tillat brukere med en delt link å laste ned en zip-fil av elementet.",
"LabelShareOpen": "Åpne deling", "LabelShareOpen": "Åpne deling",
"LabelShareURL": "Dele URL", "LabelShareURL": "Dele URL",
"LabelShowAll": "Vis alle", "LabelShowAll": "Vis alle",
@@ -615,7 +617,7 @@
"LabelStatsOverallDays": "Totale dager", "LabelStatsOverallDays": "Totale dager",
"LabelStatsOverallHours": "Totale timer", "LabelStatsOverallHours": "Totale timer",
"LabelStatsWeekListening": "Uker lyttet", "LabelStatsWeekListening": "Uker lyttet",
"LabelSubtitle": "undertekster", "LabelSubtitle": "Undertittel",
"LabelSupportedFileTypes": "Støttede filtyper", "LabelSupportedFileTypes": "Støttede filtyper",
"LabelTag": "Tag", "LabelTag": "Tag",
"LabelTags": "Tagger", "LabelTags": "Tagger",
@@ -640,11 +642,11 @@
"LabelTimeRemaining": "{0} gjennstående", "LabelTimeRemaining": "{0} gjennstående",
"LabelTimeToShift": "Tid å forflytte i sekunder", "LabelTimeToShift": "Tid å forflytte i sekunder",
"LabelTitle": "Tittel", "LabelTitle": "Tittel",
"LabelToolsEmbedMetadata": "Bak inn metadata", "LabelToolsEmbedMetadata": "Bygg inn metadata",
"LabelToolsEmbedMetadataDescription": "Bak inn metadata i lydfilen, inkludert omslagsbilde og kapitler.", "LabelToolsEmbedMetadataDescription": "Bak inn metadata i lydfilen, inkludert omslagsbilde og kapitler.",
"LabelToolsM4bEncoder": "M4B enkoder", "LabelToolsM4bEncoder": "M4B enkoder",
"LabelToolsMakeM4b": "Lag M4B Lydbokfil", "LabelToolsMakeM4b": "Lag M4B Lydbokfil",
"LabelToolsMakeM4bDescription": "Lager en.M4B lydbokfil med innbakte omslagsbilde og kapitler.", "LabelToolsMakeM4bDescription": "Lager en M4B lydbokfil med innbakt omslagsbilde og kapitler.",
"LabelToolsSplitM4b": "Del M4B inn i MP3er", "LabelToolsSplitM4b": "Del M4B inn i MP3er",
"LabelToolsSplitM4bDescription": "Lager MP3er fra en M4B inndelt i kapitler med innbakt metadata, omslagsbilde og kapitler.", "LabelToolsSplitM4bDescription": "Lager MP3er fra en M4B inndelt i kapitler med innbakt metadata, omslagsbilde og kapitler.",
"LabelTotalDuration": "Total lengde", "LabelTotalDuration": "Total lengde",
@@ -754,6 +756,7 @@
"MessageConfirmResetProgress": "Er du sikkert på at du vil tilbakestille fremgangen?", "MessageConfirmResetProgress": "Er du sikkert på at du vil tilbakestille fremgangen?",
"MessageConfirmSendEbookToDevice": "Er du sikker på at du vil sende {0} ebok \"{1}\" til enhet \"{2}\"?", "MessageConfirmSendEbookToDevice": "Er du sikker på at du vil sende {0} ebok \"{1}\" til enhet \"{2}\"?",
"MessageConfirmUnlinkOpenId": "Er du sikker på at du vil koble denne brukeren fra OpenID?", "MessageConfirmUnlinkOpenId": "Er du sikker på at du vil koble denne brukeren fra OpenID?",
"MessageDaysListenedInTheLastYear": "{0} dager med lytting siste året",
"MessageDownloadingEpisode": "Laster ned episode", "MessageDownloadingEpisode": "Laster ned episode",
"MessageDragFilesIntoTrackOrder": "Dra filene i rett spor rekkefølge", "MessageDragFilesIntoTrackOrder": "Dra filene i rett spor rekkefølge",
"MessageEmbedFailed": "Innbygging feilet!", "MessageEmbedFailed": "Innbygging feilet!",
@@ -771,6 +774,7 @@
"MessageJoinUsOn": "Følg oss nå", "MessageJoinUsOn": "Følg oss nå",
"MessageLoading": "Laster...", "MessageLoading": "Laster...",
"MessageLoadingFolders": "Laster mapper...", "MessageLoadingFolders": "Laster mapper...",
"MessageLogsDescription": "Logger lagres i <code>/metadata/logs</code> som JSON-filer. Krasjlogger lagres i <code>/metadata/logs/crash_logs.txt</code>.",
"MessageM4BFailed": "M4B mislykkes!", "MessageM4BFailed": "M4B mislykkes!",
"MessageM4BFinished": "M4B fullført!", "MessageM4BFinished": "M4B fullført!",
"MessageMapChapterTitles": "Bruk kapittel titler fra din eksisterende lydbok kapitler uten å justere tidsstempel", "MessageMapChapterTitles": "Bruk kapittel titler fra din eksisterende lydbok kapitler uten å justere tidsstempel",
@@ -787,6 +791,7 @@
"MessageNoCollections": "Ingen samlinger", "MessageNoCollections": "Ingen samlinger",
"MessageNoCoversFound": "Ingen omslagsbilde funnet", "MessageNoCoversFound": "Ingen omslagsbilde funnet",
"MessageNoDescription": "Ingen beskrivelse", "MessageNoDescription": "Ingen beskrivelse",
"MessageNoDevices": "Ingen enheter",
"MessageNoDownloadsInProgress": "Ingen aktive nedlastinger", "MessageNoDownloadsInProgress": "Ingen aktive nedlastinger",
"MessageNoDownloadsQueued": "Ingen nedlastinger i kø", "MessageNoDownloadsQueued": "Ingen nedlastinger i kø",
"MessageNoEpisodeMatchesFound": "Ingen lik episode funnet", "MessageNoEpisodeMatchesFound": "Ingen lik episode funnet",
@@ -800,6 +805,7 @@
"MessageNoLogs": "Ingen logger", "MessageNoLogs": "Ingen logger",
"MessageNoMediaProgress": "Ingen mediefremgang", "MessageNoMediaProgress": "Ingen mediefremgang",
"MessageNoNotifications": "Ingen varslinger", "MessageNoNotifications": "Ingen varslinger",
"MessageNoPodcastFeed": "Ugyldig podcast: Ingen feed",
"MessageNoPodcastsFound": "Ingen podcaster funnet", "MessageNoPodcastsFound": "Ingen podcaster funnet",
"MessageNoResults": "Ingen resultat", "MessageNoResults": "Ingen resultat",
"MessageNoSearchResultsFor": "Ingen søkeresultat for \"{0}\"", "MessageNoSearchResultsFor": "Ingen søkeresultat for \"{0}\"",
@@ -809,11 +815,17 @@
"MessageNoUpdatesWereNecessary": "Ingen oppdatering var nødvendig", "MessageNoUpdatesWereNecessary": "Ingen oppdatering var nødvendig",
"MessageNoUserPlaylists": "Du har ingen spillelister", "MessageNoUserPlaylists": "Du har ingen spillelister",
"MessageNotYetImplemented": "Ikke implementert ennå", "MessageNotYetImplemented": "Ikke implementert ennå",
"MessageOpmlPreviewNote": "PS: Dette er en forhåndvisning av en OPML-fil. Den faktiske podcast-tittelen hentes direkte fra RSS-feeden.",
"MessageOr": "eller", "MessageOr": "eller",
"MessagePauseChapter": "Pause avspilling av kapittel", "MessagePauseChapter": "Pause avspilling av kapittel",
"MessagePlayChapter": "Lytter på begynnelsen av kapittel", "MessagePlayChapter": "Lytter på begynnelsen av kapittel",
"MessagePlaylistCreateFromCollection": "Lag spilleliste fra samling", "MessagePlaylistCreateFromCollection": "Lag spilleliste fra samling",
"MessagePleaseWait": "Vennligst vent...",
"MessagePodcastHasNoRSSFeedForMatching": "Podcast har ingen RSS feed url til bruk av sammenligning", "MessagePodcastHasNoRSSFeedForMatching": "Podcast har ingen RSS feed url til bruk av sammenligning",
"MessagePodcastSearchField": "Skriv inn søkeord eller RSS-feed URL",
"MessageQuickEmbedInProgress": "Hurtiginnbygging pågår",
"MessageQuickEmbedQueue": "Kø for hurtiginnbygging ({0} i kø)",
"MessageQuickMatchAllEpisodes": "Kjapp matching av alle episoder",
"MessageQuickMatchDescription": "Fyll inn tomme gjenstandsdetaljer og omslagsbilde med første resultat fra '{0}'. Overskriver ikke detaljene utenom 'Foretrekk funnet metadata' tjenerinstilling er aktivert.", "MessageQuickMatchDescription": "Fyll inn tomme gjenstandsdetaljer og omslagsbilde med første resultat fra '{0}'. Overskriver ikke detaljene utenom 'Foretrekk funnet metadata' tjenerinstilling er aktivert.",
"MessageRemoveChapter": "fjerne kapittel", "MessageRemoveChapter": "fjerne kapittel",
"MessageRemoveEpisodes": "fjerne {0} kapitler", "MessageRemoveEpisodes": "fjerne {0} kapitler",
@@ -823,10 +835,29 @@
"MessageResetChaptersConfirm": "Er du sikker på at du vil nullstille kapitler og angre endringene du har gjort?", "MessageResetChaptersConfirm": "Er du sikker på at du vil nullstille kapitler og angre endringene du har gjort?",
"MessageRestoreBackupConfirm": "Er du sikker på at du vil gjenopprette sikkerhetskopien som var laget", "MessageRestoreBackupConfirm": "Er du sikker på at du vil gjenopprette sikkerhetskopien som var laget",
"MessageRestoreBackupWarning": "gjenoppretting av sikkerhetskopi vil overskrive hele databasen under /config og omslagsbilde under /metadata/items og /metadata/authors.<br /><br />Sikkerhetskopier endrer ikke noen filer under dine bibliotekmapper. Hvis du har aktivert tjenerinstillingen for å lagre omslagsbilder og metadata i bibliotekmapper så vil ikke de filene bli tatt sikkerhetskopi eller overskrevet.<br /><br />Alle klientene som bruker din tjener vil bli fornyet automatisk.", "MessageRestoreBackupWarning": "gjenoppretting av sikkerhetskopi vil overskrive hele databasen under /config og omslagsbilde under /metadata/items og /metadata/authors.<br /><br />Sikkerhetskopier endrer ikke noen filer under dine bibliotekmapper. Hvis du har aktivert tjenerinstillingen for å lagre omslagsbilder og metadata i bibliotekmapper så vil ikke de filene bli tatt sikkerhetskopi eller overskrevet.<br /><br />Alle klientene som bruker din tjener vil bli fornyet automatisk.",
"MessageScheduleLibraryScanNote": "For de fleste brukere er det anbefalt å la denne funksjonen være slått av, og la mappeovervåkeren stå på. Mappeovervåkeren oppdager automatisk endringer i biblioteksmappene. Mappeovervåkeren fungerer ikke med alle filsystemer (f.eks. NFS) og da kan planlagt skanning av bibliotekene brukes i steden for.",
"MessageSearchResultsFor": "Søk resultat for", "MessageSearchResultsFor": "Søk resultat for",
"MessageSelected": "{0} valgt",
"MessageServerCouldNotBeReached": "Tjener kunne ikke bli nådd", "MessageServerCouldNotBeReached": "Tjener kunne ikke bli nådd",
"MessageSetChaptersFromTracksDescription": "Sett kapitler ved å bruke hver lydfil som kapittel og kapitteltittel som lydfilnavnet", "MessageSetChaptersFromTracksDescription": "Sett kapitler ved å bruke hver lydfil som kapittel og kapitteltittel som lydfilnavnet",
"MessageShareExpirationWillBe": "Utløp vil være <strong>{0}</strong>",
"MessageShareExpiresIn": "Utløper om {0}",
"MessageShareURLWillBe": "URL for deling blir <strong>{0}</strong>",
"MessageStartPlaybackAtTime": "Start avspilling av \"{0}\" ved {1}?", "MessageStartPlaybackAtTime": "Start avspilling av \"{0}\" ved {1}?",
"MessageTaskAudioFileNotWritable": "Lydfilen \"{0}\" kan ikke skrives til",
"MessageTaskCanceledByUser": "Oppgave kansellert av bruker",
"MessageTaskDownloadingEpisodeDescription": "Laster ned episode \"{0}\"",
"MessageTaskEmbeddingMetadata": "Bygger inn metadata",
"MessageTaskEmbeddingMetadataDescription": "Bygger inn metadata i lydboken \"{0}\"",
"MessageTaskEncodingM4b": "Konverterer til M4B",
"MessageTaskEncodingM4bDescription": "Konverterer lydboken \"{0}\" til én M4B-fil",
"MessageTaskFailed": "Feilet",
"MessageTaskFailedToBackupAudioFile": "Feil ved sikkerhetskopiering av lydfilen \"{0}\"",
"MessageTaskFailedToCreateCacheDirectory": "Kunne ikke opprette mappe for mellomlagring (cache)",
"MessageTaskFailedToEmbedMetadataInFile": "Kunne ikke bygge inn metadata i filen \"{0}\"",
"MessageTaskFailedToMergeAudioFiles": "Kunne ikke slå sammen lydfiler",
"MessageTaskFailedToMoveM4bFile": "Kunne ikke flytte M4B-fil",
"MessageTaskFailedToWriteMetadataFile": "Kunne ikke lagre metadata-fil",
"MessageThinking": "Tenker...", "MessageThinking": "Tenker...",
"MessageUploaderItemFailed": "Opplastning mislykkes", "MessageUploaderItemFailed": "Opplastning mislykkes",
"MessageUploaderItemSuccess": "Opplastning fullført!", "MessageUploaderItemSuccess": "Opplastning fullført!",
@@ -873,7 +904,6 @@
"ToastBookmarkCreateFailed": "Misslykkes å opprette bokmerke", "ToastBookmarkCreateFailed": "Misslykkes å opprette bokmerke",
"ToastBookmarkCreateSuccess": "Bokmerke lagt til", "ToastBookmarkCreateSuccess": "Bokmerke lagt til",
"ToastBookmarkRemoveSuccess": "Bokmerke fjernet", "ToastBookmarkRemoveSuccess": "Bokmerke fjernet",
"ToastBookmarkUpdateSuccess": "Bokmerke oppdatert",
"ToastCachePurgeFailed": "Kunne ikke å slette mellomlager", "ToastCachePurgeFailed": "Kunne ikke å slette mellomlager",
"ToastCachePurgeSuccess": "Mellomlager slettet", "ToastCachePurgeSuccess": "Mellomlager slettet",
"ToastChaptersHaveErrors": "Kapittel har feil", "ToastChaptersHaveErrors": "Kapittel har feil",
+1 -1
View File
@@ -30,6 +30,7 @@
"ButtonEditChapters": "Edytuj rozdziały", "ButtonEditChapters": "Edytuj rozdziały",
"ButtonEditPodcast": "Edytuj podcast", "ButtonEditPodcast": "Edytuj podcast",
"ButtonEnable": "Włącz", "ButtonEnable": "Włącz",
"ButtonFireAndFail": "Fail start",
"ButtonForceReScan": "Wymuś ponowne skanowanie", "ButtonForceReScan": "Wymuś ponowne skanowanie",
"ButtonFullPath": "Pełna ścieżka", "ButtonFullPath": "Pełna ścieżka",
"ButtonHide": "Ukryj", "ButtonHide": "Ukryj",
@@ -770,7 +771,6 @@
"ToastBookmarkCreateFailed": "Nie udało się utworzyć zakładki", "ToastBookmarkCreateFailed": "Nie udało się utworzyć zakładki",
"ToastBookmarkCreateSuccess": "Dodano zakładkę", "ToastBookmarkCreateSuccess": "Dodano zakładkę",
"ToastBookmarkRemoveSuccess": "Zakładka została usunięta", "ToastBookmarkRemoveSuccess": "Zakładka została usunięta",
"ToastBookmarkUpdateSuccess": "Zaktualizowano zakładkę",
"ToastCollectionRemoveSuccess": "Kolekcja usunięta", "ToastCollectionRemoveSuccess": "Kolekcja usunięta",
"ToastCollectionUpdateSuccess": "Zaktualizowano kolekcję", "ToastCollectionUpdateSuccess": "Zaktualizowano kolekcję",
"ToastItemCoverUpdateSuccess": "Zaktualizowano okładkę", "ToastItemCoverUpdateSuccess": "Zaktualizowano okładkę",
-1
View File
@@ -729,7 +729,6 @@
"ToastBookmarkCreateFailed": "Falha ao criar marcador", "ToastBookmarkCreateFailed": "Falha ao criar marcador",
"ToastBookmarkCreateSuccess": "Marcador adicionado", "ToastBookmarkCreateSuccess": "Marcador adicionado",
"ToastBookmarkRemoveSuccess": "Marcador removido", "ToastBookmarkRemoveSuccess": "Marcador removido",
"ToastBookmarkUpdateSuccess": "Marcador atualizado",
"ToastCachePurgeFailed": "Falha ao apagar o cache", "ToastCachePurgeFailed": "Falha ao apagar o cache",
"ToastCachePurgeSuccess": "Cache apagado com sucesso", "ToastCachePurgeSuccess": "Cache apagado com sucesso",
"ToastChaptersHaveErrors": "Capítulos com erro", "ToastChaptersHaveErrors": "Capítulos com erro",
+6 -3
View File
@@ -51,7 +51,7 @@
"ButtonNext": "Следующий", "ButtonNext": "Следующий",
"ButtonNextChapter": "Следующая глава", "ButtonNextChapter": "Следующая глава",
"ButtonNextItemInQueue": "Следующий элемент в очереди", "ButtonNextItemInQueue": "Следующий элемент в очереди",
"ButtonOk": "Ok", "ButtonOk": "Ок",
"ButtonOpenFeed": "Открыть канал", "ButtonOpenFeed": "Открыть канал",
"ButtonOpenManager": "Открыть менеджер", "ButtonOpenManager": "Открыть менеджер",
"ButtonPause": "Пауза", "ButtonPause": "Пауза",
@@ -348,7 +348,7 @@
"LabelFetchingMetadata": "Извлечение метаданных", "LabelFetchingMetadata": "Извлечение метаданных",
"LabelFile": "Файл", "LabelFile": "Файл",
"LabelFileBirthtime": "Дата создания", "LabelFileBirthtime": "Дата создания",
"LabelFileBornDate": "Родился {0}", "LabelFileBornDate": "Создан {0}",
"LabelFileModified": "Дата модификации", "LabelFileModified": "Дата модификации",
"LabelFileModifiedDate": "Изменено {0}", "LabelFileModifiedDate": "Изменено {0}",
"LabelFilename": "Имя файла", "LabelFilename": "Имя файла",
@@ -758,6 +758,7 @@
"MessageConfirmResetProgress": "Вы уверены, что хотите сбросить свой прогресс?", "MessageConfirmResetProgress": "Вы уверены, что хотите сбросить свой прогресс?",
"MessageConfirmSendEbookToDevice": "Вы уверены, что хотите отправить {0} e-книгу \"{1}\" на устройство \"{2}\"?", "MessageConfirmSendEbookToDevice": "Вы уверены, что хотите отправить {0} e-книгу \"{1}\" на устройство \"{2}\"?",
"MessageConfirmUnlinkOpenId": "Вы уверены, что хотите отвязать этого пользователя от OpenID?", "MessageConfirmUnlinkOpenId": "Вы уверены, что хотите отвязать этого пользователя от OpenID?",
"MessageDaysListenedInTheLastYear": "{0} дней прослушивания за последний год",
"MessageDownloadingEpisode": "Эпизод скачивается", "MessageDownloadingEpisode": "Эпизод скачивается",
"MessageDragFilesIntoTrackOrder": "Перетащите файлы для исправления порядка треков", "MessageDragFilesIntoTrackOrder": "Перетащите файлы для исправления порядка треков",
"MessageEmbedFailed": "Вставка не удалась!", "MessageEmbedFailed": "Вставка не удалась!",
@@ -836,6 +837,7 @@
"MessageResetChaptersConfirm": "Вы уверены, что хотите сбросить главы и отменить внесенные изменения?", "MessageResetChaptersConfirm": "Вы уверены, что хотите сбросить главы и отменить внесенные изменения?",
"MessageRestoreBackupConfirm": "Вы уверены, что хотите восстановить резервную копию, созданную", "MessageRestoreBackupConfirm": "Вы уверены, что хотите восстановить резервную копию, созданную",
"MessageRestoreBackupWarning": "Восстановление резервной копии перезапишет всю базу данных, расположенную в /config, и обложки изображений в /metadata/items и /metadata/authors.<br/><br/>Бэкапы не изменяют файлы в папках библиотеки. Если вы включили параметры сервера для хранения обложек и метаданных в папках библиотеки, то они не резервируются и не перезаписываются.<br/><br/>Все клиенты, использующие ваш сервер, будут автоматически обновлены.", "MessageRestoreBackupWarning": "Восстановление резервной копии перезапишет всю базу данных, расположенную в /config, и обложки изображений в /metadata/items и /metadata/authors.<br/><br/>Бэкапы не изменяют файлы в папках библиотеки. Если вы включили параметры сервера для хранения обложек и метаданных в папках библиотеки, то они не резервируются и не перезаписываются.<br/><br/>Все клиенты, использующие ваш сервер, будут автоматически обновлены.",
"MessageScheduleLibraryScanNote": "Большинству пользователей рекомендуется отключить эту функцию и включить функцию просмотра папок. Программа просмотра папок автоматически обнаружит изменения в папках вашей библиотеки. Программа просмотра папок работает не для каждой файловой системы (например, NFS), поэтому вместо этого можно использовать запланированные проверки библиотеки.",
"MessageSearchResultsFor": "Результаты поиска для", "MessageSearchResultsFor": "Результаты поиска для",
"MessageSelected": "{0} выбрано", "MessageSelected": "{0} выбрано",
"MessageServerCouldNotBeReached": "Не удалось связаться с сервером", "MessageServerCouldNotBeReached": "Не удалось связаться с сервером",
@@ -952,7 +954,6 @@
"ToastBookmarkCreateFailed": "Не удалось создать закладку", "ToastBookmarkCreateFailed": "Не удалось создать закладку",
"ToastBookmarkCreateSuccess": "Добавлена закладка", "ToastBookmarkCreateSuccess": "Добавлена закладка",
"ToastBookmarkRemoveSuccess": "Закладка удалена", "ToastBookmarkRemoveSuccess": "Закладка удалена",
"ToastBookmarkUpdateSuccess": "Закладка обновлена",
"ToastCachePurgeFailed": "Не удалось очистить кэш", "ToastCachePurgeFailed": "Не удалось очистить кэш",
"ToastCachePurgeSuccess": "Кэш успешно очищен", "ToastCachePurgeSuccess": "Кэш успешно очищен",
"ToastChaptersHaveErrors": "Главы имеют ошибки", "ToastChaptersHaveErrors": "Главы имеют ошибки",
@@ -963,6 +964,7 @@
"ToastCollectionRemoveSuccess": "Коллекция удалена", "ToastCollectionRemoveSuccess": "Коллекция удалена",
"ToastCollectionUpdateSuccess": "Коллекция обновлена", "ToastCollectionUpdateSuccess": "Коллекция обновлена",
"ToastCoverUpdateFailed": "Не удалось обновить обложку", "ToastCoverUpdateFailed": "Не удалось обновить обложку",
"ToastDateTimeInvalidOrIncomplete": "Дата и время указаны неверно или не до конца",
"ToastDeleteFileFailed": "Не удалось удалить файл", "ToastDeleteFileFailed": "Не удалось удалить файл",
"ToastDeleteFileSuccess": "Файл удален", "ToastDeleteFileSuccess": "Файл удален",
"ToastDeviceAddFailed": "Не удалось добавить устройство", "ToastDeviceAddFailed": "Не удалось добавить устройство",
@@ -1015,6 +1017,7 @@
"ToastNewUserTagError": "Необходимо выбрать хотя бы один тег", "ToastNewUserTagError": "Необходимо выбрать хотя бы один тег",
"ToastNewUserUsernameError": "Введите имя пользователя", "ToastNewUserUsernameError": "Введите имя пользователя",
"ToastNoNewEpisodesFound": "Новых эпизодов не найдено", "ToastNoNewEpisodesFound": "Новых эпизодов не найдено",
"ToastNoRSSFeed": "У подкаста нет RSS-канала",
"ToastNoUpdatesNecessary": "Обновления не требуются", "ToastNoUpdatesNecessary": "Обновления не требуются",
"ToastNotificationCreateFailed": "Не удалось создать уведомление", "ToastNotificationCreateFailed": "Не удалось создать уведомление",
"ToastNotificationDeleteFailed": "Не удалось удалить уведомление", "ToastNotificationDeleteFailed": "Не удалось удалить уведомление",
+5 -2
View File
@@ -463,7 +463,7 @@
"LabelNotificationsMaxQueueSize": "Največja velikost čakalne vrste za dogodke obvestil", "LabelNotificationsMaxQueueSize": "Največja velikost čakalne vrste za dogodke obvestil",
"LabelNotificationsMaxQueueSizeHelp": "Dogodki so omejeni na sprožitev 1 na sekundo. Dogodki bodo prezrti, če je čakalna vrsta najvišja. To preprečuje neželeno pošiljanje obvestil.", "LabelNotificationsMaxQueueSizeHelp": "Dogodki so omejeni na sprožitev 1 na sekundo. Dogodki bodo prezrti, če je čakalna vrsta najvišja. To preprečuje neželeno pošiljanje obvestil.",
"LabelNumberOfBooks": "Število knjig", "LabelNumberOfBooks": "Število knjig",
"LabelNumberOfEpisodes": "število epizod", "LabelNumberOfEpisodes": "# epizod",
"LabelOpenIDAdvancedPermsClaimDescription": "Ime zahtevka OpenID, ki vsebuje napredna dovoljenja za uporabniška dejanja v aplikaciji, ki bodo veljala za neskrbniške vloge (<b>če je konfigurirano</b>). Če trditev manjka v odgovoru, bo dostop do ABS zavrnjen. Če ena možnost manjka, bo obravnavana kot <code>false</code>. Zagotovite, da se zahtevek ponudnika identitete ujema s pričakovano strukturo:", "LabelOpenIDAdvancedPermsClaimDescription": "Ime zahtevka OpenID, ki vsebuje napredna dovoljenja za uporabniška dejanja v aplikaciji, ki bodo veljala za neskrbniške vloge (<b>če je konfigurirano</b>). Če trditev manjka v odgovoru, bo dostop do ABS zavrnjen. Če ena možnost manjka, bo obravnavana kot <code>false</code>. Zagotovite, da se zahtevek ponudnika identitete ujema s pričakovano strukturo:",
"LabelOpenIDClaims": "Pustite naslednje možnosti prazne, da onemogočite napredno dodeljevanje skupin in dovoljenj, nato pa samodejno dodelite skupino 'Uporabnik'.", "LabelOpenIDClaims": "Pustite naslednje možnosti prazne, da onemogočite napredno dodeljevanje skupin in dovoljenj, nato pa samodejno dodelite skupino 'Uporabnik'.",
"LabelOpenIDGroupClaimDescription": "Ime zahtevka OpenID, ki vsebuje seznam uporabnikovih skupin. Običajno imenovane <code>skupine</code>. <b>Če je konfigurirana</b>, bo aplikacija samodejno dodelila vloge na podlagi članstva v skupini uporabnika, pod pogojem, da so te skupine v zahtevku poimenovane 'admin', 'user' ali 'guest' brez razlikovanja med velikimi in malimi črkami. Zahtevek mora vsebovati seznam in če uporabnik pripada več skupinam, mu aplikacija dodeli vlogo, ki ustreza najvišjemu nivoju dostopa. Če se nobena skupina ne ujema, bo dostop zavrnjen.", "LabelOpenIDGroupClaimDescription": "Ime zahtevka OpenID, ki vsebuje seznam uporabnikovih skupin. Običajno imenovane <code>skupine</code>. <b>Če je konfigurirana</b>, bo aplikacija samodejno dodelila vloge na podlagi članstva v skupini uporabnika, pod pogojem, da so te skupine v zahtevku poimenovane 'admin', 'user' ali 'guest' brez razlikovanja med velikimi in malimi črkami. Zahtevek mora vsebovati seznam in če uporabnik pripada več skupinam, mu aplikacija dodeli vlogo, ki ustreza najvišjemu nivoju dostopa. Če se nobena skupina ne ujema, bo dostop zavrnjen.",
@@ -758,6 +758,7 @@
"MessageConfirmResetProgress": "Ali ste prepričani, da želite ponastaviti svoj napredek?", "MessageConfirmResetProgress": "Ali ste prepričani, da želite ponastaviti svoj napredek?",
"MessageConfirmSendEbookToDevice": "Ali ste prepričani, da želite poslati {0} e-knjigo \"{1}\" v napravo \"{2}\"?", "MessageConfirmSendEbookToDevice": "Ali ste prepričani, da želite poslati {0} e-knjigo \"{1}\" v napravo \"{2}\"?",
"MessageConfirmUnlinkOpenId": "Ali ste prepričani, da želite prekiniti povezavo tega uporabnika z OpenID?", "MessageConfirmUnlinkOpenId": "Ali ste prepričani, da želite prekiniti povezavo tega uporabnika z OpenID?",
"MessageDaysListenedInTheLastYear": "{0} dni poslušanja v zadnjem letu",
"MessageDownloadingEpisode": "Prenašam epizodo", "MessageDownloadingEpisode": "Prenašam epizodo",
"MessageDragFilesIntoTrackOrder": "Povlecite datoteke v pravilen vrstni red posnetkov", "MessageDragFilesIntoTrackOrder": "Povlecite datoteke v pravilen vrstni red posnetkov",
"MessageEmbedFailed": "Vdelava ni uspela!", "MessageEmbedFailed": "Vdelava ni uspela!",
@@ -836,6 +837,7 @@
"MessageResetChaptersConfirm": "Ali ste prepričani, da želite ponastaviti poglavja in razveljaviti spremembe, ki ste jih naredili?", "MessageResetChaptersConfirm": "Ali ste prepričani, da želite ponastaviti poglavja in razveljaviti spremembe, ki ste jih naredili?",
"MessageRestoreBackupConfirm": "Ali ste prepričani, da želite obnoviti varnostno kopijo, ustvarjeno ob", "MessageRestoreBackupConfirm": "Ali ste prepričani, da želite obnoviti varnostno kopijo, ustvarjeno ob",
"MessageRestoreBackupWarning": "Obnovitev varnostne kopije bo prepisala celotno zbirko podatkov, ki se nahaja v /config, in zajema slike v /metadata/items in /metadata/authors.<br /><br />Varnostne kopije ne spreminjajo nobenih datotek v mapah vaše knjižnice. Če ste omogočili nastavitve strežnika za shranjevanje naslovnic in metapodatkov v mapah vaše knjižnice, potem ti niso varnostno kopirani ali prepisani.<br /><br />Vsi odjemalci, ki uporabljajo vaš strežnik, bodo samodejno osveženi.", "MessageRestoreBackupWarning": "Obnovitev varnostne kopije bo prepisala celotno zbirko podatkov, ki se nahaja v /config, in zajema slike v /metadata/items in /metadata/authors.<br /><br />Varnostne kopije ne spreminjajo nobenih datotek v mapah vaše knjižnice. Če ste omogočili nastavitve strežnika za shranjevanje naslovnic in metapodatkov v mapah vaše knjižnice, potem ti niso varnostno kopirani ali prepisani.<br /><br />Vsi odjemalci, ki uporabljajo vaš strežnik, bodo samodejno osveženi.",
"MessageScheduleLibraryScanNote": "Za večino uporabnikov je priporočljivo, da to funkcijo pustite onemogočeno in ohranite nastavitev pregledovalnika map omogočeno. Pregledovalnik map bo samodejno zaznal spremembe v mapah vaše knjižnice. Pregledovalnik map ne deluje za vse datotečne sisteme (na primer NFS), zato lahko namesto tega uporabite načrtovane preglede knjižnic.",
"MessageSearchResultsFor": "Rezultati iskanja za", "MessageSearchResultsFor": "Rezultati iskanja za",
"MessageSelected": "{0} izbrano", "MessageSelected": "{0} izbrano",
"MessageServerCouldNotBeReached": "Strežnika ni bilo mogoče doseči", "MessageServerCouldNotBeReached": "Strežnika ni bilo mogoče doseči",
@@ -952,7 +954,6 @@
"ToastBookmarkCreateFailed": "Zaznamka ni bilo mogoče ustvariti", "ToastBookmarkCreateFailed": "Zaznamka ni bilo mogoče ustvariti",
"ToastBookmarkCreateSuccess": "Zaznamek dodan", "ToastBookmarkCreateSuccess": "Zaznamek dodan",
"ToastBookmarkRemoveSuccess": "Zaznamek odstranjen", "ToastBookmarkRemoveSuccess": "Zaznamek odstranjen",
"ToastBookmarkUpdateSuccess": "Zaznamek posodobljen",
"ToastCachePurgeFailed": "Čiščenje predpomnilnika ni uspelo", "ToastCachePurgeFailed": "Čiščenje predpomnilnika ni uspelo",
"ToastCachePurgeSuccess": "Predpomnilnik je bil uspešno očiščen", "ToastCachePurgeSuccess": "Predpomnilnik je bil uspešno očiščen",
"ToastChaptersHaveErrors": "Poglavja imajo napake", "ToastChaptersHaveErrors": "Poglavja imajo napake",
@@ -963,6 +964,7 @@
"ToastCollectionRemoveSuccess": "Zbirka je bila odstranjena", "ToastCollectionRemoveSuccess": "Zbirka je bila odstranjena",
"ToastCollectionUpdateSuccess": "Zbirka je bila posodobljena", "ToastCollectionUpdateSuccess": "Zbirka je bila posodobljena",
"ToastCoverUpdateFailed": "Posodobitev naslovnice ni uspela", "ToastCoverUpdateFailed": "Posodobitev naslovnice ni uspela",
"ToastDateTimeInvalidOrIncomplete": "Datum in čas sta neveljavna ali nepopolna",
"ToastDeleteFileFailed": "Brisanje datoteke ni uspelo", "ToastDeleteFileFailed": "Brisanje datoteke ni uspelo",
"ToastDeleteFileSuccess": "Datoteka je bila izbrisana", "ToastDeleteFileSuccess": "Datoteka je bila izbrisana",
"ToastDeviceAddFailed": "Naprave ni bilo mogoče dodati", "ToastDeviceAddFailed": "Naprave ni bilo mogoče dodati",
@@ -1015,6 +1017,7 @@
"ToastNewUserTagError": "Izbrati morate vsaj eno oznako", "ToastNewUserTagError": "Izbrati morate vsaj eno oznako",
"ToastNewUserUsernameError": "Vnesite uporabniško ime", "ToastNewUserUsernameError": "Vnesite uporabniško ime",
"ToastNoNewEpisodesFound": "Ni novih epizod", "ToastNoNewEpisodesFound": "Ni novih epizod",
"ToastNoRSSFeed": "Podcast nima RSS vira",
"ToastNoUpdatesNecessary": "Posodobitve niso potrebne", "ToastNoUpdatesNecessary": "Posodobitve niso potrebne",
"ToastNotificationCreateFailed": "Obvestila ni bilo mogoče ustvariti", "ToastNotificationCreateFailed": "Obvestila ni bilo mogoče ustvariti",
"ToastNotificationDeleteFailed": "Brisanje obvestila ni uspelo", "ToastNotificationDeleteFailed": "Brisanje obvestila ni uspelo",
+325 -171
View File
@@ -19,6 +19,7 @@
"ButtonChooseFiles": "Välj filer", "ButtonChooseFiles": "Välj filer",
"ButtonClearFilter": "Rensa filter", "ButtonClearFilter": "Rensa filter",
"ButtonCloseFeed": "Stäng flöde", "ButtonCloseFeed": "Stäng flöde",
"ButtonCloseSession": "Stäng öppen session",
"ButtonCollections": "Samlingar", "ButtonCollections": "Samlingar",
"ButtonConfigureScanner": "Konfigurera skanner", "ButtonConfigureScanner": "Konfigurera skanner",
"ButtonCreate": "Skapa", "ButtonCreate": "Skapa",
@@ -28,11 +29,14 @@
"ButtonEdit": "Redigera", "ButtonEdit": "Redigera",
"ButtonEditChapters": "Redigera kapitel", "ButtonEditChapters": "Redigera kapitel",
"ButtonEditPodcast": "Redigera podcast", "ButtonEditPodcast": "Redigera podcast",
"ButtonEnable": "Aktivera",
"ButtonForceReScan": "Tvinga omstart", "ButtonForceReScan": "Tvinga omstart",
"ButtonFullPath": "Fullständig sökväg", "ButtonFullPath": "Fullständig sökväg",
"ButtonHide": "Dölj", "ButtonHide": "Dölj",
"ButtonHome": "Hem", "ButtonHome": "Hem",
"ButtonIssues": "Problem", "ButtonIssues": "Problem",
"ButtonJumpBackward": "Hoppa bakåt",
"ButtonJumpForward": "Hoppa framåt",
"ButtonLatest": "Senaste", "ButtonLatest": "Senaste",
"ButtonLibrary": "Bibliotek", "ButtonLibrary": "Bibliotek",
"ButtonLogout": "Logga ut", "ButtonLogout": "Logga ut",
@@ -44,6 +48,7 @@
"ButtonNevermind": "Glöm det", "ButtonNevermind": "Glöm det",
"ButtonNext": "Nästa", "ButtonNext": "Nästa",
"ButtonNextChapter": "Nästa kapitel", "ButtonNextChapter": "Nästa kapitel",
"ButtonNextItemInQueue": "Nästa objekt i Kö",
"ButtonOk": "Ok", "ButtonOk": "Ok",
"ButtonOpenFeed": "Öppna flöde", "ButtonOpenFeed": "Öppna flöde",
"ButtonOpenManager": "Öppna Manager", "ButtonOpenManager": "Öppna Manager",
@@ -54,8 +59,9 @@
"ButtonPlaylists": "Spellistor", "ButtonPlaylists": "Spellistor",
"ButtonPrevious": "Föregående", "ButtonPrevious": "Föregående",
"ButtonPreviousChapter": "Föregående kapitel", "ButtonPreviousChapter": "Föregående kapitel",
"ButtonProbeAudioFile": "Analysera ljudfil",
"ButtonPurgeAllCache": "Rensa all cache", "ButtonPurgeAllCache": "Rensa all cache",
"ButtonPurgeItemsCache": "Rensa föremåls-cache", "ButtonPurgeItemsCache": "Rensa cache för föremål",
"ButtonQueueAddItem": "Lägg till i kön", "ButtonQueueAddItem": "Lägg till i kön",
"ButtonQueueRemoveItem": "Ta bort från kön", "ButtonQueueRemoveItem": "Ta bort från kön",
"ButtonQuickMatch": "Snabb matchning", "ButtonQuickMatch": "Snabb matchning",
@@ -66,10 +72,10 @@
"ButtonRefresh": "Uppdatera", "ButtonRefresh": "Uppdatera",
"ButtonRemove": "Ta bort", "ButtonRemove": "Ta bort",
"ButtonRemoveAll": "Ta bort alla", "ButtonRemoveAll": "Ta bort alla",
"ButtonRemoveAllLibraryItems": "Ta bort alla biblioteksobjekt", "ButtonRemoveAllLibraryItems": "Ta bort alla objekt i biblioteket",
"ButtonRemoveFromContinueListening": "Ta bort från Fortsätt lyssna", "ButtonRemoveFromContinueListening": "Radera från 'Fortsätt läsa/lyssna'",
"ButtonRemoveFromContinueReading": "Ta bort från Fortsätt läsa", "ButtonRemoveFromContinueReading": "Ta bort från Fortsätt läsa",
"ButtonRemoveSeriesFromContinueSeries": "Ta bort serie från Fortsätt serie", "ButtonRemoveSeriesFromContinueSeries": "Radera från 'Fortsätt med serien'",
"ButtonReset": "Återställ", "ButtonReset": "Återställ",
"ButtonResetToDefault": "Återställ till standard", "ButtonResetToDefault": "Återställ till standard",
"ButtonRestore": "Återställ", "ButtonRestore": "Återställ",
@@ -82,53 +88,60 @@
"ButtonSelectFolderPath": "Välj mappens sökväg", "ButtonSelectFolderPath": "Välj mappens sökväg",
"ButtonSeries": "Serier", "ButtonSeries": "Serier",
"ButtonSetChaptersFromTracks": "Ställ in kapitel från spår", "ButtonSetChaptersFromTracks": "Ställ in kapitel från spår",
"ButtonShare": "Dela",
"ButtonShiftTimes": "Förskjut tider", "ButtonShiftTimes": "Förskjut tider",
"ButtonShow": "Visa", "ButtonShow": "Visa",
"ButtonStartM4BEncode": "Starta M4B-kodning", "ButtonStartM4BEncode": "Starta M4B-kodning",
"ButtonStartMetadataEmbed": "Starta inbäddning av metadata", "ButtonStartMetadataEmbed": "Starta inbäddning av metadata",
"ButtonStats": "Statistik", "ButtonStats": "Statistik",
"ButtonSubmit": "Skicka", "ButtonSubmit": "Spara",
"ButtonTest": "Testa", "ButtonTest": "Testa",
"ButtonUpload": "Ladda upp", "ButtonUpload": "Ladda upp",
"ButtonUploadBackup": "Ladda upp säkerhetskopia", "ButtonUploadBackup": "Ladda upp säkerhetskopia",
"ButtonUploadCover": "Ladda upp omslag", "ButtonUploadCover": "Ladda upp bokomslag",
"ButtonUploadOPMLFile": "Ladda upp OPML-fil", "ButtonUploadOPMLFile": "Ladda upp OPML-fil",
"ButtonUserDelete": "Radera användare {0}", "ButtonUserDelete": "Radera användare {0}",
"ButtonUserEdit": "Redigera användare {0}", "ButtonUserEdit": "Redigera användare {0}",
"ButtonViewAll": "Visa alla", "ButtonViewAll": "Visa alla",
"ButtonYes": "Ja", "ButtonYes": "Ja",
"ErrorUploadFetchMetadataAPI": "Fel vid hämtning av metadata",
"ErrorUploadFetchMetadataNoResults": "Metadata kunde inte hämtas - försök att ändra titel och/eller författare",
"ErrorUploadLacksTitle": "En titel måste anges",
"HeaderAccount": "Konto", "HeaderAccount": "Konto",
"HeaderAddCustomMetadataProvider": "Addera egen källa för metadata",
"HeaderAdvanced": "Avancerad", "HeaderAdvanced": "Avancerad",
"HeaderAppriseNotificationSettings": "Apprise Meddelandeinställningar", "HeaderAppriseNotificationSettings": "Apprise Meddelandeinställningar",
"HeaderAudioTracks": "Ljudspår", "HeaderAudioTracks": "Ljudspår",
"HeaderAudiobookTools": "Ljudbokshantering", "HeaderAudiobookTools": "Hantering av ljudboksfil",
"HeaderAuthentication": "Autentisering",
"HeaderBackups": "Säkerhetskopior", "HeaderBackups": "Säkerhetskopior",
"HeaderChangePassword": "Ändra lösenord", "HeaderChangePassword": "Ändra lösenord",
"HeaderChapters": "Kapitel", "HeaderChapters": "Kapitel",
"HeaderChooseAFolder": "Välj en mapp", "HeaderChooseAFolder": "Välj en mapp",
"HeaderCollection": "Samling", "HeaderCollection": "Samling",
"HeaderCollectionItems": "Samlingselement", "HeaderCollectionItems": "Böcker i samlingen",
"HeaderCover": "Omslag", "HeaderCover": "Bokomslag",
"HeaderCurrentDownloads": "Aktuella nedladdningar", "HeaderCurrentDownloads": "Aktuella nedladdningar",
"HeaderCustomMetadataProviders": "Egen källa för metadata",
"HeaderDetails": "Detaljer", "HeaderDetails": "Detaljer",
"HeaderDownloadQueue": "Nedladdningskö", "HeaderDownloadQueue": "Nedladdningskö",
"HeaderEbookFiles": "E-boksfiler", "HeaderEbookFiles": "E-boksfiler",
"HeaderEmail": "E-post", "HeaderEmail": "E-postadress",
"HeaderEmailSettings": "E-postinställningar", "HeaderEmailSettings": "Inställningar för e-post",
"HeaderEpisodes": "Avsnitt", "HeaderEpisodes": "Avsnitt",
"HeaderEreaderDevices": "E-boksläsarenheter", "HeaderEreaderDevices": "Enheter för att läsa e-böcker",
"HeaderEreaderSettings": "E-boksinställningar", "HeaderEreaderSettings": "E-boksinställningar",
"HeaderFiles": "Filer", "HeaderFiles": "Filer",
"HeaderFindChapters": "Hitta kapitel", "HeaderFindChapters": "Hitta kapitel",
"HeaderIgnoredFiles": "Ignorerade filer", "HeaderIgnoredFiles": "Ignorerade filer",
"HeaderItemFiles": "Föremålsfiler", "HeaderItemFiles": "Föremålsfiler",
"HeaderItemMetadataUtils": "Metadataverktyg för föremål", "HeaderItemMetadataUtils": "Metadataverktyg för föremål",
"HeaderLastListeningSession": "Senaste lyssningssession", "HeaderLastListeningSession": "Senaste lyssningstillfället",
"HeaderLatestEpisodes": "Senaste avsnitt", "HeaderLatestEpisodes": "Senaste avsnitten",
"HeaderLibraries": "Bibliotek", "HeaderLibraries": "Bibliotek",
"HeaderLibraryFiles": "Biblioteksfiler", "HeaderLibraryFiles": "Filer i biblioteket",
"HeaderLibraryStats": "Biblioteksstatistik", "HeaderLibraryStats": "Biblioteksstatistik",
"HeaderListeningSessions": "Lyssningssessioner", "HeaderListeningSessions": "Lyssningstillfällen",
"HeaderListeningStats": "Lyssningsstatistik", "HeaderListeningStats": "Lyssningsstatistik",
"HeaderLogin": "Logga in", "HeaderLogin": "Logga in",
"HeaderLogs": "Loggar", "HeaderLogs": "Loggar",
@@ -136,27 +149,31 @@
"HeaderManageTags": "Hantera taggar", "HeaderManageTags": "Hantera taggar",
"HeaderMapDetails": "Karta detaljer", "HeaderMapDetails": "Karta detaljer",
"HeaderMatch": "Matcha", "HeaderMatch": "Matcha",
"HeaderMetadataOrderOfPrecedence": "Metadataordning av företräde", "HeaderMetadataOrderOfPrecedence": "Prioriteringsordning vid inläsning av metadata",
"HeaderMetadataToEmbed": "Metadata att bädda in", "HeaderMetadataToEmbed": "Metadata att bädda in",
"HeaderNewAccount": "Nytt konto", "HeaderNewAccount": "Nytt konto",
"HeaderNewLibrary": "Nytt bibliotek", "HeaderNewLibrary": "Nytt bibliotek",
"HeaderNotifications": "Meddelanden", "HeaderNotifications": "Meddelanden",
"HeaderOpenRSSFeed": "Öppna RSS-flöde", "HeaderOpenRSSFeed": "Öppna RSS-flöde",
"HeaderOtherFiles": "Andra filer", "HeaderOtherFiles": "Andra filer",
"HeaderPasswordAuthentication": "Lösenordsautentisering",
"HeaderPermissions": "Behörigheter", "HeaderPermissions": "Behörigheter",
"HeaderPlayerQueue": "Spelarkö", "HeaderPlayerQueue": "Spellista",
"HeaderPlayerSettings": "Inställningar för uppspelning",
"HeaderPlaylist": "Spellista", "HeaderPlaylist": "Spellista",
"HeaderPlaylistItems": "Spellistobjekt", "HeaderPlaylistItems": "Böcker i spellistan",
"HeaderPodcastsToAdd": "Podcaster att lägga till", "HeaderPodcastsToAdd": "Podcaster att lägga till",
"HeaderPreviewCover": "Förhandsgranska omslag", "HeaderPreviewCover": "Förhandsgranska bokomslag",
"HeaderRSSFeedGeneral": "RSS-information", "HeaderRSSFeedGeneral": "RSS-information",
"HeaderRSSFeedIsOpen": "RSS-flödet är öppet", "HeaderRSSFeedIsOpen": "RSS-flödet är öppet",
"HeaderRSSFeeds": "RSS-flöden", "HeaderRSSFeeds": "RSS-flöden",
"HeaderRemoveEpisode": "Ta bort avsnitt", "HeaderRemoveEpisode": "Ta bort avsnitt",
"HeaderRemoveEpisodes": "Ta bort {0} avsnitt", "HeaderRemoveEpisodes": "Ta bort {0} avsnitt",
"HeaderSavedMediaProgress": "Sparad medieförlopp", "HeaderSavedMediaProgress": "Sparad historik",
"HeaderSchedule": "Schema", "HeaderSchedule": "Schema",
"HeaderScheduleLibraryScans": "Schemalagda biblioteksskanningar", "HeaderScheduleEpisodeDownloads": "Schemalägg automatiska avsnittsnedladdningar",
"HeaderScheduleLibraryScans": "Schema för skanning av biblioteket",
"HeaderSession": "Session",
"HeaderSetBackupSchedule": "Ange schemaläggning för säkerhetskopia", "HeaderSetBackupSchedule": "Ange schemaläggning för säkerhetskopia",
"HeaderSettings": "Inställningar", "HeaderSettings": "Inställningar",
"HeaderSettingsDisplay": "Visning", "HeaderSettingsDisplay": "Visning",
@@ -164,55 +181,62 @@
"HeaderSettingsGeneral": "Allmänt", "HeaderSettingsGeneral": "Allmänt",
"HeaderSettingsScanner": "Skanner", "HeaderSettingsScanner": "Skanner",
"HeaderSettingsWebClient": "Webklient", "HeaderSettingsWebClient": "Webklient",
"HeaderSleepTimer": "Sovtidtagare", "HeaderSleepTimer": "Timer för att sova",
"HeaderStatsLargestItems": "Största objekt", "HeaderStatsLargestItems": "Största objekten",
"HeaderStatsLongestItems": "Längsta objekt (tim)", "HeaderStatsLongestItems": "Längsta objekten (timmar)",
"HeaderStatsMinutesListeningChart": "Minuters lyssning (senaste 7 dagar)", "HeaderStatsMinutesListeningChart": "Minuters lyssning (senaste 7 dagarna)",
"HeaderStatsRecentSessions": "Senaste sessioner", "HeaderStatsRecentSessions": "Senaste tillfällena",
"HeaderStatsTop10Authors": "10 populäraste författarna", "HeaderStatsTop10Authors": "10 populäraste författarna",
"HeaderStatsTop5Genres": "5 populäraste kategorierna", "HeaderStatsTop5Genres": "5 populäraste kategorierna",
"HeaderTableOfContents": "Innehållsförteckning", "HeaderTableOfContents": "Innehållsförteckning",
"HeaderTools": "Verktyg", "HeaderTools": "Verktyg",
"HeaderUpdateAccount": "Uppdatera konto", "HeaderUpdateAccount": "Uppdatera konto",
"HeaderUpdateAuthor": "Uppdatera författare", "HeaderUpdateAuthor": "Uppdatera författare",
"HeaderUpdateDetails": "Uppdatera detaljer", "HeaderUpdateDetails": "Uppdatera detaljer om boken",
"HeaderUpdateLibrary": "Uppdatera bibliotek", "HeaderUpdateLibrary": "Uppdatera bibliotek",
"HeaderUsers": "Användare", "HeaderUsers": "Användare",
"HeaderYearReview": "Sammanställning för {0}", "HeaderYearReview": "Sammanställning av {0}",
"HeaderYourStats": "Din statistik", "HeaderYourStats": "Din statistik",
"LabelAbridged": "Förkortad", "LabelAbridged": "Förkortad",
"LabelAccessibleBy": "Tillgänglig för",
"LabelAccountType": "Kontotyp", "LabelAccountType": "Kontotyp",
"LabelAccountTypeAdmin": "Administratör",
"LabelAccountTypeGuest": "Gäst", "LabelAccountTypeGuest": "Gäst",
"LabelAccountTypeUser": "Användare", "LabelAccountTypeUser": "Användare",
"LabelActivity": "Aktivitet", "LabelActivity": "Aktivitet",
"LabelAddToCollection": "Lägg till i Samling", "LabelAddToCollection": "Lägg till i en Samling",
"LabelAddToCollectionBatch": "Lägg till {0} böcker i Samlingen", "LabelAddToCollectionBatch": "Lägg till {0} böcker i en Samling",
"LabelAddToPlaylist": "Lägg till i Spellista", "LabelAddToPlaylist": "Lägg till i Spellista",
"LabelAddToPlaylistBatch": "Lägg till {0} objekt i Spellistan", "LabelAddToPlaylistBatch": "Lägg till {0} objekt i Spellistan",
"LabelAddedAt": "Tillagd vid", "LabelAddedAt": "Datum adderad",
"LabelAddedDate": "Adderad {0}",
"LabelAdminUsersOnly": "Endast administratörer", "LabelAdminUsersOnly": "Endast administratörer",
"LabelAll": "Alla", "LabelAll": "Alla",
"LabelAllUsers": "Alla användare", "LabelAllUsers": "Alla användare",
"LabelAllUsersExcludingGuests": "Alla användare utom gäster", "LabelAllUsersExcludingGuests": "Alla användare utom gäster",
"LabelAllUsersIncludingGuests": "Alla användare inklusive gäster", "LabelAllUsersIncludingGuests": "Alla användare inklusive gäster",
"LabelAlreadyInYourLibrary": "Redan i din samling", "LabelAlreadyInYourLibrary": "Redan i din samling",
"LabelApiToken": "API-token",
"LabelAppend": "Lägg till", "LabelAppend": "Lägg till",
"LabelAuthor": "Författare", "LabelAuthor": "Författare",
"LabelAuthorFirstLast": "Författare (Förnamn Efternamn)", "LabelAuthorFirstLast": "Författare (För-, Efternamn)",
"LabelAuthorLastFirst": "Författare (Efternamn, Förnamn)", "LabelAuthorLastFirst": "Författare (Efter-, Förnamn)",
"LabelAuthors": "Författare", "LabelAuthors": "Författare",
"LabelAutoDownloadEpisodes": "Automatisk nedladdning av avsnitt", "LabelAutoDownloadEpisodes": "Automatisk nedladdning av avsnitt",
"LabelAutoFetchMetadata": "Automatisk nedladdning av metadata", "LabelAutoFetchMetadata": "Automatisk nedladdning av metadata",
"LabelAutoFetchMetadataHelp": "Hämtar metadata för titel, författare och serier. Kompletterande metadata får adderas efter uppladdningen.", "LabelAutoFetchMetadataHelp": "Hämtar metadata för titel, författare och serier. Kompletterande metadata kan manuellt adderas efter uppladdningen.",
"LabelAutoRegisterDescription": "Skapa automatiskt nya användare efter inloggning",
"LabelBackToUser": "Tillbaka till användaren", "LabelBackToUser": "Tillbaka till användaren",
"LabelBackupAudioFiles": "Säkerhetskopiera ljudfiler",
"LabelBackupLocation": "Plats för säkerhetskopia", "LabelBackupLocation": "Plats för säkerhetskopia",
"LabelBackupsEnableAutomaticBackups": "Aktivera automatiska säkerhetskopior", "LabelBackupsEnableAutomaticBackups": "Aktivera automatisk säkerhetskopiering",
"LabelBackupsEnableAutomaticBackupsHelp": "Säkerhetskopior sparas i \"/metadata/backups\"", "LabelBackupsEnableAutomaticBackupsHelp": "Säkerhetskopior sparas i \"/metadata/backups\"",
"LabelBackupsMaxBackupSize": "Maximal storlek på säkerhetskopia (i GB) (0 = obegränsad)", "LabelBackupsMaxBackupSize": "Maximal storlek på säkerhetskopia (i GB) (0 = obegränsad)",
"LabelBackupsMaxBackupSizeHelp": "Som ett skydd mot felkonfiguration kommer säkerhetskopior att misslyckas om de överskrider den konfigurerade storleken.", "LabelBackupsMaxBackupSizeHelp": "Som ett skydd mot en felaktig konfiguration kommer säkerhetskopior inte att genomföras om de överskrider den konfigurerade storleken.",
"LabelBackupsNumberToKeep": "Antal säkerhetskopior att behålla", "LabelBackupsNumberToKeep": "Antal säkerhetskopior att behålla",
"LabelBackupsNumberToKeepHelp": "Endast en säkerhetskopia tas bort åt gången, så om du redan har fler säkerhetskopior än detta bör du ta bort dem manuellt.", "LabelBackupsNumberToKeepHelp": "Endast en gammal säkerhetskopia tas bort åt gången, så om du redan har fler säkerhetskopior än det angivna beloppet bör du ta bort dem manuellt.",
"LabelBitrate": "Bitfrekvens", "LabelBitrate": "Bitfrekvens",
"LabelBonus": "Bonus",
"LabelBooks": "Böcker", "LabelBooks": "Böcker",
"LabelButtonText": "Knapptext", "LabelButtonText": "Knapptext",
"LabelByAuthor": "av {0}", "LabelByAuthor": "av {0}",
@@ -223,23 +247,26 @@
"LabelChapters": "Kapitel", "LabelChapters": "Kapitel",
"LabelChaptersFound": "hittade kapitel", "LabelChaptersFound": "hittade kapitel",
"LabelClickForMoreInfo": "Klicka för mer information", "LabelClickForMoreInfo": "Klicka för mer information",
"LabelClickToUseCurrentValue": "Klicka för att använda aktuellt värde",
"LabelClosePlayer": "Stäng spelaren", "LabelClosePlayer": "Stäng spelaren",
"LabelCollapseSeries": "Fäll ihop serie", "LabelCodec": "Codec",
"LabelCollapseSeries": "Komprimera serier",
"LabelCollection": "Samling", "LabelCollection": "Samling",
"LabelCollections": "Samlingar", "LabelCollections": "Samlingar",
"LabelComplete": "Komplett", "LabelComplete": "Komplett",
"LabelConfirmPassword": "Bekräfta lösenord", "LabelConfirmPassword": "Bekräfta lösenord",
"LabelContinueListening": "Fortsätt Lyssna", "LabelContinueListening": "Fortsätt läsa/lyssna",
"LabelContinueReading": "Fortsätt Läsa", "LabelContinueReading": "Fortsätt Läsa",
"LabelContinueSeries": "Fortsätt Serie", "LabelContinueSeries": "Fortsätt med serien",
"LabelCover": "Omslag", "LabelCover": "Bokomslag",
"LabelCoverImageURL": "URL till omslagsbild", "LabelCoverImageURL": "URL till omslagsbild",
"LabelCreatedAt": "Skapad vid", "LabelCreatedAt": "Skapad",
"LabelCronExpression": "Cron-uttryck", "LabelCronExpression": "Schemaläggning med hjälp av Cron (Cron Expression)",
"LabelCurrent": "Nuvarande", "LabelCurrent": "Nuvarande",
"LabelCurrently": "För närvarande:", "LabelCurrently": "För närvarande:",
"LabelCustomCronExpression": "Anpassat Cron-uttryck:", "LabelCustomCronExpression": "Anpassat Cron-uttryck:",
"LabelDatetime": "Datum och tid", "LabelDatetime": "Datum och klockslag",
"LabelDays": "Dagar",
"LabelDeleteFromFileSystemCheckbox": "Ta bort från filsystem (avmarkera för att endast ta bort från databasen)", "LabelDeleteFromFileSystemCheckbox": "Ta bort från filsystem (avmarkera för att endast ta bort från databasen)",
"LabelDescription": "Beskrivning", "LabelDescription": "Beskrivning",
"LabelDeselectAll": "Avmarkera alla", "LabelDeselectAll": "Avmarkera alla",
@@ -252,103 +279,126 @@
"LabelDiscover": "Upptäck", "LabelDiscover": "Upptäck",
"LabelDownload": "Ladda ner", "LabelDownload": "Ladda ner",
"LabelDownloadNEpisodes": "Ladda ner {0} avsnitt", "LabelDownloadNEpisodes": "Ladda ner {0} avsnitt",
"LabelDownloadable": "Nedladdningsbar",
"LabelDuration": "Varaktighet", "LabelDuration": "Varaktighet",
"LabelDurationComparisonExactMatch": "(exakt matchning)",
"LabelDurationFound": "Varaktighet hittad:", "LabelDurationFound": "Varaktighet hittad:",
"LabelEbook": "E-bok", "LabelEbook": "E-bok",
"LabelEbooks": "Eböcker", "LabelEbooks": "E-böcker",
"LabelEdit": "Redigera", "LabelEdit": "Redigera",
"LabelEmail": "E-post", "LabelEmail": "E-postadress",
"LabelEmailSettingsFromAddress": "Från adress", "LabelEmailSettingsFromAddress": "Från e-postadress",
"LabelEmailSettingsRejectUnauthorized": "Avvisa icke-autentiserade certifikat",
"LabelEmailSettingsRejectUnauthorizedHelp": "Inaktivering av SSL-certifikatsvalidering kan exponera din anslutning för säkerhetsrisker, såsom man-in-the-middle-attacker. Inaktivera bara denna inställning om du förstår implikationerna och litar på den epostserver du ansluter till.",
"LabelEmailSettingsSecure": "Säker", "LabelEmailSettingsSecure": "Säker",
"LabelEmailSettingsSecureHelp": "Om sant kommer anslutningen att använda TLS vid anslutning till servern. Om falskt används TLS om servern stöder STARTTLS-tillägget. I de flesta fall, om du ansluter till port 465, bör du ställa in detta värde till sant. För port 587 eller 25, låt det vara falskt. (från nodemailer.com/smtp/#authentication)", "LabelEmailSettingsSecureHelp": "Om sant kommer anslutningen att använda TLS vid anslutning till servern. Om falskt används TLS om servern stöder STARTTLS-tillägget. I de flesta fall, om du ansluter till port 465, bör du ställa in detta värde till sant. För port 587 eller 25, låt det vara falskt. (från nodemailer.com/smtp/#authentication)",
"LabelEmailSettingsTestAddress": "Testadress", "LabelEmailSettingsTestAddress": "E-postadress för test",
"LabelEmbeddedCover": "Inbäddat omslag", "LabelEmbeddedCover": "Inbäddat bokomslag",
"LabelEnable": "Aktivera", "LabelEnable": "Aktivera",
"LabelEncodingBackupLocation": "En säkerhetskopia av dina orginalljudfiler kommer att lagras i:",
"LabelEnd": "Slut", "LabelEnd": "Slut",
"LabelEndOfChapter": "Slut av kapitel", "LabelEndOfChapter": "Slut av kapitel",
"LabelEpisode": "Avsnitt", "LabelEpisode": "Avsnitt",
"LabelEpisodeTitle": "Avsnittsrubrik", "LabelEpisodeTitle": "Avsnittsrubrik",
"LabelEpisodeType": "Avsnittstyp", "LabelEpisodeType": "Avsnittstyp",
"LabelExample": "Exempel", "LabelExample": "Exempel",
"LabelExpandSeries": "Expandera serier",
"LabelFeedURL": "Flödes-URL", "LabelFeedURL": "Flödes-URL",
"LabelFetchingMetadata": "Hämtar metadata",
"LabelFile": "Fil", "LabelFile": "Fil",
"LabelFileBirthtime": "Födelse-tidpunkt för fil", "LabelFileBirthtime": "Tidpunkt, filen skapades",
"LabelFileModified": "Fil ändrad", "LabelFileModified": "Tidpunkt, filen ändrades",
"LabelFileModifiedDate": "Ändrad {0}",
"LabelFilename": "Filnamn", "LabelFilename": "Filnamn",
"LabelFilterByUser": "Filtrera efter användare", "LabelFilterByUser": "Välj användare",
"LabelFindEpisodes": "Hitta avsnitt", "LabelFindEpisodes": "Hitta avsnitt",
"LabelFinished": "Avslutad", "LabelFinished": "Avslutad",
"LabelFolder": "Mapp", "LabelFolder": "Mapp",
"LabelFolders": "Mappar", "LabelFolders": "Mappar",
"LabelFontBold": "Fetstil",
"LabelFontBoldness": "Fetstil", "LabelFontBoldness": "Fetstil",
"LabelFontFamily": "Teckensnittsfamilj", "LabelFontFamily": "Typsnittsfamilj",
"LabelFontScale": "Teckensnittsskala", "LabelFontItalic": "Kursiverad",
"LabelFontScale": "Skala på typsnitt",
"LabelFontStrikethrough": "Genomstruken",
"LabelGenre": "Kategori", "LabelGenre": "Kategori",
"LabelGenres": "Kategorier", "LabelGenres": "Kategorier",
"LabelHardDeleteFile": "Hård radering av fil", "LabelHardDeleteFile": "Hård radering av fil",
"LabelHasEbook": "Har E-bok", "LabelHasEbook": "Har e-bok",
"LabelHasSupplementaryEbook": "Har komplimenterande E-bok", "LabelHasSupplementaryEbook": "Har kompletterande e-bok",
"LabelHideSubtitles": "Dölj underrubriker",
"LabelHighestPriority": "Högst prioritet",
"LabelHost": "Värd", "LabelHost": "Värd",
"LabelHour": "Timme", "LabelHour": "Timme",
"LabelHours": "Timmar",
"LabelIcon": "Ikon", "LabelIcon": "Ikon",
"LabelImageURLFromTheWeb": "Bild-URL från webben", "LabelImageURLFromTheWeb": "Skriv URL-adressen till bilden på webben",
"LabelInProgress": "Pågående", "LabelInProgress": "Pågående",
"LabelIncludeInTracklist": "Inkludera i spårlista", "LabelIncludeInTracklist": "Inkludera i spårlista",
"LabelIncomplete": "Ofullständig", "LabelIncomplete": "Ofullständig",
"LabelInterval": "Intervall", "LabelInterval": "Intervall",
"LabelIntervalCustomDailyWeekly": "Anpassat dagligt/veckovis", "LabelIntervalCustomDailyWeekly": "Anpassad daglig/veckovis",
"LabelIntervalEvery12Hours": "Var 12:e timme", "LabelIntervalEvery12Hours": "Var 12:e timme",
"LabelIntervalEvery15Minutes": "Var 15:e minut", "LabelIntervalEvery15Minutes": "Var 15:e minut",
"LabelIntervalEvery2Hours": "Var 2:e timme", "LabelIntervalEvery2Hours": "Varannan timme",
"LabelIntervalEvery30Minutes": "Var 30:e minut", "LabelIntervalEvery30Minutes": "Var 30:e minut",
"LabelIntervalEvery6Hours": "Var 6:e timme", "LabelIntervalEvery6Hours": "Var 6:e timme",
"LabelIntervalEveryDay": "Varje dag", "LabelIntervalEveryDay": "Varje dag",
"LabelIntervalEveryHour": "Varje timme", "LabelIntervalEveryHour": "Varje timme",
"LabelInvert": "Invertera", "LabelInvert": "Invertera",
"LabelItem": "Objekt", "LabelItem": "Objekt",
"LabelJumpBackwardAmount": "Inställning för \"hopp bakåt\"",
"LabelJumpForwardAmount": "Inställning för \"hopp framåt\"",
"LabelLanguage": "Språk", "LabelLanguage": "Språk",
"LabelLanguageDefaultServer": "Standardspråk för server", "LabelLanguageDefaultServer": "Standardspråk för server",
"LabelLastBookAdded": "Senaste bok tillagd", "LabelLanguages": "Språk",
"LabelLastBookUpdated": "Senaste bok uppdaterad", "LabelLastBookAdded": "Bok senast tillagd",
"LabelLastSeen": "Senast sedd", "LabelLastBookUpdated": "Bok senast uppdaterad",
"LabelLastTime": "Senaste gången", "LabelLastSeen": "Senast inloggad",
"LabelLastUpdate": "Senaste uppdatering", "LabelLastTime": "Senaste tillfället",
"LabelLastUpdate": "Senast uppdaterad",
"LabelLayout": "Layout", "LabelLayout": "Layout",
"LabelLayoutSinglePage": "En sida", "LabelLayoutSinglePage": "En sida",
"LabelLayoutSplitPage": "Dela sida", "LabelLayoutSplitPage": "Uppslag",
"LabelLess": "Mindre", "LabelLess": "Mindre",
"LabelLibrariesAccessibleToUser": "Åtkomliga bibliotek för användare", "LabelLibrariesAccessibleToUser": "Bibliotek användaren har tillgång till",
"LabelLibrary": "Bibliotek", "LabelLibrary": "Bibliotek",
"LabelLibraryItem": "Biblioteksobjekt", "LabelLibraryItem": "Objekt",
"LabelLibraryName": "Biblioteksnamn", "LabelLibraryName": "Biblioteksnamn",
"LabelLimit": "Begränsning", "LabelLimit": "Begränsning",
"LabelLineSpacing": "Radavstånd", "LabelLineSpacing": "Radavstånd",
"LabelListenAgain": "Lyssna igen", "LabelListenAgain": "Läs/Lyssna igen",
"LabelLogLevelDebug": "Felsökningsnivå: Felsökning", "LabelLogLevelDebug": "Felsökning",
"LabelLogLevelInfo": "Felsökningsnivå: Information", "LabelLogLevelInfo": "Information",
"LabelLogLevelWarn": "Felsökningsnivå: Varning", "LabelLogLevelWarn": "Varningar",
"LabelLookForNewEpisodesAfterDate": "Sök efter nya avsnitt efter detta datum", "LabelLookForNewEpisodesAfterDate": "Sök efter nya avsnitt efter detta datum",
"LabelLowestPriority": "Lägst prioritet",
"LabelMediaPlayer": "Mediaspelare", "LabelMediaPlayer": "Mediaspelare",
"LabelMediaType": "Mediatyp", "LabelMediaType": "Mediatyp",
"LabelMetaTag": "Metamärke", "LabelMetaTag": "Metamärke",
"LabelMetaTags": "Metamärken", "LabelMetaTags": "Metamärken",
"LabelMetadataOrderOfPrecedenceDescription": "Källor för metadata med högre prioritet har företräde före källor med lägre prioritet",
"LabelMetadataProvider": "Källa för metadata", "LabelMetadataProvider": "Källa för metadata",
"LabelMinute": "Minut", "LabelMinute": "Minut",
"LabelMissing": "Saknad", "LabelMinutes": "Minuter",
"LabelMissing": "Saknar",
"LabelMissingEbook": "Saknar e-bok",
"LabelMissingSupplementaryEbook": "Saknar kompletterande e-bok",
"LabelMore": "Mer", "LabelMore": "Mer",
"LabelMoreInfo": "Mer information", "LabelMoreInfo": "Mer information",
"LabelName": "Namn", "LabelName": "Namn",
"LabelNarrator": "Uppläsare", "LabelNarrator": "Uppläsare",
"LabelNarrators": "Uppläsare", "LabelNarrators": "Uppläsare",
"LabelNew": "Ny", "LabelNew": "Nytt",
"LabelNewPassword": "Nytt lösenord", "LabelNewPassword": "Nytt lösenord",
"LabelNewestAuthors": "Senast tillagda författare", "LabelNewestAuthors": "Senast adderade författare",
"LabelNewestEpisodes": "Senast tillagda avsnitt", "LabelNewestEpisodes": "Senast tillagda avsnitt",
"LabelNextBackupDate": "Nästa datum för säkerhetskopia", "LabelNextBackupDate": "Nästa datum för säkerhetskopiering",
"LabelNextScheduledRun": "Nästa schemalagda körning", "LabelNextScheduledRun": "Nästa schemalagda körning",
"LabelNoCustomMetadataProviders": "Ingen egen källa för metadata",
"LabelNoEpisodesSelected": "Inga avsnitt valda", "LabelNoEpisodesSelected": "Inga avsnitt valda",
"LabelNotFinished": "Ej avslutad", "LabelNotFinished": "Ej avslutad",
"LabelNotStarted": "Inte påbörjad", "LabelNotStarted": "Ej påbörjad",
"LabelNotes": "Anteckningar", "LabelNotes": "Anteckningar",
"LabelNotificationAppriseURL": "Apprise URL(er)", "LabelNotificationAppriseURL": "Apprise URL(er)",
"LabelNotificationAvailableVariables": "Tillgängliga variabler", "LabelNotificationAvailableVariables": "Tillgängliga variabler",
@@ -363,22 +413,27 @@
"LabelNumberOfEpisodes": "Antal avsnitt", "LabelNumberOfEpisodes": "Antal avsnitt",
"LabelOpenRSSFeed": "Öppna RSS-flöde", "LabelOpenRSSFeed": "Öppna RSS-flöde",
"LabelOverwrite": "Skriv över", "LabelOverwrite": "Skriv över",
"LabelPaginationPageXOfY": "Sida {0} av {1}",
"LabelPassword": "Lösenord", "LabelPassword": "Lösenord",
"LabelPath": "Sökväg", "LabelPath": "Sökväg",
"LabelPermissionsAccessAllLibraries": "Kan komma åt alla bibliotek", "LabelPermissionsAccessAllLibraries": "Kan komma åt alla bibliotek",
"LabelPermissionsAccessAllTags": "Kan komma åt alla taggar", "LabelPermissionsAccessAllTags": "Kan komma åt alla taggar",
"LabelPermissionsAccessExplicitContent": "Kan komma åt explicit innehåll", "LabelPermissionsAccessExplicitContent": "Kan komma åt explicit innehåll",
"LabelPermissionsCreateEreader": "Kan addera e-läsarenhet",
"LabelPermissionsDelete": "Kan radera", "LabelPermissionsDelete": "Kan radera",
"LabelPermissionsDownload": "Kan ladda ner", "LabelPermissionsDownload": "Kan ladda ner",
"LabelPermissionsUpdate": "Kan uppdatera", "LabelPermissionsUpdate": "Kan uppdatera",
"LabelPermissionsUpload": "Kan ladda upp", "LabelPermissionsUpload": "Kan ladda upp",
"LabelPersonalYearReview": "En sammanställning av ditt år, sidan {0}",
"LabelPhotoPathURL": "Bildsökväg/URL", "LabelPhotoPathURL": "Bildsökväg/URL",
"LabelPlayMethod": "Spelläge", "LabelPlayMethod": "Spelläge",
"LabelPlayerChapterNumberMarker": "{0} av {1}",
"LabelPlaylists": "Spellistor", "LabelPlaylists": "Spellistor",
"LabelPodcast": "Podcast", "LabelPodcast": "Podcast",
"LabelPodcastSearchRegion": "Podcast-sökområde", "LabelPodcastSearchRegion": "Podcast-sökområde",
"LabelPodcastType": "Podcasttyp", "LabelPodcastType": "Podcasttyp",
"LabelPodcasts": "Podcasts", "LabelPodcasts": "Podcasts",
"LabelPort": "Port",
"LabelPrefixesToIgnore": "Prefix att ignorera (skiftlägesokänsligt)", "LabelPrefixesToIgnore": "Prefix att ignorera (skiftlägesokänsligt)",
"LabelPreventIndexing": "Förhindra att ditt flöde indexeras av iTunes och Google-podcastsökmotorer", "LabelPreventIndexing": "Förhindra att ditt flöde indexeras av iTunes och Google-podcastsökmotorer",
"LabelPrimaryEbook": "Primär e-bok", "LabelPrimaryEbook": "Primär e-bok",
@@ -386,6 +441,7 @@
"LabelProvider": "Källa", "LabelProvider": "Källa",
"LabelPubDate": "Publiceringsdatum", "LabelPubDate": "Publiceringsdatum",
"LabelPublishYear": "Publiceringsår", "LabelPublishYear": "Publiceringsår",
"LabelPublishedDecade": "Årtionde för publicering",
"LabelPublisher": "Utgivare", "LabelPublisher": "Utgivare",
"LabelRSSFeedCustomOwnerEmail": "Anpassad ägarens e-post", "LabelRSSFeedCustomOwnerEmail": "Anpassad ägarens e-post",
"LabelRSSFeedCustomOwnerName": "Anpassat ägarnamn", "LabelRSSFeedCustomOwnerName": "Anpassat ägarnamn",
@@ -397,15 +453,22 @@
"LabelRead": "Läst", "LabelRead": "Läst",
"LabelReadAgain": "Läs igen", "LabelReadAgain": "Läs igen",
"LabelReadEbookWithoutProgress": "Läs e-bok utan att behålla framsteg", "LabelReadEbookWithoutProgress": "Läs e-bok utan att behålla framsteg",
"LabelRecentSeries": "Senaste serier", "LabelRecentSeries": "Senaste serierna",
"LabelRecentlyAdded": "Nyligen tillagd", "LabelRecentlyAdded": "Nyligen tillagda",
"LabelRecommended": "Rekommenderad", "LabelRecommended": "Rekommenderad",
"LabelRegion": "Region",
"LabelReleaseDate": "Utgivningsdatum", "LabelReleaseDate": "Utgivningsdatum",
"LabelRemoveCover": "Ta bort omslag", "LabelRemoveAllMetadataAbs": "Radera alla 'metadata.abs' filer",
"LabelSearchTerm": "Sökterm", "LabelRemoveAllMetadataJson": "Radera alla 'metadata.json' filer",
"LabelSearchTitle": "Sök titel", "LabelRemoveCover": "Ta bort bokomslag",
"LabelRemoveMetadataFile": "Radera metadata-filer i alla mappar i biblioteket",
"LabelRemoveMetadataFileHelp": "Radera alla 'metadata.json' och 'metadata.abs' filer i dina {0} mappar.",
"LabelRowsPerPage": "Antal rader per sida",
"LabelSearchTerm": "Sökbegrepp",
"LabelSearchTitle": "Titel",
"LabelSearchTitleOrASIN": "Sök titel eller ASIN-kod", "LabelSearchTitleOrASIN": "Sök titel eller ASIN-kod",
"LabelSeason": "Säsong", "LabelSeason": "Säsong",
"LabelSelectAll": "Välj alla",
"LabelSelectAllEpisodes": "Välj alla avsnitt", "LabelSelectAllEpisodes": "Välj alla avsnitt",
"LabelSelectEpisodesShowing": "Välj {0} avsnitt som visas", "LabelSelectEpisodesShowing": "Välj {0} avsnitt som visas",
"LabelSelectUsers": "Välj användare", "LabelSelectUsers": "Välj användare",
@@ -413,62 +476,77 @@
"LabelSequence": "Sekvens", "LabelSequence": "Sekvens",
"LabelSeries": "Serier", "LabelSeries": "Serier",
"LabelSeriesName": "Serienamn", "LabelSeriesName": "Serienamn",
"LabelSeriesProgress": "Serieframsteg", "LabelSeriesProgress": "Status för serier",
"LabelSetEbookAsPrimary": "Ange som primär", "LabelServerLogLevel": "Nivå på loggning",
"LabelServerYearReview": "En sammanställning av ditt bibliotek, sidan {0}",
"LabelSetEbookAsPrimary": "Ange som primär fil",
"LabelSetEbookAsSupplementary": "Ange som kompletterande", "LabelSetEbookAsSupplementary": "Ange som kompletterande",
"LabelSettingsAllowIframe": "Tillåt att Audiobookshelf får visas i en iframe",
"LabelSettingsAudiobooksOnly": "Endast ljudböcker", "LabelSettingsAudiobooksOnly": "Endast ljudböcker",
"LabelSettingsAudiobooksOnlyHelp": "Aktivera detta alternativ kommer att ignorera e-boksfiler om de inte finns inom en ljudboksmapp, i vilket fall de kommer att anges som kompletterande e-böcker", "LabelSettingsAudiobooksOnlyHelp": "När detta alternativ aktiveras kommer filer med e-böcker<br>att ignoreras om de inte lagras i en mapp med en ljudbok.<br>I det fallet kommer de att anges som en kompletterande e-bok",
"LabelSettingsBookshelfViewHelp": "Skeumorfisk design med trähyllor", "LabelSettingsBookshelfViewHelp": "Bakgrund med ett utseende liknande en bokhylla i trä",
"LabelSettingsChromecastSupport": "Stöd för Chromecast", "LabelSettingsChromecastSupport": "Stöd för Chromecast",
"LabelSettingsDateFormat": "Datumformat", "LabelSettingsDateFormat": "Datumformat",
"LabelSettingsDisableWatcher": "Inaktivera Watcher", "LabelSettingsDisableWatcher": "Inaktivera Watcher",
"LabelSettingsDisableWatcherForLibrary": "Inaktivera mappbevakning för bibliotek", "LabelSettingsDisableWatcherForLibrary": "Inaktivera bevakning med Watcher för biblioteket",
"LabelSettingsDisableWatcherHelp": "Inaktiverar automatiskt lägga till/uppdatera objekt när filändringar upptäcks. *Kräver omstart av servern", "LabelSettingsDisableWatcherHelp": "Inaktiverar automatik att addera/uppdatera objekt<br>när ändringar av filer genomförs.<br>OBS: Kräver en omstart av servern",
"LabelSettingsEnableWatcher": "Aktivera Watcher", "LabelSettingsEnableWatcher": "Aktivera Watcher",
"LabelSettingsEnableWatcherForLibrary": "Aktivera mappbevakning för bibliotek", "LabelSettingsEnableWatcherForLibrary": "Aktivera bevakning med Watcher för biblioteket",
"LabelSettingsEnableWatcherHelp": "Aktiverar automatiskt lägga till/uppdatera objekt när filändringar upptäcks. *Kräver omstart av servern", "LabelSettingsEnableWatcherHelp": "Aktiverar automatik att addera/uppdatera objekt<br>när ändringar av filer genomförs.<br>OBS: Kräver en omstart av servern",
"LabelSettingsEpubsAllowScriptedContent": "Tillåt e-böcker i epubs-format som innehåller script",
"LabelSettingsEpubsAllowScriptedContentHelp": "Tillåt att epub-filer får använda script.<br>Det rekommenderas att denna inställning är<br>avstängd när du inte litar på källan för epub-filerna.",
"LabelSettingsExperimentalFeatures": "Experimentella funktioner", "LabelSettingsExperimentalFeatures": "Experimentella funktioner",
"LabelSettingsExperimentalFeaturesHelp": "Funktioner under utveckling som behöver din feedback och hjälp med testning. Klicka för att öppna diskussionen på GitHub.", "LabelSettingsExperimentalFeaturesHelp": "Funktioner under utveckling som behöver din feedback och hjälp med testning. Klicka för att öppna diskussionen på GitHub.",
"LabelSettingsFindCovers": "Hitta omslag", "LabelSettingsFindCovers": "Hitta ett bokomslag",
"LabelSettingsFindCoversHelp": "Om din ljudbok inte har ett inbäddat omslag eller en omslagsbild i mappen kommer skannern att försöka hitta ett omslag.<br>Observera: Detta kommer att förlänga skannningstiden", "LabelSettingsFindCoversHelp": "Om din bok inte har ett bokomslag inbäddat i filen eller<br>en fil med bokomslaget i mappen kommer<br>skannern att försöka hitta ett omslag.<br>OBS: Detta kommer att förlänga inläsningstiden",
"LabelSettingsHideSingleBookSeries": "Dölj serier med en bok", "LabelSettingsHideSingleBookSeries": "Dölj serier som endast innehåller en bok",
"LabelSettingsHideSingleBookSeriesHelp": "Serier som har en enda bok kommer att döljas från seriesidan och hyllsidan på startsidan.", "LabelSettingsHideSingleBookSeriesHelp": "Serier som endast har en bok kommer att<br>döljas från sidan 'Serier' och hyllorna på startsidan.",
"LabelSettingsHomePageBookshelfView": "Startsida använd bokhyllvy", "LabelSettingsHomePageBookshelfView": "Använd vy liknande en bokhylla på startsidan",
"LabelSettingsLibraryBookshelfView": "Bibliotek använd bokhyllvy", "LabelSettingsLibraryBookshelfView": "Använd vy liknande en bokhylla i biblioteket",
"LabelSettingsParseSubtitles": "Analysera undertexter", "LabelSettingsLibraryMarkAsFinishedPercentComplete": "Procent genomfört är större än",
"LabelSettingsParseSubtitlesHelp": "Extrahera undertitlar från namnet på mappar för ljudböcker.<br>Undertiteln måste vara åtskilda med ett bindestreck \" - \".<br>Mappen \"Boktitel - En undertitel här\" har undertiteln \"En undertitel här\"", "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Återstående tid är mindre än (sekunder)",
"LabelSettingsPreferMatchedMetadata": "Föredra matchad metadata", "LabelSettingsLibraryMarkAsFinishedWhen": "Markera objekt som avslutade när",
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Hoppa över tidigare böcker i en serie",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Sektionen 'Fortsätt med serien' på startsidan visar \"nästa bok\" i serien,<br>där åtminstone en bok avslutats, och ingen bok i serien har påbörjats.<br>Om detta alternativ aktiveras kommer efterföljande bok till den<br>avslutade att föreslås - istället för den första ej avslutade boken i serien.",
"LabelSettingsParseSubtitles": "Hämta undertitel från bokens mapp",
"LabelSettingsParseSubtitlesHelp": "Hämtar undertiteln från namnet på mappen där boken lagras.<br>Undertiteln måste vara åtskilda med ett bindestreck ' - '.<br>En mapp med namnet 'Boktitel - Bokens undertitel'<br> får undertiteln \"Bokens undertitel\"",
"LabelSettingsPreferMatchedMetadata": "Prioritera matchad metadata",
"LabelSettingsPreferMatchedMetadataHelp": "Matchad data kommer att åsidosätta objektdetaljer vid snabbmatchning. Som standard kommer snabbmatchning endast att fylla i saknade detaljer.", "LabelSettingsPreferMatchedMetadataHelp": "Matchad data kommer att åsidosätta objektdetaljer vid snabbmatchning. Som standard kommer snabbmatchning endast att fylla i saknade detaljer.",
"LabelSettingsSkipMatchingBooksWithASIN": "Hoppa över matchande böcker med ASIN-kod", "LabelSettingsSkipMatchingBooksWithASIN": "Hoppa över matchande böcker som har en ASIN-kod",
"LabelSettingsSkipMatchingBooksWithISBN": "Hoppa över matchande böcker med ISBN", "LabelSettingsSkipMatchingBooksWithISBN": "Hoppa över matchande böcker som har en ISBN-kod",
"LabelSettingsSortingIgnorePrefixes": "Ignorera prefix vid sortering", "LabelSettingsSortingIgnorePrefixes": "Ignorera prefix vid sortering",
"LabelSettingsSortingIgnorePrefixesHelp": "För prefix som t.ex. \"the\" kommer boktiteln \"The Book Title\" att sorteras som \"Book Title, The\"", "LabelSettingsSortingIgnorePrefixesHelp": "För prefix som t.ex. \"the\" kommer boktiteln \"The Book Title\" att sorteras som \"Book Title, The\"",
"LabelSettingsSquareBookCovers": "Använd fyrkantiga bokomslag", "LabelSettingsSquareBookCovers": "Använd kvadratiska bokomslag",
"LabelSettingsSquareBookCoversHelp": "Föredrar att använda fyrkantiga omslag över standard 1.6:1 bokomslag", "LabelSettingsSquareBookCoversHelp": "Föredrar att använda kvadratiska bokomslag<br>före standardformatet 1.6:1",
"LabelSettingsStoreCoversWithItem": "Lagra omslag med objekt", "LabelSettingsStoreCoversWithItem": "Lagra bokomslag med objektet",
"LabelSettingsStoreCoversWithItemHelp": "Som standard lagras bokomslag i mappen /metadata/items. Genom att aktivera detta alternativ kommer omslagen att lagra i din biblioteksmapp. Endast en fil med namnet \"cover\" kommer att behållas", "LabelSettingsStoreCoversWithItemHelp": "Som standard lagras bokomslag i mappen '/metadata/items'.<br>Genom att aktivera detta alternativ kommer<br>omslagen att lagra i din biblioteksmapp.<br>Endast en fil med namnet 'cover' kommer att behållas",
"LabelSettingsStoreMetadataWithItem": "Lagra metadata med objekt", "LabelSettingsStoreMetadataWithItem": "Lagra metadata med objektet",
"LabelSettingsStoreMetadataWithItemHelp": "Som standard lagras metadatafiler i mappen /metadata/items. Genom att aktivera detta alternativ kommer metadatafilerna att lagras i dina biblioteksmappar", "LabelSettingsStoreMetadataWithItemHelp": "Som standard lagras metadatafiler i mappen '/metadata/items'. Genom att aktivera detta alternativ kommer metadatafilerna att lagra i din biblioteksmapp",
"LabelSettingsTimeFormat": "Tidsformat", "LabelSettingsTimeFormat": "Tidsformat",
"LabelShare": "Dela",
"LabelShowAll": "Visa alla", "LabelShowAll": "Visa alla",
"LabelShowSeconds": "Visa sekunder",
"LabelShowSubtitles": "Visa underrubriker",
"LabelSize": "Storlek", "LabelSize": "Storlek",
"LabelSleepTimer": "Sleeptimer", "LabelSleepTimer": "Timer för sova",
"LabelSortAscending": "Stigande",
"LabelSortDescending": "Fallande",
"LabelStart": "Starta", "LabelStart": "Starta",
"LabelStartTime": "Starttid", "LabelStartTime": "Starttid",
"LabelStarted": "Startad", "LabelStarted": "Startad",
"LabelStartedAt": "Startad vid", "LabelStartedAt": "Startades",
"LabelStatsAudioTracks": "Ljudspår", "LabelStatsAudioTracks": "Ljudspår",
"LabelStatsAuthors": "Författare", "LabelStatsAuthors": "Författare",
"LabelStatsBestDay": "Bästa dag", "LabelStatsBestDay": "Bästa dag",
"LabelStatsDailyAverage": "Dagligt genomsnitt", "LabelStatsDailyAverage": "Dagligt genomsnitt",
"LabelStatsDays": "Dagar", "LabelStatsDays": "Dagar",
"LabelStatsDaysListened": "Dagar lyssnade", "LabelStatsDaysListened": "dagars lyssnande",
"LabelStatsHours": "Timmar", "LabelStatsHours": "Timmar",
"LabelStatsInARow": "i rad", "LabelStatsInARow": "i rad",
"LabelStatsItemsFinished": "Objekt avslutade", "LabelStatsItemsFinished": "böcker avslutade",
"LabelStatsItemsInLibrary": "Objekt i biblioteket", "LabelStatsItemsInLibrary": "Objekt i biblioteket",
"LabelStatsMinutes": "minuter", "LabelStatsMinutes": "minuter",
"LabelStatsMinutesListening": "Minuter av lyssnande", "LabelStatsMinutesListening": "minuters lyssnande",
"LabelStatsOverallDays": "Totalt antal dagar", "LabelStatsOverallDays": "Totalt antal dagar",
"LabelStatsOverallHours": "Totalt antal timmar", "LabelStatsOverallHours": "Totalt antal timmar",
"LabelStatsWeekListening": "Veckans lyssnande", "LabelStatsWeekListening": "Veckans lyssnande",
@@ -476,10 +554,12 @@
"LabelSupportedFileTypes": "Filtyper som accepteras", "LabelSupportedFileTypes": "Filtyper som accepteras",
"LabelTag": "Tagg", "LabelTag": "Tagg",
"LabelTags": "Taggar", "LabelTags": "Taggar",
"LabelTagsAccessibleToUser": "Taggar tillgängliga för användaren", "LabelTagsAccessibleToUser": "Taggar användaren har tillgång till",
"LabelTagsNotAccessibleToUser": "Taggar inte tillgängliga för användaren", "LabelTagsNotAccessibleToUser": "Taggar inte tillgängliga för användaren",
"LabelTasks": "Körande uppgifter", "LabelTasks": "Körande uppgifter",
"LabelTheme": "Tema", "LabelTextEditorBulletedList": "Punktlista",
"LabelTextEditorNumberedList": "Numrerad lista",
"LabelTheme": "Utseende",
"LabelThemeDark": "Mörkt", "LabelThemeDark": "Mörkt",
"LabelThemeLight": "Ljust", "LabelThemeLight": "Ljust",
"LabelTimeBase": "Tidsbas", "LabelTimeBase": "Tidsbas",
@@ -496,7 +576,7 @@
"LabelToolsEmbedMetadata": "Bädda in metadata", "LabelToolsEmbedMetadata": "Bädda in metadata",
"LabelToolsEmbedMetadataDescription": "Bädda in metadata i ljudfiler, inklusive omslagsbild och kapitel.", "LabelToolsEmbedMetadataDescription": "Bädda in metadata i ljudfiler, inklusive omslagsbild och kapitel.",
"LabelToolsMakeM4b": "Skapa M4B ljudbok", "LabelToolsMakeM4b": "Skapa M4B ljudbok",
"LabelToolsMakeM4bDescription": "Skapa en .M4B ljudboksfil med inbäddad metadata, omslagsbild och kapitel.", "LabelToolsMakeM4bDescription": "Skapa en ljudboksfil i M4B-format med inbäddad metadata, omslagsbild och kapitel.",
"LabelToolsSplitM4b": "Dela upp M4B-fil i MP3-filer", "LabelToolsSplitM4b": "Dela upp M4B-fil i MP3-filer",
"LabelToolsSplitM4bDescription": "Skapa MP3-filer från en M4B-fil uppdelad i kapitel med inbäddad metadata, omslagsbild och kapitel.", "LabelToolsSplitM4bDescription": "Skapa MP3-filer från en M4B-fil uppdelad i kapitel med inbäddad metadata, omslagsbild och kapitel.",
"LabelTotalDuration": "Total varaktighet", "LabelTotalDuration": "Total varaktighet",
@@ -510,78 +590,100 @@
"LabelTrailer": "Trailer", "LabelTrailer": "Trailer",
"LabelType": "Typ", "LabelType": "Typ",
"LabelUnabridged": "Oavkortad", "LabelUnabridged": "Oavkortad",
"LabelUndo": "Ångra",
"LabelUnknown": "Okänd", "LabelUnknown": "Okänd",
"LabelUpdateCover": "Uppdatera omslag", "LabelUpdateCover": "Uppdatera bokomslag",
"LabelUpdateCoverHelp": "Tillåt överskrivning av befintliga omslag för de valda böckerna när en matchning hittas", "LabelUpdateCoverHelp": "Tillåt att befintliga bokomslag för de valda böckerna ersätts när en matchning hittas",
"LabelUpdateDetails": "Uppdatera detaljer", "LabelUpdateDetails": "Uppdatera detaljer",
"LabelUpdateDetailsHelp": "Tillåt överskrivning av befintliga detaljer för de valda böckerna när en matchning hittas", "LabelUpdateDetailsHelp": "Tillåt att befintliga detaljer för de valda böckerna ersätts när en matchning hittas",
"LabelUpdatedAt": "Uppdaterad vid", "LabelUpdatedAt": "Uppdaterades",
"LabelUploaderDragAndDrop": "Dra och släpp filer eller mappar", "LabelUploaderDragAndDrop": "Dra och släpp filer eller mappar",
"LabelUploaderDragAndDropFilesOnly": "Dra & släpp filer",
"LabelUploaderDropFiles": "Släpp filer", "LabelUploaderDropFiles": "Släpp filer",
"LabelUploaderItemFetchMetadataHelp": "Hämtar automatiskt titel, författare och serier.", "LabelUploaderItemFetchMetadataHelp": "Hämtar automatiskt titel, författare och serier",
"LabelUseAdvancedOptions": "Använd avancerade inställningar",
"LabelUseChapterTrack": "Använd kapitelspår", "LabelUseChapterTrack": "Använd kapitelspår",
"LabelUseFullTrack": "Använd hela spåret", "LabelUseFullTrack": "Använd hela spåret",
"LabelUseZeroForUnlimited": "0 = Obegränsad",
"LabelUser": "Användare", "LabelUser": "Användare",
"LabelUsername": "Användarnamn", "LabelUsername": "Användarnamn",
"LabelValue": "Värde", "LabelValue": "Värde",
"LabelVersion": "Version", "LabelVersion": "Version",
"LabelViewBookmarks": "Visa bokmärken", "LabelViewBookmarks": "Bokmärken",
"LabelViewChapters": "Visa kapitel", "LabelViewChapters": "Visa kapitel",
"LabelViewPlayerSettings": "Visa inställningar för uppspelning",
"LabelViewQueue": "Visa spellista", "LabelViewQueue": "Visa spellista",
"LabelVolume": "Volym", "LabelVolume": "Volym",
"LabelWeekdaysToRun": "Vardagar att köra", "LabelWeekdaysToRun": "Veckodagar att köra skanning",
"LabelYearReviewHide": "Dölj sammanställning för året", "LabelXBooks": "{0} böcker",
"LabelYearReviewShow": "Visa sammanställning för året", "LabelXItems": "{0} objekt",
"LabelYearReviewHide": "Dölj årets sammanställning",
"LabelYearReviewShow": "Visa årets sammanställning",
"LabelYourAudiobookDuration": "Din ljudboks varaktighet", "LabelYourAudiobookDuration": "Din ljudboks varaktighet",
"LabelYourBookmarks": "Dina bokmärken", "LabelYourBookmarks": "Dina bokmärken",
"LabelYourPlaylists": "Dina spellistor", "LabelYourPlaylists": "Dina spellistor",
"LabelYourProgress": "Din framsteg", "LabelYourProgress": "Framsteg",
"MessageAddToPlayerQueue": "Lägg till i spellistan", "MessageAddToPlayerQueue": "Lägg till i spellistan",
"MessageAppriseDescription": "För att använda den här funktionen behöver du ha en instans av <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> igång eller en API som hanterar dessa begäranden. <br />Apprise API-urlen bör vara hela URL-sökvägen för att skicka meddelandet, t.ex., om din API-instans är tillgänglig på <code>http://192.168.1.1:8337</code>, bör du ange <code>http://192.168.1.1:8337/notify</code>.", "MessageAppriseDescription": "För att använda den här funktionen behöver du ha en instans av <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> igång eller en API som hanterar dessa begäranden. <br />Apprise API-urlen bör vara hela URL-sökvägen för att skicka meddelandet, t.ex., om din API-instans är tillgänglig på <code>http://192.168.1.1:8337</code>, bör du ange <code>http://192.168.1.1:8337/notify</code>.",
"MessageBackupsDescription": "Säkerhetskopieringar inkluderar användare, användares framsteg, biblioteksföremål, serverinställningar och bilder lagrade i <code>/metadata/items</code> & <code>/metadata/authors</code>. Säkerhetskopieringar inkluderar <strong>inte</strong> några filer lagrade i dina biblioteksmappar.", "MessageBackupsDescription": "Säkerhetskopior inkluderar användare, användarnas framsteg, biblioteksobjekt, serverinställningar<br>och bilder lagrade i <code>/metadata/items</code> & <code>/metadata/authors</code>.<br>De inkluderar <strong>INTE</strong> några filer lagrade i dina biblioteksmappar.",
"MessageBackupsLocationEditNote": "OBS: När du ändrar plats för säkerhetskopiorna så flyttas INTE gamla säkerhetskopior dit",
"MessageBackupsLocationNoEditNote": "OBS: Platsen där säkerhetskopiorna lagras bestäms av en central inställning och kan inte ändras här.",
"MessageBackupsLocationPathEmpty": "Uppgiften om platsen för lagring av säkerhetskopior kan inte lämnas tom",
"MessageBatchQuickMatchDescription": "Quick Match kommer försöka lägga till saknade omslag och metadata för de valda föremålen. Aktivera alternativen nedan för att tillåta Quick Match att överskriva befintliga omslag och/eller metadata.", "MessageBatchQuickMatchDescription": "Quick Match kommer försöka lägga till saknade omslag och metadata för de valda föremålen. Aktivera alternativen nedan för att tillåta Quick Match att överskriva befintliga omslag och/eller metadata.",
"MessageBookshelfNoCollections": "Du har ännu inte skapat några samlingar", "MessageBookshelfNoCollections": "Du har ännu inte skapat några samlingar",
"MessageBookshelfNoRSSFeeds": "Inga RSS-flöden är öppna", "MessageBookshelfNoRSSFeeds": "Inga RSS-flöden är öppna",
"MessageBookshelfNoResultsForFilter": "Inga resultat för filter \"{0}: {1}\"", "MessageBookshelfNoResultsForFilter": "Inga resultat för filter \"{0}: {1}\"",
"MessageBookshelfNoResultsForQuery": "Sökningen gav inget resultat",
"MessageBookshelfNoSeries": "Du har inga serier", "MessageBookshelfNoSeries": "Du har inga serier",
"MessageChapterEndIsAfter": "Kapitelns slut är efter din ljudboks slut", "MessageChapterEndIsAfter": "Kapitelns slut är efter din ljudboks slut",
"MessageChapterErrorFirstNotZero": "Första kapitlet måste börja vid 0", "MessageChapterErrorFirstNotZero": "Första kapitlet måste börja vid 0",
"MessageChapterErrorStartGteDuration": "Ogiltig starttid måste vara mindre än ljudbokens varaktighet", "MessageChapterErrorStartGteDuration": "Ogiltig starttid, måste vara mindre än ljudbokens varaktighet",
"MessageChapterErrorStartLtPrev": "Ogiltig starttid måste vara större än eller lika med tidigare kapitels starttid", "MessageChapterErrorStartLtPrev": "Ogiltig starttid, måste vara större än eller lika med föregående kapitlets starttid",
"MessageChapterStartIsAfter": "Kapitlets start är efter din ljudboks slut", "MessageChapterStartIsAfter": "Kapitlets start är efter din ljudboks slut",
"MessageCheckingCron": "Kontrollerar cron...", "MessageCheckingCron": "Kontrollerar cron...",
"MessageConfirmCloseFeed": "Är du säker på att du vill stänga detta flöde?", "MessageConfirmCloseFeed": "Är du säker på att du vill stänga detta flöde?",
"MessageConfirmDeleteBackup": "Är du säker på att du vill radera säkerhetskopian för {0}?", "MessageConfirmDeleteBackup": "Är du säker på att du vill radera säkerhetskopian för {0}?",
"MessageConfirmDeleteDevice": "Är du säkert på att du vill radera enheten för e-böcker \"{0}\"?",
"MessageConfirmDeleteFile": "Detta kommer att radera filen från ditt filsystem. Är du säker?", "MessageConfirmDeleteFile": "Detta kommer att radera filen från ditt filsystem. Är du säker?",
"MessageConfirmDeleteLibrary": "Är du säker på att du vill radera biblioteket \"{0}\"?", "MessageConfirmDeleteLibrary": "Är du säker på att du vill radera biblioteket '{0}'?",
"MessageConfirmDeleteLibraryItem": "Detta kommer att radera biblioteksföremålet från databasen och ditt filsystem. Är du säker?", "MessageConfirmDeleteLibraryItem": "Detta kommer att radera biblioteksobjektet från databasen och ditt filsystem. Är du säker?",
"MessageConfirmDeleteLibraryItems": "Detta kommer att radera {0} biblioteksföremål från databasen och ditt filsystem. Är du säker?", "MessageConfirmDeleteLibraryItems": "Detta kommer att radera {0} biblioteksobjekt från databasen och ditt filsystem. Är du säker?",
"MessageConfirmDeleteSession": "Är du säker på att du vill radera denna session?", "MessageConfirmDeleteMetadataProvider": "Är du säker på att du vill radera din egen källa för metadata \"{0}\"?",
"MessageConfirmDeleteSession": "Är du säker på att du vill radera detta lyssningstillfälle?",
"MessageConfirmForceReScan": "Är du säker på att du vill tvinga omgenomsökning?", "MessageConfirmForceReScan": "Är du säker på att du vill tvinga omgenomsökning?",
"MessageConfirmMarkAllEpisodesFinished": "Är du säker på att du vill markera alla avsnitt som avslutade?", "MessageConfirmMarkAllEpisodesFinished": "Är du säker på att du vill markera alla avsnitt som avslutade?",
"MessageConfirmMarkAllEpisodesNotFinished": "Är du säker på att du vill markera alla avsnitt som inte avslutade?", "MessageConfirmMarkAllEpisodesNotFinished": "Är du säker på att du vill markera alla avsnitt som ej avslutade?",
"MessageConfirmMarkItemFinished": "Är du säker på att du vill markera \"{0}\" som avslutad?",
"MessageConfirmMarkItemNotFinished": "Är du säker på att du vill markera \"{0}\" som ej avslutad?",
"MessageConfirmMarkSeriesFinished": "Är du säker på att du vill markera alla böcker i denna serie som avslutade?", "MessageConfirmMarkSeriesFinished": "Är du säker på att du vill markera alla böcker i denna serie som avslutade?",
"MessageConfirmMarkSeriesNotFinished": "Är du säker på att du vill markera alla böcker i denna serie som ej avslutade?", "MessageConfirmMarkSeriesNotFinished": "Är du säker på att du vill markera alla böcker i denna serie som ej avslutade?",
"MessageConfirmPurgeCache": "När du rensar cashen kommer katalogen <code>/metadata/cache</code> att raderas. <br /><br />Är du säker på att du vill radera katalogen?",
"MessageConfirmPurgeItemsCache": "När du rensar cashen för föremål kommer katalogen <code>/metadata/cache/items</code> att raderas. <br /><br />Är du säker på att du vill radera katalogen?",
"MessageConfirmQuickEmbed": "VARNING! Quick embed kommer inte att säkerhetskopiera dina ljudfiler. Se till att du har en säkerhetskopia av dina ljudfiler. <br><br>Vill du fortsätta?", "MessageConfirmQuickEmbed": "VARNING! Quick embed kommer inte att säkerhetskopiera dina ljudfiler. Se till att du har en säkerhetskopia av dina ljudfiler. <br><br>Vill du fortsätta?",
"MessageConfirmReScanLibraryItems": "Är du säker på att du vill göra omgenomsökning för {0} objekt?", "MessageConfirmReScanLibraryItems": "Är du säker på att du vill göra en ny genomsökning för {0} objekt?",
"MessageConfirmRemoveAllChapters": "Är du säker på att du vill ta bort alla kapitel?", "MessageConfirmRemoveAllChapters": "Är du säker på att du vill ta bort alla kapitel?",
"MessageConfirmRemoveAuthor": "Är du säker på att du vill ta bort författaren \"{0}\"?", "MessageConfirmRemoveAuthor": "Är du säker på att du vill ta bort författaren \"{0}\"?",
"MessageConfirmRemoveCollection": "Är du säker på att du vill ta bort samlingen \"{0}\"?", "MessageConfirmRemoveCollection": "Är du säker på att du vill ta bort samlingen \"{0}\"?",
"MessageConfirmRemoveEpisode": "Är du säker på att du vill ta bort avsnittet \"{0}\"?", "MessageConfirmRemoveEpisode": "Är du säker på att du vill ta bort avsnittet \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Är du säker på att du vill ta bort {0} avsnitt?", "MessageConfirmRemoveEpisodes": "Är du säker på att du vill ta bort {0} avsnitt?",
"MessageConfirmRemoveListeningSessions": "Är du säker på att du vill radera {0} lyssningstillfällen?",
"MessageConfirmRemoveMetadataFiles": "Är du säker på att du vill radera 'metadata.{0}' filerna i alla mappar i ditt bibliotek?",
"MessageConfirmRemoveNarrator": "Är du säker på att du vill ta bort uppläsaren \"{0}\"?", "MessageConfirmRemoveNarrator": "Är du säker på att du vill ta bort uppläsaren \"{0}\"?",
"MessageConfirmRemovePlaylist": "Är du säker på att du vill ta bort din spellista \"{0}\"?", "MessageConfirmRemovePlaylist": "Är du säker på att du vill ta bort din spellista \"{0}\"?",
"MessageConfirmRenameGenre": "Är du säker på att du vill byta namn på kategori \"{0}\" till \"{1}\" för alla objekt?", "MessageConfirmRenameGenre": "Är du säker på att du vill byta namn på kategorin \"{0}\" till \"{1}\" för alla objekt?",
"MessageConfirmRenameGenreMergeNote": "OBS: Den här kategorin finns redan, så de kommer att slås samman.", "MessageConfirmRenameGenreMergeNote": "OBS: Den här kategorin finns redan, så de kommer att slås samman.",
"MessageConfirmRenameGenreWarning": "Varning! En liknande kategori med annat skrivsätt finns redan \"{0}\".", "MessageConfirmRenameGenreWarning": "Varning! En liknande kategori med annat skrivsätt finns redan \"{0}\".",
"MessageConfirmRenameTag": "Är du säker på att du vill byta namn på taggen \"{0}\" till \"{1}\" för alla objekt?", "MessageConfirmRenameTag": "Är du säker på att du vill byta namn på taggen \"{0}\" till \"{1}\" för alla objekt?",
"MessageConfirmRenameTagMergeNote": "OBS: Den här taggen finns redan, så de kommer att slås samman.", "MessageConfirmRenameTagMergeNote": "OBS: Den här taggen finns redan, så de kommer att slås samman.",
"MessageConfirmRenameTagWarning": "VARNING! En liknande tagg med annat skrivsätt finns redan \"{0}\".", "MessageConfirmRenameTagWarning": "VARNING! En liknande tagg med annat skrivsätt finns redan \"{0}\".",
"MessageConfirmResetProgress": "Är du säker på att du vill nollställa ditt framsteg?",
"MessageConfirmSendEbookToDevice": "Är du säker på att du vill skicka {0} e-bok \"{1}\" till enheten \"{2}\"?", "MessageConfirmSendEbookToDevice": "Är du säker på att du vill skicka {0} e-bok \"{1}\" till enheten \"{2}\"?",
"MessageDaysListenedInTheLastYear": "{0} dagars lyssnande det senaste året",
"MessageDownloadingEpisode": "Laddar ner avsnitt", "MessageDownloadingEpisode": "Laddar ner avsnitt",
"MessageDragFilesIntoTrackOrder": "Dra filer till rätt spårordning", "MessageDragFilesIntoTrackOrder": "Dra filer till rätt spårordning",
"MessageEmbedFinished": "Inbäddning klar!", "MessageEmbedFinished": "Inbäddning genomförd!",
"MessageEpisodesQueuedForDownload": "{0} avsnitt i kö för nedladdning", "MessageEpisodesQueuedForDownload": "{0} avsnitt i kö för nedladdning",
"MessageEreaderDevices": "För att säkerställa överföring av e-böcker kan du bli tvungen<br>att addera ovanstående e-postadress som godkänd<br>avsändare för varje enhet angiven nedan.",
"MessageFeedURLWillBe": "Flödes-URL kommer att vara {0}", "MessageFeedURLWillBe": "Flödes-URL kommer att vara {0}",
"MessageFetching": "Hämtar...", "MessageFetching": "Hämtar...",
"MessageForceReScanDescription": "kommer att göra en omgångssökning av alla filer som en färsk sökning. ID3-taggar för ljudfiler, OPF-filer och textfiler kommer att sökas som nya.", "MessageForceReScanDescription": "kommer att göra en omgångssökning av alla filer som en färsk sökning. ID3-taggar för ljudfiler, OPF-filer och textfiler kommer att sökas som nya.",
@@ -592,22 +694,24 @@
"MessageJoinUsOn": "Anslut dig till oss på", "MessageJoinUsOn": "Anslut dig till oss på",
"MessageLoading": "Laddar...", "MessageLoading": "Laddar...",
"MessageLoadingFolders": "Laddar mappar...", "MessageLoadingFolders": "Laddar mappar...",
"MessageLogsDescription": "Filer med loggar sparas i mappen <code>/metadata/logs</code> som JSON-filer.<br>Filer med information om krascher sparas i <code>/metadata/logs/crash_logs.txt</code>.",
"MessageM4BFailed": "M4B misslyckades!", "MessageM4BFailed": "M4B misslyckades!",
"MessageM4BFinished": "M4B klar!", "MessageM4BFinished": "M4B klar!",
"MessageMapChapterTitles": "Kartlägg kapitelrubriker till dina befintliga ljudbokskapitel utan att justera tidstämplar", "MessageMapChapterTitles": "Kartlägg kapitelrubriker till dina befintliga ljudbokskapitel utan att justera tidstämplar",
"MessageMarkAllEpisodesFinished": "Markera alla avsnitt som avslutade", "MessageMarkAllEpisodesFinished": "Markera alla avsnitt som avslutade",
"MessageMarkAllEpisodesNotFinished": "Markera alla avsnitt som inte avslutade", "MessageMarkAllEpisodesNotFinished": "Markera alla avsnitt som ej avslutade",
"MessageMarkAsFinished": "Markera som avslutad", "MessageMarkAsFinished": "Markera som avslutad",
"MessageMarkAsNotFinished": "Markera som inte avslutad", "MessageMarkAsNotFinished": "Markera som ej avslutad",
"MessageMatchBooksDescription": "kommer att försöka matcha böcker i biblioteket med en bok från den valda källan och fylla i uppgifter som saknas och bokomslag. Inga befintliga uppgifter kommer att ersättas.", "MessageMatchBooksDescription": "kommer att försöka matcha böcker i biblioteket med en bok från<br>den valda källan och fylla i uppgifter som saknas och bokomslag.<br>Inga befintliga uppgifter kommer att ersättas.",
"MessageNoAudioTracks": "Inga ljudspår", "MessageNoAudioTracks": "Inga ljudspår",
"MessageNoAuthors": "Inga författare", "MessageNoAuthors": "Inga författare",
"MessageNoBackups": "Inga säkerhetskopior", "MessageNoBackups": "Inga säkerhetskopior",
"MessageNoBookmarks": "Inga bokmärken", "MessageNoBookmarks": "Inga bokmärken",
"MessageNoChapters": "Inga kapitel", "MessageNoChapters": "Inga kapitel",
"MessageNoCollections": "Inga samlingar", "MessageNoCollections": "Inga samlingar",
"MessageNoCoversFound": "Inga omslag hittade", "MessageNoCoversFound": "Inga bokomslag hittades",
"MessageNoDescription": "Ingen beskrivning", "MessageNoDescription": "Ingen beskrivning",
"MessageNoDevices": "Inga enheter angivna",
"MessageNoDownloadsInProgress": "Inga nedladdningar pågår för närvarande", "MessageNoDownloadsInProgress": "Inga nedladdningar pågår för närvarande",
"MessageNoDownloadsQueued": "Inga nedladdningar i kö", "MessageNoDownloadsQueued": "Inga nedladdningar i kö",
"MessageNoEpisodeMatchesFound": "Inga matchande avsnitt hittades", "MessageNoEpisodeMatchesFound": "Inga matchande avsnitt hittades",
@@ -617,7 +721,7 @@
"MessageNoIssues": "Inga problem", "MessageNoIssues": "Inga problem",
"MessageNoItems": "Inga objekt", "MessageNoItems": "Inga objekt",
"MessageNoItemsFound": "Inga objekt hittades", "MessageNoItemsFound": "Inga objekt hittades",
"MessageNoListeningSessions": "Inga lyssningssessioner", "MessageNoListeningSessions": "Inga lyssningstillfällen",
"MessageNoLogs": "Inga loggar", "MessageNoLogs": "Inga loggar",
"MessageNoMediaProgress": "Ingen medieförlopp", "MessageNoMediaProgress": "Ingen medieförlopp",
"MessageNoNotifications": "Inga aviseringar", "MessageNoNotifications": "Inga aviseringar",
@@ -634,51 +738,67 @@
"MessagePauseChapter": "Pausa kapiteluppspelning", "MessagePauseChapter": "Pausa kapiteluppspelning",
"MessagePlayChapter": "Lyssna på kapitlets början", "MessagePlayChapter": "Lyssna på kapitlets början",
"MessagePlaylistCreateFromCollection": "Skapa spellista från samling", "MessagePlaylistCreateFromCollection": "Skapa spellista från samling",
"MessagePleaseWait": "Vänta ett ögonblick...",
"MessagePodcastHasNoRSSFeedForMatching": "Podcasten har ingen RSS-flödes-URL att använda för matchning", "MessagePodcastHasNoRSSFeedForMatching": "Podcasten har ingen RSS-flödes-URL att använda för matchning",
"MessageQuickMatchDescription": "Fyll tomma objektdetaljer och omslag med första matchningsresultat från '{0}'. Överskriver inte detaljer om inte serverinställningen 'Föredra matchad metadata' är aktiverad.", "MessageQuickMatchDescription": "Adderar uppgifter som saknas samt en omslagsbild från<br>första träffen i resultatet vid sökningen från '{0}'.<br>Skriver inte över befintliga uppgifter om inte<br>inställningen 'Prioritera matchad metadata' är aktiverad.",
"MessageRemoveChapter": "Ta bort kapitel", "MessageRemoveChapter": "Ta bort kapitel",
"MessageRemoveEpisodes": "Ta bort {0} avsnitt", "MessageRemoveEpisodes": "Ta bort {0} avsnitt",
"MessageRemoveFromPlayerQueue": "Ta bort från spellistan", "MessageRemoveFromPlayerQueue": "Ta bort från spellistan",
"MessageRemoveUserWarning": "Är du säker på att du vill radera användaren \"{0}\" permanent?", "MessageRemoveUserWarning": "Är du säker på att du vill radera användaren \"{0}\"?",
"MessageReportBugsAndContribute": "Rapportera buggar, begär funktioner och bidra på", "MessageReportBugsAndContribute": "Rapportera buggar, begär funktioner och bidra på",
"MessageResetChaptersConfirm": "Är du säker på att du vill återställa kapitel och ångra ändringarna du gjort?", "MessageResetChaptersConfirm": "Är du säker på att du vill återställa alla kapitel och ångra de ändringarna du gjort?",
"MessageRestoreBackupConfirm": "Är du säker på att du vill återställa säkerhetskopian som skapades den", "MessageRestoreBackupConfirm": "Är du säker på att du vill läsa in säkerhetskopian som skapades den",
"MessageRestoreBackupWarning": "Att återställa en säkerhetskopia kommer att skriva över hela databasen som finns i /config och omslagsbilder i /metadata/items & /metadata/authors.<br /><br />Säkerhetskopior ändrar inte några filer i dina biblioteksmappar. Om du har aktiverat serverinställningar för att lagra omslagskonst och metadata i dina biblioteksmappar säkerhetskopieras eller skrivs de inte över.<br /><br />Alla klienter som använder din server kommer att uppdateras automatiskt.", "MessageRestoreBackupWarning": "Att återställa en säkerhetskopia kommer att skriva över hela databasen som finns i /config och omslagsbilder i /metadata/items & /metadata/authors.<br /><br />Säkerhetskopior ändrar inte några filer i dina biblioteksmappar. Om du har aktiverat serverinställningar för att lagra omslagskonst och metadata i dina biblioteksmappar säkerhetskopieras eller skrivs de inte över.<br /><br />Alla klienter som använder din server kommer att uppdateras automatiskt.",
"MessageSearchResultsFor": "Sökresultat för", "MessageSearchResultsFor": "Sökresultat för",
"MessageSelected": "{0} valda",
"MessageServerCouldNotBeReached": "Servern kunde inte nås", "MessageServerCouldNotBeReached": "Servern kunde inte nås",
"MessageSetChaptersFromTracksDescription": "Ställ in kapitel med varje ljudfil som ett kapitel och kapitelrubrik som ljudfilens namn", "MessageSetChaptersFromTracksDescription": "Ställ in kapitel med varje ljudfil som ett kapitel och kapitelrubrik som ljudfilens namn",
"MessageStartPlaybackAtTime": "Starta uppspelning för \"{0}\" kl. {1}?", "MessageStartPlaybackAtTime": "Starta uppspelning av \"{0}\" vid tidpunkt {1}?",
"MessageTaskCanceledByUser": "Uppgiften avslutades av användaren",
"MessageTaskFailed": "Misslyckades",
"MessageTaskFailedToCreateCacheDirectory": "Misslyckades med att skapa bibliotek för cachen",
"MessageTaskMatchingBooksInLibrary": "Matchar böcker i biblioteket \"{0}\"",
"MessageThinking": "Tänker...", "MessageThinking": "Tänker...",
"MessageUploaderItemFailed": "Misslyckades med att ladda upp", "MessageUploaderItemFailed": "Misslyckades med att ladda upp",
"MessageUploaderItemSuccess": "Uppladdning lyckades!", "MessageUploaderItemSuccess": "Uppladdning lyckades!",
"MessageUploading": "Laddar upp...", "MessageUploading": "Laddar upp...",
"MessageValidCronExpression": "Giltigt cron-uttryck", "MessageValidCronExpression": "Giltigt cron-uttryck",
"MessageWatcherIsDisabledGlobally": "Vakten är inaktiverad globalt i serverinställningarna", "MessageWatcherIsDisabledGlobally": "Watcher är inaktiverad centralt under rubriken 'Inställningar'",
"MessageXLibraryIsEmpty": "{0} biblioteket är tomt!", "MessageXLibraryIsEmpty": "Biblioteket {0} är tomt!",
"MessageYourAudiobookDurationIsLonger": "Varaktigheten på din ljudbok är längre än den hittade varaktigheten", "MessageYourAudiobookDurationIsLonger": "Varaktigheten på din ljudbok är längre än den hittade varaktigheten",
"MessageYourAudiobookDurationIsShorter": "Varaktigheten på din ljudbok är kortare än den hittade varaktigheten", "MessageYourAudiobookDurationIsShorter": "Varaktigheten på din ljudbok är kortare än den hittade varaktigheten",
"NoteChangeRootPassword": "Rotanvändaren är den enda användaren som kan ha ett tomt lösenord", "NoteChangeRootPassword": "Rotanvändaren är den enda användaren som kan ha ett tomt lösenord",
"NoteChapterEditorTimes": "Obs: Starttiden för första kapitlet måste förbli 0:00 och starttiden för det sista kapitlet får inte överstiga ljudbokens varaktighet.", "NoteChapterEditorTimes": "OBS: Starttiden för första kapitlet måste vara 0:00 och starttiden för det sista kapitlet får inte överstiga ljudbokens totala varaktighet.",
"NoteFolderPicker": "Obs: Mappar som redan är kartlagda kommer inte att visas", "NoteFolderPicker": "Obs: Mappar som redan är kartlagda kommer inte att visas",
"NoteRSSFeedPodcastAppsHttps": "Varning: De flesta podcastappar kräver att RSS-flödets URL används med HTTPS", "NoteRSSFeedPodcastAppsHttps": "Varning: De flesta podcastappar kräver att RSS-flödets URL används med HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Varning: 1 eller flera av dina avsnitt har inte ett publiceringsdatum. Vissa podcastappar kräver detta.", "NoteRSSFeedPodcastAppsPubDate": "Varning: 1 eller flera av dina avsnitt har inte ett publiceringsdatum. Vissa podcastappar kräver detta.",
"NoteUploaderFoldersWithMediaFiles": "Mappar med flera mediefiler hanteras som separata objekt i biblioteket.", "NoteUploaderFoldersWithMediaFiles": "Mappar med flera mediefiler hanteras som separata objekt i biblioteket.",
"NoteUploaderOnlyAudioFiles": "Om du bara laddar upp ljudfiler kommer varje ljudfil att hanteras som en separat ljudbok.", "NoteUploaderOnlyAudioFiles": "<br>Om du bara laddar upp ljudfiler kommer varje ljudfil att hanteras som en separat ljudbok.",
"NoteUploaderUnsupportedFiles": "Oaccepterade filer ignoreras. När du väljer eller släpper en mapp ignoreras andra filer som inte finns i ett objektmapp.", "NoteUploaderUnsupportedFiles": "Oaccepterade filer ignoreras. När du väljer eller släpper en mapp ignoreras andra filer som inte finns i ett objektmapp.",
"PlaceholderNewCollection": "Nytt samlingsnamn", "PlaceholderNewCollection": "Nytt samlingsnamn",
"PlaceholderNewFolderPath": "Nytt mappväg", "PlaceholderNewFolderPath": "Nytt sökväg till mappen",
"PlaceholderNewPlaylist": "Nytt spellistanamn", "PlaceholderNewPlaylist": "Nytt namn på spellistan",
"PlaceholderSearch": "Sök...", "PlaceholderSearch": "Sök...",
"PlaceholderSearchEpisode": "Sök avsnitt...", "PlaceholderSearchEpisode": "Sök avsnitt...",
"StatsTopAuthor": "POPULÄRAST FÖRFATTAREN", "StatsAuthorsAdded": "författare har adderats",
"StatsTopAuthors": "POPULÄRASTE FÖRFATTARNA", "StatsBooksAdded": "böcker har adderats",
"StatsTopGenre": "Populäraste kategorin", "StatsBooksAdditional": "Några exempel på det som adderats…",
"StatsBooksFinished": "avslutade böcker",
"StatsBooksFinishedThisYear": "Några böcker som avslutats under året…",
"StatsBooksListenedTo": "böcker, lyssnat på",
"StatsCollectionGrewTo": "Ditt biblioteks storlek ökade till…",
"StatsSessions": "lyssningstillfällen",
"StatsSpentListening": "tid, som lyssnats",
"StatsTopAuthor": "Populäraste författare",
"StatsTopAuthors": "Populäraste författarna",
"StatsTopGenre": "Populäraste kategori",
"StatsTopGenres": "Populäraste kategorierna", "StatsTopGenres": "Populäraste kategorierna",
"StatsTopMonth": "Bästa månaden", "StatsTopMonth": "Bästa månad",
"StatsTopNarrator": "Populärast uppläsarna", "StatsTopNarrator": "Populäraste uppläsare",
"StatsTopNarrators": "Populäraste uppläsaren", "StatsTopNarrators": "Populäraste uppläsarna",
"StatsYearInReview": "SAMMANSTÄLLNING AV ÅRET", "StatsTotalDuration": "Med en total varaktighet…",
"ToastAccountUpdateSuccess": "Kontot uppdaterat", "StatsYearInReview": "- SAMMANSTÄLLNING AV ÅRET",
"ToastAccountUpdateSuccess": "Kontot har uppdaterats",
"ToastAsinRequired": "En ASIN-kod krävs", "ToastAsinRequired": "En ASIN-kod krävs",
"ToastAuthorImageRemoveSuccess": "Författarens bild borttagen", "ToastAuthorImageRemoveSuccess": "Författarens bild borttagen",
"ToastAuthorNotFound": "Författaren \"{0}\" kunde inte identifieras", "ToastAuthorNotFound": "Författaren \"{0}\" kunde inte identifieras",
@@ -701,43 +821,77 @@
"ToastBookmarkCreateFailed": "Det gick inte att skapa bokmärket", "ToastBookmarkCreateFailed": "Det gick inte att skapa bokmärket",
"ToastBookmarkCreateSuccess": "Bokmärket har adderats", "ToastBookmarkCreateSuccess": "Bokmärket har adderats",
"ToastBookmarkRemoveSuccess": "Bokmärket har raderats", "ToastBookmarkRemoveSuccess": "Bokmärket har raderats",
"ToastBookmarkUpdateSuccess": "Bokmärket har uppdaterats", "ToastCachePurgeFailed": "Misslyckades med att rensa cachen",
"ToastCachePurgeSuccess": "Rensning av cachen har genomförts",
"ToastChaptersHaveErrors": "Kapitlen har fel", "ToastChaptersHaveErrors": "Kapitlen har fel",
"ToastChaptersMustHaveTitles": "Kapitel måste ha titlar", "ToastChaptersMustHaveTitles": "Kapitel måste ha titlar",
"ToastCollectionRemoveSuccess": "Samlingen har raderats", "ToastCollectionRemoveSuccess": "Samlingen har raderats",
"ToastCollectionUpdateSuccess": "Samlingen har uppdaterats", "ToastCollectionUpdateSuccess": "Samlingen har uppdaterats",
"ToastItemCoverUpdateSuccess": "Objektets omslag uppdaterat", "ToastCoverUpdateFailed": "Uppdatering av bokomslag misslyckades",
"ToastItemDetailsUpdateSuccess": "Objektdetaljer uppdaterade", "ToastDateTimeInvalidOrIncomplete": "Datum och klockslag är felaktigt eller ej komplett",
"ToastItemMarkedAsFinishedFailed": "Misslyckades med att markera som färdig", "ToastDeleteFileFailed": "Misslyckades att radera filen",
"ToastItemMarkedAsFinishedSuccess": "Objekt markerat som färdig", "ToastDeleteFileSuccess": "Filen har raderats",
"ToastItemMarkedAsNotFinishedFailed": "Misslyckades med att markera som ej färdig", "ToastDeviceTestEmailFailed": "Misslyckades med att skicka ett testmail",
"ToastItemMarkedAsNotFinishedSuccess": "Objekt markerat som ej färdig", "ToastDeviceTestEmailSuccess": "Ett testmail har skickats",
"ToastEmailSettingsUpdateSuccess": "Inställningarna av e-post har uppdaterats",
"ToastFailedToLoadData": "Misslyckades med att ladda data",
"ToastInvalidImageUrl": "Felaktig URL-adress till omslagsbilden",
"ToastInvalidMaxEpisodesToDownload": "Ogiltigt maximalt antal avsnitt att ladda ner",
"ToastInvalidUrl": "Felaktig URL-adress",
"ToastItemCoverUpdateSuccess": "Objektets bokomslag har uppdaterats",
"ToastItemDetailsUpdateSuccess": "Detaljerna om boken har uppdaterats",
"ToastItemMarkedAsFinishedFailed": "Misslyckades med att markera den som avslutad",
"ToastItemMarkedAsFinishedSuccess": "Den har markerat som avslutad",
"ToastItemMarkedAsNotFinishedFailed": "Misslyckades med att markera den som ej avslutad",
"ToastItemMarkedAsNotFinishedSuccess": "Den har markerats som ej avslutad",
"ToastLibraryCreateFailed": "Det gick inte att skapa biblioteket", "ToastLibraryCreateFailed": "Det gick inte att skapa biblioteket",
"ToastLibraryCreateSuccess": "Biblioteket \"{0}\" skapat", "ToastLibraryCreateSuccess": "Biblioteket \"{0}\" har skapats",
"ToastLibraryDeleteFailed": "Det gick inte att ta bort biblioteket", "ToastLibraryDeleteFailed": "Det gick inte att ta bort biblioteket",
"ToastLibraryDeleteSuccess": "Biblioteket borttaget", "ToastLibraryDeleteSuccess": "Biblioteket borttaget",
"ToastLibraryScanFailedToStart": "Misslyckades med att starta skanningen", "ToastLibraryScanFailedToStart": "Misslyckades med att starta skanningen",
"ToastLibraryScanStarted": "Skanning av biblioteket påbörjad", "ToastLibraryScanStarted": "Skanning av biblioteket påbörjad",
"ToastLibraryUpdateSuccess": "Biblioteket \"{0}\" uppdaterat", "ToastLibraryUpdateSuccess": "Biblioteket \"{0}\" har uppdaterats",
"ToastMetadataFilesRemovedError": "Misslyckades med att radera 'metadata.{0}' filerna",
"ToastMetadataFilesRemovedNoneFound": "Inga 'metadata.{0}' filer hittades i biblioteket",
"ToastMetadataFilesRemovedNoneRemoved": "Inga 'metadata.{0}' filer raderades",
"ToastMetadataFilesRemovedSuccess": "{0} 'metadata.{1}' raderades",
"ToastNameEmailRequired": "Ett namn och en e-postadress måste anges",
"ToastNameRequired": "Ett namn måste anges",
"ToastNewUserCreatedFailed": "Misslyckades med att skapa kontot \"{0}\"",
"ToastNewUserCreatedSuccess": "Ett nytt konto har skapats",
"ToastNoUpdatesNecessary": "Inga uppdateringar var nödvändiga",
"ToastPlaylistCreateFailed": "Det gick inte att skapa spellistan", "ToastPlaylistCreateFailed": "Det gick inte att skapa spellistan",
"ToastPlaylistCreateSuccess": "Spellistan skapad", "ToastPlaylistCreateSuccess": "Spellistan skapad",
"ToastPlaylistRemoveSuccess": "Spellistan borttagen", "ToastPlaylistRemoveSuccess": "Spellistan har tagits bort",
"ToastPlaylistUpdateSuccess": "Spellistan uppdaterad", "ToastPlaylistUpdateSuccess": "Spellistan uppdaterad",
"ToastPodcastCreateFailed": "Misslyckades med att skapa podcasten", "ToastPodcastCreateFailed": "Misslyckades med att skapa podcasten",
"ToastPodcastCreateSuccess": "Podcasten skapad framgångsrikt", "ToastPodcastCreateSuccess": "Podcasten skapad framgångsrikt",
"ToastProviderCreatedFailed": "Misslyckades med att addera en källa",
"ToastProviderCreatedSuccess": "En ny källa har adderats",
"ToastProviderRemoveSuccess": "Källan har tagits bort",
"ToastRSSFeedCloseFailed": "Misslyckades med att stänga RSS-flödet", "ToastRSSFeedCloseFailed": "Misslyckades med att stänga RSS-flödet",
"ToastRSSFeedCloseSuccess": "RSS-flödet stängt", "ToastRSSFeedCloseSuccess": "RSS-flödet stängt",
"ToastRemoveItemFromCollectionFailed": "Misslyckades med att ta bort objektet från samlingen", "ToastRemoveItemFromCollectionFailed": "Misslyckades med att ta bort objektet från samlingen",
"ToastRemoveItemFromCollectionSuccess": "Objektet borttaget från samlingen", "ToastRemoveItemFromCollectionSuccess": "Objektet borttaget från samlingen",
"ToastSelectAtLeastOneUser": "Åtminstone en användare måste väljas",
"ToastSendEbookToDeviceFailed": "Misslyckades med att skicka e-boken till enheten", "ToastSendEbookToDeviceFailed": "Misslyckades med att skicka e-boken till enheten",
"ToastSendEbookToDeviceSuccess": "E-boken skickad till enheten \"{0}\"", "ToastSendEbookToDeviceSuccess": "E-boken skickad till enheten \"{0}\"",
"ToastSeriesUpdateFailed": "Uppdateringen av serier misslyckades", "ToastSeriesUpdateFailed": "Uppdateringen av serier misslyckades",
"ToastSeriesUpdateSuccess": "Uppdateringen av serierna lyckades", "ToastSeriesUpdateSuccess": "Uppdateringen av serierna lyckades",
"ToastServerSettingsUpdateSuccess": "Inställningarna för servern har uppdaterats",
"ToastSessionDeleteFailed": "Misslyckades med att ta bort sessionen", "ToastSessionDeleteFailed": "Misslyckades med att ta bort sessionen",
"ToastSessionDeleteSuccess": "Sessionen borttagen", "ToastSessionDeleteSuccess": "Sessionen borttagen",
"ToastSleepTimerDone": "Timer har stängt av lyssning. Sov gott... zZzzZz",
"ToastSocketConnected": "Socket ansluten", "ToastSocketConnected": "Socket ansluten",
"ToastSocketDisconnected": "Socket frånkopplad", "ToastSocketDisconnected": "Socket frånkopplad",
"ToastSocketFailedToConnect": "Socket misslyckades med att ansluta", "ToastSocketFailedToConnect": "Socket misslyckades med att ansluta",
"ToastSortingPrefixesEmptyError": "Åtminstone ett sorteringsbegrepp måste anges",
"ToastSortingPrefixesUpdateSuccess": "{0} begrepp för sortering har uppdateras",
"ToastTitleRequired": "En titel måste anges",
"ToastUnknownError": "Ett okänt fel inträffade",
"ToastUserDeleteFailed": "Misslyckades med att ta bort användaren", "ToastUserDeleteFailed": "Misslyckades med att ta bort användaren",
"ToastUserDeleteSuccess": "Användaren borttagen" "ToastUserDeleteSuccess": "Användaren borttagen",
"ToastUserPasswordChangeSuccess": "Lösenordet har ändrats",
"ToastUserPasswordMismatch": "Lösenorden är inte identiska",
"ToastUserPasswordMustChange": "Det nya lösenordet kan inte vara samma som det gamla"
} }
+6 -3
View File
@@ -51,7 +51,7 @@
"ButtonNext": "Наступний", "ButtonNext": "Наступний",
"ButtonNextChapter": "Наступна глава", "ButtonNextChapter": "Наступна глава",
"ButtonNextItemInQueue": "Наступний елемент у черзі", "ButtonNextItemInQueue": "Наступний елемент у черзі",
"ButtonOk": "Гаразд", "ButtonOk": "Добре",
"ButtonOpenFeed": "Відкрити стрічку", "ButtonOpenFeed": "Відкрити стрічку",
"ButtonOpenManager": "Відкрити менеджер", "ButtonOpenManager": "Відкрити менеджер",
"ButtonPause": "Пауза", "ButtonPause": "Пауза",
@@ -463,7 +463,7 @@
"LabelNotificationsMaxQueueSize": "Ліміт розміру черги сповіщень", "LabelNotificationsMaxQueueSize": "Ліміт розміру черги сповіщень",
"LabelNotificationsMaxQueueSizeHelp": "Події обмежені до 1 на секунду. Події буде проігноровано, якщо ліміт черги досягнуто. Це запобігає спаму сповіщеннями.", "LabelNotificationsMaxQueueSizeHelp": "Події обмежені до 1 на секунду. Події буде проігноровано, якщо ліміт черги досягнуто. Це запобігає спаму сповіщеннями.",
"LabelNumberOfBooks": "Кількість книг", "LabelNumberOfBooks": "Кількість книг",
"LabelNumberOfEpisodes": "Кількість епізодів", "LabelNumberOfEpisodes": "Кількість серій",
"LabelOpenIDAdvancedPermsClaimDescription": "Назва OpenID claim, що містить розширені дозволи на дії користувачів у додатку, які будуть застосовуватися до ролей, що не є адміністраторами (<b>якщо налаштовано</b>). Якщо у відповіді нема claim, у доступі до Audiobookshelf буде відмовлено. Якщо відсутня хоча б одна опція, відповідь буде вважатися <code>хибною</code>. Переконайтеся, що запит постачальника ідентифікаційних даних відповідає очікуваній структурі:", "LabelOpenIDAdvancedPermsClaimDescription": "Назва OpenID claim, що містить розширені дозволи на дії користувачів у додатку, які будуть застосовуватися до ролей, що не є адміністраторами (<b>якщо налаштовано</b>). Якщо у відповіді нема claim, у доступі до Audiobookshelf буде відмовлено. Якщо відсутня хоча б одна опція, відповідь буде вважатися <code>хибною</code>. Переконайтеся, що запит постачальника ідентифікаційних даних відповідає очікуваній структурі:",
"LabelOpenIDClaims": "Не змінюйте наступні параметри, аби вимкнути розширене призначення груп і дозволів, автоматично призначаючи групу 'Користувач'.", "LabelOpenIDClaims": "Не змінюйте наступні параметри, аби вимкнути розширене призначення груп і дозволів, автоматично призначаючи групу 'Користувач'.",
"LabelOpenIDGroupClaimDescription": "Ім'я OpenID claim, що містить список груп користувачів. Зазвичай їх називають <code>групами</code>. <b>Якщо налаштовано</b>, застосунок автоматично призначатиме ролі на основі членства користувача в групах, за умови, що ці групи названі в claim'і без урахування реєстру 'admin', 'user' або 'guest'. Claim мусить містити список, і якщо користувач належить до кількох груп, програма призначить йому роль, що відповідає найвищому рівню доступу. Якщо жодна група не збігається, у доступі буде відмовлено.", "LabelOpenIDGroupClaimDescription": "Ім'я OpenID claim, що містить список груп користувачів. Зазвичай їх називають <code>групами</code>. <b>Якщо налаштовано</b>, застосунок автоматично призначатиме ролі на основі членства користувача в групах, за умови, що ці групи названі в claim'і без урахування реєстру 'admin', 'user' або 'guest'. Claim мусить містити список, і якщо користувач належить до кількох груп, програма призначить йому роль, що відповідає найвищому рівню доступу. Якщо жодна група не збігається, у доступі буде відмовлено.",
@@ -758,6 +758,7 @@
"MessageConfirmResetProgress": "Ви впевнені, що хочете скинути свій прогрес?", "MessageConfirmResetProgress": "Ви впевнені, що хочете скинути свій прогрес?",
"MessageConfirmSendEbookToDevice": "Ви дійсно хочете відправити на пристрій \"{2}\" електроні книги: {0}, \"{1}\"?", "MessageConfirmSendEbookToDevice": "Ви дійсно хочете відправити на пристрій \"{2}\" електроні книги: {0}, \"{1}\"?",
"MessageConfirmUnlinkOpenId": "Ви впевнені, що хочете відв'язати цього користувача від OpenID?", "MessageConfirmUnlinkOpenId": "Ви впевнені, що хочете відв'язати цього користувача від OpenID?",
"MessageDaysListenedInTheLastYear": "{0} днів, прослуханих за останній рік",
"MessageDownloadingEpisode": "Завантаження епізоду", "MessageDownloadingEpisode": "Завантаження епізоду",
"MessageDragFilesIntoTrackOrder": "Перетягніть файли до правильного порядку", "MessageDragFilesIntoTrackOrder": "Перетягніть файли до правильного порядку",
"MessageEmbedFailed": "Не вдалося вбудувати!", "MessageEmbedFailed": "Не вдалося вбудувати!",
@@ -836,6 +837,7 @@
"MessageResetChaptersConfirm": "Ви дійсно бажаєте скинути глави та скасувати внесені зміни?", "MessageResetChaptersConfirm": "Ви дійсно бажаєте скинути глави та скасувати внесені зміни?",
"MessageRestoreBackupConfirm": "Ви дійсно бажаєте відновити резервну копію від", "MessageRestoreBackupConfirm": "Ви дійсно бажаєте відновити резервну копію від",
"MessageRestoreBackupWarning": "Відновлення резервної копії перезапише всю базу даних, розташовану в /config, і зображення обкладинок в /metadata/items та /metadata/authors.<br /><br />Резервні копії не змінюють жодних файлів у теках бібліотеки. Якщо у налаштуваннях сервера увімкнено збереження обкладинок і метаданих у теках бібліотеки, вони не створюються під час резервного копіювання і не перезаписуються..<br /><br />Всі клієнти, що користуються вашим сервером, будуть автоматично оновлені.", "MessageRestoreBackupWarning": "Відновлення резервної копії перезапише всю базу даних, розташовану в /config, і зображення обкладинок в /metadata/items та /metadata/authors.<br /><br />Резервні копії не змінюють жодних файлів у теках бібліотеки. Якщо у налаштуваннях сервера увімкнено збереження обкладинок і метаданих у теках бібліотеки, вони не створюються під час резервного копіювання і не перезаписуються..<br /><br />Всі клієнти, що користуються вашим сервером, будуть автоматично оновлені.",
"MessageScheduleLibraryScanNote": "Для більшості користувачів рекомендується залишити цю функцію вимкненою та залишити параметр перегляду папок увімкненим. Засіб спостереження за папками автоматично виявить зміни в папках вашої бібліотеки. Засіб спостереження за папками не працює для кожної файлової системи (наприклад, NFS), тому замість нього можна використовувати сканування бібліотек за розкладом.",
"MessageSearchResultsFor": "Результати пошуку для", "MessageSearchResultsFor": "Результати пошуку для",
"MessageSelected": "Вибрано: {0}", "MessageSelected": "Вибрано: {0}",
"MessageServerCouldNotBeReached": "Не вдалося підключитися до сервера", "MessageServerCouldNotBeReached": "Не вдалося підключитися до сервера",
@@ -952,7 +954,6 @@
"ToastBookmarkCreateFailed": "Не вдалося створити закладку", "ToastBookmarkCreateFailed": "Не вдалося створити закладку",
"ToastBookmarkCreateSuccess": "Закладку додано", "ToastBookmarkCreateSuccess": "Закладку додано",
"ToastBookmarkRemoveSuccess": "Закладку видалено", "ToastBookmarkRemoveSuccess": "Закладку видалено",
"ToastBookmarkUpdateSuccess": "Закладку оновлено",
"ToastCachePurgeFailed": "Не вдалося очистити кеш", "ToastCachePurgeFailed": "Не вдалося очистити кеш",
"ToastCachePurgeSuccess": "Кеш очищено", "ToastCachePurgeSuccess": "Кеш очищено",
"ToastChaptersHaveErrors": "Глави містять помилки", "ToastChaptersHaveErrors": "Глави містять помилки",
@@ -963,6 +964,7 @@
"ToastCollectionRemoveSuccess": "Добірку видалено", "ToastCollectionRemoveSuccess": "Добірку видалено",
"ToastCollectionUpdateSuccess": "Добірку оновлено", "ToastCollectionUpdateSuccess": "Добірку оновлено",
"ToastCoverUpdateFailed": "Не вдалося оновити обкладинку", "ToastCoverUpdateFailed": "Не вдалося оновити обкладинку",
"ToastDateTimeInvalidOrIncomplete": "Дата й час недійсні або неповні",
"ToastDeleteFileFailed": "Не вдалося видалити файл", "ToastDeleteFileFailed": "Не вдалося видалити файл",
"ToastDeleteFileSuccess": "Файл видалено", "ToastDeleteFileSuccess": "Файл видалено",
"ToastDeviceAddFailed": "Не вдалося додати пристрій", "ToastDeviceAddFailed": "Не вдалося додати пристрій",
@@ -1015,6 +1017,7 @@
"ToastNewUserTagError": "Потрібно вибрати хоча б один тег", "ToastNewUserTagError": "Потрібно вибрати хоча б один тег",
"ToastNewUserUsernameError": "Введіть ім'я користувача", "ToastNewUserUsernameError": "Введіть ім'я користувача",
"ToastNoNewEpisodesFound": "Нових епізодів не знайдено", "ToastNoNewEpisodesFound": "Нових епізодів не знайдено",
"ToastNoRSSFeed": "Подкаст не має RSS-канал",
"ToastNoUpdatesNecessary": "Оновлення не потрібні", "ToastNoUpdatesNecessary": "Оновлення не потрібні",
"ToastNotificationCreateFailed": "Не вдалося створити сповіщення", "ToastNotificationCreateFailed": "Не вдалося створити сповіщення",
"ToastNotificationDeleteFailed": "Не вдалося видалити сповіщення", "ToastNotificationDeleteFailed": "Не вдалося видалити сповіщення",
-1
View File
@@ -679,7 +679,6 @@
"ToastBookmarkCreateFailed": "Tạo đánh dấu thất bại", "ToastBookmarkCreateFailed": "Tạo đánh dấu thất bại",
"ToastBookmarkCreateSuccess": "Đã thêm đánh dấu", "ToastBookmarkCreateSuccess": "Đã thêm đánh dấu",
"ToastBookmarkRemoveSuccess": "Đánh dấu đã được xóa", "ToastBookmarkRemoveSuccess": "Đánh dấu đã được xóa",
"ToastBookmarkUpdateSuccess": "Đánh dấu đã được cập nhật",
"ToastChaptersHaveErrors": "Các chương có lỗi", "ToastChaptersHaveErrors": "Các chương có lỗi",
"ToastChaptersMustHaveTitles": "Các chương phải có tiêu đề", "ToastChaptersMustHaveTitles": "Các chương phải có tiêu đề",
"ToastCollectionRemoveSuccess": "Bộ sưu tập đã được xóa", "ToastCollectionRemoveSuccess": "Bộ sưu tập đã được xóa",
+6 -1
View File
@@ -300,6 +300,7 @@
"LabelDiscover": "发现", "LabelDiscover": "发现",
"LabelDownload": "下载", "LabelDownload": "下载",
"LabelDownloadNEpisodes": "下载 {0} 集", "LabelDownloadNEpisodes": "下载 {0} 集",
"LabelDownloadable": "可下载",
"LabelDuration": "持续时间", "LabelDuration": "持续时间",
"LabelDurationComparisonExactMatch": "(完全匹配)", "LabelDurationComparisonExactMatch": "(完全匹配)",
"LabelDurationComparisonLonger": "({0} 更长)", "LabelDurationComparisonLonger": "({0} 更长)",
@@ -588,6 +589,7 @@
"LabelSettingsStoreMetadataWithItemHelp": "默认情况下元数据文件存储在/metadata/items文件夹中, 启用此设置将存储元数据在你媒体项目文件夹中", "LabelSettingsStoreMetadataWithItemHelp": "默认情况下元数据文件存储在/metadata/items文件夹中, 启用此设置将存储元数据在你媒体项目文件夹中",
"LabelSettingsTimeFormat": "时间格式", "LabelSettingsTimeFormat": "时间格式",
"LabelShare": "分享", "LabelShare": "分享",
"LabelShareDownloadableHelp": "允许用户通过共享链接的下载库项目为 zip 文件.",
"LabelShareOpen": "打开分享", "LabelShareOpen": "打开分享",
"LabelShareURL": "分享 URL", "LabelShareURL": "分享 URL",
"LabelShowAll": "全部显示", "LabelShowAll": "全部显示",
@@ -756,6 +758,7 @@
"MessageConfirmResetProgress": "你确定要重置进度吗?", "MessageConfirmResetProgress": "你确定要重置进度吗?",
"MessageConfirmSendEbookToDevice": "你确定要发送 {0} 电子书 \"{1}\" 到设备 \"{2}\"?", "MessageConfirmSendEbookToDevice": "你确定要发送 {0} 电子书 \"{1}\" 到设备 \"{2}\"?",
"MessageConfirmUnlinkOpenId": "你确定要取消该用户与 OpenID 的链接吗?", "MessageConfirmUnlinkOpenId": "你确定要取消该用户与 OpenID 的链接吗?",
"MessageDaysListenedInTheLastYear": "去年收听了 {0} 天",
"MessageDownloadingEpisode": "正在下载剧集", "MessageDownloadingEpisode": "正在下载剧集",
"MessageDragFilesIntoTrackOrder": "将文件拖动到正确的音轨顺序", "MessageDragFilesIntoTrackOrder": "将文件拖动到正确的音轨顺序",
"MessageEmbedFailed": "嵌入失败!", "MessageEmbedFailed": "嵌入失败!",
@@ -834,6 +837,7 @@
"MessageResetChaptersConfirm": "你确定要重置章节并撤消你所做的更改吗?", "MessageResetChaptersConfirm": "你确定要重置章节并撤消你所做的更改吗?",
"MessageRestoreBackupConfirm": "你确定要恢复创建的这个备份", "MessageRestoreBackupConfirm": "你确定要恢复创建的这个备份",
"MessageRestoreBackupWarning": "恢复备份将覆盖位于 /config 的整个数据库并覆盖 /metadata/items & /metadata/authors 中的图像.<br /><br />备份不会修改媒体库文件夹中的任何文件. 如果你已启用服务器设置将封面和元数据存储在库文件夹中,则不会备份或覆盖这些内容.<br /><br />将自动刷新使用服务器的所有客户端.", "MessageRestoreBackupWarning": "恢复备份将覆盖位于 /config 的整个数据库并覆盖 /metadata/items & /metadata/authors 中的图像.<br /><br />备份不会修改媒体库文件夹中的任何文件. 如果你已启用服务器设置将封面和元数据存储在库文件夹中,则不会备份或覆盖这些内容.<br /><br />将自动刷新使用服务器的所有客户端.",
"MessageScheduleLibraryScanNote": "对于大多数用户, 建议禁用此功能并保持文件夹监视程序设置启用. 文件夹监视程序将自动检测库文件夹中的更改. 文件夹监视程序不适用于每个文件系统 (如 NFS), 因此可以使用计划库扫描.",
"MessageSearchResultsFor": "搜索结果", "MessageSearchResultsFor": "搜索结果",
"MessageSelected": "{0} 已选择", "MessageSelected": "{0} 已选择",
"MessageServerCouldNotBeReached": "无法访问服务器", "MessageServerCouldNotBeReached": "无法访问服务器",
@@ -950,7 +954,6 @@
"ToastBookmarkCreateFailed": "创建书签失败", "ToastBookmarkCreateFailed": "创建书签失败",
"ToastBookmarkCreateSuccess": "书签已添加", "ToastBookmarkCreateSuccess": "书签已添加",
"ToastBookmarkRemoveSuccess": "书签已删除", "ToastBookmarkRemoveSuccess": "书签已删除",
"ToastBookmarkUpdateSuccess": "书签已更新",
"ToastCachePurgeFailed": "清除缓存失败", "ToastCachePurgeFailed": "清除缓存失败",
"ToastCachePurgeSuccess": "缓存清除成功", "ToastCachePurgeSuccess": "缓存清除成功",
"ToastChaptersHaveErrors": "章节有错误", "ToastChaptersHaveErrors": "章节有错误",
@@ -961,6 +964,7 @@
"ToastCollectionRemoveSuccess": "收藏夹已删除", "ToastCollectionRemoveSuccess": "收藏夹已删除",
"ToastCollectionUpdateSuccess": "收藏夹已更新", "ToastCollectionUpdateSuccess": "收藏夹已更新",
"ToastCoverUpdateFailed": "封面更新失败", "ToastCoverUpdateFailed": "封面更新失败",
"ToastDateTimeInvalidOrIncomplete": "日期和时间无效或不完整",
"ToastDeleteFileFailed": "删除文件失败", "ToastDeleteFileFailed": "删除文件失败",
"ToastDeleteFileSuccess": "文件已删除", "ToastDeleteFileSuccess": "文件已删除",
"ToastDeviceAddFailed": "添加设备失败", "ToastDeviceAddFailed": "添加设备失败",
@@ -1013,6 +1017,7 @@
"ToastNewUserTagError": "必须至少选择一个标签", "ToastNewUserTagError": "必须至少选择一个标签",
"ToastNewUserUsernameError": "输入用户名", "ToastNewUserUsernameError": "输入用户名",
"ToastNoNewEpisodesFound": "没有找到新剧集", "ToastNoNewEpisodesFound": "没有找到新剧集",
"ToastNoRSSFeed": "播客没有 RSS 订阅",
"ToastNoUpdatesNecessary": "无需更新", "ToastNoUpdatesNecessary": "无需更新",
"ToastNotificationCreateFailed": "无法创建通知", "ToastNotificationCreateFailed": "无法创建通知",
"ToastNotificationDeleteFailed": "删除通知失败", "ToastNotificationDeleteFailed": "删除通知失败",
-1
View File
@@ -723,7 +723,6 @@
"ToastBookmarkCreateFailed": "創建書簽失敗", "ToastBookmarkCreateFailed": "創建書簽失敗",
"ToastBookmarkCreateSuccess": "書籤已新增", "ToastBookmarkCreateSuccess": "書籤已新增",
"ToastBookmarkRemoveSuccess": "書籤已刪除", "ToastBookmarkRemoveSuccess": "書籤已刪除",
"ToastBookmarkUpdateSuccess": "書籤已更新",
"ToastChaptersHaveErrors": "章節有錯誤", "ToastChaptersHaveErrors": "章節有錯誤",
"ToastChaptersMustHaveTitles": "章節必須有標題", "ToastChaptersMustHaveTitles": "章節必須有標題",
"ToastCollectionRemoveSuccess": "收藏夾已刪除", "ToastCollectionRemoveSuccess": "收藏夾已刪除",
+27 -7
View File
@@ -1,3 +1,18 @@
const optionDefinitions = [
{ name: 'config', alias: 'c', type: String },
{ name: 'metadata', alias: 'm', type: String },
{ name: 'port', alias: 'p', type: String },
{ name: 'host', alias: 'h', type: String },
{ name: 'source', alias: 's', type: String },
{ name: 'dev', alias: 'd', type: Boolean }
]
const commandLineArgs = require('./server/libs/commandLineArgs')
const options = commandLineArgs(optionDefinitions)
const Path = require('path')
process.env.NODE_ENV = options.dev ? 'development' : process.env.NODE_ENV || 'production'
const server = require('./server/Server') const server = require('./server/Server')
global.appRoot = __dirname global.appRoot = __dirname
@@ -17,14 +32,19 @@ if (isDev) {
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || '' process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || ''
} }
const PORT = process.env.PORT || 80 const inputConfig = options.config ? Path.resolve(options.config) : null
const HOST = process.env.HOST const inputMetadata = options.metadata ? Path.resolve(options.metadata) : null
const CONFIG_PATH = process.env.CONFIG_PATH || '/config'
const METADATA_PATH = process.env.METADATA_PATH || '/metadata'
const SOURCE = process.env.SOURCE || 'docker'
const ROUTER_BASE_PATH = process.env.ROUTER_BASE_PATH || ''
console.log('Config', CONFIG_PATH, METADATA_PATH) const PORT = options.port || process.env.PORT || 3333
const HOST = options.host || process.env.HOST
const CONFIG_PATH = inputConfig || process.env.CONFIG_PATH || Path.resolve('config')
const METADATA_PATH = inputMetadata || process.env.METADATA_PATH || Path.resolve('metadata')
const SOURCE = options.source || process.env.SOURCE || 'debian'
const ROUTER_BASE_PATH = process.env.ROUTER_BASE_PATH || '/audiobookshelf'
console.log(`Running in ${process.env.NODE_ENV} mode.`)
console.log(`Options: CONFIG_PATH=${CONFIG_PATH}, METADATA_PATH=${METADATA_PATH}, PORT=${PORT}, HOST=${HOST}, SOURCE=${SOURCE}, ROUTER_BASE_PATH=${ROUTER_BASE_PATH}`)
const Server = new server(SOURCE, PORT, HOST, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) const Server = new server(SOURCE, PORT, HOST, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH)
Server.start() Server.start()
+3 -3
View File
@@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.17.7", "version": "2.18.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.17.7", "version": "2.18.0",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"axios": "^0.27.2", "axios": "^0.27.2",
@@ -30,7 +30,7 @@
"xml2js": "^0.5.0" "xml2js": "^0.5.0"
}, },
"bin": { "bin": {
"audiobookshelf": "prod.js" "audiobookshelf": "index.js"
}, },
"devDependencies": { "devDependencies": {
"chai": "^4.3.10", "chai": "^4.3.10",
+5 -5
View File
@@ -1,14 +1,14 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.17.7", "version": "2.18.0",
"buildNumber": 1, "buildNumber": 1,
"description": "Self-hosted audiobook and podcast server", "description": "Self-hosted audiobook and podcast server",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev": "nodemon --watch server index.js", "dev": "nodemon --watch server index.js -- --dev",
"start": "node index.js", "start": "node index.js",
"client": "cd client && npm ci && npm run generate", "client": "cd client && npm ci && npm run generate",
"prod": "npm run client && npm ci && node prod.js", "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": "npm run client && pkg -t node20-win-x64 -o ./dist/win/audiobookshelf -C GZip .",
"build-linux": "build/linuxpackager", "build-linux": "build/linuxpackager",
"docker": "docker buildx build --platform linux/amd64,linux/arm64 --push . -t advplyr/audiobookshelf", "docker": "docker buildx build --platform linux/amd64,linux/arm64 --push . -t advplyr/audiobookshelf",
@@ -18,7 +18,7 @@
"test": "mocha", "test": "mocha",
"coverage": "nyc mocha" "coverage": "nyc mocha"
}, },
"bin": "prod.js", "bin": "index.js",
"pkg": { "pkg": {
"assets": [ "assets": [
"client/dist/**/*", "client/dist/**/*",
@@ -26,7 +26,7 @@
"server/migrations/*.js" "server/migrations/*.js"
], ],
"scripts": [ "scripts": [
"prod.js", "index.js",
"server/**/*.js" "server/**/*.js"
] ]
}, },
+1 -1
View File
@@ -25,7 +25,7 @@ const CONFIG_PATH = inputConfig || process.env.CONFIG_PATH || Path.resolve('conf
const METADATA_PATH = inputMetadata || process.env.METADATA_PATH || Path.resolve('metadata') const METADATA_PATH = inputMetadata || process.env.METADATA_PATH || Path.resolve('metadata')
const SOURCE = options.source || process.env.SOURCE || 'debian' const SOURCE = options.source || process.env.SOURCE || 'debian'
const ROUTER_BASE_PATH = process.env.ROUTER_BASE_PATH || '' const ROUTER_BASE_PATH = process.env.ROUTER_BASE_PATH || '/audiobookshelf'
console.log(process.env.NODE_ENV, 'Config', CONFIG_PATH, METADATA_PATH) console.log(process.env.NODE_ENV, 'Config', CONFIG_PATH, METADATA_PATH)
+2 -2
View File
@@ -111,8 +111,8 @@ server {
location / { location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host; proxy_set_header Host $http_host;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
-17
View File
@@ -401,23 +401,6 @@ class Database {
return this.models.setting.updateSettingObj(settings.toJSON()) return this.models.setting.updateSettingObj(settings.toJSON())
} }
/**
* Save metadata file and update library item
*
* @param {import('./objects/LibraryItem')} oldLibraryItem
* @returns {Promise<boolean>}
*/
async updateLibraryItem(oldLibraryItem) {
if (!this.sequelize) return false
await oldLibraryItem.saveMetadata()
const updated = await this.models.libraryItem.fullUpdateFromOld(oldLibraryItem)
// Clear library filter data cache
if (updated) {
delete this.libraryFilterData[oldLibraryItem.libraryId]
}
return updated
}
getPlaybackSessions(where = null) { getPlaybackSessions(where = null) {
if (!this.sequelize) return false if (!this.sequelize) return false
return this.models.playbackSession.getOldPlaybackSessions(where) return this.models.playbackSession.getOldPlaybackSessions(where)
+6
View File
@@ -85,6 +85,12 @@ class Server {
} }
} }
if (process.env.PODCAST_DOWNLOAD_TIMEOUT) {
global.PodcastDownloadTimeout = process.env.PODCAST_DOWNLOAD_TIMEOUT
} else {
global.PodcastDownloadTimeout = 30000
}
if (!fs.pathExistsSync(global.ConfigPath)) { if (!fs.pathExistsSync(global.ConfigPath)) {
fs.mkdirSync(global.ConfigPath) fs.mkdirSync(global.ConfigPath)
} }
+10
View File
@@ -242,8 +242,18 @@ class AuthorController {
await CacheManager.purgeImageCache(req.author.id) // Purge cache await CacheManager.purgeImageCache(req.author.id) // Purge cache
} }
// Load library items so that metadata file can be updated
const allItemsWithAuthor = await Database.authorModel.getAllLibraryItemsForAuthor(req.author.id)
allItemsWithAuthor.forEach((libraryItem) => {
libraryItem.media.authors = libraryItem.media.authors.filter((au) => au.id !== req.author.id)
})
await req.author.destroy() await req.author.destroy()
for (const libraryItem of allItemsWithAuthor) {
await libraryItem.saveMetadataFile()
}
SocketAuthority.emitter('author_removed', req.author.toOldJSON()) SocketAuthority.emitter('author_removed', req.author.toOldJSON())
// Update filter data // Update filter data
+29 -7
View File
@@ -100,6 +100,15 @@ class LibraryController {
return res.status(400).send(`Invalid request. Settings "${key}" must be a string`) return res.status(400).send(`Invalid request. Settings "${key}" must be a string`)
} }
newLibraryPayload.settings[key] = req.body.settings[key] newLibraryPayload.settings[key] = req.body.settings[key]
} else if (key === 'markAsFinishedPercentComplete' || key === 'markAsFinishedTimeRemaining') {
if (req.body.settings[key] !== null && isNaN(req.body.settings[key])) {
return res.status(400).send(`Invalid request. Setting "${key}" must be a number`)
} else if (key === 'markAsFinishedPercentComplete' && req.body.settings[key] !== null && (Number(req.body.settings[key]) < 0 || Number(req.body.settings[key]) > 100)) {
return res.status(400).send(`Invalid request. Setting "${key}" must be between 0 and 100`)
} else if (key === 'markAsFinishedTimeRemaining' && req.body.settings[key] !== null && Number(req.body.settings[key]) < 0) {
return res.status(400).send(`Invalid request. Setting "${key}" must be greater than or equal to 0`)
}
newLibraryPayload.settings[key] = req.body.settings[key] === null ? null : Number(req.body.settings[key])
} else { } else {
if (typeof req.body.settings[key] !== typeof newLibraryPayload.settings[key]) { if (typeof req.body.settings[key] !== typeof newLibraryPayload.settings[key]) {
return res.status(400).send(`Invalid request. Setting "${key}" must be of type ${typeof newLibraryPayload.settings[key]}`) return res.status(400).send(`Invalid request. Setting "${key}" must be of type ${typeof newLibraryPayload.settings[key]}`)
@@ -170,21 +179,34 @@ class LibraryController {
* GET: /api/libraries * GET: /api/libraries
* Get all libraries * Get all libraries
* *
* ?include=stats to load library stats - used in android auto to filter out libraries with no audio
*
* @param {RequestWithUser} req * @param {RequestWithUser} req
* @param {Response} res * @param {Response} res
*/ */
async findAll(req, res) { async findAll(req, res) {
const libraries = await Database.libraryModel.getAllWithFolders() let libraries = await Database.libraryModel.getAllWithFolders()
const librariesAccessible = req.user.permissions?.librariesAccessible || [] const librariesAccessible = req.user.permissions?.librariesAccessible || []
if (librariesAccessible.length) { if (librariesAccessible.length) {
return res.json({ libraries = libraries.filter((lib) => librariesAccessible.includes(lib.id))
libraries: libraries.filter((lib) => librariesAccessible.includes(lib.id)).map((lib) => lib.toOldJSON()) }
})
libraries = libraries.map((lib) => lib.toOldJSON())
const includeArray = (req.query.include || '').split(',')
if (includeArray.includes('stats')) {
for (const library of libraries) {
if (library.mediaType === 'book') {
library.stats = await libraryItemsBookFilters.getBookLibraryStats(library.id)
} else if (library.mediaType === 'podcast') {
library.stats = await libraryItemsPodcastFilters.getPodcastLibraryStats(library.id)
}
}
} }
res.json({ res.json({
libraries: libraries.map((lib) => lib.toOldJSON()) libraries
}) })
} }
@@ -312,7 +334,7 @@ class LibraryController {
} }
if (req.body.settings[key] !== updatedSettings[key]) { if (req.body.settings[key] !== updatedSettings[key]) {
hasUpdates = true hasUpdates = true
updatedSettings[key] = Number(req.body.settings[key]) updatedSettings[key] = req.body.settings[key] === null ? null : Number(req.body.settings[key])
Logger.debug(`[LibraryController] Library "${req.library.name}" updating setting "${key}" to "${updatedSettings[key]}"`) Logger.debug(`[LibraryController] Library "${req.library.name}" updating setting "${key}" to "${updatedSettings[key]}"`)
} }
} else if (key === 'markAsFinishedTimeRemaining') { } else if (key === 'markAsFinishedTimeRemaining') {
@@ -325,7 +347,7 @@ class LibraryController {
} }
if (req.body.settings[key] !== updatedSettings[key]) { if (req.body.settings[key] !== updatedSettings[key]) {
hasUpdates = true hasUpdates = true
updatedSettings[key] = Number(req.body.settings[key]) updatedSettings[key] = req.body.settings[key] === null ? null : Number(req.body.settings[key])
Logger.debug(`[LibraryController] Library "${req.library.name}" updating setting "${key}" to "${updatedSettings[key]}"`) Logger.debug(`[LibraryController] Library "${req.library.name}" updating setting "${key}" to "${updatedSettings[key]}"`)
} }
} else { } else {
+74 -71
View File
@@ -81,31 +81,6 @@ class LibraryItemController {
res.json(req.libraryItem.toOldJSON()) res.json(req.libraryItem.toOldJSON())
} }
/**
* PATCH: /api/items/:id
*
* @deprecated
* Use the updateMedia /api/items/:id/media endpoint instead or updateCover /api/items/:id/cover
*
* @param {LibraryItemControllerRequest} req
* @param {Response} res
*/
async update(req, res) {
// Item has cover and update is removing cover so purge it from cache
if (req.libraryItem.media.coverPath && req.body.media && (req.body.media.coverPath === '' || req.body.media.coverPath === null)) {
await CacheManager.purgeCoverCache(req.libraryItem.id)
}
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(req.libraryItem)
const hasUpdates = oldLibraryItem.update(req.body)
if (hasUpdates) {
Logger.debug(`[LibraryItemController] Updated now saving`)
await Database.updateLibraryItem(oldLibraryItem)
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
}
res.json(oldLibraryItem.toJSON())
}
/** /**
* DELETE: /api/items/:id * DELETE: /api/items/:id
* Delete library item. Will delete from database and file system if hard delete is requested. * Delete library item. Will delete from database and file system if hard delete is requested.
@@ -219,11 +194,6 @@ class LibraryItemController {
if (res.writableEnded || res.headersSent) return if (res.writableEnded || res.headersSent) return
} }
// Book specific
if (req.libraryItem.isBook) {
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, req.libraryItem.libraryId)
}
// Podcast specific // Podcast specific
let isPodcastAutoDownloadUpdated = false let isPodcastAutoDownloadUpdated = false
if (req.libraryItem.isPodcast) { if (req.libraryItem.isPodcast) {
@@ -234,41 +204,56 @@ class LibraryItemController {
} }
} }
// Book specific - Get all series being removed from this item let hasUpdates = (await req.libraryItem.media.updateFromRequest(mediaPayload)) || mediaPayload.url
let seriesRemoved = []
if (req.libraryItem.isBook && mediaPayload.metadata?.series) { if (req.libraryItem.isBook && Array.isArray(mediaPayload.metadata?.series)) {
const seriesIdsInUpdate = mediaPayload.metadata.series?.map((se) => se.id) || [] const seriesUpdateData = await req.libraryItem.media.updateSeriesFromRequest(mediaPayload.metadata.series, req.libraryItem.libraryId)
seriesRemoved = req.libraryItem.media.series.filter((se) => !seriesIdsInUpdate.includes(se.id)) if (seriesUpdateData?.seriesRemoved.length) {
// Check remove empty series
Logger.debug(`[LibraryItemController] Series were removed from book. Check if series are now empty.`)
await this.checkRemoveEmptySeries(seriesUpdateData.seriesRemoved.map((se) => se.id))
}
if (seriesUpdateData?.seriesAdded.length) {
// Add series to filter data
seriesUpdateData.seriesAdded.forEach((se) => {
Database.addSeriesToFilterData(req.libraryItem.libraryId, se.name, se.id)
})
}
if (seriesUpdateData?.hasUpdates) {
hasUpdates = true
}
} }
let authorsRemoved = [] if (req.libraryItem.isBook && Array.isArray(mediaPayload.metadata?.authors)) {
if (req.libraryItem.isBook && mediaPayload.metadata?.authors) { const authorNames = mediaPayload.metadata.authors.map((au) => (typeof au.name === 'string' ? au.name.trim() : null)).filter((au) => au)
const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id) const authorUpdateData = await req.libraryItem.media.updateAuthorsFromRequest(authorNames, req.libraryItem.libraryId)
authorsRemoved = req.libraryItem.media.authors.filter((au) => !authorIdsInUpdate.includes(au.id)) if (authorUpdateData?.authorsRemoved.length) {
// Check remove empty authors
Logger.debug(`[LibraryItemController] Authors were removed from book. Check if authors are now empty.`)
await this.checkRemoveAuthorsWithNoBooks(authorUpdateData.authorsRemoved.map((au) => au.id))
hasUpdates = true
}
if (authorUpdateData?.authorsAdded.length) {
// Add authors to filter data
authorUpdateData.authorsAdded.forEach((au) => {
Database.addAuthorToFilterData(req.libraryItem.libraryId, au.name, au.id)
})
hasUpdates = true
}
} }
const hasUpdates = (await req.libraryItem.media.updateFromRequest(mediaPayload)) || mediaPayload.url
if (hasUpdates) { if (hasUpdates) {
req.libraryItem.changed('updatedAt', true) req.libraryItem.changed('updatedAt', true)
await req.libraryItem.save() await req.libraryItem.save()
await req.libraryItem.saveMetadataFile()
if (isPodcastAutoDownloadUpdated) { if (isPodcastAutoDownloadUpdated) {
this.cronManager.checkUpdatePodcastCron(req.libraryItem) this.cronManager.checkUpdatePodcastCron(req.libraryItem)
} }
Logger.debug(`[LibraryItemController] Updated library item media ${req.libraryItem.media.title}`) Logger.debug(`[LibraryItemController] Updated library item media ${req.libraryItem.media.title}`)
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded()) SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
if (authorsRemoved.length) {
// Check remove empty authors
Logger.debug(`[LibraryItemController] Authors were removed from book. Check if authors are now empty.`)
await this.checkRemoveAuthorsWithNoBooks(authorsRemoved.map((au) => au.id))
}
if (seriesRemoved.length) {
// Check remove empty series
Logger.debug(`[LibraryItemController] Series were removed from book. Check if series are now empty.`)
await this.checkRemoveEmptySeries(seriesRemoved.map((se) => se.id))
}
} }
res.json({ res.json({
updated: hasUpdates, updated: hasUpdates,
@@ -281,7 +266,7 @@ class LibraryItemController {
* *
* @param {LibraryItemControllerRequest} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
* @param {boolean} [updateAndReturnJson=true] * @param {boolean} [updateAndReturnJson=true] - Allows the function to be used for both direct API calls and internally
*/ */
async uploadCover(req, res, updateAndReturnJson = true) { async uploadCover(req, res, updateAndReturnJson = true) {
if (!req.user.canUpload) { if (!req.user.canUpload) {
@@ -306,11 +291,11 @@ class LibraryItemController {
return res.status(500).send('Unknown error occurred') return res.status(500).send('Unknown error occurred')
} }
if (updateAndReturnJson) { req.libraryItem.media.coverPath = result.cover
req.libraryItem.media.coverPath = result.cover req.libraryItem.media.changed('coverPath', true)
req.libraryItem.media.changed('coverPath', true) await req.libraryItem.media.save()
await req.libraryItem.media.save()
if (updateAndReturnJson) {
// client uses updatedAt timestamp in URL to force refresh cover // client uses updatedAt timestamp in URL to force refresh cover
req.libraryItem.changed('updatedAt', true) req.libraryItem.changed('updatedAt', true)
await req.libraryItem.save() await req.libraryItem.save()
@@ -527,8 +512,7 @@ class LibraryItemController {
options.overrideDetails = !!reqBody.overrideDetails options.overrideDetails = !!reqBody.overrideDetails
} }
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(req.libraryItem) const matchResult = await Scanner.quickMatchLibraryItem(this, req.libraryItem, options)
var matchResult = await Scanner.quickMatchLibraryItem(this, oldLibraryItem, options)
res.json(matchResult) res.json(matchResult)
} }
@@ -640,26 +624,44 @@ class LibraryItemController {
const mediaPayload = updatePayload.mediaPayload const mediaPayload = updatePayload.mediaPayload
const libraryItem = libraryItems.find((li) => li.id === updatePayload.id) const libraryItem = libraryItems.find((li) => li.id === updatePayload.id)
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId) let hasUpdates = await libraryItem.media.updateFromRequest(mediaPayload)
if (libraryItem.isBook) { if (libraryItem.isBook && Array.isArray(mediaPayload.metadata?.series)) {
if (Array.isArray(mediaPayload.metadata?.series)) { const seriesUpdateData = await libraryItem.media.updateSeriesFromRequest(mediaPayload.metadata.series, libraryItem.libraryId)
const seriesIdsInUpdate = mediaPayload.metadata.series.map((se) => se.id) if (seriesUpdateData?.seriesRemoved.length) {
const seriesRemoved = libraryItem.media.series.filter((se) => !seriesIdsInUpdate.includes(se.id)) seriesIdsRemoved.push(...seriesUpdateData.seriesRemoved.map((se) => se.id))
seriesIdsRemoved.push(...seriesRemoved.map((se) => se.id))
} }
if (Array.isArray(mediaPayload.metadata?.authors)) { if (seriesUpdateData?.seriesAdded.length) {
const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id) seriesUpdateData.seriesAdded.forEach((se) => {
const authorsRemoved = libraryItem.media.authors.filter((au) => !authorIdsInUpdate.includes(au.id)) Database.addSeriesToFilterData(libraryItem.libraryId, se.name, se.id)
authorIdsRemoved.push(...authorsRemoved.map((au) => au.id)) })
}
if (seriesUpdateData?.hasUpdates) {
hasUpdates = true
}
}
if (libraryItem.isBook && Array.isArray(mediaPayload.metadata?.authors)) {
const authorNames = mediaPayload.metadata.authors.map((au) => (typeof au.name === 'string' ? au.name.trim() : null)).filter((au) => au)
const authorUpdateData = await libraryItem.media.updateAuthorsFromRequest(authorNames, libraryItem.libraryId)
if (authorUpdateData?.authorsRemoved.length) {
authorIdsRemoved.push(...authorUpdateData.authorsRemoved.map((au) => au.id))
hasUpdates = true
}
if (authorUpdateData?.authorsAdded.length) {
authorUpdateData.authorsAdded.forEach((au) => {
Database.addAuthorToFilterData(libraryItem.libraryId, au.name, au.id)
})
hasUpdates = true
} }
} }
const hasUpdates = await libraryItem.media.updateFromRequest(mediaPayload)
if (hasUpdates) { if (hasUpdates) {
libraryItem.changed('updatedAt', true) libraryItem.changed('updatedAt', true)
await libraryItem.save() await libraryItem.save()
await libraryItem.saveMetadataFile()
Logger.debug(`[LibraryItemController] Updated library item media "${libraryItem.media.title}"`) Logger.debug(`[LibraryItemController] Updated library item media "${libraryItem.media.title}"`)
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded()) SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
itemsUpdated++ itemsUpdated++
@@ -739,8 +741,7 @@ class LibraryItemController {
} }
for (const libraryItem of libraryItems) { for (const libraryItem of libraryItems) {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem) const matchResult = await Scanner.quickMatchLibraryItem(this, libraryItem, options)
const matchResult = await Scanner.quickMatchLibraryItem(this, oldLibraryItem, options)
if (matchResult.updated) { if (matchResult.updated) {
itemsUpdated++ itemsUpdated++
} else if (matchResult.warning) { } else if (matchResult.warning) {
@@ -891,6 +892,8 @@ class LibraryItemController {
req.libraryItem.media.changed('chapters', true) req.libraryItem.media.changed('chapters', true)
await req.libraryItem.media.save() await req.libraryItem.media.save()
await req.libraryItem.saveMetadataFile()
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded()) SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
} }
+5 -4
View File
@@ -375,11 +375,9 @@ class PodcastController {
} }
const overrideDetails = req.query.override === '1' const overrideDetails = req.query.override === '1'
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(req.libraryItem) const episodesUpdated = await Scanner.quickMatchPodcastEpisodes(req.libraryItem, { overrideDetails })
const episodesUpdated = await Scanner.quickMatchPodcastEpisodes(oldLibraryItem, { overrideDetails })
if (episodesUpdated) { if (episodesUpdated) {
await Database.updateLibraryItem(oldLibraryItem) SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
} }
res.json({ res.json({
@@ -463,6 +461,9 @@ class PodcastController {
return res.sendStatus(404) return res.sendStatus(404)
} }
// Remove it from the podcastEpisodes array
req.libraryItem.media.podcastEpisodes = req.libraryItem.media.podcastEpisodes.filter((ep) => ep.id !== episodeId)
if (hardDelete) { if (hardDelete) {
const audioFile = episode.audioFile const audioFile = episode.audioFile
// TODO: this will trigger the watcher. should maybe handle this gracefully // TODO: this will trigger the watcher. should maybe handle this gracefully
+1 -1
View File
@@ -24,7 +24,7 @@ class SearchController {
*/ */
async findBooks(req, res) { async findBooks(req, res) {
const id = req.query.id const id = req.query.id
const libraryItem = await Database.libraryItemModel.getOldById(id) const libraryItem = await Database.libraryItemModel.getExpandedById(id)
const provider = req.query.provider || 'google' const provider = req.query.provider || 'google'
const title = req.query.title || '' const title = req.query.title || ''
const author = req.query.author || '' const author = req.query.author || ''
+13 -8
View File
@@ -7,6 +7,11 @@ const Database = require('../Database')
* @property {import('../models/User')} user * @property {import('../models/User')} user
* *
* @typedef {Request & RequestUserObject} RequestWithUser * @typedef {Request & RequestUserObject} RequestWithUser
*
* @typedef RequestEntityObject
* @property {import('../models/LibraryItem')} libraryItem
*
* @typedef {RequestWithUser & RequestEntityObject} RequestWithLibraryItem
*/ */
class ToolsController { class ToolsController {
@@ -18,7 +23,7 @@ class ToolsController {
* *
* @this import('../routers/ApiRouter') * @this import('../routers/ApiRouter')
* *
* @param {RequestWithUser} req * @param {RequestWithLibraryItem} req
* @param {Response} res * @param {Response} res
*/ */
async encodeM4b(req, res) { async encodeM4b(req, res) {
@@ -27,12 +32,12 @@ class ToolsController {
return res.status(404).send('Audiobook not found') return res.status(404).send('Audiobook not found')
} }
if (req.libraryItem.mediaType !== 'book') { if (!req.libraryItem.isBook) {
Logger.error(`[MiscController] encodeM4b: Invalid library item ${req.params.id}: not a book`) Logger.error(`[MiscController] encodeM4b: Invalid library item ${req.params.id}: not a book`)
return res.status(400).send('Invalid library item: not a book') return res.status(400).send('Invalid library item: not a book')
} }
if (req.libraryItem.media.tracks.length <= 0) { if (!req.libraryItem.hasAudioTracks) {
Logger.error(`[MiscController] encodeM4b: Invalid audiobook ${req.params.id}: no audio tracks`) Logger.error(`[MiscController] encodeM4b: Invalid audiobook ${req.params.id}: no audio tracks`)
return res.status(400).send('Invalid audiobook: no audio tracks') return res.status(400).send('Invalid audiobook: no audio tracks')
} }
@@ -72,11 +77,11 @@ class ToolsController {
* *
* @this import('../routers/ApiRouter') * @this import('../routers/ApiRouter')
* *
* @param {RequestWithUser} req * @param {RequestWithLibraryItem} req
* @param {Response} res * @param {Response} res
*/ */
async embedAudioFileMetadata(req, res) { async embedAudioFileMetadata(req, res) {
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) { if (req.libraryItem.isMissing || !req.libraryItem.hasAudioTracks || !req.libraryItem.isBook) {
Logger.error(`[ToolsController] Invalid library item`) Logger.error(`[ToolsController] Invalid library item`)
return res.sendStatus(400) return res.sendStatus(400)
} }
@@ -111,7 +116,7 @@ class ToolsController {
const libraryItems = [] const libraryItems = []
for (const libraryItemId of libraryItemIds) { for (const libraryItemId of libraryItemIds) {
const libraryItem = await Database.libraryItemModel.getOldById(libraryItemId) const libraryItem = await Database.libraryItemModel.getExpandedById(libraryItemId)
if (!libraryItem) { if (!libraryItem) {
Logger.error(`[ToolsController] Batch embed metadata library item (${libraryItemId}) not found`) Logger.error(`[ToolsController] Batch embed metadata library item (${libraryItemId}) not found`)
return res.sendStatus(404) return res.sendStatus(404)
@@ -123,7 +128,7 @@ class ToolsController {
return res.sendStatus(403) return res.sendStatus(403)
} }
if (libraryItem.isMissing || !libraryItem.hasAudioFiles || !libraryItem.isBook) { if (libraryItem.isMissing || !libraryItem.hasAudioTracks || !libraryItem.isBook) {
Logger.error(`[ToolsController] Batch embed invalid library item (${libraryItemId})`) Logger.error(`[ToolsController] Batch embed invalid library item (${libraryItemId})`)
return res.sendStatus(400) return res.sendStatus(400)
} }
@@ -157,7 +162,7 @@ class ToolsController {
} }
if (req.params.id) { if (req.params.id) {
const item = await Database.libraryItemModel.getOldById(req.params.id) const item = await Database.libraryItemModel.getExpandedById(req.params.id)
if (!item?.media) return res.sendStatus(404) if (!item?.media) return res.sendStatus(404)
// Check user can access this library item // Check user can access this library item
+1 -1
View File
@@ -361,7 +361,7 @@ class BookFinder {
/** /**
* Search for books including fuzzy searches * Search for books including fuzzy searches
* *
* @param {Object} libraryItem * @param {import('../models/LibraryItem')} libraryItem
* @param {string} provider * @param {string} provider
* @param {string} title * @param {string} title
* @param {string} author * @param {string} author
+7 -7
View File
@@ -51,7 +51,7 @@ class AbMergeManager {
/** /**
* *
* @param {string} userId * @param {string} userId
* @param {import('../objects/LibraryItem')} libraryItem * @param {import('../models/LibraryItem')} libraryItem
* @param {AbMergeEncodeOptions} [options={}] * @param {AbMergeEncodeOptions} [options={}]
*/ */
async startAudiobookMerge(userId, libraryItem, options = {}) { async startAudiobookMerge(userId, libraryItem, options = {}) {
@@ -67,7 +67,7 @@ class AbMergeManager {
libraryItemId: libraryItem.id, libraryItemId: libraryItem.id,
libraryItemDir, libraryItemDir,
userId, userId,
originalTrackPaths: libraryItem.media.tracks.map((t) => t.metadata.path), originalTrackPaths: libraryItem.media.includedAudioFiles.map((t) => t.metadata.path),
inos: libraryItem.media.includedAudioFiles.map((f) => f.ino), inos: libraryItem.media.includedAudioFiles.map((f) => f.ino),
tempFilepath, tempFilepath,
targetFilename, targetFilename,
@@ -86,9 +86,9 @@ class AbMergeManager {
key: 'MessageTaskEncodingM4b' key: 'MessageTaskEncodingM4b'
} }
const taskDescriptionString = { const taskDescriptionString = {
text: `Encoding audiobook "${libraryItem.media.metadata.title}" into a single m4b file.`, text: `Encoding audiobook "${libraryItem.media.title}" into a single m4b file.`,
key: 'MessageTaskEncodingM4bDescription', key: 'MessageTaskEncodingM4bDescription',
subs: [libraryItem.media.metadata.title] subs: [libraryItem.media.title]
} }
task.setData('encode-m4b', taskTitleString, taskDescriptionString, false, taskData) task.setData('encode-m4b', taskTitleString, taskDescriptionString, false, taskData)
TaskManager.addTask(task) TaskManager.addTask(task)
@@ -103,7 +103,7 @@ class AbMergeManager {
/** /**
* *
* @param {import('../objects/LibraryItem')} libraryItem * @param {import('../models/LibraryItem')} libraryItem
* @param {Task} task * @param {Task} task
* @param {AbMergeEncodeOptions} encodingOptions * @param {AbMergeEncodeOptions} encodingOptions
*/ */
@@ -141,7 +141,7 @@ class AbMergeManager {
const embedFraction = 1 - encodeFraction const embedFraction = 1 - encodeFraction
try { try {
const trackProgressMonitor = new TrackProgressMonitor( const trackProgressMonitor = new TrackProgressMonitor(
libraryItem.media.tracks.map((t) => t.duration), libraryItem.media.includedAudioFiles.map((t) => t.duration),
(trackIndex) => SocketAuthority.adminEmitter('track_started', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex] }), (trackIndex) => SocketAuthority.adminEmitter('track_started', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex] }),
(trackIndex, progressInTrack, taskProgress) => { (trackIndex, progressInTrack, taskProgress) => {
SocketAuthority.adminEmitter('track_progress', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex], progress: progressInTrack }) SocketAuthority.adminEmitter('track_progress', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex], progress: progressInTrack })
@@ -150,7 +150,7 @@ class AbMergeManager {
(trackIndex) => SocketAuthority.adminEmitter('track_finished', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex] }) (trackIndex) => SocketAuthority.adminEmitter('track_finished', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex] })
) )
task.data.ffmpeg = new Ffmpeg() task.data.ffmpeg = new Ffmpeg()
await ffmpegHelpers.mergeAudioFiles(libraryItem.media.tracks, task.data.duration, task.data.itemCachePath, task.data.tempFilepath, encodingOptions, (progress) => trackProgressMonitor.update(progress), task.data.ffmpeg) await ffmpegHelpers.mergeAudioFiles(libraryItem.media.includedAudioFiles, task.data.duration, task.data.itemCachePath, task.data.tempFilepath, encodingOptions, (progress) => trackProgressMonitor.update(progress), task.data.ffmpeg)
delete task.data.ffmpeg delete task.data.ffmpeg
trackProgressMonitor.finish() trackProgressMonitor.finish()
} catch (error) { } catch (error) {
+1
View File
@@ -42,6 +42,7 @@ class ApiCacheManager {
Logger.debug(`[ApiCacheManager] Skipping cache for random sort`) Logger.debug(`[ApiCacheManager] Skipping cache for random sort`)
return next() return next()
} }
const key = { user: req.user.username, url: req.url } const key = { user: req.user.username, url: req.url }
const stringifiedKey = JSON.stringify(key) const stringifiedKey = JSON.stringify(key)
Logger.debug(`[ApiCacheManager] count: ${this.cache.size} size: ${this.cache.calculatedSize}`) Logger.debug(`[ApiCacheManager] count: ${this.cache.size} size: ${this.cache.calculatedSize}`)
+7 -7
View File
@@ -40,14 +40,14 @@ class AudioMetadataMangaer {
* @returns * @returns
*/ */
getMetadataObjectForApi(libraryItem) { getMetadataObjectForApi(libraryItem) {
return ffmpegHelpers.getFFMetadataObject(libraryItem.toOldJSONExpanded(), libraryItem.media.includedAudioFiles.length) return ffmpegHelpers.getFFMetadataObject(libraryItem, libraryItem.media.includedAudioFiles.length)
} }
/** /**
* *
* @param {string} userId * @param {string} userId
* @param {*} libraryItems * @param {import('../models/LibraryItem')[]} libraryItems
* @param {*} options * @param {UpdateMetadataOptions} options
*/ */
handleBatchEmbed(userId, libraryItems, options = {}) { handleBatchEmbed(userId, libraryItems, options = {}) {
libraryItems.forEach((li) => { libraryItems.forEach((li) => {
@@ -58,7 +58,7 @@ class AudioMetadataMangaer {
/** /**
* *
* @param {string} userId * @param {string} userId
* @param {import('../objects/LibraryItem')} libraryItem * @param {import('../models/LibraryItem')} libraryItem
* @param {UpdateMetadataOptions} [options={}] * @param {UpdateMetadataOptions} [options={}]
*/ */
async updateMetadataForItem(userId, libraryItem, options = {}) { async updateMetadataForItem(userId, libraryItem, options = {}) {
@@ -108,14 +108,14 @@ class AudioMetadataMangaer {
key: 'MessageTaskEmbeddingMetadata' key: 'MessageTaskEmbeddingMetadata'
} }
const taskDescriptionString = { const taskDescriptionString = {
text: `Embedding metadata in audiobook "${libraryItem.media.metadata.title}".`, text: `Embedding metadata in audiobook "${libraryItem.media.title}".`,
key: 'MessageTaskEmbeddingMetadataDescription', key: 'MessageTaskEmbeddingMetadataDescription',
subs: [libraryItem.media.metadata.title] subs: [libraryItem.media.title]
} }
task.setData('embed-metadata', taskTitleString, taskDescriptionString, false, taskData) task.setData('embed-metadata', taskTitleString, taskDescriptionString, false, taskData)
if (this.tasksRunning.length >= this.MAX_CONCURRENT_TASKS) { if (this.tasksRunning.length >= this.MAX_CONCURRENT_TASKS) {
Logger.info(`[AudioMetadataManager] Queueing embed metadata for audiobook "${libraryItem.media.metadata.title}"`) Logger.info(`[AudioMetadataManager] Queueing embed metadata for audiobook "${libraryItem.media.title}"`)
SocketAuthority.adminEmitter('metadata_embed_queue_update', { SocketAuthority.adminEmitter('metadata_embed_queue_update', {
libraryItemId: libraryItem.id, libraryItemId: libraryItem.id,
queued: true queued: true
-55
View File
@@ -123,61 +123,6 @@ class CoverManager {
} }
} }
/**
*
* @param {Object} libraryItem - old library item
* @param {string} url
* @param {boolean} [forceLibraryItemFolder=false]
* @returns {Promise<{error:string}|{cover:string}>}
*/
async downloadCoverFromUrl(libraryItem, url, forceLibraryItemFolder = false) {
try {
// Force save cover with library item is used for adding new podcasts
var coverDirPath = forceLibraryItemFolder ? libraryItem.path : this.getCoverDirectory(libraryItem)
await fs.ensureDir(coverDirPath)
var temppath = Path.posix.join(coverDirPath, 'cover')
let errorMsg = ''
let success = await downloadImageFile(url, temppath)
.then(() => true)
.catch((err) => {
errorMsg = err.message || 'Unknown error'
Logger.error(`[CoverManager] Download image file failed for "${url}"`, errorMsg)
return false
})
if (!success) {
return {
error: 'Failed to download image from url: ' + errorMsg
}
}
var imgtype = await this.checkFileIsValidImage(temppath, true)
if (imgtype.error) {
return imgtype
}
var coverFilename = `cover.${imgtype.ext}`
var coverFullPath = Path.posix.join(coverDirPath, coverFilename)
await fs.rename(temppath, coverFullPath)
await this.removeOldCovers(coverDirPath, '.' + imgtype.ext)
await CacheManager.purgeCoverCache(libraryItem.id)
Logger.info(`[CoverManager] Downloaded libraryItem cover "${coverFullPath}" from url "${url}" for "${libraryItem.media.metadata.title}"`)
libraryItem.updateMediaCover(coverFullPath)
return {
cover: coverFullPath
}
} catch (error) {
Logger.error(`[CoverManager] Fetch cover image from url "${url}" failed`, error)
return {
error: 'Failed to fetch image from url'
}
}
}
/** /**
* *
* @param {string} coverPath * @param {string} coverPath
+5 -3
View File
@@ -343,20 +343,20 @@ class PlaybackSessionManager {
* @param {import('../models/User')} user * @param {import('../models/User')} user
* @param {*} session * @param {*} session
* @param {*} syncData * @param {*} syncData
* @returns * @returns {Promise<boolean>}
*/ */
async syncSession(user, session, syncData) { async syncSession(user, session, syncData) {
// TODO: Combine libraryItem query with library query // TODO: Combine libraryItem query with library query
const libraryItem = await Database.libraryItemModel.getExpandedById(session.libraryItemId) const libraryItem = await Database.libraryItemModel.getExpandedById(session.libraryItemId)
if (!libraryItem) { if (!libraryItem) {
Logger.error(`[PlaybackSessionManager] syncSession Library Item not found "${session.libraryItemId}"`) Logger.error(`[PlaybackSessionManager] syncSession Library Item not found "${session.libraryItemId}"`)
return null return false
} }
const library = await Database.libraryModel.findByPk(libraryItem.libraryId) const library = await Database.libraryModel.findByPk(libraryItem.libraryId)
if (!library) { if (!library) {
Logger.error(`[PlaybackSessionManager] syncSession Library not found "${libraryItem.libraryId}"`) Logger.error(`[PlaybackSessionManager] syncSession Library not found "${libraryItem.libraryId}"`)
return null return false
} }
session.currentTime = syncData.currentTime session.currentTime = syncData.currentTime
@@ -382,6 +382,8 @@ class PlaybackSessionManager {
}) })
} }
this.saveSession(session) this.saveSession(session)
return true
} }
/** /**
+16 -2
View File
@@ -115,10 +115,24 @@ class PodcastManager {
let success = false let success = false
if (this.currentDownload.isMp3) { if (this.currentDownload.isMp3) {
// Download episode and tag it // Download episode and tag it
success = await ffmpegHelpers.downloadPodcastEpisode(this.currentDownload).catch((error) => { const ffmpegDownloadResponse = await ffmpegHelpers.downloadPodcastEpisode(this.currentDownload).catch((error) => {
Logger.error(`[PodcastManager] Podcast Episode download failed`, error) Logger.error(`[PodcastManager] Podcast Episode download failed`, error)
return false
}) })
success = !!ffmpegDownloadResponse?.success
// If failed due to ffmpeg error, retry without tagging
// e.g. RSS feed may have incorrect file extension and file type
// See https://github.com/advplyr/audiobookshelf/issues/3837
if (!success && ffmpegDownloadResponse?.isFfmpegError) {
Logger.info(`[PodcastManager] Retrying episode download without tagging`)
// Download episode only
success = await downloadFile(this.currentDownload.url, this.currentDownload.targetPath)
.then(() => true)
.catch((error) => {
Logger.error(`[PodcastManager] Podcast Episode download failed`, error)
return false
})
}
} else { } else {
// Download episode only // Download episode only
success = await downloadFile(this.currentDownload.url, this.currentDownload.targetPath) success = await downloadFile(this.currentDownload.url, this.currentDownload.targetPath)
+16
View File
@@ -107,6 +107,22 @@ class Author extends Model {
return libraryItems return libraryItems
} }
/**
*
* @param {string} name
* @param {string} libraryId
* @returns {Promise<Author>}
*/
static async findOrCreateByNameAndLibrary(name, libraryId) {
const author = await this.getByNameAndLibrary(name, libraryId)
if (author) return author
return this.create({
name,
lastFirst: this.getLastFirst(name),
libraryId
})
}
/** /**
* Initialize model * Initialize model
* @param {import('../Database').sequelize} sequelize * @param {import('../Database').sequelize} sequelize
+104 -164
View File
@@ -130,130 +130,6 @@ class Book extends Model {
this.series this.series
} }
static getOldBook(libraryItemExpanded) {
const bookExpanded = libraryItemExpanded.media
let authors = []
if (bookExpanded.authors?.length) {
authors = bookExpanded.authors.map((au) => {
return {
id: au.id,
name: au.name
}
})
} else if (bookExpanded.bookAuthors?.length) {
authors = bookExpanded.bookAuthors
.map((ba) => {
if (ba.author) {
return {
id: ba.author.id,
name: ba.author.name
}
} else {
Logger.error(`[Book] Invalid bookExpanded bookAuthors: no author`, ba)
return null
}
})
.filter((a) => a)
}
let series = []
if (bookExpanded.series?.length) {
series = bookExpanded.series.map((se) => {
return {
id: se.id,
name: se.name,
sequence: se.bookSeries.sequence
}
})
} else if (bookExpanded.bookSeries?.length) {
series = bookExpanded.bookSeries
.map((bs) => {
if (bs.series) {
return {
id: bs.series.id,
name: bs.series.name,
sequence: bs.sequence
}
} else {
Logger.error(`[Book] Invalid bookExpanded bookSeries: no series`, bs)
return null
}
})
.filter((s) => s)
}
return {
id: bookExpanded.id,
libraryItemId: libraryItemExpanded.id,
coverPath: bookExpanded.coverPath,
tags: bookExpanded.tags,
audioFiles: bookExpanded.audioFiles,
chapters: bookExpanded.chapters,
ebookFile: bookExpanded.ebookFile,
metadata: {
title: bookExpanded.title,
subtitle: bookExpanded.subtitle,
authors: authors,
narrators: bookExpanded.narrators,
series: series,
genres: bookExpanded.genres,
publishedYear: bookExpanded.publishedYear,
publishedDate: bookExpanded.publishedDate,
publisher: bookExpanded.publisher,
description: bookExpanded.description,
isbn: bookExpanded.isbn,
asin: bookExpanded.asin,
language: bookExpanded.language,
explicit: bookExpanded.explicit,
abridged: bookExpanded.abridged
}
}
}
/**
* @param {object} oldBook
* @returns {boolean} true if updated
*/
static saveFromOld(oldBook) {
const book = this.getFromOld(oldBook)
return this.update(book, {
where: {
id: book.id
}
})
.then((result) => result[0] > 0)
.catch((error) => {
Logger.error(`[Book] Failed to save book ${book.id}`, error)
return false
})
}
static getFromOld(oldBook) {
return {
id: oldBook.id,
title: oldBook.metadata.title,
titleIgnorePrefix: oldBook.metadata.titleIgnorePrefix,
subtitle: oldBook.metadata.subtitle,
publishedYear: oldBook.metadata.publishedYear,
publishedDate: oldBook.metadata.publishedDate,
publisher: oldBook.metadata.publisher,
description: oldBook.metadata.description,
isbn: oldBook.metadata.isbn,
asin: oldBook.metadata.asin,
language: oldBook.metadata.language,
explicit: !!oldBook.metadata.explicit,
abridged: !!oldBook.metadata.abridged,
narrators: oldBook.metadata.narrators,
ebookFile: oldBook.ebookFile?.toJSON() || null,
coverPath: oldBook.coverPath,
duration: oldBook.duration,
audioFiles: oldBook.audioFiles?.map((af) => af.toJSON()) || [],
chapters: oldBook.chapters,
tags: oldBook.tags,
genres: oldBook.metadata.genres
}
}
/** /**
* Initialize model * Initialize model
* @param {import('../Database').sequelize} sequelize * @param {import('../Database').sequelize} sequelize
@@ -542,49 +418,113 @@ class Book extends Model {
await this.save() await this.save()
} }
if (Array.isArray(payload.metadata?.authors)) {
const authorsRemoved = this.authors.filter((au) => !payload.metadata.authors.some((a) => a.id === au.id))
const newAuthors = payload.metadata.authors.filter((a) => !this.authors.some((au) => au.id === a.id))
for (const author of authorsRemoved) {
await this.sequelize.models.bookAuthor.removeByIds(author.id, this.id)
Logger.debug(`[Book] "${this.title}" Removed author ${author.id}`)
hasUpdates = true
}
for (const author of newAuthors) {
await this.sequelize.models.bookAuthor.create({ bookId: this.id, authorId: author.id })
Logger.debug(`[Book] "${this.title}" Added author ${author.id}`)
hasUpdates = true
}
}
if (Array.isArray(payload.metadata?.series)) {
const seriesRemoved = this.series.filter((se) => !payload.metadata.series.some((s) => s.id === se.id))
const newSeries = payload.metadata.series.filter((s) => !this.series.some((se) => se.id === s.id))
for (const series of seriesRemoved) {
await this.sequelize.models.bookSeries.removeByIds(series.id, this.id)
Logger.debug(`[Book] "${this.title}" Removed series ${series.id}`)
hasUpdates = true
}
for (const series of newSeries) {
await this.sequelize.models.bookSeries.create({ bookId: this.id, seriesId: series.id, sequence: series.sequence })
Logger.debug(`[Book] "${this.title}" Added series ${series.id}`)
hasUpdates = true
}
for (const series of payload.metadata.series) {
const existingSeries = this.series.find((se) => se.id === series.id)
if (existingSeries && existingSeries.bookSeries.sequence !== series.sequence) {
await existingSeries.bookSeries.update({ sequence: series.sequence })
Logger.debug(`[Book] "${this.title}" Updated series ${series.id} sequence ${series.sequence}`)
hasUpdates = true
}
}
}
return hasUpdates return hasUpdates
} }
/**
* Creates or removes authors from the book using the author names from the request
*
* @param {string[]} authors
* @param {string} libraryId
* @returns {Promise<{authorsRemoved: import('./Author')[], authorsAdded: import('./Author')[]}>}
*/
async updateAuthorsFromRequest(authors, libraryId) {
if (!Array.isArray(authors)) return null
if (!this.authors) {
throw new Error(`[Book] Cannot update authors because authors are not loaded for book ${this.id}`)
}
/** @type {typeof import('./Author')} */
const authorModel = this.sequelize.models.author
/** @type {typeof import('./BookAuthor')} */
const bookAuthorModel = this.sequelize.models.bookAuthor
const authorsCleaned = authors.map((a) => a.toLowerCase()).filter((a) => a)
const authorsRemoved = this.authors.filter((au) => !authorsCleaned.includes(au.name.toLowerCase()))
const newAuthorNames = authors.filter((a) => !this.authors.some((au) => au.name.toLowerCase() === a.toLowerCase()))
for (const author of authorsRemoved) {
await bookAuthorModel.removeByIds(author.id, this.id)
Logger.debug(`[Book] "${this.title}" Removed author "${author.name}"`)
this.authors = this.authors.filter((au) => au.id !== author.id)
}
const authorsAdded = []
for (const authorName of newAuthorNames) {
const author = await authorModel.findOrCreateByNameAndLibrary(authorName, libraryId)
await bookAuthorModel.create({ bookId: this.id, authorId: author.id })
Logger.debug(`[Book] "${this.title}" Added author "${author.name}"`)
this.authors.push(author)
authorsAdded.push(author)
}
return {
authorsRemoved,
authorsAdded
}
}
/**
* Creates or removes series from the book using the series names from the request.
* Updates series sequence if it has changed.
*
* @param {{ name: string, sequence: string }[]} seriesObjects
* @param {string} libraryId
* @returns {Promise<{seriesRemoved: import('./Series')[], seriesAdded: import('./Series')[], hasUpdates: boolean}>}
*/
async updateSeriesFromRequest(seriesObjects, libraryId) {
if (!Array.isArray(seriesObjects) || seriesObjects.some((se) => !se.name || typeof se.name !== 'string')) return null
if (!this.series) {
throw new Error(`[Book] Cannot update series because series are not loaded for book ${this.id}`)
}
/** @type {typeof import('./Series')} */
const seriesModel = this.sequelize.models.series
/** @type {typeof import('./BookSeries')} */
const bookSeriesModel = this.sequelize.models.bookSeries
const seriesNamesCleaned = seriesObjects.map((se) => se.name.toLowerCase())
const seriesRemoved = this.series.filter((se) => !seriesNamesCleaned.includes(se.name.toLowerCase()))
const seriesAdded = []
let hasUpdates = false
for (const seriesObj of seriesObjects) {
const seriesObjSequence = typeof seriesObj.sequence === 'string' ? seriesObj.sequence : null
const existingSeries = this.series.find((se) => se.name.toLowerCase() === seriesObj.name.toLowerCase())
if (existingSeries) {
if (existingSeries.bookSeries.sequence !== seriesObjSequence) {
existingSeries.bookSeries.sequence = seriesObjSequence
await existingSeries.bookSeries.save()
hasUpdates = true
Logger.debug(`[Book] "${this.title}" Updated series "${existingSeries.name}" sequence ${seriesObjSequence}`)
}
} else {
const series = await seriesModel.findOrCreateByNameAndLibrary(seriesObj.name, libraryId)
series.bookSeries = await bookSeriesModel.create({ bookId: this.id, seriesId: series.id, sequence: seriesObjSequence })
this.series.push(series)
seriesAdded.push(series)
hasUpdates = true
Logger.debug(`[Book] "${this.title}" Added series "${series.name}"`)
}
}
for (const series of seriesRemoved) {
await bookSeriesModel.removeByIds(series.id, this.id)
this.series = this.series.filter((se) => se.id !== series.id)
Logger.debug(`[Book] "${this.title}" Removed series ${series.id}`)
hasUpdates = true
}
return {
seriesRemoved,
seriesAdded,
hasUpdates
}
}
/** /**
* Old model kept metadata in a separate object * Old model kept metadata in a separate object
*/ */
+37 -21
View File
@@ -561,7 +561,42 @@ class Feed extends Model {
* @param {string} hostPrefix * @param {string} hostPrefix
*/ */
buildXml(hostPrefix) { buildXml(hostPrefix) {
const blockTags = [{ 'itunes:block': 'yes' }, { 'googleplay:block': 'yes' }] const customElements = [
{ language: this.language || 'en' },
{ author: this.author || 'advplyr' },
{ 'itunes:author': this.author || 'advplyr' },
{ 'itunes:type': this.podcastType || 'serial' },
{
'itunes:image': {
_attr: {
href: `${hostPrefix}${this.imageURL}`
}
}
},
{ 'itunes:explicit': !!this.explicit }
]
if (this.description) {
customElements.push({ 'itunes:summary': { _cdata: this.description } })
}
const itunesOwnersData = []
if (this.ownerName || this.author) {
itunesOwnersData.push({ 'itunes:name': this.ownerName || this.author })
}
if (this.ownerEmail) {
itunesOwnersData.push({ 'itunes:email': this.ownerEmail })
}
if (itunesOwnersData.length) {
customElements.push({
'itunes:owner': itunesOwnersData
})
}
if (this.preventIndexing) {
customElements.push({ 'itunes:block': 'yes' }, { 'googleplay:block': 'yes' })
}
const rssData = { const rssData = {
title: this.title, title: this.title,
description: this.description || '', description: this.description || '',
@@ -571,29 +606,10 @@ class Feed extends Model {
image_url: `${hostPrefix}${this.imageURL}`, image_url: `${hostPrefix}${this.imageURL}`,
custom_namespaces: { custom_namespaces: {
itunes: 'http://www.itunes.com/dtds/podcast-1.0.dtd', itunes: 'http://www.itunes.com/dtds/podcast-1.0.dtd',
psc: 'http://podlove.org/simple-chapters',
podcast: 'https://podcastindex.org/namespace/1.0', podcast: 'https://podcastindex.org/namespace/1.0',
googleplay: 'http://www.google.com/schemas/play-podcasts/1.0' googleplay: 'http://www.google.com/schemas/play-podcasts/1.0'
}, },
custom_elements: [ custom_elements: customElements
{ language: this.language || 'en' },
{ author: this.author || 'advplyr' },
{ 'itunes:author': this.author || 'advplyr' },
{ 'itunes:summary': this.description || '' },
{ 'itunes:type': this.podcastType },
{
'itunes:image': {
_attr: {
href: `${hostPrefix}${this.imageURL}`
}
}
},
{
'itunes:owner': [{ 'itunes:name': this.ownerName || this.author || '' }, { 'itunes:email': this.ownerEmail || '' }]
},
{ 'itunes:explicit': !!this.explicit },
...(this.preventIndexing ? blockTags : [])
]
} }
const rssfeed = new RSS(rssData) const rssfeed = new RSS(rssData)
+21 -13
View File
@@ -139,7 +139,8 @@ class FeedEpisode extends Model {
*/ */
static getFeedEpisodeObjFromAudiobookTrack(book, pubDateStart, feed, slug, audioTrack, useChapterTitles, existingEpisodeId = null) { static getFeedEpisodeObjFromAudiobookTrack(book, pubDateStart, feed, slug, audioTrack, useChapterTitles, existingEpisodeId = null) {
// Example: <pubDate>Fri, 04 Feb 2015 00:00:00 GMT</pubDate> // Example: <pubDate>Fri, 04 Feb 2015 00:00:00 GMT</pubDate>
let timeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset pubdate to ensure correct order // Offset pubdate in 1 minute intervals to ensure correct order
let timeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 60000
let episodeId = existingEpisodeId || uuidv4() let episodeId = existingEpisodeId || uuidv4()
// e.g. Track 1 will have a pub date before Track 2 // e.g. Track 1 will have a pub date before Track 2
@@ -219,7 +220,7 @@ class FeedEpisode extends Model {
const feedEpisodeObjs = [] const feedEpisodeObjs = []
let numExisting = 0 let numExisting = 0
for (const book of books) { for (const book of books) {
const trackList = book.libraryItem.getTrackList() const trackList = book.getTracklist(book.libraryItem.id)
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(trackList, book) const useChapterTitles = this.checkUseChapterTitlesForEpisodes(trackList, book)
for (const track of trackList) { for (const track of trackList) {
// Check for existing episode by filepath // Check for existing episode by filepath
@@ -304,6 +305,23 @@ class FeedEpisode extends Model {
* @param {string} hostPrefix * @param {string} hostPrefix
*/ */
getRSSData(hostPrefix) { getRSSData(hostPrefix) {
const customElements = [
{ 'itunes:author': this.author || null },
{ 'itunes:duration': Math.round(Number(this.duration)) },
{
'itunes:explicit': !!this.explicit
},
{ 'itunes:episodeType': this.episodeType || null },
{ 'itunes:season': this.season || null },
{ 'itunes:episode': this.episode || null }
].filter((element) => {
// Remove empty custom elements
return Object.values(element)[0] !== null
})
if (this.description) {
customElements.push({ 'itunes:summary': { _cdata: this.description } })
}
return { return {
title: this.title, title: this.title,
description: this.description || '', description: this.description || '',
@@ -316,17 +334,7 @@ class FeedEpisode extends Model {
type: this.enclosureType, type: this.enclosureType,
size: this.enclosureSize size: this.enclosureSize
}, },
custom_elements: [ custom_elements: customElements
{ 'itunes:author': this.author },
{ 'itunes:duration': secondsToTimestamp(this.duration) },
{ 'itunes:summary': this.description || '' },
{
'itunes:explicit': !!this.explicit
},
{ 'itunes:episodeType': this.episodeType },
{ 'itunes:season': this.season },
{ 'itunes:episode': this.episode }
]
} }
} }
} }
+29 -359
View File
@@ -1,11 +1,8 @@
const util = require('util')
const Path = require('path') const Path = require('path')
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model } = require('sequelize')
const fsExtra = require('../libs/fsExtra') const fsExtra = require('../libs/fsExtra')
const Logger = require('../Logger') const Logger = require('../Logger')
const oldLibraryItem = require('../objects/LibraryItem')
const libraryFilters = require('../utils/queries/libraryFilters') const libraryFilters = require('../utils/queries/libraryFilters')
const { areEquivalent } = require('../utils/index')
const { filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils') const { filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')
const LibraryFile = require('../objects/files/LibraryFile') const LibraryFile = require('../objects/files/LibraryFile')
const Book = require('./Book') const Book = require('./Book')
@@ -122,244 +119,6 @@ class LibraryItem extends Model {
}) })
} }
/**
* Convert an expanded LibraryItem into an old library item
*
* @param {Model<LibraryItem>} libraryItemExpanded
* @returns {oldLibraryItem}
*/
static getOldLibraryItem(libraryItemExpanded) {
let media = null
if (libraryItemExpanded.mediaType === 'book') {
media = this.sequelize.models.book.getOldBook(libraryItemExpanded)
} else if (libraryItemExpanded.mediaType === 'podcast') {
media = this.sequelize.models.podcast.getOldPodcast(libraryItemExpanded)
}
return new oldLibraryItem({
id: libraryItemExpanded.id,
ino: libraryItemExpanded.ino,
oldLibraryItemId: libraryItemExpanded.extraData?.oldLibraryItemId || null,
libraryId: libraryItemExpanded.libraryId,
folderId: libraryItemExpanded.libraryFolderId,
path: libraryItemExpanded.path,
relPath: libraryItemExpanded.relPath,
isFile: libraryItemExpanded.isFile,
mtimeMs: libraryItemExpanded.mtime?.valueOf(),
ctimeMs: libraryItemExpanded.ctime?.valueOf(),
birthtimeMs: libraryItemExpanded.birthtime?.valueOf(),
addedAt: libraryItemExpanded.createdAt.valueOf(),
updatedAt: libraryItemExpanded.updatedAt.valueOf(),
lastScan: libraryItemExpanded.lastScan?.valueOf(),
scanVersion: libraryItemExpanded.lastScanVersion,
isMissing: !!libraryItemExpanded.isMissing,
isInvalid: !!libraryItemExpanded.isInvalid,
mediaType: libraryItemExpanded.mediaType,
media,
libraryFiles: libraryItemExpanded.libraryFiles
})
}
/**
* Updates libraryItem, book, authors and series from old library item
*
* @param {oldLibraryItem} oldLibraryItem
* @returns {Promise<boolean>} true if updates were made
*/
static async fullUpdateFromOld(oldLibraryItem) {
const libraryItemExpanded = await this.getExpandedById(oldLibraryItem.id)
if (!libraryItemExpanded) return false
let hasUpdates = false
// Check update Book/Podcast
if (libraryItemExpanded.media) {
let updatedMedia = null
if (libraryItemExpanded.mediaType === 'podcast') {
updatedMedia = this.sequelize.models.podcast.getFromOld(oldLibraryItem.media)
const existingPodcastEpisodes = libraryItemExpanded.media.podcastEpisodes || []
const updatedPodcastEpisodes = oldLibraryItem.media.episodes || []
for (const existingPodcastEpisode of existingPodcastEpisodes) {
// Episode was removed
if (!updatedPodcastEpisodes.some((ep) => ep.id === existingPodcastEpisode.id)) {
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingPodcastEpisode.title}" was removed`)
await existingPodcastEpisode.destroy()
hasUpdates = true
}
}
for (const updatedPodcastEpisode of updatedPodcastEpisodes) {
const existingEpisodeMatch = existingPodcastEpisodes.find((ep) => ep.id === updatedPodcastEpisode.id)
if (!existingEpisodeMatch) {
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${updatedPodcastEpisode.title}" was added`)
await this.sequelize.models.podcastEpisode.createFromOld(updatedPodcastEpisode)
hasUpdates = true
} else {
const updatedEpisodeCleaned = this.sequelize.models.podcastEpisode.getFromOld(updatedPodcastEpisode)
let episodeHasUpdates = false
for (const key in updatedEpisodeCleaned) {
let existingValue = existingEpisodeMatch[key]
if (existingValue instanceof Date) existingValue = existingValue.valueOf()
if (!areEquivalent(updatedEpisodeCleaned[key], existingValue, true)) {
Logger.debug(util.format(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingEpisodeMatch.title}" ${key} was updated from %j to %j`, existingValue, updatedEpisodeCleaned[key]))
episodeHasUpdates = true
}
}
if (episodeHasUpdates) {
await existingEpisodeMatch.update(updatedEpisodeCleaned)
hasUpdates = true
}
}
}
} else if (libraryItemExpanded.mediaType === 'book') {
updatedMedia = this.sequelize.models.book.getFromOld(oldLibraryItem.media)
const existingAuthors = libraryItemExpanded.media.authors || []
const existingSeriesAll = libraryItemExpanded.media.series || []
const updatedAuthors = oldLibraryItem.media.metadata.authors || []
const uniqueUpdatedAuthors = updatedAuthors.filter((au, idx) => updatedAuthors.findIndex((a) => a.id === au.id) === idx)
const updatedSeriesAll = oldLibraryItem.media.metadata.series || []
for (const existingAuthor of existingAuthors) {
// Author was removed from Book
if (!uniqueUpdatedAuthors.some((au) => au.id === existingAuthor.id)) {
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${existingAuthor.name}" was removed`)
await this.sequelize.models.bookAuthor.removeByIds(existingAuthor.id, libraryItemExpanded.media.id)
hasUpdates = true
}
}
for (const updatedAuthor of uniqueUpdatedAuthors) {
// Author was added
if (!existingAuthors.some((au) => au.id === updatedAuthor.id)) {
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${updatedAuthor.name}" was added`)
await this.sequelize.models.bookAuthor.create({ authorId: updatedAuthor.id, bookId: libraryItemExpanded.media.id })
hasUpdates = true
}
}
for (const existingSeries of existingSeriesAll) {
// Series was removed
if (!updatedSeriesAll.some((se) => se.id === existingSeries.id)) {
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${existingSeries.name}" was removed`)
await this.sequelize.models.bookSeries.removeByIds(existingSeries.id, libraryItemExpanded.media.id)
hasUpdates = true
}
}
for (const updatedSeries of updatedSeriesAll) {
// Series was added/updated
const existingSeriesMatch = existingSeriesAll.find((se) => se.id === updatedSeries.id)
if (!existingSeriesMatch) {
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" was added`)
await this.sequelize.models.bookSeries.create({ seriesId: updatedSeries.id, bookId: libraryItemExpanded.media.id, sequence: updatedSeries.sequence })
hasUpdates = true
} else if (existingSeriesMatch.bookSeries.sequence !== updatedSeries.sequence) {
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" sequence was updated from "${existingSeriesMatch.bookSeries.sequence}" to "${updatedSeries.sequence}"`)
await existingSeriesMatch.bookSeries.update({ id: updatedSeries.id, sequence: updatedSeries.sequence })
hasUpdates = true
}
}
}
let hasMediaUpdates = false
for (const key in updatedMedia) {
let existingValue = libraryItemExpanded.media[key]
if (existingValue instanceof Date) existingValue = existingValue.valueOf()
if (!areEquivalent(updatedMedia[key], existingValue, true)) {
if (key === 'chapters') {
// Handle logging of chapters separately because the object is large
const chaptersRemoved = libraryItemExpanded.media.chapters.filter((ch) => !updatedMedia.chapters.some((uch) => uch.id === ch.id))
if (chaptersRemoved.length) {
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" chapters removed: ${chaptersRemoved.map((ch) => ch.title).join(', ')}`)
}
const chaptersAdded = updatedMedia.chapters.filter((uch) => !libraryItemExpanded.media.chapters.some((ch) => ch.id === uch.id))
if (chaptersAdded.length) {
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" chapters added: ${chaptersAdded.map((ch) => ch.title).join(', ')}`)
}
if (!chaptersRemoved.length && !chaptersAdded.length) {
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" chapters updated`)
}
} else {
Logger.debug(util.format(`[LibraryItem] "${libraryItemExpanded.media.title}" ${libraryItemExpanded.mediaType}.${key} updated from %j to %j`, existingValue, updatedMedia[key]))
}
hasMediaUpdates = true
}
}
if (hasMediaUpdates && updatedMedia) {
await libraryItemExpanded.media.update(updatedMedia)
hasUpdates = true
}
}
const updatedLibraryItem = this.getFromOld(oldLibraryItem)
let hasLibraryItemUpdates = false
for (const key in updatedLibraryItem) {
let existingValue = libraryItemExpanded[key]
if (existingValue instanceof Date) existingValue = existingValue.valueOf()
if (!areEquivalent(updatedLibraryItem[key], existingValue, true)) {
if (key === 'libraryFiles') {
// Handle logging of libraryFiles separately because the object is large (should be addressed when migrating off the old library item model)
const libraryFilesRemoved = libraryItemExpanded.libraryFiles.filter((lf) => !updatedLibraryItem.libraryFiles.some((ulf) => ulf.ino === lf.ino))
if (libraryFilesRemoved.length) {
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" library files removed: ${libraryFilesRemoved.map((lf) => lf.metadata.path).join(', ')}`)
}
const libraryFilesAdded = updatedLibraryItem.libraryFiles.filter((ulf) => !libraryItemExpanded.libraryFiles.some((lf) => lf.ino === ulf.ino))
if (libraryFilesAdded.length) {
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" library files added: ${libraryFilesAdded.map((lf) => lf.metadata.path).join(', ')}`)
}
if (!libraryFilesRemoved.length && !libraryFilesAdded.length) {
Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" library files updated`)
}
} else {
Logger.debug(util.format(`[LibraryItem] "${libraryItemExpanded.media.title}" ${key} updated from %j to %j`, existingValue, updatedLibraryItem[key]))
}
hasLibraryItemUpdates = true
if (key === 'updatedAt') {
libraryItemExpanded.changed('updatedAt', true)
}
}
}
if (hasLibraryItemUpdates) {
await libraryItemExpanded.update(updatedLibraryItem)
Logger.info(`[LibraryItem] Library item "${libraryItemExpanded.id}" updated`)
hasUpdates = true
}
return hasUpdates
}
static getFromOld(oldLibraryItem) {
const extraData = {}
if (oldLibraryItem.oldLibraryItemId) {
extraData.oldLibraryItemId = oldLibraryItem.oldLibraryItemId
}
return {
id: oldLibraryItem.id,
ino: oldLibraryItem.ino,
path: oldLibraryItem.path,
relPath: oldLibraryItem.relPath,
mediaId: oldLibraryItem.media.id,
mediaType: oldLibraryItem.mediaType,
isFile: !!oldLibraryItem.isFile,
isMissing: !!oldLibraryItem.isMissing,
isInvalid: !!oldLibraryItem.isInvalid,
mtime: oldLibraryItem.mtimeMs,
ctime: oldLibraryItem.ctimeMs,
updatedAt: oldLibraryItem.updatedAt,
birthtime: oldLibraryItem.birthtimeMs,
size: oldLibraryItem.size,
lastScan: oldLibraryItem.lastScan,
lastScanVersion: oldLibraryItem.scanVersion,
libraryId: oldLibraryItem.libraryId,
libraryFolderId: oldLibraryItem.folderId,
libraryFiles: oldLibraryItem.libraryFiles?.map((lf) => lf.toJSON()) || [],
extraData
}
}
/** /**
* Remove library item by id * Remove library item by id
* *
@@ -468,12 +227,14 @@ class LibraryItem extends Model {
/** /**
* *
* @param {import('sequelize').WhereOptions} where * @param {import('sequelize').WhereOptions} where
* @param {import('sequelize').BindOrReplacements} [replacements]
* @param {import('sequelize').IncludeOptions} [include] * @param {import('sequelize').IncludeOptions} [include]
* @returns {Promise<LibraryItemExpanded>} * @returns {Promise<LibraryItemExpanded>}
*/ */
static async findOneExpanded(where, include = null) { static async findOneExpanded(where, replacements = null, include = null) {
const libraryItem = await this.findOne({ const libraryItem = await this.findOne({
where, where,
replacements,
include include
}) })
if (!libraryItem) { if (!libraryItem) {
@@ -516,61 +277,12 @@ class LibraryItem extends Model {
return libraryItem return libraryItem
} }
/**
* Get old library item by id
* @param {string} libraryItemId
* @returns {oldLibraryItem}
*/
static async getOldById(libraryItemId) {
if (!libraryItemId) return null
const libraryItem = await this.findByPk(libraryItemId)
if (!libraryItem) {
Logger.error(`[LibraryItem] Library item not found with id "${libraryItemId}"`)
return null
}
if (libraryItem.mediaType === 'podcast') {
libraryItem.media = await libraryItem.getMedia({
include: [
{
model: this.sequelize.models.podcastEpisode
}
]
})
} else {
libraryItem.media = await libraryItem.getMedia({
include: [
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
}
],
order: [
[this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'],
[this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC']
]
})
}
if (!libraryItem.media) return null
return this.getOldLibraryItem(libraryItem)
}
/** /**
* Get library items using filter and sort * Get library items using filter and sort
* @param {import('./Library')} library * @param {import('./Library')} library
* @param {import('./User')} user * @param {import('./User')} user
* @param {object} options * @param {object} options
* @returns {{ libraryItems:oldLibraryItem[], count:number }} * @returns {{ libraryItems:Object[], count:number }}
*/ */
static async getByFilterAndSort(library, user, options) { static async getByFilterAndSort(library, user, options) {
let start = Date.now() let start = Date.now()
@@ -624,17 +336,19 @@ class LibraryItem extends Model {
// "Continue Listening" shelf // "Continue Listening" shelf
const itemsInProgressPayload = await libraryFilters.getMediaItemsInProgress(library, user, include, limit, false) const itemsInProgressPayload = await libraryFilters.getMediaItemsInProgress(library, user, include, limit, false)
if (itemsInProgressPayload.items.length) { if (itemsInProgressPayload.items.length) {
const ebookOnlyItemsInProgress = itemsInProgressPayload.items.filter((li) => li.media.isEBookOnly) const ebookOnlyItemsInProgress = itemsInProgressPayload.items.filter((li) => li.media.ebookFormat && !li.media.numTracks)
const audioOnlyItemsInProgress = itemsInProgressPayload.items.filter((li) => !li.media.isEBookOnly) const audioItemsInProgress = itemsInProgressPayload.items.filter((li) => li.media.numTracks || li.mediaType === 'podcast')
shelves.push({ if (audioItemsInProgress.length) {
id: 'continue-listening', shelves.push({
label: 'Continue Listening', id: 'continue-listening',
labelStringKey: 'LabelContinueListening', label: 'Continue Listening',
type: library.isPodcast ? 'episode' : 'book', labelStringKey: 'LabelContinueListening',
entities: audioOnlyItemsInProgress, type: library.isPodcast ? 'episode' : 'book',
total: itemsInProgressPayload.count entities: audioItemsInProgress,
}) total: itemsInProgressPayload.count
})
}
if (ebookOnlyItemsInProgress.length) { if (ebookOnlyItemsInProgress.length) {
// "Continue Reading" shelf // "Continue Reading" shelf
@@ -733,17 +447,19 @@ class LibraryItem extends Model {
// "Listen Again" shelf // "Listen Again" shelf
const mediaFinishedPayload = await libraryFilters.getMediaFinished(library, user, include, limit) const mediaFinishedPayload = await libraryFilters.getMediaFinished(library, user, include, limit)
if (mediaFinishedPayload.items.length) { if (mediaFinishedPayload.items.length) {
const ebookOnlyItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.isEBookOnly) const ebookOnlyItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.ebookFormat && !li.media.numTracks)
const audioOnlyItemsInProgress = mediaFinishedPayload.items.filter((li) => !li.media.isEBookOnly) const audioItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.numTracks || li.mediaType === 'podcast')
shelves.push({ if (audioItemsInProgress.length) {
id: 'listen-again', shelves.push({
label: 'Listen Again', id: 'listen-again',
labelStringKey: 'LabelListenAgain', label: 'Listen Again',
type: library.isPodcast ? 'episode' : 'book', labelStringKey: 'LabelListenAgain',
entities: audioOnlyItemsInProgress, type: library.isPodcast ? 'episode' : 'book',
total: mediaFinishedPayload.count entities: audioItemsInProgress,
}) total: mediaFinishedPayload.count
})
}
// "Read Again" shelf // "Read Again" shelf
if (ebookOnlyItemsInProgress.length) { if (ebookOnlyItemsInProgress.length) {
@@ -801,52 +517,6 @@ class LibraryItem extends Model {
return (await this.count({ where: { id: libraryItemId } })) > 0 return (await this.count({ where: { id: libraryItemId } })) > 0
} }
/**
*
* @param {import('sequelize').WhereOptions} where
* @param {import('sequelize').BindOrReplacements} replacements
* @returns {Object} oldLibraryItem
*/
static async findOneOld(where, replacements = {}) {
const libraryItem = await this.findOne({
where,
replacements,
include: [
{
model: this.sequelize.models.book,
include: [
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
}
]
},
{
model: this.sequelize.models.podcast,
include: [
{
model: this.sequelize.models.podcastEpisode
}
]
}
],
order: [
[this.sequelize.models.book, this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'],
[this.sequelize.models.book, this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC']
]
})
if (!libraryItem) return null
return this.getOldLibraryItem(libraryItem)
}
/** /**
* *
* @param {string} libraryItemId * @param {string} libraryItemId
@@ -970,7 +640,7 @@ class LibraryItem extends Model {
} }
} }
Logger.debug(`Success saving abmetadata to "${metadataFilePath}"`) Logger.debug(`[LibraryItem] Saved metadata for "${this.media.title}" file to "${metadataFilePath}"`)
return metadataLibraryFile return metadataLibraryFile
}) })
+4 -7
View File
@@ -87,13 +87,10 @@ class MediaItemShare extends Model {
const libraryItemModel = this.sequelize.models.libraryItem const libraryItemModel = this.sequelize.models.libraryItem
if (mediaItemType === 'book') { if (mediaItemType === 'book') {
const libraryItem = await libraryItemModel.findOneExpanded( const libraryItem = await libraryItemModel.findOneExpanded({ mediaId: mediaItemId }, null, {
{ mediaId: mediaItemId }, model: this.sequelize.models.library,
{ attributes: ['settings']
model: this.sequelize.models.library, })
attributes: ['settings']
}
)
return libraryItem return libraryItem
} }
+6 -33
View File
@@ -36,33 +36,6 @@ class MediaProgress extends Model {
this.createdAt this.createdAt
} }
static upsertFromOld(oldMediaProgress) {
const mediaProgress = this.getFromOld(oldMediaProgress)
return this.upsert(mediaProgress)
}
static getFromOld(oldMediaProgress) {
return {
id: oldMediaProgress.id,
userId: oldMediaProgress.userId,
mediaItemId: oldMediaProgress.mediaItemId,
mediaItemType: oldMediaProgress.mediaItemType,
duration: oldMediaProgress.duration,
currentTime: oldMediaProgress.currentTime,
ebookLocation: oldMediaProgress.ebookLocation || null,
ebookProgress: oldMediaProgress.ebookProgress || null,
isFinished: !!oldMediaProgress.isFinished,
hideFromContinueListening: !!oldMediaProgress.hideFromContinueListening,
finishedAt: oldMediaProgress.finishedAt,
createdAt: oldMediaProgress.startedAt || oldMediaProgress.lastUpdate,
updatedAt: oldMediaProgress.lastUpdate,
extraData: {
libraryItemId: oldMediaProgress.libraryItemId,
progress: oldMediaProgress.progress
}
}
}
static removeById(mediaProgressId) { static removeById(mediaProgressId) {
return this.destroy({ return this.destroy({
where: { where: {
@@ -71,12 +44,6 @@ class MediaProgress extends Model {
}) })
} }
getMediaItem(options) {
if (!this.mediaItemType) return Promise.resolve(null)
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaItemType)}`
return this[mixinMethodName](options)
}
/** /**
* Initialize model * Initialize model
* *
@@ -162,6 +129,12 @@ class MediaProgress extends Model {
MediaProgress.belongsTo(user) MediaProgress.belongsTo(user)
} }
getMediaItem(options) {
if (!this.mediaItemType) return Promise.resolve(null)
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaItemType)}`
return this[mixinMethodName](options)
}
getOldMediaProgress() { getOldMediaProgress() {
const isPodcastEpisode = this.mediaItemType === 'podcastEpisode' const isPodcastEpisode = this.mediaItemType === 'podcastEpisode'
-60
View File
@@ -66,66 +66,6 @@ class Podcast extends Model {
this.podcastEpisodes this.podcastEpisodes
} }
static getOldPodcast(libraryItemExpanded) {
const podcastExpanded = libraryItemExpanded.media
const podcastEpisodes = podcastExpanded.podcastEpisodes?.map((ep) => ep.getOldPodcastEpisode(libraryItemExpanded.id).toJSON()).sort((a, b) => a.index - b.index)
return {
id: podcastExpanded.id,
libraryItemId: libraryItemExpanded.id,
metadata: {
title: podcastExpanded.title,
author: podcastExpanded.author,
description: podcastExpanded.description,
releaseDate: podcastExpanded.releaseDate,
genres: podcastExpanded.genres,
feedUrl: podcastExpanded.feedURL,
imageUrl: podcastExpanded.imageURL,
itunesPageUrl: podcastExpanded.itunesPageURL,
itunesId: podcastExpanded.itunesId,
itunesArtistId: podcastExpanded.itunesArtistId,
explicit: podcastExpanded.explicit,
language: podcastExpanded.language,
type: podcastExpanded.podcastType
},
coverPath: podcastExpanded.coverPath,
tags: podcastExpanded.tags,
episodes: podcastEpisodes || [],
autoDownloadEpisodes: podcastExpanded.autoDownloadEpisodes,
autoDownloadSchedule: podcastExpanded.autoDownloadSchedule,
lastEpisodeCheck: podcastExpanded.lastEpisodeCheck?.valueOf() || null,
maxEpisodesToKeep: podcastExpanded.maxEpisodesToKeep,
maxNewEpisodesToDownload: podcastExpanded.maxNewEpisodesToDownload
}
}
static getFromOld(oldPodcast) {
const oldPodcastMetadata = oldPodcast.metadata
return {
id: oldPodcast.id,
title: oldPodcastMetadata.title,
titleIgnorePrefix: oldPodcastMetadata.titleIgnorePrefix,
author: oldPodcastMetadata.author,
releaseDate: oldPodcastMetadata.releaseDate,
feedURL: oldPodcastMetadata.feedUrl,
imageURL: oldPodcastMetadata.imageUrl,
description: oldPodcastMetadata.description,
itunesPageURL: oldPodcastMetadata.itunesPageUrl,
itunesId: oldPodcastMetadata.itunesId,
itunesArtistId: oldPodcastMetadata.itunesArtistId,
language: oldPodcastMetadata.language,
podcastType: oldPodcastMetadata.type,
explicit: !!oldPodcastMetadata.explicit,
autoDownloadEpisodes: !!oldPodcast.autoDownloadEpisodes,
autoDownloadSchedule: oldPodcast.autoDownloadSchedule,
lastEpisodeCheck: oldPodcast.lastEpisodeCheck,
maxEpisodesToKeep: oldPodcast.maxEpisodesToKeep,
maxNewEpisodesToDownload: oldPodcast.maxNewEpisodesToDownload,
coverPath: oldPodcast.coverPath,
tags: oldPodcast.tags,
genres: oldPodcastMetadata.genres
}
}
/** /**
* Payload from the /api/podcasts POST endpoint * Payload from the /api/podcasts POST endpoint
* *
+1 -72
View File
@@ -1,5 +1,4 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model } = require('sequelize')
const oldPodcastEpisode = require('../objects/entities/PodcastEpisode')
/** /**
* @typedef ChapterObject * @typedef ChapterObject
@@ -53,40 +52,6 @@ class PodcastEpisode extends Model {
this.updatedAt this.updatedAt
} }
static createFromOld(oldEpisode) {
const podcastEpisode = this.getFromOld(oldEpisode)
return this.create(podcastEpisode)
}
static getFromOld(oldEpisode) {
const extraData = {}
if (oldEpisode.oldEpisodeId) {
extraData.oldEpisodeId = oldEpisode.oldEpisodeId
}
if (oldEpisode.guid) {
extraData.guid = oldEpisode.guid
}
return {
id: oldEpisode.id,
index: oldEpisode.index,
season: oldEpisode.season,
episode: oldEpisode.episode,
episodeType: oldEpisode.episodeType,
title: oldEpisode.title,
subtitle: oldEpisode.subtitle,
description: oldEpisode.description,
pubDate: oldEpisode.pubDate,
enclosureURL: oldEpisode.enclosure?.url || null,
enclosureSize: oldEpisode.enclosure?.length || null,
enclosureType: oldEpisode.enclosure?.type || null,
publishedAt: oldEpisode.publishedAt,
podcastId: oldEpisode.podcastId,
audioFile: oldEpisode.audioFile?.toJSON() || null,
chapters: oldEpisode.chapters,
extraData
}
}
/** /**
* *
* @param {import('../utils/podcastUtils').RssPodcastEpisode} rssPodcastEpisode * @param {import('../utils/podcastUtils').RssPodcastEpisode} rssPodcastEpisode
@@ -203,47 +168,11 @@ class PodcastEpisode extends Model {
getAudioTrack(libraryItemId) { getAudioTrack(libraryItemId) {
const track = structuredClone(this.audioFile) const track = structuredClone(this.audioFile)
track.startOffset = 0 track.startOffset = 0
track.title = this.audioFile.metadata.title track.title = this.audioFile.metadata.filename
track.contentUrl = `${global.RouterBasePath}/api/items/${libraryItemId}/file/${track.ino}` track.contentUrl = `${global.RouterBasePath}/api/items/${libraryItemId}/file/${track.ino}`
return track return track
} }
/**
* @param {string} libraryItemId
* @returns {oldPodcastEpisode}
*/
getOldPodcastEpisode(libraryItemId = null) {
let enclosure = null
if (this.enclosureURL) {
enclosure = {
url: this.enclosureURL,
type: this.enclosureType,
length: this.enclosureSize !== null ? String(this.enclosureSize) : null
}
}
return new oldPodcastEpisode({
libraryItemId: libraryItemId || null,
podcastId: this.podcastId,
id: this.id,
oldEpisodeId: this.extraData?.oldEpisodeId || null,
index: this.index,
season: this.season,
episode: this.episode,
episodeType: this.episodeType,
title: this.title,
subtitle: this.subtitle,
description: this.description,
enclosure,
guid: this.extraData?.guid || null,
pubDate: this.pubDate,
chapters: this.chapters,
audioFile: this.audioFile,
publishedAt: this.publishedAt?.valueOf() || null,
addedAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf()
})
}
toOldJSON(libraryItemId) { toOldJSON(libraryItemId) {
if (!libraryItemId) { if (!libraryItemId) {
throw new Error(`[PodcastEpisode] Cannot convert to old JSON because libraryItemId is not provided`) throw new Error(`[PodcastEpisode] Cannot convert to old JSON because libraryItemId is not provided`)
+17 -1
View File
@@ -1,6 +1,6 @@
const { DataTypes, Model, where, fn, col, literal } = require('sequelize') const { DataTypes, Model, where, fn, col, literal } = require('sequelize')
const { getTitlePrefixAtEnd } = require('../utils/index') const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils/index')
class Series extends Model { class Series extends Model {
constructor(values, options) { constructor(values, options) {
@@ -66,6 +66,22 @@ class Series extends Model {
return series return series
} }
/**
*
* @param {string} seriesName
* @param {string} libraryId
* @returns {Promise<Series>}
*/
static async findOrCreateByNameAndLibrary(seriesName, libraryId) {
const series = await this.getByNameAndLibrary(seriesName, libraryId)
if (series) return series
return this.create({
name: seriesName,
nameIgnorePrefix: getTitleIgnorePrefix(seriesName),
libraryId
})
}
/** /**
* Initialize model * Initialize model
* @param {import('../Database').sequelize} sequelize * @param {import('../Database').sequelize} sequelize
+1 -2
View File
@@ -563,9 +563,8 @@ class User extends Model {
/** /**
* Check user can access library item * Check user can access library item
* TODO: Currently supports both old and new library item models
* *
* @param {import('../objects/LibraryItem')|import('./LibraryItem')} libraryItem * @param {import('./LibraryItem')} libraryItem
* @returns {boolean} * @returns {boolean}
*/ */
checkCanAccessLibraryItem(libraryItem) { checkCanAccessLibraryItem(libraryItem) {
-273
View File
@@ -1,273 +0,0 @@
const fs = require('../libs/fsExtra')
const Path = require('path')
const Logger = require('../Logger')
const LibraryFile = require('./files/LibraryFile')
const Book = require('./mediaTypes/Book')
const Podcast = require('./mediaTypes/Podcast')
const { areEquivalent, copyValue } = require('../utils/index')
const { filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')
class LibraryItem {
constructor(libraryItem = null) {
this.id = null
this.ino = null // Inode
this.oldLibraryItemId = null
this.libraryId = null
this.folderId = null
this.path = null
this.relPath = null
this.isFile = false
this.mtimeMs = null
this.ctimeMs = null
this.birthtimeMs = null
this.addedAt = null
this.updatedAt = null
this.lastScan = null
this.scanVersion = null
// Was scanned and no longer exists
this.isMissing = false
// Was scanned and no longer has media files
this.isInvalid = false
this.mediaType = null
this.media = null
/** @type {LibraryFile[]} */
this.libraryFiles = []
if (libraryItem) {
this.construct(libraryItem)
}
// Temporary attributes
this.isSavingMetadata = false
}
construct(libraryItem) {
this.id = libraryItem.id
this.ino = libraryItem.ino || null
this.oldLibraryItemId = libraryItem.oldLibraryItemId
this.libraryId = libraryItem.libraryId
this.folderId = libraryItem.folderId
this.path = libraryItem.path
this.relPath = libraryItem.relPath
this.isFile = !!libraryItem.isFile
this.mtimeMs = libraryItem.mtimeMs || 0
this.ctimeMs = libraryItem.ctimeMs || 0
this.birthtimeMs = libraryItem.birthtimeMs || 0
this.addedAt = libraryItem.addedAt
this.updatedAt = libraryItem.updatedAt || this.addedAt
this.lastScan = libraryItem.lastScan || null
this.scanVersion = libraryItem.scanVersion || null
this.isMissing = !!libraryItem.isMissing
this.isInvalid = !!libraryItem.isInvalid
this.mediaType = libraryItem.mediaType
if (this.mediaType === 'book') {
this.media = new Book(libraryItem.media)
} else if (this.mediaType === 'podcast') {
this.media = new Podcast(libraryItem.media)
}
this.media.libraryItemId = this.id
this.libraryFiles = libraryItem.libraryFiles.map((f) => new LibraryFile(f))
// Migration for v2.2.23 to set ebook library files as supplementary
if (this.isBook && this.media.ebookFile) {
for (const libraryFile of this.libraryFiles) {
if (libraryFile.isEBookFile && libraryFile.isSupplementary === null) {
libraryFile.isSupplementary = this.media.ebookFile.ino !== libraryFile.ino
}
}
}
}
toJSON() {
return {
id: this.id,
ino: this.ino,
oldLibraryItemId: this.oldLibraryItemId,
libraryId: this.libraryId,
folderId: this.folderId,
path: this.path,
relPath: this.relPath,
isFile: this.isFile,
mtimeMs: this.mtimeMs,
ctimeMs: this.ctimeMs,
birthtimeMs: this.birthtimeMs,
addedAt: this.addedAt,
updatedAt: this.updatedAt,
lastScan: this.lastScan,
scanVersion: this.scanVersion,
isMissing: !!this.isMissing,
isInvalid: !!this.isInvalid,
mediaType: this.mediaType,
media: this.media.toJSON(),
libraryFiles: this.libraryFiles.map((f) => f.toJSON())
}
}
toJSONMinified() {
return {
id: this.id,
ino: this.ino,
oldLibraryItemId: this.oldLibraryItemId,
libraryId: this.libraryId,
folderId: this.folderId,
path: this.path,
relPath: this.relPath,
isFile: this.isFile,
mtimeMs: this.mtimeMs,
ctimeMs: this.ctimeMs,
birthtimeMs: this.birthtimeMs,
addedAt: this.addedAt,
updatedAt: this.updatedAt,
isMissing: !!this.isMissing,
isInvalid: !!this.isInvalid,
mediaType: this.mediaType,
media: this.media.toJSONMinified(),
numFiles: this.libraryFiles.length,
size: this.size
}
}
// Adds additional helpful fields like media duration, tracks, etc.
toJSONExpanded() {
return {
id: this.id,
ino: this.ino,
oldLibraryItemId: this.oldLibraryItemId,
libraryId: this.libraryId,
folderId: this.folderId,
path: this.path,
relPath: this.relPath,
isFile: this.isFile,
mtimeMs: this.mtimeMs,
ctimeMs: this.ctimeMs,
birthtimeMs: this.birthtimeMs,
addedAt: this.addedAt,
updatedAt: this.updatedAt,
lastScan: this.lastScan,
scanVersion: this.scanVersion,
isMissing: !!this.isMissing,
isInvalid: !!this.isInvalid,
mediaType: this.mediaType,
media: this.media.toJSONExpanded(),
libraryFiles: this.libraryFiles.map((f) => f.toJSON()),
size: this.size
}
}
get isPodcast() {
return this.mediaType === 'podcast'
}
get isBook() {
return this.mediaType === 'book'
}
get size() {
let total = 0
this.libraryFiles.forEach((lf) => (total += lf.metadata.size))
return total
}
get hasAudioFiles() {
return this.libraryFiles.some((lf) => lf.fileType === 'audio')
}
update(payload) {
const json = this.toJSON()
let hasUpdates = false
for (const key in json) {
if (payload[key] !== undefined) {
if (key === 'media') {
if (this.media.update(payload[key])) {
hasUpdates = true
}
} else if (!areEquivalent(payload[key], json[key])) {
this[key] = copyValue(payload[key])
hasUpdates = true
}
}
}
if (hasUpdates) {
this.updatedAt = Date.now()
}
return hasUpdates
}
updateMediaCover(coverPath) {
this.media.updateCover(coverPath)
this.updatedAt = Date.now()
return true
}
setMissing() {
this.isMissing = true
this.updatedAt = Date.now()
}
/**
* Save metadata.json file
* TODO: Move to new LibraryItem model
* @returns {Promise<LibraryFile>} null if not saved
*/
async saveMetadata() {
if (this.isSavingMetadata || !global.MetadataPath) return null
this.isSavingMetadata = true
let metadataPath = Path.join(global.MetadataPath, 'items', this.id)
let storeMetadataWithItem = global.ServerSettings.storeMetadataWithItem
if (storeMetadataWithItem && !this.isFile) {
metadataPath = this.path
} else {
// Make sure metadata book dir exists
storeMetadataWithItem = false
await fs.ensureDir(metadataPath)
}
const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`)
return fs
.writeFile(metadataFilePath, JSON.stringify(this.media.toJSONForMetadataFile(), null, 2))
.then(async () => {
// Add metadata.json to libraryFiles array if it is new
let metadataLibraryFile = this.libraryFiles.find((lf) => lf.metadata.path === filePathToPOSIX(metadataFilePath))
if (storeMetadataWithItem) {
if (!metadataLibraryFile) {
metadataLibraryFile = new LibraryFile()
await metadataLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`)
this.libraryFiles.push(metadataLibraryFile)
} else {
const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath)
if (fileTimestamps) {
metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs
metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs
metadataLibraryFile.metadata.size = fileTimestamps.size
metadataLibraryFile.ino = fileTimestamps.ino
}
}
const libraryItemDirTimestamps = await getFileTimestampsWithIno(this.path)
if (libraryItemDirTimestamps) {
this.mtimeMs = libraryItemDirTimestamps.mtimeMs
this.ctimeMs = libraryItemDirTimestamps.ctimeMs
}
}
Logger.debug(`[LibraryItem] Success saving abmetadata to "${metadataFilePath}"`)
return metadataLibraryFile
})
.catch((error) => {
Logger.error(`[LibraryItem] Failed to save json file at "${metadataFilePath}"`, error)
return null
})
.finally(() => {
this.isSavingMetadata = false
})
}
}
module.exports = LibraryItem
+4 -4
View File
@@ -275,15 +275,15 @@ class Stream extends EventEmitter {
this.ffmpeg.addOption([`-loglevel ${logLevel}`, '-map 0:a', `-c:a ${audioCodec}`]) this.ffmpeg.addOption([`-loglevel ${logLevel}`, '-map 0:a', `-c:a ${audioCodec}`])
const hlsOptions = ['-f hls', '-copyts', '-avoid_negative_ts make_non_negative', '-max_delay 5000000', '-max_muxing_queue_size 2048', `-hls_time 6`, `-hls_segment_type ${this.hlsSegmentType}`, `-start_number ${this.segmentStartNumber}`, '-hls_playlist_type vod', '-hls_list_size 0', '-hls_allow_cache 0'] const hlsOptions = ['-f hls', '-copyts', '-avoid_negative_ts make_non_negative', '-max_delay 5000000', '-max_muxing_queue_size 2048', `-hls_time 6`, `-hls_segment_type ${this.hlsSegmentType}`, `-start_number ${this.segmentStartNumber}`, '-hls_playlist_type vod', '-hls_list_size 0', '-hls_allow_cache 0']
this.ffmpeg.addOption(hlsOptions)
if (this.hlsSegmentType === 'fmp4') { if (this.hlsSegmentType === 'fmp4') {
hlsOptions.push('-strict -2') this.ffmpeg.addOption('-strict -2')
var fmp4InitFilename = Path.join(this.streamPath, 'init.mp4') var fmp4InitFilename = Path.join(this.streamPath, 'init.mp4')
// var fmp4InitFilename = 'init.mp4' // var fmp4InitFilename = 'init.mp4'
hlsOptions.push(`-hls_fmp4_init_filename ${fmp4InitFilename}`) this.ffmpeg.addOption('-hls_fmp4_init_filename', fmp4InitFilename)
} }
this.ffmpeg.addOption(hlsOptions)
var segmentFilename = Path.join(this.streamPath, this.segmentBasename) var segmentFilename = Path.join(this.streamPath, this.segmentBasename)
this.ffmpeg.addOption(`-hls_segment_filename ${segmentFilename}`) this.ffmpeg.addOption('-hls_segment_filename', segmentFilename)
this.ffmpeg.output(this.finalPlaylistPath) this.ffmpeg.output(this.finalPlaylistPath)
this.ffmpeg.on('start', (command) => { this.ffmpeg.on('start', (command) => {
-149
View File
@@ -1,149 +0,0 @@
const { areEquivalent, copyValue } = require('../../utils/index')
const AudioFile = require('../files/AudioFile')
const AudioTrack = require('../files/AudioTrack')
class PodcastEpisode {
constructor(episode) {
this.libraryItemId = null
this.podcastId = null
this.id = null
this.oldEpisodeId = null
this.index = null
this.season = null
this.episode = null
this.episodeType = null
this.title = null
this.subtitle = null
this.description = null
this.enclosure = null
this.guid = null
this.pubDate = null
this.chapters = []
this.audioFile = null
this.publishedAt = null
this.addedAt = null
this.updatedAt = null
if (episode) {
this.construct(episode)
}
}
construct(episode) {
this.libraryItemId = episode.libraryItemId
this.podcastId = episode.podcastId
this.id = episode.id
this.oldEpisodeId = episode.oldEpisodeId
this.index = episode.index
this.season = episode.season
this.episode = episode.episode
this.episodeType = episode.episodeType
this.title = episode.title
this.subtitle = episode.subtitle
this.description = episode.description
this.enclosure = episode.enclosure ? { ...episode.enclosure } : null
this.guid = episode.guid || null
this.pubDate = episode.pubDate
this.chapters = episode.chapters?.map((ch) => ({ ...ch })) || []
this.audioFile = episode.audioFile ? new AudioFile(episode.audioFile) : null
this.publishedAt = episode.publishedAt
this.addedAt = episode.addedAt
this.updatedAt = episode.updatedAt
if (this.audioFile) {
this.audioFile.index = 1 // Only 1 audio file per episode
}
}
toJSON() {
return {
libraryItemId: this.libraryItemId,
podcastId: this.podcastId,
id: this.id,
oldEpisodeId: this.oldEpisodeId,
index: this.index,
season: this.season,
episode: this.episode,
episodeType: this.episodeType,
title: this.title,
subtitle: this.subtitle,
description: this.description,
enclosure: this.enclosure ? { ...this.enclosure } : null,
guid: this.guid,
pubDate: this.pubDate,
chapters: this.chapters.map((ch) => ({ ...ch })),
audioFile: this.audioFile?.toJSON() || null,
publishedAt: this.publishedAt,
addedAt: this.addedAt,
updatedAt: this.updatedAt
}
}
toJSONExpanded() {
return {
libraryItemId: this.libraryItemId,
podcastId: this.podcastId,
id: this.id,
oldEpisodeId: this.oldEpisodeId,
index: this.index,
season: this.season,
episode: this.episode,
episodeType: this.episodeType,
title: this.title,
subtitle: this.subtitle,
description: this.description,
enclosure: this.enclosure ? { ...this.enclosure } : null,
guid: this.guid,
pubDate: this.pubDate,
chapters: this.chapters.map((ch) => ({ ...ch })),
audioFile: this.audioFile?.toJSON() || null,
audioTrack: this.audioTrack?.toJSON() || null,
publishedAt: this.publishedAt,
addedAt: this.addedAt,
updatedAt: this.updatedAt,
duration: this.duration,
size: this.size
}
}
get audioTrack() {
if (!this.audioFile) return null
const audioTrack = new AudioTrack()
audioTrack.setData(this.libraryItemId, this.audioFile, 0)
return audioTrack
}
get tracks() {
return [this.audioTrack]
}
get duration() {
return this.audioFile?.duration || 0
}
get size() {
return this.audioFile?.metadata.size || 0
}
get enclosureUrl() {
return this.enclosure?.url || null
}
update(payload) {
let hasUpdates = false
for (const key in this.toJSON()) {
let newValue = payload[key]
if (newValue === '') newValue = null
let existingValue = this[key]
if (existingValue === '') existingValue = null
if (newValue != undefined && !areEquivalent(newValue, existingValue)) {
this[key] = copyValue(newValue)
hasUpdates = true
}
}
if (hasUpdates) {
this.updatedAt = Date.now()
}
return hasUpdates
}
}
module.exports = PodcastEpisode
-154
View File
@@ -1,154 +0,0 @@
const Logger = require('../../Logger')
const BookMetadata = require('../metadata/BookMetadata')
const { areEquivalent, copyValue } = require('../../utils/index')
const { filePathToPOSIX } = require('../../utils/fileUtils')
const AudioFile = require('../files/AudioFile')
const AudioTrack = require('../files/AudioTrack')
const EBookFile = require('../files/EBookFile')
class Book {
constructor(book) {
this.id = null
this.libraryItemId = null
this.metadata = null
this.coverPath = null
this.tags = []
this.audioFiles = []
this.chapters = []
this.ebookFile = null
this.lastCoverSearch = null
this.lastCoverSearchQuery = null
if (book) {
this.construct(book)
}
}
construct(book) {
this.id = book.id
this.libraryItemId = book.libraryItemId
this.metadata = new BookMetadata(book.metadata)
this.coverPath = book.coverPath
this.tags = [...book.tags]
this.audioFiles = book.audioFiles.map((f) => new AudioFile(f))
this.chapters = book.chapters.map((c) => ({ ...c }))
this.ebookFile = book.ebookFile ? new EBookFile(book.ebookFile) : null
this.lastCoverSearch = book.lastCoverSearch || null
this.lastCoverSearchQuery = book.lastCoverSearchQuery || null
}
toJSON() {
return {
id: this.id,
libraryItemId: this.libraryItemId,
metadata: this.metadata.toJSON(),
coverPath: this.coverPath,
tags: [...this.tags],
audioFiles: this.audioFiles.map((f) => f.toJSON()),
chapters: this.chapters.map((c) => ({ ...c })),
ebookFile: this.ebookFile ? this.ebookFile.toJSON() : null
}
}
toJSONMinified() {
return {
id: this.id,
metadata: this.metadata.toJSONMinified(),
coverPath: this.coverPath,
tags: [...this.tags],
numTracks: this.tracks.length,
numAudioFiles: this.audioFiles.length,
numChapters: this.chapters.length,
duration: this.duration,
size: this.size,
ebookFormat: this.ebookFile?.ebookFormat
}
}
toJSONExpanded() {
return {
id: this.id,
libraryItemId: this.libraryItemId,
metadata: this.metadata.toJSONExpanded(),
coverPath: this.coverPath,
tags: [...this.tags],
audioFiles: this.audioFiles.map((f) => f.toJSON()),
chapters: this.chapters.map((c) => ({ ...c })),
duration: this.duration,
size: this.size,
tracks: this.tracks.map((t) => t.toJSON()),
ebookFile: this.ebookFile?.toJSON() || null
}
}
toJSONForMetadataFile() {
return {
tags: [...this.tags],
chapters: this.chapters.map((c) => ({ ...c })),
...this.metadata.toJSONForMetadataFile()
}
}
get size() {
var total = 0
this.audioFiles.forEach((af) => (total += af.metadata.size))
if (this.ebookFile) {
total += this.ebookFile.metadata.size
}
return total
}
get includedAudioFiles() {
return this.audioFiles.filter((af) => !af.exclude)
}
get tracks() {
let startOffset = 0
return this.includedAudioFiles.map((af) => {
const audioTrack = new AudioTrack()
audioTrack.setData(this.libraryItemId, af, startOffset)
startOffset += audioTrack.duration
return audioTrack
})
}
get duration() {
let total = 0
this.tracks.forEach((track) => (total += track.duration))
return total
}
get numTracks() {
return this.tracks.length
}
get isEBookOnly() {
return this.ebookFile && !this.numTracks
}
update(payload) {
const json = this.toJSON()
let hasUpdates = false
for (const key in json) {
if (payload[key] !== undefined) {
if (key === 'metadata') {
if (this.metadata.update(payload.metadata)) {
hasUpdates = true
}
} else if (!areEquivalent(payload[key], json[key])) {
this[key] = copyValue(payload[key])
Logger.debug('[Book] Key updated', key, this[key])
hasUpdates = true
}
}
}
return hasUpdates
}
updateCover(coverPath) {
coverPath = filePathToPOSIX(coverPath)
if (this.coverPath === coverPath) return false
this.coverPath = coverPath
return true
}
}
module.exports = Book
-178
View File
@@ -1,178 +0,0 @@
const Logger = require('../../Logger')
const PodcastEpisode = require('../entities/PodcastEpisode')
const PodcastMetadata = require('../metadata/PodcastMetadata')
const { areEquivalent, copyValue } = require('../../utils/index')
const { filePathToPOSIX } = require('../../utils/fileUtils')
class Podcast {
constructor(podcast) {
this.id = null
this.libraryItemId = null
this.metadata = null
this.coverPath = null
this.tags = []
this.episodes = []
this.autoDownloadEpisodes = false
this.autoDownloadSchedule = null
this.lastEpisodeCheck = 0
this.maxEpisodesToKeep = 0
this.maxNewEpisodesToDownload = 3
this.lastCoverSearch = null
this.lastCoverSearchQuery = null
if (podcast) {
this.construct(podcast)
}
}
construct(podcast) {
this.id = podcast.id
this.libraryItemId = podcast.libraryItemId
this.metadata = new PodcastMetadata(podcast.metadata)
this.coverPath = podcast.coverPath
this.tags = [...podcast.tags]
this.episodes = podcast.episodes.map((e) => {
var podcastEpisode = new PodcastEpisode(e)
podcastEpisode.libraryItemId = this.libraryItemId
return podcastEpisode
})
this.autoDownloadEpisodes = !!podcast.autoDownloadEpisodes
this.autoDownloadSchedule = podcast.autoDownloadSchedule || '0 * * * *' // Added in 2.1.3 so default to hourly
this.lastEpisodeCheck = podcast.lastEpisodeCheck || 0
this.maxEpisodesToKeep = podcast.maxEpisodesToKeep || 0
// Default is 3 but 0 is allowed
if (typeof podcast.maxNewEpisodesToDownload !== 'number') {
this.maxNewEpisodesToDownload = 3
} else {
this.maxNewEpisodesToDownload = podcast.maxNewEpisodesToDownload
}
}
toJSON() {
return {
id: this.id,
libraryItemId: this.libraryItemId,
metadata: this.metadata.toJSON(),
coverPath: this.coverPath,
tags: [...this.tags],
episodes: this.episodes.map((e) => e.toJSON()),
autoDownloadEpisodes: this.autoDownloadEpisodes,
autoDownloadSchedule: this.autoDownloadSchedule,
lastEpisodeCheck: this.lastEpisodeCheck,
maxEpisodesToKeep: this.maxEpisodesToKeep,
maxNewEpisodesToDownload: this.maxNewEpisodesToDownload
}
}
toJSONMinified() {
return {
id: this.id,
metadata: this.metadata.toJSONMinified(),
coverPath: this.coverPath,
tags: [...this.tags],
numEpisodes: this.episodes.length,
autoDownloadEpisodes: this.autoDownloadEpisodes,
autoDownloadSchedule: this.autoDownloadSchedule,
lastEpisodeCheck: this.lastEpisodeCheck,
maxEpisodesToKeep: this.maxEpisodesToKeep,
maxNewEpisodesToDownload: this.maxNewEpisodesToDownload,
size: this.size
}
}
toJSONExpanded() {
return {
id: this.id,
libraryItemId: this.libraryItemId,
metadata: this.metadata.toJSONExpanded(),
coverPath: this.coverPath,
tags: [...this.tags],
episodes: this.episodes.map((e) => e.toJSONExpanded()),
autoDownloadEpisodes: this.autoDownloadEpisodes,
autoDownloadSchedule: this.autoDownloadSchedule,
lastEpisodeCheck: this.lastEpisodeCheck,
maxEpisodesToKeep: this.maxEpisodesToKeep,
maxNewEpisodesToDownload: this.maxNewEpisodesToDownload,
size: this.size
}
}
toJSONForMetadataFile() {
return {
tags: [...this.tags],
title: this.metadata.title,
author: this.metadata.author,
description: this.metadata.description,
releaseDate: this.metadata.releaseDate,
genres: [...this.metadata.genres],
feedURL: this.metadata.feedUrl,
imageURL: this.metadata.imageUrl,
itunesPageURL: this.metadata.itunesPageUrl,
itunesId: this.metadata.itunesId,
itunesArtistId: this.metadata.itunesArtistId,
explicit: this.metadata.explicit,
language: this.metadata.language,
podcastType: this.metadata.type
}
}
get size() {
var total = 0
this.episodes.forEach((ep) => (total += ep.size))
return total
}
get duration() {
let total = 0
this.episodes.forEach((ep) => (total += ep.duration))
return total
}
get numTracks() {
return this.episodes.length
}
update(payload) {
var json = this.toJSON()
delete json.episodes // do not update media entities here
var hasUpdates = false
for (const key in json) {
if (payload[key] !== undefined) {
if (key === 'metadata') {
if (this.metadata.update(payload.metadata)) {
hasUpdates = true
}
} else if (!areEquivalent(payload[key], json[key])) {
this[key] = copyValue(payload[key])
Logger.debug('[Podcast] Key updated', key, this[key])
hasUpdates = true
}
}
}
return hasUpdates
}
updateEpisode(id, payload) {
var episode = this.episodes.find((ep) => ep.id == id)
if (!episode) return false
return episode.update(payload)
}
updateCover(coverPath) {
coverPath = filePathToPOSIX(coverPath)
if (this.coverPath === coverPath) return false
this.coverPath = coverPath
return true
}
getEpisode(episodeId) {
if (!episodeId) return null
// Support old episode ids for mobile downloads
if (episodeId.startsWith('ep_')) return this.episodes.find((ep) => ep.oldEpisodeId == episodeId)
return this.episodes.find((ep) => ep.id == episodeId)
}
}
module.exports = Podcast
-179
View File
@@ -1,179 +0,0 @@
const Logger = require('../../Logger')
const { areEquivalent, copyValue, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index')
const parseNameString = require('../../utils/parsers/parseNameString')
class BookMetadata {
constructor(metadata) {
this.title = null
this.subtitle = null
this.authors = []
this.narrators = [] // Array of strings
this.series = []
this.genres = [] // Array of strings
this.publishedYear = null
this.publishedDate = null
this.publisher = null
this.description = null
this.isbn = null
this.asin = null
this.language = null
this.explicit = false
this.abridged = false
if (metadata) {
this.construct(metadata)
}
}
construct(metadata) {
this.title = metadata.title
this.subtitle = metadata.subtitle
this.authors = metadata.authors?.map ? metadata.authors.map((a) => ({ ...a })) : []
this.narrators = metadata.narrators ? [...metadata.narrators].filter((n) => n) : []
this.series = metadata.series?.map
? metadata.series.map((s) => ({
...s,
name: s.name || 'No Title'
}))
: []
this.genres = metadata.genres ? [...metadata.genres] : []
this.publishedYear = metadata.publishedYear || null
this.publishedDate = metadata.publishedDate || null
this.publisher = metadata.publisher
this.description = metadata.description
this.isbn = metadata.isbn
this.asin = metadata.asin
this.language = metadata.language
this.explicit = !!metadata.explicit
this.abridged = !!metadata.abridged
}
toJSON() {
return {
title: this.title,
subtitle: this.subtitle,
authors: this.authors.map((a) => ({ ...a })), // Author JSONMinimal with name and id
narrators: [...this.narrators],
series: this.series.map((s) => ({ ...s })), // Series JSONMinimal with name, id and sequence
genres: [...this.genres],
publishedYear: this.publishedYear,
publishedDate: this.publishedDate,
publisher: this.publisher,
description: this.description,
isbn: this.isbn,
asin: this.asin,
language: this.language,
explicit: this.explicit,
abridged: this.abridged
}
}
toJSONMinified() {
return {
title: this.title,
titleIgnorePrefix: this.titlePrefixAtEnd,
subtitle: this.subtitle,
authorName: this.authorName,
authorNameLF: this.authorNameLF,
narratorName: this.narratorName,
seriesName: this.seriesName,
genres: [...this.genres],
publishedYear: this.publishedYear,
publishedDate: this.publishedDate,
publisher: this.publisher,
description: this.description,
isbn: this.isbn,
asin: this.asin,
language: this.language,
explicit: this.explicit,
abridged: this.abridged
}
}
toJSONExpanded() {
return {
title: this.title,
titleIgnorePrefix: this.titlePrefixAtEnd,
subtitle: this.subtitle,
authors: this.authors.map((a) => ({ ...a })), // Author JSONMinimal with name and id
narrators: [...this.narrators],
series: this.series.map((s) => ({ ...s })),
genres: [...this.genres],
publishedYear: this.publishedYear,
publishedDate: this.publishedDate,
publisher: this.publisher,
description: this.description,
isbn: this.isbn,
asin: this.asin,
language: this.language,
explicit: this.explicit,
authorName: this.authorName,
authorNameLF: this.authorNameLF,
narratorName: this.narratorName,
seriesName: this.seriesName,
abridged: this.abridged
}
}
toJSONForMetadataFile() {
const json = this.toJSON()
json.authors = json.authors.map((au) => au.name)
json.series = json.series.map((se) => {
if (!se.sequence) return se.name
return `${se.name} #${se.sequence}`
})
return json
}
clone() {
return new BookMetadata(this.toJSON())
}
get titleIgnorePrefix() {
return getTitleIgnorePrefix(this.title)
}
get titlePrefixAtEnd() {
return getTitlePrefixAtEnd(this.title)
}
get authorName() {
if (!this.authors.length) return ''
return this.authors.map((au) => au.name).join(', ')
}
get authorNameLF() {
// Last, First
if (!this.authors.length) return ''
return this.authors.map((au) => parseNameString.nameToLastFirst(au.name)).join(', ')
}
get seriesName() {
if (!this.series.length) return ''
return this.series
.map((se) => {
if (!se.sequence) return se.name
return `${se.name} #${se.sequence}`
})
.join(', ')
}
get narratorName() {
return this.narrators.join(', ')
}
getSeries(seriesId) {
return this.series.find((se) => se.id == seriesId)
}
update(payload) {
const json = this.toJSON()
let hasUpdates = false
for (const key in json) {
if (payload[key] !== undefined) {
if (!areEquivalent(payload[key], json[key])) {
this[key] = copyValue(payload[key])
Logger.debug('[BookMetadata] Key updated', key, this[key])
hasUpdates = true
}
}
}
return hasUpdates
}
}
module.exports = BookMetadata
-109
View File
@@ -1,109 +0,0 @@
const Logger = require('../../Logger')
const { areEquivalent, copyValue, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index')
class PodcastMetadata {
constructor(metadata) {
this.title = null
this.author = null
this.description = null
this.releaseDate = null
this.genres = []
this.feedUrl = null
this.imageUrl = null
this.itunesPageUrl = null
this.itunesId = null
this.itunesArtistId = null
this.explicit = false
this.language = null
this.type = null
if (metadata) {
this.construct(metadata)
}
}
construct(metadata) {
this.title = metadata.title
this.author = metadata.author
this.description = metadata.description
this.releaseDate = metadata.releaseDate
this.genres = [...metadata.genres]
this.feedUrl = metadata.feedUrl
this.imageUrl = metadata.imageUrl
this.itunesPageUrl = metadata.itunesPageUrl
this.itunesId = metadata.itunesId
this.itunesArtistId = metadata.itunesArtistId
this.explicit = metadata.explicit
this.language = metadata.language || null
this.type = metadata.type || 'episodic'
}
toJSON() {
return {
title: this.title,
author: this.author,
description: this.description,
releaseDate: this.releaseDate,
genres: [...this.genres],
feedUrl: this.feedUrl,
imageUrl: this.imageUrl,
itunesPageUrl: this.itunesPageUrl,
itunesId: this.itunesId,
itunesArtistId: this.itunesArtistId,
explicit: this.explicit,
language: this.language,
type: this.type
}
}
toJSONMinified() {
return {
title: this.title,
titleIgnorePrefix: this.titlePrefixAtEnd,
author: this.author,
description: this.description,
releaseDate: this.releaseDate,
genres: [...this.genres],
feedUrl: this.feedUrl,
imageUrl: this.imageUrl,
itunesPageUrl: this.itunesPageUrl,
itunesId: this.itunesId,
itunesArtistId: this.itunesArtistId,
explicit: this.explicit,
language: this.language,
type: this.type
}
}
toJSONExpanded() {
return this.toJSONMinified()
}
clone() {
return new PodcastMetadata(this.toJSON())
}
get titleIgnorePrefix() {
return getTitleIgnorePrefix(this.title)
}
get titlePrefixAtEnd() {
return getTitlePrefixAtEnd(this.title)
}
update(payload) {
const json = this.toJSON()
let hasUpdates = false
for (const key in json) {
if (payload[key] !== undefined) {
if (!areEquivalent(payload[key], json[key])) {
this[key] = copyValue(payload[key])
Logger.debug('[PodcastMetadata] Key updated', key, this[key])
hasUpdates = true
}
}
}
return hasUpdates
}
}
module.exports = PodcastMetadata
-105
View File
@@ -105,7 +105,6 @@ class ApiRouter {
this.router.post('/items/batch/scan', LibraryItemController.batchScan.bind(this)) this.router.post('/items/batch/scan', LibraryItemController.batchScan.bind(this))
this.router.get('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.findOne.bind(this)) this.router.get('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.findOne.bind(this))
this.router.patch('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.update.bind(this))
this.router.delete('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.delete.bind(this)) this.router.delete('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.delete.bind(this))
this.router.get('/items/:id/download', LibraryItemController.middleware.bind(this), LibraryItemController.download.bind(this)) this.router.get('/items/:id/download', LibraryItemController.middleware.bind(this), LibraryItemController.download.bind(this))
this.router.patch('/items/:id/media', LibraryItemController.middleware.bind(this), LibraryItemController.updateMedia.bind(this)) this.router.patch('/items/:id/media', LibraryItemController.middleware.bind(this), LibraryItemController.updateMedia.bind(this))
@@ -531,109 +530,5 @@ class ApiRouter {
}) })
return listeningStats return listeningStats
} }
async createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryId) {
if (mediaPayload.metadata) {
const mediaMetadata = mediaPayload.metadata
// Create new authors if in payload
if (mediaMetadata.authors?.length) {
const newAuthors = []
for (let i = 0; i < mediaMetadata.authors.length; i++) {
const authorName = (mediaMetadata.authors[i].name || '').trim()
if (!authorName) {
Logger.error(`[ApiRouter] Invalid author object, no name`, mediaMetadata.authors[i])
mediaMetadata.authors[i].id = null
continue
}
if (mediaMetadata.authors[i].id?.startsWith('new')) {
mediaMetadata.authors[i].id = null
}
// Ensure the ID for the author exists
if (mediaMetadata.authors[i].id && !(await Database.checkAuthorExists(libraryId, mediaMetadata.authors[i].id))) {
Logger.warn(`[ApiRouter] Author id "${mediaMetadata.authors[i].id}" does not exist`)
mediaMetadata.authors[i].id = null
}
if (!mediaMetadata.authors[i].id) {
let author = await Database.authorModel.getByNameAndLibrary(authorName, libraryId)
if (!author) {
author = await Database.authorModel.create({
name: authorName,
lastFirst: Database.authorModel.getLastFirst(authorName),
libraryId
})
Logger.debug(`[ApiRouter] Creating new author "${author.name}"`)
newAuthors.push(author)
// Update filter data
Database.addAuthorToFilterData(libraryId, author.name, author.id)
}
// Update ID in original payload
mediaMetadata.authors[i].id = author.id
}
}
// Remove authors without an id
mediaMetadata.authors = mediaMetadata.authors.filter((au) => !!au.id)
if (newAuthors.length) {
SocketAuthority.emitter(
'authors_added',
newAuthors.map((au) => au.toOldJSON())
)
}
}
// Create new series if in payload
if (mediaMetadata.series && mediaMetadata.series.length) {
const newSeries = []
for (let i = 0; i < mediaMetadata.series.length; i++) {
const seriesName = (mediaMetadata.series[i].name || '').trim()
if (!seriesName) {
Logger.error(`[ApiRouter] Invalid series object, no name`, mediaMetadata.series[i])
mediaMetadata.series[i].id = null
continue
}
if (mediaMetadata.series[i].id?.startsWith('new')) {
mediaMetadata.series[i].id = null
}
// Ensure the ID for the series exists
if (mediaMetadata.series[i].id && !(await Database.checkSeriesExists(libraryId, mediaMetadata.series[i].id))) {
Logger.warn(`[ApiRouter] Series id "${mediaMetadata.series[i].id}" does not exist`)
mediaMetadata.series[i].id = null
}
if (!mediaMetadata.series[i].id) {
let seriesItem = await Database.seriesModel.getByNameAndLibrary(seriesName, libraryId)
if (!seriesItem) {
seriesItem = await Database.seriesModel.create({
name: seriesName,
nameIgnorePrefix: getTitleIgnorePrefix(seriesName),
libraryId
})
Logger.debug(`[ApiRouter] Creating new series "${seriesItem.name}"`)
newSeries.push(seriesItem)
// Update filter data
Database.addSeriesToFilterData(libraryId, seriesItem.name, seriesItem.id)
}
// Update ID in original payload
mediaMetadata.series[i].id = seriesItem.id
}
}
// Remove series without an id
mediaMetadata.series = mediaMetadata.series.filter((se) => se.id)
if (newSeries.length) {
SocketAuthority.emitter(
'multiple_series_added',
newSeries.map((se) => se.toOldJSON())
)
}
}
}
}
} }
module.exports = ApiRouter module.exports = ApiRouter
+5 -4
View File
@@ -499,16 +499,17 @@ class AudioFileScanner {
// Filter these out and log a warning // Filter these out and log a warning
// See https://github.com/advplyr/audiobookshelf/issues/3361 // See https://github.com/advplyr/audiobookshelf/issues/3361
const afChaptersCleaned = const afChaptersCleaned =
file.chapters?.filter((c) => { file.chapters?.filter((c, i) => {
if (c.end - c.start < 0.1) { if (c.end - c.start < 0.1) {
libraryScan.addLog(LogLevel.WARN, `Chapter "${c.title}" has invalid duration of ${c.end - c.start} seconds. Skipping this chapter.`) libraryScan.addLog(LogLevel.WARN, `Audio file "${file.metadata.filename}" Chapter "${c.title}" (index ${i}) has invalid duration of ${c.end - c.start} seconds. Skipping this chapter.`)
return false return false
} }
return true return true
}) || [] }) || []
const afChapters = afChaptersCleaned.map((c) => ({
const afChapters = afChaptersCleaned.map((c, i) => ({
...c, ...c,
id: c.id + currChapterId, id: currChapterId + i,
start: c.start + currStartTime, start: c.start + currStartTime,
end: c.end + currStartTime end: c.end + currStartTime
})) }))
+9 -9
View File
@@ -582,7 +582,7 @@ class LibraryScanner {
} }
// Check if book dir group is already an item // Check if book dir group is already an item
let existingLibraryItem = await Database.libraryItemModel.findOneOld({ let existingLibraryItem = await Database.libraryItemModel.findOneExpanded({
libraryId: library.id, libraryId: library.id,
path: potentialChildDirs path: potentialChildDirs
}) })
@@ -606,17 +606,17 @@ class LibraryScanner {
if (existingLibraryItem.path === fullPath) { if (existingLibraryItem.path === fullPath) {
const exists = await fs.pathExists(fullPath) const exists = await fs.pathExists(fullPath)
if (!exists) { if (!exists) {
Logger.info(`[LibraryScanner] Scanning file update group and library item was deleted "${existingLibraryItem.media.metadata.title}" - marking as missing`) Logger.info(`[LibraryScanner] Scanning file update group and library item was deleted "${existingLibraryItem.media.title}" - marking as missing`)
existingLibraryItem.setMissing() existingLibraryItem.isMissing = true
await Database.updateLibraryItem(existingLibraryItem) await existingLibraryItem.save()
SocketAuthority.emitter('item_updated', existingLibraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', existingLibraryItem.toOldJSONExpanded())
itemGroupingResults[itemDir] = ScanResult.REMOVED itemGroupingResults[itemDir] = ScanResult.REMOVED
continue continue
} }
} }
// Scan library item for updates // Scan library item for updates
Logger.debug(`[LibraryScanner] Folder update for relative path "${itemDir}" is in library item "${existingLibraryItem.media.metadata.title}" with id "${existingLibraryItem.id}" - scan for updates`) Logger.debug(`[LibraryScanner] Folder update for relative path "${itemDir}" is in library item "${existingLibraryItem.media.title}" with id "${existingLibraryItem.id}" - scan for updates`)
itemGroupingResults[itemDir] = await LibraryItemScanner.scanLibraryItem(existingLibraryItem.id, updatedLibraryItemDetails) itemGroupingResults[itemDir] = await LibraryItemScanner.scanLibraryItem(existingLibraryItem.id, updatedLibraryItemDetails)
continue continue
} else if (library.settings.audiobooksOnly && !hasAudioFiles(fileUpdateGroup, itemDir)) { } else if (library.settings.audiobooksOnly && !hasAudioFiles(fileUpdateGroup, itemDir)) {
@@ -672,7 +672,7 @@ function isSingleMediaFile(fileUpdateGroup, itemDir) {
async function findLibraryItemByItemToItemInoMatch(libraryId, fullPath) { async function findLibraryItemByItemToItemInoMatch(libraryId, fullPath) {
const ino = await fileUtils.getIno(fullPath) const ino = await fileUtils.getIno(fullPath)
if (!ino) return null if (!ino) return null
const existingLibraryItem = await Database.libraryItemModel.findOneOld({ const existingLibraryItem = await Database.libraryItemModel.findOneExpanded({
libraryId: libraryId, libraryId: libraryId,
ino: ino ino: ino
}) })
@@ -685,7 +685,7 @@ async function findLibraryItemByItemToFileInoMatch(libraryId, fullPath, isSingle
// check if it was moved from another folder by comparing the ino to the library files // check if it was moved from another folder by comparing the ino to the library files
const ino = await fileUtils.getIno(fullPath) const ino = await fileUtils.getIno(fullPath)
if (!ino) return null if (!ino) return null
const existingLibraryItem = await Database.libraryItemModel.findOneOld( const existingLibraryItem = await Database.libraryItemModel.findOneExpanded(
[ [
{ {
libraryId: libraryId libraryId: libraryId
@@ -711,7 +711,7 @@ async function findLibraryItemByFileToItemInoMatch(libraryId, fullPath, isSingle
if (ino) itemFileInos.push(ino) if (ino) itemFileInos.push(ino)
} }
if (!itemFileInos.length) return null if (!itemFileInos.length) return null
const existingLibraryItem = await Database.libraryItemModel.findOneOld({ const existingLibraryItem = await Database.libraryItemModel.findOneExpanded({
libraryId: libraryId, libraryId: libraryId,
ino: { ino: {
[sequelize.Op.in]: itemFileInos [sequelize.Op.in]: itemFileInos
+193 -110
View File
@@ -30,14 +30,14 @@ class Scanner {
/** /**
* *
* @param {import('../routers/ApiRouter')} apiRouterCtx * @param {import('../routers/ApiRouter')} apiRouterCtx
* @param {import('../objects/LibraryItem')} libraryItem * @param {import('../models/LibraryItem')} libraryItem
* @param {QuickMatchOptions} options * @param {QuickMatchOptions} options
* @returns {Promise<{updated: boolean, libraryItem: import('../objects/LibraryItem')}>} * @returns {Promise<{updated: boolean, libraryItem: Object}>}
*/ */
async quickMatchLibraryItem(apiRouterCtx, libraryItem, options = {}) { async quickMatchLibraryItem(apiRouterCtx, libraryItem, options = {}) {
const provider = options.provider || 'google' const provider = options.provider || 'google'
const searchTitle = options.title || libraryItem.media.metadata.title const searchTitle = options.title || libraryItem.media.title
const searchAuthor = options.author || libraryItem.media.metadata.authorName const searchAuthor = options.author || libraryItem.media.authorName
// If overrideCover and overrideDetails is not sent in options than use the server setting to determine if we should override // If overrideCover and overrideDetails is not sent in options than use the server setting to determine if we should override
if (options.overrideCover === undefined && options.overrideDetails === undefined && Database.serverSettings.scannerPreferMatchedMetadata) { if (options.overrideCover === undefined && options.overrideDetails === undefined && Database.serverSettings.scannerPreferMatchedMetadata) {
@@ -52,11 +52,11 @@ class Scanner {
let existingSeries = [] let existingSeries = []
if (libraryItem.isBook) { if (libraryItem.isBook) {
existingAuthors = libraryItem.media.metadata.authors.map((a) => a.id) existingAuthors = libraryItem.media.authors.map((a) => a.id)
existingSeries = libraryItem.media.metadata.series.map((s) => s.id) existingSeries = libraryItem.media.series.map((s) => s.id)
const searchISBN = options.isbn || libraryItem.media.metadata.isbn const searchISBN = options.isbn || libraryItem.media.isbn
const searchASIN = options.asin || libraryItem.media.metadata.asin const searchASIN = options.asin || libraryItem.media.asin
const results = await BookFinder.search(libraryItem, provider, searchTitle, searchAuthor, searchISBN, searchASIN, { maxFuzzySearches: 2 }) const results = await BookFinder.search(libraryItem, provider, searchTitle, searchAuthor, searchISBN, searchASIN, { maxFuzzySearches: 2 })
if (!results.length) { if (!results.length) {
@@ -69,15 +69,21 @@ class Scanner {
// Update cover if not set OR overrideCover flag // Update cover if not set OR overrideCover flag
if (matchData.cover && (!libraryItem.media.coverPath || options.overrideCover)) { if (matchData.cover && (!libraryItem.media.coverPath || options.overrideCover)) {
Logger.debug(`[Scanner] Updating cover "${matchData.cover}"`) Logger.debug(`[Scanner] Updating cover "${matchData.cover}"`)
var coverResult = await CoverManager.downloadCoverFromUrl(libraryItem, matchData.cover) const coverResult = await CoverManager.downloadCoverFromUrlNew(matchData.cover, libraryItem.id, libraryItem.isFile ? null : libraryItem.path)
if (!coverResult || coverResult.error || !coverResult.cover) { if (coverResult.error) {
Logger.warn(`[Scanner] Match cover "${matchData.cover}" failed to use: ${coverResult ? coverResult.error : 'Unknown Error'}`) Logger.warn(`[Scanner] Match cover "${matchData.cover}" failed to use: ${coverResult.error}`)
} else { } else {
libraryItem.media.coverPath = coverResult.cover
libraryItem.media.changed('coverPath', true) // Cover path may be the same but this forces the update
hasUpdated = true hasUpdated = true
} }
} }
updatePayload = await this.quickMatchBookBuildUpdatePayload(libraryItem, matchData, options) const bookBuildUpdateData = await this.quickMatchBookBuildUpdatePayload(apiRouterCtx, libraryItem, matchData, options)
updatePayload = bookBuildUpdateData.updatePayload
if (bookBuildUpdateData.hasSeriesUpdates || bookBuildUpdateData.hasAuthorUpdates) {
hasUpdated = true
}
} else if (libraryItem.isPodcast) { } else if (libraryItem.isPodcast) {
// Podcast quick match // Podcast quick match
const results = await PodcastFinder.search(searchTitle) const results = await PodcastFinder.search(searchTitle)
@@ -91,10 +97,12 @@ class Scanner {
// Update cover if not set OR overrideCover flag // Update cover if not set OR overrideCover flag
if (matchData.cover && (!libraryItem.media.coverPath || options.overrideCover)) { if (matchData.cover && (!libraryItem.media.coverPath || options.overrideCover)) {
Logger.debug(`[Scanner] Updating cover "${matchData.cover}"`) Logger.debug(`[Scanner] Updating cover "${matchData.cover}"`)
var coverResult = await CoverManager.downloadCoverFromUrl(libraryItem, matchData.cover) const coverResult = await CoverManager.downloadCoverFromUrlNew(matchData.cover, libraryItem.id, libraryItem.path)
if (!coverResult || coverResult.error || !coverResult.cover) { if (coverResult.error) {
Logger.warn(`[Scanner] Match cover "${matchData.cover}" failed to use: ${coverResult ? coverResult.error : 'Unknown Error'}`) Logger.warn(`[Scanner] Match cover "${matchData.cover}" failed to use: ${coverResult.error}`)
} else { } else {
libraryItem.media.coverPath = coverResult.cover
libraryItem.media.changed('coverPath', true) // Cover path may be the same but this forces the update
hasUpdated = true hasUpdated = true
} }
} }
@@ -103,44 +111,45 @@ class Scanner {
} }
if (Object.keys(updatePayload).length) { if (Object.keys(updatePayload).length) {
Logger.debug('[Scanner] Updating details', updatePayload) Logger.debug('[Scanner] Updating details with payload', updatePayload)
if (libraryItem.media.update(updatePayload)) { libraryItem.media.set(updatePayload)
if (libraryItem.media.changed()) {
Logger.debug(`[Scanner] Updating library item "${libraryItem.media.title}" keys`, libraryItem.media.changed())
hasUpdated = true hasUpdated = true
} }
} }
if (hasUpdated) { if (hasUpdated) {
if (libraryItem.isPodcast && libraryItem.media.metadata.feedUrl) { if (libraryItem.isPodcast && libraryItem.media.feedURL) {
// Quick match all unmatched podcast episodes // Quick match all unmatched podcast episodes
await this.quickMatchPodcastEpisodes(libraryItem, options) await this.quickMatchPodcastEpisodes(libraryItem, options)
} }
await Database.updateLibraryItem(libraryItem) await libraryItem.media.save()
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
// Check if any authors or series are now empty and should be removed libraryItem.changed('updatedAt', true)
if (libraryItem.isBook) { await libraryItem.save()
const authorsRemoved = existingAuthors.filter((aid) => !libraryItem.media.metadata.authors.find((au) => au.id === aid))
const seriesRemoved = existingSeries.filter((sid) => !libraryItem.media.metadata.series.find((se) => se.id === sid))
if (authorsRemoved.length) { await libraryItem.saveMetadataFile()
await apiRouterCtx.checkRemoveAuthorsWithNoBooks(authorsRemoved)
} SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
if (seriesRemoved.length) {
await apiRouterCtx.checkRemoveEmptySeries(seriesRemoved)
}
}
} }
return { return {
updated: hasUpdated, updated: hasUpdated,
libraryItem: libraryItem.toJSONExpanded() libraryItem: libraryItem.toOldJSONExpanded()
} }
} }
/**
*
* @param {import('../models/LibraryItem')} libraryItem
* @param {*} matchData
* @param {QuickMatchOptions} options
* @returns {Map<string, any>} - Update payload
*/
quickMatchPodcastBuildUpdatePayload(libraryItem, matchData, options) { quickMatchPodcastBuildUpdatePayload(libraryItem, matchData, options) {
const updatePayload = {} const updatePayload = {}
updatePayload.metadata = {}
const matchDataTransformed = { const matchDataTransformed = {
title: matchData.title || null, title: matchData.title || null,
@@ -158,7 +167,7 @@ class Scanner {
for (const key in matchDataTransformed) { for (const key in matchDataTransformed) {
if (matchDataTransformed[key]) { if (matchDataTransformed[key]) {
if (key === 'genres') { if (key === 'genres') {
if (!libraryItem.media.metadata.genres.length || options.overrideDetails) { if (!libraryItem.media.genres.length || options.overrideDetails) {
var genresArray = [] var genresArray = []
if (Array.isArray(matchDataTransformed[key])) genresArray = [...matchDataTransformed[key]] if (Array.isArray(matchDataTransformed[key])) genresArray = [...matchDataTransformed[key]]
else { else {
@@ -169,46 +178,42 @@ class Scanner {
.map((v) => v.trim()) .map((v) => v.trim())
.filter((v) => !!v) .filter((v) => !!v)
} }
updatePayload.metadata[key] = genresArray updatePayload[key] = genresArray
} }
} else if (libraryItem.media.metadata[key] !== matchDataTransformed[key] && (!libraryItem.media.metadata[key] || options.overrideDetails)) { } else if (libraryItem.media[key] !== matchDataTransformed[key] && (!libraryItem.media[key] || options.overrideDetails)) {
updatePayload.metadata[key] = matchDataTransformed[key] updatePayload[key] = matchDataTransformed[key]
} }
} }
} }
if (!Object.keys(updatePayload.metadata).length) {
delete updatePayload.metadata
}
return updatePayload return updatePayload
} }
/** /**
* *
* @param {import('../objects/LibraryItem')} libraryItem * @param {import('../routers/ApiRouter')} apiRouterCtx
* @param {import('../models/LibraryItem')} libraryItem
* @param {*} matchData * @param {*} matchData
* @param {QuickMatchOptions} options * @param {QuickMatchOptions} options
* @returns * @returns {Promise<{updatePayload: Map<string, any>, seriesIdsRemoved: string[], hasSeriesUpdates: boolean, authorIdsRemoved: string[], hasAuthorUpdates: boolean}>}
*/ */
async quickMatchBookBuildUpdatePayload(libraryItem, matchData, options) { async quickMatchBookBuildUpdatePayload(apiRouterCtx, libraryItem, matchData, options) {
// Update media metadata if not set OR overrideDetails flag // Update media metadata if not set OR overrideDetails flag
const detailKeysToUpdate = ['title', 'subtitle', 'description', 'narrator', 'publisher', 'publishedYear', 'genres', 'tags', 'language', 'explicit', 'abridged', 'asin', 'isbn'] const detailKeysToUpdate = ['title', 'subtitle', 'description', 'narrator', 'publisher', 'publishedYear', 'genres', 'tags', 'language', 'explicit', 'abridged', 'asin', 'isbn']
const updatePayload = {} const updatePayload = {}
updatePayload.metadata = {}
for (const key in matchData) { for (const key in matchData) {
if (matchData[key] && detailKeysToUpdate.includes(key)) { if (matchData[key] && detailKeysToUpdate.includes(key)) {
if (key === 'narrator') { if (key === 'narrator') {
if (!libraryItem.media.metadata.narratorName || options.overrideDetails) { if (!libraryItem.media.narrators?.length || options.overrideDetails) {
updatePayload.metadata.narrators = matchData[key] updatePayload.narrators = matchData[key]
.split(',') .split(',')
.map((v) => v.trim()) .map((v) => v.trim())
.filter((v) => !!v) .filter((v) => !!v)
} }
} else if (key === 'genres') { } else if (key === 'genres') {
if (!libraryItem.media.metadata.genres.length || options.overrideDetails) { if (!libraryItem.media.genres.length || options.overrideDetails) {
var genresArray = [] let genresArray = []
if (Array.isArray(matchData[key])) genresArray = [...matchData[key]] if (Array.isArray(matchData[key])) genresArray = [...matchData[key]]
else { else {
// Genres should always be passed in as an array but just incase handle a string // Genres should always be passed in as an array but just incase handle a string
@@ -218,11 +223,11 @@ class Scanner {
.map((v) => v.trim()) .map((v) => v.trim())
.filter((v) => !!v) .filter((v) => !!v)
} }
updatePayload.metadata[key] = genresArray updatePayload[key] = genresArray
} }
} else if (key === 'tags') { } else if (key === 'tags') {
if (!libraryItem.media.tags.length || options.overrideDetails) { if (!libraryItem.media.tags.length || options.overrideDetails) {
var tagsArray = [] let tagsArray = []
if (Array.isArray(matchData[key])) tagsArray = [...matchData[key]] if (Array.isArray(matchData[key])) tagsArray = [...matchData[key]]
else else
tagsArray = matchData[key] tagsArray = matchData[key]
@@ -231,94 +236,174 @@ class Scanner {
.filter((v) => !!v) .filter((v) => !!v)
updatePayload[key] = tagsArray updatePayload[key] = tagsArray
} }
} else if (!libraryItem.media.metadata[key] || options.overrideDetails) { } else if (!libraryItem.media[key] || options.overrideDetails) {
updatePayload.metadata[key] = matchData[key] updatePayload[key] = matchData[key]
} }
} }
} }
// Add or set author if not set // Add or set author if not set
if (matchData.author && (!libraryItem.media.metadata.authorName || options.overrideDetails)) { let hasAuthorUpdates = false
if (matchData.author && (!libraryItem.media.authorName || options.overrideDetails)) {
if (!Array.isArray(matchData.author)) { if (!Array.isArray(matchData.author)) {
matchData.author = matchData.author matchData.author = matchData.author
.split(',') .split(',')
.map((au) => au.trim()) .map((au) => au.trim())
.filter((au) => !!au) .filter((au) => !!au)
} }
const authorPayload = [] const authorIdsRemoved = []
for (const authorName of matchData.author) { for (const authorName of matchData.author) {
let author = await Database.authorModel.getByNameAndLibrary(authorName, libraryItem.libraryId) const existingAuthor = libraryItem.media.authors.find((a) => a.name.toLowerCase() === authorName.toLowerCase())
if (!author) { if (!existingAuthor) {
author = await Database.authorModel.create({ let author = await Database.authorModel.getByNameAndLibrary(authorName, libraryItem.libraryId)
name: authorName, if (!author) {
lastFirst: Database.authorModel.getLastFirst(authorName), author = await Database.authorModel.create({
libraryId: libraryItem.libraryId name: authorName,
}) lastFirst: Database.authorModel.getLastFirst(authorName),
SocketAuthority.emitter('author_added', author.toOldJSON()) libraryId: libraryItem.libraryId
// Update filter data })
Database.addAuthorToFilterData(libraryItem.libraryId, author.name, author.id) SocketAuthority.emitter('author_added', author.toOldJSON())
// Update filter data
Database.addAuthorToFilterData(libraryItem.libraryId, author.name, author.id)
await Database.bookAuthorModel
.create({
authorId: author.id,
bookId: libraryItem.media.id
})
.then(() => {
Logger.info(`[Scanner] quickMatchBookBuildUpdatePayload: Added author "${author.name}" to "${libraryItem.media.title}"`)
libraryItem.media.authors.push(author)
hasAuthorUpdates = true
})
}
}
const authorsRemoved = libraryItem.media.authors.filter((a) => !matchData.author.find((ma) => ma.toLowerCase() === a.name.toLowerCase()))
if (authorsRemoved.length) {
for (const author of authorsRemoved) {
await Database.bookAuthorModel.destroy({ where: { authorId: author.id, bookId: libraryItem.media.id } })
libraryItem.media.authors = libraryItem.media.authors.filter((a) => a.id !== author.id)
authorIdsRemoved.push(author.id)
Logger.info(`[Scanner] quickMatchBookBuildUpdatePayload: Removed author "${author.name}" from "${libraryItem.media.title}"`)
}
hasAuthorUpdates = true
} }
authorPayload.push(author.toJSONMinimal())
} }
updatePayload.metadata.authors = authorPayload
// For all authors removed from book, check if they are empty now and should be removed
if (authorIdsRemoved.length) {
await apiRouterCtx.checkRemoveAuthorsWithNoBooks(authorIdsRemoved)
}
} }
// Add or set series if not set // Add or set series if not set
if (matchData.series && (!libraryItem.media.metadata.seriesName || options.overrideDetails)) { let hasSeriesUpdates = false
if (matchData.series && (!libraryItem.media.seriesName || options.overrideDetails)) {
if (!Array.isArray(matchData.series)) matchData.series = [{ series: matchData.series, sequence: matchData.sequence }] if (!Array.isArray(matchData.series)) matchData.series = [{ series: matchData.series, sequence: matchData.sequence }]
const seriesPayload = [] const seriesIdsRemoved = []
for (const seriesMatchItem of matchData.series) { for (const seriesMatchItem of matchData.series) {
let seriesItem = await Database.seriesModel.getByNameAndLibrary(seriesMatchItem.series, libraryItem.libraryId) const existingSeries = libraryItem.media.series.find((s) => s.name.toLowerCase() === seriesMatchItem.series.toLowerCase())
if (!seriesItem) { if (existingSeries) {
seriesItem = await Database.seriesModel.create({ if (existingSeries.bookSeries.sequence !== seriesMatchItem.sequence) {
name: seriesMatchItem.series, existingSeries.bookSeries.sequence = seriesMatchItem.sequence
nameIgnorePrefix: getTitleIgnorePrefix(seriesMatchItem.series), await existingSeries.bookSeries.save()
libraryId: libraryItem.libraryId Logger.info(`[Scanner] quickMatchBookBuildUpdatePayload: Updated series sequence for "${existingSeries.name}" to ${seriesMatchItem.sequence} in "${libraryItem.media.title}"`)
hasSeriesUpdates = true
}
} else {
let seriesItem = await Database.seriesModel.getByNameAndLibrary(seriesMatchItem.series, libraryItem.libraryId)
if (!seriesItem) {
seriesItem = await Database.seriesModel.create({
name: seriesMatchItem.series,
nameIgnorePrefix: getTitleIgnorePrefix(seriesMatchItem.series),
libraryId: libraryItem.libraryId
})
// Update filter data
Database.addSeriesToFilterData(libraryItem.libraryId, seriesItem.name, seriesItem.id)
SocketAuthority.emitter('series_added', seriesItem.toOldJSON())
}
const bookSeries = await Database.bookSeriesModel.create({
seriesId: seriesItem.id,
bookId: libraryItem.media.id,
sequence: seriesMatchItem.sequence
}) })
// Update filter data seriesItem.bookSeries = bookSeries
Database.addSeriesToFilterData(libraryItem.libraryId, seriesItem.name, seriesItem.id) libraryItem.media.series.push(seriesItem)
SocketAuthority.emitter('series_added', seriesItem.toOldJSON()) Logger.info(`[Scanner] quickMatchBookBuildUpdatePayload: Added series "${seriesItem.name}" to "${libraryItem.media.title}"`)
hasSeriesUpdates = true
}
const seriesRemoved = libraryItem.media.series.filter((s) => !matchData.series.find((ms) => ms.series.toLowerCase() === s.name.toLowerCase()))
if (seriesRemoved.length) {
for (const series of seriesRemoved) {
await series.bookSeries.destroy()
libraryItem.media.series = libraryItem.media.series.filter((s) => s.id !== series.id)
seriesIdsRemoved.push(series.id)
Logger.info(`[Scanner] quickMatchBookBuildUpdatePayload: Removed series "${series.name}" from "${libraryItem.media.title}"`)
}
hasSeriesUpdates = true
} }
seriesPayload.push(seriesItem.toJSONMinimal(seriesMatchItem.sequence))
} }
updatePayload.metadata.series = seriesPayload
// For all series removed from book, check if it is empty now and should be removed
if (seriesIdsRemoved.length) {
await apiRouterCtx.checkRemoveEmptySeries(seriesIdsRemoved)
}
} }
if (!Object.keys(updatePayload.metadata).length) { return {
delete updatePayload.metadata updatePayload,
hasSeriesUpdates,
hasAuthorUpdates
} }
return updatePayload
} }
/**
*
* @param {import('../models/LibraryItem')} libraryItem
* @param {QuickMatchOptions} options
* @returns {Promise<number>} - Number of episodes updated
*/
async quickMatchPodcastEpisodes(libraryItem, options = {}) { async quickMatchPodcastEpisodes(libraryItem, options = {}) {
const episodesToQuickMatch = libraryItem.media.episodes.filter((ep) => !ep.enclosureUrl) // Only quick match episodes without enclosure /** @type {import('../models/PodcastEpisode')[]} */
if (!episodesToQuickMatch.length) return false const episodesToQuickMatch = libraryItem.media.podcastEpisodes.filter((ep) => !ep.enclosureURL) // Only quick match episodes that are not already matched
if (!episodesToQuickMatch.length) return 0
const feed = await getPodcastFeed(libraryItem.media.metadata.feedUrl) const feed = await getPodcastFeed(libraryItem.media.feedURL)
if (!feed) { if (!feed) {
Logger.error(`[Scanner] quickMatchPodcastEpisodes: Unable to quick match episodes feed not found for "${libraryItem.media.metadata.feedUrl}"`) Logger.error(`[Scanner] quickMatchPodcastEpisodes: Unable to quick match episodes feed not found for "${libraryItem.media.feedURL}"`)
return false return 0
} }
let numEpisodesUpdated = 0 let numEpisodesUpdated = 0
for (const episode of episodesToQuickMatch) { for (const episode of episodesToQuickMatch) {
const episodeMatches = findMatchingEpisodesInFeed(feed, episode.title) const episodeMatches = findMatchingEpisodesInFeed(feed, episode.title)
if (episodeMatches && episodeMatches.length) { if (episodeMatches?.length) {
const wasUpdated = this.updateEpisodeWithMatch(libraryItem, episode, episodeMatches[0].episode, options) const wasUpdated = await this.updateEpisodeWithMatch(episode, episodeMatches[0].episode, options)
if (wasUpdated) numEpisodesUpdated++ if (wasUpdated) numEpisodesUpdated++
} }
} }
if (numEpisodesUpdated) {
Logger.info(`[Scanner] quickMatchPodcastEpisodes: Updated ${numEpisodesUpdated} episodes for "${libraryItem.media.title}"`)
}
return numEpisodesUpdated return numEpisodesUpdated
} }
updateEpisodeWithMatch(libraryItem, episode, episodeToMatch, options = {}) { /**
*
* @param {import('../models/PodcastEpisode')} episode
* @param {import('../utils/podcastUtils').RssPodcastEpisode} episodeToMatch
* @param {QuickMatchOptions} options
* @returns {Promise<boolean>} - true if episode was updated
*/
async updateEpisodeWithMatch(episode, episodeToMatch, options = {}) {
Logger.debug(`[Scanner] quickMatchPodcastEpisodes: Found episode match for "${episode.title}" => ${episodeToMatch.title}`) Logger.debug(`[Scanner] quickMatchPodcastEpisodes: Found episode match for "${episode.title}" => ${episodeToMatch.title}`)
const matchDataTransformed = { const matchDataTransformed = {
title: episodeToMatch.title || '', title: episodeToMatch.title || '',
subtitle: episodeToMatch.subtitle || '', subtitle: episodeToMatch.subtitle || '',
description: episodeToMatch.description || '', description: episodeToMatch.description || '',
enclosure: episodeToMatch.enclosure || null, enclosureURL: episodeToMatch.enclosure?.url || null,
enclosureSize: episodeToMatch.enclosure?.length || null,
enclosureType: episodeToMatch.enclosure?.type || null,
episode: episodeToMatch.episode || '', episode: episodeToMatch.episode || '',
episodeType: episodeToMatch.episodeType || 'full', episodeType: episodeToMatch.episodeType || 'full',
season: episodeToMatch.season || '', season: episodeToMatch.season || '',
@@ -328,20 +413,19 @@ class Scanner {
const updatePayload = {} const updatePayload = {}
for (const key in matchDataTransformed) { for (const key in matchDataTransformed) {
if (matchDataTransformed[key]) { if (matchDataTransformed[key]) {
if (key === 'enclosure') { if (episode[key] !== matchDataTransformed[key] && (!episode[key] || options.overrideDetails)) {
if (!episode.enclosure || JSON.stringify(episode.enclosure) !== JSON.stringify(matchDataTransformed.enclosure)) {
updatePayload[key] = {
...matchDataTransformed.enclosure
}
}
} else if (episode[key] !== matchDataTransformed[key] && (!episode[key] || options.overrideDetails)) {
updatePayload[key] = matchDataTransformed[key] updatePayload[key] = matchDataTransformed[key]
} }
} }
} }
if (Object.keys(updatePayload).length) { if (Object.keys(updatePayload).length) {
return libraryItem.media.updateEpisode(episode.id, updatePayload) episode.set(updatePayload)
if (episode.changed()) {
Logger.debug(`[Scanner] quickMatchPodcastEpisodes: Updating episode "${episode.title}" keys`, episode.changed())
await episode.save()
return true
}
} }
return false return false
} }
@@ -351,7 +435,7 @@ class Scanner {
* *
* @param {import('../routers/ApiRouter')} apiRouterCtx * @param {import('../routers/ApiRouter')} apiRouterCtx
* @param {import('../models/Library')} library * @param {import('../models/Library')} library
* @param {import('../objects/LibraryItem')[]} libraryItems * @param {import('../models/LibraryItem')[]} libraryItems
* @param {LibraryScan} libraryScan * @param {LibraryScan} libraryScan
* @returns {Promise<boolean>} false if scan canceled * @returns {Promise<boolean>} false if scan canceled
*/ */
@@ -359,20 +443,20 @@ class Scanner {
for (let i = 0; i < libraryItems.length; i++) { for (let i = 0; i < libraryItems.length; i++) {
const libraryItem = libraryItems[i] const libraryItem = libraryItems[i]
if (libraryItem.media.metadata.asin && library.settings.skipMatchingMediaWithAsin) { if (libraryItem.media.asin && library.settings.skipMatchingMediaWithAsin) {
Logger.debug(`[Scanner] matchLibraryItems: Skipping "${libraryItem.media.metadata.title}" because it already has an ASIN (${i + 1} of ${libraryItems.length})`) Logger.debug(`[Scanner] matchLibraryItems: Skipping "${libraryItem.media.title}" because it already has an ASIN (${i + 1} of ${libraryItems.length})`)
continue continue
} }
if (libraryItem.media.metadata.isbn && library.settings.skipMatchingMediaWithIsbn) { if (libraryItem.media.isbn && library.settings.skipMatchingMediaWithIsbn) {
Logger.debug(`[Scanner] matchLibraryItems: Skipping "${libraryItem.media.metadata.title}" because it already has an ISBN (${i + 1} of ${libraryItems.length})`) Logger.debug(`[Scanner] matchLibraryItems: Skipping "${libraryItem.media.title}" because it already has an ISBN (${i + 1} of ${libraryItems.length})`)
continue continue
} }
Logger.debug(`[Scanner] matchLibraryItems: Quick matching "${libraryItem.media.metadata.title}" (${i + 1} of ${libraryItems.length})`) Logger.debug(`[Scanner] matchLibraryItems: Quick matching "${libraryItem.media.title}" (${i + 1} of ${libraryItems.length})`)
const result = await this.quickMatchLibraryItem(apiRouterCtx, libraryItem, { provider: library.provider }) const result = await this.quickMatchLibraryItem(apiRouterCtx, libraryItem, { provider: library.provider })
if (result.warning) { if (result.warning) {
Logger.warn(`[Scanner] matchLibraryItems: Match warning ${result.warning} for library item "${libraryItem.media.metadata.title}"`) Logger.warn(`[Scanner] matchLibraryItems: Match warning ${result.warning} for library item "${libraryItem.media.title}"`)
} else if (result.updated) { } else if (result.updated) {
libraryScan.resultsUpdated++ libraryScan.resultsUpdated++
} }
@@ -430,9 +514,8 @@ class Scanner {
offset += limit offset += limit
hasMoreChunks = libraryItems.length === limit hasMoreChunks = libraryItems.length === limit
let oldLibraryItems = libraryItems.map((li) => Database.libraryItemModel.getOldLibraryItem(li))
const shouldContinue = await this.matchLibraryItemsChunk(apiRouterCtx, library, oldLibraryItems, libraryScan) const shouldContinue = await this.matchLibraryItemsChunk(apiRouterCtx, library, libraryItems, libraryScan)
if (!shouldContinue) { if (!shouldContinue) {
isCanceled = true isCanceled = true
break break
+8 -2
View File
@@ -189,8 +189,14 @@ class CbzStreamZipComicBookExtractor extends AbstractComicBookExtractor {
} }
close() { close() {
this.archive?.close() this.archive
Logger.debug(`[CbzStreamZipComicBookExtractor] Closed comic book "${this.comicPath}"`) ?.close()
.then(() => {
Logger.debug(`[CbzStreamZipComicBookExtractor] Closed comic book "${this.comicPath}"`)
})
.catch((error) => {
Logger.error(`[CbzStreamZipComicBookExtractor] Failed to close comic book "${this.comicPath}"`, error)
})
} }
} }
+32 -26
View File
@@ -5,11 +5,10 @@ const fs = require('../libs/fsExtra')
const Path = require('path') const Path = require('path')
const Logger = require('../Logger') const Logger = require('../Logger')
const { filePathToPOSIX, copyToExisting } = require('./fileUtils') const { filePathToPOSIX, copyToExisting } = require('./fileUtils')
const LibraryItem = require('../objects/LibraryItem')
function escapeSingleQuotes(path) { function escapeSingleQuotes(path) {
// return path.replace(/'/g, '\'\\\'\'') // A ' within a quoted string is escaped with '\'' in ffmpeg (see https://www.ffmpeg.org/ffmpeg-utils.html#Quoting-and-escaping)
return filePathToPOSIX(path).replace(/ /g, '\\ ').replace(/'/g, "\\'") return filePathToPOSIX(path).replace(/'/g, "'\\''")
} }
// Returns first track start time // Returns first track start time
@@ -33,7 +32,7 @@ async function writeConcatFile(tracks, outputPath, startTime = 0) {
var tracksToInclude = tracks.filter((t) => t.index >= trackToStartWithIndex) var tracksToInclude = tracks.filter((t) => t.index >= trackToStartWithIndex)
var trackPaths = tracksToInclude.map((t) => { var trackPaths = tracksToInclude.map((t) => {
var line = 'file ' + escapeSingleQuotes(t.metadata.path) + '\n' + `duration ${t.duration}` var line = "file '" + escapeSingleQuotes(t.metadata.path) + "'\n" + `duration ${t.duration}`
return line return line
}) })
var inputstr = trackPaths.join('\n\n') var inputstr = trackPaths.join('\n\n')
@@ -100,7 +99,7 @@ module.exports.resizeImage = resizeImage
/** /**
* *
* @param {import('../objects/PodcastEpisodeDownload')} podcastEpisodeDownload * @param {import('../objects/PodcastEpisodeDownload')} podcastEpisodeDownload
* @returns * @returns {Promise<{success: boolean, isFfmpegError?: boolean}>}
*/ */
module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => { module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
return new Promise(async (resolve) => { return new Promise(async (resolve) => {
@@ -111,12 +110,16 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
headers: { headers: {
'User-Agent': 'audiobookshelf (+https://audiobookshelf.org)' 'User-Agent': 'audiobookshelf (+https://audiobookshelf.org)'
}, },
timeout: 30000 timeout: global.PodcastDownloadTimeout
}).catch((error) => { }).catch((error) => {
Logger.error(`[ffmpegHelpers] Failed to download podcast episode with url "${podcastEpisodeDownload.url}"`, error) Logger.error(`[ffmpegHelpers] Failed to download podcast episode with url "${podcastEpisodeDownload.url}"`, error)
return null return null
}) })
if (!response) return resolve(false) if (!response) {
return resolve({
success: false
})
}
/** @type {import('../libs/fluentFfmpeg/index').FfmpegCommand} */ /** @type {import('../libs/fluentFfmpeg/index').FfmpegCommand} */
const ffmpeg = Ffmpeg(response.data) const ffmpeg = Ffmpeg(response.data)
@@ -178,7 +181,10 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
if (stderrLines.length) { if (stderrLines.length) {
Logger.error(`Full stderr dump for episode url "${podcastEpisodeDownload.url}": ${stderrLines.join('\n')}`) Logger.error(`Full stderr dump for episode url "${podcastEpisodeDownload.url}": ${stderrLines.join('\n')}`)
} }
resolve(false) resolve({
success: false,
isFfmpegError: true
})
}) })
ffmpeg.on('progress', (progress) => { ffmpeg.on('progress', (progress) => {
let progressPercent = 0 let progressPercent = 0
@@ -190,7 +196,9 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
}) })
ffmpeg.on('end', () => { ffmpeg.on('end', () => {
Logger.debug(`[FfmpegHelpers] downloadPodcastEpisode: Complete`) Logger.debug(`[FfmpegHelpers] downloadPodcastEpisode: Complete`)
resolve(podcastEpisodeDownload.targetPath) resolve({
success: true
})
}) })
ffmpeg.run() ffmpeg.run()
}) })
@@ -365,28 +373,26 @@ function escapeFFMetadataValue(value) {
/** /**
* Retrieves the FFmpeg metadata object for a given library item. * Retrieves the FFmpeg metadata object for a given library item.
* *
* @param {LibraryItem} libraryItem - The library item containing the media metadata. * @param {import('../models/LibraryItem')} libraryItem - The library item containing the media metadata.
* @param {number} audioFilesLength - The length of the audio files. * @param {number} audioFilesLength - The length of the audio files.
* @returns {Object} - The FFmpeg metadata object. * @returns {Object} - The FFmpeg metadata object.
*/ */
function getFFMetadataObject(libraryItem, audioFilesLength) { function getFFMetadataObject(libraryItem, audioFilesLength) {
const metadata = libraryItem.media.metadata
const ffmetadata = { const ffmetadata = {
title: metadata.title, title: libraryItem.media.title,
artist: metadata.authorName, artist: libraryItem.media.authorName,
album_artist: metadata.authorName, album_artist: libraryItem.media.authorName,
album: (metadata.title || '') + (metadata.subtitle ? `: ${metadata.subtitle}` : ''), album: (libraryItem.media.title || '') + (libraryItem.media.subtitle ? `: ${libraryItem.media.subtitle}` : ''),
TIT3: metadata.subtitle, // mp3 only TIT3: libraryItem.media.subtitle, // mp3 only
genre: metadata.genres?.join('; '), genre: libraryItem.media.genres?.join('; '),
date: metadata.publishedYear, date: libraryItem.media.publishedYear,
comment: metadata.description, comment: libraryItem.media.description,
description: metadata.description, description: libraryItem.media.description,
composer: metadata.narratorName, composer: (libraryItem.media.narrators || []).join(', '),
copyright: metadata.publisher, copyright: libraryItem.media.publisher,
publisher: metadata.publisher, // mp3 only publisher: libraryItem.media.publisher, // mp3 only
TRACKTOTAL: `${audioFilesLength}`, // mp3 only TRACKTOTAL: `${audioFilesLength}`, // mp3 only
grouping: metadata.series?.map((s) => s.name + (s.sequence ? ` #${s.sequence}` : '')).join('; ') grouping: libraryItem.media.series?.map((s) => s.name + (s.bookSeries.sequence ? ` #${s.bookSeries.sequence}` : '')).join('; ')
} }
Object.keys(ffmetadata).forEach((key) => { Object.keys(ffmetadata).forEach((key) => {
if (!ffmetadata[key]) { if (!ffmetadata[key]) {
@@ -402,7 +408,7 @@ module.exports.getFFMetadataObject = getFFMetadataObject
/** /**
* Merges audio files into a single output file using FFmpeg. * Merges audio files into a single output file using FFmpeg.
* *
* @param {Array} audioTracks - The audio tracks to merge. * @param {import('../models/Book').AudioFileObject} audioTracks - The audio tracks to merge.
* @param {number} duration - The total duration of the audio tracks. * @param {number} duration - The total duration of the audio tracks.
* @param {string} itemCachePath - The path to the item cache. * @param {string} itemCachePath - The path to the item cache.
* @param {string} outputFilePath - The path to the output file. * @param {string} outputFilePath - The path to the output file.
+1 -1
View File
@@ -112,7 +112,7 @@ function secondsToTimestamp(seconds, includeMs = false, alwaysIncludeHours = fal
var ms = _seconds - Math.floor(seconds) var ms = _seconds - Math.floor(seconds)
_seconds = Math.floor(_seconds) _seconds = Math.floor(_seconds)
var msString = '.' + (includeMs ? ms.toFixed(3) : '0.0').split('.')[1] const msString = includeMs ? '.' + ms.toFixed(3).split('.')[1] : ''
if (alwaysIncludeHours) { if (alwaysIncludeHours) {
return `${_hours.toString().padStart(2, '0')}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}${msString}` return `${_hours.toString().padStart(2, '0')}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}${msString}`
} }
+44 -27
View File
@@ -6,35 +6,41 @@ const naturalSort = createNewSortInstance({
}) })
module.exports = { module.exports = {
getSeriesFromBooks(books, filterSeries, hideSingleBookSeries) { /**
*
* @param {import('../models/LibraryItem')[]} libraryItems
* @param {*} filterSeries
* @param {*} hideSingleBookSeries
* @returns
*/
getSeriesFromBooks(libraryItems, filterSeries, hideSingleBookSeries) {
const _series = {} const _series = {}
const seriesToFilterOut = {} const seriesToFilterOut = {}
books.forEach((libraryItem) => { libraryItems.forEach((libraryItem) => {
// get all book series for item that is not already filtered out // get all book series for item that is not already filtered out
const bookSeries = (libraryItem.media.metadata.series || []).filter((se) => !seriesToFilterOut[se.id]) const allBookSeries = (libraryItem.media.series || []).filter((se) => !seriesToFilterOut[se.id])
if (!bookSeries.length) return if (!allBookSeries.length) return
bookSeries.forEach((bookSeriesObj) => { allBookSeries.forEach((bookSeries) => {
// const series = allSeries.find(se => se.id === bookSeriesObj.id) const abJson = libraryItem.toOldJSONMinified()
abJson.sequence = bookSeries.bookSeries.sequence
const abJson = libraryItem.toJSONMinified()
abJson.sequence = bookSeriesObj.sequence
if (filterSeries) { if (filterSeries) {
abJson.filterSeriesSequence = libraryItem.media.metadata.getSeries(filterSeries).sequence const series = libraryItem.media.series.find((se) => se.id === filterSeries)
abJson.filterSeriesSequence = series.bookSeries.sequence
} }
if (!_series[bookSeriesObj.id]) { if (!_series[bookSeries.id]) {
_series[bookSeriesObj.id] = { _series[bookSeries.id] = {
id: bookSeriesObj.id, id: bookSeries.id,
name: bookSeriesObj.name, name: bookSeries.name,
nameIgnorePrefix: getTitlePrefixAtEnd(bookSeriesObj.name), nameIgnorePrefix: getTitlePrefixAtEnd(bookSeries.name),
nameIgnorePrefixSort: getTitleIgnorePrefix(bookSeriesObj.name), nameIgnorePrefixSort: getTitleIgnorePrefix(bookSeries.name),
type: 'series', type: 'series',
books: [abJson], books: [abJson],
totalDuration: isNullOrNaN(abJson.media.duration) ? 0 : Number(abJson.media.duration) totalDuration: isNullOrNaN(abJson.media.duration) ? 0 : Number(abJson.media.duration)
} }
} else { } else {
_series[bookSeriesObj.id].books.push(abJson) _series[bookSeries.id].books.push(abJson)
_series[bookSeriesObj.id].totalDuration += isNullOrNaN(abJson.media.duration) ? 0 : Number(abJson.media.duration) _series[bookSeries.id].totalDuration += isNullOrNaN(abJson.media.duration) ? 0 : Number(abJson.media.duration)
} }
}) })
}) })
@@ -52,6 +58,13 @@ module.exports = {
}) })
}, },
/**
*
* @param {import('../models/LibraryItem')[]} libraryItems
* @param {string} filterSeries - series id
* @param {boolean} hideSingleBookSeries
* @returns
*/
collapseBookSeries(libraryItems, filterSeries, hideSingleBookSeries) { collapseBookSeries(libraryItems, filterSeries, hideSingleBookSeries) {
// Get series from the library items. If this list is being collapsed after filtering for a series, // Get series from the library items. If this list is being collapsed after filtering for a series,
// don't collapse that series, only books that are in other series. // don't collapse that series, only books that are in other series.
@@ -123,8 +136,9 @@ module.exports = {
let libraryItems = books let libraryItems = books
.map((book) => { .map((book) => {
const libraryItem = book.libraryItem const libraryItem = book.libraryItem
delete book.libraryItem
libraryItem.media = book libraryItem.media = book
return Database.libraryItemModel.getOldLibraryItem(libraryItem) return libraryItem
}) })
.filter((li) => { .filter((li) => {
return user.checkCanAccessLibraryItem(li) return user.checkCanAccessLibraryItem(li)
@@ -143,15 +157,18 @@ module.exports = {
if (!payload.sortBy || payload.sortBy === 'sequence') { if (!payload.sortBy || payload.sortBy === 'sequence') {
sortArray = [ sortArray = [
{ {
[direction]: (li) => li.media.metadata.getSeries(seriesId).sequence [direction]: (li) => {
const series = li.media.series.find((se) => se.id === seriesId)
return series.bookSeries.sequence
}
}, },
{ {
// If no series sequence then fallback to sorting by title (or collapsed series name for sub-series) // If no series sequence then fallback to sorting by title (or collapsed series name for sub-series)
[direction]: (li) => { [direction]: (li) => {
if (sortingIgnorePrefix) { if (sortingIgnorePrefix) {
return li.collapsedSeries?.nameIgnorePrefix || li.media.metadata.titleIgnorePrefix return li.collapsedSeries?.nameIgnorePrefix || li.media.titleIgnorePrefix
} else { } else {
return li.collapsedSeries?.name || li.media.metadata.title return li.collapsedSeries?.name || li.media.title
} }
} }
} }
@@ -174,9 +191,9 @@ module.exports = {
[direction]: (li) => { [direction]: (li) => {
if (payload.sortBy === 'media.metadata.title') { if (payload.sortBy === 'media.metadata.title') {
if (sortingIgnorePrefix) { if (sortingIgnorePrefix) {
return li.collapsedSeries?.nameIgnorePrefix || li.media.metadata.titleIgnorePrefix return li.collapsedSeries?.nameIgnorePrefix || li.media.titleIgnorePrefix
} else { } else {
return li.collapsedSeries?.name || li.media.metadata.title return li.collapsedSeries?.name || li.media.title
} }
} else { } else {
return payload.sortBy.split('.').reduce((a, b) => a[b], li) return payload.sortBy.split('.').reduce((a, b) => a[b], li)
@@ -194,12 +211,12 @@ module.exports = {
return Promise.all( return Promise.all(
libraryItems.map(async (li) => { libraryItems.map(async (li) => {
const filteredSeries = li.media.metadata.getSeries(seriesId) const filteredSeries = li.media.series.find((se) => se.id === seriesId)
const json = li.toJSONMinified() const json = li.toOldJSONMinified()
json.media.metadata.series = { json.media.metadata.series = {
id: filteredSeries.id, id: filteredSeries.id,
name: filteredSeries.name, name: filteredSeries.name,
sequence: filteredSeries.sequence sequence: filteredSeries.bookSeries.sequence
} }
if (li.collapsedSeries) { if (li.collapsedSeries) {
+3 -3
View File
@@ -1200,7 +1200,7 @@ async function migrationPatchNewColumns(queryInterface) {
*/ */
async function handleOldLibraryItems(ctx) { async function handleOldLibraryItems(ctx) {
const oldLibraryItems = await oldDbFiles.loadOldData('libraryItems') const oldLibraryItems = await oldDbFiles.loadOldData('libraryItems')
const libraryItems = (await ctx.models.libraryItem.findAllExpandedWhere()).map((li) => ctx.models.libraryItem.getOldLibraryItem(li)) const libraryItems = await ctx.models.libraryItem.findAllExpandedWhere()
const bulkUpdateItems = [] const bulkUpdateItems = []
const bulkUpdateEpisodes = [] const bulkUpdateEpisodes = []
@@ -1218,8 +1218,8 @@ async function handleOldLibraryItems(ctx) {
} }
}) })
if (libraryItem.media.episodes?.length && matchingOldLibraryItem.media.episodes?.length) { if (libraryItem.media.podcastEpisodes?.length && matchingOldLibraryItem.media.episodes?.length) {
for (const podcastEpisode of libraryItem.media.episodes) { for (const podcastEpisode of libraryItem.media.podcastEpisodes) {
// Find matching old episode by audio file ino // Find matching old episode by audio file ino
const matchingOldPodcastEpisode = matchingOldLibraryItem.media.episodes.find((oep) => oep.audioFile?.ino && oep.audioFile.ino === podcastEpisode.audioFile?.ino) const matchingOldPodcastEpisode = matchingOldLibraryItem.media.episodes.find((oep) => oep.audioFile?.ino && oep.audioFile.ino === podcastEpisode.audioFile?.ino)
if (matchingOldPodcastEpisode) { if (matchingOldPodcastEpisode) {
+3 -1
View File
@@ -43,7 +43,9 @@ async function parse(ebookFile) {
archive = createComicBookExtractor(comicPath) archive = createComicBookExtractor(comicPath)
await archive.open() await archive.open()
const filePaths = await archive.getFilePaths() const filePaths = await archive.getFilePaths().catch((error) => {
Logger.error(`[parseComicMetadata] Failed to get file paths from comic at "${comicPath}"`, error)
})
// Sort the file paths in a natural order to get the first image // Sort the file paths in a natural order to get the first image
filePaths.sort((a, b) => { filePaths.sort((a, b) => {
+34 -6
View File
@@ -52,6 +52,29 @@ function extractFirstArrayItem(json, key) {
return json[key][0] return json[key][0]
} }
function extractStringOrStringify(json) {
try {
if (typeof json[Object.keys(json)[0]]?.[0] === 'string') {
return json[Object.keys(json)[0]][0]
}
// Handles case where html was included without being wrapped in CDATA
return JSON.stringify(value)
} catch {
return ''
}
}
function extractFirstArrayItemString(json, key) {
const item = extractFirstArrayItem(json, key)
if (!item) return ''
if (typeof item === 'object') {
if (item?.['_'] && typeof item['_'] === 'string') return item['_']
return extractStringOrStringify(item)
}
return typeof item === 'string' ? item : ''
}
function extractImage(channel) { function extractImage(channel) {
if (!channel.image || !channel.image.url || !channel.image.url.length) { if (!channel.image || !channel.image.url || !channel.image.url.length) {
if (!channel['itunes:image'] || !channel['itunes:image'].length || !channel['itunes:image'][0]['$']) { if (!channel['itunes:image'] || !channel['itunes:image'].length || !channel['itunes:image'][0]['$']) {
@@ -101,7 +124,7 @@ function extractPodcastMetadata(channel) {
} }
if (channel['description']) { if (channel['description']) {
const rawDescription = extractFirstArrayItem(channel, 'description') || '' const rawDescription = extractFirstArrayItemString(channel, 'description')
metadata.description = htmlSanitizer.sanitize(rawDescription.trim()) metadata.description = htmlSanitizer.sanitize(rawDescription.trim())
metadata.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription.trim()) metadata.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription.trim())
} }
@@ -145,7 +168,8 @@ function extractEpisodeData(item) {
// Supposed to be the plaintext description but not always followed // Supposed to be the plaintext description but not always followed
if (item['description']) { if (item['description']) {
const rawDescription = extractFirstArrayItem(item, 'description') || '' const rawDescription = extractFirstArrayItemString(item, 'description')
if (!episode.description) episode.description = htmlSanitizer.sanitize(rawDescription.trim()) if (!episode.description) episode.description = htmlSanitizer.sanitize(rawDescription.trim())
episode.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription.trim()) episode.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription.trim())
} }
@@ -175,9 +199,7 @@ function extractEpisodeData(item) {
const arrayFields = ['title', 'itunes:episodeType', 'itunes:season', 'itunes:episode', 'itunes:author', 'itunes:duration', 'itunes:explicit', 'itunes:subtitle'] const arrayFields = ['title', 'itunes:episodeType', 'itunes:season', 'itunes:episode', 'itunes:author', 'itunes:duration', 'itunes:explicit', 'itunes:subtitle']
arrayFields.forEach((key) => { arrayFields.forEach((key) => {
const cleanKey = key.split(':').pop() const cleanKey = key.split(':').pop()
let value = extractFirstArrayItem(item, key) episode[cleanKey] = extractFirstArrayItemString(item, key)
if (value?.['_']) value = value['_']
episode[cleanKey] = value
}) })
return episode return episode
} }
@@ -281,7 +303,7 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => {
return axios({ return axios({
url: feedUrl, url: feedUrl,
method: 'GET', method: 'GET',
timeout: 12000, timeout: global.PodcastDownloadTimeout,
responseType: 'arraybuffer', responseType: 'arraybuffer',
headers: { headers: {
Accept: 'application/rss+xml, application/xhtml+xml, application/xml, */*;q=0.8', Accept: 'application/rss+xml, application/xhtml+xml, application/xml, */*;q=0.8',
@@ -330,6 +352,12 @@ module.exports.findMatchingEpisodes = async (feedUrl, searchTitle) => {
return this.findMatchingEpisodesInFeed(feed, searchTitle) return this.findMatchingEpisodesInFeed(feed, searchTitle)
} }
/**
*
* @param {RssPodcast} feed
* @param {string} searchTitle
* @returns {Array<{ episode: RssPodcastEpisode, levenshtein: number }>}
*/
module.exports.findMatchingEpisodesInFeed = (feed, searchTitle) => { module.exports.findMatchingEpisodesInFeed = (feed, searchTitle) => {
searchTitle = searchTitle.toLowerCase().trim() searchTitle = searchTitle.toLowerCase().trim()
if (!feed?.episodes) { if (!feed?.episodes) {
+1
View File
@@ -143,6 +143,7 @@ function parseChapters(_chapters) {
.map((chap) => { .map((chap) => {
let title = chap['TAG:title'] || chap.title || '' let title = chap['TAG:title'] || chap.title || ''
if (!title && chap.tags?.title) title = chap.tags.title if (!title && chap.tags?.title) title = chap.tags.title
title = title.trim()
const timebase = chap.time_base?.includes('/') ? Number(chap.time_base.split('/')[1]) : 1 const timebase = chap.time_base?.includes('/') ? Number(chap.time_base.split('/')[1]) : 1
const start = !isNullOrNaN(chap.start_time) ? Number(chap.start_time) : !isNullOrNaN(chap.start) ? Number(chap.start) / timebase : 0 const start = !isNullOrNaN(chap.start_time) ? Number(chap.start_time) : !isNullOrNaN(chap.start) ? Number(chap.start) / timebase : 0

Some files were not shown because too many files have changed in this diff Show More