Compare commits

...

215 Commits

Author SHA1 Message Date
advplyr
b39268ccb0 Remove old Feed/FeedEpisode/FeedMeta objects 2024-12-15 17:54:36 -06:00
advplyr
de8a9304d2 Remove unused old feed methods 2024-12-15 17:05:57 -06:00
advplyr
f8fbd3ac8c Migrate Feed updating and build xml to new model 2024-12-15 16:56:59 -06:00
advplyr
369c05936b Fix feed create entityUpdatedAt value 2024-12-15 14:07:46 -06:00
advplyr
837a180dc1 Refactor RssFeedManager.init to use new model only 2024-12-15 13:14:55 -06:00
advplyr
302b651e7b Fix library item unit test 2024-12-15 12:38:50 -06:00
advplyr
4c68ad46f4 Refactor RssFeedManager to use new model when closing feeds, fix close series feed when series is removed, update RssFeedManager to singleton 2024-12-15 12:37:01 -06:00
advplyr
e50bd93958 Refactor Feed model to create new feed for series 2024-12-15 11:44:07 -06:00
advplyr
d576625cb7 Refactor Feed model to create new feed for collection 2024-12-15 10:53:31 -06:00
advplyr
ca2327aba3 Merge pull request #3721 from advplyr/refactor-feeds-from-item
Refactor Feed model to create new feed for library item
2024-12-14 17:25:10 -06:00
advplyr
9bd1f9e3d5 Refactor Feed model to create new feed for library item 2024-12-14 16:55:56 -06:00
advplyr
c4610e6102 Update:Remove outline for focused modal content 2024-12-13 16:22:32 -06:00
advplyr
329bbea043 Fix:Downloading podcast episode when file extension is mp3 but enclosure type is not mp3 #3711 2024-12-13 16:06:00 -06:00
advplyr
e616b53877 Accessibility update for book & series cards, home page shelf scroll #2268 #3699 2024-12-12 16:51:36 -06:00
advplyr
eab86f90a8 Accessibility update for config side nav and modal, set focus on modal content on open 2024-12-12 15:16:49 -06:00
advplyr
f97389cb2b More accessibility updates: adding roles for toolbars, bookshelf cards, author sort #2268 #3699 2024-12-11 17:24:48 -06:00
advplyr
c5c3aab130 Update:Accessibility for buttons on item page, context menu dropdown, library filter/sort #3699 2024-12-10 17:20:13 -06:00
advplyr
4610e58337 Update:Home shelf labels use h2 tag, play & edit buttons overlaying item page updated to button tag with aria-label for accessibility #3699 2024-12-09 17:24:21 -06:00
advplyr
190a1000d9 Version bump v2.17.5 2024-12-08 09:03:05 -06:00
advplyr
455b96d1ab Merge pull request #3694 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2024-12-08 09:02:14 -06:00
thehijacker
8aaf62f243 Translated using Weblate (Slovenian)
Currently translated at 100.0% (1074 of 1074 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/
2024-12-08 15:57:55 +01:00
Bezruchenko Simon
e6d754113e Translated using Weblate (Ukrainian)
Currently translated at 100.0% (1074 of 1074 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/
2024-12-08 15:57:55 +01:00
Clara Papke
5f72e30e63 Translated using Weblate (German)
Currently translated at 100.0% (1074 of 1074 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/
2024-12-08 15:57:55 +01:00
advplyr
57906540fe Add:Server setting to allow iframe & update UI to differentiate web client settings #3684 2024-12-08 08:57:45 -06:00
advplyr
726adbb3bf Merge pull request #3692 from mikiher/rss-remove-server-address
Remove serverAddress from Feeds and FeedEpisodes URLs
2024-12-08 08:24:41 -06:00
advplyr
f7b7b85673 Add v2.17.5 migration to changelog 2024-12-08 08:19:23 -06:00
advplyr
5646466aa3 Update JSDocs for feeds endpoints 2024-12-08 08:05:33 -06:00
mikiher
b38ce41731 Remove xml cache from Feed object 2024-12-08 09:48:58 +02:00
mikiher
a8ab8badd5 always set req.originalHostPrefix 2024-12-08 09:23:39 +02:00
advplyr
5eca43082e Merge pull request #3687 from jaumet/Catalan-version
Catalan translation added
2024-12-07 15:19:27 -06:00
advplyr
6fa11934be Add:Catalan language option 2024-12-07 15:15:47 -06:00
advplyr
ff7edc32a1 Merge pull request #3689 from Vito0912/feat/fixServercrashPlaybacksession
Resolved a server crash when a playback session lacked media metadata
2024-12-07 15:02:20 -06:00
mikiher
9b8e059efe Remove serverAddress from Feeds and FeedEpisodes URLs 2024-12-07 19:27:37 +02:00
Vito0912
7486d6345d Resolved a server crash when a playback session lacked associated media metadata. 2024-12-07 09:34:06 +01:00
Jaume
835490a9fc Catalan translation added
new file 
client/strings/ca.json
2024-12-07 01:45:41 +01:00
advplyr
3b4a5b8785 Support ALLOW_IFRAME env variable to not include frame-ancestors header #3684 2024-12-06 17:17:32 -06:00
advplyr
9a1c773b7a Fix:Server crash on uploadCover temp file mv failed #3685 2024-12-06 16:59:34 -06:00
advplyr
890b0b949e Version bump v2.17.4 2024-12-05 16:50:30 -06:00
advplyr
b19e360bbb Merge pull request #3674 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2024-12-05 16:32:58 -06:00
SunSpring
1ff7952074 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (1074 of 1074 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/
2024-12-05 23:15:39 +01:00
SunSpring
259d93d882 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (1074 of 1074 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/
2024-12-05 23:15:38 +01:00
Tamanegii
14f60a593b Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (1074 of 1074 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/
2024-12-05 23:15:38 +01:00
SunSpring
7334580c8c Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (1074 of 1074 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/
2024-12-05 23:15:37 +01:00
Tamanegii
f467c44543 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 99.9% (1073 of 1074 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/
2024-12-05 23:15:37 +01:00
Milo Ivir
867354e59d Translated using Weblate (Croatian)
Currently translated at 100.0% (1074 of 1074 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/
2024-12-05 23:15:36 +01:00
gallegonovato
67952cc577 Translated using Weblate (Spanish)
Currently translated at 100.0% (1074 of 1074 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/
2024-12-05 23:15:35 +01:00
Milo Ivir
079a15541c Translated using Weblate (Croatian)
Currently translated at 100.0% (1072 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/
2024-12-05 23:15:35 +01:00
Mario
658ac04268 Translated using Weblate (German)
Currently translated at 100.0% (1072 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/
2024-12-05 23:15:34 +01:00
Mario
cbee6d8f5e Translated using Weblate (German)
Currently translated at 100.0% (1072 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/
2024-12-05 23:15:33 +01:00
thehijacker
68413ae2f6 Translated using Weblate (Slovenian)
Currently translated at 100.0% (1072 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/
2024-12-05 23:15:33 +01:00
Henning
252a233282 Translated using Weblate (German)
Currently translated at 100.0% (1072 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/
2024-12-05 23:15:32 +01:00
advplyr
c35185fff7 Update prober to accept grp1 as an alternative tag to grouping #3681 2024-12-05 16:15:23 -06:00
advplyr
9774b2cfa5 Update JSDocs for groupFileItemsIntoLibraryItemDirs 2024-12-04 16:30:35 -06:00
advplyr
344890fb45 Update watcher files changed function to use the same grouping function as other scans 2024-12-04 16:25:17 -06:00
advplyr
5fa0897ad7 Merge pull request #3665 from mikiher/subdirectory-fixes-3
Subdirectory support for OIDC and SocketIO
2024-12-03 17:29:57 -06:00
advplyr
95c80a5b18 Merge pull request #3672 from Techwolfy/disc-folder-support
Support additional disc folder names
2024-12-03 17:28:32 -06:00
advplyr
0f1b64b883 Add test for grouping book library items 2024-12-03 17:21:57 -06:00
advplyr
615ed26f0f Update:Users table show count next to header 2024-12-02 17:35:35 -06:00
advplyr
84803cef82 Fix:Load year in review stats for playback sessions with null mediaMetadata 2024-12-02 17:23:25 -06:00
Techwolf
605bd73c11 Fix third instance of regex 2024-12-01 23:57:47 -08:00
Techwolf
cc89db059b Fix second instance of regex 2024-12-01 18:41:38 -08:00
Techwolf
a03146e09c Support additional disc folder names 2024-12-01 18:10:44 -08:00
advplyr
33aa4f1952 Merge master 2024-12-01 13:27:20 -06:00
advplyr
c03f18b90a Merge pull request #3670 from advplyr/fix_remove_authors_no_books
Fix:Remove authors with no books when a books is removed #3668
2024-12-01 12:56:57 -06:00
advplyr
0dedb09a07 Update:batchUpdate endpoint validate req.body is an array of objects 2024-12-01 12:49:39 -06:00
advplyr
2b5484243b Add LibraryItemController test for delete/batchDelete/updateMedia endpoint functions to correctly remove authors & series with no books 2024-12-01 12:44:21 -06:00
advplyr
c496db7c95 Fix:Remove authors with no books when a books is removed #3668
- Handles bulk delete, single delete, deleting library folder, and removing items with issues
- Also handles bulk editing and removing authors
2024-12-01 09:51:26 -06:00
advplyr
ea4d5ff665 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2024-11-30 16:26:52 -06:00
advplyr
468a547864 Version bump v2.17.3 2024-11-30 16:26:48 -06:00
advplyr
cd9999d192 Merge pull request #3643 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2024-11-30 16:23:45 -06:00
Charlie
31e302ea59 Translated using Weblate (French)
Currently translated at 100.0% (1072 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/
2024-11-30 23:10:44 +01:00
Dmitry
1ff1ba66fd Translated using Weblate (Russian)
Currently translated at 100.0% (1072 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ru/
2024-11-30 23:10:43 +01:00
Pierrick Guillaume
a5457d7e22 Translated using Weblate (French)
Currently translated at 100.0% (1072 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/
2024-11-30 23:10:43 +01:00
Soaibuzzaman
ddcbfd4500 Translated using Weblate (Bengali)
Currently translated at 100.0% (1072 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/bn/
2024-11-30 23:10:42 +01:00
biuklija
293e530297 Translated using Weblate (Croatian)
Currently translated at 100.0% (1072 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/
2024-11-30 23:10:41 +01:00
thehijacker
7278ad4ee7 Translated using Weblate (Slovenian)
Currently translated at 100.0% (1072 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/
2024-11-30 23:10:40 +01:00
Bezruchenko Simon
0449fb5ef9 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (1072 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/
2024-11-30 23:10:40 +01:00
gallegonovato
d2c28fc69c Translated using Weblate (Spanish)
Currently translated at 100.0% (1072 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/
2024-11-30 23:10:39 +01:00
Vito0912
60ba0163af Translated using Weblate (German)
Currently translated at 99.9% (1071 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/
2024-11-30 23:10:39 +01:00
advplyr
02ca926d88 Merge pull request #3664 from advplyr/v2.17.3-fk-constraints-migration
Add migration to fix dropped foreign key constraints dropped in v2.17.0 migration
2024-11-30 16:10:30 -06:00
advplyr
4b52f31d58 Update v2.17.3 migration file to first check if constraints need to be updated, add unit test 2024-11-30 15:48:20 -06:00
mikiher
9917f2d358 Change migration to v2.17.4 2024-11-29 09:01:03 +02:00
mikiher
8c3ba67583 Fix label order 2024-11-29 05:48:04 +02:00
mikiher
6d8720b404 Subfolder support for OIDC auth 2024-11-29 04:28:50 +02:00
mikiher
843dd0b1b2 Keep original socket.io server for non-subdir clients 2024-11-29 04:13:00 +02:00
advplyr
70f466d03c Add migration for v2.17.3 to fix dropped fk constraints 2024-11-28 17:18:34 -06:00
advplyr
ef82e8b0d0 Fix:Server crash deleting user with sessions 2024-11-27 16:48:07 -06:00
advplyr
c643d4cec8 Merge pull request #3655 from glorenzen/fix/player-settings-modal
Fix player settings modal on share page
2024-11-26 17:12:17 -06:00
advplyr
718d8b5999 Update jump backward amount for share player 2024-11-26 17:05:50 -06:00
advplyr
2ba0f9157d Update share player to load user settings 2024-11-26 17:03:01 -06:00
Greg Lorenzen
53fdb5273c Remove player settings modal from MediaPlayerContainer 2024-11-26 04:04:55 +00:00
Greg Lorenzen
fabdfd5517 Add player settings modal to PlayerUi 2024-11-26 04:04:44 +00:00
advplyr
950993f652 Update:View episode modal includes audio filename and size #3648 2024-11-25 17:26:06 -06:00
advplyr
5a968b002a Update readme.md 2024-11-25 13:29:06 -06:00
advplyr
3acd29fab3 Update readme.md 2024-11-25 13:27:33 -06:00
advplyr
315b21db00 Fix:API get media progress for episode 2024-11-24 15:05:19 -06:00
advplyr
f9aaeb3a34 Update:Set Content-Security-Policy header to disallow iframes 2024-11-23 11:17:13 -06:00
advplyr
d19bb909b3 Fix:Server crash deleting library that has playback sessions #3634 2024-11-22 17:20:31 -06:00
advplyr
f850db23fe Version bump v2.17.2 2024-11-21 15:24:45 -06:00
advplyr
5f81010f6a Merge pull request #3631 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2024-11-21 15:17:42 -06:00
burghy86
daf2493f50 Translated using Weblate (Italian)
Currently translated at 100.0% (1071 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/it/
2024-11-21 22:05:10 +01:00
DR
57222f3611 Translated using Weblate (Hebrew)
Currently translated at 72.8% (780 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/he/
2024-11-21 22:05:10 +01:00
Mohamad Dahhan
62b185979e Translated using Weblate (Arabic)
Currently translated at 14.2% (153 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ar/
2024-11-21 22:05:10 +01:00
DR
ebcc85acc4 Translated using Weblate (Hebrew)
Currently translated at 70.5% (756 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/he/
2024-11-21 22:05:10 +01:00
advplyr
33a7ba4acd Merge pull request #3632 from sevenlayercookie/master
on iOS, do not restrict file types for upload
2024-11-21 15:05:05 -06:00
advplyr
1d4e6993fc Upload page UI updates for mobile 2024-11-21 14:56:43 -06:00
advplyr
784b761629 Fix:Unable to edit series sequence #3636 2024-11-21 14:19:40 -06:00
Harrison Rose
268fb2ce9a on iOS, hide UI on upload page related to folder selection (since iOS Webkit does not support folder selection) 2024-11-21 04:43:03 +00:00
Harrison Rose
fc5f35b388 on iOS, do not restrict file types for upload 2024-11-21 02:06:53 +00:00
advplyr
ff026a06bb Fix v2.17.0 migration to ensure mediaItemShares table exists 2024-11-20 16:48:09 -06:00
advplyr
b148a57c98 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2024-11-19 16:48:09 -06:00
advplyr
ee6e2d2983 Update:Persist podcast episode table sort and filter options in local storage #1321 2024-11-19 16:48:05 -06:00
advplyr
ea3a6fd75e Merge pull request #3603 from nichwall/pr_template
PR Template
2024-11-19 16:15:29 -06:00
advplyr
22f85d3af9 Version bump v2.17.1 2024-11-18 08:02:46 -06:00
advplyr
75f4c2ee99 Merge pull request #3626 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2024-11-18 08:01:58 -06:00
thehijacker
dd3467efa2 Translated using Weblate (Slovenian)
Currently translated at 100.0% (1071 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/
2024-11-18 15:00:12 +01:00
Clara Papke
4adb15c11b Translated using Weblate (German)
Currently translated at 100.0% (1071 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/
2024-11-18 15:00:12 +01:00
advplyr
a5e38d1473 Fix:Error adding new series if a series has a null title #3622 2024-11-18 07:59:02 -06:00
advplyr
778256ca16 Fix:Server crash on new libraries when getting filter data #3623 2024-11-18 07:42:24 -06:00
advplyr
2b0ba7d1e2 Version bump v2.17.0 2024-11-17 16:25:40 -06:00
advplyr
772f3fedb3 Merge pull request #3613 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2024-11-17 16:10:58 -06:00
Mohamad Dahhan
fe25d1dccd Translated using Weblate (Arabic)
Currently translated at 11.9% (128 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ar/
2024-11-17 22:00:57 +00:00
Julio Cesar de jesus
10a7cd0987 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (1071 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/
2024-11-17 22:00:56 +00:00
Julio Cesar de jesus
6786df6965 Translated using Weblate (Spanish)
Currently translated at 100.0% (1071 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/
2024-11-17 22:00:56 +00:00
Mohamad Dahhan
4cfd18c81a Translated using Weblate (Arabic)
Currently translated at 3.8% (41 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ar/
2024-11-17 22:00:55 +00:00
Paulo Henrique Dos Santos Garcia
d25a21cd32 Translated using Weblate (Portuguese (Brazil))
Currently translated at 72.6% (778 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/pt_BR/
2024-11-17 22:00:55 +00:00
DR
b5f0a6f4a6 Translated using Weblate (Hebrew)
Currently translated at 70.5% (756 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/he/
2024-11-17 22:00:54 +00:00
SunSpring
cf19dd23cf Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (1071 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/
2024-11-17 22:00:53 +00:00
Bezruchenko Simon
3e6a2d670e Translated using Weblate (Ukrainian)
Currently translated at 100.0% (1071 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/
2024-11-17 22:00:53 +00:00
biuklija
26ef33a4b6 Translated using Weblate (Croatian)
Currently translated at 100.0% (1071 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/
2024-11-17 22:00:52 +00:00
gallegonovato
9940f1d6db Translated using Weblate (Spanish)
Currently translated at 100.0% (1071 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/
2024-11-17 22:00:52 +00:00
advplyr
75eef8d722 Fix:Book library sort by publishedYear #3620
- Updated sort to cast publishedYear to INTEGER
2024-11-17 16:00:44 -06:00
advplyr
46a3c3de33 Merge pull request #3597 from nichwall/mediatype_uuid_migration
`MediaId` UUID migration
2024-11-17 15:50:10 -06:00
advplyr
2b7e3f0efe Update uuid migration to v2.17.0 and for all tables still using UUIDv4 2024-11-17 15:45:21 -06:00
Nicholas Wallace
d5fbc1d455 Add: statement about workflows passing 2024-11-17 12:22:15 -07:00
advplyr
bbe59499ad Merge pull request #3615 from mikiher/fullupdatefromold-fix
Use a simpler database fetch in fullUpdateFromOld
2024-11-16 16:26:13 -06:00
advplyr
4c88e9c8d2 Merge pull request #3594 from nichwall/filter_data_longer_cache
Increase cache time for `filterdata` in library
2024-11-16 16:18:54 -06:00
mikiher
5ccf5d7150 Use a simpler database fetch in fullUpdateFromOld 2024-11-16 06:26:32 +02:00
advplyr
45f8b06d56 Fix:CBC Radio podcast RSS feeds not accepting our user-agent string #3322 2024-11-15 08:30:54 -06:00
advplyr
2a62992c75 Merge pull request #3576 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2024-11-12 17:15:21 -06:00
thehijacker
997afc1b2f Translated using Weblate (Slovenian)
Currently translated at 100.0% (1071 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/
2024-11-13 00:07:56 +01:00
burghy86
f941ea6500 Translated using Weblate (Italian)
Currently translated at 100.0% (1071 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/it/
2024-11-13 00:07:56 +01:00
thehijacker
92d083164f Translated using Weblate (Slovenian)
Currently translated at 100.0% (1071 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/
2024-11-13 00:07:56 +01:00
Tamanegii
2dd30c7a26 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 71.3% (764 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hant/
2024-11-13 00:07:56 +01:00
gallegonovato
3f0347253e Translated using Weblate (Spanish)
Currently translated at 100.0% (1071 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/
2024-11-13 00:07:56 +01:00
Nicholas W
bb6377fb22 Deleted translation using Weblate (English (United States)) 2024-11-13 00:07:56 +01:00
Pavel Vachek
12c2071358 Translated using Weblate (Czech)
Currently translated at 83.4% (894 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/cs/
2024-11-13 00:07:56 +01:00
kuci-JK
ec4c4a4d5a Translated using Weblate (Czech)
Currently translated at 83.4% (894 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/cs/
2024-11-13 00:07:56 +01:00
Languages add-on
876fcf3296 Added translation using Weblate (Arabic) 2024-11-13 00:07:56 +01:00
Bezruchenko Simon
023ceed286 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (1071 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/
2024-11-13 00:07:56 +01:00
thehijacker
cc42aa32ef Translated using Weblate (Slovenian)
Currently translated at 100.0% (1071 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/
2024-11-13 00:07:56 +01:00
Bezruchenko Simon
7cbb1c60a2 Translated using Weblate (Ukrainian)
Currently translated at 88.6% (949 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/
2024-11-13 00:07:56 +01:00
thehijacker
4ad130a11a Translated using Weblate (Slovenian)
Currently translated at 100.0% (1071 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/
2024-11-13 00:07:56 +01:00
gallegonovato
9bf46b6367 Translated using Weblate (Spanish)
Currently translated at 98.4% (1054 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/
2024-11-13 00:07:55 +01:00
Charlie
4be2909b24 Translated using Weblate (French)
Currently translated at 100.0% (1071 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/
2024-11-13 00:07:55 +01:00
gallegonovato
f161158d83 Translated using Weblate (Spanish)
Currently translated at 98.3% (1053 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/
2024-11-13 00:07:55 +01:00
Dmitry
3a5f6ab6f1 Translated using Weblate (Russian)
Currently translated at 100.0% (1071 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ru/
2024-11-13 00:07:55 +01:00
Charlie
c1b626da14 Translated using Weblate (French)
Currently translated at 97.5% (1045 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/
2024-11-13 00:07:55 +01:00
advplyr
48e0a3c450 Merge pull request #3604 from mikiher/episode-download-oom
Remove unnecessary episode_download_queue_updated socket event causing OOM
2024-11-12 17:07:49 -06:00
mikiher
8626fa3e00 Add episode_download_queue_cleared socket event 2024-11-12 07:37:38 +02:00
mikiher
b50d7f0927 Remove unnecessary socket event causing OOM 2024-11-12 07:25:10 +02:00
Nicholas Wallace
0d54b57151 Add: PR template 2024-11-11 21:20:53 -07:00
advplyr
5a2bdc58da Merge pull request #3595 from nichwall/workflow_trigger_updates
Only run CodeQL and Integration actions if code changed
2024-11-10 17:24:34 -06:00
advplyr
01446c02aa Merge pull request #3599 from mikiher/add-user-cache
Add in-memory user cache
2024-11-10 17:23:58 -06:00
mikiher
a382482173 Add in-memory user cache 2024-11-10 08:34:47 +02:00
advplyr
2e970cbb39 Fix:Series Progress filters incorrect - showing for any users progress #2923 2024-11-09 18:03:50 -06:00
Nicholas Wallace
161a3f4da9 Update migrations changelog for 2.16.3 2024-11-09 13:20:59 -07:00
Nicholas Wallace
713bdcbc41 Add: migration for mediaId to use UUID instead of UUIDV4 2024-11-09 13:10:46 -07:00
Nicholas Wallace
1fa67535f9 Update: only run CodeQL and Integration actions if code changed 2024-11-08 11:20:02 -07:00
Nicholas Wallace
e8d8b67c0a Add: check for deleted items 2024-11-08 10:49:12 -07:00
Nicholas Wallace
e57d4cc544 Add: filter update check to podcast libraries 2024-11-08 09:33:34 -07:00
Nicholas Wallace
435b7fda7e Add: check for changes to library items 2024-11-08 09:09:18 -07:00
advplyr
d7e810fc2f Update readme localization chart to for web client only 2024-11-08 08:04:50 -06:00
advplyr
850ed48955 Fix:Podcast episodes duplicated when a scan runs while the episode is downloading #2785 2024-11-07 17:26:51 -06:00
advplyr
a5ebd89817 Update FolderWatcher to singleton 2024-11-07 16:32:05 -06:00
advplyr
a8ec07cfc9 Merge pull request #3589 from nichwall/migration_table_check_fix
Check that `migrationsMeta` table is well formed instead of just existing
2024-11-07 16:05:07 -06:00
Nicholas Wallace
41fe5373a7 Add: check that migrationsMeta table is well formed 2024-11-06 22:06:58 -07:00
advplyr
0c244cbf95 Merge pull request #3585 from snakehnb/master
Avoid parsing first and last names in Chinese, Japanese and Korean la…
2024-11-06 17:19:58 -06:00
snakehnb
7ef14aabed Avoid parsing first and last names in Chinese, Japanese and Korean languages 2024-11-04 16:13:14 +08:00
advplyr
978c2b05f2 Merge pull request #3584 from mikiher/author-image-performance
Improve author image performance
2024-11-03 13:25:06 -06:00
mikiher
68fd1d67cb Remove token from author image URLs 2024-11-03 08:46:09 +02:00
mikiher
bf8407274e No auth for author images 2024-11-03 08:45:43 +02:00
mikiher
3bc2941445 No db access for author image if in disk cache 2024-11-03 08:44:57 +02:00
advplyr
654b1d6b34 Merge pull request #3580 from mikiher/cover-image-performance
Improve cover image performance
2024-11-02 13:10:00 -05:00
advplyr
7a49681dd2 Fix includes 2024-11-02 13:02:40 -05:00
advplyr
7a1623e6a1 Move cover path func to LibraryItem model 2024-11-02 12:56:40 -05:00
mikiher
c25acb41fa Remove token from cover image urls 2024-11-02 15:37:14 +02:00
mikiher
4224b8a486 No auth and req.user for cover images 2024-11-02 15:17:11 +02:00
mikiher
9e990d7927 Optimize LibraryItemController.getCover 2024-11-02 09:05:30 +02:00
mikiher
431ae97593 add Database.getLibraryItemCoverPath 2024-11-02 09:02:23 +02:00
advplyr
633ff810cf Merge pull request #3574 from 4ch1m/add_mpeg_audio_type
'mpg' and 'mpeg' added as supported audio-type/file-extension
2024-11-01 09:17:52 -05:00
advplyr
f3d2b781ab Add mime types for MPEG/MPG 2024-11-01 09:12:40 -05:00
Achim
32105665c1 'mpg' and 'mpeg' added as supported audio-type/file-extension 2024-10-31 15:29:40 +01:00
advplyr
2b18efdfdc Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2024-10-30 15:27:22 -05:00
advplyr
e0c66ea6df Fix:Global search unclickable from trackpad due to blur event closing menu 2024-10-30 15:27:18 -05:00
advplyr
667c7361d7 Merge pull request #3568 from nichwall/docker_compose_update
Update: user directive in docker compose file
2024-10-30 13:35:02 -05:00
Nicholas Wallace
63fdf0d18e Update: user directive in docker compose file 2024-10-29 18:22:38 -07:00
advplyr
e05cb0ef4d Version bump v2.16.2 2024-10-29 16:11:36 -05:00
advplyr
925c7f7dc7 Merge pull request #3566 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2024-10-29 16:04:27 -05:00
Charlie
c69e97ea24 Translated using Weblate (French)
Currently translated at 95.7% (1025 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/
2024-10-29 22:00:15 +01:00
advplyr
5e2aebc724 Merge pull request #3565 from mikiher/handle-download-errors-2
Fix incorrect call to handleDownloadError
2024-10-29 15:55:37 -05:00
advplyr
6eba467b91 Fix:Session sync for streaming podcast episodes using incorrect duration #3560 2024-10-29 15:41:31 -05:00
mikiher
524cf5ec5b Fix incorrect call to handleDownloadError 2024-10-29 21:42:44 +02:00
advplyr
50fd659749 Version bump v2.16.1 2024-10-28 17:05:47 -05:00
advplyr
8169afb59b Merge pull request #3554 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2024-10-28 17:01:24 -05:00
SunSpring
d40086fea1 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (1071 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/
2024-10-28 23:00:51 +01:00
biuklija
399c40debd Translated using Weblate (Croatian)
Currently translated at 100.0% (1071 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/
2024-10-28 23:00:50 +01:00
Vito0912
d986673dfd Translated using Weblate (German)
Currently translated at 99.8% (1069 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/
2024-10-28 23:00:50 +01:00
thehijacker
f83f4d41f1 Translated using Weblate (Slovenian)
Currently translated at 100.0% (1071 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/
2024-10-28 23:00:49 +01:00
Dmitry
7ed711730e Translated using Weblate (Russian)
Currently translated at 100.0% (1071 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ru/
2024-10-28 23:00:49 +01:00
Frantisek Nagy
94e2ea9df3 Translated using Weblate (Hungarian)
Currently translated at 75.6% (810 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hu/
2024-10-28 23:00:48 +01:00
Bálint Kristóf
8c8c4a15c3 Translated using Weblate (Hungarian)
Currently translated at 75.6% (810 of 1071 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hu/
2024-10-28 23:00:48 +01:00
advplyr
2a9159f106 Merge pull request #3553 from mikiher/handle-download-errors
Add proper error handing for file downloads
2024-10-28 17:00:40 -05:00
advplyr
8f113d17c2 Fix:Ensure library has all settings defined when validating settings for update #3559 2024-10-28 16:57:37 -05:00
mikiher
9084055b95 Add proper error handing for file downloads 2024-10-28 08:03:31 +02:00
144 changed files with 5887 additions and 2097 deletions

33
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,33 @@
<!--
For Work In Progress Pull Requests, please use the Draft PR feature,
see https://github.blog/2019-02-14-introducing-draft-pull-requests/ for further details.
If you do not follow this template, the PR may be closed without review.
Please ensure all checks pass.
If you are a new contributor, the workflows will need to be manually approved before they run.
-->
## Brief summary
<!-- Please provide a brief summary of what your PR attempts to achieve. -->
## Which issue is fixed?
<!-- Which issue number does this PR fix? Ex: "Fixes #1234" -->
## In-depth Description
<!--
Describe your solution in more depth.
How does it work? Why is this the best solution?
Does it solve a problem that affects multiple users or is this an edge case for your setup?
-->
## How have you tested this?
<!-- Please describe in detail with reproducible steps how you tested your changes. -->
## Screenshots
<!-- If your PR includes any changes to the web client, please include screenshots or a short video from before and after your changes. -->

View File

@@ -1,11 +1,25 @@
name: "CodeQL"
name: 'CodeQL'
on:
push:
branches: [ 'master' ]
branches: ['master']
# Only build when files in these directories have been changed
paths:
- client/**
- server/**
- test/**
- index.js
- package.json
pull_request:
# The branches below must be a subset of the branches above
branches: [ 'master' ]
branches: ['master']
# Only build when files in these directories have been changed
paths:
- client/**
- server/**
- test/**
- index.js
- package.json
schedule:
- cron: '16 5 * * 4'
@@ -21,45 +35,44 @@ jobs:
strategy:
fail-fast: false
matrix:
language: [ 'javascript' ]
language: ['javascript']
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Use only 'java' to analyze code written in Java, Kotlin or both
# Use only 'javascript' to analyze code written in JavaScript, TypeScript or both
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Checkout repository
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
# If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
# - run: |
# echo "Run, Build Application using script"
# ./location_of_script_within_repo/buildscript.sh
# - run: |
# echo "Run, Build Application using script"
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
with:
category: "/language:${{matrix.language}}"
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
with:
category: '/language:${{matrix.language}}'

View File

@@ -5,6 +5,13 @@ on:
push:
branches-ignore:
- 'dependabot/**' # Don't run dependabot branches, as they are already covered by pull requests
# Only build when files in these directories have been changed
paths:
- client/**
- server/**
- test/**
- index.js
- package.json
jobs:
build:

View File

@@ -1,6 +1,6 @@
<template>
<div class="w-full h-16 bg-primary relative">
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-60">
<div id="appbar" role="toolbar" aria-label="Appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-60">
<div class="flex h-full items-center">
<nuxt-link to="/">
<img src="~static/icon.svg" :alt="$strings.ButtonHome" class="w-8 min-w-8 h-8 mr-2 sm:w-10 sm:min-w-10 sm:h-10 sm:mr-4" />

View File

@@ -17,7 +17,7 @@
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24e">
<template v-for="(shelf, index) in supportedShelves">
<widgets-item-slider :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" :type="shelf.type" class="bookshelf-row pl-8e my-6e" @selectEntity="(payload) => selectEntity(payload, index)">
<p class="font-semibold text-gray-100">{{ $strings[shelf.labelStringKey] }}</p>
<h2 class="font-semibold text-gray-100">{{ $strings[shelf.labelStringKey] }}</h2>
</widgets-item-slider>
</template>
</div>

View File

@@ -37,18 +37,18 @@
<div class="relative">
<div class="relative text-center categoryPlacard transform z-30 top-0 left-4e md:left-8e w-44e rounded-md">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em 0.5em` }">
<p :style="{ fontSize: 0.9 + 'em' }">{{ $strings[shelf.labelStringKey] }}</p>
<h2 :style="{ fontSize: 0.9 + 'em' }">{{ $strings[shelf.labelStringKey] }}</h2>
</div>
</div>
<div class="bookshelfDividerCategorized h-6e w-full absolute top-0 left-0 right-0 z-20"></div>
</div>
<div v-show="canScrollLeft && !isScrolling" class="hidden sm:flex absolute top-0 left-0 w-32 pr-8 bg-black book-shelf-arrow-left items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollLeft">
<button v-show="canScrollLeft && !isScrolling" :aria-label="$strings.ButtonScrollLeft" class="hidden sm:flex absolute top-0 left-0 w-32 pr-8 bg-black book-shelf-arrow-left items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollLeft">
<span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_left</span>
</div>
<div v-show="canScrollRight && !isScrolling" class="hidden sm:flex absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollRight">
</button>
<button v-show="canScrollRight && !isScrolling" :aria-label="$strings.ButtonScrollRight" class="hidden sm:flex absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollRight">
<span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_right</span>
</div>
</button>
</div>
</template>

View File

@@ -43,7 +43,7 @@
<p class="text-sm">{{ $strings.ButtonAdd }}</p>
</nuxt-link>
</div>
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-40 flex items-center justify-end md:justify-start px-2 md:px-8">
<div id="toolbar" role="toolbar" aria-label="Library Toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-40 flex items-center justify-end md:justify-start px-2 md:px-8">
<!-- Series books page -->
<template v-if="selectedSeries">
<p class="pl-2 text-base md:text-lg">

View File

@@ -1,6 +1,6 @@
<template>
<div>
<div class="w-44 fixed left-0 top-16 bg-bg bg-opacity-100 md:bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-3 transform transition-transform mb-12 overflow-y-auto" :class="wrapperClass + ' ' + (streamLibraryItem ? 'h-[calc(100%-270px)]' : 'h-[calc(100%-110px)]')" v-click-outside="clickOutside">
<div role="toolbar" aria-orientation="vertical" aria-label="Config Sidebar">
<div role="navigation" aria-label="Config Navigation" class="w-44 fixed left-0 top-16 bg-bg bg-opacity-100 md:bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-3 transform transition-transform mb-12 overflow-y-auto" :class="wrapperClass + ' ' + (streamLibraryItem ? 'h-[calc(100%-270px)]' : 'h-[calc(100%-110px)]')" v-click-outside="clickOutside">
<div v-show="isMobilePortrait" class="flex items-center justify-end pb-2 px-4 mb-1" @click="closeDrawer">
<span class="material-symbols text-2xl">arrow_back</span>
</div>

View File

@@ -53,7 +53,6 @@
@showBookmarks="showBookmarks"
@showSleepTimer="showSleepTimerModal = true"
@showPlayerQueueItems="showPlayerQueueItemsModal = true"
@showPlayerSettings="showPlayerSettingsModal = true"
/>
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :library-item-id="libraryItemId" @select="selectBookmark" />
@@ -61,8 +60,6 @@
<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-player-queue-items-modal v-model="showPlayerQueueItemsModal" />
<modals-player-settings-modal v-model="showPlayerSettingsModal" />
</div>
</template>
@@ -81,7 +78,6 @@ export default {
currentTime: 0,
showSleepTimerModal: false,
showPlayerQueueItemsModal: false,
showPlayerSettingsModal: false,
sleepTimerSet: false,
sleepTimerRemaining: 0,
sleepTimerType: null,

View File

@@ -1,9 +1,9 @@
<template>
<div class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-50" style="min-width: 80px" :style="{ top: offsetTop + 'px' }">
<div role="toolbar" aria-orientation="vertical" aria-label="Library Sidebar" class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-50" style="min-width: 80px" :style="{ top: offsetTop + 'px' }">
<!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar -->
<div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
<div id="siderail-buttons-container" :class="{ 'player-open': streamLibraryItem }" class="w-full overflow-y-auto overflow-x-hidden">
<div id="siderail-buttons-container" role="navigation" aria-label="Library Navigation" :class="{ 'player-open': streamLibraryItem }" class="w-full overflow-y-auto overflow-x-hidden">
<nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />

View File

@@ -1,5 +1,5 @@
<template>
<div class="pb-3e" :style="{ minWidth: cardWidth + 'px', maxWidth: cardWidth + 'px' }">
<article class="pb-3e" :style="{ minWidth: cardWidth + 'px', maxWidth: cardWidth + 'px' }">
<nuxt-link :to="`/author/${author?.id}`">
<div cy-id="card" @mouseover="mouseover" @mouseleave="mouseleave">
<div cy-id="imageArea" :style="{ height: cardHeight + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
@@ -34,7 +34,7 @@
</div>
</div>
</nuxt-link>
</div>
</article>
</template>
<script>

View File

@@ -1,5 +1,5 @@
<template>
<div ref="card" :id="`book-card-${index}`" :style="{ minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }" class="absolute rounded-sm z-10 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<article ref="card" :id="`book-card-${index}`" tabindex="0" :aria-label="displayTitle" :style="{ minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }" class="absolute rounded-sm z-10 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div :id="`cover-area-${index}`" class="relative w-full top-0 left-0 rounded overflow-hidden z-10 bg-primary box-shadow-book" :style="{ height: coverHeight + 'px ' }">
<!-- When cover image does not fill -->
<div cy-id="coverBg" v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
@@ -14,21 +14,21 @@
</div>
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
<div cy-id="titleImageNotReady" v-show="libraryItem && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: 0.5 + 'em' }">
<div cy-id="titleImageNotReady" v-show="libraryItem && !imageReady" aria-hidden="true" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: 0.5 + 'em' }">
<p :style="{ fontSize: 0.8 + 'em' }" class="text-gray-300 text-center">{{ title }}</p>
</div>
<!-- Cover Image -->
<img cy-id="coverImage" v-show="libraryItem" ref="cover" :src="bookCoverSrc" class="relative w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
<img cy-id="coverImage" v-show="libraryItem" :alt="`${displayTitle}, ${$strings.LabelCover}`" ref="cover" aria-hidden="true" :src="bookCoverSrc" class="relative w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
<!-- Placeholder Cover Title & Author -->
<div cy-id="placeholderTitle" v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em' }">
<div>
<p cy-id="placeholderTitleText" class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'em' }">{{ titleCleaned }}</p>
<p cy-id="placeholderTitleText" aria-hidden="true" class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'em' }">{{ titleCleaned }}</p>
</div>
</div>
<div cy-id="placeholderAuthor" v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em', bottom: authorBottom + 'em' }">
<p cy-id="placeholderAuthorText" class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'em' }">{{ authorCleaned }}</p>
<p cy-id="placeholderAuthorText" aria-hidden="true" class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'em' }">{{ authorCleaned }}</p>
</div>
<div v-if="seriesSequenceList" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20 text-right" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }" style="background-color: #78350f">
@@ -93,11 +93,11 @@
<!-- rss feed icon -->
<div cy-id="rssFeed" v-if="rssFeed && !isSelectionMode && !isHovering" class="absolute text-success top-0 left-0 z-10" :style="{ padding: 0.375 + 'em' }">
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">rss_feed</span>
<span class="material-symbols" aria-hidden="true" :style="{ fontSize: 1.5 + 'em' }">rss_feed</span>
</div>
<!-- media item shared icon -->
<div cy-id="mediaItemShare" v-if="mediaItemShare && !isSelectionMode && !isHovering" class="absolute text-success left-0 z-10" :style="{ padding: 0.375 + 'em', top: rssFeed ? '2em' : '0px' }">
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">public</span>
<span class="material-symbols" aria-hidden="true" :style="{ fontSize: 1.5 + 'em' }">public</span>
</div>
<!-- Series sequence -->
@@ -114,7 +114,7 @@
<!-- Podcast Num Episodes -->
<div cy-id="numEpisodes" v-else-if="!numEpisodesIncomplete && numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', width: 1.25 + 'em', height: 1.25 + 'em' }">
<p :style="{ fontSize: 0.8 + 'em' }">{{ numEpisodes }}</p>
<p :style="{ fontSize: 0.8 + 'em' }" role="status" :aria-label="$strings.LabelNumberOfEpisodes">{{ numEpisodes }}</p>
</div>
<!-- Podcast Num Episodes -->
@@ -128,7 +128,7 @@
<div cy-id="detailBottom" :id="`description-area-${index}`" v-if="isAlternativeBookshelfView || isAuthorBookshelfView" dir="auto" class="relative mt-2e mb-2e left-0 z-50 w-full">
<div :style="{ fontSize: 0.9 + 'em' }">
<ui-tooltip v-if="displayTitle" :text="displayTitle" :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
<p cy-id="title" ref="displayTitle" class="truncate">{{ displayTitle }}</p>
<p cy-id="title" ref="displayTitle" aria-hidden="true" class="truncate">{{ displayTitle }}</p>
<widgets-explicit-indicator cy-id="explicitIndicator" v-if="isExplicit" />
</ui-tooltip>
</div>
@@ -138,7 +138,7 @@
<p cy-id="line2" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displayLineTwo || '&nbsp;' }}</p>
<p cy-id="line3" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displaySortLine }}</p>
</div>
</div>
</article>
</template>
<script>

View File

@@ -1,5 +1,5 @@
<template>
<div ref="card" :id="`collection-card-${index}`" :style="{ width: cardWidth + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div ref="card" :id="`collection-card-${index}`" role="button" :style="{ width: cardWidth + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div class="relative" :style="{ height: coverHeight + 'px' }">
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
<div class="w-full h-full bg-primary relative rounded overflow-hidden">

View File

@@ -1,5 +1,5 @@
<template>
<div ref="card" :id="`playlist-card-${index}`" :style="{ width: cardWidth + 'px', fontSize: sizeMultiplier + 'rem' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div ref="card" :id="`playlist-card-${index}`" role="button" :style="{ width: cardWidth + 'px', fontSize: sizeMultiplier + 'rem' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div class="relative" :style="{ height: coverHeight + 'px' }">
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
<div class="w-full h-full bg-primary relative rounded overflow-hidden">

View File

@@ -1,5 +1,5 @@
<template>
<div cy-id="card" ref="card" :id="`series-card-${index}`" :style="{ width: cardWidth + 'px' }" class="absolute rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<article cy-id="card" ref="card" :id="`series-card-${index}`" tabindex="0" :aria-label="displayTitle" :style="{ width: cardWidth + 'px' }" class="absolute rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div cy-id="covers-area" class="relative" :style="{ height: coverHeight + 'px' }">
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
<div class="w-full h-full bg-primary relative rounded overflow-hidden z-0">
@@ -7,12 +7,12 @@
</div>
<div cy-id="seriesLengthMarker" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `0.1em 0.25em` }" style="background-color: #cd9d49dd">
<p :style="{ fontSize: 0.8 + 'em' }">{{ books.length }}</p>
<p :style="{ fontSize: 0.8 + 'em' }" role="status" :aria-label="$strings.LabelNumberOfBooks">{{ books.length }}</p>
</div>
<div cy-id="seriesProgressBar" v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1e shadow-sm max-w-full z-10 rounded-b w-full" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
<div cy-id="hoveringDisplayTitle" v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: '1em' }">
<div cy-id="hoveringDisplayTitle" v-if="hasValidCovers" aria-hidden="true" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: '1em' }">
<p :style="{ fontSize: 1.2 + 'em' }">{{ displayTitle }}</p>
</div>
@@ -21,14 +21,14 @@
<div cy-id="standardBottomText" v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-10 left-0 right-0 mx-auto -bottom-6e h-6e rounded-md text-center" :style="{ width: Math.min(200, cardWidth) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em 0.5em` }">
<p cy-id="standardBottomDisplayTitle" class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
<p cy-id="standardBottomDisplayTitle" class="truncate" aria-hidden="true" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
</div>
</div>
<div cy-id="detailBottomText" v-else class="relative z-30 left-0 right-0 mx-auto py-1e rounded-md text-center">
<p cy-id="detailBottomDisplayTitle" class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
<p cy-id="detailBottomDisplayTitle" class="truncate" aria-hidden="true" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
<p cy-id="detailBottomSortLine" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displaySortLine }}</p>
</div>
</div>
</article>
</template>
<script>

View File

@@ -1,15 +1,15 @@
<template>
<div class="">
<div class="w-full relative sm:w-80">
<form @submit.prevent="submitSearch">
<form role="search" @submit.prevent="submitSearch">
<ui-text-input ref="input" v-model="search" :placeholder="$strings.PlaceholderSearch" @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
</form>
<div class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear">
<button :aria-hidden="!search" class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear">
<span v-if="!search" class="material-symbols" style="font-size: 1.2rem">&#xe8b6;</span>
<span v-else class="material-symbols" style="font-size: 1.2rem">close</span>
</div>
</button>
</div>
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full max-w-64 sm:max-w-80 sm:w-80 bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu">
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full max-w-64 sm:max-w-80 sm:w-80 bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu" @mousedown.stop.prevent>
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<li v-if="isTyping" class="py-2 px-2">
<p>{{ $strings.MessageThinking }}</p>
@@ -157,7 +157,7 @@ export default {
clearTimeout(this.focusTimeout)
this.focusTimeout = setTimeout(() => {
this.showMenu = false
}, 200)
}, 100)
},
async runSearch(value) {
this.lastSearch = value

View File

@@ -1,28 +1,30 @@
<template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full bg-bg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between">
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
</span>
<div class="relative h-7">
<button type="button" class="relative w-full h-full bg-bg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="menu" :aria-expanded="showMenu" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between">
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
</span>
</button>
<span v-if="selected === 'all'" class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</span>
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-200" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
<button v-else :aria-label="$strings.ButtonClearFilter" class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-200" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
<span class="material-symbols" style="font-size: 1.1rem">close</span>
</div>
</button>
</button>
</div>
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm libraryFilterMenu">
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<ul v-show="!sublist" class="h-full w-full" role="menu">
<template v-for="item in selectItems">
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item)">
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" :aria-haspopup="item.sublist ? '' : 'menu'" @click="clickedOption(item)">
<div class="flex items-center justify-between">
<span class="font-normal ml-3 block truncate text-sm">{{ item.text }}</span>
</div>
<div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center">
<span class="material-symbols text-2xl">arrow_right</span>
<span class="material-symbols text-2xl" :aria-label="$strings.LabelMore">arrow_right</span>
</div>
<!-- selected checkmark icon -->
<div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
@@ -31,8 +33,8 @@
</li>
</template>
</ul>
<ul v-show="sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-white/5" role="option" @click="sublist = null">
<ul v-show="sublist" class="h-full w-full" role="menu">
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-white/5" role="menuitem" @click="sublist = null">
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
<span class="material-symbols text-2xl">arrow_left</span>
</div>
@@ -40,13 +42,13 @@
<span class="font-normal block truncate">{{ $strings.ButtonBack }}</span>
</div>
</li>
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option">
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="menuitem">
<div class="flex items-center justify-center">
<span class="font-normal block truncate py-2">{{ $getString('LabelLibraryFilterSublistEmpty', [selectedSublistText]) }}</span>
</div>
</li>
<template v-for="item in sublistItems">
<li :key="item.value" class="select-none relative px-2 cursor-pointer hover:bg-white/5" :class="`${sublist}.${item.value}` === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedSublistOption(item.value)">
<li :key="item.value" class="select-none relative px-2 cursor-pointer hover:bg-white/5" :class="`${sublist}.${item.value}` === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" @click="clickedSublistOption(item.value)">
<div class="flex items-center">
<span class="font-normal truncate py-2 text-xs">{{ item.text }}</span>
</div>

View File

@@ -1,20 +1,20 @@
<template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="menu" :aria-expanded="showMenu" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between">
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
<span class="material-symbols text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
<span class="material-symbols text-lg text-yellow-400" :aria-label="descending ? $strings.LabelSortDescending : $strings.LabelSortAscending">{{ descending ? 'expand_more' : 'expand_less' }}</span>
</span>
</button>
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox" aria-labelledby="listbox-label">
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="menu">
<template v-for="item in selectItems">
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item.value)">
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
</div>
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-symbols text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
<span class="material-symbols text-xl" :aria-label="descending ? $strings.LabelSortDescending : $strings.LabelSortAscending">{{ descending ? 'expand_more' : 'expand_less' }}</span>
</span>
</li>
</template>

View File

@@ -1,20 +1,20 @@
<template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none cursor-pointer" aria-haspopup="menu" :aria-expanded="showMenu" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between">
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
<span class="material-symbols text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
<span class="material-symbols text-lg text-yellow-400" :aria-label="descending ? $strings.LabelSortDescending : $strings.LabelSortAscending">{{ descending ? 'expand_more' : 'expand_less' }}</span>
</span>
</button>
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox" aria-labelledby="listbox-label">
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="menu">
<template v-for="item in items">
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item.value)">
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
</div>
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-symbols text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
<span class="material-symbols text-xl" :aria-label="descending ? $strings.LabelSortDescending : $strings.LabelSortAscending">{{ descending ? 'expand_more' : 'expand_less' }}</span>
</span>
</li>
</template>

View File

@@ -56,7 +56,7 @@ export default {
},
imgSrc() {
if (!this.imagePath) return null
return `${this.$config.routerBasePath}/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}`
return `${this.$config.routerBasePath}/api/authors/${this.authorId}/image?ts=${this.updatedAt}`
}
},
methods: {

View File

@@ -121,6 +121,8 @@ export default {
var img = document.createElement('img')
img.src = src
img.alt = `${this.name}, ${this.$strings.LabelCover}`
img.ariaHidden = true
img.className = 'absolute top-0 left-0 w-full h-full'
img.style.objectFit = showCoverBg ? 'contain' : 'cover'

View File

@@ -1,12 +1,12 @@
<template>
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary items-center justify-center opacity-0 hidden" :class="`z-${zIndex} bg-opacity-${bgOpacity}`">
<div ref="wrapper" role="dialog" aria-modal="true" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary items-center justify-center opacity-0 hidden" :class="`z-${zIndex} bg-opacity-${bgOpacity}`">
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
<button class="absolute top-4 right-4 landscape:top-4 landscape:right-4 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 inline-flex text-gray-200 hover:text-white" aria-label="Close modal" @click="clickClose">
<span class="material-symbols text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span>
</button>
<slot name="outer" />
<div ref="content" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white" aria-modal="true" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
<div ref="content" tabindex="0" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white outline-none" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
<slot />
<div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center">
<ui-loading-indicator />
@@ -126,6 +126,9 @@ export default {
this.$eventBus.$on('modal-hotkey', this.hotkey)
this.$store.commit('setOpenModal', this.name)
// Set focus to the modal content
this.content.focus()
},
setHide() {
if (this.content) this.content.style.transform = 'scale(0)'

View File

@@ -59,12 +59,19 @@ export default {
setJumpBackwardAmount(val) {
this.jumpBackwardAmount = val
this.$store.dispatch('user/updateUserSettings', { jumpBackwardAmount: val })
},
settingsUpdated() {
this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack')
this.jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount')
this.jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount')
}
},
mounted() {
this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack')
this.jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount')
this.jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount')
this.settingsUpdated()
this.$eventBus.$on('user-settings', this.settingsUpdated)
},
beforeDestroy() {
this.$eventBus.$off('user-settings', this.settingsUpdated)
}
}
</script>

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="changelog" :width="800" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="text-3xl text-white truncate">Changelog</p>
<h1 class="text-3xl text-white truncate">Changelog</h1>
</div>
</template>
<div class="px-8 py-6 w-full rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-scroll" style="max-height: 80vh">
@@ -13,7 +13,7 @@
</p>
<div class="custom-text" v-html="getChangelog(release)" />
</div>
<div v-if="release !== releasesToShow[releasesToShow.length - 1]" class="border-b border-black-300 my-8" />
<div v-if="release !== releasesToShow[releasesToShow.length - 1]" :key="`${release.name}-divider`" class="border-b border-black-300 my-8" />
</template>
</div>
</modals-modal>

View File

@@ -18,6 +18,23 @@
<p dir="auto" class="text-lg font-semibold mb-6">{{ title }}</p>
<div v-if="description" dir="auto" class="default-style" v-html="description" />
<p v-else class="mb-2">{{ $strings.MessageNoDescription }}</p>
<div class="w-full h-px bg-white/5 my-4" />
<div class="flex items-center">
<div class="flex-grow">
<p class="font-semibold text-xs mb-1">{{ $strings.LabelFilename }}</p>
<p class="mb-2 text-xs">
{{ audioFileFilename }}
</p>
</div>
<div class="flex-grow">
<p class="font-semibold text-xs mb-1">{{ $strings.LabelSize }}</p>
<p class="mb-2 text-xs">
{{ audioFileSize }}
</p>
</div>
</div>
</div>
</modals-modal>
</template>
@@ -54,7 +71,7 @@ export default {
return this.episode.description || ''
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
return this.libraryItem?.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
@@ -65,6 +82,14 @@ export default {
podcastAuthor() {
return this.mediaMetadata.author
},
audioFileFilename() {
return this.episode.audioFile?.metadata?.filename || ''
},
audioFileSize() {
const size = this.episode.audioFile?.metadata?.size || 0
return this.$bytesPretty(size)
},
bookCoverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio']
}

View File

@@ -10,9 +10,9 @@
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedIsOpen }}</p>
<div class="w-full relative">
<ui-text-input v-model="currentFeed.feedUrl" readonly />
<ui-text-input :value="feedUrl" readonly />
<span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(currentFeed.feedUrl)">content_copy</span>
<span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(feedUrl)">content_copy</span>
</div>
<div v-if="currentFeed.meta" class="mt-5">
@@ -111,8 +111,11 @@ export default {
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
feedUrl() {
return this.currentFeed ? `${window.origin}${this.$config.routerBasePath}${this.currentFeed.feedUrl}` : ''
},
demoFeedUrl() {
return `${window.origin}/feed/${this.newFeedSlug}`
return `${window.origin}${this.$config.routerBasePath}/feed/${this.newFeedSlug}`
},
isHttp() {
return window.origin.startsWith('http://')

View File

@@ -5,8 +5,8 @@
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedGeneral }}</p>
<div class="w-full relative">
<ui-text-input v-model="feed.feedUrl" readonly />
<span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(feed.feedUrl)">content_copy</span>
<ui-text-input :value="feedUrl" readonly />
<span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(feedUrl)">content_copy</span>
</div>
<div v-if="feed.meta" class="mt-5">
@@ -70,6 +70,9 @@ export default {
},
_feed() {
return this.feed || {}
},
feedUrl() {
return this.feed ? `${window.origin}${this.$config.routerBasePath}${this.feed.feedUrl}` : ''
}
},
methods: {

View File

@@ -37,7 +37,7 @@
</ui-tooltip>
<ui-tooltip direction="top" :text="$strings.LabelViewPlayerSettings">
<button :aria-label="$strings.LabelViewPlayerSettings" class="outline-none text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showPlayerSettings')">
<button :aria-label="$strings.LabelViewPlayerSettings" class="outline-none text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="showPlayerSettings">
<span class="material-symbols text-2xl sm:text-2.5xl">settings_slow_motion</span>
</button>
</ui-tooltip>
@@ -64,6 +64,8 @@
</div>
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :playback-rate="playbackRate" :chapters="chapters" @select="selectChapter" />
<modals-player-settings-modal v-model="showPlayerSettingsModal" />
</div>
</template>
@@ -96,6 +98,7 @@ export default {
audioEl: null,
seekLoading: false,
showChaptersModal: false,
showPlayerSettingsModal: false,
currentTime: 0,
duration: 0
}
@@ -315,6 +318,9 @@ export default {
if (!this.chapters.length) return
this.showChaptersModal = !this.showChaptersModal
},
showPlayerSettings() {
this.showPlayerSettingsModal = !this.showPlayerSettingsModal
},
init() {
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1

View File

@@ -120,6 +120,7 @@ export default {
this.users = res.users.sort((a, b) => {
return a.createdAt - b.createdAt
})
this.$emit('numUsers', this.users.length)
})
.catch((error) => {
console.error('Failed', error)

View File

@@ -25,7 +25,6 @@
</template>
</div>
</div>
<!-- <p v-if="!episodes.length" class="py-4 text-center text-lg">{{ $strings.MessageNoEpisodes }}</p> -->
<div v-if="episodes.length" class="w-full py-3 mx-auto flex">
<form @submit.prevent="submit" class="flex flex-grow">
<ui-text-input v-model="search" @input="inputUpdate" type="search" :placeholder="$strings.PlaceholderSearchEpisode" class="flex-grow mr-2 text-sm md:text-base" />
@@ -515,6 +514,10 @@ export default {
}
},
filterSortChanged() {
// Save filterKey and sortKey to local storage
localStorage.setItem('podcastEpisodesFilter', this.filterKey)
localStorage.setItem('podcastEpisodesSortBy', this.sortKey + (this.sortDesc ? '-desc' : ''))
this.init()
},
refresh() {
@@ -537,6 +540,11 @@ export default {
}
},
mounted() {
this.filterKey = localStorage.getItem('podcastEpisodesFilter') || 'incomplete'
const sortBy = localStorage.getItem('podcastEpisodesSortBy') || 'publishedAt-desc'
this.sortKey = sortBy.split('-')[0]
this.sortDesc = sortBy.split('-')[1] === 'desc'
this.episodesCopy = this.episodes.map((ep) => ({ ...ep }))
this.initListeners()
this.init()

View File

@@ -1,7 +1,7 @@
<template>
<div class="relative h-9 w-9" v-click-outside="clickOutsideObj">
<slot :disabled="disabled" :showMenu="showMenu" :clickShowMenu="clickShowMenu" :processing="processing">
<button v-if="!processing" type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<button v-if="!processing" type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" :aria-label="$strings.LabelMore" aria-haspopup="menu" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<span class="material-symbols text-2xl" :class="iconClass">&#xe5d4;</span>
</button>
<div v-else class="h-full w-full flex items-center justify-center">
@@ -10,12 +10,12 @@
</slot>
<transition name="menu">
<div v-show="showMenu" ref="menuWrapper" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg rounded-md py-1 focus:outline-none sm:text-sm" :style="{ width: menuWidth + 'px' }">
<div v-show="showMenu" ref="menuWrapper" role="menu" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg rounded-md py-1 focus:outline-none sm:text-sm" :style="{ width: menuWidth + 'px' }">
<template v-for="(item, index) in items">
<template v-if="item.subitems">
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-default" :class="{ 'bg-white/5': mouseoverItemIndex == index }" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop>
<button :key="index" role="menuitem" aria-haspopup="menu" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-default w-full" :class="{ 'bg-white/5': mouseoverItemIndex == index }" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop>
<p>{{ item.text }}</p>
</div>
</button>
<div
v-if="mouseoverItemIndex === index"
:key="`subitems-${index}`"
@@ -25,14 +25,14 @@
:class="openSubMenuLeft ? 'rounded-l-md' : 'rounded-r-md'"
:style="{ left: submenuLeftPos + 'px', top: index * 28 + 'px', width: submenuWidth + 'px' }"
>
<div v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @click.stop="clickAction(subitem.action, subitem.data)">
<button v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" role="menuitem" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer w-full" @click.stop="clickAction(subitem.action, subitem.data)">
<p>{{ subitem.text }}</p>
</div>
</button>
</div>
</template>
<div v-else :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.action)">
<button v-else :key="index" role="menuitem" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer w-full" @click.stop="clickAction(item.action)">
<p class="text-left">{{ item.text }}</p>
</div>
</button>
</template>
</div>
</transition>

View File

@@ -1,5 +1,5 @@
<template>
<button class="icon-btn rounded-md flex items-center justify-center relative" @mousedown.prevent :disabled="disabled || loading" :class="className" @click="clickBtn">
<button :aria-label="ariaLabel" class="icon-btn rounded-md flex items-center justify-center relative" @mousedown.prevent :disabled="disabled || loading" :class="className" @click="clickBtn">
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
@@ -28,7 +28,8 @@ export default {
size: {
type: Number,
default: 9
}
},
ariaLabel: String
},
data() {
return {}

View File

@@ -4,7 +4,7 @@
type="button"
:disabled="disabled"
class="w-10 sm:w-full relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200"
aria-haspopup="listbox"
aria-haspopup="menu"
:aria-expanded="showMenu"
:aria-label="$strings.ButtonLibrary + ': ' + currentLibrary.name"
@click.stop.prevent="clickShowMenu"
@@ -16,9 +16,9 @@
</button>
<transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full min-w-48 bg-primary border border-black-200 shadow-lg rounded-b-md py-1 overflow-auto focus:outline-none sm:text-sm librariesDropdownMenu" tabindex="-1" role="listbox">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full min-w-48 bg-primary border border-black-200 shadow-lg rounded-b-md py-1 overflow-auto focus:outline-none sm:text-sm librariesDropdownMenu" tabindex="-1" role="menu">
<template v-for="library in librariesFiltered">
<li :key="library.id" class="text-gray-400 hover:text-white relative py-2 cursor-pointer hover:bg-black-400" role="option" tabindex="0" @keydown.enter="selectLibrary(library)" @click="selectLibrary(library)">
<li :key="library.id" class="text-gray-400 hover:text-white relative py-2 cursor-pointer hover:bg-black-400" role="menuitem" tabindex="0" @keydown.enter="selectLibrary(library)" @click="selectLibrary(library)">
<div class="flex items-center px-2">
<ui-library-icon :icon="library.icon" class="mr-1.5" />
<span class="font-normal block truncate font-sans text-sm">{{ library.name }}</span>

View File

@@ -1,5 +1,5 @@
<template>
<button class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" :class="borderless ? '' : 'bg-primary border border-gray-600'" @click="clickBtn">
<button :aria-label="isRead ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" :class="borderless ? '' : 'bg-primary border border-gray-600'" @click="clickBtn">
<div class="w-5 h-5 text-white relative">
<svg v-if="isRead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)">
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" />

View File

@@ -1,6 +1,6 @@
<template>
<div>
<div class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent>
<div aria-hidden="true" class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent>
<span class="material-symbols" :class="selectedSizeIndex === 0 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="decreaseSize" aria-label="Decrease Cover Size" role="button">&#xe15b;</span>
<p class="px-2 font-mono" style="font-size: 1rem">{{ bookCoverWidth }}</p>
<span class="material-symbols" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize" aria-label="Increase Cover Size" role="button">&#xe145;</span>

View File

@@ -3,10 +3,10 @@
<div class="flex items-center py-3e">
<slot />
<div class="flex-grow" />
<button cy-id="leftScrollButton" v-if="isScrollable" class="w-8e h-8e mx-1e flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
<button cy-id="leftScrollButton" v-if="isScrollable" :aria-label="$strings.ButtonScrollLeft" class="w-8e h-8e mx-1e flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">chevron_left</span>
</button>
<button cy-id="rightScrollButton" v-if="isScrollable" class="w-8e h-8e mx-1e flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
<button cy-id="rightScrollButton" v-if="isScrollable" :aria-label="$strings.ButtonScrollRight" class="w-8e h-8e mx-1e flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">chevron_right</span>
</button>
</div>

View File

@@ -71,8 +71,6 @@ export default {
this.showSeriesForm = true
},
submitSeriesForm() {
console.log('submit series form', this.value, this.selectedSeries)
if (!this.selectedSeries.name) {
this.$toast.error('Must enter a series')
return

View File

@@ -28,10 +28,8 @@ export default {
var validOtherFiles = []
var ignoredFiles = []
files.forEach((file) => {
// var filetype = this.checkFileType(file.name)
if (!file.filetype) ignoredFiles.push(file)
else {
// file.filetype = filetype
if (file.filetype === 'audio' || (file.filetype === 'ebook' && mediaType === 'book')) validItemFiles.push(file)
else validOtherFiles.push(file)
}
@@ -165,7 +163,7 @@ export default {
var firstBookPath = Path.dirname(firstBookFile.filepath)
var dirs = firstBookPath.split('/').filter(d => !!d && d !== '.')
var dirs = firstBookPath.split('/').filter((d) => !!d && d !== '.')
if (dirs.length) {
audiobook.title = dirs.pop()
if (dirs.length > 1) {
@@ -189,7 +187,7 @@ export default {
var firstAudioFile = podcast.itemFiles[0]
if (!firstAudioFile.filepath) return podcast // No path
var firstPath = Path.dirname(firstAudioFile.filepath)
var dirs = firstPath.split('/').filter(d => !!d && d !== '.')
var dirs = firstPath.split('/').filter((d) => !!d && d !== '.')
if (dirs.length) {
podcast.title = dirs.length > 1 ? dirs[1] : dirs[0]
} else {
@@ -212,13 +210,15 @@ export default {
}
var ignoredFiles = itemData.ignoredFiles
var index = 1
var items = itemData.items.filter((ab) => {
if (!ab.itemFiles.length) {
if (ab.otherFiles.length) ignoredFiles = ignoredFiles.concat(ab.otherFiles)
if (ab.ignoredFiles.length) ignoredFiles = ignoredFiles.concat(ab.ignoredFiles)
}
return ab.itemFiles.length
}).map(ab => this.cleanItem(ab, mediaType, index++))
var items = itemData.items
.filter((ab) => {
if (!ab.itemFiles.length) {
if (ab.otherFiles.length) ignoredFiles = ignoredFiles.concat(ab.otherFiles)
if (ab.ignoredFiles.length) ignoredFiles = ignoredFiles.concat(ab.ignoredFiles)
}
return ab.itemFiles.length
})
.map((ab) => this.cleanItem(ab, mediaType, index++))
return {
items,
ignoredFiles
@@ -259,7 +259,7 @@ export default {
otherFiles.forEach((file) => {
var dir = Path.dirname(file.filepath)
var findItem = Object.values(itemMap).find(b => dir.startsWith(b.path))
var findItem = Object.values(itemMap).find((b) => dir.startsWith(b.path))
if (findItem) {
findItem.otherFiles.push(file)
} else {
@@ -270,18 +270,18 @@ export default {
var items = []
var index = 1
// If book media type and all files are audio files then treat each one as an audiobook
if (itemMap[''] && !otherFiles.length && mediaType === 'book' && !itemMap[''].itemFiles.some(f => f.filetype !== 'audio')) {
if (itemMap[''] && !otherFiles.length && mediaType === 'book' && !itemMap[''].itemFiles.some((f) => f.filetype !== 'audio')) {
items = itemMap[''].itemFiles.map((audioFile) => {
return this.cleanItem({ itemFiles: [audioFile], otherFiles: [], ignoredFiles: [] }, mediaType, index++)
})
} else {
items = Object.values(itemMap).map(i => this.cleanItem(i, mediaType, index++))
items = Object.values(itemMap).map((i) => this.cleanItem(i, mediaType, index++))
}
return {
items,
ignoredFiles: ignoredFiles
}
},
}
}
}
}

View File

@@ -1,12 +1,12 @@
{
"name": "audiobookshelf-client",
"version": "2.16.0",
"version": "2.17.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf-client",
"version": "2.16.0",
"version": "2.17.5",
"license": "ISC",
"dependencies": {
"@nuxtjs/axios": "^5.13.6",

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "2.16.0",
"version": "2.17.5",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast client",
"main": "index.js",

View File

@@ -64,6 +64,20 @@
<ui-multi-select ref="redirectUris" v-model="newAuthSettings.authOpenIDMobileRedirectURIs" :items="newAuthSettings.authOpenIDMobileRedirectURIs" :label="$strings.LabelMobileRedirectURIs" class="mb-2" :menuDisabled="true" :disabled="savingSettings" />
<p class="sm:pl-4 text-sm text-gray-300 mb-2" v-html="$strings.LabelMobileRedirectURIsDescription" />
<div class="flex sm:items-center flex-col sm:flex-row pt-1 mb-2">
<div class="w-44">
<ui-dropdown v-model="newAuthSettings.authOpenIDSubfolderForRedirectURLs" small :items="subfolderOptions" :label="$strings.LabelWebRedirectURLsSubfolder" :disabled="savingSettings" />
</div>
<div class="mt-2 sm:mt-5">
<p class="sm:pl-4 text-sm text-gray-300">{{ $strings.LabelWebRedirectURLsDescription }}</p>
<p class="sm:pl-4 text-sm text-gray-300 mb-2">
<code>{{ webCallbackURL }}</code>
<br />
<code>{{ mobileAppCallbackURL }}</code>
</p>
</div>
</div>
<ui-text-input-with-label ref="buttonTextInput" v-model="newAuthSettings.authOpenIDButtonText" :disabled="savingSettings" :label="$strings.LabelButtonText" class="mb-2" />
<div class="flex sm:items-center flex-col sm:flex-row pt-1 mb-2">
@@ -164,6 +178,27 @@ export default {
value: 'username'
}
]
},
subfolderOptions() {
const options = [
{
text: 'None',
value: ''
}
]
if (this.$config.routerBasePath) {
options.push({
text: this.$config.routerBasePath,
value: this.$config.routerBasePath
})
}
return options
},
webCallbackURL() {
return `https://<your.server.com>${this.newAuthSettings.authOpenIDSubfolderForRedirectURLs ? this.newAuthSettings.authOpenIDSubfolderForRedirectURLs : ''}/auth/openid/callback`
},
mobileAppCallbackURL() {
return `https://<your.server.com>${this.newAuthSettings.authOpenIDSubfolderForRedirectURLs ? this.newAuthSettings.authOpenIDSubfolderForRedirectURLs : ''}/auth/openid/mobile-redirect`
}
},
methods: {
@@ -325,7 +360,8 @@ export default {
},
init() {
this.newAuthSettings = {
...this.authSettings
...this.authSettings,
authOpenIDSubfolderForRedirectURLs: this.authSettings.authOpenIDSubfolderForRedirectURLs === undefined ? this.$config.routerBasePath : this.authSettings.authOpenIDSubfolderForRedirectURLs
}
this.enableLocalAuth = this.authMethods.includes('local')
this.enableOpenIDAuth = this.authMethods.includes('openid')

View File

@@ -42,11 +42,6 @@
</div>
</div>
<div class="flex items-center py-2 mb-2">
<ui-toggle-switch labeledBy="settings-chromecast-support" v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
<p class="pl-4" id="settings-chromecast-support">{{ $strings.LabelSettingsChromecastSupport }}</p>
</div>
<div class="pt-4">
<h2 class="font-semibold">{{ $strings.HeaderSettingsScanner }}</h2>
</div>
@@ -94,6 +89,20 @@
</p>
</ui-tooltip>
</div>
<div class="pt-4">
<h2 class="font-semibold">{{ $strings.HeaderSettingsWebClient }}</h2>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch labeledBy="settings-chromecast-support" v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
<p class="pl-4" id="settings-chromecast-support">{{ $strings.LabelSettingsChromecastSupport }}</p>
</div>
<div class="flex items-center py-2 mb-2">
<ui-toggle-switch labeledBy="settings-allow-iframe" v-model="newServerSettings.allowIframe" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('allowIframe', val)" />
<p class="pl-4" id="settings-allow-iframe">{{ $strings.LabelSettingsAllowIframe }}</p>
</div>
</div>
<div class="flex-1">
@@ -324,21 +333,21 @@ export default {
},
updateServerSettings(payload) {
this.updatingServerSettings = true
this.$store
.dispatch('updateServerSettings', payload)
.then(() => {
this.updatingServerSettings = false
this.$store.dispatch('updateServerSettings', payload).then((response) => {
this.updatingServerSettings = false
if (payload.language) {
// Updating language after save allows for re-rendering
this.$setLanguageCode(payload.language)
}
})
.catch((error) => {
console.error('Failed to update server settings', error)
this.updatingServerSettings = false
this.$toast.error(this.$strings.ToastFailedToUpdate)
})
if (response.error) {
console.error('Failed to update server settins', response.error)
this.$toast.error(response.error)
this.initServerSettings()
return
}
if (payload.language) {
// Updating language after save allows for re-rendering
this.$setLanguageCode(payload.language)
}
})
},
initServerSettings() {
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}

View File

@@ -126,7 +126,7 @@ export default {
},
coverUrl(feed) {
if (!feed.coverPath) return `${this.$config.routerBasePath}/Logo.png`
return `${feed.feedUrl}/cover`
return `${this.$config.routerBasePath}${feed.feedUrl}/cover`
},
async loadFeeds() {
const data = await this.$axios.$get(`/api/feeds`).catch((err) => {

View File

@@ -2,6 +2,10 @@
<div>
<app-settings-content :header-text="$strings.HeaderUsers">
<template #header-items>
<div v-if="numUsers" class="mx-2 px-1.5 rounded-lg bg-primary/50 text-gray-300/90 text-sm inline-flex items-center justify-center">
<span>{{ numUsers }}</span>
</div>
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides/users" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
@@ -13,7 +17,7 @@
<ui-btn color="primary" small @click="setShowUserModal()">{{ $strings.ButtonAddUser }}</ui-btn>
</template>
<tables-users-table class="pt-2" @edit="setShowUserModal" />
<tables-users-table class="pt-2" @edit="setShowUserModal" @numUsers="(count) => (numUsers = count)" />
</app-settings-content>
<modals-account-modal ref="accountModal" v-model="showAccountModal" :account="selectedAccount" />
</div>
@@ -29,7 +33,8 @@ export default {
data() {
return {
selectedAccount: null,
showAccountModal: false
showAccountModal: false,
numUsers: 0
}
},
computed: {},

View File

@@ -12,12 +12,12 @@
<!-- Item Cover Overlay -->
<div class="absolute top-0 left-0 w-full h-full z-10 opacity-0 group-hover:opacity-100 pointer-events-none">
<div v-show="showPlayButton && !isStreaming" class="h-full flex items-center justify-center pointer-events-none">
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto cursor-pointer" @click.stop.prevent="playItem">
<button class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto cursor-pointer" :aria-label="$strings.ButtonPlay" @click.stop.prevent="playItem">
<span class="material-symbols fill text-4xl">play_arrow</span>
</div>
</button>
</div>
<span class="absolute bottom-2.5 right-2.5 z-10 material-symbols text-lg cursor-pointer text-white text-opacity-75 hover:text-opacity-100 hover:scale-110 transform duration-200 pointer-events-auto" @click="showEditCover">edit</span>
<button class="absolute bottom-2.5 right-2.5 z-10 material-symbols text-lg cursor-pointer text-white text-opacity-75 hover:text-opacity-100 hover:scale-110 transform duration-200 pointer-events-auto" :aria-label="$strings.ButtonEdit" @click="showEditCover">edit</button>
</div>
</div>
</div>
@@ -87,7 +87,7 @@
</ui-btn>
<ui-btn v-else-if="isMissing || isInvalid" color="error" :padding-x="4" small class="flex items-center h-9 mr-2">
<span v-show="!isStreaming" class="material-symbols text-2xl -ml-2 pr-1 text-white">error</span>
<span class="material-symbols text-2xl -ml-2 pr-1 text-white">error</span>
{{ isMissing ? $strings.LabelMissing : $strings.LabelIncomplete }}
</ui-btn>
@@ -96,12 +96,12 @@
</ui-tooltip>
<ui-btn v-if="showReadButton" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
<span class="material-symbols text-2xl -ml-2 pr-2 text-white">auto_stories</span>
<span class="material-symbols text-2xl -ml-2 pr-2 text-white" aria-hidden="true">auto_stories</span>
{{ $strings.ButtonRead }}
</ui-btn>
<ui-tooltip v-if="userCanUpdate" :text="$strings.LabelEdit" direction="top">
<ui-icon-btn icon="&#xe3c9;" outlined class="mx-0.5" @click="editClick" />
<ui-icon-btn icon="&#xe3c9;" outlined class="mx-0.5" :aria-label="$strings.LabelEdit" @click="editClick" />
</ui-tooltip>
<ui-tooltip v-if="!isPodcast" :text="userIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="top">
@@ -110,12 +110,12 @@
<!-- Only admin or root user can download new episodes -->
<ui-tooltip v-if="isPodcast && userIsAdminOrUp" :text="$strings.LabelFindEpisodes" direction="top">
<ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
<ui-icon-btn icon="search" class="mx-0.5" :aria-label="$strings.LabelFindEpisodes" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
</ui-tooltip>
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="148" @action="contextMenuAction">
<template #default="{ showMenu, clickShowMenu, disabled }">
<button type="button" :disabled="disabled" class="mx-0.5 icon-btn bg-primary border border-gray-600 w-9 h-9 rounded-md flex items-center justify-center relative" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<button type="button" :disabled="disabled" class="mx-0.5 icon-btn bg-primary border border-gray-600 w-9 h-9 rounded-md flex items-center justify-center relative" aria-haspopup="listbox" :aria-expanded="showMenu" :aria-label="$strings.LabelMore" @click.stop.prevent="clickShowMenu">
<span class="material-symbols text-2xl">&#xe5d3;</span>
</button>
</template>
@@ -638,6 +638,11 @@ export default {
this.episodesDownloading = this.episodesDownloading.filter((d) => d.id !== episodeDownload.id)
}
},
episodeDownloadQueueCleared(libraryItemId) {
if (libraryItemId === this.libraryItemId) {
this.episodeDownloadsQueued = []
}
},
rssFeedOpen(data) {
if (data.entityId === this.libraryItemId) {
console.log('RSS Feed Opened', data)
@@ -776,6 +781,7 @@ export default {
this.$root.socket.on('episode_download_queued', this.episodeDownloadQueued)
this.$root.socket.on('episode_download_started', this.episodeDownloadStarted)
this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished)
this.$root.socket.on('episode_download_queue_cleared', this.episodeDownloadQueueCleared)
},
beforeDestroy() {
this.$eventBus.$off(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
@@ -787,6 +793,7 @@ export default {
this.$root.socket.off('episode_download_queued', this.episodeDownloadQueued)
this.$root.socket.off('episode_download_started', this.episodeDownloadStarted)
this.$root.socket.off('episode_download_finished', this.episodeDownloadFinished)
this.$root.socket.off('episode_download_queue_cleared', this.episodeDownloadQueueCleared)
}
}
</script>

View File

@@ -104,9 +104,6 @@ export default {
this.episodesDownloading = this.episodesDownloading.filter((d) => d.id !== episodeDownload.id)
}
},
episodeDownloadQueueUpdated(downloadQueueDetails) {
this.episodeDownloadsQueued = downloadQueueDetails.queue.filter((q) => q.libraryId == this.libraryId)
},
async loadInitialDownloadQueue() {
this.processing = true
const queuePayload = await this.$axios.$get(`/api/libraries/${this.libraryId}/episode-downloads`).catch((error) => {
@@ -128,7 +125,6 @@ export default {
this.$root.socket.on('episode_download_queued', this.episodeDownloadQueued)
this.$root.socket.on('episode_download_started', this.episodeDownloadStarted)
this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished)
this.$root.socket.on('episode_download_queue_updated', this.episodeDownloadQueueUpdated)
}
},
mounted() {
@@ -138,7 +134,6 @@ export default {
this.$root.socket.off('episode_download_queued', this.episodeDownloadQueued)
this.$root.socket.off('episode_download_started', this.episodeDownloadStarted)
this.$root.socket.off('episode_download_finished', this.episodeDownloadFinished)
this.$root.socket.off('episode_download_queue_updated', this.episodeDownloadQueueUpdated)
}
}
</script>

View File

@@ -126,12 +126,14 @@ export default {
if (!this.localAudioPlayer || !this.hasLoaded) return
const currentTime = this.localAudioPlayer.getCurrentTime()
const duration = this.localAudioPlayer.getDuration()
this.seek(Math.min(currentTime + 10, duration))
const jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount') || 10
this.seek(Math.min(currentTime + jumpForwardAmount, duration))
},
jumpBackward() {
if (!this.localAudioPlayer || !this.hasLoaded) return
const currentTime = this.localAudioPlayer.getCurrentTime()
this.seek(Math.max(currentTime - 10, 0))
const jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount') || 10
this.seek(Math.max(currentTime - jumpBackwardAmount, 0))
},
setVolume(volume) {
if (!this.localAudioPlayer || !this.hasLoaded) return
@@ -248,6 +250,8 @@ export default {
}
},
mounted() {
this.$store.dispatch('user/loadUserSettings')
this.resize()
window.addEventListener('resize', this.resize)
window.addEventListener('keydown', this.keyDown)

View File

@@ -1,20 +1,20 @@
<template>
<div id="page-wrapper" class="page p-0 sm:p-6 overflow-y-auto" :class="streamLibraryItem ? 'streaming' : ''">
<div id="page-wrapper" class="page p-1 sm:p-6 overflow-y-auto" :class="streamLibraryItem ? 'streaming' : ''">
<div class="w-full max-w-6xl mx-auto">
<!-- Library & folder picker -->
<div class="flex my-6 -mx-2">
<div class="w-1/5 px-2">
<div class="flex flex-wrap my-6 md:-mx-2">
<div class="w-full md:w-1/5 px-2">
<ui-dropdown v-model="selectedLibraryId" :items="libraryItems" :label="$strings.LabelLibrary" :disabled="!!items.length" @input="libraryChanged" />
</div>
<div class="w-3/5 px-2">
<div class="w-full md:w-3/5 px-2">
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="!selectedLibraryId || !!items.length" :label="$strings.LabelFolder" />
</div>
<div class="w-1/5 px-2">
<div class="w-full md:w-1/5 px-2">
<ui-text-input-with-label :value="selectedLibraryMediaType" readonly :label="$strings.LabelMediaType" />
</div>
</div>
<div v-if="!selectedLibraryIsPodcast" class="flex items-center mb-6">
<div v-if="!selectedLibraryIsPodcast" class="flex items-center mb-6 px-2 md:px-0">
<label class="flex cursor-pointer pt-4">
<ui-toggle-switch v-model="fetchMetadata.enabled" class="inline-flex" />
<span class="pl-2 text-base">{{ $strings.LabelAutoFetchMetadata }}</span>
@@ -33,13 +33,13 @@
</widgets-alert>
<!-- Picker display -->
<div v-if="!items.length && !ignoredFiles.length" class="w-full mx-auto border border-white border-opacity-20 px-12 pt-12 pb-4 my-12 relative" :class="isDragging ? 'bg-primary bg-opacity-40' : 'border-dashed'">
<p class="text-2xl text-center">{{ isDragging ? $strings.LabelUploaderDropFiles : $strings.LabelUploaderDragAndDrop }}</p>
<div v-if="!items.length && !ignoredFiles.length" class="w-full mx-auto border border-white border-opacity-20 px-4 md:px-12 pt-12 pb-4 my-12 relative" :class="isDragging ? 'bg-primary bg-opacity-40' : 'border-dashed'">
<p class="text-2xl text-center">{{ isDragging ? $strings.LabelUploaderDropFiles : isIOS ? $strings.LabelUploaderDragAndDropFilesOnly : $strings.LabelUploaderDragAndDrop }}</p>
<p class="text-center text-sm my-5">{{ $strings.MessageOr }}</p>
<div class="w-full max-w-xl mx-auto">
<div class="flex">
<ui-btn class="w-full mx-1" @click="openFilePicker">{{ $strings.ButtonChooseFiles }}</ui-btn>
<ui-btn class="w-full mx-1" @click="openFolderPicker">{{ $strings.ButtonChooseAFolder }}</ui-btn>
<ui-btn v-if="!isIOS" class="w-full mx-1" @click="openFolderPicker">{{ $strings.ButtonChooseAFolder }} </ui-btn>
</div>
</div>
<div class="pt-8 text-center">
@@ -48,7 +48,7 @@
</p>
<p class="text-sm text-white text-opacity-70">
{{ $strings.NoteUploaderFoldersWithMediaFiles }} <span v-if="selectedLibraryMediaType === 'book'">{{ $strings.NoteUploaderOnlyAudioFiles }}</span>
<span v-if="!isIOS">{{ $strings.NoteUploaderFoldersWithMediaFiles }}</span> <span v-if="selectedLibraryMediaType === 'book'">{{ $strings.NoteUploaderOnlyAudioFiles }}</span>
</p>
</div>
</div>
@@ -84,8 +84,8 @@
</div>
</div>
<input ref="fileInput" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
<input ref="fileFolderInput" type="file" webkitdirectory multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
<input ref="fileInput" type="file" multiple :accept="isIOS ? '' : inputAccept" class="hidden" @change="inputChanged" />
<input ref="fileFolderInput" type="file" webkitdirectory multiple :accept="inputAccept" class="hidden" @change="inputChanged" v-if="!isIOS" />
</div>
</template>
@@ -127,6 +127,10 @@ export default {
})
return extensions
},
isIOS() {
const ua = window.navigator.userAgent
return /iPad|iPhone|iPod/.test(ua) && !window.MSStream
},
streamLibraryItem() {
return this.$store.state.streamLibraryItem
},

View File

@@ -147,7 +147,7 @@ export default class LocalAudioPlayer extends EventEmitter {
timeoutRetry: {
maxNumRetry: 4,
retryDelayMs: 0,
maxRetryDelayMs: 0,
maxRetryDelayMs: 0
},
errorRetry: {
maxNumRetry: 8,
@@ -160,7 +160,7 @@ export default class LocalAudioPlayer extends EventEmitter {
}
return retry
}
},
}
}
}
}
@@ -194,7 +194,7 @@ export default class LocalAudioPlayer extends EventEmitter {
setDirectPlay() {
// Set initial track and track time offset
var trackIndex = this.audioTracks.findIndex(t => this.startTime >= t.startOffset && this.startTime < (t.startOffset + t.duration))
var trackIndex = this.audioTracks.findIndex((t) => this.startTime >= t.startOffset && this.startTime < t.startOffset + t.duration)
this.currentTrackIndex = trackIndex >= 0 ? trackIndex : 0
this.loadCurrentTrack()
@@ -270,7 +270,7 @@ export default class LocalAudioPlayer extends EventEmitter {
// Seeking Direct play
if (time < this.currentTrack.startOffset || time > this.currentTrack.startOffset + this.currentTrack.duration) {
// Change Track
var trackIndex = this.audioTracks.findIndex(t => time >= t.startOffset && time < (t.startOffset + t.duration))
var trackIndex = this.audioTracks.findIndex((t) => time >= t.startOffset && time < t.startOffset + t.duration)
if (trackIndex >= 0) {
this.startTime = time
this.currentTrackIndex = trackIndex
@@ -293,7 +293,6 @@ export default class LocalAudioPlayer extends EventEmitter {
this.player.volume = volume
}
// Utils
isValidDuration(duration) {
if (duration && !isNaN(duration) && duration !== Number.POSITIVE_INFINITY && duration !== Number.NEGATIVE_INFINITY) {
@@ -338,4 +337,4 @@ export default class LocalAudioPlayer extends EventEmitter {
var last = bufferedRanges[bufferedRanges.length - 1]
return last.end
}
}
}

View File

@@ -1,6 +1,6 @@
const SupportedFileTypes = {
image: ['png', 'jpg', 'jpeg', 'webp'],
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb', 'caf'],
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb', 'caf', 'mpeg', 'mpg'],
ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
info: ['nfo'],
text: ['txt'],
@@ -81,11 +81,9 @@ const Hotkeys = {
}
}
export {
Constants
}
export { Constants }
export default ({ app }, inject) => {
inject('constants', Constants)
inject('keynames', KeyNames)
inject('hotkeys', Hotkeys)
}
}

View File

@@ -7,6 +7,7 @@ const defaultCode = 'en-us'
const languageCodeMap = {
bg: { label: 'Български', dateFnsLocale: 'bg' },
bn: { label: 'বাংলা', dateFnsLocale: 'bn' },
ca: { label: 'Català', dateFnsLocale: 'ca' },
cs: { label: 'Čeština', dateFnsLocale: 'cs' },
da: { label: 'Dansk', dateFnsLocale: 'da' },
de: { label: 'Deutsch', dateFnsLocale: 'de' },

View File

@@ -98,7 +98,7 @@ export const getters = {
const userToken = rootGetters['user/getToken']
const lastUpdate = libraryItem.updatedAt || Date.now()
const libraryItemId = libraryItem.libraryItemId || libraryItem.id // Workaround for /users/:id page showing media progress covers
return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}&ts=${lastUpdate}${raw ? '&raw=1' : ''}`
return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?ts=${lastUpdate}${raw ? '&raw=1' : ''}`
},
getLibraryItemCoverSrcById:
(state, getters, rootState, rootGetters) =>
@@ -106,7 +106,7 @@ export const getters = {
const placeholder = `${rootState.routerBasePath}/book_placeholder.jpg`
if (!libraryItemId) return placeholder
const userToken = rootGetters['user/getToken']
return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}${raw ? '&raw=1' : ''}${timestamp ? `&ts=${timestamp}` : ''}`
return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?${raw ? '&raw=1' : ''}${timestamp ? `&ts=${timestamp}` : ''}`
},
getIsBatchSelectingMediaItems: (state) => {
return state.selectedMediaItems.length

View File

@@ -72,16 +72,17 @@ export const actions = {
return this.$axios
.$patch('/api/settings', updatePayload)
.then((result) => {
if (result.success) {
if (result.serverSettings) {
commit('setServerSettings', result.serverSettings)
return true
} else {
return false
}
return result
})
.catch((error) => {
console.error('Failed to update server settings', error)
return false
const errorMsg = error.response?.data || 'Unknown error'
return {
error: errorMsg
}
})
},
checkForUpdate({ commit }) {

156
client/strings/ar.json Normal file
View File

@@ -0,0 +1,156 @@
{
"ButtonAdd": "إضافة",
"ButtonAddChapters": "إضافة الفصول",
"ButtonAddDevice": "إضافة جهاز",
"ButtonAddLibrary": "إضافة مكتبة",
"ButtonAddPodcasts": "إضافة بودكاست",
"ButtonAddUser": "إضافة مستخدم",
"ButtonAddYourFirstLibrary": "أضف مكتبتك الأولى",
"ButtonApply": "حفظ",
"ButtonApplyChapters": "حفظ الفصول",
"ButtonAuthors": "المؤلفون",
"ButtonBack": "الرجوع",
"ButtonBrowseForFolder": "البحث عن المجلد",
"ButtonCancel": "إلغاء",
"ButtonCancelEncode": "إلغاء الترميز",
"ButtonChangeRootPassword": "تغيير كلمة المرور الرئيسية",
"ButtonCheckAndDownloadNewEpisodes": "التحقق من الحلقات الجديدة وتنزيلها",
"ButtonChooseAFolder": "اختر المجلد",
"ButtonChooseFiles": "اختر الملفات",
"ButtonClearFilter": "تصفية الفرز",
"ButtonCloseFeed": "إغلاق",
"ButtonCloseSession": "إغلاق الجلسة المفتوحة",
"ButtonCollections": "المجموعات",
"ButtonConfigureScanner": "إعدادات الماسح الضوئي",
"ButtonCreate": "إنشاء",
"ButtonCreateBackup": "إنشاء نسخة احتياطية",
"ButtonDelete": "حذف",
"ButtonDownloadQueue": "قائمة",
"ButtonEdit": "تعديل",
"ButtonEditChapters": "تعديل الفصول",
"ButtonEditPodcast": "تعديل البودكاست",
"ButtonEnable": "تفعيل",
"ButtonFireAndFail": "النار والفشل",
"ButtonFireOnTest": "حادثة إطلاق النار",
"ButtonForceReScan": "فرض إعادة المسح",
"ButtonFullPath": "المسار الكامل",
"ButtonHide": "إخفاء",
"ButtonHome": "الرئيسية",
"ButtonIssues": "مشاكل",
"ButtonJumpBackward": "اقفز للخلف",
"ButtonJumpForward": "اقفز للأمام",
"ButtonLatest": "أحدث",
"ButtonLibrary": "المكتبة",
"ButtonLogout": "تسجيل الخروج",
"ButtonLookup": "البحث",
"ButtonManageTracks": "إدارة المقاطع",
"ButtonMapChapterTitles": "مطابقة عناوين الفصول",
"ButtonMatchAllAuthors": "مطابقة كل المؤلفون",
"ButtonMatchBooks": "مطابقة الكتب",
"ButtonNevermind": "لا تهتم",
"ButtonNext": "التالي",
"ButtonNextChapter": "الفصل التالي",
"ButtonNextItemInQueue": "العنصر التالي في قائمة الانتظار",
"ButtonOk": "نعم",
"ButtonOpenFeed": "فتح التغذية",
"ButtonOpenManager": "فتح الإدارة",
"ButtonPause": "تَوَقَّف",
"ButtonPlay": "تشغيل",
"ButtonPlayAll": "تشغيل الكل",
"ButtonPlaying": "مشغل الآن",
"ButtonPlaylists": "قوائم التشغيل",
"ButtonPrevious": "سابِق",
"ButtonPreviousChapter": "الفصل السابق",
"ButtonProbeAudioFile": "فحص ملف الصوت",
"ButtonPurgeAllCache": "مسح كافة ذاكرة التخزين المؤقتة",
"ButtonPurgeItemsCache": "مسح ذاكرة التخزين المؤقتة للعناصر",
"ButtonQueueAddItem": "أضف إلى قائمة الانتظار",
"ButtonQueueRemoveItem": "إزالة من قائمة الانتظار",
"ButtonQuickEmbed": "التضمين السريع",
"ButtonQuickEmbedMetadata": "إدراج سريع للبيانات الوصفية",
"ButtonQuickMatch": "مطابقة سريعة",
"ButtonReScan": "إعادة البحث",
"ButtonRead": "اقرأ",
"ButtonReadLess": "قلص",
"ButtonReadMore": "المزيد",
"ButtonRefresh": "تحديث",
"ButtonRemove": "إزالة",
"ButtonRemoveAll": "إزالة الكل",
"ButtonRemoveAllLibraryItems": "إزالة كافة عناصر المكتبة",
"ButtonRemoveFromContinueListening": "إزالة من متابعة الاستماع",
"ButtonRemoveFromContinueReading": "إزالة من متابعة القراءة",
"ButtonRemoveSeriesFromContinueSeries": "إزالة السلسلة من استمرار السلسلة",
"ButtonReset": "إعادة ضبط",
"ButtonResetToDefault": "إعادة ضبط إلى الوضع الافتراضي",
"ButtonRestore": "إستِعادة",
"ButtonSave": "حفظ",
"ButtonSaveAndClose": "حفظ و إغلاق",
"ButtonSaveTracklist": "حفظ قائمة التشغيل",
"ButtonScan": "تَحَقُق",
"ButtonScanLibrary": "تَحَقُق من المكتبة",
"ButtonSearch": "بحث",
"ButtonSelectFolderPath": "حدد مسار المجلد",
"ButtonSeries": "سلسلة",
"ButtonSetChaptersFromTracks": "تعيين الفصول من الملفات",
"ButtonShare": "نشر",
"ButtonShiftTimes": "أوقات العمل",
"ButtonShow": "عرض",
"ButtonStartM4BEncode": "ابدأ ترميز M4B",
"ButtonStartMetadataEmbed": "ابدأ تضمين البيانات الوصفية",
"ButtonStats": "الإحصائيات",
"ButtonSubmit": "تقديم",
"ButtonTest": "اختبار",
"ButtonUnlinkOpenId": "إلغاء ربط المعرف",
"ButtonUpload": "رفع",
"ButtonUploadBackup": "تحميل النسخة الاحتياطية",
"ButtonUploadCover": "ارفق الغلاف",
"ButtonUploadOPMLFile": "رفع ملف OPML",
"ButtonUserDelete": "حذف المستخدم {0}",
"ButtonUserEdit": "تعديل المستخدم {0}",
"ButtonViewAll": "عرض الكل",
"ButtonYes": "نعم",
"ErrorUploadFetchMetadataAPI": "خطأ في جلب البيانات الوصفية",
"ErrorUploadFetchMetadataNoResults": "لم يتم العثور على البيانات الوصفية - حاول تحديث العنوان و/أو المؤلف",
"ErrorUploadLacksTitle": "يجب أن يكون له عنوان",
"HeaderAccount": "الحساب",
"HeaderAddCustomMetadataProvider": "إضافة موفر بيانات تعريفية مخصص",
"HeaderAdvanced": "متقدم",
"HeaderAppriseNotificationSettings": "إعدادات الإشعارات",
"HeaderAudioTracks": "المسارات الصوتية",
"HeaderAudiobookTools": "أدوات إدارة ملفات الكتب الصوتية",
"HeaderAuthentication": "المصادقة",
"HeaderBackups": "النسخ الاحتياطية",
"HeaderChangePassword": "تغيير كلمة المرور",
"HeaderChapters": "الفصول",
"HeaderChooseAFolder": "اختيار المجلد",
"HeaderCollection": "مجموعة",
"HeaderCollectionItems": "عناصر المجموعة",
"HeaderCover": "الغلاف",
"HeaderCurrentDownloads": "التنزيلات الجارية",
"HeaderCustomMessageOnLogin": "رسالة مخصصة عند تسجيل الدخول",
"HeaderCustomMetadataProviders": "مقدمو البيانات الوصفية المخصصة",
"HeaderDetails": "التفاصيل",
"HeaderDownloadQueue": "تنزيل قائمة الانتظار",
"HeaderEbookFiles": "ملفات الكتب الإلكترونية",
"HeaderEmail": "البريد الإلكتروني",
"HeaderEmailSettings": "إعدادات البريد الإلكتروني",
"HeaderEpisodes": "الحلقات",
"HeaderEreaderDevices": "أجهزة قراءة الكتب الإلكترونية",
"HeaderEreaderSettings": "إعدادات القارئ الإلكتروني",
"HeaderFiles": "ملفات",
"HeaderFindChapters": "البحث عن الفصول",
"HeaderIgnoredFiles": "الملفات المتجاهلة",
"HeaderItemFiles": "ملفات العنصر",
"HeaderItemMetadataUtils": "بيانات تعريف العنصر",
"HeaderLastListeningSession": "آخر جلسة استماع",
"HeaderLatestEpisodes": "أحدث الحلقات",
"HeaderLibraries": "المكتبات",
"HeaderLibraryFiles": "ملفات المكتبة",
"HeaderLibraryStats": "إحصائيات المكتبة",
"HeaderListeningSessions": "جلسات الاستماع",
"HeaderListeningStats": "جلسات الاستماع",
"HeaderLogin": "تسجيل الدخول",
"HeaderLogs": "السجلات",
"HeaderManageGenres": "إدارة الانواع",
"HeaderManageTags": "إدارة العلامات"
}

View File

@@ -66,6 +66,7 @@
"ButtonPurgeItemsCache": "আইটেম ক্যাশে পরিষ্কার করুন",
"ButtonQueueAddItem": "সারিতে যোগ করুন",
"ButtonQueueRemoveItem": "সারি থেকে মুছে ফেলুন",
"ButtonQuickEmbed": "দ্রুত এম্বেড করুন",
"ButtonQuickEmbedMetadata": "মেটাডেটা দ্রুত এম্বেড করুন",
"ButtonQuickMatch": "দ্রুত ম্যাচ",
"ButtonReScan": "পুনরায় স্ক্যান",
@@ -162,6 +163,7 @@
"HeaderNotificationUpdate": "বিজ্ঞপ্তি আপডেট করুন",
"HeaderNotifications": "বিজ্ঞপ্তি",
"HeaderOpenIDConnectAuthentication": "ওপেনআইডি সংযোগ প্রমাণীকরণ",
"HeaderOpenListeningSessions": "শোনার সেশন খুলুন",
"HeaderOpenRSSFeed": "আরএসএস ফিড খুলুন",
"HeaderOtherFiles": "অন্যান্য ফাইল",
"HeaderPasswordAuthentication": "পাসওয়ার্ড প্রমাণীকরণ",
@@ -179,6 +181,7 @@
"HeaderRemoveEpisodes": "{0}টি পর্ব সরান",
"HeaderSavedMediaProgress": "মিডিয়া সংরক্ষণের অগ্রগতি",
"HeaderSchedule": "সময়সূচী",
"HeaderScheduleEpisodeDownloads": "স্বয়ংক্রিয় পর্ব ডাউনলোডের সময়সূচী নির্ধারন করুন",
"HeaderScheduleLibraryScans": "স্বয়ংক্রিয় লাইব্রেরি স্ক্যানের সময়সূচী",
"HeaderSession": "সেশন",
"HeaderSetBackupSchedule": "ব্যাকআপ সময়সূচী সেট করুন",
@@ -224,7 +227,11 @@
"LabelAllUsersExcludingGuests": "অতিথি ব্যতীত সকল ব্যবহারকারী",
"LabelAllUsersIncludingGuests": "অতিথি সহ সকল ব্যবহারকারী",
"LabelAlreadyInYourLibrary": "ইতিমধ্যেই আপনার লাইব্রেরিতে রয়েছে",
"LabelApiToken": "API টোকেন",
"LabelAppend": "সংযোজন",
"LabelAudioBitrate": "অডিও বিটরেট (যেমন- 128k)",
"LabelAudioChannels": "অডিও চ্যানেল (১ বা ২)",
"LabelAudioCodec": "অডিও কোডেক",
"LabelAuthor": "লেখক",
"LabelAuthorFirstLast": "লেখক (প্রথম শেষ)",
"LabelAuthorLastFirst": "লেখক (শেষ, প্রথম)",
@@ -237,6 +244,7 @@
"LabelAutoRegister": "স্বয়ংক্রিয় নিবন্ধন",
"LabelAutoRegisterDescription": "লগ ইন করার পর স্বয়ংক্রিয়ভাবে নতুন ব্যবহারকারী তৈরি করুন",
"LabelBackToUser": "ব্যবহারকারীর কাছে ফিরে যান",
"LabelBackupAudioFiles": "অডিও ফাইলগুলো ব্যাকআপ",
"LabelBackupLocation": "ব্যাকআপ অবস্থান",
"LabelBackupsEnableAutomaticBackups": "স্বয়ংক্রিয় ব্যাকআপ সক্ষম করুন",
"LabelBackupsEnableAutomaticBackupsHelp": "ব্যাকআপগুলি /মেটাডাটা/ব্যাকআপে সংরক্ষিত",
@@ -245,15 +253,18 @@
"LabelBackupsNumberToKeep": "ব্যাকআপের সংখ্যা রাখুন",
"LabelBackupsNumberToKeepHelp": "এক সময়ে শুধুমাত্র ১ টি ব্যাকআপ সরানো হবে তাই যদি আপনার কাছে ইতিমধ্যে এর চেয়ে বেশি ব্যাকআপ থাকে তাহলে আপনাকে ম্যানুয়ালি সেগুলি সরিয়ে ফেলতে হবে।",
"LabelBitrate": "বিটরেট",
"LabelBonus": "উপরিলাভ",
"LabelBooks": "বইগুলো",
"LabelButtonText": "ঘর পাঠ্য",
"LabelByAuthor": "দ্বারা {0}",
"LabelChangePassword": "পাসওয়ার্ড পরিবর্তন করুন",
"LabelChannels": "চ্যানেল",
"LabelChapterCount": "{0} অধ্যায়",
"LabelChapterTitle": "অধ্যায়ের শিরোনাম",
"LabelChapters": "অধ্যায়",
"LabelChaptersFound": "অধ্যায় পাওয়া গেছে",
"LabelClickForMoreInfo": "আরো তথ্যের জন্য ক্লিক করুন",
"LabelClickToUseCurrentValue": "বর্তমান মান ব্যবহার করতে ক্লিক করুন",
"LabelClosePlayer": "প্লেয়ার বন্ধ করুন",
"LabelCodec": "কোডেক",
"LabelCollapseSeries": "সিরিজ সঙ্কুচিত করুন",
@@ -303,12 +314,25 @@
"LabelEmailSettingsTestAddress": "পরীক্ষার ঠিকানা",
"LabelEmbeddedCover": "এম্বেডেড কভার",
"LabelEnable": "সক্ষম করুন",
"LabelEncodingBackupLocation": "আপনার আসল অডিও ফাইলগুলোর একটি ব্যাকআপ এখানে সংরক্ষণ করা হবে:",
"LabelEncodingChaptersNotEmbedded": "মাল্টি-ট্র্যাক অডিওবুকগুলোতে অধ্যায় এম্বেড করা হয় না।",
"LabelEncodingClearItemCache": "পর্যায়ক্রমে আইটেম ক্যাশে পরিষ্কার করতে ভুলবেন না।",
"LabelEncodingFinishedM4B": "সমাপ্ত হওয়া M4B-গুলো আপনার অডিওবুক ফোল্ডারে এখানে রাখা হবে:",
"LabelEncodingInfoEmbedded": "আপনার অডিওবুক ফোল্ডারের ভিতরে অডিও ট্র্যাকগুলোতে মেটাডেটা এমবেড করা হবে।",
"LabelEncodingStartedNavigation": "একবার টাস্ক শুরু হলে আপনি এই পৃষ্ঠা থেকে অন্যত্র যেতে পারেন।",
"LabelEncodingTimeWarning": "এনকোডিং ৩০ মিনিট পর্যন্ত সময় নিতে পারে।",
"LabelEncodingWarningAdvancedSettings": "সতর্কতা: এই সেটিংস আপডেট করবেন না, যদি না আপনি ffmpeg এনকোডিং বিকল্পগুলোর সাথে পরিচিত হন।",
"LabelEncodingWatcherDisabled": "আপনার যদি পর্যবেক্ষক অক্ষম থাকে তবে আপনাকে পরে এই অডিওবুকটি পুনরায় স্ক্যান করতে হবে।",
"LabelEnd": "সমাপ্ত",
"LabelEndOfChapter": "অধ্যায়ের সমাপ্তি",
"LabelEpisode": "পর্ব",
"LabelEpisodeNotLinkedToRssFeed": "পর্বটি আরএসএস ফিডের সাথে সংযুক্ত করা হয়নি",
"LabelEpisodeNumber": "পর্ব #{0}",
"LabelEpisodeTitle": "পর্বের শিরোনাম",
"LabelEpisodeType": "পর্বের ধরন",
"LabelEpisodeUrlFromRssFeed": "আরএসএস ফিড থেকে পর্ব URL",
"LabelEpisodes": "পর্বগুলো",
"LabelEpisodic": "প্রাসঙ্গিক",
"LabelExample": "উদাহরণ",
"LabelExpandSeries": "সিরিজ প্রসারিত করুন",
"LabelExpandSubSeries": "সাব সিরিজ প্রসারিত করুন",
@@ -336,6 +360,7 @@
"LabelFontScale": "ফন্ট স্কেল",
"LabelFontStrikethrough": "অবচ্ছেদন রেখা",
"LabelFormat": "ফরম্যাট",
"LabelFull": "পূর্ণ",
"LabelGenre": "ঘরানা",
"LabelGenres": "ঘরানাগুলো",
"LabelHardDeleteFile": "জোরপূর্বক ফাইল মুছে ফেলুন",
@@ -391,6 +416,10 @@
"LabelLowestPriority": "সর্বনিম্ন অগ্রাধিকার",
"LabelMatchExistingUsersBy": "বিদ্যমান ব্যবহারকারীদের দ্বারা মিলিত করুন",
"LabelMatchExistingUsersByDescription": "বিদ্যমান ব্যবহারকারীদের সংযোগ করার জন্য ব্যবহৃত হয়। একবার সংযুক্ত হলে, ব্যবহারকারীদের আপনার SSO প্রদানকারীর থেকে একটি অনন্য আইডি দ্বারা মিলিত হবে",
"LabelMaxEpisodesToDownload": "সর্বাধিক # টি পর্ব ডাউনলোড করা হবে। অসীমের জন্য 0 ব্যবহার করুন।",
"LabelMaxEpisodesToDownloadPerCheck": "প্রতি কিস্তিতে সর্বাধিক # টি নতুন পর্ব ডাউনলোড করা হবে",
"LabelMaxEpisodesToKeep": "সর্বোচ্চ # টি পর্ব রাখা হবে",
"LabelMaxEpisodesToKeepHelp": " কোন সর্বোচ্চ সীমা সেট করে না। একটি নতুন পর্ব স্বয়ংক্রিয়-ডাউনলোড হওয়ার পরে আপনার যদি X-এর বেশি পর্ব থাকে তবে এটি সবচেয়ে পুরানো পর্বটি মুছে ফেলবে। এটি প্রতি নতুন ডাউনলোডের জন্য শুধুমাত্র ১ টি পর্ব মুছে ফেলবে।",
"LabelMediaPlayer": "মিডিয়া প্লেয়ার",
"LabelMediaType": "মিডিয়ার ধরন",
"LabelMetaTag": "মেটা ট্যাগ",
@@ -436,12 +465,14 @@
"LabelOpenIDGroupClaimDescription": "ওপেনআইডি দাবির নাম যাতে ব্যবহারকারীর গোষ্ঠীর একটি তালিকা থাকে। সাধারণত <code>গ্রুপ</code> হিসাবে উল্লেখ করা হয়। <b>কনফিগার করা থাকলে</b>, অ্যাপ্লিকেশনটি স্বয়ংক্রিয়ভাবে এর উপর ভিত্তি করে ব্যবহারকারীর গোষ্ঠীর সদস্যপদ নির্ধারণ করবে, শর্ত এই যে এই গোষ্ঠীগুলি কেস-অসংবেদনশীলভাবে দাবিতে 'অ্যাডমিন', 'ব্যবহারকারী' বা 'অতিথি' নাম দেওয়া হয়৷ দাবিতে একটি তালিকা থাকা উচিত এবং যদি একজন ব্যবহারকারী একাধিক গোষ্ঠীর অন্তর্গত হয় তবে অ্যাপ্লিকেশনটি বরাদ্দ করবে সর্বোচ্চ স্তরের অ্যাক্সেসের সাথে সঙ্গতিপূর্ণ ভূমিকা৷ যদি কোনও গোষ্ঠীর সাথে মেলে না, তবে অ্যাক্সেস অস্বীকার করা হবে।",
"LabelOpenRSSFeed": "আরএসএস ফিড খুলুন",
"LabelOverwrite": "পুনঃলিখিত",
"LabelPaginationPageXOfY": "{1} টির মধ্যে {0} পৃষ্ঠা",
"LabelPassword": "পাসওয়ার্ড",
"LabelPath": "পথ",
"LabelPermanent": "স্থায়ী",
"LabelPermissionsAccessAllLibraries": "সমস্ত লাইব্রেরি অ্যাক্সেস করতে পারবে",
"LabelPermissionsAccessAllTags": "সমস্ত ট্যাগ অ্যাক্সেস করতে পারবে",
"LabelPermissionsAccessExplicitContent": "স্পষ্ট বিষয়বস্তু অ্যাক্সেস করতে পারে",
"LabelPermissionsCreateEreader": "ইরিডার তৈরি করতে পারেন",
"LabelPermissionsDelete": "মুছে দিতে পারবে",
"LabelPermissionsDownload": "ডাউনলোড করতে পারবে",
"LabelPermissionsUpdate": "আপডেট করতে পারবে",
@@ -465,6 +496,8 @@
"LabelPubDate": "প্রকাশের তারিখ",
"LabelPublishYear": "প্রকাশের বছর",
"LabelPublishedDate": "প্রকাশিত {0}",
"LabelPublishedDecade": "প্রকাশনার দশক",
"LabelPublishedDecades": "প্রকাশনার দশকগুলো",
"LabelPublisher": "প্রকাশক",
"LabelPublishers": "প্রকাশকরা",
"LabelRSSFeedCustomOwnerEmail": "কাস্টম মালিকের ইমেইল",
@@ -484,21 +517,28 @@
"LabelRedo": "পুনরায় করুন",
"LabelRegion": "অঞ্চল",
"LabelReleaseDate": "উন্মোচনের তারিখ",
"LabelRemoveAllMetadataAbs": "সমস্ত metadata.abs ফাইল সরান",
"LabelRemoveAllMetadataJson": "সমস্ত metadata.json ফাইল সরান",
"LabelRemoveCover": "কভার সরান",
"LabelRemoveMetadataFile": "লাইব্রেরি আইটেম ফোল্ডারে মেটাডেটা ফাইল সরান",
"LabelRemoveMetadataFileHelp": "আপনার {0} ফোল্ডারের সমস্ত metadata.json এবং metadata.abs ফাইলগুলি সরান।",
"LabelRowsPerPage": "প্রতি পৃষ্ঠায় সারি",
"LabelSearchTerm": "অনুসন্ধান শব্দ",
"LabelSearchTitle": "অনুসন্ধান শিরোনাম",
"LabelSearchTitleOrASIN": "অনুসন্ধান শিরোনাম বা ASIN",
"LabelSeason": "সেশন",
"LabelSeasonNumber": "মরসুম #{0}",
"LabelSelectAll": "সব নির্বাচন করুন",
"LabelSelectAllEpisodes": "সমস্ত পর্ব নির্বাচন করুন",
"LabelSelectEpisodesShowing": "দেখানো {0}টি পর্ব নির্বাচন করুন",
"LabelSelectUsers": "ব্যবহারকারী নির্বাচন করুন",
"LabelSendEbookToDevice": "ই-বই পাঠান...",
"LabelSequence": "ক্রম",
"LabelSerial": "ধারাবাহিক",
"LabelSeries": "সিরিজ",
"LabelSeriesName": "সিরিজের নাম",
"LabelSeriesProgress": "সিরিজের অগ্রগতি",
"LabelServerLogLevel": "সার্ভার লগ লেভেল",
"LabelServerYearReview": "সার্ভারের বাৎসরিক পর্যালোচনা ({0})",
"LabelSetEbookAsPrimary": "প্রাথমিক হিসাবে সেট করুন",
"LabelSetEbookAsSupplementary": "পরিপূরক হিসেবে সেট করুন",
@@ -523,6 +563,9 @@
"LabelSettingsHideSingleBookSeriesHelp": "যে সিরিজগুলোতে একটি বই আছে সেগুলো সিরিজের পাতা এবং নীড় পেজের তাক থেকে লুকিয়ে রাখা হবে।",
"LabelSettingsHomePageBookshelfView": "নীড় পেজে বুকশেলফ ভিউ ব্যবহার করুন",
"LabelSettingsLibraryBookshelfView": "লাইব্রেরি বুকশেলফ ভিউ ব্যবহার করুন",
"LabelSettingsLibraryMarkAsFinishedPercentComplete": "শতকরা সম্পূর্ণ এর চেয়ে বেশি",
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "বাকি সময় (সেকেন্ড) এর চেয়ে কম",
"LabelSettingsLibraryMarkAsFinishedWhen": "মিডিয়া আইটেমকে সমাপ্ত হিসাবে চিহ্নিত করুন যখন",
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "কন্টিনিউ সিরিজে আগের বইগুলো এড়িয়ে যান",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "কন্টিনিউ সিরিজের নীড় পেজ শেল্ফ দেখায় যে সিরিজে শুরু হয়নি এমন প্রথম বই যার অন্তত একটি বই শেষ হয়েছে এবং কোনো বই চলছে না। এই সেটিংটি সক্ষম করলে শুরু না হওয়া প্রথম বইটির পরিবর্তে সবচেয়ে দূরের সম্পূর্ণ বই থেকে সিরিজ চলতে থাকবে।",
"LabelSettingsParseSubtitles": "সাবটাইটেল পার্স করুন",
@@ -587,6 +630,7 @@
"LabelTimeDurationXMinutes": "{0} মিনিট",
"LabelTimeDurationXSeconds": "{0} সেকেন্ড",
"LabelTimeInMinutes": "মিনিটে সময়",
"LabelTimeLeft": "{0} বাকি",
"LabelTimeListened": "সময় শোনা হয়েছে",
"LabelTimeListenedToday": "আজ শোনার সময়",
"LabelTimeRemaining": "{0}টি অবশিষ্ট",
@@ -594,6 +638,7 @@
"LabelTitle": "শিরোনাম",
"LabelToolsEmbedMetadata": "মেটাডেটা এম্বেড করুন",
"LabelToolsEmbedMetadataDescription": "কভার ইমেজ এবং অধ্যায় সহ অডিও ফাইলগুলিতে মেটাডেটা এম্বেড করুন।",
"LabelToolsM4bEncoder": "M4B এনকোডার",
"LabelToolsMakeM4b": "M4B অডিওবুক ফাইল তৈরি করুন",
"LabelToolsMakeM4bDescription": "এমবেডেড মেটাডেটা, কভার ইমেজ এবং অধ্যায় সহ একটি .M4B অডিওবুক ফাইল তৈরি করুন।",
"LabelToolsSplitM4b": "M4B কে MP3 তে বিভক্ত করুন",
@@ -606,6 +651,7 @@
"LabelTracksMultiTrack": "মাল্টি-ট্র্যাক",
"LabelTracksNone": "কোন ট্র্যাক নেই",
"LabelTracksSingleTrack": "একক-ট্র্যাক",
"LabelTrailer": "আনুগমিক",
"LabelType": "টাইপ",
"LabelUnabridged": "অসংলগ্ন",
"LabelUndo": "পূর্বাবস্থা",
@@ -617,10 +663,13 @@
"LabelUpdateDetailsHelp": "একটি মিল থাকা অবস্থায় নির্বাচিত বইগুলির বিদ্যমান বিবরণ ওভাররাইট করার অনুমতি দিন",
"LabelUpdatedAt": "আপডেট করা হয়েছে",
"LabelUploaderDragAndDrop": "ফাইল বা ফোল্ডার টেনে আনুন এবং ফেলে দিন",
"LabelUploaderDragAndDropFilesOnly": "ফাইল টেনে আনুন",
"LabelUploaderDropFiles": "ফাইলগুলো ফেলে দিন",
"LabelUploaderItemFetchMetadataHelp": "স্বয়ংক্রিয়ভাবে শিরোনাম, লেখক এবং সিরিজ আনুন",
"LabelUseAdvancedOptions": "উন্নত বিকল্প ব্যবহার করুন",
"LabelUseChapterTrack": "অধ্যায় ট্র্যাক ব্যবহার করুন",
"LabelUseFullTrack": "সম্পূর্ণ ট্র্যাক ব্যবহার করুন",
"LabelUseZeroForUnlimited": "অসীমের জন্য 0 ব্যবহার করুন",
"LabelUser": "ব্যবহারকারী",
"LabelUsername": "ব্যবহারকারীর নাম",
"LabelValue": "মান",
@@ -667,6 +716,7 @@
"MessageConfirmDeleteMetadataProvider": "আপনি কি নিশ্চিতভাবে কাস্টম মেটাডেটা প্রদানকারী \"{0}\" মুছতে চান?",
"MessageConfirmDeleteNotification": "আপনি কি নিশ্চিতভাবে এই বিজ্ঞপ্তিটি মুছতে চান?",
"MessageConfirmDeleteSession": "আপনি কি নিশ্চিত আপনি এই অধিবেশন মুছে দিতে চান?",
"MessageConfirmEmbedMetadataInAudioFiles": "আপনি কি {0}টি অডিও ফাইলে মেটাডেটা এম্বেড করার বিষয়ে নিশ্চিত?",
"MessageConfirmForceReScan": "আপনি কি নিশ্চিত যে আপনি জোর করে পুনরায় স্ক্যান করতে চান?",
"MessageConfirmMarkAllEpisodesFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্ব সমাপ্ত হিসাবে চিহ্নিত করতে চান?",
"MessageConfirmMarkAllEpisodesNotFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্বকে শেষ হয়নি বলে চিহ্নিত করতে চান?",
@@ -678,6 +728,7 @@
"MessageConfirmPurgeCache": "ক্যাশে পরিষ্কারক <code>/metadata/cache</code>-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে। <br /><br />আপনি কি নিশ্চিত আপনি ক্যাশে ডিরেক্টরি সরাতে চান?",
"MessageConfirmPurgeItemsCache": "আইটেম ক্যাশে পরিষ্কারক <code>/metadata/cache/items</code>-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে।<br />আপনি কি নিশ্চিত?",
"MessageConfirmQuickEmbed": "সতর্কতা! দ্রুত এম্বেড আপনার অডিও ফাইলের ব্যাকআপ করবে না। নিশ্চিত করুন যে আপনার অডিও ফাইলগুলির একটি ব্যাকআপ আছে। <br><br>আপনি কি চালিয়ে যেতে চান?",
"MessageConfirmQuickMatchEpisodes": "একটি মিল পাওয়া গেলে দ্রুত ম্যাচিং পর্বগুলি বিস্তারিত ওভাররাইট করবে। শুধুমাত্র অতুলনীয় পর্ব আপডেট করা হবে। আপনি কি নিশ্চিত?",
"MessageConfirmReScanLibraryItems": "আপনি কি নিশ্চিত যে আপনি {0}টি আইটেম পুনরায় স্ক্যান করতে চান?",
"MessageConfirmRemoveAllChapters": "আপনি কি নিশ্চিত যে আপনি সমস্ত অধ্যায় সরাতে চান?",
"MessageConfirmRemoveAuthor": "আপনি কি নিশ্চিত যে আপনি লেখক \"{0}\" অপসারণ করতে চান?",
@@ -685,6 +736,7 @@
"MessageConfirmRemoveEpisode": "আপনি কি নিশ্চিত আপনি \"{0}\" পর্বটি সরাতে চান?",
"MessageConfirmRemoveEpisodes": "আপনি কি নিশ্চিত যে আপনি {0}টি পর্ব সরাতে চান?",
"MessageConfirmRemoveListeningSessions": "আপনি কি নিশ্চিত যে আপনি {0}টি শোনার সেশন সরাতে চান?",
"MessageConfirmRemoveMetadataFiles": "আপনি কি আপনার লাইব্রেরি আইটেম ফোল্ডারে থাকা সমস্ত মেটাডেটা {0} ফাইল মুছে ফেলার বিষয়ে নিশ্চিত?",
"MessageConfirmRemoveNarrator": "আপনি কি \"{0}\" বর্ণনাকারীকে সরানোর বিষয়ে নিশ্চিত?",
"MessageConfirmRemovePlaylist": "আপনি কি নিশ্চিত যে আপনি আপনার প্লেলিস্ট \"{0}\" সরাতে চান?",
"MessageConfirmRenameGenre": "আপনি কি নিশ্চিত যে আপনি সমস্ত আইটেমের জন্য \"{0}\" ধারার নাম পরিবর্তন করে \"{1}\" করতে চান?",
@@ -700,6 +752,7 @@
"MessageDragFilesIntoTrackOrder": "সঠিক ট্র্যাক অর্ডারে ফাইল টেনে আনুন",
"MessageEmbedFailed": "এম্বেড ব্যর্থ হয়েছে!",
"MessageEmbedFinished": "এম্বেড করা শেষ!",
"MessageEmbedQueue": "মেটাডেটা এম্বেডের জন্য সারিবদ্ধ ({0} সারিতে)",
"MessageEpisodesQueuedForDownload": "{0} পর্ব(গুলি) ডাউনলোডের জন্য সারিবদ্ধ",
"MessageEreaderDevices": "ই-বুক সরবরাহ নিশ্চিত করতে, আপনাকে নীচে তালিকাভুক্ত প্রতিটি ডিভাইসের জন্য একটি বৈধ প্রেরক হিসাবে উপরের ইমেল ঠিকানাটি যুক্ত করতে হতে পারে।",
"MessageFeedURLWillBe": "ফিড URL হবে {0}",
@@ -744,6 +797,7 @@
"MessageNoLogs": "কোনও লগ নেই",
"MessageNoMediaProgress": "মিডিয়া অগ্রগতি নেই",
"MessageNoNotifications": "কোনো বিজ্ঞপ্তি নেই",
"MessageNoPodcastFeed": "অবৈধ পডকাস্ট: কোনো ফিড নেই",
"MessageNoPodcastsFound": "কোন পডকাস্ট পাওয়া যায়নি",
"MessageNoResults": "কোন ফলাফল নেই",
"MessageNoSearchResultsFor": "\"{0}\" এর জন্য কোন অনুসন্ধান ফলাফল নেই",
@@ -760,6 +814,10 @@
"MessagePlaylistCreateFromCollection": "সংগ্রহ থেকে প্লেলিস্ট তৈরি করুন",
"MessagePleaseWait": "অনুগ্রহ করে অপেক্ষা করুন..।",
"MessagePodcastHasNoRSSFeedForMatching": "পডকাস্টের সাথে মিলের জন্য ব্যবহার করার জন্য কোন RSS ফিড ইউআরএল নেই",
"MessagePodcastSearchField": "অনুসন্ধান শব্দ বা RSS ফিড URL লিখুন",
"MessageQuickEmbedInProgress": "দ্রুত এম্বেড করা হচ্ছে",
"MessageQuickEmbedQueue": "দ্রুত এম্বেড করার জন্য সারিবদ্ধ ({0} সারিতে)",
"MessageQuickMatchAllEpisodes": "দ্রুত ম্যাচ সব পর্ব",
"MessageQuickMatchDescription": "খালি আইটেমের বিশদ বিবরণ এবং '{0}' থেকে প্রথম ম্যাচের ফলাফলের সাথে কভার করুন। সার্ভার সেটিং সক্ষম না থাকলে বিশদ ওভাররাইট করে না।",
"MessageRemoveChapter": "অধ্যায় সরান",
"MessageRemoveEpisodes": "{0}টি পর্ব(গুলি) সরান",
@@ -802,6 +860,9 @@
"MessageTaskOpmlImportFeedPodcastExists": "পডকাস্ট আগে থেকেই পাথে বিদ্যমান",
"MessageTaskOpmlImportFeedPodcastFailed": "পডকাস্ট তৈরি করতে ব্যর্থ",
"MessageTaskOpmlImportFinished": "{0}টি পডকাস্ট যোগ করা হয়েছে",
"MessageTaskOpmlParseFailed": "OPML ফাইল পার্স করতে ব্যর্থ হয়েছে",
"MessageTaskOpmlParseFastFail": "অবৈধ OPML ফাইল <opml> ট্যাগ পাওয়া যায়নি বা একটি <outline> ট্যাগ পাওয়া যায়নি",
"MessageTaskOpmlParseNoneFound": "OPML ফাইলে কোনো ফিড পাওয়া যায়নি",
"MessageTaskScanItemsAdded": "{0}টি করা হয়েছে",
"MessageTaskScanItemsMissing": "{0}টি অনুপস্থিত",
"MessageTaskScanItemsUpdated": "{0} টি আপডেট করা হয়েছে",
@@ -826,6 +887,10 @@
"NoteUploaderFoldersWithMediaFiles": "মিডিয়া ফাইল সহ ফোল্ডারগুলি আলাদা লাইব্রেরি আইটেম হিসাবে পরিচালনা করা হবে।",
"NoteUploaderOnlyAudioFiles": "যদি শুধুমাত্র অডিও ফাইল আপলোড করা হয় তবে প্রতিটি অডিও ফাইল একটি পৃথক অডিওবুক হিসাবে পরিচালনা করা হবে।",
"NoteUploaderUnsupportedFiles": "অসমর্থিত ফাইলগুলি উপেক্ষা করা হয়। একটি ফোল্ডার বেছে নেওয়া বা ফেলে দেওয়ার সময়, আইটেম ফোল্ডারে নেই এমন অন্যান্য ফাইলগুলি উপেক্ষা করা হয়।",
"NotificationOnBackupCompletedDescription": "ব্যাকআপ সম্পূর্ণ হলে ট্রিগার হবে",
"NotificationOnBackupFailedDescription": "ব্যাকআপ ব্যর্থ হলে ট্রিগার হবে",
"NotificationOnEpisodeDownloadedDescription": "একটি পডকাস্ট পর্ব স্বয়ংক্রিয়ভাবে ডাউনলোড হলে ট্রিগার হবে",
"NotificationOnTestDescription": "বিজ্ঞপ্তি সিস্টেম পরীক্ষার জন্য ইভেন্ট",
"PlaceholderNewCollection": "নতুন সংগ্রহের নাম",
"PlaceholderNewFolderPath": "নতুন ফোল্ডার পথ",
"PlaceholderNewPlaylist": "নতুন প্লেলিস্টের নাম",
@@ -851,6 +916,7 @@
"StatsYearInReview": "বাৎসরিক পর্যালোচনা",
"ToastAccountUpdateSuccess": "অ্যাকাউন্ট আপডেট করা হয়েছে",
"ToastAppriseUrlRequired": "একটি Apprise ইউআরএল লিখতে হবে",
"ToastAsinRequired": "ASIN প্রয়োজন",
"ToastAuthorImageRemoveSuccess": "লেখকের ছবি সরানো হয়েছে",
"ToastAuthorNotFound": "লেখক \"{0}\" খুঁজে পাওয়া যায়নি",
"ToastAuthorRemoveSuccess": "লেখক সরানো হয়েছে",
@@ -870,6 +936,8 @@
"ToastBackupUploadSuccess": "ব্যাকআপ আপলোড হয়েছে",
"ToastBatchDeleteFailed": "ব্যাচ মুছে ফেলতে ব্যর্থ হয়েছে",
"ToastBatchDeleteSuccess": "ব্যাচ মুছে ফেলা সফল হয়েছে",
"ToastBatchQuickMatchFailed": "ব্যাচ কুইক ম্যাচ ব্যর্থ!",
"ToastBatchQuickMatchStarted": "{0}টি বইয়ের ব্যাচ কুইক ম্যাচ শুরু হয়েছে!",
"ToastBatchUpdateFailed": "ব্যাচ আপডেট ব্যর্থ হয়েছে",
"ToastBatchUpdateSuccess": "ব্যাচ আপডেট সাফল্য",
"ToastBookmarkCreateFailed": "বুকমার্ক তৈরি করতে ব্যর্থ",
@@ -881,6 +949,7 @@
"ToastChaptersHaveErrors": "অধ্যায়ে ত্রুটি আছে",
"ToastChaptersMustHaveTitles": "অধ্যায়ের শিরোনাম থাকতে হবে",
"ToastChaptersRemoved": "অধ্যায়গুলো মুছে ফেলা হয়েছে",
"ToastChaptersUpdated": "অধ্যায় আপডেট করা হয়েছে",
"ToastCollectionItemsAddFailed": "আইটেম(গুলি) সংগ্রহে যোগ করা ব্যর্থ হয়েছে",
"ToastCollectionItemsAddSuccess": "আইটেম(গুলি) সংগ্রহে যোগ করা সফল হয়েছে",
"ToastCollectionItemsRemoveSuccess": "আইটেম(গুলি) সংগ্রহ থেকে সরানো হয়েছে",
@@ -898,11 +967,14 @@
"ToastEncodeCancelSucces": "এনকোড বাতিল করা হয়েছে",
"ToastEpisodeDownloadQueueClearFailed": "সারি সাফ করতে ব্যর্থ হয়েছে",
"ToastEpisodeDownloadQueueClearSuccess": "পর্ব ডাউনলোড সারি পরিষ্কার করা হয়েছে",
"ToastEpisodeUpdateSuccess": "{0}টি পর্ব আপডেট করা হয়েছে",
"ToastErrorCannotShare": "এই ডিভাইসে স্থানীয়ভাবে শেয়ার করা যাবে না",
"ToastFailedToLoadData": "ডেটা লোড করা যায়নি",
"ToastFailedToMatch": "মেলাতে ব্যর্থ হয়েছে",
"ToastFailedToShare": "শেয়ার করতে ব্যর্থ",
"ToastFailedToUpdate": "আপডেট করতে ব্যর্থ হয়েছে",
"ToastInvalidImageUrl": "অকার্যকর ছবির ইউআরএল",
"ToastInvalidMaxEpisodesToDownload": "ডাউনলোড করার জন্য অবৈধ সর্বোচ্চ পর্ব",
"ToastInvalidUrl": "অকার্যকর ইউআরএল",
"ToastItemCoverUpdateSuccess": "আইটেম কভার আপডেট করা হয়েছে",
"ToastItemDeletedFailed": "আইটেম মুছে ফেলতে ব্যর্থ",
@@ -920,14 +992,22 @@
"ToastLibraryScanFailedToStart": "স্ক্যান শুরু করতে ব্যর্থ",
"ToastLibraryScanStarted": "লাইব্রেরি স্ক্যান শুরু হয়েছে",
"ToastLibraryUpdateSuccess": "লাইব্রেরি \"{0}\" আপডেট করা হয়েছে",
"ToastMatchAllAuthorsFailed": "সমস্ত লেখকের সাথে মিলতে ব্যর্থ হয়েছে",
"ToastMetadataFilesRemovedError": "মেটাডেটা সরানোর সময় ত্রুটি {0} ফাইল",
"ToastMetadataFilesRemovedNoneFound": "কোনো মেটাডেটা নেই।লাইব্রেরিতে {0} ফাইল পাওয়া গেছে",
"ToastMetadataFilesRemovedNoneRemoved": "কোনো মেটাডেটা নেই।{0} ফাইল সরানো হয়েছে",
"ToastMetadataFilesRemovedSuccess": "{0} মেটাডেটা৷{1} ফাইল সরানো হয়েছে",
"ToastMustHaveAtLeastOnePath": "অন্তত একটি পথ থাকতে হবে",
"ToastNameEmailRequired": "নাম এবং ইমেইল আবশ্যক",
"ToastNameRequired": "নাম আবশ্যক",
"ToastNewEpisodesFound": "{0}টি নতুন পর্ব পাওয়া গেছে",
"ToastNewUserCreatedFailed": "অ্যাকাউন্ট তৈরি করতে ব্যর্থ: \"{0}\"",
"ToastNewUserCreatedSuccess": "নতুন একাউন্ট তৈরি হয়েছে",
"ToastNewUserLibraryError": "অন্তত একটি লাইব্রেরি নির্বাচন করতে হবে",
"ToastNewUserPasswordError": "অন্তত একটি পাসওয়ার্ড থাকতে হবে, শুধুমাত্র রুট ব্যবহারকারীর একটি খালি পাসওয়ার্ড থাকতে পারে",
"ToastNewUserTagError": "অন্তত একটি ট্যাগ নির্বাচন করতে হবে",
"ToastNewUserUsernameError": "একটি ব্যবহারকারীর নাম লিখুন",
"ToastNoNewEpisodesFound": "কোন নতুন পর্ব পাওয়া যায়নি",
"ToastNoUpdatesNecessary": "কোন আপডেটের প্রয়োজন নেই",
"ToastNotificationCreateFailed": "বিজ্ঞপ্তি তৈরি করতে ব্যর্থ",
"ToastNotificationDeleteFailed": "বিজ্ঞপ্তি মুছে ফেলতে ব্যর্থ",
@@ -946,6 +1026,7 @@
"ToastPodcastGetFeedFailed": "পডকাস্ট ফিড পেতে ব্যর্থ হয়েছে",
"ToastPodcastNoEpisodesInFeed": "আরএসএস ফিডে কোনো পর্ব পাওয়া যায়নি",
"ToastPodcastNoRssFeed": "পডকাস্টের কোন আরএসএস ফিড নেই",
"ToastProgressIsNotBeingSynced": "অগ্রগতি সিঙ্ক হচ্ছে না, প্লেব্যাক পুনরায় চালু করুন",
"ToastProviderCreatedFailed": "প্রদানকারী যোগ করতে ব্যর্থ হয়েছে",
"ToastProviderCreatedSuccess": "নতুন প্রদানকারী যোগ করা হয়েছে",
"ToastProviderNameAndUrlRequired": "নাম এবং ইউআরএল আবশ্যক",
@@ -972,6 +1053,7 @@
"ToastSessionCloseFailed": "অধিবেশন বন্ধ করতে ব্যর্থ হয়েছে",
"ToastSessionDeleteFailed": "সেশন মুছে ফেলতে ব্যর্থ",
"ToastSessionDeleteSuccess": "সেশন মুছে ফেলা হয়েছে",
"ToastSleepTimerDone": "স্লিপ টাইমার হয়ে গেছে... zZzzZz",
"ToastSlugMustChange": "স্লাগে অবৈধ অক্ষর রয়েছে",
"ToastSlugRequired": "স্লাগ আবশ্যক",
"ToastSocketConnected": "সকেট সংযুক্ত",

1027
client/strings/ca.json Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@@ -67,7 +67,7 @@
"ButtonQueueAddItem": "Přidat do fronty",
"ButtonQueueRemoveItem": "Odstranit z fronty",
"ButtonQuickEmbed": "Rychle Zapsat",
"ButtonQuickEmbedMetadata": "Rychle Zapsat Metadata",
"ButtonQuickEmbedMetadata": "Rychle zapsat Metadata",
"ButtonQuickMatch": "Rychlé přiřazení",
"ButtonReScan": "Znovu prohledat",
"ButtonRead": "Číst",
@@ -163,6 +163,7 @@
"HeaderNotificationUpdate": "Aktualizovat notifikaci",
"HeaderNotifications": "Oznámení",
"HeaderOpenIDConnectAuthentication": "Ověřování pomocí OpenID Connect",
"HeaderOpenListeningSessions": "Otevřené relace přehrávače",
"HeaderOpenRSSFeed": "Otevřít RSS kanál",
"HeaderOtherFiles": "Ostatní soubory",
"HeaderPasswordAuthentication": "Autentizace heslem",
@@ -258,6 +259,7 @@
"LabelByAuthor": "od {0}",
"LabelChangePassword": "Změnit heslo",
"LabelChannels": "Kanály",
"LabelChapterCount": "{0} Kapitol",
"LabelChapterTitle": "Název kapitoly",
"LabelChapters": "Kapitoly",
"LabelChaptersFound": "Kapitoly nalezeny",

View File

@@ -71,8 +71,8 @@
"ButtonQuickMatch": "Schnellabgleich",
"ButtonReScan": "Neu scannen",
"ButtonRead": "Lesen",
"ButtonReadLess": "Weniger anzeigen",
"ButtonReadMore": "Mehr anzeigen",
"ButtonReadLess": "weniger Anzeigen",
"ButtonReadMore": "Mehr Anzeigen",
"ButtonRefresh": "Neu Laden",
"ButtonRemove": "Entfernen",
"ButtonRemoveAll": "Alles entfernen",
@@ -220,7 +220,7 @@
"LabelAddToPlaylist": "Zur Wiedergabeliste hinzufügen",
"LabelAddToPlaylistBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Wiedergabeliste hinzu",
"LabelAddedAt": "Hinzugefügt am",
"LabelAddedDate": "Hinzugefügt {0}",
"LabelAddedDate": "{0} Hinzugefügt",
"LabelAdminUsersOnly": "Nur Admin Benutzer",
"LabelAll": "Alle",
"LabelAllUsers": "Alle Benutzer",
@@ -253,6 +253,7 @@
"LabelBackupsNumberToKeep": "Anzahl der aufzubewahrenden Sicherungen",
"LabelBackupsNumberToKeepHelp": "Es wird immer nur 1 Sicherung auf einmal entfernt. Wenn du bereits mehrere Sicherungen als die definierte max. Anzahl hast, solltest du diese manuell entfernen.",
"LabelBitrate": "Bitrate",
"LabelBonus": "Bonus",
"LabelBooks": "Bücher",
"LabelButtonText": "Knopftext",
"LabelByAuthor": "von {0}",
@@ -331,6 +332,7 @@
"LabelEpisodeType": "Episodentyp",
"LabelEpisodeUrlFromRssFeed": "Episoden URL vom RSS-Feed",
"LabelEpisodes": "Episoden",
"LabelEpisodic": "Episodisch",
"LabelExample": "Beispiel",
"LabelExpandSeries": "Serie ausklappen",
"LabelExpandSubSeries": "Unterserie ausklappen",
@@ -470,6 +472,7 @@
"LabelPermissionsAccessAllLibraries": "Zugriff auf alle Bibliotheken",
"LabelPermissionsAccessAllTags": "Zugriff auf alle Schlagwörter",
"LabelPermissionsAccessExplicitContent": "Zugriff auf explizite (alterbeschränkte) Inhalte",
"LabelPermissionsCreateEreader": "Kann E-Reader erstellen",
"LabelPermissionsDelete": "Darf Löschen",
"LabelPermissionsDownload": "Herunterladen",
"LabelPermissionsUpdate": "Aktualisieren",
@@ -531,6 +534,7 @@
"LabelSelectUsers": "Benutzer auswählen",
"LabelSendEbookToDevice": "E-Buch senden an …",
"LabelSequence": "Reihenfolge",
"LabelSerial": "fortlaufend",
"LabelSeries": "Serien",
"LabelSeriesName": "Serienname",
"LabelSeriesProgress": "Serienfortschritt",
@@ -559,6 +563,9 @@
"LabelSettingsHideSingleBookSeriesHelp": "Serien, die nur ein einzelnes Buch enthalten, werden auf der Startseite und in der Serienansicht ausgeblendet.",
"LabelSettingsHomePageBookshelfView": "Startseite verwendet die Bücherregalansicht",
"LabelSettingsLibraryBookshelfView": "Bibliothek verwendet die Bücherregalansicht",
"LabelSettingsLibraryMarkAsFinishedPercentComplete": "In Prozent gehört größer als",
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Verbleibende Zeit ist weniger als (Sekunden)",
"LabelSettingsLibraryMarkAsFinishedWhen": "Markiere Mediendateien als fertig, wenn",
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Überspringe vorherige Bücher in fortführender Serie",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Die Startseite von \"Fortführende Serien\" zeigt das erste noch nicht begonnene Buch in Serien an, die mindestens ein Buch abgeschlossen und keine Bücher begonnen haben. Wenn diese Einstellung aktiviert wird, werden Serien ab dem letzten abgeschlossenen Buch fortgesetzt und nicht ab dem ersten nicht begonnenen Buch.",
"LabelSettingsParseSubtitles": "Analysiere Untertitel",
@@ -577,7 +584,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",
"LabelSettingsTimeFormat": "Zeitformat",
"LabelShare": "Freigeben",
"LabelShareOpen": "Freigabe",
"LabelShareOpen": "Freigeben",
"LabelShareURL": "Freigabe URL",
"LabelShowAll": "Alles anzeigen",
"LabelShowSeconds": "Zeige Sekunden",
@@ -644,6 +651,7 @@
"LabelTracksMultiTrack": "Mehrfachdatei",
"LabelTracksNone": "Keine Dateien",
"LabelTracksSingleTrack": "Einzeldatei",
"LabelTrailer": "Vorschau",
"LabelType": "Typ",
"LabelUnabridged": "Ungekürzt",
"LabelUndo": "Rückgängig machen",
@@ -655,6 +663,7 @@
"LabelUpdateDetailsHelp": "Erlaube das Überschreiben bestehender Details für die ausgewählten Hörbücher, wenn eine Übereinstimmung gefunden wird",
"LabelUpdatedAt": "Aktualisiert am",
"LabelUploaderDragAndDrop": "Ziehen und Ablegen von Dateien oder Ordnern",
"LabelUploaderDragAndDropFilesOnly": "Dateien per Drag & Drop hierher ziehen",
"LabelUploaderDropFiles": "Dateien löschen",
"LabelUploaderItemFetchMetadataHelp": "Automatisches Aktualisieren von Titel, Autor und Serie",
"LabelUseAdvancedOptions": "Nutze Erweiterte Optionen",
@@ -670,11 +679,13 @@
"LabelViewPlayerSettings": "Zeige player Einstellungen",
"LabelViewQueue": "Player-Warteschlange anzeigen",
"LabelVolume": "Lautstärke",
"LabelWebRedirectURLsDescription": "Autorisieren Sie diese URLs bei ihrem OAuth-Anbieter, um die Weiterleitung zurück zur Webanwendung nach dem Login zu ermöglichen:",
"LabelWebRedirectURLsSubfolder": "Unterordner für Weiterleitung-URLs",
"LabelWeekdaysToRun": "Wochentage für die Ausführung",
"LabelXBooks": "{0} Bücher",
"LabelXItems": "{0} Medien",
"LabelYearReviewHide": "Verstecke Jahr in Übersicht",
"LabelYearReviewShow": "Zeige Jahr in Übersicht",
"LabelYearReviewHide": "Jahresrückblick verbergen",
"LabelYearReviewShow": "Jahresrückblick anzeigen",
"LabelYourAudiobookDuration": "Laufzeit deines Mediums",
"LabelYourBookmarks": "Lesezeichen",
"LabelYourPlaylists": "Eigene Wiedergabelisten",
@@ -719,6 +730,7 @@
"MessageConfirmPurgeCache": "Cache leeren wird das ganze Verzeichnis <code>/metadata/cache</code> löschen. <br /><br />Bist du dir sicher, dass das Cache Verzeichnis gelöscht werden soll?",
"MessageConfirmPurgeItemsCache": "Durch Elementcache leeren wird das gesamte Verzeichnis unter <code>/metadata/cache/items</code> gelöscht.<br />Bist du dir sicher?",
"MessageConfirmQuickEmbed": "Warnung! Audiodateien werden bei der Schnelleinbettung nicht gesichert! Achte darauf, dass du eine Sicherungskopie der Audiodateien besitzt. <br><br>Möchtest du fortfahren?",
"MessageConfirmQuickMatchEpisodes": "Schnellabgleich von Episoden überschreibt deren Details, wenn ein passender Eintrag gefunden wurde, wird aber nur auf bisher unbearbeitete Episoden angewendet. Wirklich fortfahren?",
"MessageConfirmReScanLibraryItems": "{0} Elemente werden erneut gescannt! Bist du dir sicher?",
"MessageConfirmRemoveAllChapters": "Alle Kapitel werden entfernt! Bist du dir sicher?",
"MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Bist du dir sicher?",
@@ -823,7 +835,7 @@
"MessageSetChaptersFromTracksDescription": "Kaitelerstellung basiert auf den existierenden einzelnen Audiodateien. Pro existierende Audiodatei wird 1 Kapitel erstellt, wobei deren Kapitelname aus dem Audiodateinamen extrahiert wird",
"MessageShareExpirationWillBe": "Läuft am <strong>{0}</strong> ab",
"MessageShareExpiresIn": "Läuft in {0} ab",
"MessageShareURLWillBe": "Der Freigabe Link wird <strong>{0}</strong> sein.",
"MessageShareURLWillBe": "Der Freigabe Link wird <strong>{0}</strong> sein",
"MessageStartPlaybackAtTime": "Start der Wiedergabe für \"{0}\" bei {1}?",
"MessageTaskAudioFileNotWritable": "Die Audiodatei \"{0}\" ist schreibgeschützt",
"MessageTaskCanceledByUser": "Aufgabe vom Benutzer abgebrochen",
@@ -1031,7 +1043,7 @@
"ToastRenameFailed": "Umbenennen fehlgeschlagen",
"ToastRescanFailed": "Erneut scannen fehlgeschlagen für {0}",
"ToastRescanRemoved": "Erneut scannen erledigt, Artikel wurde entfernt",
"ToastRescanUpToDate": "Erneut scannen erledigt, Artikel wahr auf dem neusten Stand",
"ToastRescanUpToDate": "Erneut scannen erledigt, Artikel war auf dem neusten Stand",
"ToastRescanUpdated": "Erneut scannen erledigt, Artikel wurde verändert",
"ToastScanFailed": "Fehler beim scannen des Artikels der Bibliothek",
"ToastSelectAtLeastOneUser": "Wähle mindestens einen Benutzer aus",

View File

@@ -88,6 +88,8 @@
"ButtonSaveTracklist": "Save Tracklist",
"ButtonScan": "Scan",
"ButtonScanLibrary": "Scan Library",
"ButtonScrollLeft": "Scroll Left",
"ButtonScrollRight": "Scroll Right",
"ButtonSearch": "Search",
"ButtonSelectFolderPath": "Select Folder Path",
"ButtonSeries": "Series",
@@ -190,6 +192,7 @@
"HeaderSettingsExperimental": "Experimental Features",
"HeaderSettingsGeneral": "General",
"HeaderSettingsScanner": "Scanner",
"HeaderSettingsWebClient": "Web Client",
"HeaderSleepTimer": "Sleep Timer",
"HeaderStatsLargestItems": "Largest Items",
"HeaderStatsLongestItems": "Longest Items (hrs)",
@@ -542,6 +545,7 @@
"LabelServerYearReview": "Server Year in Review ({0})",
"LabelSetEbookAsPrimary": "Set as primary",
"LabelSetEbookAsSupplementary": "Set as supplementary",
"LabelSettingsAllowIframe": "Allow embedding in an iframe",
"LabelSettingsAudiobooksOnly": "Audiobooks only",
"LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks",
"LabelSettingsBookshelfViewHelp": "Skeumorphic design with wooden shelves",
@@ -592,6 +596,8 @@
"LabelSize": "Size",
"LabelSleepTimer": "Sleep timer",
"LabelSlug": "Slug",
"LabelSortAscending": "Ascending",
"LabelSortDescending": "Descending",
"LabelStart": "Start",
"LabelStartTime": "Start Time",
"LabelStarted": "Started",
@@ -663,6 +669,7 @@
"LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located",
"LabelUpdatedAt": "Updated At",
"LabelUploaderDragAndDrop": "Drag & drop files or folders",
"LabelUploaderDragAndDropFilesOnly": "Drag & drop files",
"LabelUploaderDropFiles": "Drop files",
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
"LabelUseAdvancedOptions": "Use Advanced Options",
@@ -678,6 +685,8 @@
"LabelViewPlayerSettings": "View player settings",
"LabelViewQueue": "View player queue",
"LabelVolume": "Volume",
"LabelWebRedirectURLsDescription": "Authorize these URLs in your OAuth provider to allow redirection back to the web app after login:",
"LabelWebRedirectURLsSubfolder": "Subfolder for Redirect URLs",
"LabelWeekdaysToRun": "Weekdays to run",
"LabelXBooks": "{0} books",
"LabelXItems": "{0} items",

View File

@@ -1 +0,0 @@
{}

View File

@@ -1,6 +1,6 @@
{
"ButtonAdd": "Agregar",
"ButtonAddChapters": "Agregar Capitulo",
"ButtonAdd": "Agregaro",
"ButtonAddChapters": "Agregar",
"ButtonAddDevice": "Agregar Dispositivo",
"ButtonAddLibrary": "Crear Biblioteca",
"ButtonAddPodcasts": "Agregar Podcasts",
@@ -71,8 +71,8 @@
"ButtonQuickMatch": "Encontrar Rápido",
"ButtonReScan": "Re-Escanear",
"ButtonRead": "Leer",
"ButtonReadLess": "Lea menos",
"ButtonReadMore": "Lea mas",
"ButtonReadLess": "Leer menos",
"ButtonReadMore": "Leer más",
"ButtonRefresh": "Refrecar",
"ButtonRemove": "Remover",
"ButtonRemoveAll": "Remover Todos",
@@ -163,6 +163,7 @@
"HeaderNotificationUpdate": "Notificación de actualización",
"HeaderNotifications": "Notificaciones",
"HeaderOpenIDConnectAuthentication": "Autenticación OpenID Connect",
"HeaderOpenListeningSessions": "Sesiones públicas de escucha",
"HeaderOpenRSSFeed": "Abrir fuente RSS",
"HeaderOtherFiles": "Otros Archivos",
"HeaderPasswordAuthentication": "Autenticación por contraseña",
@@ -219,13 +220,14 @@
"LabelAddToPlaylist": "Añadido a la lista de reproducción",
"LabelAddToPlaylistBatch": "Se Añadieron {0} Artículos a la Lista de Reproducción",
"LabelAddedAt": "Añadido",
"LabelAddedDate": "Añadido {0}",
"LabelAddedDate": "{0} Añadido",
"LabelAdminUsersOnly": "Solamente usuarios administradores",
"LabelAll": "Todos",
"LabelAllUsers": "Todos los Usuarios",
"LabelAllUsersExcludingGuests": "Todos los usuarios excepto invitados",
"LabelAllUsersIncludingGuests": "Todos los usuarios e invitados",
"LabelAlreadyInYourLibrary": "Ya existe en la Biblioteca",
"LabelApiToken": "Token de la API",
"LabelAppend": "Adjuntar",
"LabelAudioBitrate": "Tasa de bits del audio (por ejemplo, 128k)",
"LabelAudioChannels": "Canales de audio (1 o 2)",
@@ -415,6 +417,9 @@
"LabelMatchExistingUsersBy": "Emparejar a los usuarios existentes por",
"LabelMatchExistingUsersByDescription": "Se utiliza para conectar usuarios existentes. Una vez conectados, los usuarios serán emparejados por un identificador único de su proveedor de SSO",
"LabelMaxEpisodesToDownload": "Número máximo # de episodios para descargar. Usa 0 para descargar una cantidad ilimitada.",
"LabelMaxEpisodesToDownloadPerCheck": "Número máximo de episodios nuevos que se descargarán por comprobación",
"LabelMaxEpisodesToKeep": "Número máximo de episodios que se mantendrán",
"LabelMaxEpisodesToKeepHelp": "El valor 0 no establece un límite máximo. Después de que se descargue automáticamente un nuevo episodio, esto eliminará el episodio más antiguo si tiene más de X episodios. Esto solo eliminará 1 episodio por nueva descarga.",
"LabelMediaPlayer": "Reproductor de Medios",
"LabelMediaType": "Tipo de multimedia",
"LabelMetaTag": "Metaetiqueta",
@@ -460,12 +465,14 @@
"LabelOpenIDGroupClaimDescription": "Nombre de la declaración OpenID que contiene una lista de grupos del usuario. Comúnmente conocidos como <code>grupos</code>. <b>Si se configura</b>, la aplicación asignará automáticamente roles en función de la pertenencia a grupos del usuario, siempre que estos grupos se denominen \"admin\", \"user\" o \"guest\" en la notificación. La solicitud debe contener una lista, y si un usuario pertenece a varios grupos, la aplicación asignará el rol correspondiente al mayor nivel de acceso. Si ningún grupo coincide, se denegará el acceso.",
"LabelOpenRSSFeed": "Abrir Fuente RSS",
"LabelOverwrite": "Sobrescribir",
"LabelPaginationPageXOfY": "Página {0} de {1}",
"LabelPassword": "Contraseña",
"LabelPath": "Ruta de carpeta",
"LabelPermanent": "Permanente",
"LabelPermissionsAccessAllLibraries": "Puede Accesar a Todas las bibliotecas",
"LabelPermissionsAccessAllTags": "Pueda Accesar a Todas las Etiquetas",
"LabelPermissionsAccessExplicitContent": "Puede Accesar a Contenido Explicito",
"LabelPermissionsCreateEreader": "Puede crear un gestor de proyectos",
"LabelPermissionsDelete": "Puede Eliminar",
"LabelPermissionsDownload": "Puede Descargar",
"LabelPermissionsUpdate": "Puede Actualizar",
@@ -510,18 +517,24 @@
"LabelRedo": "Rehacer",
"LabelRegion": "Región",
"LabelReleaseDate": "Fecha de Estreno",
"LabelRemoveAllMetadataAbs": "Eliminar todos los archivos metadata.abs",
"LabelRemoveAllMetadataJson": "Eliminar todos los archivos metadata.json",
"LabelRemoveCover": "Remover Portada",
"LabelRemoveMetadataFile": "Eliminar archivos de metadatos en carpetas de elementos de biblioteca",
"LabelRemoveMetadataFileHelp": "Elimine todos los archivos metadata.json y metadata.abs de sus carpetas {0}.",
"LabelRowsPerPage": "Filas por página",
"LabelSearchTerm": "Buscar Termino",
"LabelSearchTitle": "Buscar Titulo",
"LabelSearchTitleOrASIN": "Buscar Título o ASIN",
"LabelSeason": "Temporada",
"LabelSeasonNumber": "Sesión #{0}",
"LabelSelectAll": "Seleccionar todo",
"LabelSelectAllEpisodes": "Seleccionar todos los episodios",
"LabelSelectEpisodesShowing": "Seleccionar los {0} episodios visibles",
"LabelSelectUsers": "Seleccionar usuarios",
"LabelSendEbookToDevice": "Enviar Ebook a...",
"LabelSequence": "Secuencia",
"LabelSerial": "Serial",
"LabelSeries": "Series",
"LabelSeriesName": "Nombre de la Serie",
"LabelSeriesProgress": "Progreso de la Serie",
@@ -550,6 +563,9 @@
"LabelSettingsHideSingleBookSeriesHelp": "Las series con un solo libro no aparecerán en la página de series ni la repisa para series de la página principal.",
"LabelSettingsHomePageBookshelfView": "Usar la vista de librero en la página principal",
"LabelSettingsLibraryBookshelfView": "Usar la vista de librero en la biblioteca",
"LabelSettingsLibraryMarkAsFinishedPercentComplete": "El porcentaje completado es mayor que",
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "El tiempo restante es menor a (segundos)",
"LabelSettingsLibraryMarkAsFinishedWhen": "Marcar el archivo multimedia como terminado cuando",
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Saltar libros anteriores de la serie Continuada",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "El estante de la página de inicio de Continuar Serie muestra el primer libro no iniciado de una serie que tenga por lo menos un libro finalizado y no tenga libros en progreso. Habilitar esta opción le permitirá continuar series desde el último libro que ha completado en vez del primer libro que no ha empezado.",
"LabelSettingsParseSubtitles": "Extraer Subtítulos",
@@ -614,6 +630,7 @@
"LabelTimeDurationXMinutes": "{0} minutos",
"LabelTimeDurationXSeconds": "{0} segundos",
"LabelTimeInMinutes": "Tiempo en minutos",
"LabelTimeLeft": "Quedan {0}",
"LabelTimeListened": "Tiempo Escuchando",
"LabelTimeListenedToday": "Tiempo Escuchando Hoy",
"LabelTimeRemaining": "{0} restante",
@@ -634,6 +651,7 @@
"LabelTracksMultiTrack": "Varias pistas",
"LabelTracksNone": "Ninguna pista",
"LabelTracksSingleTrack": "Una pista",
"LabelTrailer": "Tráiler",
"LabelType": "Tipo",
"LabelUnabridged": "No Abreviado",
"LabelUndo": "Deshacer",
@@ -645,11 +663,13 @@
"LabelUpdateDetailsHelp": "Permitir sobrescribir detalles existentes de los libros seleccionados cuando sean encontrados",
"LabelUpdatedAt": "Actualizado En",
"LabelUploaderDragAndDrop": "Arrastre y suelte archivos o carpetas",
"LabelUploaderDragAndDropFilesOnly": "Arrastrar y soltar archivos",
"LabelUploaderDropFiles": "Suelte los Archivos",
"LabelUploaderItemFetchMetadataHelp": "Buscar título, autor y series automáticamente",
"LabelUseAdvancedOptions": "Usar opciones avanzadas",
"LabelUseChapterTrack": "Usar pista por capitulo",
"LabelUseFullTrack": "Usar pista completa",
"LabelUseZeroForUnlimited": "Utilice 0 para ilimitado",
"LabelUser": "Usuario",
"LabelUsername": "Nombre de Usuario",
"LabelValue": "Valor",
@@ -659,11 +679,13 @@
"LabelViewPlayerSettings": "Ver los ajustes del reproductor",
"LabelViewQueue": "Ver Fila del Reproductor",
"LabelVolume": "Volumen",
"LabelWebRedirectURLsDescription": "Autorice estas URL en su proveedor OAuth para permitir la redirección a la aplicación web después de iniciar sesión:",
"LabelWebRedirectURLsSubfolder": "Subcarpeta para URL de redireccionamiento",
"LabelWeekdaysToRun": "Correr en Días de la Semana",
"LabelXBooks": "{0} libros",
"LabelXItems": "{0} elementos",
"LabelYearReviewHide": "Ocultar Year in Review",
"LabelYearReviewShow": "Ver Year in Review",
"LabelYearReviewHide": "Ocultar Resumen del año",
"LabelYearReviewShow": "Resumen del año",
"LabelYourAudiobookDuration": "Duración de tu Audiolibro",
"LabelYourBookmarks": "Tus Marcadores",
"LabelYourPlaylists": "Tus Listas",
@@ -708,6 +730,7 @@
"MessageConfirmPurgeCache": "Purgar el caché eliminará el directorio completo ubicado en <code>/metadata/cache</code>. <br /><br />¿Está seguro que desea eliminar el directorio del caché?",
"MessageConfirmPurgeItemsCache": "Purgar la caché de los elementos eliminará todo el directorio <code>/metadata/cache/items</code>.<br />¿Estás seguro?",
"MessageConfirmQuickEmbed": "¡Advertencia! La integración rápida no realiza copias de seguridad a ninguno de tus archivos de audio. Asegúrate de haber realizado una copia de los mismos previamente. <br><br>¿Deseas continuar?",
"MessageConfirmQuickMatchEpisodes": "El reconocimiento rápido de extensiones sobrescribirá los detalles si se encuentra una coincidencia. Se actualizarán las extensiones no reconocidas. ¿Está seguro?",
"MessageConfirmReScanLibraryItems": "¿Estás seguro de querer re escanear {0} elemento(s)?",
"MessageConfirmRemoveAllChapters": "¿Está seguro de que desea remover todos los capitulos?",
"MessageConfirmRemoveAuthor": "¿Está seguro de que desea remover el autor \"{0}\"?",
@@ -715,6 +738,7 @@
"MessageConfirmRemoveEpisode": "¿Está seguro de que desea remover el episodio \"{0}\"?",
"MessageConfirmRemoveEpisodes": "¿Está seguro de que desea remover {0} episodios?",
"MessageConfirmRemoveListeningSessions": "¿Está seguro que desea remover {0} sesiones de escuchar?",
"MessageConfirmRemoveMetadataFiles": "¿Está seguro de que desea eliminar todos los archivos de metadatos.{0} en las carpetas de elementos de su biblioteca?",
"MessageConfirmRemoveNarrator": "¿Está seguro de que desea remover el narrador \"{0}\"?",
"MessageConfirmRemovePlaylist": "¿Está seguro de que desea remover la lista de reproducción \"{0}\"?",
"MessageConfirmRenameGenre": "¿Está seguro de que desea renombrar el genero \"{0}\" a \"{1}\" de todos los elementos?",
@@ -758,7 +782,7 @@
"MessageNoBackups": "Sin Respaldos",
"MessageNoBookmarks": "Sin marcadores",
"MessageNoChapters": "Sin capítulos",
"MessageNoCollections": "Sin Colecciones",
"MessageNoCollections": "Sin colecciones",
"MessageNoCoversFound": "Ninguna Portada Encontrada",
"MessageNoDescription": "Sin Descripción",
"MessageNoDevices": "Sin dispositivos",
@@ -795,6 +819,7 @@
"MessagePodcastSearchField": "Introduzca el término de búsqueda o la URL de la fuente RSS",
"MessageQuickEmbedInProgress": "Integración rápida en proceso",
"MessageQuickEmbedQueue": "En cola para inserción rápida ({0} en cola)",
"MessageQuickMatchAllEpisodes": "Combina rápidamente todos los episodios",
"MessageQuickMatchDescription": "Rellenar detalles de elementos vacíos y portada con los primeros resultados de '{0}'. No sobrescribe los detalles a menos que la opción \"Preferir Metadatos Encontrados\" del servidor esté habilitada.",
"MessageRemoveChapter": "Remover capítulos",
"MessageRemoveEpisodes": "Remover {0} episodio(s)",
@@ -893,6 +918,7 @@
"StatsYearInReview": "RESEÑA DEL AÑO",
"ToastAccountUpdateSuccess": "Cuenta actualizada",
"ToastAppriseUrlRequired": "Debes ingresar una URL de Apprise",
"ToastAsinRequired": "Se requiere ASIN",
"ToastAuthorImageRemoveSuccess": "Se eliminó la imagen del autor",
"ToastAuthorNotFound": "No se encontró el autor \"{0}\"",
"ToastAuthorRemoveSuccess": "Autor eliminado",
@@ -912,6 +938,8 @@
"ToastBackupUploadSuccess": "Respaldo cargado",
"ToastBatchDeleteFailed": "Error al eliminar por lotes",
"ToastBatchDeleteSuccess": "Borrado por lotes correcto",
"ToastBatchQuickMatchFailed": "¡Error en la sincronización rápida por lotes!",
"ToastBatchQuickMatchStarted": "¡Se inició el lote de búsqueda rápida de {0} libros!",
"ToastBatchUpdateFailed": "Subida masiva fallida",
"ToastBatchUpdateSuccess": "Subida masiva exitosa",
"ToastBookmarkCreateFailed": "Error al crear marcador",
@@ -923,6 +951,7 @@
"ToastChaptersHaveErrors": "Los capítulos tienen errores",
"ToastChaptersMustHaveTitles": "Los capítulos tienen que tener un título",
"ToastChaptersRemoved": "Capítulos eliminados",
"ToastChaptersUpdated": "Capítulos actualizados",
"ToastCollectionItemsAddFailed": "Artículo(s) añadido(s) a la colección fallido(s)",
"ToastCollectionItemsAddSuccess": "Artículo(s) añadido(s) a la colección correctamente",
"ToastCollectionItemsRemoveSuccess": "Elementos(s) removidos de la colección",
@@ -940,11 +969,14 @@
"ToastEncodeCancelSucces": "Codificación cancelada",
"ToastEpisodeDownloadQueueClearFailed": "No se pudo borrar la cola",
"ToastEpisodeDownloadQueueClearSuccess": "Se borró la cola de descargas de los episodios",
"ToastEpisodeUpdateSuccess": "{0} episodio(s) actualizado(s)",
"ToastErrorCannotShare": "No se puede compartir de forma nativa en este dispositivo",
"ToastFailedToLoadData": "Error al cargar data",
"ToastFailedToMatch": "Error al emparejar",
"ToastFailedToShare": "Error al compartir",
"ToastFailedToUpdate": "Error al actualizar",
"ToastInvalidImageUrl": "URL de la imagen no válida",
"ToastInvalidMaxEpisodesToDownload": "Número máximo de episodios para descargar no válidos",
"ToastInvalidUrl": "URL no válida",
"ToastItemCoverUpdateSuccess": "Portada del elemento actualizada",
"ToastItemDeletedFailed": "Error al eliminar el elemento",
@@ -963,14 +995,21 @@
"ToastLibraryScanStarted": "Se inició el escaneo de la biblioteca",
"ToastLibraryUpdateSuccess": "Biblioteca \"{0}\" actualizada",
"ToastMatchAllAuthorsFailed": "No coincide con todos los autores",
"ToastMetadataFilesRemovedError": "Error al eliminar metadatos de {0} archivo(s)",
"ToastMetadataFilesRemovedNoneFound": "No hay metadatos.{0} archivo(s) encontrado(s) en la biblioteca",
"ToastMetadataFilesRemovedNoneRemoved": "Sin metadatos.{0} archivo(s) eliminado(s)",
"ToastMetadataFilesRemovedSuccess": "{0} metadatos.{1} archivos eliminados",
"ToastMustHaveAtLeastOnePath": "Debe tener al menos una ruta",
"ToastNameEmailRequired": "Son obligatorios el nombre y el correo electrónico",
"ToastNameRequired": "Nombre obligatorio",
"ToastNewEpisodesFound": "{0} nuevo(s) episodio(s) encontrado(s)",
"ToastNewUserCreatedFailed": "Error al crear la cuenta: \"{0}\"",
"ToastNewUserCreatedSuccess": "Nueva cuenta creada",
"ToastNewUserLibraryError": "Debes seleccionar al menos una biblioteca",
"ToastNewUserPasswordError": "Debes tener una contraseña, solo el usuario root puede estar sin contraseña",
"ToastNewUserTagError": "Debes seleccionar al menos una etiqueta",
"ToastNewUserUsernameError": "Introduce un nombre de usuario",
"ToastNoNewEpisodesFound": "No se encontraron nuevos episodios",
"ToastNoUpdatesNecessary": "No es necesario actualizar",
"ToastNotificationCreateFailed": "Error al crear notificación",
"ToastNotificationDeleteFailed": "Error al borrar la notificación",
@@ -989,6 +1028,7 @@
"ToastPodcastGetFeedFailed": "No se puede obtener el podcast",
"ToastPodcastNoEpisodesInFeed": "No se han encontrado episodios en el feed del RSS",
"ToastPodcastNoRssFeed": "El podcast no tiene feed RSS",
"ToastProgressIsNotBeingSynced": "El progreso no se sincroniza, reinicia la reproducción",
"ToastProviderCreatedFailed": "Error al añadir el proveedor",
"ToastProviderCreatedSuccess": "Nuevo proveedor añadido",
"ToastProviderNameAndUrlRequired": "Nombre y Url obligatorios",
@@ -1015,6 +1055,7 @@
"ToastSessionCloseFailed": "Error al cerrar la sesión",
"ToastSessionDeleteFailed": "Error al eliminar sesión",
"ToastSessionDeleteSuccess": "Sesión eliminada",
"ToastSleepTimerDone": "Temporizador de apagado automático activado... zZzzZz",
"ToastSlugMustChange": "El slug contiene caracteres no válidos",
"ToastSlugRequired": "Slug obligatorio",
"ToastSocketConnected": "Socket conectado",

View File

@@ -122,7 +122,7 @@
"HeaderBackups": "Sauvegardes",
"HeaderChangePassword": "Modifier le mot de passe",
"HeaderChapters": "Chapitres",
"HeaderChooseAFolder": "Choisir un dossier",
"HeaderChooseAFolder": "Sélectionner un dossier",
"HeaderCollection": "Collection",
"HeaderCollectionItems": "Entrées de la collection",
"HeaderCover": "Couverture",
@@ -163,6 +163,7 @@
"HeaderNotificationUpdate": "Mise à jour de la notification",
"HeaderNotifications": "Notifications",
"HeaderOpenIDConnectAuthentication": "Authentification via OpenID Connect",
"HeaderOpenListeningSessions": "Ouvrir les sessions d'écoutes",
"HeaderOpenRSSFeed": "Ouvrir le flux RSS",
"HeaderOtherFiles": "Autres fichiers",
"HeaderPasswordAuthentication": "Authentification par mot de passe",
@@ -180,6 +181,7 @@
"HeaderRemoveEpisodes": "Suppression de {0} épisodes",
"HeaderSavedMediaProgress": "Progression de la sauvegarde des médias",
"HeaderSchedule": "Programmation",
"HeaderScheduleEpisodeDownloads": "Programmer des téléchargements automatiques d'épisodes",
"HeaderScheduleLibraryScans": "Analyse automatique de la bibliothèque",
"HeaderSession": "Session",
"HeaderSetBackupSchedule": "Activer la sauvegarde automatique",
@@ -225,6 +227,7 @@
"LabelAllUsersExcludingGuests": "Tous les utilisateurs à lexception des invités",
"LabelAllUsersIncludingGuests": "Tous les utilisateurs, y compris les invités",
"LabelAlreadyInYourLibrary": "Déjà dans la bibliothèque",
"LabelApiToken": "Token API",
"LabelAppend": "Ajouter",
"LabelAudioBitrate": "Débit audio (par exemple 128k)",
"LabelAudioChannels": "Canaux audio (1 ou 2)",
@@ -250,15 +253,18 @@
"LabelBackupsNumberToKeep": "Nombre de sauvegardes à conserver",
"LabelBackupsNumberToKeepHelp": "Seule une sauvegarde sera supprimée à la fois. Si vous avez déjà plus de sauvegardes à effacer, vous devez les supprimer manuellement.",
"LabelBitrate": "Débit binaire",
"LabelBonus": "Bonus",
"LabelBooks": "Livres",
"LabelButtonText": "Texte du bouton",
"LabelByAuthor": "par {0}",
"LabelChangePassword": "Modifier le mot de passe",
"LabelChannels": "Canaux",
"LabelChapterCount": "{0} Chapitres",
"LabelChapterTitle": "Titre du chapitre",
"LabelChapters": "Chapitres",
"LabelChaptersFound": "chapitres trouvés",
"LabelClickForMoreInfo": "Cliquez ici pour plus dinformations",
"LabelClickToUseCurrentValue": "Cliquez pour utiliser la valeur actuelle",
"LabelClosePlayer": "Fermer le lecteur",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Réduire les séries",
@@ -304,7 +310,7 @@
"LabelEmailSettingsRejectUnauthorized": "Rejeter les certificats non autorisés",
"LabelEmailSettingsRejectUnauthorizedHelp": "Désactiver la validation du certificat SSL peut exposer votre connexion à des risques de sécurité, tels que des attaques de type « Attaque de lhomme du milieu ». Ne désactivez cette option que si vous en comprenez les implications et si vous faites confiance au serveur de messagerie auquel vous vous connectez.",
"LabelEmailSettingsSecure": "Sécurisé",
"LabelEmailSettingsSecureHelp": "Si cette option est activée, la connexion utilisera TLS lors de la connexion au serveur. Si elle est désactivée, TLS sera utilisé uniquement si le serveur prend en charge lextension STARTTLS. Dans la plupart des cas, définissez cette valeur sur « true » si vous vous connectez au port 465. Pour les ports 587 ou 25, laissez-la sur « false ». (source: nodemailer.com/smtp/#authentication)",
"LabelEmailSettingsSecureHelp": "Si cette option est activée, la connexion utilisera TLS lors de la connexion au serveur. Si elle est désactivée, TLS sera utilisé uniquement si le serveur prend en charge lextension STARTTLS. Dans la plupart des cas, définissez cette valeur sur « true » si vous vous connectez au port 465. Pour les ports 587 ou 25, laissez-la sur « false ». (source : nodemailer.com/smtp/#authentication)",
"LabelEmailSettingsTestAddress": "Adresse de test",
"LabelEmbeddedCover": "Couverture du livre intégrée",
"LabelEnable": "Activer",
@@ -320,9 +326,13 @@
"LabelEnd": "Fin",
"LabelEndOfChapter": "Fin du chapitre",
"LabelEpisode": "Épisode",
"LabelEpisodeNotLinkedToRssFeed": "Épisode non lié au flux RSS",
"LabelEpisodeNumber": "Épisode n°{0}",
"LabelEpisodeTitle": "Titre de lépisode",
"LabelEpisodeType": "Type de lépisode",
"LabelEpisodeUrlFromRssFeed": "URL de lépisode à partir du flux RSS",
"LabelEpisodes": "Épisodes",
"LabelEpisodic": "Épisodique",
"LabelExample": "Exemple",
"LabelExpandSeries": "Développer la série",
"LabelExpandSubSeries": "Développer les sous-séries",
@@ -350,6 +360,7 @@
"LabelFontScale": "Taille de la police de caractère",
"LabelFontStrikethrough": "Barrer",
"LabelFormat": "Format",
"LabelFull": "Complet",
"LabelGenre": "Genre",
"LabelGenres": "Genres",
"LabelHardDeleteFile": "Suppression du fichier",
@@ -405,6 +416,10 @@
"LabelLowestPriority": "Priorité la plus basse",
"LabelMatchExistingUsersBy": "Correspondance avec les utilisateurs existants",
"LabelMatchExistingUsersByDescription": "Utilisé pour connecter les utilisateurs existants. Une fois connectés, les utilisateurs seront associés à un identifiant unique provenant de votre fournisseur SSO",
"LabelMaxEpisodesToDownload": "Nombre maximum dépisodes à télécharger. 0 pour illimité.",
"LabelMaxEpisodesToDownloadPerCheck": "Nombre maximum de nouveaux épisodes à télécharger par vérification",
"LabelMaxEpisodesToKeep": "Nombre maximum dépisodes à conserver",
"LabelMaxEpisodesToKeepHelp": "La valeur 0 ne définit aucune limite maximale. Une fois quun nouvel épisode est téléchargé automatiquement, lépisode le plus ancien sera supprimé si vous avez plus de X épisodes. Cela ne supprimera quun seul épisode par nouveau téléchargement.",
"LabelMediaPlayer": "Lecteur multimédia",
"LabelMediaType": "Type de média",
"LabelMetaTag": "Balise de métadonnée",
@@ -450,12 +465,14 @@
"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é.",
"LabelOpenRSSFeed": "Ouvrir le flux RSS",
"LabelOverwrite": "Écraser",
"LabelPaginationPageXOfY": "Page {0} sur {1}",
"LabelPassword": "Mot de passe",
"LabelPath": "Chemin",
"LabelPermanent": "Permanent",
"LabelPermissionsAccessAllLibraries": "Peut accéder à toutes les bibliothèque",
"LabelPermissionsAccessAllTags": "Peut accéder à toutes les étiquettes",
"LabelPermissionsAccessExplicitContent": "Peut accéder au contenu restreint",
"LabelPermissionsCreateEreader": "Peut créer une liseuse",
"LabelPermissionsDelete": "Peut supprimer",
"LabelPermissionsDownload": "Peut télécharger",
"LabelPermissionsUpdate": "Peut mettre à jour",
@@ -500,18 +517,24 @@
"LabelRedo": "Refaire",
"LabelRegion": "Région",
"LabelReleaseDate": "Date de parution",
"LabelRemoveAllMetadataAbs": "Supprimer tous les fichiers metadata.abs",
"LabelRemoveAllMetadataJson": "Supprimer tous les fichiers metadata.json",
"LabelRemoveCover": "Supprimer la couverture",
"LabelRemoveMetadataFile": "Supprimer les fichiers de métadonnées dans les dossiers des éléments de la bibliothèque",
"LabelRemoveMetadataFileHelp": "Supprimer tous les fichiers metadata.json et metadata.abs de vos dossiers {0}.",
"LabelRowsPerPage": "Lignes par page",
"LabelSearchTerm": "Terme de recherche",
"LabelSearchTitle": "Titre de recherche",
"LabelSearchTitleOrASIN": "Recherche du titre ou ASIN",
"LabelSeason": "Saison",
"LabelSeasonNumber": "Saison n°{0}",
"LabelSelectAll": "Tout sélectionner",
"LabelSelectAllEpisodes": "Sélectionner tous les épisodes",
"LabelSelectEpisodesShowing": "Sélectionner {0} épisode(s) en cours",
"LabelSelectUsers": "Sélectionner les utilisateurs",
"LabelSendEbookToDevice": "Envoyer le livre numérique à…",
"LabelSequence": "Séquence",
"LabelSerial": "N° de série",
"LabelSeries": "Séries",
"LabelSeriesName": "Nom de la série",
"LabelSeriesProgress": "Progression de séries",
@@ -540,6 +563,9 @@
"LabelSettingsHideSingleBookSeriesHelp": "Les séries qui ne comportent quun seul livre seront masquées sur la page de la série et sur les étagères de la page daccueil.",
"LabelSettingsHomePageBookshelfView": "Utiliser la vue étagère sur la page daccueil",
"LabelSettingsLibraryBookshelfView": "Utiliser la vue étagère pour la bibliothèque",
"LabelSettingsLibraryMarkAsFinishedPercentComplete": "Le pourcentage d'achèvement est supérieur à",
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Le temps restant est inférieur à (secondes)",
"LabelSettingsLibraryMarkAsFinishedWhen": "Marquer lélément multimédia comme terminé lorsque",
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Sauter les livres précédents dans « Continuer la série »",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Létagère de la page daccueil « Continuer la série » affiche le premier livre non commencé dans les séries dont au moins un livre est terminé et aucun livre nest en cours. Lactivation de ce paramètre permet de poursuivre la série à partir du dernier livre terminé au lieu du premier livre non commencé.",
"LabelSettingsParseSubtitles": "Analyser les sous-titres",
@@ -604,6 +630,7 @@
"LabelTimeDurationXMinutes": "{0} minutes",
"LabelTimeDurationXSeconds": "{0} secondes",
"LabelTimeInMinutes": "Temps en minutes",
"LabelTimeLeft": "{0} restant",
"LabelTimeListened": "Temps découte",
"LabelTimeListenedToday": "Nombres découtes aujourdhui",
"LabelTimeRemaining": "{0} restantes",
@@ -624,6 +651,7 @@
"LabelTracksMultiTrack": "Piste multiple",
"LabelTracksNone": "Aucune piste",
"LabelTracksSingleTrack": "Piste simple",
"LabelTrailer": "Bande-annonce",
"LabelType": "Type",
"LabelUnabridged": "Version intégrale",
"LabelUndo": "Annuler",
@@ -635,11 +663,13 @@
"LabelUpdateDetailsHelp": "Autoriser la mise à jour des détails existants lorsquune correspondance est trouvée",
"LabelUpdatedAt": "Mis à jour à",
"LabelUploaderDragAndDrop": "Glisser et déposer des fichiers ou dossiers",
"LabelUploaderDragAndDropFilesOnly": "Glisser & déposer des fichiers",
"LabelUploaderDropFiles": "Déposer des fichiers",
"LabelUploaderItemFetchMetadataHelp": "Récupérer automatiquement le titre, lauteur et la série",
"LabelUseAdvancedOptions": "Utiliser les options avancées",
"LabelUseChapterTrack": "Utiliser la piste du chapitre",
"LabelUseFullTrack": "Utiliser la piste complète",
"LabelUseZeroForUnlimited": "0 pour illimité",
"LabelUser": "Utilisateur",
"LabelUsername": "Nom dutilisateur",
"LabelValue": "Valeur",
@@ -686,7 +716,7 @@
"MessageConfirmDeleteMetadataProvider": "Êtes-vous sûr·e de vouloir supprimer le fournisseur de métadonnées personnalisées « {0} » ?",
"MessageConfirmDeleteNotification": "Êtes-vous sûr·e de vouloir supprimer cette notification?",
"MessageConfirmDeleteSession": "Êtes-vous sûr·e de vouloir supprimer cette session?",
"MessageConfirmEmbedMetadataInAudioFiles": "Souhaitez-vous vraiment intégrer des métadonnées dans {0} fichiers audio?",
"MessageConfirmEmbedMetadataInAudioFiles": "Êtes-vous sûr·e de vouloir intégrer des métadonnées dans {0} fichiers audio?",
"MessageConfirmForceReScan": "Êtes-vous sûr·e de vouloir lancer une analyse forcée?",
"MessageConfirmMarkAllEpisodesFinished": "Êtes-vous sûr·e de marquer tous les épisodes comme terminés?",
"MessageConfirmMarkAllEpisodesNotFinished": "Êtes-vous sûr·e de vouloir marquer tous les épisodes comme non terminés?",
@@ -697,7 +727,8 @@
"MessageConfirmNotificationTestTrigger": "Déclencher cette notification avec des données de test?",
"MessageConfirmPurgeCache": "La purge du cache supprimera lintégralité du répertoire à <code>/metadata/cache</code>.<br /><br />Êtes-vous sûr·e de vouloir supprimer le répertoire de cache?",
"MessageConfirmPurgeItemsCache": "Purger le cache des éléments supprimera l'ensemble du répertoire <code>/metadata/cache/items</code>.<br />Êtes-vous sûr?",
"MessageConfirmQuickEmbed": "Attention ! L'intégration rapide ne permet pas de sauvegarder vos fichiers audio. Assurez-vous davoir effectuer une sauvegarde de vos fichiers audio.<br><br>Souhaitez-vous continuer ?",
"MessageConfirmQuickEmbed": "Attention! L'intégration rapide ne permet pas de sauvegarder vos fichiers audio. Assurez-vous davoir effectuer une sauvegarde de vos fichiers audio.<br><br>Êtes-vous sûr·e de vouloir continuer?",
"MessageConfirmQuickMatchEpisodes": "Les épisodes correspondants seront écrasés si une correspondance est trouvée. Seuls les épisodes non correspondants seront mis à jour. Êtes-vous sûr·e?",
"MessageConfirmReScanLibraryItems": "Êtes-vous sûr·e de vouloir réanalyser {0} éléments?",
"MessageConfirmRemoveAllChapters": "Êtes-vous sûr·e de vouloir supprimer tous les chapitres?",
"MessageConfirmRemoveAuthor": "Êtes-vous sûr·e de vouloir supprimer lauteur « {0} » ?",
@@ -705,6 +736,7 @@
"MessageConfirmRemoveEpisode": "Êtes-vous sûr·e de vouloir supprimer lépisode « {0} » ?",
"MessageConfirmRemoveEpisodes": "Êtes-vous sûr·e de vouloir supprimer {0} épisodes?",
"MessageConfirmRemoveListeningSessions": "Êtes-vous sûr·e de vouloir supprimer {0} sessions découte?",
"MessageConfirmRemoveMetadataFiles": "Êtes-vous sûr·e de vouloir supprimer tous les fichiers « metatadata.{0} » des dossiers déléments de votre bibliothèque?",
"MessageConfirmRemoveNarrator": "Êtes-vous sûr·e de vouloir supprimer le narrateur « {0} » ?",
"MessageConfirmRemovePlaylist": "Êtes-vous sûr·e de vouloir supprimer la liste de lecture « {0} » ?",
"MessageConfirmRenameGenre": "Êtes-vous sûr·e de vouloir renommer le genre « {0} » en « {1} » pour tous les éléments?",
@@ -785,6 +817,7 @@
"MessagePodcastSearchField": "Saisissez le terme de recherche ou l'URL du flux RSS",
"MessageQuickEmbedInProgress": "Intégration rapide en cours",
"MessageQuickEmbedQueue": "En file d'attente pour une intégration rapide ({0} dans la file d'attente)",
"MessageQuickMatchAllEpisodes": "Associer rapidement tous les épisodes",
"MessageQuickMatchDescription": "Renseigne les détails manquants ainsi que la couverture avec la première correspondance de « {0} ». Nécrase pas les données présentes à moins que le paramètre « Préférer les Métadonnées par correspondance » soit activé.",
"MessageRemoveChapter": "Supprimer le chapitre",
"MessageRemoveEpisodes": "Suppression de {0} épisode(s)",
@@ -837,10 +870,10 @@
"MessageTaskScanningFileChanges": "Analyse des modifications du fichier dans « {0} »",
"MessageTaskScanningLibrary": "Analyse de la bibliothèque « {0} »",
"MessageTaskTargetDirectoryNotWritable": "Le répertoire cible nest pas accessible en écriture",
"MessageThinking": "Je cherche…",
"MessageThinking": "À la recherche de…",
"MessageUploaderItemFailed": "Échec du téléversement",
"MessageUploaderItemSuccess": "Téléversement effectué !",
"MessageUploading": "Téléversement…",
"MessageUploading": "Téléchargement…",
"MessageValidCronExpression": "Expression cron valide",
"MessageWatcherIsDisabledGlobally": "La surveillance est désactivée par un paramètre global du serveur",
"MessageXLibraryIsEmpty": "La bibliothèque {0} est vide !",
@@ -883,6 +916,7 @@
"StatsYearInReview": "BILAN DE LANNÉE",
"ToastAccountUpdateSuccess": "Compte mis à jour",
"ToastAppriseUrlRequired": "Vous devez entrer une URL Apprise",
"ToastAsinRequired": "ASIN requis",
"ToastAuthorImageRemoveSuccess": "Image de lauteur supprimée",
"ToastAuthorNotFound": "Auteur \"{0}\" non trouvé",
"ToastAuthorRemoveSuccess": "Auteur supprimé",
@@ -902,6 +936,8 @@
"ToastBackupUploadSuccess": "Sauvegarde téléversée",
"ToastBatchDeleteFailed": "Échec de la suppression par lot",
"ToastBatchDeleteSuccess": "Suppression par lot réussie",
"ToastBatchQuickMatchFailed": "Échec de la correspondance rapide par lot!",
"ToastBatchQuickMatchStarted": "La correspondance rapide par lots de {0} livres a commencé!",
"ToastBatchUpdateFailed": "Échec de la mise à jour par lot",
"ToastBatchUpdateSuccess": "Mise à jour par lot terminée",
"ToastBookmarkCreateFailed": "Échec de la création de signet",
@@ -913,6 +949,7 @@
"ToastChaptersHaveErrors": "Les chapitres contiennent des erreurs",
"ToastChaptersMustHaveTitles": "Les chapitre doivent avoir un titre",
"ToastChaptersRemoved": "Chapitres supprimés",
"ToastChaptersUpdated": "Chapitres mis à jour",
"ToastCollectionItemsAddFailed": "Échec de lajout de(s) élément(s) à la collection",
"ToastCollectionItemsAddSuccess": "Ajout de(s) élément(s) à la collection réussi",
"ToastCollectionItemsRemoveSuccess": "Élément(s) supprimé(s) de la collection",
@@ -930,11 +967,14 @@
"ToastEncodeCancelSucces": "Encodage annulé",
"ToastEpisodeDownloadQueueClearFailed": "Échec de la suppression de la file d'attente",
"ToastEpisodeDownloadQueueClearSuccess": "File dattente de téléchargement des épisodes effacée",
"ToastEpisodeUpdateSuccess": "{0} épisodes mis à jour",
"ToastErrorCannotShare": "Impossible de partager nativement sur cet appareil",
"ToastFailedToLoadData": "Échec du chargement des données",
"ToastFailedToMatch": "Échec de la correspondance",
"ToastFailedToShare": "Échec du partage",
"ToastFailedToUpdate": "Échec de la mise à jour",
"ToastInvalidImageUrl": "URL de l'image invalide",
"ToastInvalidMaxEpisodesToDownload": "Nombre maximum dépisodes à télécharger non valide",
"ToastInvalidUrl": "URL invalide",
"ToastItemCoverUpdateSuccess": "Couverture mise à jour",
"ToastItemDeletedFailed": "La suppression de l'élément à échouée",
@@ -953,14 +993,21 @@
"ToastLibraryScanStarted": "Analyse de la bibliothèque démarrée",
"ToastLibraryUpdateSuccess": "Bibliothèque « {0} » mise à jour",
"ToastMatchAllAuthorsFailed": "Tous les auteurs et autrices nont pas pu être classés",
"ToastMetadataFilesRemovedError": "Erreur lors de la suppression des fichiers « metadata.{0} »",
"ToastMetadataFilesRemovedNoneFound": "Aucun fichier « metadata.{0} » trouvé dans la bibliothèque",
"ToastMetadataFilesRemovedNoneRemoved": "Aucun fichier « metadata.{0} » na été supprimé",
"ToastMetadataFilesRemovedSuccess": "{0} fichiers metadata.{1} supprimés",
"ToastMustHaveAtLeastOnePath": "Doit avoir au moins un chemin",
"ToastNameEmailRequired": "Le nom et le courriel sont requis",
"ToastNameRequired": "Le nom est requis",
"ToastNewEpisodesFound": "{0} nouveaux épisodes trouvés",
"ToastNewUserCreatedFailed": "La création du compte à échouée: « {0} »",
"ToastNewUserCreatedSuccess": "Nouveau compte créé",
"ToastNewUserLibraryError": "Au moins une bibliothèque est requise",
"ToastNewUserPasswordError": "Un mot de passe est requis, seul lutilisateur root peut avoir un mot de passe vide",
"ToastNewUserTagError": "Au moins une étiquette est requise",
"ToastNewUserUsernameError": "Entrez un nom dutilisateur",
"ToastNoNewEpisodesFound": "Aucun nouvel épisode trouvé",
"ToastNoUpdatesNecessary": "Aucune mise à jour nécessaire",
"ToastNotificationCreateFailed": "La création de la notification à échouée",
"ToastNotificationDeleteFailed": "La suppression de la notification à échouée",
@@ -979,6 +1026,7 @@
"ToastPodcastGetFeedFailed": "Échec de la récupération du flux du podcast",
"ToastPodcastNoEpisodesInFeed": "Aucun épisode trouvé dans le flux RSS",
"ToastPodcastNoRssFeed": "Le podcast na pas de flux RSS",
"ToastProgressIsNotBeingSynced": "La progression nest pas synchronisée, redémarrez la lecture",
"ToastProviderCreatedFailed": "Échec de lajout du fournisseur",
"ToastProviderCreatedSuccess": "Nouveau fournisseur ajouté",
"ToastProviderNameAndUrlRequired": "Nom et URL requis",
@@ -1005,6 +1053,7 @@
"ToastSessionCloseFailed": "Échec de la fermeture de la session",
"ToastSessionDeleteFailed": "Échec de la suppression de session",
"ToastSessionDeleteSuccess": "Session supprimée",
"ToastSleepTimerDone": "Minuterie de mise en veille terminée… zZzzZz",
"ToastSlugMustChange": "Lidentifiant dURL contient des caractères invalides",
"ToastSlugRequired": "Lidentifiant dURL est requis",
"ToastSocketConnected": "WebSocket connecté",

View File

@@ -8,17 +8,18 @@
"ButtonAddYourFirstLibrary": "הוסף את הספרייה הראשונה שלך",
"ButtonApply": "החל",
"ButtonApplyChapters": "החל פרקים",
"ButtonAuthors": "יוצרים",
"ButtonAuthors": "סופרים",
"ButtonBack": "חזור",
"ButtonBrowseForFolder": "עיין בתיקייה",
"ButtonCancel": טל",
"ButtonCancel": יטול",
"ButtonCancelEncode": "בטל קידוד",
"ButtonChangeRootPassword": "שנה סיסמת root",
"ButtonCheckAndDownloadNewEpisodes": "בדוק והורד פרקים חדשים",
"ButtonChooseAFolder": "בחר תיקייה",
"ButtonChooseFiles": "בחר קבצים",
"ButtonClearFilter": "נקה סינון",
"ButtonCloseFeed": "סגור פיד",
"ButtonCloseFeed": "סגור ערוץ",
"ButtonCloseSession": "סגור סשן פתוח",
"ButtonCollections": "אוספים",
"ButtonConfigureScanner": "הגדר סורק",
"ButtonCreate": "צור",
@@ -28,6 +29,7 @@
"ButtonEdit": "ערוך",
"ButtonEditChapters": "ערוך פרקים",
"ButtonEditPodcast": "ערוך פודקאסט",
"ButtonEnable": "הפעל",
"ButtonForceReScan": "סרוק מחדש בכוח",
"ButtonFullPath": "נתיב מלא",
"ButtonHide": "הסתר",
@@ -46,19 +48,24 @@
"ButtonNevermind": "לא משנה",
"ButtonNext": "הבא",
"ButtonNextChapter": "פרק הבא",
"ButtonNextItemInQueue": "פריט הבא בתור",
"ButtonOk": "אישור",
"ButtonOpenFeed": "פתח פיד",
"ButtonOpenManager": "פתח מנהל",
"ButtonPause": "השהה",
"ButtonPlay": "נגן",
"ButtonPlayAll": "נגן הכל",
"ButtonPlaying": "מנגן",
"ButtonPlaylists": "רשימות השמעה",
"ButtonPrevious": "קודם",
"ButtonPreviousChapter": "פרק קודם",
"ButtonProbeAudioFile": "בדוק קובץ אודיו",
"ButtonPurgeAllCache": "נקה את כל המטמון",
"ButtonPurgeItemsCache": "נקה את מטמון הפריטים",
"ButtonQueueAddItem": "הוסף לתור",
"ButtonQueueRemoveItem": "הסר מהתור",
"ButtonQuickEmbed": "הטמעה מהירה",
"ButtonQuickEmbedMetadata": "הטמעת מטא נתונים מהירה",
"ButtonQuickMatch": "התאמה מהירה",
"ButtonReScan": "סרוק מחדש",
"ButtonRead": "קרא",
@@ -88,8 +95,10 @@
"ButtonShow": "הצג",
"ButtonStartM4BEncode": "התחל קידוד M4B",
"ButtonStartMetadataEmbed": "התחל הטמעת מטא-נתונים",
"ButtonStats": "סטטיסטיקות",
"ButtonSubmit": "שלח",
"ButtonTest": "בדיקה",
"ButtonUnlinkOpenId": "נתק OpenID",
"ButtonUpload": "העלה",
"ButtonUploadBackup": "העלה גיבוי",
"ButtonUploadCover": "העלה כריכה",
@@ -102,6 +111,7 @@
"ErrorUploadFetchMetadataNoResults": "לא ניתן לשלוף מטא-נתונים - נסה לעדכן כותרת ו/או יוצר",
"ErrorUploadLacksTitle": "חובה לתת כותרת",
"HeaderAccount": "חשבון",
"HeaderAddCustomMetadataProvider": "הוסף ספק מטא-נתונים מותאם אישית",
"HeaderAdvanced": "מתקדם",
"HeaderAppriseNotificationSettings": "הגדרות התראות של Apprise",
"HeaderAudioTracks": "רצועות קול",
@@ -147,13 +157,17 @@
"HeaderMetadataToEmbed": "מטא-נתונים להטמעה",
"HeaderNewAccount": "חשבון חדש",
"HeaderNewLibrary": "ספרייה חדשה",
"HeaderNotificationCreate": "צור התראה",
"HeaderNotificationUpdate": "עדכון התראה",
"HeaderNotifications": "התראות",
"HeaderOpenIDConnectAuthentication": "אימות OpenID Connect",
"HeaderOpenListeningSessions": "פתח הפעלות האזנה",
"HeaderOpenRSSFeed": "פתח ערוץ RSS",
"HeaderOtherFiles": "קבצים אחרים",
"HeaderPasswordAuthentication": "אימות סיסמה",
"HeaderPermissions": "הרשאות",
"HeaderPlayerQueue": "תור ניגון",
"HeaderPlayerSettings": "הגדרות נגן",
"HeaderPlaylist": "רשימת השמעה",
"HeaderPlaylistItems": "פריטי רשימת השמעה",
"HeaderPodcastsToAdd": "פודקאסטים להוספה",
@@ -165,6 +179,7 @@
"HeaderRemoveEpisodes": "הסר {0} פרקים",
"HeaderSavedMediaProgress": "התקדמות מדיה שמורה",
"HeaderSchedule": "תיזמון",
"HeaderScheduleEpisodeDownloads": "תזמן הורדת פרקים אוטומטית",
"HeaderScheduleLibraryScans": "קבע סריקות ספרייה אוטומטיות",
"HeaderSession": "הפעלה",
"HeaderSetBackupSchedule": "קבע לוח זמנים לגיבוי",
@@ -190,6 +205,9 @@
"HeaderYearReview": "שנת {0} בסקירה",
"HeaderYourStats": "הסטטיסטיקות שלך",
"LabelAbridged": "מקוצר",
"LabelAbridgedChecked": "מקוצר (מסומן)",
"LabelAbridgedUnchecked": "בלתי מקוצר (לא מסומן)",
"LabelAccessibleBy": "נגיש על ידי",
"LabelAccountType": "סוג חשבון",
"LabelAccountTypeAdmin": "מנהל",
"LabelAccountTypeGuest": "אורח",
@@ -200,13 +218,18 @@
"LabelAddToPlaylist": "הוסף לרשימת השמעה",
"LabelAddToPlaylistBatch": "הוסף {0} פריטים לרשימת השמעה",
"LabelAddedAt": "נוסף בתאריך",
"LabelAddedDate": "נוסף ב-{0}",
"LabelAdminUsersOnly": "רק מנהלים",
"LabelAll": "הכל",
"LabelAllUsers": "כל המשתמשים",
"LabelAllUsersExcludingGuests": "כל המשתמשים, ללא אורחים",
"LabelAllUsersIncludingGuests": "כל המשתמשים כולל אורחים",
"LabelAlreadyInYourLibrary": "כבר קיים בספרייה שלך",
"LabelApiToken": "טוקן API",
"LabelAppend": "הוסף לסוף",
"LabelAudioBitrate": "קצב סיביות (לדוגמא 128k)",
"LabelAudioChannels": "ערוצי קול (1 או 2)",
"LabelAudioCodec": "קידוד קול",
"LabelAuthor": "יוצר",
"LabelAuthorFirstLast": "יוצר (שם פרטי שם משפחה)",
"LabelAuthorLastFirst": "יוצר (שם משפחה, שם פרטי)",

View File

@@ -271,7 +271,7 @@
"LabelCollapseSubSeries": "Podserijale prikaži sažeto",
"LabelCollection": "Zbirka",
"LabelCollections": "Zbirke",
"LabelComplete": "Dovršeno",
"LabelComplete": "Potpuno",
"LabelConfirmPassword": "Potvrda zaporke",
"LabelContinueListening": "Nastavi slušati",
"LabelContinueReading": "Nastavi čitati",
@@ -472,6 +472,7 @@
"LabelPermissionsAccessAllLibraries": "Ima pristup svim knjižnicama",
"LabelPermissionsAccessAllTags": "Ima pristup svim oznakama",
"LabelPermissionsAccessExplicitContent": "Ima pristup eksplicitnom sadržaju",
"LabelPermissionsCreateEreader": "Može stvoriti e-čitač",
"LabelPermissionsDelete": "Smije brisati",
"LabelPermissionsDownload": "Smije preuzimati",
"LabelPermissionsUpdate": "Smije ažurirati",
@@ -531,7 +532,7 @@
"LabelSelectAllEpisodes": "Označi sve nastavke",
"LabelSelectEpisodesShowing": "Prikazujem {0} odabranih nastavaka",
"LabelSelectUsers": "Označi korisnike",
"LabelSendEbookToDevice": "Pošalji e-knjigu",
"LabelSendEbookToDevice": "Pošalji e-knjigu",
"LabelSequence": "Slijed",
"LabelSerial": "Serijal",
"LabelSeries": "Serijal",
@@ -562,8 +563,11 @@
"LabelSettingsHideSingleBookSeriesHelp": "Serijali koji se sastoje od samo jedne knjige neće se prikazivati na stranici serijala i na policama početne stranice.",
"LabelSettingsHomePageBookshelfView": "Prikaži početnu stranicu kao policu s knjigama",
"LabelSettingsLibraryBookshelfView": "Prikaži knjižnicu kao policu s knjigama",
"LabelSettingsLibraryMarkAsFinishedPercentComplete": "Postotak dovršenosti veći od",
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Preostalo vrijeme je manje od (sekundi)",
"LabelSettingsLibraryMarkAsFinishedWhen": "Označi medij dovršenim kada",
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Preskoči ranije knjige u funkciji Nastavi serijal",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Na polici početne stranice Nastavi serijal prikazuje se prva nezapočeta knjiga serijala koji imaju barem jednu dovršenu knjigu i nijednu započetu knjigu. Ako uključite ovu opciju, serijal će vam se nastaviti od zadnje dovršene knjige umjesto od prve nezapočete knjige.",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Na polici početne stranice Nastavi serijal prikazuje se prva nezapočeta knjiga serijala koji imaju barem jednu dovršenu knjigu i nijednu započetu knjigu. Ako se ova opcija uključi serijal će nastaviti od zadnje dovršene knjige umjesto od prve nezapočete knjige.",
"LabelSettingsParseSubtitles": "Raščlani podnaslove",
"LabelSettingsParseSubtitlesHelp": "Iz naziva mape zvučne knjige raščlanjuje podnaslov.<br>Podnaslov mora biti odvojen s \" - \"<br>npr. \"Naslov knjige - Ovo je podnaslov\" imat će podnaslov \"Ovo je podnaslov\"",
"LabelSettingsPreferMatchedMetadata": "Daj prednost meta-podatcima prepoznatih stavki",
@@ -659,6 +663,7 @@
"LabelUpdateDetailsHelp": "Dopusti prepisivanje postojećih podataka za odabrane knjige kada se prepoznaju",
"LabelUpdatedAt": "Ažurirano",
"LabelUploaderDragAndDrop": "Pritisni i prevuci datoteke ili mape",
"LabelUploaderDragAndDropFilesOnly": "Pritisni i prevuci datoteke",
"LabelUploaderDropFiles": "Ispusti datoteke",
"LabelUploaderItemFetchMetadataHelp": "Automatski dohvati naslov, autora i serijal",
"LabelUseAdvancedOptions": "Koristi se naprednim opcijama",
@@ -674,6 +679,8 @@
"LabelViewPlayerSettings": "Pogledaj postavke reproduktora",
"LabelViewQueue": "Pogledaj redoslijed izvođenja reproduktora",
"LabelVolume": "Glasnoća",
"LabelWebRedirectURLsDescription": "Autoriziraj ove URL-ove u svom pružatelju OAuth ovjere kako bi omogućio preusmjeravanje natrag na web-aplikaciju nakon prijave:",
"LabelWebRedirectURLsSubfolder": "Podmapa za URL-ove preusmjeravanja",
"LabelWeekdaysToRun": "Dani u tjednu za pokretanje",
"LabelXBooks": "{0} knjiga",
"LabelXItems": "{0} stavki",
@@ -809,7 +816,7 @@
"MessagePlaylistCreateFromCollection": "Stvori popis za izvođenje od zbirke",
"MessagePleaseWait": "Molimo pričekajte...",
"MessagePodcastHasNoRSSFeedForMatching": "Podcast nema adresu RSS izvora za prepoznavanje",
"MessagePodcastSearchField": "Unesite upit za pretragu ili URL RSS izvora",
"MessagePodcastSearchField": "Upišite izraz za pretraživanje ili URL RSS izvora",
"MessageQuickEmbedInProgress": "Brzo ugrađivanje u tijeku",
"MessageQuickEmbedQueue": "Dodano u red za brzo ugrađivanje ({0} u redu izvođenja)",
"MessageQuickMatchAllEpisodes": "Brzo prepoznavanje svih nastavaka",

View File

@@ -66,6 +66,7 @@
"ButtonPurgeItemsCache": "Elemek gyorsítótárának törlése",
"ButtonQueueAddItem": "Hozzáadás a sorhoz",
"ButtonQueueRemoveItem": "Eltávolítás a sorból",
"ButtonQuickEmbed": "Gyors beágyazás",
"ButtonQuickEmbedMetadata": "Metaadat gyors beágyazása",
"ButtonQuickMatch": "Gyors egyeztetés",
"ButtonReScan": "Újraszkennelés",
@@ -343,7 +344,7 @@
"LabelHasSupplementaryEbook": "Van kiegészítő e-könyve",
"LabelHideSubtitles": "Alcím elrejtése",
"LabelHighestPriority": "Legmagasabb prioritás",
"LabelHost": "Házigazda",
"LabelHost": "Kiszolgáló",
"LabelHour": "Óra",
"LabelHours": "Órák",
"LabelIcon": "Ikon",

View File

@@ -66,13 +66,13 @@
"ButtonPurgeItemsCache": "Elimina la Cache selezionata",
"ButtonQueueAddItem": "Aggiungi alla Coda",
"ButtonQueueRemoveItem": "Rimuovi dalla Coda",
"ButtonQuickEmbed": "Quick Embed",
"ButtonQuickEmbed": "Incorporazione Rapida",
"ButtonQuickEmbedMetadata": "Incorporamento rapido Metadati",
"ButtonQuickMatch": "Controlla Metadata Auto",
"ButtonReScan": "Ri-scansiona",
"ButtonRead": "Leggi",
"ButtonReadLess": "Leggi di Meno",
"ButtonReadMore": "Leggi di Più",
"ButtonReadLess": "Riduci",
"ButtonReadMore": "Espandi",
"ButtonRefresh": "Aggiorna",
"ButtonRemove": "Rimuovi",
"ButtonRemoveAll": "Rimuovi Tutto",
@@ -163,6 +163,7 @@
"HeaderNotificationUpdate": "Aggiornamento della notifica",
"HeaderNotifications": "Notifiche",
"HeaderOpenIDConnectAuthentication": "Autenticazione OpenID Connect",
"HeaderOpenListeningSessions": "Apri sessioni di ascolto",
"HeaderOpenRSSFeed": "Apri il flusso RSS",
"HeaderOtherFiles": "Altri File",
"HeaderPasswordAuthentication": "Autenticazione della password",
@@ -180,6 +181,7 @@
"HeaderRemoveEpisodes": "Rimuovi {0} Episodi",
"HeaderSavedMediaProgress": "Progressi salvati",
"HeaderSchedule": "Schedula",
"HeaderScheduleEpisodeDownloads": "Imposta il download automatico degli episodi",
"HeaderScheduleLibraryScans": "Schedula la scansione della libreria",
"HeaderSession": "Sessione",
"HeaderSetBackupSchedule": "Imposta programmazione Backup",
@@ -218,13 +220,14 @@
"LabelAddToPlaylist": "Aggiungi alla playlist",
"LabelAddToPlaylistBatch": "Aggiungi {0} file alla Playlist",
"LabelAddedAt": "Aggiunto il",
"LabelAddedDate": "{0} aggiunti",
"LabelAddedDate": "Aggiunti {0}",
"LabelAdminUsersOnly": "Solo utenti Amministratori",
"LabelAll": "Tutti",
"LabelAllUsers": "Tutti gli Utenti",
"LabelAllUsersExcludingGuests": "Tutti gli Utenti Esclusi gli ospiti",
"LabelAllUsersIncludingGuests": "Tutti gli Utenti Inclusi gli ospiti",
"LabelAlreadyInYourLibrary": "Già esistente nella libreria",
"LabelApiToken": "API Token",
"LabelAppend": "Appese",
"LabelAudioBitrate": "Audio Bitrate (es. 128k)",
"LabelAudioChannels": "Canali Audio (1 o 2)",
@@ -250,15 +253,18 @@
"LabelBackupsNumberToKeep": "Numero di backup da mantenere",
"LabelBackupsNumberToKeepHelp": "Verrà rimosso solo 1 backup alla volta, quindi se hai più backup, dovrai rimuoverli manualmente.",
"LabelBitrate": "Velocità di trasmissione",
"LabelBonus": "Bonus",
"LabelBooks": "Libri",
"LabelButtonText": "Buttone Testo",
"LabelByAuthor": "da {0}",
"LabelChangePassword": "Cambia Password",
"LabelChannels": "Canali",
"LabelChapterCount": "{0} Capitoli",
"LabelChapterTitle": "Titoli dei Capitoli",
"LabelChapters": "Capitoli",
"LabelChaptersFound": "Capitoli Trovati",
"LabelClickForMoreInfo": "Click per altre Info",
"LabelClickToUseCurrentValue": "Clicca per usare il valore corrente",
"LabelClosePlayer": "Chiudi player",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Comprimi Serie",
@@ -320,9 +326,13 @@
"LabelEnd": "Fine",
"LabelEndOfChapter": "Fine Capitolo",
"LabelEpisode": "Episodio",
"LabelEpisodeNotLinkedToRssFeed": "Episode non linkati nel RSS feed",
"LabelEpisodeNumber": "Episodio #{0}",
"LabelEpisodeTitle": "Titolo Episodio",
"LabelEpisodeType": "Tipo Episodio",
"LabelEpisodeUrlFromRssFeed": "URL dell'episodio dal RSS feed",
"LabelEpisodes": "Episodi",
"LabelEpisodic": "Episodico",
"LabelExample": "Esempio",
"LabelExpandSeries": "Espandi Serie",
"LabelExpandSubSeries": "Espandi Sub Serie",
@@ -350,6 +360,7 @@
"LabelFontScale": "Dimensione font",
"LabelFontStrikethrough": "Barrato",
"LabelFormat": "Formato",
"LabelFull": "Pieno",
"LabelGenre": "Genere",
"LabelGenres": "Generi",
"LabelHardDeleteFile": "Elimina Definitivamente",
@@ -405,6 +416,10 @@
"LabelLowestPriority": "Priorità Minima",
"LabelMatchExistingUsersBy": "Abbina gli utenti esistenti per",
"LabelMatchExistingUsersByDescription": "Utilizzato per connettere gli utenti esistenti. Una volta connessi, gli utenti verranno abbinati a un ID univoco dal tuo provider SSO",
"LabelMaxEpisodesToDownload": "Max # di episodi da scaricare. Usa 0 per illimitati.",
"LabelMaxEpisodesToDownloadPerCheck": "Massimo # di nuovi episodi da scaricare per il controllo",
"LabelMaxEpisodesToKeep": "Massimo # di episodi da tenere",
"LabelMaxEpisodesToKeepHelp": "Il valore 0 non imposta alcun limite massimo. Dopo che un nuovo episodio è stato scaricato automaticamente, questo eliminerà l'episodio più vecchio se hai più di X episodi. Questo eliminerà solo 1 episodio per ogni nuovo download.",
"LabelMediaPlayer": "Media Player",
"LabelMediaType": "Tipo Media",
"LabelMetaTag": "Meta Tag",
@@ -450,12 +465,14 @@
"LabelOpenIDGroupClaimDescription": "Nome dell'attestazione OpenID che contiene un elenco dei gruppi dell'utente. Comunemente indicato come <code>gruppo</code>. <b>se configurato</b>, l'applicazione assegnerà automaticamente i ruoli in base alle appartenenze ai gruppi dell'utente, a condizione che tali gruppi siano denominati \"admin\", \"utente\" o \"ospite\" senza distinzione tra maiuscole e minuscole nell'attestazione. L'attestazione deve contenere un elenco e, se un utente appartiene a più gruppi, l'applicazione assegnerà il ruolo corrispondente al livello di accesso più alto. Se nessun gruppo corrisponde, l'accesso verrà negato.",
"LabelOpenRSSFeed": "Apri RSS Feed",
"LabelOverwrite": "Sovrascrivi",
"LabelPaginationPageXOfY": "Pagina {0} di {1}",
"LabelPassword": "Password",
"LabelPath": "Percorso",
"LabelPermanent": "Permanente",
"LabelPermissionsAccessAllLibraries": "Può accedere a tutte le librerie",
"LabelPermissionsAccessAllTags": "Può accedere a tutti i tag",
"LabelPermissionsAccessExplicitContent": "Può accedere a contenuti espliciti",
"LabelPermissionsCreateEreader": "Può creare un e-reader",
"LabelPermissionsDelete": "Può Cancellare",
"LabelPermissionsDownload": "Può Scaricare",
"LabelPermissionsUpdate": "Può Aggiornare",
@@ -478,7 +495,7 @@
"LabelProviderAuthorizationValue": "Authorization Header Value",
"LabelPubDate": "Data di pubblicazione",
"LabelPublishYear": "Anno di pubblicazione",
"LabelPublishedDate": "{0} pubblicati",
"LabelPublishedDate": "Pubblicati {0}",
"LabelPublishedDecade": "Decennio di pubblicazione",
"LabelPublishedDecades": "Decenni di pubblicazione",
"LabelPublisher": "Editore",
@@ -500,18 +517,24 @@
"LabelRedo": "Rifai",
"LabelRegion": "Regione",
"LabelReleaseDate": "Data Release",
"LabelRemoveAllMetadataAbs": "Remuovi tutti i metadata.abs files",
"LabelRemoveAllMetadataJson": "Rimuovi tutti i metadata.json files",
"LabelRemoveCover": "Rimuovi cover",
"LabelRemoveMetadataFile": "Rimuovi i file metadata nella cartella della libreria",
"LabelRemoveMetadataFileHelp": "Rimuovi tutti i file metadata.json e i file metadata.abs nelle tue {0} cartelle.",
"LabelRowsPerPage": "Righe per pagina",
"LabelSearchTerm": "Ricerca",
"LabelSearchTitle": "Cerca Titolo",
"LabelSearchTitleOrASIN": "Cerca titolo o ASIN",
"LabelSeason": "Stagione",
"LabelSeasonNumber": "Stagione #{0}",
"LabelSelectAll": "Seleziona tutto",
"LabelSelectAllEpisodes": "Seleziona tutti gli Episodi",
"LabelSelectEpisodesShowing": "Selezionati {0} episodi da visualizzare",
"LabelSelectUsers": "Selezione Utenti",
"LabelSendEbookToDevice": "Invia il libro a...",
"LabelSequence": "Sequenza",
"LabelSerial": "Seriale",
"LabelSeries": "Serie",
"LabelSeriesName": "Nome Serie",
"LabelSeriesProgress": "Cominciato",
@@ -540,6 +563,9 @@
"LabelSettingsHideSingleBookSeriesHelp": "Le serie che hanno un solo libro saranno nascoste dalla pagina della serie e dagli scaffali della home page.",
"LabelSettingsHomePageBookshelfView": "Home page con sfondo legno",
"LabelSettingsLibraryBookshelfView": "Libreria con sfondo legno",
"LabelSettingsLibraryMarkAsFinishedPercentComplete": "La percentuale di completamento è maggiore di",
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Il tempo rimanente è inferiore a (secondi)",
"LabelSettingsLibraryMarkAsFinishedWhen": "Contrassegna l'elemento multimediale come terminato quando",
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Salta i libri precedenti nella serie Continua",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Lo scaffale della home page Continua serie mostra il primo libro non iniziato della serie che ha almeno un libro finito e nessun libro in corso. Abilitando questa impostazione le serie continueranno dal libro completato più lontano invece che dal primo libro non iniziato.",
"LabelSettingsParseSubtitles": "Analizza sottotitoli",
@@ -604,6 +630,7 @@
"LabelTimeDurationXMinutes": "{0} minuti",
"LabelTimeDurationXSeconds": "{0} secondi",
"LabelTimeInMinutes": "Tempo in minuti",
"LabelTimeLeft": "{0} sinistra",
"LabelTimeListened": "Tempo di Ascolto",
"LabelTimeListenedToday": "Tempo di Ascolto Oggi",
"LabelTimeRemaining": "{0} rimanente",
@@ -624,6 +651,7 @@
"LabelTracksMultiTrack": "Multi-traccia",
"LabelTracksNone": "Nessuna traccia",
"LabelTracksSingleTrack": "Traccia-singola",
"LabelTrailer": "Trailer",
"LabelType": "Tipo",
"LabelUnabridged": "Integrale",
"LabelUndo": "Annulla",
@@ -640,6 +668,7 @@
"LabelUseAdvancedOptions": "Usa le opzioni avanzate",
"LabelUseChapterTrack": "Usa il Capitolo della Traccia",
"LabelUseFullTrack": "Usa la traccia totale",
"LabelUseZeroForUnlimited": "Usa 0 per illimitato",
"LabelUser": "Utente",
"LabelUsername": "Nome utente",
"LabelValue": "Valore",
@@ -653,7 +682,7 @@
"LabelXBooks": "{0} libri",
"LabelXItems": "{0} oggetti",
"LabelYearReviewHide": "Nascondi Anno in rassegna",
"LabelYearReviewShow": "Vedi Anno in rassegna",
"LabelYearReviewShow": "Mostra Anno in rassegna",
"LabelYourAudiobookDuration": "La durata dell'audiolibro",
"LabelYourBookmarks": "I tuoi preferiti",
"LabelYourPlaylists": "le tue Playlist",
@@ -698,6 +727,7 @@
"MessageConfirmPurgeCache": "L'eliminazione della cache eliminerà l'intera directory dei <code>/metadata/cache</code>. <br /><br />Sei sicuro di voler rimuovere la directory della cache?",
"MessageConfirmPurgeItemsCache": "L'eliminazione della cache degli elementi eliminerà l'intera directory <code>/metadata/cache/oggetti</code>.<br />Sei sicuro?",
"MessageConfirmQuickEmbed": "Attenzione! L'incorporamento rapido non eseguirà il backup dei file audio. Assicurati di avere un backup dei tuoi file audio. <br><br>Vuoi Continuare?",
"MessageConfirmQuickMatchEpisodes": "Gli episodi di corrispondenza rapida sovrascriveranno i dettagli se viene trovata una corrispondenza. Saranno aggiornati solo gli episodi non corrispondenti. Sei sicuro?",
"MessageConfirmReScanLibraryItems": "Sei sicuro di voler ripetere la scansione? {0} oggetti?",
"MessageConfirmRemoveAllChapters": "Sei sicuro di voler rimuovere tutti i capitoli?",
"MessageConfirmRemoveAuthor": "Sei sicuro di voler rimuovere l'autore? \"{0}\"?",
@@ -705,6 +735,7 @@
"MessageConfirmRemoveEpisode": "Sei sicuro di voler rimuovere l'episodio \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Sei sicuro di voler rimuovere {0} episodi?",
"MessageConfirmRemoveListeningSessions": "Sei sicuro di voler rimuovere {0} sessioni di Ascolto?",
"MessageConfirmRemoveMetadataFiles": "Vuoi davvero rimuovere tutti i metadati.{0} file nelle cartelle degli elementi della tua libreria?",
"MessageConfirmRemoveNarrator": "Sei sicuro di voler rimuovere il narratore \"{0}\"?",
"MessageConfirmRemovePlaylist": "Sei sicuro di voler rimuovere la tua playlist \"{0}\"?",
"MessageConfirmRenameGenre": "Sei sicuro di voler rinominare il genere \"{0}\" in \"{1}\" per tutti gli oggetti?",
@@ -748,7 +779,7 @@
"MessageNoBackups": "Nessun Backup",
"MessageNoBookmarks": "Nessun preferito",
"MessageNoChapters": "Nessun capitolo",
"MessageNoCollections": "Nessuna Raccolta",
"MessageNoCollections": "Nessuna Collezione",
"MessageNoCoversFound": "Nessuna Cover Trovata",
"MessageNoDescription": "Nessuna descrizione",
"MessageNoDevices": "nessun dispositivo",
@@ -785,6 +816,7 @@
"MessagePodcastSearchField": "Inserisci il termine di ricerca o l'URL del feed RSS",
"MessageQuickEmbedInProgress": "Incorporamento rapido in corso",
"MessageQuickEmbedQueue": "In coda per incorporamento rapido ({0} in coda)",
"MessageQuickMatchAllEpisodes": "Associamento veloce di Tutti gli episodi",
"MessageQuickMatchDescription": "Compila i dettagli dell'articolo vuoto e copri con il risultato della prima corrispondenza di '{0}'. Non sovrascrive i dettagli a meno che non sia abilitata l'impostazione del server \"Preferisci metadati corrispondenti\".",
"MessageRemoveChapter": "Rimuovi Capitolo",
"MessageRemoveEpisodes": "rimuovi {0} episodio(i)",
@@ -883,6 +915,7 @@
"StatsYearInReview": "ANNO IN RASSEGNA",
"ToastAccountUpdateSuccess": "Account Aggiornato",
"ToastAppriseUrlRequired": "È necessario immettere un indirizzo Apprise",
"ToastAsinRequired": "L'ASIN è obbligatorio",
"ToastAuthorImageRemoveSuccess": "Immagine Autore Rimossa",
"ToastAuthorNotFound": "Autore\"{0}\" non trovato",
"ToastAuthorRemoveSuccess": "Autore rimosso",
@@ -902,6 +935,8 @@
"ToastBackupUploadSuccess": "Backup caricato",
"ToastBatchDeleteFailed": "Eliminazione batch non riuscita",
"ToastBatchDeleteSuccess": "Eliminazione batch riuscita",
"ToastBatchQuickMatchFailed": "Batch Quick Match non riuscito!",
"ToastBatchQuickMatchStarted": "Avviata la ricerca rapida in batch di {0} libri!",
"ToastBatchUpdateFailed": "Batch di aggiornamento fallito",
"ToastBatchUpdateSuccess": "Batch di aggiornamento finito",
"ToastBookmarkCreateFailed": "Creazione segnalibro fallita",
@@ -913,6 +948,7 @@
"ToastChaptersHaveErrors": "I capitoli contengono errori",
"ToastChaptersMustHaveTitles": "I capitoli devono avere titoli",
"ToastChaptersRemoved": "Capitoli rimossi",
"ToastChaptersUpdated": "Capitoli aggiornati",
"ToastCollectionItemsAddFailed": "l'aggiunta dell'elemento(i) alla raccolta non è riuscito",
"ToastCollectionItemsAddSuccess": "L'aggiunta dell'elemento(i) alla raccolta è riuscito",
"ToastCollectionItemsRemoveSuccess": "Oggetto(i) rimossi dalla Raccolta",
@@ -930,11 +966,14 @@
"ToastEncodeCancelSucces": "Codifica annullata",
"ToastEpisodeDownloadQueueClearFailed": "Impossibile cancellare la coda",
"ToastEpisodeDownloadQueueClearSuccess": "Coda di download degli episodi cancellata",
"ToastEpisodeUpdateSuccess": "{0} episodi aggiornati",
"ToastErrorCannotShare": "Impossibile condividere in modo nativo su questo dispositivo",
"ToastFailedToLoadData": "Impossibile caricare i dati",
"ToastFailedToMatch": "Impossibile abbinare",
"ToastFailedToShare": "Impossibile condividere",
"ToastFailedToUpdate": "Non aggiornato",
"ToastInvalidImageUrl": "URL dell'immagine non valido",
"ToastInvalidMaxEpisodesToDownload": "Numero massimo di episodi non valido da scaricare",
"ToastInvalidUrl": "URL non valido",
"ToastItemCoverUpdateSuccess": "Cover aggiornata",
"ToastItemDeletedFailed": "Impossibile eliminare l'elemento",
@@ -953,14 +992,21 @@
"ToastLibraryScanStarted": "Scansione Libreria iniziata",
"ToastLibraryUpdateSuccess": "Libreria \"{0}\" aggiornata",
"ToastMatchAllAuthorsFailed": "Tutti gli autori non sono potuti essere classificati",
"ToastMetadataFilesRemovedError": "Errore durante la rimozione dei metadati. {0} file",
"ToastMetadataFilesRemovedNoneFound": "Nessun metadato. {0} file trovati nella libreria",
"ToastMetadataFilesRemovedNoneRemoved": "Nessun metadato. {0} file rimossi",
"ToastMetadataFilesRemovedSuccess": "{0} metadati.{1} file rimossi",
"ToastMustHaveAtLeastOnePath": "Deve avere almeno un percorso",
"ToastNameEmailRequired": "Nome ed email sono obbligatori",
"ToastNameRequired": "Il nome è obbligatorio",
"ToastNewEpisodesFound": "{0} nuovi episodi trovati",
"ToastNewUserCreatedFailed": "Impossibile creare l'account: \"{0}\"",
"ToastNewUserCreatedSuccess": "Nuovo account creato",
"ToastNewUserLibraryError": "È necessario selezionare almeno una libreria",
"ToastNewUserPasswordError": "Deve avere una password, solo l'utente root può avere una password vuota",
"ToastNewUserTagError": "Devi selezionare almeno un tag",
"ToastNewUserUsernameError": "Inserisci un nome utente",
"ToastNoNewEpisodesFound": "Nessun nuovo episodio trovato",
"ToastNoUpdatesNecessary": "Nessun aggiornamento necessario",
"ToastNotificationCreateFailed": "Impossibile creare la notifica",
"ToastNotificationDeleteFailed": "Impossibile eliminare la notifica",
@@ -979,6 +1025,7 @@
"ToastPodcastGetFeedFailed": "Impossibile ottenere il feed del podcast",
"ToastPodcastNoEpisodesInFeed": "Nessun episodio trovato nel feed RSS",
"ToastPodcastNoRssFeed": "Il podcast non ha un feed RSS",
"ToastProgressIsNotBeingSynced": "L'avanzamento non è sincronizzato, riavviare la riproduzione",
"ToastProviderCreatedFailed": "Impossibile aggiungere il provider",
"ToastProviderCreatedSuccess": "Aggiunto nuovo provider",
"ToastProviderNameAndUrlRequired": "Nome e URL richiesti",
@@ -1005,6 +1052,7 @@
"ToastSessionCloseFailed": "Disconnessione Fallita",
"ToastSessionDeleteFailed": "Errore eliminazione sessione",
"ToastSessionDeleteSuccess": "Sessione cancellata",
"ToastSleepTimerDone": "Timer di spegnimento eseguito... zZzzZz",
"ToastSlugMustChange": "Lo slug contiene caratteri non validi",
"ToastSlugRequired": "È richiesto lo slug",
"ToastSocketConnected": "Socket connesso",

View File

@@ -258,12 +258,15 @@
"LabelDiscFromFilename": "Disco a partir do nome do arquivo",
"LabelDiscFromMetadata": "Disco a partir dos metadados",
"LabelDiscover": "Descobrir",
"LabelDownload": "Download",
"LabelDownloadNEpisodes": "Download de {0} Episódios",
"LabelDuration": "Duração",
"LabelDurationComparisonExactMatch": "(exato)",
"LabelDurationComparisonLonger": "({0} maior)",
"LabelDurationComparisonShorter": "({0} menor)",
"LabelDurationFound": "Duração comprovada:",
"LabelEbook": "Ebook",
"LabelEbooks": "Ebooks",
"LabelEdit": "Editar",
"LabelEmailSettingsFromAddress": "Remetente",
"LabelEmailSettingsRejectUnauthorized": "Rejeitar certificados não autorizados",

View File

@@ -66,6 +66,7 @@
"ButtonPurgeItemsCache": "Очистить кэш элементов",
"ButtonQueueAddItem": "Добавить в очередь",
"ButtonQueueRemoveItem": "Удалить из очереди",
"ButtonQuickEmbed": "Быстрое внедрение",
"ButtonQuickEmbedMetadata": "Быстрое встраивание метаданных",
"ButtonQuickMatch": "Быстрый поиск",
"ButtonReScan": "Пересканировать",
@@ -162,6 +163,7 @@
"HeaderNotificationUpdate": "Уведомление об обновлении",
"HeaderNotifications": "Уведомления",
"HeaderOpenIDConnectAuthentication": "Аутентификация OpenID Connect",
"HeaderOpenListeningSessions": "Открытые сеансы прослушивания",
"HeaderOpenRSSFeed": "Открыть RSS-канал",
"HeaderOtherFiles": "Другие файлы",
"HeaderPasswordAuthentication": "Аутентификация по паролю",
@@ -179,6 +181,7 @@
"HeaderRemoveEpisodes": "Удалить {0} эпизодов",
"HeaderSavedMediaProgress": "Прогресс медиа сохранен",
"HeaderSchedule": "Планировщик",
"HeaderScheduleEpisodeDownloads": "Запланируйте автоматическую загрузку эпизодов",
"HeaderScheduleLibraryScans": "Планировщик автоматического сканирования библиотеки",
"HeaderSession": "Сеансы",
"HeaderSetBackupSchedule": "Установить планировщик бэкапов",
@@ -224,7 +227,11 @@
"LabelAllUsersExcludingGuests": "Все пользователи, кроме гостей",
"LabelAllUsersIncludingGuests": "Все пользователи, включая гостей",
"LabelAlreadyInYourLibrary": "Уже в Вашей библиотеке",
"LabelApiToken": "Токен API",
"LabelAppend": "Добавить",
"LabelAudioBitrate": "Битрейт (напр. 128k)",
"LabelAudioChannels": "Аудиоканалы (1 или 2)",
"LabelAudioCodec": "Аудиокодек",
"LabelAuthor": "Автор",
"LabelAuthorFirstLast": "Автор (Имя Фамилия)",
"LabelAuthorLastFirst": "Автор (Фамилия, Имя)",
@@ -237,6 +244,7 @@
"LabelAutoRegister": "Автоматическая регистрация",
"LabelAutoRegisterDescription": "Автоматическое создание новых пользователей после входа в систему",
"LabelBackToUser": "Назад к пользователю",
"LabelBackupAudioFiles": "Резервное копирование аудиофайлов",
"LabelBackupLocation": "Путь для бэкапов",
"LabelBackupsEnableAutomaticBackups": "Включить автоматическое бэкапирование",
"LabelBackupsEnableAutomaticBackupsHelp": "Бэкапы сохраняются в /metadata/backups",
@@ -245,15 +253,18 @@
"LabelBackupsNumberToKeep": "Сохранять бэкапов",
"LabelBackupsNumberToKeepHelp": "За один раз только 1 бэкап будет удален, так что если у вас будет больше бэкапов, то их нужно удалить вручную.",
"LabelBitrate": "Битрейт",
"LabelBonus": "Бонус",
"LabelBooks": "Книги",
"LabelButtonText": "Текст кнопки",
"LabelByAuthor": "{0}",
"LabelChangePassword": "Изменить пароль",
"LabelChannels": "Каналы",
"LabelChapterCount": "{0} Главы",
"LabelChapterTitle": "Название главы",
"LabelChapters": "Главы",
"LabelChaptersFound": "глав найдено",
"LabelClickForMoreInfo": "Нажмите, чтобы узнать больше",
"LabelClickToUseCurrentValue": "Нажмите, чтобы использовать текущее значение",
"LabelClosePlayer": "Закрыть проигрыватель",
"LabelCodec": "Кодек",
"LabelCollapseSeries": "Свернуть серии",
@@ -303,12 +314,25 @@
"LabelEmailSettingsTestAddress": "Тестовый адрес",
"LabelEmbeddedCover": "Встроенная обложка",
"LabelEnable": "Включить",
"LabelEncodingBackupLocation": "Резервная копия ваших оригинальных аудиофайлов будет сохранена в:",
"LabelEncodingChaptersNotEmbedded": "Главы не встраиваются в многодорожечные аудиокниги.",
"LabelEncodingClearItemCache": "Обязательно периодически очищайте кэш элементов.",
"LabelEncodingFinishedM4B": "Готовый M4B будет помещен в вашу папку с аудиокнигами по адресу:",
"LabelEncodingInfoEmbedded": "Метаданные будут встроены в звуковые дорожки внутри папки вашей аудиокниги.",
"LabelEncodingStartedNavigation": "Как только задача будет запущена, вы сможете перейти с этой страницы.",
"LabelEncodingTimeWarning": "Кодирование может занять до 30 минут.",
"LabelEncodingWarningAdvancedSettings": "Предупреждение: Не обновляйте эти настройки, если вы не знакомы с параметрами кодировки ffmpeg.",
"LabelEncodingWatcherDisabled": "Если у вас отключено наблюдение за папкой, вам нужно будет повторно пересканировать эту аудиокнигу.",
"LabelEnd": "Конец",
"LabelEndOfChapter": "Конец главы",
"LabelEpisode": "Эпизод",
"LabelEpisodeNotLinkedToRssFeed": "Эпизод, не связанный с RSS-каналом",
"LabelEpisodeNumber": "Эпизод #{0}",
"LabelEpisodeTitle": "Имя эпизода",
"LabelEpisodeType": "Тип эпизода",
"LabelEpisodeUrlFromRssFeed": "URL-адрес эпизода из RSS-ленты",
"LabelEpisodes": "Эпизодов",
"LabelEpisodic": "Эпизодический",
"LabelExample": "Пример",
"LabelExpandSeries": "Развернуть серию",
"LabelExpandSubSeries": "Развернуть подсерию",
@@ -336,12 +360,13 @@
"LabelFontScale": "Масштаб шрифта",
"LabelFontStrikethrough": "Зачеркнутый",
"LabelFormat": "Формат",
"LabelFull": "Полный",
"LabelGenre": "Жанр",
"LabelGenres": "Жанры",
"LabelHardDeleteFile": "Жесткое удаление файла",
"LabelHasEbook": "Есть e-книга",
"LabelHasSupplementaryEbook": "Есть дополнительная e-книга",
"LabelHideSubtitles": "Скрыть субтитры",
"LabelHideSubtitles": "Скрыть серии",
"LabelHighestPriority": "Наивысший приоритет",
"LabelHost": "Хост",
"LabelHour": "Часы",
@@ -391,6 +416,10 @@
"LabelLowestPriority": "Самый низкий приоритет",
"LabelMatchExistingUsersBy": "Сопоставление существующих пользователей по",
"LabelMatchExistingUsersByDescription": "Используется для подключения существующих пользователей. После подключения пользователям будет присвоен уникальный идентификатор от поставщика единого входа",
"LabelMaxEpisodesToDownload": "Максимальное количество эпизодов для загрузки. Используйте 0 для неограниченного количества.",
"LabelMaxEpisodesToDownloadPerCheck": "Максимальное количество новых эпизодов для загрузки за одну проверку",
"LabelMaxEpisodesToKeep": "Максимальное количество сохраняемых эпизодов",
"LabelMaxEpisodesToKeepHelp": "Значение 0 не устанавливает максимального ограничения. После автоматической загрузки нового эпизода самый старый эпизод будет удален, если у вас более X эпизодов. При этом будет удален только 1 эпизод за каждую новую загрузку.",
"LabelMediaPlayer": "Медиа проигрыватель",
"LabelMediaType": "Тип медиа",
"LabelMetaTag": "Мета тег",
@@ -436,12 +465,14 @@
"LabelOpenIDGroupClaimDescription": "Имя утверждения OpenID, содержащего список групп пользователя. Обычно их называют <code>groups</code>. <b>Если эта настройка</b> настроена, приложение будет автоматически назначать роли на основе членства пользователя в группах при условии, что эти группы названы в утверждении без учета регистра \"admin\", \"user\" или \"guest\". Утверждение должно содержать список, и если пользователь принадлежит к нескольким группам, то приложение назначит роль, соответствующую самому высокому уровню доступа. Если ни одна из групп не совпадает, доступ будет запрещен.",
"LabelOpenRSSFeed": "Открыть RSS-канал",
"LabelOverwrite": "Перезаписать",
"LabelPaginationPageXOfY": "Страница {0} из {1}",
"LabelPassword": "Пароль",
"LabelPath": "Путь",
"LabelPermanent": "Постоянный",
"LabelPermissionsAccessAllLibraries": "Есть доступ ко всем библиотекам",
"LabelPermissionsAccessAllTags": "Есть доступ ко всем тегам",
"LabelPermissionsAccessExplicitContent": "Есть доступ к явному содержимому",
"LabelPermissionsCreateEreader": "Можно создать читалку",
"LabelPermissionsDelete": "Может удалять",
"LabelPermissionsDownload": "Может скачивать",
"LabelPermissionsUpdate": "Может обновлять",
@@ -465,6 +496,8 @@
"LabelPubDate": "Дата публикации",
"LabelPublishYear": "Год публикации",
"LabelPublishedDate": "Опубликовано {0}",
"LabelPublishedDecade": "Декада публикации",
"LabelPublishedDecades": "Декады публикации",
"LabelPublisher": "Издатель",
"LabelPublishers": "Издатели",
"LabelRSSFeedCustomOwnerEmail": "Пользовательский Email владельца",
@@ -484,21 +517,28 @@
"LabelRedo": "Повторить",
"LabelRegion": "Регион",
"LabelReleaseDate": "Дата выхода",
"LabelRemoveAllMetadataAbs": "Удалите все файлы metadata.abs",
"LabelRemoveAllMetadataJson": "Удалите все файлы metadata.json",
"LabelRemoveCover": "Удалить обложку",
"LabelRemoveMetadataFile": "Удаление файлов метаданных в папках элементов библиотеки",
"LabelRemoveMetadataFileHelp": "Удалите все файлы metadata.json и metadata.abs из ваших папок {0}.",
"LabelRowsPerPage": "Строк на странице",
"LabelSearchTerm": "Поисковый запрос",
"LabelSearchTitle": "Поиск по названию",
"LabelSearchTitleOrASIN": "Поиск по названию или ASIN",
"LabelSeason": "Сезон",
"LabelSeasonNumber": "Сезон #{0}",
"LabelSelectAll": "Выбрать все",
"LabelSelectAllEpisodes": "Выбрать все эпизоды",
"LabelSelectEpisodesShowing": "Выберите {0} эпизодов для показа",
"LabelSelectUsers": "Выбор пользователей",
"LabelSendEbookToDevice": "Отправить e-книгу в...",
"LabelSequence": "Последовательность",
"LabelSerial": "Серийный",
"LabelSeries": "Серия",
"LabelSeriesName": "Имя серии",
"LabelSeriesProgress": "Прогресс серии",
"LabelServerLogLevel": "Уровень журнала сервера",
"LabelServerYearReview": "Итоги года всего сервера ({0})",
"LabelSetEbookAsPrimary": "Установить как основную",
"LabelSetEbookAsSupplementary": "Установить как дополнительную",
@@ -523,6 +563,9 @@
"LabelSettingsHideSingleBookSeriesHelp": "Серии, в которых всего одна книга, будут скрыты со страницы серий и полок домашней страницы.",
"LabelSettingsHomePageBookshelfView": "Вид книжной полки на Домашней странице",
"LabelSettingsLibraryBookshelfView": "Вид книжной полки в Библиотеке",
"LabelSettingsLibraryMarkAsFinishedPercentComplete": "Процент выполнения больше, чем",
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Оставшееся время составляет менее (секунд)",
"LabelSettingsLibraryMarkAsFinishedWhen": "Отметьте мультимедийный элемент как законченный, когда",
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Пропустить предыдущие книги в \"Продолжить серию\"",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "На домашней странице \"Продолжить серию\" отображается первая книга, не начатая в серии, в которой закончена хотя бы одна книга и нет начатых книг. При включении этого параметра серия будет продолжена с самой последней завершенной книги, а не с первой, которая не начата.",
"LabelSettingsParseSubtitles": "Разбор подзаголовков",
@@ -545,7 +588,7 @@
"LabelShareURL": "Общедоступный URL",
"LabelShowAll": "Показать все",
"LabelShowSeconds": "Отображать секунды",
"LabelShowSubtitles": "Показать субтитры",
"LabelShowSubtitles": "Показать серии",
"LabelSize": "Размер",
"LabelSleepTimer": "Таймер сна",
"LabelSlug": "Слизень",
@@ -587,13 +630,15 @@
"LabelTimeDurationXMinutes": "{0} минут",
"LabelTimeDurationXSeconds": "{0} секунд",
"LabelTimeInMinutes": "Время в минутах",
"LabelTimeLeft": "{0} осталось",
"LabelTimeListened": "Время прослушивания",
"LabelTimeListenedToday": "Время прослушивания сегодня",
"LabelTimeRemaining": "{0} осталось",
"LabelTimeToShift": "Время смещения в сек.",
"LabelTimeToShift": "Время смещения в секундах",
"LabelTitle": "Название",
"LabelToolsEmbedMetadata": "Встроить метаданные",
"LabelToolsEmbedMetadataDescription": "Встроить метаданные в аудио файлы, включая обложку и главы.",
"LabelToolsM4bEncoder": "Кодировщик M4B",
"LabelToolsMakeM4b": "Создать M4B файл аудиокниги",
"LabelToolsMakeM4bDescription": "Создает .M4B файл аудиокниги с встроенными метаданными, обложкой и главами.",
"LabelToolsSplitM4b": "Разделить M4B на MP3 файлы",
@@ -606,6 +651,7 @@
"LabelTracksMultiTrack": "Мультитрек",
"LabelTracksNone": "Нет треков",
"LabelTracksSingleTrack": "Один трек",
"LabelTrailer": "Трейлер",
"LabelType": "Тип",
"LabelUnabridged": "Полное издание",
"LabelUndo": "Отменить",
@@ -617,10 +663,13 @@
"LabelUpdateDetailsHelp": "Позволяет перезаписывать текущие подробности для выбранных книг если будут найдены",
"LabelUpdatedAt": "Обновлено в",
"LabelUploaderDragAndDrop": "Перетащите файлы или каталоги",
"LabelUploaderDragAndDropFilesOnly": "Перетаскивание файлов",
"LabelUploaderDropFiles": "Перетащите файлы",
"LabelUploaderItemFetchMetadataHelp": "Автоматическое извлечение названия, автора и серии",
"LabelUseAdvancedOptions": "Используйте расширенные опции",
"LabelUseChapterTrack": "Показывать время главы",
"LabelUseFullTrack": "Показывать время книги",
"LabelUseZeroForUnlimited": "Используйте 0 для неограниченного количества",
"LabelUser": "Пользователь",
"LabelUsername": "Имя пользователя",
"LabelValue": "Значение",
@@ -667,6 +716,7 @@
"MessageConfirmDeleteMetadataProvider": "Вы уверены, что хотите удалить пользовательский поставщик метаданных \"{0}\"?",
"MessageConfirmDeleteNotification": "Вы уверены, что хотите удалить это уведомление?",
"MessageConfirmDeleteSession": "Вы уверены, что хотите удалить этот сеанс?",
"MessageConfirmEmbedMetadataInAudioFiles": "Вы уверены, что хотите вставить метаданные в {0} аудиофайлов?",
"MessageConfirmForceReScan": "Вы уверены, что хотите принудительно выполнить повторное сканирование?",
"MessageConfirmMarkAllEpisodesFinished": "Вы уверены, что хотите отметить все эпизоды как завершенные?",
"MessageConfirmMarkAllEpisodesNotFinished": "Вы уверены, что хотите отметить все эпизоды как не завершенные?",
@@ -678,6 +728,7 @@
"MessageConfirmPurgeCache": "Очистка кэша удалит весь каталог в <code>/metadata/cache</code>. <br /><br />Вы уверены, что хотите удалить каталог кэша?",
"MessageConfirmPurgeItemsCache": "Очистка кэша элементов удалит весь каталог в <code>/metadata/cache/items</code>.<br />Вы уверены?",
"MessageConfirmQuickEmbed": "Предупреждение! Быстрое встраивание не позволяет создавать резервные копии аудиофайлов. Убедитесь, что у вас есть резервная копия аудиофайлов. <br><br>Хотите продолжить?",
"MessageConfirmQuickMatchEpisodes": "При обнаружении совпадений информация о эпизодах быстрого поиска будет перезаписана. Будут обновлены только несопоставимые эпизоды. Вы уверены?",
"MessageConfirmReScanLibraryItems": "Вы уверены, что хотите пересканировать {0} элементов?",
"MessageConfirmRemoveAllChapters": "Вы уверены, что хотите удалить все главы?",
"MessageConfirmRemoveAuthor": "Вы уверены, что хотите удалить автора \"{0}\"?",
@@ -685,6 +736,7 @@
"MessageConfirmRemoveEpisode": "Вы уверены, что хотите удалить эпизод \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Вы уверены, что хотите удалить {0} эпизодов?",
"MessageConfirmRemoveListeningSessions": "Вы уверены, что хотите удалить {0} сеансов прослушивания?",
"MessageConfirmRemoveMetadataFiles": "Вы уверены, что хотите удалить все файлы metadata. {0} файлов из папок элементов вашей библиотеки?",
"MessageConfirmRemoveNarrator": "Вы уверены, что хотите удалить чтеца \"{0}\"?",
"MessageConfirmRemovePlaylist": "Вы уверены, что хотите удалить плейлист \"{0}\"?",
"MessageConfirmRenameGenre": "Вы уверены, что хотите переименовать жанр \"{0}\" в \"{1}\" для всех элементов?",
@@ -700,6 +752,7 @@
"MessageDragFilesIntoTrackOrder": "Перетащите файлы для исправления порядка треков",
"MessageEmbedFailed": "Вставка не удалась!",
"MessageEmbedFinished": "Встраивание завершено!",
"MessageEmbedQueue": "Поставлен в очередь для внедрения метаданных ({0} в очереди)",
"MessageEpisodesQueuedForDownload": "{0} Эпизод(ов) запланировано для закачки",
"MessageEreaderDevices": "Чтобы обеспечить доставку электронных книг, вам может потребоваться добавить указанный выше адрес электронной почты в качестве действительного отправителя для каждого устройства, перечисленного ниже.",
"MessageFeedURLWillBe": "URL канала будет {0}",
@@ -744,6 +797,7 @@
"MessageNoLogs": "Нет логов",
"MessageNoMediaProgress": "Нет прогресса медиа",
"MessageNoNotifications": "Нет уведомлений",
"MessageNoPodcastFeed": "Недопустимый подкаст: Нет канала",
"MessageNoPodcastsFound": "Подкасты не найдены",
"MessageNoResults": "Нет результатов",
"MessageNoSearchResultsFor": "Нет результатов поиска для \"{0}\"",
@@ -760,6 +814,10 @@
"MessagePlaylistCreateFromCollection": "Создать плейлист из коллекции",
"MessagePleaseWait": "Пожалуйста подождите...",
"MessagePodcastHasNoRSSFeedForMatching": "Подкаст не имеет URL-адреса RSS-канала, который можно использовать для поиска",
"MessagePodcastSearchField": "Введите поисковый запрос или URL-адрес RSS-канала",
"MessageQuickEmbedInProgress": "Быстрое внедрение в процессе выполнения",
"MessageQuickEmbedQueue": "Поставлен в очередь для быстрого внедрения ({0} в очереди)",
"MessageQuickMatchAllEpisodes": "Быстрое сопоставление всех эпизодов",
"MessageQuickMatchDescription": "Заполняет пустые детали элемента и обложку первым результатом поиска из «{0}». Не перезаписывает сведения, если не включен параметр сервера 'Предпочитать метаданные поиска'.",
"MessageRemoveChapter": "Удалить главу",
"MessageRemoveEpisodes": "Удалить {0} эпизод(ов)",
@@ -777,6 +835,41 @@
"MessageShareExpiresIn": "Срок действия истекает через {0}",
"MessageShareURLWillBe": "URL-адрес общего доступа будет <strong>{0}</strong>",
"MessageStartPlaybackAtTime": "Начать воспроизведение для \"{0}\" с {1}?",
"MessageTaskAudioFileNotWritable": "Аудиофайл \"{0}\" недоступен для записи",
"MessageTaskCanceledByUser": "Задание отменено пользователем",
"MessageTaskDownloadingEpisodeDescription": "Загрузка эпизода \"{0}\"",
"MessageTaskEmbeddingMetadata": "Внедрение метаданных",
"MessageTaskEmbeddingMetadataDescription": "Встраивание метаданных в аудиокнигу \"{0}\"",
"MessageTaskEncodingM4b": "Кодировка M4B",
"MessageTaskEncodingM4bDescription": "Кодирование аудиокниги \"{0}\" в один файл формата m4b",
"MessageTaskFailed": "Неудачный",
"MessageTaskFailedToBackupAudioFile": "Не удалось создать резервную копию аудиофайла \"{0}\"",
"MessageTaskFailedToCreateCacheDirectory": "Не удалось создать каталог кэша",
"MessageTaskFailedToEmbedMetadataInFile": "Не удалось вставить метаданные в файл \"{0}\"",
"MessageTaskFailedToMergeAudioFiles": "Не удалось объединить аудиофайлы",
"MessageTaskFailedToMoveM4bFile": "Не удалось переместить файл m4b",
"MessageTaskFailedToWriteMetadataFile": "Не удалось записать файл метаданных",
"MessageTaskMatchingBooksInLibrary": "Сопоставление книг в библиотеке \"{0}\"",
"MessageTaskNoFilesToScan": "Нет файлов для сканирования",
"MessageTaskOpmlImport": "Импорт OPML",
"MessageTaskOpmlImportDescription": "Создание подкастов из {0} RSS-каналов",
"MessageTaskOpmlImportFeed": "Канал импорта OPML",
"MessageTaskOpmlImportFeedDescription": "Импорт RSS-канала \"{0}\"",
"MessageTaskOpmlImportFeedFailed": "Не удалось получить ленту подкаста",
"MessageTaskOpmlImportFeedPodcastDescription": "Создание подкаста \"{0}\"",
"MessageTaskOpmlImportFeedPodcastExists": "Подкаст уже существует по адресу",
"MessageTaskOpmlImportFeedPodcastFailed": "Не удалось создать подкаст",
"MessageTaskOpmlImportFinished": "Добавлено {0} подкастов",
"MessageTaskOpmlParseFailed": "Не удалось разобрать OPML-файл",
"MessageTaskOpmlParseFastFail": "Недопустимый тег <opml> файла OPML не найден ИЛИ тег <outline> не найден",
"MessageTaskOpmlParseNoneFound": "В OPML-файле не найдено ни одного канала",
"MessageTaskScanItemsAdded": "{0} добавлено",
"MessageTaskScanItemsMissing": "{0} отсутствует",
"MessageTaskScanItemsUpdated": "{0} обновлено",
"MessageTaskScanNoChangesNeeded": "Никаких изменений не требуется",
"MessageTaskScanningFileChanges": "Проверка изменений файлов в \"{0}\"",
"MessageTaskScanningLibrary": "Сканирование библиотеки \"{0}\"",
"MessageTaskTargetDirectoryNotWritable": "Целевой каталог недоступен для записи",
"MessageThinking": "Думаю...",
"MessageUploaderItemFailed": "Не удалось загрузить",
"MessageUploaderItemSuccess": "Успешно загружено!",
@@ -794,6 +887,10 @@
"NoteUploaderFoldersWithMediaFiles": "Папки с медиафайлами будут обрабатываться как отдельные элементы библиотеки.",
"NoteUploaderOnlyAudioFiles": "Если загружать только аудиофайлы, то каждый аудиофайл будет обрабатываться как отдельная аудиокнига.",
"NoteUploaderUnsupportedFiles": "Неподдерживаемые файлы игнорируются. При выборе или удалении папки другие файлы, не находящиеся в папке элемента, игнорируются.",
"NotificationOnBackupCompletedDescription": "Запускается при завершении резервного копирования",
"NotificationOnBackupFailedDescription": "Срабатывает при сбое резервного копирования",
"NotificationOnEpisodeDownloadedDescription": "Запускается при автоматической загрузке эпизода подкаста",
"NotificationOnTestDescription": "Событие для тестирования системы оповещения",
"PlaceholderNewCollection": "Новое имя коллекции",
"PlaceholderNewFolderPath": "Путь к новой папке",
"PlaceholderNewPlaylist": "Новое название плейлиста",
@@ -819,6 +916,7 @@
"StatsYearInReview": "ИТОГИ ГОДА",
"ToastAccountUpdateSuccess": "Учетная запись обновлена",
"ToastAppriseUrlRequired": "Необходимо ввести URL-адрес Apprise",
"ToastAsinRequired": "Требуется ASIN",
"ToastAuthorImageRemoveSuccess": "Изображение автора удалено",
"ToastAuthorNotFound": "Автор \"{0}\" не найден",
"ToastAuthorRemoveSuccess": "Автор удален",
@@ -838,6 +936,8 @@
"ToastBackupUploadSuccess": "Бэкап загружен",
"ToastBatchDeleteFailed": "Не удалось выполнить пакетное удаление",
"ToastBatchDeleteSuccess": "Успешное пакетное удаление",
"ToastBatchQuickMatchFailed": "Не удалось выполнить пакетное быстрое сопоставление!",
"ToastBatchQuickMatchStarted": "Начато пакетное быстрое сопоставление {0} книг!",
"ToastBatchUpdateFailed": "Сбой пакетного обновления",
"ToastBatchUpdateSuccess": "Успешное пакетное обновление",
"ToastBookmarkCreateFailed": "Не удалось создать закладку",
@@ -849,6 +949,7 @@
"ToastChaptersHaveErrors": "Главы имеют ошибки",
"ToastChaptersMustHaveTitles": "Главы должны содержать названия",
"ToastChaptersRemoved": "Удалены главы",
"ToastChaptersUpdated": "Обновленные главы",
"ToastCollectionItemsAddFailed": "Не удалось добавить элемент(ы) в коллекцию",
"ToastCollectionItemsAddSuccess": "Элемент(ы) добавлены в коллекцию",
"ToastCollectionItemsRemoveSuccess": "Элемент(ы), удалены из коллекции",
@@ -866,10 +967,14 @@
"ToastEncodeCancelSucces": "Кодирование отменено",
"ToastEpisodeDownloadQueueClearFailed": "Не удалось очистить очередь",
"ToastEpisodeDownloadQueueClearSuccess": "Очередь загрузки эпизода очищена",
"ToastEpisodeUpdateSuccess": "{0 эпизодов обновлено",
"ToastErrorCannotShare": "Невозможно предоставить общий доступ на этом устройстве",
"ToastFailedToLoadData": "Не удалось загрузить данные",
"ToastFailedToMatch": "Не удалось найти совпадения",
"ToastFailedToShare": "Не удалось поделиться",
"ToastFailedToUpdate": "Не удалось обновить",
"ToastInvalidImageUrl": "Неверный URL изображения",
"ToastInvalidMaxEpisodesToDownload": "Недопустимое максимальное количество загружаемых эпизодов",
"ToastInvalidUrl": "Неверный URL",
"ToastItemCoverUpdateSuccess": "Обложка элемента обновлена",
"ToastItemDeletedFailed": "Не удалось удалить элемент",
@@ -887,14 +992,22 @@
"ToastLibraryScanFailedToStart": "Не удалось запустить сканирование",
"ToastLibraryScanStarted": "Запущено сканирование библиотеки",
"ToastLibraryUpdateSuccess": "Библиотека \"{0}\" обновлена",
"ToastMatchAllAuthorsFailed": "Не удалось найти совпадения со всеми авторами",
"ToastMetadataFilesRemovedError": "Ошибка при удалении файлов metadata.{0}",
"ToastMetadataFilesRemovedNoneFound": "В библиотеке не найдено файлов metadata.{0}",
"ToastMetadataFilesRemovedNoneRemoved": "Нет удаленных файлов metadata.{0}",
"ToastMetadataFilesRemovedSuccess": "{0} metadata.{1} файлов удалено",
"ToastMustHaveAtLeastOnePath": "Должен быть хотя бы один путь",
"ToastNameEmailRequired": "Имя и адрес электронной почты обязательны",
"ToastNameRequired": "Имя обязательно для заполнения",
"ToastNewEpisodesFound": "{0} новых эпизодов найдено",
"ToastNewUserCreatedFailed": "Не удалось создать учетную запись: \"{0}\"",
"ToastNewUserCreatedSuccess": "Новая учетная запись создана",
"ToastNewUserLibraryError": "Необходимо выбрать хотя бы одну библиотеку",
"ToastNewUserPasswordError": "Должен иметь пароль, только пользователь root может иметь пустой пароль",
"ToastNewUserTagError": "Необходимо выбрать хотя бы один тег",
"ToastNewUserUsernameError": "Введите имя пользователя",
"ToastNoNewEpisodesFound": "Новых эпизодов не найдено",
"ToastNoUpdatesNecessary": "Обновления не требуются",
"ToastNotificationCreateFailed": "Не удалось создать уведомление",
"ToastNotificationDeleteFailed": "Не удалось удалить уведомление",
@@ -913,6 +1026,7 @@
"ToastPodcastGetFeedFailed": "Не удалось получить ленту подкастов",
"ToastPodcastNoEpisodesInFeed": "В RSS-ленте эпизодов не найдено",
"ToastPodcastNoRssFeed": "В подкасте нет RSS-канала",
"ToastProgressIsNotBeingSynced": "Прогресс не синхронизируется, перезапустите воспроизведение",
"ToastProviderCreatedFailed": "Не удалось добавить провайдера",
"ToastProviderCreatedSuccess": "Добавлен новый провайдер",
"ToastProviderNameAndUrlRequired": "Имя и URL обязательные",
@@ -939,6 +1053,7 @@
"ToastSessionCloseFailed": "Не удалось закрыть сеанс",
"ToastSessionDeleteFailed": "Не удалось удалить сеанс",
"ToastSessionDeleteSuccess": "Сеанс удален",
"ToastSleepTimerDone": "Выполнен таймер сна... Хр-р-р-р",
"ToastSlugMustChange": "Slug содержит недопустимые символы",
"ToastSlugRequired": "Требуется Slug",
"ToastSocketConnected": "Сокет подключен",

View File

@@ -136,7 +136,7 @@
"HeaderEmailSettings": "Nastavitve e-pošte",
"HeaderEpisodes": "Epizode",
"HeaderEreaderDevices": "E-bralniki",
"HeaderEreaderSettings": "Nastavitve ebralnika",
"HeaderEreaderSettings": "Nastavitve e-bralnika",
"HeaderFiles": "Datoteke",
"HeaderFindChapters": "Najdi poglavja",
"HeaderIgnoredFiles": "Prezrte datoteke",
@@ -184,7 +184,7 @@
"HeaderScheduleEpisodeDownloads": "Načrtovanje samodejnega prenosa epizod",
"HeaderScheduleLibraryScans": "Načrtuj samodejno pregledovanje knjižnice",
"HeaderSession": "Seja",
"HeaderSetBackupSchedule": "Nastavite urnik varnostnega kopiranja",
"HeaderSetBackupSchedule": "Nastavi urnik varnostnega kopiranja",
"HeaderSettings": "Nastavitve",
"HeaderSettingsDisplay": "Zaslon",
"HeaderSettingsExperimental": "Eksperimentalne funkcije",
@@ -366,7 +366,7 @@
"LabelHardDeleteFile": "Trdo brisanje datoteke",
"LabelHasEbook": "Ima e-knjigo",
"LabelHasSupplementaryEbook": "Ima dodatno e-knjigo",
"LabelHideSubtitles": "Skrij podnapise",
"LabelHideSubtitles": "Skrij podnaslove",
"LabelHighestPriority": "Najvišja prioriteta",
"LabelHost": "Gostitelj",
"LabelHour": "Ura",
@@ -407,7 +407,7 @@
"LabelLibraryItem": "Element knjižnice",
"LabelLibraryName": "Ime knjižnice",
"LabelLimit": "Omejitev",
"LabelLineSpacing": "Razmik med vrsticami",
"LabelLineSpacing": "Vrstični razmak",
"LabelListenAgain": "Poslušaj znova",
"LabelLogLevelDebug": "Odpravljanje napak",
"LabelLogLevelInfo": "Info",
@@ -472,6 +472,7 @@
"LabelPermissionsAccessAllLibraries": "Lahko dostopa do vseh knjižnic",
"LabelPermissionsAccessAllTags": "Lahko dostopa do vseh oznak",
"LabelPermissionsAccessExplicitContent": "Lahko dostopa do eksplicitne vsebine",
"LabelPermissionsCreateEreader": "Lahko ustvari e-bralnik",
"LabelPermissionsDelete": "Lahko briše",
"LabelPermissionsDownload": "Lahko prenaša",
"LabelPermissionsUpdate": "Lahko posodablja",
@@ -494,7 +495,7 @@
"LabelProviderAuthorizationValue": "Vrednost glave avtorizacije",
"LabelPubDate": "Datum objave",
"LabelPublishYear": "Leto izdaje",
"LabelPublishedDate": "Izdano {0}",
"LabelPublishedDate": "Objavljeno {0}",
"LabelPublishedDecade": "Desetletje izdaje",
"LabelPublishedDecades": "Desetletja izdaje",
"LabelPublisher": "Izdajatelj",
@@ -567,8 +568,8 @@
"LabelSettingsLibraryMarkAsFinishedWhen": "Označi medijski element kot končan, ko",
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Preskoči prejšnje knjige v nadaljevanju serije",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Polica z domačo stranjo Nadaljuj serijo prikazuje prvo nezačeto knjigo v seriji, ki ima vsaj eno dokončano knjigo in ni nobene knjige v teku. Če omogočite to nastavitev, se bo serija nadaljevala od najbolj dokončane knjige namesto od prve nezačete knjige.",
"LabelSettingsParseSubtitles": "Uporabi podnapise",
"LabelSettingsParseSubtitlesHelp": "Izvleci podnapise iz imen map zvočnih knjig.<br>Podnapis mora biti ločen z \" - \"<br>npr. \"Naslov knjige tu podnapis\" ima podnapis \"tu podnapis\"",
"LabelSettingsParseSubtitles": "Razčleni podnaslove",
"LabelSettingsParseSubtitlesHelp": "Izvleci padnaslove iz imen map zvočnih knjig.<br>Podnaslov mora biti ločen z \" - \"<br>npr. \"Naslov knjige tu podnaslove\" ima podnaslov \"tu podnaslov\"",
"LabelSettingsPreferMatchedMetadata": "Prednost imajo ujemajoči se metapodatki",
"LabelSettingsPreferMatchedMetadataHelp": "Pri uporabi hitrega ujemanja bodo ujemajoči se podatki preglasili podrobnosti artikla. Hitro ujemanje bo privzeto izpolnil samo manjkajoče podrobnosti.",
"LabelSettingsSkipMatchingBooksWithASIN": "Preskoči ujemajoče se knjige, ki že imajo ASIN",
@@ -587,7 +588,7 @@
"LabelShareURL": "Deli URL",
"LabelShowAll": "Prikaži vse",
"LabelShowSeconds": "Prikaži sekunde",
"LabelShowSubtitles": "Prikaži podnapise",
"LabelShowSubtitles": "Prikaži podnaslove",
"LabelSize": "Velikost",
"LabelSleepTimer": "Časovnik za spanje",
"LabelSlug": "Slug",
@@ -610,7 +611,7 @@
"LabelStatsOverallDays": "Skupaj dnevi",
"LabelStatsOverallHours": "Skupaj ur",
"LabelStatsWeekListening": "Tednov poslušanja",
"LabelSubtitle": "Podnapis",
"LabelSubtitle": "Podnaslov",
"LabelSupportedFileTypes": "Podprte vrste datotek",
"LabelTag": "Oznaka",
"LabelTags": "Oznake",
@@ -624,7 +625,7 @@
"LabelTheme": "Tema",
"LabelThemeDark": "Temna",
"LabelThemeLight": "Svetla",
"LabelTimeBase": "Odvisna od časa",
"LabelTimeBase": "Osnovni čas",
"LabelTimeDurationXHours": "{0} ur",
"LabelTimeDurationXMinutes": "{0} minut",
"LabelTimeDurationXSeconds": "{0} sekund",
@@ -662,6 +663,7 @@
"LabelUpdateDetailsHelp": "Dovoli prepisovanje obstoječih podrobnosti za izbrane knjige, ko se najde ujemanje",
"LabelUpdatedAt": "Posodobljeno ob",
"LabelUploaderDragAndDrop": "Povleci in spusti datoteke ali mape",
"LabelUploaderDragAndDropFilesOnly": "Povleci in spusti datoteke",
"LabelUploaderDropFiles": "Spusti datoteke",
"LabelUploaderItemFetchMetadataHelp": "Samodejno pridobi naslov, avtorja in serijo",
"LabelUseAdvancedOptions": "Uporabi napredne možnosti",
@@ -677,11 +679,13 @@
"LabelViewPlayerSettings": "Ogled nastavitev predvajalnika",
"LabelViewQueue": "Ogled čakalno vrsto predvajalnika",
"LabelVolume": "Glasnost",
"LabelWebRedirectURLsDescription": "Avtorizirajte URL-je pri svojem ponudniku OAuth ter s tem omogočite preusmeritev nazaj v spletno aplikacijo po prijavi:",
"LabelWebRedirectURLsSubfolder": "Podmapa za URL-je preusmeritve",
"LabelWeekdaysToRun": "Delovni dnevi predvajanja",
"LabelXBooks": "{0} knjig",
"LabelXItems": "{0} elementov",
"LabelYearReviewHide": "Skrij pregled leta",
"LabelYearReviewShow": "Poglej pregled leta",
"LabelYearReviewShow": "Poglej si pregled leta",
"LabelYourAudiobookDuration": "Trajanje tvojih zvočnih knjig",
"LabelYourBookmarks": "Tvoji zaznamki",
"LabelYourPlaylists": "Tvoje seznami predvajanj",
@@ -772,7 +776,7 @@
"MessageMarkAllEpisodesNotFinished": "Označi vse epizode kot nedokončane",
"MessageMarkAsFinished": "Označi kot dokončano",
"MessageMarkAsNotFinished": "Označi kot nedokončano",
"MessageMatchBooksDescription": "bo poskušal povezati knjige v knjižnici s knjigo izbranega ponudnika iskanja in izpolniti prazne podatke in naslovnico. Ne prepisuje čez obstoječe podatke.",
"MessageMatchBooksDescription": "bo poskušalo povezati knjige v knjižnici s knjigo izbranega ponudnika iskanja in izpolniti prazne podatke in naslovnico. Ne prepisuje čez obstoječe podatke.",
"MessageNoAudioTracks": "Ni zvočnih posnetkov",
"MessageNoAuthors": "Brez avtorjev",
"MessageNoBackups": "Brez varnostnih kopij",
@@ -828,7 +832,7 @@
"MessageSearchResultsFor": "Rezultati iskanja za",
"MessageSelected": "{0} izbrano",
"MessageServerCouldNotBeReached": "Strežnika ni bilo mogoče doseči",
"MessageSetChaptersFromTracksDescription": "Nastavite poglavja z uporabo vsake zvočne datoteke kot poglavja in naslova poglavja kot imena zvočne datoteke",
"MessageSetChaptersFromTracksDescription": "Nastavi poglavja z uporabo vsake zvočne datoteke kot poglavja in naslova poglavja kot imena zvočne datoteke",
"MessageShareExpirationWillBe": "Potečeno bo <strong>{0}</strong>",
"MessageShareExpiresIn": "Poteče čez {0}",
"MessageShareURLWillBe": "URL za skupno rabo bo <strong>{0}</strong>",
@@ -901,7 +905,7 @@
"StatsBooksFinishedThisYear": "Nekaj knjig, ki so bile dokončane letos…",
"StatsBooksListenedTo": "poslušanih knjig",
"StatsCollectionGrewTo": "Vaša zbirka knjig se je povečala na …",
"StatsSessions": "sej",
"StatsSessions": "seje",
"StatsSpentListening": "porabil za poslušanje",
"StatsTopAuthor": "TOP AVTOR",
"StatsTopAuthors": "TOP AVTORJI",

View File

@@ -19,6 +19,7 @@
"ButtonChooseFiles": "Обрати файли",
"ButtonClearFilter": "Очистити фільтр",
"ButtonCloseFeed": "Закрити стрічку",
"ButtonCloseSession": "Закрити відкритий сеанс",
"ButtonCollections": "Добірки",
"ButtonConfigureScanner": "Налаштувати сканер",
"ButtonCreate": "Створити",
@@ -28,6 +29,9 @@
"ButtonEdit": "Редагувати",
"ButtonEditChapters": "Редагувати глави",
"ButtonEditPodcast": "Редагувати подкаст",
"ButtonEnable": "Увімкнути",
"ButtonFireAndFail": "Вогонь і невдача",
"ButtonFireOnTest": "Випробування на вогнестійкість",
"ButtonForceReScan": "Примусово сканувати",
"ButtonFullPath": "Повний шлях",
"ButtonHide": "Приховати",
@@ -46,19 +50,23 @@
"ButtonNevermind": "Скасувати",
"ButtonNext": "Наступний",
"ButtonNextChapter": "Наступна глава",
"ButtonNextItemInQueue": "Наступний елемент у черзі",
"ButtonOk": "Гаразд",
"ButtonOpenFeed": "Відкрити стрічку",
"ButtonOpenManager": "Відкрити менеджер",
"ButtonPause": "Пауза",
"ButtonPlay": "Слухати",
"ButtonPlayAll": "Відтворити все",
"ButtonPlaying": "Відтворюється",
"ButtonPlaylists": "Списки відтворення",
"ButtonPrevious": "Попередній",
"ButtonPreviousChapter": "Попередня глава",
"ButtonProbeAudioFile": "Перевірити аудіофайл",
"ButtonPurgeAllCache": "Очистити весь кеш",
"ButtonPurgeItemsCache": "Очистити кеш елементів",
"ButtonQueueAddItem": "Додати до черги",
"ButtonQueueRemoveItem": "Вилучити з черги",
"ButtonQuickEmbed": "Швидке вбудовування",
"ButtonQuickEmbedMetadata": "Швидко вбудувати метадані",
"ButtonQuickMatch": "Швидкий пошук",
"ButtonReScan": "Пересканувати",
@@ -92,6 +100,7 @@
"ButtonStats": "Статистика",
"ButtonSubmit": "Надіслати",
"ButtonTest": "Перевірити",
"ButtonUnlinkOpenId": "Вимкнути OpenID",
"ButtonUpload": "Завантажити",
"ButtonUploadBackup": "Завантажити резервну копію",
"ButtonUploadCover": "Завантажити обкладинку",
@@ -104,6 +113,7 @@
"ErrorUploadFetchMetadataNoResults": "Не вдалося отримати метадані — спробуйте оновити заголовок та/або автора",
"ErrorUploadLacksTitle": "Назва обов'язкова",
"HeaderAccount": "Профіль",
"HeaderAddCustomMetadataProvider": "Додати користувацький постачальник метаданих",
"HeaderAdvanced": "Розширені",
"HeaderAppriseNotificationSettings": "Налаштування сповіщень Apprise",
"HeaderAudioTracks": "Аудіодоріжки",
@@ -149,8 +159,11 @@
"HeaderMetadataToEmbed": "Вбудувати метадані",
"HeaderNewAccount": "Новий профіль",
"HeaderNewLibrary": "Нова бібліотека",
"HeaderNotificationCreate": "Створити сповіщення",
"HeaderNotificationUpdate": "Оновити сповіщення",
"HeaderNotifications": "Сповіщення",
"HeaderOpenIDConnectAuthentication": "Автентифікація OpenID Connect",
"HeaderOpenListeningSessions": "Відкриті сеанси прослуховування",
"HeaderOpenRSSFeed": "Відкрити RSS-канал",
"HeaderOtherFiles": "Інші файли",
"HeaderPasswordAuthentication": "Автентифікація за паролем",
@@ -168,6 +181,7 @@
"HeaderRemoveEpisodes": "Видалити епізодів: {0}",
"HeaderSavedMediaProgress": "Збережений прогрес медіа",
"HeaderSchedule": "Розклад",
"HeaderScheduleEpisodeDownloads": "Запланувати автоматичне завантаження епізодів",
"HeaderScheduleLibraryScans": "Розклад автосканування бібліотеки",
"HeaderSession": "Сеанс",
"HeaderSetBackupSchedule": "Встановити розклад резервного копіювання",
@@ -206,13 +220,18 @@
"LabelAddToPlaylist": "Додати до списку відтворення",
"LabelAddToPlaylistBatch": "Додано елементів у список відтворення: {0}",
"LabelAddedAt": "Дата додавання",
"LabelAddedDate": "Додано {0}",
"LabelAdminUsersOnly": "Тільки для адміністраторів",
"LabelAll": "Усе",
"LabelAllUsers": "Усі користувачі",
"LabelAllUsersExcludingGuests": "Усі, крім гостей",
"LabelAllUsersIncludingGuests": "Усі, включно з гостями",
"LabelAlreadyInYourLibrary": "Вже у вашій бібліотеці",
"LabelApiToken": "Токен API",
"LabelAppend": "Додати",
"LabelAudioBitrate": "Бітрейт аудіо (напр. 128k)",
"LabelAudioChannels": "Канали аудіо (1 або 2)",
"LabelAudioCodec": "Аудіокодек",
"LabelAuthor": "Автор",
"LabelAuthorFirstLast": "Автор (за ім'ям)",
"LabelAuthorLastFirst": "Автор (за прізвищем)",
@@ -225,6 +244,7 @@
"LabelAutoRegister": "Автореєстрація",
"LabelAutoRegisterDescription": "Автоматично створювати нових користувачів після входу",
"LabelBackToUser": "Повернутися до користувача",
"LabelBackupAudioFiles": "Резервне копіювання аудіофайлів",
"LabelBackupLocation": "Розташування резервних копій",
"LabelBackupsEnableAutomaticBackups": "Автоматичне резервне копіювання",
"LabelBackupsEnableAutomaticBackupsHelp": "Резервні копії збережено у /metadata/backups",
@@ -233,18 +253,22 @@
"LabelBackupsNumberToKeep": "Кількість резервних копій",
"LabelBackupsNumberToKeepHelp": "Лиш 1 резервну копію буде видалено за раз, тож якщо їх багато, то вам варто видалити їх вручну.",
"LabelBitrate": "Бітрейт",
"LabelBonus": "Бонус",
"LabelBooks": "Книги",
"LabelButtonText": "Текст кнопки",
"LabelByAuthor": "від {0}",
"LabelChangePassword": "Змінити пароль",
"LabelChannels": "Канали",
"LabelChapterCount": "{0} Глав",
"LabelChapterTitle": "Назва глави",
"LabelChapters": "Глави",
"LabelChaptersFound": "глав знайдено",
"LabelClickForMoreInfo": "Натисніть, щоб дізнатися більше",
"LabelClickToUseCurrentValue": "Натисніть, щоб використати поточне значення",
"LabelClosePlayer": "Закрити програвач",
"LabelCodec": "Кодек",
"LabelCollapseSeries": "Згорнути серії",
"LabelCollapseSubSeries": "Згорнути підсерії",
"LabelCollection": "Добірка",
"LabelCollections": "Добірки",
"LabelComplete": "Завершити",
@@ -290,13 +314,28 @@
"LabelEmailSettingsTestAddress": "Тестова адреса",
"LabelEmbeddedCover": "Вбудована обкладинка",
"LabelEnable": "Увімкнути",
"LabelEncodingBackupLocation": "Резервна копія ваших оригінальних аудіофайлів буде збережена в:",
"LabelEncodingChaptersNotEmbedded": "Глави не вбудовуються в багатодоріжкові аудіокниги.",
"LabelEncodingClearItemCache": "Переконайтесь, що періодично очищуєте кеш елементів.",
"LabelEncodingFinishedM4B": "Готовий M4B буде поміщений у вашу папку з аудіокнигами за адресою:",
"LabelEncodingInfoEmbedded": "Метадані будуть вбудовані в звукові доріжки всередині папки вашої аудіокниги.",
"LabelEncodingStartedNavigation": "Як тільки завдання розпочнеться, ви можете покинути цю сторінку.",
"LabelEncodingTimeWarning": "Кодування може зайняти до 30 хвилин.",
"LabelEncodingWarningAdvancedSettings": "Увага: не змінюйте ці налаштування, якщо ви не знайомі з параметрами кодування ffmpeg.",
"LabelEncodingWatcherDisabled": "Якщо у вас вимкнено спостереження за папкою, вам потрібно буде повторно відсканувати цю аудіокнигу.",
"LabelEnd": "Кінець",
"LabelEndOfChapter": "Кінець глави",
"LabelEpisode": "Епізод",
"LabelEpisodeNotLinkedToRssFeed": "Епізод не прив'язаний до RSS-каналу",
"LabelEpisodeNumber": "Епізод #{0}",
"LabelEpisodeTitle": "Назва епізоду",
"LabelEpisodeType": "Тип епізоду",
"LabelEpisodeUrlFromRssFeed": "URL епізоду з RSS-каналу",
"LabelEpisodes": "Епізодов",
"LabelEpisodic": "Епізодичний",
"LabelExample": "Приклад",
"LabelExpandSeries": "Розгорнути серії",
"LabelExpandSubSeries": "Розгорнути підсерії",
"LabelExplicit": "Відверта",
"LabelExplicitChecked": "Відверта (з прапорцем)",
"LabelExplicitUnchecked": "Не відверта (без прапорця)",
@@ -305,7 +344,9 @@
"LabelFetchingMetadata": "Отримання метаданих",
"LabelFile": "Файл",
"LabelFileBirthtime": "Дата створення",
"LabelFileBornDate": "Народився {0}",
"LabelFileModified": "Дата змінення",
"LabelFileModifiedDate": "Змінено {0}",
"LabelFilename": "Ім'я файлу",
"LabelFilterByUser": "Фільтрувати за користувачем",
"LabelFindEpisodes": "Знайти епізоди",
@@ -319,6 +360,7 @@
"LabelFontScale": "Розмір шрифту",
"LabelFontStrikethrough": "Закреслений",
"LabelFormat": "Формат",
"LabelFull": "Повний",
"LabelGenre": "Жанр",
"LabelGenres": "Жанри",
"LabelHardDeleteFile": "Остаточно видалити файл",
@@ -361,6 +403,7 @@
"LabelLess": "Менше",
"LabelLibrariesAccessibleToUser": "Бібліотеки, доступні користувачу",
"LabelLibrary": "Бібліотека",
"LabelLibraryFilterSublistEmpty": "Ні {0}",
"LabelLibraryItem": "Елемент бібліотеки",
"LabelLibraryName": "Назва бібліотеки",
"LabelLimit": "Обмеження",
@@ -373,6 +416,10 @@
"LabelLowestPriority": "Найнижчий пріоритет",
"LabelMatchExistingUsersBy": "Шукати наявних користувачів за",
"LabelMatchExistingUsersByDescription": "Використовується для підключення наявних користувачів. Після підключення користувач отримає унікальний id від вашого сервісу SSO",
"LabelMaxEpisodesToDownload": "Максимальна кількість епізодів для завантаження. Використовуйте 0 для необмеженої кількості.",
"LabelMaxEpisodesToDownloadPerCheck": "Максимальна кількість нових епізодів для завантаження за перевірку",
"LabelMaxEpisodesToKeep": "Максимальна кількість епізодів для зберігання",
"LabelMaxEpisodesToKeepHelp": "Значення 0 не встановлює обмеження. Після автоматичного завантаження нового епізоду, буде видалено найстаріший епізод, якщо у вас більше ніж X епізодів. Видаляється лише 1 епізод за одне нове завантаження.",
"LabelMediaPlayer": "Програвач медіа",
"LabelMediaType": "Тип медіа",
"LabelMetaTag": "Метатег",
@@ -418,12 +465,14 @@
"LabelOpenIDGroupClaimDescription": "Ім'я OpenID claim, що містить список груп користувачів. Зазвичай їх називають <code>групами</code>. <b>Якщо налаштовано</b>, застосунок автоматично призначатиме ролі на основі членства користувача в групах, за умови, що ці групи названі в claim'і без урахування реєстру 'admin', 'user' або 'guest'. Claim мусить містити список, і якщо користувач належить до кількох груп, програма призначить йому роль, що відповідає найвищому рівню доступу. Якщо жодна група не збігається, у доступі буде відмовлено.",
"LabelOpenRSSFeed": "Відкрити RSS-канал",
"LabelOverwrite": "Перезаписати",
"LabelPaginationPageXOfY": "Сторінка {0} з {1}",
"LabelPassword": "Пароль",
"LabelPath": "Шлях",
"LabelPermanent": "Постійний",
"LabelPermissionsAccessAllLibraries": "Доступ до усіх бібліотек",
"LabelPermissionsAccessAllTags": "Доступ до усіх міток",
"LabelPermissionsAccessExplicitContent": "Доступ до відвертого вмісту",
"LabelPermissionsCreateEreader": "Можна створити читалку",
"LabelPermissionsDelete": "Може видаляти",
"LabelPermissionsDownload": "Може завантажувати",
"LabelPermissionsUpdate": "Може оновлювати",
@@ -431,6 +480,7 @@
"LabelPersonalYearReview": "Ваші підсумки року ({0})",
"LabelPhotoPathURL": "Шлях/URL фото",
"LabelPlayMethod": "Метод відтворення",
"LabelPlayerChapterNumberMarker": "{0} з {1}",
"LabelPlaylists": "Списки відтворення",
"LabelPodcast": "Подкаст",
"LabelPodcastSearchRegion": "Регіон пошуку подкасту",
@@ -442,8 +492,12 @@
"LabelPrimaryEbook": "Основна електронна книга",
"LabelProgress": "Прогрес",
"LabelProvider": "Джерело",
"LabelProviderAuthorizationValue": "Значення заголовка авторизації",
"LabelPubDate": "Дата публікації",
"LabelPublishYear": "Рік публікації",
"LabelPublishedDate": "Опубліковано {0}",
"LabelPublishedDecade": "Десятиліття публікації",
"LabelPublishedDecades": "Опубліковані десятиліття",
"LabelPublisher": "Видавець",
"LabelPublishers": "Видавці",
"LabelRSSFeedCustomOwnerEmail": "Користувацька електронна адреса власника",
@@ -463,21 +517,28 @@
"LabelRedo": "Повторити",
"LabelRegion": "Регіон",
"LabelReleaseDate": "Дата публікації",
"LabelRemoveAllMetadataAbs": "Видалити всі файли metadata.abs",
"LabelRemoveAllMetadataJson": "Видалити всі файли metadata.json",
"LabelRemoveCover": "Видалити обкладинку",
"LabelRemoveMetadataFile": "Видалити файли метаданих у папках елементів бібліотеки",
"LabelRemoveMetadataFileHelp": "Видалити всі файли metadata.json та metadata.abs у ваших папках {0}.",
"LabelRowsPerPage": "Рядків на сторінку",
"LabelSearchTerm": "Пошуковий запит",
"LabelSearchTitle": "Пошук за назвою",
"LabelSearchTitleOrASIN": "Пошук назви або ASIN",
"LabelSeason": "Сезон",
"LabelSeasonNumber": "Сезон #{0}",
"LabelSelectAll": "Вибрати все",
"LabelSelectAllEpisodes": "Вибрати всі серії",
"LabelSelectEpisodesShowing": "Обрати показані епізоди: {0}",
"LabelSelectUsers": "Вибрати користувачів",
"LabelSendEbookToDevice": "Надіслати електронну книгу на...",
"LabelSequence": "Послідовність",
"LabelSerial": "Серійний",
"LabelSeries": "Серії",
"LabelSeriesName": "Назва серії",
"LabelSeriesProgress": "Прогрес серії",
"LabelServerLogLevel": "Рівень журналу сервера",
"LabelServerYearReview": "Підсумки року сервера ({0})",
"LabelSetEbookAsPrimary": "Зробити основною",
"LabelSetEbookAsSupplementary": "Зробити додатковою",
@@ -502,6 +563,9 @@
"LabelSettingsHideSingleBookSeriesHelp": "Серії, що містять одну книгу, будуть приховані зі сторінки серій та полиць головної сторінки.",
"LabelSettingsHomePageBookshelfView": "Полиці на головній сторінці",
"LabelSettingsLibraryBookshelfView": "Показувати полиці у бібліотеці",
"LabelSettingsLibraryMarkAsFinishedPercentComplete": "Відсоток виконання більше ніж",
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Час, що залишився, менше ніж (секунди)",
"LabelSettingsLibraryMarkAsFinishedWhen": "Позначити медіа-елемент як завершений, коли",
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Пропускати попередні книги у Продовжити серії",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Полиця Продовжити серії на головній сторінці показує найпершу непочату книгу з тих серій, у яких ви завершили хоча б одну книгу та не маєте книг у процесі. Якщо увімкнути це налаштування, то серії продовжуватимуться з останньої завершеної книги, а не з першої непочатої.",
"LabelSettingsParseSubtitles": "Дістати підзаголовки",
@@ -566,6 +630,7 @@
"LabelTimeDurationXMinutes": "{0} хвилини",
"LabelTimeDurationXSeconds": "{0} секунди",
"LabelTimeInMinutes": "Час у хвилинах",
"LabelTimeLeft": "{0} залишилось",
"LabelTimeListened": "Часу прослухано",
"LabelTimeListenedToday": "Сьогодні прослухано",
"LabelTimeRemaining": "Лишилося: {0}",
@@ -573,6 +638,7 @@
"LabelTitle": "Назва",
"LabelToolsEmbedMetadata": "Вбудувати метадані",
"LabelToolsEmbedMetadataDescription": "Вбудувати метадані в аудіофайли, включно з обкладинками та главами.",
"LabelToolsM4bEncoder": "Кодувальник M4B",
"LabelToolsMakeM4b": "Створити M4B-файл аудіокниги",
"LabelToolsMakeM4bDescription": "Створити .M4B-аудіокнигу з вбудованими метаданими, обкладинкою та главами.",
"LabelToolsSplitM4b": "Розділити M4B на MP3",
@@ -585,20 +651,25 @@
"LabelTracksMultiTrack": "Декілька доріжок",
"LabelTracksNone": "Доріжки відсутні",
"LabelTracksSingleTrack": "Одна доріжка",
"LabelTrailer": "Трейлер",
"LabelType": "Тип",
"LabelUnabridged": "Повна",
"LabelUndo": "Скасувати",
"LabelUnknown": "Невідомо",
"LabelUnknownPublishDate": "Невідома дата публікації",
"LabelUpdateCover": "Оновити обкладинку",
"LabelUpdateCoverHelp": "Дозволити перезапис наявних обкладинок обраних книг після віднайдення",
"LabelUpdateDetails": "Оновити подробиці",
"LabelUpdateDetailsHelp": "Дозволити перезапис наявних подробиць обраних книг після віднайдення",
"LabelUpdatedAt": "Оновлення",
"LabelUploaderDragAndDrop": "Перетягніть файли або теки",
"LabelUploaderDragAndDropFilesOnly": "Перетягніть і скиньте файли",
"LabelUploaderDropFiles": "Перетягніть файли",
"LabelUploaderItemFetchMetadataHelp": "Автоматично шукати назву, автора та серію",
"LabelUseAdvancedOptions": "Використовувати розширені налаштування",
"LabelUseChapterTrack": "Прогрес глави",
"LabelUseFullTrack": "Використовувати доріжку повністю",
"LabelUseZeroForUnlimited": "Використовуйте 0 для необмеженої кількості",
"LabelUser": "Користувач",
"LabelUsername": "Ім’я користувача",
"LabelValue": "Значення",
@@ -608,6 +679,8 @@
"LabelViewPlayerSettings": "Переглянути налаштування програвача",
"LabelViewQueue": "Переглянути чергу відтворення",
"LabelVolume": "Гучність",
"LabelWebRedirectURLsDescription": "Авторизуйте ці URL у вашому OAuth постачальнику, щоб дозволити редирекцію назад до веб-додатку після входу:",
"LabelWebRedirectURLsSubfolder": "Підпапка для Redirect URL",
"LabelWeekdaysToRun": "Виконувати у дні",
"LabelXBooks": "{0} книг",
"LabelXItems": "{0} елементів",
@@ -637,19 +710,27 @@
"MessageCheckingCron": "Перевірка планувальника...",
"MessageConfirmCloseFeed": "Ви дійсно бажаєте закрити цей канал?",
"MessageConfirmDeleteBackup": "Ви дійсно бажаєте видалити резервну копію за {0}?",
"MessageConfirmDeleteDevice": "Ви впевнені, що хочете видалити пристрій для читання \"{0}\"?",
"MessageConfirmDeleteFile": "Файл буде видалено з вашої файлової системи. Ви впевнені?",
"MessageConfirmDeleteLibrary": "Ви дійсно бажаєте назавжди видалити бібліотеку \"{0}\"?",
"MessageConfirmDeleteLibraryItem": "Елемент бібліотеки буде видалено з бази даних та вашої файлової системи. Ви впевнені?",
"MessageConfirmDeleteLibraryItems": "З бази даних та вашої файлової системи будуть видалені елементи бібліотеки: {0}. Ви впевнені?",
"MessageConfirmDeleteMetadataProvider": "Ви впевнені, що хочете видалити користувацького постачальника метаданих \"{0}\"?",
"MessageConfirmDeleteNotification": "Ви впевнені, що хочете видалити це сповіщення?",
"MessageConfirmDeleteSession": "Ви дійсно бажаєте видалити цей сеанс?",
"MessageConfirmEmbedMetadataInAudioFiles": "Ви впевнені, що хочете вставити метадані в {0} аудіофайлів?",
"MessageConfirmForceReScan": "Ви дійсно бажаєте примусово пересканувати?",
"MessageConfirmMarkAllEpisodesFinished": "Ви дійсно бажаєте позначити усі епізоди завершеними?",
"MessageConfirmMarkAllEpisodesNotFinished": "Ви дійсно бажаєте позначити усі епізоди незавершеними?",
"MessageConfirmMarkItemFinished": "Ви впевнені, що хочете позначити \"{0}\" як завершене?",
"MessageConfirmMarkItemNotFinished": "Ви впевнені, що хочете позначити \"{0}\" як незавершене?",
"MessageConfirmMarkSeriesFinished": "Ви дійсно бажаєте позначити усі книги серії завершеними?",
"MessageConfirmMarkSeriesNotFinished": "Ви дійсно бажаєте позначити всі книги серії незавершеними?",
"MessageConfirmNotificationTestTrigger": "Активувати це сповіщення з тестовими даними?",
"MessageConfirmPurgeCache": "Очищення кешу видалить усю теку <code>/metadata/cache</code>. <br /><br />Ви дійсно бажаєте видалити теку кешу?",
"MessageConfirmPurgeItemsCache": "Очищення кешу елементів видалить усю теку <code>/metadata/cache/items</code>. <br />Ви певні?",
"MessageConfirmQuickEmbed": "Увага! Швидке вбудування не створює резервних копій ваших аудіо. Переконайтеся, що маєте копію ваших файлів.<br><br>Продовжити?",
"MessageConfirmQuickMatchEpisodes": "При виявленні співпадінь інформація про епізоди швидкого пошуку буде перезаписана. Будуть оновлені тільки несуперечливі епізоди. Ви впевнені?",
"MessageConfirmReScanLibraryItems": "Ви дійсно бажаєте пересканувати елементи: {0}?",
"MessageConfirmRemoveAllChapters": "Ви дійсно бажаєте видалити усі глави?",
"MessageConfirmRemoveAuthor": "Ви дійсно бажаєте видалити автора \"{0}\"?",
@@ -657,6 +738,7 @@
"MessageConfirmRemoveEpisode": "Ви дійсно бажаєте видалити епізод \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Ви дійсно бажаєте видалити епізодів: {0}?",
"MessageConfirmRemoveListeningSessions": "Ви дійсно бажаєте видалити сеанси прослуховування: {0}?",
"MessageConfirmRemoveMetadataFiles": "Ви впевнені, що хочете видалити всі файли metadata.{0} у папках елементів вашої бібліотеки?",
"MessageConfirmRemoveNarrator": "Ви дійсно бажаєте видалити читця \"{0}\"?",
"MessageConfirmRemovePlaylist": "Ви дійсно бажаєте видалити список відтворення \"{0}\"?",
"MessageConfirmRenameGenre": "Ви дійсно бажаєте замінити жанр \"{0}\" на \"{1}\" для усіх елементів?",
@@ -665,11 +747,14 @@
"MessageConfirmRenameTag": "Ви дійсно бажаєте замінити мітку \"{0}\" на \"{1}\" для усіх елементів?",
"MessageConfirmRenameTagMergeNote": "Примітка: така мітка вже існує, тож їх буде об'єднано.",
"MessageConfirmRenameTagWarning": "Увага! Вже існує схожа мітка у іншому регістрі \"{0}\".",
"MessageConfirmResetProgress": "Ви впевнені, що хочете скинути свій прогрес?",
"MessageConfirmSendEbookToDevice": "Ви дійсно хочете відправити на пристрій \"{2}\" електроні книги: {0}, \"{1}\"?",
"MessageConfirmUnlinkOpenId": "Ви впевнені, що хочете відв'язати цього користувача від OpenID?",
"MessageDownloadingEpisode": "Завантаження епізоду",
"MessageDragFilesIntoTrackOrder": "Перетягніть файли до правильного порядку",
"MessageEmbedFailed": "Не вдалося вбудувати!",
"MessageEmbedFinished": "Вбудовано!",
"MessageEmbedQueue": "В черзі на вбудовування метаданих ({0} в черзі)",
"MessageEpisodesQueuedForDownload": "Епізодів у черзі завантаження: {0}",
"MessageEreaderDevices": "Аби гарантувати отримання електронних книг, вам може знадобитися додати вказану вище адресу електронної пошти як правильного відправника на кожному з пристроїв зі списку нижче.",
"MessageFeedURLWillBe": "URL-адреса каналу буде {0}",
@@ -700,6 +785,7 @@
"MessageNoCollections": "Добірки відсутні",
"MessageNoCoversFound": "Обкладинок не знайдено",
"MessageNoDescription": "Без опису",
"MessageNoDevices": "Немає пристроїв",
"MessageNoDownloadsInProgress": "Немає активних завантажень",
"MessageNoDownloadsQueued": "Немає завантажень у черзі",
"MessageNoEpisodeMatchesFound": "Відповідних епізодів не знайдено",
@@ -713,6 +799,7 @@
"MessageNoLogs": "Журнал порожній",
"MessageNoMediaProgress": "Прогрес відсутній",
"MessageNoNotifications": "Сповіщення відсутні",
"MessageNoPodcastFeed": "Невірний подкаст: Немає каналу",
"MessageNoPodcastsFound": "Подкастів не знайдено",
"MessageNoResults": "Немає результатів",
"MessageNoSearchResultsFor": "Немає результатів пошуку для \"{0}\"",
@@ -727,7 +814,12 @@
"MessagePauseChapter": "Призупинити відтворення глави",
"MessagePlayChapter": "Слухати початок глави",
"MessagePlaylistCreateFromCollection": "Створити список відтворення з добірки",
"MessagePleaseWait": "Будь ласка, зачекайте...",
"MessagePodcastHasNoRSSFeedForMatching": "Подкаст не має RSS-каналу для пошуку",
"MessagePodcastSearchField": "Введіть пошуковий запит або URL RSS-стрічки",
"MessageQuickEmbedInProgress": "Швидке вбудовування в процесі",
"MessageQuickEmbedQueue": "В черзі на швидке вбудовування ({0} в черзі)",
"MessageQuickMatchAllEpisodes": "Швидке співставлення всіх епізодів",
"MessageQuickMatchDescription": "Заповнити відсутні подробиці та обкладинку першим результатом пошуку '{0}'. Не перезаписує подробиці, якщо не увімкнено параметр \"Надавати перевагу віднайденим метаданим\".",
"MessageRemoveChapter": "Видалити главу",
"MessageRemoveEpisodes": "Видалити епізодів: {0}",
@@ -745,6 +837,41 @@
"MessageShareExpiresIn": "Сплине за {0}",
"MessageShareURLWillBe": "Поширюваний URL - <strong>{0}</strong>",
"MessageStartPlaybackAtTime": "Почати відтворення \"{0}\" з {1}?",
"MessageTaskAudioFileNotWritable": "Аудіофайл \"{0}\" недоступний для запису",
"MessageTaskCanceledByUser": "Задача скасована користувачем",
"MessageTaskDownloadingEpisodeDescription": "Завантаження епізоду \"{0}\"",
"MessageTaskEmbeddingMetadata": "Вбудовування метаданих",
"MessageTaskEmbeddingMetadataDescription": "Вбудовування метаданих у аудіокнигу \"{0}\"",
"MessageTaskEncodingM4b": "Кодування M4B",
"MessageTaskEncodingM4bDescription": "Кодування аудіокниги \"{0}\" в один файл m4b",
"MessageTaskFailed": "Неуспішно",
"MessageTaskFailedToBackupAudioFile": "Не вдалося створити резервну копію аудіофайлу \"{0}\"",
"MessageTaskFailedToCreateCacheDirectory": "Не вдалося створити каталог кешу",
"MessageTaskFailedToEmbedMetadataInFile": "Не вдалося вбудувати метадані у файл \"{0}\"",
"MessageTaskFailedToMergeAudioFiles": "Не вдалося об’єднати аудіофайли",
"MessageTaskFailedToMoveM4bFile": "Не вдалося перемістити файл m4b",
"MessageTaskFailedToWriteMetadataFile": "Не вдалося записати файл метаданих",
"MessageTaskMatchingBooksInLibrary": "Відповідність книг у бібліотеці \"{0}\"",
"MessageTaskNoFilesToScan": "Немає файлів для сканування",
"MessageTaskOpmlImport": "Імпорт OPML",
"MessageTaskOpmlImportDescription": "Створення подкастів з {0} RSS-стрічок",
"MessageTaskOpmlImportFeed": "Канал імпорту OPML",
"MessageTaskOpmlImportFeedDescription": "Імпорт RSS-каналу \"{0}\"",
"MessageTaskOpmlImportFeedFailed": "Не вдалося отримати подкаст-стрічку",
"MessageTaskOpmlImportFeedPodcastDescription": "Створення подкасту \"{0}\"",
"MessageTaskOpmlImportFeedPodcastExists": "Подкаст вже існує за цим шляхом",
"MessageTaskOpmlImportFeedPodcastFailed": "Не вдалося створити подкаст",
"MessageTaskOpmlImportFinished": "Додано {0} подкастів",
"MessageTaskOpmlParseFailed": "Не вдалося розібрати файл OPML",
"MessageTaskOpmlParseFastFail": "Невірний файл OPML: не знайдено тег <opml> або тег <outline>",
"MessageTaskOpmlParseNoneFound": "У файлі OPML не знайдено жодного канала",
"MessageTaskScanItemsAdded": "{0} додано",
"MessageTaskScanItemsMissing": "{0} відсутній",
"MessageTaskScanItemsUpdated": "{0} оновлено",
"MessageTaskScanNoChangesNeeded": "Змін не потрібно",
"MessageTaskScanningFileChanges": "Сканування змін файлів у \"{0}\"",
"MessageTaskScanningLibrary": "Сканування бібліотеки \"{0}\"",
"MessageTaskTargetDirectoryNotWritable": "Цільовий каталог недоступний для запису",
"MessageThinking": "Думаю…",
"MessageUploaderItemFailed": "Не вдалося завантажити",
"MessageUploaderItemSuccess": "Успішно завантажено!",
@@ -762,6 +889,10 @@
"NoteUploaderFoldersWithMediaFiles": "Теки з медіафайлами буде оброблено як окремі елементи бібліотеки.",
"NoteUploaderOnlyAudioFiles": "Якщо завантажувати лише аудіофайли, то кожен файл буде оброблено як окрему книгу.",
"NoteUploaderUnsupportedFiles": "Непідтримувані файли пропущено. Під час вибору або перетягування теки, файли, що знаходяться поза текою, пропускаються.",
"NotificationOnBackupCompletedDescription": "Запускається після завершення резервного копіювання",
"NotificationOnBackupFailedDescription": "Срабатывает при збої резервного копіювання",
"NotificationOnEpisodeDownloadedDescription": "Запускається при автоматичному завантаженні епізоду подкасту",
"NotificationOnTestDescription": "Подія для тестування системи сповіщень",
"PlaceholderNewCollection": "Нова назва добірки",
"PlaceholderNewFolderPath": "Новий шлях до теки",
"PlaceholderNewPlaylist": "Нова назва списку",
@@ -786,17 +917,29 @@
"StatsTotalDuration": "Загальною довжиною…",
"StatsYearInReview": "ОГЛЯД РОКУ",
"ToastAccountUpdateSuccess": "Профіль оновлено",
"ToastAppriseUrlRequired": "Необхідно ввести URL для Apprise",
"ToastAsinRequired": "ASIN є обов'язковим",
"ToastAuthorImageRemoveSuccess": "Фото автора видалено",
"ToastAuthorNotFound": "Автор \"{0}\" не знайдений",
"ToastAuthorRemoveSuccess": "Автор видалений",
"ToastAuthorSearchNotFound": "Автор не знайдений",
"ToastAuthorUpdateMerged": "Автора об'єднано",
"ToastAuthorUpdateSuccess": "Автора оновлено",
"ToastAuthorUpdateSuccessNoImageFound": "Автора оновлено (фото не знайдено)",
"ToastBackupAppliedSuccess": "Резервна копія застосована",
"ToastBackupCreateFailed": "Не вдалося створити резервну копію",
"ToastBackupCreateSuccess": "Резервну копію створено",
"ToastBackupDeleteFailed": "Не вдалося видалити резервну копію",
"ToastBackupDeleteSuccess": "Резервну копію видалено",
"ToastBackupInvalidMaxKeep": "Профіль оновленоПрофіль оновлено",
"ToastBackupInvalidMaxSize": "Невірний максимальний розмір резервної копії",
"ToastBackupRestoreFailed": "Не вдалося відновити резервну копію",
"ToastBackupUploadFailed": "Не вдалося завантажити резервну копію",
"ToastBackupUploadSuccess": "Резервну копію завантажено",
"ToastBatchDeleteFailed": "Помилка при пакетному видаленні",
"ToastBatchDeleteSuccess": "Пакетне видалення успішне",
"ToastBatchQuickMatchFailed": "Не вдалося виконати пакетне швидке співпадіння!",
"ToastBatchQuickMatchStarted": "Пакетне швидке співпадіння {0} книг розпочато!",
"ToastBatchUpdateFailed": "Не вдалося оновити обрані",
"ToastBatchUpdateSuccess": "Обрані успішно оновлено",
"ToastBookmarkCreateFailed": "Не вдалося створити закладку",
@@ -807,19 +950,43 @@
"ToastCachePurgeSuccess": "Кеш очищено",
"ToastChaptersHaveErrors": "Глави містять помилки",
"ToastChaptersMustHaveTitles": "Глави повинні мати назви",
"ToastChaptersRemoved": "Розділи видалені",
"ToastChaptersUpdated": "Розділи оновлені",
"ToastCollectionItemsAddFailed": "Не вдалося додати елемент(и) до колекції",
"ToastCollectionItemsAddSuccess": "Елемент(и) успішно додано до колекції",
"ToastCollectionItemsRemoveSuccess": "Елемент(и) видалено з добірки",
"ToastCollectionRemoveSuccess": "Добірку видалено",
"ToastCollectionUpdateSuccess": "Добірку оновлено",
"ToastCoverUpdateFailed": "Не вдалося оновити обкладинку",
"ToastDeleteFileFailed": "Не вдалося видалити файл",
"ToastDeleteFileSuccess": "Файл видалено",
"ToastDeviceAddFailed": "Не вдалося додати пристрій",
"ToastDeviceNameAlreadyExists": "Пристрій для електронних книг з таким ім'ям вже існує",
"ToastDeviceTestEmailFailed": "Не вдалося надіслати тестовий електронний лист",
"ToastDeviceTestEmailSuccess": "Тестовий електронний лист надіслано",
"ToastEmailSettingsUpdateSuccess": "Налаштування електронної пошти оновлено",
"ToastEncodeCancelFailed": "Не вдалося скасувати кодування",
"ToastEncodeCancelSucces": "Кодування скасовано",
"ToastEpisodeDownloadQueueClearFailed": "Не вдалося очистити чергу",
"ToastEpisodeDownloadQueueClearSuccess": "Чергу на завантаження епізодів очищено",
"ToastEpisodeUpdateSuccess": "{0} епізодів оновлено",
"ToastErrorCannotShare": "Не можна типово поширити на цей пристрій",
"ToastFailedToLoadData": "Не вдалося завантажити дані",
"ToastFailedToMatch": "Не вдалося знайти відповідність",
"ToastFailedToShare": "Не вдалося поділитися",
"ToastFailedToUpdate": "Не вдалося оновити",
"ToastInvalidImageUrl": "Невірний URL зображення",
"ToastInvalidMaxEpisodesToDownload": "Невірна кількість епізодів для завантаження",
"ToastInvalidUrl": "Невірний URL",
"ToastItemCoverUpdateSuccess": "Обкладинку елемента оновлено",
"ToastItemDeletedFailed": "Не вдалося видалити елемент",
"ToastItemDeletedSuccess": "Видалений елемент",
"ToastItemDetailsUpdateSuccess": "Подробиці про елемент оновлено",
"ToastItemMarkedAsFinishedFailed": "Не вдалося позначити як завершене",
"ToastItemMarkedAsFinishedSuccess": "Елемент позначено як завершений",
"ToastItemMarkedAsNotFinishedFailed": "Не вдалося позначити незавершеним",
"ToastItemMarkedAsNotFinishedSuccess": "Елемент позначено незавершеним",
"ToastItemUpdateSuccess": "Елемент оновлено",
"ToastLibraryCreateFailed": "Не вдалося створити бібліотеку",
"ToastLibraryCreateSuccess": "Бібліотеку \"{0}\" створено",
"ToastLibraryDeleteFailed": "Не вдалося видалити бібліотеку",
@@ -827,28 +994,83 @@
"ToastLibraryScanFailedToStart": "Не вдалося розпочати сканування",
"ToastLibraryScanStarted": "Почалося сканування бібліотеки",
"ToastLibraryUpdateSuccess": "Бібліотеку \"{0}\" оновлено",
"ToastMatchAllAuthorsFailed": "Не вдалось знайти відповідності з усіма авторами",
"ToastMetadataFilesRemovedError": "Помилка при видаленні metadata.{0} файли",
"ToastMetadataFilesRemovedNoneFound": "У бібліотеці не знайдено metadata.{0} файлів",
"ToastMetadataFilesRemovedNoneRemoved": "Не видалено metadata.{0} файлів",
"ToastMetadataFilesRemovedSuccess": "{0} metadata.{1} файлів видалено",
"ToastMustHaveAtLeastOnePath": "Повинен бути хоча б один шлях",
"ToastNameEmailRequired": "Ім'я та електронна пошта обов'язкові",
"ToastNameRequired": "Ім'я обов'язкове",
"ToastNewEpisodesFound": "{0} нових епізодів знайдено",
"ToastNewUserCreatedFailed": "Не вдалося створити акаунт: \"{0}\"",
"ToastNewUserCreatedSuccess": "Новий акаунт створено",
"ToastNewUserLibraryError": "Потрібно вибрати хоча б одну бібліотеку",
"ToastNewUserPasswordError": "Пароль обов'язковий, лише користувач з правами root може мати порожній пароль",
"ToastNewUserTagError": "Потрібно вибрати хоча б один тег",
"ToastNewUserUsernameError": "Введіть ім'я користувача",
"ToastNoNewEpisodesFound": "Нових епізодів не знайдено",
"ToastNoUpdatesNecessary": "Оновлення не потрібні",
"ToastNotificationCreateFailed": "Не вдалося створити сповіщення",
"ToastNotificationDeleteFailed": "Не вдалося видалити сповіщення",
"ToastNotificationFailedMaximum": "Максимальна кількість невдалих спроб повинна бути >= 0",
"ToastNotificationQueueMaximum": "Максимальна кількість сповіщень у черзі повинна бути >= 0",
"ToastNotificationSettingsUpdateSuccess": "Налаштування сповіщень оновлено",
"ToastNotificationTestTriggerFailed": "Не вдалося ініціювати тестове сповіщення",
"ToastNotificationTestTriggerSuccess": "Спрацьовувало сповіщення про тестування",
"ToastNotificationUpdateSuccess": "Сповіщення оновлено",
"ToastPlaylistCreateFailed": "Не вдалося створити список",
"ToastPlaylistCreateSuccess": "Список відтворення створено",
"ToastPlaylistRemoveSuccess": "Список відтворення видалено",
"ToastPlaylistUpdateSuccess": "Список відтворення оновлено",
"ToastPodcastCreateFailed": "Не вдалося створити подкаст",
"ToastPodcastCreateSuccess": "Подкаст успішно створено",
"ToastPodcastGetFeedFailed": "Не вдалося отримати фід подкасту",
"ToastPodcastNoEpisodesInFeed": "У RSS-каналі не знайдено епізодів",
"ToastPodcastNoRssFeed": "Подкаст не має RSS-каналу",
"ToastProgressIsNotBeingSynced": "Прогрес не синхронізується, перезапустіть відтворення",
"ToastProviderCreatedFailed": "Не вдалося додати постачальника",
"ToastProviderCreatedSuccess": "Новий постачальник доданий",
"ToastProviderNameAndUrlRequired": "Ім'я та URL обов'язкові",
"ToastProviderRemoveSuccess": "Постачальник видалений",
"ToastRSSFeedCloseFailed": "Не вдалося закрити RSS-канал",
"ToastRSSFeedCloseSuccess": "RSS-канал закрито",
"ToastRemoveFailed": "Не вдалося видалити",
"ToastRemoveItemFromCollectionFailed": "Не вдалося видалити елемент із добірки",
"ToastRemoveItemFromCollectionSuccess": "Елемент видалено з добірки",
"ToastRemoveItemsWithIssuesFailed": "Не вдалося видалити елементи бібліотеки з проблемами",
"ToastRemoveItemsWithIssuesSuccess": "Видалено елементи бібліотеки з проблемами",
"ToastRenameFailed": "Не вдалося перейменувати",
"ToastRescanFailed": "Не вдалося повторно сканувати для {0}",
"ToastRescanRemoved": "Повторне сканування завершено, елемент був видалений",
"ToastRescanUpToDate": "Повторне сканування завершено, елемент актуальний",
"ToastRescanUpdated": "Повторне сканування завершено, елемент оновлено",
"ToastScanFailed": "Не вдалося сканувати елемент бібліотеки",
"ToastSelectAtLeastOneUser": "Виберіть хоча б одного користувача",
"ToastSendEbookToDeviceFailed": "Не вдалося надіслати електронну книгу на пристрій",
"ToastSendEbookToDeviceSuccess": "Електронну книгу надіслано на пристрій \"{0}\"",
"ToastSeriesUpdateFailed": "Не вдалося оновити серію",
"ToastSeriesUpdateSuccess": "Серію успішно оновлено",
"ToastServerSettingsUpdateSuccess": "Налаштування сервера оновлено",
"ToastSessionCloseFailed": "Не вдалося закрити сесію",
"ToastSessionDeleteFailed": "Не вдалося видалити сесію",
"ToastSessionDeleteSuccess": "Сесію видалено",
"ToastSleepTimerDone": "Час сну завершено... зЗзЗз",
"ToastSlugMustChange": "Slug містить недопустимі символи",
"ToastSlugRequired": "Slug обов'язковий",
"ToastSocketConnected": "Сокет під'єднано",
"ToastSocketDisconnected": "Сокет від'єднано",
"ToastSocketFailedToConnect": "Не вдалося під'єднатися до сокета",
"ToastSortingPrefixesEmptyError": "Мусить мати хоча б 1 префікс сортування",
"ToastSortingPrefixesUpdateSuccess": "Префікси сортування оновлено ({0})",
"ToastTitleRequired": "Заголовок обов'язковий",
"ToastUnknownError": "Невідома помилка",
"ToastUnlinkOpenIdFailed": "Не вдалося відв'язати користувача від OpenID",
"ToastUnlinkOpenIdSuccess": "Користувача відв'язано від OpenID",
"ToastUserDeleteFailed": "Не вдалося видалити користувача",
"ToastUserDeleteSuccess": "Користувача видалено"
"ToastUserDeleteSuccess": "Користувача видалено",
"ToastUserPasswordChangeSuccess": "Пароль успішно змінено",
"ToastUserPasswordMismatch": "Паролі не збігаються",
"ToastUserPasswordMustChange": "Новий пароль не може співпадати з попереднім",
"ToastUserRootRequireName": "Потрібно ввести ім'я користувача root"
}

View File

@@ -71,7 +71,7 @@
"ButtonQuickMatch": "快速匹配",
"ButtonReScan": "重新扫描",
"ButtonRead": "读取",
"ButtonReadLess": "阅读少",
"ButtonReadLess": "阅读少",
"ButtonReadMore": "阅读更多",
"ButtonRefresh": "刷新",
"ButtonRemove": "移除",
@@ -220,7 +220,7 @@
"LabelAddToPlaylist": "添加到播放列表",
"LabelAddToPlaylistBatch": "添加 {0} 个项目到播放列表",
"LabelAddedAt": "添加于",
"LabelAddedDate": "添加 {0}",
"LabelAddedDate": "添加 {0}",
"LabelAdminUsersOnly": "仅限管理员用户",
"LabelAll": "全部",
"LabelAllUsers": "所有用户",
@@ -472,6 +472,7 @@
"LabelPermissionsAccessAllLibraries": "可以访问所有媒体库",
"LabelPermissionsAccessAllTags": "可以访问所有标签",
"LabelPermissionsAccessExplicitContent": "可以访问显式内容",
"LabelPermissionsCreateEreader": "可以创建电子阅读器",
"LabelPermissionsDelete": "可以删除",
"LabelPermissionsDownload": "可以下载",
"LabelPermissionsUpdate": "可以更新",
@@ -662,6 +663,7 @@
"LabelUpdateDetailsHelp": "找到匹配项时允许覆盖所选书籍存在的详细信息",
"LabelUpdatedAt": "更新时间",
"LabelUploaderDragAndDrop": "拖放文件或文件夹",
"LabelUploaderDragAndDropFilesOnly": "拖放文件",
"LabelUploaderDropFiles": "删除文件",
"LabelUploaderItemFetchMetadataHelp": "自动获取标题, 作者和系列",
"LabelUseAdvancedOptions": "使用高级选项",
@@ -677,6 +679,8 @@
"LabelViewPlayerSettings": "查看播放器设置",
"LabelViewQueue": "查看播放列表",
"LabelVolume": "音量",
"LabelWebRedirectURLsDescription": "在你的 OAuth 提供商中授权这些链接,以允许在登录后重定向回 Web 应用程序:",
"LabelWebRedirectURLsSubfolder": "重定向 URL 的子文件夹",
"LabelWeekdaysToRun": "工作日运行",
"LabelXBooks": "{0} 本书",
"LabelXItems": "{0} 项目",

View File

@@ -1,5 +1,5 @@
{
"ButtonAdd": "加",
"ButtonAdd": "加",
"ButtonAddChapters": "新增章節",
"ButtonAddDevice": "新增設備",
"ButtonAddLibrary": "新增庫",
@@ -17,7 +17,7 @@
"ButtonCheckAndDownloadNewEpisodes": "檢查並下載新劇集",
"ButtonChooseAFolder": "選擇資料夾",
"ButtonChooseFiles": "選擇檔案",
"ButtonClearFilter": "清過濾器",
"ButtonClearFilter": "清過濾器",
"ButtonCloseFeed": "關閉源",
"ButtonCloseSession": "關閉開放會話",
"ButtonCollections": "收藏",
@@ -35,6 +35,8 @@
"ButtonHide": "隱藏",
"ButtonHome": "首頁",
"ButtonIssues": "問題",
"ButtonJumpBackward": "向後跳轉",
"ButtonJumpForward": "向前跳轉",
"ButtonLatest": "最新",
"ButtonLibrary": "媒體庫",
"ButtonLogout": "登出",
@@ -53,6 +55,7 @@
"ButtonPlay": "播放",
"ButtonPlaying": "正在播放",
"ButtonPlaylists": "播放列表",
"ButtonPrevious": "上一個",
"ButtonPreviousChapter": "過去的章節",
"ButtonPurgeAllCache": "清理所有快取",
"ButtonPurgeItemsCache": "清理項目快取",
@@ -76,7 +79,7 @@
"ButtonSaveTracklist": "保存音軌列表",
"ButtonScan": "掃描",
"ButtonScanLibrary": "掃描庫",
"ButtonSearch": "查找",
"ButtonSearch": "搜索",
"ButtonSelectFolderPath": "選擇資料夾路徑",
"ButtonSeries": "系列",
"ButtonSetChaptersFromTracks": "將音軌設定為章節",
@@ -97,7 +100,7 @@
"ErrorUploadFetchMetadataAPI": "獲取元數據時出錯",
"ErrorUploadFetchMetadataNoResults": "無法獲取元數據 - 嘗試更新標題和/或作者",
"ErrorUploadLacksTitle": "必須有標題",
"HeaderAccount": "號",
"HeaderAccount": "號",
"HeaderAdvanced": "高級",
"HeaderAppriseNotificationSettings": "測試通知設定",
"HeaderAudioTracks": "音軌",
@@ -111,6 +114,7 @@
"HeaderCollectionItems": "收藏項目",
"HeaderCover": "封面",
"HeaderCurrentDownloads": "當前下載",
"HeaderCustomMessageOnLogin": "登錄時的自定義信息",
"HeaderCustomMetadataProviders": "自訂 Metadata 提供者",
"HeaderDetails": "詳情",
"HeaderDownloadQueue": "下載佇列",
@@ -144,7 +148,7 @@
"HeaderNewLibrary": "新建媒體庫",
"HeaderNotifications": "通知",
"HeaderOpenIDConnectAuthentication": "OpenID 連接身份驗證",
"HeaderOpenRSSFeed": "打開 RSS 源",
"HeaderOpenRSSFeed": "打開 Rss 源",
"HeaderOtherFiles": "其他檔案",
"HeaderPasswordAuthentication": "密碼認證",
"HeaderPermissions": "權限",
@@ -168,7 +172,7 @@
"HeaderSettingsExperimental": "實驗功能",
"HeaderSettingsGeneral": "通用",
"HeaderSettingsScanner": "掃描",
"HeaderSleepTimer": "睡眠時",
"HeaderSleepTimer": "睡眠時",
"HeaderStatsLargestItems": "最大的項目",
"HeaderStatsLongestItems": "項目時長(小時)",
"HeaderStatsMinutesListeningChart": "收聽分鐘數(最近7天)",
@@ -182,8 +186,12 @@
"HeaderUpdateDetails": "更新詳情",
"HeaderUpdateLibrary": "更新媒體庫",
"HeaderUsers": "使用者",
"HeaderYearReview": "{0} 年回顧",
"HeaderYourStats": "你的統計數據",
"LabelAbridged": "概要",
"LabelAbridgedChecked": "刪節版(已勾選)",
"LabelAbridgedUnchecked": "未刪節版(未勾選)",
"LabelAccessibleBy": "可訪問",
"LabelAccountType": "帳號類型",
"LabelAccountTypeAdmin": "管理員",
"LabelAccountTypeGuest": "來賓",
@@ -260,26 +268,32 @@
"LabelDownload": "下載",
"LabelDownloadNEpisodes": "下載 {0} 集",
"LabelDuration": "持續時間",
"LabelDurationComparisonExactMatch": "(完全匹配)",
"LabelDurationComparisonLonger": "{0} 更長)",
"LabelDurationComparisonShorter": "{0} 更短)",
"LabelDurationFound": "找到持續時間:",
"LabelEbook": "電子書",
"LabelEbooks": "電子書",
"LabelEdit": "編輯",
"LabelEmail": "郵箱",
"LabelEmailSettingsFromAddress": "發件人位址",
"LabelEmailSettingsRejectUnauthorized": "拒絕未經授權的證書",
"LabelEmailSettingsRejectUnauthorizedHelp": "停用 SSL 證書驗證可能會使您的連接暴露於安全風險中,例如中間人攻擊。僅在您了解其含義並信任您所連接的郵件伺服器的情況下才停用此選項。",
"LabelEmailSettingsSecure": "安全",
"LabelEmailSettingsSecureHelp": "如果選是, 則連接將在連接到伺服器時使用TLS. 如果選否, 則若伺服器支援STARTTLS擴展, 則使用TLS. 在大多數情況下, 如果連接到465埠, 請將該值設定為是. 對於587或25埠, 請保持為否. (來自nodemailer.com/smtp/#authentication)",
"LabelEmailSettingsTestAddress": "測試位址",
"LabelEmbeddedCover": "嵌入封面",
"LabelEnable": "啟用",
"LabelEnd": "結束",
"LabelEndOfChapter": "章節結束",
"LabelEpisode": "劇集",
"LabelEpisodeTitle": "劇集標題",
"LabelEpisodeType": "劇集類型",
"LabelExample": "示例",
"LabelExplicit": "信息準確",
"LabelFeedURL": "源 URL",
"LabelFeedURL": "源鏈接",
"LabelFetchingMetadata": "正在獲取元數據",
"LabelFile": "檔案",
"LabelFile": "文件",
"LabelFileBirthtime": "檔案創建時間",
"LabelFileModified": "檔案修改時間",
"LabelFilename": "檔名",
@@ -288,6 +302,7 @@
"LabelFinished": "已聽完",
"LabelFolder": "資料夾",
"LabelFolders": "資料夾",
"LabelFontBoldness": "字體粗細",
"LabelFontFamily": "字體系列",
"LabelFontItalic": "斜體",
"LabelFontScale": "字體比例",
@@ -353,7 +368,7 @@
"LabelMobileRedirectURIs": "允許移動應用重定向 URI",
"LabelMobileRedirectURIsDescription": "這是移動應用程序的有效重定向 URI 白名單. 預設值為 <code>audiobookshelf://oauth</code>,您可以刪除它或加入其他 URI 以進行第三方應用集成. 使用星號 (<code>*</code>) 作為唯一條目允許任何 URI.",
"LabelMore": "更多",
"LabelMoreInfo": "更多..",
"LabelMoreInfo": "更多信息",
"LabelName": "名稱",
"LabelNarrator": "講述者",
"LabelNarrators": "講述者",
@@ -399,7 +414,7 @@
"LabelPodcasts": "播客",
"LabelPort": "埠",
"LabelPrefixesToIgnore": "忽略的前綴 (不區分大小寫)",
"LabelPreventIndexing": "防止 iTunes 和 Google 播客目錄對你的源進行索引",
"LabelPreventIndexing": "防止您的訂閱源被 iTunes 和 Google 播客目錄索引",
"LabelPrimaryEbook": "主電子書",
"LabelProgress": "進度",
"LabelProvider": "供應商",
@@ -412,6 +427,7 @@
"LabelRSSFeedPreventIndexing": "防止索引",
"LabelRSSFeedSlug": "RSS 源段",
"LabelRSSFeedURL": "RSS 源 URL",
"LabelRandomly": "隨機",
"LabelRead": "閱讀",
"LabelReadAgain": "再次閱讀",
"LabelReadEbookWithoutProgress": "閱讀電子書而不保存進度",
@@ -635,20 +651,20 @@
"MessageNoFoldersAvailable": "沒有可用資料夾",
"MessageNoGenres": "無流派",
"MessageNoIssues": "無問題",
"MessageNoItems": "項目",
"MessageNoItemsFound": "找到任何項目",
"MessageNoListeningSessions": "收聽會話",
"MessageNoItems": "沒有項目",
"MessageNoItemsFound": "沒有找到任何項目",
"MessageNoListeningSessions": "沒有收聽會話",
"MessageNoLogs": "無日誌",
"MessageNoMediaProgress": "無媒體進度",
"MessageNoNotifications": "無通知",
"MessageNoPodcastsFound": "找到播客",
"MessageNoPodcastsFound": "沒有找到播客",
"MessageNoResults": "無結果",
"MessageNoSearchResultsFor": "沒有搜尋到結果 \"{0}\"",
"MessageNoSeries": "無系列",
"MessageNoTags": "無標籤",
"MessageNoTasksRunning": "沒有正在運行的任務",
"MessageNoUpdatesWereNecessary": "無需更新",
"MessageNoUserPlaylists": "沒有播放列表",
"MessageNoUserPlaylists": "沒有播放列表",
"MessageNotYetImplemented": "尚未實施",
"MessageOr": "或",
"MessagePauseChapter": "暫停章節播放",
@@ -660,7 +676,7 @@
"MessageRemoveEpisodes": "移除 {0} 劇集",
"MessageRemoveFromPlayerQueue": "從播放佇列中移除",
"MessageRemoveUserWarning": "是否確實要永久刪除使用者 \"{0}\"?",
"MessageReportBugsAndContribute": "報告錯誤、請求功能和貢獻",
"MessageReportBugsAndContribute": "報告錯誤、請求功能和做出貢獻",
"MessageResetChaptersConfirm": "你確定要重置章節並撤消你所做的更改嗎?",
"MessageRestoreBackupConfirm": "你確定要恢復創建的這個備份",
"MessageRestoreBackupWarning": "恢復備份將覆蓋位於 /config 的整個資料庫並覆蓋 /metadata/items & /metadata/authors 中的圖像.<br /><br />備份不會修改媒體庫資料夾中的任何檔案. 如果您已啟用伺服器設定將封面和元數據存儲在庫資料夾中,則不會備份或覆蓋這些內容.<br /><br />將自動刷新使用伺服器的所有客戶端.",
@@ -681,8 +697,8 @@
"NoteChangeRootPassword": "Root 是唯一可以擁有空密碼的使用者",
"NoteChapterEditorTimes": "注意: 第一章開始時間必須保持在 0:00, 最後一章開始時間不能超過有聲書持續時間.",
"NoteFolderPicker": "注意: 將不顯示已映射的資料夾",
"NoteRSSFeedPodcastAppsHttps": "警告: 大多數播客應用程序都需要 RSS 源 URL 使用 HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "警告: 您的一或多集沒有發布日期. 一些播客應用程要求這樣做.",
"NoteRSSFeedPodcastAppsHttps": "警告大多數播客應用程式要求 RSS 訂閱源 URL 使用 HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "警告您的一或多個劇集沒有發布日期。某些播客應用程要求提供此資訊。",
"NoteUploaderFoldersWithMediaFiles": "包含媒體檔案的資料夾將作為單獨的媒體庫項目處理.",
"NoteUploaderOnlyAudioFiles": "如果只上傳音頻檔, 則每個音頻檔將作為單獨的有聲書處理.",
"NoteUploaderUnsupportedFiles": "不支援的檔案將被忽略. 選擇或刪除資料夾時, 將忽略不在項目資料夾中的其他檔案.",
@@ -705,7 +721,7 @@
"ToastBackupUploadSuccess": "備份已上傳",
"ToastBatchUpdateFailed": "批量更新失敗",
"ToastBatchUpdateSuccess": "批量更新成功",
"ToastBookmarkCreateFailed": "創建書失敗",
"ToastBookmarkCreateFailed": "創建書失敗",
"ToastBookmarkCreateSuccess": "書籤已新增",
"ToastBookmarkRemoveSuccess": "書籤已刪除",
"ToastBookmarkUpdateSuccess": "書籤已更新",

View File

@@ -1,6 +1,4 @@
### EXAMPLE DOCKER COMPOSE ###
version: "3.7"
services:
audiobookshelf:
image: ghcr.io/advplyr/audiobookshelf:latest
@@ -23,8 +21,7 @@ services:
# you are running ABS on
- ./config:/config
restart: unless-stopped
# You can use the following environment variable to run the ABS
# You can use the following user directive to run the ABS
# docker container as a specific user. You will need to change
# the UID and GID to the correct values for your user.
#environment:
# - user=1000:1000
# user: 1000:1000

View File

@@ -11,6 +11,7 @@ if (isDev) {
if (devEnv.FFProbePath) process.env.FFPROBE_PATH = devEnv.FFProbePath
if (devEnv.NunicodePath) process.env.NUSQLITE3_PATH = devEnv.NunicodePath
if (devEnv.SkipBinariesCheck) process.env.SKIP_BINARIES_CHECK = '1'
if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1'
if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath
process.env.SOURCE = 'local'
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || ''

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "audiobookshelf",
"version": "2.16.0",
"version": "2.17.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf",
"version": "2.16.0",
"version": "2.17.5",
"license": "GPL-3.0",
"dependencies": {
"axios": "^0.27.2",

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "2.16.0",
"version": "2.17.5",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",

View File

@@ -41,6 +41,13 @@ Is there a feature you are looking for? [Suggest it](https://github.com/advplyr/
Join us on [Discord](https://discord.gg/HQgCbd6E75)
### Demo
Check out the web client demo: https://audiobooks.dev/ (thanks for hosting [@Vito0912](https://github.com/Vito0912)!)
Username/password: `demo`/`demo` (user account)
### Android App (beta)
Try it out on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app)
@@ -114,7 +121,7 @@ server {
proxy_pass http://<URL_to_forward_to>;
proxy_redirect http:// https://;
# Prevent 413 Request Entity Too Large error
# Prevent 413 Request Entity Too Large error
# by increasing the maximum allowed size of the client request body
# For example, set it to 10 GiB
client_max_body_size 10240M;
@@ -339,7 +346,7 @@ This application is built using [NodeJs](https://nodejs.org/).
### Localization
Thank you to [Weblate](https://hosted.weblate.org/engage/audiobookshelf/) for hosting our localization infrastructure pro-bono. If you want to see Audiobookshelf in your language, please help us localize. Additional information on helping with the translations [here](https://www.audiobookshelf.org/faq#how-do-i-help-with-translations). <a href="https://hosted.weblate.org/engage/audiobookshelf/"> <img src="https://hosted.weblate.org/widget/audiobookshelf/horizontal-auto.svg" alt="Translation status" /> </a>
Thank you to [Weblate](https://hosted.weblate.org/engage/audiobookshelf/) for hosting our localization infrastructure pro-bono. If you want to see Audiobookshelf in your language, please help us localize. Additional information on helping with the translations [here](https://www.audiobookshelf.org/faq#how-do-i-help-with-translations). <a href="https://hosted.weblate.org/engage/audiobookshelf/"> <img src="https://hosted.weblate.org/widget/audiobookshelf/abs-web-client/horizontal-auto.svg" alt="Translation status" /> </a>
### Dev Container Setup

View File

@@ -18,6 +18,26 @@ class Auth {
constructor() {
// Map of openId sessions indexed by oauth2 state-variable
this.openIdAuthSession = new Map()
this.ignorePatterns = [/\/api\/items\/[^/]+\/cover/, /\/api\/authors\/[^/]+\/image/]
}
/**
* Checks if the request should not be authenticated.
* @param {Request} req
* @returns {boolean}
* @private
*/
authNotNeeded(req) {
return req.method === 'GET' && this.ignorePatterns.some((pattern) => pattern.test(req.originalUrl))
}
ifAuthNeeded(middleware) {
return (req, res, next) => {
if (this.authNotNeeded(req)) {
return next()
}
middleware(req, res, next)
}
}
/**
@@ -111,7 +131,7 @@ class Auth {
{
client: openIdClient,
params: {
redirect_uri: '/auth/openid/callback',
redirect_uri: `${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`,
scope: 'openid profile email'
}
},
@@ -460,9 +480,9 @@ class Auth {
// for the request to mobile-redirect and as such the session is not shared
this.openIdAuthSession.set(state, { mobile_redirect_uri: req.query.redirect_uri })
redirectUri = new URL('/auth/openid/mobile-redirect', hostUrl).toString()
redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString()
} else {
redirectUri = new URL('/auth/openid/callback', hostUrl).toString()
redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, hostUrl).toString()
if (req.query.state) {
Logger.debug(`[Auth] Invalid state - not allowed on web openid flow`)
@@ -713,7 +733,7 @@ class Auth {
const host = req.get('host')
// TODO: ABS does currently not support subfolders for installation
// If we want to support it we need to include a config for the serverurl
postLogoutRedirectUri = `${protocol}://${host}/login`
postLogoutRedirectUri = `${protocol}://${host}${global.RouterBasePath}/login`
}
// else for openid-mobile we keep postLogoutRedirectUri on null
// nice would be to redirect to the app here, but for example Authentik does not implement
@@ -970,28 +990,18 @@ class Auth {
})
}
}
Database.userModel
.update(
{
pash: pw
},
{
where: { id: matchingUser.id }
}
)
.then(() => {
Logger.info(`[Auth] User "${matchingUser.username}" changed password`)
res.json({
success: true
})
try {
await matchingUser.update({ pash: pw })
Logger.info(`[Auth] User "${matchingUser.username}" changed password`)
res.json({
success: true
})
.catch((error) => {
Logger.error(`[Auth] User "${matchingUser.username}" failed to change password`, error)
res.json({
error: 'Unknown error'
})
} catch (error) {
Logger.error(`[Auth] User "${matchingUser.username}" failed to change password`, error)
res.json({
error: 'Unknown error'
})
}
}
}

View File

@@ -406,11 +406,6 @@ class Database {
return Promise.all(oldBooks.map((oldBook) => this.models.book.saveFromOld(oldBook)))
}
removeLibrary(libraryId) {
if (!this.sequelize) return false
return this.models.library.removeById(libraryId)
}
createBulkCollectionBooks(collectionBooks) {
if (!this.sequelize) return false
return this.models.collectionBook.bulkCreate(collectionBooks)
@@ -449,21 +444,6 @@ class Database {
return updated
}
async createFeed(oldFeed) {
if (!this.sequelize) return false
await this.models.feed.fullCreateFromOld(oldFeed)
}
updateFeed(oldFeed) {
if (!this.sequelize) return false
return this.models.feed.fullUpdateFromOld(oldFeed)
}
async removeFeed(feedId) {
if (!this.sequelize) return false
await this.models.feed.removeById(feedId)
}
async createBulkBookAuthors(bookAuthors) {
if (!this.sequelize) return false
await this.models.bookAuthor.bulkCreate(bookAuthors)

View File

@@ -62,7 +62,6 @@ class Server {
fs.mkdirSync(global.MetadataPath)
}
this.watcher = new Watcher()
this.auth = new Auth()
// Managers
@@ -70,9 +69,8 @@ class Server {
this.backupManager = new BackupManager()
this.abMergeManager = new AbMergeManager()
this.playbackSessionManager = new PlaybackSessionManager()
this.podcastManager = new PodcastManager(this.watcher)
this.podcastManager = new PodcastManager()
this.audioMetadataManager = new AudioMetadataMangaer()
this.rssFeedManager = new RssFeedManager()
this.cronManager = new CronManager(this.podcastManager, this.playbackSessionManager)
this.apiCacheManager = new ApiCacheManager()
this.binaryManager = new BinaryManager()
@@ -85,7 +83,6 @@ class Server {
Logger.logManager = new LogManager()
this.server = null
this.io = null
}
/**
@@ -139,7 +136,7 @@ class Server {
await ShareManager.init()
await this.backupManager.init()
await this.rssFeedManager.init()
await RssFeedManager.init()
const libraries = await Database.libraryModel.getAllWithFolders()
await this.cronManager.init(libraries)
@@ -147,9 +144,12 @@ class Server {
if (Database.serverSettings.scannerDisableWatcher) {
Logger.info(`[Server] Watcher is disabled`)
this.watcher.disabled = true
Watcher.disabled = true
} else {
this.watcher.initWatcher(libraries)
Watcher.initWatcher(libraries)
Watcher.on('scanFilesChanged', (pendingFileUpdates, pendingTask) => {
LibraryScanner.scanFilesChanged(pendingFileUpdates, pendingTask)
})
}
}
@@ -192,18 +192,23 @@ class Server {
const app = express()
/**
* @temporary
* This is necessary for the ebook & cover API endpoint in the mobile apps
* The mobile app ereader is using fetch api in Capacitor that is currently difficult to switch to native requests
* so we have to allow cors for specific origins to the /api/items/:id/ebook endpoint
* The cover image is fetched with XMLHttpRequest in the mobile apps to load into a canvas and extract colors
* @see https://ionicframework.com/docs/troubleshooting/cors
*
* Running in development allows cors to allow testing the mobile apps in the browser
* or env variable ALLOW_CORS = '1'
*/
app.use((req, res, next) => {
if (!global.ServerSettings.allowIframe) {
// Prevent clickjacking by disallowing iframes
res.setHeader('Content-Security-Policy', "frame-ancestors 'self'")
}
/**
* @temporary
* This is necessary for the ebook & cover API endpoint in the mobile apps
* The mobile app ereader is using fetch api in Capacitor that is currently difficult to switch to native requests
* so we have to allow cors for specific origins to the /api/items/:id/ebook endpoint
* The cover image is fetched with XMLHttpRequest in the mobile apps to load into a canvas and extract colors
* @see https://ionicframework.com/docs/troubleshooting/cors
*
* Running in development allows cors to allow testing the mobile apps in the browser
* or env variable ALLOW_CORS = '1'
*/
if (Logger.isDev || req.path.match(/\/api\/items\/([a-z0-9-]{36})\/(ebook|cover)(\/[0-9]+)?/)) {
const allowedOrigins = ['capacitor://localhost', 'http://localhost']
if (global.AllowCors || Logger.isDev || allowedOrigins.some((o) => o === req.get('origin'))) {
@@ -238,20 +243,23 @@ class Server {
// init passport.js
app.use(passport.initialize())
// register passport in express-session
app.use(passport.session())
app.use(this.auth.ifAuthNeeded(passport.session()))
// config passport.js
await this.auth.initPassportJs()
const router = express.Router()
// if RouterBasePath is set, modify all requests to include the base path
if (global.RouterBasePath) {
app.use((req, res, next) => {
if (!req.url.startsWith(global.RouterBasePath)) {
req.url = `${global.RouterBasePath}${req.url}`
}
next()
})
}
app.use((req, res, next) => {
const urlStartsWithRouterBasePath = req.url.startsWith(global.RouterBasePath)
const host = req.get('host')
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
const prefix = urlStartsWithRouterBasePath ? global.RouterBasePath : ''
req.originalHostPrefix = `${protocol}://${host}${prefix}`
if (!urlStartsWithRouterBasePath) {
req.url = `${global.RouterBasePath}${req.url}`
}
next()
})
app.use(global.RouterBasePath, router)
app.disable('x-powered-by')
@@ -268,6 +276,10 @@ class Server {
router.use(express.urlencoded({ extended: true, limit: '5mb' }))
router.use(express.json({ limit: '5mb' }))
router.use('/api', this.auth.ifAuthNeeded(this.authMiddleware.bind(this)), this.apiRouter.router)
router.use('/hls', this.authMiddleware.bind(this), this.hlsRouter.router)
router.use('/public', this.publicRouter.router)
// Static path to generated nuxt
const distPath = Path.join(global.appRoot, '/client/dist')
router.use(express.static(distPath))
@@ -275,28 +287,24 @@ class Server {
// Static folder
router.use(express.static(Path.join(global.appRoot, 'static')))
router.use('/api', this.authMiddleware.bind(this), this.apiRouter.router)
router.use('/hls', this.authMiddleware.bind(this), this.hlsRouter.router)
router.use('/public', this.publicRouter.router)
// RSS Feed temp route
router.get('/feed/:slug', (req, res) => {
Logger.info(`[Server] Requesting rss feed ${req.params.slug}`)
this.rssFeedManager.getFeed(req, res)
RssFeedManager.getFeed(req, res)
})
router.get('/feed/:slug/cover*', (req, res) => {
this.rssFeedManager.getFeedCover(req, res)
RssFeedManager.getFeedCover(req, res)
})
router.get('/feed/:slug/item/:episodeId/*', (req, res) => {
Logger.debug(`[Server] Requesting rss feed episode ${req.params.slug}/${req.params.episodeId}`)
this.rssFeedManager.getFeedItem(req, res)
RssFeedManager.getFeedItem(req, res)
})
// Auth routes
await this.auth.initAuthRoutes(router)
// Client dynamic routes
const dyanimicRoutes = [
const dynamicRoutes = [
'/item/:id',
'/author/:id',
'/audiobook/:id/chapters',
@@ -319,7 +327,7 @@ class Server {
'/playlist/:id',
'/share/:slug'
]
dyanimicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))
dynamicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))
router.post('/init', (req, res) => {
if (Database.hasRootUser) {
@@ -435,19 +443,12 @@ class Server {
*/
async stop() {
Logger.info('=== Stopping Server ===')
await this.watcher.close()
Logger.info('Watcher Closed')
return new Promise((resolve) => {
SocketAuthority.close((err) => {
if (err) {
Logger.error('Failed to close server', err)
} else {
Logger.info('Server successfully closed')
}
resolve()
})
})
Watcher.close()
Logger.info('[Server] Watcher Closed')
await SocketAuthority.close()
Logger.info('[Server] Closing HTTP Server')
await new Promise((resolve) => this.server.close(resolve))
Logger.info('[Server] HTTP Server Closed')
}
}
module.exports = Server

View File

@@ -14,7 +14,7 @@ const Auth = require('./Auth')
class SocketAuthority {
constructor() {
this.Server = null
this.io = null
this.socketIoServers = []
/** @type {Object.<string, SocketClient>} */
this.clients = {}
@@ -89,82 +89,104 @@ class SocketAuthority {
*
* @param {Function} callback
*/
close(callback) {
Logger.info('[SocketAuthority] Shutting down')
// This will close all open socket connections, and also close the underlying http server
if (this.io) this.io.close(callback)
else callback()
async close() {
Logger.info('[SocketAuthority] closing...')
const closePromises = this.socketIoServers.map((io) => {
return new Promise((resolve) => {
Logger.info(`[SocketAuthority] Closing Socket.IO server: ${io.path}`)
io.close(() => {
Logger.info(`[SocketAuthority] Socket.IO server closed: ${io.path}`)
resolve()
})
})
})
await Promise.all(closePromises)
Logger.info('[SocketAuthority] closed')
this.socketIoServers = []
}
initialize(Server) {
this.Server = Server
this.io = new SocketIO.Server(this.Server.server, {
const socketIoOptions = {
cors: {
origin: '*',
methods: ['GET', 'POST']
},
path: `${global.RouterBasePath}/socket.io`
})
this.io.on('connection', (socket) => {
this.clients[socket.id] = {
id: socket.id,
socket,
connected_at: Date.now()
}
socket.sheepClient = this.clients[socket.id]
}
Logger.info('[SocketAuthority] Socket Connected', socket.id)
const ioServer = new SocketIO.Server(Server.server, socketIoOptions)
ioServer.path = '/socket.io'
this.socketIoServers.push(ioServer)
// Required for associating a User with a socket
socket.on('auth', (token) => this.authenticateSocket(socket, token))
if (global.RouterBasePath) {
// open a separate socket.io server for the router base path, keeping the original server open for legacy clients
const ioBasePath = `${global.RouterBasePath}/socket.io`
const ioBasePathServer = new SocketIO.Server(Server.server, { ...socketIoOptions, path: ioBasePath })
ioBasePathServer.path = ioBasePath
this.socketIoServers.push(ioBasePathServer)
}
// Scanning
socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId))
// Logs
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))
// Sent automatically from socket.io clients
socket.on('disconnect', (reason) => {
Logger.removeSocketListener(socket.id)
const _client = this.clients[socket.id]
if (!_client) {
Logger.warn(`[SocketAuthority] Socket ${socket.id} disconnect, no client (Reason: ${reason})`)
} else if (!_client.user) {
Logger.info(`[SocketAuthority] Unauth socket ${socket.id} disconnected (Reason: ${reason})`)
delete this.clients[socket.id]
} else {
Logger.debug('[SocketAuthority] User Offline ' + _client.user.username)
this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions))
const disconnectTime = Date.now() - _client.connected_at
Logger.info(`[SocketAuthority] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`)
delete this.clients[socket.id]
this.socketIoServers.forEach((io) => {
io.on('connection', (socket) => {
this.clients[socket.id] = {
id: socket.id,
socket,
connected_at: Date.now()
}
})
socket.sheepClient = this.clients[socket.id]
//
// Events for testing
//
socket.on('message_all_users', (payload) => {
// admin user can send a message to all authenticated users
// displays on the web app as a toast
const client = this.clients[socket.id] || {}
if (client.user?.isAdminOrUp) {
this.emitter('admin_message', payload.message || '')
} else {
Logger.error(`[SocketAuthority] Non-admin user sent the message_all_users event`)
}
})
socket.on('ping', () => {
const client = this.clients[socket.id] || {}
const user = client.user || {}
Logger.debug(`[SocketAuthority] Received ping from socket ${user.username || 'No User'}`)
socket.emit('pong')
Logger.info(`[SocketAuthority] Socket Connected to ${io.path}`, socket.id)
// Required for associating a User with a socket
socket.on('auth', (token) => this.authenticateSocket(socket, token))
// Scanning
socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId))
// Logs
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))
// Sent automatically from socket.io clients
socket.on('disconnect', (reason) => {
Logger.removeSocketListener(socket.id)
const _client = this.clients[socket.id]
if (!_client) {
Logger.warn(`[SocketAuthority] Socket ${socket.id} disconnect, no client (Reason: ${reason})`)
} else if (!_client.user) {
Logger.info(`[SocketAuthority] Unauth socket ${socket.id} disconnected (Reason: ${reason})`)
delete this.clients[socket.id]
} else {
Logger.debug('[SocketAuthority] User Offline ' + _client.user.username)
this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions))
const disconnectTime = Date.now() - _client.connected_at
Logger.info(`[SocketAuthority] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`)
delete this.clients[socket.id]
}
})
//
// Events for testing
//
socket.on('message_all_users', (payload) => {
// admin user can send a message to all authenticated users
// displays on the web app as a toast
const client = this.clients[socket.id] || {}
if (client.user?.isAdminOrUp) {
this.emitter('admin_message', payload.message || '')
} else {
Logger.error(`[SocketAuthority] Non-admin user sent the message_all_users event`)
}
})
socket.on('ping', () => {
const client = this.clients[socket.id] || {}
const user = client.user || {}
Logger.debug(`[SocketAuthority] Received ping from socket ${user.username || 'No User'}`)
socket.emit('pong')
})
})
})
}

View File

@@ -2,7 +2,6 @@ const Path = require('path')
const EventEmitter = require('events')
const Watcher = require('./libs/watcher/watcher')
const Logger = require('./Logger')
const LibraryScanner = require('./scanner/LibraryScanner')
const Task = require('./objects/Task')
const TaskManager = require('./managers/TaskManager')
@@ -31,6 +30,8 @@ class FolderWatcher extends EventEmitter {
this.filesBeingAdded = new Set()
/** @type {Set<string>} */
this.ignoreFilePathsDownloading = new Set()
/** @type {string[]} */
this.ignoreDirs = []
/** @type {string[]} */
@@ -333,7 +334,7 @@ class FolderWatcher extends EventEmitter {
}
if (this.pendingFileUpdates.length) {
LibraryScanner.scanFilesChanged(this.pendingFileUpdates, this.pendingTask)
this.emit('scanFilesChanged', this.pendingFileUpdates, this.pendingTask)
} else {
const taskFinishedString = {
text: 'No files to scan',
@@ -348,12 +349,29 @@ class FolderWatcher extends EventEmitter {
}, this.pendingDelay)
}
/**
*
* @param {string} path
* @returns {boolean}
*/
checkShouldIgnorePath(path) {
return !!this.ignoreDirs.find((dirpath) => {
return isSameOrSubPath(dirpath, path)
})
}
/**
* When scanning a library item folder these files should be ignored
* Either a podcast episode downloading or a file that is pending by the watcher
*
* @param {string} path
* @returns {boolean}
*/
checkShouldIgnoreFilePath(path) {
if (this.pendingFilePaths.includes(path)) return true
return this.ignoreFilePathsDownloading.has(path)
}
/**
* Convert to POSIX and remove trailing slash
* @param {string} path
@@ -409,4 +427,4 @@ class FolderWatcher extends EventEmitter {
}, 5000)
}
}
module.exports = FolderWatcher
module.exports = new FolderWatcher()

View File

@@ -381,16 +381,23 @@ class AuthorController {
*/
async getImage(req, res) {
const {
query: { width, height, format, raw },
author
query: { width, height, format, raw }
} = req
if (!author.imagePath || !(await fs.pathExists(author.imagePath))) {
Logger.warn(`[AuthorController] Author "${author.name}" has invalid imagePath: ${author.imagePath}`)
return res.sendStatus(404)
}
const authorId = req.params.id
if (raw) {
const author = await Database.authorModel.findByPk(authorId)
if (!author) {
Logger.warn(`[AuthorController] Author "${authorId}" not found`)
return res.sendStatus(404)
}
if (!author.imagePath || !(await fs.pathExists(author.imagePath))) {
Logger.warn(`[AuthorController] Author "${author.name}" has invalid imagePath: ${author.imagePath}`)
return res.sendStatus(404)
}
return res.sendFile(author.imagePath)
}
@@ -399,7 +406,7 @@ class AuthorController {
height: height ? parseInt(height) : null,
width: width ? parseInt(width) : null
}
return CacheManager.handleAuthorCache(res, author, options)
return CacheManager.handleAuthorCache(res, authorId, options)
}
/**

View File

@@ -4,6 +4,7 @@ const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const RssFeedManager = require('../managers/RssFeedManager')
const Collection = require('../objects/Collection')
/**
@@ -115,6 +116,7 @@ class CollectionController {
}
// If books array is passed in then update order in collection
let collectionBooksUpdated = false
if (req.body.books?.length) {
const collectionBooks = await req.collection.getCollectionBooks({
include: {
@@ -133,9 +135,15 @@ class CollectionController {
await collectionBooks[i].update({
order: i + 1
})
wasUpdated = true
collectionBooksUpdated = true
}
}
if (collectionBooksUpdated) {
req.collection.changed('updatedAt', true)
await req.collection.save()
wasUpdated = true
}
}
const jsonExpanded = await req.collection.getOldJsonExpanded()
@@ -148,6 +156,8 @@ class CollectionController {
/**
* DELETE: /api/collections/:id
*
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithUser} req
* @param {Response} res
*/
@@ -155,7 +165,7 @@ class CollectionController {
const jsonExpanded = await req.collection.getOldJsonExpanded()
// Close rss feed - remove from db and emit socket event
await this.rssFeedManager.closeFeedForEntityId(req.collection.id)
await RssFeedManager.closeFeedForEntityId(req.collection.id)
await req.collection.destroy()

View File

@@ -17,6 +17,9 @@ const naturalSort = createNewSortInstance({
const LibraryScanner = require('../scanner/LibraryScanner')
const Scanner = require('../scanner/Scanner')
const Database = require('../Database')
const Watcher = require('../Watcher')
const RssFeedManager = require('../managers/RssFeedManager')
const libraryFilters = require('../utils/queries/libraryFilters')
const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')
const authorFilters = require('../utils/queries/authorFilters')
@@ -158,7 +161,7 @@ class LibraryController {
SocketAuthority.emitter('library_added', library.toOldJSON(), userFilter)
// Add library watcher
this.watcher.addLibrary(library)
Watcher.addLibrary(library)
res.json(library.toOldJSON())
}
@@ -235,12 +238,14 @@ class LibraryController {
for (const key of keysToCheck) {
if (!req.body[key]) continue
if (typeof req.body[key] !== 'string') {
Logger.error(`[LibraryController] Invalid request. ${key} must be a string`)
return res.status(400).send(`Invalid request. ${key} must be a string`)
}
updatePayload[key] = req.body[key]
}
if (req.body.displayOrder !== undefined) {
if (isNaN(req.body.displayOrder)) {
Logger.error(`[LibraryController] Invalid request. displayOrder must be a number`)
return res.status(400).send('Invalid request. displayOrder must be a number')
}
updatePayload.displayOrder = req.body.displayOrder
@@ -259,6 +264,13 @@ class LibraryController {
const updatedSettings = {
...(req.library.settings || defaultLibrarySettings)
}
// In case new settings are added in the future, ensure all settings are present
for (const key in defaultLibrarySettings) {
if (updatedSettings[key] === undefined) {
updatedSettings[key] = defaultLibrarySettings[key]
}
}
let hasUpdates = false
let hasUpdatedDisableWatcher = false
let hasUpdatedScanCron = false
@@ -270,6 +282,7 @@ class LibraryController {
if (key === 'metadataPrecedence') {
if (!Array.isArray(req.body.settings[key])) {
Logger.error(`[LibraryController] Invalid request. Settings "metadataPrecedence" must be an array`)
return res.status(400).send('Invalid request. Settings "metadataPrecedence" must be an array')
}
if (JSON.stringify(req.body.settings[key]) !== JSON.stringify(updatedSettings[key])) {
@@ -279,6 +292,7 @@ class LibraryController {
}
} else if (key === 'autoScanCronExpression' || key === 'podcastSearchRegion') {
if (req.body.settings[key] !== null && typeof req.body.settings[key] !== 'string') {
Logger.error(`[LibraryController] Invalid request. Settings "${key}" must be a string`)
return res.status(400).send(`Invalid request. Settings "${key}" must be a string`)
}
if (req.body.settings[key] !== updatedSettings[key]) {
@@ -290,8 +304,10 @@ class LibraryController {
}
} else if (key === 'markAsFinishedPercentComplete') {
if (req.body.settings[key] !== null && isNaN(req.body.settings[key])) {
Logger.error(`[LibraryController] Invalid request. Setting "${key}" must be a number`)
return res.status(400).send(`Invalid request. Setting "${key}" must be a number`)
} else if (req.body.settings[key] !== null && (Number(req.body.settings[key]) < 0 || Number(req.body.settings[key]) > 100)) {
Logger.error(`[LibraryController] Invalid request. Setting "${key}" must be between 0 and 100`)
return res.status(400).send(`Invalid request. Setting "${key}" must be between 0 and 100`)
}
if (req.body.settings[key] !== updatedSettings[key]) {
@@ -301,8 +317,10 @@ class LibraryController {
}
} else if (key === 'markAsFinishedTimeRemaining') {
if (req.body.settings[key] !== null && isNaN(req.body.settings[key])) {
Logger.error(`[LibraryController] Invalid request. Setting "${key}" must be a number`)
return res.status(400).send(`Invalid request. Setting "${key}" must be a number`)
} else if (req.body.settings[key] !== null && Number(req.body.settings[key]) < 0) {
Logger.error(`[LibraryController] Invalid request. Setting "${key}" must be greater than or equal to 0`)
return res.status(400).send(`Invalid request. Setting "${key}" must be greater than or equal to 0`)
}
if (req.body.settings[key] !== updatedSettings[key]) {
@@ -312,6 +330,7 @@ class LibraryController {
}
} else {
if (typeof req.body.settings[key] !== typeof updatedSettings[key]) {
Logger.error(`[LibraryController] Invalid request. Setting "${key}" must be of type ${typeof updatedSettings[key]}`)
return res.status(400).send(`Invalid request. Setting "${key}" must be of type ${typeof updatedSettings[key]}`)
}
if (req.body.settings[key] !== updatedSettings[key]) {
@@ -353,6 +372,7 @@ class LibraryController {
return false
})
if (!success) {
Logger.error(`[LibraryController] Invalid folder directory "${path}"`)
return res.status(400).send(`Invalid folder directory "${path}"`)
}
}
@@ -382,19 +402,48 @@ class LibraryController {
model: Database.podcastEpisodeModel,
attributes: ['id']
}
},
{
model: Database.bookModel,
attributes: ['id'],
include: [
{
model: Database.bookAuthorModel,
attributes: ['authorId']
},
{
model: Database.bookSeriesModel,
attributes: ['seriesId']
}
]
}
]
})
Logger.info(`[LibraryController] Removed folder "${folder.path}" from library "${req.library.name}" with ${libraryItemsInFolder.length} library items`)
const seriesIds = []
const authorIds = []
for (const libraryItem of libraryItemsInFolder) {
let mediaItemIds = []
if (req.library.isPodcast) {
mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id)
} else {
mediaItemIds.push(libraryItem.mediaId)
if (libraryItem.media.bookAuthors.length) {
authorIds.push(...libraryItem.media.bookAuthors.map((ba) => ba.authorId))
}
if (libraryItem.media.bookSeries.length) {
seriesIds.push(...libraryItem.media.bookSeries.map((bs) => bs.seriesId))
}
}
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from folder "${folder.path}"`)
await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds)
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
}
if (authorIds.length) {
await this.checkRemoveAuthorsWithNoBooks(authorIds)
}
if (seriesIds.length) {
await this.checkRemoveEmptySeries(seriesIds)
}
// Remove folder
@@ -423,7 +472,7 @@ class LibraryController {
req.library.libraryFolders = await req.library.getLibraryFolders()
// Update watcher
this.watcher.updateLibrary(req.library)
Watcher.updateLibrary(req.library)
hasUpdates = true
}
@@ -449,7 +498,7 @@ class LibraryController {
*/
async delete(req, res) {
// Remove library watcher
this.watcher.removeLibrary(req.library)
Watcher.removeLibrary(req.library)
// Remove collections for library
const numCollectionsRemoved = await Database.collectionModel.removeAllForLibrary(req.library.id)
@@ -483,11 +532,24 @@ class LibraryController {
mediaItemIds.push(libraryItem.mediaId)
}
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from library "${req.library.name}"`)
await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds)
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
}
// Set PlaybackSessions libraryId to null
const [sessionsUpdated] = await Database.playbackSessionModel.update(
{
libraryId: null
},
{
where: {
libraryId: req.library.id
}
}
)
Logger.info(`[LibraryController] Updated ${sessionsUpdated} playback sessions to remove library id`)
const libraryJson = req.library.toOldJSON()
await Database.removeLibrary(req.library.id)
await req.library.destroy()
// Re-order libraries
await Database.libraryModel.resetDisplayOrder()
@@ -549,6 +611,8 @@ class LibraryController {
* DELETE: /api/libraries/:id/issues
* Remove all library items missing or invalid
*
* @this {import('../routers/ApiRouter')}
*
* @param {LibraryControllerRequest} req
* @param {Response} res
*/
@@ -574,6 +638,20 @@ class LibraryController {
model: Database.podcastEpisodeModel,
attributes: ['id']
}
},
{
model: Database.bookModel,
attributes: ['id'],
include: [
{
model: Database.bookAuthorModel,
attributes: ['authorId']
},
{
model: Database.bookSeriesModel,
attributes: ['seriesId']
}
]
}
]
})
@@ -584,15 +662,30 @@ class LibraryController {
}
Logger.info(`[LibraryController] Removing ${libraryItemsWithIssues.length} items with issues`)
const authorIds = []
const seriesIds = []
for (const libraryItem of libraryItemsWithIssues) {
let mediaItemIds = []
if (req.library.isPodcast) {
mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id)
} else {
mediaItemIds.push(libraryItem.mediaId)
if (libraryItem.media.bookAuthors.length) {
authorIds.push(...libraryItem.media.bookAuthors.map((ba) => ba.authorId))
}
if (libraryItem.media.bookSeries.length) {
seriesIds.push(...libraryItem.media.bookSeries.map((bs) => bs.seriesId))
}
}
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" with issue`)
await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds)
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
}
if (authorIds.length) {
await this.checkRemoveAuthorsWithNoBooks(authorIds)
}
if (seriesIds.length) {
await this.checkRemoveEmptySeries(seriesIds)
}
// Set numIssues to 0 for library filter data
@@ -668,8 +761,8 @@ class LibraryController {
}
if (include.includes('rssfeed')) {
const feedObj = await this.rssFeedManager.findFeedForEntityId(seriesJson.id)
seriesJson.rssFeed = feedObj?.toJSONMinified() || null
const feedObj = await RssFeedManager.findFeedForEntityId(seriesJson.id)
seriesJson.rssFeed = feedObj?.toOldJSONMinified() || null
}
res.json(seriesJson)

View File

@@ -13,6 +13,8 @@ const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUti
const LibraryItemScanner = require('../scanner/LibraryItemScanner')
const AudioFileScanner = require('../scanner/AudioFileScanner')
const Scanner = require('../scanner/Scanner')
const RssFeedManager = require('../managers/RssFeedManager')
const CacheManager = require('../managers/CacheManager')
const CoverManager = require('../managers/CoverManager')
const ShareManager = require('../managers/ShareManager')
@@ -48,8 +50,8 @@ class LibraryItemController {
}
if (includeEntities.includes('rssfeed')) {
const feedData = await this.rssFeedManager.findFeedForEntityId(item.id)
item.rssFeed = feedData?.toJSONMinified() || null
const feedData = await RssFeedManager.findFeedForEntityId(item.id)
item.rssFeed = feedData?.toOldJSONMinified() || null
}
if (item.mediaType === 'book' && req.user.isAdminOrUp && includeEntities.includes('share')) {
@@ -96,6 +98,8 @@ class LibraryItemController {
* Optional query params:
* ?hard=1
*
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithUser} req
* @param {Response} res
*/
@@ -103,18 +107,50 @@ class LibraryItemController {
const hardDelete = req.query.hard == 1 // Delete from file system
const libraryItemPath = req.libraryItem.path
const mediaItemIds = req.libraryItem.mediaType === 'podcast' ? req.libraryItem.media.episodes.map((ep) => ep.id) : [req.libraryItem.media.id]
await this.handleDeleteLibraryItem(req.libraryItem.mediaType, req.libraryItem.id, mediaItemIds)
const mediaItemIds = []
const authorIds = []
const seriesIds = []
if (req.libraryItem.isPodcast) {
mediaItemIds.push(...req.libraryItem.media.episodes.map((ep) => ep.id))
} else {
mediaItemIds.push(req.libraryItem.media.id)
if (req.libraryItem.media.metadata.authors?.length) {
authorIds.push(...req.libraryItem.media.metadata.authors.map((au) => au.id))
}
if (req.libraryItem.media.metadata.series?.length) {
seriesIds.push(...req.libraryItem.media.metadata.series.map((se) => se.id))
}
}
await this.handleDeleteLibraryItem(req.libraryItem.id, mediaItemIds)
if (hardDelete) {
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
await fs.remove(libraryItemPath).catch((error) => {
Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
})
}
if (authorIds.length) {
await this.checkRemoveAuthorsWithNoBooks(authorIds)
}
if (seriesIds.length) {
await this.checkRemoveEmptySeries(seriesIds)
}
await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId)
res.sendStatus(200)
}
static handleDownloadError(error, res) {
if (!res.headersSent) {
if (error.code === 'ENOENT') {
return res.status(404).send('File not found')
} else {
return res.status(500).send('Download failed')
}
}
}
/**
* GET: /api/items/:id/download
* Download library item. Zip file if multiple files.
@@ -122,7 +158,7 @@ class LibraryItemController {
* @param {RequestWithUser} req
* @param {Response} res
*/
download(req, res) {
async download(req, res) {
if (!req.user.canDownload) {
Logger.warn(`User "${req.user.username}" attempted to download without permission`)
return res.sendStatus(403)
@@ -130,21 +166,26 @@ class LibraryItemController {
const libraryItemPath = req.libraryItem.path
const itemTitle = req.libraryItem.media.metadata.title
// If library item is a single file in root dir then no need to zip
if (req.libraryItem.isFile) {
// Express does not set the correct mimetype for m4b files so use our defined mimetypes if available
const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(libraryItemPath))
if (audioMimeType) {
res.setHeader('Content-Type', audioMimeType)
}
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${itemTitle}" at "${libraryItemPath}"`)
res.download(libraryItemPath, req.libraryItem.relPath)
return
}
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${itemTitle}" at "${libraryItemPath}"`)
const filename = `${itemTitle}.zip`
zipHelpers.zipDirectoryPipe(libraryItemPath, filename, res)
try {
// If library item is a single file in root dir then no need to zip
if (req.libraryItem.isFile) {
// Express does not set the correct mimetype for m4b files so use our defined mimetypes if available
const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(libraryItemPath))
if (audioMimeType) {
res.setHeader('Content-Type', audioMimeType)
}
await new Promise((resolve, reject) => res.download(libraryItemPath, req.libraryItem.relPath, (error) => (error ? reject(error) : resolve())))
} else {
const filename = `${itemTitle}.zip`
await zipHelpers.zipDirectoryPipe(libraryItemPath, filename, res)
}
Logger.info(`[LibraryItemController] Downloaded item "${itemTitle}" at "${libraryItemPath}"`)
} catch (error) {
Logger.error(`[LibraryItemController] Download failed for item "${itemTitle}" at "${libraryItemPath}"`, error)
LibraryItemController.handleDownloadError(error, res)
}
}
/**
@@ -197,15 +238,6 @@ class LibraryItemController {
if (hasUpdates) {
libraryItem.updatedAt = Date.now()
if (seriesRemoved.length) {
// Check remove empty series
Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`)
await this.checkRemoveEmptySeries(
libraryItem.media.id,
seriesRemoved.map((se) => se.id)
)
}
if (isPodcastAutoDownloadUpdated) {
this.cronManager.checkUpdatePodcastCron(libraryItem)
}
@@ -217,10 +249,12 @@ class LibraryItemController {
if (authorsRemoved.length) {
// Check remove empty authors
Logger.debug(`[LibraryItemController] Authors were removed from book. Check if authors are now empty.`)
await this.checkRemoveAuthorsWithNoBooks(
libraryItem.libraryId,
authorsRemoved.map((au) => au.id)
)
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({
@@ -327,44 +361,25 @@ class LibraryItemController {
query: { width, height, format, raw }
} = req
const libraryItem = await Database.libraryItemModel.findByPk(req.params.id, {
attributes: ['id', 'mediaType', 'mediaId', 'libraryId'],
include: [
{
model: Database.bookModel,
attributes: ['id', 'coverPath', 'tags', 'explicit']
},
{
model: Database.podcastModel,
attributes: ['id', 'coverPath', 'tags', 'explicit']
}
]
})
if (!libraryItem) {
Logger.warn(`[LibraryItemController] getCover: Library item "${req.params.id}" does not exist`)
return res.sendStatus(404)
}
// Check if user can access this library item
if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
return res.sendStatus(403)
}
// Check if library item media has a cover path
if (!libraryItem.media.coverPath || !(await fs.pathExists(libraryItem.media.coverPath))) {
return res.sendStatus(404)
}
if (req.query.ts) res.set('Cache-Control', 'private, max-age=86400')
const libraryItemId = req.params.id
if (!libraryItemId) {
return res.sendStatus(400)
}
if (raw) {
const coverPath = await Database.libraryItemModel.getCoverPath(libraryItemId)
if (!coverPath || !(await fs.pathExists(coverPath))) {
return res.sendStatus(404)
}
// any value
if (global.XAccel) {
const encodedURI = encodeUriPath(global.XAccel + libraryItem.media.coverPath)
const encodedURI = encodeUriPath(global.XAccel + coverPath)
Logger.debug(`Use X-Accel to serve static file ${encodedURI}`)
return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send()
}
return res.sendFile(libraryItem.media.coverPath)
return res.sendFile(coverPath)
}
const options = {
@@ -372,7 +387,7 @@ class LibraryItemController {
height: height ? parseInt(height) : null,
width: width ? parseInt(width) : null
}
return CacheManager.handleCoverCache(res, libraryItem.id, libraryItem.media.coverPath, options)
return CacheManager.handleCoverCache(res, libraryItemId, options)
}
/**
@@ -454,6 +469,8 @@ class LibraryItemController {
* Optional query params:
* ?hard=1
*
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithUser} req
* @param {Response} res
*/
@@ -481,14 +498,33 @@ class LibraryItemController {
for (const libraryItem of itemsToDelete) {
const libraryItemPath = libraryItem.path
Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.metadata.title}" with id "${libraryItem.id}"`)
const mediaItemIds = libraryItem.mediaType === 'podcast' ? libraryItem.media.episodes.map((ep) => ep.id) : [libraryItem.media.id]
await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds)
const mediaItemIds = []
const seriesIds = []
const authorIds = []
if (libraryItem.isPodcast) {
mediaItemIds.push(...libraryItem.media.episodes.map((ep) => ep.id))
} else {
mediaItemIds.push(libraryItem.media.id)
if (libraryItem.media.metadata.series?.length) {
seriesIds.push(...libraryItem.media.metadata.series.map((se) => se.id))
}
if (libraryItem.media.metadata.authors?.length) {
authorIds.push(...libraryItem.media.metadata.authors.map((au) => au.id))
}
}
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
if (hardDelete) {
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
await fs.remove(libraryItemPath).catch((error) => {
Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
})
}
if (seriesIds.length) {
await this.checkRemoveEmptySeries(seriesIds)
}
if (authorIds.length) {
await this.checkRemoveAuthorsWithNoBooks(authorIds)
}
}
await Database.resetLibraryIssuesFilterData(libraryId)
@@ -498,48 +534,74 @@ class LibraryItemController {
/**
* POST: /api/items/batch/update
*
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async batchUpdate(req, res) {
const updatePayloads = req.body
if (!updatePayloads?.length) {
return res.sendStatus(500)
if (!Array.isArray(updatePayloads) || !updatePayloads.length) {
Logger.error(`[LibraryItemController] Batch update failed. Invalid payload`)
return res.sendStatus(400)
}
// Ensure that each update payload has a unique library item id
const libraryItemIds = [...new Set(updatePayloads.map((up) => up?.id).filter((id) => id))]
if (!libraryItemIds.length || libraryItemIds.length !== updatePayloads.length) {
Logger.error(`[LibraryItemController] Batch update failed. Each update payload must have a unique library item id`)
return res.sendStatus(400)
}
// Get all library items to update
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({
id: libraryItemIds
})
if (updatePayloads.length !== libraryItems.length) {
Logger.error(`[LibraryItemController] Batch update failed. Not all library items found`)
return res.sendStatus(404)
}
let itemsUpdated = 0
const seriesIdsRemoved = []
const authorIdsRemoved = []
for (const updatePayload of updatePayloads) {
const mediaPayload = updatePayload.mediaPayload
const libraryItem = await Database.libraryItemModel.getOldById(updatePayload.id)
if (!libraryItem) return null
const libraryItem = libraryItems.find((li) => li.id === updatePayload.id)
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId)
let seriesRemoved = []
if (libraryItem.isBook && mediaPayload.metadata?.series) {
const seriesIdsInUpdate = (mediaPayload.metadata?.series || []).map((se) => se.id)
seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id))
if (libraryItem.isBook) {
if (Array.isArray(mediaPayload.metadata?.series)) {
const seriesIdsInUpdate = mediaPayload.metadata.series.map((se) => se.id)
const seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id))
seriesIdsRemoved.push(...seriesRemoved.map((se) => se.id))
}
if (Array.isArray(mediaPayload.metadata?.authors)) {
const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id)
const authorsRemoved = libraryItem.media.metadata.authors.filter((au) => !authorIdsInUpdate.includes(au.id))
authorIdsRemoved.push(...authorsRemoved.map((au) => au.id))
}
}
if (libraryItem.media.update(mediaPayload)) {
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
if (seriesRemoved.length) {
// Check remove empty series
Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`)
await this.checkRemoveEmptySeries(
libraryItem.media.id,
seriesRemoved.map((se) => se.id)
)
}
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
itemsUpdated++
}
}
if (seriesIdsRemoved.length) {
await this.checkRemoveEmptySeries(seriesIdsRemoved)
}
if (authorIdsRemoved.length) {
await this.checkRemoveAuthorsWithNoBooks(authorIdsRemoved)
}
res.json({
success: true,
updates: itemsUpdated
@@ -845,7 +907,13 @@ class LibraryItemController {
res.setHeader('Content-Type', audioMimeType)
}
res.download(libraryFile.metadata.path, libraryFile.metadata.filename)
try {
await new Promise((resolve, reject) => res.download(libraryFile.metadata.path, libraryFile.metadata.filename, (error) => (error ? reject(error) : resolve())))
Logger.info(`[LibraryItemController] Downloaded file "${libraryFile.metadata.path}"`)
} catch (error) {
Logger.error(`[LibraryItemController] Failed to download file "${libraryFile.metadata.path}"`, error)
LibraryItemController.handleDownloadError(error, res)
}
}
/**
@@ -883,7 +951,13 @@ class LibraryItemController {
return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send()
}
res.sendFile(ebookFilePath)
try {
await new Promise((resolve, reject) => res.sendFile(ebookFilePath, (error) => (error ? reject(error) : resolve())))
Logger.info(`[LibraryItemController] Downloaded ebook file "${ebookFilePath}"`)
} catch (error) {
Logger.error(`[LibraryItemController] Failed to download ebook file "${ebookFilePath}"`, error)
LibraryItemController.handleDownloadError(error, res)
}
}
/**

View File

@@ -5,6 +5,7 @@ const fs = require('../libs/fsExtra')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const Watcher = require('../Watcher')
const libraryItemFilters = require('../utils/queries/libraryItemFilters')
const patternValidation = require('../libs/nodeCron/pattern-validation')
@@ -125,6 +126,10 @@ class MiscController {
if (!isObject(settingsUpdate)) {
return res.status(400).send('Invalid settings update object')
}
if (settingsUpdate.allowIframe == false && process.env.ALLOW_IFRAME === '1') {
Logger.warn('Cannot disable iframe when ALLOW_IFRAME is enabled in environment')
return res.status(400).send('Cannot disable iframe when ALLOW_IFRAME is enabled in environment')
}
const madeUpdates = Database.serverSettings.update(settingsUpdate)
if (madeUpdates) {
@@ -136,7 +141,6 @@ class MiscController {
}
}
return res.json({
success: true,
serverSettings: Database.serverSettings.toJSONForBrowser()
})
}
@@ -557,10 +561,10 @@ class MiscController {
switch (type) {
case 'add':
this.watcher.onFileAdded(libraryId, path)
Watcher.onFileAdded(libraryId, path)
break
case 'unlink':
this.watcher.onFileRemoved(libraryId, path)
Watcher.onFileRemoved(libraryId, path)
break
case 'rename':
const oldPath = req.body.oldPath
@@ -568,7 +572,7 @@ class MiscController {
Logger.error(`[MiscController] Invalid request body for updateWatchedPath. oldPath is required for rename.`)
return res.sendStatus(400)
}
this.watcher.onFileRename(libraryId, oldPath, path)
Watcher.onFileRename(libraryId, oldPath, path)
break
default:
Logger.error(`[MiscController] Invalid type for updateWatchedPath. type: "${type}"`)
@@ -678,9 +682,9 @@ class MiscController {
continue
}
let updatedValue = settingsUpdate[key]
if (updatedValue === '') updatedValue = null
if (updatedValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') updatedValue = null
let currentValue = currentAuthenticationSettings[key]
if (currentValue === '') currentValue = null
if (currentValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') currentValue = null
if (updatedValue !== currentValue) {
Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${currentValue}" to "${updatedValue}"`)

View File

@@ -1,7 +1,8 @@
const { Request, Response, NextFunction } = require('express')
const Logger = require('../Logger')
const Database = require('../Database')
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
const RssFeedManager = require('../managers/RssFeedManager')
/**
* @typedef RequestUserObject
@@ -22,10 +23,10 @@ class RSSFeedController {
* @param {Response} res
*/
async getAll(req, res) {
const feeds = await this.rssFeedManager.getFeeds()
const feeds = await RssFeedManager.getFeeds()
res.json({
feeds: feeds.map((f) => f.toJSON()),
minified: feeds.map((f) => f.toJSONMinified())
feeds: feeds.map((f) => f.toOldJSON()),
minified: feeds.map((f) => f.toOldJSONMinified())
})
}
@@ -38,38 +39,43 @@ class RSSFeedController {
* @param {Response} res
*/
async openRSSFeedForItem(req, res) {
const options = req.body || {}
const reqBody = req.body || {}
const item = await Database.libraryItemModel.getOldById(req.params.itemId)
if (!item) return res.sendStatus(404)
const itemExpanded = await Database.libraryItemModel.getExpandedById(req.params.itemId)
if (!itemExpanded) return res.sendStatus(404)
// Check user can access this library item
if (!req.user.checkCanAccessLibraryItem(item)) {
Logger.error(`[RSSFeedController] User "${req.user.username}" attempted to open an RSS feed for item "${item.media.metadata.title}" that they don\'t have access to`)
if (!req.user.checkCanAccessLibraryItem(itemExpanded)) {
Logger.error(`[RSSFeedController] User "${req.user.username}" attempted to open an RSS feed for item "${itemExpanded.media.title}" that they don\'t have access to`)
return res.sendStatus(403)
}
// Check request body options exist
if (!options.serverAddress || !options.slug) {
if (!reqBody.serverAddress || !reqBody.slug || typeof reqBody.serverAddress !== 'string' || typeof reqBody.slug !== 'string') {
Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)
return res.status(400).send('Invalid request body')
}
// Check item has audio tracks
if (!item.media.numTracks) {
Logger.error(`[RSSFeedController] Cannot open RSS feed for item "${item.media.metadata.title}" because it has no audio tracks`)
if (!itemExpanded.hasAudioTracks()) {
Logger.error(`[RSSFeedController] Cannot open RSS feed for item "${itemExpanded.media.title}" because it has no audio tracks`)
return res.status(400).send('Item has no audio tracks')
}
// Check that this slug is not being used for another feed (slug will also be the Feed id)
if (await this.rssFeedManager.findFeedBySlug(options.slug)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
if (await RssFeedManager.checkExistsBySlug(reqBody.slug)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${reqBody.slug}" is already in use`)
return res.status(400).send('Slug already in use')
}
const feed = await this.rssFeedManager.openFeedForItem(req.user.id, item, req.body)
const feed = await RssFeedManager.openFeedForItem(req.user.id, itemExpanded, reqBody)
if (!feed) {
Logger.error(`[RSSFeedController] Failed to open RSS feed for item "${itemExpanded.media.title}"`)
return res.status(500).send('Failed to open RSS feed')
}
res.json({
feed: feed.toJSONMinified()
feed: feed.toOldJSONMinified()
})
}
@@ -82,35 +88,37 @@ class RSSFeedController {
* @param {Response} res
*/
async openRSSFeedForCollection(req, res) {
const options = req.body || {}
const collection = await Database.collectionModel.findByPk(req.params.collectionId)
if (!collection) return res.sendStatus(404)
const reqBody = req.body || {}
// Check request body options exist
if (!options.serverAddress || !options.slug) {
if (!reqBody.serverAddress || !reqBody.slug || typeof reqBody.serverAddress !== 'string' || typeof reqBody.slug !== 'string') {
Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)
return res.status(400).send('Invalid request body')
}
// Check that this slug is not being used for another feed (slug will also be the Feed id)
if (await this.rssFeedManager.findFeedBySlug(options.slug)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
if (await RssFeedManager.checkExistsBySlug(reqBody.slug)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${reqBody.slug}" is already in use`)
return res.status(400).send('Slug already in use')
}
const collectionExpanded = await collection.getOldJsonExpanded()
const collectionItemsWithTracks = collectionExpanded.books.filter((li) => li.media.tracks.length)
const collection = await Database.collectionModel.getExpandedById(req.params.collectionId)
if (!collection) return res.sendStatus(404)
// Check collection has audio tracks
if (!collectionItemsWithTracks.length) {
if (!collection.books.some((book) => book.includedAudioFiles.length)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed for collection "${collection.name}" because it has no audio tracks`)
return res.status(400).send('Collection has no audio tracks')
}
const feed = await this.rssFeedManager.openFeedForCollection(req.user.id, collectionExpanded, req.body)
const feed = await RssFeedManager.openFeedForCollection(req.user.id, collection, reqBody)
if (!feed) {
Logger.error(`[RSSFeedController] Failed to open RSS feed for collection "${collection.name}"`)
return res.status(500).send('Failed to open RSS feed')
}
res.json({
feed: feed.toJSONMinified()
feed: feed.toOldJSONMinified()
})
}
@@ -123,37 +131,37 @@ class RSSFeedController {
* @param {Response} res
*/
async openRSSFeedForSeries(req, res) {
const options = req.body || {}
const series = await Database.seriesModel.findByPk(req.params.seriesId)
if (!series) return res.sendStatus(404)
const reqBody = req.body || {}
// Check request body options exist
if (!options.serverAddress || !options.slug) {
if (!reqBody.serverAddress || !reqBody.slug || typeof reqBody.serverAddress !== 'string' || typeof reqBody.slug !== 'string') {
Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)
return res.status(400).send('Invalid request body')
}
// Check that this slug is not being used for another feed (slug will also be the Feed id)
if (await this.rssFeedManager.findFeedBySlug(options.slug)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
if (await RssFeedManager.checkExistsBySlug(reqBody.slug)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${reqBody.slug}" is already in use`)
return res.status(400).send('Slug already in use')
}
const seriesJson = series.toOldJSON()
// Get books in series that have audio tracks
seriesJson.books = (await libraryItemsBookFilters.getLibraryItemsForSeries(series)).filter((li) => li.media.numTracks)
const series = await Database.seriesModel.getExpandedById(req.params.seriesId)
if (!series) return res.sendStatus(404)
// Check series has audio tracks
if (!seriesJson.books.length) {
Logger.error(`[RSSFeedController] Cannot open RSS feed for series "${seriesJson.name}" because it has no audio tracks`)
if (!series.books.some((book) => book.includedAudioFiles.length)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed for series "${series.name}" because it has no audio tracks`)
return res.status(400).send('Series has no audio tracks')
}
const feed = await this.rssFeedManager.openFeedForSeries(req.user.id, seriesJson, req.body)
const feed = await RssFeedManager.openFeedForSeries(req.user.id, series, req.body)
if (!feed) {
Logger.error(`[RSSFeedController] Failed to open RSS feed for series "${series.name}"`)
return res.status(500).send('Failed to open RSS feed')
}
res.json({
feed: feed.toJSONMinified()
feed: feed.toOldJSONMinified()
})
}
@@ -165,8 +173,16 @@ class RSSFeedController {
* @param {RequestWithUser} req
* @param {Response} res
*/
closeRSSFeed(req, res) {
this.rssFeedManager.closeRssFeed(req, res)
async closeRSSFeed(req, res) {
const feed = await Database.feedModel.findByPk(req.params.id)
if (!feed) {
Logger.error(`[RSSFeedController] Cannot close RSS feed because feed "${req.params.id}" does not exist`)
return res.sendStatus(404)
}
await RssFeedManager.handleCloseFeed(feed)
res.sendStatus(200)
}
/**

View File

@@ -2,6 +2,9 @@ const { Request, Response, NextFunction } = require('express')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const RssFeedManager = require('../managers/RssFeedManager')
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
/**
@@ -51,8 +54,8 @@ class SeriesController {
}
if (include.includes('rssfeed')) {
const feedObj = await this.rssFeedManager.findFeedForEntityId(seriesJson.id)
seriesJson.rssFeed = feedObj?.toJSONMinified() || null
const feedObj = await RssFeedManager.findFeedForEntityId(seriesJson.id)
seriesJson.rssFeed = feedObj?.toOldJSONMinified() || null
}
res.json(seriesJson)

View File

@@ -368,6 +368,19 @@ class UserController {
await playlist.destroy()
}
// Set PlaybackSessions userId to null
const [sessionsUpdated] = await Database.playbackSessionModel.update(
{
userId: null
},
{
where: {
userId: user.id
}
}
)
Logger.info(`[UserController] Updated ${sessionsUpdated} playback sessions to remove user id`)
const userJson = user.toOldJSONForBrowser()
await user.destroy()
SocketAuthority.adminEmitter('user_removed', userJson)

View File

@@ -4,6 +4,7 @@ const stream = require('stream')
const Logger = require('../Logger')
const { resizeImage } = require('../utils/ffmpegHelpers')
const { encodeUriPath } = require('../utils/fileUtils')
const Database = require('../Database')
class CacheManager {
constructor() {
@@ -29,24 +30,24 @@ class CacheManager {
await fs.ensureDir(this.ItemCachePath)
}
async handleCoverCache(res, libraryItemId, coverPath, options = {}) {
async handleCoverCache(res, libraryItemId, options = {}) {
const format = options.format || 'webp'
const width = options.width || 400
const height = options.height || null
res.type(`image/${format}`)
const path = Path.join(this.CoverCachePath, `${libraryItemId}_${width}${height ? `x${height}` : ''}`) + '.' + format
const cachePath = Path.join(this.CoverCachePath, `${libraryItemId}_${width}${height ? `x${height}` : ''}`) + '.' + format
// Cache exists
if (await fs.pathExists(path)) {
if (await fs.pathExists(cachePath)) {
if (global.XAccel) {
const encodedURI = encodeUriPath(global.XAccel + path)
const encodedURI = encodeUriPath(global.XAccel + cachePath)
Logger.debug(`Use X-Accel to serve static file ${encodedURI}`)
return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send()
}
const r = fs.createReadStream(path)
const r = fs.createReadStream(cachePath)
const ps = new stream.PassThrough()
stream.pipeline(r, ps, (err) => {
if (err) {
@@ -57,7 +58,13 @@ class CacheManager {
return ps.pipe(res)
}
const writtenFile = await resizeImage(coverPath, path, width, height)
// Cached cover does not exist, generate it
const coverPath = await Database.libraryItemModel.getCoverPath(libraryItemId)
if (!coverPath || !(await fs.pathExists(coverPath))) {
return res.sendStatus(404)
}
const writtenFile = await resizeImage(coverPath, cachePath, width, height)
if (!writtenFile) return res.sendStatus(500)
if (global.XAccel) {
@@ -79,6 +86,7 @@ class CacheManager {
}
async purgeEntityCache(entityId, cachePath) {
if (!entityId || !cachePath) return []
return Promise.all(
(await fs.readdir(cachePath)).reduce((promises, file) => {
if (file.startsWith(entityId)) {
@@ -127,22 +135,22 @@ class CacheManager {
/**
*
* @param {import('express').Response} res
* @param {import('../models/Author')} author
* @param {String} authorId
* @param {{ format?: string, width?: number, height?: number }} options
* @returns
*/
async handleAuthorCache(res, author, options = {}) {
async handleAuthorCache(res, authorId, options = {}) {
const format = options.format || 'webp'
const width = options.width || 400
const height = options.height || null
res.type(`image/${format}`)
var path = Path.join(this.ImageCachePath, `${author.id}_${width}${height ? `x${height}` : ''}`) + '.' + format
var cachePath = Path.join(this.ImageCachePath, `${authorId}_${width}${height ? `x${height}` : ''}`) + '.' + format
// Cache exists
if (await fs.pathExists(path)) {
const r = fs.createReadStream(path)
if (await fs.pathExists(cachePath)) {
const r = fs.createReadStream(cachePath)
const ps = new stream.PassThrough()
stream.pipeline(r, ps, (err) => {
if (err) {
@@ -153,7 +161,12 @@ class CacheManager {
return ps.pipe(res)
}
let writtenFile = await resizeImage(author.imagePath, path, width, height)
const author = await Database.authorModel.findByPk(authorId)
if (!author || !author.imagePath || !(await fs.pathExists(author.imagePath))) {
return res.sendStatus(404)
}
let writtenFile = await resizeImage(author.imagePath, cachePath, width, height)
if (!writtenFile) return res.sendStatus(500)
var readStream = fs.createReadStream(writtenFile)

View File

@@ -12,7 +12,7 @@ const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata')
const CacheManager = require('../managers/CacheManager')
class CoverManager {
constructor() { }
constructor() {}
getCoverDirectory(libraryItem) {
if (global.ServerSettings.storeCoverWithItem && !libraryItem.isFile) {
@@ -93,10 +93,13 @@ class CoverManager {
const coverFullPath = Path.posix.join(coverDirPath, `cover${extname}`)
// Move cover from temp upload dir to destination
const success = await coverFile.mv(coverFullPath).then(() => true).catch((error) => {
Logger.error('[CoverManager] Failed to move cover file', path, error)
return false
})
const success = await coverFile
.mv(coverFullPath)
.then(() => true)
.catch((error) => {
Logger.error('[CoverManager] Failed to move cover file', coverFullPath, error)
return false
})
if (!success) {
return {
@@ -124,11 +127,13 @@ class CoverManager {
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
})
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
@@ -180,7 +185,7 @@ class CoverManager {
}
// Cover path does not exist
if (!await fs.pathExists(coverPath)) {
if (!(await fs.pathExists(coverPath))) {
Logger.error(`[CoverManager] validate cover path does not exist "${coverPath}"`)
return {
error: 'Cover path does not exist'
@@ -188,7 +193,7 @@ class CoverManager {
}
// Cover path is not a file
if (!await checkPathIsFile(coverPath)) {
if (!(await checkPathIsFile(coverPath))) {
Logger.error(`[CoverManager] validate cover path is not a file "${coverPath}"`)
return {
error: 'Cover path is not a file'
@@ -211,10 +216,13 @@ class CoverManager {
var newCoverPath = Path.posix.join(coverDirPath, coverFilename)
Logger.debug(`[CoverManager] validate cover path copy cover from "${coverPath}" to "${newCoverPath}"`)
var copySuccess = await fs.copy(coverPath, newCoverPath, { overwrite: true }).then(() => true).catch((error) => {
Logger.error(`[CoverManager] validate cover path failed to copy cover`, error)
return false
})
var copySuccess = await fs
.copy(coverPath, newCoverPath, { overwrite: true })
.then(() => true)
.catch((error) => {
Logger.error(`[CoverManager] validate cover path failed to copy cover`, error)
return false
})
if (!copySuccess) {
return {
error: 'Failed to copy cover to dir'
@@ -236,14 +244,14 @@ class CoverManager {
/**
* Extract cover art from audio file and save for library item
*
* @param {import('../models/Book').AudioFileObject[]} audioFiles
* @param {string} libraryItemId
* @param {string} [libraryItemPath] null for isFile library items
*
* @param {import('../models/Book').AudioFileObject[]} audioFiles
* @param {string} libraryItemId
* @param {string} [libraryItemPath] null for isFile library items
* @returns {Promise<string>} returns cover path
*/
async saveEmbeddedCoverArt(audioFiles, libraryItemId, libraryItemPath) {
let audioFileWithCover = audioFiles.find(af => af.embeddedCoverArt)
let audioFileWithCover = audioFiles.find((af) => af.embeddedCoverArt)
if (!audioFileWithCover) return null
let coverDirPath = null
@@ -273,10 +281,10 @@ class CoverManager {
/**
* Extract cover art from ebook and save for library item
*
* @param {import('../utils/parsers/parseEbookMetadata').EBookFileScanData} ebookFileScanData
* @param {string} libraryItemId
* @param {string} [libraryItemPath] null for isFile library items
*
* @param {import('../utils/parsers/parseEbookMetadata').EBookFileScanData} ebookFileScanData
* @param {string} libraryItemId
* @param {string} [libraryItemPath] null for isFile library items
* @returns {Promise<string>} returns cover path
*/
async saveEbookCoverArt(ebookFileScanData, libraryItemId, libraryItemPath) {
@@ -310,9 +318,9 @@ class CoverManager {
}
/**
*
* @param {string} url
* @param {string} libraryItemId
*
* @param {string} url
* @param {string} libraryItemId
* @param {string} [libraryItemPath] null if library item isFile or is from adding new podcast
* @returns {Promise<{error:string}|{cover:string}>}
*/
@@ -328,10 +336,12 @@ class CoverManager {
await fs.ensureDir(coverDirPath)
const temppath = Path.posix.join(coverDirPath, 'cover')
const success = await downloadImageFile(url, temppath).then(() => true).catch((err) => {
Logger.error(`[CoverManager] Download image file failed for "${url}"`, err)
return false
})
const success = await downloadImageFile(url, temppath)
.then(() => true)
.catch((err) => {
Logger.error(`[CoverManager] Download image file failed for "${url}"`, err)
return false
})
if (!success) {
return {
error: 'Failed to download image from url'
@@ -361,4 +371,4 @@ class CoverManager {
}
}
}
module.exports = new CoverManager()
module.exports = new CoverManager()

View File

@@ -191,7 +191,21 @@ class MigrationManager {
const queryInterface = this.sequelize.getQueryInterface()
let migrationsMetaTableExists = await queryInterface.tableExists(MigrationManager.MIGRATIONS_META_TABLE)
// If the table exists, check that the `version` and `maxVersion` rows exist
if (migrationsMetaTableExists) {
const [{ count }] = await this.sequelize.query("SELECT COUNT(*) as count FROM :migrationsMeta WHERE key IN ('version', 'maxVersion')", {
replacements: { migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE },
type: Sequelize.QueryTypes.SELECT
})
if (count < 2) {
Logger.warn(`[MigrationManager] migrationsMeta table exists but is missing 'version' or 'maxVersion' row. Dropping it...`)
await queryInterface.dropTable(MigrationManager.MIGRATIONS_META_TABLE)
migrationsMetaTableExists = false
}
}
if (this.isDatabaseNew && migrationsMetaTableExists) {
Logger.warn(`[MigrationManager] migrationsMeta table already exists. Dropping it...`)
// This can happen if database was initialized with force: true
await queryInterface.dropTable(MigrationManager.MIGRATIONS_META_TABLE)
migrationsMetaTableExists = false

View File

@@ -366,7 +366,7 @@ class PlaybackSessionManager {
libraryItemId: libraryItem.id,
episodeId: session.episodeId,
// duration no longer required (v2.15.1) but used if available
duration: syncData.duration || libraryItem.media.duration || 0,
duration: syncData.duration || session.duration || 0,
currentTime: syncData.currentTime,
progress: session.progress,
markAsFinishedTimeRemaining: library.librarySettings.markAsFinishedTimeRemaining,

View File

@@ -1,6 +1,7 @@
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const Watcher = require('../Watcher')
const fs = require('../libs/fsExtra')
@@ -23,10 +24,10 @@ const AudioFile = require('../objects/files/AudioFile')
const LibraryItem = require('../objects/LibraryItem')
class PodcastManager {
constructor(watcher) {
this.watcher = watcher
constructor() {
/** @type {PodcastEpisodeDownload[]} */
this.downloadQueue = []
/** @type {PodcastEpisodeDownload} */
this.currentDownload = null
this.failedCheckMap = {}
@@ -47,6 +48,7 @@ class PodcastManager {
var itemDownloads = this.getEpisodeDownloadsInQueue(libraryItemId)
Logger.info(`[PodcastManager] Clearing downloads in queue for item "${libraryItemId}" (${itemDownloads.length})`)
this.downloadQueue = this.downloadQueue.filter((d) => d.libraryItemId !== libraryItemId)
SocketAuthority.emitter('episode_download_queue_cleared', libraryItemId)
}
}
@@ -63,8 +65,12 @@ class PodcastManager {
}
}
/**
*
* @param {PodcastEpisodeDownload} podcastEpisodeDownload
* @returns
*/
async startPodcastEpisodeDownload(podcastEpisodeDownload) {
SocketAuthority.emitter('episode_download_queue_updated', this.getDownloadQueueDetails())
if (this.currentDownload) {
this.downloadQueue.push(podcastEpisodeDownload)
SocketAuthority.emitter('episode_download_queued', podcastEpisodeDownload.toJSONForClient())
@@ -97,7 +103,8 @@ class PodcastManager {
}
// Ignores all added files to this dir
this.watcher.addIgnoreDir(this.currentDownload.libraryItem.path)
Watcher.addIgnoreDir(this.currentDownload.libraryItem.path)
Watcher.ignoreFilePathsDownloading.add(this.currentDownload.targetPath)
// Make sure podcast library item folder exists
if (!(await fs.pathExists(this.currentDownload.libraryItem.path))) {
@@ -106,7 +113,7 @@ class PodcastManager {
}
let success = false
if (this.currentDownload.urlFileExtension === 'mp3') {
if (this.currentDownload.isMp3) {
// Download episode and tag it
success = await ffmpegHelpers.downloadPodcastEpisode(this.currentDownload).catch((error) => {
Logger.error(`[PodcastManager] Podcast Episode download failed`, error)
@@ -149,9 +156,10 @@ class PodcastManager {
TaskManager.taskFinished(task)
SocketAuthority.emitter('episode_download_finished', this.currentDownload.toJSONForClient())
SocketAuthority.emitter('episode_download_queue_updated', this.getDownloadQueueDetails())
this.watcher.removeIgnoreDir(this.currentDownload.libraryItem.path)
Watcher.removeIgnoreDir(this.currentDownload.libraryItem.path)
Watcher.ignoreFilePathsDownloading.delete(this.currentDownload.targetPath)
this.currentDownload = null
if (this.downloadQueue.length) {
this.startPodcastEpisodeDownload(this.downloadQueue.shift())

View File

@@ -1,3 +1,4 @@
const { Request, Response } = require('express')
const Path = require('path')
const Logger = require('../Logger')
@@ -5,170 +6,190 @@ const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const fs = require('../libs/fsExtra')
const Feed = require('../objects/Feed')
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
class RssFeedManager {
constructor() {}
async validateFeedEntity(feedObj) {
if (feedObj.entityType === 'collection') {
const collection = await Database.collectionModel.getOldById(feedObj.entityId)
if (!collection) {
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Collection "${feedObj.entityId}" not found`)
return false
}
} else if (feedObj.entityType === 'libraryItem') {
const libraryItemExists = await Database.libraryItemModel.checkExistsById(feedObj.entityId)
if (!libraryItemExists) {
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Library item "${feedObj.entityId}" not found`)
return false
}
} else if (feedObj.entityType === 'series') {
const series = await Database.seriesModel.findByPk(feedObj.entityId)
if (!series) {
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Series "${feedObj.entityId}" not found`)
return false
}
} else {
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Invalid entityType "${feedObj.entityType}"`)
return false
}
return true
}
/**
* Validate all feeds and remove invalid
* Remove invalid feeds (invalid if the entity does not exist)
*/
async init() {
const feeds = await Database.feedModel.getOldFeeds()
const feeds = await Database.feedModel.findAll({
attributes: ['id', 'entityId', 'entityType', 'title'],
include: [
{
model: Database.libraryItemModel,
attributes: ['id']
},
{
model: Database.collectionModel,
attributes: ['id']
},
{
model: Database.seriesModel,
attributes: ['id']
}
]
})
const feedIdsToRemove = []
for (const feed of feeds) {
// Remove invalid feeds
if (!(await this.validateFeedEntity(feed))) {
await Database.removeFeed(feed.id)
if (!feed.entity) {
Logger.error(`[RssFeedManager] Removing feed "${feed.title}". Entity not found`)
feedIdsToRemove.push(feed.id)
}
}
if (feedIdsToRemove.length) {
Logger.info(`[RssFeedManager] Removing ${feedIdsToRemove.length} invalid feeds`)
await Database.feedModel.destroy({
where: {
id: feedIdsToRemove
}
})
}
}
/**
* Find open feed for an entity (e.g. collection id, playlist id, library item id)
* @param {string} entityId
* @returns {Promise<objects.Feed>} oldFeed
* @returns {Promise<import('../models/Feed')>}
*/
findFeedForEntityId(entityId) {
return Database.feedModel.findOneOld({ entityId })
return Database.feedModel.findOne({
where: {
entityId
}
})
}
/**
* Find open feed for a slug
*
* @param {string} slug
* @returns {Promise<objects.Feed>} oldFeed
* @returns {Promise<boolean>}
*/
findFeedBySlug(slug) {
return Database.feedModel.findOneOld({ slug })
checkExistsBySlug(slug) {
return Database.feedModel
.count({
where: {
slug
}
})
.then((count) => count > 0)
}
/**
* Find open feed for a slug
* @param {string} slug
* @returns {Promise<objects.Feed>} oldFeed
* Feed requires update if the entity (or child entities) has been updated since the feed was last updated
*
* @param {import('../models/Feed')} feed
* @returns {Promise<boolean>}
*/
findFeed(id) {
return Database.feedModel.findByPkOld(id)
async checkFeedRequiresUpdate(feed) {
if (feed.entityType === 'libraryItem') {
feed.entity = await feed.getEntity({
attributes: ['id', 'updatedAt', 'mediaId', 'mediaType']
})
let newEntityUpdatedAt = feed.entity.updatedAt
if (feed.entity.mediaType === 'podcast') {
const mostRecentPodcastEpisode = await Database.podcastEpisodeModel.findOne({
where: {
podcastId: feed.entity.mediaId
},
attributes: ['id', 'updatedAt'],
order: [['createdAt', 'DESC']]
})
if (mostRecentPodcastEpisode && mostRecentPodcastEpisode.updatedAt > newEntityUpdatedAt) {
newEntityUpdatedAt = mostRecentPodcastEpisode.updatedAt
}
}
return newEntityUpdatedAt > feed.entityUpdatedAt
} else if (feed.entityType === 'collection' || feed.entityType === 'series') {
feed.entity = await feed.getEntity({
attributes: ['id', 'updatedAt'],
include: {
model: Database.bookModel,
attributes: ['id'],
through: {
attributes: []
},
include: {
model: Database.libraryItemModel,
attributes: ['id', 'updatedAt']
}
}
})
let newEntityUpdatedAt = feed.entity.updatedAt
const mostRecentItemUpdatedAt = feed.entity.books.reduce((mostRecent, book) => {
if (book.libraryItem.updatedAt > mostRecent) {
return book.libraryItem.updatedAt
}
return mostRecent
}, 0)
if (mostRecentItemUpdatedAt > newEntityUpdatedAt) {
newEntityUpdatedAt = mostRecentItemUpdatedAt
}
return newEntityUpdatedAt > feed.entityUpdatedAt
} else {
throw new Error('Invalid feed entity type')
}
}
/**
* GET: /feed/:slug
*
* @param {Request} req
* @param {Response} res
*/
async getFeed(req, res) {
const feed = await this.findFeedBySlug(req.params.slug)
let feed = await Database.feedModel.findOne({
where: {
slug: req.params.slug
}
})
if (!feed) {
Logger.warn(`[RssFeedManager] Feed not found ${req.params.slug}`)
res.sendStatus(404)
return
}
// Check if feed needs to be updated
if (feed.entityType === 'libraryItem') {
const libraryItem = await Database.libraryItemModel.getOldById(feed.entityId)
let mostRecentlyUpdatedAt = libraryItem.updatedAt
if (libraryItem.isPodcast) {
libraryItem.media.episodes.forEach((episode) => {
if (episode.updatedAt > mostRecentlyUpdatedAt) mostRecentlyUpdatedAt = episode.updatedAt
})
}
if (libraryItem && (!feed.entityUpdatedAt || mostRecentlyUpdatedAt > feed.entityUpdatedAt)) {
Logger.debug(`[RssFeedManager] Updating RSS feed for item ${libraryItem.id} "${libraryItem.media.metadata.title}"`)
feed.updateFromItem(libraryItem)
await Database.updateFeed(feed)
}
} else if (feed.entityType === 'collection') {
const collection = await Database.collectionModel.findByPk(feed.entityId, {
include: Database.collectionBookModel
})
if (collection) {
const collectionExpanded = await collection.getOldJsonExpanded()
// Find most recently updated item in collection
let mostRecentlyUpdatedAt = collectionExpanded.lastUpdate
// Check for most recently updated book
collectionExpanded.books.forEach((libraryItem) => {
if (libraryItem.media.tracks.length && libraryItem.updatedAt > mostRecentlyUpdatedAt) {
mostRecentlyUpdatedAt = libraryItem.updatedAt
}
})
// Check for most recently added collection book
collection.collectionBooks.forEach((collectionBook) => {
if (collectionBook.createdAt.valueOf() > mostRecentlyUpdatedAt) {
mostRecentlyUpdatedAt = collectionBook.createdAt.valueOf()
}
})
const hasBooksRemoved = collection.collectionBooks.length < feed.episodes.length
if (!feed.entityUpdatedAt || hasBooksRemoved || mostRecentlyUpdatedAt > feed.entityUpdatedAt) {
Logger.debug(`[RssFeedManager] Updating RSS feed for collection "${collection.name}"`)
feed.updateFromCollection(collectionExpanded)
await Database.updateFeed(feed)
}
}
} else if (feed.entityType === 'series') {
const series = await Database.seriesModel.findByPk(feed.entityId)
if (series) {
const seriesJson = series.toOldJSON()
// Get books in series that have audio tracks
seriesJson.books = (await libraryItemsBookFilters.getLibraryItemsForSeries(series)).filter((li) => li.media.numTracks)
// Find most recently updated item in series
let mostRecentlyUpdatedAt = seriesJson.updatedAt
let totalTracks = 0 // Used to detect series items removed
seriesJson.books.forEach((libraryItem) => {
totalTracks += libraryItem.media.tracks.length
if (libraryItem.media.tracks.length && libraryItem.updatedAt > mostRecentlyUpdatedAt) {
mostRecentlyUpdatedAt = libraryItem.updatedAt
}
})
if (totalTracks !== feed.episodes.length) {
mostRecentlyUpdatedAt = Date.now()
}
if (!feed.entityUpdatedAt || mostRecentlyUpdatedAt > feed.entityUpdatedAt) {
Logger.debug(`[RssFeedManager] Updating RSS feed for series "${seriesJson.name}"`)
feed.updateFromSeries(seriesJson)
await Database.updateFeed(feed)
}
}
const feedRequiresUpdate = await this.checkFeedRequiresUpdate(feed)
if (feedRequiresUpdate) {
Logger.info(`[RssFeedManager] Feed "${feed.title}" requires update - updating feed`)
feed = await feed.updateFeedForEntity()
} else {
feed.feedEpisodes = await feed.getFeedEpisodes()
}
const xml = feed.buildXml()
const xml = feed.buildXml(req.originalHostPrefix)
res.set('Content-Type', 'text/xml')
res.send(xml)
}
/**
* GET: /feed/:slug/item/:episodeId/*
*
* @param {Request} req
* @param {Response} res
*/
async getFeedItem(req, res) {
const feed = await this.findFeedBySlug(req.params.slug)
const feed = await Database.feedModel.findOne({
where: {
slug: req.params.slug
},
attributes: ['id', 'slug'],
include: {
model: Database.feedEpisodeModel,
attributes: ['id', 'filePath']
}
})
if (!feed) {
Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`)
res.sendStatus(404)
@@ -183,8 +204,19 @@ class RssFeedManager {
res.sendFile(episodePath)
}
/**
* GET: /feed/:slug/cover*
*
* @param {Request} req
* @param {Response} res
*/
async getFeedCover(req, res) {
const feed = await this.findFeedBySlug(req.params.slug)
const feed = await Database.feedModel.findOne({
where: {
slug: req.params.slug
},
attributes: ['coverPath']
})
if (!feed) {
Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`)
res.sendStatus(404)
@@ -204,100 +236,143 @@ class RssFeedManager {
/**
*
* @param {string} userId
* @param {*} libraryItem
* @param {*} options
* @returns
* @returns {import('../models/Feed').FeedOptions}
*/
getFeedOptionsFromReqOptions(options) {
const metadataDetails = options.metadataDetails || {}
if (metadataDetails.preventIndexing !== false) {
metadataDetails.preventIndexing = true
}
return {
preventIndexing: metadataDetails.preventIndexing,
ownerName: metadataDetails.ownerName && typeof metadataDetails.ownerName === 'string' ? metadataDetails.ownerName : null,
ownerEmail: metadataDetails.ownerEmail && typeof metadataDetails.ownerEmail === 'string' ? metadataDetails.ownerEmail : null
}
}
/**
*
* @param {string} userId
* @param {import('../models/LibraryItem')} libraryItem
* @param {*} options
* @returns {Promise<import('../models/Feed').FeedExpanded>}
*/
async openFeedForItem(userId, libraryItem, options) {
const serverAddress = options.serverAddress
const slug = options.slug
const preventIndexing = options.metadataDetails?.preventIndexing ?? true
const ownerName = options.metadataDetails?.ownerName
const ownerEmail = options.metadataDetails?.ownerEmail
const feedOptions = this.getFeedOptionsFromReqOptions(options)
const feed = new Feed()
feed.setFromItem(userId, slug, libraryItem, serverAddress, preventIndexing, ownerName, ownerEmail)
Logger.info(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
await Database.createFeed(feed)
SocketAuthority.emitter('rss_feed_open', feed.toJSONMinified())
return feed
Logger.info(`[RssFeedManager] Creating RSS feed for item ${libraryItem.id} "${libraryItem.media.title}"`)
const feedExpanded = await Database.feedModel.createFeedForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions)
if (feedExpanded) {
Logger.info(`[RssFeedManager] Opened RSS feed "${feedExpanded.feedURL}"`)
SocketAuthority.emitter('rss_feed_open', feedExpanded.toOldJSONMinified())
}
return feedExpanded
}
/**
*
* @param {string} userId
* @param {*} collectionExpanded
* @param {import('../models/Collection')} collectionExpanded
* @param {*} options
* @returns
* @returns {Promise<import('../models/Feed').FeedExpanded>}
*/
async openFeedForCollection(userId, collectionExpanded, options) {
const serverAddress = options.serverAddress
const slug = options.slug
const preventIndexing = options.metadataDetails?.preventIndexing ?? true
const ownerName = options.metadataDetails?.ownerName
const ownerEmail = options.metadataDetails?.ownerEmail
const feedOptions = this.getFeedOptionsFromReqOptions(options)
const feed = new Feed()
feed.setFromCollection(userId, slug, collectionExpanded, serverAddress, preventIndexing, ownerName, ownerEmail)
Logger.info(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
await Database.createFeed(feed)
SocketAuthority.emitter('rss_feed_open', feed.toJSONMinified())
return feed
Logger.info(`[RssFeedManager] Creating RSS feed for collection "${collectionExpanded.name}"`)
const feedExpanded = await Database.feedModel.createFeedForCollection(userId, collectionExpanded, slug, serverAddress, feedOptions)
if (feedExpanded) {
Logger.info(`[RssFeedManager] Opened RSS feed "${feedExpanded.feedURL}"`)
SocketAuthority.emitter('rss_feed_open', feedExpanded.toOldJSONMinified())
}
return feedExpanded
}
/**
*
* @param {string} userId
* @param {*} seriesExpanded
* @param {import('../models/Series')} seriesExpanded
* @param {*} options
* @returns
* @returns {Promise<import('../models/Feed').FeedExpanded>}
*/
async openFeedForSeries(userId, seriesExpanded, options) {
const serverAddress = options.serverAddress
const slug = options.slug
const preventIndexing = options.metadataDetails?.preventIndexing ?? true
const ownerName = options.metadataDetails?.ownerName
const ownerEmail = options.metadataDetails?.ownerEmail
const feedOptions = this.getFeedOptionsFromReqOptions(options)
const feed = new Feed()
feed.setFromSeries(userId, slug, seriesExpanded, serverAddress, preventIndexing, ownerName, ownerEmail)
Logger.info(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
await Database.createFeed(feed)
SocketAuthority.emitter('rss_feed_open', feed.toJSONMinified())
return feed
}
async handleCloseFeed(feed) {
if (!feed) return
await Database.removeFeed(feed.id)
SocketAuthority.emitter('rss_feed_closed', feed.toJSONMinified())
Logger.info(`[RssFeedManager] Closed RSS feed "${feed.feedUrl}"`)
}
async closeRssFeed(req, res) {
const feed = await this.findFeed(req.params.id)
if (!feed) {
Logger.error(`[RssFeedManager] RSS feed not found with id "${req.params.id}"`)
return res.sendStatus(404)
Logger.info(`[RssFeedManager] Creating RSS feed for series "${seriesExpanded.name}"`)
const feedExpanded = await Database.feedModel.createFeedForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions)
if (feedExpanded) {
Logger.info(`[RssFeedManager] Opened RSS feed "${feedExpanded.feedURL}"`)
SocketAuthority.emitter('rss_feed_open', feedExpanded.toOldJSONMinified())
}
await this.handleCloseFeed(feed)
res.sendStatus(200)
return feedExpanded
}
/**
* Close Feed and emit Socket event
*
* @param {import('../models/Feed')} feed
* @returns {Promise<boolean>} - true if feed was closed
*/
async handleCloseFeed(feed) {
if (!feed) return false
const wasRemoved = await Database.feedModel.removeById(feed.id)
SocketAuthority.emitter('rss_feed_closed', feed.toOldJSONMinified())
Logger.info(`[RssFeedManager] Closed RSS feed "${feed.feedURL}"`)
return wasRemoved
}
/**
*
* @param {string} entityId
* @returns {Promise<boolean>} - true if feed was closed
*/
async closeFeedForEntityId(entityId) {
const feed = await this.findFeedForEntityId(entityId)
if (!feed) return
const feed = await Database.feedModel.findOne({
where: {
entityId
}
})
if (!feed) {
Logger.warn(`[RssFeedManager] closeFeedForEntityId: Feed not found for entity id ${entityId}`)
return false
}
return this.handleCloseFeed(feed)
}
async getFeeds() {
const feeds = await Database.models.feed.getOldFeeds()
Logger.info(`[RssFeedManager] Fetched all feeds`)
return feeds
/**
*
* @param {string[]} entityIds
*/
async closeFeedsForEntityIds(entityIds) {
const feeds = await Database.feedModel.findAll({
where: {
entityId: entityIds
}
})
for (const feed of feeds) {
await this.handleCloseFeed(feed)
}
}
/**
*
* @returns {Promise<import('../models/Feed').FeedExpanded[]>}
*/
getFeeds() {
return Database.feedModel.findAll({
include: {
model: Database.feedEpisodeModel
}
})
}
}
module.exports = RssFeedManager
module.exports = new RssFeedManager()

View File

@@ -2,8 +2,12 @@
Please add a record of every database migration that you create to this file. This will help us keep track of changes to the database schema over time.
| Server Version | Migration Script Name | Description |
| -------------- | ---------------------------- | ------------------------------------------------------------------------------------ |
| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library |
| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 |
| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes |
| Server Version | Migration Script Name | Description |
| -------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library |
| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 |
| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes |
| v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model |
| v2.17.3 | v2.17.3-fk-constraints | Changes the foreign key constraints for tables due to sequelize bug dropping constraints in v2.17.0 migration |
| v2.17.4 | v2.17.4-use-subfolder-for-oidc-redirect-uris | Save subfolder to OIDC redirect URIs to support existing installations |
| v2.17.5 | v2.17.5-remove-host-from-feed-urls | removes the host (serverAddress) from URL columns in the feeds and feedEpisodes tables |

View File

@@ -0,0 +1,102 @@
/**
* @typedef MigrationContext
* @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
* @property {import('../Logger')} logger - a Logger object.
*
* @typedef MigrationOptions
* @property {MigrationContext} context - an object containing the migration context.
*/
/**
* This upward migration script changes table columns with data type UUIDv4 to UUID to match associated models.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function up({ context: { queryInterface, logger } }) {
// Upwards migration script
logger.info('[2.17.0 migration] UPGRADE BEGIN: 2.17.0-uuid-replacement')
logger.info('[2.17.0 migration] Changing libraryItems.mediaId column to UUID')
await queryInterface.changeColumn('libraryItems', 'mediaId', {
type: 'UUID'
})
logger.info('[2.17.0 migration] Changing feeds.entityId column to UUID')
await queryInterface.changeColumn('feeds', 'entityId', {
type: 'UUID'
})
if (await queryInterface.tableExists('mediaItemShares')) {
logger.info('[2.17.0 migration] Changing mediaItemShares.mediaItemId column to UUID')
await queryInterface.changeColumn('mediaItemShares', 'mediaItemId', {
type: 'UUID'
})
} else {
logger.info('[2.17.0 migration] mediaItemShares table does not exist, skipping column change')
}
logger.info('[2.17.0 migration] Changing playbackSessions.mediaItemId column to UUID')
await queryInterface.changeColumn('playbackSessions', 'mediaItemId', {
type: 'UUID'
})
logger.info('[2.17.0 migration] Changing playlistMediaItems.mediaItemId column to UUID')
await queryInterface.changeColumn('playlistMediaItems', 'mediaItemId', {
type: 'UUID'
})
logger.info('[2.17.0 migration] Changing mediaProgresses.mediaItemId column to UUID')
await queryInterface.changeColumn('mediaProgresses', 'mediaItemId', {
type: 'UUID'
})
// Completed migration
logger.info('[2.17.0 migration] UPGRADE END: 2.17.0-uuid-replacement')
}
/**
* This downward migration script changes table columns data type back to UUIDv4.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function down({ context: { queryInterface, logger } }) {
// Downward migration script
logger.info('[2.17.0 migration] DOWNGRADE BEGIN: 2.17.0-uuid-replacement')
logger.info('[2.17.0 migration] Changing libraryItems.mediaId column to UUIDV4')
await queryInterface.changeColumn('libraryItems', 'mediaId', {
type: 'UUIDV4'
})
logger.info('[2.17.0 migration] Changing feeds.entityId column to UUIDV4')
await queryInterface.changeColumn('feeds', 'entityId', {
type: 'UUIDV4'
})
logger.info('[2.17.0 migration] Changing mediaItemShares.mediaItemId column to UUIDV4')
await queryInterface.changeColumn('mediaItemShares', 'mediaItemId', {
type: 'UUIDV4'
})
logger.info('[2.17.0 migration] Changing playbackSessions.mediaItemId column to UUIDV4')
await queryInterface.changeColumn('playbackSessions', 'mediaItemId', {
type: 'UUIDV4'
})
logger.info('[2.17.0 migration] Changing playlistMediaItems.mediaItemId column to UUIDV4')
await queryInterface.changeColumn('playlistMediaItems', 'mediaItemId', {
type: 'UUIDV4'
})
logger.info('[2.17.0 migration] Changing mediaProgresses.mediaItemId column to UUIDV4')
await queryInterface.changeColumn('mediaProgresses', 'mediaItemId', {
type: 'UUIDV4'
})
// Completed migration
logger.info('[2.17.0 migration] DOWNGRADE END: 2.17.0-uuid-replacement')
}
module.exports = { up, down }

View File

@@ -0,0 +1,259 @@
/**
* @typedef MigrationContext
* @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
* @property {import('../Logger')} logger - a Logger object.
*
* @typedef MigrationOptions
* @property {MigrationContext} context - an object containing the migration context.
*/
/**
* This upward migration script changes foreign key constraints for the
* libraryItems, feeds, mediaItemShares, playbackSessions, playlistMediaItems, and mediaProgresses tables.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function up({ context: { queryInterface, logger } }) {
// Upwards migration script
logger.info('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-fk-constraints')
const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize)
// Disable foreign key constraints for the next sequence of operations
await execQuery(`PRAGMA foreign_keys = OFF;`)
try {
await execQuery(`BEGIN TRANSACTION;`)
logger.info('[2.17.3 migration] Updating libraryItems constraints')
const libraryItemsConstraints = [
{ field: 'libraryId', onDelete: 'SET NULL', onUpdate: 'CASCADE' },
{ field: 'libraryFolderId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }
]
if (await changeConstraints(queryInterface, 'libraryItems', libraryItemsConstraints)) {
logger.info('[2.17.3 migration] Finished updating libraryItems constraints')
} else {
logger.info('[2.17.3 migration] No changes needed for libraryItems constraints')
}
logger.info('[2.17.3 migration] Updating feeds constraints')
const feedsConstraints = [{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }]
if (await changeConstraints(queryInterface, 'feeds', feedsConstraints)) {
logger.info('[2.17.3 migration] Finished updating feeds constraints')
} else {
logger.info('[2.17.3 migration] No changes needed for feeds constraints')
}
if (await queryInterface.tableExists('mediaItemShares')) {
logger.info('[2.17.3 migration] Updating mediaItemShares constraints')
const mediaItemSharesConstraints = [{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }]
if (await changeConstraints(queryInterface, 'mediaItemShares', mediaItemSharesConstraints)) {
logger.info('[2.17.3 migration] Finished updating mediaItemShares constraints')
} else {
logger.info('[2.17.3 migration] No changes needed for mediaItemShares constraints')
}
} else {
logger.info('[2.17.3 migration] mediaItemShares table does not exist, skipping column change')
}
logger.info('[2.17.3 migration] Updating playbackSessions constraints')
const playbackSessionsConstraints = [
{ field: 'deviceId', onDelete: 'SET NULL', onUpdate: 'CASCADE' },
{ field: 'libraryId', onDelete: 'SET NULL', onUpdate: 'CASCADE' },
{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }
]
if (await changeConstraints(queryInterface, 'playbackSessions', playbackSessionsConstraints)) {
logger.info('[2.17.3 migration] Finished updating playbackSessions constraints')
} else {
logger.info('[2.17.3 migration] No changes needed for playbackSessions constraints')
}
logger.info('[2.17.3 migration] Updating playlistMediaItems constraints')
const playlistMediaItemsConstraints = [{ field: 'playlistId', onDelete: 'CASCADE', onUpdate: 'CASCADE' }]
if (await changeConstraints(queryInterface, 'playlistMediaItems', playlistMediaItemsConstraints)) {
logger.info('[2.17.3 migration] Finished updating playlistMediaItems constraints')
} else {
logger.info('[2.17.3 migration] No changes needed for playlistMediaItems constraints')
}
logger.info('[2.17.3 migration] Updating mediaProgresses constraints')
const mediaProgressesConstraints = [{ field: 'userId', onDelete: 'CASCADE', onUpdate: 'CASCADE' }]
if (await changeConstraints(queryInterface, 'mediaProgresses', mediaProgressesConstraints)) {
logger.info('[2.17.3 migration] Finished updating mediaProgresses constraints')
} else {
logger.info('[2.17.3 migration] No changes needed for mediaProgresses constraints')
}
await execQuery(`COMMIT;`)
} catch (error) {
logger.error(`[2.17.3 migration] Migration failed - rolling back. Error:`, error)
await execQuery(`ROLLBACK;`)
}
await execQuery(`PRAGMA foreign_keys = ON;`)
// Completed migration
logger.info('[2.17.3 migration] UPGRADE END: 2.17.3-fk-constraints')
}
/**
* This downward migration script is a no-op.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function down({ context: { queryInterface, logger } }) {
// Downward migration script
logger.info('[2.17.3 migration] DOWNGRADE BEGIN: 2.17.3-fk-constraints')
// This migration is a no-op
logger.info('[2.17.3 migration] No action required for downgrade')
// Completed migration
logger.info('[2.17.3 migration] DOWNGRADE END: 2.17.3-fk-constraints')
}
/**
* @typedef ConstraintUpdateObj
* @property {string} field - The field to update
* @property {string} onDelete - The onDelete constraint
* @property {string} onUpdate - The onUpdate constraint
*/
/**
* @typedef SequelizeFKObj
* @property {{ model: string, key: string }} references
* @property {string} onDelete
* @property {string} onUpdate
*/
/**
* @param {Object} fk - The foreign key object from PRAGMA foreign_key_list
* @returns {SequelizeFKObj} - The foreign key object formatted for Sequelize
*/
const formatFKsPragmaToSequelizeFK = (fk) => {
return {
references: {
model: fk.table,
key: fk.to
},
onDelete: fk['on_delete'],
onUpdate: fk['on_update']
}
}
/**
*
* @param {import('sequelize').QueryInterface} queryInterface
* @param {string} tableName
* @param {ConstraintUpdateObj[]} constraints
* @returns {Promise<Record<string, SequelizeFKObj>|null>}
*/
async function getUpdatedForeignKeys(queryInterface, tableName, constraints) {
const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize)
const quotedTableName = queryInterface.quoteIdentifier(tableName)
const foreignKeys = await execQuery(`PRAGMA foreign_key_list(${quotedTableName});`)
let hasUpdates = false
const foreignKeysByColName = foreignKeys.reduce((prev, curr) => {
const fk = formatFKsPragmaToSequelizeFK(curr)
const constraint = constraints.find((c) => c.field === curr.from)
if (constraint && (constraint.onDelete !== fk.onDelete || constraint.onUpdate !== fk.onUpdate)) {
fk.onDelete = constraint.onDelete
fk.onUpdate = constraint.onUpdate
hasUpdates = true
}
return { ...prev, [curr.from]: fk }
}, {})
return hasUpdates ? foreignKeysByColName : null
}
/**
* Extends the Sequelize describeTable function to include the updated foreign key constraints
*
* @param {import('sequelize').QueryInterface} queryInterface
* @param {String} tableName
* @param {Record<string, SequelizeFKObj>} updatedForeignKeys
*/
async function describeTableWithFKs(queryInterface, tableName, updatedForeignKeys) {
const tableDescription = await queryInterface.describeTable(tableName)
const tableDescriptionWithFks = Object.entries(tableDescription).reduce((prev, [col, attributes]) => {
let extendedAttributes = attributes
if (updatedForeignKeys[col]) {
extendedAttributes = {
...extendedAttributes,
...updatedForeignKeys[col]
}
}
return { ...prev, [col]: extendedAttributes }
}, {})
return tableDescriptionWithFks
}
/**
* @see https://www.sqlite.org/lang_altertable.html#otheralter
* @see https://sequelize.org/docs/v6/other-topics/query-interface/#changing-and-removing-columns-in-sqlite
*
* @param {import('sequelize').QueryInterface} queryInterface
* @param {string} tableName
* @param {ConstraintUpdateObj[]} constraints
* @returns {Promise<boolean>} - Return false if no changes are needed, true otherwise
*/
async function changeConstraints(queryInterface, tableName, constraints) {
const updatedForeignKeys = await getUpdatedForeignKeys(queryInterface, tableName, constraints)
if (!updatedForeignKeys) {
return false
}
const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize)
const quotedTableName = queryInterface.quoteIdentifier(tableName)
const backupTableName = `${tableName}_${Math.round(Math.random() * 100)}_backup`
const quotedBackupTableName = queryInterface.quoteIdentifier(backupTableName)
try {
const tableDescriptionWithFks = await describeTableWithFKs(queryInterface, tableName, updatedForeignKeys)
const attributes = queryInterface.queryGenerator.attributesToSQL(tableDescriptionWithFks)
// Create the backup table
await queryInterface.createTable(backupTableName, attributes)
const attributeNames = Object.keys(attributes)
.map((attr) => queryInterface.quoteIdentifier(attr))
.join(', ')
// Copy all data from the target table to the backup table
await execQuery(`INSERT INTO ${quotedBackupTableName} SELECT ${attributeNames} FROM ${quotedTableName};`)
// Drop the old (original) table
await queryInterface.dropTable(tableName)
// Rename the backup table to the original table's name
await queryInterface.renameTable(backupTableName, tableName)
// Validate that all foreign key constraints are correct
const result = await execQuery(`PRAGMA foreign_key_check(${quotedTableName});`, {
type: queryInterface.sequelize.Sequelize.QueryTypes.SELECT
})
// There are foreign key violations, exit
if (result.length) {
return Promise.reject(`Foreign key violations detected: ${JSON.stringify(result, null, 2)}`)
}
return true
} catch (error) {
return Promise.reject(error)
}
}
module.exports = { up, down }

View File

@@ -0,0 +1,84 @@
/**
* @typedef MigrationContext
* @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
* @property {import('../Logger')} logger - a Logger object.
*
* @typedef MigrationOptions
* @property {MigrationContext} context - an object containing the migration context.
*/
/**
* This upward migration adds an subfolder setting for OIDC redirect URIs.
* It updates existing OIDC setups to set this option to None (empty subfolder), so they continue to work as before.
* IF OIDC is not enabled, no action is taken (i.e. the subfolder is left undefined),
* so that future OIDC setups will use the default subfolder.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function up({ context: { queryInterface, logger } }) {
// Upwards migration script
logger.info('[2.17.4 migration] UPGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris')
const serverSettings = await getServerSettings(queryInterface, logger)
if (serverSettings.authActiveAuthMethods?.includes('openid')) {
logger.info('[2.17.4 migration] OIDC is enabled, adding authOpenIDSubfolderForRedirectURLs to server settings')
serverSettings.authOpenIDSubfolderForRedirectURLs = ''
await updateServerSettings(queryInterface, logger, serverSettings)
} else {
logger.info('[2.17.4 migration] OIDC is not enabled, no action required')
}
logger.info('[2.17.4 migration] UPGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris')
}
/**
* This downward migration script removes the subfolder setting for OIDC redirect URIs.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function down({ context: { queryInterface, logger } }) {
// Downward migration script
logger.info('[2.17.4 migration] DOWNGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris ')
// Remove the OIDC subfolder option from the server settings
const serverSettings = await getServerSettings(queryInterface, logger)
if (serverSettings.authOpenIDSubfolderForRedirectURLs !== undefined) {
logger.info('[2.17.4 migration] Removing authOpenIDSubfolderForRedirectURLs from server settings')
delete serverSettings.authOpenIDSubfolderForRedirectURLs
await updateServerSettings(queryInterface, logger, serverSettings)
} else {
logger.info('[2.17.4 migration] authOpenIDSubfolderForRedirectURLs not found in server settings, no action required')
}
logger.info('[2.17.4 migration] DOWNGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris ')
}
async function getServerSettings(queryInterface, logger) {
const result = await queryInterface.sequelize.query('SELECT value FROM settings WHERE key = "server-settings";')
if (!result[0].length) {
logger.error('[2.17.4 migration] Server settings not found')
throw new Error('Server settings not found')
}
let serverSettings = null
try {
serverSettings = JSON.parse(result[0][0].value)
} catch (error) {
logger.error('[2.17.4 migration] Error parsing server settings:', error)
throw error
}
return serverSettings
}
async function updateServerSettings(queryInterface, logger, serverSettings) {
await queryInterface.sequelize.query('UPDATE settings SET value = :value WHERE key = "server-settings";', {
replacements: {
value: JSON.stringify(serverSettings)
}
})
}
module.exports = { up, down }

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