Compare commits

...

145 Commits

Author SHA1 Message Date
advplyr 90f4833c9e Version bump v2.7.2 2024-01-16 17:26:50 -06:00
advplyr c0cb3a176f Update:Hide audiobook tools for windows install, remove debian folder picker alert 2024-01-16 17:19:45 -06:00
advplyr 7b0fa48e2e Update jsdocs for expanded library items 2024-01-16 16:31:16 -06:00
advplyr b51853b3df Update:Use raw cover art for media session #2514 2024-01-15 08:34:12 -06:00
advplyr f5545cd3f4 Add:Scanner extracts cover from comic files #1837 and ComicInfo.xml parser 2024-01-14 17:51:26 -06:00
advplyr e76af3bfc2 Fix comic page menu dropdown highlight correct page 2024-01-13 16:41:13 -06:00
advplyr 850397e4c1 Add:Playlist button to podcast episodes on latest page #2455 2024-01-12 17:58:07 -06:00
advplyr e8fa029df7 Fix:Specific podcast rss feed cannot be fetched due to accept header #2446 2024-01-10 08:12:26 -06:00
advplyr 1a361c91f1 Merge pull request #2506 from FreedomBen/remove-dev-logs
Change `Logger.dev` calls to `Logger.debug`
2024-01-09 16:47:21 -06:00
Benjamin Porter 4a76059608 Change Logger.dev calls to Logger.debug
Logger.dev is kind of in a weird spot where it doesn't fit into the
standard log level.  It is called directly by some code and it only
checks whether a property is set (which comes from an env var) before
deciding to print out.

This standardizes on `debug` by changing the dev calls to debug. Also
removes the now unused code.
2024-01-09 15:24:23 -07:00
advplyr da25eff5c1 Fix:Parse series sequence from OPF in cases where series_index is not directly underneath series meta #2505 2024-01-08 18:21:15 -06:00
advplyr 69e23ef9f2 Add:Epub metadata parser and cover extractor #1479 2024-01-07 17:51:07 -06:00
advplyr 48a08e9659 Merge pull request #2503 from Machou/master
Little Missed Update fr.json
2024-01-07 12:42:09 -06:00
Machou 4608f91ec6 Update fr.json 2024-01-07 02:41:16 +01:00
advplyr e88c1fa329 Update:Show tooltip for library item card titles that are truncated #2451
- Refactored tooltip so that they dont overflow the window
2024-01-06 15:54:48 -06:00
advplyr 935e545caa Update readme for iOS beta full 2024-01-06 14:13:39 -06:00
advplyr a426da534c Fix:Export OPML not escaping characters #2487 2024-01-05 14:45:25 -06:00
advplyr eaf6bf29cc Fix:Improve performance for podcast rss feed episodes modal for large rss feeds 2024-01-05 14:39:25 -06:00
advplyr a0eb6bd3dc Fix:Refresh podcast episode table when new episodes are downloaded 2024-01-05 14:38:29 -06:00
advplyr fbe228a4f8 Merge pull request #2485 from Machou/patch-1
Update fr.json
2024-01-05 10:40:29 -06:00
advplyr 578a59063f Update discord invite link 2024-01-05 09:24:18 -06:00
Machou ffa7cc0d22 Update fr.json 2024-01-05 07:19:07 +01:00
advplyr 4f9969cd9b Merge pull request #2488 from FreedomBen/add-init-system-to-docker
Add tini as PID 1 handler in container image
2024-01-04 13:50:54 -06:00
advplyr 9f909b0d85 Update:Library folder browser to also work for debian and windows 2024-01-03 16:23:17 -06:00
Benjamin Porter baa65b8155 Add tini as PID 1 handler in container image
This PR adds `tini` to the container image and uses it as PID 1 when
starting the container.  This ensures that proper PID 1 signal-handling
is implemented and passed to the underlying node.js process, thereby
ensuring that the ABS process has a chance to receive and handle signals
other than `SIGKILL`, such as the important `SIGINT`.

This is somewhat related to #2445 . Without this, the signal handled by
2445 won't be received when running in a container.

Some background:

In linux, PID 1 has special duties involving signal handling that are
different than other processes.  Node doesn't properly handle these
signals, which can lead to a number of problems ranging from annoying to
disruptive.  PID 1 also has reaping duties that can lead to resource
exhaustion if not properly handled.

For example, the container ignores `SIGINT` (Ctrl+C) as well as `docker stop`,
which can be annoying in development as you have to kill or wait for
the timeout to be reached.  In a production environment (such as Kubernetes)
this can lead to signal escalation and unnecessarily adds delays to
deployments and restarts as K8s has to wait for the timeout to be reached
before sending `SIGKILL`.

At best this is annoying and unnecessarily adds
delays.  At worst this can lead to file/data corruption as the process
doesn't get a chance to clean anything up when it is sent `SIGKILL`.
Without a proper PID 1 to forward signals, only SIGKILL can be used to
terminate the running process.
2024-01-03 13:55:43 -07:00
Machou a1e321b153 Update fr.json 2024-01-03 20:16:21 +01:00
advplyr 8c6a2ac5dd Merge pull request #2391 from mikiher/binary-manager
Add a binary manager that finds ffmpeg and ffprobe and installs them if not found
2024-01-02 14:25:56 -06:00
advplyr b489bf9236 Restrict binary manager to Windows or development 2024-01-02 14:24:59 -06:00
advplyr aa63aa6cf3 Merge branch 'master' into binary-manager 2024-01-02 14:16:27 -06:00
advplyr 9a2b93fb37 Version bump v2.7.1 2023-12-31 15:37:23 -06:00
advplyr e8ea7efc98 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-12-31 15:36:37 -06:00
advplyr 81a76593da Fix:Merging chapters from multiple audio files with the same chapter titles #2461 2023-12-31 15:35:17 -06:00
advplyr 5336864f7d Merge pull request #2465 from thevoltagesource/getFileMtimeMs_Unhandled_Exception
Add try/catch to fileUtils.getFileMtimeMs
2023-12-31 15:34:43 -06:00
advplyr d38058e1d2 Fix:Podcast episode time remaining shown on button showing 0 seconds after toggling mark as finished 2023-12-31 15:32:44 -06:00
advplyr fececd4651 Fix:Playlists navigation button not showing on mobile screen #2469 2023-12-31 15:09:35 -06:00
advplyr 021adf3104 Update:Podcast episode table is lazy loaded #1549 2023-12-31 14:51:01 -06:00
advplyr 160c83df4a Update:podcastEpisodes table index added for createdAt column #2073 #2075 2023-12-30 16:14:14 -06:00
advplyr 456bb87a00 Update:Find one library item endpoint sequelize query split into two queries to improve performance #2073 #2075 2023-12-30 12:12:48 -06:00
advplyr 707451309c Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-12-29 17:05:40 -06:00
advplyr 269676e8a5 Update:CORS for /cover API endpoint for use in canvas in the mobile apps 2023-12-29 17:05:35 -06:00
Jacob Southard e4effebc19 Add try/catch to fileutils.getFileMtimeMs 2023-12-29 10:04:59 -06:00
advplyr fbbceddba8 Merge pull request #2454 from mikiher/socket-authority-close
Add SocketAuthority.close()
2023-12-28 16:32:40 -06:00
advplyr 9a634e0de5 Add JS docs for server stop 2023-12-28 16:32:21 -06:00
mikiher 21d0d43edc Add SocketAuthority.close() 2023-12-27 15:33:33 +02:00
mikiher 3051b963ef Merge branch 'advplyr:master' into binary-manager 2023-12-27 06:44:22 +02:00
advplyr 0d0bdce337 Fix:Fetch RSS feed request accept header #2446 2023-12-25 13:15:55 -06:00
advplyr bdb5dc8c28 Merge pull request #2445 from mikiher/sigint-handler
Add a SIGINT handler for proper server shutdown
2023-12-25 12:51:22 -06:00
mikiher 209847d98a Add a SIGINT handler for proper server shutdown 2023-12-25 09:25:04 +02:00
advplyr 14f42e15d1 Fix:Book scanner update book series sequence if changed 2023-12-24 11:53:57 -06:00
advplyr 7402e4811d Merge pull request #2444 from jedrus2000/opf-multiple-series-support
Add: OPF file supports multiple series as sequence of : calibre:series and calibre:series_index; including tests
2023-12-24 11:42:06 -06:00
advplyr 6de0465b86 Update opf parser to ignore series with empty content and add tests 2023-12-24 11:41:27 -06:00
Andrzej Bargański cd7c4baaaf Add: OPF file supports multiple series as sequence of : calibre:series and calibre:series_index; including tests 2023-12-24 00:43:42 +01:00
advplyr a2db81bf7d Fix share button for year in review short card 2023-12-23 17:13:44 -06:00
advplyr b376f89ce5 Version bump v2.7.0 2023-12-23 17:05:44 -06:00
advplyr 5633113f25 Update share buttons to not show an error on abort 2023-12-23 16:39:56 -06:00
advplyr 669415cfbf Merge pull request #2431 from pablojimenezmateo/comic-zoom
Add zoom controls to comic reader
2023-12-23 16:18:23 -06:00
advplyr 9f366863a9 Update comic reader buttons for mobile screens, add left scrollBy 2023-12-23 16:16:24 -06:00
advplyr 0d644fe0c9 Add:Year in review banner for user stats page #2373 2023-12-23 15:29:34 -06:00
advplyr 72fa6b8200 Fix:Show cover size widget when audio player is open #2443 2023-12-23 10:50:04 -06:00
advplyr 6d3f1d263a Merge pull request #2442 from JBlond/master
Follow up Translations for 76119445a3
2023-12-23 08:55:28 -06:00
JBlond 47bf9f7836 Follow up Translations for 76119445a3
* Update:Listening sessions table for multi-select, sorting and rows per page
- Updated get all sessions API endpoint to include sorting
- Added sessions API endpoint for batch deleting
2023-12-23 14:42:56 +01:00
advplyr 2738402aac Add:Year in review card for server stats #2373 2023-12-22 17:01:07 -06:00
advplyr 68d36522b1 Update:Listening sessions table UI for mobile 2023-12-21 14:36:51 -06:00
advplyr 24a587b944 Update:Remove playback sessions that are 3s or less on startup 2023-12-21 14:29:36 -06:00
advplyr 76119445a3 Update:Listening sessions table for multi-select, sorting and rows per page
- Updated get all sessions API endpoint to include sorting
- Added sessions API endpoint for batch deleting
2023-12-21 13:52:42 -06:00
advplyr 46ec59c74e Update:Year in review card prevent text overflow for narrator, author and genre #2373 2023-12-21 09:44:37 -06:00
advplyr 2b7122c744 Update:Year stats API endpoint & generate year in review image #2373 2023-12-20 17:18:21 -06:00
Pablo 52f0a5432b feat: enable zoom through the arrow buttons 2023-12-20 11:45:21 +01:00
advplyr 7391b4d0ec Add:User stats API for year stats 2023-12-19 17:19:33 -06:00
Pablo aa7ee3e8ff fix: zoom buttons were showing when loading the image 2023-12-19 18:45:11 +01:00
Pablo bef0f3709f feat: add basic zoom functionality to comic reader 2023-12-19 18:39:02 +01:00
advplyr f33b011847 Merge pull request #2420 from treyg/synology-reverse-proxy-docs
docs: update synology reverse proxy to use the latest DSM settings
2023-12-17 16:42:43 -06:00
Trey Gordon 2d8d11d4da docs: update synology reverse proxy to use the latest DSM settings 2023-12-17 15:56:14 -05:00
advplyr 10b1784f6d Fix:Library search API endpoint /libraries/:id/search to check that query param q is a valid string 2023-12-17 12:23:55 -06:00
advplyr f2f2ea161c Update:API endpoint /podcasts/feed validates rssFeed URL and uses SSRF req filter 2023-12-17 12:00:11 -06:00
advplyr dc67a52000 Update:API endpoint /search/podcast throw 400 error if term query param is not supplied 2023-12-17 11:18:21 -06:00
advplyr 05820aa820 Update:API endpoints /podcasts/feed and /podcasts/opml restricted to admin users 2023-12-17 11:17:35 -06:00
advplyr 8966dbbcd1 Fix:Restrict podcast search page to admins 2023-12-17 11:06:03 -06:00
advplyr cf32819c01 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-12-17 10:41:55 -06:00
advplyr 728496010c Update:/auth/openid/config API endpoint to require admin user and validate issuer URL 2023-12-17 10:41:39 -06:00
advplyr 0a08f47942 Merge pull request #2417 from springsunx/master
Update zh-cn.json
2023-12-15 12:14:15 -06:00
SunX 39ceb02500 Update zh-cn.json 2023-12-15 19:04:56 +08:00
advplyr 4336714248 Merge pull request #2415 from nichwall/docker_compose_update
Added comments to the Docker Compose file
2023-12-15 04:14:16 -06:00
nichwall 1d41904fc3 Added comments to the Docker Compose file 2023-12-14 21:04:37 -07:00
advplyr fae383a045 Fix:RSS feeds for collections not updating #2414 2023-12-14 15:45:34 -06:00
mikiher 8f7a420cca Fix directory writable check (fs.access not working on Windows) 2023-12-14 09:47:18 +02:00
advplyr 9720ba3eed Merge pull request #2413 from JBlond/master
More german translations
2023-12-13 13:40:36 -06:00
JBlond d3256d59d5 - Translate more strings
- Add missing least empty line
2023-12-13 20:12:25 +01:00
advplyr fa5f7ab7a5 Merge pull request #2411 from Nab0y/master
Update Russian localization
2023-12-12 16:57:35 -06:00
Dmitry Naboychenko 6f26fd7238 Update Russian localization 2023-12-12 22:56:05 +03:00
advplyr 6abc0819d9 Merge pull request #2400 from mikiher/bookfinder-improvements
A few BookFinder improvements (including a fix for #2238)
2023-12-10 10:36:21 -06:00
advplyr b580a23e7e BookFinder formatting update 2023-12-10 10:35:21 -06:00
advplyr f659c3f11c Fix:Podcast RSS feed request header to include application/rss+xml #2401 2023-12-09 13:51:28 -06:00
mikiher 0282a0521b Sort audible match results by duration difference 2023-12-09 00:33:06 +02:00
advplyr 75637e4b94 Merge pull request #2397 from JBlond/master
Follow up for sso-redirecturi and #2305 and #2333
2023-12-08 15:42:28 -06:00
mikiher b6c789dee6 Merge branch 'advplyr:master' into bookfinder-improvements 2023-12-08 14:07:25 +02:00
JBlond 8d3d636329 Follow up for sso-redirecturi and #2305 #2333
8f4c65ec8c / 7c9c278cc4 sso-redirecturi
2f6756eddf #2333
2e5822b7c8 #2305
2023-12-08 09:39:04 +01:00
advplyr 6f6395bad7 Only log update binary env path if it was updated 2023-12-07 17:32:06 -06:00
advplyr b8c8d2a02e Merge pull request #2386 from Sapd/sso-redirecturi
SSO/OpenID: Use a mobile-redirect route (Fixes #2379 and #2381)
2023-12-07 17:12:36 -06:00
advplyr 98104a3c03 Map new translations to other files 2023-12-07 17:05:52 -06:00
advplyr 8f4c65ec8c Merge branch 'master' into sso-redirecturi 2023-12-07 17:04:59 -06:00
advplyr 341a0452da Update auth settings endpoint to return updated flag and show whether updates were made in client toast 2023-12-07 17:01:33 -06:00
mikiher 6afb8de3dd Remove ffbinaries local cache 2023-12-08 00:53:53 +02:00
mikiher 0e62ccc7aa Merge branch 'binary-manager' of https://github.com/mikiher/audiobookshelf into binary-manager 2023-12-07 23:51:33 +02:00
mikiher 09282a9a62 Remove all callbacks and refactor spaghetti code in downloadUrls 2023-12-07 23:49:46 +02:00
advplyr 18b3ab5610 Revert package-lock updates 2023-12-07 15:12:49 -06:00
mikiher 699a658df9 Remove debug printing from libs/ffbinaries 2023-12-07 08:50:45 +02:00
advplyr b5e255a384 Update:Clean series sequence response from audible provider #2380
- Removes Book prefix
- Splits on spaces and takes first, removes trailing comma
2023-12-06 17:31:36 -06:00
mikiher 67ccd2c1fb Fix test after switching to libs/ffbinaries 2023-12-06 13:45:28 +02:00
mikiher 898b072e68 Merge branch 'advplyr:master' into binary-manager 2023-12-06 09:27:17 +02:00
advplyr 34156af403 Fix:Updating media progress not clearing cache #2392 2023-12-05 17:58:54 -06:00
advplyr 61a0126278 Remove ffbinaries dependency 2023-12-05 17:35:57 -06:00
advplyr 1ce1904c89 Add ffbinaries lib 2023-12-05 17:35:15 -06:00
advplyr 7c9c278cc4 Merge pull request #2293 from Dr-Blank/gujarati-translation
More Gujarati translations
2023-12-05 15:42:05 -06:00
advplyr 450507a812 Map english translations and merge with gu 2023-12-05 15:41:12 -06:00
mikiher c074c835d4 Remove semicolons from test 2023-12-05 22:18:37 +02:00
mikiher 2e989fbe83 Add BinaryManager 2023-12-05 21:19:17 +02:00
mikiher b1b325d00b Add ffbinaries dependency 2023-12-05 21:18:30 +02:00
Denis Arnst cf00650c6d SSO/OpenID: Also fix possible race condition
- We need to define redirect_uri in the callback again, because the global params of passport can change between calls to the first route (ie. if multiple users log in at same time)
- Removed is_rest parameter as requirement for mobile flow (to maximise compatibility with possible oauth libraries)
- Also renamed some variables for clarity
2023-12-05 09:43:06 +01:00
Denis Arnst e6ab28365f SSO/OpenID: Remove modifying redirect_uri in the callback
The redirect URI will be now correctly set to either /callback or /mobile-redirect in the /auth/openid route
2023-12-05 00:18:58 +01:00
Denis Arnst 80fd2a1a18 SSO/OpenID: Use a mobile-redirect route (Fixes #2379 and #2381)
- Implement /auth/openid/mobile-redirect this will redirect to an app-link like audiobookshelf://oauth
- An app must provide an `redirect_uri` parameter with the app-link in the authorization request to /auth/openid
- The user will have to whitelist possible URLs, or explicitly allow all
- Also modified MultiSelect to allow to hide the menu/popup
2023-12-04 22:36:34 +01:00
advplyr 84160b2f07 Fix:Server crash when user without a password attempts to login with a password #2378 2023-12-02 16:17:52 -06:00
advplyr fbc2c2b481 Merge pull request #2333 from kieraneglin/ke/feature/upload-auto-fetch-data
Add ability to fetch book data on upload
2023-12-02 15:56:39 -06:00
Kieran Eglin 57a5005197 Addressed feedback changes 2023-12-01 21:42:54 -08:00
Kieran Eglin 9350c5513e Removed unneeded mixin 2023-12-01 15:19:50 -08:00
advplyr f59516cc6e Fix:Hide change password form when password auth is disabled #2367 2023-12-01 17:10:33 -06:00
advplyr 88078ff813 Fix undefined series string when match has no series, minor ui updates 2023-12-01 16:44:04 -06:00
mikiher 281de48ed4 Fix "et al" cleanup 2023-11-30 21:49:24 +02:00
mikiher 3c6d6bf688 Merge branch 'advplyr:master' into bookfinder-improvements 2023-11-30 21:37:01 +02:00
mikiher 8ac0ce399f Remove "et al[.]" in author cleanup 2023-11-30 21:17:13 +02:00
mikiher 80458e24bd "[un]abridged" in title candidate generation 2023-11-30 21:15:25 +02:00
advplyr 6ab966ee2f Merge pull request #2365 from Sapd/sso-errorhandling
SSO/OpenID: Provide error messages to logs
2023-11-28 16:39:01 -06:00
advplyr 166477ae27 Fix:Narrators page 404 on reload #2359 2023-11-28 16:39:52 -06:00
advplyr a719065b8d Auto formatting 2023-11-28 16:37:19 -06:00
Denis Arnst 36599a2984 SSO/OpenID: Rename probably misleading message 2023-11-28 21:16:39 +01:00
Kieran Eglin d9c9289d65 Added error handling; Made querystring helper 2023-11-28 12:11:14 -08:00
Kieran Eglin e5579b2c33 Improved UI; Added tooltips; Fixed unrelated layout issues 2023-11-28 11:45:44 -08:00
Denis Arnst 618028503b SSO/OpenID: Also Log token header 2023-11-28 20:07:49 +01:00
Kieran Eglin 2f6756eddf Merged parent 2023-11-28 10:21:41 -08:00
Denis Arnst ad53894ea1 SSO/OpenID: Provide detailed error messages 2023-11-28 17:29:22 +01:00
Kieran Eglin 8c434703fb Added computed metadata check to UI dropdown 2023-11-20 09:18:50 -08:00
Kieran Eglin 3cc900ffbf Adds fetching book data on upload 2023-11-20 08:51:00 -08:00
Dr-Blank 6d968f9044 Update gu.json 2023-11-06 18:16:03 -05:00
Dr-Blank 23fa9e8d7f Update gu.json 2023-11-06 18:15:18 -05:00
Dr-Blank 59a428d549 more gu translations 2023-11-06 18:10:57 -05:00
117 changed files with 5519 additions and 883 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ body:
value: "### Mobile app issues report [here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose)." value: "### Mobile app issues report [here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose)."
- type: markdown - type: markdown
attributes: attributes:
value: "### Join the [discord server](https://discord.gg/pJsjuNCKRq) for questions or if you are not sure about a bug." value: "### Join the [discord server](https://discord.gg/HQgCbd6E75) for questions or if you are not sure about a bug."
- type: markdown - type: markdown
attributes: attributes:
value: "## Be as descriptive as you can. Include screenshots, error logs, browser, file types, everything you can think of that might be relevant." value: "## Be as descriptive as you can. Include screenshots, error logs, browser, file types, everything you can think of that might be relevant."
+1 -1
View File
@@ -1,7 +1,7 @@
blank_issues_enabled: false blank_issues_enabled: false
contact_links: contact_links:
- name: Discord - name: Discord
url: https://discord.gg/pJsjuNCKRq url: https://discord.gg/HQgCbd6E75
about: Ask questions, get help troubleshooting, and join the Abs community here. about: Ask questions, get help troubleshooting, and join the Abs community here.
- name: Matrix - name: Matrix
url: https://matrix.to/#/#audiobookshelf:matrix.org url: https://matrix.to/#/#audiobookshelf:matrix.org
+2
View File
@@ -13,6 +13,8 @@
/deploy/ /deploy/
/coverage/ /coverage/
/.nyc_output/ /.nyc_output/
/ffmpeg*
/ffprobe*
sw.* sw.*
.DS_STORE .DS_STORE
+3 -1
View File
@@ -18,7 +18,8 @@ RUN apk update && \
ffmpeg \ ffmpeg \
make \ make \
python3 \ python3 \
g++ g++ \
tini
COPY --from=tone /usr/local/bin/tone /usr/local/bin/ COPY --from=tone /usr/local/bin/tone /usr/local/bin/
COPY --from=build /client/dist /client/dist COPY --from=build /client/dist /client/dist
@@ -31,4 +32,5 @@ RUN apk del make python3 g++
EXPOSE 80 EXPOSE 80
ENTRYPOINT ["tini", "--"]
CMD ["node", "index.js"] CMD ["node", "index.js"]
@@ -1,7 +1,7 @@
<template> <template>
<div id="bookshelf" ref="wrapper" class="w-full max-w-full h-full overflow-y-scroll relative"> <div id="bookshelf" ref="wrapper" class="w-full max-w-full h-full overflow-y-scroll relative">
<!-- Cover size widget --> <!-- Cover size widget -->
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-50" /> <widgets-cover-size-widget class="fixed right-4 z-50" :style="{ bottom: streamLibraryItem ? '181px' : '16px' }" />
<div v-if="loaded && !shelves.length && !search" class="w-full flex flex-col items-center justify-center py-12"> <div v-if="loaded && !shelves.length && !search" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl mb-4 py-4">{{ libraryName }} Library is empty!</p> <p class="text-center text-2xl mb-4 py-4">{{ libraryName }} Library is empty!</p>
@@ -94,6 +94,9 @@ export default {
}, },
selectedMediaItems() { selectedMediaItems() {
return this.$store.state.globals.selectedMediaItems || [] return this.$store.state.globals.selectedMediaItems || []
},
streamLibraryItem() {
return this.$store.state.streamLibraryItem
} }
}, },
methods: { methods: {
@@ -22,6 +22,10 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
</svg> </svg>
</nuxt-link> </nuxt-link>
<nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="flex-grow h-full flex justify-center items-center" :class="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p v-if="isPlaylistsPage || isPodcastLibrary" class="text-sm">{{ $strings.ButtonPlaylists }}</p>
<span v-else class="material-icons-outlined text-lg">queue_music</span>
</nuxt-link>
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="flex-grow h-full flex justify-center items-center" :class="isCollectionsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'"> <nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="flex-grow h-full flex justify-center items-center" :class="isCollectionsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p v-if="isCollectionsPage" class="text-sm">{{ $strings.ButtonCollections }}</p> <p v-if="isCollectionsPage" class="text-sm">{{ $strings.ButtonCollections }}</p>
<span v-else class="material-icons-outlined text-lg">collections_bookmark</span> <span v-else class="material-icons-outlined text-lg">collections_bookmark</span>
@@ -293,6 +297,9 @@ export default {
} }
return items return items
},
showPlaylists() {
return this.$store.state.libraries.numUserPlaylists > 0
} }
}, },
methods: { methods: {
+4 -1
View File
@@ -21,7 +21,7 @@
</div> </div>
</div> </div>
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-50" /> <widgets-cover-size-widget class="fixed right-4 z-50" :style="{ bottom: streamLibraryItem ? '181px' : '16px' }" />
</div> </div>
</template> </template>
@@ -205,6 +205,9 @@ export default {
sizeMultiplier() { sizeMultiplier() {
const baseSize = this.isCoverSquareAspectRatio ? 192 : 120 const baseSize = this.isCoverSquareAspectRatio ? 192 : 120
return this.entityWidth / baseSize return this.entityWidth / baseSize
},
streamLibraryItem() {
return this.$store.state.streamLibraryItem
} }
}, },
methods: { methods: {
+1 -1
View File
@@ -349,7 +349,7 @@ export default {
} }
if ('mediaSession' in navigator) { if ('mediaSession' in navigator) {
var coverImageSrc = this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png') var coverImageSrc = this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png', true)
const artwork = [ const artwork = [
{ {
src: coverImageSrc src: coverImageSrc
+69 -18
View File
@@ -15,24 +15,33 @@
<div class="flex my-2 -mx-2"> <div class="flex my-2 -mx-2">
<div class="w-1/2 px-2"> <div class="w-1/2 px-2">
<ui-text-input-with-label v-model="itemData.title" :disabled="processing" :label="$strings.LabelTitle" @input="titleUpdated" /> <ui-text-input-with-label v-model.trim="itemData.title" :disabled="processing" :label="$strings.LabelTitle" @input="titleUpdated" />
</div> </div>
<div class="w-1/2 px-2"> <div class="w-1/2 px-2">
<ui-text-input-with-label v-if="!isPodcast" v-model="itemData.author" :disabled="processing" :label="$strings.LabelAuthor" /> <div v-if="!isPodcast" class="flex items-end">
<ui-text-input-with-label v-model.trim="itemData.author" :disabled="processing" :label="$strings.LabelAuthor" />
<ui-tooltip :text="$strings.LabelUploaderItemFetchMetadataHelp">
<div
class="ml-2 mb-1 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full hover:bg-primary cursor-pointer"
@click="fetchMetadata">
<span class="text-base text-white text-opacity-80 font-mono material-icons">sync</span>
</div>
</ui-tooltip>
</div>
<div v-else class="w-full"> <div v-else class="w-full">
<p class="px-1 text-sm font-semibold">{{ $strings.LabelDirectory }} <em class="font-normal text-xs pl-2">(auto)</em></p> <p class="px-1 text-sm font-semibold">{{ $strings.LabelDirectory }} <em class="font-normal text-xs pl-2">(auto)</em></p>
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" style="height: 38px" /> <ui-text-input :value="directory" disabled class="w-full font-mono text-xs" />
</div> </div>
</div> </div>
</div> </div>
<div v-if="!isPodcast" class="flex my-2 -mx-2"> <div v-if="!isPodcast" class="flex my-2 -mx-2">
<div class="w-1/2 px-2"> <div class="w-1/2 px-2">
<ui-text-input-with-label v-model="itemData.series" :disabled="processing" :label="$strings.LabelSeries" note="(optional)" /> <ui-text-input-with-label v-model.trim="itemData.series" :disabled="processing" :label="$strings.LabelSeries" note="(optional)" inputClass="h-10" />
</div> </div>
<div class="w-1/2 px-2"> <div class="w-1/2 px-2">
<div class="w-full"> <div class="w-full">
<p class="px-1 text-sm font-semibold">{{ $strings.LabelDirectory }} <em class="font-normal text-xs pl-2">(auto)</em></p> <label class="px-1 text-sm font-semibold">{{ $strings.LabelDirectory }} <em class="font-normal text-xs pl-2">(auto)</em></label>
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" style="height: 38px" /> <ui-text-input :value="directory" disabled class="w-full font-mono text-xs h-10" />
</div> </div>
</div> </div>
</div> </div>
@@ -48,8 +57,8 @@
<p class="text-base">{{ $strings.MessageUploaderItemFailed }}</p> <p class="text-base">{{ $strings.MessageUploaderItemFailed }}</p>
</widgets-alert> </widgets-alert>
<div v-if="isUploading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 flex items-center justify-center z-20"> <div v-if="isNonInteractable" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 flex items-center justify-center z-20">
<ui-loading-indicator :text="$strings.MessageUploading" /> <ui-loading-indicator :text="nonInteractionLabel" />
</div> </div>
</div> </div>
</template> </template>
@@ -61,10 +70,11 @@ export default {
props: { props: {
item: { item: {
type: Object, type: Object,
default: () => {} default: () => { }
}, },
mediaType: String, mediaType: String,
processing: Boolean processing: Boolean,
provider: String
}, },
data() { data() {
return { return {
@@ -76,7 +86,8 @@ export default {
error: '', error: '',
isUploading: false, isUploading: false,
uploadFailed: false, uploadFailed: false,
uploadSuccess: false uploadSuccess: false,
isFetchingMetadata: false
} }
}, },
computed: { computed: {
@@ -87,12 +98,19 @@ export default {
if (!this.itemData.title) return '' if (!this.itemData.title) return ''
if (this.isPodcast) return this.itemData.title if (this.isPodcast) return this.itemData.title
if (this.itemData.series && this.itemData.author) { const outputPathParts = [this.itemData.author, this.itemData.series, this.itemData.title]
return Path.join(this.itemData.author, this.itemData.series, this.itemData.title) const cleanedOutputPathParts = outputPathParts.filter(Boolean).map(part => this.$sanitizeFilename(part))
} else if (this.itemData.author) {
return Path.join(this.itemData.author, this.itemData.title) return Path.join(...cleanedOutputPathParts)
} else { },
return this.itemData.title isNonInteractable() {
return this.isUploading || this.isFetchingMetadata
},
nonInteractionLabel() {
if (this.isUploading) {
return this.$strings.MessageUploading
} else if (this.isFetchingMetadata) {
return this.$strings.LabelFetchingMetadata
} }
} }
}, },
@@ -105,9 +123,42 @@ export default {
titleUpdated() { titleUpdated() {
this.error = '' this.error = ''
}, },
async fetchMetadata() {
if (!this.itemData.title.trim().length) {
return
}
this.isFetchingMetadata = true
this.error = ''
try {
const searchQueryString = new URLSearchParams({
title: this.itemData.title,
author: this.itemData.author,
provider: this.provider
})
const [bestCandidate, ..._rest] = await this.$axios.$get(`/api/search/books?${searchQueryString}`)
if (bestCandidate) {
this.itemData = {
...this.itemData,
title: bestCandidate.title,
author: bestCandidate.author,
series: (bestCandidate.series || [])[0]?.series
}
} else {
this.error = this.$strings.ErrorUploadFetchMetadataNoResults
}
} catch (e) {
console.error('Failed', e)
this.error = this.$strings.ErrorUploadFetchMetadataAPI
} finally {
this.isFetchingMetadata = false
}
},
getData() { getData() {
if (!this.itemData.title) { if (!this.itemData.title) {
this.error = 'Must have a title' this.error = this.$strings.ErrorUploadLacksTitle
return null return null
} }
this.error = '' this.error = ''
+10 -3
View File
@@ -8,10 +8,10 @@
<!-- Alternative bookshelf title/author/sort --> <!-- Alternative bookshelf title/author/sort -->
<div v-if="isAlternativeBookshelfView || isAuthorBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }"> <div v-if="isAlternativeBookshelfView || isAuthorBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
<div :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }"> <div :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
<div class="flex items-center"> <ui-tooltip :text="displayTitle" :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
<span class="truncate">{{ displayTitle }}</span> <p ref="displayTitle" class="truncate">{{ displayTitle }}</p>
<widgets-explicit-indicator :explicit="isExplicit" /> <widgets-explicit-indicator :explicit="isExplicit" />
</div> </ui-tooltip>
</div> </div>
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || '&nbsp;' }}</p> <p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || '&nbsp;' }}</p>
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p> <p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
@@ -164,6 +164,7 @@ export default {
imageReady: false, imageReady: false,
selected: false, selected: false,
isSelectionMode: false, isSelectionMode: false,
displayTitleTruncated: false,
showCoverBg: false showCoverBg: false
} }
}, },
@@ -642,6 +643,12 @@ export default {
} }
this.libraryItem = libraryItem this.libraryItem = libraryItem
this.$nextTick(() => {
if (this.$refs.displayTitle) {
this.displayTitleTruncated = this.$refs.displayTitle.scrollWidth > this.$refs.displayTitle.clientWidth
}
})
}, },
clickCard(e) { clickCard(e) {
if (this.processing) return if (this.processing) return
@@ -332,6 +332,7 @@ export default {
if (this.isPodcast) return `term=${encodeURIComponent(this.searchTitle)}` if (this.isPodcast) return `term=${encodeURIComponent(this.searchTitle)}`
var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${encodeURIComponent(this.searchTitle)}` var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${encodeURIComponent(this.searchTitle)}`
if (this.searchAuthor) searchQuery += `&author=${encodeURIComponent(this.searchAuthor)}` if (this.searchAuthor) searchQuery += `&author=${encodeURIComponent(this.searchAuthor)}`
if (this.libraryItemId) searchQuery += `&id=${this.libraryItemId}`
return searchQuery return searchQuery
}, },
submitSearch() { submitSearch() {
+11 -16
View File
@@ -2,8 +2,11 @@
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6"> <div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
<p class="text-xl font-semibold mb-2">{{ $strings.HeaderAudiobookTools }}</p> <p class="text-xl font-semibold mb-2">{{ $strings.HeaderAudiobookTools }}</p>
<!-- alert for windows install -->
<widgets-alert v-if="isWindowsInstall" type="warning" class="my-8 text-base">Not supported for the Windows install yet</widgets-alert>
<!-- Merge to m4b --> <!-- Merge to m4b -->
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-8"> <div v-if="showM4bDownload && !isWindowsInstall" class="w-full border border-black-200 p-4 my-8">
<div class="flex flex-wrap items-center"> <div class="flex flex-wrap items-center">
<div> <div>
<p class="text-lg">{{ $strings.LabelToolsMakeM4b }}</p> <p class="text-lg">{{ $strings.LabelToolsMakeM4b }}</p>
@@ -19,22 +22,8 @@
</div> </div>
</div> </div>
<!-- Split to mp3 -->
<!-- <div v-if="showMp3Split" class="w-full border border-black-200 p-4 my-8">
<div class="flex items-center">
<div>
<p class="text-lg">{{ $strings.LabelToolsSplitM4b }}</p>
<p class="max-w-sm text-sm pt-2 text-gray-300">{{ $strings.LabelToolsSplitM4bDescription }}</p>
</div>
<div class="flex-grow" />
<div>
<ui-btn :disabled="true">{{ $strings.MessageNotYetImplemented }}</ui-btn>
</div>
</div>
</div> -->
<!-- Embed Metadata --> <!-- Embed Metadata -->
<div v-if="mediaTracks.length" class="w-full border border-black-200 p-4 my-8"> <div v-if="mediaTracks.length && !isWindowsInstall" class="w-full border border-black-200 p-4 my-8">
<div class="flex items-center"> <div class="flex items-center">
<div> <div>
<p class="text-lg">{{ $strings.LabelToolsEmbedMetadata }}</p> <p class="text-lg">{{ $strings.LabelToolsEmbedMetadata }}</p>
@@ -122,6 +111,12 @@ export default {
}, },
isEncodeTaskRunning() { isEncodeTaskRunning() {
return this.encodeTask && !this.encodeTask?.isFinished return this.encodeTask && !this.encodeTask?.isFinished
},
isWindowsInstall() {
return this.Source == 'windows'
},
Source() {
return this.$store.state.Source
} }
}, },
methods: { methods: {
@@ -31,7 +31,7 @@
<ui-btn class="w-full mt-2" color="primary" @click="browseForFolder">{{ $strings.ButtonBrowseForFolder }}</ui-btn> <ui-btn class="w-full mt-2" color="primary" @click="browseForFolder">{{ $strings.ButtonBrowseForFolder }}</ui-btn>
</div> </div>
</div> </div>
<modals-libraries-folder-chooser v-else :paths="folderPaths" @back="showDirectoryPicker = false" @select="selectFolder" /> <modals-libraries-lazy-folder-chooser v-else :paths="folderPaths" @back="showDirectoryPicker = false" @select="selectFolder" />
</div> </div>
</template> </template>
@@ -4,35 +4,37 @@
<span class="material-icons text-3xl cursor-pointer hover:text-gray-300" @click="$emit('back')">arrow_back</span> <span class="material-icons text-3xl cursor-pointer hover:text-gray-300" @click="$emit('back')">arrow_back</span>
<p class="px-4 text-xl">{{ $strings.HeaderChooseAFolder }}</p> <p class="px-4 text-xl">{{ $strings.HeaderChooseAFolder }}</p>
</div> </div>
<div v-if="allFolders.length" class="w-full bg-primary bg-opacity-70 py-1 px-4 mb-2"> <div v-if="rootDirs.length" class="w-full bg-primary bg-opacity-70 py-1 px-4 mb-2">
<p class="font-mono truncate">{{ selectedPath || '\\' }}</p> <p class="font-mono truncate">{{ selectedPath || '/' }}</p>
</div> </div>
<div v-if="allFolders.length" class="flex bg-primary bg-opacity-50 p-4 folder-container"> <div v-if="rootDirs.length" class="relative flex bg-primary bg-opacity-50 p-4 folder-container">
<div class="w-1/2 border-r border-bg h-full overflow-y-auto"> <div class="w-1/2 border-r border-bg h-full overflow-y-auto">
<div v-if="level > 0" class="w-full p-1 cursor-pointer flex items-center" @click="goBack"> <div v-if="level > 0" class="w-full p-1 cursor-pointer flex items-center hover:bg-white/10" @click="goBack">
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span> <span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
<p class="text-base font-mono px-2">..</p> <p class="text-base font-mono px-2">..</p>
</div> </div>
<div v-for="dir in _directories" :key="dir.path" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200" :class="dir.className" @click="selectDir(dir)"> <div v-for="dir in _directories" :key="dir.path" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200 hover:bg-white/10" :class="dir.className" @click="selectDir(dir)">
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span> <span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
<p class="text-base font-mono px-2 truncate">{{ dir.dirname }}</p> <p class="text-base font-mono px-2 truncate">{{ dir.dirname }}</p>
<span v-if="dir.dirs && dir.dirs.length && dir.path === selectedPath" class="material-icons" style="font-size: 1.1rem">arrow_right</span> <span v-if="dir.path === selectedPath" class="material-icons" style="font-size: 1.1rem">arrow_right</span>
</div> </div>
</div> </div>
<div class="w-1/2 h-full overflow-y-auto"> <div class="w-1/2 h-full overflow-y-auto">
<div v-for="dir in _subdirs" :key="dir.path" :class="dir.className" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200" @click="selectSubDir(dir)"> <div v-for="dir in _subdirs" :key="dir.path" :class="dir.className" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200 hover:bg-white/10" @click="selectSubDir(dir)">
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span> <span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
<p class="text-base font-mono px-2 truncate">{{ dir.dirname }}</p> <p class="text-base font-mono px-2 truncate">{{ dir.dirname }}</p>
</div> </div>
</div> </div>
<div v-if="loadingDirs" class="absolute inset-0 w-full h-full flex items-center justify-center bg-black/10">
<ui-loading-indicator />
</div>
</div> </div>
<div v-else-if="loadingFolders" class="py-12 text-center"> <div v-else-if="initialLoad" class="py-12 text-center">
<p>{{ $strings.MessageLoadingFolders }}</p> <p>{{ $strings.MessageLoadingFolders }}</p>
</div> </div>
<div v-else class="py-12 text-center max-w-sm mx-auto"> <div v-else class="py-12 text-center max-w-sm mx-auto">
<p class="text-lg mb-2">{{ $strings.MessageNoFoldersAvailable }}</p> <p class="text-lg mb-2">{{ $strings.MessageNoFoldersAvailable }}</p>
<p class="text-gray-300 mb-2">{{ $strings.NoteFolderPicker }}</p> <p class="text-gray-300 mb-2">{{ $strings.NoteFolderPicker }}</p>
<p v-if="isDebian" class="text-red-400">{{ $strings.NoteFolderPickerDebian }}</p>
</div> </div>
<div class="w-full py-2"> <div class="w-full py-2">
@@ -51,11 +53,12 @@ export default {
}, },
data() { data() {
return { return {
loadingFolders: false, initialLoad: false,
allFolders: [], loadingDirs: false,
isPosix: true,
rootDirs: [],
directories: [], directories: [],
selectedPath: '', selectedPath: '',
selectedFullPath: '',
subdirs: [], subdirs: [],
level: 0, level: 0,
currentDir: null, currentDir: null,
@@ -89,68 +92,91 @@ export default {
...d ...d
} }
}) })
},
isDebian() {
return this.Source == 'debian'
},
Source() {
return this.$store.state.Source
} }
}, },
methods: { methods: {
goBack() { async goBack() {
var splitPaths = this.selectedPath.split('\\').slice(1) let selPath = this.selectedPath.replace(/^\//, '')
var prev = splitPaths.slice(0, -1).join('\\') var splitPaths = selPath.split('/')
var currDirs = this.allFolders let previousPath = ''
for (let i = 0; i < splitPaths.length; i++) { let lookupPath = ''
var _dir = currDirs.find((dir) => dir.dirname === splitPaths[i])
if (_dir && _dir.path.slice(1) === prev) { if (splitPaths.length > 2) {
this.directories = currDirs lookupPath = splitPaths.slice(0, -2).join('/')
this.selectDir(_dir)
return
} else if (_dir) {
currDirs = _dir.dirs
}
} }
previousPath = splitPaths.slice(0, -1).join('/')
if (!this.isPosix) {
// For windows drives add a trailing slash. e.g. C:/
if (!this.isPosix && lookupPath.endsWith(':')) {
lookupPath += '/'
}
if (!this.isPosix && previousPath.endsWith(':')) {
previousPath += '/'
}
} else {
// Add leading slash
if (previousPath) previousPath = '/' + previousPath
if (lookupPath) lookupPath = '/' + lookupPath
}
this.level--
this.subdirs = this.directories
this.selectedPath = previousPath
this.directories = await this.fetchDirs(lookupPath, this.level)
}, },
selectDir(dir) { async selectDir(dir) {
if (dir.isUsed) return if (dir.isUsed) return
this.selectedPath = dir.path this.selectedPath = dir.path
this.selectedFullPath = dir.fullPath
this.level = dir.level this.level = dir.level
this.subdirs = dir.dirs this.subdirs = await this.fetchDirs(dir.path, dir.level + 1)
}, },
selectSubDir(dir) { async selectSubDir(dir) {
if (dir.isUsed) return if (dir.isUsed) return
this.selectedPath = dir.path this.selectedPath = dir.path
this.selectedFullPath = dir.fullPath
this.level = dir.level this.level = dir.level
this.directories = this.subdirs this.directories = this.subdirs
this.subdirs = dir.dirs this.subdirs = await this.fetchDirs(dir.path, dir.level + 1)
}, },
selectFolder() { selectFolder() {
if (!this.selectedPath) { if (!this.selectedPath) {
console.error('No Selected path') console.error('No Selected path')
return return
} }
if (this.paths.find((p) => p.startsWith(this.selectedFullPath))) { if (this.paths.find((p) => p.startsWith(this.selectedPath))) {
this.$toast.error(`Oops, you cannot add a parent directory of a folder already added`) this.$toast.error(`Oops, you cannot add a parent directory of a folder already added`)
return return
} }
this.$emit('select', this.selectedFullPath) this.$emit('select', this.selectedPath)
this.selectedPath = '' this.selectedPath = ''
this.selectedFullPath = '' },
fetchDirs(path, level) {
this.loadingDirs = true
return this.$axios
.$get(`/api/filesystem?path=${path}&level=${level}`)
.then((data) => {
console.log('Fetched directories', data.directories)
this.isPosix = !!data.posix
return data.directories
})
.catch((error) => {
console.error('Failed to get filesystem paths', error)
this.$toast.error('Failed to get filesystem paths')
return []
})
.finally(() => {
this.loadingDirs = false
})
}, },
async init() { async init() {
this.loadingFolders = true this.initialLoad = true
this.allFolders = await this.$store.dispatch('libraries/loadFolders') this.rootDirs = await this.fetchDirs('', 0)
this.loadingFolders = false this.initialLoad = false
this.directories = this.allFolders this.directories = this.rootDirs
this.subdirs = [] this.subdirs = []
this.selectedPath = '' this.selectedPath = ''
this.selectedFullPath = ''
} }
}, },
mounted() { mounted() {
@@ -63,7 +63,7 @@ export default {
}, },
audioMetatags: { audioMetatags: {
id: 'audioMetatags', id: 'audioMetatags',
name: 'Audio file meta tags', name: 'Audio file meta tags OR ebook metadata',
include: true include: true
}, },
nfoFile: { nfoFile: {
@@ -68,7 +68,9 @@ export default {
selectAll: false, selectAll: false,
search: null, search: null,
searchTimeout: null, searchTimeout: null,
searchText: null searchText: null,
downloadedEpisodeGuidMap: {},
downloadedEpisodeUrlMap: {}
} }
}, },
watch: { watch: {
@@ -122,11 +124,13 @@ export default {
}, },
methods: { methods: {
getIsEpisodeDownloaded(episode) { getIsEpisodeDownloaded(episode) {
return this.itemEpisodes.some((downloadedEpisode) => { if (episode.guid && !!this.downloadedEpisodeGuidMap[episode.guid]) {
if (episode.guid && downloadedEpisode.guid === episode.guid) return true return true
if (!downloadedEpisode.enclosure?.url) return false }
return this.getCleanEpisodeUrl(downloadedEpisode.enclosure.url) === episode.cleanUrl if (this.downloadedEpisodeUrlMap[episode.cleanUrl]) {
}) return true
}
return false
}, },
/** /**
* UPDATE: As of v2.4.5 guid is used for matching existing downloaded episodes if it is found on the RSS feed. * UPDATE: As of v2.4.5 guid is used for matching existing downloaded episodes if it is found on the RSS feed.
@@ -219,6 +223,14 @@ export default {
}) })
}, },
init() { init() {
this.downloadedEpisodeGuidMap = {}
this.downloadedEpisodeUrlMap = {}
this.itemEpisodes.forEach((episode) => {
if (episode.guid) this.downloadedEpisodeGuidMap[episode.guid] = episode.id
if (episode.enclosure?.url) this.downloadedEpisodeUrlMap[this.getCleanEpisodeUrl(episode.enclosure.url)] = episode.id
})
this.episodesCleaned = this.episodes this.episodesCleaned = this.episodes
.filter((ep) => ep.enclosure?.url) .filter((ep) => ep.enclosure?.url)
.map((_ep) => { .map((_ep) => {
+62 -18
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="w-full h-full"> <div class="w-full h-full">
<div v-show="showPageMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 left-8 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400" :style="{ width: pageMenuWidth + 'px' }"> <div v-show="showPageMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 left-8 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400" :style="{ width: pageMenuWidth + 'px' }">
<div v-for="(file, index) in cleanedPageNames" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index ? 'bg-black-200' : ''" @click="setPage(index + 1)"> <div v-for="(file, index) in cleanedPageNames" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index + 1 ? 'bg-black-200' : ''" @click="setPage(index + 1)">
<p class="text-sm truncate">{{ file }}</p> <p class="text-sm truncate">{{ file }}</p>
</div> </div>
</div> </div>
@@ -14,34 +14,40 @@
</div> </div>
</div> </div>
<a v-if="pages && numPages" :href="mainImg" :download="pages[page - 1]" class="absolute top-0 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" :class="comicMetadata ? 'left-32' : 'left-20'"> <div v-if="numPages" class="absolute top-0 left-4 sm:left-8 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="clickShowPageMenu">
<span class="material-icons text-xl">download</span>
</a>
<div v-if="comicMetadata" class="absolute top-0 left-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="clickShowInfoMenu">
<span class="material-icons text-xl">more</span>
</div>
<div v-if="numPages" class="absolute top-0 left-8 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="clickShowPageMenu">
<span class="material-icons text-xl">menu</span> <span class="material-icons text-xl">menu</span>
</div> </div>
<div v-if="numPages" class="absolute top-0 right-16 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20"> <div v-if="comicMetadata" class="absolute top-0 left-16 sm:left-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="clickShowInfoMenu">
<span class="material-icons text-xl">more</span>
</div>
<a v-if="pages && numPages" :href="mainImg" :download="pages[page - 1]" class="absolute top-0 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" :class="comicMetadata ? 'left-28 sm:left-32' : 'left-16 sm:left-20'">
<span class="material-icons text-xl">download</span>
</a>
<div v-if="numPages" class="absolute top-0 right-14 sm:right-16 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20">
<p class="font-mono">{{ page }} / {{ numPages }}</p> <p class="font-mono">{{ page }} / {{ numPages }}</p>
</div> </div>
<div v-if="mainImg" class="absolute top-0 right-36 sm:right-40 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20">
<ui-icon-btn icon="zoom_out" :size="8" :disabled="!canScaleDown" borderless class="mr-px" @click="zoomOut" />
<ui-icon-btn icon="zoom_in" :size="8" :disabled="!canScaleUp" borderless class="ml-px" @click="zoomIn" />
</div>
<div class="overflow-hidden w-full h-full relative"> <div class="w-full h-full relative">
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 lg:w-1/3 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent> <div v-show="canGoPrev" ref="prevButton" class="absolute top-0 left-0 h-full w-1/2 lg:w-1/3 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
<div class="flex items-center justify-center h-full w-1/2"> <div class="flex items-center justify-center h-full w-1/2">
<span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span> <span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span>
</div> </div>
</div> </div>
<div v-show="canGoNext" class="absolute top-0 right-0 h-full w-1/2 lg:w-1/3 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent> <div v-show="canGoNext" ref="nextButton" class="absolute top-0 right-0 h-full w-1/2 lg:w-1/3 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent>
<div class="flex items-center justify-center h-full w-1/2 ml-auto"> <div class="flex items-center justify-center h-full w-1/2 ml-auto">
<span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_forward_ios</span> <span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_forward_ios</span>
</div> </div>
</div> </div>
<div class="h-full flex justify-center"> <div ref="imageContainer" class="w-full h-full relative overflow-auto">
<img v-if="mainImg" :src="mainImg" class="object-contain h-full m-auto" /> <div class="h-full flex" :class="scale > 100 ? '' : 'justify-center'">
<img v-if="mainImg" :style="{ minWidth: scale + '%', width: scale + '%' }" :src="mainImg" class="object-contain m-auto" />
</div>
</div> </div>
<div v-show="loading" class="w-full h-full absolute top-0 left-0 flex items-center justify-center z-10"> <div v-show="loading" class="w-full h-full absolute top-0 left-0 flex items-center justify-center z-10">
<ui-loading-indicator /> <ui-loading-indicator />
</div> </div>
@@ -54,6 +60,10 @@ import Path from 'path'
import { Archive } from 'libarchive.js/main.js' import { Archive } from 'libarchive.js/main.js'
import { CompressedFile } from 'libarchive.js/src/compressed-file' import { CompressedFile } from 'libarchive.js/src/compressed-file'
// This is % with respect to the screen width
const MAX_SCALE = 400
const MIN_SCALE = 10
Archive.init({ Archive.init({
workerUrl: '/libarchive/worker-bundle.js' workerUrl: '/libarchive/worker-bundle.js'
}) })
@@ -81,7 +91,8 @@ export default {
showInfoMenu: false, showInfoMenu: false,
loadTimeout: null, loadTimeout: null,
loadedFirstPage: false, loadedFirstPage: false,
comicMetadata: null comicMetadata: null,
scale: 80
} }
}, },
watch: { watch: {
@@ -136,6 +147,12 @@ export default {
return p return p
}) || [] }) || []
) )
},
canScaleUp() {
return this.scale < MAX_SCALE
},
canScaleDown() {
return this.scale > MIN_SCALE
} }
}, },
methods: { methods: {
@@ -331,10 +348,37 @@ export default {
orderedImages = orderedImages.concat(noNumImages.map((i) => i.filename)) orderedImages = orderedImages.concat(noNumImages.map((i) => i.filename))
this.pages = orderedImages this.pages = orderedImages
},
zoomIn() {
this.scale += 10
},
zoomOut() {
this.scale -= 10
},
scroll(event) {
const imageContainer = this.$refs.imageContainer
imageContainer.scrollBy({
top: event.deltaY,
left: event.deltaX,
behavior: 'auto'
})
} }
}, },
mounted() {}, mounted() {
beforeDestroy() {} const prevButton = this.$refs.prevButton
const nextButton = this.$refs.nextButton
prevButton.addEventListener('wheel', this.scroll, { passive: false })
nextButton.addEventListener('wheel', this.scroll, { passive: false })
},
beforeDestroy() {
const prevButton = this.$refs.prevButton
const nextButton = this.$refs.nextButton
prevButton.removeEventListener('wheel', this.scroll, { passive: false })
nextButton.removeEventListener('wheel', this.scroll, { passive: false })
}
} }
</script> </script>
+285
View File
@@ -0,0 +1,285 @@
<template>
<div>
<div v-if="processing" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
<widgets-loading-spinner />
</div>
<img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" />
</div>
</template>
<script>
export default {
props: {
variant: {
type: Number,
default: 0
},
year: Number,
processing: Boolean
},
data() {
return {
canvas: null,
dataUrl: null,
yearStats: null
}
},
watch: {
variant() {
this.init()
}
},
methods: {
async initCanvas() {
if (!this.yearStats) return
const canvas = document.createElement('canvas')
canvas.width = 800
canvas.height = 800
const ctx = canvas.getContext('2d')
const createRoundedRect = (x, y, w, h) => {
const grd1 = ctx.createLinearGradient(x, y, x + w, y + h)
grd1.addColorStop(0, '#44444455')
grd1.addColorStop(1, '#ffffff11')
ctx.fillStyle = grd1
ctx.strokeStyle = '#C0C0C088'
ctx.beginPath()
ctx.roundRect(x, y, w, h, [20])
ctx.fill()
ctx.stroke()
}
const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => {
ctx.fillStyle = color
ctx.font = `${fontWeight} ${fontSize} Source Sans Pro`
ctx.letterSpacing = letterSpacing
// If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis
if (maxWidth) {
let txtWidth = ctx.measureText(text).width
while (txtWidth > maxWidth) {
console.warn(`Text "${text}" is greater than max width ${maxWidth} (width:${txtWidth})`)
if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time
else text = text.slice(0, -3) // First check remove last 3 chars
text += '...'
txtWidth = ctx.measureText(text).width
console.log(`Checking text "${text}" (width:${txtWidth})`)
}
}
ctx.fillText(text, x, y)
}
const addIcon = (icon, color, fontSize, x, y) => {
ctx.fillStyle = color
ctx.font = `${fontSize} Material Icons Outlined`
ctx.fillText(icon, x, y)
}
// Bg color
ctx.fillStyle = '#232323'
ctx.fillRect(0, 0, canvas.width, canvas.height)
// Cover image tiles
const bookCovers = this.yearStats.finishedBooksWithCovers
bookCovers.push(...this.yearStats.booksWithCovers)
let finishedBookCoverImgs = {}
if (bookCovers.length) {
let index = 0
ctx.globalAlpha = 0.25
ctx.save()
ctx.translate(canvas.width / 2, canvas.height / 2)
ctx.rotate((-Math.PI / 180) * 25)
ctx.translate(-canvas.width / 2, -canvas.height / 2)
ctx.translate(-130, -120)
for (let x = 0; x < 5; x++) {
for (let y = 0; y < 5; y++) {
const coverIndex = index % bookCovers.length
let libraryItemId = bookCovers[coverIndex]
index++
await new Promise((resolve) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.addEventListener('load', () => {
let sw = img.width
if (img.width > img.height) {
sw = img.height
}
let sx = -(sw - img.width) / 2
let sy = -(sw - img.height) / 2
ctx.drawImage(img, sx, sy, sw, sw, 215 * x, 215 * y, 215, 215)
resolve()
if (this.yearStats.finishedBooksWithCovers.includes(libraryItemId) && !finishedBookCoverImgs[libraryItemId]) {
finishedBookCoverImgs[libraryItemId] = {
img,
sx,
sy,
sw
}
}
})
img.addEventListener('error', () => {
resolve()
})
img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)
})
}
}
ctx.restore()
}
ctx.globalAlpha = 1
ctx.textBaseline = 'middle'
// Create gradient
const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
grd1.addColorStop(0, '#000000aa')
grd1.addColorStop(1, '#cd9d49aa')
ctx.fillStyle = grd1
ctx.fillRect(0, 0, canvas.width, canvas.height)
// Top Abs icon
let tanColor = '#ffdb70'
ctx.fillStyle = tanColor
ctx.font = '42px absicons'
ctx.fillText('\ue900', 15, 36)
// Top text
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51)
// Top left box
createRoundedRect(50, 100, 340, 160)
addText(this.yearStats.numBooksFinished, '64px', 'bold', 'white', '0px', 160, 165)
addText('books finished', '28px', 'normal', tanColor, '0px', 160, 210)
const readIconPath = new Path2D()
readIconPath.addPath(new Path2D('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'), { a: 2, d: 2, e: 100, f: 160 })
ctx.fillStyle = '#ffffff'
ctx.fill(readIconPath)
// Box top right
createRoundedRect(410, 100, 340, 160)
addText(this.$elapsedPrettyExtended(this.yearStats.totalListeningTime, true, false), '40px', 'bold', 'white', '0px', 500, 165)
addText('spent listening', '28px', 'normal', tanColor, '0px', 500, 205)
addIcon('watch_later', 'white', '52px', 440, 180)
// Box bottom left
createRoundedRect(50, 280, 340, 160)
addText(this.yearStats.totalListeningSessions, '64px', 'bold', 'white', '0px', 160, 345)
addText('sessions', '28px', 'normal', tanColor, '1px', 160, 390)
addIcon('headphones', 'white', '52px', 95, 360)
// Box bottom right
createRoundedRect(410, 280, 340, 160)
addText(this.yearStats.numBooksListened, '64px', 'bold', 'white', '0px', 500, 345)
addText('books listened to', '28px', 'normal', tanColor, '0px', 500, 390)
addIcon('local_library', 'white', '52px', 440, 360)
if (!this.variant) {
// Text stats
const topNarrator = this.yearStats.mostListenedNarrator
if (topNarrator) {
addText('TOP NARRATOR', '24px', 'normal', tanColor, '1px', 70, 520)
addText(topNarrator.name, '36px', 'bolder', 'white', '0px', 70, 564, 330)
addText(this.$elapsedPrettyExtended(topNarrator.time, true, false), '24px', 'lighter', 'white', '1px', 70, 599)
}
const topGenre = this.yearStats.topGenres[0]
if (topGenre) {
addText('TOP GENRE', '24px', 'normal', tanColor, '1px', 430, 520)
addText(topGenre.genre, '36px', 'bolder', 'white', '0px', 430, 564, 330)
addText(this.$elapsedPrettyExtended(topGenre.time, true, false), '24px', 'lighter', 'white', '1px', 430, 599)
}
const topAuthor = this.yearStats.topAuthors[0]
if (topAuthor) {
addText('TOP AUTHOR', '24px', 'normal', tanColor, '1px', 70, 670)
addText(topAuthor.name, '36px', 'bolder', 'white', '0px', 70, 714, 330)
addText(this.$elapsedPrettyExtended(topAuthor.time, true, false), '24px', 'lighter', 'white', '1px', 70, 749)
}
if (this.yearStats.mostListenedMonth?.time) {
const jsdate = new Date(this.year, this.yearStats.mostListenedMonth.month, 1)
const monthName = this.$formatJsDate(jsdate, 'LLLL')
addText('TOP MONTH', '24px', 'normal', tanColor, '1px', 430, 670)
addText(monthName, '36px', 'bolder', 'white', '0px', 430, 714, 330)
addText(this.$elapsedPrettyExtended(this.yearStats.mostListenedMonth.time, true, false), '24px', 'lighter', 'white', '1px', 430, 749)
}
} else if (this.variant === 1) {
// Bottom images
finishedBookCoverImgs = Object.values(finishedBookCoverImgs)
if (finishedBookCoverImgs.length > 0) {
ctx.textAlign = 'center'
addText('Some books finished this year...', '28px', 'normal', tanColor, '0px', canvas.width / 2, 530)
for (let i = 0; i < Math.min(5, finishedBookCoverImgs.length); i++) {
let imgToAdd = finishedBookCoverImgs[i]
ctx.drawImage(imgToAdd.img, imgToAdd.sx, imgToAdd.sy, imgToAdd.sw, imgToAdd.sw, 40 + 145 * i, 570, 140, 140)
}
}
} else if (this.variant === 2) {
// Text stats
if (this.yearStats.topAuthors.length) {
addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 524)
for (let i = 0; i < this.yearStats.topAuthors.length; i++) {
addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 584 + i * 60, 330)
}
}
if (this.yearStats.topGenres.length) {
addText('TOP GENRES', '24px', 'normal', tanColor, '1px', 430, 524)
for (let i = 0; i < this.yearStats.topGenres.length; i++) {
addText(this.yearStats.topGenres[i].genre, '36px', 'bolder', 'white', '0px', 430, 584 + i * 60, 330)
}
}
}
this.canvas = canvas
this.dataUrl = canvas.toDataURL('png')
},
refresh() {
this.init()
},
share() {
this.canvas.toBlob((blob) => {
const file = new File([blob], 'yearinreview.png', { type: blob.type })
const shareData = {
files: [file]
}
if (navigator.canShare(shareData)) {
navigator
.share(shareData)
.then(() => {
console.log('Share success')
})
.catch((error) => {
console.error('Failed to share', error)
if (error.name !== 'AbortError') {
this.$toast.error('Failed to share: ' + error.message)
}
})
} else {
this.$toast.error('Cannot share natively on this device')
}
})
},
async init() {
this.$emit('update:processing', true)
this.yearStats = await this.$axios.$get(`/api/me/stats/year/${this.year}`).catch((err) => {
console.error('Failed to load stats for year', err)
this.$toast.error('Failed to load year stats')
return null
})
await this.initCanvas()
this.$emit('update:processing', false)
}
},
mounted() {
this.init()
}
}
</script>
@@ -0,0 +1,141 @@
<template>
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-1 sm:p-4 mb-4">
<!-- hack to get icon fonts loaded on init -->
<div class="h-0 w-0 overflow-hidden opacity-0">
<span class="material-icons-outlined">close</span>
<span class="abs-icons icon-audiobookshelf" />
</div>
<div class="flex items-center">
<p class="hidden md:block text-xl font-semibold">{{ yearInReviewYear }} Year in Review</p>
<div class="hidden md:block flex-grow" />
<ui-btn class="w-full md:w-auto" @click.stop="clickShowYearInReview">{{ showYearInReview ? 'Hide Year in Review' : 'See Year in Review' }}</ui-btn>
</div>
<!-- your year in review -->
<div v-if="showYearInReview">
<div class="w-full h-px bg-slate-200/10 my-4" />
<div class="flex items-center justify-center mb-2 max-w-[800px] mx-auto">
<!-- previous button -->
<ui-btn small :disabled="!yearInReviewVariant || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant--">
<span class="material-icons text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
<span class="hidden sm:inline-block pr-2">Previous</span>
</ui-btn>
<!-- share button -->
<ui-btn v-if="showShareButton" small :disabled="processingYearInReview" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReview"> Share </ui-btn>
<div class="flex-grow" />
<p class="hidden sm:block text-lg font-semibold">Your Year in Review ({{ yearInReviewVariant + 1 }})</p>
<p class="block sm:hidden text-lg font-semibold">{{ yearInReviewVariant + 1 }}</p>
<div class="flex-grow" />
<!-- refresh button -->
<ui-btn small :disabled="processingYearInReview" class="inline-flex items-center font-semibold mr-1 sm:mr-2" @click="refreshYearInReview">
<span class="hidden sm:inline-block">Refresh</span>
<span class="material-icons sm:!hidden text-lg py-px">refresh</span>
</ui-btn>
<!-- next button -->
<ui-btn small :disabled="yearInReviewVariant >= 2 || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant++">
<span class="hidden sm:inline-block pl-2">Next</span>
<span class="material-icons-outlined text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
</ui-btn>
</div>
<stats-year-in-review ref="yearInReview" :variant="yearInReviewVariant" :year="yearInReviewYear" :processing.sync="processingYearInReview" />
<!-- your year in review short -->
<div class="w-full max-w-[800px] mx-auto my-4">
<!-- share button -->
<ui-btn v-if="showShareButton" small :disabled="processingYearInReviewShort" class="inline-flex sm:hidden items-center font-semibold mb-1" @click="shareYearInReviewShort"> Share </ui-btn>
<stats-year-in-review-short ref="yearInReviewShort" :year="yearInReviewYear" :processing.sync="processingYearInReviewShort" />
</div>
<!-- your server in review -->
<div v-if="isAdminOrUp" class="w-full max-w-[800px] mx-auto mb-2 mt-4 border-t pt-4 border-white/10">
<div class="flex items-center justify-center mb-2">
<!-- previous button -->
<ui-btn small :disabled="!yearInReviewServerVariant || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant--">
<span class="material-icons text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
<span class="hidden sm:inline-block pr-2">Previous</span>
</ui-btn>
<!-- share button -->
<ui-btn v-if="showShareButton" small :disabled="processingYearInReviewServer" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReviewServer"> Share </ui-btn>
<div class="flex-grow" />
<p class="hidden sm:block text-lg font-semibold">Server Year in Review ({{ yearInReviewServerVariant + 1 }})</p>
<p class="block sm:hidden text-lg font-semibold">{{ yearInReviewServerVariant + 1 }}</p>
<div class="flex-grow" />
<!-- refresh button -->
<ui-btn small :disabled="processingYearInReviewServer" class="inline-flex items-center font-semibold mr-1 sm:mr-2" @click="refreshYearInReviewServer">
<span class="hidden sm:inline-block">Refresh</span>
<span class="material-icons sm:!hidden text-lg py-px">refresh</span>
</ui-btn>
<!-- next button -->
<ui-btn small :disabled="yearInReviewServerVariant >= 2 || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant++">
<span class="hidden sm:inline-block pl-2">Next</span>
<span class="material-icons-outlined text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
</ui-btn>
</div>
</div>
<stats-year-in-review-server v-if="isAdminOrUp" ref="yearInReviewServer" :year="yearInReviewYear" :variant="yearInReviewServerVariant" :processing.sync="processingYearInReviewServer" />
</div>
</div>
</template>
<script>
export default {
data() {
return {
showYearInReview: false,
yearInReviewYear: 0,
yearInReviewVariant: 0,
yearInReviewServerVariant: 0,
processingYearInReview: false,
processingYearInReviewShort: false,
processingYearInReviewServer: false,
showShareButton: false
}
},
computed: {
isAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
}
},
methods: {
shareYearInReviewServer() {
this.$refs.yearInReviewServer.share()
},
shareYearInReview() {
this.$refs.yearInReview.share()
},
shareYearInReviewShort() {
this.$refs.yearInReviewShort.share()
},
refreshYearInReviewServer() {
this.$refs.yearInReviewServer.refresh()
},
refreshYearInReview() {
this.$refs.yearInReview.refresh()
this.$refs.yearInReviewShort.refresh()
},
clickShowYearInReview() {
this.showYearInReview = !this.showYearInReview
}
},
beforeMount() {
this.yearInReviewYear = new Date().getFullYear()
// When not December show previous year
if (new Date().getMonth() < 11) {
this.yearInReviewYear--
}
},
mounted() {
if (typeof navigator.share !== 'undefined' && navigator.share) {
this.showShareButton = true
} else {
console.warn('Navigator.share not supported')
}
}
}
</script>
@@ -0,0 +1,264 @@
<template>
<div>
<div v-if="processing" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
<widgets-loading-spinner />
</div>
<img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" />
</div>
</template>
<script>
export default {
props: {
variant: {
type: Number,
default: 0
},
processing: Boolean,
year: Number
},
data() {
return {
canvas: null,
dataUrl: null,
yearStats: null
}
},
watch: {
variant() {
this.init()
}
},
methods: {
async initCanvas() {
if (!this.yearStats) return
const canvas = document.createElement('canvas')
canvas.width = 800
canvas.height = 800
const ctx = canvas.getContext('2d')
const createRoundedRect = (x, y, w, h) => {
const grd1 = ctx.createLinearGradient(x, y, x + w, y + h)
grd1.addColorStop(0, '#44444455')
grd1.addColorStop(1, '#ffffff11')
ctx.fillStyle = grd1
ctx.strokeStyle = '#C0C0C088'
ctx.beginPath()
ctx.roundRect(x, y, w, h, [20])
ctx.fill()
ctx.stroke()
}
const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => {
ctx.fillStyle = color
ctx.font = `${fontWeight} ${fontSize} Source Sans Pro`
ctx.letterSpacing = letterSpacing
// If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis
if (maxWidth) {
let txtWidth = ctx.measureText(text).width
while (txtWidth > maxWidth) {
console.warn(`Text "${text}" is greater than max width ${maxWidth} (width:${txtWidth})`)
if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time
else text = text.slice(0, -3) // First check remove last 3 chars
text += '...'
txtWidth = ctx.measureText(text).width
console.log(`Checking text "${text}" (width:${txtWidth})`)
}
}
ctx.fillText(text, x, y)
}
// Bg color
ctx.fillStyle = '#232323'
ctx.fillRect(0, 0, canvas.width, canvas.height)
// Cover image tiles
let imgsToAdd = {}
if (this.yearStats.booksAddedWithCovers.length) {
let index = 0
ctx.globalAlpha = 0.25
ctx.save()
ctx.translate(canvas.width / 2, canvas.height / 2)
ctx.rotate((-Math.PI / 180) * 25)
ctx.translate(-canvas.width / 2, -canvas.height / 2)
ctx.translate(-130, -120)
for (let x = 0; x < 5; x++) {
for (let y = 0; y < 5; y++) {
const coverIndex = index % this.yearStats.booksAddedWithCovers.length
let libraryItemId = this.yearStats.booksAddedWithCovers[coverIndex]
index++
await new Promise((resolve) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.addEventListener('load', () => {
let sw = img.width
if (img.width > img.height) {
sw = img.height
}
let sx = -(sw - img.width) / 2
let sy = -(sw - img.height) / 2
ctx.drawImage(img, sx, sy, sw, sw, 215 * x, 215 * y, 215, 215)
if (!imgsToAdd[libraryItemId]) {
imgsToAdd[libraryItemId] = {
img,
sx,
sy,
sw
}
}
resolve()
})
img.addEventListener('error', () => {
resolve()
})
img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)
})
}
}
ctx.restore()
}
ctx.globalAlpha = 1
ctx.textBaseline = 'middle'
// Create gradient
const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
grd1.addColorStop(0, '#000000aa')
grd1.addColorStop(1, '#cd9d49aa')
ctx.fillStyle = grd1
ctx.fillRect(0, 0, canvas.width, canvas.height)
// Top Abs icon
let tanColor = '#ffdb70'
ctx.fillStyle = tanColor
ctx.font = '42px absicons'
ctx.fillText('\ue900', 15, 36)
// Top text
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51)
// Top left box
createRoundedRect(40, 100, 230, 100)
ctx.textAlign = 'center'
addText(this.yearStats.numBooksAdded, '48px', 'bold', 'white', '0px', 155, 140)
addText('books added', '18px', 'normal', tanColor, '0px', 155, 170)
// Box top right
createRoundedRect(285, 100, 230, 100)
addText(this.yearStats.numAuthorsAdded, '48px', 'bold', 'white', '0px', 400, 140)
addText('authors added', '18px', 'normal', tanColor, '0px', 400, 170)
// Box bottom left
createRoundedRect(530, 100, 230, 100)
addText(this.yearStats.numListeningSessions, '48px', 'bold', 'white', '0px', 645, 140)
addText('sessions', '18px', 'normal', tanColor, '1px', 645, 170)
// Text stats
if (this.yearStats.totalBooksAddedSize) {
addText('Your book collection grew to...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 260)
addText(this.$bytesPretty(this.yearStats.totalBooksSize), '36px', 'bolder', 'white', '0px', canvas.width / 2, 300)
addText('+' + this.$bytesPretty(this.yearStats.totalBooksAddedSize), '20px', 'lighter', 'white', '0px', canvas.width / 2, 330)
}
if (this.yearStats.totalBooksAddedDuration) {
addText('With a total duration of...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 400)
addText(this.$elapsedPrettyExtended(this.yearStats.totalBooksDuration, true, false), '36px', 'bolder', 'white', '0px', canvas.width / 2, 440)
addText('+' + this.$elapsedPrettyExtended(this.yearStats.totalBooksAddedDuration, true, false), '20px', 'lighter', 'white', '0px', canvas.width / 2, 470)
}
if (!this.variant) {
// Bottom images
imgsToAdd = Object.values(imgsToAdd)
if (imgsToAdd.length > 0) {
addText('Some additions include...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 540)
for (let i = 0; i < Math.min(5, imgsToAdd.length); i++) {
let imgToAdd = imgsToAdd[i]
ctx.drawImage(imgToAdd.img, imgToAdd.sx, imgToAdd.sy, imgToAdd.sw, imgToAdd.sw, 40 + 145 * i, 580, 140, 140)
}
}
} else if (this.variant === 1) {
// Text stats
ctx.textAlign = 'left'
if (this.yearStats.topAuthors.length) {
addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 549)
for (let i = 0; i < this.yearStats.topAuthors.length; i++) {
addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 609 + i * 60, 330)
}
}
if (this.yearStats.topNarrators.length) {
addText('TOP NARRATORS', '24px', 'normal', tanColor, '1px', 430, 549)
for (let i = 0; i < this.yearStats.topNarrators.length; i++) {
addText(this.yearStats.topNarrators[i].name, '36px', 'bolder', 'white', '0px', 430, 609 + i * 60, 330)
}
}
} else if (this.variant === 2) {
// Text stats
ctx.textAlign = 'left'
if (this.yearStats.topAuthors.length) {
addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 549)
for (let i = 0; i < this.yearStats.topAuthors.length; i++) {
addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 609 + i * 60, 330)
}
}
if (this.yearStats.topGenres.length) {
addText('TOP GENRES', '24px', 'normal', tanColor, '1px', 430, 549)
for (let i = 0; i < this.yearStats.topGenres.length; i++) {
addText(this.yearStats.topGenres[i].genre, '36px', 'bolder', 'white', '0px', 430, 609 + i * 60, 330)
}
}
}
this.canvas = canvas
this.dataUrl = canvas.toDataURL('png')
},
share() {
this.canvas.toBlob((blob) => {
const file = new File([blob], 'yearinreviewserver.png', { type: blob.type })
const shareData = {
files: [file]
}
if (navigator.canShare(shareData)) {
navigator
.share(shareData)
.then(() => {
console.log('Share success')
})
.catch((error) => {
console.error('Failed to share', error)
if (error.name !== 'AbortError') {
this.$toast.error('Failed to share: ' + error.message)
}
})
} else {
this.$toast.error('Cannot share natively on this device')
}
})
},
refresh() {
this.init()
},
async init() {
this.$emit('update:processing', true)
this.yearStats = await this.$axios.$get(`/api/stats/year/${this.year}`).catch((err) => {
console.error('Failed to load stats for year', err)
this.$toast.error('Failed to load year stats')
return null
})
await this.initCanvas()
this.$emit('update:processing', false)
}
},
mounted() {
this.init()
}
}
</script>
@@ -0,0 +1,194 @@
<template>
<div>
<div v-if="processing" class="max-w-[600px] h-32 sm:h-[200px] flex items-center justify-center">
<widgets-loading-spinner />
</div>
<img v-else-if="dataUrl" :src="dataUrl" />
</div>
</template>
<script>
export default {
props: {
processing: Boolean,
year: Number
},
data() {
return {
canvas: null,
dataUrl: null,
yearStats: null
}
},
methods: {
async initCanvas() {
if (!this.yearStats) return
const canvas = document.createElement('canvas')
canvas.width = 600
canvas.height = 200
const ctx = canvas.getContext('2d')
const createRoundedRect = (x, y, w, h) => {
const grd1 = ctx.createLinearGradient(x, y, x + w, y + h)
grd1.addColorStop(0, '#44444455')
grd1.addColorStop(1, '#ffffff11')
ctx.fillStyle = grd1
ctx.strokeStyle = '#C0C0C088'
ctx.beginPath()
ctx.roundRect(x, y, w, h, [20])
ctx.fill()
ctx.stroke()
}
const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => {
ctx.fillStyle = color
ctx.font = `${fontWeight} ${fontSize} Source Sans Pro`
ctx.letterSpacing = letterSpacing
// If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis
if (maxWidth) {
let txtWidth = ctx.measureText(text).width
while (txtWidth > maxWidth) {
console.warn(`Text "${text}" is greater than max width ${maxWidth} (width:${txtWidth})`)
if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time
else text = text.slice(0, -3) // First check remove last 3 chars
text += '...'
txtWidth = ctx.measureText(text).width
console.log(`Checking text "${text}" (width:${txtWidth})`)
}
}
ctx.fillText(text, x, y)
}
const addIcon = (icon, color, fontSize, x, y) => {
ctx.fillStyle = color
ctx.font = `${fontSize} Material Icons Outlined`
ctx.fillText(icon, x, y)
}
// Bg color
ctx.fillStyle = '#232323'
ctx.fillRect(0, 0, canvas.width, canvas.height)
// Cover image tiles
const bookCovers = this.yearStats.finishedBooksWithCovers
bookCovers.push(...this.yearStats.booksWithCovers)
if (bookCovers.length) {
let index = 0
ctx.globalAlpha = 0.25
ctx.save()
ctx.translate(canvas.width / 2, canvas.height / 2)
ctx.rotate((-Math.PI / 180) * 25)
ctx.translate(-canvas.width / 2, -canvas.height / 2)
ctx.translate(-10, -90)
for (let x = 0; x < 4; x++) {
for (let y = 0; y < 3; y++) {
const coverIndex = index % bookCovers.length
let libraryItemId = bookCovers[coverIndex]
index++
await new Promise((resolve) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.addEventListener('load', () => {
let sw = img.width
if (img.width > img.height) {
sw = img.height
}
let sx = -(sw - img.width) / 2
let sy = -(sw - img.height) / 2
ctx.drawImage(img, sx, sy, sw, sw, 155 * x, 155 * y, 155, 155)
resolve()
})
img.addEventListener('error', () => {
resolve()
})
img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)
})
}
}
ctx.restore()
}
ctx.globalAlpha = 1
ctx.textBaseline = 'middle'
// Create gradient
const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
grd1.addColorStop(0, '#000000aa')
grd1.addColorStop(1, '#cd9d49aa')
ctx.fillStyle = grd1
ctx.fillRect(0, 0, canvas.width, canvas.height)
// Top Abs icon
let tanColor = '#ffdb70'
ctx.fillStyle = tanColor
ctx.font = '42px absicons'
ctx.fillText('\ue900', 15, 36)
// Top text
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51)
// Top left box
createRoundedRect(15, 75, 280, 110)
addText(this.yearStats.numBooksFinished, '48px', 'bold', 'white', '0px', 105, 120)
addText('books finished', '20px', 'normal', tanColor, '0px', 105, 155)
const readIconPath = new Path2D()
readIconPath.addPath(new Path2D('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'), { a: 1.5, d: 1.5, e: 55, f: 115 })
ctx.fillStyle = '#ffffff'
ctx.fill(readIconPath)
createRoundedRect(305, 75, 280, 110)
addText(this.yearStats.numBooksListened, '48px', 'bold', 'white', '0px', 400, 120)
addText('books listened to', '20px', 'normal', tanColor, '0px', 400, 155)
addIcon('local_library', 'white', '42px', 345, 130)
this.canvas = canvas
this.dataUrl = canvas.toDataURL('png')
},
share() {
this.canvas.toBlob((blob) => {
const file = new File([blob], 'yearinreviewshort.png', { type: blob.type })
const shareData = {
files: [file]
}
if (navigator.canShare(shareData)) {
navigator
.share(shareData)
.then(() => {
console.log('Share success')
})
.catch((error) => {
console.error('Failed to share', error)
if (error.name !== 'AbortError') {
this.$toast.error('Failed to share: ' + error.message)
}
})
} else {
this.$toast.error('Cannot share natively on this device')
}
})
},
refresh() {
this.init()
},
async init() {
this.$emit('update:processing', true)
this.yearStats = await this.$axios.$get(`/api/me/stats/year/${this.year}`).catch((err) => {
console.error('Failed to load stats for year', err)
this.$toast.error('Failed to load year stats')
return null
})
await this.initCanvas()
this.$emit('update:processing', false)
}
},
mounted() {
this.init()
}
}
</script>
@@ -12,7 +12,7 @@
</div> </div>
</div> </div>
<transition name="slide"> <transition name="slide">
<div class="w-full" v-show="showFiles"> <div class="w-full" v-if="showFiles">
<table class="text-sm tracksTable"> <table class="text-sm tracksTable">
<tr> <tr>
<th class="text-left px-4">{{ $strings.LabelPath }}</th> <th class="text-left px-4">{{ $strings.LabelPath }}</th>
@@ -70,7 +70,7 @@ export default {
}, },
audioFiles() { audioFiles() {
if (this.libraryItem.mediaType === 'podcast') { if (this.libraryItem.mediaType === 'podcast') {
return this.libraryItem.media?.episodes.map((ep) => ep.audioFile) || [] return this.libraryItem.media?.episodes.map((ep) => ep.audioFile).filter((af) => af) || []
} }
return this.libraryItem.media?.audioFiles || [] return this.libraryItem.media?.audioFiles || []
}, },
@@ -1,18 +1,22 @@
<template> <template>
<div class="w-full px-2 py-3 overflow-hidden relative border-b border-white border-opacity-10" @mouseover="mouseover" @mouseleave="mouseleave"> <div :id="`lazy-episode-${index}`" class="w-full h-full cursor-pointer" @mouseover="mouseover" @mouseleave="mouseleave">
<div v-if="episode" class="flex items-center cursor-pointer" :class="{ 'opacity-70': isSelected || selectionMode }" @click="clickedEpisode"> <div class="flex" @click="clickedEpisode">
<div class="flex-grow px-2"> <div class="flex-grow">
<div class="flex items-center"> <div class="flex items-center">
<span class="text-sm font-semibold">{{ title }}</span> <span class="text-sm font-semibold">{{ episodeTitle }}</span>
<widgets-podcast-type-indicator :type="episode.episodeType" /> <widgets-podcast-type-indicator :type="episodeType" />
</div> </div>
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5" v-html="subtitle"></p> <div class="h-10 flex items-center mt-1.5 mb-0.5">
<div class="flex justify-between pt-2 max-w-xl"> <p class="text-sm text-gray-200 episode-subtitle" v-html="episodeSubtitle"></p>
<p v-if="episode.season" class="text-sm text-gray-300">Season #{{ episode.season }}</p> </div>
<p v-if="episode.episode" class="text-sm text-gray-300">Episode #{{ episode.episode }}</p> <div class="h-8 flex items-center">
<p v-if="episode.chapters?.length" class="text-sm text-gray-300">{{ episode.chapters.length }} Chapters</p> <div class="w-full inline-flex justify-between max-w-xl">
<p v-if="publishedAt" class="text-sm text-gray-300">Published {{ $formatDate(publishedAt, dateFormat) }}</p> <p v-if="episode?.season" class="text-sm text-gray-300">Season #{{ episode.season }}</p>
<p v-if="episode?.episode" class="text-sm text-gray-300">Episode #{{ episode.episode }}</p>
<p v-if="episode?.chapters?.length" class="text-sm text-gray-300">{{ episode.chapters.length }} Chapters</p>
<p v-if="publishedAt" class="text-sm text-gray-300">Published {{ $formatDate(publishedAt, dateFormat) }}</p>
</div>
</div> </div>
<div class="flex items-center pt-2"> <div class="flex items-center pt-2">
@@ -37,10 +41,11 @@
<ui-icon-btn v-if="userCanDelete" icon="close" borderless @click="removeClick" /> <ui-icon-btn v-if="userCanDelete" icon="close" borderless @click="removeClick" />
</div> </div>
</div> </div>
<div v-if="isHovering || isSelected || selectionMode" class="hidden md:block w-12 min-w-12" /> <div v-if="isHovering || isSelected || isSelectionMode" class="hidden md:block w-12 min-w-12" />
</div> </div>
<div v-if="isSelected || selectionMode" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-10 z-10 cursor-pointer" @click.stop="clickedSelectionBg" />
<div class="hidden md:block md:w-12 md:min-w-12 md:-right-0 md:absolute md:top-0 h-full transform transition-transform z-20" :class="!isHovering && !isSelected && !selectionMode ? 'translate-x-24' : 'translate-x-0'"> <div v-if="isSelected || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-10 z-10 cursor-pointer" @click.stop="clickedSelectionBg" />
<div class="hidden md:block md:w-12 md:min-w-12 md:-right-0 md:absolute md:top-0 h-full transform transition-transform z-20" :class="!isHovering && !isSelected && !isSelectionMode ? 'translate-x-24' : 'translate-x-0'">
<div class="flex h-full items-center"> <div class="flex h-full items-center">
<div class="mx-1"> <div class="mx-1">
<ui-checkbox v-model="isSelected" @input="selectedUpdated" checkbox-bg="bg" /> <ui-checkbox v-model="isSelected" @input="selectedUpdated" checkbox-bg="bg" />
@@ -55,84 +60,91 @@
<script> <script>
export default { export default {
props: { props: {
index: Number,
libraryItemId: String, libraryItemId: String,
episode: { episode: {
type: Object, type: Object,
default: () => {} default: () => null
}, }
selectionMode: Boolean
}, },
data() { data() {
return { return {
isProcessingReadUpdate: false, isProcessingReadUpdate: false,
processingRemove: false, processingRemove: false,
isHovering: false, isHovering: false,
isSelected: false isSelected: false,
isSelectionMode: false
} }
}, },
computed: { computed: {
store() {
return this.$store || this.$nuxt.$store
},
axios() {
return this.$axios || this.$nuxt.$axios
},
userCanUpdate() { userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate'] return this.store.getters['user/getUserCanUpdate']
}, },
userCanDelete() { userCanDelete() {
return this.$store.getters['user/getUserCanDelete'] return this.store.getters['user/getUserCanDelete']
}, },
audioFile() { episodeId() {
return this.episode.audioFile return this.episode?.id || ''
}, },
title() { episodeTitle() {
return this.episode.title || '' return this.episode?.title || ''
}, },
subtitle() { episodeSubtitle() {
return this.episode.subtitle || this.description return this.episode?.subtitle || ''
}, },
description() { episodeType() {
return this.episode.description || '' return this.episode?.episodeType || ''
}, },
duration() { publishedAt() {
return this.$secondsToTimestamp(this.episode.duration) return this.episode?.publishedAt
}, },
libraryItemIdStreaming() { dateFormat() {
return this.$store.getters['getLibraryItemIdStreaming'] return this.store.state.serverSettings.dateFormat
},
isStreamingFromDifferentLibrary() {
return this.$store.getters['getIsStreamingFromDifferentLibrary']
},
isStreaming() {
return this.$store.getters['getIsMediaStreaming'](this.libraryItemId, this.episode.id)
},
isQueued() {
return this.$store.getters['getIsMediaQueued'](this.libraryItemId, this.episode.id)
},
streamIsPlaying() {
return this.$store.state.streamIsPlaying && this.isStreaming
}, },
itemProgress() { itemProgress() {
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, this.episode.id) return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.episodeId)
}, },
itemProgressPercent() { itemProgressPercent() {
return this.itemProgress ? this.itemProgress.progress : 0 return this.itemProgress?.progress || 0
}, },
userIsFinished() { userIsFinished() {
return this.itemProgress ? !!this.itemProgress.isFinished : false return !!this.itemProgress?.isFinished
},
libraryItemIdStreaming() {
return this.store.getters['getLibraryItemIdStreaming']
},
isStreamingFromDifferentLibrary() {
return this.store.getters['getIsStreamingFromDifferentLibrary']
},
isStreaming() {
return this.store.getters['getIsMediaStreaming'](this.libraryItemId, this.episodeId)
},
isQueued() {
return this.store.getters['getIsMediaQueued'](this.libraryItemId, this.episodeId)
},
streamIsPlaying() {
return this.store.state.streamIsPlaying && this.isStreaming
}, },
timeRemaining() { timeRemaining() {
if (this.streamIsPlaying) return 'Playing' if (this.streamIsPlaying) return 'Playing'
if (!this.itemProgress) return this.$elapsedPretty(this.episode.duration) if (!this.itemProgress) return this.$elapsedPretty(this.episode?.duration || 0)
if (this.userIsFinished) return 'Finished' if (this.userIsFinished) return 'Finished'
var remaining = Math.floor(this.itemProgress.duration - this.itemProgress.currentTime)
const duration = this.itemProgress.duration || this.episode?.duration || 0
const remaining = Math.floor(duration - this.itemProgress.currentTime)
return `${this.$elapsedPretty(remaining)} left` return `${this.$elapsedPretty(remaining)} left`
},
publishedAt() {
return this.episode.publishedAt
},
dateFormat() {
return this.$store.state.serverSettings.dateFormat
} }
}, },
methods: { methods: {
clickAddToPlaylist() { setSelectionMode(isSelectionMode) {
this.$emit('addToPlaylist', this.episode) this.isSelectionMode = isSelectionMode
if (!this.isSelectionMode) this.isSelected = false
}, },
clickedEpisode() { clickedEpisode() {
this.$emit('view', this.episode) this.$emit('view', this.episode)
@@ -150,16 +162,23 @@ export default {
mouseleave() { mouseleave() {
this.isHovering = false this.isHovering = false
}, },
clickEdit() {
this.$emit('edit', this.episode)
},
playClick() { playClick() {
if (this.streamIsPlaying) { if (this.streamIsPlaying) {
this.$eventBus.$emit('pause-item') const eventBus = this.$eventBus || this.$nuxt.$eventBus
eventBus.$emit('pause-item')
} else { } else {
this.$emit('play', this.episode) this.$emit('play', this.episode)
} }
}, },
queueBtnClick() {
if (this.isQueued) {
// Remove from queue
this.store.commit('removeItemFromQueue', { libraryItemId: this.libraryItemId, episodeId: this.episodeId })
} else {
// Add to queue
this.$emit('addToQueue', this.episode)
}
},
toggleFinished(confirmed = false) { toggleFinished(confirmed = false) {
if (!this.userIsFinished && this.itemProgressPercent > 0 && !confirmed) { if (!this.userIsFinished && this.itemProgressPercent > 0 && !confirmed) {
const payload = { const payload = {
@@ -171,37 +190,47 @@ export default {
}, },
type: 'yesNo' type: 'yesNo'
} }
this.$store.commit('globals/setConfirmPrompt', payload) this.store.commit('globals/setConfirmPrompt', payload)
return return
} }
var updatePayload = { const updatePayload = {
isFinished: !this.userIsFinished isFinished: !this.userIsFinished
} }
this.isProcessingReadUpdate = true this.isProcessingReadUpdate = true
this.$axios this.axios
.$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload) .$patch(`/api/me/progress/${this.libraryItemId}/${this.episodeId}`, updatePayload)
.then(() => { .then(() => {
this.isProcessingReadUpdate = false this.isProcessingReadUpdate = false
}) })
.catch((error) => { .catch((error) => {
console.error('Failed', error) console.error('Failed', error)
this.isProcessingReadUpdate = false this.isProcessingReadUpdate = false
this.$toast.error(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedFailed : this.$strings.ToastItemMarkedAsNotFinishedFailed) const toast = this.$toast || this.$nuxt.$toast
toast.error(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedFailed : this.$strings.ToastItemMarkedAsNotFinishedFailed)
}) })
}, },
clickAddToPlaylist() {
this.$emit('addToPlaylist', this.episode)
},
clickEdit() {
this.$emit('edit', this.episode)
},
removeClick() { removeClick() {
this.$emit('remove', this.episode) this.$emit('remove', this.episode)
}, },
queueBtnClick() { destroy() {
if (this.isQueued) { // destroy the vue listeners, etc
// Remove from queue this.$destroy()
this.$store.commit('removeItemFromQueue', { libraryItemId: this.libraryItemId, episodeId: this.episode.id })
} else { // remove the element from the DOM
// Add to queue if (this.$el && this.$el.parentNode) {
this.$emit('addToQueue', this.episode) this.$el.parentNode.removeChild(this.$el)
} else if (this.$el && this.$el.remove) {
this.$el.remove()
} }
} }
} },
mounted() {}
} }
</script> </script>
@@ -1,5 +1,5 @@
<template> <template>
<div class="w-full py-6"> <div id="lazy-episodes-table" class="w-full py-6">
<div class="flex flex-wrap flex-col md:flex-row md:items-center mb-4"> <div class="flex flex-wrap flex-col md:flex-row md:items-center mb-4">
<div class="flex items-center flex-nowrap whitespace-nowrap mb-2 md:mb-0"> <div class="flex items-center flex-nowrap whitespace-nowrap mb-2 md:mb-0">
<p class="text-lg mb-0 font-semibold">{{ $strings.HeaderEpisodes }}</p> <p class="text-lg mb-0 font-semibold">{{ $strings.HeaderEpisodes }}</p>
@@ -18,28 +18,41 @@
<ui-btn :disabled="processing" small class="ml-2 h-9" @click="clearSelected">{{ $strings.ButtonCancel }}</ui-btn> <ui-btn :disabled="processing" small class="ml-2 h-9" @click="clearSelected">{{ $strings.ButtonCancel }}</ui-btn>
</template> </template>
<template v-else> <template v-else>
<controls-filter-select v-model="filterKey" :items="filterItems" class="w-36 h-9 md:ml-4" /> <controls-filter-select v-model="filterKey" :items="filterItems" class="w-36 h-9 md:ml-4" @change="filterSortChanged" />
<controls-sort-select v-model="sortKey" :descending.sync="sortDesc" :items="sortItems" class="w-44 md:w-48 h-9 ml-1 sm:ml-4" /> <controls-sort-select v-model="sortKey" :descending.sync="sortDesc" :items="sortItems" class="w-44 md:w-48 h-9 ml-1 sm:ml-4" @change="filterSortChanged" />
<div class="flex-grow md:hidden" /> <div class="flex-grow md:hidden" />
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" class="ml-1" @action="contextMenuAction" /> <ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" class="ml-1" @action="contextMenuAction" />
</template> </template>
</div> </div>
</div> </div>
<p v-if="!episodes.length" class="py-4 text-center text-lg">{{ $strings.MessageNoEpisodes }}</p> <!-- <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"> <div v-if="episodes.length" class="w-full py-3 mx-auto flex">
<form @submit.prevent="submit" class="flex flex-grow"> <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" /> <ui-text-input v-model="search" @input="inputUpdate" type="search" :placeholder="$strings.PlaceholderSearchEpisode" class="flex-grow mr-2 text-sm md:text-base" />
</form> </form>
</div> </div>
<template v-for="episode in episodesList"> <div class="relative min-h-[176px]">
<tables-podcast-episode-table-row ref="episodeRow" :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" :selection-mode="isSelectionMode" class="item" @play="playEpisode" @remove="removeEpisode" @edit="editEpisode" @view="viewEpisode" @selected="episodeSelected" @addToQueue="addEpisodeToQueue" @addToPlaylist="addToPlaylist" /> <template v-for="episode in totalEpisodes">
</template> <div :key="episode" :id="`episode-${episode - 1}`" class="w-full h-44 px-2 py-3 overflow-hidden relative border-b border-white/10">
<!-- episode is mounted here -->
</div>
</template>
<div v-if="isSearching" class="w-full h-full absolute inset-0 flex justify-center py-12" :class="{ 'bg-black/50': totalEpisodes }">
<ui-loading-indicator />
</div>
<div v-else-if="!totalEpisodes" class="h-44 flex items-center justify-center">
<p class="text-lg">{{ $strings.MessageNoEpisodes }}</p>
</div>
</div>
<modals-podcast-remove-episode v-model="showPodcastRemoveModal" @input="removeEpisodeModalToggled" :library-item="libraryItem" :episodes="episodesToRemove" @clearSelected="clearSelected" /> <modals-podcast-remove-episode v-model="showPodcastRemoveModal" @input="removeEpisodeModalToggled" :library-item="libraryItem" :episodes="episodesToRemove" @clearSelected="clearSelected" />
</div> </div>
</template> </template>
<script> <script>
import Vue from 'vue'
import LazyEpisodeRow from './LazyEpisodeRow.vue'
export default { export default {
props: { props: {
libraryItem: { libraryItem: {
@@ -60,13 +73,21 @@ export default {
processing: false, processing: false,
search: null, search: null,
searchTimeout: null, searchTimeout: null,
searchText: null searchText: null,
isSearching: false,
totalEpisodes: 0,
episodesPerPage: null,
episodeIndexesMounted: [],
episodeComponentRefs: {},
windowHeight: 0,
episodesTableOffsetTop: 0,
episodeRowHeight: 176
} }
}, },
watch: { watch: {
libraryItem: { libraryItem: {
handler() { handler() {
this.init() this.refresh()
} }
} }
}, },
@@ -194,13 +215,19 @@ export default {
submit() {}, submit() {},
inputUpdate() { inputUpdate() {
clearTimeout(this.searchTimeout) clearTimeout(this.searchTimeout)
this.isSearching = true
let searchStart = this.searchText
this.searchTimeout = setTimeout(() => { this.searchTimeout = setTimeout(() => {
if (!this.search || !this.search.trim()) { this.isSearching = false
if (!this.search?.trim()) {
this.searchText = '' this.searchText = ''
return } else {
this.searchText = this.search.toLowerCase().trim()
} }
this.searchText = this.search.toLowerCase().trim() if (searchStart !== this.searchText) {
}, 500) this.init()
}
}, 750)
}, },
contextMenuAction({ action }) { contextMenuAction({ action }) {
if (action === 'quick-match-episodes') { if (action === 'quick-match-episodes') {
@@ -304,24 +331,30 @@ export default {
if (!val) this.episodesToRemove = [] if (!val) this.episodesToRemove = []
}, },
clearSelected() { clearSelected() {
const episodeRows = this.$refs.episodeRow
if (episodeRows && episodeRows.length) {
for (const epRow of episodeRows) {
if (epRow) epRow.isSelected = false
}
}
this.selectedEpisodes = [] this.selectedEpisodes = []
this.setSelectionModeForEpisodes()
}, },
removeSelectedEpisodes() { removeSelectedEpisodes() {
this.episodesToRemove = this.selectedEpisodes this.episodesToRemove = this.selectedEpisodes
this.showPodcastRemoveModal = true this.showPodcastRemoveModal = true
}, },
episodeSelected({ isSelected, episode }) { episodeSelected({ isSelected, episode }) {
let isSelectionModeBefore = this.isSelectionMode
if (isSelected) { if (isSelected) {
this.selectedEpisodes.push(episode) this.selectedEpisodes.push(episode)
} else { } else {
this.selectedEpisodes = this.selectedEpisodes.filter((ep) => ep.id !== episode.id) this.selectedEpisodes = this.selectedEpisodes.filter((ep) => ep.id !== episode.id)
} }
if (this.isSelectionMode !== isSelectionModeBefore) {
this.setSelectionModeForEpisodes()
}
},
setSelectionModeForEpisodes() {
for (const key in this.episodeComponentRefs) {
if (this.episodeComponentRefs[key]?.setSelectionMode) {
this.episodeComponentRefs[key].setSelectionMode(this.isSelectionMode)
}
}
}, },
playEpisode(episode) { playEpisode(episode) {
const queueItems = [] const queueItems = []
@@ -367,12 +400,147 @@ export default {
this.$store.commit('globals/setSelectedEpisode', episode) this.$store.commit('globals/setSelectedEpisode', episode)
this.$store.commit('globals/setShowViewPodcastEpisodeModal', true) this.$store.commit('globals/setShowViewPodcastEpisodeModal', true)
}, },
init() { destroyEpisodeComponents() {
for (const key in this.episodeComponentRefs) {
if (this.episodeComponentRefs[key]?.destroy) {
this.episodeComponentRefs[key].destroy()
}
}
this.episodeComponentRefs = {}
this.episodeIndexesMounted = []
},
mountEpisode(index) {
const episodeEl = document.getElementById(`episode-${index}`)
if (!episodeEl) {
console.warn('Episode row el not found at ' + index)
return
}
this.episodeIndexesMounted.push(index)
if (this.episodeComponentRefs[index]) {
const episodeComponent = this.episodeComponentRefs[index]
episodeEl.appendChild(episodeComponent.$el)
if (this.isSelectionMode) {
episodeComponent.setSelectionMode(true)
if (this.selectedEpisodes.some((i) => i.id === episodeComponent.episodeId)) {
episodeComponent.isSelected = true
} else {
episodeComponent.isSelected = false
}
} else {
episodeComponent.setSelectionMode(false)
}
} else {
const _this = this
const ComponentClass = Vue.extend(LazyEpisodeRow)
const instance = new ComponentClass({
propsData: {
index,
libraryItemId: this.libraryItem.id,
episode: this.episodesList[index]
},
created() {
this.$on('selected', (payload) => {
_this.episodeSelected(payload)
})
this.$on('view', (payload) => {
_this.viewEpisode(payload)
})
this.$on('play', (payload) => {
_this.playEpisode(payload)
})
this.$on('addToQueue', (payload) => {
_this.addEpisodeToQueue(payload)
})
this.$on('remove', (payload) => {
_this.removeEpisode(payload)
})
this.$on('edit', (payload) => {
_this.editEpisode(payload)
})
this.$on('addToPlaylist', (payload) => {
_this.addToPlaylist(payload)
})
}
})
this.episodeComponentRefs[index] = instance
instance.$mount()
episodeEl.appendChild(instance.$el)
if (this.isSelectionMode) {
instance.setSelectionMode(true)
if (this.selectedEpisodes.some((i) => i.id === this.episodesList[index].id)) {
instance.isSelected = true
}
}
}
},
mountEpisodes(startIndex, endIndex) {
for (let i = startIndex; i < endIndex; i++) {
if (!this.episodeIndexesMounted.includes(i)) {
this.mountEpisode(i)
}
}
},
scroll(evt) {
if (!evt?.target?.scrollTop) return
const scrollTop = Math.max(evt.target.scrollTop - this.episodesTableOffsetTop, 0)
let firstEpisodeIndex = Math.floor(scrollTop / this.episodeRowHeight)
let lastEpisodeIndex = Math.ceil((scrollTop + this.windowHeight) / this.episodeRowHeight)
lastEpisodeIndex = Math.min(this.totalEpisodes - 1, lastEpisodeIndex)
this.episodeIndexesMounted = this.episodeIndexesMounted.filter((_index) => {
if (_index < firstEpisodeIndex || _index >= lastEpisodeIndex) {
const el = document.getElementById(`lazy-episode-${_index}`)
if (el) el.remove()
return false
}
return true
})
this.mountEpisodes(firstEpisodeIndex, lastEpisodeIndex + 1)
},
initListeners() {
const itemPageWrapper = document.getElementById('item-page-wrapper')
if (itemPageWrapper) {
itemPageWrapper.addEventListener('scroll', this.scroll)
}
},
removeListeners() {
const itemPageWrapper = document.getElementById('item-page-wrapper')
if (itemPageWrapper) {
itemPageWrapper.removeEventListener('scroll', this.scroll)
}
},
filterSortChanged() {
this.init()
},
refresh() {
this.episodesCopy = this.episodes.map((ep) => ({ ...ep })) this.episodesCopy = this.episodes.map((ep) => ({ ...ep }))
this.init()
},
init() {
this.destroyEpisodeComponents()
this.totalEpisodes = this.episodesList.length
const lazyEpisodesTableEl = document.getElementById('lazy-episodes-table')
this.episodesTableOffsetTop = (lazyEpisodesTableEl?.offsetTop || 0) + 64
this.windowHeight = window.innerHeight
this.episodesPerPage = Math.ceil(this.windowHeight / this.episodeRowHeight)
this.$nextTick(() => {
this.mountEpisodes(0, Math.min(this.episodesPerPage, this.totalEpisodes))
})
} }
}, },
mounted() { mounted() {
this.episodesCopy = this.episodes.map((ep) => ({ ...ep }))
this.initListeners()
this.init() this.init()
},
beforeDestroy() {
this.removeListeners()
} }
} }
</script> </script>
+4 -2
View File
@@ -2,7 +2,8 @@
<label class="flex justify-start items-center" :class="!disabled ? 'cursor-pointer' : ''"> <label class="flex justify-start items-center" :class="!disabled ? 'cursor-pointer' : ''">
<div class="border-2 rounded flex flex-shrink-0 justify-center items-center" :class="wrapperClass"> <div class="border-2 rounded flex flex-shrink-0 justify-center items-center" :class="wrapperClass">
<input v-model="selected" :disabled="disabled" type="checkbox" class="opacity-0 absolute" :class="!disabled ? 'cursor-pointer' : ''" /> <input v-model="selected" :disabled="disabled" type="checkbox" class="opacity-0 absolute" :class="!disabled ? 'cursor-pointer' : ''" />
<svg v-if="selected" class="fill-current pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg> <span v-if="partial" class="material-icons text-base leading-none text-gray-400">remove</span>
<svg v-else-if="selected" class="fill-current pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
</div> </div>
<div v-if="label" class="select-none" :class="[labelClassname, disabled ? 'text-gray-400' : 'text-gray-100']">{{ label }}</div> <div v-if="label" class="select-none" :class="[labelClassname, disabled ? 'text-gray-400' : 'text-gray-100']">{{ label }}</div>
</label> </label>
@@ -31,7 +32,8 @@ export default {
type: String, type: String,
default: '' default: ''
}, },
disabled: Boolean disabled: Boolean,
partial: Boolean
}, },
data() { data() {
return {} return {}
+2 -2
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="relative w-full" v-click-outside="clickOutsideObj"> <div class="relative w-full" v-click-outside="clickOutsideObj">
<p class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p> <p v-if="label" class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
<button type="button" :aria-label="longLabel" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu"> <button type="button" :aria-label="longLabel" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
<span class="flex items-center"> <span class="flex items-center">
<span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small }">{{ selectedText }}</span> <span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small }">{{ selectedText }}</span>
@@ -64,7 +64,7 @@ export default {
}, },
itemsToShow() { itemsToShow() {
return this.items.map((i) => { return this.items.map((i) => {
if (typeof i === 'string') { if (typeof i === 'string' || typeof i === 'number') {
return { return {
text: i, text: i,
value: i value: i
+1 -1
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="w-full"> <div class="w-full">
<label class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</label> <label v-if="label" class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</label>
<div ref="wrapper" class="relative"> <div ref="wrapper" class="relative">
<form @submit.prevent="submitForm"> <form @submit.prevent="submitForm">
<div ref="inputWrapper" class="input-wrapper flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-2" :class="disabled ? 'pointer-events-none bg-black-300 text-gray-400' : 'bg-primary'"> <div ref="inputWrapper" class="input-wrapper flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-2" :class="disabled ? 'pointer-events-none bg-black-300 text-gray-400' : 'bg-primary'">
+6 -2
View File
@@ -50,7 +50,11 @@ export default {
label: String, label: String,
disabled: Boolean, disabled: Boolean,
readonly: Boolean, readonly: Boolean,
showEdit: Boolean showEdit: Boolean,
menuDisabled: {
type: Boolean,
default: false
},
}, },
data() { data() {
return { return {
@@ -77,7 +81,7 @@ export default {
} }
}, },
showMenu() { showMenu() {
return this.isFocused return this.isFocused && !this.menuDisabled
}, },
wrapperClass() { wrapperClass() {
var classes = [] var classes = []
+59 -18
View File
@@ -15,6 +15,13 @@ export default {
type: String, type: String,
default: 'right' default: 'right'
}, },
/**
* Delay showing the tooltip after X milliseconds of hovering
*/
delayOnShow: {
type: Number,
default: 0
},
disabled: Boolean disabled: Boolean
}, },
data() { data() {
@@ -22,7 +29,8 @@ export default {
tooltip: null, tooltip: null,
tooltipId: null, tooltipId: null,
isShowing: false, isShowing: false,
hideTimeout: null hideTimeout: null,
delayOnShowTimeout: null
} }
}, },
watch: { watch: {
@@ -59,29 +67,44 @@ export default {
this.tooltip = tooltip this.tooltip = tooltip
}, },
setTooltipPosition(tooltip) { setTooltipPosition(tooltip) {
var boxChow = this.$refs.box.getBoundingClientRect() const boxRect = this.$refs.box.getBoundingClientRect()
const shouldMount = !tooltip.isConnected
var shouldMount = !tooltip.isConnected
// Calculate size of tooltip // Calculate size of tooltip
if (shouldMount) document.body.appendChild(tooltip) if (shouldMount) document.body.appendChild(tooltip)
var { width, height } = tooltip.getBoundingClientRect() const tooltipRect = tooltip.getBoundingClientRect()
if (shouldMount) tooltip.remove() if (shouldMount) tooltip.remove()
var top = 0 // Subtracting scrollbar size
var left = 0 const windowHeight = window.innerHeight - 8
const windowWidth = window.innerWidth - 8
let top = 0
let left = 0
if (this.direction === 'right') { if (this.direction === 'right') {
top = boxChow.top - height / 2 + boxChow.height / 2 top = Math.max(0, boxRect.top - tooltipRect.height / 2 + boxRect.height / 2)
left = boxChow.left + boxChow.width + 4 left = Math.max(0, boxRect.left + boxRect.width + 4)
} else if (this.direction === 'bottom') { } else if (this.direction === 'bottom') {
top = boxChow.top + boxChow.height + 4 top = Math.max(0, boxRect.top + boxRect.height + 4)
left = boxChow.left - width / 2 + boxChow.width / 2 left = Math.max(0, boxRect.left - tooltipRect.width / 2 + boxRect.width / 2)
} else if (this.direction === 'top') { } else if (this.direction === 'top') {
top = boxChow.top - height - 4 top = Math.max(0, boxRect.top - tooltipRect.height - 4)
left = boxChow.left - width / 2 + boxChow.width / 2 left = Math.max(0, boxRect.left - tooltipRect.width / 2 + boxRect.width / 2)
} else if (this.direction === 'left') { } else if (this.direction === 'left') {
top = boxChow.top - height / 2 + boxChow.height / 2 top = Math.max(0, boxRect.top - tooltipRect.height / 2 + boxRect.height / 2)
left = boxChow.left - width - 4 left = Math.max(0, boxRect.left - tooltipRect.width - 4)
} }
// Shift left if tooltip would overflow the window on the right
if (left + tooltipRect.width > windowWidth) {
left -= left + tooltipRect.width - windowWidth
}
// Shift up if tooltip would overflow the window on the bottom
if (top + tooltipRect.height > windowHeight) {
top -= top + tooltipRect.height - windowHeight
}
tooltip.style.top = top + 'px' tooltip.style.top = top + 'px'
tooltip.style.left = left + 'px' tooltip.style.left = left + 'px'
}, },
@@ -107,15 +130,33 @@ export default {
this.isShowing = false this.isShowing = false
}, },
cancelHide() { cancelHide() {
if (this.hideTimeout) clearTimeout(this.hideTimeout) clearTimeout(this.hideTimeout)
}, },
mouseover() { mouseover() {
if (!this.isShowing) this.showTooltip() if (this.isShowing || this.disabled) return
if (this.delayOnShow) {
if (this.delayOnShowTimeout) {
// Delay already running
return
}
this.delayOnShowTimeout = setTimeout(() => {
this.showTooltip()
this.delayOnShowTimeout = null
}, this.delayOnShow)
} else {
this.showTooltip()
}
}, },
mouseleave() { mouseleave() {
if (this.isShowing) { if (!this.isShowing) {
this.hideTimeout = setTimeout(this.hideTooltip, 100) clearTimeout(this.delayOnShowTimeout)
this.delayOnShowTimeout = null
return
} }
this.hideTimeout = setTimeout(this.hideTooltip, 100)
} }
}, },
beforeDestroy() { beforeDestroy() {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.6.0", "version": "2.7.2",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.6.0", "version": "2.7.2",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@nuxtjs/axios": "^5.13.6", "@nuxtjs/axios": "^5.13.6",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.6.0", "version": "2.7.2",
"buildNumber": 1, "buildNumber": 1,
"description": "Self-hosted audiobook and podcast client", "description": "Self-hosted audiobook and podcast client",
"main": "index.js", "main": "index.js",
+9 -2
View File
@@ -19,8 +19,8 @@
<div class="w-full h-px bg-white/10 my-4" /> <div class="w-full h-px bg-white/10 my-4" />
<p v-if="!isGuest" class="mb-4 text-lg">{{ $strings.HeaderChangePassword }}</p> <p v-if="showChangePasswordForm" class="mb-4 text-lg">{{ $strings.HeaderChangePassword }}</p>
<form v-if="!isGuest" @submit.prevent="submitChangePassword"> <form v-if="showChangePasswordForm" @submit.prevent="submitChangePassword">
<ui-text-input-with-label v-model="password" :disabled="changingPassword" type="password" :label="$strings.LabelPassword" class="my-2" /> <ui-text-input-with-label v-model="password" :disabled="changingPassword" type="password" :label="$strings.LabelPassword" class="my-2" />
<ui-text-input-with-label v-model="newPassword" :disabled="changingPassword" type="password" :label="$strings.LabelNewPassword" class="my-2" /> <ui-text-input-with-label v-model="newPassword" :disabled="changingPassword" type="password" :label="$strings.LabelNewPassword" class="my-2" />
<ui-text-input-with-label v-model="confirmPassword" :disabled="changingPassword" type="password" :label="$strings.LabelConfirmPassword" class="my-2" /> <ui-text-input-with-label v-model="confirmPassword" :disabled="changingPassword" type="password" :label="$strings.LabelConfirmPassword" class="my-2" />
@@ -68,6 +68,13 @@ export default {
}, },
isGuest() { isGuest() {
return this.usertype === 'guest' return this.usertype === 'guest'
},
isPasswordAuthEnabled() {
const activeAuthMethods = this.$store.getters['getServerSetting']('authActiveAuthMethods') || []
return activeAuthMethods.includes('local')
},
showChangePasswordForm() {
return !this.isGuest && this.isPasswordAuthEnabled
} }
}, },
methods: { methods: {
+27 -1
View File
@@ -46,6 +46,9 @@
<ui-text-input-with-label ref="openidClientSecret" v-model="newAuthSettings.authOpenIDClientSecret" :disabled="savingSettings" :label="'Client Secret'" class="mb-2" /> <ui-text-input-with-label ref="openidClientSecret" v-model="newAuthSettings.authOpenIDClientSecret" :disabled="savingSettings" :label="'Client Secret'" class="mb-2" />
<ui-multi-select ref="redirectUris" v-model="newAuthSettings.authOpenIDMobileRedirectURIs" :items="newAuthSettings.authOpenIDMobileRedirectURIs" :label="$strings.LabelMobileRedirectURIs" class="mb-2" :menuDisabled="true" :disabled="savingSettings" />
<p class="pl-4 text-sm text-gray-300 mb-2" v-html="$strings.LabelMobileRedirectURIsDescription" />
<ui-text-input-with-label ref="buttonTextInput" v-model="newAuthSettings.authOpenIDButtonText" :disabled="savingSettings" :label="$strings.LabelButtonText" class="mb-2" /> <ui-text-input-with-label ref="buttonTextInput" v-model="newAuthSettings.authOpenIDButtonText" :disabled="savingSettings" :label="$strings.LabelButtonText" class="mb-2" />
<div class="flex items-center pt-1 mb-2"> <div class="flex items-center pt-1 mb-2">
@@ -187,6 +190,25 @@ export default {
this.$toast.error('Client Secret required') this.$toast.error('Client Secret required')
isValid = false isValid = false
} }
function isValidRedirectURI(uri) {
// Check for somestring://someother/string
const pattern = new RegExp('^\\w+://[\\w\\.-]+$', 'i')
return pattern.test(uri)
}
const uris = this.newAuthSettings.authOpenIDMobileRedirectURIs
if (uris.includes('*') && uris.length > 1) {
this.$toast.error('Mobile Redirect URIs: Asterisk (*) must be the only entry if used')
isValid = false
} else {
uris.forEach((uri) => {
if (uri !== '*' && !isValidRedirectURI(uri)) {
this.$toast.error(`Mobile Redirect URIs: Invalid URI ${uri}`)
isValid = false
}
})
}
return isValid return isValid
}, },
async saveSettings() { async saveSettings() {
@@ -208,7 +230,11 @@ export default {
.$patch('/api/auth-settings', this.newAuthSettings) .$patch('/api/auth-settings', this.newAuthSettings)
.then((data) => { .then((data) => {
this.$store.commit('setServerSettings', data.serverSettings) this.$store.commit('setServerSettings', data.serverSettings)
this.$toast.success('Server settings updated') if (data.updated) {
this.$toast.success('Server settings updated')
} else {
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
}
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to update server settings', error) console.error('Failed to update server settings', error)
+2 -2
View File
@@ -178,9 +178,9 @@
</a> </a>
<p class="pl-4 pr-2 text-sm text-yellow-400"> <p class="pl-4 pr-2 text-sm text-yellow-400">
{{ $strings.MessageJoinUsOn }} {{ $strings.MessageJoinUsOn }}
<a class="underline" href="https://discord.gg/pJsjuNCKRq" target="_blank">discord</a> <a class="underline" href="https://discord.gg/HQgCbd6E75" target="_blank">discord</a>
</p> </p>
<a href="https://discord.gg/pJsjuNCKRq" target="_blank" class="text-white hover:text-gray-200 hover:scale-150 hover:rotate-6 transform duration-500"> <a href="https://discord.gg/HQgCbd6E75" target="_blank" class="text-white hover:text-gray-200 hover:scale-150 hover:rotate-6 transform duration-500">
<svg width="31" height="24" viewBox="0 0 71 55" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="31" height="24" viewBox="0 0 71 55" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0)"> <g clip-path="url(#clip0)">
<path <path
+179 -26
View File
@@ -5,37 +5,72 @@
<ui-dropdown v-model="selectedUser" :items="userItems" :label="$strings.LabelFilterByUser" small class="max-w-48" @input="updateUserFilter" /> <ui-dropdown v-model="selectedUser" :items="userItems" :label="$strings.LabelFilterByUser" small class="max-w-48" @input="updateUserFilter" />
</div> </div>
<div v-if="listeningSessions.length" class="block max-w-full"> <div v-if="listeningSessions.length" class="block max-w-full relative">
<table class="userSessionsTable"> <table class="userSessionsTable">
<tr class="bg-primary bg-opacity-40"> <tr class="bg-primary bg-opacity-40">
<th class="w-48 min-w-48 text-left">{{ $strings.LabelItem }}</th> <th class="w-6 min-w-6 text-left hidden md:table-cell h-11">
<th class="w-20 min-w-20 text-left hidden md:table-cell">{{ $strings.LabelUser }}</th> <ui-checkbox v-model="isAllSelected" :partial="numSelected > 0 && !isAllSelected" small checkbox-bg="bg" />
<th class="w-32 min-w-32 text-left hidden md:table-cell">{{ $strings.LabelPlayMethod }}</th> </th>
<th class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th> <th v-if="numSelected" class="flex-grow text-left" :colspan="7">
<th class="w-32 min-w-32">{{ $strings.LabelTimeListened }}</th> <div class="flex items-center">
<th class="w-16 min-w-16">{{ $strings.LabelLastTime }}</th> <p>{{ $getString('MessageSelected', [numSelected]) }}</p>
<th class="flex-grow hidden sm:table-cell">{{ $strings.LabelLastUpdate }}</th> <div class="flex-grow" />
<ui-btn small color="error" :loading="deletingSessions" @click.stop="removeSessionsClick">{{ $strings.ButtonRemove }}</ui-btn>
</div>
</th>
<th v-if="!numSelected" class="flex-grow sm:flex-grow-0 sm:w-48 sm:max-w-48 text-left group cursor-pointer" @click.stop="sortColumn('displayTitle')">
<div class="inline-flex items-center">
{{ $strings.LabelItem }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('displayTitle') }" class="material-icons text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
</div>
</th>
<th v-if="!numSelected" class="w-20 min-w-20 text-left hidden md:table-cell">{{ $strings.LabelUser }}</th>
<th v-if="!numSelected" class="w-26 min-w-26 text-left hidden md:table-cell group cursor-pointer" @click.stop="sortColumn('playMethod')">
<div class="inline-flex items-center">
{{ $strings.LabelPlayMethod }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('playMethod') }" class="material-icons text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
</div>
</th>
<th v-if="!numSelected" class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th>
<th v-if="!numSelected" class="w-24 min-w-24 sm:w-32 sm:min-w-32 group cursor-pointer" @click.stop="sortColumn('timeListening')">
<div class="inline-flex items-center">
{{ $strings.LabelTimeListened }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('timeListening') }" class="material-icons text-base pl-px hidden sm:inline-block">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
</div>
</th>
<th v-if="!numSelected" class="w-24 min-w-24 group cursor-pointer" @click.stop="sortColumn('currentTime')">
<div class="inline-flex items-center">
{{ $strings.LabelLastTime }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('currentTime') }" class="material-icons text-base pl-px hidden sm:inline-block">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
</div>
</th>
<th v-if="!numSelected" class="flex-grow hidden sm:table-cell cursor-pointer group" @click.stop="sortColumn('updatedAt')">
<div class="inline-flex items-center">
{{ $strings.LabelLastUpdate }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('updatedAt') }" class="material-icons text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
</div>
</th>
</tr> </tr>
<tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)"> <tr v-for="session in listeningSessions" :key="session.id" :class="{ selected: session.selected }" class="cursor-pointer" @click="clickSessionRow(session)">
<td class="py-1 max-w-48"> <td class="hidden md:table-cell py-1 max-w-6 relative">
<ui-checkbox v-model="session.selected" small checkbox-bg="bg" />
<!-- overlay of the checkbox so that the entire box is clickable -->
<div class="absolute inset-0 w-full h-full" @click.stop="session.selected = !session.selected" />
</td>
<td class="py-1 flex-grow sm:flex-grow-0 sm:w-48 sm:max-w-48">
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p> <p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p> <p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
</td> </td>
<td class="hidden md:table-cell"> <td class="hidden md:table-cell w-20 min-w-20">
<p v-if="filteredUserUsername" class="text-xs">{{ filteredUserUsername }}</p> <p v-if="filteredUserUsername" class="text-xs">{{ filteredUserUsername }}</p>
<p v-else class="text-xs">{{ session.user ? session.user.username : 'N/A' }}</p> <p v-else class="text-xs">{{ session.user ? session.user.username : 'N/A' }}</p>
</td> </td>
<td class="hidden md:table-cell"> <td class="hidden md:table-cell w-26 min-w-26">
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p> <p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
</td> </td>
<td class="hidden sm:table-cell"> <td class="hidden sm:table-cell w-32 min-w-32">
<p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" /> <p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" />
</td> </td>
<td class="text-center"> <td class="text-center w-24 min-w-24 sm:w-32 sm:min-w-32">
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p> <p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
</td> </td>
<td class="text-center hover:underline" @click.stop="clickCurrentTime(session)"> <td class="text-center hover:underline w-24 min-w-24" @click.stop="clickCurrentTime(session)">
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p> <p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
</td> </td>
<td class="text-center hidden sm:table-cell"> <td class="text-center hidden sm:table-cell">
@@ -45,10 +80,22 @@
</td> </td>
</tr> </tr>
</table> </table>
<div class="flex items-center justify-end my-2"> <!-- table bottom options -->
<ui-icon-btn icon="arrow_back_ios_new" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage === 0" @click="prevPage" /> <div class="flex items-center my-2">
<p class="text-sm mx-1">Page {{ currentPage + 1 }} of {{ numPages }}</p> <div class="flex-grow" />
<ui-icon-btn icon="arrow_forward_ios" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage >= numPages - 1" @click="nextPage" /> <div class="hidden sm:inline-flex items-center">
<p class="text-sm">{{ $strings.LabelRowsPerPage }}</p>
<ui-dropdown v-model="itemsPerPage" :items="itemsPerPageOptions" small class="w-24 mx-2" @input="updatedItemsPerPage" />
</div>
<div class="inline-flex items-center">
<p class="text-sm mx-2">Page {{ currentPage + 1 }} of {{ numPages }}</p>
<ui-icon-btn icon="arrow_back_ios_new" :size="9" icon-font-size="1rem" class="mx-1" :disabled="currentPage === 0" @click="prevPage" />
<ui-icon-btn icon="arrow_forward_ios" :size="9" icon-font-size="1rem" class="mx-1" :disabled="currentPage >= numPages - 1" @click="nextPage" />
</div>
</div>
<div v-if="deletingSessions || loading" class="absolute inset-0 w-full h-full flex items-center justify-center">
<ui-loading-indicator />
</div> </div>
</div> </div>
<p v-else class="text-white text-opacity-50">{{ $strings.MessageNoListeningSessions }}</p> <p v-else class="text-white text-opacity-50">{{ $strings.MessageNoListeningSessions }}</p>
@@ -128,6 +175,7 @@ export default {
}, },
data() { data() {
return { return {
loading: false,
showSessionModal: false, showSessionModal: false,
selectedSession: null, selectedSession: null,
listeningSessions: [], listeningSessions: [],
@@ -138,7 +186,11 @@ export default {
itemsPerPage: 10, itemsPerPage: 10,
userFilter: null, userFilter: null,
selectedUser: '', selectedUser: '',
processingGoToTimestamp: false sortBy: 'updatedAt',
sortDesc: true,
processingGoToTimestamp: false,
deletingSessions: false,
itemsPerPageOptions: [10, 25, 50, 100]
} }
}, },
computed: { computed: {
@@ -162,9 +214,85 @@ export default {
}, },
timeFormat() { timeFormat() {
return this.$store.state.serverSettings.timeFormat return this.$store.state.serverSettings.timeFormat
},
numSelected() {
return this.listeningSessions.filter((s) => s.selected).length
},
isAllSelected: {
get() {
return this.numSelected === this.listeningSessions.length
},
set(val) {
this.setSelectionForAll(val)
}
} }
}, },
methods: { methods: {
isSortSelected(column) {
return this.sortBy === column
},
sortColumn(column) {
if (this.sortBy === column) {
this.sortDesc = !this.sortDesc
} else {
this.sortBy = column
}
this.loadSessions(this.currentPage)
},
removeSelectedSessions() {
if (!this.numSelected) return
this.deletingSessions = true
let isAllSessions = this.isAllSelected
const payload = {
sessions: this.listeningSessions.filter((s) => s.selected).map((s) => s.id)
}
this.$axios
.$post(`/api/sessions/batch/delete`, payload)
.then(() => {
this.$toast.success('Sessions removed')
if (isAllSessions) {
// If all sessions were removed from the current page then go to the previous page
if (this.currentPage > 0) {
this.currentPage--
}
this.loadSessions(this.currentPage)
} else {
// Filter out the deleted sessions
this.listeningSessions = this.listeningSessions.filter((ls) => !payload.sessions.includes(ls.id))
}
})
.catch((error) => {
const errorMsg = error.response?.data || 'Failed to remove sessions'
this.$toast.error(errorMsg)
})
.finally(() => {
this.deletingSessions = false
})
},
removeSessionsClick() {
if (!this.numSelected) return
const payload = {
message: this.$getString('MessageConfirmRemoveListeningSessions', [this.numSelected]),
callback: (confirmed) => {
if (confirmed) {
this.removeSelectedSessions()
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
setSelectionForAll(val) {
this.listeningSessions = this.listeningSessions.map((s) => {
s.selected = val
return s
})
},
updatedItemsPerPage() {
this.currentPage = 0
this.loadSessions(this.currentPage)
},
closedSession() { closedSession() {
this.loadOpenSessions() this.loadOpenSessions()
}, },
@@ -252,6 +380,13 @@ export default {
nextPage() { nextPage() {
this.loadSessions(this.currentPage + 1) this.loadSessions(this.currentPage + 1)
}, },
clickSessionRow(session) {
if (this.numSelected > 0) {
session.selected = !session.selected
} else {
this.showSession(session)
}
},
showSession(session) { showSession(session) {
this.selectedSession = session this.selectedSession = session
this.showSessionModal = true this.showSessionModal = true
@@ -274,11 +409,21 @@ export default {
return 'Unknown' return 'Unknown'
}, },
async loadSessions(page) { async loadSessions(page) {
const userFilterQuery = this.selectedUser ? `&user=${this.selectedUser}` : '' this.loading = true
const data = await this.$axios.$get(`/api/sessions?page=${page}&itemsPerPage=${this.itemsPerPage}${userFilterQuery}`).catch((err) => { const urlSearchParams = new URLSearchParams()
urlSearchParams.set('page', page)
urlSearchParams.set('itemsPerPage', this.itemsPerPage)
urlSearchParams.set('sort', this.sortBy)
urlSearchParams.set('desc', this.sortDesc ? '1' : '0')
if (this.selectedUser) {
urlSearchParams.set('user', this.selectedUser)
}
const data = await this.$axios.$get(`/api/sessions?${urlSearchParams.toString()}`).catch((err) => {
console.error('Failed to load listening sessions', err) console.error('Failed to load listening sessions', err)
return null return null
}) })
this.loading = false
if (!data) { if (!data) {
this.$toast.error('Failed to load listening sessions') this.$toast.error('Failed to load listening sessions')
return return
@@ -287,8 +432,13 @@ export default {
this.numPages = data.numPages this.numPages = data.numPages
this.total = data.total this.total = data.total
this.currentPage = data.page this.currentPage = data.page
this.listeningSessions = data.sessions this.listeningSessions = data.sessions.map((ls) => {
this.userFilter = data.userFilter return {
...ls,
selected: false
}
})
this.userFilter = data.userId
}, },
async loadOpenSessions() { async loadOpenSessions() {
const data = await this.$axios.$get('/api/sessions/open').catch((err) => { const data = await this.$axios.$get('/api/sessions/open').catch((err) => {
@@ -326,15 +476,18 @@ export default {
.userSessionsTable tr:first-child { .userSessionsTable tr:first-child {
background-color: #272727; background-color: #272727;
} }
.userSessionsTable tr:not(:first-child) { .userSessionsTable tr:not(:first-child):not(.selected) {
background-color: #373838; background-color: #373838;
} }
.userSessionsTable tr:not(:first-child):nth-child(odd) { .userSessionsTable tr:not(:first-child):nth-child(odd):not(.selected):not(:hover) {
background-color: #2f2f2f; background-color: #2f2f2f;
} }
.userSessionsTable tr:hover:not(:first-child) { .userSessionsTable tr:hover:not(:first-child) {
background-color: #474747; background-color: #474747;
} }
.userSessionsTable tr.selected {
background-color: #474747;
}
.userSessionsTable td { .userSessionsTable td {
padding: 4px 8px; padding: 4px 8px;
} }
+15 -2
View File
@@ -1,6 +1,9 @@
<template> <template>
<div> <div>
<app-settings-content :header-text="$strings.HeaderYourStats"> <!-- Year in review banner shown at the top in December and January -->
<stats-year-in-review-banner v-if="showYearInReviewBanner" />
<app-settings-content :header-text="$strings.HeaderYourStats" class="!mb-4">
<div class="flex justify-center"> <div class="flex justify-center">
<div class="flex p-2"> <div class="flex p-2">
<svg class="hidden sm:block h-14 w-14 lg:h-18 lg:w-18" viewBox="0 0 24 24"> <svg class="hidden sm:block h-14 w-14 lg:h-18 lg:w-18" viewBox="0 0 24 24">
@@ -63,6 +66,9 @@
</div> </div>
<stats-heatmap v-if="listeningStats" :days-listening="listeningStats.days" class="my-2" /> <stats-heatmap v-if="listeningStats" :days-listening="listeningStats.days" class="my-2" />
</app-settings-content> </app-settings-content>
<!-- Year in review banner shown at the bottom Feb - Nov -->
<stats-year-in-review-banner v-if="!showYearInReviewBanner" />
</div> </div>
</template> </template>
@@ -71,7 +77,8 @@ export default {
data() { data() {
return { return {
listeningStats: null, listeningStats: null,
windowWidth: 0 windowWidth: 0,
showYearInReviewBanner: false
} }
}, },
watch: { watch: {
@@ -119,6 +126,12 @@ export default {
console.error('Failed to load listening sesions', err) console.error('Failed to load listening sesions', err)
return [] return []
}) })
let month = new Date().getMonth()
// January and December show year in review banner
if (month === 11 || month === 0) {
this.showYearInReviewBanner = true
}
} }
}, },
mounted() { mounted() {
+2 -2
View File
@@ -1,6 +1,6 @@
<template> <template>
<div id="page-wrapper" class="bg-bg page overflow-hidden" :class="streamLibraryItem ? 'streaming' : ''"> <div id="page-wrapper" class="bg-bg page overflow-hidden" :class="streamLibraryItem ? 'streaming' : ''">
<div class="w-full h-full overflow-y-auto px-2 py-6 lg:p-8"> <div id="item-page-wrapper" class="w-full h-full overflow-y-auto px-2 py-6 lg:p-8">
<div class="flex flex-col lg:flex-row max-w-6xl mx-auto"> <div class="flex flex-col lg:flex-row max-w-6xl mx-auto">
<div class="w-full flex justify-center lg:block lg:w-52" style="min-width: 208px"> <div class="w-full flex justify-center lg:block lg:w-52" style="min-width: 208px">
<div class="relative group" style="height: fit-content"> <div class="relative group" style="height: fit-content">
@@ -136,7 +136,7 @@
<widgets-audiobook-data v-if="tracks.length" :library-item-id="libraryItemId" :is-file="isFile" :media="media" /> <widgets-audiobook-data v-if="tracks.length" :library-item-id="libraryItemId" :is-file="isFile" :media="media" />
<tables-podcast-episodes-table v-if="isPodcast" :library-item="libraryItem" /> <tables-podcast-lazy-episodes-table v-if="isPodcast" :library-item="libraryItem" />
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" class="mt-6" /> <tables-chapters-table v-if="chapters.length" :library-item="libraryItem" class="mt-6" />
@@ -54,9 +54,16 @@
<p class="pl-2 pr-1 text-sm font-semibold">{{ getButtonText(episode) }}</p> <p class="pl-2 pr-1 text-sm font-semibold">{{ getButtonText(episode) }}</p>
</button> </button>
<button v-if="libraryItemIdStreaming && !isStreamingFromDifferentLibrary" class="h-8 w-8 flex justify-center items-center mx-2" :class="playerQueueEpisodeIdMap[episode.id] ? 'text-success' : ''" @click.stop="queueBtnClick(episode)"> <ui-tooltip v-if="libraryItemIdStreaming && !isStreamingFromDifferentLibrary" :text="playerQueueEpisodeIdMap[episode.id] ? $strings.MessageRemoveFromPlayerQueue : $strings.MessageAddToPlayerQueue" :class="playerQueueEpisodeIdMap[episode.id] ? 'text-success' : ''" direction="top">
<span class="material-icons-outlined text-2xl">{{ playerQueueEpisodeIdMap[episode.id] ? 'playlist_add_check' : 'playlist_add' }}</span> <ui-icon-btn :icon="playerQueueEpisodeIdMap[episode.id] ? 'playlist_add_check' : 'playlist_play'" borderless @click="queueBtnClick(episode)" />
</button> <!-- <button class="h-8 w-8 flex justify-center items-center mx-2" :class="playerQueueEpisodeIdMap[episode.id] ? 'text-success' : ''" @click.stop="queueBtnClick(episode)">
<span class="material-icons-outlined text-2xl">{{ playerQueueEpisodeIdMap[episode.id] ? 'playlist_add_check' : 'playlist_add' }}</span>
</button> -->
</ui-tooltip>
<ui-tooltip :text="$strings.LabelYourPlaylists" direction="top">
<ui-icon-btn icon="playlist_add" borderless @click="clickAddToPlaylist(episode)" />
</ui-tooltip>
</div> </div>
</div> </div>
@@ -136,6 +143,15 @@ export default {
} }
}, },
methods: { methods: {
clickAddToPlaylist(episode) {
// Makeshift libraryItem
const libraryItem = {
id: episode.libraryItemId,
media: episode.podcast
}
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: libraryItem, episode }])
this.$store.commit('globals/setShowPlaylistsModal', true)
},
async clickEpisode(episode) { async clickEpisode(episode) {
if (this.openingItem) return if (this.openingItem) return
this.openingItem = true this.openingItem = true
@@ -155,7 +171,9 @@ export default {
if (this.episodeIdStreaming === episode.id) return this.streamIsPlaying ? 'Streaming' : 'Play' if (this.episodeIdStreaming === episode.id) return this.streamIsPlaying ? 'Streaming' : 'Play'
if (!episode.progress) return this.$elapsedPretty(episode.duration) if (!episode.progress) return this.$elapsedPretty(episode.duration)
if (episode.progress.isFinished) return 'Finished' if (episode.progress.isFinished) return 'Finished'
var remaining = Math.floor(episode.progress.duration - episode.progress.currentTime)
const duration = episode.progress.duration || episode.duration
const remaining = Math.floor(duration - episode.progress.currentTime)
return `${this.$elapsedPretty(remaining)} left` return `${this.$elapsedPretty(remaining)} left`
}, },
playClick(episodeToPlay) { playClick(episodeToPlay) {
@@ -45,6 +45,11 @@
<script> <script>
export default { export default {
async asyncData({ params, query, store, app, redirect }) { async asyncData({ params, query, store, app, redirect }) {
// Podcast search/add page is restricted to admins
if (!store.getters['user/getIsAdminOrUp']) {
return redirect(`/library/${params.library}`)
}
var libraryId = params.library var libraryId = params.library
var libraryData = await store.dispatch('libraries/fetch', libraryId) var libraryData = await store.dispatch('libraries/fetch', libraryId)
if (!libraryData) { if (!libraryData) {
+66 -14
View File
@@ -14,6 +14,20 @@
</div> </div>
</div> </div>
<div v-if="!selectedLibraryIsPodcast" class="flex items-center mb-6">
<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>
</label>
<ui-tooltip :text="$strings.LabelAutoFetchMetadataHelp" class="inline-flex pt-4">
<span class="pl-1 material-icons icon-text text-sm cursor-pointer">info_outlined</span>
</ui-tooltip>
<div class="flex-grow ml-4">
<ui-dropdown v-model="fetchMetadata.provider" :items="providers" :label="$strings.LabelProvider" />
</div>
</div>
<widgets-alert v-if="error" type="error"> <widgets-alert v-if="error" type="error">
<p class="text-lg">{{ error }}</p> <p class="text-lg">{{ error }}</p>
</widgets-alert> </widgets-alert>
@@ -61,9 +75,7 @@
</widgets-alert> </widgets-alert>
<!-- Item Upload cards --> <!-- Item Upload cards -->
<template v-for="item in items"> <cards-item-upload-card v-for="item in items" :key="item.index" :ref="`itemCard-${item.index}`" :media-type="selectedLibraryMediaType" :item="item" :provider="fetchMetadata.provider" :processing="processing" @remove="removeItem(item)" />
<cards-item-upload-card :ref="`itemCard-${item.index}`" :key="item.index" :media-type="selectedLibraryMediaType" :item="item" :processing="processing" @remove="removeItem(item)" />
</template>
<!-- Upload/Reset btns --> <!-- Upload/Reset btns -->
<div v-show="items.length" class="flex justify-end pb-8 pt-4"> <div v-show="items.length" class="flex justify-end pb-8 pt-4">
@@ -92,13 +104,18 @@ export default {
selectedLibraryId: null, selectedLibraryId: null,
selectedFolderId: null, selectedFolderId: null,
processing: false, processing: false,
uploadFinished: false uploadFinished: false,
fetchMetadata: {
enabled: false,
provider: null
}
} }
}, },
watch: { watch: {
selectedLibrary(newVal) { selectedLibrary(newVal) {
if (newVal && !this.selectedFolderId) { if (newVal && !this.selectedFolderId) {
this.setDefaultFolder() this.setDefaultFolder()
this.setMetadataProvider()
} }
} }
}, },
@@ -133,6 +150,13 @@ export default {
selectedLibraryIsPodcast() { selectedLibraryIsPodcast() {
return this.selectedLibraryMediaType === 'podcast' return this.selectedLibraryMediaType === 'podcast'
}, },
providers() {
if (this.selectedLibraryIsPodcast) return this.$store.state.scanners.podcastProviders
return this.$store.state.scanners.providers
},
canFetchMetadata() {
return !this.selectedLibraryIsPodcast && this.fetchMetadata.enabled
},
selectedFolder() { selectedFolder() {
if (!this.selectedLibrary) return null if (!this.selectedLibrary) return null
return this.selectedLibrary.folders.find((fold) => fold.id === this.selectedFolderId) return this.selectedLibrary.folders.find((fold) => fold.id === this.selectedFolderId)
@@ -160,12 +184,16 @@ export default {
} }
} }
this.setDefaultFolder() this.setDefaultFolder()
this.setMetadataProvider()
}, },
setDefaultFolder() { setDefaultFolder() {
if (!this.selectedFolderId && this.selectedLibrary && this.selectedLibrary.folders.length) { if (!this.selectedFolderId && this.selectedLibrary && this.selectedLibrary.folders.length) {
this.selectedFolderId = this.selectedLibrary.folders[0].id this.selectedFolderId = this.selectedLibrary.folders[0].id
} }
}, },
setMetadataProvider() {
this.fetchMetadata.provider ||= this.$store.getters['libraries/getLibraryProvider'](this.selectedLibraryId)
},
removeItem(item) { removeItem(item) {
this.items = this.items.filter((b) => b.index !== item.index) this.items = this.items.filter((b) => b.index !== item.index)
if (!this.items.length) { if (!this.items.length) {
@@ -213,27 +241,49 @@ export default {
var items = e.dataTransfer.items || [] var items = e.dataTransfer.items || []
var itemResults = await this.uploadHelpers.getItemsFromDrop(items, this.selectedLibraryMediaType) var itemResults = await this.uploadHelpers.getItemsFromDrop(items, this.selectedLibraryMediaType)
this.setResults(itemResults) this.onItemsSelected(itemResults)
}, },
inputChanged(e) { inputChanged(e) {
if (!e.target || !e.target.files) return if (!e.target || !e.target.files) return
var _files = Array.from(e.target.files) var _files = Array.from(e.target.files)
if (_files && _files.length) { if (_files && _files.length) {
var itemResults = this.uploadHelpers.getItemsFromPicker(_files, this.selectedLibraryMediaType) var itemResults = this.uploadHelpers.getItemsFromPicker(_files, this.selectedLibraryMediaType)
this.setResults(itemResults) this.onItemsSelected(itemResults)
} }
}, },
setResults(itemResults) { onItemsSelected(itemResults) {
if (this.itemSelectionSuccessful(itemResults)) {
// setTimeout ensures the new item ref is attached before this method is called
setTimeout(this.attemptMetadataFetch, 0)
}
},
itemSelectionSuccessful(itemResults) {
console.log('Upload results', itemResults)
if (itemResults.error) { if (itemResults.error) {
this.error = itemResults.error this.error = itemResults.error
this.items = [] this.items = []
this.ignoredFiles = [] this.ignoredFiles = []
} else { return false
this.error = ''
this.items = itemResults.items
this.ignoredFiles = itemResults.ignoredFiles
} }
console.log('Upload results', itemResults)
this.error = ''
this.items = itemResults.items
this.ignoredFiles = itemResults.ignoredFiles
return true
},
attemptMetadataFetch() {
if (!this.canFetchMetadata) {
return false
}
this.items.forEach((item) => {
let itemRef = this.$refs[`itemCard-${item.index}`]
if (itemRef?.length) {
itemRef[0].fetchMetadata(this.fetchMetadata.provider)
}
})
}, },
updateItemCardStatus(index, status) { updateItemCardStatus(index, status) {
var ref = this.$refs[`itemCard-${index}`] var ref = this.$refs[`itemCard-${index}`]
@@ -248,8 +298,8 @@ export default {
var form = new FormData() var form = new FormData()
form.set('title', item.title) form.set('title', item.title)
if (!this.selectedLibraryIsPodcast) { if (!this.selectedLibraryIsPodcast) {
form.set('author', item.author) form.set('author', item.author || '')
form.set('series', item.series) form.set('series', item.series || '')
} }
form.set('library', this.selectedLibraryId) form.set('library', this.selectedLibraryId)
form.set('folder', this.selectedFolderId) form.set('folder', this.selectedFolderId)
@@ -346,6 +396,8 @@ export default {
}, },
mounted() { mounted() {
this.selectedLibraryId = this.$store.state.libraries.currentLibraryId this.selectedLibraryId = this.$store.state.libraries.currentLibraryId
this.setMetadataProvider()
this.setDefaultFolder() this.setDefaultFolder()
window.addEventListener('dragenter', this.dragenter) window.addEventListener('dragenter', this.dragenter)
window.addEventListener('dragleave', this.dragleave) window.addEventListener('dragleave', this.dragleave)
+1
View File
@@ -77,6 +77,7 @@ Vue.prototype.$sanitizeFilename = (filename, colonReplacement = ' - ') => {
.replace(lineBreaks, replacement) .replace(lineBreaks, replacement)
.replace(windowsReservedRe, replacement) .replace(windowsReservedRe, replacement)
.replace(windowsTrailingRe, replacement) .replace(windowsTrailingRe, replacement)
.replace(/\s+/g, ' ') // Replace consecutive spaces with a single space
// Check if basename is too many bytes // Check if basename is too many bytes
const ext = Path.extname(sanitized) // separate out file extension const ext = Path.extname(sanitized) // separate out file extension
+5 -6
View File
@@ -80,13 +80,11 @@ export const actions = {
return state.folders return state.folders
} }
} }
console.log('Loading folders')
commit('setFoldersLastUpdate') commit('setFoldersLastUpdate')
return this.$axios return this.$axios
.$get('/api/filesystem') .$get('/api/filesystem')
.then((res) => { .then((res) => {
console.log('Settings folders', res)
commit('setFolders', res.directories) commit('setFolders', res.directories)
return res.directories return res.directories
}) })
@@ -119,15 +117,16 @@ export const actions = {
dispatch('user/checkUpdateLibrarySortFilter', library.mediaType, { root: true }) dispatch('user/checkUpdateLibrarySortFilter', library.mediaType, { root: true })
if (libraryChanging) {
commit('setCollections', [])
commit('setUserPlaylists', [])
}
commit('addUpdate', library) commit('addUpdate', library)
commit('setLibraryIssues', issues) commit('setLibraryIssues', issues)
commit('setLibraryFilterData', filterData) commit('setLibraryFilterData', filterData)
commit('setNumUserPlaylists', numUserPlaylists) commit('setNumUserPlaylists', numUserPlaylists)
commit('setCurrentLibrary', libraryId) commit('setCurrentLibrary', libraryId)
if (libraryChanging) {
commit('setCollections', [])
commit('setUserPlaylists', [])
}
return data return data
}) })
.catch((error) => { .catch((error) => {
+12 -1
View File
@@ -87,6 +87,9 @@
"ButtonUserEdit": "Upravit uživatelské {0}", "ButtonUserEdit": "Upravit uživatelské {0}",
"ButtonViewAll": "Zobrazit vše", "ButtonViewAll": "Zobrazit vše",
"ButtonYes": "Ano", "ButtonYes": "Ano",
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
"ErrorUploadLacksTitle": "Must have a title",
"HeaderAccount": "Účet", "HeaderAccount": "Účet",
"HeaderAdvanced": "Pokročilé", "HeaderAdvanced": "Pokročilé",
"HeaderAppriseNotificationSettings": "Nastavení oznámení Apprise", "HeaderAppriseNotificationSettings": "Nastavení oznámení Apprise",
@@ -196,6 +199,8 @@
"LabelAuthorLastFirst": "Autor (příjmení a jméno)", "LabelAuthorLastFirst": "Autor (příjmení a jméno)",
"LabelAuthors": "Autoři", "LabelAuthors": "Autoři",
"LabelAutoDownloadEpisodes": "Automaticky stahovat epizody", "LabelAutoDownloadEpisodes": "Automaticky stahovat epizody",
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
"LabelAutoLaunch": "Auto Launch", "LabelAutoLaunch": "Auto Launch",
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Auto Register", "LabelAutoRegister": "Auto Register",
@@ -266,6 +271,7 @@
"LabelExample": "Příklad", "LabelExample": "Příklad",
"LabelExplicit": "Explicitní", "LabelExplicit": "Explicitní",
"LabelFeedURL": "URL zdroje", "LabelFeedURL": "URL zdroje",
"LabelFetchingMetadata": "Fetching Metadata",
"LabelFile": "Soubor", "LabelFile": "Soubor",
"LabelFileBirthtime": "Čas vzniku souboru", "LabelFileBirthtime": "Čas vzniku souboru",
"LabelFileModified": "Soubor změněn", "LabelFileModified": "Soubor změněn",
@@ -337,6 +343,8 @@
"LabelMinute": "Minuta", "LabelMinute": "Minuta",
"LabelMissing": "Chybějící", "LabelMissing": "Chybějící",
"LabelMissingParts": "Chybějící díly", "LabelMissingParts": "Chybějící díly",
"LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs",
"LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.",
"LabelMore": "Více", "LabelMore": "Více",
"LabelMoreInfo": "Více informací", "LabelMoreInfo": "Více informací",
"LabelName": "Jméno", "LabelName": "Jméno",
@@ -398,6 +406,7 @@
"LabelRegion": "Region", "LabelRegion": "Region",
"LabelReleaseDate": "Datum vydání", "LabelReleaseDate": "Datum vydání",
"LabelRemoveCover": "Odstranit obálku", "LabelRemoveCover": "Odstranit obálku",
"LabelRowsPerPage": "Rows per page",
"LabelRSSFeedCustomOwnerEmail": "Vlastní e-mail vlastníka", "LabelRSSFeedCustomOwnerEmail": "Vlastní e-mail vlastníka",
"LabelRSSFeedCustomOwnerName": "Vlastní jméno vlastníka", "LabelRSSFeedCustomOwnerName": "Vlastní jméno vlastníka",
"LabelRSSFeedOpen": "Otevření RSS kanálu", "LabelRSSFeedOpen": "Otevření RSS kanálu",
@@ -515,6 +524,7 @@
"LabelUpdateDetailsHelp": "Povolit přepsání existujících údajů o vybraných knihách, když je nalezena shoda", "LabelUpdateDetailsHelp": "Povolit přepsání existujících údajů o vybraných knihách, když je nalezena shoda",
"LabelUploaderDragAndDrop": "Přetáhnout soubory nebo složky", "LabelUploaderDragAndDrop": "Přetáhnout soubory nebo složky",
"LabelUploaderDropFiles": "Odstranit soubory", "LabelUploaderDropFiles": "Odstranit soubory",
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
"LabelUseChapterTrack": "Použít stopu kapitoly", "LabelUseChapterTrack": "Použít stopu kapitoly",
"LabelUseFullTrack": "Použít celou stopu", "LabelUseFullTrack": "Použít celou stopu",
"LabelUser": "Uživatel", "LabelUser": "Uživatel",
@@ -562,6 +572,7 @@
"MessageConfirmRemoveCollection": "Opravdu chcete odstranit kolekci \"{0}\"?", "MessageConfirmRemoveCollection": "Opravdu chcete odstranit kolekci \"{0}\"?",
"MessageConfirmRemoveEpisode": "Opravdu chcete odstranit epizodu \"{0}\"?", "MessageConfirmRemoveEpisode": "Opravdu chcete odstranit epizodu \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Opravdu chcete odstranit {0} epizody?", "MessageConfirmRemoveEpisodes": "Opravdu chcete odstranit {0} epizody?",
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
"MessageConfirmRemoveNarrator": "Opravdu chcete odebrat předčítání \"{0}\"?", "MessageConfirmRemoveNarrator": "Opravdu chcete odebrat předčítání \"{0}\"?",
"MessageConfirmRemovePlaylist": "Opravdu chcete odstranit svůj playlist \"{0}\"?", "MessageConfirmRemovePlaylist": "Opravdu chcete odstranit svůj playlist \"{0}\"?",
"MessageConfirmRenameGenre": "Opravdu chcete přejmenovat žánr \"{0}\" na \"{1}\" pro všechny položky?", "MessageConfirmRenameGenre": "Opravdu chcete přejmenovat žánr \"{0}\" na \"{1}\" pro všechny položky?",
@@ -641,6 +652,7 @@
"MessageRestoreBackupConfirm": "Opravdu chcete obnovit zálohu vytvořenou dne?", "MessageRestoreBackupConfirm": "Opravdu chcete obnovit zálohu vytvořenou dne?",
"MessageRestoreBackupWarning": "Obnovení zálohy přepíše celou databázi umístěnou v /config a obálku obrázků v /metadata/items & /metadata/authors.<br /><br />Backups nezmění žádné soubory ve složkách knihovny. Pokud jste povolili nastavení serveru pro ukládání obrázků obalu a metadat do složek knihovny, nebudou zálohovány ani přepsány.<br /><br />Všichni klienti používající váš server budou automaticky obnoveni.", "MessageRestoreBackupWarning": "Obnovení zálohy přepíše celou databázi umístěnou v /config a obálku obrázků v /metadata/items & /metadata/authors.<br /><br />Backups nezmění žádné soubory ve složkách knihovny. Pokud jste povolili nastavení serveru pro ukládání obrázků obalu a metadat do složek knihovny, nebudou zálohovány ani přepsány.<br /><br />Všichni klienti používající váš server budou automaticky obnoveni.",
"MessageSearchResultsFor": "Výsledky hledání pro", "MessageSearchResultsFor": "Výsledky hledání pro",
"MessageSelected": "{0} selected",
"MessageServerCouldNotBeReached": "Server je nedostupný", "MessageServerCouldNotBeReached": "Server je nedostupný",
"MessageSetChaptersFromTracksDescription": "Nastavit kapitoly jako kapitolu a název kapitoly jako název zvukového souboru", "MessageSetChaptersFromTracksDescription": "Nastavit kapitoly jako kapitolu a název kapitoly jako název zvukového souboru",
"MessageStartPlaybackAtTime": "Spustit přehrávání pro \"{0}\" v {1}?", "MessageStartPlaybackAtTime": "Spustit přehrávání pro \"{0}\" v {1}?",
@@ -656,7 +668,6 @@
"NoteChangeRootPassword": "Uživatel root je jediný uživatel, který může mít prázdné heslo", "NoteChangeRootPassword": "Uživatel root je jediný uživatel, který může mít prázdné heslo",
"NoteChapterEditorTimes": "Poznámka: Čas začátku první kapitoly musí zůstat v 0:00 a čas začátku poslední kapitoly nesmí překročit tuto dobu trvání audioknihy.", "NoteChapterEditorTimes": "Poznámka: Čas začátku první kapitoly musí zůstat v 0:00 a čas začátku poslední kapitoly nesmí překročit tuto dobu trvání audioknihy.",
"NoteFolderPicker": "Poznámka: složky, které jsou již namapovány, nebudou zobrazeny", "NoteFolderPicker": "Poznámka: složky, které jsou již namapovány, nebudou zobrazeny",
"NoteFolderPickerDebian": "Poznámka: Výběr složek pro instalaci debianu není plně implementován. Cestu ke své knihovně byste měli zadat přímo.",
"NoteRSSFeedPodcastAppsHttps": "Upozornění: Většina aplikací pro podcasty bude vyžadovat, aby adresa URL kanálu RSS používala protokol HTTPS", "NoteRSSFeedPodcastAppsHttps": "Upozornění: Většina aplikací pro podcasty bude vyžadovat, aby adresa URL kanálu RSS používala protokol HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Upozornění: 1 nebo více epizod nemá datum vydání. Některé podcastové aplikace to vyžadují.", "NoteRSSFeedPodcastAppsPubDate": "Upozornění: 1 nebo více epizod nemá datum vydání. Některé podcastové aplikace to vyžadují.",
"NoteUploaderFoldersWithMediaFiles": "Se složkami s multimediálními soubory bude zacházeno jako se samostatnými položkami knihovny.", "NoteUploaderFoldersWithMediaFiles": "Se složkami s multimediálními soubory bude zacházeno jako se samostatnými položkami knihovny.",
+12 -1
View File
@@ -87,6 +87,9 @@
"ButtonUserEdit": "Rediger bruger {0}", "ButtonUserEdit": "Rediger bruger {0}",
"ButtonViewAll": "Vis Alle", "ButtonViewAll": "Vis Alle",
"ButtonYes": "Ja", "ButtonYes": "Ja",
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
"ErrorUploadLacksTitle": "Must have a title",
"HeaderAccount": "Konto", "HeaderAccount": "Konto",
"HeaderAdvanced": "Avanceret", "HeaderAdvanced": "Avanceret",
"HeaderAppriseNotificationSettings": "Apprise Notifikationsindstillinger", "HeaderAppriseNotificationSettings": "Apprise Notifikationsindstillinger",
@@ -196,6 +199,8 @@
"LabelAuthorLastFirst": "Forfatter (Efternavn, Fornavn)", "LabelAuthorLastFirst": "Forfatter (Efternavn, Fornavn)",
"LabelAuthors": "Forfattere", "LabelAuthors": "Forfattere",
"LabelAutoDownloadEpisodes": "Auto Download Episoder", "LabelAutoDownloadEpisodes": "Auto Download Episoder",
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
"LabelAutoLaunch": "Auto Launch", "LabelAutoLaunch": "Auto Launch",
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Auto Register", "LabelAutoRegister": "Auto Register",
@@ -266,6 +271,7 @@
"LabelExample": "Eksempel", "LabelExample": "Eksempel",
"LabelExplicit": "Eksplisit", "LabelExplicit": "Eksplisit",
"LabelFeedURL": "Feed URL", "LabelFeedURL": "Feed URL",
"LabelFetchingMetadata": "Fetching Metadata",
"LabelFile": "Fil", "LabelFile": "Fil",
"LabelFileBirthtime": "Fødselstidspunkt for fil", "LabelFileBirthtime": "Fødselstidspunkt for fil",
"LabelFileModified": "Fil ændret", "LabelFileModified": "Fil ændret",
@@ -337,6 +343,8 @@
"LabelMinute": "Minut", "LabelMinute": "Minut",
"LabelMissing": "Mangler", "LabelMissing": "Mangler",
"LabelMissingParts": "Manglende dele", "LabelMissingParts": "Manglende dele",
"LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs",
"LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.",
"LabelMore": "Mere", "LabelMore": "Mere",
"LabelMoreInfo": "Mere info", "LabelMoreInfo": "Mere info",
"LabelName": "Navn", "LabelName": "Navn",
@@ -398,6 +406,7 @@
"LabelRegion": "Region", "LabelRegion": "Region",
"LabelReleaseDate": "Udgivelsesdato", "LabelReleaseDate": "Udgivelsesdato",
"LabelRemoveCover": "Fjern omslag", "LabelRemoveCover": "Fjern omslag",
"LabelRowsPerPage": "Rows per page",
"LabelRSSFeedCustomOwnerEmail": "Brugerdefineret ejerens e-mail", "LabelRSSFeedCustomOwnerEmail": "Brugerdefineret ejerens e-mail",
"LabelRSSFeedCustomOwnerName": "Brugerdefineret ejerens navn", "LabelRSSFeedCustomOwnerName": "Brugerdefineret ejerens navn",
"LabelRSSFeedOpen": "Åben RSS-feed", "LabelRSSFeedOpen": "Åben RSS-feed",
@@ -515,6 +524,7 @@
"LabelUpdateDetailsHelp": "Tillad overskrivning af eksisterende detaljer for de valgte bøger, når der findes en match", "LabelUpdateDetailsHelp": "Tillad overskrivning af eksisterende detaljer for de valgte bøger, når der findes en match",
"LabelUploaderDragAndDrop": "Træk og slip filer eller mapper", "LabelUploaderDragAndDrop": "Træk og slip filer eller mapper",
"LabelUploaderDropFiles": "Smid filer", "LabelUploaderDropFiles": "Smid filer",
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
"LabelUseChapterTrack": "Brug kapitel-spor", "LabelUseChapterTrack": "Brug kapitel-spor",
"LabelUseFullTrack": "Brug fuldt spor", "LabelUseFullTrack": "Brug fuldt spor",
"LabelUser": "Bruger", "LabelUser": "Bruger",
@@ -562,6 +572,7 @@
"MessageConfirmRemoveCollection": "Er du sikker på, at du vil fjerne samlingen \"{0}\"?", "MessageConfirmRemoveCollection": "Er du sikker på, at du vil fjerne samlingen \"{0}\"?",
"MessageConfirmRemoveEpisode": "Er du sikker på, at du vil fjerne episoden \"{0}\"?", "MessageConfirmRemoveEpisode": "Er du sikker på, at du vil fjerne episoden \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Er du sikker på, at du vil fjerne {0} episoder?", "MessageConfirmRemoveEpisodes": "Er du sikker på, at du vil fjerne {0} episoder?",
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
"MessageConfirmRemoveNarrator": "Er du sikker på, at du vil fjerne fortælleren \"{0}\"?", "MessageConfirmRemoveNarrator": "Er du sikker på, at du vil fjerne fortælleren \"{0}\"?",
"MessageConfirmRemovePlaylist": "Er du sikker på, at du vil fjerne din spilleliste \"{0}\"?", "MessageConfirmRemovePlaylist": "Er du sikker på, at du vil fjerne din spilleliste \"{0}\"?",
"MessageConfirmRenameGenre": "Er du sikker på, at du vil omdøbe genre \"{0}\" til \"{1}\" for alle elementer?", "MessageConfirmRenameGenre": "Er du sikker på, at du vil omdøbe genre \"{0}\" til \"{1}\" for alle elementer?",
@@ -641,6 +652,7 @@
"MessageRestoreBackupConfirm": "Er du sikker på, at du vil gendanne sikkerhedskopien oprettet den", "MessageRestoreBackupConfirm": "Er du sikker på, at du vil gendanne sikkerhedskopien oprettet den",
"MessageRestoreBackupWarning": "Gendannelse af en sikkerhedskopi vil overskrive hele databasen, som er placeret på /config, og omslagsbilleder i /metadata/items & /metadata/authors.<br /><br />Sikkerhedskopier ændrer ikke nogen filer i dine biblioteksmapper. Hvis du har aktiveret serverindstillinger for at gemme omslagskunst og metadata i dine biblioteksmapper, sikkerhedskopieres eller overskrives disse ikke.<br /><br />Alle klienter, der bruger din server, opdateres automatisk.", "MessageRestoreBackupWarning": "Gendannelse af en sikkerhedskopi vil overskrive hele databasen, som er placeret på /config, og omslagsbilleder i /metadata/items & /metadata/authors.<br /><br />Sikkerhedskopier ændrer ikke nogen filer i dine biblioteksmapper. Hvis du har aktiveret serverindstillinger for at gemme omslagskunst og metadata i dine biblioteksmapper, sikkerhedskopieres eller overskrives disse ikke.<br /><br />Alle klienter, der bruger din server, opdateres automatisk.",
"MessageSearchResultsFor": "Søgeresultater for", "MessageSearchResultsFor": "Søgeresultater for",
"MessageSelected": "{0} selected",
"MessageServerCouldNotBeReached": "Serveren kunne ikke nås", "MessageServerCouldNotBeReached": "Serveren kunne ikke nås",
"MessageSetChaptersFromTracksDescription": "Indstil kapitler ved at bruge hver lydfil som et kapitel og kapiteloverskrift som lydfilnavn", "MessageSetChaptersFromTracksDescription": "Indstil kapitler ved at bruge hver lydfil som et kapitel og kapiteloverskrift som lydfilnavn",
"MessageStartPlaybackAtTime": "Start afspilning for \"{0}\" kl. {1}?", "MessageStartPlaybackAtTime": "Start afspilning for \"{0}\" kl. {1}?",
@@ -656,7 +668,6 @@
"NoteChangeRootPassword": "Root-brugeren er den eneste bruger, der kan have en tom adgangskode", "NoteChangeRootPassword": "Root-brugeren er den eneste bruger, der kan have en tom adgangskode",
"NoteChapterEditorTimes": "Bemærk: Første kapitel starttidspunkt skal forblive kl. 0:00, og det sidste kapitel starttidspunkt må ikke overstige denne lydbogs varighed.", "NoteChapterEditorTimes": "Bemærk: Første kapitel starttidspunkt skal forblive kl. 0:00, og det sidste kapitel starttidspunkt må ikke overstige denne lydbogs varighed.",
"NoteFolderPicker": "Bemærk: Mapper, der allerede er mappet, vises ikke", "NoteFolderPicker": "Bemærk: Mapper, der allerede er mappet, vises ikke",
"NoteFolderPickerDebian": "Bemærk: Mappicker for Debian-installationen er ikke fuldt implementeret. Du bør indtaste stien til dit bibliotek direkte.",
"NoteRSSFeedPodcastAppsHttps": "Advarsel: De fleste podcast-apps kræver, at RSS-feedets URL bruger HTTPS", "NoteRSSFeedPodcastAppsHttps": "Advarsel: De fleste podcast-apps kræver, at RSS-feedets URL bruger HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Advarsel: En eller flere af dine episoder har ikke en Pub Date. Nogle podcast-apps kræver dette.", "NoteRSSFeedPodcastAppsPubDate": "Advarsel: En eller flere af dine episoder har ikke en Pub Date. Nogle podcast-apps kræver dette.",
"NoteUploaderFoldersWithMediaFiles": "Mapper med mediefiler håndteres som separate bibliotekselementer.", "NoteUploaderFoldersWithMediaFiles": "Mapper med mediefiler håndteres som separate bibliotekselementer.",
+22 -11
View File
@@ -1,10 +1,10 @@
{ {
"ButtonAdd": "Hinzufügen", "ButtonAdd": "Hinzufügen",
"ButtonAddChapters": "Kapitel hinzufügen", "ButtonAddChapters": "Kapitel hinzufügen",
"ButtonAddDevice": "Add Device", "ButtonAddDevice": "Gerät hinzufügen",
"ButtonAddLibrary": "Add Library", "ButtonAddLibrary": "Bibliothek hinzufügen",
"ButtonAddPodcasts": "Podcasts hinzufügen", "ButtonAddPodcasts": "Podcasts hinzufügen",
"ButtonAddUser": "Add User", "ButtonAddUser": "Benutzer hinzufügen",
"ButtonAddYourFirstLibrary": "Erstelle deine erste Bibliothek", "ButtonAddYourFirstLibrary": "Erstelle deine erste Bibliothek",
"ButtonApply": "Übernehmen", "ButtonApply": "Übernehmen",
"ButtonApplyChapters": "Kapitel anwenden", "ButtonApplyChapters": "Kapitel anwenden",
@@ -58,11 +58,11 @@
"ButtonRemoveAll": "Alles löschen", "ButtonRemoveAll": "Alles löschen",
"ButtonRemoveAllLibraryItems": "Lösche alle Bibliothekseinträge", "ButtonRemoveAllLibraryItems": "Lösche alle Bibliothekseinträge",
"ButtonRemoveFromContinueListening": "Lösche den Eintrag aus der Fortsetzungsliste", "ButtonRemoveFromContinueListening": "Lösche den Eintrag aus der Fortsetzungsliste",
"ButtonRemoveFromContinueReading": "Remove from Continue Reading", "ButtonRemoveFromContinueReading": "Lösche die Serie aus der Lesefortsetzungsliste",
"ButtonRemoveSeriesFromContinueSeries": "Lösche die Serie aus der Serienfortsetzungsliste", "ButtonRemoveSeriesFromContinueSeries": "Lösche die Serie aus der Serienfortsetzungsliste",
"ButtonReScan": "Neu scannen", "ButtonReScan": "Neu scannen",
"ButtonReset": "Zurücksetzen", "ButtonReset": "Zurücksetzen",
"ButtonResetToDefault": "Reset to default", "ButtonResetToDefault": "Zurücksetzen auf Standard",
"ButtonRestore": "Wiederherstellen", "ButtonRestore": "Wiederherstellen",
"ButtonSave": "Speichern", "ButtonSave": "Speichern",
"ButtonSaveAndClose": "Speichern & Schließen", "ButtonSaveAndClose": "Speichern & Schließen",
@@ -87,6 +87,9 @@
"ButtonUserEdit": "Benutzer {0} bearbeiten", "ButtonUserEdit": "Benutzer {0} bearbeiten",
"ButtonViewAll": "Alles anzeigen", "ButtonViewAll": "Alles anzeigen",
"ButtonYes": "Ja", "ButtonYes": "Ja",
"ErrorUploadFetchMetadataAPI": "Fehler beim Abrufen der Metadaten",
"ErrorUploadFetchMetadataNoResults": "Metadaten konnten nicht abgerufen werden. Versuchen Sie den Titel und oder den Autor zu updaten",
"ErrorUploadLacksTitle": "Es muss ein Titel eingegeben werden",
"HeaderAccount": "Konto", "HeaderAccount": "Konto",
"HeaderAdvanced": "Erweitert", "HeaderAdvanced": "Erweitert",
"HeaderAppriseNotificationSettings": "Apprise Benachrichtigungseinstellungen", "HeaderAppriseNotificationSettings": "Apprise Benachrichtigungseinstellungen",
@@ -196,6 +199,8 @@
"LabelAuthorLastFirst": "Autor (Nachname, Vorname)", "LabelAuthorLastFirst": "Autor (Nachname, Vorname)",
"LabelAuthors": "Autoren", "LabelAuthors": "Autoren",
"LabelAutoDownloadEpisodes": "Episoden automatisch herunterladen", "LabelAutoDownloadEpisodes": "Episoden automatisch herunterladen",
"LabelAutoFetchMetadata": "Automatisches Abholen der Metadaten",
"LabelAutoFetchMetadataHelp": "Abholen der Metadaten von Titel, Autor und Serien, um das Hochladen zu optimieren. Möglicherweise müssen zusätzliche Metadaten nach dem Hochladen abgeglichen werden.",
"LabelAutoLaunch": "Automatischer Start", "LabelAutoLaunch": "Automatischer Start",
"LabelAutoLaunchDescription": "Automatische Weiterleitung zum Authentifizierungsanbieter beim Navigieren zur Anmeldeseite (manueller Überschreibungspfad <code>/login?autoLaunch=0</code>)", "LabelAutoLaunchDescription": "Automatische Weiterleitung zum Authentifizierungsanbieter beim Navigieren zur Anmeldeseite (manueller Überschreibungspfad <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Automatische Registrierung", "LabelAutoRegister": "Automatische Registrierung",
@@ -216,7 +221,7 @@
"LabelChapters": "Kapitel", "LabelChapters": "Kapitel",
"LabelChaptersFound": "gefundene Kapitel", "LabelChaptersFound": "gefundene Kapitel",
"LabelChapterTitle": "Kapitelüberschrift", "LabelChapterTitle": "Kapitelüberschrift",
"LabelClickForMoreInfo": "Click for more info", "LabelClickForMoreInfo": "Klicken für mehr Informationen",
"LabelClosePlayer": "Player schließen", "LabelClosePlayer": "Player schließen",
"LabelCodec": "Codec", "LabelCodec": "Codec",
"LabelCollapseSeries": "Serien zusammenfassen", "LabelCollapseSeries": "Serien zusammenfassen",
@@ -246,7 +251,7 @@
"LabelDiscFromMetadata": "CD aus den Metadaten", "LabelDiscFromMetadata": "CD aus den Metadaten",
"LabelDiscover": "Entdecken", "LabelDiscover": "Entdecken",
"LabelDownload": "Herunterladen", "LabelDownload": "Herunterladen",
"LabelDownloadNEpisodes": "Download {0} episodes", "LabelDownloadNEpisodes": "Download {0} Episoden",
"LabelDuration": "Laufzeit", "LabelDuration": "Laufzeit",
"LabelDurationFound": "Gefundene Laufzeit:", "LabelDurationFound": "Gefundene Laufzeit:",
"LabelEbook": "E-Book", "LabelEbook": "E-Book",
@@ -266,6 +271,7 @@
"LabelExample": "Beispiel", "LabelExample": "Beispiel",
"LabelExplicit": "Explizit (Altersbeschränkung)", "LabelExplicit": "Explizit (Altersbeschränkung)",
"LabelFeedURL": "Feed URL", "LabelFeedURL": "Feed URL",
"LabelFetchingMetadata": "Abholen der Metadaten",
"LabelFile": "Datei", "LabelFile": "Datei",
"LabelFileBirthtime": "Datei erstellt", "LabelFileBirthtime": "Datei erstellt",
"LabelFileModified": "Datei geändert", "LabelFileModified": "Datei geändert",
@@ -283,7 +289,7 @@
"LabelHardDeleteFile": "Datei dauerhaft löschen", "LabelHardDeleteFile": "Datei dauerhaft löschen",
"LabelHasEbook": "mit E-Book", "LabelHasEbook": "mit E-Book",
"LabelHasSupplementaryEbook": "mit zusätlichem E-Book", "LabelHasSupplementaryEbook": "mit zusätlichem E-Book",
"LabelHighestPriority": "Highest priority", "LabelHighestPriority": "Höchste Priorität",
"LabelHost": "Host", "LabelHost": "Host",
"LabelHour": "Stunde", "LabelHour": "Stunde",
"LabelIcon": "Symbol", "LabelIcon": "Symbol",
@@ -325,18 +331,20 @@
"LabelLogLevelInfo": "Informationen", "LabelLogLevelInfo": "Informationen",
"LabelLogLevelWarn": "Warnungen", "LabelLogLevelWarn": "Warnungen",
"LabelLookForNewEpisodesAfterDate": "Suchen nach neuen Episoden nach diesem Datum", "LabelLookForNewEpisodesAfterDate": "Suchen nach neuen Episoden nach diesem Datum",
"LabelLowestPriority": "Lowest Priority", "LabelLowestPriority": "Niedrigste Priorität",
"LabelMatchExistingUsersBy": "Zuordnen existierender Benutzer mit", "LabelMatchExistingUsersBy": "Zuordnen existierender Benutzer mit",
"LabelMatchExistingUsersByDescription": "Wird zum Verbinden vorhandener Benutzer verwendet. Sobald die Verbindung hergestellt ist, wird den Benutzern eine eindeutige ID von Ihrem SSO-Anbieter zugeordnet", "LabelMatchExistingUsersByDescription": "Wird zum Verbinden vorhandener Benutzer verwendet. Sobald die Verbindung hergestellt ist, wird den Benutzern eine eindeutige ID von Ihrem SSO-Anbieter zugeordnet",
"LabelMediaPlayer": "Mediaplayer", "LabelMediaPlayer": "Mediaplayer",
"LabelMediaType": "Medientyp", "LabelMediaType": "Medientyp",
"LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources", "LabelMetadataOrderOfPrecedenceDescription": "Eine Höhere Priorität Quelle für Metadaten wird die Metadaten aus eine Quelle mit niedrigerer Priorität überschreiben.",
"LabelMetadataProvider": "Metadatenanbieter", "LabelMetadataProvider": "Metadatenanbieter",
"LabelMetaTag": "Meta Schlagwort", "LabelMetaTag": "Meta Schlagwort",
"LabelMetaTags": "Meta Tags", "LabelMetaTags": "Meta Tags",
"LabelMinute": "Minute", "LabelMinute": "Minute",
"LabelMissing": "Fehlend", "LabelMissing": "Fehlend",
"LabelMissingParts": "Fehlende Teile", "LabelMissingParts": "Fehlende Teile",
"LabelMobileRedirectURIs": "Erlaubte Weiterleitungs-URIs für die mobile App",
"LabelMobileRedirectURIsDescription": "Dies ist eine Whitelist gültiger Umleitungs-URIs für mobile Apps. Der Standardwert ist <code>audiobookshelf://oauth</code>, den Sie entfernen oder durch zusätzliche URIs für die Integration von Drittanbieter-Apps ergänzen können. Die Verwendung eines Sternchens (<code>*</code>) als alleiniger Eintrag erlaubt jede URI.",
"LabelMore": "Mehr", "LabelMore": "Mehr",
"LabelMoreInfo": "Mehr Info", "LabelMoreInfo": "Mehr Info",
"LabelName": "Name", "LabelName": "Name",
@@ -398,6 +406,7 @@
"LabelRegion": "Region", "LabelRegion": "Region",
"LabelReleaseDate": "Veröffentlichungsdatum", "LabelReleaseDate": "Veröffentlichungsdatum",
"LabelRemoveCover": "Lösche Titelbild", "LabelRemoveCover": "Lösche Titelbild",
"LabelRowsPerPage": "Zeilen pro Seite",
"LabelRSSFeedCustomOwnerEmail": "Benutzerdefinierte Eigentümer-E-Mail", "LabelRSSFeedCustomOwnerEmail": "Benutzerdefinierte Eigentümer-E-Mail",
"LabelRSSFeedCustomOwnerName": "Benutzerdefinierter Name des Eigentümers", "LabelRSSFeedCustomOwnerName": "Benutzerdefinierter Name des Eigentümers",
"LabelRSSFeedOpen": "RSS Feed Offen", "LabelRSSFeedOpen": "RSS Feed Offen",
@@ -515,6 +524,7 @@
"LabelUpdateDetailsHelp": "Erlaube das Überschreiben bestehender Details für die ausgewählten Hörbücher wenn eine Übereinstimmung gefunden wird", "LabelUpdateDetailsHelp": "Erlaube das Überschreiben bestehender Details für die ausgewählten Hörbücher wenn eine Übereinstimmung gefunden wird",
"LabelUploaderDragAndDrop": "Ziehen und Ablegen von Dateien oder Ordnern", "LabelUploaderDragAndDrop": "Ziehen und Ablegen von Dateien oder Ordnern",
"LabelUploaderDropFiles": "Dateien löschen", "LabelUploaderDropFiles": "Dateien löschen",
"LabelUploaderItemFetchMetadataHelp": "Automatisches Abholden von Titel, Author und Serien",
"LabelUseChapterTrack": "Kapiteldatei verwenden", "LabelUseChapterTrack": "Kapiteldatei verwenden",
"LabelUseFullTrack": "Gesamte Datei verwenden", "LabelUseFullTrack": "Gesamte Datei verwenden",
"LabelUser": "Benutzer", "LabelUser": "Benutzer",
@@ -562,6 +572,7 @@
"MessageConfirmRemoveCollection": "Sammlung \"{0}\" wird gelöscht! Sind Sie sicher?", "MessageConfirmRemoveCollection": "Sammlung \"{0}\" wird gelöscht! Sind Sie sicher?",
"MessageConfirmRemoveEpisode": "Episode \"{0}\" wird geloscht! Sind Sie sicher?", "MessageConfirmRemoveEpisode": "Episode \"{0}\" wird geloscht! Sind Sie sicher?",
"MessageConfirmRemoveEpisodes": "{0} Episoden werden gelöscht! Sind Sie sicher?", "MessageConfirmRemoveEpisodes": "{0} Episoden werden gelöscht! Sind Sie sicher?",
"MessageConfirmRemoveListeningSessions": "Sind Sie sicher, dass sie {0} Hörsitzungen enfernen möchten?",
"MessageConfirmRemoveNarrator": "Erzähler \"{0}\" wird gelöscht! Sind Sie sicher?", "MessageConfirmRemoveNarrator": "Erzähler \"{0}\" wird gelöscht! Sind Sie sicher?",
"MessageConfirmRemovePlaylist": "Wiedergabeliste \"{0}\" wird entfernt! Sind Sie sicher?", "MessageConfirmRemovePlaylist": "Wiedergabeliste \"{0}\" wird entfernt! Sind Sie sicher?",
"MessageConfirmRenameGenre": "Kategorie \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts werden umbenannt! Sind Sie sicher?", "MessageConfirmRenameGenre": "Kategorie \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts werden umbenannt! Sind Sie sicher?",
@@ -641,6 +652,7 @@
"MessageRestoreBackupConfirm": "Sind Sie sicher, dass Sie die Sicherung wiederherstellen wollen, welche am", "MessageRestoreBackupConfirm": "Sind Sie sicher, dass Sie die Sicherung wiederherstellen wollen, welche am",
"MessageRestoreBackupWarning": "Bei der Wiederherstellung einer Sicherung wird die gesamte Datenbank unter /config und die Titelbilder in /metadata/items und /metadata/authors überschrieben.<br /><br />Bei der Sicherung werden keine Dateien in Ihren Bibliotheksordnern verändert. Wenn Sie die Servereinstellungen aktiviert haben, um Cover und Metadaten in Ihren Bibliotheksordnern zu speichern, werden diese nicht gesichert oder überschrieben.<br /><br />Alle Clients, die Ihren Server nutzen, werden automatisch aktualisiert.", "MessageRestoreBackupWarning": "Bei der Wiederherstellung einer Sicherung wird die gesamte Datenbank unter /config und die Titelbilder in /metadata/items und /metadata/authors überschrieben.<br /><br />Bei der Sicherung werden keine Dateien in Ihren Bibliotheksordnern verändert. Wenn Sie die Servereinstellungen aktiviert haben, um Cover und Metadaten in Ihren Bibliotheksordnern zu speichern, werden diese nicht gesichert oder überschrieben.<br /><br />Alle Clients, die Ihren Server nutzen, werden automatisch aktualisiert.",
"MessageSearchResultsFor": "Suchergebnisse für", "MessageSearchResultsFor": "Suchergebnisse für",
"MessageSelected": "{0} ausgewählt",
"MessageServerCouldNotBeReached": "Server kann nicht erreicht werden", "MessageServerCouldNotBeReached": "Server kann nicht erreicht werden",
"MessageSetChaptersFromTracksDescription": "Kaitelerstellung basiert auf den existierenden einzelnen Audiodateien. Pro existierende Audiodatei wird 1 Kapitel erstellt, wobei deren Kapitelname aus dem Audiodateinamen extrahiert wird", "MessageSetChaptersFromTracksDescription": "Kaitelerstellung basiert auf den existierenden einzelnen Audiodateien. Pro existierende Audiodatei wird 1 Kapitel erstellt, wobei deren Kapitelname aus dem Audiodateinamen extrahiert wird",
"MessageStartPlaybackAtTime": "Start der Wiedergabe für \"{0}\" bei {1}?", "MessageStartPlaybackAtTime": "Start der Wiedergabe für \"{0}\" bei {1}?",
@@ -656,7 +668,6 @@
"NoteChangeRootPassword": "Der Root-Benutzer (Hauptbenutzer) ist der einzige Benutzer, der ein leeres Passwort haben kann", "NoteChangeRootPassword": "Der Root-Benutzer (Hauptbenutzer) ist der einzige Benutzer, der ein leeres Passwort haben kann",
"NoteChapterEditorTimes": "Hinweis: Die Anfangszeit des ersten Kapitels muss bei 0:00 beginnen und die Anfangszeit des letzten Kapitels darf die Dauer des Mediums nicht überschreiten.", "NoteChapterEditorTimes": "Hinweis: Die Anfangszeit des ersten Kapitels muss bei 0:00 beginnen und die Anfangszeit des letzten Kapitels darf die Dauer des Mediums nicht überschreiten.",
"NoteFolderPicker": "Hinweis: Bereits zugeordnete Ordner werden nicht angezeigt.", "NoteFolderPicker": "Hinweis: Bereits zugeordnete Ordner werden nicht angezeigt.",
"NoteFolderPickerDebian": "Hinweis: Der Ordnerauswahldialog für die Debian-Installation ist nicht vollständig implementiert. Sie sollten den Pfad zu Ihrer Bibliothek direkt eingeben.",
"NoteRSSFeedPodcastAppsHttps": "Warnung: Die meisten Podcast-Apps verlangen, dass die URL des RSS-Feeds HTTPS verwendet.", "NoteRSSFeedPodcastAppsHttps": "Warnung: Die meisten Podcast-Apps verlangen, dass die URL des RSS-Feeds HTTPS verwendet.",
"NoteRSSFeedPodcastAppsPubDate": "Warnung: 1 oder mehrere Ihrer Episoden haben kein Veröffentlichungsdatum. Einige Podcast-Apps verlangen dies.", "NoteRSSFeedPodcastAppsPubDate": "Warnung: 1 oder mehrere Ihrer Episoden haben kein Veröffentlichungsdatum. Einige Podcast-Apps verlangen dies.",
"NoteUploaderFoldersWithMediaFiles": "Ordner mit Mediendateien werden als separate Bibliothekselemente behandelt.", "NoteUploaderFoldersWithMediaFiles": "Ordner mit Mediendateien werden als separate Bibliothekselemente behandelt.",
+12 -1
View File
@@ -87,6 +87,9 @@
"ButtonUserEdit": "Edit user {0}", "ButtonUserEdit": "Edit user {0}",
"ButtonViewAll": "View All", "ButtonViewAll": "View All",
"ButtonYes": "Yes", "ButtonYes": "Yes",
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
"ErrorUploadLacksTitle": "Must have a title",
"HeaderAccount": "Account", "HeaderAccount": "Account",
"HeaderAdvanced": "Advanced", "HeaderAdvanced": "Advanced",
"HeaderAppriseNotificationSettings": "Apprise Notification Settings", "HeaderAppriseNotificationSettings": "Apprise Notification Settings",
@@ -196,6 +199,8 @@
"LabelAuthorLastFirst": "Author (Last, First)", "LabelAuthorLastFirst": "Author (Last, First)",
"LabelAuthors": "Authors", "LabelAuthors": "Authors",
"LabelAutoDownloadEpisodes": "Auto Download Episodes", "LabelAutoDownloadEpisodes": "Auto Download Episodes",
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
"LabelAutoLaunch": "Auto Launch", "LabelAutoLaunch": "Auto Launch",
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Auto Register", "LabelAutoRegister": "Auto Register",
@@ -266,6 +271,7 @@
"LabelExample": "Example", "LabelExample": "Example",
"LabelExplicit": "Explicit", "LabelExplicit": "Explicit",
"LabelFeedURL": "Feed URL", "LabelFeedURL": "Feed URL",
"LabelFetchingMetadata": "Fetching Metadata",
"LabelFile": "File", "LabelFile": "File",
"LabelFileBirthtime": "File Birthtime", "LabelFileBirthtime": "File Birthtime",
"LabelFileModified": "File Modified", "LabelFileModified": "File Modified",
@@ -337,6 +343,8 @@
"LabelMinute": "Minute", "LabelMinute": "Minute",
"LabelMissing": "Missing", "LabelMissing": "Missing",
"LabelMissingParts": "Missing Parts", "LabelMissingParts": "Missing Parts",
"LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs",
"LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.",
"LabelMore": "More", "LabelMore": "More",
"LabelMoreInfo": "More Info", "LabelMoreInfo": "More Info",
"LabelName": "Name", "LabelName": "Name",
@@ -398,6 +406,7 @@
"LabelRegion": "Region", "LabelRegion": "Region",
"LabelReleaseDate": "Release Date", "LabelReleaseDate": "Release Date",
"LabelRemoveCover": "Remove cover", "LabelRemoveCover": "Remove cover",
"LabelRowsPerPage": "Rows per page",
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email", "LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
"LabelRSSFeedCustomOwnerName": "Custom owner Name", "LabelRSSFeedCustomOwnerName": "Custom owner Name",
"LabelRSSFeedOpen": "RSS Feed Open", "LabelRSSFeedOpen": "RSS Feed Open",
@@ -515,6 +524,7 @@
"LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located", "LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located",
"LabelUploaderDragAndDrop": "Drag & drop files or folders", "LabelUploaderDragAndDrop": "Drag & drop files or folders",
"LabelUploaderDropFiles": "Drop files", "LabelUploaderDropFiles": "Drop files",
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
"LabelUseChapterTrack": "Use chapter track", "LabelUseChapterTrack": "Use chapter track",
"LabelUseFullTrack": "Use full track", "LabelUseFullTrack": "Use full track",
"LabelUser": "User", "LabelUser": "User",
@@ -562,6 +572,7 @@
"MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?", "MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?",
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?", "MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?", "MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?", "MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?", "MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?", "MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
@@ -641,6 +652,7 @@
"MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on", "MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on",
"MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.", "MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.",
"MessageSearchResultsFor": "Search results for", "MessageSearchResultsFor": "Search results for",
"MessageSelected": "{0} selected",
"MessageServerCouldNotBeReached": "Server could not be reached", "MessageServerCouldNotBeReached": "Server could not be reached",
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name", "MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
"MessageStartPlaybackAtTime": "Start playback for \"{0}\" at {1}?", "MessageStartPlaybackAtTime": "Start playback for \"{0}\" at {1}?",
@@ -656,7 +668,6 @@
"NoteChangeRootPassword": "Root user is the only user that can have an empty password", "NoteChangeRootPassword": "Root user is the only user that can have an empty password",
"NoteChapterEditorTimes": "Note: First chapter start time must remain at 0:00 and the last chapter start time cannot exceed this audiobooks duration.", "NoteChapterEditorTimes": "Note: First chapter start time must remain at 0:00 and the last chapter start time cannot exceed this audiobooks duration.",
"NoteFolderPicker": "Note: folders already mapped will not be shown", "NoteFolderPicker": "Note: folders already mapped will not be shown",
"NoteFolderPickerDebian": "Note: Folder picker for the debian install is not fully implemented. You should enter the path to your library directly.",
"NoteRSSFeedPodcastAppsHttps": "Warning: Most podcast apps will require the RSS feed URL is using HTTPS", "NoteRSSFeedPodcastAppsHttps": "Warning: Most podcast apps will require the RSS feed URL is using HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.", "NoteRSSFeedPodcastAppsPubDate": "Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.",
"NoteUploaderFoldersWithMediaFiles": "Folders with media files will be handled as separate library items.", "NoteUploaderFoldersWithMediaFiles": "Folders with media files will be handled as separate library items.",
+12 -1
View File
@@ -87,6 +87,9 @@
"ButtonUserEdit": "Editar Usuario {0}", "ButtonUserEdit": "Editar Usuario {0}",
"ButtonViewAll": "Ver Todos", "ButtonViewAll": "Ver Todos",
"ButtonYes": "Aceptar", "ButtonYes": "Aceptar",
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
"ErrorUploadLacksTitle": "Must have a title",
"HeaderAccount": "Cuenta", "HeaderAccount": "Cuenta",
"HeaderAdvanced": "Avanzado", "HeaderAdvanced": "Avanzado",
"HeaderAppriseNotificationSettings": "Ajustes de Notificaciones de Apprise", "HeaderAppriseNotificationSettings": "Ajustes de Notificaciones de Apprise",
@@ -196,6 +199,8 @@
"LabelAuthorLastFirst": "Autor (Apellido, Nombre)", "LabelAuthorLastFirst": "Autor (Apellido, Nombre)",
"LabelAuthors": "Autores", "LabelAuthors": "Autores",
"LabelAutoDownloadEpisodes": "Descargar Episodios Automáticamente", "LabelAutoDownloadEpisodes": "Descargar Episodios Automáticamente",
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
"LabelAutoLaunch": "Auto Launch", "LabelAutoLaunch": "Auto Launch",
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Auto Register", "LabelAutoRegister": "Auto Register",
@@ -266,6 +271,7 @@
"LabelExample": "Ejemplo", "LabelExample": "Ejemplo",
"LabelExplicit": "Explicito", "LabelExplicit": "Explicito",
"LabelFeedURL": "Fuente de URL", "LabelFeedURL": "Fuente de URL",
"LabelFetchingMetadata": "Fetching Metadata",
"LabelFile": "Archivo", "LabelFile": "Archivo",
"LabelFileBirthtime": "Archivo Creado en", "LabelFileBirthtime": "Archivo Creado en",
"LabelFileModified": "Archivo modificado", "LabelFileModified": "Archivo modificado",
@@ -337,6 +343,8 @@
"LabelMinute": "Minuto", "LabelMinute": "Minuto",
"LabelMissing": "Ausente", "LabelMissing": "Ausente",
"LabelMissingParts": "Partes Ausentes", "LabelMissingParts": "Partes Ausentes",
"LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs",
"LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.",
"LabelMore": "Más", "LabelMore": "Más",
"LabelMoreInfo": "Más Información", "LabelMoreInfo": "Más Información",
"LabelName": "Nombre", "LabelName": "Nombre",
@@ -398,6 +406,7 @@
"LabelRegion": "Región", "LabelRegion": "Región",
"LabelReleaseDate": "Fecha de Estreno", "LabelReleaseDate": "Fecha de Estreno",
"LabelRemoveCover": "Remover Portada", "LabelRemoveCover": "Remover Portada",
"LabelRowsPerPage": "Rows per page",
"LabelRSSFeedCustomOwnerEmail": "Email de dueño personalizado", "LabelRSSFeedCustomOwnerEmail": "Email de dueño personalizado",
"LabelRSSFeedCustomOwnerName": "Nombre de dueño personalizado", "LabelRSSFeedCustomOwnerName": "Nombre de dueño personalizado",
"LabelRSSFeedOpen": "Fuente RSS Abierta", "LabelRSSFeedOpen": "Fuente RSS Abierta",
@@ -515,6 +524,7 @@
"LabelUpdateDetailsHelp": "Permitir sobrescribir detalles existentes de los libros seleccionados cuando sean encontrados", "LabelUpdateDetailsHelp": "Permitir sobrescribir detalles existentes de los libros seleccionados cuando sean encontrados",
"LabelUploaderDragAndDrop": "Arrastre y suelte archivos o carpetas", "LabelUploaderDragAndDrop": "Arrastre y suelte archivos o carpetas",
"LabelUploaderDropFiles": "Suelte los Archivos", "LabelUploaderDropFiles": "Suelte los Archivos",
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
"LabelUseChapterTrack": "Usar pista por capitulo", "LabelUseChapterTrack": "Usar pista por capitulo",
"LabelUseFullTrack": "Usar pista completa", "LabelUseFullTrack": "Usar pista completa",
"LabelUser": "Usuario", "LabelUser": "Usuario",
@@ -562,6 +572,7 @@
"MessageConfirmRemoveCollection": "¿Está seguro de que desea remover la colección \"{0}\"?", "MessageConfirmRemoveCollection": "¿Está seguro de que desea remover la colección \"{0}\"?",
"MessageConfirmRemoveEpisode": "¿Está seguro de que desea remover el episodio \"{0}\"?", "MessageConfirmRemoveEpisode": "¿Está seguro de que desea remover el episodio \"{0}\"?",
"MessageConfirmRemoveEpisodes": "¿Está seguro de que desea remover {0} episodios?", "MessageConfirmRemoveEpisodes": "¿Está seguro de que desea remover {0} episodios?",
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
"MessageConfirmRemoveNarrator": "¿Está seguro de que desea remover el narrador \"{0}\"?", "MessageConfirmRemoveNarrator": "¿Está seguro de que desea remover el narrador \"{0}\"?",
"MessageConfirmRemovePlaylist": "¿Está seguro de que desea remover la lista de reproducción \"{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?", "MessageConfirmRenameGenre": "¿Está seguro de que desea renombrar el genero \"{0}\" a \"{1}\" de todos los elementos?",
@@ -641,6 +652,7 @@
"MessageRestoreBackupConfirm": "¿Está seguro de que desea para restaurar del respaldo creado en", "MessageRestoreBackupConfirm": "¿Está seguro de que desea para restaurar del respaldo creado en",
"MessageRestoreBackupWarning": "Restaurar sobrescribirá toda la base de datos localizada en /config y las imágenes de portadas en /metadata/items y /metadata/authors.<br /><br />El respaldo no modifica ningún archivo en las carpetas de su biblioteca. Si ha habilitado la opción del servidor para almacenar portadas y metadata en las carpetas de su biblioteca, esos archivos no se respaldan o sobrescriben.<br /><br />Todos los clientes que usen su servidor se actualizarán automáticamente.", "MessageRestoreBackupWarning": "Restaurar sobrescribirá toda la base de datos localizada en /config y las imágenes de portadas en /metadata/items y /metadata/authors.<br /><br />El respaldo no modifica ningún archivo en las carpetas de su biblioteca. Si ha habilitado la opción del servidor para almacenar portadas y metadata en las carpetas de su biblioteca, esos archivos no se respaldan o sobrescriben.<br /><br />Todos los clientes que usen su servidor se actualizarán automáticamente.",
"MessageSearchResultsFor": "Resultados de la búsqueda de", "MessageSearchResultsFor": "Resultados de la búsqueda de",
"MessageSelected": "{0} selected",
"MessageServerCouldNotBeReached": "No se pudo establecer la conexión con el servidor", "MessageServerCouldNotBeReached": "No se pudo establecer la conexión con el servidor",
"MessageSetChaptersFromTracksDescription": "Establecer capítulos usando cada archivo de audio como un capítulo y el título del capítulo como el nombre del archivo de audio", "MessageSetChaptersFromTracksDescription": "Establecer capítulos usando cada archivo de audio como un capítulo y el título del capítulo como el nombre del archivo de audio",
"MessageStartPlaybackAtTime": "Iniciar reproducción para \"{0}\" en {1}?", "MessageStartPlaybackAtTime": "Iniciar reproducción para \"{0}\" en {1}?",
@@ -656,7 +668,6 @@
"NoteChangeRootPassword": "El usuario Root es el único usuario que puede no tener una contraseña", "NoteChangeRootPassword": "El usuario Root es el único usuario que puede no tener una contraseña",
"NoteChapterEditorTimes": "Nota: El tiempo de inicio del primer capítulo debe permanecer en 0:00, y el tiempo de inicio del último capítulo no puede exceder la duración del audiolibro.", "NoteChapterEditorTimes": "Nota: El tiempo de inicio del primer capítulo debe permanecer en 0:00, y el tiempo de inicio del último capítulo no puede exceder la duración del audiolibro.",
"NoteFolderPicker": "Nota: Las carpetas ya asignadas no se mostrarán", "NoteFolderPicker": "Nota: Las carpetas ya asignadas no se mostrarán",
"NoteFolderPickerDebian": "Nota: El selector de archivos no está completamente implementado para instalaciones en Debian. Deberá ingresar la ruta de la carpeta de su biblioteca directamente.",
"NoteRSSFeedPodcastAppsHttps": "Advertencia: La mayoría de las aplicaciones de podcast requieren que la URL de la fuente RSS use HTTPS", "NoteRSSFeedPodcastAppsHttps": "Advertencia: La mayoría de las aplicaciones de podcast requieren que la URL de la fuente RSS use HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Advertencia: 1 o más de sus episodios no tienen fecha de publicación. Algunas aplicaciones de podcast lo requieren.", "NoteRSSFeedPodcastAppsPubDate": "Advertencia: 1 o más de sus episodios no tienen fecha de publicación. Algunas aplicaciones de podcast lo requieren.",
"NoteUploaderFoldersWithMediaFiles": "Las carpetas con archivos multimedia se manejarán como elementos separados en la biblioteca.", "NoteUploaderFoldersWithMediaFiles": "Las carpetas con archivos multimedia se manejarán como elementos separados en la biblioteca.",
+117 -106
View File
@@ -1,10 +1,10 @@
{ {
"ButtonAdd": "Ajouter", "ButtonAdd": "Ajouter",
"ButtonAddChapters": "Ajouter le chapitre", "ButtonAddChapters": "Ajouter le chapitre",
"ButtonAddDevice": "Add Device", "ButtonAddDevice": "Ajouter un appareil",
"ButtonAddLibrary": "Add Library", "ButtonAddLibrary": "Ajouter une bibliothèque",
"ButtonAddPodcasts": "Ajouter des podcasts", "ButtonAddPodcasts": "Ajouter des podcasts",
"ButtonAddUser": "Add User", "ButtonAddUser": "Ajouter un utilisateur",
"ButtonAddYourFirstLibrary": "Ajouter votre première bibliothèque", "ButtonAddYourFirstLibrary": "Ajouter votre première bibliothèque",
"ButtonApply": "Appliquer", "ButtonApply": "Appliquer",
"ButtonApplyChapters": "Appliquer les chapitres", "ButtonApplyChapters": "Appliquer les chapitres",
@@ -62,7 +62,7 @@
"ButtonRemoveSeriesFromContinueSeries": "Ne plus continuer à écouter la série", "ButtonRemoveSeriesFromContinueSeries": "Ne plus continuer à écouter la série",
"ButtonReScan": "Nouvelle analyse", "ButtonReScan": "Nouvelle analyse",
"ButtonReset": "Réinitialiser", "ButtonReset": "Réinitialiser",
"ButtonResetToDefault": "Reset to default", "ButtonResetToDefault": "Réinitialiser aux valeurs par défaut",
"ButtonRestore": "Rétablir", "ButtonRestore": "Rétablir",
"ButtonSave": "Sauvegarder", "ButtonSave": "Sauvegarder",
"ButtonSaveAndClose": "Sauvegarder et Fermer", "ButtonSaveAndClose": "Sauvegarder et Fermer",
@@ -87,6 +87,9 @@
"ButtonUserEdit": "Modifier lutilisateur {0}", "ButtonUserEdit": "Modifier lutilisateur {0}",
"ButtonViewAll": "Afficher tout", "ButtonViewAll": "Afficher tout",
"ButtonYes": "Oui", "ButtonYes": "Oui",
"ErrorUploadFetchMetadataAPI": "Erreur lors de la récupération des métadonnées",
"ErrorUploadFetchMetadataNoResults": "Impossible de récupérer les métadonnées - essayez de mettre à jour le titre et/ou lauteur.",
"ErrorUploadLacksTitle": "Doit avoir un titre",
"HeaderAccount": "Compte", "HeaderAccount": "Compte",
"HeaderAdvanced": "Avancé", "HeaderAdvanced": "Avancé",
"HeaderAppriseNotificationSettings": "Configuration des Notifications Apprise", "HeaderAppriseNotificationSettings": "Configuration des Notifications Apprise",
@@ -98,7 +101,7 @@
"HeaderChapters": "Chapitres", "HeaderChapters": "Chapitres",
"HeaderChooseAFolder": "Choisir un dossier", "HeaderChooseAFolder": "Choisir un dossier",
"HeaderCollection": "Collection", "HeaderCollection": "Collection",
"HeaderCollectionItems": "Entrées de la Collection", "HeaderCollectionItems": "Entrées de la collection",
"HeaderCover": "Couverture", "HeaderCover": "Couverture",
"HeaderCurrentDownloads": "Téléchargements en cours", "HeaderCurrentDownloads": "Téléchargements en cours",
"HeaderDetails": "Détails", "HeaderDetails": "Détails",
@@ -111,10 +114,10 @@
"HeaderEreaderSettings": "Options Ereader", "HeaderEreaderSettings": "Options Ereader",
"HeaderFiles": "Fichiers", "HeaderFiles": "Fichiers",
"HeaderFindChapters": "Trouver les chapitres", "HeaderFindChapters": "Trouver les chapitres",
"HeaderIgnoredFiles": "Fichiers Ignorés", "HeaderIgnoredFiles": "Fichiers ignorés",
"HeaderItemFiles": "Fichiers des Articles", "HeaderItemFiles": "Fichiers des articles",
"HeaderItemMetadataUtils": "Outils de gestion des métadonnées", "HeaderItemMetadataUtils": "Outils de gestion des métadonnées",
"HeaderLastListeningSession": "Dernière Session d’écoute", "HeaderLastListeningSession": "Dernière session d’écoute",
"HeaderLatestEpisodes": "Dernier épisodes", "HeaderLatestEpisodes": "Dernier épisodes",
"HeaderLibraries": "Bibliothèque", "HeaderLibraries": "Bibliothèque",
"HeaderLibraryFiles": "Fichier de bibliothèque", "HeaderLibraryFiles": "Fichier de bibliothèque",
@@ -127,15 +130,15 @@
"HeaderManageTags": "Gérer les étiquettes", "HeaderManageTags": "Gérer les étiquettes",
"HeaderMapDetails": "Édition en masse", "HeaderMapDetails": "Édition en masse",
"HeaderMatch": "Chercher", "HeaderMatch": "Chercher",
"HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataOrderOfPrecedence": "Ordre de priorité des métadonnées",
"HeaderMetadataToEmbed": "Métadonnée à intégrer", "HeaderMetadataToEmbed": "Métadonnées à intégrer",
"HeaderNewAccount": "Nouveau compte", "HeaderNewAccount": "Nouveau compte",
"HeaderNewLibrary": "Nouvelle bibliothèque", "HeaderNewLibrary": "Nouvelle bibliothèque",
"HeaderNotifications": "Notifications", "HeaderNotifications": "Notifications",
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication", "HeaderOpenIDConnectAuthentication": "Authentification via OpenID Connect",
"HeaderOpenRSSFeed": "Ouvrir Flux RSS", "HeaderOpenRSSFeed": "Ouvrir un flux RSS",
"HeaderOtherFiles": "Autres fichiers", "HeaderOtherFiles": "Autres fichiers",
"HeaderPasswordAuthentication": "Password Authentication", "HeaderPasswordAuthentication": "Authentification par mot de passe",
"HeaderPermissions": "Permissions", "HeaderPermissions": "Permissions",
"HeaderPlayerQueue": "Liste d’écoute", "HeaderPlayerQueue": "Liste d’écoute",
"HeaderPlaylist": "Liste de lecture", "HeaderPlaylist": "Liste de lecture",
@@ -151,7 +154,7 @@
"HeaderSchedule": "Programmation", "HeaderSchedule": "Programmation",
"HeaderScheduleLibraryScans": "Analyse automatique de la bibliothèque", "HeaderScheduleLibraryScans": "Analyse automatique de la bibliothèque",
"HeaderSession": "Session", "HeaderSession": "Session",
"HeaderSetBackupSchedule": "Activer la Sauvegarde Automatique", "HeaderSetBackupSchedule": "Activer la sauvegarde automatique",
"HeaderSettings": "Paramètres", "HeaderSettings": "Paramètres",
"HeaderSettingsDisplay": "Affichage", "HeaderSettingsDisplay": "Affichage",
"HeaderSettingsExperimental": "Fonctionnalités expérimentales", "HeaderSettingsExperimental": "Fonctionnalités expérimentales",
@@ -184,11 +187,11 @@
"LabelAddToCollectionBatch": "Ajout de {0} livres à la lollection", "LabelAddToCollectionBatch": "Ajout de {0} livres à la lollection",
"LabelAddToPlaylist": "Ajouter à la liste de lecture", "LabelAddToPlaylist": "Ajouter à la liste de lecture",
"LabelAddToPlaylistBatch": "{0} éléments ajoutés à la liste de lecture", "LabelAddToPlaylistBatch": "{0} éléments ajoutés à la liste de lecture",
"LabelAdminUsersOnly": "Admin users only", "LabelAdminUsersOnly": "Administrateurs uniquement",
"LabelAll": "Tout", "LabelAll": "Tout",
"LabelAllUsers": "Tous les utilisateurs", "LabelAllUsers": "Tous les utilisateurs",
"LabelAllUsersExcludingGuests": "All users excluding guests", "LabelAllUsersExcludingGuests": "Tous les utilisateurs à lexception des invités",
"LabelAllUsersIncludingGuests": "All users including guests", "LabelAllUsersIncludingGuests": "Tous les utilisateurs, y compris les invités",
"LabelAlreadyInYourLibrary": "Déjà dans la bibliothèque", "LabelAlreadyInYourLibrary": "Déjà dans la bibliothèque",
"LabelAppend": "Ajouter", "LabelAppend": "Ajouter",
"LabelAuthor": "Auteur", "LabelAuthor": "Auteur",
@@ -196,27 +199,29 @@
"LabelAuthorLastFirst": "Auteur (Nom, Prénom)", "LabelAuthorLastFirst": "Auteur (Nom, Prénom)",
"LabelAuthors": "Auteurs", "LabelAuthors": "Auteurs",
"LabelAutoDownloadEpisodes": "Téléchargement automatique d’épisode", "LabelAutoDownloadEpisodes": "Téléchargement automatique d’épisode",
"LabelAutoLaunch": "Auto Launch", "LabelAutoFetchMetadata": "Recherche automatique de métadonnées",
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", "LabelAutoFetchMetadataHelp": "Récupère les métadonnées du titre, de lauteur et de la série pour simplifier le téléchargement. Il se peut que des métadonnées supplémentaires doivent être ajoutées après le téléchargement.",
"LabelAutoRegister": "Auto Register", "LabelAutoLaunch": "Lancement automatique",
"LabelAutoRegisterDescription": "Automatically create new users after logging in", "LabelAutoLaunchDescription": "Redirection automatique vers le fournisseur d'authentification lors de la navigation vers la page de connexion (chemin de remplacement manuel <code>/login?autoLaunch=0</code>)",
"LabelBackToUser": "Revenir à lUtilisateur", "LabelAutoRegister": "Enregistrement automatique",
"LabelBackupLocation": "Backup Location", "LabelAutoRegisterDescription": "Créer automatiquement de nouveaux utilisateurs après la connexion",
"LabelBackToUser": "Retour à lutilisateur",
"LabelBackupLocation": "Emplacement de la sauvegarde",
"LabelBackupsEnableAutomaticBackups": "Activer les sauvegardes automatiques", "LabelBackupsEnableAutomaticBackups": "Activer les sauvegardes automatiques",
"LabelBackupsEnableAutomaticBackupsHelp": "Sauvegardes Enregistrées dans /metadata/backups", "LabelBackupsEnableAutomaticBackupsHelp": "Sauvegardes enregistrées dans /metadata/backups",
"LabelBackupsMaxBackupSize": "Taille maximale de la sauvegarde (en Go)", "LabelBackupsMaxBackupSize": "Taille maximale de la sauvegarde (en Go)",
"LabelBackupsMaxBackupSizeHelp": "Afin de prévenir les mauvaises configuration, la sauvegarde échouera si elle excède la taille limite.", "LabelBackupsMaxBackupSizeHelp": "Afin de prévenir les mauvaises configuration, la sauvegarde échouera si elle excède la taille limite.",
"LabelBackupsNumberToKeep": "Nombre de sauvegardes à maintenir", "LabelBackupsNumberToKeep": "Nombre de sauvegardes à conserver",
"LabelBackupsNumberToKeepHelp": "Une seule sauvegarde sera effacée à la fois. Si vous avez plus de sauvegardes à effacer, vous devrez le faire manuellement.", "LabelBackupsNumberToKeepHelp": "Seule une sauvegarde sera supprimée à la fois. Si vous avez déjà plus de sauvegardes à effacer, vous devez les supprimer manuellement.",
"LabelBitrate": "Bitrate", "LabelBitrate": "Bitrate",
"LabelBooks": "Livres", "LabelBooks": "Livres",
"LabelButtonText": "Button Text", "LabelButtonText": "Texte du bouton",
"LabelChangePassword": "Modifier le mot de passe", "LabelChangePassword": "Modifier le mot de passe",
"LabelChannels": "Canaux", "LabelChannels": "Canaux",
"LabelChapters": "Chapitres", "LabelChapters": "Chapitres",
"LabelChaptersFound": "Chapitres trouvés", "LabelChaptersFound": "chapitres trouvés",
"LabelChapterTitle": "Titres du chapitre", "LabelChapterTitle": "Titre du chapitre",
"LabelClickForMoreInfo": "Click for more info", "LabelClickForMoreInfo": "Cliquez ici pour plus dinformations",
"LabelClosePlayer": "Fermer le lecteur", "LabelClosePlayer": "Fermer le lecteur",
"LabelCodec": "Codec", "LabelCodec": "Codec",
"LabelCollapseSeries": "Réduire les séries", "LabelCollapseSeries": "Réduire les séries",
@@ -230,20 +235,20 @@
"LabelCover": "Couverture", "LabelCover": "Couverture",
"LabelCoverImageURL": "URL vers limage de couverture", "LabelCoverImageURL": "URL vers limage de couverture",
"LabelCreatedAt": "Créé le", "LabelCreatedAt": "Créé le",
"LabelCronExpression": "Expression Cron", "LabelCronExpression": "Expression cron",
"LabelCurrent": "Courrant", "LabelCurrent": "Actuel",
"LabelCurrently": "En ce moment :", "LabelCurrently": "Actuellement :",
"LabelCustomCronExpression": "Expression cron personnalisée:", "LabelCustomCronExpression": "Expression cron personnalisée :",
"LabelDatetime": "Datetime", "LabelDatetime": "Date",
"LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDeleteFromFileSystemCheckbox": "Supprimer du système de fichiers (décocher pour ne supprimer que de la base de données)",
"LabelDescription": "Description", "LabelDescription": "Description",
"LabelDeselectAll": "Tout déselectionner", "LabelDeselectAll": "Tout déselectionner",
"LabelDevice": "Appareil", "LabelDevice": "Appareil",
"LabelDeviceInfo": "Détail de lappareil", "LabelDeviceInfo": "Détail de lappareil",
"LabelDeviceIsAvailableTo": "Device is available to...", "LabelDeviceIsAvailableTo": "Lappareil est disponible pour…",
"LabelDirectory": "Répertoire", "LabelDirectory": "Répertoire",
"LabelDiscFromFilename": "Disque depuis le fichier", "LabelDiscFromFilename": "Depuis le fichier",
"LabelDiscFromMetadata": "Disque depuis les métadonnées", "LabelDiscFromMetadata": "Depuis les métadonnées",
"LabelDiscover": "Découvrir", "LabelDiscover": "Découvrir",
"LabelDownload": "Téléchargement", "LabelDownload": "Téléchargement",
"LabelDownloadNEpisodes": "Télécharger {0} épisode(s)", "LabelDownloadNEpisodes": "Télécharger {0} épisode(s)",
@@ -266,16 +271,17 @@
"LabelExample": "Exemple", "LabelExample": "Exemple",
"LabelExplicit": "Restriction", "LabelExplicit": "Restriction",
"LabelFeedURL": "URL du flux", "LabelFeedURL": "URL du flux",
"LabelFetchingMetadata": "Récupération des métadonnées",
"LabelFile": "Fichier", "LabelFile": "Fichier",
"LabelFileBirthtime": "Création du fichier", "LabelFileBirthtime": "Création du fichier",
"LabelFileModified": "Modification du fichier", "LabelFileModified": "Modification du fichier",
"LabelFilename": "Nom de fichier", "LabelFilename": "Nom de fichier",
"LabelFilterByUser": "Filtrer par lutilisateur", "LabelFilterByUser": "Filtrer par utilisateur",
"LabelFindEpisodes": "Trouver des épisodes", "LabelFindEpisodes": "Trouver des épisodes",
"LabelFinished": "Fini(e)", "LabelFinished": "Terminé le",
"LabelFolder": "Dossier", "LabelFolder": "Dossier",
"LabelFolders": "Dossiers", "LabelFolders": "Dossiers",
"LabelFontFamily": "Famille de polices", "LabelFontFamily": "Polices de caractères",
"LabelFontScale": "Taille de la police de caractère", "LabelFontScale": "Taille de la police de caractère",
"LabelFormat": "Format", "LabelFormat": "Format",
"LabelGenre": "Genre", "LabelGenre": "Genre",
@@ -283,16 +289,16 @@
"LabelHardDeleteFile": "Suppression du fichier", "LabelHardDeleteFile": "Suppression du fichier",
"LabelHasEbook": "Dispose dun livre numérique", "LabelHasEbook": "Dispose dun livre numérique",
"LabelHasSupplementaryEbook": "Dispose dun livre numérique supplémentaire", "LabelHasSupplementaryEbook": "Dispose dun livre numérique supplémentaire",
"LabelHighestPriority": "Highest priority", "LabelHighestPriority": "Priorité la plus élevée",
"LabelHost": "Hôte", "LabelHost": "Hôte",
"LabelHour": "Heure", "LabelHour": "Heure",
"LabelIcon": "Icone", "LabelIcon": "Icône",
"LabelImageURLFromTheWeb": "Image URL from the web", "LabelImageURLFromTheWeb": "URL de limage à partir du web",
"LabelIncludeInTracklist": "Inclure dans la liste des pistes", "LabelIncludeInTracklist": "Inclure dans la liste de lecture",
"LabelIncomplete": "Incomplet", "LabelIncomplete": "Incomplet",
"LabelInProgress": "En cours", "LabelInProgress": "En cours",
"LabelInterval": "Intervalle", "LabelInterval": "Intervalle",
"LabelIntervalCustomDailyWeekly": "Journalier / Hebdomadaire personnalisé", "LabelIntervalCustomDailyWeekly": "Personnaliser quotidiennement / hebdomadairement",
"LabelIntervalEvery12Hours": "Toutes les 12 heures", "LabelIntervalEvery12Hours": "Toutes les 12 heures",
"LabelIntervalEvery15Minutes": "Toutes les 15 minutes", "LabelIntervalEvery15Minutes": "Toutes les 15 minutes",
"LabelIntervalEvery2Hours": "Toutes les 2 heures", "LabelIntervalEvery2Hours": "Toutes les 2 heures",
@@ -325,20 +331,22 @@
"LabelLogLevelInfo": "Info", "LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Warn", "LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Chercher de nouveaux épisode après cette date", "LabelLookForNewEpisodesAfterDate": "Chercher de nouveaux épisode après cette date",
"LabelLowestPriority": "Lowest Priority", "LabelLowestPriority": "Priorité la plus basse",
"LabelMatchExistingUsersBy": "Match existing users by", "LabelMatchExistingUsersBy": "Faire correspondre les utilisateurs existants par",
"LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider", "LabelMatchExistingUsersByDescription": "Utilisé pour connecter les utilisateurs existants. Une fois connectés, les utilisateurs seront associés à un identifiant unique provenant de votre fournisseur SSO.",
"LabelMediaPlayer": "Lecteur multimédia", "LabelMediaPlayer": "Lecteur multimédia",
"LabelMediaType": "Type de média", "LabelMediaType": "Type de média",
"LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources", "LabelMetadataOrderOfPrecedenceDescription": "Les sources de métadonnées ayant une priorité plus élevée auront la priorité sur celles ayant une priorité moins élevée.",
"LabelMetadataProvider": "Fournisseur de métadonnées", "LabelMetadataProvider": "Fournisseur de métadonnées",
"LabelMetaTag": "Etiquette de métadonnée", "LabelMetaTag": "Balise de métadonnée",
"LabelMetaTags": "Etiquettes de métadonnée", "LabelMetaTags": "Balises de métadonnée",
"LabelMinute": "Minute", "LabelMinute": "Minute",
"LabelMissing": "Manquant", "LabelMissing": "Manquant",
"LabelMissingParts": "Parties manquantes", "LabelMissingParts": "Parties manquantes",
"LabelMobileRedirectURIs": "URI de redirection mobile autorisés",
"LabelMobileRedirectURIsDescription": "Il s'agit d'une liste blanche dURI de redirection valides pour les applications mobiles. Celui par défaut est <code>audiobookshelf://oauth</code>, que vous pouvez supprimer ou compléter avec des URIs supplémentaires pour l'intégration d'applications tierces. Lutilisation dun astérisque (<code>*</code>) comme seule entrée autorise nimporte quel URI.",
"LabelMore": "Plus", "LabelMore": "Plus",
"LabelMoreInfo": "Plus dinfo", "LabelMoreInfo": "Plus dinformations",
"LabelName": "Nom", "LabelName": "Nom",
"LabelNarrator": "Narrateur", "LabelNarrator": "Narrateur",
"LabelNarrators": "Narrateurs", "LabelNarrators": "Narrateurs",
@@ -350,7 +358,7 @@
"LabelNextScheduledRun": "Prochain lancement prévu", "LabelNextScheduledRun": "Prochain lancement prévu",
"LabelNoEpisodesSelected": "Aucun épisode sélectionné", "LabelNoEpisodesSelected": "Aucun épisode sélectionné",
"LabelNotes": "Notes", "LabelNotes": "Notes",
"LabelNotFinished": "Non terminé(e)", "LabelNotFinished": "Non terminé",
"LabelNotificationAppriseURL": "URL(s) dApprise", "LabelNotificationAppriseURL": "URL(s) dApprise",
"LabelNotificationAvailableVariables": "Variables disponibles", "LabelNotificationAvailableVariables": "Variables disponibles",
"LabelNotificationBodyTemplate": "Modèle de Message", "LabelNotificationBodyTemplate": "Modèle de Message",
@@ -359,10 +367,10 @@
"LabelNotificationsMaxFailedAttemptsHelp": "La notification est abandonnée une fois ce seuil atteint", "LabelNotificationsMaxFailedAttemptsHelp": "La notification est abandonnée une fois ce seuil atteint",
"LabelNotificationsMaxQueueSize": "Nombres de notifications maximum à mettre en attente", "LabelNotificationsMaxQueueSize": "Nombres de notifications maximum à mettre en attente",
"LabelNotificationsMaxQueueSizeHelp": "La limite de notification est de un évènement par seconde. Les notifications seront ignorées si la file dattente est à son maximum. Cela empêche un flot trop important.", "LabelNotificationsMaxQueueSizeHelp": "La limite de notification est de un évènement par seconde. Les notifications seront ignorées si la file dattente est à son maximum. Cela empêche un flot trop important.",
"LabelNotificationTitleTemplate": "Modèle de Titre", "LabelNotificationTitleTemplate": "Modèle de titre",
"LabelNotStarted": "Non Démarré(e)", "LabelNotStarted": "Pas commencé",
"LabelNumberOfBooks": "Nombre de Livres", "LabelNumberOfBooks": "Nombre de livres",
"LabelNumberOfEpisodes": "Nombre dEpisodes", "LabelNumberOfEpisodes": "Nombre dépisodes",
"LabelOpenRSSFeed": "Ouvrir le flux RSS", "LabelOpenRSSFeed": "Ouvrir le flux RSS",
"LabelOverwrite": "Écraser", "LabelOverwrite": "Écraser",
"LabelPassword": "Mot de passe", "LabelPassword": "Mot de passe",
@@ -398,11 +406,12 @@
"LabelRegion": "Région", "LabelRegion": "Région",
"LabelReleaseDate": "Date de parution", "LabelReleaseDate": "Date de parution",
"LabelRemoveCover": "Supprimer la couverture", "LabelRemoveCover": "Supprimer la couverture",
"LabelRowsPerPage": "Lignes par page",
"LabelRSSFeedCustomOwnerEmail": "Courriel du propriétaire personnalisé", "LabelRSSFeedCustomOwnerEmail": "Courriel du propriétaire personnalisé",
"LabelRSSFeedCustomOwnerName": "Nom propriétaire personnalisé", "LabelRSSFeedCustomOwnerName": "Nom propriétaire personnalisé",
"LabelRSSFeedOpen": "Flux RSS ouvert", "LabelRSSFeedOpen": "Flux RSS ouvert",
"LabelRSSFeedPreventIndexing": "Empêcher lindexation", "LabelRSSFeedPreventIndexing": "Empêcher lindexation",
"LabelRSSFeedSlug": "Identificateur dadresse du Flux RSS ", "LabelRSSFeedSlug": "Balise URL du flux RSS",
"LabelRSSFeedURL": "Adresse du flux RSS", "LabelRSSFeedURL": "Adresse du flux RSS",
"LabelSearchTerm": "Terme de recherche", "LabelSearchTerm": "Terme de recherche",
"LabelSearchTitle": "Titre de recherche", "LabelSearchTitle": "Titre de recherche",
@@ -410,8 +419,8 @@
"LabelSeason": "Saison", "LabelSeason": "Saison",
"LabelSelectAllEpisodes": "Sélectionner tous les épisodes", "LabelSelectAllEpisodes": "Sélectionner tous les épisodes",
"LabelSelectEpisodesShowing": "Sélectionner {0} episode(s) en cours", "LabelSelectEpisodesShowing": "Sélectionner {0} episode(s) en cours",
"LabelSelectUsers": "Select users", "LabelSelectUsers": "Sélectionner les utilisateurs",
"LabelSendEbookToDevice": "Envoyer le livre numérique à...", "LabelSendEbookToDevice": "Envoyer le livre numérique à",
"LabelSequence": "Séquence", "LabelSequence": "Séquence",
"LabelSeries": "Séries", "LabelSeries": "Séries",
"LabelSeriesName": "Nom de la série", "LabelSeriesName": "Nom de la série",
@@ -419,18 +428,18 @@
"LabelSetEbookAsPrimary": "Définir comme principale", "LabelSetEbookAsPrimary": "Définir comme principale",
"LabelSetEbookAsSupplementary": "Définir comme supplémentaire", "LabelSetEbookAsSupplementary": "Définir comme supplémentaire",
"LabelSettingsAudiobooksOnly": "Livres audios seulement", "LabelSettingsAudiobooksOnly": "Livres audios seulement",
"LabelSettingsAudiobooksOnlyHelp": "Lactivation de ce paramètre ignorera les fichiers “ ebook ”, à moins quils ne se trouvent dans un dossier de livres audio, auquel cas ils seront définis comme des livres numériques supplémentaires.", "LabelSettingsAudiobooksOnlyHelp": "L'activation de ce paramètre ignorera les fichiers de type « livre numériques », sauf s'ils se trouvent dans un dossier spécifique , auquel cas ils seront définis comme des livres numériques supplémentaires.",
"LabelSettingsBookshelfViewHelp": "Interface skeuomorphique avec une étagère en bois", "LabelSettingsBookshelfViewHelp": "Interface skeuomorphique avec une étagère en bois",
"LabelSettingsChromecastSupport": "Support du Chromecast", "LabelSettingsChromecastSupport": "Support du Chromecast",
"LabelSettingsDateFormat": "Format de date", "LabelSettingsDateFormat": "Format de date",
"LabelSettingsDisableWatcher": "Désactiver la surveillance", "LabelSettingsDisableWatcher": "Désactiver la surveillance",
"LabelSettingsDisableWatcherForLibrary": "Désactiver la surveillance des dossiers pour la bibliothèque", "LabelSettingsDisableWatcherForLibrary": "Désactiver la surveillance des dossiers pour la bibliothèque",
"LabelSettingsDisableWatcherHelp": "Désactive la mise à jour automatique lorsque des modifications de fichiers sont détectées. *Nécessite le redémarrage du serveur", "LabelSettingsDisableWatcherHelp": "Désactive la mise à jour automatique lorsque des modifications de fichiers sont détectées. * nécessite le redémarrage du serveur",
"LabelSettingsEnableWatcher": "Activer la veille", "LabelSettingsEnableWatcher": "Activer la veille",
"LabelSettingsEnableWatcherForLibrary": "Activer la surveillance des dossiers pour la bibliothèque", "LabelSettingsEnableWatcherForLibrary": "Activer la surveillance des dossiers pour la bibliothèque",
"LabelSettingsEnableWatcherHelp": "Active la mise à jour automatique automatique lorsque des modifications de fichiers sont détectées. *Nécessite le redémarrage du serveur", "LabelSettingsEnableWatcherHelp": "Active la mise à jour automatique automatique lorsque des modifications de fichiers sont détectées. * nécessite le redémarrage du serveur",
"LabelSettingsExperimentalFeatures": "Fonctionnalités expérimentales", "LabelSettingsExperimentalFeatures": "Fonctionnalités expérimentales",
"LabelSettingsExperimentalFeaturesHelp": "Fonctionnalités en cours de développement sur lesquelles nous attendons votre retour et expérience. Cliquez pour ouvrir la discussion Github.", "LabelSettingsExperimentalFeaturesHelp": "Fonctionnalités en cours de développement sur lesquelles nous attendons votre retour et expérience. Cliquez pour ouvrir la discussion GitHub.",
"LabelSettingsFindCovers": "Chercher des couvertures de livre", "LabelSettingsFindCovers": "Chercher des couvertures de livre",
"LabelSettingsFindCoversHelp": "Si votre livre audio ne possède pas de couverture intégrée ou une image de couverture dans le dossier, lanalyseur tentera de récupérer une couverture.<br>Attention, cela peut augmenter le temps danalyse.", "LabelSettingsFindCoversHelp": "Si votre livre audio ne possède pas de couverture intégrée ou une image de couverture dans le dossier, lanalyseur tentera de récupérer une couverture.<br>Attention, cela peut augmenter le temps danalyse.",
"LabelSettingsHideSingleBookSeries": "Masquer les séries de livres uniques", "LabelSettingsHideSingleBookSeries": "Masquer les séries de livres uniques",
@@ -438,13 +447,13 @@
"LabelSettingsHomePageBookshelfView": "La page daccueil utilise la vue étagère", "LabelSettingsHomePageBookshelfView": "La page daccueil utilise la vue étagère",
"LabelSettingsLibraryBookshelfView": "La bibliothèque utilise la vue étagère", "LabelSettingsLibraryBookshelfView": "La bibliothèque utilise la vue étagère",
"LabelSettingsParseSubtitles": "Analyser les sous-titres", "LabelSettingsParseSubtitles": "Analyser les sous-titres",
"LabelSettingsParseSubtitlesHelp": "Extrait les sous-titres depuis le dossier du Livre Audio.<br>Les sous-titres doivent être séparés par « - »<br>i.e. « Titre du Livre - Ceci est un sous-titre » aura le sous-titre « Ceci est un sous-titre »", "LabelSettingsParseSubtitlesHelp": "Extrait les sous-titres depuis le dossier du livre audio.<br>Les sous-titres doivent être séparés par « - »<br>cest-à-dire : « Titre du livre - Ceci est un sous-titre » aura le sous-titre « Ceci est un sous-titre »",
"LabelSettingsPreferMatchedMetadata": "Préférer les métadonnées par correspondance", "LabelSettingsPreferMatchedMetadata": "Préférer les métadonnées par correspondance",
"LabelSettingsPreferMatchedMetadataHelp": "Les métadonnées par correspondance écrase les détails de larticle lors dune recherche par correspondance rapide. Par défaut, la recherche par correspondance rapide ne comblera que les éléments manquant.", "LabelSettingsPreferMatchedMetadataHelp": "Les métadonnées par correspondance écrase les détails de larticle lors dune recherche par correspondance rapide. Par défaut, la recherche par correspondance rapide ne comblera que les éléments manquant.",
"LabelSettingsSkipMatchingBooksWithASIN": "Ignorer la recherche par correspondance sur les livres ayant déjà un ASIN", "LabelSettingsSkipMatchingBooksWithASIN": "Ignorer la recherche par correspondance sur les livres ayant déjà un ASIN",
"LabelSettingsSkipMatchingBooksWithISBN": "Ignorer la recherche par correspondance sur les livres ayant déjà un ISBN", "LabelSettingsSkipMatchingBooksWithISBN": "Ignorer la recherche par correspondance sur les livres ayant déjà un ISBN",
"LabelSettingsSortingIgnorePrefixes": "Ignorer les préfixes lors du tri", "LabelSettingsSortingIgnorePrefixes": "Ignorer les préfixes lors du tri",
"LabelSettingsSortingIgnorePrefixesHelp": "i.e. pour le préfixe « le », le livre avec pour titre « Le Titre du Livre » sera trié en tant que « Titre du Livre, Le »", "LabelSettingsSortingIgnorePrefixesHelp": "cest-à-dire : pour le préfixe « le », le livre avec pour titre « Le Titre du Livre » sera trié en tant que « Titre du Livre, Le »",
"LabelSettingsSquareBookCovers": "Utiliser des couvertures carrées", "LabelSettingsSquareBookCovers": "Utiliser des couvertures carrées",
"LabelSettingsSquareBookCoversHelp": "Préférer les couvertures carrées par rapport aux couvertures standards de ratio 1.6:1.", "LabelSettingsSquareBookCoversHelp": "Préférer les couvertures carrées par rapport aux couvertures standards de ratio 1.6:1.",
"LabelSettingsStoreCoversWithItem": "Enregistrer la couverture avec les articles", "LabelSettingsStoreCoversWithItem": "Enregistrer la couverture avec les articles",
@@ -452,30 +461,30 @@
"LabelSettingsStoreMetadataWithItem": "Enregistrer les Métadonnées avec les articles", "LabelSettingsStoreMetadataWithItem": "Enregistrer les Métadonnées avec les articles",
"LabelSettingsStoreMetadataWithItemHelp": "Par défaut, les métadonnées sont enregistrées dans /metadata/items", "LabelSettingsStoreMetadataWithItemHelp": "Par défaut, les métadonnées sont enregistrées dans /metadata/items",
"LabelSettingsTimeFormat": "Format dheure", "LabelSettingsTimeFormat": "Format dheure",
"LabelShowAll": "Afficher Tout", "LabelShowAll": "Tout afficher",
"LabelSize": "Taille", "LabelSize": "Taille",
"LabelSleepTimer": "Minuterie", "LabelSleepTimer": "Minuterie",
"LabelSlug": "Slug", "LabelSlug": "Balise",
"LabelStart": "Démarrer", "LabelStart": "Démarrer",
"LabelStarted": "Démarré", "LabelStarted": "Démarré",
"LabelStartedAt": "Démarré à", "LabelStartedAt": "Démarré à",
"LabelStartTime": "Heure de Démarrage", "LabelStartTime": "Heure de démarrage",
"LabelStatsAudioTracks": "Pistes Audios", "LabelStatsAudioTracks": "Pistes Audios",
"LabelStatsAuthors": "Auteurs", "LabelStatsAuthors": "Auteurs",
"LabelStatsBestDay": "Meilleur Jour", "LabelStatsBestDay": "Meilleur jour",
"LabelStatsDailyAverage": "Moyenne Journalière", "LabelStatsDailyAverage": "Moyenne journalière",
"LabelStatsDays": "Jours", "LabelStatsDays": "Jours",
"LabelStatsDaysListened": "Jours d’écoute", "LabelStatsDaysListened": "Jours d’écoute",
"LabelStatsHours": "Heures", "LabelStatsHours": "Heures",
"LabelStatsInARow": "daffilé(s)", "LabelStatsInARow": "daffilée(s)",
"LabelStatsItemsFinished": "Articles terminés", "LabelStatsItemsFinished": "Articles terminés",
"LabelStatsItemsInLibrary": "Articles dans la Bibliothèque", "LabelStatsItemsInLibrary": "Articles dans la bibliothèque",
"LabelStatsMinutes": "minutes", "LabelStatsMinutes": "minutes",
"LabelStatsMinutesListening": "Minutes d’écoute", "LabelStatsMinutesListening": "Minutes d’écoute",
"LabelStatsOverallDays": "Jours au total", "LabelStatsOverallDays": "Nombre total de jours",
"LabelStatsOverallHours": "Heures au total", "LabelStatsOverallHours": "Nombre total d'heures",
"LabelStatsWeekListening": "Écoute de la semaine", "LabelStatsWeekListening": "Écoute de la semaine",
"LabelSubtitle": "Sous-Titre", "LabelSubtitle": "Sous-titre",
"LabelSupportedFileTypes": "Types de fichiers supportés", "LabelSupportedFileTypes": "Types de fichiers supportés",
"LabelTag": "Étiquette", "LabelTag": "Étiquette",
"LabelTags": "Étiquettes", "LabelTags": "Étiquettes",
@@ -487,23 +496,23 @@
"LabelThemeLight": "Clair", "LabelThemeLight": "Clair",
"LabelTimeBase": "Base de temps", "LabelTimeBase": "Base de temps",
"LabelTimeListened": "Temps d’écoute", "LabelTimeListened": "Temps d’écoute",
"LabelTimeListenedToday": "Nombres d’écoutes Aujourdhui", "LabelTimeListenedToday": "Nombres d’écoutes aujourdhui",
"LabelTimeRemaining": "{0} restantes", "LabelTimeRemaining": "{0} restantes",
"LabelTimeToShift": "Temps de décalage en secondes", "LabelTimeToShift": "Temps de décalage en secondes",
"LabelTitle": "Titre", "LabelTitle": "Titre",
"LabelToolsEmbedMetadata": "Métadonnées Intégrées", "LabelToolsEmbedMetadata": "Métadonnées intégrées",
"LabelToolsEmbedMetadataDescription": "Intègre les métadonnées au fichier audio avec la couverture et les chapitres.", "LabelToolsEmbedMetadataDescription": "Intègre les métadonnées au fichier audio avec la couverture et les chapitres.",
"LabelToolsMakeM4b": "Créer un fichier Livre Audio M4B", "LabelToolsMakeM4b": "Créer un fichier livre audio M4B",
"LabelToolsMakeM4bDescription": "Génère un fichier Livre Audio .M4B avec intégration des métadonnées, image de couverture et les chapitres.", "LabelToolsMakeM4bDescription": "Générer un fichier de livre audio .M4B avec des métadonnées intégrées, une image de couverture et des chapitres.",
"LabelToolsSplitM4b": "Scinde le fichier M4B en fichiers MP3", "LabelToolsSplitM4b": "Scinde le fichier M4B en fichiers MP3",
"LabelToolsSplitM4bDescription": "Créer plusieurs fichier MP3 à partir du découpage par chapitre, en incluant les métadonnées, limage de couverture et les chapitres.", "LabelToolsSplitM4bDescription": "Créer plusieurs fichier MP3 à partir du découpage par chapitre, en incluant les métadonnées, limage de couverture et les chapitres.",
"LabelTotalDuration": "Durée Totale", "LabelTotalDuration": "Durée totale",
"LabelTotalTimeListened": "Temps d’écoute total", "LabelTotalTimeListened": "Temps d’écoute total",
"LabelTrackFromFilename": "Piste depuis le fichier", "LabelTrackFromFilename": "Piste depuis le fichier",
"LabelTrackFromMetadata": "Piste depuis les métadonnées", "LabelTrackFromMetadata": "Piste depuis les métadonnées",
"LabelTracks": "Pistes", "LabelTracks": "Pistes",
"LabelTracksMultiTrack": "Piste multiple", "LabelTracksMultiTrack": "Piste multiple",
"LabelTracksNone": "No tracks", "LabelTracksNone": "Aucune piste",
"LabelTracksSingleTrack": "Piste simple", "LabelTracksSingleTrack": "Piste simple",
"LabelType": "Type", "LabelType": "Type",
"LabelUnabridged": "Version intégrale", "LabelUnabridged": "Version intégrale",
@@ -515,8 +524,9 @@
"LabelUpdateDetailsHelp": "Autoriser la mise à jour des détails existants lorsquune correspondance est trouvée", "LabelUpdateDetailsHelp": "Autoriser la mise à jour des détails existants lorsquune correspondance est trouvée",
"LabelUploaderDragAndDrop": "Glisser et déposer des fichiers ou dossiers", "LabelUploaderDragAndDrop": "Glisser et déposer des fichiers ou dossiers",
"LabelUploaderDropFiles": "Déposer des fichiers", "LabelUploaderDropFiles": "Déposer des fichiers",
"LabelUploaderItemFetchMetadataHelp": "Récupérer automatiquement le titre, lauteur et la série",
"LabelUseChapterTrack": "Utiliser la piste du chapitre", "LabelUseChapterTrack": "Utiliser la piste du chapitre",
"LabelUseFullTrack": "Utiliser la piste Complète", "LabelUseFullTrack": "Utiliser la piste complète",
"LabelUser": "Utilisateur", "LabelUser": "Utilisateur",
"LabelUsername": "Nom dutilisateur", "LabelUsername": "Nom dutilisateur",
"LabelValue": "Valeur", "LabelValue": "Valeur",
@@ -531,14 +541,14 @@
"LabelYourPlaylists": "Vos listes de lecture", "LabelYourPlaylists": "Vos listes de lecture",
"LabelYourProgress": "Votre progression", "LabelYourProgress": "Votre progression",
"MessageAddToPlayerQueue": "Ajouter en file dattente", "MessageAddToPlayerQueue": "Ajouter en file dattente",
"MessageAppriseDescription": "Nécessite une instance d<a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">API Apprise</a> pour utiliser cette fonctionnalité ou une api qui prend en charge les mêmes requêtes. <br />lURL de lAPI Apprise doit comprendre le chemin complet pour envoyer la notification. Par exemple, si votre instance écoute sur <code>http://192.168.1.1:8337</code> alors vous devez mettre <code>http://192.168.1.1:8337/notify</code>.", "MessageAppriseDescription": "Nécessite une instance d<a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">API Apprise</a> pour utiliser cette fonctionnalité ou une api qui prend en charge les mêmes requêtes.<br>LURL de lAPI Apprise doit comprendre le chemin complet pour envoyer la notification. Par exemple, si votre instance écoute sur <code>http://192.168.1.1:8337</code> alors vous devez mettre <code>http://192.168.1.1:8337/notify</code>.",
"MessageBackupsDescription": "Les sauvegardes incluent les utilisateurs, la progression de lecture par utilisateur, les détails des articles des bibliothèques, les paramètres du serveur et les images sauvegardées. Les sauvegardes nincluent pas les fichiers de votre bibliothèque.", "MessageBackupsDescription": "Les sauvegardes incluent les utilisateurs, la progression de lecture par utilisateur, les détails des articles des bibliothèques, les paramètres du serveur et les images sauvegardées. Les sauvegardes nincluent pas les fichiers de votre bibliothèque.",
"MessageBatchQuickMatchDescription": "La recherche par correspondance rapide tentera dajouter les couvertures et les métadonnées manquantes pour les articles sélectionnés. Activer loption suivante pour autoriser la recherche par correspondance à écraser les données existantes.", "MessageBatchQuickMatchDescription": "La recherche par correspondance rapide tentera dajouter les couvertures et les métadonnées manquantes pour les articles sélectionnés. Activer loption suivante pour autoriser la recherche par correspondance à écraser les données existantes.",
"MessageBookshelfNoCollections": "Vous navez pas encore de collections", "MessageBookshelfNoCollections": "Vous navez pas encore de collections",
"MessageBookshelfNoResultsForFilter": "Aucun résultat pour le filtre « {0}: {1} »", "MessageBookshelfNoResultsForFilter": "Aucun résultat pour le filtre « {0} : {1} »",
"MessageBookshelfNoRSSFeeds": "Aucun flux RSS nest ouvert", "MessageBookshelfNoRSSFeeds": "Aucun flux RSS nest ouvert",
"MessageBookshelfNoSeries": "Vous navez aucune série", "MessageBookshelfNoSeries": "Vous navez aucune série",
"MessageChapterEndIsAfter": "Le Chapitre Fin est situé à la fin de votre Livre Audio", "MessageChapterEndIsAfter": "La fin du chapitre se situe après la fin de votre livre audio.",
"MessageChapterErrorFirstNotZero": "Le premier capitre doit débuter à 0", "MessageChapterErrorFirstNotZero": "Le premier capitre doit débuter à 0",
"MessageChapterErrorStartGteDuration": "Horodatage invalide car il doit débuter avant la fin du livre", "MessageChapterErrorStartGteDuration": "Horodatage invalide car il doit débuter avant la fin du livre",
"MessageChapterErrorStartLtPrev": "Horodatage invalide car il doit débuter au moins après le précédent chapitre", "MessageChapterErrorStartLtPrev": "Horodatage invalide car il doit débuter au moins après le précédent chapitre",
@@ -548,20 +558,21 @@
"MessageConfirmDeleteBackup": "Êtes-vous sûr de vouloir supprimer la sauvegarde de « {0} » ?", "MessageConfirmDeleteBackup": "Êtes-vous sûr de vouloir supprimer la sauvegarde de « {0} » ?",
"MessageConfirmDeleteFile": "Cela supprimera le fichier de votre système de fichiers. Êtes-vous sûr ?", "MessageConfirmDeleteFile": "Cela supprimera le fichier de votre système de fichiers. Êtes-vous sûr ?",
"MessageConfirmDeleteLibrary": "Êtes-vous sûr de vouloir supprimer définitivement la bibliothèque « {0} » ?", "MessageConfirmDeleteLibrary": "Êtes-vous sûr de vouloir supprimer définitivement la bibliothèque « {0} » ?",
"MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", "MessageConfirmDeleteLibraryItem": "Cette opération supprimera l’élément de la base de données et de votre système de fichiers. Êtes-vous sûr ?",
"MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", "MessageConfirmDeleteLibraryItems": "Cette opération supprimera {0} éléments de la base de données et de votre système de fichiers. Êtes-vous sûr ?",
"MessageConfirmDeleteSession": "Êtes-vous sûr de vouloir supprimer cette session ?", "MessageConfirmDeleteSession": "Êtes-vous sûr de vouloir supprimer cette session ?",
"MessageConfirmForceReScan": "Êtes-vous sûr de vouloir lancer une analyse forcée ?", "MessageConfirmForceReScan": "Êtes-vous sûr de vouloir lancer une analyse forcée ?",
"MessageConfirmMarkAllEpisodesFinished": "Êtes-vous sûr de marquer tous les épisodes comme terminés ?", "MessageConfirmMarkAllEpisodesFinished": "Êtes-vous sûr de marquer tous les épisodes comme terminés ?",
"MessageConfirmMarkAllEpisodesNotFinished": "Êtes-vous sûr de vouloir marquer tous les épisodes comme non terminés ?", "MessageConfirmMarkAllEpisodesNotFinished": "Êtes-vous sûr de vouloir marquer tous les épisodes comme non terminés ?",
"MessageConfirmMarkSeriesFinished": "Êtes-vous sûr de vouloir marquer tous les livres de cette série comme terminées ?", "MessageConfirmMarkSeriesFinished": "Êtes-vous sûr de vouloir marquer tous les livres de cette série comme terminées ?",
"MessageConfirmMarkSeriesNotFinished": "Êtes-vous sûr de vouloir marquer tous les livres de cette série comme comme non terminés ?", "MessageConfirmMarkSeriesNotFinished": "Êtes-vous sûr de vouloir marquer tous les livres de cette série comme comme non terminés ?",
"MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", "MessageConfirmQuickEmbed": "Attention ! Lintégration rapide ne sauvegardera pas vos fichiers audio. Assurez-vous davoir effectuer une sauvegarde de vos fichiers audio.<br><br>Souhaitez-vous continuer ?",
"MessageConfirmRemoveAllChapters": "Êtes-vous sûr de vouloir supprimer tous les chapitres ?", "MessageConfirmRemoveAllChapters": "Êtes-vous sûr de vouloir supprimer tous les chapitres ?",
"MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?", "MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?",
"MessageConfirmRemoveCollection": "Êtes-vous sûr de vouloir supprimer la collection « {0} » ?", "MessageConfirmRemoveCollection": "Êtes-vous sûr de vouloir supprimer la collection « {0} » ?",
"MessageConfirmRemoveEpisode": "Êtes-vous sûr de vouloir supprimer l’épisode « {0} » ?", "MessageConfirmRemoveEpisode": "Êtes-vous sûr de vouloir supprimer l’épisode « {0} » ?",
"MessageConfirmRemoveEpisodes": "Êtes-vous sûr de vouloir supprimer {0} épisodes ?", "MessageConfirmRemoveEpisodes": "Êtes-vous sûr de vouloir supprimer {0} épisodes ?",
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
"MessageConfirmRemoveNarrator": "Êtes-vous sûr de vouloir supprimer le narrateur « {0} » ?", "MessageConfirmRemoveNarrator": "Êtes-vous sûr de vouloir supprimer le narrateur « {0} » ?",
"MessageConfirmRemovePlaylist": "Êtes-vous sûr de vouloir supprimer la liste de lecture « {0} » ?", "MessageConfirmRemovePlaylist": "Êtes-vous sûr de vouloir supprimer la liste de lecture « {0} » ?",
"MessageConfirmRenameGenre": "Êtes-vous sûr de vouloir renommer le genre « {0} » en « {1} » pour tous les articles ?", "MessageConfirmRenameGenre": "Êtes-vous sûr de vouloir renommer le genre « {0} » en « {1} » pour tous les articles ?",
@@ -570,16 +581,16 @@
"MessageConfirmRenameTag": "Êtes-vous sûr de vouloir renommer l’étiquette « {0} » en « {1} » pour tous les articles ?", "MessageConfirmRenameTag": "Êtes-vous sûr de vouloir renommer l’étiquette « {0} » en « {1} » pour tous les articles ?",
"MessageConfirmRenameTagMergeNote": "Information: Cette étiquette existe déjà et sera fusionnée.", "MessageConfirmRenameTagMergeNote": "Information: Cette étiquette existe déjà et sera fusionnée.",
"MessageConfirmRenameTagWarning": "Attention ! Une étiquette similaire avec une casse différente existe déjà « {0} ».", "MessageConfirmRenameTagWarning": "Attention ! Une étiquette similaire avec une casse différente existe déjà « {0} ».",
"MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", "MessageConfirmReScanLibraryItems": "Êtes-vous sûr de vouloir re-analyser {0} éléments ?",
"MessageConfirmSendEbookToDevice": "Êtes-vous sûr de vouloir envoyer le livre numérique {0} « {1} » à lappareil « {2} »?", "MessageConfirmSendEbookToDevice": "Êtes-vous sûr de vouloir envoyer le livre numérique {0} « {1} » à lappareil « {2} »?",
"MessageDownloadingEpisode": "Téléchargement de l’épisode", "MessageDownloadingEpisode": "Téléchargement de l’épisode",
"MessageDragFilesIntoTrackOrder": "Faire glisser les fichiers dans lordre correct", "MessageDragFilesIntoTrackOrder": "Faites glisser les fichiers dans lordre correct des pistes",
"MessageEmbedFinished": "Intégration terminée !", "MessageEmbedFinished": "Intégration terminée !",
"MessageEpisodesQueuedForDownload": "{0} épisode(s) mis en file pour téléchargement", "MessageEpisodesQueuedForDownload": "{0} épisode(s) mis en file pour téléchargement",
"MessageFeedURLWillBe": "lURL du flux sera {0}", "MessageFeedURLWillBe": "LURL du flux sera {0}",
"MessageFetching": "Récupération…", "MessageFetching": "Récupération…",
"MessageForceReScanDescription": "Analysera tous les fichiers de nouveau. Les étiquettes ID3 des fichiers audios, fichiers OPF, et les fichiers textes seront analysés comme sils étaient nouveaux.", "MessageForceReScanDescription": "analysera de nouveau tous les fichiers. Les étiquettes ID3 des fichiers audio, les fichiers OPF et les fichiers texte seront analysés comme sils étaient nouveaux.",
"MessageImportantNotice": "Information Importante !", "MessageImportantNotice": "Information importante !",
"MessageInsertChapterBelow": "Insérer le chapitre ci-dessous", "MessageInsertChapterBelow": "Insérer le chapitre ci-dessous",
"MessageItemsSelected": "{0} articles sélectionnés", "MessageItemsSelected": "{0} articles sélectionnés",
"MessageItemsUpdated": "{0} articles mis à jour", "MessageItemsUpdated": "{0} articles mis à jour",
@@ -635,12 +646,13 @@
"MessageRemoveChapter": "Supprimer le chapitre", "MessageRemoveChapter": "Supprimer le chapitre",
"MessageRemoveEpisodes": "Suppression de {0} épisode(s)", "MessageRemoveEpisodes": "Suppression de {0} épisode(s)",
"MessageRemoveFromPlayerQueue": "Supprimer de la liste d’écoute", "MessageRemoveFromPlayerQueue": "Supprimer de la liste d’écoute",
"MessageRemoveUserWarning": "Êtes-vous certain de vouloir supprimer définitivement lutilisateur « {0} » ?", "MessageRemoveUserWarning": "Êtes-vous sûr de vouloir supprimer définitivement lutilisateur « {0} » ?",
"MessageReportBugsAndContribute": "Remonter des anomalies, demander des fonctionnalités et contribuer sur", "MessageReportBugsAndContribute": "Remonter des anomalies, demander des fonctionnalités et contribuer sur",
"MessageResetChaptersConfirm": "Êtes-vous certain de vouloir réinitialiser les chapitres et annuler les changements effectués ?", "MessageResetChaptersConfirm": "Êtes-vous sûr de vouloir réinitialiser les chapitres et annuler les changements effectués ?",
"MessageRestoreBackupConfirm": "Êtes-vous certain de vouloir restaurer la sauvegarde créée le", "MessageRestoreBackupConfirm": "Êtes-vous sûr de vouloir restaurer la sauvegarde créée le",
"MessageRestoreBackupWarning": "Restaurer la sauvegarde écrasera la base de donnée située dans le dossier /config ainsi que les images sur /metadata/items et /metadata/authors.<br /><br />Les sauvegardes ne touchent pas aux fichiers de la bibliothèque. Si vous avez activé le paramètre pour sauvegarder les métadonnées et les images de couverture dans le même dossier que les fichiers, ceux-ci ne ni sauvegardés, ni écrasés lors de la restauration.<br /><br />Tous les clients utilisant votre serveur seront automatiquement mis à jour.", "MessageRestoreBackupWarning": "Restaurer la sauvegarde écrasera la base de donnée située dans le dossier /config ainsi que les images sur /metadata/items et /metadata/authors.<br><br>Les sauvegardes ne touchent pas aux fichiers de la bibliothèque. Si vous avez activé le paramètre pour sauvegarder les métadonnées et les images de couverture dans le même dossier que les fichiers, ceux-ci ne ni sauvegardés, ni écrasés lors de la restauration.<br><br>Tous les clients utilisant votre serveur seront automatiquement mis à jour.",
"MessageSearchResultsFor": "Résultats de recherche pour", "MessageSearchResultsFor": "Résultats de recherche pour",
"MessageSelected": "{0} sélectionnés",
"MessageServerCouldNotBeReached": "Serveur inaccessible", "MessageServerCouldNotBeReached": "Serveur inaccessible",
"MessageSetChaptersFromTracksDescription": "Positionne un chapitre par fichier audio, avec le titre du fichier comme titre de chapitre", "MessageSetChaptersFromTracksDescription": "Positionne un chapitre par fichier audio, avec le titre du fichier comme titre de chapitre",
"MessageStartPlaybackAtTime": "Démarrer la lecture pour « {0} » à {1} ?", "MessageStartPlaybackAtTime": "Démarrer la lecture pour « {0} » à {1} ?",
@@ -651,12 +663,11 @@
"MessageValidCronExpression": "Expression cron valide", "MessageValidCronExpression": "Expression cron valide",
"MessageWatcherIsDisabledGlobally": "La surveillance est désactivée par un paramètre global du serveur", "MessageWatcherIsDisabledGlobally": "La surveillance est désactivée par un paramètre global du serveur",
"MessageXLibraryIsEmpty": "La bibliothèque {0} est vide !", "MessageXLibraryIsEmpty": "La bibliothèque {0} est vide !",
"MessageYourAudiobookDurationIsLonger": "La durée de votre Livre Audio est plus longue que la durée trouvée", "MessageYourAudiobookDurationIsLonger": "La durée de votre livre audio est plus longue que la durée trouvée",
"MessageYourAudiobookDurationIsShorter": "La durée de votre Livre Audio est plus courte que la durée trouvée", "MessageYourAudiobookDurationIsShorter": "La durée de votre livre audio est plus courte que la durée trouvée",
"NoteChangeRootPassword": "seul lutilisateur « root » peut utiliser un mot de passe vide", "NoteChangeRootPassword": "seul lutilisateur « root » peut utiliser un mot de passe vide",
"NoteChapterEditorTimes": "Information : lhorodatage du premier chapitre doit être à 0:00 et celui du dernier chapitre ne peut se situer au-delà de la durée du Livre Audio.", "NoteChapterEditorTimes": "Information : lhorodatage du premier chapitre doit être à 0:00 et celui du dernier chapitre ne peut se situer au-delà de la durée du livre audio.",
"NoteFolderPicker": "Information : Les dossiers déjà surveillés ne sont pas affichés", "NoteFolderPicker": "Information : Les dossiers déjà surveillés ne sont pas affichés",
"NoteFolderPickerDebian": "Information : La sélection de dossier sur une installation debian nest pas finalisée. Merci de renseigner le chemin complet vers votre bibliothèque manuellement.",
"NoteRSSFeedPodcastAppsHttps": "Attention : la majorité des application de podcast nécessite une adresse de flux en HTTPS.", "NoteRSSFeedPodcastAppsHttps": "Attention : la majorité des application de podcast nécessite une adresse de flux en HTTPS.",
"NoteRSSFeedPodcastAppsPubDate": "Attention : un ou plusieurs de vos épisodes ne possèdent pas de date de publication. Certaines applications de podcast le requièrent.", "NoteRSSFeedPodcastAppsPubDate": "Attention : un ou plusieurs de vos épisodes ne possèdent pas de date de publication. Certaines applications de podcast le requièrent.",
"NoteUploaderFoldersWithMediaFiles": "Les dossiers contenant des fichiers multimédias seront traités comme des éléments distincts de la bibliothèque.", "NoteUploaderFoldersWithMediaFiles": "Les dossiers contenant des fichiers multimédias seront traités comme des éléments distincts de la bibliothèque.",
@@ -665,8 +676,8 @@
"PlaceholderNewCollection": "Nom de la nouvelle collection", "PlaceholderNewCollection": "Nom de la nouvelle collection",
"PlaceholderNewFolderPath": "Nouveau chemin de dossier", "PlaceholderNewFolderPath": "Nouveau chemin de dossier",
"PlaceholderNewPlaylist": "Nouveau nom de liste de lecture", "PlaceholderNewPlaylist": "Nouveau nom de liste de lecture",
"PlaceholderSearch": "Recherche...", "PlaceholderSearch": "Recherche",
"PlaceholderSearchEpisode": "Recherche d’épisode...", "PlaceholderSearchEpisode": "Recherche d’épisode",
"ToastAccountUpdateFailed": "Échec de la mise à jour du compte", "ToastAccountUpdateFailed": "Échec de la mise à jour du compte",
"ToastAccountUpdateSuccess": "Compte mis à jour", "ToastAccountUpdateSuccess": "Compte mis à jour",
"ToastAuthorImageRemoveFailed": "Échec de la suppression de limage", "ToastAuthorImageRemoveFailed": "Échec de la suppression de limage",
+87 -76
View File
@@ -1,10 +1,10 @@
{ {
"ButtonAdd": "ઉમેરો", "ButtonAdd": "ઉમેરો",
"ButtonAddChapters": "પ્રકરણો ઉમેરો", "ButtonAddChapters": "પ્રકરણો ઉમેરો",
"ButtonAddDevice": "Add Device", "ButtonAddDevice": "ઉપકરણ ઉમેરો",
"ButtonAddLibrary": "Add Library", "ButtonAddLibrary": "પુસ્તકાલય ઉમેરો",
"ButtonAddPodcasts": "પોડકાસ્ટ ઉમેરો", "ButtonAddPodcasts": "પોડકાસ્ટ ઉમેરો",
"ButtonAddUser": "Add User", "ButtonAddUser": "વપરાશકર્તા ઉમેરો",
"ButtonAddYourFirstLibrary": "તમારી પ્રથમ પુસ્તકાલય ઉમેરો", "ButtonAddYourFirstLibrary": "તમારી પ્રથમ પુસ્તકાલય ઉમેરો",
"ButtonApply": "લાગુ કરો", "ButtonApply": "લાગુ કરો",
"ButtonApplyChapters": "પ્રકરણો લાગુ કરો", "ButtonApplyChapters": "પ્રકરણો લાગુ કરો",
@@ -58,11 +58,11 @@
"ButtonRemoveAll": "બધું કાઢી નાખો", "ButtonRemoveAll": "બધું કાઢી નાખો",
"ButtonRemoveAllLibraryItems": "બધું પુસ્તકાલય વસ્તુઓ કાઢી નાખો", "ButtonRemoveAllLibraryItems": "બધું પુસ્તકાલય વસ્તુઓ કાઢી નાખો",
"ButtonRemoveFromContinueListening": "સાંભળતી પુસ્તકો માંથી કાઢી નાખો", "ButtonRemoveFromContinueListening": "સાંભળતી પુસ્તકો માંથી કાઢી નાખો",
"ButtonRemoveFromContinueReading": "Remove from Continue Reading", "ButtonRemoveFromContinueReading": "સાંભળતી પુસ્તકો માંથી કાઢી નાખો",
"ButtonRemoveSeriesFromContinueSeries": "સાંભળતી સિરીઝ માંથી કાઢી નાખો", "ButtonRemoveSeriesFromContinueSeries": "સાંભળતી સિરીઝ માંથી કાઢી નાખો",
"ButtonReScan": "ફરીથી સ્કેન કરો", "ButtonReScan": "ફરીથી સ્કેન કરો",
"ButtonReset": "રીસેટ કરો", "ButtonReset": "રીસેટ કરો",
"ButtonResetToDefault": "Reset to default", "ButtonResetToDefault": "ડિફોલ્ટ પર રીસેટ કરો",
"ButtonRestore": "પુનઃસ્થાપિત કરો", "ButtonRestore": "પુનઃસ્થાપિત કરો",
"ButtonSave": "સાચવો", "ButtonSave": "સાચવો",
"ButtonSaveAndClose": "સાચવો અને બંધ કરો", "ButtonSaveAndClose": "સાચવો અને બંધ કરો",
@@ -78,7 +78,7 @@
"ButtonStartM4BEncode": "M4B એન્કોડ શરૂ કરો", "ButtonStartM4BEncode": "M4B એન્કોડ શરૂ કરો",
"ButtonStartMetadataEmbed": "મેટાડેટા એમ્બેડ શરૂ કરો", "ButtonStartMetadataEmbed": "મેટાડેટા એમ્બેડ શરૂ કરો",
"ButtonSubmit": "સબમિટ કરો", "ButtonSubmit": "સબમિટ કરો",
"ButtonTest": "Test", "ButtonTest": "પરખ કરો",
"ButtonUpload": "અપલોડ કરો", "ButtonUpload": "અપલોડ કરો",
"ButtonUploadBackup": "બેકઅપ અપલોડ કરો", "ButtonUploadBackup": "બેકઅપ અપલોડ કરો",
"ButtonUploadCover": "કવર અપલોડ કરો", "ButtonUploadCover": "કવર અપલોડ કરો",
@@ -87,81 +87,84 @@
"ButtonUserEdit": "વપરાશકર્તા {0} સંપાદિત કરો", "ButtonUserEdit": "વપરાશકર્તા {0} સંપાદિત કરો",
"ButtonViewAll": "બધું જુઓ", "ButtonViewAll": "બધું જુઓ",
"ButtonYes": "હા", "ButtonYes": "હા",
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
"ErrorUploadLacksTitle": "Must have a title",
"HeaderAccount": "એકાઉન્ટ", "HeaderAccount": "એકાઉન્ટ",
"HeaderAdvanced": "અડ્વાન્સડ", "HeaderAdvanced": "અડ્વાન્સડ",
"HeaderAppriseNotificationSettings": "Apprise સૂચના સેટિંગ્સ", "HeaderAppriseNotificationSettings": "Apprise સૂચના સેટિંગ્સ",
"HeaderAudiobookTools": "Audiobook File Management Tools", "HeaderAudiobookTools": "ઓડિયોબુક ફાઇલ વ્યવસ્થાપન ટૂલ્સ",
"HeaderAudioTracks": "Audio Tracks", "HeaderAudioTracks": "ઓડિયો ટ્રેક્સ",
"HeaderAuthentication": "Authentication", "HeaderAuthentication": "Authentication",
"HeaderBackups": "Backups", "HeaderBackups": "બેકઅપ્સ",
"HeaderChangePassword": "Change Password", "HeaderChangePassword": "પાસવર્ડ બદલો",
"HeaderChapters": "Chapters", "HeaderChapters": "પ્રકરણો",
"HeaderChooseAFolder": "Choose a Folder", "HeaderChooseAFolder": "ફોલ્ડર પસંદ કરો",
"HeaderCollection": "Collection", "HeaderCollection": "સંગ્રહ",
"HeaderCollectionItems": "Collection Items", "HeaderCollectionItems": "સંગ્રહ વસ્તુઓ",
"HeaderCover": "Cover", "HeaderCover": "આવરણ",
"HeaderCurrentDownloads": "Current Downloads", "HeaderCurrentDownloads": "વર્તમાન ડાઉનલોડ્સ",
"HeaderDetails": "Details", "HeaderDetails": "વિગતો",
"HeaderDownloadQueue": "Download Queue", "HeaderDownloadQueue": "ડાઉનલોડ કતાર",
"HeaderEbookFiles": "Ebook Files", "HeaderEbookFiles": "ઇબુક ફાઇલો",
"HeaderEmail": "Email", "HeaderEmail": "ઈમેલ",
"HeaderEmailSettings": "Email Settings", "HeaderEmailSettings": "ઈમેલ સેટિંગ્સ",
"HeaderEpisodes": "Episodes", "HeaderEpisodes": "એપિસોડ્સ",
"HeaderEreaderDevices": "Ereader Devices", "HeaderEreaderDevices": "ઇરીડર ઉપકરણો",
"HeaderEreaderSettings": "Ereader Settings", "HeaderEreaderSettings": "ઇરીડર સેટિંગ્સ",
"HeaderFiles": "Files", "HeaderFiles": "ફાઇલો",
"HeaderFindChapters": "Find Chapters", "HeaderFindChapters": "પ્રકરણો શોધો",
"HeaderIgnoredFiles": "Ignored Files", "HeaderIgnoredFiles": "અવગણેલી ફાઇલો",
"HeaderItemFiles": "Item Files", "HeaderItemFiles": "વાસ્તુ ની ફાઈલો",
"HeaderItemMetadataUtils": "Item Metadata Utils", "HeaderItemMetadataUtils": "વસ્તુ મેટાડેટા સાધનો",
"HeaderLastListeningSession": "Last Listening Session", "HeaderLastListeningSession": "છેલ્લી સાંભળતી સેશન",
"HeaderLatestEpisodes": "Latest episodes", "HeaderLatestEpisodes": "નવીનતમ એપિસોડ્સ",
"HeaderLibraries": "Libraries", "HeaderLibraries": "પુસ્તકાલયો",
"HeaderLibraryFiles": "Library Files", "HeaderLibraryFiles": "પુસ્તકાલય ફાઇલો",
"HeaderLibraryStats": "Library Stats", "HeaderLibraryStats": "પુસ્તકાલય આંકડા",
"HeaderListeningSessions": "Listening Sessions", "HeaderListeningSessions": "સાંભળતી સેશન્સ",
"HeaderListeningStats": "Listening Stats", "HeaderListeningStats": "સાંભળતી આંકડા",
"HeaderLogin": "Login", "HeaderLogin": "લોગિન",
"HeaderLogs": "Logs", "HeaderLogs": "લોગ્સ",
"HeaderManageGenres": "Manage Genres", "HeaderManageGenres": "જાતિઓ મેનેજ કરો",
"HeaderManageTags": "Manage Tags", "HeaderManageTags": "ટેગ્સ મેનેજ કરો",
"HeaderMapDetails": "Map details", "HeaderMapDetails": "વિગતો મેપ કરો",
"HeaderMatch": "Match", "HeaderMatch": "મેળ ખાતી શોધો",
"HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataOrderOfPrecedence": "મેટાડેટા પ્રાધાન્યતાનો ક્રમ",
"HeaderMetadataToEmbed": "Metadata to embed", "HeaderMetadataToEmbed": "એમ્બેડ કરવા માટે મેટાડેટા",
"HeaderNewAccount": "New Account", "HeaderNewAccount": "નવું એકાઉન્ટ",
"HeaderNewLibrary": "New Library", "HeaderNewLibrary": "નવી પુસ્તકાલય",
"HeaderNotifications": "Notifications", "HeaderNotifications": "સૂચનાઓ",
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication", "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
"HeaderOpenRSSFeed": "Open RSS Feed", "HeaderOpenRSSFeed": "RSS ફીડ ખોલો",
"HeaderOtherFiles": "Other Files", "HeaderOtherFiles": "અન્ય ફાઇલો",
"HeaderPasswordAuthentication": "Password Authentication", "HeaderPasswordAuthentication": "Password Authentication",
"HeaderPermissions": "Permissions", "HeaderPermissions": "પરવાનગીઓ",
"HeaderPlayerQueue": "Player Queue", "HeaderPlayerQueue": "પ્લેયર કતાર",
"HeaderPlaylist": "Playlist", "HeaderPlaylist": "પ્લેલિસ્ટ",
"HeaderPlaylistItems": "Playlist Items", "HeaderPlaylistItems": "પ્લેલિસ્ટ ની વસ્તુઓ",
"HeaderPodcastsToAdd": "Podcasts to Add", "HeaderPodcastsToAdd": "ઉમેરવા માટે પોડકાસ્ટ્સ",
"HeaderPreviewCover": "Preview Cover", "HeaderPreviewCover": "પૂર્વાવલોકન કવર",
"HeaderRemoveEpisode": "Remove Episode", "HeaderRemoveEpisode": "એપિસોડ કાઢી નાખો",
"HeaderRemoveEpisodes": "Remove {0} Episodes", "HeaderRemoveEpisodes": "{0} એપિસોડ્સ કાઢી નાખો",
"HeaderRSSFeedGeneral": "RSS Details", "HeaderRSSFeedGeneral": "સામાન્ય RSS ફીડ",
"HeaderRSSFeedIsOpen": "RSS Feed is Open", "HeaderRSSFeedIsOpen": "RSS ફીડ ખોલેલી છે",
"HeaderRSSFeeds": "RSS Feeds", "HeaderRSSFeeds": "RSS ફીડ્સ",
"HeaderSavedMediaProgress": "Saved Media Progress", "HeaderSavedMediaProgress": "સાચવેલ મીડિયા પ્રગતિ",
"HeaderSchedule": "Schedule", "HeaderSchedule": "સમયપત્રક",
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans", "HeaderScheduleLibraryScans": "પુસ્તકાલય સ્કેન સમયપત્રક",
"HeaderSession": "Session", "HeaderSession": "સેશન",
"HeaderSetBackupSchedule": "Set Backup Schedule", "HeaderSetBackupSchedule": "બેકઅપ સમયપત્રક સેટ કરો",
"HeaderSettings": "Settings", "HeaderSettings": "સેટિંગ્સ",
"HeaderSettingsDisplay": "Display", "HeaderSettingsDisplay": "ડિસ્પ્લે સેટિંગ્સ",
"HeaderSettingsExperimental": "Experimental Features", "HeaderSettingsExperimental": "પ્રયોગશીલ સેટિંગ્સ",
"HeaderSettingsGeneral": "General", "HeaderSettingsGeneral": "સામાન્ય સેટિંગ્સ",
"HeaderSettingsScanner": "Scanner", "HeaderSettingsScanner": "સ્કેનર સેટિંગ્સ",
"HeaderSleepTimer": "Sleep Timer", "HeaderSleepTimer": "સ્લીપ ટાઈમર",
"HeaderStatsLargestItems": "Largest Items", "HeaderStatsLargestItems": "સૌથી મોટી વસ્તુઓ",
"HeaderStatsLongestItems": "Longest Items (hrs)", "HeaderStatsLongestItems": "સૌથી લાંબી વસ્તુઓ (કલાક)",
"HeaderStatsMinutesListeningChart": "Minutes Listening (last 7 days)", "HeaderStatsMinutesListeningChart": "સાંભળવાની મિનિટ (છેલ્લા ૭ દિવસ)",
"HeaderStatsRecentSessions": "Recent Sessions", "HeaderStatsRecentSessions": "છેલ્લી સાંભળતી સેશન્સ",
"HeaderStatsTop10Authors": "Top 10 Authors", "HeaderStatsTop10Authors": "Top 10 Authors",
"HeaderStatsTop5Genres": "Top 5 Genres", "HeaderStatsTop5Genres": "Top 5 Genres",
"HeaderTableOfContents": "Table of Contents", "HeaderTableOfContents": "Table of Contents",
@@ -196,6 +199,8 @@
"LabelAuthorLastFirst": "Author (Last, First)", "LabelAuthorLastFirst": "Author (Last, First)",
"LabelAuthors": "Authors", "LabelAuthors": "Authors",
"LabelAutoDownloadEpisodes": "Auto Download Episodes", "LabelAutoDownloadEpisodes": "Auto Download Episodes",
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
"LabelAutoLaunch": "Auto Launch", "LabelAutoLaunch": "Auto Launch",
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Auto Register", "LabelAutoRegister": "Auto Register",
@@ -266,6 +271,7 @@
"LabelExample": "Example", "LabelExample": "Example",
"LabelExplicit": "Explicit", "LabelExplicit": "Explicit",
"LabelFeedURL": "Feed URL", "LabelFeedURL": "Feed URL",
"LabelFetchingMetadata": "Fetching Metadata",
"LabelFile": "File", "LabelFile": "File",
"LabelFileBirthtime": "File Birthtime", "LabelFileBirthtime": "File Birthtime",
"LabelFileModified": "File Modified", "LabelFileModified": "File Modified",
@@ -337,6 +343,8 @@
"LabelMinute": "Minute", "LabelMinute": "Minute",
"LabelMissing": "Missing", "LabelMissing": "Missing",
"LabelMissingParts": "Missing Parts", "LabelMissingParts": "Missing Parts",
"LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs",
"LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.",
"LabelMore": "More", "LabelMore": "More",
"LabelMoreInfo": "More Info", "LabelMoreInfo": "More Info",
"LabelName": "Name", "LabelName": "Name",
@@ -398,6 +406,7 @@
"LabelRegion": "Region", "LabelRegion": "Region",
"LabelReleaseDate": "Release Date", "LabelReleaseDate": "Release Date",
"LabelRemoveCover": "Remove cover", "LabelRemoveCover": "Remove cover",
"LabelRowsPerPage": "Rows per page",
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email", "LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
"LabelRSSFeedCustomOwnerName": "Custom owner Name", "LabelRSSFeedCustomOwnerName": "Custom owner Name",
"LabelRSSFeedOpen": "RSS Feed Open", "LabelRSSFeedOpen": "RSS Feed Open",
@@ -515,6 +524,7 @@
"LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located", "LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located",
"LabelUploaderDragAndDrop": "Drag & drop files or folders", "LabelUploaderDragAndDrop": "Drag & drop files or folders",
"LabelUploaderDropFiles": "Drop files", "LabelUploaderDropFiles": "Drop files",
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
"LabelUseChapterTrack": "Use chapter track", "LabelUseChapterTrack": "Use chapter track",
"LabelUseFullTrack": "Use full track", "LabelUseFullTrack": "Use full track",
"LabelUser": "User", "LabelUser": "User",
@@ -562,6 +572,7 @@
"MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?", "MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?",
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?", "MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?", "MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?", "MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?", "MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?", "MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
@@ -641,6 +652,7 @@
"MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on", "MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on",
"MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.", "MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.",
"MessageSearchResultsFor": "Search results for", "MessageSearchResultsFor": "Search results for",
"MessageSelected": "{0} selected",
"MessageServerCouldNotBeReached": "Server could not be reached", "MessageServerCouldNotBeReached": "Server could not be reached",
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name", "MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
"MessageStartPlaybackAtTime": "Start playback for \"{0}\" at {1}?", "MessageStartPlaybackAtTime": "Start playback for \"{0}\" at {1}?",
@@ -656,7 +668,6 @@
"NoteChangeRootPassword": "Root user is the only user that can have an empty password", "NoteChangeRootPassword": "Root user is the only user that can have an empty password",
"NoteChapterEditorTimes": "Note: First chapter start time must remain at 0:00 and the last chapter start time cannot exceed this audiobooks duration.", "NoteChapterEditorTimes": "Note: First chapter start time must remain at 0:00 and the last chapter start time cannot exceed this audiobooks duration.",
"NoteFolderPicker": "Note: folders already mapped will not be shown", "NoteFolderPicker": "Note: folders already mapped will not be shown",
"NoteFolderPickerDebian": "Note: Folder picker for the debian install is not fully implemented. You should enter the path to your library directly.",
"NoteRSSFeedPodcastAppsHttps": "Warning: Most podcast apps will require the RSS feed URL is using HTTPS", "NoteRSSFeedPodcastAppsHttps": "Warning: Most podcast apps will require the RSS feed URL is using HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.", "NoteRSSFeedPodcastAppsPubDate": "Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.",
"NoteUploaderFoldersWithMediaFiles": "Folders with media files will be handled as separate library items.", "NoteUploaderFoldersWithMediaFiles": "Folders with media files will be handled as separate library items.",
+12 -1
View File
@@ -87,6 +87,9 @@
"ButtonUserEdit": "उपयोगकर्ता {0} को संपादित करें", "ButtonUserEdit": "उपयोगकर्ता {0} को संपादित करें",
"ButtonViewAll": "सभी को देखें", "ButtonViewAll": "सभी को देखें",
"ButtonYes": "हाँ", "ButtonYes": "हाँ",
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
"ErrorUploadLacksTitle": "Must have a title",
"HeaderAccount": "खाता", "HeaderAccount": "खाता",
"HeaderAdvanced": "विकसित", "HeaderAdvanced": "विकसित",
"HeaderAppriseNotificationSettings": "Apprise अधिसूचना सेटिंग्स", "HeaderAppriseNotificationSettings": "Apprise अधिसूचना सेटिंग्स",
@@ -196,6 +199,8 @@
"LabelAuthorLastFirst": "Author (Last, First)", "LabelAuthorLastFirst": "Author (Last, First)",
"LabelAuthors": "Authors", "LabelAuthors": "Authors",
"LabelAutoDownloadEpisodes": "Auto Download Episodes", "LabelAutoDownloadEpisodes": "Auto Download Episodes",
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
"LabelAutoLaunch": "Auto Launch", "LabelAutoLaunch": "Auto Launch",
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Auto Register", "LabelAutoRegister": "Auto Register",
@@ -266,6 +271,7 @@
"LabelExample": "Example", "LabelExample": "Example",
"LabelExplicit": "Explicit", "LabelExplicit": "Explicit",
"LabelFeedURL": "Feed URL", "LabelFeedURL": "Feed URL",
"LabelFetchingMetadata": "Fetching Metadata",
"LabelFile": "File", "LabelFile": "File",
"LabelFileBirthtime": "File Birthtime", "LabelFileBirthtime": "File Birthtime",
"LabelFileModified": "File Modified", "LabelFileModified": "File Modified",
@@ -337,6 +343,8 @@
"LabelMinute": "Minute", "LabelMinute": "Minute",
"LabelMissing": "Missing", "LabelMissing": "Missing",
"LabelMissingParts": "Missing Parts", "LabelMissingParts": "Missing Parts",
"LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs",
"LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.",
"LabelMore": "More", "LabelMore": "More",
"LabelMoreInfo": "More Info", "LabelMoreInfo": "More Info",
"LabelName": "Name", "LabelName": "Name",
@@ -398,6 +406,7 @@
"LabelRegion": "Region", "LabelRegion": "Region",
"LabelReleaseDate": "Release Date", "LabelReleaseDate": "Release Date",
"LabelRemoveCover": "Remove cover", "LabelRemoveCover": "Remove cover",
"LabelRowsPerPage": "Rows per page",
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email", "LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
"LabelRSSFeedCustomOwnerName": "Custom owner Name", "LabelRSSFeedCustomOwnerName": "Custom owner Name",
"LabelRSSFeedOpen": "RSS Feed Open", "LabelRSSFeedOpen": "RSS Feed Open",
@@ -515,6 +524,7 @@
"LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located", "LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located",
"LabelUploaderDragAndDrop": "Drag & drop files or folders", "LabelUploaderDragAndDrop": "Drag & drop files or folders",
"LabelUploaderDropFiles": "Drop files", "LabelUploaderDropFiles": "Drop files",
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
"LabelUseChapterTrack": "Use chapter track", "LabelUseChapterTrack": "Use chapter track",
"LabelUseFullTrack": "Use full track", "LabelUseFullTrack": "Use full track",
"LabelUser": "User", "LabelUser": "User",
@@ -562,6 +572,7 @@
"MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?", "MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?",
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?", "MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?", "MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?", "MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?", "MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?", "MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
@@ -641,6 +652,7 @@
"MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on", "MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on",
"MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.", "MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.",
"MessageSearchResultsFor": "Search results for", "MessageSearchResultsFor": "Search results for",
"MessageSelected": "{0} selected",
"MessageServerCouldNotBeReached": "Server could not be reached", "MessageServerCouldNotBeReached": "Server could not be reached",
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name", "MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
"MessageStartPlaybackAtTime": "Start playback for \"{0}\" at {1}?", "MessageStartPlaybackAtTime": "Start playback for \"{0}\" at {1}?",
@@ -656,7 +668,6 @@
"NoteChangeRootPassword": "रूट user is the only user that can have an empty password", "NoteChangeRootPassword": "रूट user is the only user that can have an empty password",
"NoteChapterEditorTimes": "Note: First chapter start time must remain at 0:00 and the last chapter start time cannot exceed this audiobooks duration.", "NoteChapterEditorTimes": "Note: First chapter start time must remain at 0:00 and the last chapter start time cannot exceed this audiobooks duration.",
"NoteFolderPicker": "Note: folders already mapped will not be shown", "NoteFolderPicker": "Note: folders already mapped will not be shown",
"NoteFolderPickerDebian": "Note: Folder picker for the debian install is not fully implemented. You should enter the path to your library directly.",
"NoteRSSFeedPodcastAppsHttps": "Warning: Most podcast apps will require the RSS feed URL is using HTTPS", "NoteRSSFeedPodcastAppsHttps": "Warning: Most podcast apps will require the RSS feed URL is using HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.", "NoteRSSFeedPodcastAppsPubDate": "Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.",
"NoteUploaderFoldersWithMediaFiles": "Folders with media files will be handled as separate library items.", "NoteUploaderFoldersWithMediaFiles": "Folders with media files will be handled as separate library items.",
+12 -1
View File
@@ -87,6 +87,9 @@
"ButtonUserEdit": "Edit user {0}", "ButtonUserEdit": "Edit user {0}",
"ButtonViewAll": "Prikaži sve", "ButtonViewAll": "Prikaži sve",
"ButtonYes": "Da", "ButtonYes": "Da",
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
"ErrorUploadLacksTitle": "Must have a title",
"HeaderAccount": "Korisnički račun", "HeaderAccount": "Korisnički račun",
"HeaderAdvanced": "Napredno", "HeaderAdvanced": "Napredno",
"HeaderAppriseNotificationSettings": "Apprise Notification Settings", "HeaderAppriseNotificationSettings": "Apprise Notification Settings",
@@ -196,6 +199,8 @@
"LabelAuthorLastFirst": "Author (Last, First)", "LabelAuthorLastFirst": "Author (Last, First)",
"LabelAuthors": "Autori", "LabelAuthors": "Autori",
"LabelAutoDownloadEpisodes": "Automatski preuzmi epizode", "LabelAutoDownloadEpisodes": "Automatski preuzmi epizode",
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
"LabelAutoLaunch": "Auto Launch", "LabelAutoLaunch": "Auto Launch",
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Auto Register", "LabelAutoRegister": "Auto Register",
@@ -266,6 +271,7 @@
"LabelExample": "Example", "LabelExample": "Example",
"LabelExplicit": "Explicit", "LabelExplicit": "Explicit",
"LabelFeedURL": "Feed URL", "LabelFeedURL": "Feed URL",
"LabelFetchingMetadata": "Fetching Metadata",
"LabelFile": "Datoteka", "LabelFile": "Datoteka",
"LabelFileBirthtime": "File Birthtime", "LabelFileBirthtime": "File Birthtime",
"LabelFileModified": "File Modified", "LabelFileModified": "File Modified",
@@ -337,6 +343,8 @@
"LabelMinute": "Minuta", "LabelMinute": "Minuta",
"LabelMissing": "Nedostaje", "LabelMissing": "Nedostaje",
"LabelMissingParts": "Nedostajali dijelovi", "LabelMissingParts": "Nedostajali dijelovi",
"LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs",
"LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.",
"LabelMore": "Više", "LabelMore": "Više",
"LabelMoreInfo": "More Info", "LabelMoreInfo": "More Info",
"LabelName": "Ime", "LabelName": "Ime",
@@ -398,6 +406,7 @@
"LabelRegion": "Regija", "LabelRegion": "Regija",
"LabelReleaseDate": "Datum izlaska", "LabelReleaseDate": "Datum izlaska",
"LabelRemoveCover": "Remove cover", "LabelRemoveCover": "Remove cover",
"LabelRowsPerPage": "Rows per page",
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email", "LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
"LabelRSSFeedCustomOwnerName": "Custom owner Name", "LabelRSSFeedCustomOwnerName": "Custom owner Name",
"LabelRSSFeedOpen": "RSS Feed Open", "LabelRSSFeedOpen": "RSS Feed Open",
@@ -515,6 +524,7 @@
"LabelUpdateDetailsHelp": "Dozvoli postavljanje novih detalja za odabrane knjige nakon što je match pronađen", "LabelUpdateDetailsHelp": "Dozvoli postavljanje novih detalja za odabrane knjige nakon što je match pronađen",
"LabelUploaderDragAndDrop": "Drag & Drop datoteke ili foldere", "LabelUploaderDragAndDrop": "Drag & Drop datoteke ili foldere",
"LabelUploaderDropFiles": "Ubaci datoteke", "LabelUploaderDropFiles": "Ubaci datoteke",
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
"LabelUseChapterTrack": "Koristi poglavlja track", "LabelUseChapterTrack": "Koristi poglavlja track",
"LabelUseFullTrack": "Koristi cijeli track", "LabelUseFullTrack": "Koristi cijeli track",
"LabelUser": "Korisnik", "LabelUser": "Korisnik",
@@ -562,6 +572,7 @@
"MessageConfirmRemoveCollection": "AJeste li sigurni da želite obrisati kolekciju \"{0}\"?", "MessageConfirmRemoveCollection": "AJeste li sigurni da želite obrisati kolekciju \"{0}\"?",
"MessageConfirmRemoveEpisode": "Jeste li sigurni da želite obrisati epizodu \"{0}\"?", "MessageConfirmRemoveEpisode": "Jeste li sigurni da želite obrisati epizodu \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Jeste li sigurni da želite obrisati {0} epizoda/-u?", "MessageConfirmRemoveEpisodes": "Jeste li sigurni da želite obrisati {0} epizoda/-u?",
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?", "MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?", "MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?", "MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
@@ -641,6 +652,7 @@
"MessageRestoreBackupConfirm": "Jeste li sigurni da želite povratiti backup kreiran", "MessageRestoreBackupConfirm": "Jeste li sigurni da želite povratiti backup kreiran",
"MessageRestoreBackupWarning": "Povračanje backupa će zamijeniti postoječu bazu podataka u /config i slike covera u /metadata/items i /metadata/authors.<br /><br />Backups ne modificiraju nikakve datoteke u folderu od biblioteke. Ako imate uključene server postavke da spremate cover i metapodtake u folderu od biblioteke, onda oni neće biti backupani ili overwritten.<br /><br />Svi klijenti koji koriste tvoj server će biti automatski osvježeni.", "MessageRestoreBackupWarning": "Povračanje backupa će zamijeniti postoječu bazu podataka u /config i slike covera u /metadata/items i /metadata/authors.<br /><br />Backups ne modificiraju nikakve datoteke u folderu od biblioteke. Ako imate uključene server postavke da spremate cover i metapodtake u folderu od biblioteke, onda oni neće biti backupani ili overwritten.<br /><br />Svi klijenti koji koriste tvoj server će biti automatski osvježeni.",
"MessageSearchResultsFor": "Traži rezultate za", "MessageSearchResultsFor": "Traži rezultate za",
"MessageSelected": "{0} selected",
"MessageServerCouldNotBeReached": "Server ne može biti kontaktiran", "MessageServerCouldNotBeReached": "Server ne može biti kontaktiran",
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name", "MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
"MessageStartPlaybackAtTime": "Pokreni reprodukciju za \"{0}\" na {1}?", "MessageStartPlaybackAtTime": "Pokreni reprodukciju za \"{0}\" na {1}?",
@@ -656,7 +668,6 @@
"NoteChangeRootPassword": "Root korisnik je jedini korisnik koji može imati praznu lozinku", "NoteChangeRootPassword": "Root korisnik je jedini korisnik koji može imati praznu lozinku",
"NoteChapterEditorTimes": "Bilješka: Prvo početno vrijeme poglavlja mora ostati na 0:00 i posljednje vrijeme poglavlja ne smije preći vrijeme trajanja ove audio knjige.", "NoteChapterEditorTimes": "Bilješka: Prvo početno vrijeme poglavlja mora ostati na 0:00 i posljednje vrijeme poglavlja ne smije preći vrijeme trajanja ove audio knjige.",
"NoteFolderPicker": "Bilješka: več mapirani folderi neće biti prikazani", "NoteFolderPicker": "Bilješka: več mapirani folderi neće biti prikazani",
"NoteFolderPickerDebian": "Bilješka: Folder picker za debian instalaciju nije potpuno implementiran. Trebate unjeti direktnu putanju do biblioteke.",
"NoteRSSFeedPodcastAppsHttps": "Upozorenje: Večina podcasta će trebati RSS feed URL koji koristi HTTPS", "NoteRSSFeedPodcastAppsHttps": "Upozorenje: Večina podcasta će trebati RSS feed URL koji koristi HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Upozorenje: 1 ili više vaših epizoda nemaju datum objavljivanja. Neke podcast aplikacije zahtjevaju to.", "NoteRSSFeedPodcastAppsPubDate": "Upozorenje: 1 ili više vaših epizoda nemaju datum objavljivanja. Neke podcast aplikacije zahtjevaju to.",
"NoteUploaderFoldersWithMediaFiles": "Folderi sa media datotekama će biti tretirane kao odvojene stavke u biblioteki.", "NoteUploaderFoldersWithMediaFiles": "Folderi sa media datotekama će biti tretirane kao odvojene stavke u biblioteki.",
+12 -1
View File
@@ -87,6 +87,9 @@
"ButtonUserEdit": "Modifica Utente {0}", "ButtonUserEdit": "Modifica Utente {0}",
"ButtonViewAll": "Mostra Tutto", "ButtonViewAll": "Mostra Tutto",
"ButtonYes": "Si", "ButtonYes": "Si",
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
"ErrorUploadLacksTitle": "Must have a title",
"HeaderAccount": "Account", "HeaderAccount": "Account",
"HeaderAdvanced": "Avanzate", "HeaderAdvanced": "Avanzate",
"HeaderAppriseNotificationSettings": "Apprendi le impostazioni di Notifica", "HeaderAppriseNotificationSettings": "Apprendi le impostazioni di Notifica",
@@ -196,6 +199,8 @@
"LabelAuthorLastFirst": "Autori (Per Cognome)", "LabelAuthorLastFirst": "Autori (Per Cognome)",
"LabelAuthors": "Autori", "LabelAuthors": "Autori",
"LabelAutoDownloadEpisodes": "Auto Download Episodi", "LabelAutoDownloadEpisodes": "Auto Download Episodi",
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
"LabelAutoLaunch": "Auto Launch", "LabelAutoLaunch": "Auto Launch",
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Auto Register", "LabelAutoRegister": "Auto Register",
@@ -266,6 +271,7 @@
"LabelExample": "Esempio", "LabelExample": "Esempio",
"LabelExplicit": "Esplicito", "LabelExplicit": "Esplicito",
"LabelFeedURL": "Feed URL", "LabelFeedURL": "Feed URL",
"LabelFetchingMetadata": "Fetching Metadata",
"LabelFile": "File", "LabelFile": "File",
"LabelFileBirthtime": "Data Creazione", "LabelFileBirthtime": "Data Creazione",
"LabelFileModified": "Ultima modifica", "LabelFileModified": "Ultima modifica",
@@ -337,6 +343,8 @@
"LabelMinute": "Minuto", "LabelMinute": "Minuto",
"LabelMissing": "Altro", "LabelMissing": "Altro",
"LabelMissingParts": "Parti rimantenti", "LabelMissingParts": "Parti rimantenti",
"LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs",
"LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.",
"LabelMore": "Molto", "LabelMore": "Molto",
"LabelMoreInfo": "Più Info", "LabelMoreInfo": "Più Info",
"LabelName": "Nome", "LabelName": "Nome",
@@ -398,6 +406,7 @@
"LabelRegion": "Regione", "LabelRegion": "Regione",
"LabelReleaseDate": "Data Release", "LabelReleaseDate": "Data Release",
"LabelRemoveCover": "Rimuovi cover", "LabelRemoveCover": "Rimuovi cover",
"LabelRowsPerPage": "Rows per page",
"LabelRSSFeedCustomOwnerEmail": "Email del proprietario personalizzato", "LabelRSSFeedCustomOwnerEmail": "Email del proprietario personalizzato",
"LabelRSSFeedCustomOwnerName": "Nome del proprietario personalizzato", "LabelRSSFeedCustomOwnerName": "Nome del proprietario personalizzato",
"LabelRSSFeedOpen": "RSS Feed Aperto", "LabelRSSFeedOpen": "RSS Feed Aperto",
@@ -515,6 +524,7 @@
"LabelUpdateDetailsHelp": "Consenti la sovrascrittura dei dettagli esistenti per i libri selezionati quando viene individuata una corrispondenza", "LabelUpdateDetailsHelp": "Consenti la sovrascrittura dei dettagli esistenti per i libri selezionati quando viene individuata una corrispondenza",
"LabelUploaderDragAndDrop": "Drag & drop file o Cartelle", "LabelUploaderDragAndDrop": "Drag & drop file o Cartelle",
"LabelUploaderDropFiles": "Elimina file", "LabelUploaderDropFiles": "Elimina file",
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
"LabelUseChapterTrack": "Usa il Capitolo della Traccia", "LabelUseChapterTrack": "Usa il Capitolo della Traccia",
"LabelUseFullTrack": "Usa la traccia totale", "LabelUseFullTrack": "Usa la traccia totale",
"LabelUser": "Utente", "LabelUser": "Utente",
@@ -562,6 +572,7 @@
"MessageConfirmRemoveCollection": "Sei sicuro di voler rimuovere la Raccolta \"{0}\"?", "MessageConfirmRemoveCollection": "Sei sicuro di voler rimuovere la Raccolta \"{0}\"?",
"MessageConfirmRemoveEpisode": "Sei sicuro di voler rimuovere l'episodio \"{0}\"?", "MessageConfirmRemoveEpisode": "Sei sicuro di voler rimuovere l'episodio \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Sei sicuro di voler rimuovere {0} episodi?", "MessageConfirmRemoveEpisodes": "Sei sicuro di voler rimuovere {0} episodi?",
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
"MessageConfirmRemoveNarrator": "Sei sicuro di voler rimuovere il narratore \"{0}\"?", "MessageConfirmRemoveNarrator": "Sei sicuro di voler rimuovere il narratore \"{0}\"?",
"MessageConfirmRemovePlaylist": "Sei sicuro di voler rimuovere la tua playlist \"{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?", "MessageConfirmRenameGenre": "Sei sicuro di voler rinominare il genere \"{0}\" in \"{1}\" per tutti gli oggetti?",
@@ -641,6 +652,7 @@
"MessageRestoreBackupConfirm": "Sei sicuro di voler ripristinare il backup creato su", "MessageRestoreBackupConfirm": "Sei sicuro di voler ripristinare il backup creato su",
"MessageRestoreBackupWarning": "Il ripristino di un backup sovrascriverà l'intero database situato in /config e sovrascrive le immagini in /metadata/items & /metadata/authors.<br /><br />I backup non modificano alcun file nelle cartelle della libreria. Se hai abilitato le impostazioni del server per archiviare copertine e metadati nelle cartelle della libreria, questi non vengono sottoposti a backup o sovrascritti.<br /><br />Tutti i client che utilizzano il tuo server verranno aggiornati automaticamente.", "MessageRestoreBackupWarning": "Il ripristino di un backup sovrascriverà l'intero database situato in /config e sovrascrive le immagini in /metadata/items & /metadata/authors.<br /><br />I backup non modificano alcun file nelle cartelle della libreria. Se hai abilitato le impostazioni del server per archiviare copertine e metadati nelle cartelle della libreria, questi non vengono sottoposti a backup o sovrascritti.<br /><br />Tutti i client che utilizzano il tuo server verranno aggiornati automaticamente.",
"MessageSearchResultsFor": "cerca risultati per", "MessageSearchResultsFor": "cerca risultati per",
"MessageSelected": "{0} selected",
"MessageServerCouldNotBeReached": "Impossibile raggiungere il server", "MessageServerCouldNotBeReached": "Impossibile raggiungere il server",
"MessageSetChaptersFromTracksDescription": "Impostare i capitoli utilizzando ciascun file audio come capitolo e il titolo del capitolo come nome del file audio", "MessageSetChaptersFromTracksDescription": "Impostare i capitoli utilizzando ciascun file audio come capitolo e il titolo del capitolo come nome del file audio",
"MessageStartPlaybackAtTime": "Avvia la riproduzione per \"{0}\" a {1}?", "MessageStartPlaybackAtTime": "Avvia la riproduzione per \"{0}\" a {1}?",
@@ -656,7 +668,6 @@
"NoteChangeRootPassword": "L'utente root è l'unico utente che può avere una password vuota", "NoteChangeRootPassword": "L'utente root è l'unico utente che può avere una password vuota",
"NoteChapterEditorTimes": "Nota: l'ora di inizio del primo capitolo deve rimanere alle 0:00 e l'ora di inizio dell'ultimo capitolo non può superare la durata di questo audiolibro.", "NoteChapterEditorTimes": "Nota: l'ora di inizio del primo capitolo deve rimanere alle 0:00 e l'ora di inizio dell'ultimo capitolo non può superare la durata di questo audiolibro.",
"NoteFolderPicker": "Nota: le cartelle già mappate non verranno visualizzate", "NoteFolderPicker": "Nota: le cartelle già mappate non verranno visualizzate",
"NoteFolderPickerDebian": "Nota: il selettore di cartelle per l'installazione di Debian non è completamente implementato. Dovresti inserire direttamente il percorso della tua libreria.",
"NoteRSSFeedPodcastAppsHttps": "Avviso: la maggior parte delle app di podcast richiede che l'URL del feed RSS utilizzi HTTPS", "NoteRSSFeedPodcastAppsHttps": "Avviso: la maggior parte delle app di podcast richiede che l'URL del feed RSS utilizzi HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Avviso: 1 o più delle tue puntate non hanno una data di pubblicazione. Alcune app di podcast lo richiedono.", "NoteRSSFeedPodcastAppsPubDate": "Avviso: 1 o più delle tue puntate non hanno una data di pubblicazione. Alcune app di podcast lo richiedono.",
"NoteUploaderFoldersWithMediaFiles": "Le cartelle con file multimediali verranno gestite come elementi della libreria separati.", "NoteUploaderFoldersWithMediaFiles": "Le cartelle con file multimediali verranno gestite come elementi della libreria separati.",
+12 -1
View File
@@ -87,6 +87,9 @@
"ButtonUserEdit": "Redaguoti naudotoją {0}", "ButtonUserEdit": "Redaguoti naudotoją {0}",
"ButtonViewAll": "Peržiūrėti visus", "ButtonViewAll": "Peržiūrėti visus",
"ButtonYes": "Taip", "ButtonYes": "Taip",
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
"ErrorUploadLacksTitle": "Must have a title",
"HeaderAccount": "Paskyra", "HeaderAccount": "Paskyra",
"HeaderAdvanced": "Papildomi", "HeaderAdvanced": "Papildomi",
"HeaderAppriseNotificationSettings": "Apprise pranešimo nustatymai", "HeaderAppriseNotificationSettings": "Apprise pranešimo nustatymai",
@@ -196,6 +199,8 @@
"LabelAuthorLastFirst": "Autorius (Pavardė, Vardas)", "LabelAuthorLastFirst": "Autorius (Pavardė, Vardas)",
"LabelAuthors": "Autoriai", "LabelAuthors": "Autoriai",
"LabelAutoDownloadEpisodes": "Automatiškai atsisiųsti epizodus", "LabelAutoDownloadEpisodes": "Automatiškai atsisiųsti epizodus",
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
"LabelAutoLaunch": "Auto Launch", "LabelAutoLaunch": "Auto Launch",
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Auto Register", "LabelAutoRegister": "Auto Register",
@@ -266,6 +271,7 @@
"LabelExample": "Pavyzdys", "LabelExample": "Pavyzdys",
"LabelExplicit": "Suaugusiems", "LabelExplicit": "Suaugusiems",
"LabelFeedURL": "Srauto URL", "LabelFeedURL": "Srauto URL",
"LabelFetchingMetadata": "Fetching Metadata",
"LabelFile": "Failas", "LabelFile": "Failas",
"LabelFileBirthtime": "Failo kūrimo laikas", "LabelFileBirthtime": "Failo kūrimo laikas",
"LabelFileModified": "Failo keitimo laikas", "LabelFileModified": "Failo keitimo laikas",
@@ -337,6 +343,8 @@
"LabelMinute": "Minutė", "LabelMinute": "Minutė",
"LabelMissing": "Trūksta", "LabelMissing": "Trūksta",
"LabelMissingParts": "Trūkstamos dalys", "LabelMissingParts": "Trūkstamos dalys",
"LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs",
"LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.",
"LabelMore": "Daugiau", "LabelMore": "Daugiau",
"LabelMoreInfo": "Daugiau informacijos", "LabelMoreInfo": "Daugiau informacijos",
"LabelName": "Pavadinimas", "LabelName": "Pavadinimas",
@@ -398,6 +406,7 @@
"LabelRegion": "Regionas", "LabelRegion": "Regionas",
"LabelReleaseDate": "Išleidimo data", "LabelReleaseDate": "Išleidimo data",
"LabelRemoveCover": "Pašalinti viršelį", "LabelRemoveCover": "Pašalinti viršelį",
"LabelRowsPerPage": "Rows per page",
"LabelRSSFeedCustomOwnerEmail": "Pasirinktinis savininko el. paštas", "LabelRSSFeedCustomOwnerEmail": "Pasirinktinis savininko el. paštas",
"LabelRSSFeedCustomOwnerName": "Pasirinktinis savininko vardas", "LabelRSSFeedCustomOwnerName": "Pasirinktinis savininko vardas",
"LabelRSSFeedOpen": "Atidarytas RSS srautas", "LabelRSSFeedOpen": "Atidarytas RSS srautas",
@@ -515,6 +524,7 @@
"LabelUpdateDetailsHelp": "Leisti perrašyti esamus duomenis pasirinktoms knygoms, kai yra rasta atitikmenų", "LabelUpdateDetailsHelp": "Leisti perrašyti esamus duomenis pasirinktoms knygoms, kai yra rasta atitikmenų",
"LabelUploaderDragAndDrop": "Tempkite ir paleiskite failus ar aplankus", "LabelUploaderDragAndDrop": "Tempkite ir paleiskite failus ar aplankus",
"LabelUploaderDropFiles": "Nutempti failus", "LabelUploaderDropFiles": "Nutempti failus",
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
"LabelUseChapterTrack": "Naudoti skyrių takelį", "LabelUseChapterTrack": "Naudoti skyrių takelį",
"LabelUseFullTrack": "Naudoti visą takelį", "LabelUseFullTrack": "Naudoti visą takelį",
"LabelUser": "Vartotojas", "LabelUser": "Vartotojas",
@@ -562,6 +572,7 @@
"MessageConfirmRemoveCollection": "Ar tikrai norite pašalinti kolekciją \"{0}\"?", "MessageConfirmRemoveCollection": "Ar tikrai norite pašalinti kolekciją \"{0}\"?",
"MessageConfirmRemoveEpisode": "Ar tikrai norite pašalinti epizodą \"{0}\"?", "MessageConfirmRemoveEpisode": "Ar tikrai norite pašalinti epizodą \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Ar tikrai norite pašalinti {0} epizodus?", "MessageConfirmRemoveEpisodes": "Ar tikrai norite pašalinti {0} epizodus?",
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
"MessageConfirmRemoveNarrator": "Ar tikrai norite pašalinti skaitytoją \"{0}\"?", "MessageConfirmRemoveNarrator": "Ar tikrai norite pašalinti skaitytoją \"{0}\"?",
"MessageConfirmRemovePlaylist": "Ar tikrai norite pašalinti savo grojaraštį \"{0}\"?", "MessageConfirmRemovePlaylist": "Ar tikrai norite pašalinti savo grojaraštį \"{0}\"?",
"MessageConfirmRenameGenre": "Ar tikrai norite pervadinti žanrą \"{0}\" į \"{1}\" visiems elementams?", "MessageConfirmRenameGenre": "Ar tikrai norite pervadinti žanrą \"{0}\" į \"{1}\" visiems elementams?",
@@ -641,6 +652,7 @@
"MessageRestoreBackupConfirm": "Ar tikrai norite atkurti atsarginę kopiją, sukurtą", "MessageRestoreBackupConfirm": "Ar tikrai norite atkurti atsarginę kopiją, sukurtą",
"MessageRestoreBackupWarning": "Atkurdami atsarginę kopiją perrašysite visą duomenų bazę, esančią /config ir viršelių vaizdus /metadata/items ir /metadata/authors.<br /><br />Atsarginės kopijos nekeičia jokių failų jūsų bibliotekos aplankuose. Jei esate įgalinę serverio nustatymus, kad viršelio meną ir metaduomenis saugotumėte savo bibliotekos aplankuose, šie neperrašomi ar atkuriami.<br /><br />Visi klientai, naudojantys jūsų serverį, bus automatiškai atnaujinti.", "MessageRestoreBackupWarning": "Atkurdami atsarginę kopiją perrašysite visą duomenų bazę, esančią /config ir viršelių vaizdus /metadata/items ir /metadata/authors.<br /><br />Atsarginės kopijos nekeičia jokių failų jūsų bibliotekos aplankuose. Jei esate įgalinę serverio nustatymus, kad viršelio meną ir metaduomenis saugotumėte savo bibliotekos aplankuose, šie neperrašomi ar atkuriami.<br /><br />Visi klientai, naudojantys jūsų serverį, bus automatiškai atnaujinti.",
"MessageSearchResultsFor": "Paieškos rezultatai „{0}“", "MessageSearchResultsFor": "Paieškos rezultatai „{0}“",
"MessageSelected": "{0} selected",
"MessageServerCouldNotBeReached": "Nepavyko pasiekti serverio", "MessageServerCouldNotBeReached": "Nepavyko pasiekti serverio",
"MessageSetChaptersFromTracksDescription": "Nustatyti skyrius, naudojant kiekvieną garso failą kaip skyrių ir skyriaus pavadinimą kaip garso failo pavadinimą", "MessageSetChaptersFromTracksDescription": "Nustatyti skyrius, naudojant kiekvieną garso failą kaip skyrių ir skyriaus pavadinimą kaip garso failo pavadinimą",
"MessageStartPlaybackAtTime": "Paleisti klausymą „{0}“ nuo {1}?", "MessageStartPlaybackAtTime": "Paleisti klausymą „{0}“ nuo {1}?",
@@ -656,7 +668,6 @@
"NoteChangeRootPassword": "Tik root vartotojas gali turėti tuščią slaptažodį", "NoteChangeRootPassword": "Tik root vartotojas gali turėti tuščią slaptažodį",
"NoteChapterEditorTimes": "Pastaba: Pirmasis skyriaus pradžios laikas turi likti 0:00, o paskutinio skyriaus pradžios laikas negali viršyti šios garso knygos trukmės.", "NoteChapterEditorTimes": "Pastaba: Pirmasis skyriaus pradžios laikas turi likti 0:00, o paskutinio skyriaus pradžios laikas negali viršyti šios garso knygos trukmės.",
"NoteFolderPicker": "Pastaba: jau susieti aplankai nebus rodomi", "NoteFolderPicker": "Pastaba: jau susieti aplankai nebus rodomi",
"NoteFolderPickerDebian": "Pastaba: Aplanko pasirinkimo įrankis „Debian“ sistemoje nėra visiškai įgyvendintas. Turėtumėte tiesiogiai įvesti kelią į savo biblioteką.",
"NoteRSSFeedPodcastAppsHttps": "Įspėjimas: Dauguma tinklalaidžių programų reikalauja, kad RSS kanalo URL būtų naudojamas su HTTPS", "NoteRSSFeedPodcastAppsHttps": "Įspėjimas: Dauguma tinklalaidžių programų reikalauja, kad RSS kanalo URL būtų naudojamas su HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Įspėjimas: Vienas ar daugiau jūsų epizodų neturi publikavimo datos. Kai kurios tinklalaidžių programos to reikalauja.", "NoteRSSFeedPodcastAppsPubDate": "Įspėjimas: Vienas ar daugiau jūsų epizodų neturi publikavimo datos. Kai kurios tinklalaidžių programos to reikalauja.",
"NoteUploaderFoldersWithMediaFiles": "Aplankai su medijos failais bus tvarkomi kaip atskiri bibliotekos elementai.", "NoteUploaderFoldersWithMediaFiles": "Aplankai su medijos failais bus tvarkomi kaip atskiri bibliotekos elementai.",
+12 -1
View File
@@ -87,6 +87,9 @@
"ButtonUserEdit": "Wijzig gebruiker {0}", "ButtonUserEdit": "Wijzig gebruiker {0}",
"ButtonViewAll": "Toon alle", "ButtonViewAll": "Toon alle",
"ButtonYes": "Ja", "ButtonYes": "Ja",
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
"ErrorUploadLacksTitle": "Must have a title",
"HeaderAccount": "Account", "HeaderAccount": "Account",
"HeaderAdvanced": "Geavanceerd", "HeaderAdvanced": "Geavanceerd",
"HeaderAppriseNotificationSettings": "Apprise-notificatie instellingen", "HeaderAppriseNotificationSettings": "Apprise-notificatie instellingen",
@@ -196,6 +199,8 @@
"LabelAuthorLastFirst": "Auteur (Achternaam, Voornaam)", "LabelAuthorLastFirst": "Auteur (Achternaam, Voornaam)",
"LabelAuthors": "Auteurs", "LabelAuthors": "Auteurs",
"LabelAutoDownloadEpisodes": "Afleveringen automatisch downloaden", "LabelAutoDownloadEpisodes": "Afleveringen automatisch downloaden",
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
"LabelAutoLaunch": "Auto Launch", "LabelAutoLaunch": "Auto Launch",
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Auto Register", "LabelAutoRegister": "Auto Register",
@@ -266,6 +271,7 @@
"LabelExample": "Voorbeeld", "LabelExample": "Voorbeeld",
"LabelExplicit": "Expliciet", "LabelExplicit": "Expliciet",
"LabelFeedURL": "Feed URL", "LabelFeedURL": "Feed URL",
"LabelFetchingMetadata": "Fetching Metadata",
"LabelFile": "Bestand", "LabelFile": "Bestand",
"LabelFileBirthtime": "Aanmaaktijd bestand", "LabelFileBirthtime": "Aanmaaktijd bestand",
"LabelFileModified": "Bestand gewijzigd", "LabelFileModified": "Bestand gewijzigd",
@@ -337,6 +343,8 @@
"LabelMinute": "Minuut", "LabelMinute": "Minuut",
"LabelMissing": "Ontbrekend", "LabelMissing": "Ontbrekend",
"LabelMissingParts": "Ontbrekende delen", "LabelMissingParts": "Ontbrekende delen",
"LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs",
"LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.",
"LabelMore": "Meer", "LabelMore": "Meer",
"LabelMoreInfo": "Meer info", "LabelMoreInfo": "Meer info",
"LabelName": "Naam", "LabelName": "Naam",
@@ -398,6 +406,7 @@
"LabelRegion": "Regio", "LabelRegion": "Regio",
"LabelReleaseDate": "Verschijningsdatum", "LabelReleaseDate": "Verschijningsdatum",
"LabelRemoveCover": "Verwijder cover", "LabelRemoveCover": "Verwijder cover",
"LabelRowsPerPage": "Rows per page",
"LabelRSSFeedCustomOwnerEmail": "Aangepast e-mailadres eigenaar", "LabelRSSFeedCustomOwnerEmail": "Aangepast e-mailadres eigenaar",
"LabelRSSFeedCustomOwnerName": "Aangepaste naam eigenaar", "LabelRSSFeedCustomOwnerName": "Aangepaste naam eigenaar",
"LabelRSSFeedOpen": "RSS-feed open", "LabelRSSFeedOpen": "RSS-feed open",
@@ -515,6 +524,7 @@
"LabelUpdateDetailsHelp": "Sta overschrijven van bestaande details toe voor de geselecteerde boeken wanneer een match is gevonden", "LabelUpdateDetailsHelp": "Sta overschrijven van bestaande details toe voor de geselecteerde boeken wanneer een match is gevonden",
"LabelUploaderDragAndDrop": "Slepen & neerzeten van bestanden of mappen", "LabelUploaderDragAndDrop": "Slepen & neerzeten van bestanden of mappen",
"LabelUploaderDropFiles": "Bestanden neerzetten", "LabelUploaderDropFiles": "Bestanden neerzetten",
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
"LabelUseChapterTrack": "Gebruik hoofdstuktrack", "LabelUseChapterTrack": "Gebruik hoofdstuktrack",
"LabelUseFullTrack": "Gebruik volledige track", "LabelUseFullTrack": "Gebruik volledige track",
"LabelUser": "Gebruiker", "LabelUser": "Gebruiker",
@@ -562,6 +572,7 @@
"MessageConfirmRemoveCollection": "Weet je zeker dat je de collectie \"{0}\" wil verwijderen?", "MessageConfirmRemoveCollection": "Weet je zeker dat je de collectie \"{0}\" wil verwijderen?",
"MessageConfirmRemoveEpisode": "Weet je zeker dat je de aflevering \"{0}\" wil verwijderen?", "MessageConfirmRemoveEpisode": "Weet je zeker dat je de aflevering \"{0}\" wil verwijderen?",
"MessageConfirmRemoveEpisodes": "Weet je zeker dat je {0} afleveringen wil verwijderen?", "MessageConfirmRemoveEpisodes": "Weet je zeker dat je {0} afleveringen wil verwijderen?",
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
"MessageConfirmRemoveNarrator": "Weet je zeker dat je verteller \"{0}\" wil verwijderen?", "MessageConfirmRemoveNarrator": "Weet je zeker dat je verteller \"{0}\" wil verwijderen?",
"MessageConfirmRemovePlaylist": "Weet je zeker dat je je afspeellijst \"{0}\" wil verwijderen?", "MessageConfirmRemovePlaylist": "Weet je zeker dat je je afspeellijst \"{0}\" wil verwijderen?",
"MessageConfirmRenameGenre": "Weet je zeker dat je genre \"{0}\" wil hernoemen naar \"{1}\" voor alle onderdelen?", "MessageConfirmRenameGenre": "Weet je zeker dat je genre \"{0}\" wil hernoemen naar \"{1}\" voor alle onderdelen?",
@@ -641,6 +652,7 @@
"MessageRestoreBackupConfirm": "Weet je zeker dat je wil herstellen met behulp van de back-up gemaakt op", "MessageRestoreBackupConfirm": "Weet je zeker dat je wil herstellen met behulp van de back-up gemaakt op",
"MessageRestoreBackupWarning": "Herstellen met een back-up zal de volledige database in /config en de covers in /metadata/items & /metadata/authors overschrijven.<br /><br />Back-ups wijzigen geen bestanden in je bibliotheekmappen. Als je de serverinstelling gebruikt om covers en metadata in je bibliotheekmappen te bewaren dan worden deze niet geback-upt of overschreven.<br /><br />Alle clients die van je server gebruik maken zullen automatisch worden ververst.", "MessageRestoreBackupWarning": "Herstellen met een back-up zal de volledige database in /config en de covers in /metadata/items & /metadata/authors overschrijven.<br /><br />Back-ups wijzigen geen bestanden in je bibliotheekmappen. Als je de serverinstelling gebruikt om covers en metadata in je bibliotheekmappen te bewaren dan worden deze niet geback-upt of overschreven.<br /><br />Alle clients die van je server gebruik maken zullen automatisch worden ververst.",
"MessageSearchResultsFor": "Zoekresultaten voor", "MessageSearchResultsFor": "Zoekresultaten voor",
"MessageSelected": "{0} selected",
"MessageServerCouldNotBeReached": "Server niet bereikbaar", "MessageServerCouldNotBeReached": "Server niet bereikbaar",
"MessageSetChaptersFromTracksDescription": "Stel hoofdstukken in met ieder audiobestand als een hoofdstuk en de audiobestandsnaam als hoofdstuktitel", "MessageSetChaptersFromTracksDescription": "Stel hoofdstukken in met ieder audiobestand als een hoofdstuk en de audiobestandsnaam als hoofdstuktitel",
"MessageStartPlaybackAtTime": "Afspelen van \"{0}\" beginnen op {1}?", "MessageStartPlaybackAtTime": "Afspelen van \"{0}\" beginnen op {1}?",
@@ -656,7 +668,6 @@
"NoteChangeRootPassword": "Root-gebruiker is de enige gebruiker die een leeg wachtwoord kan hebben", "NoteChangeRootPassword": "Root-gebruiker is de enige gebruiker die een leeg wachtwoord kan hebben",
"NoteChapterEditorTimes": "Opmerking: Starttijd van het eerste hoofdstuk moet op 0:00 blijven en de starttijd van het laatste hoofdstuk mag niet de duur van het audioboek overschrijden.", "NoteChapterEditorTimes": "Opmerking: Starttijd van het eerste hoofdstuk moet op 0:00 blijven en de starttijd van het laatste hoofdstuk mag niet de duur van het audioboek overschrijden.",
"NoteFolderPicker": "Opmerking: Reeds gemapte mappen worden niet getoond", "NoteFolderPicker": "Opmerking: Reeds gemapte mappen worden niet getoond",
"NoteFolderPickerDebian": "Opmerking: Mappenkiezer voor de debian installatie is niet volledig geimplementeerd. Je moet het pad naar je map zelf invoeren.",
"NoteRSSFeedPodcastAppsHttps": "Waarschuwing: De meeste podcast-apps zullen eisen dat de RSS-feed URL HTTPS gebruikt", "NoteRSSFeedPodcastAppsHttps": "Waarschuwing: De meeste podcast-apps zullen eisen dat de RSS-feed URL HTTPS gebruikt",
"NoteRSSFeedPodcastAppsPubDate": "Waarschuwing: 1 of meer van je afleveringen hebben geen Pub Datum. Sommige podcast-apps vereisen dit.", "NoteRSSFeedPodcastAppsPubDate": "Waarschuwing: 1 of meer van je afleveringen hebben geen Pub Datum. Sommige podcast-apps vereisen dit.",
"NoteUploaderFoldersWithMediaFiles": "Mappen met mediabestanden zullen worden behandeld als aparte bibliotheekonderdelen.", "NoteUploaderFoldersWithMediaFiles": "Mappen met mediabestanden zullen worden behandeld als aparte bibliotheekonderdelen.",
+12 -1
View File
@@ -87,6 +87,9 @@
"ButtonUserEdit": "Rediger bruker {0}", "ButtonUserEdit": "Rediger bruker {0}",
"ButtonViewAll": "Vis alt", "ButtonViewAll": "Vis alt",
"ButtonYes": "Ja", "ButtonYes": "Ja",
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
"ErrorUploadLacksTitle": "Must have a title",
"HeaderAccount": "Konto", "HeaderAccount": "Konto",
"HeaderAdvanced": "Avansert", "HeaderAdvanced": "Avansert",
"HeaderAppriseNotificationSettings": "Apprise notifikasjonsinstillinger", "HeaderAppriseNotificationSettings": "Apprise notifikasjonsinstillinger",
@@ -196,6 +199,8 @@
"LabelAuthorLastFirst": "Forfatter (Etternavn Fornavn)", "LabelAuthorLastFirst": "Forfatter (Etternavn Fornavn)",
"LabelAuthors": "Forfattere", "LabelAuthors": "Forfattere",
"LabelAutoDownloadEpisodes": "Last ned episoder automatisk", "LabelAutoDownloadEpisodes": "Last ned episoder automatisk",
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
"LabelAutoLaunch": "Auto Launch", "LabelAutoLaunch": "Auto Launch",
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Auto Register", "LabelAutoRegister": "Auto Register",
@@ -266,6 +271,7 @@
"LabelExample": "Eksempel", "LabelExample": "Eksempel",
"LabelExplicit": "Eksplisitt", "LabelExplicit": "Eksplisitt",
"LabelFeedURL": "Feed Adresse", "LabelFeedURL": "Feed Adresse",
"LabelFetchingMetadata": "Fetching Metadata",
"LabelFile": "Fil", "LabelFile": "Fil",
"LabelFileBirthtime": "Fil Opprettelsesdato", "LabelFileBirthtime": "Fil Opprettelsesdato",
"LabelFileModified": "Fil Endret", "LabelFileModified": "Fil Endret",
@@ -337,6 +343,8 @@
"LabelMinute": "Minutt", "LabelMinute": "Minutt",
"LabelMissing": "Mangler", "LabelMissing": "Mangler",
"LabelMissingParts": "Manglende deler", "LabelMissingParts": "Manglende deler",
"LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs",
"LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.",
"LabelMore": "Mer", "LabelMore": "Mer",
"LabelMoreInfo": "Mer info", "LabelMoreInfo": "Mer info",
"LabelName": "Navn", "LabelName": "Navn",
@@ -398,6 +406,7 @@
"LabelRegion": "Region", "LabelRegion": "Region",
"LabelReleaseDate": "Utgivelsesdato", "LabelReleaseDate": "Utgivelsesdato",
"LabelRemoveCover": "Fjern omslag", "LabelRemoveCover": "Fjern omslag",
"LabelRowsPerPage": "Rows per page",
"LabelRSSFeedCustomOwnerEmail": "Tilpasset eier Epost", "LabelRSSFeedCustomOwnerEmail": "Tilpasset eier Epost",
"LabelRSSFeedCustomOwnerName": "Tilpasset eier Navn", "LabelRSSFeedCustomOwnerName": "Tilpasset eier Navn",
"LabelRSSFeedOpen": "RSS Feed åpne", "LabelRSSFeedOpen": "RSS Feed åpne",
@@ -515,6 +524,7 @@
"LabelUpdateDetailsHelp": "Tillat overskriving av eksisterende detaljer for de valgte bøkene når en lik bok er funnet", "LabelUpdateDetailsHelp": "Tillat overskriving av eksisterende detaljer for de valgte bøkene når en lik bok er funnet",
"LabelUploaderDragAndDrop": "Dra og slipp filer eller mapper", "LabelUploaderDragAndDrop": "Dra og slipp filer eller mapper",
"LabelUploaderDropFiles": "Slipp filer", "LabelUploaderDropFiles": "Slipp filer",
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
"LabelUseChapterTrack": "Bruk kapittelspor", "LabelUseChapterTrack": "Bruk kapittelspor",
"LabelUseFullTrack": "Bruke hele sporet", "LabelUseFullTrack": "Bruke hele sporet",
"LabelUser": "Bruker", "LabelUser": "Bruker",
@@ -562,6 +572,7 @@
"MessageConfirmRemoveCollection": "Er du sikker på at du vil fjerne samling\"{0}\"?", "MessageConfirmRemoveCollection": "Er du sikker på at du vil fjerne samling\"{0}\"?",
"MessageConfirmRemoveEpisode": "Er du sikker på at du vil fjerne episode \"{0}\"?", "MessageConfirmRemoveEpisode": "Er du sikker på at du vil fjerne episode \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Er du sikker på at du vil fjerne {0} episoder?", "MessageConfirmRemoveEpisodes": "Er du sikker på at du vil fjerne {0} episoder?",
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
"MessageConfirmRemoveNarrator": "Er du sikker på at du vil fjerne forteller \"{0}\"?", "MessageConfirmRemoveNarrator": "Er du sikker på at du vil fjerne forteller \"{0}\"?",
"MessageConfirmRemovePlaylist": "Er du sikker på at du vil fjerne spillelisten \"{0}\"?", "MessageConfirmRemovePlaylist": "Er du sikker på at du vil fjerne spillelisten \"{0}\"?",
"MessageConfirmRenameGenre": "Er du sikker på at du vil endre sjanger \"{0}\" til \"{1}\" for alle gjenstandene?", "MessageConfirmRenameGenre": "Er du sikker på at du vil endre sjanger \"{0}\" til \"{1}\" for alle gjenstandene?",
@@ -641,6 +652,7 @@
"MessageRestoreBackupConfirm": "Er du sikker på at du vil gjenopprette sikkerhetskopien som var laget", "MessageRestoreBackupConfirm": "Er du sikker på at du vil gjenopprette sikkerhetskopien som var laget",
"MessageRestoreBackupWarning": "gjenoppretting av sikkerhetskopi vil overskrive hele databasen under /config og omslagsbilde under /metadata/items og /metadata/authors.<br /><br />Sikkerhetskopier endrer ikke noen filer under dine bibliotekmapper. Hvis du har aktivert tjenerinstillingen for å lagre omslagsbilder og metadata i bibliotekmapper så vil ikke de filene bli tatt sikkerhetskopi eller overskrevet.<br /><br />Alle klientene som bruker din tjener vil bli fornyet automatisk.", "MessageRestoreBackupWarning": "gjenoppretting av sikkerhetskopi vil overskrive hele databasen under /config og omslagsbilde under /metadata/items og /metadata/authors.<br /><br />Sikkerhetskopier endrer ikke noen filer under dine bibliotekmapper. Hvis du har aktivert tjenerinstillingen for å lagre omslagsbilder og metadata i bibliotekmapper så vil ikke de filene bli tatt sikkerhetskopi eller overskrevet.<br /><br />Alle klientene som bruker din tjener vil bli fornyet automatisk.",
"MessageSearchResultsFor": "Søk resultat for", "MessageSearchResultsFor": "Søk resultat for",
"MessageSelected": "{0} selected",
"MessageServerCouldNotBeReached": "Tjener kunne ikke bli nådd", "MessageServerCouldNotBeReached": "Tjener kunne ikke bli nådd",
"MessageSetChaptersFromTracksDescription": "Sett kapitler ved å bruke hver lydfil som kapittel og kapitteltittel som lydfilnavnet", "MessageSetChaptersFromTracksDescription": "Sett kapitler ved å bruke hver lydfil som kapittel og kapitteltittel som lydfilnavnet",
"MessageStartPlaybackAtTime": "Start avspilling av \"{0}\" ved {1}?", "MessageStartPlaybackAtTime": "Start avspilling av \"{0}\" ved {1}?",
@@ -656,7 +668,6 @@
"NoteChangeRootPassword": "Root-bruker er eneste bruker som kan ha tumt passord", "NoteChangeRootPassword": "Root-bruker er eneste bruker som kan ha tumt passord",
"NoteChapterEditorTimes": "Notis: Første kapittel start tid må være 0:00 og siste kapittel start tid kan ikke overskride denne lydbokens lengde.", "NoteChapterEditorTimes": "Notis: Første kapittel start tid må være 0:00 og siste kapittel start tid kan ikke overskride denne lydbokens lengde.",
"NoteFolderPicker": "Notis: allerede funnet mapper vil ikke bli vist", "NoteFolderPicker": "Notis: allerede funnet mapper vil ikke bli vist",
"NoteFolderPickerDebian": "Notis: Mappevelger for debian er ikke fullstendig implementert. Du burde skrive inn stien til biblioteket direkte.",
"NoteRSSFeedPodcastAppsHttps": "Advarsel! De fleste podcast applikasjoner trenger RSS feed URL som bruker HTTPS", "NoteRSSFeedPodcastAppsHttps": "Advarsel! De fleste podcast applikasjoner trenger RSS feed URL som bruker HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Advarsel! 1 eller flere av episodene har ikke publikasjonsdato. Noen podcast applikasjoner trenger dette.", "NoteRSSFeedPodcastAppsPubDate": "Advarsel! 1 eller flere av episodene har ikke publikasjonsdato. Noen podcast applikasjoner trenger dette.",
"NoteUploaderFoldersWithMediaFiles": "Mapper med mediefiler vil bli behandlet som separate bibliotekgjenstander.", "NoteUploaderFoldersWithMediaFiles": "Mapper med mediefiler vil bli behandlet som separate bibliotekgjenstander.",
+12 -1
View File
@@ -87,6 +87,9 @@
"ButtonUserEdit": "Edit user {0}", "ButtonUserEdit": "Edit user {0}",
"ButtonViewAll": "Zobacz wszystko", "ButtonViewAll": "Zobacz wszystko",
"ButtonYes": "Tak", "ButtonYes": "Tak",
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
"ErrorUploadLacksTitle": "Must have a title",
"HeaderAccount": "Konto", "HeaderAccount": "Konto",
"HeaderAdvanced": "Zaawansowane", "HeaderAdvanced": "Zaawansowane",
"HeaderAppriseNotificationSettings": "Ustawienia powiadomień Apprise", "HeaderAppriseNotificationSettings": "Ustawienia powiadomień Apprise",
@@ -196,6 +199,8 @@
"LabelAuthorLastFirst": "Author (Malejąco)", "LabelAuthorLastFirst": "Author (Malejąco)",
"LabelAuthors": "Autorzy", "LabelAuthors": "Autorzy",
"LabelAutoDownloadEpisodes": "Automatyczne pobieranie odcinków", "LabelAutoDownloadEpisodes": "Automatyczne pobieranie odcinków",
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
"LabelAutoLaunch": "Auto Launch", "LabelAutoLaunch": "Auto Launch",
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Auto Register", "LabelAutoRegister": "Auto Register",
@@ -266,6 +271,7 @@
"LabelExample": "Example", "LabelExample": "Example",
"LabelExplicit": "Nieprzyzwoite", "LabelExplicit": "Nieprzyzwoite",
"LabelFeedURL": "URL kanału", "LabelFeedURL": "URL kanału",
"LabelFetchingMetadata": "Fetching Metadata",
"LabelFile": "Plik", "LabelFile": "Plik",
"LabelFileBirthtime": "Data utworzenia pliku", "LabelFileBirthtime": "Data utworzenia pliku",
"LabelFileModified": "Data modyfikacji pliku", "LabelFileModified": "Data modyfikacji pliku",
@@ -337,6 +343,8 @@
"LabelMinute": "Minuta", "LabelMinute": "Minuta",
"LabelMissing": "Brakujący", "LabelMissing": "Brakujący",
"LabelMissingParts": "Brakujące cześci", "LabelMissingParts": "Brakujące cześci",
"LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs",
"LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.",
"LabelMore": "Więcej", "LabelMore": "Więcej",
"LabelMoreInfo": "More Info", "LabelMoreInfo": "More Info",
"LabelName": "Nazwa", "LabelName": "Nazwa",
@@ -398,6 +406,7 @@
"LabelRegion": "Region", "LabelRegion": "Region",
"LabelReleaseDate": "Data wydania", "LabelReleaseDate": "Data wydania",
"LabelRemoveCover": "Remove cover", "LabelRemoveCover": "Remove cover",
"LabelRowsPerPage": "Rows per page",
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email", "LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
"LabelRSSFeedCustomOwnerName": "Custom owner Name", "LabelRSSFeedCustomOwnerName": "Custom owner Name",
"LabelRSSFeedOpen": "RSS Feed otwarty", "LabelRSSFeedOpen": "RSS Feed otwarty",
@@ -515,6 +524,7 @@
"LabelUpdateDetailsHelp": "Umożliwienie nadpisania istniejących szczegółów dla wybranych książek w przypadku znalezienia dopasowania", "LabelUpdateDetailsHelp": "Umożliwienie nadpisania istniejących szczegółów dla wybranych książek w przypadku znalezienia dopasowania",
"LabelUploaderDragAndDrop": "Przeciągnij i puść foldery lub pliki", "LabelUploaderDragAndDrop": "Przeciągnij i puść foldery lub pliki",
"LabelUploaderDropFiles": "Puść pliki", "LabelUploaderDropFiles": "Puść pliki",
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
"LabelUseChapterTrack": "Użyj ścieżki rozdziału", "LabelUseChapterTrack": "Użyj ścieżki rozdziału",
"LabelUseFullTrack": "Użycie ścieżki rozdziału", "LabelUseFullTrack": "Użycie ścieżki rozdziału",
"LabelUser": "Użytkownik", "LabelUser": "Użytkownik",
@@ -562,6 +572,7 @@
"MessageConfirmRemoveCollection": "Czy na pewno chcesz usunąć kolekcję \"{0}\"?", "MessageConfirmRemoveCollection": "Czy na pewno chcesz usunąć kolekcję \"{0}\"?",
"MessageConfirmRemoveEpisode": "Czy na pewno chcesz usunąć odcinek \"{0}\"?", "MessageConfirmRemoveEpisode": "Czy na pewno chcesz usunąć odcinek \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Czy na pewno chcesz usunąć {0} odcinki?", "MessageConfirmRemoveEpisodes": "Czy na pewno chcesz usunąć {0} odcinki?",
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?", "MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?", "MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?", "MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
@@ -641,6 +652,7 @@
"MessageRestoreBackupConfirm": "Czy na pewno chcesz przywrócić kopię zapasową utworzoną w dniu", "MessageRestoreBackupConfirm": "Czy na pewno chcesz przywrócić kopię zapasową utworzoną w dniu",
"MessageRestoreBackupWarning": "Przywrócenie kopii zapasowej spowoduje nadpisane bazy danych w folderze /config oraz okładke w folderze /metadata/items & /metadata/authors.<br /><br />Kopie zapasowe nie modyfikują żadnego pliku w folderach z plikami audio. Jeśli włączyłeś ustawienia serwera, aby przechowywać okładki i metadane w folderach biblioteki, to nie są one zapisywane w kopii zapasowej lub nadpisywane<br /><br />Wszyscy klienci korzystający z Twojego serwera będą automatycznie odświeżani", "MessageRestoreBackupWarning": "Przywrócenie kopii zapasowej spowoduje nadpisane bazy danych w folderze /config oraz okładke w folderze /metadata/items & /metadata/authors.<br /><br />Kopie zapasowe nie modyfikują żadnego pliku w folderach z plikami audio. Jeśli włączyłeś ustawienia serwera, aby przechowywać okładki i metadane w folderach biblioteki, to nie są one zapisywane w kopii zapasowej lub nadpisywane<br /><br />Wszyscy klienci korzystający z Twojego serwera będą automatycznie odświeżani",
"MessageSearchResultsFor": "Wyniki wyszukiwania dla", "MessageSearchResultsFor": "Wyniki wyszukiwania dla",
"MessageSelected": "{0} selected",
"MessageServerCouldNotBeReached": "Nie udało się uzyskać połączenia z serwerem", "MessageServerCouldNotBeReached": "Nie udało się uzyskać połączenia z serwerem",
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name", "MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
"MessageStartPlaybackAtTime": "Rozpoczęcie odtwarzania \"{0}\" od {1}?", "MessageStartPlaybackAtTime": "Rozpoczęcie odtwarzania \"{0}\" od {1}?",
@@ -656,7 +668,6 @@
"NoteChangeRootPassword": "Tylko użytkownik root, może posiadać puste hasło", "NoteChangeRootPassword": "Tylko użytkownik root, może posiadać puste hasło",
"NoteChapterEditorTimes": "Uwaga: Czas rozpoczęcia pierwszego rozdziału musi pozostać na poziomie 0:00, a czas rozpoczęcia ostatniego rozdziału nie może przekroczyć czasu trwania audiobooka.", "NoteChapterEditorTimes": "Uwaga: Czas rozpoczęcia pierwszego rozdziału musi pozostać na poziomie 0:00, a czas rozpoczęcia ostatniego rozdziału nie może przekroczyć czasu trwania audiobooka.",
"NoteFolderPicker": "Uwaga: dotychczas zmapowane foldery nie zostaną wyświetlone", "NoteFolderPicker": "Uwaga: dotychczas zmapowane foldery nie zostaną wyświetlone",
"NoteFolderPickerDebian": "Uwaga: Wybór folderu w instalcji opartej o system debian nie jest w pełni zaimplementowany. Powinieneś wprowadzić ścieżkę do swojej biblioteki bezpośrednio.",
"NoteRSSFeedPodcastAppsHttps": "Ostrzeżenie: Większość aplikacji do obsługi podcastów wymaga, aby adres URL kanału RSS korzystał z protokołu HTTPS.", "NoteRSSFeedPodcastAppsHttps": "Ostrzeżenie: Większość aplikacji do obsługi podcastów wymaga, aby adres URL kanału RSS korzystał z protokołu HTTPS.",
"NoteRSSFeedPodcastAppsPubDate": "Ostrzeżenie: 1 lub więcej odcinków nie ma daty publikacji. Niektóre aplikacje do słuchania podcastów tego wymagają.", "NoteRSSFeedPodcastAppsPubDate": "Ostrzeżenie: 1 lub więcej odcinków nie ma daty publikacji. Niektóre aplikacje do słuchania podcastów tego wymagają.",
"NoteUploaderFoldersWithMediaFiles": "Foldery z plikami multimedialnymi będą traktowane jako osobne elementy w bibliotece.", "NoteUploaderFoldersWithMediaFiles": "Foldery z plikami multimedialnymi będą traktowane jako osobne elementy w bibliotece.",
+44 -33
View File
@@ -1,10 +1,10 @@
{ {
"ButtonAdd": "Добавить", "ButtonAdd": "Добавить",
"ButtonAddChapters": "Добавить главы", "ButtonAddChapters": "Добавить главы",
"ButtonAddDevice": "Add Device", "ButtonAddDevice": "Добавить устройство",
"ButtonAddLibrary": "Add Library", "ButtonAddLibrary": "Добавить библиотеку",
"ButtonAddPodcasts": "Добавить подкасты", "ButtonAddPodcasts": "Добавить подкасты",
"ButtonAddUser": "Add User", "ButtonAddUser": "Добавить пользователя",
"ButtonAddYourFirstLibrary": "Добавьте Вашу первую библиотеку", "ButtonAddYourFirstLibrary": "Добавьте Вашу первую библиотеку",
"ButtonApply": "Применить", "ButtonApply": "Применить",
"ButtonApplyChapters": "Применить главы", "ButtonApplyChapters": "Применить главы",
@@ -62,7 +62,7 @@
"ButtonRemoveSeriesFromContinueSeries": "Удалить серию из Продолжить серию", "ButtonRemoveSeriesFromContinueSeries": "Удалить серию из Продолжить серию",
"ButtonReScan": "Пересканировать", "ButtonReScan": "Пересканировать",
"ButtonReset": "Сбросить", "ButtonReset": "Сбросить",
"ButtonResetToDefault": "Reset to default", "ButtonResetToDefault": "Сборосить по умолчанию",
"ButtonRestore": "Восстановить", "ButtonRestore": "Восстановить",
"ButtonSave": "Сохранить", "ButtonSave": "Сохранить",
"ButtonSaveAndClose": "Сохранить и закрыть", "ButtonSaveAndClose": "Сохранить и закрыть",
@@ -78,7 +78,7 @@
"ButtonStartM4BEncode": "Начать кодирование M4B", "ButtonStartM4BEncode": "Начать кодирование M4B",
"ButtonStartMetadataEmbed": "Начать встраивание метаданных", "ButtonStartMetadataEmbed": "Начать встраивание метаданных",
"ButtonSubmit": "Применить", "ButtonSubmit": "Применить",
"ButtonTest": "Test", "ButtonTest": "Тест",
"ButtonUpload": "Загрузить", "ButtonUpload": "Загрузить",
"ButtonUploadBackup": "Загрузить бэкап", "ButtonUploadBackup": "Загрузить бэкап",
"ButtonUploadCover": "Загрузить обложку", "ButtonUploadCover": "Загрузить обложку",
@@ -87,12 +87,15 @@
"ButtonUserEdit": "Редактировать пользователя {0}", "ButtonUserEdit": "Редактировать пользователя {0}",
"ButtonViewAll": "Посмотреть все", "ButtonViewAll": "Посмотреть все",
"ButtonYes": "Да", "ButtonYes": "Да",
"ErrorUploadFetchMetadataAPI": "Ошибка при получении метаданных",
"ErrorUploadFetchMetadataNoResults": "Не удалось получить метаданные - попробуйте обновить название и/или автора",
"ErrorUploadLacksTitle": "Название должно быть заполнено",
"HeaderAccount": "Учетная запись", "HeaderAccount": "Учетная запись",
"HeaderAdvanced": "Дополнительно", "HeaderAdvanced": "Дополнительно",
"HeaderAppriseNotificationSettings": "Настройки оповещений", "HeaderAppriseNotificationSettings": "Настройки оповещений",
"HeaderAudiobookTools": "Инструменты файлов аудиокниг", "HeaderAudiobookTools": "Инструменты файлов аудиокниг",
"HeaderAudioTracks": "Аудио треки", "HeaderAudioTracks": "Аудио треки",
"HeaderAuthentication": "Authentication", "HeaderAuthentication": "Аутентификация",
"HeaderBackups": "Бэкапы", "HeaderBackups": "Бэкапы",
"HeaderChangePassword": "Изменить пароль", "HeaderChangePassword": "Изменить пароль",
"HeaderChapters": "Главы", "HeaderChapters": "Главы",
@@ -127,15 +130,15 @@
"HeaderManageTags": "Редактировать теги", "HeaderManageTags": "Редактировать теги",
"HeaderMapDetails": "Найти подробности", "HeaderMapDetails": "Найти подробности",
"HeaderMatch": "Поиск", "HeaderMatch": "Поиск",
"HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataOrderOfPrecedence": "Порядок приоритета метаданных",
"HeaderMetadataToEmbed": "Метаинформация для встраивания", "HeaderMetadataToEmbed": "Метаинформация для встраивания",
"HeaderNewAccount": "Новая учетная запись", "HeaderNewAccount": "Новая учетная запись",
"HeaderNewLibrary": "Новая библиотека", "HeaderNewLibrary": "Новая библиотека",
"HeaderNotifications": "Уведомления", "HeaderNotifications": "Уведомления",
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication", "HeaderOpenIDConnectAuthentication": "Аутентификация OpenID Connect",
"HeaderOpenRSSFeed": "Открыть RSS-канал", "HeaderOpenRSSFeed": "Открыть RSS-канал",
"HeaderOtherFiles": "Другие файлы", "HeaderOtherFiles": "Другие файлы",
"HeaderPasswordAuthentication": "Password Authentication", "HeaderPasswordAuthentication": "Аутентификация по паролю",
"HeaderPermissions": "Разрешения", "HeaderPermissions": "Разрешения",
"HeaderPlayerQueue": "Очередь воспроизведения", "HeaderPlayerQueue": "Очередь воспроизведения",
"HeaderPlaylist": "Плейлист", "HeaderPlaylist": "Плейлист",
@@ -184,11 +187,11 @@
"LabelAddToCollectionBatch": "Добавить {0} книг в коллекцию", "LabelAddToCollectionBatch": "Добавить {0} книг в коллекцию",
"LabelAddToPlaylist": "Добавить в плейлист", "LabelAddToPlaylist": "Добавить в плейлист",
"LabelAddToPlaylistBatch": "Добавить {0} элементов в плейлист", "LabelAddToPlaylistBatch": "Добавить {0} элементов в плейлист",
"LabelAdminUsersOnly": "Admin users only", "LabelAdminUsersOnly": "Только для пользователей с правами администратора",
"LabelAll": "Все", "LabelAll": "Все",
"LabelAllUsers": "Все пользователи", "LabelAllUsers": "Все пользователи",
"LabelAllUsersExcludingGuests": "All users excluding guests", "LabelAllUsersExcludingGuests": "Все пользователи, кроме гостей",
"LabelAllUsersIncludingGuests": "All users including guests", "LabelAllUsersIncludingGuests": "Все пользователи, включая гостей",
"LabelAlreadyInYourLibrary": "Уже в Вашей библиотеке", "LabelAlreadyInYourLibrary": "Уже в Вашей библиотеке",
"LabelAppend": "Добавить", "LabelAppend": "Добавить",
"LabelAuthor": "Автор", "LabelAuthor": "Автор",
@@ -196,12 +199,14 @@
"LabelAuthorLastFirst": "Автор (Фамилия, Имя)", "LabelAuthorLastFirst": "Автор (Фамилия, Имя)",
"LabelAuthors": "Авторы", "LabelAuthors": "Авторы",
"LabelAutoDownloadEpisodes": "Скачивать эпизоды автоматически", "LabelAutoDownloadEpisodes": "Скачивать эпизоды автоматически",
"LabelAutoLaunch": "Auto Launch", "LabelAutoFetchMetadata": "Автоматическое извлечение метаданных",
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", "LabelAutoFetchMetadataHelp": "Извлекает метаданные для названия, автора и серии для упрощения загрузки. После загрузки может потребоваться сопоставление дополнительных метаданных.",
"LabelAutoRegister": "Auto Register", "LabelAutoLaunch": "Автозапуск",
"LabelAutoRegisterDescription": "Automatically create new users after logging in", "LabelAutoLaunchDescription": "Редирект на провайдера аутентификации автоматически при переходе на страницу входа (путь ручного переопределения <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Автоматическая регистрация",
"LabelAutoRegisterDescription": "Автоматическое создание новых пользователей после входа в систему",
"LabelBackToUser": "Назад к пользователю", "LabelBackToUser": "Назад к пользователю",
"LabelBackupLocation": "Backup Location", "LabelBackupLocation": "Путь для бэкапов",
"LabelBackupsEnableAutomaticBackups": "Включить автоматическое бэкапирование", "LabelBackupsEnableAutomaticBackups": "Включить автоматическое бэкапирование",
"LabelBackupsEnableAutomaticBackupsHelp": "Бэкапы сохраняются в /metadata/backups", "LabelBackupsEnableAutomaticBackupsHelp": "Бэкапы сохраняются в /metadata/backups",
"LabelBackupsMaxBackupSize": "Максимальный размер бэкапа (в GB)", "LabelBackupsMaxBackupSize": "Максимальный размер бэкапа (в GB)",
@@ -210,13 +215,13 @@
"LabelBackupsNumberToKeepHelp": "За один раз только 1 бэкап будет удален, так что если у вас будет больше бэкапов, то их нужно удалить вручную.", "LabelBackupsNumberToKeepHelp": "За один раз только 1 бэкап будет удален, так что если у вас будет больше бэкапов, то их нужно удалить вручную.",
"LabelBitrate": "Битрейт", "LabelBitrate": "Битрейт",
"LabelBooks": "Книги", "LabelBooks": "Книги",
"LabelButtonText": "Button Text", "LabelButtonText": "Текст кнопки",
"LabelChangePassword": "Изменить пароль", "LabelChangePassword": "Изменить пароль",
"LabelChannels": "Каналы", "LabelChannels": "Каналы",
"LabelChapters": "Главы", "LabelChapters": "Главы",
"LabelChaptersFound": "глав найдено", "LabelChaptersFound": "глав найдено",
"LabelChapterTitle": "Название главы", "LabelChapterTitle": "Название главы",
"LabelClickForMoreInfo": "Click for more info", "LabelClickForMoreInfo": "Нажмите, чтобы узнать больше",
"LabelClosePlayer": "Закрыть проигрыватель", "LabelClosePlayer": "Закрыть проигрыватель",
"LabelCodec": "Кодек", "LabelCodec": "Кодек",
"LabelCollapseSeries": "Свернуть серии", "LabelCollapseSeries": "Свернуть серии",
@@ -235,12 +240,12 @@
"LabelCurrently": "Текущее:", "LabelCurrently": "Текущее:",
"LabelCustomCronExpression": "Пользовательское выражение Cron:", "LabelCustomCronExpression": "Пользовательское выражение Cron:",
"LabelDatetime": "Дата и время", "LabelDatetime": "Дата и время",
"LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDeleteFromFileSystemCheckbox": "Удалить из файловой системы (снимите флажок, чтобы удалить только из базы данных)",
"LabelDescription": "Описание", "LabelDescription": "Описание",
"LabelDeselectAll": "Снять выделение", "LabelDeselectAll": "Снять выделение",
"LabelDevice": "Устройство", "LabelDevice": "Устройство",
"LabelDeviceInfo": "Информация об устройстве", "LabelDeviceInfo": "Информация об устройстве",
"LabelDeviceIsAvailableTo": "Device is available to...", "LabelDeviceIsAvailableTo": "Устройство доступно для...",
"LabelDirectory": "Каталог", "LabelDirectory": "Каталог",
"LabelDiscFromFilename": "Диск из Имени файла", "LabelDiscFromFilename": "Диск из Имени файла",
"LabelDiscFromMetadata": "Диск из Метаданных", "LabelDiscFromMetadata": "Диск из Метаданных",
@@ -266,6 +271,7 @@
"LabelExample": "Пример", "LabelExample": "Пример",
"LabelExplicit": "Явный", "LabelExplicit": "Явный",
"LabelFeedURL": "URL канала", "LabelFeedURL": "URL канала",
"LabelFetchingMetadata": "Извлечение метаданных",
"LabelFile": "Файл", "LabelFile": "Файл",
"LabelFileBirthtime": "Дата создания", "LabelFileBirthtime": "Дата создания",
"LabelFileModified": "Дата модификации", "LabelFileModified": "Дата модификации",
@@ -283,11 +289,11 @@
"LabelHardDeleteFile": "Жесткое удаление файла", "LabelHardDeleteFile": "Жесткое удаление файла",
"LabelHasEbook": "Есть e-книга", "LabelHasEbook": "Есть e-книга",
"LabelHasSupplementaryEbook": "Есть дополнительная e-книга", "LabelHasSupplementaryEbook": "Есть дополнительная e-книга",
"LabelHighestPriority": "Highest priority", "LabelHighestPriority": "Наивысший приоритет",
"LabelHost": "Хост", "LabelHost": "Хост",
"LabelHour": "Часы", "LabelHour": "Часы",
"LabelIcon": "Иконка", "LabelIcon": "Иконка",
"LabelImageURLFromTheWeb": "Image URL from the web", "LabelImageURLFromTheWeb": "URL-адрес изображения из Интернета",
"LabelIncludeInTracklist": "Включать в список воспроизведения", "LabelIncludeInTracklist": "Включать в список воспроизведения",
"LabelIncomplete": "Не завершен", "LabelIncomplete": "Не завершен",
"LabelInProgress": "В процессе", "LabelInProgress": "В процессе",
@@ -325,18 +331,20 @@
"LabelLogLevelInfo": "Info", "LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Warn", "LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Искать новые эпизоды после этой даты", "LabelLookForNewEpisodesAfterDate": "Искать новые эпизоды после этой даты",
"LabelLowestPriority": "Lowest Priority", "LabelLowestPriority": "Самый низкий приоритет",
"LabelMatchExistingUsersBy": "Match existing users by", "LabelMatchExistingUsersBy": "Сопоставление существующих пользователей по",
"LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider", "LabelMatchExistingUsersByDescription": "Используется для подключения существующих пользователей. После подключения пользователям будет присвоен уникальный идентификатор от поставщика единого входа",
"LabelMediaPlayer": "Медиа проигрыватель", "LabelMediaPlayer": "Медиа проигрыватель",
"LabelMediaType": "Тип медиа", "LabelMediaType": "Тип медиа",
"LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources", "LabelMetadataOrderOfPrecedenceDescription": "Источники метаданных с более высоким приоритетом будут переопределять источники метаданных с более низким приоритетом",
"LabelMetadataProvider": "Провайдер", "LabelMetadataProvider": "Провайдер",
"LabelMetaTag": "Мета тег", "LabelMetaTag": "Мета тег",
"LabelMetaTags": "Мета теги", "LabelMetaTags": "Мета теги",
"LabelMinute": "Минуты", "LabelMinute": "Минуты",
"LabelMissing": "Потеряно", "LabelMissing": "Потеряно",
"LabelMissingParts": "Потерянные части", "LabelMissingParts": "Потерянные части",
"LabelMobileRedirectURIs": "Разрешенные URI перенаправления с мобильных устройств",
"LabelMobileRedirectURIsDescription": "Это белый список допустимых URI перенаправления для мобильных приложений. По умолчанию используется <code>audiobookshelf://oauth</code>, который можно удалить или дополнить дополнительными URI для интеграции со сторонними приложениями. Использование звездочки (<code>*</code>) в качестве единственной записи разрешает любой URI.",
"LabelMore": "Еще", "LabelMore": "Еще",
"LabelMoreInfo": "Больше информации", "LabelMoreInfo": "Больше информации",
"LabelName": "Имя", "LabelName": "Имя",
@@ -398,6 +406,7 @@
"LabelRegion": "Регион", "LabelRegion": "Регион",
"LabelReleaseDate": "Дата выхода", "LabelReleaseDate": "Дата выхода",
"LabelRemoveCover": "Удалить обложку", "LabelRemoveCover": "Удалить обложку",
"LabelRowsPerPage": "Rows per page",
"LabelRSSFeedCustomOwnerEmail": "Пользовательский Email владельца", "LabelRSSFeedCustomOwnerEmail": "Пользовательский Email владельца",
"LabelRSSFeedCustomOwnerName": "Пользовательское Имя владельца", "LabelRSSFeedCustomOwnerName": "Пользовательское Имя владельца",
"LabelRSSFeedOpen": "Открыть RSS-канал", "LabelRSSFeedOpen": "Открыть RSS-канал",
@@ -515,6 +524,7 @@
"LabelUpdateDetailsHelp": "Позволяет перезаписывать текущие подробности для выбранных книг если будут найдены", "LabelUpdateDetailsHelp": "Позволяет перезаписывать текущие подробности для выбранных книг если будут найдены",
"LabelUploaderDragAndDrop": "Перетащите файлы или каталоги", "LabelUploaderDragAndDrop": "Перетащите файлы или каталоги",
"LabelUploaderDropFiles": "Перетащите файлы", "LabelUploaderDropFiles": "Перетащите файлы",
"LabelUploaderItemFetchMetadataHelp": "Автоматическое извлечение названия, автора и серии",
"LabelUseChapterTrack": "Показывать время главы", "LabelUseChapterTrack": "Показывать время главы",
"LabelUseFullTrack": "Показывать время книги", "LabelUseFullTrack": "Показывать время книги",
"LabelUser": "Пользователь", "LabelUser": "Пользователь",
@@ -548,20 +558,21 @@
"MessageConfirmDeleteBackup": "Вы уверены, что хотите удалить бэкап для {0}?", "MessageConfirmDeleteBackup": "Вы уверены, что хотите удалить бэкап для {0}?",
"MessageConfirmDeleteFile": "Это удалит файл из Вашей файловой системы. Вы уверены?", "MessageConfirmDeleteFile": "Это удалит файл из Вашей файловой системы. Вы уверены?",
"MessageConfirmDeleteLibrary": "Вы уверены, что хотите навсегда удалить библиотеку \"{0}\"?", "MessageConfirmDeleteLibrary": "Вы уверены, что хотите навсегда удалить библиотеку \"{0}\"?",
"MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", "MessageConfirmDeleteLibraryItem": "Это приведет к удалению элемента библиотеки из базы данных и файловой системы. Вы уверены?",
"MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", "MessageConfirmDeleteLibraryItems": "Это приведет к удалению {0} элементов библиотеки из базы данных и файловой системы. Вы уверены?",
"MessageConfirmDeleteSession": "Вы уверены, что хотите удалить этот сеанс?", "MessageConfirmDeleteSession": "Вы уверены, что хотите удалить этот сеанс?",
"MessageConfirmForceReScan": "Вы уверены, что хотите принудительно выполнить повторное сканирование?", "MessageConfirmForceReScan": "Вы уверены, что хотите принудительно выполнить повторное сканирование?",
"MessageConfirmMarkAllEpisodesFinished": "Вы уверены, что хотите отметить все эпизоды как завершенные?", "MessageConfirmMarkAllEpisodesFinished": "Вы уверены, что хотите отметить все эпизоды как завершенные?",
"MessageConfirmMarkAllEpisodesNotFinished": "Вы уверены, что хотите отметить все эпизоды как не завершенные?", "MessageConfirmMarkAllEpisodesNotFinished": "Вы уверены, что хотите отметить все эпизоды как не завершенные?",
"MessageConfirmMarkSeriesFinished": "Вы уверены, что хотите отметить все книги этой серии как завершенные?", "MessageConfirmMarkSeriesFinished": "Вы уверены, что хотите отметить все книги этой серии как завершенные?",
"MessageConfirmMarkSeriesNotFinished": "Вы уверены, что хотите отметить все книги этой серии как не завершенные?", "MessageConfirmMarkSeriesNotFinished": "Вы уверены, что хотите отметить все книги этой серии как не завершенные?",
"MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", "MessageConfirmQuickEmbed": "Предупреждение! Быстрое встраивание не позволяет создавать резервные копии аудиофайлов. Убедитесь, что у вас есть резервная копия аудиофайлов. <br><br>Хотите продолжить?",
"MessageConfirmRemoveAllChapters": "Вы уверены, что хотите удалить все главы?", "MessageConfirmRemoveAllChapters": "Вы уверены, что хотите удалить все главы?",
"MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?", "MessageConfirmRemoveAuthor": "Вы уверены, что хотите удалить автора \"{0}\"?",
"MessageConfirmRemoveCollection": "Вы уверены, что хотите удалить коллекцию \"{0}\"?", "MessageConfirmRemoveCollection": "Вы уверены, что хотите удалить коллекцию \"{0}\"?",
"MessageConfirmRemoveEpisode": "Вы уверены, что хотите удалить эпизод \"{0}\"?", "MessageConfirmRemoveEpisode": "Вы уверены, что хотите удалить эпизод \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Вы уверены, что хотите удалить {0} эпизодов?", "MessageConfirmRemoveEpisodes": "Вы уверены, что хотите удалить {0} эпизодов?",
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
"MessageConfirmRemoveNarrator": "Вы уверены, что хотите удалить чтеца \"{0}\"?", "MessageConfirmRemoveNarrator": "Вы уверены, что хотите удалить чтеца \"{0}\"?",
"MessageConfirmRemovePlaylist": "Вы уверены, что хотите удалить плейлист \"{0}\"?", "MessageConfirmRemovePlaylist": "Вы уверены, что хотите удалить плейлист \"{0}\"?",
"MessageConfirmRenameGenre": "Вы уверены, что хотите переименовать жанр \"{0}\" в \"{1}\" для всех элементов?", "MessageConfirmRenameGenre": "Вы уверены, что хотите переименовать жанр \"{0}\" в \"{1}\" для всех элементов?",
@@ -570,7 +581,7 @@
"MessageConfirmRenameTag": "Вы уверены, что хотите переименовать тег \"{0}\" в \"{1}\" для всех элементов?", "MessageConfirmRenameTag": "Вы уверены, что хотите переименовать тег \"{0}\" в \"{1}\" для всех элементов?",
"MessageConfirmRenameTagMergeNote": "Примечание: Этот тег уже существует, поэтому они будут объединены.", "MessageConfirmRenameTagMergeNote": "Примечание: Этот тег уже существует, поэтому они будут объединены.",
"MessageConfirmRenameTagWarning": "Предупреждение! Похожий тег с другими начальными буквами уже существует \"{0}\".", "MessageConfirmRenameTagWarning": "Предупреждение! Похожий тег с другими начальными буквами уже существует \"{0}\".",
"MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", "MessageConfirmReScanLibraryItems": "Вы уверены, что хотите пересканировать {0} элементов?",
"MessageConfirmSendEbookToDevice": "Вы уверены, что хотите отправить {0} e-книгу \"{1}\" на устройство \"{2}\"?", "MessageConfirmSendEbookToDevice": "Вы уверены, что хотите отправить {0} e-книгу \"{1}\" на устройство \"{2}\"?",
"MessageDownloadingEpisode": "Эпизод скачивается", "MessageDownloadingEpisode": "Эпизод скачивается",
"MessageDragFilesIntoTrackOrder": "Перетащите файлы для исправления порядка треков", "MessageDragFilesIntoTrackOrder": "Перетащите файлы для исправления порядка треков",
@@ -641,6 +652,7 @@
"MessageRestoreBackupConfirm": "Вы уверены, что хотите восстановить резервную копию, созданную", "MessageRestoreBackupConfirm": "Вы уверены, что хотите восстановить резервную копию, созданную",
"MessageRestoreBackupWarning": "Восстановление резервной копии перезапишет всю базу данных, расположенную в /config, и обложки изображений в /metadata/items и /metadata/authors.<br/><br/>Бэкапы не изменяют файлы в папках библиотеки. Если вы включили параметры сервера для хранения обложек и метаданных в папках библиотеки, то они не резервируются и не перезаписываются.<br/><br/>Все клиенты, использующие ваш сервер, будут автоматически обновлены.", "MessageRestoreBackupWarning": "Восстановление резервной копии перезапишет всю базу данных, расположенную в /config, и обложки изображений в /metadata/items и /metadata/authors.<br/><br/>Бэкапы не изменяют файлы в папках библиотеки. Если вы включили параметры сервера для хранения обложек и метаданных в папках библиотеки, то они не резервируются и не перезаписываются.<br/><br/>Все клиенты, использующие ваш сервер, будут автоматически обновлены.",
"MessageSearchResultsFor": "Результаты поиска для", "MessageSearchResultsFor": "Результаты поиска для",
"MessageSelected": "{0} selected",
"MessageServerCouldNotBeReached": "Не удалось связаться с сервером", "MessageServerCouldNotBeReached": "Не удалось связаться с сервером",
"MessageSetChaptersFromTracksDescription": "Установка глав с использованием каждого аудиофайла в качестве главы и заголовка главы в качестве имени аудиофайла", "MessageSetChaptersFromTracksDescription": "Установка глав с использованием каждого аудиофайла в качестве главы и заголовка главы в качестве имени аудиофайла",
"MessageStartPlaybackAtTime": "Начать воспроизведение для \"{0}\" с {1}?", "MessageStartPlaybackAtTime": "Начать воспроизведение для \"{0}\" с {1}?",
@@ -656,7 +668,6 @@
"NoteChangeRootPassword": "Пользователь root — единственный пользователь, который может иметь пустой пароль", "NoteChangeRootPassword": "Пользователь root — единственный пользователь, который может иметь пустой пароль",
"NoteChapterEditorTimes": "Примечание: Время начала первой главы должно оставаться в 0:00, а время начала последней главы не может превышать продолжительность этой аудиокниги.", "NoteChapterEditorTimes": "Примечание: Время начала первой главы должно оставаться в 0:00, а время начала последней главы не может превышать продолжительность этой аудиокниги.",
"NoteFolderPicker": "Примечание: папки, уже сопоставленные, не будут отображаться", "NoteFolderPicker": "Примечание: папки, уже сопоставленные, не будут отображаться",
"NoteFolderPickerDebian": "Примечание: Выбор папок debian не реализован полностью. Необходимо ввести путь к библиотеке напрямую.",
"NoteRSSFeedPodcastAppsHttps": "Предупреждение: Большинству приложений подкастов потребуется, чтобы URL-адрес RSS-канала использовал HTTPS", "NoteRSSFeedPodcastAppsHttps": "Предупреждение: Большинству приложений подкастов потребуется, чтобы URL-адрес RSS-канала использовал HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Предупреждение: 1 или более эпизодов не имеют даты публикации. Некоторые приложения для подкастов требуют этого.", "NoteRSSFeedPodcastAppsPubDate": "Предупреждение: 1 или более эпизодов не имеют даты публикации. Некоторые приложения для подкастов требуют этого.",
"NoteUploaderFoldersWithMediaFiles": "Папки с медиафайлами будут обрабатываться как отдельные элементы библиотеки.", "NoteUploaderFoldersWithMediaFiles": "Папки с медиафайлами будут обрабатываться как отдельные элементы библиотеки.",
+12 -1
View File
@@ -87,6 +87,9 @@
"ButtonUserEdit": "Redigera användare {0}", "ButtonUserEdit": "Redigera användare {0}",
"ButtonViewAll": "Visa alla", "ButtonViewAll": "Visa alla",
"ButtonYes": "Ja", "ButtonYes": "Ja",
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
"ErrorUploadLacksTitle": "Must have a title",
"HeaderAccount": "Konto", "HeaderAccount": "Konto",
"HeaderAdvanced": "Avancerad", "HeaderAdvanced": "Avancerad",
"HeaderAppriseNotificationSettings": "Apprise Meddelandeinställningar", "HeaderAppriseNotificationSettings": "Apprise Meddelandeinställningar",
@@ -196,6 +199,8 @@
"LabelAuthorLastFirst": "Författare (Efternamn, Förnamn)", "LabelAuthorLastFirst": "Författare (Efternamn, Förnamn)",
"LabelAuthors": "Författare", "LabelAuthors": "Författare",
"LabelAutoDownloadEpisodes": "Automatisk nedladdning av avsnitt", "LabelAutoDownloadEpisodes": "Automatisk nedladdning av avsnitt",
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
"LabelAutoLaunch": "Auto Launch", "LabelAutoLaunch": "Auto Launch",
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Auto Register", "LabelAutoRegister": "Auto Register",
@@ -266,6 +271,7 @@
"LabelExample": "Exempel", "LabelExample": "Exempel",
"LabelExplicit": "Explicit", "LabelExplicit": "Explicit",
"LabelFeedURL": "Flödes-URL", "LabelFeedURL": "Flödes-URL",
"LabelFetchingMetadata": "Fetching Metadata",
"LabelFile": "Fil", "LabelFile": "Fil",
"LabelFileBirthtime": "Födelse-tidpunkt för fil", "LabelFileBirthtime": "Födelse-tidpunkt för fil",
"LabelFileModified": "Fil ändrad", "LabelFileModified": "Fil ändrad",
@@ -337,6 +343,8 @@
"LabelMinute": "Minut", "LabelMinute": "Minut",
"LabelMissing": "Saknad", "LabelMissing": "Saknad",
"LabelMissingParts": "Saknade delar", "LabelMissingParts": "Saknade delar",
"LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs",
"LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.",
"LabelMore": "Mer", "LabelMore": "Mer",
"LabelMoreInfo": "Mer information", "LabelMoreInfo": "Mer information",
"LabelName": "Namn", "LabelName": "Namn",
@@ -398,6 +406,7 @@
"LabelRegion": "Region", "LabelRegion": "Region",
"LabelReleaseDate": "Utgivningsdatum", "LabelReleaseDate": "Utgivningsdatum",
"LabelRemoveCover": "Ta bort omslag", "LabelRemoveCover": "Ta bort omslag",
"LabelRowsPerPage": "Rows per page",
"LabelRSSFeedCustomOwnerEmail": "Anpassad ägarens e-post", "LabelRSSFeedCustomOwnerEmail": "Anpassad ägarens e-post",
"LabelRSSFeedCustomOwnerName": "Anpassat ägarnamn", "LabelRSSFeedCustomOwnerName": "Anpassat ägarnamn",
"LabelRSSFeedOpen": "Öppna RSS-flöde", "LabelRSSFeedOpen": "Öppna RSS-flöde",
@@ -515,6 +524,7 @@
"LabelUpdateDetailsHelp": "Tillåt överskrivning av befintliga detaljer för de valda böckerna när en matchning hittas", "LabelUpdateDetailsHelp": "Tillåt överskrivning av befintliga detaljer för de valda böckerna när en matchning hittas",
"LabelUploaderDragAndDrop": "Dra och släpp filer eller mappar", "LabelUploaderDragAndDrop": "Dra och släpp filer eller mappar",
"LabelUploaderDropFiles": "Släpp filer", "LabelUploaderDropFiles": "Släpp filer",
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
"LabelUseChapterTrack": "Använd kapitelspår", "LabelUseChapterTrack": "Använd kapitelspår",
"LabelUseFullTrack": "Använd hela spåret", "LabelUseFullTrack": "Använd hela spåret",
"LabelUser": "Användare", "LabelUser": "Användare",
@@ -562,6 +572,7 @@
"MessageConfirmRemoveCollection": "Är du säker på att du vill ta bort samlingen \"{0}\"?", "MessageConfirmRemoveCollection": "Är du säker på att du vill ta bort samlingen \"{0}\"?",
"MessageConfirmRemoveEpisode": "Är du säker på att du vill ta bort avsnittet \"{0}\"?", "MessageConfirmRemoveEpisode": "Är du säker på att du vill ta bort avsnittet \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Är du säker på att du vill ta bort {0} avsnitt?", "MessageConfirmRemoveEpisodes": "Är du säker på att du vill ta bort {0} avsnitt?",
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
"MessageConfirmRemoveNarrator": "Är du säker på att du vill ta bort berättaren \"{0}\"?", "MessageConfirmRemoveNarrator": "Är du säker på att du vill ta bort berättaren \"{0}\"?",
"MessageConfirmRemovePlaylist": "Är du säker på att du vill ta bort din spellista \"{0}\"?", "MessageConfirmRemovePlaylist": "Är du säker på att du vill ta bort din spellista \"{0}\"?",
"MessageConfirmRenameGenre": "Är du säker på att du vill byta namn på genren \"{0}\" till \"{1}\" för alla objekt?", "MessageConfirmRenameGenre": "Är du säker på att du vill byta namn på genren \"{0}\" till \"{1}\" för alla objekt?",
@@ -641,6 +652,7 @@
"MessageRestoreBackupConfirm": "Är du säker på att du vill återställa säkerhetskopian som skapades den", "MessageRestoreBackupConfirm": "Är du säker på att du vill återställa säkerhetskopian som skapades den",
"MessageRestoreBackupWarning": "Att återställa en säkerhetskopia kommer att skriva över hela databasen som finns i /config och omslagsbilder i /metadata/items & /metadata/authors.<br /><br />Säkerhetskopior ändrar inte några filer i dina biblioteksmappar. Om du har aktiverat serverinställningar för att lagra omslagskonst och metadata i dina biblioteksmappar säkerhetskopieras eller skrivs de inte över.<br /><br />Alla klienter som använder din server kommer att uppdateras automatiskt.", "MessageRestoreBackupWarning": "Att återställa en säkerhetskopia kommer att skriva över hela databasen som finns i /config och omslagsbilder i /metadata/items & /metadata/authors.<br /><br />Säkerhetskopior ändrar inte några filer i dina biblioteksmappar. Om du har aktiverat serverinställningar för att lagra omslagskonst och metadata i dina biblioteksmappar säkerhetskopieras eller skrivs de inte över.<br /><br />Alla klienter som använder din server kommer att uppdateras automatiskt.",
"MessageSearchResultsFor": "Sökresultat för", "MessageSearchResultsFor": "Sökresultat för",
"MessageSelected": "{0} selected",
"MessageServerCouldNotBeReached": "Servern kunde inte nås", "MessageServerCouldNotBeReached": "Servern kunde inte nås",
"MessageSetChaptersFromTracksDescription": "Ställ in kapitel med varje ljudfil som ett kapitel och kapitelrubrik som ljudfilens namn", "MessageSetChaptersFromTracksDescription": "Ställ in kapitel med varje ljudfil som ett kapitel och kapitelrubrik som ljudfilens namn",
"MessageStartPlaybackAtTime": "Starta uppspelning för \"{0}\" kl. {1}?", "MessageStartPlaybackAtTime": "Starta uppspelning för \"{0}\" kl. {1}?",
@@ -656,7 +668,6 @@
"NoteChangeRootPassword": "Rotanvändaren är den enda användaren som kan ha ett tomt lösenord", "NoteChangeRootPassword": "Rotanvändaren är den enda användaren som kan ha ett tomt lösenord",
"NoteChapterEditorTimes": "Obs: Starttiden för första kapitlet måste förbli 0:00 och starttiden för det sista kapitlet får inte överstiga ljudbokens varaktighet.", "NoteChapterEditorTimes": "Obs: Starttiden för första kapitlet måste förbli 0:00 och starttiden för det sista kapitlet får inte överstiga ljudbokens varaktighet.",
"NoteFolderPicker": "Obs: Mappar som redan är kartlagda kommer inte att visas", "NoteFolderPicker": "Obs: Mappar som redan är kartlagda kommer inte att visas",
"NoteFolderPickerDebian": "Obs: Mappväljaren för Debian-installationen är inte fullständigt implementerad. Du bör ange sökvägen till ditt bibliotek direkt.",
"NoteRSSFeedPodcastAppsHttps": "Varning: De flesta podcastappar kräver att RSS-flödets URL används med HTTPS", "NoteRSSFeedPodcastAppsHttps": "Varning: De flesta podcastappar kräver att RSS-flödets URL används med HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Varning: 1 eller flera av dina avsnitt har inte ett publiceringsdatum. Vissa podcastappar kräver detta.", "NoteRSSFeedPodcastAppsPubDate": "Varning: 1 eller flera av dina avsnitt har inte ett publiceringsdatum. Vissa podcastappar kräver detta.",
"NoteUploaderFoldersWithMediaFiles": "Mappar med mediefiler hanteras som separata biblioteksobjekt.", "NoteUploaderFoldersWithMediaFiles": "Mappar med mediefiler hanteras som separata biblioteksobjekt.",
+40 -29
View File
@@ -1,10 +1,10 @@
{ {
"ButtonAdd": "增加", "ButtonAdd": "增加",
"ButtonAddChapters": "添加章节", "ButtonAddChapters": "添加章节",
"ButtonAddDevice": "Add Device", "ButtonAddDevice": "添加设备",
"ButtonAddLibrary": "Add Library", "ButtonAddLibrary": "添加库",
"ButtonAddPodcasts": "添加播客", "ButtonAddPodcasts": "添加播客",
"ButtonAddUser": "Add User", "ButtonAddUser": "添加用户",
"ButtonAddYourFirstLibrary": "添加第一个媒体库", "ButtonAddYourFirstLibrary": "添加第一个媒体库",
"ButtonApply": "应用", "ButtonApply": "应用",
"ButtonApplyChapters": "应用到章节", "ButtonApplyChapters": "应用到章节",
@@ -62,7 +62,7 @@
"ButtonRemoveSeriesFromContinueSeries": "从继续收听系列中删除", "ButtonRemoveSeriesFromContinueSeries": "从继续收听系列中删除",
"ButtonReScan": "重新扫描", "ButtonReScan": "重新扫描",
"ButtonReset": "重置", "ButtonReset": "重置",
"ButtonResetToDefault": "Reset to default", "ButtonResetToDefault": "重置为默认",
"ButtonRestore": "恢复", "ButtonRestore": "恢复",
"ButtonSave": "保存", "ButtonSave": "保存",
"ButtonSaveAndClose": "保存并关闭", "ButtonSaveAndClose": "保存并关闭",
@@ -87,12 +87,15 @@
"ButtonUserEdit": "编辑用户 {0}", "ButtonUserEdit": "编辑用户 {0}",
"ButtonViewAll": "查看全部", "ButtonViewAll": "查看全部",
"ButtonYes": "确定", "ButtonYes": "确定",
"ErrorUploadFetchMetadataAPI": "获取元数据时出错",
"ErrorUploadFetchMetadataNoResults": "无法获取元数据 - 尝试更新标题和/或作者",
"ErrorUploadLacksTitle": "必须有标题",
"HeaderAccount": "帐户", "HeaderAccount": "帐户",
"HeaderAdvanced": "高级", "HeaderAdvanced": "高级",
"HeaderAppriseNotificationSettings": "测试通知设置", "HeaderAppriseNotificationSettings": "测试通知设置",
"HeaderAudiobookTools": "有声读物文件管理工具", "HeaderAudiobookTools": "有声读物文件管理工具",
"HeaderAudioTracks": "音轨", "HeaderAudioTracks": "音轨",
"HeaderAuthentication": "Authentication", "HeaderAuthentication": "身份验证",
"HeaderBackups": "备份", "HeaderBackups": "备份",
"HeaderChangePassword": "更改密码", "HeaderChangePassword": "更改密码",
"HeaderChapters": "章节", "HeaderChapters": "章节",
@@ -127,15 +130,15 @@
"HeaderManageTags": "管理标签", "HeaderManageTags": "管理标签",
"HeaderMapDetails": "编辑详情", "HeaderMapDetails": "编辑详情",
"HeaderMatch": "匹配", "HeaderMatch": "匹配",
"HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataOrderOfPrecedence": "元数据优先级",
"HeaderMetadataToEmbed": "嵌入元数据", "HeaderMetadataToEmbed": "嵌入元数据",
"HeaderNewAccount": "新建帐户", "HeaderNewAccount": "新建帐户",
"HeaderNewLibrary": "新建媒体库", "HeaderNewLibrary": "新建媒体库",
"HeaderNotifications": "通知", "HeaderNotifications": "通知",
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication", "HeaderOpenIDConnectAuthentication": "OpenID 连接身份验证",
"HeaderOpenRSSFeed": "打开 RSS 源", "HeaderOpenRSSFeed": "打开 RSS 源",
"HeaderOtherFiles": "其他文件", "HeaderOtherFiles": "其他文件",
"HeaderPasswordAuthentication": "Password Authentication", "HeaderPasswordAuthentication": "密码认证",
"HeaderPermissions": "权限", "HeaderPermissions": "权限",
"HeaderPlayerQueue": "播放队列", "HeaderPlayerQueue": "播放队列",
"HeaderPlaylist": "播放列表", "HeaderPlaylist": "播放列表",
@@ -184,11 +187,11 @@
"LabelAddToCollectionBatch": "批量添加 {0} 个媒体到收藏", "LabelAddToCollectionBatch": "批量添加 {0} 个媒体到收藏",
"LabelAddToPlaylist": "添加到播放列表", "LabelAddToPlaylist": "添加到播放列表",
"LabelAddToPlaylistBatch": "添加 {0} 个项目到播放列表", "LabelAddToPlaylistBatch": "添加 {0} 个项目到播放列表",
"LabelAdminUsersOnly": "Admin users only", "LabelAdminUsersOnly": "仅限管理员用户",
"LabelAll": "全部", "LabelAll": "全部",
"LabelAllUsers": "所有用户", "LabelAllUsers": "所有用户",
"LabelAllUsersExcludingGuests": "All users excluding guests", "LabelAllUsersExcludingGuests": "除访客外的所有用户",
"LabelAllUsersIncludingGuests": "All users including guests", "LabelAllUsersIncludingGuests": "包括访客的所有用户",
"LabelAlreadyInYourLibrary": "已存在你的库中", "LabelAlreadyInYourLibrary": "已存在你的库中",
"LabelAppend": "附加", "LabelAppend": "附加",
"LabelAuthor": "作者", "LabelAuthor": "作者",
@@ -196,10 +199,12 @@
"LabelAuthorLastFirst": "作者 (名, 姓)", "LabelAuthorLastFirst": "作者 (名, 姓)",
"LabelAuthors": "作者", "LabelAuthors": "作者",
"LabelAutoDownloadEpisodes": "自动下载剧集", "LabelAutoDownloadEpisodes": "自动下载剧集",
"LabelAutoLaunch": "Auto Launch", "LabelAutoFetchMetadata": "自动获取元数据",
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", "LabelAutoFetchMetadataHelp": "获取标题, 作者和系列的元数据以简化上传. 上传后可能需要匹配其他元数据.",
"LabelAutoRegister": "Auto Register", "LabelAutoLaunch": "自动启动",
"LabelAutoRegisterDescription": "Automatically create new users after logging in", "LabelAutoLaunchDescription": "导航到登录页面时自动重定向到身份验证提供程序 (手动覆盖路径 <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "自动注册",
"LabelAutoRegisterDescription": "登录后自动创建新用户",
"LabelBackToUser": "返回到用户", "LabelBackToUser": "返回到用户",
"LabelBackupLocation": "备份位置", "LabelBackupLocation": "备份位置",
"LabelBackupsEnableAutomaticBackups": "启用自动备份", "LabelBackupsEnableAutomaticBackups": "启用自动备份",
@@ -210,13 +215,13 @@
"LabelBackupsNumberToKeepHelp": "一次只能删除一个备份, 因此如果你已经有超过此数量的备份, 则应手动删除它们.", "LabelBackupsNumberToKeepHelp": "一次只能删除一个备份, 因此如果你已经有超过此数量的备份, 则应手动删除它们.",
"LabelBitrate": "比特率", "LabelBitrate": "比特率",
"LabelBooks": "图书", "LabelBooks": "图书",
"LabelButtonText": "Button Text", "LabelButtonText": "按钮文本",
"LabelChangePassword": "修改密码", "LabelChangePassword": "修改密码",
"LabelChannels": "声道", "LabelChannels": "声道",
"LabelChapters": "章节", "LabelChapters": "章节",
"LabelChaptersFound": "找到的章节", "LabelChaptersFound": "找到的章节",
"LabelChapterTitle": "章节标题", "LabelChapterTitle": "章节标题",
"LabelClickForMoreInfo": "Click for more info", "LabelClickForMoreInfo": "点击了解更多信息",
"LabelClosePlayer": "关闭播放器", "LabelClosePlayer": "关闭播放器",
"LabelCodec": "编解码", "LabelCodec": "编解码",
"LabelCollapseSeries": "折叠系列", "LabelCollapseSeries": "折叠系列",
@@ -235,12 +240,12 @@
"LabelCurrently": "当前:", "LabelCurrently": "当前:",
"LabelCustomCronExpression": "自定义计划任务表达式:", "LabelCustomCronExpression": "自定义计划任务表达式:",
"LabelDatetime": "日期时间", "LabelDatetime": "日期时间",
"LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDeleteFromFileSystemCheckbox": "从文件系统删除 (取消选中仅从数据库中删除)",
"LabelDescription": "描述", "LabelDescription": "描述",
"LabelDeselectAll": "全部取消选择", "LabelDeselectAll": "全部取消选择",
"LabelDevice": "设备", "LabelDevice": "设备",
"LabelDeviceInfo": "设备信息", "LabelDeviceInfo": "设备信息",
"LabelDeviceIsAvailableTo": "Device is available to...", "LabelDeviceIsAvailableTo": "设备可用于...",
"LabelDirectory": "目录", "LabelDirectory": "目录",
"LabelDiscFromFilename": "从文件名获取光盘", "LabelDiscFromFilename": "从文件名获取光盘",
"LabelDiscFromMetadata": "从元数据获取光盘", "LabelDiscFromMetadata": "从元数据获取光盘",
@@ -266,6 +271,7 @@
"LabelExample": "示例", "LabelExample": "示例",
"LabelExplicit": "信息准确", "LabelExplicit": "信息准确",
"LabelFeedURL": "源 URL", "LabelFeedURL": "源 URL",
"LabelFetchingMetadata": "正在获取元数据",
"LabelFile": "文件", "LabelFile": "文件",
"LabelFileBirthtime": "文件创建时间", "LabelFileBirthtime": "文件创建时间",
"LabelFileModified": "文件修改时间", "LabelFileModified": "文件修改时间",
@@ -283,7 +289,7 @@
"LabelHardDeleteFile": "完全删除文件", "LabelHardDeleteFile": "完全删除文件",
"LabelHasEbook": "有电子书", "LabelHasEbook": "有电子书",
"LabelHasSupplementaryEbook": "有补充电子书", "LabelHasSupplementaryEbook": "有补充电子书",
"LabelHighestPriority": "Highest priority", "LabelHighestPriority": "最高优先级",
"LabelHost": "主机", "LabelHost": "主机",
"LabelHour": "小时", "LabelHour": "小时",
"LabelIcon": "图标", "LabelIcon": "图标",
@@ -325,18 +331,20 @@
"LabelLogLevelInfo": "信息", "LabelLogLevelInfo": "信息",
"LabelLogLevelWarn": "警告", "LabelLogLevelWarn": "警告",
"LabelLookForNewEpisodesAfterDate": "在此日期后查找新剧集", "LabelLookForNewEpisodesAfterDate": "在此日期后查找新剧集",
"LabelLowestPriority": "Lowest Priority", "LabelLowestPriority": "最低优先级",
"LabelMatchExistingUsersBy": "Match existing users by", "LabelMatchExistingUsersBy": "匹配现有用户",
"LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider", "LabelMatchExistingUsersByDescription": "用于连接现有用户. 连接后, 用户将通过SSO提供商提供的唯一 id 进行匹配",
"LabelMediaPlayer": "媒体播放器", "LabelMediaPlayer": "媒体播放器",
"LabelMediaType": "媒体类型", "LabelMediaType": "媒体类型",
"LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources", "LabelMetadataOrderOfPrecedenceDescription": "较高优先级的元数据源将覆盖较低优先级的元数据源",
"LabelMetadataProvider": "元数据提供者", "LabelMetadataProvider": "元数据提供者",
"LabelMetaTag": "元数据标签", "LabelMetaTag": "元数据标签",
"LabelMetaTags": "元标签", "LabelMetaTags": "元标签",
"LabelMinute": "分钟", "LabelMinute": "分钟",
"LabelMissing": "丢失", "LabelMissing": "丢失",
"LabelMissingParts": "丢失的部分", "LabelMissingParts": "丢失的部分",
"LabelMobileRedirectURIs": "允许移动应用重定向 URI",
"LabelMobileRedirectURIsDescription": "这是移动应用程序的有效重定向 URI 白名单. 默认值为 <code>audiobookshelf://oauth</code>,您可以删除它或添加其他 URI 以进行第三方应用集成. 使用星号 (<code>*</code>) 作为唯一条目允许任何 URI.",
"LabelMore": "更多", "LabelMore": "更多",
"LabelMoreInfo": "更多..", "LabelMoreInfo": "更多..",
"LabelName": "名称", "LabelName": "名称",
@@ -398,6 +406,7 @@
"LabelRegion": "区域", "LabelRegion": "区域",
"LabelReleaseDate": "发布日期", "LabelReleaseDate": "发布日期",
"LabelRemoveCover": "移除封面", "LabelRemoveCover": "移除封面",
"LabelRowsPerPage": "Rows per page",
"LabelRSSFeedCustomOwnerEmail": "自定义所有者电子邮件", "LabelRSSFeedCustomOwnerEmail": "自定义所有者电子邮件",
"LabelRSSFeedCustomOwnerName": "自定义所有者名称", "LabelRSSFeedCustomOwnerName": "自定义所有者名称",
"LabelRSSFeedOpen": "打开 RSS 源", "LabelRSSFeedOpen": "打开 RSS 源",
@@ -515,6 +524,7 @@
"LabelUpdateDetailsHelp": "找到匹配项时允许覆盖所选书籍存在的详细信息", "LabelUpdateDetailsHelp": "找到匹配项时允许覆盖所选书籍存在的详细信息",
"LabelUploaderDragAndDrop": "拖放文件或文件夹", "LabelUploaderDragAndDrop": "拖放文件或文件夹",
"LabelUploaderDropFiles": "删除文件", "LabelUploaderDropFiles": "删除文件",
"LabelUploaderItemFetchMetadataHelp": "自动获取标题, 作者和系列",
"LabelUseChapterTrack": "使用章节音轨", "LabelUseChapterTrack": "使用章节音轨",
"LabelUseFullTrack": "使用完整音轨", "LabelUseFullTrack": "使用完整音轨",
"LabelUser": "用户", "LabelUser": "用户",
@@ -548,20 +558,21 @@
"MessageConfirmDeleteBackup": "你确定要删除备份 {0}?", "MessageConfirmDeleteBackup": "你确定要删除备份 {0}?",
"MessageConfirmDeleteFile": "这将从文件系统中删除该文件. 你确定吗?", "MessageConfirmDeleteFile": "这将从文件系统中删除该文件. 你确定吗?",
"MessageConfirmDeleteLibrary": "你确定要永久删除媒体库 \"{0}\"?", "MessageConfirmDeleteLibrary": "你确定要永久删除媒体库 \"{0}\"?",
"MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", "MessageConfirmDeleteLibraryItem": "这将从数据库和文件系统中删除库项目. 你确定吗?",
"MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", "MessageConfirmDeleteLibraryItems": "这将从数据库和文件系统中删除 {0} 个库项目. 你确定吗?",
"MessageConfirmDeleteSession": "你确定要删除此会话吗?", "MessageConfirmDeleteSession": "你确定要删除此会话吗?",
"MessageConfirmForceReScan": "你确定要强制重新扫描吗?", "MessageConfirmForceReScan": "你确定要强制重新扫描吗?",
"MessageConfirmMarkAllEpisodesFinished": "你确定要将所有剧集都标记为已完成吗?", "MessageConfirmMarkAllEpisodesFinished": "你确定要将所有剧集都标记为已完成吗?",
"MessageConfirmMarkAllEpisodesNotFinished": "你确定要将所有剧集都标记为未完成吗?", "MessageConfirmMarkAllEpisodesNotFinished": "你确定要将所有剧集都标记为未完成吗?",
"MessageConfirmMarkSeriesFinished": "你确定要将此系列中的所有书籍都标记为已听完吗?", "MessageConfirmMarkSeriesFinished": "你确定要将此系列中的所有书籍都标记为已听完吗?",
"MessageConfirmMarkSeriesNotFinished": "你确定要将此系列中的所有书籍都标记为未听完吗?", "MessageConfirmMarkSeriesNotFinished": "你确定要将此系列中的所有书籍都标记为未听完吗?",
"MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", "MessageConfirmQuickEmbed": "警告! 快速嵌入不会备份你的音频文件. 确保你有音频文件的备份. <br><br>你是否想继续吗?",
"MessageConfirmRemoveAllChapters": "你确定要移除所有章节吗?", "MessageConfirmRemoveAllChapters": "你确定要移除所有章节吗?",
"MessageConfirmRemoveAuthor": "你确定要删除作者 \"{0}\"?", "MessageConfirmRemoveAuthor": "你确定要删除作者 \"{0}\"?",
"MessageConfirmRemoveCollection": "你确定要移除收藏 \"{0}\"?", "MessageConfirmRemoveCollection": "你确定要移除收藏 \"{0}\"?",
"MessageConfirmRemoveEpisode": "你确定要移除剧集 \"{0}\"?", "MessageConfirmRemoveEpisode": "你确定要移除剧集 \"{0}\"?",
"MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?", "MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?",
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
"MessageConfirmRemoveNarrator": "你确定要删除演播者 \"{0}\"?", "MessageConfirmRemoveNarrator": "你确定要删除演播者 \"{0}\"?",
"MessageConfirmRemovePlaylist": "你确定要移除播放列表 \"{0}\"?", "MessageConfirmRemovePlaylist": "你确定要移除播放列表 \"{0}\"?",
"MessageConfirmRenameGenre": "你确定要将所有项目流派 \"{0}\" 重命名到 \"{1}\"?", "MessageConfirmRenameGenre": "你确定要将所有项目流派 \"{0}\" 重命名到 \"{1}\"?",
@@ -570,7 +581,7 @@
"MessageConfirmRenameTag": "你确定要将所有项目标签 \"{0}\" 重命名到 \"{1}\"?", "MessageConfirmRenameTag": "你确定要将所有项目标签 \"{0}\" 重命名到 \"{1}\"?",
"MessageConfirmRenameTagMergeNote": "注意: 该标签已经存在, 因此它们将被合并.", "MessageConfirmRenameTagMergeNote": "注意: 该标签已经存在, 因此它们将被合并.",
"MessageConfirmRenameTagWarning": "警告! 已经存在有大小写不同的类似标签 \"{0}\".", "MessageConfirmRenameTagWarning": "警告! 已经存在有大小写不同的类似标签 \"{0}\".",
"MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", "MessageConfirmReScanLibraryItems": "你确定要重新扫描 {0} 个项目吗?",
"MessageConfirmSendEbookToDevice": "你确定要发送 {0} 电子书 \"{1}\" 到设备 \"{2}\"?", "MessageConfirmSendEbookToDevice": "你确定要发送 {0} 电子书 \"{1}\" 到设备 \"{2}\"?",
"MessageDownloadingEpisode": "正在下载剧集", "MessageDownloadingEpisode": "正在下载剧集",
"MessageDragFilesIntoTrackOrder": "将文件拖动到正确的音轨顺序", "MessageDragFilesIntoTrackOrder": "将文件拖动到正确的音轨顺序",
@@ -641,6 +652,7 @@
"MessageRestoreBackupConfirm": "你确定要恢复创建的这个备份", "MessageRestoreBackupConfirm": "你确定要恢复创建的这个备份",
"MessageRestoreBackupWarning": "恢复备份将覆盖位于 /config 的整个数据库并覆盖 /metadata/items & /metadata/authors 中的图像.<br /><br />备份不会修改媒体库文件夹中的任何文件. 如果您已启用服务器设置将封面和元数据存储在库文件夹中,则不会备份或覆盖这些内容.<br /><br />将自动刷新使用服务器的所有客户端.", "MessageRestoreBackupWarning": "恢复备份将覆盖位于 /config 的整个数据库并覆盖 /metadata/items & /metadata/authors 中的图像.<br /><br />备份不会修改媒体库文件夹中的任何文件. 如果您已启用服务器设置将封面和元数据存储在库文件夹中,则不会备份或覆盖这些内容.<br /><br />将自动刷新使用服务器的所有客户端.",
"MessageSearchResultsFor": "搜索结果", "MessageSearchResultsFor": "搜索结果",
"MessageSelected": "{0} selected",
"MessageServerCouldNotBeReached": "无法访问服务器", "MessageServerCouldNotBeReached": "无法访问服务器",
"MessageSetChaptersFromTracksDescription": "把每个音频文件设置为章节并将章节标题设置为音频文件名", "MessageSetChaptersFromTracksDescription": "把每个音频文件设置为章节并将章节标题设置为音频文件名",
"MessageStartPlaybackAtTime": "开始播放 \"{0}\" 在 {1}?", "MessageStartPlaybackAtTime": "开始播放 \"{0}\" 在 {1}?",
@@ -656,7 +668,6 @@
"NoteChangeRootPassword": "Root 是唯一可以拥有空密码的用户", "NoteChangeRootPassword": "Root 是唯一可以拥有空密码的用户",
"NoteChapterEditorTimes": "注意: 第一章开始时间必须保持在 0:00, 最后一章开始时间不能超过有声读物持续时间.", "NoteChapterEditorTimes": "注意: 第一章开始时间必须保持在 0:00, 最后一章开始时间不能超过有声读物持续时间.",
"NoteFolderPicker": "注意: 将不显示已映射的文件夹", "NoteFolderPicker": "注意: 将不显示已映射的文件夹",
"NoteFolderPickerDebian": "注意: debian 安装的文件夹选择器尚未完全实现. 您应该直接输入媒体库的路径.",
"NoteRSSFeedPodcastAppsHttps": "警告: 大多数播客应用程序都需要 RSS 源 URL 使用 HTTPS", "NoteRSSFeedPodcastAppsHttps": "警告: 大多数播客应用程序都需要 RSS 源 URL 使用 HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "警告: 您的一集或多集没有发布日期. 一些播客应用程序要求这样做.", "NoteRSSFeedPodcastAppsPubDate": "警告: 您的一集或多集没有发布日期. 一些播客应用程序要求这样做.",
"NoteUploaderFoldersWithMediaFiles": "包含媒体文件的文件夹将作为单独的媒体库项目处理.", "NoteUploaderFoldersWithMediaFiles": "包含媒体文件的文件夹将作为单独的媒体库项目处理.",
+1
View File
@@ -56,6 +56,7 @@ module.exports = {
'16': '4rem', '16': '4rem',
'20': '5rem', '20': '5rem',
'24': '6rem', '24': '6rem',
'26': '6.5rem',
'32': '8rem', '32': '8rem',
'48': '12rem', '48': '12rem',
'64': '16rem', '64': '16rem',
+17 -1
View File
@@ -3,12 +3,28 @@ version: "3.7"
services: services:
audiobookshelf: audiobookshelf:
image: ghcr.io/advplyr/audiobookshelf image: ghcr.io/advplyr/audiobookshelf:latest
# ABS runs on port 13378 by default. If you want to change
# the port, only change the external port, not the internal port
ports: ports:
- 13378:80 - 13378:80
volumes: volumes:
# These volumes are needed to keep your library persistent
# and allow media to be accessed by the ABS server.
# The path to the left of the colon is the path on your computer,
# and the path to the right of the colon is where the data is
# available to ABS in Docker.
# You can change these media directories or add as many as you want
- ./audiobooks:/audiobooks - ./audiobooks:/audiobooks
- ./podcasts:/podcasts - ./podcasts:/podcasts
# The metadata directory can be stored anywhere on your computer
- ./metadata:/metadata - ./metadata:/metadata
# The config directory needs to be on the same physical machine
# you are running ABS on
- ./config:/config - ./config:/config
restart: unless-stopped restart: unless-stopped
# You can use the following environment variable 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
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.6.0", "version": "2.7.2",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.6.0", "version": "2.7.2",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"axios": "^0.27.2", "axios": "^0.27.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.6.0", "version": "2.7.2",
"buildNumber": 1, "buildNumber": 1,
"description": "Self-hosted audiobook and podcast server", "description": "Self-hosted audiobook and podcast server",
"main": "index.js", "main": "index.js",
+45 -10
View File
@@ -39,13 +39,15 @@ Audiobookshelf is a self-hosted audiobook and podcast server.
Is there a feature you are looking for? [Suggest it](https://github.com/advplyr/audiobookshelf/issues/new/choose) Is there a feature you are looking for? [Suggest it](https://github.com/advplyr/audiobookshelf/issues/new/choose)
Join us on [Discord](https://discord.gg/pJsjuNCKRq) or [Matrix](https://matrix.to/#/#audiobookshelf:matrix.org) Join us on [Discord](https://discord.gg/HQgCbd6E75) or [Matrix](https://matrix.to/#/#audiobookshelf:matrix.org)
### Android App (beta) ### Android App (beta)
Try it out on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app) Try it out on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app)
### iOS App (beta) ### iOS App (beta)
Available using Test Flight: https://testflight.apple.com/join/wiic7QIW - [Join the discussion](https://github.com/advplyr/audiobookshelf-app/discussions/60) **Beta is currently full. Apple has a hard limit of 10k beta testers. Updates will be posted in Discord/Matrix.**
Using Test Flight: https://testflight.apple.com/join/wiic7QIW ***(beta is full)***
### Build your own tools & clients ### Build your own tools & clients
Check out the [API documentation](https://api.audiobookshelf.org/) Check out the [API documentation](https://api.audiobookshelf.org/)
@@ -174,16 +176,49 @@ serve that directly:
[See LinuxServer.io config sample](https://github.com/linuxserver/reverse-proxy-confs/blob/master/audiobookshelf.subdomain.conf.sample) [See LinuxServer.io config sample](https://github.com/linuxserver/reverse-proxy-confs/blob/master/audiobookshelf.subdomain.conf.sample)
### Synology Reverse Proxy ### Synology NAS Reverse Proxy Setup (DSM 7+/Quickconnect)
1. Open Control Panel > Application Portal 1. **Open Control Panel**
2. Change to the Reverse Proxy tab - Navigate to `Login Portal > Advanced`.
3. Select the proxy rule for which you want to enable Websockets and click on Edit
4. Change to the "Custom Header" tab 2. **General Tab**
5. Click Create > WebSocket - Click `Reverse Proxy` > `Create`.
6. Click Save
| Setting | Value |
|---------|----------------|
| Reverse Proxy Name | audiobookshelf |
3. **Source Configuration**
| Setting | Value |
|-------------------------|-------------------------------------|
| Protocol | HTTPS |
| Hostname | `<sub>.<quickconnectdomain>.synology.me` |
| Port | 443 |
| Access Control Profile | Leave as is |
- Example Hostname: `audiobookshelf.mydomain.synology.me`
4. **Destination Configuration**
| Setting | Value |
|-----------|------------------|
| Protocol | HTTP |
| Hostname | Your NAS IP |
| Port | 13378 |
5. **Custom Header Tab**
- Go to `Create > Websocket`.
- Configure Headers (leave as is):
| Header Name | Value |
|-------------|------------------|
| Upgrade | `$http_upgrade` |
| Connection | `$connection_upgrade` |
6. **Advanced Settings Tab**
- Leave as is.
[from @silentArtifact](https://github.com/advplyr/audiobookshelf/issues/241#issuecomment-1036732329)
### [Traefik Reverse Proxy](https://doc.traefik.io/traefik/) ### [Traefik Reverse Proxy](https://doc.traefik.io/traefik/)
+136 -17
View File
@@ -8,6 +8,7 @@ const ExtractJwt = require('passport-jwt').ExtractJwt
const OpenIDClient = require('openid-client') const OpenIDClient = require('openid-client')
const Database = require('./Database') const Database = require('./Database')
const Logger = require('./Logger') const Logger = require('./Logger')
const e = require('express')
/** /**
* @class Class for handling all the authentication related functionality. * @class Class for handling all the authentication related functionality.
@@ -15,6 +16,8 @@ const Logger = require('./Logger')
class Auth { class Auth {
constructor() { constructor() {
// Map of openId sessions indexed by oauth2 state-variable
this.openIdAuthSession = new Map()
} }
/** /**
@@ -187,9 +190,10 @@ class Auth {
* @param {import('express').Response} res * @param {import('express').Response} res
*/ */
paramsToCookies(req, res) { paramsToCookies(req, res) {
if (req.query.isRest?.toLowerCase() == 'true') { // Set if isRest flag is set or if mobile oauth flow is used
if (req.query.isRest?.toLowerCase() == 'true' || req.query.redirect_uri) {
// store the isRest flag to the is_rest cookie // store the isRest flag to the is_rest cookie
res.cookie('is_rest', req.query.isRest.toLowerCase(), { res.cookie('is_rest', 'true', {
maxAge: 120000, // 2 min maxAge: 120000, // 2 min
httpOnly: true httpOnly: true
}) })
@@ -283,8 +287,27 @@ class Auth {
// for API or mobile clients // for API or mobile clients
const oidcStrategy = passport._strategy('openid-client') const oidcStrategy = passport._strategy('openid-client')
const protocol = (req.secure || req.get('x-forwarded-proto') === 'https') ? 'https' : 'http' const protocol = (req.secure || req.get('x-forwarded-proto') === 'https') ? 'https' : 'http'
oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/callback`).toString()
Logger.debug(`[Auth] Set oidc redirect_uri=${oidcStrategy._params.redirect_uri}`) let mobile_redirect_uri = null
// The client wishes a different redirect_uri
// We will allow if it is in the whitelist, by saving it into this.openIdAuthSession and setting the redirect uri to /auth/openid/mobile-redirect
// where we will handle the redirect to it
if (req.query.redirect_uri) {
// Check if the redirect_uri is in the whitelist
if (Database.serverSettings.authOpenIDMobileRedirectURIs.includes(req.query.redirect_uri) ||
(Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*')) {
oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/mobile-redirect`).toString()
mobile_redirect_uri = req.query.redirect_uri
} else {
Logger.debug(`[Auth] Invalid redirect_uri=${req.query.redirect_uri} - not in whitelist`)
return res.status(400).send('Invalid redirect_uri')
}
} else {
oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/callback`).toString()
}
Logger.debug(`[Auth] Oidc redirect_uri=${oidcStrategy._params.redirect_uri}`)
const client = oidcStrategy._client const client = oidcStrategy._client
const sessionKey = oidcStrategy._key const sessionKey = oidcStrategy._key
@@ -324,16 +347,21 @@ class Auth {
req.session[sessionKey] = { req.session[sessionKey] = {
...req.session[sessionKey], ...req.session[sessionKey],
...pick(params, 'nonce', 'state', 'max_age', 'response_type'), ...pick(params, 'nonce', 'state', 'max_age', 'response_type'),
mobile: req.query.isRest?.toLowerCase() === 'true' // Used in the abs callback later mobile: req.query.redirect_uri, // Used in the abs callback later, set mobile if redirect_uri is filled out
sso_redirect_uri: oidcStrategy._params.redirect_uri // Save the redirect_uri (for the SSO Provider) for the callback
} }
// We cannot save redirect_uri in the session, because it the mobile client uses browser instead of the API
// for the request to mobile-redirect and as such the session is not shared
this.openIdAuthSession.set(params.state, { mobile_redirect_uri: mobile_redirect_uri })
// Now get the URL to direct to // Now get the URL to direct to
const authorizationUrl = client.authorizationUrl({ const authorizationUrl = client.authorizationUrl({
...params, ...params,
scope: 'openid profile email', scope: 'openid profile email',
response_type: 'code', response_type: 'code',
code_challenge, code_challenge,
code_challenge_method, code_challenge_method
}) })
// params (isRest, callback) to a cookie that will be send to the client // params (isRest, callback) to a cookie that will be send to the client
@@ -347,6 +375,37 @@ class Auth {
} }
}) })
// This will be the oauth2 callback route for mobile clients
// It will redirect to an app-link like audiobookshelf://oauth
router.get('/auth/openid/mobile-redirect', (req, res) => {
try {
// Extract the state parameter from the request
const { state, code } = req.query
// Check if the state provided is in our list
if (!state || !this.openIdAuthSession.has(state)) {
Logger.error('[Auth] /auth/openid/mobile-redirect route: State parameter mismatch')
return res.status(400).send('State parameter mismatch')
}
let mobile_redirect_uri = this.openIdAuthSession.get(state).mobile_redirect_uri
if (!mobile_redirect_uri) {
Logger.error('[Auth] No redirect URI')
return res.status(400).send('No redirect URI')
}
this.openIdAuthSession.delete(state)
const redirectUri = `${mobile_redirect_uri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`
// Redirect to the overwrite URI saved in the map
res.redirect(redirectUri)
} catch (error) {
Logger.error(`[Auth] Error in /auth/openid/mobile-redirect route: ${error}`)
res.status(500).send('Internal Server Error')
}
})
// openid strategy callback route (this receives the token from the configured openid login provider) // openid strategy callback route (this receives the token from the configured openid login provider)
router.get('/auth/openid/callback', (req, res, next) => { router.get('/auth/openid/callback', (req, res, next) => {
const oidcStrategy = passport._strategy('openid-client') const oidcStrategy = passport._strategy('openid-client')
@@ -363,29 +422,85 @@ class Auth {
req.session[sessionKey].code_verifier = req.query.code_verifier req.session[sessionKey].code_verifier = req.query.code_verifier
} }
function handleAuthError(isMobile, errorCode, errorMessage, logMessage, response) {
Logger.error(logMessage)
if (response) {
// Depending on the error, it can also have a body
// We also log the request header the passport plugin sents for the URL
const header = response.req?._header.replace(/Authorization: [^\r\n]*/i, 'Authorization: REDACTED')
Logger.debug(header + '\n' + response.body?.toString())
}
if (isMobile) {
return res.status(errorCode).send(errorMessage)
} else {
return res.redirect(`/login?error=${encodeURIComponent(errorMessage)}&autoLaunch=0`)
}
}
function passportCallback(req, res, next) {
return (err, user, info) => {
const isMobile = req.session[sessionKey]?.mobile === true
if (err) {
return handleAuthError(isMobile, 500, 'Error in callback', `[Auth] Error in openid callback - ${err}`, err?.response)
}
if (!user) {
// Info usually contains the error message from the SSO provider
return handleAuthError(isMobile, 401, 'Unauthorized', `[Auth] No data in openid callback - ${info}`, info?.response)
}
req.logIn(user, (loginError) => {
if (loginError) {
return handleAuthError(isMobile, 500, 'Error during login', `[Auth] Error in openid callback: ${loginError}`)
}
next()
})
}
}
// While not required by the standard, the passport plugin re-sends the original redirect_uri in the token request // While not required by the standard, the passport plugin re-sends the original redirect_uri in the token request
// We need to set it correctly, as some SSO providers (e.g. keycloak) check that parameter when it is provided // We need to set it correctly, as some SSO providers (e.g. keycloak) check that parameter when it is provided
if (req.session[sessionKey].mobile) { // We set it here again because the passport param can change between requests
return passport.authenticate('openid-client', { redirect_uri: 'audiobookshelf://oauth' })(req, res, next) return passport.authenticate('openid-client', { redirect_uri: req.session[sessionKey].sso_redirect_uri }, passportCallback(req, res, next))(req, res, next)
} else {
return passport.authenticate('openid-client', { failureRedirect: '/login?error=Unauthorized&autoLaunch=0' })(req, res, next)
}
}, },
// on a successfull login: read the cookies and react like the client requested (callback or json) // on a successfull login: read the cookies and react like the client requested (callback or json)
this.handleLoginSuccessBasedOnCookie.bind(this)) this.handleLoginSuccessBasedOnCookie.bind(this))
/** /**
* Used to auto-populate the openid URLs in config/authentication * Helper route used to auto-populate the openid URLs in config/authentication
* Takes an issuer URL as a query param and requests the config data at "/.well-known/openid-configuration"
*
* @example /auth/openid/config?issuer=http://192.168.1.66:9000/application/o/audiobookshelf/
*/ */
router.get('/auth/openid/config', async (req, res) => { router.get('/auth/openid/config', this.isAuthenticated, async (req, res) => {
if (!req.user.isAdminOrUp) {
Logger.error(`[Auth] Non-admin user "${req.user.username}" attempted to get issuer config`)
return res.sendStatus(403)
}
if (!req.query.issuer) { if (!req.query.issuer) {
return res.status(400).send('Invalid request. Query param \'issuer\' is required') return res.status(400).send('Invalid request. Query param \'issuer\' is required')
} }
// Strip trailing slash
let issuerUrl = req.query.issuer let issuerUrl = req.query.issuer
if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1) if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1)
const configUrl = `${issuerUrl}/.well-known/openid-configuration` // Append config pathname and validate URL
axios.get(configUrl).then(({ data }) => { let configUrl = null
try {
configUrl = new URL(`${issuerUrl}/.well-known/openid-configuration`)
if (!configUrl.pathname.endsWith('/.well-known/openid-configuration')) {
throw new Error('Invalid pathname')
}
} catch (error) {
Logger.error(`[Auth] Failed to get openid configuration. Invalid URL "${configUrl}"`, error)
return res.status(400).send('Invalid request. Query param \'issuer\' is invalid')
}
axios.get(configUrl.toString()).then(({ data }) => {
res.json({ res.json({
issuer: data.issuer, issuer: data.issuer,
authorization_endpoint: data.authorization_endpoint, authorization_endpoint: data.authorization_endpoint,
@@ -504,13 +619,13 @@ class Auth {
// Load the user given it's username // Load the user given it's username
const user = await Database.userModel.getUserByUsername(username.toLowerCase()) const user = await Database.userModel.getUserByUsername(username.toLowerCase())
if (!user || !user.isActive) { if (!user?.isActive) {
done(null, null) done(null, null)
return return
} }
// Check passwordless root user // Check passwordless root user
if (user.type === 'root' && (!user.pash || user.pash === '')) { if (user.type === 'root' && !user.pash) {
if (password) { if (password) {
// deny login // deny login
done(null, null) done(null, null)
@@ -519,6 +634,10 @@ class Auth {
// approve login // approve login
done(null, user) done(null, user)
return return
} else if (!user.pash) {
Logger.error(`[Auth] User "${user.username}"/"${user.type}" attempted to login without a password set`)
done(null, null)
return
} }
// Check password match // Check password match
+22 -4
View File
@@ -1,5 +1,5 @@
const Path = require('path') const Path = require('path')
const { Sequelize } = require('sequelize') const { Sequelize, Op } = require('sequelize')
const packageJson = require('../package.json') const packageJson = require('../package.json')
const fs = require('./libs/fsExtra') const fs = require('./libs/fsExtra')
@@ -122,11 +122,16 @@ class Database {
return this.models.feed return this.models.feed
} }
/** @type {typeof import('./models/Feed')} */ /** @type {typeof import('./models/FeedEpisode')} */
get feedEpisodeModel() { get feedEpisodeModel() {
return this.models.feedEpisode return this.models.feedEpisode
} }
/** @type {typeof import('./models/PlaybackSession')} */
get playbackSessionModel() {
return this.models.playbackSession
}
/** /**
* Check if db file exists * Check if db file exists
* @returns {boolean} * @returns {boolean}
@@ -172,11 +177,11 @@ class Database {
if (process.env.QUERY_LOGGING === "log") { if (process.env.QUERY_LOGGING === "log") {
// Setting QUERY_LOGGING=log will log all Sequelize queries before they run // Setting QUERY_LOGGING=log will log all Sequelize queries before they run
Logger.info(`[Database] Query logging enabled`) Logger.info(`[Database] Query logging enabled`)
logging = (query) => Logger.dev(`Running the following query:\n ${query}`) logging = (query) => Logger.debug(`Running the following query:\n ${query}`)
} else if (process.env.QUERY_LOGGING === "benchmark") { } else if (process.env.QUERY_LOGGING === "benchmark") {
// Setting QUERY_LOGGING=benchmark will log all Sequelize queries and their execution times, after they run // Setting QUERY_LOGGING=benchmark will log all Sequelize queries and their execution times, after they run
Logger.info(`[Database] Query benchmarking enabled"`) Logger.info(`[Database] Query benchmarking enabled"`)
logging = (query, time) => Logger.dev(`Ran the following query in ${time}ms:\n ${query}`) logging = (query, time) => Logger.debug(`Ran the following query in ${time}ms:\n ${query}`)
benchmark = true benchmark = true
} }
@@ -693,6 +698,7 @@ class Database {
* Clean invalid records in database * Clean invalid records in database
* Series should have atleast one Book * Series should have atleast one Book
* Book and Podcast must have an associated LibraryItem * Book and Podcast must have an associated LibraryItem
* Remove playback sessions that are 3 seconds or less
*/ */
async cleanDatabase() { async cleanDatabase() {
// Remove invalid Podcast records // Remove invalid Podcast records
@@ -733,6 +739,18 @@ class Database {
Logger.warn(`Found series "${series.name}" with no books - removing it`) Logger.warn(`Found series "${series.name}" with no books - removing it`)
await series.destroy() await series.destroy()
} }
// Remove playback sessions that were 3 seconds or less
const badSessionsRemoved = await this.playbackSessionModel.destroy({
where: {
timeListening: {
[Op.lte]: 3
}
}
})
if (badSessionsRemoved > 0) {
Logger.warn(`Removed ${badSessionsRemoved} sessions that were 3 seconds or less`)
}
} }
} }
-10
View File
@@ -5,7 +5,6 @@ class Logger {
constructor() { constructor() {
this.isDev = process.env.NODE_ENV !== 'production' this.isDev = process.env.NODE_ENV !== 'production'
this.logLevel = !this.isDev ? LogLevel.INFO : LogLevel.TRACE this.logLevel = !this.isDev ? LogLevel.INFO : LogLevel.TRACE
this.hideDevLogs = process.env.HIDE_DEV_LOGS === undefined ? !this.isDev : process.env.HIDE_DEV_LOGS === '1'
this.socketListeners = [] this.socketListeners = []
this.logManager = null this.logManager = null
@@ -88,15 +87,6 @@ class Logger {
this.debug(`Set Log Level to ${this.levelString}`) this.debug(`Set Log Level to ${this.levelString}`)
} }
/**
* Only to console and only for development
* @param {...any} args
*/
dev(...args) {
if (this.hideDevLogs) return
console.log(`[${this.timestamp}] DEV:`, ...args)
}
trace(...args) { trace(...args) {
if (this.logLevel > LogLevel.TRACE) return if (this.logLevel > LogLevel.TRACE) return
console.trace(`[${this.timestamp}] TRACE:`, ...args) console.trace(`[${this.timestamp}] TRACE:`, ...args)
+30 -3
View File
@@ -33,6 +33,7 @@ const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
const RssFeedManager = require('./managers/RssFeedManager') const RssFeedManager = require('./managers/RssFeedManager')
const CronManager = require('./managers/CronManager') const CronManager = require('./managers/CronManager')
const ApiCacheManager = require('./managers/ApiCacheManager') const ApiCacheManager = require('./managers/ApiCacheManager')
const BinaryManager = require('./managers/BinaryManager')
const LibraryScanner = require('./scanner/LibraryScanner') const LibraryScanner = require('./scanner/LibraryScanner')
//Import the main Passport and Express-Session library //Import the main Passport and Express-Session library
@@ -74,6 +75,7 @@ class Server {
this.rssFeedManager = new RssFeedManager() this.rssFeedManager = new RssFeedManager()
this.cronManager = new CronManager(this.podcastManager) this.cronManager = new CronManager(this.podcastManager)
this.apiCacheManager = new ApiCacheManager() this.apiCacheManager = new ApiCacheManager()
this.binaryManager = new BinaryManager()
// Routers // Routers
this.apiRouter = new ApiRouter(this) this.apiRouter = new ApiRouter(this)
@@ -120,6 +122,11 @@ class Server {
await this.cronManager.init(libraries) await this.cronManager.init(libraries)
this.apiCacheManager.init() this.apiCacheManager.init()
// Download ffmpeg & ffprobe if not found (Currently only in use for Windows installs)
if (global.isWin || Logger.isDev) {
await this.binaryManager.init()
}
if (Database.serverSettings.scannerDisableWatcher) { if (Database.serverSettings.scannerDisableWatcher) {
Logger.info(`[Server] Watcher is disabled`) Logger.info(`[Server] Watcher is disabled`)
this.watcher.disabled = true this.watcher.disabled = true
@@ -136,15 +143,16 @@ class Server {
/** /**
* @temporary * @temporary
* This is necessary for the ebook API endpoint in the mobile apps * 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 * 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 * 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 * @see https://ionicframework.com/docs/troubleshooting/cors
* *
* Running in development allows cors to allow testing the mobile apps in the browser * Running in development allows cors to allow testing the mobile apps in the browser
*/ */
app.use((req, res, next) => { app.use((req, res, next) => {
if (Logger.isDev || req.path.match(/\/api\/items\/([a-z0-9-]{36})\/ebook(\/[0-9]+)?/)) { if (Logger.isDev || req.path.match(/\/api\/items\/([a-z0-9-]{36})\/(ebook|cover)(\/[0-9]+)?/)) {
const allowedOrigins = ['capacitor://localhost', 'http://localhost'] const allowedOrigins = ['capacitor://localhost', 'http://localhost']
if (Logger.isDev || allowedOrigins.some(o => o === req.get('origin'))) { if (Logger.isDev || allowedOrigins.some(o => o === req.get('origin'))) {
res.header('Access-Control-Allow-Origin', req.get('origin')) res.header('Access-Control-Allow-Origin', req.get('origin'))
@@ -231,6 +239,7 @@ class Server {
'/library/:library/search', '/library/:library/search',
'/library/:library/bookshelf/:id?', '/library/:library/bookshelf/:id?',
'/library/:library/authors', '/library/:library/authors',
'/library/:library/narrators',
'/library/:library/series/:id?', '/library/:library/series/:id?',
'/library/:library/podcast/search', '/library/:library/podcast/search',
'/library/:library/podcast/latest', '/library/:library/podcast/latest',
@@ -275,6 +284,19 @@ class Server {
}) })
app.get('/healthcheck', (req, res) => res.sendStatus(200)) app.get('/healthcheck', (req, res) => res.sendStatus(200))
let sigintAlreadyReceived = false
process.on('SIGINT', async () => {
if (!sigintAlreadyReceived) {
sigintAlreadyReceived = true
Logger.info('SIGINT (Ctrl+C) received. Shutting down...')
await this.stop()
Logger.info('Server stopped. Exiting.')
} else {
Logger.info('SIGINT (Ctrl+C) received again. Exiting immediately.')
}
process.exit(0)
})
this.server.listen(this.Port, this.Host, () => { this.server.listen(this.Port, this.Host, () => {
if (this.Host) Logger.info(`Listening on http://${this.Host}:${this.Port}`) if (this.Host) Logger.info(`Listening on http://${this.Host}:${this.Port}`)
else Logger.info(`Listening on port :${this.Port}`) else Logger.info(`Listening on port :${this.Port}`)
@@ -381,12 +403,17 @@ class Server {
res.sendStatus(200) res.sendStatus(200)
} }
/**
* Gracefully stop server
* Stops watcher and socket server
*/
async stop() { async stop() {
Logger.info('=== Stopping Server ===')
await this.watcher.close() await this.watcher.close()
Logger.info('Watcher Closed') Logger.info('Watcher Closed')
return new Promise((resolve) => { return new Promise((resolve) => {
this.server.close((err) => { SocketAuthority.close((err) => {
if (err) { if (err) {
Logger.error('Failed to close server', err) Logger.error('Failed to close server', err)
} else { } else {
+14
View File
@@ -73,6 +73,20 @@ class SocketAuthority {
} }
} }
/**
* Closes the Socket.IO server and disconnect all clients
*
* @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()
}
initialize(Server) { initialize(Server) {
this.Server = Server this.Server = Server
+49 -11
View File
@@ -1,31 +1,69 @@
const Path = require('path') const Path = require('path')
const Logger = require('../Logger') const Logger = require('../Logger')
const Database = require('../Database')
const fs = require('../libs/fsExtra') const fs = require('../libs/fsExtra')
const { toNumber } = require('../utils/index')
const fileUtils = require('../utils/fileUtils')
class FileSystemController { class FileSystemController {
constructor() { } constructor() { }
/**
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async getPaths(req, res) { async getPaths(req, res) {
if (!req.user.isAdminOrUp) { if (!req.user.isAdminOrUp) {
Logger.error(`[FileSystemController] Non-admin user attempting to get filesystem paths`, req.user) Logger.error(`[FileSystemController] Non-admin user attempting to get filesystem paths`, req.user)
return res.sendStatus(403) return res.sendStatus(403)
} }
const excludedDirs = ['node_modules', 'client', 'server', '.git', 'static', 'build', 'dist', 'metadata', 'config', 'sys', 'proc'].map(dirname => { const relpath = req.query.path
return Path.sep + dirname const level = toNumber(req.query.level, 0)
})
// Do not include existing mapped library paths in response // Validate path. Must be absolute
const libraryFoldersPaths = await Database.libraryFolderModel.getAllLibraryFolderPaths() if (relpath && (!Path.isAbsolute(relpath) || !await fs.pathExists(relpath))) {
libraryFoldersPaths.forEach((path) => { Logger.error(`[FileSystemController] Invalid path in query string "${relpath}"`)
let dir = path || '' return res.status(400).send('Invalid "path" query string')
if (dir.includes(global.appRoot)) dir = dir.replace(global.appRoot, '') }
excludedDirs.push(dir) Logger.debug(`[FileSystemController] Getting file paths at ${relpath || 'root'} (${level})`)
let directories = []
// Windows returns drives first
if (global.isWin) {
if (relpath) {
directories = await fileUtils.getDirectoriesInPath(relpath, level)
} else {
const drives = await fileUtils.getWindowsDrives().catch((error) => {
Logger.error(`[FileSystemController] Failed to get windows drives`, error)
return []
})
if (drives.length) {
directories = drives.map(d => {
return {
path: d,
dirname: d,
level: 0
}
})
}
}
} else {
directories = await fileUtils.getDirectoriesInPath(relpath || '/', level)
}
// Exclude some dirs from this project to be cleaner in Docker
const excludedDirs = ['node_modules', 'client', 'server', '.git', 'static', 'build', 'dist', 'metadata', 'config', 'sys', 'proc', '.devcontainer', '.nyc_output', '.github', '.vscode'].map(dirname => {
return fileUtils.filePathToPOSIX(Path.join(global.appRoot, dirname))
})
directories = directories.filter(dir => {
return !excludedDirs.includes(dir.path)
}) })
res.json({ res.json({
directories: await this.getDirectories(global.appRoot, '/', excludedDirs) posix: !global.isWin,
directories
}) })
} }
+2 -2
View File
@@ -552,8 +552,8 @@ class LibraryController {
* @param {import('express').Response} res * @param {import('express').Response} res
*/ */
async search(req, res) { async search(req, res) {
if (!req.query.q) { if (!req.query.q || typeof req.query.q !== 'string') {
return res.status(400).send('No query string') return res.status(400).send('Invalid request. Query param "q" must be a string')
} }
const limit = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12 const limit = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12
const query = asciiOnlyToLowerCase(req.query.q.trim()) const query = asciiOnlyToLowerCase(req.query.q.trim())
+17
View File
@@ -3,6 +3,7 @@ const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database') const Database = require('../Database')
const { sort } = require('../libs/fastSort') const { sort } = require('../libs/fastSort')
const { toNumber } = require('../utils/index') const { toNumber } = require('../utils/index')
const userStats = require('../utils/queries/userStats')
class MeController { class MeController {
constructor() { } constructor() { }
@@ -333,5 +334,21 @@ class MeController {
} }
res.json(req.user.toJSONForBrowser()) res.json(req.user.toJSONForBrowser())
} }
/**
* GET: /api/stats/year/:year
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async getStatsForYear(req, res) {
const year = Number(req.params.year)
if (isNaN(year) || year < 2000 || year > 9999) {
Logger.error(`[MeController] Invalid year "${year}"`)
return res.status(400).send('Invalid year')
}
const data = await userStats.getStatsForYear(req.user, year)
res.json(data)
}
} }
module.exports = new MeController() module.exports = new MeController()
+63 -36
View File
@@ -8,8 +8,10 @@ const Database = require('../Database')
const libraryItemFilters = require('../utils/queries/libraryItemFilters') const libraryItemFilters = require('../utils/queries/libraryItemFilters')
const patternValidation = require('../libs/nodeCron/pattern-validation') const patternValidation = require('../libs/nodeCron/pattern-validation')
const { isObject, getTitleIgnorePrefix } = require('../utils/index') const { isObject, getTitleIgnorePrefix } = require('../utils/index')
const { sanitizeFilename } = require('../utils/fileUtils')
const TaskManager = require('../managers/TaskManager') const TaskManager = require('../managers/TaskManager')
const adminStats = require('../utils/queries/adminStats')
// //
// This is a controller for routes that don't have a home yet :( // This is a controller for routes that don't have a home yet :(
@@ -32,12 +34,9 @@ class MiscController {
Logger.error('Invalid request, no files') Logger.error('Invalid request, no files')
return res.sendStatus(400) return res.sendStatus(400)
} }
const files = Object.values(req.files) const files = Object.values(req.files)
const title = req.body.title const { title, author, series, folder: folderId, library: libraryId } = req.body
const author = req.body.author
const series = req.body.series
const libraryId = req.body.library
const folderId = req.body.folder
const library = await Database.libraryModel.getOldById(libraryId) const library = await Database.libraryModel.getOldById(libraryId)
if (!library) { if (!library) {
@@ -52,43 +51,29 @@ class MiscController {
return res.status(500).send(`Invalid post data`) return res.status(500).send(`Invalid post data`)
} }
// For setting permissions recursively // Podcasts should only be one folder deep
let outputDirectory = '' const outputDirectoryParts = library.isPodcast ? [title] : [author, series, title]
let firstDirPath = '' // `.filter(Boolean)` to strip out all the potentially missing details (eg: `author`)
// before sanitizing all the directory parts to remove illegal chars and finally prepending
if (library.isPodcast) { // Podcasts only in 1 folder // the base folder path
outputDirectory = Path.join(folder.fullPath, title) const cleanedOutputDirectoryParts = outputDirectoryParts.filter(Boolean).map(part => sanitizeFilename(part))
firstDirPath = outputDirectory const outputDirectory = Path.join(...[folder.fullPath, ...cleanedOutputDirectoryParts])
} else {
firstDirPath = Path.join(folder.fullPath, author)
if (series && author) {
outputDirectory = Path.join(folder.fullPath, author, series, title)
} else if (author) {
outputDirectory = Path.join(folder.fullPath, author, title)
} else {
outputDirectory = Path.join(folder.fullPath, title)
}
}
if (await fs.pathExists(outputDirectory)) {
Logger.error(`[Server] Upload directory "${outputDirectory}" already exists`)
return res.status(500).send(`Directory "${outputDirectory}" already exists`)
}
await fs.ensureDir(outputDirectory) await fs.ensureDir(outputDirectory)
Logger.info(`Uploading ${files.length} files to`, outputDirectory) Logger.info(`Uploading ${files.length} files to`, outputDirectory)
for (let i = 0; i < files.length; i++) { for (const file of files) {
var file = files[i] const path = Path.join(outputDirectory, sanitizeFilename(file.name))
var path = Path.join(outputDirectory, file.name) await file.mv(path)
await file.mv(path).then(() => { .then(() => {
return true return true
}).catch((error) => { })
Logger.error('Failed to move file', path, error) .catch((error) => {
return false Logger.error('Failed to move file', path, error)
}) return false
})
} }
res.sendStatus(200) res.sendStatus(200)
@@ -645,6 +630,27 @@ class MiscController {
} else { } else {
Logger.warn(`[MiscController] Invalid value for authActiveAuthMethods`) Logger.warn(`[MiscController] Invalid value for authActiveAuthMethods`)
} }
} else if (key === 'authOpenIDMobileRedirectURIs') {
function isValidRedirectURI(uri) {
if (typeof uri !== 'string') return false
const pattern = new RegExp('^\\w+://[\\w.-]+$', 'i')
return pattern.test(uri)
}
const uris = settingsUpdate[key]
if (!Array.isArray(uris) ||
(uris.includes('*') && uris.length > 1) ||
uris.some(uri => uri !== '*' && !isValidRedirectURI(uri))) {
Logger.warn(`[MiscController] Invalid value for authOpenIDMobileRedirectURIs`)
continue
}
// Update the URIs
if (Database.serverSettings[key].some(uri => !uris.includes(uri)) || uris.some(uri => !Database.serverSettings[key].includes(uri))) {
Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${Database.serverSettings[key]}" to "${uris}"`)
Database.serverSettings[key] = uris
hasUpdates = true
}
} else { } else {
const updatedValueType = typeof settingsUpdate[key] const updatedValueType = typeof settingsUpdate[key]
if (['authOpenIDAutoLaunch', 'authOpenIDAutoRegister'].includes(key)) { if (['authOpenIDAutoLaunch', 'authOpenIDAutoRegister'].includes(key)) {
@@ -687,8 +693,29 @@ class MiscController {
} }
res.json({ res.json({
updated: hasUpdates,
serverSettings: Database.serverSettings.toJSONForBrowser() serverSettings: Database.serverSettings.toJSONForBrowser()
}) })
} }
/**
* GET: /api/me/stats/year/:year
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async getAdminStatsForYear(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to get admin stats for year`)
return res.sendStatus(403)
}
const year = Number(req.params.year)
if (isNaN(year) || year < 2000 || year > 9999) {
Logger.error(`[MiscController] Invalid year "${year}"`)
return res.status(400).send('Invalid year')
}
const stats = await adminStats.getStatsForYear(year)
res.json(stats)
}
} }
module.exports = new MiscController() module.exports = new MiscController()
+23 -3
View File
@@ -6,6 +6,7 @@ const fs = require('../libs/fsExtra')
const { getPodcastFeed, findMatchingEpisodes } = require('../utils/podcastUtils') const { getPodcastFeed, findMatchingEpisodes } = require('../utils/podcastUtils')
const { getFileTimestampsWithIno, filePathToPOSIX } = require('../utils/fileUtils') const { getFileTimestampsWithIno, filePathToPOSIX } = require('../utils/fileUtils')
const { validateUrl } = require('../utils/index')
const Scanner = require('../scanner/Scanner') const Scanner = require('../scanner/Scanner')
const CoverManager = require('../managers/CoverManager') const CoverManager = require('../managers/CoverManager')
@@ -16,7 +17,7 @@ class PodcastController {
async create(req, res) { async create(req, res) {
if (!req.user.isAdminOrUp) { if (!req.user.isAdminOrUp) {
Logger.error(`[PodcastController] Non-admin user attempted to create podcast`, req.user) Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to create podcast`)
return res.sendStatus(403) return res.sendStatus(403)
} }
const payload = req.body const payload = req.body
@@ -102,10 +103,24 @@ class PodcastController {
} }
} }
/**
* POST: /api/podcasts/feed
*
* @typedef getPodcastFeedReqBody
* @property {string} rssFeed
*
* @param {import('express').Request<{}, {}, getPodcastFeedReqBody, {}} req
* @param {import('express').Response} res
*/
async getPodcastFeed(req, res) { async getPodcastFeed(req, res) {
var url = req.body.rssFeed if (!req.user.isAdminOrUp) {
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to get podcast feed`)
return res.sendStatus(403)
}
const url = validateUrl(req.body.rssFeed)
if (!url) { if (!url) {
return res.status(400).send('Bad request') return res.status(400).send('Invalid request body. "rssFeed" must be a valid URL')
} }
const podcast = await getPodcastFeed(url) const podcast = await getPodcastFeed(url)
@@ -116,6 +131,11 @@ class PodcastController {
} }
async getFeedsFromOPMLText(req, res) { async getFeedsFromOPMLText(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to get feeds from opml`)
return res.sendStatus(403)
}
if (!req.body.opmlText) { if (!req.body.opmlText) {
return res.sendStatus(400) return res.sendStatus(400)
} }
+15 -1
View File
@@ -3,15 +3,18 @@ const BookFinder = require('../finders/BookFinder')
const PodcastFinder = require('../finders/PodcastFinder') const PodcastFinder = require('../finders/PodcastFinder')
const AuthorFinder = require('../finders/AuthorFinder') const AuthorFinder = require('../finders/AuthorFinder')
const MusicFinder = require('../finders/MusicFinder') const MusicFinder = require('../finders/MusicFinder')
const Database = require("../Database")
class SearchController { class SearchController {
constructor() { } constructor() { }
async findBooks(req, res) { async findBooks(req, res) {
const id = req.query.id
const libraryItem = await Database.libraryItemModel.getOldById(id)
const provider = req.query.provider || 'google' const provider = req.query.provider || 'google'
const title = req.query.title || '' const title = req.query.title || ''
const author = req.query.author || '' const author = req.query.author || ''
const results = await BookFinder.search(provider, title, author) const results = await BookFinder.search(libraryItem, provider, title, author)
res.json(results) res.json(results)
} }
@@ -32,8 +35,19 @@ class SearchController {
}) })
} }
/**
* Find podcast RSS feeds given a term
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async findPodcasts(req, res) { async findPodcasts(req, res) {
const term = req.query.term const term = req.query.term
if (!term) {
Logger.error('[SearchController] Invalid request query param "term" is required')
return res.status(400).send('Invalid request query param "term" is required')
}
const results = await PodcastFinder.search(term) const results = await PodcastFinder.search(term)
res.json(results) res.json(results)
} }
+121 -16
View File
@@ -1,6 +1,6 @@
const Logger = require('../Logger') const Logger = require('../Logger')
const Database = require('../Database') const Database = require('../Database')
const { toNumber } = require('../utils/index') const { toNumber, isUUID } = require('../utils/index')
class SessionController { class SessionController {
constructor() { } constructor() { }
@@ -9,35 +9,97 @@ class SessionController {
return res.json(req.playbackSession) return res.json(req.playbackSession)
} }
/**
* GET: /api/sessions
* @this import('../routers/ApiRouter')
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async getAllWithUserData(req, res) { async getAllWithUserData(req, res) {
if (!req.user.isAdminOrUp) { if (!req.user.isAdminOrUp) {
Logger.error(`[SessionController] getAllWithUserData: Non-admin user requested all session data ${req.user.id}/"${req.user.username}"`) Logger.error(`[SessionController] getAllWithUserData: Non-admin user requested all session data ${req.user.id}/"${req.user.username}"`)
return res.sendStatus(404) return res.sendStatus(404)
} }
// Validate "user" query
let listeningSessions = [] let userId = req.query.user
if (req.query.user) { if (userId && !isUUID(userId)) {
listeningSessions = await this.getUserListeningSessionsHelper(req.query.user) Logger.warn(`[SessionController] Invalid "user" query string "${userId}"`)
} else { userId = null
listeningSessions = await this.getAllSessionsWithUserData() }
// Validate "sort" query
const validSortOrders = ['displayTitle', 'duration', 'playMethod', 'startTime', 'currentTime', 'timeListening', 'updatedAt', 'createdAt']
let orderKey = req.query.sort || 'updatedAt'
if (!validSortOrders.includes(orderKey)) {
Logger.warn(`[SessionController] Invalid "sort" query string "${orderKey}" (Must be one of "${validSortOrders.join('|')}")`)
orderKey = 'updatedAt'
}
let orderDesc = req.query.desc === '1' ? 'DESC' : 'ASC'
// Validate "itemsPerPage" and "page" query
let itemsPerPage = toNumber(req.query.itemsPerPage, 10) || 10
if (itemsPerPage < 1) {
Logger.warn(`[SessionController] Invalid "itemsPerPage" query string "${itemsPerPage}"`)
itemsPerPage = 10
}
let page = toNumber(req.query.page, 0)
if (page < 0) {
Logger.warn(`[SessionController] Invalid "page" query string "${page}"`)
page = 0
} }
const itemsPerPage = toNumber(req.query.itemsPerPage, 10) || 10 let where = null
const page = toNumber(req.query.page, 0) const include = [
{
model: Database.models.device
}
]
const start = page * itemsPerPage if (userId) {
const sessions = listeningSessions.slice(start, start + itemsPerPage) where = {
userId
}
} else {
include.push({
model: Database.userModel,
attributes: ['id', 'username']
})
}
const { rows, count } = await Database.playbackSessionModel.findAndCountAll({
where,
include,
order: [
[orderKey, orderDesc]
],
limit: itemsPerPage,
offset: itemsPerPage * page
})
// Map playback sessions to old playback sessions
const sessions = rows.map(session => {
const oldPlaybackSession = Database.playbackSessionModel.getOldPlaybackSession(session)
if (session.user) {
return {
...oldPlaybackSession,
user: {
id: session.user.id,
username: session.user.username
}
}
} else {
return oldPlaybackSession.toJSON()
}
})
const payload = { const payload = {
total: listeningSessions.length, total: count,
numPages: Math.ceil(listeningSessions.length / itemsPerPage), numPages: Math.ceil(count / itemsPerPage),
page, page,
itemsPerPage, itemsPerPage,
sessions sessions
} }
if (userId) {
if (req.query.user) { payload.userId = userId
payload.userFilter = req.query.user
} }
res.json(payload) res.json(payload)
@@ -92,6 +154,49 @@ class SessionController {
res.sendStatus(200) res.sendStatus(200)
} }
/**
* POST: /api/sessions/batch/delete
* @this import('../routers/ApiRouter')
*
* @typedef batchDeleteReqBody
* @property {string[]} sessions
*
* @param {import('express').Request<{}, {}, batchDeleteReqBody, {}} req
* @param {import('express').Response} res
*/
async batchDelete(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[SessionController] Non-admin user attempted to batch delete sessions "${req.user.username}"`)
return res.sendStatus(403)
}
// Validate session ids
if (!req.body.sessions?.length || !Array.isArray(req.body.sessions) || req.body.sessions.some(s => !isUUID(s))) {
Logger.error(`[SessionController] Invalid request body. "sessions" array is required`, req.body)
return res.status(400).send('Invalid request body. "sessions" array of session id strings is required.')
}
// Check if any of these sessions are open and close it
for (const sessionId of req.body.sessions) {
const openSession = this.playbackSessionManager.getSession(sessionId)
if (openSession) {
await this.playbackSessionManager.removeSession(sessionId)
}
}
try {
const sessionsRemoved = await Database.playbackSessionModel.destroy({
where: {
id: req.body.sessions
}
})
Logger.info(`[SessionController] ${sessionsRemoved} playback sessions removed by "${req.user.username}"`)
res.sendStatus(200)
} catch (error) {
Logger.error(`[SessionController] Failed to remove playback sessions`, error)
res.status(500).send('Failed to remove sessions')
}
}
// POST: api/session/local // POST: api/session/local
syncLocal(req, res) { syncLocal(req, res) {
this.playbackSessionManager.syncLocalSessionRequest(req, res) this.playbackSessionManager.syncLocalSessionRequest(req, res)
+24 -5
View File
@@ -167,6 +167,7 @@ class BookFinder {
[/ (2nd|3rd|\d+th)\s+ed(\.|ition)?/g, ''], // Remove edition [/ (2nd|3rd|\d+th)\s+ed(\.|ition)?/g, ''], // Remove edition
[/(^| |\.)(m4b|m4a|mp3)( |$)/g, ''], // Remove file-type [/(^| |\.)(m4b|m4a|mp3)( |$)/g, ''], // Remove file-type
[/ a novel.*$/g, ''], // Remove "a novel" [/ a novel.*$/g, ''], // Remove "a novel"
[/(^| )(un)?abridged( |$)/g, ' '], // Remove "unabridged/abridged"
[/^\d+ | \d+$/g, ''], // Remove preceding/trailing numbers [/^\d+ | \d+$/g, ''], // Remove preceding/trailing numbers
] ]
@@ -298,6 +299,7 @@ class BookFinder {
/** /**
* Search for books including fuzzy searches * Search for books including fuzzy searches
* *
* @param {Object} libraryItem
* @param {string} provider * @param {string} provider
* @param {string} title * @param {string} title
* @param {string} author * @param {string} author
@@ -306,7 +308,7 @@ class BookFinder {
* @param {{titleDistance:number, authorDistance:number, maxFuzzySearches:number}} options * @param {{titleDistance:number, authorDistance:number, maxFuzzySearches:number}} options
* @returns {Promise<Object[]>} * @returns {Promise<Object[]>}
*/ */
async search(provider, title, author, isbn, asin, options = {}) { async search(libraryItem, provider, title, author, isbn, asin, options = {}) {
let books = [] let books = []
const maxTitleDistance = !isNaN(options.titleDistance) ? Number(options.titleDistance) : 4 const maxTitleDistance = !isNaN(options.titleDistance) ? Number(options.titleDistance) : 4
const maxAuthorDistance = !isNaN(options.authorDistance) ? Number(options.authorDistance) : 4 const maxAuthorDistance = !isNaN(options.authorDistance) ? Number(options.authorDistance) : 4
@@ -335,6 +337,7 @@ class BookFinder {
for (const titlePart of titleParts) for (const titlePart of titleParts)
authorCandidates.add(titlePart) authorCandidates.add(titlePart)
authorCandidates = await authorCandidates.getCandidates() authorCandidates = await authorCandidates.getCandidates()
loop_author:
for (const authorCandidate of authorCandidates) { for (const authorCandidate of authorCandidates) {
let titleCandidates = new BookFinder.TitleCandidates(authorCandidate) let titleCandidates = new BookFinder.TitleCandidates(authorCandidate)
for (const titlePart of titleParts) for (const titlePart of titleParts)
@@ -342,13 +345,27 @@ class BookFinder {
titleCandidates = titleCandidates.getCandidates() titleCandidates = titleCandidates.getCandidates()
for (const titleCandidate of titleCandidates) { for (const titleCandidate of titleCandidates) {
if (titleCandidate == title && authorCandidate == author) continue // We already tried this if (titleCandidate == title && authorCandidate == author) continue // We already tried this
if (++numFuzzySearches > maxFuzzySearches) return books if (++numFuzzySearches > maxFuzzySearches) break loop_author
books = await this.runSearch(titleCandidate, authorCandidate, provider, asin, maxTitleDistance, maxAuthorDistance) books = await this.runSearch(titleCandidate, authorCandidate, provider, asin, maxTitleDistance, maxAuthorDistance)
if (books.length) return books if (books.length) break loop_author
} }
} }
} }
if (books.length) {
const resultsHaveDuration = provider.startsWith('audible')
if (resultsHaveDuration && libraryItem?.media?.duration) {
const libraryItemDurationMinutes = libraryItem.media.duration / 60
// If provider results have duration, sort by ascendinge duration difference from libraryItem
books.sort((a, b) => {
const aDuration = a.duration || Number.POSITIVE_INFINITY
const bDuration = b.duration || Number.POSITIVE_INFINITY
const aDurationDiff = Math.abs(aDuration - libraryItemDurationMinutes)
const bDurationDiff = Math.abs(bDuration - libraryItemDurationMinutes)
return aDurationDiff - bDurationDiff
})
}
}
return books return books
} }
@@ -392,12 +409,12 @@ class BookFinder {
if (provider === 'all') { if (provider === 'all') {
for (const providerString of this.providers) { for (const providerString of this.providers) {
const providerResults = await this.search(providerString, title, author, options) const providerResults = await this.search(null, providerString, title, author, options)
Logger.debug(`[BookFinder] Found ${providerResults.length} covers from ${providerString}`) Logger.debug(`[BookFinder] Found ${providerResults.length} covers from ${providerString}`)
searchResults.push(...providerResults) searchResults.push(...providerResults)
} }
} else { } else {
searchResults = await this.search(provider, title, author, options) searchResults = await this.search(null, provider, title, author, options)
} }
Logger.debug(`[BookFinder] FindCovers search results: ${searchResults.length}`) Logger.debug(`[BookFinder] FindCovers search results: ${searchResults.length}`)
@@ -461,6 +478,8 @@ function cleanAuthorForCompares(author) {
cleanAuthor = cleanAuthor.replace(/([a-z])\.([a-z])/g, '$1. $2') cleanAuthor = cleanAuthor.replace(/([a-z])\.([a-z])/g, '$1. $2')
// remove middle initials // remove middle initials
cleanAuthor = cleanAuthor.replace(/(?<=\w\w)(\s+[a-z]\.?)+(?=\s+\w\w)/g, '') cleanAuthor = cleanAuthor.replace(/(?<=\w\w)(\s+[a-z]\.?)+(?=\s+\w\w)/g, '')
// remove et al.
cleanAuthor = cleanAuthor.replace(/ et al\.?(?= |$)/g, '')
return cleanAuthor return cleanAuthor
} }
+315
View File
@@ -0,0 +1,315 @@
const os = require('os')
const path = require('path')
const axios = require('axios')
const fse = require('../fsExtra')
const async = require('../async')
const StreamZip = require('../nodeStreamZip')
const { finished } = require('stream/promises')
var API_URL = 'https://ffbinaries.com/api/v1'
var RUNTIME_CACHE = {}
var errorMsgs = {
connectionIssues: 'Couldn\'t connect to ffbinaries.com API. Check your Internet connection.',
parsingVersionData: 'Couldn\'t parse retrieved version data.',
parsingVersionList: 'Couldn\'t parse the list of available versions.',
notFound: 'Requested data not found.',
incorrectVersionParam: '"version" parameter must be a string.'
}
function ensureDirSync(dir) {
try {
fse.accessSync(dir)
} catch (e) {
fse.mkdirSync(dir)
}
}
/**
* Resolves the platform key based on input string
*/
function resolvePlatform(input) {
var rtn = null
switch (input) {
case 'mac':
case 'osx':
case 'mac-64':
case 'osx-64':
rtn = 'osx-64'
break
case 'linux':
case 'linux-32':
rtn = 'linux-32'
break
case 'linux-64':
rtn = 'linux-64'
break
case 'linux-arm':
case 'linux-armel':
rtn = 'linux-armel'
break
case 'linux-armhf':
rtn = 'linux-armhf'
break
case 'win':
case 'win-32':
case 'windows':
case 'windows-32':
rtn = 'windows-32'
break
case 'win-64':
case 'windows-64':
rtn = 'windows-64'
break
default:
rtn = null
}
return rtn
}
/**
* Detects the platform of the machine the script is executed on.
* Object can be provided to detect platform from info derived elsewhere.
*
* @param {object} osinfo Contains "type" and "arch" properties
*/
function detectPlatform(osinfo) {
var inputIsValid = typeof osinfo === 'object' && typeof osinfo.type === 'string' && typeof osinfo.arch === 'string'
var type = (inputIsValid ? osinfo.type : os.type()).toLowerCase()
var arch = (inputIsValid ? osinfo.arch : os.arch()).toLowerCase()
if (type === 'darwin') {
return 'osx-64'
}
if (type === 'windows_nt') {
return arch === 'x64' ? 'windows-64' : 'windows-32'
}
if (type === 'linux') {
if (arch === 'arm' || arch === 'arm64') {
return 'linux-armel'
}
return arch === 'x64' ? 'linux-64' : 'linux-32'
}
return null
}
/**
* Gets the binary filename (appends exe in Windows)
*
* @param {string} component "ffmpeg", "ffplay", "ffprobe" or "ffserver"
* @param {platform} platform "ffmpeg", "ffplay", "ffprobe" or "ffserver"
*/
function getBinaryFilename(component, platform) {
var platformCode = resolvePlatform(platform)
if (platformCode === 'windows-32' || platformCode === 'windows-64') {
return component + '.exe'
}
return component
}
function listPlatforms() {
return ['osx-64', 'linux-32', 'linux-64', 'linux-armel', 'linux-armhf', 'windows-32', 'windows-64']
}
/**
*
* @returns {Promise<string[]>} array of version strings
*/
function listVersions() {
if (RUNTIME_CACHE.versionsAll) {
return RUNTIME_CACHE.versionsAll
}
return axios.get(API_URL).then((res) => {
if (!res.data?.versions || !Object.keys(res.data.versions)?.length) {
throw new Error(errorMsgs.parsingVersionList)
}
const versionKeys = Object.keys(res.data.versions)
RUNTIME_CACHE.versionsAll = versionKeys
return versionKeys
})
}
/**
* Gets full data set from ffbinaries.com
*/
function getVersionData(version) {
if (RUNTIME_CACHE[version]) {
return RUNTIME_CACHE[version]
}
if (version && typeof version !== 'string') {
throw new Error(errorMsgs.incorrectVersionParam)
}
var url = version ? '/version/' + version : '/latest'
return axios.get(`${API_URL}${url}`).then((res) => {
RUNTIME_CACHE[version] = res.data
return res.data
}).catch((error) => {
if (error.response?.status == 404) {
throw new Error(errorMsgs.notFound)
} else {
throw new Error(errorMsgs.connectionIssues)
}
})
}
/**
* Download file(s) and save them in the specified directory
*/
async function downloadUrls(components, urls, opts) {
const destinationDir = opts.destination
const results = []
const remappedUrls = []
if (components && !Array.isArray(components)) {
components = [components]
} else if (!components || !Array.isArray(components)) {
components = []
}
// returns an array of objects like this: {component: 'ffmpeg', url: 'https://...'}
if (typeof urls === 'object') {
for (const key in urls) {
if (components.includes(key) && urls[key]) {
remappedUrls.push({
component: key,
url: urls[key]
})
}
}
}
async function extractZipToDestination(zipFilename) {
const oldpath = path.join(destinationDir, zipFilename)
const zip = new StreamZip.async({ file: oldpath })
const count = await zip.extract(null, destinationDir)
await zip.close()
}
await async.each(remappedUrls, async function (urlObject) {
try {
const url = urlObject.url
const zipFilename = url.split('/').pop()
const binFilenameBase = urlObject.component
const binFilename = getBinaryFilename(binFilenameBase, opts.platform || detectPlatform())
let runningTotal = 0
let totalFilesize
let interval
if (typeof opts.tickerFn === 'function') {
opts.tickerInterval = parseInt(opts.tickerInterval, 10)
const tickerInterval = (!Number.isNaN(opts.tickerInterval)) ? opts.tickerInterval : 1000
const tickData = { filename: zipFilename, progress: 0 }
// Schedule next ticks
interval = setInterval(function () {
if (totalFilesize && runningTotal == totalFilesize) {
return clearInterval(interval)
}
tickData.progress = totalFilesize > -1 ? runningTotal / totalFilesize : 0
opts.tickerFn(tickData)
}, tickerInterval)
}
// Check if file already exists in target directory
const binPath = path.join(destinationDir, binFilename)
if (!opts.force && await fse.pathExists(binPath)) {
// if the accessSync method doesn't throw we know the binary already exists
results.push({
filename: binFilename,
path: destinationDir,
status: 'File exists',
code: 'FILE_EXISTS'
})
clearInterval(interval)
return
}
if (opts.quiet) clearInterval(interval)
const zipPath = path.join(destinationDir, zipFilename)
const zipFileTempName = zipPath + '.part'
const zipFileFinalName = zipPath
const response = await axios({
url,
method: 'GET',
responseType: 'stream'
})
totalFilesize = response.headers?.['content-length'] || []
const writer = fse.createWriteStream(zipFileTempName)
response.data.on('data', (chunk) => {
runningTotal += chunk.length
})
response.data.pipe(writer)
await finished(writer)
await fse.rename(zipFileTempName, zipFileFinalName)
await extractZipToDestination(zipFilename)
await fse.remove(zipFileFinalName)
results.push({
filename: binFilename,
path: destinationDir,
size: Math.floor(totalFilesize / 1024 / 1024 * 1000) / 1000 + 'MB',
status: 'File extracted to destination (downloaded from "' + url + '")',
code: 'DONE_CLEAN'
})
} catch (err) {
console.error(`Failed to download or extract file for component: ${urlObject.component}`, err)
}
})
return results
}
/**
* Gets binaries for the platform
* It will get the data from ffbinaries, pick the correct files
* and save it to the specified directory
*
* @param {Array} components
* @param {Object} [opts]
*/
async function downloadBinaries(components, opts = {}) {
var platform = resolvePlatform(opts.platform) || detectPlatform()
opts.destination = path.resolve(opts.destination || '.')
ensureDirSync(opts.destination)
const versionData = await getVersionData(opts.version)
const urls = versionData?.bin?.[platform]
if (!urls) {
throw new Error('No URLs!')
}
return await downloadUrls(components, urls, opts)
}
module.exports = {
downloadBinaries: downloadBinaries,
getVersionData: getVersionData,
listVersions: listVersions,
listPlatforms: listPlatforms,
detectPlatform: detectPlatform,
resolvePlatform: resolvePlatform,
getBinaryFilename: getBinaryFilename
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 ნიკა
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+262
View File
@@ -0,0 +1,262 @@
/**
* Modified from https://github.com/nika-begiashvili/libarchivejs
*/
const Path = require('path')
const { Worker } = require('worker_threads')
/**
* Represents compressed file before extraction
*/
class CompressedFile {
constructor(name, size, path, archiveRef) {
this._name = name
this._size = size
this._path = path
this._archiveRef = archiveRef
}
/**
* file name
*/
get name() {
return this._name
}
/**
* file size
*/
get size() {
return this._size
}
/**
* Extract file from archive
* @returns {Promise<File>} extracted file
*/
extract() {
return this._archiveRef.extractSingleFile(this._path)
}
}
class Archive {
/**
* Creates new archive instance from browser native File object
* @param {Buffer} fileBuffer
* @param {object} options
* @returns {Archive}
*/
static open(fileBuffer) {
const arch = new Archive(fileBuffer, { workerUrl: Path.join(__dirname, 'libarchiveWorker.js') })
return arch.open()
}
/**
* Create new archive
* @param {File} file
* @param {Object} options
*/
constructor(file, options) {
this._worker = new Worker(options.workerUrl)
this._worker.on('message', this._workerMsg.bind(this))
this._callbacks = []
this._content = {}
this._processed = 0
this._file = file
}
/**
* Prepares file for reading
* @returns {Promise<Archive>} archive instance
*/
async open() {
await this._postMessage({ type: 'HELLO' }, (resolve, reject, msg) => {
if (msg.type === 'READY') {
resolve()
}
})
return await this._postMessage({ type: 'OPEN', file: this._file }, (resolve, reject, msg) => {
if (msg.type === 'OPENED') {
resolve(this)
}
})
}
/**
* Terminate worker to free up memory
*/
close() {
this._worker.terminate()
this._worker = null
}
/**
* detect if archive has encrypted data
* @returns {boolean|null} null if could not be determined
*/
hasEncryptedData() {
return this._postMessage({ type: 'CHECK_ENCRYPTION' },
(resolve, reject, msg) => {
if (msg.type === 'ENCRYPTION_STATUS') {
resolve(msg.status)
}
}
)
}
/**
* set password to be used when reading archive
*/
usePassword(archivePassword) {
return this._postMessage({ type: 'SET_PASSPHRASE', passphrase: archivePassword },
(resolve, reject, msg) => {
if (msg.type === 'PASSPHRASE_STATUS') {
resolve(msg.status)
}
}
)
}
/**
* Returns object containing directory structure and file information
* @returns {Promise<object>}
*/
getFilesObject() {
if (this._processed > 0) {
return Promise.resolve().then(() => this._content)
}
return this._postMessage({ type: 'LIST_FILES' }, (resolve, reject, msg) => {
if (msg.type === 'ENTRY') {
const entry = msg.entry
const [target, prop] = this._getProp(this._content, entry.path)
if (entry.type === 'FILE') {
target[prop] = new CompressedFile(entry.fileName, entry.size, entry.path, this)
}
return true
} else if (msg.type === 'END') {
this._processed = 1
resolve(this._cloneContent(this._content))
}
})
}
getFilesArray() {
return this.getFilesObject().then((obj) => {
return this._objectToArray(obj)
})
}
extractSingleFile(target) {
// Prevent extraction if worker already terminated
if (this._worker === null) {
throw new Error("Archive already closed")
}
return this._postMessage({ type: 'EXTRACT_SINGLE_FILE', target: target },
(resolve, reject, msg) => {
if (msg.type === 'FILE') {
resolve(msg.entry)
}
}
)
}
/**
* Returns object containing directory structure and extracted File objects
* @param {Function} extractCallback
*
*/
extractFiles(extractCallback) {
if (this._processed > 1) {
return Promise.resolve().then(() => this._content)
}
return this._postMessage({ type: 'EXTRACT_FILES' }, (resolve, reject, msg) => {
if (msg.type === 'ENTRY') {
const [target, prop] = this._getProp(this._content, msg.entry.path)
if (msg.entry.type === 'FILE') {
target[prop] = msg.entry
if (extractCallback !== undefined) {
setTimeout(extractCallback.bind(null, {
file: target[prop],
path: msg.entry.path,
}))
}
}
return true
} else if (msg.type === 'END') {
this._processed = 2
this._worker.terminate()
resolve(this._cloneContent(this._content))
}
})
}
_cloneContent(obj) {
if (obj instanceof CompressedFile || obj === null) return obj
const o = {}
for (const prop of Object.keys(obj)) {
o[prop] = this._cloneContent(obj[prop])
}
return o
}
_objectToArray(obj, path = '') {
const files = []
for (const key of Object.keys(obj)) {
if (obj[key] instanceof CompressedFile || obj[key] === null) {
files.push({
file: obj[key] || key,
path: path
})
} else {
files.push(...this._objectToArray(obj[key], `${path}${key}/`))
}
}
return files
}
_getProp(obj, path) {
const parts = path.split('/')
if (parts[parts.length - 1] === '') parts.pop()
let cur = obj, prev = null
for (const part of parts) {
cur[part] = cur[part] || {}
prev = cur
cur = cur[part]
}
return [prev, parts[parts.length - 1]]
}
_postMessage(msg, callback) {
this._worker.postMessage(msg)
return new Promise((resolve, reject) => {
this._callbacks.push(this._msgHandler.bind(this, callback, resolve, reject))
})
}
_msgHandler(callback, resolve, reject, msg) {
if (!msg) {
reject('invalid msg')
return
}
if (msg.type === 'BUSY') {
reject('worker is busy')
} else if (msg.type === 'ERROR') {
reject(msg.error)
} else {
return callback(resolve, reject, msg)
}
}
_workerMsg(msg) {
const callback = this._callbacks[this._callbacks.length - 1]
const next = callback(msg)
if (!next) {
this._callbacks.pop()
}
}
}
module.exports = Archive
@@ -0,0 +1,72 @@
/**
* Modified from https://github.com/nika-begiashvili/libarchivejs
*/
const { parentPort } = require('worker_threads')
const { getArchiveReader } = require('./wasm-module')
let reader = null
let busy = false
getArchiveReader((_reader) => {
reader = _reader
busy = false
parentPort.postMessage({ type: 'READY' })
})
parentPort.on('message', async msg => {
if (busy) {
parentPort.postMessage({ type: 'BUSY' })
return
}
let skipExtraction = false
busy = true
try {
switch (msg.type) {
case 'HELLO': // module will respond READY when it's ready
break
case 'OPEN':
await reader.open(msg.file)
parentPort.postMessage({ type: 'OPENED' })
break
case 'LIST_FILES':
skipExtraction = true
// eslint-disable-next-line no-fallthrough
case 'EXTRACT_FILES':
for (const entry of reader.entries(skipExtraction)) {
parentPort.postMessage({ type: 'ENTRY', entry })
}
parentPort.postMessage({ type: 'END' })
break
case 'EXTRACT_SINGLE_FILE':
for (const entry of reader.entries(true, msg.target)) {
if (entry.fileData) {
parentPort.postMessage({ type: 'FILE', entry })
}
}
break
case 'CHECK_ENCRYPTION':
parentPort.postMessage({ type: 'ENCRYPTION_STATUS', status: reader.hasEncryptedData() })
break
case 'SET_PASSPHRASE':
reader.setPassphrase(msg.passphrase)
parentPort.postMessage({ type: 'PASSPHRASE_STATUS', status: true })
break
default:
throw new Error('Invalid Command')
}
} catch (err) {
parentPort.postMessage({
type: 'ERROR',
error: {
message: err.message,
name: err.name,
stack: err.stack
}
})
} finally {
// eslint-disable-next-line require-atomic-updates
busy = false
}
})
File diff suppressed because one or more lines are too long
+235
View File
@@ -0,0 +1,235 @@
/**
* Modified from https://github.com/nika-begiashvili/libarchivejs
*/
const Path = require('path')
const libarchive = require('./wasm-libarchive')
const TYPE_MAP = {
32768: 'FILE',
16384: 'DIR',
40960: 'SYMBOLIC_LINK',
49152: 'SOCKET',
8192: 'CHARACTER_DEVICE',
24576: 'BLOCK_DEVICE',
4096: 'NAMED_PIPE',
}
class ArchiveReader {
/**
* archive reader
* @param {WasmModule} wasmModule emscripten module
*/
constructor(wasmModule) {
this._wasmModule = wasmModule
this._runCode = wasmModule.runCode
this._file = null
this._passphrase = null
}
/**
* open archive, needs to closed manually
* @param {File} file
*/
open(file) {
if (this._file !== null) {
console.warn('Closing previous file')
this.close()
}
const { promise, resolve, reject } = this._promiseHandles()
this._file = file
this._loadFile(file, resolve, reject)
return promise
}
/**
* close archive
*/
close() {
this._runCode.closeArchive(this._archive)
this._wasmModule._free(this._filePtr)
this._file = null
this._filePtr = null
this._archive = null
}
/**
* detect if archive has encrypted data
* @returns {boolean|null} null if could not be determined
*/
hasEncryptedData() {
this._archive = this._runCode.openArchive(this._filePtr, this._fileLength, this._passphrase)
this._runCode.getNextEntry(this._archive)
const status = this._runCode.hasEncryptedEntries(this._archive)
if (status === 0) {
return false
} else if (status > 0) {
return true
} else {
return null
}
}
/**
* set passphrase to be used with archive
* @param {*} passphrase
*/
setPassphrase(passphrase) {
this._passphrase = passphrase
}
/**
* get archive entries
* @param {boolean} skipExtraction
* @param {string} except don't skip this entry
*/
*entries(skipExtraction = false, except = null) {
this._archive = this._runCode.openArchive(this._filePtr, this._fileLength, this._passphrase)
let entry
while (true) {
entry = this._runCode.getNextEntry(this._archive)
if (entry === 0) break
const entryData = {
size: this._runCode.getEntrySize(entry),
path: this._runCode.getEntryName(entry),
type: TYPE_MAP[this._runCode.getEntryType(entry)],
ref: entry,
}
if (entryData.type === 'FILE') {
let fileName = entryData.path.split('/')
entryData.fileName = fileName[fileName.length - 1]
}
if (skipExtraction && except !== entryData.path) {
this._runCode.skipEntry(this._archive)
} else {
const ptr = this._runCode.getFileData(this._archive, entryData.size)
if (ptr < 0) {
throw new Error(this._runCode.getError(this._archive))
}
entryData.fileData = this._wasmModule.HEAP8.slice(ptr, ptr + entryData.size)
this._wasmModule._free(ptr)
}
yield entryData
}
}
_loadFile(fileBuffer, resolve, reject) {
try {
const array = new Uint8Array(fileBuffer)
this._fileLength = array.length
this._filePtr = this._runCode.malloc(this._fileLength)
this._wasmModule.HEAP8.set(array, this._filePtr)
resolve()
} catch (error) {
reject(error)
}
}
_promiseHandles() {
let resolve = null, reject = null
const promise = new Promise((_resolve, _reject) => {
resolve = _resolve
reject = _reject
})
return { promise, resolve, reject }
}
}
class WasmModule {
constructor() {
this.preRun = []
this.postRun = []
this.totalDependencies = 0
}
print(...text) {
console.log(text)
}
printErr(...text) {
console.error(text)
}
initFunctions() {
this.runCode = {
// const char * get_version()
getVersion: this.cwrap('get_version', 'string', []),
// void * archive_open( const void * buffer, size_t buffer_size)
// retuns archive pointer
openArchive: this.cwrap('archive_open', 'number', ['number', 'number', 'string']),
// void * get_entry(void * archive)
// return archive entry pointer
getNextEntry: this.cwrap('get_next_entry', 'number', ['number']),
// void * get_filedata( void * archive, size_t bufferSize )
getFileData: this.cwrap('get_filedata', 'number', ['number', 'number']),
// int archive_read_data_skip(struct archive *_a)
skipEntry: this.cwrap('archive_read_data_skip', 'number', ['number']),
// void archive_close( void * archive )
closeArchive: this.cwrap('archive_close', null, ['number']),
// la_int64_t archive_entry_size( struct archive_entry * )
getEntrySize: this.cwrap('archive_entry_size', 'number', ['number']),
// const char * archive_entry_pathname( struct archive_entry * )
getEntryName: this.cwrap('archive_entry_pathname', 'string', ['number']),
// __LA_MODE_T archive_entry_filetype( struct archive_entry * )
/*
#define AE_IFMT ((__LA_MODE_T)0170000)
#define AE_IFREG ((__LA_MODE_T)0100000) // Regular file
#define AE_IFLNK ((__LA_MODE_T)0120000) // Sybolic link
#define AE_IFSOCK ((__LA_MODE_T)0140000) // Socket
#define AE_IFCHR ((__LA_MODE_T)0020000) // Character device
#define AE_IFBLK ((__LA_MODE_T)0060000) // Block device
#define AE_IFDIR ((__LA_MODE_T)0040000) // Directory
#define AE_IFIFO ((__LA_MODE_T)0010000) // Named pipe
*/
getEntryType: this.cwrap('archive_entry_filetype', 'number', ['number']),
// const char * archive_error_string(struct archive *);
getError: this.cwrap('archive_error_string', 'string', ['number']),
/*
* Returns 1 if the archive contains at least one encrypted entry.
* If the archive format not support encryption at all
* ARCHIVE_READ_FORMAT_ENCRYPTION_UNSUPPORTED is returned.
* If for any other reason (e.g. not enough data read so far)
* we cannot say whether there are encrypted entries, then
* ARCHIVE_READ_FORMAT_ENCRYPTION_DONT_KNOW is returned.
* In general, this function will return values below zero when the
* reader is uncertain or totally incapable of encryption support.
* When this function returns 0 you can be sure that the reader
* supports encryption detection but no encrypted entries have
* been found yet.
*
* NOTE: If the metadata/header of an archive is also encrypted, you
* cannot rely on the number of encrypted entries. That is why this
* function does not return the number of encrypted entries but#
* just shows that there are some.
*/
// __LA_DECL int archive_read_has_encrypted_entries(struct archive *);
entryIsEncrypted: this.cwrap('archive_entry_is_encrypted', 'number', ['number']),
hasEncryptedEntries: this.cwrap('archive_read_has_encrypted_entries', 'number', ['number']),
// __LA_DECL int archive_read_add_passphrase(struct archive *, const char *);
addPassphrase: this.cwrap('archive_read_add_passphrase', 'number', ['number', 'string']),
//this.stringToUTF(str), //
string: (str) => this.allocate(this.intArrayFromString(str), 'i8', 0),
malloc: this.cwrap('malloc', 'number', ['number']),
free: this.cwrap('free', null, ['number']),
}
}
monitorRunDependencies() { }
locateFile(path /* ,prefix */) {
const wasmFilepath = Path.join(__dirname, `../../../client/dist/libarchive/wasm-gen/${path}`)
return wasmFilepath
}
}
module.exports.getArchiveReader = (cb) => {
libarchive(new WasmModule()).then((module) => {
module.initFunctions()
cb(new ArchiveReader(module))
})
}
+1 -1
View File
@@ -13,7 +13,7 @@ class ApiCacheManager {
} }
init(database = Database) { init(database = Database) {
let hooks = ['afterCreate', 'afterUpdate', 'afterDestroy', 'afterBulkCreate', 'afterBulkUpdate', 'afterBulkDestroy'] let hooks = ['afterCreate', 'afterUpdate', 'afterDestroy', 'afterBulkCreate', 'afterBulkUpdate', 'afterBulkDestroy', 'afterUpsert']
hooks.forEach(hook => database.sequelize.addHook(hook, (model) => this.clear(model, hook))) hooks.forEach(hook => database.sequelize.addHook(hook, (model) => this.clear(model, hook)))
} }
+74
View File
@@ -0,0 +1,74 @@
const path = require('path')
const which = require('../libs/which')
const fs = require('../libs/fsExtra')
const ffbinaries = require('../libs/ffbinaries')
const Logger = require('../Logger')
const fileUtils = require('../utils/fileUtils')
class BinaryManager {
defaultRequiredBinaries = [
{ name: 'ffmpeg', envVariable: 'FFMPEG_PATH' },
{ name: 'ffprobe', envVariable: 'FFPROBE_PATH' }
]
constructor(requiredBinaries = this.defaultRequiredBinaries) {
this.requiredBinaries = requiredBinaries
this.mainInstallPath = process.pkg ? path.dirname(process.execPath) : global.appRoot
this.altInstallPath = global.ConfigPath
}
async init() {
if (this.initialized) return
const missingBinaries = await this.findRequiredBinaries()
if (missingBinaries.length == 0) return
await this.install(missingBinaries)
const missingBinariesAfterInstall = await this.findRequiredBinaries()
if (missingBinariesAfterInstall.length != 0) {
Logger.error(`[BinaryManager] Failed to find or install required binaries: ${missingBinariesAfterInstall.join(', ')}`)
process.exit(1)
}
this.initialized = true
}
async findRequiredBinaries() {
const missingBinaries = []
for (const binary of this.requiredBinaries) {
const binaryPath = await this.findBinary(binary.name, binary.envVariable)
if (binaryPath) {
Logger.info(`[BinaryManager] Found ${binary.name} at ${binaryPath}`)
if (process.env[binary.envVariable] !== binaryPath) {
Logger.info(`[BinaryManager] Updating process.env.${binary.envVariable}`)
process.env[binary.envVariable] = binaryPath
}
} else {
Logger.info(`[BinaryManager] ${binary.name} not found`)
missingBinaries.push(binary.name)
}
}
return missingBinaries
}
async findBinary(name, envVariable) {
const executable = name + (process.platform == 'win32' ? '.exe' : '')
const defaultPath = process.env[envVariable]
if (defaultPath && await fs.pathExists(defaultPath)) return defaultPath
const whichPath = which.sync(executable, { nothrow: true })
if (whichPath) return whichPath
const mainInstallPath = path.join(this.mainInstallPath, executable)
if (await fs.pathExists(mainInstallPath)) return mainInstallPath
const altInstallPath = path.join(this.altInstallPath, executable)
if (await fs.pathExists(altInstallPath)) return altInstallPath
return null
}
async install(binaries) {
if (binaries.length == 0) return
Logger.info(`[BinaryManager] Installing binaries: ${binaries.join(', ')}`)
let destination = await fileUtils.isWritable(this.mainInstallPath) ? this.mainInstallPath : this.altInstallPath
await ffbinaries.downloadBinaries(binaries, { destination })
Logger.info(`[BinaryManager] Binaries installed to ${destination}`)
}
}
module.exports = BinaryManager
+41
View File
@@ -7,6 +7,8 @@ const imageType = require('../libs/imageType')
const globals = require('../utils/globals') const globals = require('../utils/globals')
const { downloadImageFile, filePathToPOSIX, checkPathIsFile } = require('../utils/fileUtils') const { downloadImageFile, filePathToPOSIX, checkPathIsFile } = require('../utils/fileUtils')
const { extractCoverArt } = require('../utils/ffmpegHelpers') const { extractCoverArt } = require('../utils/ffmpegHelpers')
const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata')
const CacheManager = require('../managers/CacheManager') const CacheManager = require('../managers/CacheManager')
class CoverManager { class CoverManager {
@@ -234,6 +236,7 @@ class CoverManager {
/** /**
* Extract cover art from audio file and save for library item * Extract cover art from audio file and save for library item
*
* @param {import('../models/Book').AudioFileObject[]} audioFiles * @param {import('../models/Book').AudioFileObject[]} audioFiles
* @param {string} libraryItemId * @param {string} libraryItemId
* @param {string} [libraryItemPath] null for isFile library items * @param {string} [libraryItemPath] null for isFile library items
@@ -268,6 +271,44 @@ class CoverManager {
return null return null
} }
/**
* 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
* @returns {Promise<string>} returns cover path
*/
async saveEbookCoverArt(ebookFileScanData, libraryItemId, libraryItemPath) {
if (!ebookFileScanData?.ebookCoverPath) return null
let coverDirPath = null
if (global.ServerSettings.storeCoverWithItem && libraryItemPath) {
coverDirPath = libraryItemPath
} else {
coverDirPath = Path.posix.join(global.MetadataPath, 'items', libraryItemId)
}
await fs.ensureDir(coverDirPath)
let extname = Path.extname(ebookFileScanData.ebookCoverPath) || '.jpg'
if (extname === '.jpeg') extname = '.jpg'
const coverFilename = `cover${extname}`
const coverFilePath = Path.join(coverDirPath, coverFilename)
// TODO: Overwrite if exists?
const coverAlreadyExists = await fs.pathExists(coverFilePath)
if (coverAlreadyExists) {
Logger.warn(`[CoverManager] Extract embedded cover art but cover already exists for "${coverFilePath}" - overwriting`)
}
const success = await parseEbookMetadata.extractCoverImage(ebookFileScanData, coverFilePath)
if (success) {
await CacheManager.purgeCoverCache(libraryItemId)
return coverFilePath
}
return null
}
/** /**
* *
* @param {string} url * @param {string} url
+12 -2
View File
@@ -103,19 +103,29 @@ class RssFeedManager {
await Database.updateFeed(feed) await Database.updateFeed(feed)
} }
} else if (feed.entityType === 'collection') { } else if (feed.entityType === 'collection') {
const collection = await Database.collectionModel.findByPk(feed.entityId) const collection = await Database.collectionModel.findByPk(feed.entityId, {
include: Database.collectionBookModel
})
if (collection) { if (collection) {
const collectionExpanded = await collection.getOldJsonExpanded() const collectionExpanded = await collection.getOldJsonExpanded()
// Find most recently updated item in collection // Find most recently updated item in collection
let mostRecentlyUpdatedAt = collectionExpanded.lastUpdate let mostRecentlyUpdatedAt = collectionExpanded.lastUpdate
// Check for most recently updated book
collectionExpanded.books.forEach((libraryItem) => { collectionExpanded.books.forEach((libraryItem) => {
if (libraryItem.media.tracks.length && libraryItem.updatedAt > mostRecentlyUpdatedAt) { if (libraryItem.media.tracks.length && libraryItem.updatedAt > mostRecentlyUpdatedAt) {
mostRecentlyUpdatedAt = libraryItem.updatedAt 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 || mostRecentlyUpdatedAt > feed.entityUpdatedAt) { if (!feed.entityUpdatedAt || hasBooksRemoved || mostRecentlyUpdatedAt > feed.entityUpdatedAt) {
Logger.debug(`[RssFeedManager] Updating RSS feed for collection "${collection.name}"`) Logger.debug(`[RssFeedManager] Updating RSS feed for collection "${collection.name}"`)
feed.updateFromCollection(collectionExpanded) feed.updateFromCollection(collectionExpanded)
+15
View File
@@ -18,6 +18,19 @@ const Logger = require('../Logger')
* @property {string} title * @property {string} title
*/ */
/**
* @typedef SeriesExpandedProperties
* @property {{sequence:string}} bookSeries
*
* @typedef {import('./Series') & SeriesExpandedProperties} SeriesExpanded
*
* @typedef BookExpandedProperties
* @property {import('./Author')[]} authors
* @property {SeriesExpanded[]} series
*
* @typedef {Book & BookExpandedProperties} BookExpanded
*/
/** /**
* @typedef AudioFileObject * @typedef AudioFileObject
* @property {number} index * @property {number} index
@@ -54,6 +67,8 @@ class Book extends Model {
/** @type {string} */ /** @type {string} */
this.titleIgnorePrefix this.titleIgnorePrefix
/** @type {string} */ /** @type {string} */
this.subtitle
/** @type {string} */
this.publishedYear this.publishedYear
/** @type {string} */ /** @type {string} */
this.publishedDate this.publishedDate
+4 -4
View File
@@ -108,7 +108,7 @@ class Feed extends Model {
/** /**
* Find all library item ids that have an open feed (used in library filter) * Find all library item ids that have an open feed (used in library filter)
* @returns {Promise<Array<String>>} array of library item ids * @returns {Promise<string[]>} array of library item ids
*/ */
static async findAllLibraryItemIds() { static async findAllLibraryItemIds() {
const feeds = await this.findAll({ const feeds = await this.findAll({
@@ -122,8 +122,8 @@ class Feed extends Model {
/** /**
* Find feed where and return oldFeed * Find feed where and return oldFeed
* @param {object} where sequelize where object * @param {Object} where sequelize where object
* @returns {Promise<objects.Feed>} oldFeed * @returns {Promise<oldFeed>} oldFeed
*/ */
static async findOneOld(where) { static async findOneOld(where) {
if (!where) return null if (!where) return null
@@ -140,7 +140,7 @@ class Feed extends Model {
/** /**
* Find feed and return oldFeed * Find feed and return oldFeed
* @param {string} id * @param {string} id
* @returns {Promise<objects.Feed>} oldFeed * @returns {Promise<oldFeed>} oldFeed
*/ */
static async findByPkOld(id) { static async findByPkOld(id) {
if (!id) return null if (!id) return null
+1 -1
View File
@@ -233,7 +233,7 @@ class Library extends Model {
for (let i = 0; i < libraries.length; i++) { for (let i = 0; i < libraries.length; i++) {
const library = libraries[i] const library = libraries[i]
if (library.displayOrder !== i + 1) { if (library.displayOrder !== i + 1) {
Logger.dev(`[Library] Updating display order of library from ${library.displayOrder} to ${i + 1}`) Logger.debug(`[Library] Updating display order of library from ${library.displayOrder} to ${i + 1}`)
await library.update({ displayOrder: i + 1 }).catch((error) => { await library.update({ displayOrder: i + 1 }).catch((error) => {
Logger.error(`[Library] Failed to update library display order to ${i + 1}`, error) Logger.error(`[Library] Failed to update library display order to ${i + 1}`, error)
}) })
+111 -50
View File
@@ -15,6 +15,13 @@ const Podcast = require('./Podcast')
* @property {{filename:string, ext:string, path:string, relPath:string, size:number, mtimeMs:number, ctimeMs:number, birthtimeMs:number}} metadata * @property {{filename:string, ext:string, path:string, relPath:string, size:number, mtimeMs:number, ctimeMs:number, birthtimeMs:number}} metadata
*/ */
/**
* @typedef LibraryItemExpandedProperties
* @property {Book.BookExpanded|Podcast.PodcastExpanded} media
*
* @typedef {LibraryItem & LibraryItemExpandedProperties} LibraryItemExpanded
*/
class LibraryItem extends Model { class LibraryItem extends Model {
constructor(values, options) { constructor(values, options) {
super(values, options) super(values, options)
@@ -264,7 +271,7 @@ class LibraryItem extends Model {
for (const existingPodcastEpisode of existingPodcastEpisodes) { for (const existingPodcastEpisode of existingPodcastEpisodes) {
// Episode was removed // Episode was removed
if (!updatedPodcastEpisodes.some(ep => ep.id === existingPodcastEpisode.id)) { if (!updatedPodcastEpisodes.some(ep => ep.id === existingPodcastEpisode.id)) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingPodcastEpisode.title}" was removed`) Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingPodcastEpisode.title}" was removed`)
await existingPodcastEpisode.destroy() await existingPodcastEpisode.destroy()
hasUpdates = true hasUpdates = true
} }
@@ -272,7 +279,7 @@ class LibraryItem extends Model {
for (const updatedPodcastEpisode of updatedPodcastEpisodes) { for (const updatedPodcastEpisode of updatedPodcastEpisodes) {
const existingEpisodeMatch = existingPodcastEpisodes.find(ep => ep.id === updatedPodcastEpisode.id) const existingEpisodeMatch = existingPodcastEpisodes.find(ep => ep.id === updatedPodcastEpisode.id)
if (!existingEpisodeMatch) { if (!existingEpisodeMatch) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${updatedPodcastEpisode.title}" was added`) Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${updatedPodcastEpisode.title}" was added`)
await this.sequelize.models.podcastEpisode.createFromOld(updatedPodcastEpisode) await this.sequelize.models.podcastEpisode.createFromOld(updatedPodcastEpisode)
hasUpdates = true hasUpdates = true
} else { } else {
@@ -283,7 +290,7 @@ class LibraryItem extends Model {
if (existingValue instanceof Date) existingValue = existingValue.valueOf() if (existingValue instanceof Date) existingValue = existingValue.valueOf()
if (!areEquivalent(updatedEpisodeCleaned[key], existingValue, true)) { if (!areEquivalent(updatedEpisodeCleaned[key], existingValue, true)) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingEpisodeMatch.title}" ${key} was updated from "${existingValue}" to "${updatedEpisodeCleaned[key]}"`) Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingEpisodeMatch.title}" ${key} was updated from "${existingValue}" to "${updatedEpisodeCleaned[key]}"`)
episodeHasUpdates = true episodeHasUpdates = true
} }
} }
@@ -304,7 +311,7 @@ class LibraryItem extends Model {
for (const existingAuthor of existingAuthors) { for (const existingAuthor of existingAuthors) {
// Author was removed from Book // Author was removed from Book
if (!updatedAuthors.some(au => au.id === existingAuthor.id)) { if (!updatedAuthors.some(au => au.id === existingAuthor.id)) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${existingAuthor.name}" was removed`) Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${existingAuthor.name}" was removed`)
await this.sequelize.models.bookAuthor.removeByIds(existingAuthor.id, libraryItemExpanded.media.id) await this.sequelize.models.bookAuthor.removeByIds(existingAuthor.id, libraryItemExpanded.media.id)
hasUpdates = true hasUpdates = true
} }
@@ -312,7 +319,7 @@ class LibraryItem extends Model {
for (const updatedAuthor of updatedAuthors) { for (const updatedAuthor of updatedAuthors) {
// Author was added // Author was added
if (!existingAuthors.some(au => au.id === updatedAuthor.id)) { if (!existingAuthors.some(au => au.id === updatedAuthor.id)) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${updatedAuthor.name}" was added`) Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${updatedAuthor.name}" was added`)
await this.sequelize.models.bookAuthor.create({ authorId: updatedAuthor.id, bookId: libraryItemExpanded.media.id }) await this.sequelize.models.bookAuthor.create({ authorId: updatedAuthor.id, bookId: libraryItemExpanded.media.id })
hasUpdates = true hasUpdates = true
} }
@@ -320,7 +327,7 @@ class LibraryItem extends Model {
for (const existingSeries of existingSeriesAll) { for (const existingSeries of existingSeriesAll) {
// Series was removed // Series was removed
if (!updatedSeriesAll.some(se => se.id === existingSeries.id)) { if (!updatedSeriesAll.some(se => se.id === existingSeries.id)) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${existingSeries.name}" was removed`) Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${existingSeries.name}" was removed`)
await this.sequelize.models.bookSeries.removeByIds(existingSeries.id, libraryItemExpanded.media.id) await this.sequelize.models.bookSeries.removeByIds(existingSeries.id, libraryItemExpanded.media.id)
hasUpdates = true hasUpdates = true
} }
@@ -329,11 +336,11 @@ class LibraryItem extends Model {
// Series was added/updated // Series was added/updated
const existingSeriesMatch = existingSeriesAll.find(se => se.id === updatedSeries.id) const existingSeriesMatch = existingSeriesAll.find(se => se.id === updatedSeries.id)
if (!existingSeriesMatch) { if (!existingSeriesMatch) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" was added`) Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" was added`)
await this.sequelize.models.bookSeries.create({ seriesId: updatedSeries.id, bookId: libraryItemExpanded.media.id, sequence: updatedSeries.sequence }) await this.sequelize.models.bookSeries.create({ seriesId: updatedSeries.id, bookId: libraryItemExpanded.media.id, sequence: updatedSeries.sequence })
hasUpdates = true hasUpdates = true
} else if (existingSeriesMatch.bookSeries.sequence !== updatedSeries.sequence) { } else if (existingSeriesMatch.bookSeries.sequence !== updatedSeries.sequence) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" sequence was updated from "${existingSeriesMatch.bookSeries.sequence}" to "${updatedSeries.sequence}"`) Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" sequence was updated from "${existingSeriesMatch.bookSeries.sequence}" to "${updatedSeries.sequence}"`)
await existingSeriesMatch.bookSeries.update({ id: updatedSeries.id, sequence: updatedSeries.sequence }) await existingSeriesMatch.bookSeries.update({ id: updatedSeries.id, sequence: updatedSeries.sequence })
hasUpdates = true hasUpdates = true
} }
@@ -346,7 +353,7 @@ class LibraryItem extends Model {
if (existingValue instanceof Date) existingValue = existingValue.valueOf() if (existingValue instanceof Date) existingValue = existingValue.valueOf()
if (!areEquivalent(updatedMedia[key], existingValue, true)) { if (!areEquivalent(updatedMedia[key], existingValue, true)) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" ${libraryItemExpanded.mediaType}.${key} updated from ${existingValue} to ${updatedMedia[key]}`) Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" ${libraryItemExpanded.mediaType}.${key} updated from ${existingValue} to ${updatedMedia[key]}`)
hasMediaUpdates = true hasMediaUpdates = true
} }
} }
@@ -363,7 +370,7 @@ class LibraryItem extends Model {
if (existingValue instanceof Date) existingValue = existingValue.valueOf() if (existingValue instanceof Date) existingValue = existingValue.valueOf()
if (!areEquivalent(updatedLibraryItem[key], existingValue, true)) { if (!areEquivalent(updatedLibraryItem[key], existingValue, true)) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" ${key} updated from ${existingValue} to ${updatedLibraryItem[key]}`) Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" ${key} updated from ${existingValue} to ${updatedLibraryItem[key]}`)
hasLibraryItemUpdates = true hasLibraryItemUpdates = true
} }
} }
@@ -412,6 +419,55 @@ class LibraryItem extends Model {
}) })
} }
/**
*
* @param {string} libraryItemId
* @returns {Promise<LibraryItemExpanded>}
*/
static async getExpandedById(libraryItemId) {
if (!libraryItemId) return null
const libraryItem = await this.findByPk(libraryItemId)
if (!libraryItem) {
Logger.error(`[LibraryItem] Library item not found with id "${libraryItemId}"`)
return null
}
if (libraryItem.mediaType === 'podcast') {
libraryItem.media = await libraryItem.getMedia({
include: [
{
model: this.sequelize.models.podcastEpisode
}
]
})
} else {
libraryItem.media = await libraryItem.getMedia({
include: [
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
}
],
order: [
[this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'],
[this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC']
]
})
}
if (!libraryItem.media) return null
return libraryItem
}
/** /**
* Get old library item by id * Get old library item by id
* @param {string} libraryItemId * @param {string} libraryItemId
@@ -419,40 +475,45 @@ class LibraryItem extends Model {
*/ */
static async getOldById(libraryItemId) { static async getOldById(libraryItemId) {
if (!libraryItemId) return null if (!libraryItemId) return null
const libraryItem = await this.findByPk(libraryItemId, {
include: [ const libraryItem = await this.findByPk(libraryItemId)
{ if (!libraryItem) {
model: this.sequelize.models.book, Logger.error(`[LibraryItem] Library item not found with id "${libraryItemId}"`)
include: [ return null
{ }
model: this.sequelize.models.author,
through: { if (libraryItem.mediaType === 'podcast') {
attributes: [] libraryItem.media = await libraryItem.getMedia({
} include: [
}, {
{ model: this.sequelize.models.podcastEpisode
model: this.sequelize.models.series, }
through: { ]
attributes: ['sequence'] })
} } else {
libraryItem.media = await libraryItem.getMedia({
include: [
{
model: this.sequelize.models.author,
through: {
attributes: []
} }
] },
}, {
{ model: this.sequelize.models.series,
model: this.sequelize.models.podcast, through: {
include: [ attributes: ['sequence']
{
model: this.sequelize.models.podcastEpisode
} }
] }
} ],
], order: [
order: [ [this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'],
[this.sequelize.models.book, this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'], [this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC']
[this.sequelize.models.book, this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC'] ]
] })
}) }
if (!libraryItem) return null
if (!libraryItem.media) return null
return this.getOldLibraryItem(libraryItem) return this.getOldLibraryItem(libraryItem)
} }
@@ -536,7 +597,7 @@ class LibraryItem extends Model {
}) })
} }
} }
Logger.dev(`Loaded ${itemsInProgressPayload.items.length} of ${itemsInProgressPayload.count} items for "Continue Listening/Reading" in ${((Date.now() - fullStart) / 1000).toFixed(2)}s`) Logger.debug(`Loaded ${itemsInProgressPayload.items.length} of ${itemsInProgressPayload.count} items for "Continue Listening/Reading" in ${((Date.now() - fullStart) / 1000).toFixed(2)}s`)
let start = Date.now() let start = Date.now()
if (library.isBook) { if (library.isBook) {
@@ -553,7 +614,7 @@ class LibraryItem extends Model {
total: continueSeriesPayload.count total: continueSeriesPayload.count
}) })
} }
Logger.dev(`Loaded ${continueSeriesPayload.libraryItems.length} of ${continueSeriesPayload.count} items for "Continue Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`) Logger.debug(`Loaded ${continueSeriesPayload.libraryItems.length} of ${continueSeriesPayload.count} items for "Continue Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
} else if (library.isPodcast) { } else if (library.isPodcast) {
// "Newest Episodes" shelf // "Newest Episodes" shelf
const newestEpisodesPayload = await libraryFilters.getNewestPodcastEpisodes(library, user, limit) const newestEpisodesPayload = await libraryFilters.getNewestPodcastEpisodes(library, user, limit)
@@ -567,7 +628,7 @@ class LibraryItem extends Model {
total: newestEpisodesPayload.count total: newestEpisodesPayload.count
}) })
} }
Logger.dev(`Loaded ${newestEpisodesPayload.libraryItems.length} of ${newestEpisodesPayload.count} episodes for "Newest Episodes" in ${((Date.now() - start) / 1000).toFixed(2)}s`) Logger.debug(`Loaded ${newestEpisodesPayload.libraryItems.length} of ${newestEpisodesPayload.count} episodes for "Newest Episodes" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
} }
start = Date.now() start = Date.now()
@@ -583,7 +644,7 @@ class LibraryItem extends Model {
total: mostRecentPayload.count total: mostRecentPayload.count
}) })
} }
Logger.dev(`Loaded ${mostRecentPayload.libraryItems.length} of ${mostRecentPayload.count} items for "Recently Added" in ${((Date.now() - start) / 1000).toFixed(2)}s`) Logger.debug(`Loaded ${mostRecentPayload.libraryItems.length} of ${mostRecentPayload.count} items for "Recently Added" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
if (library.isBook) { if (library.isBook) {
start = Date.now() start = Date.now()
@@ -599,7 +660,7 @@ class LibraryItem extends Model {
total: seriesMostRecentPayload.count total: seriesMostRecentPayload.count
}) })
} }
Logger.dev(`Loaded ${seriesMostRecentPayload.series.length} of ${seriesMostRecentPayload.count} series for "Recent Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`) Logger.debug(`Loaded ${seriesMostRecentPayload.series.length} of ${seriesMostRecentPayload.count} series for "Recent Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
start = Date.now() start = Date.now()
// "Discover" shelf // "Discover" shelf
@@ -614,7 +675,7 @@ class LibraryItem extends Model {
total: discoverLibraryItemsPayload.count total: discoverLibraryItemsPayload.count
}) })
} }
Logger.dev(`Loaded ${discoverLibraryItemsPayload.libraryItems.length} of ${discoverLibraryItemsPayload.count} items for "Discover" in ${((Date.now() - start) / 1000).toFixed(2)}s`) Logger.debug(`Loaded ${discoverLibraryItemsPayload.libraryItems.length} of ${discoverLibraryItemsPayload.count} items for "Discover" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
} }
start = Date.now() start = Date.now()
@@ -645,7 +706,7 @@ class LibraryItem extends Model {
}) })
} }
} }
Logger.dev(`Loaded ${mediaFinishedPayload.items.length} of ${mediaFinishedPayload.count} items for "Listen/Read Again" in ${((Date.now() - start) / 1000).toFixed(2)}s`) Logger.debug(`Loaded ${mediaFinishedPayload.items.length} of ${mediaFinishedPayload.count} items for "Listen/Read Again" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
if (library.isBook) { if (library.isBook) {
start = Date.now() start = Date.now()
@@ -661,7 +722,7 @@ class LibraryItem extends Model {
total: newestAuthorsPayload.count total: newestAuthorsPayload.count
}) })
} }
Logger.dev(`Loaded ${newestAuthorsPayload.authors.length} of ${newestAuthorsPayload.count} authors for "Newest Authors" in ${((Date.now() - start) / 1000).toFixed(2)}s`) Logger.debug(`Loaded ${newestAuthorsPayload.authors.length} of ${newestAuthorsPayload.count} authors for "Newest Authors" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
} }
Logger.debug(`Loaded ${shelves.length} personalized shelves in ${((Date.now() - fullStart) / 1000).toFixed(2)}s`) Logger.debug(`Loaded ${shelves.length} personalized shelves in ${((Date.now() - fullStart) / 1000).toFixed(2)}s`)
+7
View File
@@ -1,5 +1,12 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model } = require('sequelize')
/**
* @typedef PodcastExpandedProperties
* @property {import('./PodcastEpisode')[]} podcastEpisodes
*
* @typedef {Podcast & PodcastExpandedProperties} PodcastExpanded
*/
class Podcast extends Model { class Podcast extends Model {
constructor(values, options) { constructor(values, options) {
super(values, options) super(values, options)
+6 -1
View File
@@ -152,7 +152,12 @@ class PodcastEpisode extends Model {
extraData: DataTypes.JSON extraData: DataTypes.JSON
}, { }, {
sequelize, sequelize,
modelName: 'podcastEpisode' modelName: 'podcastEpisode',
indexes: [
{
fields: ['createdAt']
}
]
}) })
const { podcast } = sequelize.models const { podcast } = sequelize.models
+10 -7
View File
@@ -48,12 +48,14 @@ class PodcastEpisode {
this.guid = episode.guid || null this.guid = episode.guid || null
this.pubDate = episode.pubDate this.pubDate = episode.pubDate
this.chapters = episode.chapters?.map(ch => ({ ...ch })) || [] this.chapters = episode.chapters?.map(ch => ({ ...ch })) || []
this.audioFile = new AudioFile(episode.audioFile) this.audioFile = episode.audioFile ? new AudioFile(episode.audioFile) : null
this.publishedAt = episode.publishedAt this.publishedAt = episode.publishedAt
this.addedAt = episode.addedAt this.addedAt = episode.addedAt
this.updatedAt = episode.updatedAt this.updatedAt = episode.updatedAt
this.audioFile.index = 1 // Only 1 audio file per episode if (this.audioFile) {
this.audioFile.index = 1 // Only 1 audio file per episode
}
} }
toJSON() { toJSON() {
@@ -73,7 +75,7 @@ class PodcastEpisode {
guid: this.guid, guid: this.guid,
pubDate: this.pubDate, pubDate: this.pubDate,
chapters: this.chapters.map(ch => ({ ...ch })), chapters: this.chapters.map(ch => ({ ...ch })),
audioFile: this.audioFile.toJSON(), audioFile: this.audioFile?.toJSON() || null,
publishedAt: this.publishedAt, publishedAt: this.publishedAt,
addedAt: this.addedAt, addedAt: this.addedAt,
updatedAt: this.updatedAt updatedAt: this.updatedAt
@@ -97,8 +99,8 @@ class PodcastEpisode {
guid: this.guid, guid: this.guid,
pubDate: this.pubDate, pubDate: this.pubDate,
chapters: this.chapters.map(ch => ({ ...ch })), chapters: this.chapters.map(ch => ({ ...ch })),
audioFile: this.audioFile.toJSON(), audioFile: this.audioFile?.toJSON() || null,
audioTrack: this.audioTrack.toJSON(), audioTrack: this.audioTrack?.toJSON() || null,
publishedAt: this.publishedAt, publishedAt: this.publishedAt,
addedAt: this.addedAt, addedAt: this.addedAt,
updatedAt: this.updatedAt, updatedAt: this.updatedAt,
@@ -108,6 +110,7 @@ class PodcastEpisode {
} }
get audioTrack() { get audioTrack() {
if (!this.audioFile) return null
const audioTrack = new AudioTrack() const audioTrack = new AudioTrack()
audioTrack.setData(this.libraryItemId, this.audioFile, 0) audioTrack.setData(this.libraryItemId, this.audioFile, 0)
return audioTrack return audioTrack
@@ -116,9 +119,9 @@ class PodcastEpisode {
return [this.audioTrack] return [this.audioTrack]
} }
get duration() { get duration() {
return this.audioFile.duration return this.audioFile?.duration || 0
} }
get size() { return this.audioFile.metadata.size } get size() { return this.audioFile?.metadata.size || 0 }
get enclosureUrl() { get enclosureUrl() {
return this.enclosure?.url || null return this.enclosure?.url || null
} }
+7 -2
View File
@@ -71,6 +71,7 @@ class ServerSettings {
this.authOpenIDAutoLaunch = false this.authOpenIDAutoLaunch = false
this.authOpenIDAutoRegister = false this.authOpenIDAutoRegister = false
this.authOpenIDMatchExistingBy = null this.authOpenIDMatchExistingBy = null
this.authOpenIDMobileRedirectURIs = ['audiobookshelf://oauth']
if (settings) { if (settings) {
this.construct(settings) this.construct(settings)
@@ -126,6 +127,7 @@ class ServerSettings {
this.authOpenIDAutoLaunch = !!settings.authOpenIDAutoLaunch this.authOpenIDAutoLaunch = !!settings.authOpenIDAutoLaunch
this.authOpenIDAutoRegister = !!settings.authOpenIDAutoRegister this.authOpenIDAutoRegister = !!settings.authOpenIDAutoRegister
this.authOpenIDMatchExistingBy = settings.authOpenIDMatchExistingBy || null this.authOpenIDMatchExistingBy = settings.authOpenIDMatchExistingBy || null
this.authOpenIDMobileRedirectURIs = settings.authOpenIDMobileRedirectURIs || ['audiobookshelf://oauth']
if (!Array.isArray(this.authActiveAuthMethods)) { if (!Array.isArray(this.authActiveAuthMethods)) {
this.authActiveAuthMethods = ['local'] this.authActiveAuthMethods = ['local']
@@ -211,7 +213,8 @@ class ServerSettings {
authOpenIDButtonText: this.authOpenIDButtonText, authOpenIDButtonText: this.authOpenIDButtonText,
authOpenIDAutoLaunch: this.authOpenIDAutoLaunch, authOpenIDAutoLaunch: this.authOpenIDAutoLaunch,
authOpenIDAutoRegister: this.authOpenIDAutoRegister, authOpenIDAutoRegister: this.authOpenIDAutoRegister,
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy,
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs // Do not return to client
} }
} }
@@ -220,6 +223,7 @@ class ServerSettings {
delete json.tokenSecret delete json.tokenSecret
delete json.authOpenIDClientID delete json.authOpenIDClientID
delete json.authOpenIDClientSecret delete json.authOpenIDClientSecret
delete json.authOpenIDMobileRedirectURIs
return json return json
} }
@@ -254,7 +258,8 @@ class ServerSettings {
authOpenIDButtonText: this.authOpenIDButtonText, authOpenIDButtonText: this.authOpenIDButtonText,
authOpenIDAutoLaunch: this.authOpenIDAutoLaunch, authOpenIDAutoLaunch: this.authOpenIDAutoLaunch,
authOpenIDAutoRegister: this.authOpenIDAutoRegister, authOpenIDAutoRegister: this.authOpenIDAutoRegister,
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy,
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs // Do not return to client
} }
} }
+24 -3
View File
@@ -18,6 +18,27 @@ class Audible {
} }
} }
/**
* Audible will sometimes send sequences with "Book 1" or "2, Dramatized Adaptation"
* @see https://github.com/advplyr/audiobookshelf/issues/2380
* @see https://github.com/advplyr/audiobookshelf/issues/1339
*
* @param {string} seriesName
* @param {string} sequence
* @returns {string}
*/
cleanSeriesSequence(seriesName, sequence) {
if (!sequence) return ''
let updatedSequence = sequence.replace(/Book /, '').trim()
if (updatedSequence.includes(' ')) {
updatedSequence = updatedSequence.split(' ').shift().replace(/,$/, '')
}
if (sequence !== updatedSequence) {
Logger.debug(`[Audible] Series "${seriesName}" sequence was cleaned from "${sequence}" to "${updatedSequence}"`)
}
return updatedSequence
}
cleanResult(item) { cleanResult(item) {
const { title, subtitle, asin, authors, narrators, publisherName, summary, releaseDate, image, genres, seriesPrimary, seriesSecondary, language, runtimeLengthMin, formatType } = item const { title, subtitle, asin, authors, narrators, publisherName, summary, releaseDate, image, genres, seriesPrimary, seriesSecondary, language, runtimeLengthMin, formatType } = item
@@ -25,13 +46,13 @@ class Audible {
if (seriesPrimary) { if (seriesPrimary) {
series.push({ series.push({
series: seriesPrimary.name, series: seriesPrimary.name,
sequence: (seriesPrimary.position || '').replace(/Book /, '') // Can be badly formatted see #1339 sequence: this.cleanSeriesSequence(seriesPrimary.name, seriesPrimary.position || '')
}) })
} }
if (seriesSecondary) { if (seriesSecondary) {
series.push({ series.push({
series: seriesSecondary.name, series: seriesSecondary.name,
sequence: (seriesSecondary.position || '').replace(/Book /, '') sequence: this.cleanSeriesSequence(seriesSecondary.name, seriesSecondary.position || '')
}) })
} }
@@ -64,7 +85,7 @@ class Audible {
} }
asinSearch(asin, region) { asinSearch(asin, region) {
asin = encodeURIComponent(asin); asin = encodeURIComponent(asin)
var regionQuery = region ? `?region=${region}` : '' var regionQuery = region ? `?region=${region}` : ''
var url = `https://api.audnex.us/books/${asin}${regionQuery}` var url = `https://api.audnex.us/books/${asin}${regionQuery}`
Logger.debug(`[Audible] ASIN url: ${url}`) Logger.debug(`[Audible] ASIN url: ${url}`)
+3 -41
View File
@@ -180,6 +180,7 @@ class ApiRouter {
this.router.get('/me/items-in-progress', MeController.getAllLibraryItemsInProgress.bind(this)) this.router.get('/me/items-in-progress', MeController.getAllLibraryItemsInProgress.bind(this))
this.router.get('/me/series/:id/remove-from-continue-listening', MeController.removeSeriesFromContinueListening.bind(this)) this.router.get('/me/series/:id/remove-from-continue-listening', MeController.removeSeriesFromContinueListening.bind(this))
this.router.get('/me/series/:id/readd-to-continue-listening', MeController.readdSeriesFromContinueListening.bind(this)) this.router.get('/me/series/:id/readd-to-continue-listening', MeController.readdSeriesFromContinueListening.bind(this))
this.router.get('/me/stats/year/:year', MeController.getStatsForYear.bind(this))
// //
// Backup Routes // Backup Routes
@@ -220,6 +221,7 @@ class ApiRouter {
this.router.get('/sessions', SessionController.getAllWithUserData.bind(this)) this.router.get('/sessions', SessionController.getAllWithUserData.bind(this))
this.router.delete('/sessions/:id', SessionController.middleware.bind(this), SessionController.delete.bind(this)) this.router.delete('/sessions/:id', SessionController.middleware.bind(this), SessionController.delete.bind(this))
this.router.get('/sessions/open', SessionController.getOpenSessions.bind(this)) this.router.get('/sessions/open', SessionController.getOpenSessions.bind(this))
this.router.post('/sessions/batch/delete', SessionController.batchDelete.bind(this))
this.router.post('/session/local', SessionController.syncLocal.bind(this)) this.router.post('/session/local', SessionController.syncLocal.bind(this))
this.router.post('/session/local-all', SessionController.syncLocalSessions.bind(this)) this.router.post('/session/local-all', SessionController.syncLocalSessions.bind(this))
// TODO: Update these endpoints because they are only for open playback sessions // TODO: Update these endpoints because they are only for open playback sessions
@@ -315,35 +317,7 @@ class ApiRouter {
this.router.get('/auth-settings', MiscController.getAuthSettings.bind(this)) this.router.get('/auth-settings', MiscController.getAuthSettings.bind(this))
this.router.patch('/auth-settings', MiscController.updateAuthSettings.bind(this)) this.router.patch('/auth-settings', MiscController.updateAuthSettings.bind(this))
this.router.post('/watcher/update', MiscController.updateWatchedPath.bind(this)) this.router.post('/watcher/update', MiscController.updateWatchedPath.bind(this))
} this.router.get('/stats/year/:year', MiscController.getAdminStatsForYear.bind(this))
async getDirectories(dir, relpath, excludedDirs, level = 0) {
try {
const paths = await fs.readdir(dir)
let dirs = await Promise.all(paths.map(async dirname => {
const fullPath = Path.join(dir, dirname)
const path = Path.join(relpath, dirname)
const isDir = (await fs.lstat(fullPath)).isDirectory()
if (isDir && !excludedDirs.includes(path) && dirname !== 'node_modules') {
return {
path,
dirname,
fullPath,
level,
dirs: level < 4 ? (await this.getDirectories(fullPath, path, excludedDirs, level + 1)) : []
}
} else {
return false
}
}))
dirs = dirs.filter(d => d)
return dirs
} catch (error) {
Logger.error('Failed to readdir', dir, error)
return []
}
} }
// //
@@ -490,18 +464,6 @@ class ApiRouter {
return userSessions.sort((a, b) => b.updatedAt - a.updatedAt) return userSessions.sort((a, b) => b.updatedAt - a.updatedAt)
} }
async getAllSessionsWithUserData() {
const sessions = await Database.getPlaybackSessions()
sessions.sort((a, b) => b.updatedAt - a.updatedAt)
const minifiedUserObjects = await Database.userModel.getMinifiedUserObjects()
return sessions.map(se => {
return {
...se,
user: minifiedUserObjects.find(u => u.id === se.userId) || null
}
})
}
async getUserListeningStatsHelpers(userId) { async getUserListeningStatsHelpers(userId) {
const today = date.format(new Date(), 'YYYY-MM-DD') const today = date.format(new Date(), 'YYYY-MM-DD')
+2
View File
@@ -36,6 +36,8 @@ class AbsMetadataFileScanner {
for (const key in abMetadata) { for (const key in abMetadata) {
// TODO: When to override with null or empty arrays? // TODO: When to override with null or empty arrays?
if (abMetadata[key] === undefined || abMetadata[key] === null) continue if (abMetadata[key] === undefined || abMetadata[key] === null) continue
if (key === 'authors' && !abMetadata.authors?.length) continue
if (key === 'genres' && !abMetadata.genres?.length) continue
if (key === 'tags' && !abMetadata.tags?.length) continue if (key === 'tags' && !abMetadata.tags?.length) continue
if (key === 'chapters' && !abMetadata.chapters?.length) continue if (key === 'chapters' && !abMetadata.chapters?.length) continue
+1 -1
View File
@@ -468,7 +468,7 @@ class AudioFileScanner {
audioFiles.length === 1 || audioFiles.length === 1 ||
audioFiles.length > 1 && audioFiles.length > 1 &&
audioFiles[0].chapters.length === audioFiles[1].chapters?.length && audioFiles[0].chapters.length === audioFiles[1].chapters?.length &&
audioFiles[0].chapters.every((c, i) => c.title === audioFiles[1].chapters[i].title) audioFiles[0].chapters.every((c, i) => c.title === audioFiles[1].chapters[i].title && c.start === audioFiles[1].chapters[i].start)
) { ) {
libraryScan.addLog(LogLevel.DEBUG, `setChapters: Using embedded chapters in first audio file ${audioFiles[0].metadata?.path}`) libraryScan.addLog(LogLevel.DEBUG, `setChapters: Using embedded chapters in first audio file ${audioFiles[0].metadata?.path}`)
chapters = audioFiles[0].chapters.map((c) => ({ ...c })) chapters = audioFiles[0].chapters.map((c) => ({ ...c }))
+88 -27
View File
@@ -3,8 +3,8 @@ const Path = require('path')
const sequelize = require('sequelize') const sequelize = require('sequelize')
const { LogLevel } = require('../utils/constants') const { LogLevel } = require('../utils/constants')
const { getTitleIgnorePrefix, areEquivalent } = require('../utils/index') const { getTitleIgnorePrefix, areEquivalent } = require('../utils/index')
const abmetadataGenerator = require('../utils/generators/abmetadataGenerator')
const parseNameString = require('../utils/parsers/parseNameString') const parseNameString = require('../utils/parsers/parseNameString')
const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata')
const globals = require('../utils/globals') const globals = require('../utils/globals')
const AudioFileScanner = require('./AudioFileScanner') const AudioFileScanner = require('./AudioFileScanner')
const Database = require('../Database') const Database = require('../Database')
@@ -170,7 +170,9 @@ class BookScanner {
hasMediaChanges = true hasMediaChanges = true
} }
const bookMetadata = await this.getBookMetadataFromScanData(media.audioFiles, libraryItemData, libraryScan, librarySettings, existingLibraryItem.id) const ebookFileScanData = await parseEbookMetadata.parse(media.ebookFile)
const bookMetadata = await this.getBookMetadataFromScanData(media.audioFiles, ebookFileScanData, libraryItemData, libraryScan, librarySettings, existingLibraryItem.id)
let authorsUpdated = false let authorsUpdated = false
const bookAuthorsRemoved = [] const bookAuthorsRemoved = []
let seriesUpdated = false let seriesUpdated = false
@@ -217,7 +219,8 @@ class BookScanner {
} else if (key === 'series') { } else if (key === 'series') {
// Check for series added // Check for series added
for (const seriesObj of bookMetadata.series) { for (const seriesObj of bookMetadata.series) {
if (!media.series.some(se => se.name === seriesObj.name)) { const existingBookSeries = media.series.find(se => se.name === seriesObj.name)
if (!existingBookSeries) {
const existingSeries = Database.libraryFilterData[libraryItemData.libraryId].series.find(se => se.name === seriesObj.name) const existingSeries = Database.libraryFilterData[libraryItemData.libraryId].series.find(se => se.name === seriesObj.name)
if (existingSeries) { if (existingSeries) {
await Database.bookSeriesModel.create({ await Database.bookSeriesModel.create({
@@ -238,6 +241,11 @@ class BookScanner {
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" added new series "${seriesObj.name}"${seriesObj.sequence ? ` with sequence "${seriesObj.sequence}"` : ''}`) libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" added new series "${seriesObj.name}"${seriesObj.sequence ? ` with sequence "${seriesObj.sequence}"` : ''}`)
seriesUpdated = true seriesUpdated = true
} }
} else if (seriesObj.sequence && existingBookSeries.bookSeries.sequence !== seriesObj.sequence) {
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" series "${seriesObj.name}" sequence "${existingBookSeries.bookSeries.sequence || ''}" => "${seriesObj.sequence}"`)
seriesUpdated = true
existingBookSeries.bookSeries.sequence = seriesObj.sequence
await existingBookSeries.bookSeries.save()
} }
} }
// Check for series removed // Check for series removed
@@ -311,24 +319,34 @@ class BookScanner {
}) })
} }
// If no cover then extract cover from audio file if available OR search for cover if enabled in server settings // If no cover then extract cover from audio file OR from ebook
const libraryItemDir = existingLibraryItem.isFile ? null : existingLibraryItem.path
if (!media.coverPath) { if (!media.coverPath) {
const libraryItemDir = existingLibraryItem.isFile ? null : existingLibraryItem.path let extractedCoverPath = await CoverManager.saveEmbeddedCoverArt(media.audioFiles, existingLibraryItem.id, libraryItemDir)
const extractedCoverPath = await CoverManager.saveEmbeddedCoverArt(media.audioFiles, existingLibraryItem.id, libraryItemDir)
if (extractedCoverPath) { if (extractedCoverPath) {
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" extracted embedded cover art from audio file to path "${extractedCoverPath}"`) libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" extracted embedded cover art from audio file to path "${extractedCoverPath}"`)
media.coverPath = extractedCoverPath media.coverPath = extractedCoverPath
hasMediaChanges = true hasMediaChanges = true
} else if (Database.serverSettings.scannerFindCovers) { } else if (ebookFileScanData?.ebookCoverPath) {
const authorName = media.authors.map(au => au.name).filter(au => au).join(', ') extractedCoverPath = await CoverManager.saveEbookCoverArt(ebookFileScanData, existingLibraryItem.id, libraryItemDir)
const coverPath = await this.searchForCover(existingLibraryItem.id, libraryItemDir, media.title, authorName, libraryScan) if (extractedCoverPath) {
if (coverPath) { libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" extracted embedded cover art from ebook file to path "${extractedCoverPath}"`)
media.coverPath = coverPath media.coverPath = extractedCoverPath
hasMediaChanges = true hasMediaChanges = true
} }
} }
} }
// If no cover then search for cover if enabled in server settings
if (!media.coverPath && Database.serverSettings.scannerFindCovers) {
const authorName = media.authors.map(au => au.name).filter(au => au).join(', ')
const coverPath = await this.searchForCover(existingLibraryItem.id, libraryItemDir, media.title, authorName, libraryScan)
if (coverPath) {
media.coverPath = coverPath
hasMediaChanges = true
}
}
existingLibraryItem.media = media existingLibraryItem.media = media
let libraryItemUpdated = false let libraryItemUpdated = false
@@ -402,12 +420,14 @@ class BookScanner {
return null return null
} }
let ebookFileScanData = null
if (ebookLibraryFile) { if (ebookLibraryFile) {
ebookLibraryFile = ebookLibraryFile.toJSON() ebookLibraryFile = ebookLibraryFile.toJSON()
ebookLibraryFile.ebookFormat = ebookLibraryFile.metadata.ext.slice(1).toLowerCase() ebookLibraryFile.ebookFormat = ebookLibraryFile.metadata.ext.slice(1).toLowerCase()
ebookFileScanData = await parseEbookMetadata.parse(ebookLibraryFile)
} }
const bookMetadata = await this.getBookMetadataFromScanData(scannedAudioFiles, libraryItemData, libraryScan, librarySettings) const bookMetadata = await this.getBookMetadataFromScanData(scannedAudioFiles, ebookFileScanData, libraryItemData, libraryScan, librarySettings)
bookMetadata.explicit = !!bookMetadata.explicit // Ensure boolean bookMetadata.explicit = !!bookMetadata.explicit // Ensure boolean
bookMetadata.abridged = !!bookMetadata.abridged // Ensure boolean bookMetadata.abridged = !!bookMetadata.abridged // Ensure boolean
@@ -475,19 +495,28 @@ class BookScanner {
} }
} }
// If cover was not found in folder then check embedded covers in audio files OR search for cover // If cover was not found in folder then check embedded covers in audio files OR ebook file
const libraryItemDir = libraryItemObj.isFile ? null : libraryItemObj.path
if (!bookObject.coverPath) { if (!bookObject.coverPath) {
const libraryItemDir = libraryItemObj.isFile ? null : libraryItemObj.path let extractedCoverPath = await CoverManager.saveEmbeddedCoverArt(scannedAudioFiles, libraryItemObj.id, libraryItemDir)
// Extract and save embedded cover art
const extractedCoverPath = await CoverManager.saveEmbeddedCoverArt(scannedAudioFiles, libraryItemObj.id, libraryItemDir)
if (extractedCoverPath) { if (extractedCoverPath) {
libraryScan.addLog(LogLevel.DEBUG, `Extracted embedded cover from audio file at "${extractedCoverPath}" for book "${bookObject.title}"`)
bookObject.coverPath = extractedCoverPath bookObject.coverPath = extractedCoverPath
} else if (Database.serverSettings.scannerFindCovers) { } else if (ebookFileScanData?.ebookCoverPath) {
const authorName = bookMetadata.authors.join(', ') extractedCoverPath = await CoverManager.saveEbookCoverArt(ebookFileScanData, libraryItemObj.id, libraryItemDir)
bookObject.coverPath = await this.searchForCover(libraryItemObj.id, libraryItemDir, bookObject.title, authorName, libraryScan) if (extractedCoverPath) {
libraryScan.addLog(LogLevel.DEBUG, `Extracted embedded cover from ebook file at "${extractedCoverPath}" for book "${bookObject.title}"`)
bookObject.coverPath = extractedCoverPath
}
} }
} }
// If cover not found then search for cover if enabled in settings
if (!bookObject.coverPath && Database.serverSettings.scannerFindCovers) {
const authorName = bookMetadata.authors.join(', ')
bookObject.coverPath = await this.searchForCover(libraryItemObj.id, libraryItemDir, bookObject.title, authorName, libraryScan)
}
libraryItemObj.book = bookObject libraryItemObj.book = bookObject
const libraryItem = await Database.libraryItemModel.create(libraryItemObj, { const libraryItem = await Database.libraryItemModel.create(libraryItemObj, {
include: { include: {
@@ -564,13 +593,14 @@ class BookScanner {
/** /**
* *
* @param {import('../models/Book').AudioFileObject[]} audioFiles * @param {import('../models/Book').AudioFileObject[]} audioFiles
* @param {import('../utils/parsers/parseEbookMetadata').EBookFileScanData} ebookFileScanData
* @param {import('./LibraryItemScanData')} libraryItemData * @param {import('./LibraryItemScanData')} libraryItemData
* @param {LibraryScan} libraryScan * @param {LibraryScan} libraryScan
* @param {import('../models/Library').LibrarySettingsObject} librarySettings * @param {import('../models/Library').LibrarySettingsObject} librarySettings
* @param {string} [existingLibraryItemId] * @param {string} [existingLibraryItemId]
* @returns {Promise<BookMetadataObject>} * @returns {Promise<BookMetadataObject>}
*/ */
async getBookMetadataFromScanData(audioFiles, libraryItemData, libraryScan, librarySettings, existingLibraryItemId = null) { async getBookMetadataFromScanData(audioFiles, ebookFileScanData, libraryItemData, libraryScan, librarySettings, existingLibraryItemId = null) {
// First set book metadata from folder/file names // First set book metadata from folder/file names
const bookMetadata = { const bookMetadata = {
title: libraryItemData.mediaMetadata.title, // required title: libraryItemData.mediaMetadata.title, // required
@@ -593,7 +623,7 @@ class BookScanner {
coverPath: undefined coverPath: undefined
} }
const bookMetadataSourceHandler = new BookScanner.BookMetadataSourceHandler(bookMetadata, audioFiles, libraryItemData, libraryScan, existingLibraryItemId) const bookMetadataSourceHandler = new BookScanner.BookMetadataSourceHandler(bookMetadata, audioFiles, ebookFileScanData, libraryItemData, libraryScan, existingLibraryItemId)
const metadataPrecedence = librarySettings.metadataPrecedence || ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata'] const metadataPrecedence = librarySettings.metadataPrecedence || ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata']
libraryScan.addLog(LogLevel.DEBUG, `"${bookMetadata.title}" Getting metadata with precedence [${metadataPrecedence.join(', ')}]`) libraryScan.addLog(LogLevel.DEBUG, `"${bookMetadata.title}" Getting metadata with precedence [${metadataPrecedence.join(', ')}]`)
for (const metadataSource of metadataPrecedence) { for (const metadataSource of metadataPrecedence) {
@@ -621,13 +651,15 @@ class BookScanner {
* *
* @param {Object} bookMetadata * @param {Object} bookMetadata
* @param {import('../models/Book').AudioFileObject[]} audioFiles * @param {import('../models/Book').AudioFileObject[]} audioFiles
* @param {import('../utils/parsers/parseEbookMetadata').EBookFileScanData} ebookFileScanData
* @param {import('./LibraryItemScanData')} libraryItemData * @param {import('./LibraryItemScanData')} libraryItemData
* @param {LibraryScan} libraryScan * @param {LibraryScan} libraryScan
* @param {string} existingLibraryItemId * @param {string} existingLibraryItemId
*/ */
constructor(bookMetadata, audioFiles, libraryItemData, libraryScan, existingLibraryItemId) { constructor(bookMetadata, audioFiles, ebookFileScanData, libraryItemData, libraryScan, existingLibraryItemId) {
this.bookMetadata = bookMetadata this.bookMetadata = bookMetadata
this.audioFiles = audioFiles this.audioFiles = audioFiles
this.ebookFileScanData = ebookFileScanData
this.libraryItemData = libraryItemData this.libraryItemData = libraryItemData
this.libraryScan = libraryScan this.libraryScan = libraryScan
this.existingLibraryItemId = existingLibraryItemId this.existingLibraryItemId = existingLibraryItemId
@@ -641,13 +673,42 @@ class BookScanner {
} }
/** /**
* Metadata from audio file meta tags * Metadata from audio file meta tags OR metadata from ebook file
*/ */
audioMetatags() { audioMetatags() {
if (!this.audioFiles.length) return if (this.audioFiles.length) {
// Modifies bookMetadata with metadata mapped from audio file meta tags // Modifies bookMetadata with metadata mapped from audio file meta tags
const bookTitle = this.bookMetadata.title || this.libraryItemData.mediaMetadata.title const bookTitle = this.bookMetadata.title || this.libraryItemData.mediaMetadata.title
AudioFileScanner.setBookMetadataFromAudioMetaTags(bookTitle, this.audioFiles, this.bookMetadata, this.libraryScan) AudioFileScanner.setBookMetadataFromAudioMetaTags(bookTitle, this.audioFiles, this.bookMetadata, this.libraryScan)
} else if (this.ebookFileScanData) {
const ebookMetdataObject = this.ebookFileScanData.metadata || {}
for (const key in ebookMetdataObject) {
if (key === 'tags') {
if (ebookMetdataObject.tags.length) {
this.bookMetadata.tags = ebookMetdataObject.tags
}
} else if (key === 'genres') {
if (ebookMetdataObject.genres.length) {
this.bookMetadata.genres = ebookMetdataObject.genres
}
} else if (key === 'authors') {
if (ebookMetdataObject.authors?.length) {
this.bookMetadata.authors = ebookMetdataObject.authors
}
} else if (key === 'narrators') {
if (ebookMetdataObject.narrators?.length) {
this.bookMetadata.narrators = ebookMetdataObject.narrators
}
} else if (key === 'series') {
if (ebookMetdataObject.series?.length) {
this.bookMetadata.series = ebookMetdataObject.series
}
} else if (ebookMetdataObject[key] && key !== 'sequence') {
this.bookMetadata[key] = ebookMetdataObject[key]
}
}
}
return null
} }
/** /**

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