Compare commits

..

192 Commits

Author SHA1 Message Date
advplyr ebedaeb3b0 Version bump 2.2.12 2023-01-08 10:48:25 -06:00
advplyr 62aec63d1d Fix:Backups to not backup temp db files 2023-01-08 09:59:24 -06:00
advplyr 3c25e87e8d Update:Cleanup audio player 2023-01-08 09:38:37 -06:00
advplyr 08d16ce7c2 Silence remove invalid sessions debug log 2023-01-08 09:15:11 -06:00
advplyr 2cb3808326 Fix:Loading backups catching failed backups 2023-01-08 09:11:55 -06:00
advplyr bdb6f0c0aa Update:Sync session API endpoint to not respond with a payload 2023-01-07 17:33:05 -06:00
advplyr 5255bf13cc Update:Libraries table using context menu instead of hover buttons. Cleanup mobile view #1342 2023-01-07 17:14:55 -06:00
advplyr 3588e1e8d3 Update:Handle badly formatted series sequence from Audible #1339 2023-01-07 16:33:20 -06:00
advplyr 8fa8360e99 Update:Manual match tab prefer using ASIN with audible providers #1352 2023-01-07 16:22:59 -06:00
advplyr b305cfd268 Update max playback speed to 10x 2023-01-07 16:18:52 -06:00
advplyr ff10287d05 Fix:Force AAC when transcoding ALAC audio file streams #1372 2023-01-07 15:58:57 -06:00
advplyr 7a7708403f Update:Item metadata utils tag and genre loading indicator visible in viewport #1346 2023-01-07 15:44:59 -06:00
advplyr ddabd0ee75 Update:Library folder browser to not save folders from request #1371 2023-01-07 15:31:51 -06:00
advplyr 5a26704c32 Add:Option to disable backup of audio files in embed metadata tool #1370 2023-01-07 15:16:52 -06:00
advplyr 7ccf36a896 Merge pull request #1374 from lkiesow/gentium-book-basic
Update Gentium Book Basic Font
2023-01-07 13:35:06 -06:00
Lars Kiesow e9a84dd7dd Update Gentium Book Basic Font
This patch updates the Gentium Book Basic font file [1]. While I
couldn't get any client to use the previous file, it doesn't seem to be
a problem with this file and now the text is being rendered correctly.

[1] https://gwfh.mranftl.com/fonts/gentium-book-basic?subsets=latin
2023-01-07 20:25:11 +01:00
advplyr b00510855e Fix:Gentium Book Basic font 2023-01-07 13:06:44 -06:00
advplyr 2cd9079692 Add MusicBrainz provider 2023-01-07 13:05:33 -06:00
advplyr 3e4b1652fc Fix disc/track metadata mapping 2023-01-06 17:39:15 -06:00
advplyr 878330b4fb Fix filePathToPOSIX used in scan, updates for music track page 2023-01-06 17:10:55 -06:00
advplyr 9a85ad1f6b Fix:Check if Windows before cleaning file path for POSIX separators #1254 2023-01-05 17:45:27 -06:00
advplyr f76f9c7f84 Merge pull request #1367 from lkiesow/log-source
Add Source to Logging
2023-01-05 16:51:37 -06:00
advplyr 3426832f2b Fix for windows, update regex to only include line number, move to end of log 2023-01-05 16:44:34 -06:00
Lars Kiesow 10fd51498c Add Source to Logging
The Audiobookshelf logs sometimes contain information about the source
of the log statement, but sometimes they don't This really depends on
developers adding these information to the log messages.

But even then, the information is usually just a hint about the module
logging this, like `[Db]` or [Watcher]`, and finding the exact line can
be hard.

This patch automatically adds the source of the log statement to the
logs. This means if someone calls `Logger.info(…)` in line `22` of
`foo.js`, the log statement will contain this file and line:

```
[2023-01-05 19:04:12[ (LogManager.js:85:18) DEBUG: Daily Log file found 2023-01-05.txt
[2023-01-05 19:04:12] (LogManager.js:59:12)  INFO: [LogManager] Init current daily log filename: 2023-01-05.txt
```

This should make it much easier to identify the code where the log
statement originated from.

Long-term, this also means that we can probably remove the manually set
identifiers contained in the log messages, like the `[LogManager]` in
the example above.
2023-01-05 19:13:31 +01:00
advplyr 49c581ed35 Add:Podcast option to quick match all unmatched episodes 2023-01-04 18:13:46 -06:00
advplyr 1609f1a499 Add:Global library search also searches on podcast episode titles #1363 2023-01-04 17:43:15 -06:00
advplyr 88bd51e2da Fix:Update authors in different order #1361 2023-01-04 17:21:25 -06:00
advplyr 74388fe0b9 Fix:Series sequence parsed from metadata.abs allow non-numerical characters #1128 #1360 2023-01-04 15:55:02 -06:00
advplyr 7f5356100d Bookshelf updates for music tracks 2023-01-03 18:00:01 -06:00
advplyr 84d2d00a30 Merge pull request #1353 from tomazed/translation-fr
Update fr.json
2023-01-03 15:37:30 -06:00
Tomazed 31dddfbb60 Update fr.json 2023-01-03 10:53:27 +01:00
advplyr d6da161b13 Music albums grouping and page 2023-01-02 18:02:04 -06:00
advplyr 9de7be1cb4 Update scanner, music meta tags and fix issue with force update 2023-01-02 16:35:39 -06:00
advplyr 5410aae8fc Remove old scanner setting from ServerSettings 2023-01-02 12:07:26 -06:00
advplyr 86bf6bfc62 Remove scannerMaxThreads from ServerSettings 2023-01-02 12:05:58 -06:00
advplyr 0807146aab Cleanup scanner 2023-01-02 12:05:07 -06:00
advplyr 591d8a8ab1 Add:OPF file pulls ASIN and subtitle #1330 2023-01-02 10:47:13 -06:00
advplyr b1d4e28027 Merge pull request #1350 from lkiesow/settings-menu
Fix Hidden Settings Menu
2023-01-02 10:40:24 -06:00
advplyr 44363f05ac Start of new epub reader 2023-01-01 18:09:00 -06:00
Lars Kiesow 452af43916 Fix Hidden Settings Menu
This patch fixes several problems of the settings menu related to
display on mobile devices or small(ish) windows:

- The `isMobileLandscape` is now calculated correctly. Previously, this
  was set to `true` if a device was in portrait mode.

- Showing the button to collapse the settings menu and making the menu
  collapsible now use the same mechanism. Previously, it could happen
  that the menu was opened and not fixed, but no button to close it
  again was shown.

- The icons fore opening and closing the settings menu are now both
  arrows, indicating that their functionality is reversed.

- The button to open the menu now always has the string “Settings”,
  instead of using the name of the current page. The current page hader
  is listed below that anyway and this is the action component to open
  the settings menu after all.

This fixes #1334
2023-01-01 19:49:43 +01:00
advplyr 70ba2f7850 Add:RSS feed for series & cleanup empty series from db #1265 2022-12-31 16:58:19 -06:00
advplyr a364fe5031 Merge RSS feed modals into a universal one 2022-12-31 15:26:37 -06:00
advplyr ca6765c8e7 Add translations for series #1166 2022-12-31 15:04:37 -06:00
advplyr 6bfa281dc5 Update:Series page toolbar add context menu and confirm dialog for marking series as finished 2022-12-31 14:56:18 -06:00
advplyr d8ee61bfab Update:Personalized API endpoint include query string to add rssFeed to entities 2022-12-31 14:31:38 -06:00
advplyr c6763dee2d Remove invalid RSS feeds on init and remove feeds when associated entity is removed 2022-12-31 14:08:34 -06:00
advplyr 0e6b0d3eff Update:Remove RSS feeds from login response payload and include feeds from library items request 2022-12-31 10:59:12 -06:00
advplyr 8bbfee334c Update:Show RSS feed icon on collection card & update API endpoint for fetching collections 2022-12-31 10:33:38 -06:00
advplyr f806e4cce3 Merge pull request #1343 from lkiesow/a11y-main-settings
Accessibility Improvements for Main Settings
2022-12-31 09:30:48 -06:00
advplyr 209ba308bd Merge branch 'master' into a11y-main-settings 2022-12-31 08:43:26 -06:00
advplyr 4cd9088a66 Add translations for aria labels #1166 2022-12-30 16:27:21 -06:00
advplyr ac5e2e5c73 Merge pull request #1341 from lkiesow/a11y-user-settings
Fix keyboard navigation in user settings
2022-12-30 16:26:07 -06:00
Lars Kiesow f1329d2847 Accessibility Improvements for Main Settings
This patch fixes some accessibility problems on the main settings page.
Most notably, it makes sure that the different options have labels which
are picked up by screen readers.

As a more generic addition, this also makes sure that the dropdown
component will always have a proper label constructed, explaining what
the dropdown is for and what its current value is.
2022-12-30 19:14:04 +01:00
advplyr 27faefc64d Merge pull request #1338 from naleo/master
Fix incorrect series and seriespart tag codes, they were swapped
2022-12-29 18:05:41 -06:00
advplyr 0fa7e61dc1 Merge pull request #1336 from lkiesow/user-settings-screenreader
Make User Settings Accessible via Screen Reader
2022-12-29 18:03:40 -06:00
advplyr 5a3f14ae51 Remove extra space from label 2022-12-29 18:03:05 -06:00
Lars Kiesow 4e61185136 Fix keyboard navigation in user settings
This patch makes sure that the option in the user settings are
accessible via keyboard navigation and that the labels, if users use a
screen reader, actually make sense.

This patch introduces new strings which need to be translated. Although
I did already provide a German translation.
2022-12-29 21:36:42 +01:00
Naleo 6ee06d5dae Fix incorrent series and seriespart tag codes, they were swapped 2022-12-29 08:41:46 -10:00
Lars Kiesow 2c344a0bc0 Make User Settings Accessible via Screen Reader
This patch should fix most of the problems for users trying to access
the user settings via screen reader. It makes sure user interface
elements can be reached via keyboard and provides proper labels, roles
and values so you not only can interact with elements but also know what
you are actually changing.

While not focused on other views, this should also already fix a number
of accessibility issues with other settings pages.
2022-12-29 05:00:40 +01:00
advplyr 315c83e4c3 RSS feed for collection to update when any item in the collection is updated #606 2022-12-28 18:08:03 -06:00
advplyr 9e4bc582cb Merge pull request #1335 from lkiesow/keyboard-navigation-libraries
Fix keyboard navigation in library selection
2022-12-28 17:18:35 -06:00
Lars Kiesow fc6aa1f91f Fix keyboard navigation in library selection
This patch fixes the keyboard navigation in the library selection of the
main app bar. Without this patch, no options are selectable via keyboard
and selecting an option and hitting return has no effect.
2022-12-29 00:09:22 +01:00
advplyr d4bea34423 Merge pull request #1333 from lkiesow/keynoard-navigation-border
Highlight items when navigating via keyboard
2022-12-28 17:01:17 -06:00
advplyr a551a2d288 Merge pull request #1332 from lkiesow/home-img-alt
Text description of home link
2022-12-28 16:40:21 -06:00
Lars Kiesow 4b0c59b174 Highlight items when navigating via keyboard
This patch highlights items in the app bar if a user uses the keyboard
to navigate in audiobookshelf. This ensures that users actually know
which item they have selected.

This also modifies the text for the library selector, so that users
which are using a screen reader understand that it is a selector for
libraries and not only a button related to the current library.
2022-12-28 22:59:27 +01:00
Lars Kiesow a0840d2a08 Text description of home link
This patch adds the missing alt attribute to the image linking the home
page of audiobookshelf. This allows screen readers to explain to users
where this link leads to.
2022-12-28 22:55:11 +01:00
advplyr 308ccf470f Add:Open RSS feed for collection #606 #1265 2022-12-27 18:03:31 -06:00
advplyr 4021b6eca1 Merge pull request #1320 from lkiesow/undefined-default
Fix undefined string assignment
2022-12-27 15:37:31 -06:00
advplyr 061695f922 Add:API endpoint for opening RSS feed for collection #606 #1265 2022-12-26 17:48:39 -06:00
advplyr e803dcd325 Update:RSS feed API routes 2022-12-26 16:58:36 -06:00
Lars Kiesow 128796bd36 Fix undefined string assignment
Assigning something to `process.env.profile`, Node stringifies the value. This
means that assigning `undefined` to an environment variable in Node will result
in it holding the string `undefined`.

This means, for example, that `module.exports.FFPROBE_PATH || 'ffprobe'` in
`server/libs/nodeFfprobe/index.js` will actually result in the string
`undefined`.

This patch fixes several such assignments in the `index.js`, potentially
causing problems in the development mode.
2022-12-26 23:55:14 +01:00
advplyr 775dedc338 Cleanup and remove more vars 2022-12-26 16:08:53 -06:00
advplyr 45c9038954 Fix:Manually updating author image path & realtime update author image #1317 2022-12-26 15:45:42 -06:00
advplyr 8acf962864 Update:Remove relImagePath from Author entity 2022-12-26 15:29:45 -06:00
advplyr c3fc38639e Merge pull request #1297 from Eschguy/master
Add Caddyfile example to readme
2022-12-25 16:24:57 -06:00
Austin Eschweiler b60b75c8da Update readme.md 2022-12-25 11:32:12 -06:00
advplyr 0f7edec73b Add note in readme about subfolder 2022-12-24 13:32:45 -06:00
advplyr 321277826f Readme updates 2022-12-24 11:32:40 -06:00
advplyr 6e752af2c0 Update readme with new docs 2022-12-24 11:29:58 -06:00
advplyr 0717ae39db Fix music fine file with inode 2022-12-24 11:12:39 -06:00
advplyr 7bc5902ea8 Merge pull request #1312 from Machou/master
Update fr.json
2022-12-24 06:58:04 -06:00
Machou a28e1ed5e0 Update fr.json 2022-12-24 07:49:22 +01:00
advplyr 43d9e129a6 Merge pull request #1310 from k9withabone/socket-fixes
Socket fixes
2022-12-23 07:47:32 -06:00
advplyr b516019ddd Merge branch 'socket-fixes' of https://github.com/k9withabone/audiobookshelf into socket-fixes 2022-12-23 07:34:15 -06:00
advplyr e4c20d677c Update server/controllers/SeriesController.js
Co-authored-by: Paul Nettleton <paulnett7@hotmail.com>
2022-12-23 07:33:33 -06:00
advplyr 33e183b802 Merge branch 'master' into socket-fixes 2022-12-23 07:27:14 -06:00
advplyr b884f8fe11 Laying the groundwork for music media type #964 2022-12-22 16:38:55 -06:00
Paul Nettleton 2cba83f1dd Server socket event fixes 2022-12-22 16:26:11 -06:00
Paul Nettleton a9ee9031c3 Add rss feed minified 2022-12-22 16:04:42 -06:00
advplyr c3717f6979 Merge pull request #1306 from burghy86/patch-7
Update it.json
2022-12-21 07:22:43 -06:00
advplyr 657d4dd705 Update:Trim whitespace from audio file meta tag values #1305 2022-12-21 07:13:28 -06:00
burghy86 17356ffd79 Update it.json
fix
2022-12-21 10:46:21 +01:00
advplyr c4be75b5bd Fix:Backups cron scheduler modal #1304 2022-12-20 12:35:31 -06:00
advplyr 57422d0759 Fix:PWA manifest add PNG icon for desktop browsers #1300 2022-12-20 11:57:52 -06:00
advplyr d2454201b4 Merge pull request #1302 from Hallo951/master
Update de.json
2022-12-20 09:43:43 -06:00
Hallo951 3a92a69693 Update de.json
- minor fixes
- Item translated with medium or media
2022-12-20 09:36:50 +01:00
Austin Eschweiler d733c9ccc6 Add Caddyfile example to readme
An example Caddyfile based on what I use
2022-12-19 18:10:55 -06:00
advplyr 3e15e09c07 Fix:Get libraries endpoint #1296 2022-12-19 17:46:32 -06:00
advplyr 0592a41d4f Version bump 2.2.11 2022-12-19 17:16:58 -06:00
advplyr c32e33f804 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-12-19 17:16:48 -06:00
advplyr 616ffb8f79 Add:M4b tool configurable options bitrate/channels/codec #1029 #1257 2022-12-19 17:13:04 -06:00
advplyr bc771a3a44 Delete DownloadManager.js 2022-12-19 16:20:18 -06:00
advplyr 539d1a2d4f Merge pull request #1294 from tomazed/translation-fr
Update fr.json regarding new Metadata strings
2022-12-19 16:16:56 -06:00
advplyr 4d8cea0bb4 Merge pull request #1293 from springsunx/patch-1
Update zh-cn.json
2022-12-19 16:16:37 -06:00
advplyr 8b46262e93 Merge pull request #1292 from Hallo951/master
Update de.json
2022-12-19 16:16:17 -06:00
advplyr eb9a077520 Fix scroll listener for multi select inputs 2022-12-19 16:10:45 -06:00
advplyr 3d3a224402 Fix:Edit modal dropdown menus hidden #1295 2022-12-19 15:32:17 -06:00
advplyr e1397a6dda Update:Author cover image API endpoint to get raw cover image #1291 2022-12-19 15:06:43 -06:00
advplyr 8f49aae979 Fix:Adding podcast and filename sanitize func #1290 2022-12-19 15:02:31 -06:00
Tomazed c0a13f01d4 Update fr.json regarding new Metadata strings 2022-12-19 16:39:46 +01:00
SunX efcebc616c Update zh-cn.json 2022-12-19 22:08:54 +08:00
Hallo951 902867c3bc Update de.json 2022-12-19 09:02:17 +01:00
advplyr b7abd372e4 Version bump 2.2.10 2022-12-18 18:38:00 -06:00
advplyr 147ffc0210 Fix:Cover size widget behind home page arrow #1288 2022-12-18 18:37:03 -06:00
advplyr 1b2ccb6cee Fix:Series inner input behind details modal #1289 2022-12-18 18:35:05 -06:00
advplyr c58a6b9047 Version bump 2.2.9 2022-12-18 15:50:47 -06:00
advplyr b787fb18f3 Merge pull request #1251 from lkiesow/PermissionsStartOnly
No PermissionsStartOnly=true
2022-12-18 15:50:10 -06:00
advplyr 17cce9c914 Merge pull request #1287 from lkiesow/subpath-detection
Fix Sub-path Detection
2022-12-18 15:48:28 -06:00
Lars Kiesow 90299e348c Fix Sub-path Detection
If the scanner detects new files with a path containing part of the name
of an already existing library item, the new item will incorrectly be
detected as being a parent directory of the already existing item and
the import will be aborted.

You can follow these steps to reproduce the issue:

```
❯ mkdir audiobooks/author/

❯ mv title\ 10 audiobooks/author
[2022-12-18 22:14:12] DEBUG: [Watcher] File Added /home/lars/dev/audiobookshelf/audiobooks/author/title 10/dictaphone.mp3
[2022-12-18 22:14:16] DEBUG: [DB] Library Items inserted 1

❯ mv title\ 1 audiobooks/author
[2022-12-18 22:15:03] DEBUG: [Watcher] File Added /home/lars/dev/audiobookshelf/audiobooks/author/title 1/dictaphone.mp3
[2022-12-18 22:15:07]  WARN: [Scanner] Files were modified in a parent directory of a library item "title 10" - ignoring
```

Since `'title 10'.startsWith('title 1')` is `true`, the current code
makes this false assumption.

This patch fixes the issue by requiring a path separator to be part of
the matching path. This should ensure that only true parent directories
are detected.

This patch requires audiobookshelf to always use Unix file separators.
But that shouldn't be a problem since audiobookshelf always seems to use
these kinds of separators. Even on Windows.
2022-12-18 22:23:50 +01:00
advplyr fe25a1bc54 Update item metadata pages sort 2022-12-18 15:16:32 -06:00
advplyr edbe1851b5 Add translation strings for item metadata utils #1166 2022-12-18 15:11:48 -06:00
advplyr ad6c5a4f00 Merge pull request #1286 from tomazed/translation-fr
Update fr.json with new strings from d7cc8a0
2022-12-18 14:54:08 -06:00
advplyr 4971787482 Add:Manage genres #1163 2022-12-18 14:52:53 -06:00
Tomazed 56d2ec9c22 Update fr.json with new strings from d7cc8a052a 2022-12-18 21:37:47 +01:00
advplyr 106ddc9541 Fix scan log path #1285 2022-12-18 14:26:15 -06:00
advplyr 4d93e39fa9 Add:Item metadata utils config page for managing tags #1163 2022-12-18 14:17:52 -06:00
advplyr 54b41b15c2 Merge pull request #1282 from lkiesow/google-books-https
Use HTTPS for Google Books Images
2022-12-17 17:59:44 -06:00
advplyr 54ca42a903 Update:Bookshelf view title sign width 2022-12-17 17:50:16 -06:00
advplyr d7cc8a052a New translation strings for collections/playlist #1166 2022-12-17 17:47:35 -06:00
advplyr 5165f11460 Add:Create playlist from a collection #1226 2022-12-17 17:31:19 -06:00
Lars Kiesow b47ce4fb24 Use HTTPS for Google Books Images
The API for Google Books will return HTTP image URLs when matiching any
books using it as a search provider. In a secure environment, this
causes browser warnings.

All Google image links support HTTPS and we can safely switch to HTTOS
to avoid these warnings.
2022-12-18 00:18:11 +01:00
advplyr 9b1f7f566f Fix:On bookshelf view show series name placard on shelf #1239 2022-12-17 16:36:41 -06:00
advplyr 10295b000a Update:Remove HOST default to allow for ipv6 #1256 2022-12-17 15:55:53 -06:00
advplyr c06d734d5e Update:Persist series sort/filter options #1272 2022-12-17 15:10:25 -06:00
advplyr 49a69193d8 Comments where user settings needs to be removed 2022-12-17 14:52:10 -06:00
advplyr 7852804a9c Update:Remove call to server for user settings, user settings stored locally 2022-12-17 14:50:01 -06:00
advplyr 415dda37a4 Update:Match tab persist selected details to use #1276 2022-12-17 10:27:27 -06:00
advplyr 179d339afd Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-12-16 17:58:42 -06:00
advplyr 858c1a7353 Update:Series inner input modal update button Save to Submit #1277 2022-12-16 17:57:46 -06:00
advplyr 0b42b81558 Update:Author modal Submit button to Save #1280 2022-12-16 17:54:00 -06:00
advplyr f9678dec2f Merge pull request #1275 from tomazed/translation-fr
Update fr.json for batch update
2022-12-15 17:58:17 -06:00
advplyr 82642b295c Merge pull request #1271 from tomazed/localization-update
Missing Localization in Appbar.vue
2022-12-15 17:57:52 -06:00
advplyr ba3d84a924 Update client/components/app/Appbar.vue 2022-12-15 17:57:42 -06:00
advplyr 96e2f934a3 Merge pull request #1270 from Hallo951/master
Update de.json
2022-12-15 17:56:53 -06:00
advplyr a68ade2b3d Update:Select largest cover image from Google Books provider #1244 2022-12-15 17:54:02 -06:00
advplyr 4fcdeda447 Add:Book library filter for missing cover image #1243 2022-12-15 17:46:27 -06:00
advplyr dc03835742 Update:Trim whitespace from chapter titles in chapter editor #1248 2022-12-15 17:40:34 -06:00
advplyr 50430e6b27 Update:Audiobook RSS feed track episode pub dates #1253 2022-12-15 17:36:29 -06:00
advplyr d130dd6d5e Fix:Setting file ownership for /config and /metadata/logs #584 2022-12-15 17:30:45 -06:00
advplyr 793cc989de Fix:Overflowing edit library folders #1266 2022-12-15 16:51:37 -06:00
Tomazed 27d8c4d67c Update fr.json for batch update 2022-12-15 23:19:46 +01:00
Tomazed 48f493a9f5 Missing Localization in Appbar.vue 2022-12-15 17:50:13 +01:00
Hallo951 04992ee3fb Update de.json 2022-12-15 16:36:28 +01:00
advplyr 4d8e2a1279 Update:Max filename to 255 bytes in utf-16 #1261 2022-12-13 17:46:18 -06:00
advplyr 2af7b6b6f1 Add translation strings for batch update page #1166 2022-12-13 16:59:46 -06:00
advplyr e59351566d Add:Batch append details #848 2022-12-13 16:28:05 -06:00
advplyr 05d10b73c3 Merge pull request #1231 from k9withabone/server/respond-with-objects
Server respond with objects
2022-12-12 17:53:57 -06:00
advplyr 41e192c6a5 Update more vars 2022-12-12 17:52:20 -06:00
advplyr ea42ab7624 Update get all users route 2022-12-12 17:48:57 -06:00
advplyr 2d9035d90b Update get tags route and revert podcast/books search route 2022-12-12 17:45:51 -06:00
advplyr 0ae853c119 Update library items batch get route 2022-12-12 17:36:53 -06:00
advplyr 3c0fdff7b4 Update libraries reorder and get all authors routes 2022-12-12 17:33:59 -06:00
advplyr eede2bbd46 Update for filesystem and libraries api update and revert personalized shelves route 2022-12-12 17:29:56 -06:00
advplyr 5c31687a0f Merge branch 'master' into server/respond-with-objects 2022-12-12 17:20:14 -06:00
advplyr 6b654d3c2d Update:Starting session for finished item sets the user start time back to 0 2022-12-12 17:18:56 -06:00
Lars Kiesow 91cbe45839 No PermissionsStartOnly=true
This patch removes `PermissionsStartOnly=true` from the systemd unit
file used for packaging. This shouldn't be necessary for any commands
run by the unit.
2022-12-06 00:52:23 +01:00
advplyr 7883d4a97f Merge pull request #1249 from lkiesow/tooltips
Add Missing Tooltips
2022-12-05 17:13:14 -06:00
advplyr 9f4547cff8 Update client/components/app/Appbar.vue 2022-12-05 17:13:03 -06:00
advplyr a98106593d Update client/components/app/Appbar.vue 2022-12-05 17:12:58 -06:00
advplyr c625b3f08c Update client/components/app/Appbar.vue 2022-12-05 17:12:53 -06:00
advplyr 9e7f09c21b Merge pull request #1245 from burghy86/patch-6
Update it.json
2022-12-05 17:03:19 -06:00
Lars Kiesow 616caecdf1 Add Missing Tooltips
This patch adds a few more missing tooltips to the user interface.
2022-12-05 23:16:27 +01:00
burghy86 cee19c5128 Update it.json
fix and add
2022-12-05 16:50:16 +01:00
advplyr 67db41a525 Update:Get item cover API endpoint to allow for returning the raw cover image 2022-12-04 16:23:15 -06:00
advplyr 3ea3e55d17 Fix:Typo in library settings 2022-12-03 17:50:54 -06:00
advplyr 4959a28485 Update:Playlists cover size 2022-12-03 15:44:53 -06:00
advplyr 6d2482a98e Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-12-01 18:08:57 -06:00
advplyr 4b23b842bb Version bump 2.2.8 2022-12-01 18:08:52 -06:00
advplyr 07bebc8808 Merge pull request #1238 from Hallo951/master
Update german language
2022-12-01 18:06:04 -06:00
advplyr 027d7f7a5b Fix:Chapter editor show save button when applying lookup data #1237 2022-12-01 17:42:02 -06:00
advplyr 6baa0fa047 Fix:Multi-select library items using shift key #1236 2022-12-01 17:39:23 -06:00
advplyr 8425fac543 Update PWA workbox 2022-12-01 17:26:22 -06:00
Hallo951 7b2ac7b9e9 Update german language 2022-12-01 11:27:06 +01:00
Paul Nettleton c9ab2a242d Update MiscController.js to respond with objects
Changes:
- `getAllTags` (GET /api/tags)
2022-11-29 12:26:59 -06:00
Paul Nettleton 13532cba14 Update SearchController.js to respond with objects
Changes:
- `findCovers` (GET /api/search/covers)
- `findBooks` (GET /api/search/books)
- `findPodcasts` (GET /api/search/podcast)
2022-11-29 12:23:02 -06:00
Paul Nettleton 3fb2bd3362 Update SeriesController.js to respond with objects
Changes:
- `search` (GET /api/series/search)
2022-11-29 12:08:40 -06:00
Paul Nettleton e80c3a1c5a Update AuthorController.js to respond with objects
Changes:
- `search` (GET /api/authors/search)
2022-11-29 12:04:45 -06:00
Paul Nettleton e04d26307e Update FileSystemController.js to respond with objects
Changes:
- `getPaths` (GET /api/filesystem)
2022-11-29 11:55:22 -06:00
Paul Nettleton b8f74e1c98 Update CollectionController.js to respond with objects
Changes:
- `findAll` (GET /api/collections)
2022-11-29 11:48:21 -06:00
Paul Nettleton 0851050392 Update UserController.js to respond with objects
Changes:
- `findAll` (GET /api/users)
2022-11-29 11:43:39 -06:00
Paul Nettleton b84882d9d1 Update LibraryItemController.js to respond with objects
Changes:
- `batchGet` (POST /api/items/batch/get)
2022-11-29 11:37:45 -06:00
Paul Nettleton cd37a7618e Update LibraryController.js to respond with objects
Changes:
- `findAll` (GET /api/libraries)
- `getLibraryUserPersonalizedOptimal` (GET /api/libraries/<ID>/personalized)
- `getAuthors` (GET /api/libraries/<ID>/authors)
- `reorder` (POST /api/libraries/order)
2022-11-29 11:30:25 -06:00
176 changed files with 6455 additions and 2365 deletions
@@ -11,7 +11,6 @@ ExecReload=/bin/kill -HUP $MAINPID
Restart=always Restart=always
User=audiobookshelf User=audiobookshelf
Group=audiobookshelf Group=audiobookshelf
PermissionsStartOnly=true
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
-11
View File
@@ -54,17 +54,6 @@
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url(~static/fonts/GentiumBookBasic.woff2) format('woff2'); src: url(~static/fonts/GentiumBookBasic.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Gentium Book Basic';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(~static/fonts/GentiumBookBasic.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
} }
/* cyrillic-ext */ /* cyrillic-ext */
+36 -21
View File
@@ -1,9 +1,9 @@
<template> <template>
<div class="w-full h-16 bg-primary relative"> <div class="w-full h-16 bg-primary relative">
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-50"> <div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-60">
<div class="flex h-full items-center"> <div class="flex h-full items-center">
<nuxt-link to="/"> <nuxt-link to="/">
<img src="~static/icon.svg" class="w-8 min-w-8 h-8 mr-2 sm:w-12 sm:min-w-12 sm:h-12 sm:mr-4" /> <img src="~static/icon.svg" :alt="$strings.ButtonHome" class="w-8 min-w-8 h-8 mr-2 sm:w-12 sm:min-w-12 sm:h-12 sm:mr-4" />
</nuxt-link> </nuxt-link>
<nuxt-link to="/"> <nuxt-link to="/">
@@ -24,19 +24,25 @@
<google-cast-launcher></google-cast-launcher> <google-cast-launcher></google-cast-launcher>
</div> </div>
<nuxt-link v-if="currentLibrary" to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 hidden sm:flex items-center justify-center mx-1"> <nuxt-link v-if="currentLibrary" to="/config/stats" class="hover:text-gray-200 cursor-pointer w-8 h-8 hidden sm:flex items-center justify-center mx-1">
<span class="material-icons text-2xl" aria-label="User Stats" role="button">equalizer</span> <ui-tooltip :text="$strings.HeaderYourStats" direction="bottom" class="flex items-center">
<span class="material-icons text-2xl" aria-label="User Stats" role="button">equalizer</span>
</ui-tooltip>
</nuxt-link> </nuxt-link>
<nuxt-link v-if="userCanUpload && currentLibrary" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1"> <nuxt-link v-if="userCanUpload && currentLibrary" to="/upload" class="hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
<span class="material-icons text-2xl" aria-label="Upload Media" role="button">upload</span> <ui-tooltip :text="$strings.ButtonUpload" direction="bottom" class="flex items-center">
<span class="material-icons text-2xl" aria-label="Upload Media" role="button">upload</span>
</ui-tooltip>
</nuxt-link> </nuxt-link>
<nuxt-link v-if="userIsAdminOrUp" to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1"> <nuxt-link v-if="userIsAdminOrUp" to="/config" class="hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
<span class="material-icons text-2xl" aria-label="System Settings" role="button">settings</span> <ui-tooltip :text="$strings.HeaderSettings" direction="bottom" class="flex items-center">
<span class="material-icons text-2xl" aria-label="System Settings" role="button">settings</span>
</ui-tooltip>
</nuxt-link> </nuxt-link>
<nuxt-link to="/account" class="relative w-9 h-9 md:w-32 bg-fg border border-gray-500 rounded shadow-sm ml-1.5 sm:ml-3 md:ml-5 md:pl-3 md:pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer hover:bg-bg hover:bg-opacity-40" aria-haspopup="listbox" aria-expanded="true"> <nuxt-link to="/account" class="relative w-9 h-9 md:w-32 bg-fg border border-gray-500 rounded shadow-sm ml-1.5 sm:ml-3 md:ml-5 md:pl-3 md:pr-10 py-2 text-left sm:text-sm cursor-pointer hover:bg-bg hover:bg-opacity-40" aria-haspopup="listbox" aria-expanded="true">
<span class="items-center hidden md:flex"> <span class="items-center hidden md:flex">
<span class="block truncate">{{ username }}</span> <span class="block truncate">{{ username }}</span>
</span> </span>
@@ -52,17 +58,17 @@
<span class="material-icons text-2xl -ml-2 pr-1 text-white">play_arrow</span> <span class="material-icons text-2xl -ml-2 pr-1 text-white">play_arrow</span>
{{ $strings.ButtonPlay }} {{ $strings.ButtonPlay }}
</ui-btn> </ui-btn>
<ui-tooltip v-if="userIsAdminOrUp && !isPodcastLibrary" :text="$strings.ButtonQuickMatch" direction="bottom"> <ui-tooltip v-if="userIsAdminOrUp && isBookLibrary" :text="$strings.ButtonQuickMatch" direction="bottom">
<ui-icon-btn :disabled="processingBatch" icon="auto_awesome" @click="batchAutoMatchClick" class="mx-1.5" /> <ui-icon-btn :disabled="processingBatch" icon="auto_awesome" @click="batchAutoMatchClick" class="mx-1.5" />
</ui-tooltip> </ui-tooltip>
<ui-tooltip v-if="!isPodcastLibrary" :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom"> <ui-tooltip v-if="isBookLibrary" :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom">
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsFinished" @click="toggleBatchRead" class="mx-1.5" /> <ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsFinished" @click="toggleBatchRead" class="mx-1.5" />
</ui-tooltip> </ui-tooltip>
<ui-tooltip v-if="userCanUpdate && !isPodcastLibrary" :text="$strings.LabelAddToCollection" direction="bottom"> <ui-tooltip v-if="userCanUpdate && isBookLibrary" :text="$strings.LabelAddToCollection" direction="bottom">
<ui-icon-btn :disabled="processingBatch" icon="collections_bookmark" @click="batchAddToCollectionClick" class="mx-1.5" /> <ui-icon-btn :disabled="processingBatch" icon="collections_bookmark" @click="batchAddToCollectionClick" class="mx-1.5" />
</ui-tooltip> </ui-tooltip>
<template v-if="userCanUpdate"> <template v-if="userCanUpdate">
<ui-tooltip text="Edit" direction="bottom"> <ui-tooltip :text="$strings.LabelEdit" direction="bottom">
<ui-icon-btn :disabled="processingBatch" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" /> <ui-icon-btn :disabled="processingBatch" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
</ui-tooltip> </ui-tooltip>
</template> </template>
@@ -97,6 +103,9 @@ export default {
isPodcastLibrary() { isPodcastLibrary() {
return this.libraryMediaType === 'podcast' return this.libraryMediaType === 'podcast'
}, },
isBookLibrary() {
return this.libraryMediaType === 'book'
},
isHome() { isHome() {
return this.$route.name === 'library-library' return this.$route.name === 'library-library'
}, },
@@ -116,7 +125,7 @@ export default {
return this.$store.state.globals.selectedMediaItems return this.$store.state.globals.selectedMediaItems
}, },
selectedMediaItemsArePlayable() { selectedMediaItemsArePlayable() {
return !this.selectedMediaItems.some(i => !i.hasTracks) return !this.selectedMediaItems.some((i) => !i.hasTracks)
}, },
userMediaProgress() { userMediaProgress() {
return this.$store.state.user.user.mediaProgress || [] return this.$store.state.user.user.mediaProgress || []
@@ -158,12 +167,15 @@ export default {
this.$store.commit('setProcessingBatch', true) this.$store.commit('setProcessingBatch', true)
const libraryItemIds = this.selectedMediaItems.map((i) => i.id) const libraryItemIds = this.selectedMediaItems.map((i) => i.id)
const libraryItems = await this.$axios.$post(`/api/items/batch/get`, { libraryItemIds }).catch((error) => { const libraryItems = await this.$axios
const errorMsg = error.response.data || 'Failed to get items' .$post(`/api/items/batch/get`, { libraryItemIds })
console.error(errorMsg, error) .then((res) => res.libraryItems)
this.$toast.error(errorMsg) .catch((error) => {
return [] const errorMsg = error.response.data || 'Failed to get items'
}) console.error(errorMsg, error)
this.$toast.error(errorMsg)
return []
})
if (!libraryItems.length) { if (!libraryItems.length) {
this.$store.commit('setProcessingBatch', false) this.$store.commit('setProcessingBatch', false)
@@ -172,12 +184,15 @@ export default {
const queueItems = [] const queueItems = []
libraryItems.forEach((item) => { libraryItems.forEach((item) => {
let subtitle = ''
if (item.mediaType === 'book') subtitle = item.media.metadata.authors.map((au) => au.name).join(', ')
else if (item.mediaType === 'music') subtitle = item.media.metadata.artists.join(', ')
queueItems.push({ queueItems.push({
libraryItemId: item.id, libraryItemId: item.id,
libraryId: item.libraryId, libraryId: item.libraryId,
episodeId: null, episodeId: null,
title: item.media.metadata.title, title: item.media.metadata.title,
subtitle: item.media.metadata.authors.map((au) => au.name).join(', '), subtitle,
caption: '', caption: '',
duration: item.media.duration || null, duration: item.media.duration || null,
coverPath: item.media.coverPath || null coverPath: item.media.coverPath || null
@@ -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-30" /> <widgets-cover-size-widget class="fixed bottom-4 right-4 z-50" />
<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 font-book mb-4 py-4">{{ libraryName }} Library is empty!</p> <p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
@@ -136,7 +136,7 @@ export default {
const mediaItem = { const mediaItem = {
id: thisEntity.id, id: thisEntity.id,
mediaType: thisEntity.mediaType, mediaType: thisEntity.mediaType,
hasTracks: thisEntity.mediaType === 'podcast' || thisEntity.media.numTracks || (thisEntity.media.tracks && thisEntity.media.tracks.length) hasTracks: thisEntity.mediaType === 'podcast' || thisEntity.media.audioFile || thisEntity.media.numTracks || (thisEntity.media.tracks && thisEntity.media.tracks.length)
} }
this.$store.commit('globals/setMediaItemSelected', { item: mediaItem, selected: isSelecting }) this.$store.commit('globals/setMediaItemSelected', { item: mediaItem, selected: isSelecting })
} else { } else {
@@ -147,7 +147,7 @@ export default {
const mediaItem = { const mediaItem = {
id: entity.id, id: entity.id,
mediaType: entity.mediaType, mediaType: entity.mediaType,
hasTracks: entity.mediaType === 'podcast' || entity.media.numTracks || (entity.media.tracks && entity.media.tracks.length) hasTracks: entity.mediaType === 'podcast' || entity.media.audioFile || entity.media.numTracks || (entity.media.tracks && entity.media.tracks.length)
} }
this.$store.commit('globals/toggleMediaItemSelected', mediaItem) this.$store.commit('globals/toggleMediaItemSelected', mediaItem)
} }
@@ -167,8 +167,8 @@ export default {
this.loaded = true this.loaded = true
}, },
async fetchCategories() { async fetchCategories() {
var categories = await this.$axios const categories = await this.$axios
.$get(`/api/libraries/${this.currentLibraryId}/personalized`) .$get(`/api/libraries/${this.currentLibraryId}/personalized?include=rssfeed`)
.then((data) => { .then((data) => {
return data return data
}) })
@@ -405,8 +405,6 @@ export default {
} }
}, },
removeListeners() { removeListeners() {
this.$store.commit('user/removeSettingsListener', 'bookshelf')
if (this.$root.socket) { if (this.$root.socket) {
this.$root.socket.off('user_updated', this.userUpdated) this.$root.socket.off('user_updated', this.userUpdated)
this.$root.socket.off('author_updated', this.authorUpdated) this.$root.socket.off('author_updated', this.authorUpdated)
+132 -68
View File
@@ -16,17 +16,17 @@
<nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/latest`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastLatestPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'"> <nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/latest`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastLatestPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p class="text-sm">{{ $strings.ButtonLatest }}</p> <p class="text-sm">{{ $strings.ButtonLatest }}</p>
</nuxt-link> </nuxt-link>
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="flex-grow h-full flex justify-center items-center" :class="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'"> <nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="flex-grow h-full flex justify-center items-center" :class="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p v-if="isSeriesPage" class="text-sm">{{ $strings.ButtonSeries }}</p> <p v-if="isSeriesPage" class="text-sm">{{ $strings.ButtonSeries }}</p>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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="!isPodcastLibrary" :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>
</nuxt-link> </nuxt-link>
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/authors`" class="flex-grow h-full flex justify-center items-center" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'"> <nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/authors`" class="flex-grow h-full flex justify-center items-center" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p v-if="isAuthorsPage" class="text-sm">{{ $strings.ButtonAuthors }}</p> <p v-if="isAuthorsPage" class="text-sm">{{ $strings.ButtonAuthors }}</p>
<svg v-else class="w-5 h-5" viewBox="0 0 24 24"> <svg v-else class="w-5 h-5" viewBox="0 0 24 24">
<path <path
@@ -39,7 +39,7 @@
<p class="text-sm">{{ $strings.ButtonSearch }}</p> <p class="text-sm">{{ $strings.ButtonSearch }}</p>
</nuxt-link> </nuxt-link>
</div> </div>
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-30 flex items-center justify-end md:justify-start px-2 md:px-8"> <div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-40 flex items-center justify-end md:justify-start px-2 md:px-8">
<!-- Series books page --> <!-- Series books page -->
<template v-if="selectedSeries"> <template v-if="selectedSeries">
<p class="pl-2 font-book text-base md:text-lg"> <p class="pl-2 font-book text-base md:text-lg">
@@ -50,18 +50,13 @@
</div> </div>
<div class="flex-grow" /> <div class="flex-grow" />
<ui-checkbox v-if="!isBatchSelecting" v-model="settings.collapseBookSeries" :label="$strings.LabelCollapseSeries" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseBookSeries" /> <ui-checkbox v-if="!isBatchSelecting" v-model="settings.collapseBookSeries" :label="$strings.LabelCollapseSeries" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseBookSeries" />
<ui-btn v-if="!isBatchSelecting" color="primary" small :loading="processingSeries" class="items-center ml-1 sm:ml-4 hidden md:flex" @click="markSeriesFinished">
<div class="h-5 w-5"> <!-- RSS feed -->
<svg v-if="isSeriesFinished" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)"> <ui-tooltip v-if="seriesRssFeed" :text="$strings.LabelOpenRSSFeed" direction="top">
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" /> <ui-icon-btn icon="rss_feed" class="mx-0.5" :size="7" icon-font-size="1.2rem" bg-color="success" outlined @click="showOpenSeriesRSSFeed" />
</svg> </ui-tooltip>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-7 19.6l-7-4.66V3h14v12.93l-7 4.67zm-2.01-7.42l-2.58-2.59L6 12l4 4 8-8-1.42-1.42z" /> <ui-context-menu-dropdown v-if="!isBatchSelecting && seriesContextMenuItems.length" :items="seriesContextMenuItems" class="mx-px" @action="seriesContextMenuAction" />
</svg>
</div>
<span class="pl-2"> {{ $strings.LabelMarkSeries }} {{ isSeriesFinished ? $strings.LabelNotFinished : $strings.LabelFinished }}</span>
</ui-btn>
<ui-btn v-if="isSeriesRemovedFromContinueListening && !isBatchSelecting" small :loading="processingSeries" @click="reAddSeriesToContinueListening" class="hidden md:block ml-2"> Re-Add Series to Continue Listening </ui-btn>
</template> </template>
<!-- library & collections page --> <!-- library & collections page -->
<template v-else-if="page !== 'search' && page !== 'podcast-search' && page !== 'recent-episodes' && !isHome"> <template v-else-if="page !== 'search' && page !== 'podcast-search' && page !== 'recent-episodes' && !isHome">
@@ -69,11 +64,11 @@
<div class="flex-grow hidden sm:inline-block" /> <div class="flex-grow hidden sm:inline-block" />
<ui-checkbox v-if="isLibraryPage && !isPodcastLibrary && !isBatchSelecting" v-model="settings.collapseSeries" :label="$strings.LabelCollapseSeries" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseSeries" /> <ui-checkbox v-if="isLibraryPage && isBookLibrary && !isBatchSelecting" v-model="settings.collapseSeries" :label="$strings.LabelCollapseSeries" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseSeries" />
<controls-library-filter-select v-if="isLibraryPage && !isBatchSelecting" v-model="settings.filterBy" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateFilter" /> <controls-library-filter-select v-if="isLibraryPage && !isBatchSelecting" v-model="settings.filterBy" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateFilter" />
<controls-library-sort-select v-if="isLibraryPage && !isBatchSelecting" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateOrder" /> <controls-library-sort-select v-if="isLibraryPage && !isBatchSelecting" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateOrder" />
<controls-library-filter-select v-if="isSeriesPage && !isBatchSelecting" v-model="seriesFilterBy" is-series class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesFilter" /> <controls-library-filter-select v-if="isSeriesPage && !isBatchSelecting" v-model="settings.seriesFilterBy" is-series class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesFilter" />
<controls-sort-select v-if="isSeriesPage && !isBatchSelecting" v-model="seriesSortBy" :descending.sync="seriesSortDesc" :items="seriesSortItems" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesSort" /> <controls-sort-select v-if="isSeriesPage && !isBatchSelecting" v-model="settings.seriesSortBy" :descending.sync="settings.seriesSortDesc" :items="seriesSortItems" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesSort" />
<ui-btn v-if="isIssuesFilter && userCanDelete && !isBatchSelecting" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">{{ $strings.ButtonRemoveAll }} {{ numShowing }} {{ entityName }}</ui-btn> <ui-btn v-if="isIssuesFilter && userCanDelete && !isBatchSelecting" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">{{ $strings.ButtonRemoveAll }} {{ numShowing }} {{ entityName }}</ui-btn>
</template> </template>
@@ -118,6 +113,32 @@ export default {
} }
}, },
computed: { computed: {
seriesContextMenuItems() {
if (!this.selectedSeries) return []
const items = [
{
text: this.isSeriesFinished ? this.$strings.MessageMarkAsNotFinished : this.$strings.MessageMarkAsFinished,
action: 'mark-series-finished'
}
]
if (this.userIsAdminOrUp || this.selectedSeries.rssFeed) {
items.push({
text: this.$strings.LabelOpenRSSFeed,
action: 'open-rss-feed'
})
}
if (this.isSeriesRemovedFromContinueListening) {
items.push({
text: 'Re-Add Series to Continue Listening',
action: 're-add-to-continue-listening'
})
}
return items
},
seriesSortItems() { seriesSortItems() {
return [ return [
{ {
@@ -153,9 +174,15 @@ export default {
currentLibraryMediaType() { currentLibraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] return this.$store.getters['libraries/getCurrentLibraryMediaType']
}, },
isBookLibrary() {
return this.currentLibraryMediaType === 'book'
},
isPodcastLibrary() { isPodcastLibrary() {
return this.currentLibraryMediaType === 'podcast' return this.currentLibraryMediaType === 'podcast'
}, },
isMusicLibrary() {
return this.currentLibraryMediaType === 'music'
},
isLibraryPage() { isLibraryPage() {
return this.page === '' return this.page === ''
}, },
@@ -180,10 +207,16 @@ export default {
isAuthorsPage() { isAuthorsPage() {
return this.$route.name === 'library-library-authors' return this.$route.name === 'library-library-authors'
}, },
isAlbumsPage() {
return this.page === 'albums'
},
numShowing() { numShowing() {
return this.totalEntities return this.totalEntities
}, },
entityName() { entityName() {
if (this.isAlbumsPage) return 'Albums'
if (this.isMusicLibrary) return 'Tracks'
if (this.isPodcastLibrary) return this.$strings.LabelPodcasts if (this.isPodcastLibrary) return this.$strings.LabelPodcasts
if (!this.page) return this.$strings.LabelBooks if (!this.page) return this.$strings.LabelBooks
if (this.isSeriesPage) return this.$strings.LabelSeries if (this.isSeriesPage) return this.$strings.LabelSeries
@@ -200,6 +233,9 @@ export default {
seriesProgress() { seriesProgress() {
return this.selectedSeries ? this.selectedSeries.progress : null return this.selectedSeries ? this.selectedSeries.progress : null
}, },
seriesRssFeed() {
return this.selectedSeries ? this.selectedSeries.rssFeed : null
},
seriesLibraryItemIds() { seriesLibraryItemIds() {
if (!this.seriesProgress) return [] if (!this.seriesProgress) return []
return this.seriesProgress.libraryItemIds || [] return this.seriesProgress.libraryItemIds || []
@@ -219,33 +255,34 @@ export default {
}, },
isIssuesFilter() { isIssuesFilter() {
return this.filterBy === 'issues' && this.$route.query.filter === 'issues' return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
},
seriesSortBy: {
get() {
return this.$store.state.libraries.seriesSortBy
},
set(val) {
this.$store.commit('libraries/setSeriesSortBy', val)
}
},
seriesSortDesc: {
get() {
return this.$store.state.libraries.seriesSortDesc
},
set(val) {
this.$store.commit('libraries/setSeriesSortDesc', val)
}
},
seriesFilterBy: {
get() {
return this.$store.state.libraries.seriesFilterBy
},
set(val) {
this.$store.commit('libraries/setSeriesFilterBy', val)
}
} }
}, },
methods: { methods: {
seriesContextMenuAction(action) {
if (action === 'open-rss-feed') {
this.showOpenSeriesRSSFeed()
} else if (action === 're-add-to-continue-listening') {
if (this.processingSeries) {
console.warn('Already processing series')
return
}
this.reAddSeriesToContinueListening()
} else if (action === 'mark-series-finished') {
if (this.processingSeries) {
console.warn('Already processing series')
return
}
this.markSeriesFinished()
}
},
showOpenSeriesRSSFeed() {
this.$store.commit('globals/setRSSFeedOpenCloseModal', {
id: this.selectedSeries.id,
name: this.selectedSeries.name,
type: 'series',
feed: this.selectedSeries.rssFeed
})
},
reAddSeriesToContinueListening() { reAddSeriesToContinueListening() {
this.processingSeries = true this.processingSeries = true
this.$axios this.$axios
@@ -310,27 +347,38 @@ export default {
} }
}, },
markSeriesFinished() { markSeriesFinished() {
var newIsFinished = !this.isSeriesFinished const newIsFinished = !this.isSeriesFinished
this.processingSeries = true
var updateProgressPayloads = this.seriesLibraryItemIds.map((lid) => { const payload = {
return { message: newIsFinished ? this.$strings.MessageConfirmMarkSeriesFinished : this.$strings.MessageConfirmMarkSeriesNotFinished,
libraryItemId: lid, callback: (confirmed) => {
isFinished: newIsFinished if (confirmed) {
} this.processingSeries = true
}) const updateProgressPayloads = this.seriesLibraryItemIds.map((lid) => {
console.log('Progress payloads', updateProgressPayloads) return {
this.$axios libraryItemId: lid,
.patch(`/api/me/progress/batch/update`, updateProgressPayloads) isFinished: newIsFinished
.then(() => { }
this.$toast.success('Series update success') })
this.selectedSeries.progress.isFinished = newIsFinished console.log('Progress payloads', updateProgressPayloads)
this.processingSeries = false this.$axios
}) .patch(`/api/me/progress/batch/update`, updateProgressPayloads)
.catch((error) => { .then(() => {
this.$toast.error('Series update failed') this.$toast.success(this.$strings.ToastSeriesUpdateSuccess)
console.error('Failed to batch update read/not read', error) this.selectedSeries.progress.isFinished = newIsFinished
this.processingSeries = false })
}) .catch((error) => {
this.$toast.error(this.$strings.ToastSeriesUpdateFailed)
console.error('Failed to batch update read/not read', error)
})
.finally(() => {
this.processingSeries = false
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
}, },
updateOrder() { updateOrder() {
this.saveSettings() this.saveSettings()
@@ -339,10 +387,10 @@ export default {
this.saveSettings() this.saveSettings()
}, },
updateSeriesSort() { updateSeriesSort() {
this.$eventBus.$emit('series-sort-updated') this.saveSettings()
}, },
updateSeriesFilter() { updateSeriesFilter() {
this.$eventBus.$emit('series-sort-updated') this.saveSettings()
}, },
updateCollapseSeries() { updateCollapseSeries() {
this.saveSettings() this.saveSettings()
@@ -363,16 +411,32 @@ export default {
}, },
setBookshelfTotalEntities(totalEntities) { setBookshelfTotalEntities(totalEntities) {
this.totalEntities = totalEntities this.totalEntities = totalEntities
},
rssFeedOpen(data) {
if (data.entityId === this.seriesId) {
console.log('RSS Feed Opened', data)
this.selectedSeries.rssFeed = data
}
},
rssFeedClosed(data) {
if (data.entityId === this.seriesId) {
console.log('RSS Feed Closed', data)
this.selectedSeries.rssFeed = null
}
} }
}, },
mounted() { mounted() {
this.init() this.init()
this.$store.commit('user/addSettingsListener', { id: 'bookshelftoolbar', meth: this.settingsUpdated }) this.$eventBus.$on('user-settings', this.settingsUpdated)
this.$eventBus.$on('bookshelf-total-entities', this.setBookshelfTotalEntities) this.$eventBus.$on('bookshelf-total-entities', this.setBookshelfTotalEntities)
this.$root.socket.on('rss_feed_open', this.rssFeedOpen)
this.$root.socket.on('rss_feed_closed', this.rssFeedClosed)
}, },
beforeDestroy() { beforeDestroy() {
this.$store.commit('user/removeSettingsListener', 'bookshelftoolbar') this.$eventBus.$off('user-settings', this.settingsUpdated)
this.$eventBus.$off('bookshelf-total-entities', this.setBookshelfTotalEntities) this.$eventBus.$off('bookshelf-total-entities', this.setBookshelfTotalEntities)
this.$root.socket.off('rss_feed_open', this.rssFeedOpen)
this.$root.socket.off('rss_feed_closed', this.rssFeedClosed)
} }
} }
</script> </script>
+11 -4
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="w-44 fixed left-0 top-16 h-full bg-bg bg-opacity-100 md:bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-3 transform transition-transform" :class="wrapperClass" v-click-outside="clickOutside"> <div class="w-44 fixed left-0 top-16 h-full bg-bg bg-opacity-100 md:bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-3 transform transition-transform" :class="wrapperClass" v-click-outside="clickOutside">
<div class="md:hidden flex items-center justify-end pb-2 px-4 mb-1" @click="closeDrawer"> <div v-show="isMobilePortrait" class="flex items-center justify-end pb-2 px-4 mb-1" @click="closeDrawer">
<span class="material-icons text-2xl">arrow_back</span> <span class="material-icons text-2xl">arrow_back</span>
</div> </div>
@@ -87,6 +87,11 @@ export default {
id: 'config-notifications', id: 'config-notifications',
title: this.$strings.HeaderNotifications, title: this.$strings.HeaderNotifications,
path: '/config/notifications' path: '/config/notifications'
},
{
id: 'config-item-metadata-utils',
title: this.$strings.HeaderItemMetadataUtils,
path: '/config/item-metadata-utils'
} }
] ]
@@ -109,7 +114,7 @@ export default {
var classes = [] var classes = []
if (this.drawerOpen) classes.push('translate-x-0') if (this.drawerOpen) classes.push('translate-x-0')
else classes.push('-translate-x-44') else classes.push('-translate-x-44')
if (this.isMobile) classes.push('z-50') if (this.isMobilePortrait) classes.push('z-50')
else classes.push('z-40') else classes.push('z-40')
return classes.join(' ') return classes.join(' ')
}, },
@@ -119,9 +124,11 @@ export default {
isMobileLandscape() { isMobileLandscape() {
return this.$store.state.globals.isMobileLandscape return this.$store.state.globals.isMobileLandscape
}, },
isMobilePortrait() {
return this.$store.state.globals.isMobilePortrait
},
drawerOpen() { drawerOpen() {
if (this.isMobile) return this.isOpen return !this.isMobilePortrait || this.isOpen
return true
}, },
routeName() { routeName() {
return this.$route.name return this.$route.name
+34 -37
View File
@@ -6,7 +6,7 @@
</div> </div>
</template> </template>
<div v-if="initialized && !totalShelves && !hasFilter && entityName === 'books'" class="w-full flex flex-col items-center justify-center py-12"> <div v-if="initialized && !totalShelves && !hasFilter && entityName === 'items'" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl font-book mb-4 py-4">{{ $getString('MessageXLibraryIsEmpty', [libraryName]) }}</p> <p class="text-center text-2xl font-book mb-4 py-4">{{ $getString('MessageXLibraryIsEmpty', [libraryName]) }}</p>
<div v-if="userIsAdminOrUp" class="flex"> <div v-if="userIsAdminOrUp" class="flex">
<ui-btn to="/config" color="primary" class="w-52 mr-2">{{ $strings.ButtonConfigureScanner }}</ui-btn> <ui-btn to="/config" color="primary" class="w-52 mr-2">{{ $strings.ButtonConfigureScanner }}</ui-btn>
@@ -16,12 +16,12 @@
<div v-else-if="!totalShelves && initialized" class="w-full py-16"> <div v-else-if="!totalShelves && initialized" class="w-full py-16">
<p class="text-xl text-center">{{ emptyMessage }}</p> <p class="text-xl text-center">{{ emptyMessage }}</p>
<!-- Clear filter only available on Library bookshelf --> <!-- Clear filter only available on Library bookshelf -->
<div v-if="entityName === 'books'" class="flex justify-center mt-2"> <div v-if="entityName === 'items'" class="flex justify-center mt-2">
<ui-btn v-if="hasFilter" color="primary" @click="clearFilter">{{ $strings.ButtonClearFilter }}</ui-btn> <ui-btn v-if="hasFilter" color="primary" @click="clearFilter">{{ $strings.ButtonClearFilter }}</ui-btn>
</div> </div>
</div> </div>
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" /> <widgets-cover-size-widget class="fixed bottom-4 right-4 z-50" />
</div> </div>
</template> </template>
@@ -81,8 +81,11 @@ export default {
showExperimentalFeatures() { showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures return this.$store.state.showExperimentalFeatures
}, },
libraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
isPodcast() { isPodcast() {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast' return this.libraryMediaType === 'podcast'
}, },
emptyMessage() { emptyMessage() {
if (this.page === 'series') return this.$strings.MessageBookshelfNoSeries if (this.page === 'series') return this.$strings.MessageBookshelfNoSeries
@@ -96,17 +99,17 @@ export default {
return this.$strings.MessageNoResults return this.$strings.MessageNoResults
}, },
entityName() { entityName() {
if (!this.page) return 'books' if (!this.page) return 'items'
return this.page return this.page
}, },
seriesSortBy() { seriesSortBy() {
return this.$store.state.libraries.seriesSortBy return this.$store.getters['user/getUserSetting']('seriesSortBy')
}, },
seriesSortDesc() { seriesSortDesc() {
return this.$store.state.libraries.seriesSortDesc return this.$store.getters['user/getUserSetting']('seriesSortDesc')
}, },
seriesFilterBy() { seriesFilterBy() {
return this.$store.state.libraries.seriesFilterBy return this.$store.getters['user/getUserSetting']('seriesFilterBy')
}, },
orderBy() { orderBy() {
return this.$store.getters['user/getUserSetting']('orderBy') return this.$store.getters['user/getUserSetting']('orderBy')
@@ -158,12 +161,9 @@ export default {
libraryName() { libraryName() {
return this.$store.getters['libraries/getCurrentLibraryName'] return this.$store.getters['libraries/getCurrentLibraryName']
}, },
isEntityBook() {
return this.entityName === 'series-books' || this.entityName === 'books'
},
bookWidth() { bookWidth() {
var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize') const coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
if (this.isCoverSquareAspectRatio) return coverSize * 1.6 if (this.isCoverSquareAspectRatio || this.entityName === 'playlists') return coverSize * 1.6
return coverSize return coverSize
}, },
bookHeight() { bookHeight() {
@@ -192,7 +192,8 @@ export default {
}, },
shelfHeight() { shelfHeight() {
if (this.isAlternativeBookshelfView) { if (this.isAlternativeBookshelfView) {
var extraTitleSpace = this.isEntityBook ? 80 : 40 const isItemEntity = this.entityName === 'series-books' || this.entityName === 'items'
const extraTitleSpace = isItemEntity ? 80 : this.entityName === 'albums' ? 60 : 40
return this.entityHeight + extraTitleSpace * this.sizeMultiplier return this.entityHeight + extraTitleSpace * this.sizeMultiplier
} }
return this.entityHeight + 40 return this.entityHeight + 40
@@ -205,7 +206,7 @@ export default {
return this.$store.state.globals.selectedMediaItems || [] return this.$store.state.globals.selectedMediaItems || []
}, },
sizeMultiplier() { sizeMultiplier() {
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120 const baseSize = this.isCoverSquareAspectRatio ? 192 : 120
return this.entityWidth / baseSize return this.entityWidth / baseSize
} }
}, },
@@ -214,8 +215,8 @@ export default {
this.$store.dispatch('user/updateUserSettings', { filterBy: 'all' }) this.$store.dispatch('user/updateUserSettings', { filterBy: 'all' })
}, },
editEntity(entity) { editEntity(entity) {
if (this.entityName === 'books' || this.entityName === 'series-books') { if (this.entityName === 'items' || this.entityName === 'series-books') {
var bookIds = this.entities.map((e) => e.id) const bookIds = this.entities.map((e) => e.id)
this.$store.commit('setBookshelfBookIds', bookIds) this.$store.commit('setBookshelfBookIds', bookIds)
this.$store.commit('showEditModal', entity) this.$store.commit('showEditModal', entity)
} else if (this.entityName === 'collections') { } else if (this.entityName === 'collections') {
@@ -229,8 +230,8 @@ export default {
this.isSelectionMode = false this.isSelectionMode = false
}, },
selectEntity(entity, shiftKey) { selectEntity(entity, shiftKey) {
if (this.entityName === 'books' || this.entityName === 'series-books') { if (this.entityName === 'items' || this.entityName === 'series-books') {
var indexOf = this.entities.findIndex((ent) => ent && ent.id === entity.id) const indexOf = this.entities.findIndex((ent) => ent && ent.id === entity.id)
const lastLastItemIndexSelected = this.lastItemIndexSelected const lastLastItemIndexSelected = this.lastItemIndexSelected
if (!this.selectedMediaItems.some((i) => i.id === entity.id)) { if (!this.selectedMediaItems.some((i) => i.id === entity.id)) {
this.lastItemIndexSelected = indexOf this.lastItemIndexSelected = indexOf
@@ -239,14 +240,14 @@ export default {
} }
if (shiftKey && lastLastItemIndexSelected >= 0) { if (shiftKey && lastLastItemIndexSelected >= 0) {
var loopStart = indexOf let loopStart = indexOf
var loopEnd = lastLastItemIndexSelected let loopEnd = lastLastItemIndexSelected
if (indexOf > lastLastItemIndexSelected) { if (indexOf > lastLastItemIndexSelected) {
loopStart = lastLastItemIndexSelected loopStart = lastLastItemIndexSelected
loopEnd = indexOf loopEnd = indexOf
} }
var isSelecting = false let isSelecting = false
// If any items in this range is not selected then select all otherwise unselect all // If any items in this range is not selected then select all otherwise unselect all
for (let i = loopStart; i <= loopEnd; i++) { for (let i = loopStart; i <= loopEnd; i++) {
const thisEntity = this.entities[i] const thisEntity = this.entities[i]
@@ -273,7 +274,7 @@ export default {
const mediaItem = { const mediaItem = {
id: thisEntity.id, id: thisEntity.id,
mediaType: thisEntity.mediaType, mediaType: thisEntity.mediaType,
hasTracks: thisEntity.mediaType === 'podcast' || thisEntity.media.numTracks || (thisEntity.media.tracks && thisEntity.media.tracks.length) hasTracks: thisEntity.mediaType === 'podcast' || thisEntity.media.audioFile || thisEntity.media.numTracks || (thisEntity.media.tracks && thisEntity.media.tracks.length)
} }
this.$store.commit('globals/setMediaItemSelected', { item: mediaItem, selected: isSelecting }) this.$store.commit('globals/setMediaItemSelected', { item: mediaItem, selected: isSelecting })
} else { } else {
@@ -284,7 +285,7 @@ export default {
const mediaItem = { const mediaItem = {
id: entity.id, id: entity.id,
mediaType: entity.mediaType, mediaType: entity.mediaType,
hasTracks: entity.mediaType === 'podcast' || entity.media.numTracks || (entity.media.tracks && entity.media.tracks.length) hasTracks: entity.mediaType === 'podcast' || entity.media.audioFile || entity.media.numTracks || (entity.media.tracks && entity.media.tracks.length)
} }
this.$store.commit('globals/toggleMediaItemSelected', mediaItem) this.$store.commit('globals/toggleMediaItemSelected', mediaItem)
} }
@@ -307,7 +308,7 @@ export default {
} }
}, },
async fetchEntites(page = 0) { async fetchEntites(page = 0) {
var startIndex = page * this.booksPerFetch const startIndex = page * this.booksPerFetch
this.isFetchingEntities = true this.isFetchingEntities = true
@@ -315,9 +316,9 @@ export default {
this.currentSFQueryString = this.buildSearchParams() this.currentSFQueryString = this.buildSearchParams()
} }
const entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? 'items' : this.entityName const entityPath = this.entityName === 'series-books' ? 'items' : this.entityName
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : '' const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1` const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed`
const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => { const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
console.error('failed to fetch books', error) console.error('failed to fetch books', error)
@@ -339,7 +340,7 @@ export default {
} }
for (let i = 0; i < payload.results.length; i++) { for (let i = 0; i < payload.results.length; i++) {
var index = i + startIndex const index = i + startIndex
this.entities[index] = payload.results[i] this.entities[index] = payload.results[i]
if (this.entityComponentRefs[index]) { if (this.entityComponentRefs[index]) {
this.entityComponentRefs[index].setEntity(this.entities[index]) this.entityComponentRefs[index].setEntity(this.entities[index])
@@ -497,7 +498,7 @@ export default {
} }
}, },
settingsUpdated(settings) { settingsUpdated(settings) {
var wasUpdated = this.checkUpdateSearchParams() const wasUpdated = this.checkUpdateSearchParams()
if (wasUpdated) { if (wasUpdated) {
this.resetEntities() this.resetEntities()
} else if (settings.bookshelfCoverSize !== this.currentBookWidth) { } else if (settings.bookshelfCoverSize !== this.currentBookWidth) {
@@ -516,7 +517,7 @@ export default {
}, },
libraryItemUpdated(libraryItem) { libraryItemUpdated(libraryItem) {
console.log('Item updated', libraryItem) console.log('Item updated', libraryItem)
if (this.entityName === 'books' || this.entityName === 'series-books') { if (this.entityName === 'items' || this.entityName === 'series-books') {
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id) var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
if (indexOf >= 0) { if (indexOf >= 0) {
this.entities[indexOf] = libraryItem this.entities[indexOf] = libraryItem
@@ -527,7 +528,7 @@ export default {
} }
}, },
libraryItemRemoved(libraryItem) { libraryItemRemoved(libraryItem) {
if (this.entityName === 'books' || this.entityName === 'series-books') { if (this.entityName === 'items' || this.entityName === 'series-books') {
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id) var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
if (indexOf >= 0) { if (indexOf >= 0) {
this.entities = this.entities.filter((ent) => ent.id !== libraryItem.id) this.entities = this.entities.filter((ent) => ent.id !== libraryItem.id)
@@ -666,11 +667,9 @@ export default {
} }
}) })
this.$eventBus.$on('series-sort-updated', this.seriesSortUpdated)
this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities) this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities)
this.$eventBus.$on('socket_init', this.socketInit) this.$eventBus.$on('socket_init', this.socketInit)
this.$eventBus.$on('user-settings', this.settingsUpdated)
this.$store.commit('user/addSettingsListener', { id: 'lazy-bookshelf', meth: this.settingsUpdated })
if (this.$root.socket) { if (this.$root.socket) {
this.$root.socket.on('item_updated', this.libraryItemUpdated) this.$root.socket.on('item_updated', this.libraryItemUpdated)
@@ -695,11 +694,9 @@ export default {
bookshelf.removeEventListener('scroll', this.scroll) bookshelf.removeEventListener('scroll', this.scroll)
} }
this.$eventBus.$off('series-sort-updated', this.seriesSortUpdated)
this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities) this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities)
this.$eventBus.$off('socket_init', this.socketInit) this.$eventBus.$off('socket_init', this.socketInit)
this.$eventBus.$off('user-settings', this.settingsUpdated)
this.$store.commit('user/removeSettingsListener', 'lazy-bookshelf')
if (this.$root.socket) { if (this.$root.socket) {
this.$root.socket.off('item_updated', this.libraryItemUpdated) this.$root.socket.off('item_updated', this.libraryItemUpdated)
+1 -1
View File
@@ -4,7 +4,7 @@
<h1 class="text-xl">{{ headerText }}</h1> <h1 class="text-xl">{{ headerText }}</h1>
<div v-if="showAddButton" class="mx-2 w-7 h-7 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center" @click="clicked"> <div v-if="showAddButton" class="mx-2 w-7 h-7 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center" @click="clicked">
<span class="material-icons" style="font-size: 1.4rem">add</span> <button class="material-icons" :aria-label="$strings.ButtonAdd + ': ' + headerText" style="font-size: 1.4rem">add</button>
</div> </div>
</div> </div>
+21 -5
View File
@@ -1,6 +1,5 @@
<template> <template>
<!-- <div class="w-20 bg-bg h-full relative box-shadow-side z-40" style="min-width: 80px"> --> <div class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-50" style="min-width: 80px" :style="{ top: offsetTop + 'px' }">
<div class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-40" style="min-width: 80px" :style="{ top: offsetTop + 'px' }">
<!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar --> <!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar -->
<div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" /> <div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
@@ -32,7 +31,7 @@
<div v-show="showLibrary" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> <div v-show="showLibrary" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link> </nuxt-link>
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> <nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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>
@@ -42,7 +41,7 @@
<div v-show="isSeriesPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> <div v-show="isSeriesPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link> </nuxt-link>
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> <nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons-outlined text-2xl">collections_bookmark</span> <span class="material-icons-outlined text-2xl">collections_bookmark</span>
<p class="font-book pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonCollections }}</p> <p class="font-book pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonCollections }}</p>
@@ -50,7 +49,7 @@
<div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> <div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link> </nuxt-link>
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/authors`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> <nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/authors`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg class="w-6 h-6" viewBox="0 0 24 24"> <svg class="w-6 h-6" viewBox="0 0 24 24">
<path <path
fill="currentColor" fill="currentColor"
@@ -71,6 +70,14 @@
<div v-show="isPodcastSearchPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> <div v-show="isPodcastSearchPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link> </nuxt-link>
<nuxt-link v-if="isMusicLibrary" :to="`/library/${currentLibraryId}/bookshelf/albums`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isMusicAlbumsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons-outlined text-xl">album</span>
<p class="font-book pt-1.5 text-center leading-4" style="font-size: 0.9rem">Albums</p>
<div v-show="isMusicAlbumsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> <nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons text-2.5xl">queue_music</span> <span class="material-icons text-2.5xl">queue_music</span>
@@ -133,15 +140,24 @@ export default {
currentLibraryMediaType() { currentLibraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] return this.$store.getters['libraries/getCurrentLibraryMediaType']
}, },
isBookLibrary() {
return this.currentLibraryMediaType === 'book'
},
isPodcastLibrary() { isPodcastLibrary() {
return this.currentLibraryMediaType === 'podcast' return this.currentLibraryMediaType === 'podcast'
}, },
isMusicLibrary() {
return this.currentLibraryMediaType === 'music'
},
isPodcastSearchPage() { isPodcastSearchPage() {
return this.$route.name === 'library-library-podcast-search' return this.$route.name === 'library-library-podcast-search'
}, },
isPodcastLatestPage() { isPodcastLatestPage() {
return this.$route.name === 'library-library-podcast-latest' return this.$route.name === 'library-library-podcast-latest'
}, },
isMusicAlbumsPage() {
return this.paramId === 'albums'
},
homePage() { homePage() {
return this.$route.name === 'library-library' return this.$route.name === 'library-library'
}, },
+23 -11
View File
@@ -1,10 +1,10 @@
<template> <template>
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-40 bg-primary px-4 pb-1 md:pb-4 pt-2"> <div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-50 bg-primary px-2 md:px-4 pb-1 md:pb-4 pt-2">
<div id="videoDock" /> <div id="videoDock" />
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-1 sm:left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }"> <nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-2 top-2 md:left-4 cursor-pointer">
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" /> <covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
</nuxt-link> </nuxt-link>
<div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : 'pl-20 sm:pl-24'"> <div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'">
<div> <div>
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg"> <nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg">
{{ title }} {{ title }}
@@ -12,6 +12,7 @@
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center"> <div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center">
<span class="material-icons text-sm">person</span> <span class="material-icons text-sm">person</span>
<p v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</p> <p v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</p>
<p v-else-if="musicArtists" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ musicArtists }}</p>
<p v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base"> <p v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base">
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link> <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
</p> </p>
@@ -85,12 +86,15 @@ export default {
coverAspectRatio() { coverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
}, },
bookCoverWidth() { isSquareCover() {
return 88 return this.coverAspectRatio === 1
}, },
bookCoverPosTop() { isMobile() {
if (this.coverAspectRatio == 1) return -10 return this.$store.state.globals.isMobile
return -64 },
bookCoverWidth() {
if (this.isMobile) return 64 / this.coverAspectRatio
return 77 / this.coverAspectRatio
}, },
cover() { cover() {
if (this.media.coverPath) return this.media.coverPath if (this.media.coverPath) return this.media.coverPath
@@ -122,6 +126,9 @@ export default {
isPodcast() { isPodcast() {
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'podcast' : false return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'podcast' : false
}, },
isMusic() {
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'music' : false
},
mediaMetadata() { mediaMetadata() {
return this.media.metadata || {} return this.media.metadata || {}
}, },
@@ -145,6 +152,10 @@ export default {
if (!this.isPodcast) return null if (!this.isPodcast) return null
return this.mediaMetadata.author || 'Unknown' return this.mediaMetadata.author || 'Unknown'
}, },
musicArtists() {
if (!this.isMusic) return null
return this.mediaMetadata.artists.join(', ')
},
playerQueueItems() { playerQueueItems() {
return this.$store.state.playerQueueItems || [] return this.$store.state.playerQueueItems || []
} }
@@ -405,8 +416,8 @@ export default {
} }
}, },
async playLibraryItem(payload) { async playLibraryItem(payload) {
var libraryItemId = payload.libraryItemId const libraryItemId = payload.libraryItemId
var episodeId = payload.episodeId || null const episodeId = payload.episodeId || null
if (this.playerHandler.libraryItemId == libraryItemId && this.playerHandler.episodeId == episodeId) { if (this.playerHandler.libraryItemId == libraryItemId && this.playerHandler.episodeId == episodeId) {
if (payload.startTime !== null && !isNaN(payload.startTime)) { if (payload.startTime !== null && !isNaN(payload.startTime)) {
@@ -417,11 +428,12 @@ export default {
return return
} }
var libraryItem = await this.$axios.$get(`/api/items/${libraryItemId}?expanded=1`).catch((error) => { const libraryItem = await this.$axios.$get(`/api/items/${libraryItemId}?expanded=1`).catch((error) => {
console.error('Failed to fetch full item', error) console.error('Failed to fetch full item', error)
return null return null
}) })
if (!libraryItem) return if (!libraryItem) return
this.$store.commit('setMediaPlaying', { this.$store.commit('setMediaPlaying', {
libraryItem, libraryItem,
episodeId, episodeId,
+6 -2
View File
@@ -13,10 +13,14 @@
<!-- Search icon btn --> <!-- Search icon btn -->
<div v-show="!searching && isHovering && userCanUpdate" class="absolute top-0 left-0 p-2 cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="searchAuthor"> <div v-show="!searching && isHovering && userCanUpdate" class="absolute top-0 left-0 p-2 cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="searchAuthor">
<span class="material-icons text-lg">search</span> <ui-tooltip :text="$strings.ButtonQuickMatch" direction="bottom">
<span class="material-icons text-lg">search</span>
</ui-tooltip>
</div> </div>
<div v-show="isHovering && !searching && userCanUpdate" class="absolute top-0 right-0 p-2 cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="$emit('edit', author)"> <div v-show="isHovering && !searching && userCanUpdate" class="absolute top-0 right-0 p-2 cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="$emit('edit', author)">
<span class="material-icons text-lg">edit</span> <ui-tooltip :text="$strings.LabelEdit" direction="bottom">
<span class="material-icons text-lg">edit</span>
</ui-tooltip>
</div> </div>
<!-- Loading spinner --> <!-- Loading spinner -->
+2 -1
View File
@@ -10,7 +10,7 @@
<p v-if="matchKey !== 'authors'" class="text-xs text-gray-200 truncate">by {{ authorName }}</p> <p v-if="matchKey !== 'authors'" class="text-xs text-gray-200 truncate">by {{ authorName }}</p>
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" /> <p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" /> <div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin' || matchKey === 'episode'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
</div> </div>
</div> </div>
</template> </template>
@@ -67,6 +67,7 @@ export default {
// but with removing commas periods etc this is no longer plausible // but with removing commas periods etc this is no longer plausible
const html = this.matchText const html = this.matchText
if (this.matchKey === 'episode') return `<p class="truncate">Episode: ${html}</p>`
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>` if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
if (this.matchKey === 'authors') return `by ${html}` if (this.matchKey === 'authors') return `by ${html}`
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>` if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
+114
View File
@@ -0,0 +1,114 @@
<template>
<div ref="card" :id="`album-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
<covers-preview-cover ref="cover" :src="coverSrc" :width="width" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div>
</div>
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ artist || '&nbsp;' }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
index: Number,
width: Number,
height: Number,
bookCoverAspectRatio: Number,
bookshelfView: {
type: Number,
default: 0
},
albumMount: {
type: Object,
default: () => null
}
},
data() {
return {
album: null,
isSelectionMode: false,
selected: false,
isHovering: false
}
},
computed: {
coverSrc() {
const config = this.$config || this.$nuxt.$config
if (!this.album || !this.album.libraryItemId) return `${config.routerBasePath}/book_placeholder.jpg`
return this.store.getters['globals/getLibraryItemCoverSrcById'](this.album.libraryItemId)
},
labelFontSize() {
if (this.width < 160) return 0.75
return 0.875
},
sizeMultiplier() {
const baseSize = this.bookCoverAspectRatio === 1 ? 192 : 120
return this.width / baseSize
},
title() {
return this.album ? this.album.title : ''
},
artist() {
return this.album ? this.album.artist : ''
},
store() {
return this.$store || this.$nuxt.$store
},
currentLibraryId() {
return this.store.state.libraries.currentLibraryId
},
isAlternativeBookshelfView() {
const constants = this.$constants || this.$nuxt.$constants
return this.bookshelfView == constants.BookshelfView.DETAIL
}
},
methods: {
setEntity(album) {
this.album = album
},
setSelectionMode(val) {
this.isSelectionMode = val
},
mouseover() {
this.isHovering = true
},
mouseleave() {
this.isHovering = false
},
clickCard() {
if (!this.album) return
// const router = this.$router || this.$nuxt.$router
// router.push(`/album/${this.$encode(this.title)}`)
},
clickEdit() {
this.$emit('edit', this.album)
},
destroy() {
// destroy the vue listeners, etc
this.$destroy()
// remove the element from the DOM
if (this.$el && this.$el.parentNode) {
this.$el.parentNode.removeChild(this.$el)
} else if (this.$el && this.$el.remove) {
this.$el.remove()
}
}
},
mounted() {
if (this.albumMount) {
this.setEntity(this.albumMount)
}
}
}
</script>
+20 -9
View File
@@ -70,7 +70,7 @@
</div> </div>
<!-- More Menu Icon --> <!-- More Menu Icon -->
<div ref="moreIcon" v-show="!isSelectionMode" class="hidden md:block absolute cursor-pointer hover:text-yellow-300 300 hover:scale-125 transform duration-150" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore"> <div ref="moreIcon" v-show="!isSelectionMode && moreMenuItems.length" class="hidden md:block absolute cursor-pointer hover:text-yellow-300 300 hover:scale-125 transform duration-150" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
<span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span> <span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
</div> </div>
</div> </div>
@@ -190,6 +190,9 @@ export default {
isPodcast() { isPodcast() {
return this.mediaType === 'podcast' return this.mediaType === 'podcast'
}, },
isMusic() {
return this.mediaType === 'music'
},
placeholderUrl() { placeholderUrl() {
const config = this.$config || this.$nuxt.$config const config = this.$config || this.$nuxt.$config
return `${config.routerBasePath}/book_placeholder.jpg` return `${config.routerBasePath}/book_placeholder.jpg`
@@ -257,7 +260,7 @@ export default {
return this.bookCoverAspectRatio === 1 return this.bookCoverAspectRatio === 1
}, },
sizeMultiplier() { sizeMultiplier() {
var baseSize = this.squareAspectRatio ? 192 : 120 const baseSize = this.squareAspectRatio ? 192 : 120
return this.width / baseSize return this.width / baseSize
}, },
title() { title() {
@@ -273,6 +276,10 @@ export default {
authorLF() { authorLF() {
return this.mediaMetadata.authorNameLF return this.mediaMetadata.authorNameLF
}, },
artist() {
const artists = this.mediaMetadata.artists || []
return artists.join(', ')
},
displayTitle() { displayTitle() {
if (this.recentEpisode) return this.recentEpisode.title if (this.recentEpisode) return this.recentEpisode.title
const ignorePrefix = this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix const ignorePrefix = this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix
@@ -282,6 +289,7 @@ export default {
displayLineTwo() { displayLineTwo() {
if (this.recentEpisode) return this.title if (this.recentEpisode) return this.title
if (this.isPodcast) return this.author if (this.isPodcast) return this.author
if (this.isMusic) return this.artist
if (this.collapsedSeries) return '' if (this.collapsedSeries) return ''
if (this.isAuthorBookshelfView) { if (this.isAuthorBookshelfView) {
return this.mediaMetadata.publishedYear || '' return this.mediaMetadata.publishedYear || ''
@@ -305,6 +313,7 @@ export default {
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.recentEpisode.id) return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.recentEpisode.id)
}, },
userProgress() { userProgress() {
if (this.isMusic) return null
if (this.episodeProgress) return this.episodeProgress if (this.episodeProgress) return this.episodeProgress
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId) return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
}, },
@@ -341,7 +350,7 @@ export default {
return !this.isSelectionMode && !this.showPlayButton && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader) return !this.isSelectionMode && !this.showPlayButton && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader)
}, },
showPlayButton() { showPlayButton() {
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode) return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode || this.isMusic)
}, },
showSmallEBookIcon() { showSmallEBookIcon() {
return !this.isSelectionMode && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader) return !this.isSelectionMode && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader)
@@ -366,7 +375,7 @@ export default {
if (this.isPodcast) return 'Podcast has no episodes' if (this.isPodcast) return 'Podcast has no episodes'
return 'Item has no audio tracks & ebook' return 'Item has no audio tracks & ebook'
} }
var txt = '' let txt = ''
if (this.numMissingParts) { if (this.numMissingParts) {
txt += `${this.numMissingParts} missing parts.` txt += `${this.numMissingParts} missing parts.`
} }
@@ -377,7 +386,7 @@ export default {
return txt || 'Unknown Error' return txt || 'Unknown Error'
}, },
overlayWrapperClasslist() { overlayWrapperClasslist() {
var classes = [] const classes = []
if (this.isSelectionMode) classes.push('bg-opacity-60') if (this.isSelectionMode) classes.push('bg-opacity-60')
else classes.push('bg-opacity-40') else classes.push('bg-opacity-40')
if (this.selected) { if (this.selected) {
@@ -401,6 +410,8 @@ export default {
return this.store.getters['user/getIsAdminOrUp'] return this.store.getters['user/getIsAdminOrUp']
}, },
moreMenuItems() { moreMenuItems() {
if (this.isMusic) return []
if (this.recentEpisode) { if (this.recentEpisode) {
const items = [ const items = [
{ {
@@ -438,7 +449,7 @@ export default {
return items return items
} }
var items = [] let items = []
if (!this.isPodcast) { if (!this.isPodcast) {
items = [ items = [
{ {
@@ -534,11 +545,11 @@ export default {
return this.author return this.author
}, },
isAlternativeBookshelfView() { isAlternativeBookshelfView() {
var constants = this.$constants || this.$nuxt.$constants const constants = this.$constants || this.$nuxt.$constants
return this.bookshelfView === constants.BookshelfView.DETAIL return this.bookshelfView === constants.BookshelfView.DETAIL
}, },
isAuthorBookshelfView() { isAuthorBookshelfView() {
var constants = this.$constants || this.$nuxt.$constants const constants = this.$constants || this.$nuxt.$constants
return this.bookshelfView === constants.BookshelfView.AUTHOR return this.bookshelfView === constants.BookshelfView.AUTHOR
}, },
titleDisplayBottomOffset() { titleDisplayBottomOffset() {
@@ -548,7 +559,7 @@ export default {
}, },
rssFeed() { rssFeed() {
if (this.booksInSeries) return null if (this.booksInSeries) return null
return this.store.getters['feeds/getFeedForItem'](this.libraryItemId) return this._libraryItem.rssFeed || null
} }
}, },
methods: { methods: {
@@ -9,7 +9,10 @@
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span> <span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
</div> </div>
</div> </div>
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
<span v-if="!isHovering && rssFeed" class="absolute z-10 material-icons text-success" :style="{ top: 0.5 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem', fontSize: 1.5 * sizeMultiplier + 'rem' }">rss_feed</span>
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }"> <div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p> <p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div> </div>
@@ -72,6 +75,9 @@ export default {
}, },
userCanUpdate() { userCanUpdate() {
return this.store.getters['user/getUserCanUpdate'] return this.store.getters['user/getUserCanUpdate']
},
rssFeed() {
return this.collection ? this.collection.rssFeed : null
} }
}, },
methods: { methods: {
+1 -1
View File
@@ -9,7 +9,7 @@
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span> <span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
</div> </div>
</div> </div>
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }"> <div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }"> <div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p> <p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div> </div>
+7 -2
View File
@@ -1,5 +1,5 @@
<template> <template>
<div ref="card" :id="`series-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="rounded-sm z-10 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard"> <div ref="card" :id="`series-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" /> <div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
<div class="w-full h-full bg-primary relative rounded overflow-hidden z-0"> <div class="w-full h-full bg-primary relative rounded overflow-hidden z-0">
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="displayTitle" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="displayTitle" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
@@ -13,7 +13,9 @@
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p> <p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
</div> </div>
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }"> <span v-if="!isHovering && rssFeed" class="absolute z-10 material-icons text-success" :style="{ top: 0.5 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem', fontSize: 1.5 * sizeMultiplier + 'rem' }">rss_feed</span>
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-10 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }"> <div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ displayTitle }}</p> <p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ displayTitle }}</p>
</div> </div>
@@ -125,6 +127,9 @@ export default {
isAlternativeBookshelfView() { isAlternativeBookshelfView() {
const constants = this.$constants || this.$nuxt.$constants const constants = this.$constants || this.$nuxt.$constants
return this.bookshelfView == constants.BookshelfView.DETAIL return this.bookshelfView == constants.BookshelfView.DETAIL
},
rssFeed() {
return this.series ? this.series.rssFeed : null
} }
}, },
methods: { methods: {
@@ -87,8 +87,14 @@ export default {
this.$emit('input', val) this.$emit('input', val)
} }
}, },
libraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
isPodcast() { isPodcast() {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast' return this.libraryMediaType === 'podcast'
},
isMusic() {
return this.libraryMediaType === 'music'
}, },
seriesItems() { seriesItems() {
return [ return [
@@ -214,9 +220,33 @@ export default {
} }
] ]
}, },
musicItems() {
return [
{
text: this.$strings.LabelAll,
value: 'all'
},
{
text: this.$strings.LabelGenre,
value: 'genres',
sublist: true
},
{
text: this.$strings.LabelTag,
value: 'tags',
sublist: true
},
{
text: this.$strings.ButtonIssues,
value: 'issues',
sublist: false
}
]
},
selectItems() { selectItems() {
if (this.isSeries) return this.seriesItems if (this.isSeries) return this.seriesItems
if (this.isPodcast) return this.podcastItems if (this.isPodcast) return this.podcastItems
if (this.isMusic) return this.musicItems
return this.bookItems return this.bookItems
}, },
selectedItemSublist() { selectedItemSublist() {
@@ -348,6 +378,10 @@ export default {
{ {
id: 'language', id: 'language',
name: this.$strings.LabelLanguage name: this.$strings.LabelLanguage
},
{
id: 'cover',
name: this.$strings.LabelCover
} }
] ]
}, },
@@ -50,8 +50,14 @@ export default {
this.$emit('update:descending', val) this.$emit('update:descending', val)
} }
}, },
libraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
isPodcast() { isPodcast() {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast' return this.libraryMediaType === 'podcast'
},
isMusic() {
return this.libraryMediaType === 'music'
}, },
podcastItems() { podcastItems() {
return [ return [
@@ -134,10 +140,40 @@ export default {
} }
] ]
}, },
musicItems() {
return [
{
text: this.$strings.LabelTitle,
value: 'media.metadata.title'
},
{
text: this.$strings.LabelAddedAt,
value: 'addedAt'
},
{
text: this.$strings.LabelSize,
value: 'size'
},
{
text: this.$strings.LabelDuration,
value: 'media.duration'
},
{
text: this.$strings.LabelFileBirthtime,
value: 'birthtimeMs'
},
{
text: this.$strings.LabelFileModified,
value: 'mtimeMs'
}
]
},
selectItems() { selectItems() {
let items = null let items = null
if (this.isPodcast) { if (this.isPodcast) {
items = this.podcastItems items = this.podcastItems
} else if (this.isMusic) {
items = this.musicItems
} else if (this.$store.getters['user/getUserSetting']('filterBy').startsWith('series.')) { } else if (this.$store.getters['user/getUserSetting']('filterBy').startsWith('series.')) {
items = this.seriesItems items = this.seriesItems
} else { } else {
@@ -40,7 +40,7 @@ export default {
showMenu: false, showMenu: false,
currentPlaybackRate: 0, currentPlaybackRate: 0,
MIN_SPEED: 0.5, MIN_SPEED: 0.5,
MAX_SPEED: 3, MAX_SPEED: 10,
menuLeft: -92, menuLeft: -92,
arrowLeft: 0 arrowLeft: 0
} }
+2 -1
View File
@@ -65,7 +65,8 @@ export default {
return `${this.naturalWidth}x${this.naturalHeight}px` return `${this.naturalWidth}x${this.naturalHeight}px`
}, },
placeholderUrl() { placeholderUrl() {
return `${this.$config.routerBasePath}/book_placeholder.jpg` const config = this.$config || this.$nuxt.$config
return `${config.routerBasePath}/book_placeholder.jpg`
} }
}, },
methods: { methods: {
+16 -16
View File
@@ -22,8 +22,8 @@
</div> </div>
<div class="flex-grow" /> <div class="flex-grow" />
<div class="flex items-center pt-4 px-2"> <div class="flex items-center pt-4 px-2">
<p class="px-3 font-semibold" :class="isEditingRoot ? 'text-gray-300' : ''">{{ $strings.LabelEnable }}</p> <p class="px-3 font-semibold" id="user-enabled-toggle" :class="isEditingRoot ? 'text-gray-300' : ''">{{ $strings.LabelEnable }}</p>
<ui-toggle-switch v-model="newUser.isActive" :disabled="isEditingRoot" /> <ui-toggle-switch labeledBy="user-enabled-toggle" v-model="newUser.isActive" :disabled="isEditingRoot" />
</div> </div>
</div> </div>
@@ -31,55 +31,55 @@
<p class="text-lg mb-2 font-semibold">{{ $strings.HeaderPermissions }}</p> <p class="text-lg mb-2 font-semibold">{{ $strings.HeaderPermissions }}</p>
<div class="flex items-center my-2 max-w-md"> <div class="flex items-center my-2 max-w-md">
<div class="w-1/2"> <div class="w-1/2">
<p>{{ $strings.LabelPermissionsDownload }}</p> <p id="download-permissions-toggle">{{ $strings.LabelPermissionsDownload }}</p>
</div> </div>
<div class="w-1/2"> <div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.download" /> <ui-toggle-switch labeledBy="download-permissions-toggle" v-model="newUser.permissions.download" />
</div> </div>
</div> </div>
<div class="flex items-center my-2 max-w-md"> <div class="flex items-center my-2 max-w-md">
<div class="w-1/2"> <div class="w-1/2">
<p>{{ $strings.LabelPermissionsUpdate }}</p> <p id="update-permissions-toggle">{{ $strings.LabelPermissionsUpdate }}</p>
</div> </div>
<div class="w-1/2"> <div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.update" /> <ui-toggle-switch labeledBy="update-permissions-toggle" v-model="newUser.permissions.update" />
</div> </div>
</div> </div>
<div class="flex items-center my-2 max-w-md"> <div class="flex items-center my-2 max-w-md">
<div class="w-1/2"> <div class="w-1/2">
<p>{{ $strings.LabelPermissionsDelete }}</p> <p id="delete-permissions-toggle">{{ $strings.LabelPermissionsDelete }}</p>
</div> </div>
<div class="w-1/2"> <div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.delete" /> <ui-toggle-switch labeledBy="delete-permissions-toggle" v-model="newUser.permissions.delete" />
</div> </div>
</div> </div>
<div class="flex items-center my-2 max-w-md"> <div class="flex items-center my-2 max-w-md">
<div class="w-1/2"> <div class="w-1/2">
<p>{{ $strings.LabelPermissionsUpload }}</p> <p id="upload-permissions-toggle">{{ $strings.LabelPermissionsUpload }}</p>
</div> </div>
<div class="w-1/2"> <div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.upload" /> <ui-toggle-switch labeledBy="upload-permissions-toggle" v-model="newUser.permissions.upload" />
</div> </div>
</div> </div>
<div class="flex items-center my-2 max-w-md"> <div class="flex items-center my-2 max-w-md">
<div class="w-1/2"> <div class="w-1/2">
<p>{{ $strings.LabelPermissionsAccessExplicitContent }}</p> <p id="explicit-content-permissions-toggle">{{ $strings.LabelPermissionsAccessExplicitContent }}</p>
</div> </div>
<div class="w-1/2"> <div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.accessExplicitContent" /> <ui-toggle-switch labeledBy="explicit-content-permissions-toggle" v-model="newUser.permissions.accessExplicitContent" />
</div> </div>
</div> </div>
<div class="flex items-center my-2 max-w-md"> <div class="flex items-center my-2 max-w-md">
<div class="w-1/2"> <div class="w-1/2">
<p>{{ $strings.LabelPermissionsAccessAllLibraries }}</p> <p id="access-all-libs--permissions-toggle">{{ $strings.LabelPermissionsAccessAllLibraries }}</p>
</div> </div>
<div class="w-1/2"> <div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.accessAllLibraries" @input="accessAllLibrariesToggled" /> <ui-toggle-switch labeledBy="access-all-libs--permissions-toggle" v-model="newUser.permissions.accessAllLibraries" @input="accessAllLibrariesToggled" />
</div> </div>
</div> </div>
@@ -201,8 +201,8 @@ export default {
this.loadingTags = true this.loadingTags = true
this.$axios this.$axios
.$get(`/api/tags`) .$get(`/api/tags`)
.then((tags) => { .then((res) => {
this.tags = tags this.tags = res.tags
this.loadingTags = false this.loadingTags = false
}) })
.catch((error) => { .catch((error) => {
@@ -1,5 +1,5 @@
<template> <template>
<div ref="wrapper" class="hidden absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 rounded-lg items-center justify-center" style="z-index: 51" @click="clickClose"> <div ref="wrapper" class="hidden absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 rounded-lg items-center justify-center" style="z-index: 61" @click="clickClose">
<div class="absolute top-3 right-3 md:top-5 md:right-5 h-8 w-8 md:h-12 md:w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300"> <div class="absolute top-3 right-3 md:top-5 md:right-5 h-8 w-8 md:h-12 md:w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300">
<span class="material-icons text-2xl md:text-4xl">close</span> <span class="material-icons text-2xl md:text-4xl">close</span>
</div> </div>
@@ -15,7 +15,7 @@
</div> </div>
</div> </div>
<div class="flex justify-end mt-2 p-1"> <div class="flex justify-end mt-2 p-1">
<ui-btn type="submit">{{ $strings.ButtonSave }}</ui-btn> <ui-btn type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
</div> </div>
</div> </div>
</form> </form>
+1 -1
View File
@@ -39,7 +39,7 @@ export default {
}, },
zIndex: { zIndex: {
type: Number, type: Number,
default: 50 default: 60
}, },
bgOpacity: { bgOpacity: {
type: Number, type: Number,
@@ -35,7 +35,7 @@
<div class="flex pt-2 px-2"> <div class="flex pt-2 px-2">
<ui-btn type="button" @click="searchAuthor">{{ $strings.ButtonQuickMatch }}</ui-btn> <ui-btn type="button" @click="searchAuthor">{{ $strings.ButtonQuickMatch }}</ui-btn>
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn type="submit">{{ $strings.ButtonSubmit }}</ui-btn> <ui-btn type="submit">{{ $strings.ButtonSave }}</ui-btn>
</div> </div>
</div> </div>
</div> </div>
@@ -109,7 +109,8 @@ export default {
this.processing = true this.processing = true
var result = await this.$axios.$patch(`/api/authors/${this.authorId}`, updatePayload).catch((error) => { var result = await this.$axios.$patch(`/api/authors/${this.authorId}`, updatePayload).catch((error) => {
console.error('Failed', error) console.error('Failed', error)
this.$toast.error(this.$strings.ToastAuthorUpdateFailed) const errorMsg = error.response ? error.response.data : null
this.$toast.error(errorMsg || this.$strings.ToastAuthorUpdateFailed)
return null return null
}) })
if (result) { if (result) {
@@ -125,8 +126,7 @@ export default {
}, },
async removeCover() { async removeCover() {
var updatePayload = { var updatePayload = {
imagePath: null, imagePath: null
relImagePath: null
} }
this.processing = true this.processing = true
var result = await this.$axios.$patch(`/api/authors/${this.authorId}`, updatePayload).catch((error) => { var result = await this.$axios.$patch(`/api/authors/${this.authorId}`, updatePayload).catch((error) => {
@@ -161,8 +161,7 @@ export default {
if (response.author.imagePath) { if (response.author.imagePath) {
this.$toast.success(this.$strings.ToastAuthorUpdateSuccess) this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)
this.$store.commit('globals/showEditAuthorModal', response.author) this.$store.commit('globals/showEditAuthorModal', response.author)
} } else this.$toast.success(this.$strings.ToastAuthorUpdateSuccessNoImageFound)
else this.$toast.success(this.$strings.ToastAuthorUpdateSuccessNoImageFound)
} else { } else {
this.$toast.info('No updates were made for Author') this.$toast.info('No updates were made for Author')
} }
+8 -5
View File
@@ -303,11 +303,14 @@ export default {
this.persistProvider() this.persistProvider()
this.isProcessing = true this.isProcessing = true
var searchQuery = this.getSearchQuery() const searchQuery = this.getSearchQuery()
var results = await this.$axios.$get(`/api/search/covers?${searchQuery}`).catch((error) => { const results = await this.$axios
console.error('Failed', error) .$get(`/api/search/covers?${searchQuery}`)
return [] .then((res) => res.results)
}) .catch((error) => {
console.error('Failed', error)
return []
})
this.coversFound = results this.coversFound = results
this.isProcessing = false this.isProcessing = false
this.hasSearched = true this.hasSearched = true
+39 -9
View File
@@ -306,13 +306,13 @@ export default {
this.runSearch() this.runSearch()
}, },
async runSearch() { async runSearch() {
var searchQuery = this.getSearchQuery() const searchQuery = this.getSearchQuery()
if (this.lastSearch === searchQuery) return if (this.lastSearch === searchQuery) return
this.searchResults = [] this.searchResults = []
this.isProcessing = true this.isProcessing = true
this.lastSearch = searchQuery this.lastSearch = searchQuery
var searchEntity = this.isPodcast ? 'podcast' : 'books' const searchEntity = this.isPodcast ? 'podcast' : 'books'
var results = await this.$axios.$get(`/api/search/${searchEntity}?${searchQuery}`, { timeout: 20000 }).catch((error) => { let results = await this.$axios.$get(`/api/search/${searchEntity}?${searchQuery}`, { timeout: 20000 }).catch((error) => {
console.error('Failed', error) console.error('Failed', error)
return [] return []
}) })
@@ -335,8 +335,7 @@ export default {
this.isProcessing = false this.isProcessing = false
this.hasSearched = true this.hasSearched = true
}, },
init() { initSelectedMatchUsage() {
this.clearSelectedMatch()
this.selectedMatchUsage = { this.selectedMatchUsage = {
title: true, title: true,
subtitle: true, subtitle: true,
@@ -360,6 +359,27 @@ export default {
releaseDate: true releaseDate: true
} }
// Load saved selected match from local storage
try {
let savedSelectedMatchUsage = localStorage.getItem('selectedMatchUsage')
if (!savedSelectedMatchUsage) return
savedSelectedMatchUsage = JSON.parse(savedSelectedMatchUsage)
for (const key in savedSelectedMatchUsage) {
if (this.selectedMatchUsage[key] !== undefined) {
this.selectedMatchUsage[key] = !!savedSelectedMatchUsage[key]
}
}
} catch (error) {
console.error('Failed to load saved selectedMatchUsage', error)
}
this.checkboxToggled()
},
init() {
this.clearSelectedMatch()
this.initSelectedMatchUsage()
if (this.libraryItem.id !== this.libraryItemId) { if (this.libraryItem.id !== this.libraryItemId) {
this.searchResults = [] this.searchResults = []
this.hasSearched = false this.hasSearched = false
@@ -376,6 +396,12 @@ export default {
if (this.isPodcast) this.provider = 'itunes' if (this.isPodcast) this.provider = 'itunes'
else this.provider = localStorage.getItem('book-provider') || 'google' else this.provider = localStorage.getItem('book-provider') || 'google'
// Prefer using ASIN if set and using audible provider
if (this.provider.startsWith('audible') && this.libraryItem.media.metadata.asin) {
this.searchTitle = this.libraryItem.media.metadata.asin
this.searchAuthor = ''
}
if (this.searchTitle) { if (this.searchTitle) {
this.submitSearch() this.submitSearch()
} }
@@ -465,11 +491,14 @@ export default {
console.log('Match payload', updatePayload) console.log('Match payload', updatePayload)
this.isProcessing = true this.isProcessing = true
// Persist in local storage
localStorage.setItem('selectedMatchUsage', JSON.stringify(this.selectedMatchUsage))
if (updatePayload.metadata.cover) { if (updatePayload.metadata.cover) {
var coverPayload = { const coverPayload = {
url: updatePayload.metadata.cover url: updatePayload.metadata.cover
} }
var success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, coverPayload).catch((error) => { const success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, coverPayload).catch((error) => {
console.error('Failed to update', error) console.error('Failed to update', error)
return false return false
}) })
@@ -483,8 +512,8 @@ export default {
} }
if (Object.keys(updatePayload).length) { if (Object.keys(updatePayload).length) {
var mediaUpdatePayload = updatePayload const mediaUpdatePayload = updatePayload
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, mediaUpdatePayload).catch((error) => { const updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, mediaUpdatePayload).catch((error) => {
console.error('Failed to update', error) console.error('Failed to update', error)
return false return false
}) })
@@ -502,6 +531,7 @@ export default {
} else { } else {
this.clearSelectedMatch() this.clearSelectedMatch()
} }
this.isProcessing = false this.isProcessing = false
}, },
clearSelectedMatch() { clearSelectedMatch() {
@@ -1,7 +1,7 @@
<template> <template>
<div class="w-full h-full px-1 md:px-4 py-2 mb-4"> <div class="w-full h-full md:px-4 py-2 mb-4">
<div v-if="!showDirectoryPicker" class="w-full h-full py-4"> <div v-if="!showDirectoryPicker" class="w-full h-full md:py-4">
<div class="flex flex-wrap md:flex-nowrap -mx-1"> <div class="flex flex-wrap md:flex-nowrap -mx-1 mb-2">
<div class="w-2/5 md:w-72 px-1 py-1 md:py-0"> <div class="w-2/5 md:w-72 px-1 py-1 md:py-0">
<ui-dropdown v-model="mediaType" :items="mediaTypes" :label="$strings.LabelMediaType" :disabled="!isNew" small @input="changedMediaType" /> <ui-dropdown v-model="mediaType" :items="mediaTypes" :label="$strings.LabelMediaType" :disabled="!isNew" small @input="changedMediaType" />
</div> </div>
@@ -16,7 +16,7 @@
</div> </div>
</div> </div>
<div class="w-full py-4"> <div class="folders-container overflow-y-auto w-full py-2 mb-2">
<p class="px-1 text-sm font-semibold">{{ $strings.LabelFolders }}</p> <p class="px-1 text-sm font-semibold">{{ $strings.LabelFolders }}</p>
<div v-for="(folder, index) in folders" :key="index" class="w-full flex items-center py-1 px-2"> <div v-for="(folder, index) in folders" :key="index" class="w-full flex items-center py-1 px-2">
<span class="material-icons bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span> <span class="material-icons bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span>
@@ -67,6 +67,10 @@ export default {
value: 'podcast', value: 'podcast',
text: this.$strings.LabelPodcasts text: this.$strings.LabelPodcasts
} }
// {
// value: 'music',
// text: 'Music'
// }
] ]
}, },
folderPaths() { folderPaths() {
@@ -140,3 +144,14 @@ export default {
} }
} }
</script> </script>
<style>
.folders-container {
max-height: calc(80vh - 192px);
}
@media (max-device-width: 768px) {
.folders-container {
max-height: calc(80vh - 292px);
}
}
</style>
@@ -11,7 +11,7 @@
</template> </template>
</div> </div>
<div class="px-2 md:px-4 w-full text-sm pt-6 pb-20 rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh"> <div class="px-2 md:px-4 w-full text-sm pt-2 md:pt-6 pb-20 rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
<component v-if="libraryCopy && show" ref="tabComponent" :is="tabName" :is-new="!library" :library="libraryCopy" :processing.sync="processing" @update="updateLibrary" @close="show = false" /> <component v-if="libraryCopy && show" ref="tabComponent" :is="tabName" :is-new="!library" :library="libraryCopy" :processing.sync="processing" @update="updateLibrary" @close="show = false" />
<div class="absolute bottom-0 left-0 w-full px-4 py-4 border-t border-white border-opacity-10"> <div class="absolute bottom-0 left-0 w-full px-4 py-4 border-t border-white border-opacity-10">
@@ -6,13 +6,13 @@
</div> </div>
</template> </template>
<div ref="wrapper" class="px-8 py-6 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden"> <div ref="wrapper" class="px-8 py-6 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
<div v-if="currentFeedUrl" class="w-full"> <div v-if="currentFeed" class="w-full">
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedIsOpen }}</p> <p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedIsOpen }}</p>
<div class="w-full relative"> <div class="w-full relative">
<ui-text-input v-model="currentFeedUrl" readonly /> <ui-text-input v-model="currentFeed.feedUrl" readonly />
<span class="material-icons absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(currentFeedUrl)">content_copy</span> <span class="material-icons absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(currentFeed.feedUrl)">content_copy</span>
</div> </div>
</div> </div>
<div v-else class="w-full"> <div v-else class="w-full">
@@ -28,7 +28,7 @@
</div> </div>
<div v-show="userIsAdminOrUp" class="flex items-center pt-6"> <div v-show="userIsAdminOrUp" class="flex items-center pt-6">
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn v-if="currentFeedUrl" color="error" small @click="closeFeed">{{ $strings.ButtonCloseFeed }}</ui-btn> <ui-btn v-if="currentFeed" color="error" small @click="closeFeed">{{ $strings.ButtonCloseFeed }}</ui-btn>
<ui-btn v-else color="success" small @click="openFeed">{{ $strings.ButtonOpenFeed }}</ui-btn> <ui-btn v-else color="success" small @click="openFeed">{{ $strings.ButtonOpenFeed }}</ui-btn>
</div> </div>
</div> </div>
@@ -37,19 +37,11 @@
<script> <script>
export default { export default {
props: {
value: Boolean,
libraryItem: {
type: Object,
default: () => null
},
feedUrl: String
},
data() { data() {
return { return {
processing: false, processing: false,
newFeedSlug: null, newFeedSlug: null,
currentFeedUrl: null currentFeed: null
} }
}, },
watch: { watch: {
@@ -65,23 +57,29 @@ export default {
computed: { computed: {
show: { show: {
get() { get() {
return this.value return this.$store.state.globals.showRSSFeedOpenCloseModal
}, },
set(val) { set(val) {
this.$emit('input', val) this.$store.commit('globals/setShowRSSFeedOpenCloseModal', val)
} }
}, },
libraryItemId() { rssFeedEntity() {
return this.libraryItem.id return this.$store.state.globals.rssFeedEntity || {}
}, },
media() { entityId() {
return this.libraryItem.media || {} return this.rssFeedEntity.id
}, },
mediaMetadata() { entityType() {
return this.media.metadata || {} return this.rssFeedEntity.type
},
entityFeed() {
return this.rssFeedEntity.feed
},
hasEpisodesWithoutPubDate() {
return !!this.rssFeedEntity.hasEpisodesWithoutPubDate
}, },
title() { title() {
return this.mediaMetadata.title return this.rssFeedEntity.name
}, },
userIsAdminOrUp() { userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp'] return this.$store.getters['user/getIsAdminOrUp']
@@ -91,12 +89,6 @@ export default {
}, },
isHttp() { isHttp() {
return window.origin.startsWith('http://') return window.origin.startsWith('http://')
},
episodes() {
return this.media.episodes || []
},
hasEpisodesWithoutPubDate() {
return this.episodes.some((ep) => !ep.pubDate)
} }
}, },
methods: { methods: {
@@ -106,7 +98,7 @@ export default {
return return
} }
var sanitized = this.$sanitizeSlug(this.newFeedSlug) const sanitized = this.$sanitizeSlug(this.newFeedSlug)
if (this.newFeedSlug !== sanitized) { if (this.newFeedSlug !== sanitized) {
this.newFeedSlug = sanitized this.newFeedSlug = sanitized
this.$toast.warning('Slug had to be modified - Run again') this.$toast.warning('Slug had to be modified - Run again')
@@ -121,19 +113,15 @@ export default {
console.log('Payload', payload) console.log('Payload', payload)
this.$axios this.$axios
.$post(`/api/items/${this.libraryItemId}/open-feed`, payload) .$post(`/api/feeds/${this.entityType}/${this.entityId}/open`, payload)
.then((data) => { .then((data) => {
if (data.success) { console.log('Opened RSS Feed', data)
console.log('Opened RSS Feed', data) this.currentFeed = data.feed
this.currentFeedUrl = data.feedUrl
} else {
const errorMsg = data.error || 'Unknown error'
this.$toast.error(errorMsg)
}
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to open RSS Feed', error) console.error('Failed to open RSS Feed', error)
this.$toast.error() const errorMsg = error.response ? error.response.data : null
this.$toast.error(errorMsg || 'Failed to open RSS Feed')
}) })
}, },
copyToClipboard(str) { copyToClipboard(str) {
@@ -142,22 +130,23 @@ export default {
closeFeed() { closeFeed() {
this.processing = true this.processing = true
this.$axios this.$axios
.$post(`/api/items/${this.libraryItem.id}/close-feed`) .$post(`/api/feeds/${this.currentFeed.id}/close`)
.then(() => { .then(() => {
this.$toast.success(this.$strings.ToastRSSFeedCloseSuccess) this.$toast.success(this.$strings.ToastRSSFeedCloseSuccess)
this.show = false this.show = false
this.processing = false
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to close RSS feed', error) console.error('Failed to close RSS feed', error)
this.processing = false
this.$toast.error(this.$strings.ToastRSSFeedCloseFailed) this.$toast.error(this.$strings.ToastRSSFeedCloseFailed)
}) })
.finally(() => {
this.processing = false
})
}, },
init() { init() {
if (!this.libraryItem) return if (!this.entityId) return
this.newFeedSlug = this.libraryItem.id this.newFeedSlug = this.entityId
this.currentFeedUrl = this.feedUrl this.currentFeed = this.entityFeed
} }
}, },
mounted() {} mounted() {}
+8 -10
View File
@@ -234,13 +234,10 @@ export default {
this.showChaptersModal = false this.showChaptersModal = false
}, },
setUseChapterTrack() { setUseChapterTrack() {
var useChapterTrack = !this.useChapterTrack this.useChapterTrack = !this.useChapterTrack
this.useChapterTrack = useChapterTrack if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(useChapterTrack)
this.$store.dispatch('user/updateUserSettings', { useChapterTrack }).catch((err) => { this.$store.dispatch('user/updateUserSettings', { useChapterTrack: this.useChapterTrack })
console.error('Failed to update settings', err)
})
this.updateTimestamp() this.updateTimestamp()
}, },
checkUpdateChapterTrack() { checkUpdateChapterTrack() {
@@ -311,7 +308,7 @@ export default {
init() { init() {
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1 this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
var _useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') || false const _useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') || false
this.useChapterTrack = this.chapters.length ? _useChapterTrack : false this.useChapterTrack = this.chapters.length ? _useChapterTrack : false
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack) if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
@@ -345,13 +342,14 @@ export default {
} }
}, },
mounted() { mounted() {
this.$store.commit('user/addSettingsListener', { id: 'audioplayer', meth: this.settingsUpdated })
this.init()
this.$eventBus.$on('player-hotkey', this.hotkey) this.$eventBus.$on('player-hotkey', this.hotkey)
this.$eventBus.$on('user-settings', this.settingsUpdated)
this.init()
}, },
beforeDestroy() { beforeDestroy() {
this.$store.commit('user/removeSettingsListener', 'audioplayer')
this.$eventBus.$off('player-hotkey', this.hotkey) this.$eventBus.$off('player-hotkey', this.hotkey)
this.$eventBus.$off('user-settings', this.settingsUpdated)
} }
} }
</script> </script>
+88
View File
@@ -0,0 +1,88 @@
<template>
<div class="h-full w-full">
<div id="viewer" class="border border-gray-100 bg-white text-black shadow-md h-screen overflow-y-auto p-4" v-html="pageHtml"></div>
</div>
</template>
<script>
export default {
props: {
url: String,
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {
bookInfo: {},
page: 0,
numPages: 0,
pageHtml: '',
progress: 0
}
},
computed: {
libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null
},
hasPrev() {
return this.page > 0
},
hasNext() {
return this.page < this.numPages - 1
}
},
methods: {
prev() {
if (!this.hasPrev) return
this.page--
this.loadPage()
},
next() {
if (!this.hasNext) return
this.page++
this.loadPage()
},
keyUp() {
if ((e.keyCode || e.which) == 37) {
this.prev()
} else if ((e.keyCode || e.which) == 39) {
this.next()
}
},
loadPage() {
this.$axios
.$get(`/api/ebooks/${this.libraryItemId}/page/${this.page}?dev=${this.$isDev ? 1 : 0}`)
.then((html) => {
this.pageHtml = html
})
.catch((error) => {
console.error('Failed to load page', error)
this.$toast.error('Failed to load page')
})
},
loadInfo() {
this.$axios
.$get(`/api/ebooks/${this.libraryItemId}/info?dev=${this.$isDev ? 1 : 0}`)
.then((bookInfo) => {
this.bookInfo = bookInfo
this.numPages = bookInfo.pages
this.page = 0
this.loadPage()
})
.catch((error) => {
console.error('Failed to load page', error)
this.$toast.error('Failed to load info')
})
},
initEpub() {
if (!this.libraryItemId) return
this.loadInfo()
}
},
mounted() {
this.initEpub()
}
}
</script>
+4 -3
View File
@@ -1,5 +1,5 @@
<template> <template>
<div v-if="show" class="w-screen h-screen fixed top-0 left-0 z-50 bg-primary text-white"> <div v-if="show" class="w-screen h-screen fixed top-0 left-0 z-60 bg-primary text-white">
<div class="absolute top-4 right-4 z-20"> <div class="absolute top-4 right-4 z-20">
<span class="material-icons cursor-pointer text-4xl" @click="close">close</span> <span class="material-icons cursor-pointer text-4xl" @click="close">close</span>
</div> </div>
@@ -9,7 +9,7 @@
<p v-if="abAuthor">by {{ abAuthor }}</p> <p v-if="abAuthor">by {{ abAuthor }}</p>
</div> </div>
<component v-if="componentName" ref="readerComponent" :is="componentName" :url="ebookUrl" /> <component v-if="componentName" ref="readerComponent" :is="componentName" :url="ebookUrl" :library-item="selectedLibraryItem" />
<div class="absolute bottom-2 left-2">{{ ebookType }}</div> <div class="absolute bottom-2 left-2">{{ ebookType }}</div>
</div> </div>
@@ -37,7 +37,8 @@ export default {
} }
}, },
componentName() { componentName() {
if (this.ebookType === 'epub') return 'readers-epub-reader' if (this.ebookType === 'epub' && this.$isDev) return 'readers-epub-reader2'
else if (this.ebookType === 'epub') return 'readers-epub-reader'
else if (this.ebookType === 'mobi') return 'readers-mobi-reader' else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
else if (this.ebookType === 'pdf') return 'readers-pdf-reader' else if (this.ebookType === 'pdf') return 'readers-pdf-reader'
else if (this.ebookType === 'comic') return 'readers-comic-reader' else if (this.ebookType === 'comic') return 'readers-comic-reader'
+5 -5
View File
@@ -35,13 +35,13 @@
</ui-tooltip> </ui-tooltip>
</td> </td>
<td class="py-0"> <td class="py-0">
<div class="w-full flex justify-center"> <div class="w-full flex justify-left">
<!-- Dont show edit for non-root users --> <!-- Dont show edit for non-root users -->
<div v-if="user.type !== 'root' || userIsRoot" class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-opacity-100 cursor-pointer" @click.stop="editUser(user)"> <div v-if="user.type !== 'root' || userIsRoot" class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-opacity-100 cursor-pointer" @click.stop="editUser(user)">
<span class="material-icons text-base">edit</span> <button :aria-label="$getString('ButtonUserEdit', [user.username])" class="material-icons text-base">edit</button>
</div> </div>
<div v-show="user.type !== 'root'" class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-error cursor-pointer" @click.stop="deleteUserClick(user)"> <div v-show="user.type !== 'root'" class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-error cursor-pointer" @click.stop="deleteUserClick(user)">
<span class="material-icons text-base">delete</span> <button :aria-label="$getString('ButtonUserDelete', [user.username])" class="material-icons text-base">delete</button>
</div> </div>
</div> </div>
</td> </td>
@@ -109,8 +109,8 @@ export default {
loadUsers() { loadUsers() {
this.$axios this.$axios
.$get('/api/users') .$get('/api/users')
.then((users) => { .then((res) => {
this.users = users.sort((a, b) => { this.users = res.users.sort((a, b) => {
return a.createdAt - b.createdAt return a.createdAt - b.createdAt
}) })
}) })
@@ -1,5 +1,5 @@
<template> <template>
<div id="librariesTable"> <div>
<draggable v-if="libraryCopies.length" :list="libraryCopies" v-bind="dragOptions" class="list-group" handle=".drag-handle" draggable=".item" tag="div" @start="startDrag" @end="endDrag"> <draggable v-if="libraryCopies.length" :list="libraryCopies" v-bind="dragOptions" class="list-group" handle=".drag-handle" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
<template v-for="library in libraryCopies"> <template v-for="library in libraryCopies">
<div :key="library.id" class="item"> <div :key="library.id" class="item">
@@ -82,10 +82,10 @@ export default {
}) })
var newOrder = libraryOrderData.map((lib) => lib.id).join(',') var newOrder = libraryOrderData.map((lib) => lib.id).join(',')
if (currOrder !== newOrder) { if (currOrder !== newOrder) {
this.$axios.$post('/api/libraries/order', libraryOrderData).then((libraries) => { this.$axios.$post('/api/libraries/order', libraryOrderData).then((response) => {
if (libraries && libraries.length) { if (response.libraries && response.libraries.length) {
this.$toast.success('Library order saved', { timeout: 1500 }) this.$toast.success('Library order saved', { timeout: 1500 })
this.$store.commit('libraries/set', libraries) this.$store.commit('libraries/set', response.libraries)
} }
}) })
} }
@@ -1,32 +1,29 @@
<template> <template>
<div class="w-full px-4 h-12 border border-white border-opacity-10 flex items-center relative -mt-px" :class="selected ? 'bg-primary bg-opacity-50' : 'hover:bg-primary hover:bg-opacity-25'" @mouseover="mouseover = true" @mouseleave="mouseover = false"> <div class="w-full pl-2 pr-4 md:px-4 h-12 border border-white border-opacity-10 flex items-center relative -mt-px" :class="selected ? 'bg-primary bg-opacity-50' : 'hover:bg-primary hover:bg-opacity-25'" @mouseover="mouseover = true" @mouseleave="mouseover = false">
<div v-show="selected" class="absolute top-0 left-0 h-full w-0.5 bg-warning z-10" /> <div v-show="selected" class="absolute top-0 left-0 h-full w-0.5 bg-warning z-10" />
<ui-library-icon v-if="!libraryScan" :icon="library.icon" :size="6" font-size="xl" class="text-white" :class="isHovering ? 'text-opacity-90' : 'text-opacity-50'" /> <ui-library-icon v-if="!libraryScan" :icon="library.icon" :size="6" font-size="lg md:text-xl" class="text-white" :class="isHovering ? 'text-opacity-90' : 'text-opacity-50'" />
<svg v-else viewBox="0 0 24 24" class="h-6 w-6 text-white text-opacity-50 animate-spin"> <svg v-else viewBox="0 0 24 24" class="h-6 w-6 text-white text-opacity-50 animate-spin">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" /> <path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg> </svg>
<p class="text-xl font-book pl-4 hover:underline cursor-pointer" @click.stop="$emit('click', library)">{{ library.name }}</p> <p class="text-base md:text-xl font-book pl-2 md:pl-4 hover:underline cursor-pointer" @click.stop="$emit('click', library)">{{ library.name }}</p>
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn v-show="isHovering && !libraryScan" class="hidden md:block" small color="success" @click.stop="scan">{{ $strings.ButtonScan }}</ui-btn>
<ui-btn v-show="isHovering && !libraryScan" small color="bg" class="ml-2 hidden md:block" @click.stop="forceScan">{{ $strings.ButtonForceReScan }}</ui-btn>
<ui-btn v-show="isHovering && !libraryScan && isBookLibrary" small color="bg" class="ml-2 hidden md:block" @click.stop="matchAll">{{ $strings.ButtonMatchBooks }}</ui-btn> <!-- Desktop context menu icon -->
<ui-context-menu-dropdown v-if="!libraryScan && !isDeleting" :items="contextMenuItems" :icon-class="`text-1.5xl text-gray-${isHovering ? 50 : 400}`" class="!hidden md:!block" @action="contextMenuAction" />
<span v-if="isHovering && !libraryScan" class="!hidden md:!block material-icons text-xl text-gray-300 hover:text-gray-50 ml-4 cursor-pointer" @click.stop="editClick">edit</span> <!-- Mobile context menu icon -->
<span v-if="!libraryScan && isHovering && !isDeleting" class="!hidden md:!block material-icons text-xl text-gray-300 ml-3 hover:text-gray-50 cursor-pointer" @click.stop="deleteClick">delete</span> <span v-if="!libraryScan && !isDeleting" class="!block md:!hidden material-icons text-xl text-gray-300 ml-3 cursor-pointer" @click.stop="showMenu">more_vert</span>
<!-- For mobile -->
<span v-if="!libraryScan" class="!block md:!hidden material-icons text-xl text-gray-300 ml-4 cursor-pointer" @click.stop="editClick">edit</span>
<span v-if="!libraryScan && !isDeleting" class="!block md:!hidden material-icons text-2xl text-gray-300 ml-3 cursor-pointer" @click.stop="showMenu">more_vert</span>
<div v-show="isDeleting" class="text-xl text-gray-300 ml-3 animate-spin"> <div v-show="isDeleting" class="text-xl text-gray-300 ml-3 animate-spin">
<svg viewBox="0 0 24 24" class="w-6 h-6"> <svg viewBox="0 0 24 24" class="w-6 h-6">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" /> <path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg> </svg>
</div> </div>
<span class="material-icons drag-handle text-xl text-gray-400 hover:text-gray-50 ml-4">reorder</span> <span class="material-icons drag-handle text-xl text-gray-400 hover:text-gray-50 ml-2 md:ml-4">reorder</span>
<!-- For mobile --> <!-- For mobile -->
<modals-dialog v-model="showMobileMenu" :title="menuTitle" :items="mobileMenuItems" @action="mobileMenuAction" /> <modals-dialog v-model="showMobileMenu" :title="menuTitle" :items="contextMenuItems" @action="contextMenuAction" />
</div> </div>
</template> </template>
@@ -63,34 +60,45 @@ export default {
menuTitle() { menuTitle() {
return this.library.name return this.library.name
}, },
mobileMenuItems() { contextMenuItems() {
const items = [ const items = [
{
text: this.$strings.ButtonEdit,
action: 'edit',
value: 'edit'
},
{ {
text: this.$strings.ButtonScan, text: this.$strings.ButtonScan,
action: 'scan',
value: 'scan' value: 'scan'
}, },
{ {
text: this.$strings.ButtonForceReScan, text: this.$strings.ButtonForceReScan,
action: 'force-scan',
value: 'force-scan' value: 'force-scan'
} }
] ]
if (this.isBookLibrary) { if (this.isBookLibrary) {
items.push({ items.push({
text: this.$strings.ButtonMatchBooks, text: this.$strings.ButtonMatchBooks,
action: 'match-books',
value: 'match-books' value: 'match-books'
}) })
} }
items.push({ items.push({
text: this.$strings.ButtonDelete, text: this.$strings.ButtonDelete,
action: 'delete',
value: 'delete' value: 'delete'
}) })
return items return items
} }
}, },
methods: { methods: {
mobileMenuAction(action) { contextMenuAction(action) {
this.showMobileMenu = false this.showMobileMenu = false
if (action === 'scan') { if (action === 'edit') {
this.editClick()
} else if (action === 'scan') {
this.scan() this.scan()
} else if (action === 'force-scan') { } else if (action === 'force-scan') {
this.forceScan() this.forceScan()
@@ -130,37 +138,52 @@ export default {
}) })
}, },
forceScan() { forceScan() {
if (confirm(this.$strings.MessageConfirmForceReScan)) { const payload = {
this.$store message: this.$strings.MessageConfirmForceReScan,
.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id, force: 1 }) callback: (confirmed) => {
.then(() => { if (confirmed) {
this.$toast.success(this.$strings.ToastLibraryScanStarted) this.$store
}) .dispatch('libraries/requestLibraryScan', { libraryId: this.library.id, force: 1 })
.catch((error) => { .then(() => {
console.error('Failed to start scan', error) this.$toast.success(this.$strings.ToastLibraryScanStarted)
this.$toast.error(this.$strings.ToastLibraryScanFailedToStart) })
}) .catch((error) => {
console.error('Failed to start scan', error)
this.$toast.error(this.$strings.ToastLibraryScanFailedToStart)
})
}
},
type: 'yesNo'
} }
this.$store.commit('globals/setConfirmPrompt', payload)
}, },
deleteClick() { deleteClick() {
if (confirm(this.$getString('MessageConfirmDeleteLibrary', [this.library.name]))) { const payload = {
this.isDeleting = true message: this.$getString('MessageConfirmDeleteLibrary', [this.library.name]),
this.$axios callback: (confirmed) => {
.$delete(`/api/libraries/${this.library.id}`) if (confirmed) {
.then((data) => { this.isDeleting = true
this.isDeleting = false this.$axios
if (data.error) { .$delete(`/api/libraries/${this.library.id}`)
this.$toast.error(data.error) .then((data) => {
} else { if (data.error) {
this.$toast.success(this.$strings.ToastLibraryDeleteSuccess) this.$toast.error(data.error)
} } else {
}) this.$toast.success(this.$strings.ToastLibraryDeleteSuccess)
.catch((error) => { }
console.error('Failed to delete library', error) })
this.$toast.error(this.$strings.ToastLibraryDeleteFailed) .catch((error) => {
this.isDeleting = false console.error('Failed to delete library', error)
}) this.$toast.error(this.$strings.ToastLibraryDeleteFailed)
})
.finally(() => {
this.isDeleting = false
})
}
},
type: 'yesNo'
} }
this.$store.commit('globals/setConfirmPrompt', payload)
} }
}, },
mounted() {} mounted() {}
@@ -1,8 +1,9 @@
<template> <template>
<div class="w-full py-6"> <div class="w-full py-6">
<p class="text-lg mb-2 font-semibold md:hidden">{{ $strings.HeaderEpisodes }}</p>
<div class="flex items-center mb-4"> <div class="flex items-center mb-4">
<p class="text-lg mb-0 font-semibold">{{ $strings.HeaderEpisodes }}</p> <p class="text-lg mb-0 font-semibold hidden md:block">{{ $strings.HeaderEpisodes }}</p>
<div class="flex-grow" /> <div class="flex-grow hidden md:block" />
<template v-if="isSelectionMode"> <template v-if="isSelectionMode">
<ui-tooltip :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom"> <ui-tooltip :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom">
<ui-read-icon-btn :disabled="processing" :is-read="selectedIsFinished" @click="toggleBatchFinished" class="mx-1.5" /> <ui-read-icon-btn :disabled="processing" :is-read="selectedIsFinished" @click="toggleBatchFinished" class="mx-1.5" />
@@ -11,8 +12,10 @@
<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-32 md:w-36 h-9 ml-1 sm:ml-4" /> <controls-filter-select v-model="filterKey" :items="filterItems" class="w-36 h-9 sm:ml-4" />
<controls-sort-select v-model="sortKey" :descending.sync="sortDesc" :items="sortItems" class="w-32 sm: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" />
<div class="flex-grow md:hidden" />
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" class="ml-1" @action="contextMenuAction" />
</template> </template>
</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>
@@ -42,15 +45,27 @@ export default {
showPodcastRemoveModal: false, showPodcastRemoveModal: false,
selectedEpisodes: [], selectedEpisodes: [],
episodesToRemove: [], episodesToRemove: [],
processing: false processing: false,
quickMatchingEpisodes: false
} }
}, },
watch: { watch: {
libraryItem() { libraryItem: {
this.init() handler() {
this.init()
}
} }
}, },
computed: { computed: {
contextMenuItems() {
if (!this.userIsAdminOrUp) return []
return [
{
text: 'Quick match all episodes',
action: 'quick-match-episodes'
}
]
},
sortItems() { sortItems() {
return [ return [
{ {
@@ -94,8 +109,8 @@ export default {
isSelectionMode() { isSelectionMode() {
return this.selectedEpisodes.length > 0 return this.selectedEpisodes.length > 0
}, },
userCanUpdate() { userIsAdminOrUp() {
return this.$store.getters['user/getUserCanUpdate'] return this.$store.getters['user/getIsAdminOrUp']
}, },
media() { media() {
return this.libraryItem.media || {} return this.libraryItem.media || {}
@@ -131,6 +146,44 @@ export default {
} }
}, },
methods: { methods: {
contextMenuAction(action) {
if (action === 'quick-match-episodes') {
if (this.quickMatchingEpisodes) return
this.quickMatchAllEpisodes()
}
},
quickMatchAllEpisodes() {
if (!this.mediaMetadata.feedUrl) {
this.$toast.error(this.$strings.MessagePodcastHasNoRSSFeedForMatching)
return
}
this.quickMatchingEpisodes = true
const payload = {
message: 'Quick matching episodes will overwrite details if a match is found. Only unmatched episodes will be updated. Are you sure?',
callback: (confirmed) => {
if (confirmed) {
this.$axios
.$post(`/api/podcasts/${this.libraryItem.id}/match-episodes?override=1`)
.then((data) => {
if (data.numEpisodesUpdated) {
this.$toast.success(`${data.numEpisodesUpdated} episodes updated`)
} else {
this.$toast.info('No changes were made')
}
})
.catch((error) => {
console.error('Failed to request match episodes', error)
this.$toast.error('Failed to match episodes')
})
}
this.quickMatchingEpisodes = false
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
addToPlaylist(episode) { addToPlaylist(episode) {
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode }]) this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode }])
this.$store.commit('globals/setShowPlaylistsModal', true) this.$store.commit('globals/setShowPlaylistsModal', true)
@@ -0,0 +1,59 @@
<template>
<div class="relative h-9 w-9" v-click-outside="clickOutsideObj">
<button type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<span class="material-icons" :class="iconClass">more_vert</span>
</button>
<transition name="menu">
<div v-show="showMenu" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg max-h-56 w-48 rounded-md py-1 overflow-auto focus:outline-none sm:text-sm">
<template v-for="(item, index) in items">
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.action)">
<p>{{ item.text }}</p>
</div>
</template>
</div>
</transition>
</div>
</template>
<script>
export default {
props: {
disabled: Boolean,
items: {
type: Array,
default: () => []
},
iconClass: {
type: String,
default: ''
}
},
data() {
return {
clickOutsideObj: {
handler: this.clickedOutside,
events: ['mousedown'],
isActive: true
},
showMenu: false
}
},
computed: {},
methods: {
clickShowMenu() {
if (this.disabled) return
this.showMenu = !this.showMenu
},
clickedOutside() {
this.showMenu = false
},
clickAction(action) {
if (this.disabled) return
this.showMenu = false
this.$emit('action', action)
}
},
mounted() {}
}
</script>
+10 -3
View File
@@ -1,7 +1,7 @@
<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 class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
<button type="button" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none 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>
<span v-if="selectedSubtext">:&nbsp;</span> <span v-if="selectedSubtext">:&nbsp;</span>
@@ -13,9 +13,9 @@
</button> </button>
<transition name="menu"> <transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox"> <ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto sm:text-sm" tabindex="-1" role="listbox">
<template v-for="item in itemsToShow"> <template v-for="item in itemsToShow">
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)"> <li :key="item.value" class="text-gray-100 relative py-2 cursor-pointer hover:bg-black-400" :id="'listbox-option-' + item.value" role="option" tabindex="0" @keyup.enter="clickedOption(item.value)" @click="clickedOption(item.value)">
<div class="flex items-center"> <div class="flex items-center">
<span class="ml-3 block truncate font-sans text-sm" :class="{ 'font-semibold': item.subtext }">{{ item.text }}</span> <span class="ml-3 block truncate font-sans text-sm" :class="{ 'font-semibold': item.subtext }">{{ item.text }}</span>
<span v-if="item.subtext">:&nbsp;</span> <span v-if="item.subtext">:&nbsp;</span>
@@ -91,6 +91,13 @@ export default {
else classes.push('cursor-pointer border-gray-600 bg-primary text-gray-100') else classes.push('cursor-pointer border-gray-600 bg-primary text-gray-100')
return classes.join(' ') return classes.join(' ')
},
longLabel() {
let result = ''
if (this.label) result += this.label + ': '
if (this.selectedText) result += this.selectedText
if (this.selectedSubtext) result += ' ' + this.selectedSubtext
return result
} }
}, },
methods: { methods: {
+2 -2
View File
@@ -1,6 +1,6 @@
<template> <template>
<div v-if="currentLibrary" class="relative h-8 max-w-52 md:min-w-32" v-click-outside="clickOutsideObj"> <div v-if="currentLibrary" class="relative h-8 max-w-52 md:min-w-32" v-click-outside="clickOutsideObj">
<button type="button" :disabled="disabled" class="w-10 sm:w-full relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm focus:outline-none cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu"> <button type="button" :disabled="disabled" class="w-10 sm:w-full relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" :aria-expanded="showMenu" :aria-label="$strings.ButtonLibrary + ': ' + currentLibrary.name" @click.stop.prevent="clickShowMenu">
<div class="flex items-center justify-center sm:justify-start"> <div class="flex items-center justify-center sm:justify-start">
<ui-library-icon :icon="currentLibraryIcon" class="sm:mr-1.5" /> <ui-library-icon :icon="currentLibraryIcon" class="sm:mr-1.5" />
<span class="hidden sm:block truncate">{{ currentLibrary.name }}</span> <span class="hidden sm:block truncate">{{ currentLibrary.name }}</span>
@@ -10,7 +10,7 @@
<transition name="menu"> <transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px min-w-48 w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox"> <ul v-show="showMenu" class="absolute z-10 -mt-px min-w-48 w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
<template v-for="library in librariesFiltered"> <template v-for="library in librariesFiltered">
<li :key="library.id" class="text-gray-400 hover:text-white select-none relative py-2 cursor-pointer hover:bg-black-400" role="option" @click="selectLibrary(library)"> <li :key="library.id" class="text-gray-400 hover:text-white relative py-2 cursor-pointer hover:bg-black-400" role="option" tabindex="0" @keydown.enter="selectLibrary(library)" @click="selectLibrary(library)">
<div class="flex items-center px-2"> <div class="flex items-center px-2">
<ui-library-icon :icon="library.icon" class="mr-1.5" /> <ui-library-icon :icon="library.icon" class="mr-1.5" />
<span class="font-normal block truncate font-sans text-sm">{{ library.name }}</span> <span class="font-normal block truncate font-sans text-sm">{{ library.name }}</span>
+3 -3
View File
@@ -15,7 +15,7 @@
</div> </div>
</form> </form>
<ul ref="menu" v-show="showMenu" class="absolute z-50 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label"> <ul ref="menu" v-show="showMenu" class="absolute z-60 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in itemsToShow"> <template v-for="item in itemsToShow">
<li :key="item" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent> <li :key="item" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center"> <div class="flex items-center">
@@ -117,7 +117,7 @@ export default {
}, 50) }, 50)
}, },
recalcMenuPos() { recalcMenuPos() {
if (!this.menu) return if (!this.menu || !this.$refs.inputWrapper) return
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect() var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
if (boundingBox.y > window.innerHeight - 8) { if (boundingBox.y > window.innerHeight - 8) {
// Input is off the page // Input is off the page
@@ -135,7 +135,7 @@ export default {
this.menu.style.width = boundingBox.width + 'px' this.menu.style.width = boundingBox.width + 'px'
}, },
unmountMountMenu() { unmountMountMenu() {
if (!this.$refs.menu) return if (!this.$refs.menu || !this.$refs.inputWrapper) return
this.menu = this.$refs.menu this.menu = this.$refs.menu
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect() var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
+3 -3
View File
@@ -11,7 +11,7 @@
</div> </div>
</div> </div>
<ul ref="menu" v-show="showMenu" class="absolute z-50 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label"> <ul ref="menu" v-show="showMenu" class="absolute z-60 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items"> <template v-for="item in items">
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent> <li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center"> <div class="flex items-center">
@@ -68,14 +68,14 @@ export default {
}, },
methods: { methods: {
recalcMenuPos() { recalcMenuPos() {
if (!this.menu) return if (!this.menu || !this.$refs.inputWrapper) return
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect() var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
this.menu.style.top = boundingBox.y + boundingBox.height - 4 + 'px' this.menu.style.top = boundingBox.y + boundingBox.height - 4 + 'px'
this.menu.style.left = boundingBox.x + 'px' this.menu.style.left = boundingBox.x + 'px'
this.menu.style.width = boundingBox.width + 'px' this.menu.style.width = boundingBox.width + 'px'
}, },
unmountMountMenu() { unmountMountMenu() {
if (!this.$refs.menu) return if (!this.$refs.menu || !this.$refs.inputWrapper) return
this.menu = this.$refs.menu this.menu = this.$refs.menu
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect() var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
+23 -12
View File
@@ -18,7 +18,7 @@
</div> </div>
</form> </form>
<ul ref="menu" v-show="showMenu" class="absolute z-50 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label"> <ul ref="menu" v-show="showMenu" class="absolute z-60 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in itemsToShow"> <template v-for="item in itemsToShow">
<li :key="item.id" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent> <li :key="item.id" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center"> <div class="flex items-center">
@@ -113,10 +113,14 @@ export default {
if (this.searching) return if (this.searching) return
this.currentSearch = this.textInput this.currentSearch = this.textInput
this.searching = true this.searching = true
var results = await this.$axios.$get(`/api/${this.endpoint}?q=${this.currentSearch}&limit=15&token=${this.userToken}`).catch((error) => { const results = await this.$axios
console.error('Failed to get search results', error) .$get(`/api/${this.endpoint}?q=${this.currentSearch}&limit=15&token=${this.userToken}`)
return [] .then((res) => res.results || res)
}) .catch((error) => {
console.error('Failed to get search results', error)
return []
})
this.items = results || [] this.items = results || []
this.searching = false this.searching = false
}, },
@@ -136,7 +140,7 @@ export default {
}, 50) }, 50)
}, },
recalcMenuPos() { recalcMenuPos() {
if (!this.menu) return if (!this.menu || !this.$refs.inputWrapper) return
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect() var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
if (boundingBox.y > window.innerHeight - 8) { if (boundingBox.y > window.innerHeight - 8) {
// Input is off the page // Input is off the page
@@ -154,7 +158,7 @@ export default {
this.menu.style.width = boundingBox.width + 'px' this.menu.style.width = boundingBox.width + 'px'
}, },
unmountMountMenu() { unmountMountMenu() {
if (!this.$refs.menu) return if (!this.$refs.menu || !this.$refs.inputWrapper) return
this.menu = this.$refs.menu this.menu = this.$refs.menu
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect() var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
@@ -200,15 +204,21 @@ export default {
} }
if (this.$refs.input) this.$refs.input.focus() if (this.$refs.input) this.$refs.input.focus()
var newSelected = null let newSelected = null
if (this.getIsSelected(item.id)) { if (this.getIsSelected(item.id)) {
newSelected = this.selected.filter((s) => s.id !== item.id) newSelected = this.selected.filter((s) => s.id !== item.id)
this.$emit('removedItem', item.id) this.$emit('removedItem', item.id)
} else { } else {
newSelected = this.selected.concat([item]) newSelected = this.selected.concat([
{
id: item.id,
name: item.name
}
])
} }
this.textInput = null this.textInput = null
this.currentSearch = null this.currentSearch = null
this.$emit('input', newSelected) this.$emit('input', newSelected)
this.$nextTick(() => { this.$nextTick(() => {
this.recalcMenuPos() this.recalcMenuPos()
@@ -242,10 +252,11 @@ export default {
submitForm() { submitForm() {
if (!this.textInput) return if (!this.textInput) return
var cleaned = this.textInput.trim() const cleaned = this.textInput.trim()
var matchesItem = this.items.find((i) => { const matchesItem = this.items.find((i) => {
return i === cleaned return i.name === cleaned
}) })
if (matchesItem) { if (matchesItem) {
this.clickedOption(null, matchesItem) this.clickedOption(null, matchesItem)
} else { } else {
+1 -1
View File
@@ -8,7 +8,7 @@
</div> </div>
</form> </form>
<ul ref="menu" v-show="isFocused && currentSearch" class="absolute z-50 mt-0 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label"> <ul ref="menu" v-show="isFocused && currentSearch" class="absolute z-60 mt-0 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items"> <template v-for="item in items">
<li :key="item.id" class="text-gray-50 select-none relative py-2 pr-3 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent> <li :key="item.id" class="text-gray-50 select-none relative py-2 pr-3 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center"> <div class="flex items-center">
+3 -2
View File
@@ -1,6 +1,6 @@
<template> <template>
<div ref="wrapper" class="relative"> <div ref="wrapper" class="relative">
<input ref="input" v-model="inputValue" :type="actualType" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" /> <input :id="inputId" ref="input" v-model="inputValue" :type="actualType" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
<div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center"> <div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center">
<span class="material-icons text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span> <span class="material-icons text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
</div> </div>
@@ -31,7 +31,8 @@ export default {
}, },
noSpinner: Boolean, noSpinner: Boolean,
textCenter: Boolean, textCenter: Boolean,
clearable: Boolean clearable: Boolean,
inputId: String
}, },
data() { data() {
return { return {
+7 -4
View File
@@ -1,11 +1,11 @@
<template> <template>
<div class="w-full"> <div class="w-full">
<slot> <slot>
<p class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }"> <label :for="identifier" class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }"
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em> >{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em></label
</p> >
</slot> </slot>
<ui-text-input ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" :class="inputClass" @blur="inputBlurred" /> <ui-text-input :placeholder="label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" :class="inputClass" @blur="inputBlurred" />
</div> </div>
</template> </template>
@@ -34,6 +34,9 @@ export default {
set(val) { set(val) {
this.$emit('input', val) this.$emit('input', val)
} }
},
identifier() {
return Math.random().toString(36).substring(2)
} }
}, },
methods: { methods: {
+4 -3
View File
@@ -1,8 +1,8 @@
<template> <template>
<div> <div>
<div class="border rounded-full border-black-100 flex items-center cursor-pointer w-10 justify-start" :class="className" @click="clickToggle"> <button :aria-labelledby="labeledBy" role="checkbox" class="border rounded-full border-black-100 flex items-center cursor-pointer w-10 justify-start" :aria-checked="toggleValue" :class="className" @click="clickToggle">
<span class="rounded-full border w-5 h-5 border-black-50 shadow transform transition-transform duration-100" :class="switchClassName"></span> <span class="rounded-full border w-5 h-5 border-black-50 shadow transform transition-transform duration-100" :class="switchClassName"></span>
</div> </button>
</div> </div>
</template> </template>
@@ -18,7 +18,8 @@ export default {
type: String, type: String,
default: 'primary' default: 'primary'
}, },
disabled: Boolean disabled: Boolean,
labeledBy: String
}, },
computed: { computed: {
toggleValue: { toggleValue: {
+32 -13
View File
@@ -137,16 +137,33 @@ export default {
author: (this.details.authors || []).map((au) => au.name).join(', ') author: (this.details.authors || []).map((au) => au.name).join(', ')
} }
}, },
mapBatchDetails(batchDetails) { mapBatchDetails(batchDetails, mapType = 'overwrite') {
for (const key in batchDetails) { for (const key in batchDetails) {
if (key === 'tags') { if (mapType === 'append') {
this.newTags = [...batchDetails.tags] if (key === 'tags') {
} else if (key === 'genres' || key === 'narrators') { // Concat and remove dupes
this.details[key] = [...batchDetails[key]] this.newTags = [...new Set(this.newTags.concat(batchDetails.tags))]
} else if (key === 'authors' || key === 'series') { } else if (key === 'genres' || key === 'narrators') {
this.details[key] = batchDetails[key].map((i) => ({ ...i })) // Concat and remove dupes
this.details[key] = [...new Set(this.details[key].concat(batchDetails[key]))]
} else if (key === 'authors' || key === 'series') {
batchDetails[key].forEach((detail) => {
const existingDetail = this.details[key].find((_d) => _d.name.toLowerCase() == detail.name.toLowerCase().trim() || _d.id == detail.id)
if (!existingDetail) {
this.details[key].push({ ...detail })
}
})
}
} else { } else {
this.details[key] = batchDetails[key] if (key === 'tags') {
this.newTags = [...batchDetails.tags]
} else if (key === 'genres' || key === 'narrators') {
this.details[key] = [...batchDetails[key]]
} else if (key === 'authors' || key === 'series') {
this.details[key] = batchDetails[key].map((i) => ({ ...i }))
} else {
this.details[key] = batchDetails[key]
}
} }
} }
}, },
@@ -193,11 +210,13 @@ export default {
// array of objects with id key // array of objects with id key
if (array1.length !== array2.length) return false if (array1.length !== array2.length) return false
for (var item of array1) { for (let i = 0; i < array1.length; i++) {
var matchingItem = array2.find((a) => a.id === item.id) const item1 = array1[i]
if (!matchingItem) return false const item2 = array2[i]
for (var key in item) { if (!item1 || !item2) return false
if (item[key] !== matchingItem[key]) {
for (const key in item1) {
if (item1[key] !== item2[key]) {
// console.log('Object array item keys changed', key, item[key], matchingItem[key]) // console.log('Object array item keys changed', key, item[key], matchingItem[key])
return false return false
} }
@@ -107,14 +107,24 @@ export default {
author: this.details.author author: this.details.author
} }
}, },
mapBatchDetails(batchDetails) { mapBatchDetails(batchDetails, mapType = 'overwrite') {
for (const key in batchDetails) { for (const key in batchDetails) {
if (key === 'tags') { if (mapType === 'append') {
this.newTags = [...batchDetails.tags] if (key === 'tags') {
} else if (key === 'genres') { // Concat and remove dupes
this.details[key] = [...batchDetails[key]] this.newTags = [...new Set(this.newTags.concat(batchDetails.tags))]
} else if (key === 'genres') {
// Concat and remove dupes
this.details[key] = [...new Set(this.details[key].concat(batchDetails[key]))]
}
} else { } else {
this.details[key] = batchDetails[key] if (key === 'tags') {
this.newTags = [...batchDetails.tags]
} else if (key === 'genres') {
this.details[key] = [...batchDetails[key]]
} else {
this.details[key] = batchDetails[key]
}
} }
} }
}, },
@@ -59,7 +59,6 @@ export default {
..._series ..._series
} }
console.log('Selected series', this.selectedSeries)
this.showSeriesForm = true this.showSeriesForm = true
}, },
addNewSeries() { addNewSeries() {
+1 -11
View File
@@ -18,6 +18,7 @@
<modals-podcast-view-episode /> <modals-podcast-view-episode />
<modals-authors-edit-modal /> <modals-authors-edit-modal />
<modals-batch-quick-match-model /> <modals-batch-quick-match-model />
<modals-rssfeed-open-close-modal />
<prompt-confirm /> <prompt-confirm />
<readers-reader /> <readers-reader />
</div> </div>
@@ -280,7 +281,6 @@ export default {
userUpdated(user) { userUpdated(user) {
if (this.$store.state.user.user.id === user.id) { if (this.$store.state.user.user.id === user.id) {
this.$store.commit('user/setUser', user) this.$store.commit('user/setUser', user)
this.$store.commit('user/setSettings', user.settings)
} }
}, },
userOnline(user) { userOnline(user) {
@@ -330,12 +330,6 @@ export default {
} }
this.$store.commit('libraries/removeUserPlaylist', playlist) this.$store.commit('libraries/removeUserPlaylist', playlist)
}, },
rssFeedOpen(data) {
this.$store.commit('feeds/addFeed', data)
},
rssFeedClosed(data) {
this.$store.commit('feeds/removeFeed', data)
},
backupApplied() { backupApplied() {
// Force refresh // Force refresh
location.reload() location.reload()
@@ -425,10 +419,6 @@ export default {
this.socket.on('task_started', this.taskStarted) this.socket.on('task_started', this.taskStarted)
this.socket.on('task_finished', this.taskFinished) this.socket.on('task_finished', this.taskFinished)
// Feed Listeners
this.socket.on('rss_feed_open', this.rssFeedOpen)
this.socket.on('rss_feed_closed', this.rssFeedClosed)
this.socket.on('backup_applied', this.backupApplied) this.socket.on('backup_applied', this.backupApplied)
this.socket.on('batch_quickmatch_complete', this.batchQuickMatchComplete) this.socket.on('batch_quickmatch_complete', this.batchQuickMatchComplete)
+11 -9
View File
@@ -3,6 +3,7 @@ import LazyBookCard from '@/components/cards/LazyBookCard'
import LazySeriesCard from '@/components/cards/LazySeriesCard' import LazySeriesCard from '@/components/cards/LazySeriesCard'
import LazyCollectionCard from '@/components/cards/LazyCollectionCard' import LazyCollectionCard from '@/components/cards/LazyCollectionCard'
import LazyPlaylistCard from '@/components/cards/LazyPlaylistCard' import LazyPlaylistCard from '@/components/cards/LazyPlaylistCard'
import LazyAlbumCard from '@/components/cards/LazyAlbumCard'
export default { export default {
data() { data() {
@@ -17,6 +18,7 @@ export default {
if (this.entityName === 'series') return Vue.extend(LazySeriesCard) if (this.entityName === 'series') return Vue.extend(LazySeriesCard)
if (this.entityName === 'collections') return Vue.extend(LazyCollectionCard) if (this.entityName === 'collections') return Vue.extend(LazyCollectionCard)
if (this.entityName === 'playlists') return Vue.extend(LazyPlaylistCard) if (this.entityName === 'playlists') return Vue.extend(LazyPlaylistCard)
if (this.entityName === 'albums') return Vue.extend(LazyAlbumCard)
return Vue.extend(LazyBookCard) return Vue.extend(LazyBookCard)
}, },
async mountEntityCard(index) { async mountEntityCard(index) {
@@ -28,7 +30,7 @@ export default {
} }
this.entityIndexesMounted.push(index) this.entityIndexesMounted.push(index)
if (this.entityComponentRefs[index]) { if (this.entityComponentRefs[index]) {
var bookComponent = this.entityComponentRefs[index] const bookComponent = this.entityComponentRefs[index]
shelfEl.appendChild(bookComponent.$el) shelfEl.appendChild(bookComponent.$el)
if (this.isSelectionMode) { if (this.isSelectionMode) {
bookComponent.setSelectionMode(true) bookComponent.setSelectionMode(true)
@@ -43,13 +45,13 @@ export default {
bookComponent.isHovering = false bookComponent.isHovering = false
return return
} }
var shelfOffsetY = 16 const shelfOffsetY = 16
var row = index % this.entitiesPerShelf const row = index % this.entitiesPerShelf
var shelfOffsetX = row * this.totalEntityCardWidth + this.bookshelfMarginLeft const shelfOffsetX = row * this.totalEntityCardWidth + this.bookshelfMarginLeft
var ComponentClass = this.getComponentClass() const ComponentClass = this.getComponentClass()
var props = { const props = {
index, index,
width: this.entityWidth, width: this.entityWidth,
height: this.entityHeight, height: this.entityHeight,
@@ -58,15 +60,15 @@ export default {
sortingIgnorePrefix: !!this.sortingIgnorePrefix sortingIgnorePrefix: !!this.sortingIgnorePrefix
} }
if (this.entityName === 'books') { if (this.entityName === 'items') {
props.filterBy = this.filterBy props.filterBy = this.filterBy
props.orderBy = this.orderBy props.orderBy = this.orderBy
} else if (this.entityName === 'series') { } else if (this.entityName === 'series') {
props.orderBy = this.seriesSortBy props.orderBy = this.seriesSortBy
} }
var _this = this const _this = this
var instance = new ComponentClass({ const instance = new ComponentClass({
propsData: props, propsData: props,
created() { created() {
this.$on('edit', (entity) => { this.$on('edit', (entity) => {
+7
View File
@@ -114,10 +114,17 @@ module.exports = {
{ {
src: (process.env.ROUTER_BASE_PATH || '') + '/icon.svg', src: (process.env.ROUTER_BASE_PATH || '') + '/icon.svg',
sizes: "any" sizes: "any"
},
{
src: (process.env.ROUTER_BASE_PATH || '') + '/icon64.png',
type: "image/png",
sizes: "64x64"
} }
] ]
}, },
workbox: { workbox: {
offline: false,
cacheAssets: false,
preCaching: [], preCaching: [],
runtimeCaching: [] runtimeCaching: []
} }
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.2.7", "version": "2.2.12",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.2.7", "version": "2.2.12",
"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.2.7", "version": "2.2.12",
"description": "Self-hosted audiobook and podcast client", "description": "Self-hosted audiobook and podcast client",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
+7 -3
View File
@@ -354,6 +354,7 @@ export default {
for (let i = 0; i < this.newChapters.length; i++) { for (let i = 0; i < this.newChapters.length; i++) {
this.newChapters[i].id = i this.newChapters[i].id = i
this.newChapters[i].start = Number(this.newChapters[i].start) this.newChapters[i].start = Number(this.newChapters[i].start)
this.newChapters[i].title = (this.newChapters[i].title || '').trim()
if (i === 0 && this.newChapters[i].start !== 0) { if (i === 0 && this.newChapters[i].start !== 0) {
this.newChapters[i].error = this.$strings.MessageChapterErrorFirstNotZero this.newChapters[i].error = this.$strings.MessageChapterErrorFirstNotZero
@@ -508,22 +509,25 @@ export default {
this.showFindChaptersModal = false this.showFindChaptersModal = false
this.chapterData = null this.chapterData = null
this.checkChapters()
}, },
applyChapterData() { applyChapterData() {
var index = 0 let index = 0
this.newChapters = this.chapterData.chapters this.newChapters = this.chapterData.chapters
.filter((chap) => chap.startOffsetSec < this.mediaDuration) .filter((chap) => chap.startOffsetSec < this.mediaDuration)
.map((chap) => { .map((chap) => {
var chapEnd = Math.min(this.mediaDuration, (chap.startOffsetMs + chap.lengthMs) / 1000)
return { return {
id: index++, id: index++,
start: chap.startOffsetMs / 1000, start: chap.startOffsetMs / 1000,
end: chapEnd, end: Math.min(this.mediaDuration, (chap.startOffsetMs + chap.lengthMs) / 1000),
title: chap.title title: chap.title
} }
}) })
this.showFindChaptersModal = false this.showFindChaptersModal = false
this.chapterData = null this.chapterData = null
this.checkChapters()
}, },
findChapters() { findChapters() {
if (!this.asinInput) { if (!this.asinInput) {
+74 -13
View File
@@ -62,19 +62,42 @@
<div class="w-full h-px bg-white bg-opacity-10 my-8" /> <div class="w-full h-px bg-white bg-opacity-10 my-8" />
<div class="w-full max-w-4xl mx-auto"> <div class="w-full max-w-4xl mx-auto">
<div v-if="selectedTool === 'embed'" class="w-full flex justify-end items-center mb-4"> <div v-if="isEmbedTool" class="w-full flex justify-end items-center mb-4">
<ui-checkbox v-if="!isFinished" v-model="shouldBackupAudioFiles" label="Backup audio files" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" @input="toggleBackupAudioFiles" />
<div class="flex-grow" />
<ui-btn v-if="!isFinished" color="primary" :loading="processing" @click.stop="embedClick">{{ $strings.ButtonStartMetadataEmbed }}</ui-btn> <ui-btn v-if="!isFinished" color="primary" :loading="processing" @click.stop="embedClick">{{ $strings.ButtonStartMetadataEmbed }}</ui-btn>
<p v-else class="text-success text-lg font-semibold">{{ $strings.MessageEmbedFinished }}</p> <p v-else class="text-success text-lg font-semibold">{{ $strings.MessageEmbedFinished }}</p>
</div> </div>
<div v-else class="w-full flex justify-end items-center mb-4"> <div v-else class="w-full flex items-center mb-4">
<button :disabled="processing" class="text-sm uppercase text-gray-200 flex items-center pt-px pl-1 pr-2 hover:bg-white/5 rounded-md" @click="showEncodeOptions = !showEncodeOptions">
<span class="material-icons text-xl">{{ showEncodeOptions ? 'check_box' : 'check_box_outline_blank' }}</span> <span class="pl-1">Use Advanced Options</span>
</button>
<div class="flex-grow" />
<ui-btn v-if="!isTaskFinished && processing" color="error" :loading="isCancelingEncode" class="mr-2" @click.stop="cancelEncodeClick">{{ $strings.ButtonCancelEncode }}</ui-btn> <ui-btn v-if="!isTaskFinished && processing" color="error" :loading="isCancelingEncode" class="mr-2" @click.stop="cancelEncodeClick">{{ $strings.ButtonCancelEncode }}</ui-btn>
<ui-btn v-if="!isTaskFinished" color="primary" :loading="processing" @click.stop="encodeM4bClick">{{ $strings.ButtonStartM4BEncode }}</ui-btn> <ui-btn v-if="!isTaskFinished" color="primary" :loading="processing" @click.stop="encodeM4bClick">{{ $strings.ButtonStartM4BEncode }}</ui-btn>
<p v-else-if="taskFailed" class="text-error text-lg font-semibold">{{ $strings.MessageM4BFailed }} {{ taskError }}</p> <p v-else-if="taskFailed" class="text-error text-lg font-semibold">{{ $strings.MessageM4BFailed }} {{ taskError }}</p>
<p v-else class="text-success text-lg font-semibold">{{ $strings.MessageM4BFinished }}</p> <p v-else class="text-success text-lg font-semibold">{{ $strings.MessageM4BFinished }}</p>
</div> </div>
<div v-if="isM4BTool" class="overflow-hidden">
<transition name="slide">
<div v-if="showEncodeOptions" class="mb-4 pb-4 border-b border-white/10">
<div class="flex flex-wrap -mx-2">
<ui-text-input-with-label ref="bitrateInput" v-model="encodingOptions.bitrate" :disabled="processing || isTaskFinished" :label="'Audio Bitrate (e.g. 64k)'" class="m-2 max-w-40" />
<ui-text-input-with-label ref="channelsInput" v-model="encodingOptions.channels" :disabled="processing || isTaskFinished" :label="'Audio Channels (1 or 2)'" class="m-2 max-w-40" />
<ui-text-input-with-label ref="codecInput" v-model="encodingOptions.codec" :disabled="processing || isTaskFinished" :label="'Audio Codec'" class="m-2 max-w-40" />
</div>
<p class="text-sm text-warning">Warning: Do not update these settings unless you are familiar with ffmpeg encoding options.</p>
</div>
</transition>
</div>
<div class="mb-4"> <div class="mb-4">
<div v-if="selectedTool === 'embed'" class="flex items-start mb-2"> <div v-if="isEmbedTool" class="flex items-start mb-2">
<span class="material-icons text-base text-warning pt-1">star</span> <span class="material-icons text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">Metadata will be embedded in the audio tracks inside your audiobook folder.</p> <p class="text-gray-200 ml-2">Metadata will be embedded in the audio tracks inside your audiobook folder.</p>
</div> </div>
@@ -85,21 +108,21 @@
</p> </p>
</div> </div>
<div class="flex items-start mb-2"> <div v-if="shouldBackupAudioFiles || isM4BTool" class="flex items-start mb-2">
<span class="material-icons text-base text-warning pt-1">star</span> <span class="material-icons text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2"> <p class="text-gray-200 ml-2">
A backup of your original audio files will be stored in <span class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono">/metadata/cache/items/{{ libraryItemId }}/</span>. Make sure to periodically purge items cache. A backup of your original audio files will be stored in <span class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono">/metadata/cache/items/{{ libraryItemId }}/</span>. Make sure to periodically purge items cache.
</p> </p>
</div> </div>
<div v-if="selectedTool === 'embed' && audioFiles.length > 1" class="flex items-start mb-2"> <div v-if="isEmbedTool && audioFiles.length > 1" class="flex items-start mb-2">
<span class="material-icons text-base text-warning pt-1">star</span> <span class="material-icons text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">Chapters are not embedded in multi-track audiobooks.</p> <p class="text-gray-200 ml-2">Chapters are not embedded in multi-track audiobooks.</p>
</div> </div>
<div v-if="selectedTool === 'm4b'" class="flex items-start mb-2"> <div v-if="isM4BTool" class="flex items-start mb-2">
<span class="material-icons text-base text-warning pt-1">star</span> <span class="material-icons text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">Encoding can take up to 30 minutes.</p> <p class="text-gray-200 ml-2">Encoding can take up to 30 minutes.</p>
</div> </div>
<div v-if="selectedTool === 'm4b'" class="flex items-start mb-2"> <div v-if="isM4BTool" class="flex items-start mb-2">
<span class="material-icons text-base text-warning pt-1">star</span> <span class="material-icons text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">If you have the watcher disabled you will need to re-scan this audiobook afterwards.</p> <p class="text-gray-200 ml-2">If you have the watcher disabled you will need to re-scan this audiobook afterwards.</p>
</div> </div>
@@ -152,7 +175,7 @@ export default {
if (!store.getters['user/getIsAdminOrUp']) { if (!store.getters['user/getIsAdminOrUp']) {
return redirect('/?error=unauthorized') return redirect('/?error=unauthorized')
} }
var libraryItem = await app.$axios.$get(`/api/items/${params.id}?expanded=1`).catch((error) => { const libraryItem = await app.$axios.$get(`/api/items/${params.id}?expanded=1`).catch((error) => {
console.error('Failed', error) console.error('Failed', error)
return false return false
}) })
@@ -180,7 +203,14 @@ export default {
isFinished: false, isFinished: false,
toneObject: null, toneObject: null,
selectedTool: 'embed', selectedTool: 'embed',
isCancelingEncode: false isCancelingEncode: false,
showEncodeOptions: false,
shouldBackupAudioFiles: true,
encodingOptions: {
bitrate: '64k',
channels: '2',
codec: 'aac'
}
} }
}, },
watch: { watch: {
@@ -193,6 +223,12 @@ export default {
} }
}, },
computed: { computed: {
isEmbedTool() {
return this.selectedTool === 'embed'
},
isM4BTool() {
return this.selectedTool === 'm4b'
},
libraryItemId() { libraryItemId() {
return this.libraryItem.id return this.libraryItem.id
}, },
@@ -244,6 +280,9 @@ export default {
} }
}, },
methods: { methods: {
toggleBackupAudioFiles(val) {
localStorage.setItem('embedMetadataShouldBackup', val ? 1 : 0)
},
cancelEncodeClick() { cancelEncodeClick() {
this.isCancelingEncode = true this.isCancelingEncode = true
this.$axios this.$axios
@@ -260,9 +299,23 @@ export default {
}) })
}, },
encodeM4bClick() { encodeM4bClick() {
if (this.$refs.bitrateInput) this.$refs.bitrateInput.blur()
if (this.$refs.channelsInput) this.$refs.channelsInput.blur()
if (this.$refs.codecInput) this.$refs.codecInput.blur()
let queryStr = ''
if (this.showEncodeOptions) {
const options = []
if (this.encodingOptions.bitrate) options.push(`bitrate=${this.encodingOptions.bitrate}`)
if (this.encodingOptions.channels) options.push(`channels=${this.encodingOptions.channels}`)
if (this.encodingOptions.codec) options.push(`codec=${this.encodingOptions.codec}`)
if (options.length) {
queryStr = `?${options.join('&')}`
}
}
this.processing = true this.processing = true
this.$axios this.$axios
.$post(`/api/tools/item/${this.libraryItemId}/encode-m4b`) .$post(`/api/tools/item/${this.libraryItemId}/encode-m4b${queryStr}`)
.then(() => { .then(() => {
console.log('Ab m4b merge started') console.log('Ab m4b merge started')
}) })
@@ -287,7 +340,7 @@ export default {
updateAudioFileMetadata() { updateAudioFileMetadata() {
this.processing = true this.processing = true
this.$axios this.$axios
.$post(`/api/tools/item/${this.libraryItemId}/embed-metadata?tone=1`) .$post(`/api/tools/item/${this.libraryItemId}/embed-metadata?backup=${this.shouldBackupAudioFiles ? 1 : 0}`)
.then(() => { .then(() => {
console.log('Audio metadata encode started') console.log('Audio metadata encode started')
}) })
@@ -305,9 +358,14 @@ export default {
console.log('audio metadata finished', data) console.log('audio metadata finished', data)
if (data.libraryItemId !== this.libraryItemId) return if (data.libraryItemId !== this.libraryItemId) return
this.processing = false this.processing = false
this.isFinished = true
this.audiofilesEncoding = {} this.audiofilesEncoding = {}
this.$toast.success('Audio file metadata updated')
if (data.failed) {
this.$toast.error(data.error)
} else {
this.isFinished = true
this.$toast.success('Audio file metadata updated')
}
}, },
audiofileMetadataStarted(data) { audiofileMetadataStarted(data) {
if (data.libraryItemId !== this.libraryItemId) return if (data.libraryItemId !== this.libraryItemId) return
@@ -333,6 +391,9 @@ export default {
} }
if (this.task) this.taskUpdated(this.task) if (this.task) this.taskUpdated(this.task)
const shouldBackupAudioFiles = localStorage.getItem('embedMetadataShouldBackup')
this.shouldBackupAudioFiles = shouldBackupAudioFiles != 0
}, },
fetchToneObject() { fetchToneObject() {
this.$axios this.$axios
+54 -35
View File
@@ -4,12 +4,23 @@
<div class="flex items-center px-4 py-4 cursor-pointer" @click="openMapOptions = !openMapOptions" @mousedown.prevent @mouseup.prevent> <div class="flex items-center px-4 py-4 cursor-pointer" @click="openMapOptions = !openMapOptions" @mousedown.prevent @mouseup.prevent>
<span class="material-icons text-2xl">{{ openMapOptions ? 'expand_less' : 'expand_more' }}</span> <span class="material-icons text-2xl">{{ openMapOptions ? 'expand_less' : 'expand_more' }}</span>
<p class="ml-4 text-gray-200 text-lg">Map details</p> <p class="ml-4 text-gray-200 text-lg">{{ $strings.HeaderMapDetails }}</p>
<div class="flex-grow" />
<div class="w-64 flex">
<button class="w-32 h-8 rounded-l-md shadow-md border border-gray-600" :class="!isMapOverwrite ? 'bg-bg text-white/30' : 'bg-primary'" @click.stop.prevent="mapDetailsType = 'overwrite'">
<p class="text-sm">{{ $strings.LabelOverwrite }}</p>
</button>
<button class="w-32 h-8 rounded-r-md shadow-md border border-gray-600" :class="!isMapAppend ? 'bg-bg text-white/30' : 'bg-primary'" @click.stop.prevent="mapDetailsType = 'append'">
<p class="text-sm">{{ $strings.LabelAppend }}</p>
</button>
</div>
</div> </div>
<div class="overflow-hidden"> <div class="overflow-hidden">
<transition name="slide"> <transition name="slide">
<div v-if="openMapOptions" class="flex flex-wrap"> <div v-if="openMapOptions" class="flex flex-wrap">
<div v-if="!isPodcastLibrary" class="flex items-center px-4 w-1/2"> <div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.subtitle" /> <ui-checkbox v-model="selectedBatchUsage.subtitle" />
<ui-text-input-with-label ref="subtitleInput" v-model="batchDetails.subtitle" :disabled="!selectedBatchUsage.subtitle" :label="$strings.LabelSubtitle" class="mb-4 ml-4" /> <ui-text-input-with-label ref="subtitleInput" v-model="batchDetails.subtitle" :disabled="!selectedBatchUsage.subtitle" :label="$strings.LabelSubtitle" class="mb-4 ml-4" />
</div> </div>
@@ -18,13 +29,13 @@
<!-- Authors filter only contains authors in this library, use query input to query all authors --> <!-- Authors filter only contains authors in this library, use query input to query all authors -->
<ui-multi-select-query-input ref="authorsSelect" v-model="batchDetails.authors" :disabled="!selectedBatchUsage.authors" :label="$strings.LabelAuthors" endpoint="authors/search" class="mb-4 ml-4" /> <ui-multi-select-query-input ref="authorsSelect" v-model="batchDetails.authors" :disabled="!selectedBatchUsage.authors" :label="$strings.LabelAuthors" endpoint="authors/search" class="mb-4 ml-4" />
</div> </div>
<div v-if="!isPodcastLibrary" class="flex items-center px-4 w-1/2"> <div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.publishedYear" /> <ui-checkbox v-model="selectedBatchUsage.publishedYear" />
<ui-text-input-with-label ref="publishedYearInput" v-model="batchDetails.publishedYear" :disabled="!selectedBatchUsage.publishedYear" :label="$strings.LabelPublishYear" class="mb-4 ml-4" /> <ui-text-input-with-label ref="publishedYearInput" v-model="batchDetails.publishedYear" :disabled="!selectedBatchUsage.publishedYear" :label="$strings.LabelPublishYear" class="mb-4 ml-4" />
</div> </div>
<div v-if="!isPodcastLibrary" class="flex items-center px-4 w-1/2"> <div v-if="!isPodcastLibrary" class="flex items-center px-4 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.series" /> <ui-checkbox v-model="selectedBatchUsage.series" />
<ui-multi-select ref="seriesSelect" v-model="batchDetails.series" :disabled="!selectedBatchUsage.series" :label="$strings.LabelSeries" :items="seriesItems" @newItem="newSeriesItem" @removedItem="removedSeriesItem" class="mb-4 ml-4" /> <ui-multi-select ref="seriesSelect" v-model="batchDetails.series" :disabled="!selectedBatchUsage.series" :label="$strings.LabelSeries" :items="existingSeriesNames" @newItem="newSeriesItem" @removedItem="removedSeriesItem" class="mb-4 ml-4" />
</div> </div>
<div class="flex items-center px-4 w-1/2"> <div class="flex items-center px-4 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.genres" /> <ui-checkbox v-model="selectedBatchUsage.genres" />
@@ -38,15 +49,15 @@
<ui-checkbox v-model="selectedBatchUsage.narrators" /> <ui-checkbox v-model="selectedBatchUsage.narrators" />
<ui-multi-select ref="narratorsSelect" v-model="batchDetails.narrators" :disabled="!selectedBatchUsage.narrators" :label="$strings.LabelNarrators" :items="narratorItems" @newItem="newNarratorItem" @removedItem="removedNarratorItem" class="mb-4 ml-4" /> <ui-multi-select ref="narratorsSelect" v-model="batchDetails.narrators" :disabled="!selectedBatchUsage.narrators" :label="$strings.LabelNarrators" :items="narratorItems" @newItem="newNarratorItem" @removedItem="removedNarratorItem" class="mb-4 ml-4" />
</div> </div>
<div v-if="!isPodcastLibrary" class="flex items-center px-4 w-1/2"> <div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.publisher" /> <ui-checkbox v-model="selectedBatchUsage.publisher" />
<ui-text-input-with-label ref="publisherInput" v-model="batchDetails.publisher" :disabled="!selectedBatchUsage.publisher" :label="$strings.LabelPublisher" class="mb-4 ml-4" /> <ui-text-input-with-label ref="publisherInput" v-model="batchDetails.publisher" :disabled="!selectedBatchUsage.publisher" :label="$strings.LabelPublisher" class="mb-4 ml-4" />
</div> </div>
<div class="flex items-center px-4 w-1/2"> <div v-if="!isMapAppend" class="flex items-center px-4 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.language" /> <ui-checkbox v-model="selectedBatchUsage.language" />
<ui-text-input-with-label ref="languageInput" v-model="batchDetails.language" :disabled="!selectedBatchUsage.language" :label="$strings.LabelLanguage" class="mb-4 ml-4" /> <ui-text-input-with-label ref="languageInput" v-model="batchDetails.language" :disabled="!selectedBatchUsage.language" :label="$strings.LabelLanguage" class="mb-4 ml-4" />
</div> </div>
<div class="flex items-center px-4 w-1/2"> <div v-if="!isMapAppend" class="flex items-center px-4 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.explicit" /> <ui-checkbox v-model="selectedBatchUsage.explicit" />
<div class="ml-4"> <div class="ml-4">
<ui-checkbox <ui-checkbox
@@ -96,11 +107,14 @@ export default {
} }
const libraryItemIds = store.state.globals.selectedMediaItems.map((i) => i.id) const libraryItemIds = store.state.globals.selectedMediaItems.map((i) => i.id)
const libraryItems = await app.$axios.$post(`/api/items/batch/get`, { libraryItemIds }).catch((error) => { const libraryItems = await app.$axios
const errorMsg = error.response.data || 'Failed to get items' .$post(`/api/items/batch/get`, { libraryItemIds })
console.error(errorMsg, error) .then((res) => res.libraryItems)
return [] .catch((error) => {
}) const errorMsg = error.response.data || 'Failed to get items'
console.error(errorMsg, error)
return []
})
return { return {
mediaType: libraryItems[0].mediaType, mediaType: libraryItems[0].mediaType,
libraryItems libraryItems
@@ -111,10 +125,10 @@ export default {
isProcessing: false, isProcessing: false,
libraryItemCopies: [], libraryItemCopies: [],
isScrollable: false, isScrollable: false,
newSeriesNames: [],
newTagItems: [], newTagItems: [],
newGenreItems: [], newGenreItems: [],
newNarratorItems: [], newNarratorItems: [],
mapDetailsType: 'overwrite',
batchDetails: { batchDetails: {
subtitle: null, subtitle: null,
authors: null, authors: null,
@@ -139,10 +153,17 @@ export default {
language: false, language: false,
explicit: false explicit: false
}, },
appendableKeys: ['authors', 'genres', 'tags', 'narrators', 'series'],
openMapOptions: false openMapOptions: false
} }
}, },
computed: { computed: {
isMapOverwrite() {
return this.mapDetailsType === 'overwrite'
},
isMapAppend() {
return this.mapDetailsType === 'append'
},
isPodcastLibrary() { isPodcastLibrary() {
return this.mediaType === 'podcast' return this.mediaType === 'podcast'
}, },
@@ -155,9 +176,6 @@ export default {
tagItems() { tagItems() {
return this.tags.concat(this.newTagItems) return this.tags.concat(this.newTagItems)
}, },
seriesItems() {
return [...this.existingSeriesNames, ...this.newSeriesNames]
},
narratorItems() { narratorItems() {
return [...this.narrators, ...this.newNarratorItems] return [...this.narrators, ...this.newNarratorItems]
}, },
@@ -216,31 +234,32 @@ export default {
mapBatchDetails() { mapBatchDetails() {
this.blurBatchForm() this.blurBatchForm()
var batchMapPayload = {} const batchMapPayload = {}
for (const key in this.selectedBatchUsage) { for (const key in this.selectedBatchUsage) {
if (this.selectedBatchUsage[key]) { if (!this.selectedBatchUsage[key]) continue
if (key === 'series') { if (this.isMapAppend && !this.appendableKeys.includes(key)) continue
// Map string of series to series objects
batchMapPayload[key] = this.batchDetails[key].map((seItem) => { if (key === 'series') {
var existingSeries = this.series.find((se) => se.name.toLowerCase() === seItem.toLowerCase().trim()) // Map string of series to series objects
if (existingSeries) { batchMapPayload[key] = this.batchDetails[key].map((seItem) => {
return existingSeries const existingSeries = this.series.find((se) => se.name.toLowerCase() === seItem.toLowerCase().trim())
} else { if (existingSeries) {
return { return existingSeries
id: `new-${Math.floor(Math.random() * 10000)}`, } else {
name: seItem return {
} id: `new-${Math.floor(Math.random() * 10000)}`,
name: seItem
} }
}) }
} else { })
batchMapPayload[key] = this.batchDetails[key] } else {
} batchMapPayload[key] = this.batchDetails[key]
} }
} }
this.libraryItemCopies.forEach((li) => { this.libraryItemCopies.forEach((li) => {
var ref = this.getEditFormRef(li.id) const ref = this.getEditFormRef(li.id)
ref.mapBatchDetails(batchMapPayload) ref.mapBatchDetails(batchMapPayload, this.mapDetailsType)
}) })
this.$toast.success('Details mapped') this.$toast.success('Details mapped')
}, },
+95 -10
View File
@@ -19,9 +19,16 @@
{{ streaming ? $strings.ButtonPlaying : $strings.ButtonPlay }} {{ streaming ? $strings.ButtonPlaying : $strings.ButtonPlay }}
</ui-btn> </ui-btn>
<ui-icon-btn v-if="userCanUpdate" icon="edit" class="mx-0.5" @click="editClick" /> <!-- RSS feed -->
<ui-tooltip v-if="rssFeed" :text="$strings.LabelOpenRSSFeed" direction="top">
<ui-icon-btn icon="rss_feed" class="mx-0.5" :bg-color="rssFeed ? 'success' : 'primary'" outlined @click="showRSSFeedModal" />
</ui-tooltip>
<ui-icon-btn v-if="userCanDelete" icon="delete" class="mx-0.5" @click="removeClick" /> <button type="button" class="h-9 w-9 flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5 mx-px" @click.stop.prevent="editClick">
<span class="material-icons text-xl">edit</span>
</button>
<ui-context-menu-dropdown :items="contextMenuItems" class="mx-px" @action="contextMenuAction" />
</div> </div>
<div class="my-8 max-w-2xl"> <div class="my-8 max-w-2xl">
@@ -32,7 +39,7 @@
</div> </div>
</div> </div>
</div> </div>
<div v-show="processingRemove" class="absolute top-0 left-0 w-full h-full z-10 bg-black bg-opacity-40 flex items-center justify-center"> <div v-show="processing" class="absolute top-0 left-0 w-full h-full z-10 bg-black bg-opacity-40 flex items-center justify-center">
<ui-loading-indicator /> <ui-loading-indicator />
</div> </div>
</div> </div>
@@ -44,7 +51,7 @@ export default {
if (!store.state.user.user) { if (!store.state.user.user) {
return redirect(`/login?redirect=${route.path}`) return redirect(`/login?redirect=${route.path}`)
} }
var collection = await app.$axios.$get(`/api/collections/${params.id}`).catch((error) => { const collection = await app.$axios.$get(`/api/collections/${params.id}?include=rssfeed`).catch((error) => {
console.error('Failed', error) console.error('Failed', error)
return false return false
}) })
@@ -59,12 +66,13 @@ export default {
store.commit('libraries/addUpdateCollection', collection) store.commit('libraries/addUpdateCollection', collection)
return { return {
collectionId: collection.id collectionId: collection.id,
rssFeed: collection.rssFeed || null
} }
}, },
data() { data() {
return { return {
processingRemove: false processing: false
} }
}, },
computed: { computed: {
@@ -97,20 +105,79 @@ export default {
showPlayButton() { showPlayButton() {
return this.playableBooks.length return this.playableBooks.length
}, },
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
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']
},
contextMenuItems() {
const items = [
{
text: this.$strings.MessagePlaylistCreateFromCollection,
action: 'create-playlist'
}
]
if (this.userIsAdminOrUp || this.rssFeed) {
items.push({
text: this.$strings.LabelOpenRSSFeed,
action: 'open-rss-feed'
})
}
if (this.userCanDelete) {
items.push({
text: this.$strings.ButtonDelete,
action: 'delete'
})
}
return items
} }
}, },
methods: { methods: {
showRSSFeedModal() {
this.$store.commit('globals/setRSSFeedOpenCloseModal', {
id: this.collectionId,
name: this.collectionName,
type: 'collection',
feed: this.rssFeed
})
},
contextMenuAction(action) {
if (action === 'delete') {
this.removeClick()
} else if (action === 'create-playlist') {
this.createPlaylistFromCollection()
} else if (action === 'open-rss-feed') {
this.showRSSFeedModal()
}
},
createPlaylistFromCollection() {
this.processing = true
this.$axios
.$post(`/api/playlists/collection/${this.collectionId}`)
.then((playlist) => {
if (playlist) {
this.$toast.success(this.$strings.ToastPlaylistCreateSuccess)
this.$router.push(`/playlist/${playlist.id}`)
}
})
.catch((error) => {
const errMsg = error.response ? error.response.data || '' : ''
this.$toast.error(errMsg || this.$strings.ToastPlaylistCreateFailed)
})
.finally(() => {
this.processing = false
})
},
editClick() { editClick() {
this.$store.commit('globals/setEditCollection', this.collection) this.$store.commit('globals/setEditCollection', this.collection)
}, },
removeClick() { removeClick() {
if (confirm(this.$getString('MessageConfirmRemoveCollection', [this.collectionName]))) { if (confirm(this.$getString('MessageConfirmRemoveCollection', [this.collectionName]))) {
this.processingRemove = true this.processing = true
this.$axios this.$axios
.$delete(`/api/collections/${this.collection.id}`) .$delete(`/api/collections/${this.collection.id}`)
.then(() => { .then(() => {
@@ -121,7 +188,7 @@ export default {
this.$toast.error(this.$strings.ToastCollectionRemoveFailed) this.$toast.error(this.$strings.ToastCollectionRemoveFailed)
}) })
.finally(() => { .finally(() => {
this.processingRemove = false this.processing = false
}) })
} }
}, },
@@ -164,9 +231,27 @@ export default {
queueItems queueItems
}) })
} }
},
rssFeedOpen(data) {
if (data.entityId === this.collectionId) {
console.log('RSS Feed Opened', data)
this.rssFeed = data
}
},
rssFeedClosed(data) {
if (data.entityId === this.collectionId) {
console.log('RSS Feed Closed', data)
this.rssFeed = null
}
} }
}, },
mounted() {}, mounted() {
beforeDestroy() {} this.$root.socket.on('rss_feed_open', this.rssFeedOpen)
this.$root.socket.on('rss_feed_closed', this.rssFeedClosed)
},
beforeDestroy() {
this.$root.socket.off('rss_feed_open', this.rssFeedOpen)
this.$root.socket.off('rss_feed_closed', this.rssFeedClosed)
}
} }
</script> </script>
+8 -7
View File
@@ -2,9 +2,9 @@
<div id="page-wrapper" class="page p-2 md:p-6 overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''"> <div id="page-wrapper" class="page p-2 md:p-6 overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
<app-config-side-nav :is-open.sync="sideDrawerOpen" /> <app-config-side-nav :is-open.sync="sideDrawerOpen" />
<div class="configContent" :class="`page-${currentPage}`"> <div class="configContent" :class="`page-${currentPage}`">
<div v-show="isMobile" class="w-full pb-4 px-2 flex border-b border-white border-opacity-10 mb-2"> <div v-show="isMobilePortrait" class="w-full pb-4 px-2 flex border-b border-white border-opacity-10 mb-2 cursor-pointer" @click.stop.prevent="toggleShowMore">
<span class="material-icons text-2xl cursor-pointer" @click.stop.prevent="showMore">more_vert</span> <span class="material-icons text-2xl cursor-pointer">arrow_forward</span>
<p class="pl-3 capitalize">{{ currentPage }}</p> <p class="pl-3 capitalize">{{ $strings.HeaderSettings }}</p>
</div> </div>
<nuxt-child /> <nuxt-child />
</div> </div>
@@ -35,8 +35,8 @@ export default {
} }
}, },
computed: { computed: {
isMobile() { isMobilePortrait() {
return this.$store.state.globals.isMobile return this.$store.state.globals.isMobilePortrait
}, },
streamLibraryItem() { streamLibraryItem() {
return this.$store.state.streamLibraryItem return this.$store.state.streamLibraryItem
@@ -54,13 +54,14 @@ export default {
else if (pageName === 'stats') return this.$strings.HeaderYourStats else if (pageName === 'stats') return this.$strings.HeaderYourStats
else if (pageName === 'library-stats') return this.$strings.HeaderLibraryStats else if (pageName === 'library-stats') return this.$strings.HeaderLibraryStats
else if (pageName === 'users') return this.$strings.HeaderUsers else if (pageName === 'users') return this.$strings.HeaderUsers
else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils
} }
return this.$strings.HeaderSettings return this.$strings.HeaderSettings
} }
}, },
methods: { methods: {
showMore() { toggleShowMore() {
this.sideDrawerOpen = true this.sideDrawerOpen = !this.sideDrawerOpen
}, },
setDeveloperMode() { setDeveloperMode() {
var value = !this.$store.state.developerMode var value = !this.$store.state.developerMode
+2
View File
@@ -33,6 +33,8 @@
</div> </div>
<tables-backups-table /> <tables-backups-table />
<modals-backup-schedule-modal v-model="showCronBuilder" :cron-expression.sync="cronExpression" />
</app-settings-content> </app-settings-content>
</div> </div>
</template> </template>
+33 -35
View File
@@ -7,30 +7,30 @@
<h2 class="font-semibold">{{ $strings.HeaderSettingsGeneral }}</h2> <h2 class="font-semibold">{{ $strings.HeaderSettingsGeneral }}</h2>
</div> </div>
<div class="flex items-end py-2"> <div class="flex items-end py-2">
<ui-toggle-switch v-model="newServerSettings.storeCoverWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithItem', val)" /> <ui-toggle-switch labeledBy="settings-store-cover-with-items" v-model="newServerSettings.storeCoverWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithItem', val)" />
<ui-tooltip :text="$strings.LabelSettingsStoreCoversWithItemHelp"> <ui-tooltip :text="$strings.LabelSettingsStoreCoversWithItemHelp">
<p class="pl-4"> <p class="pl-4">
{{ $strings.LabelSettingsStoreCoversWithItem }} <span id="settings-store-cover-with-items">{{ $strings.LabelSettingsStoreCoversWithItem }}</span>
<span class="material-icons icon-text">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.storeMetadataWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeMetadataWithItem', val)" /> <ui-toggle-switch labeledBy="settings-store-metadata-with-items" v-model="newServerSettings.storeMetadataWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeMetadataWithItem', val)" />
<ui-tooltip :text="$strings.LabelSettingsStoreMetadataWithItemHelp"> <ui-tooltip :text="$strings.LabelSettingsStoreMetadataWithItemHelp">
<p class="pl-4"> <p class="pl-4">
{{ $strings.LabelSettingsStoreMetadataWithItem }} <span id="settings-store-metadata-with-items">{{ $strings.LabelSettingsStoreMetadataWithItem }}</span>
<span class="material-icons icon-text">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" /> <ui-toggle-switch labeledBy="settings-sorting-ignore-prefixes" v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" />
<ui-tooltip :text="$strings.LabelSettingsSortingIgnorePrefixesHelp"> <ui-tooltip :text="$strings.LabelSettingsSortingIgnorePrefixesHelp">
<p class="pl-4"> <p class="pl-4">
{{ $strings.LabelSettingsSortingIgnorePrefixes }} <span id="settings-sorting-ignore-prefixes">{{ $strings.LabelSettingsSortingIgnorePrefixes }}</span>
<span class="material-icons icon-text">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
@@ -40,8 +40,8 @@
</div> </div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" /> <ui-toggle-switch labeledBy="settings-chromecast-support" v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
<p class="pl-4">{{ $strings.LabelSettingsChromecastSupport }}</p> <p class="pl-4" id="settings-chromecast-support">{{ $strings.LabelSettingsChromecastSupport }}</p>
</div> </div>
<div class="pt-4"> <div class="pt-4">
@@ -49,33 +49,31 @@
</div> </div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="homepageUseBookshelfView" :disabled="updatingServerSettings" @input="updateHomeUseBookshelfView" /> <ui-toggle-switch labeledBy="settings-home-page-uses-bookshelf" v-model="homepageUseBookshelfView" :disabled="updatingServerSettings" @input="updateHomeUseBookshelfView" />
<ui-tooltip :text="$strings.LabelSettingsBookshelfViewHelp"> <ui-tooltip :text="$strings.LabelSettingsBookshelfViewHelp">
<p class="pl-4"> <p class="pl-4">
{{ $strings.LabelSettingsHomePageBookshelfView }} <span id="settings-home-page-uses-bookshelf">{{ $strings.LabelSettingsHomePageBookshelfView }}</span>
<span class="material-icons icon-text">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="useBookshelfView" :disabled="updatingServerSettings" @input="updateUseBookshelfView" /> <ui-toggle-switch labeledBy="settings-library-uses-bookshelf" v-model="useBookshelfView" :disabled="updatingServerSettings" @input="updateUseBookshelfView" />
<ui-tooltip :text="$strings.LabelSettingsBookshelfViewHelp"> <ui-tooltip :text="$strings.LabelSettingsBookshelfViewHelp">
<p class="pl-4"> <p class="pl-4">
{{ $strings.LabelSettingsLibraryBookshelfView }} <span id="settings-library-uses-bookshelf">{{ $strings.LabelSettingsLibraryBookshelfView }}</span>
<span class="material-icons icon-text">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
<div class="py-2"> <div class="py-2">
<p class="px-1 text-sm font-semibold">{{ $strings.LabelSettingsDateFormat }}</p> <ui-dropdown :label="$strings.LabelSettingsDateFormat" v-model="newServerSettings.dateFormat" :items="dateFormats" small class="max-w-52" @input="(val) => updateSettingsKey('dateFormat', val)" />
<ui-dropdown v-model="newServerSettings.dateFormat" :items="dateFormats" small class="max-w-52" @input="(val) => updateSettingsKey('dateFormat', val)" />
</div> </div>
<div class="py-2"> <div class="py-2">
<p class="px-1 text-sm font-semibold">{{ $strings.LabelLanguageDefaultServer }}</p> <ui-dropdown :label="$strings.LabelLanguageDefaultServer" ref="langDropdown" v-model="newServerSettings.language" :items="$languageCodeOptions" small class="max-w-52" @input="updateServerLanguage" />
<ui-dropdown ref="langDropdown" v-model="newServerSettings.language" :items="$languageCodeOptions" small class="max-w-52" @input="updateServerLanguage" />
</div> </div>
</div> </div>
@@ -85,20 +83,20 @@
</div> </div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" /> <ui-toggle-switch labeledBy="settings-parse-subtitles" v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" />
<ui-tooltip :text="$strings.LabelSettingsParseSubtitlesHelp"> <ui-tooltip :text="$strings.LabelSettingsParseSubtitlesHelp">
<p class="pl-4"> <p class="pl-4">
{{ $strings.LabelSettingsParseSubtitles }} <span id="settings-parse-subtitles">{{ $strings.LabelSettingsParseSubtitles }}</span>
<span class="material-icons icon-text">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" /> <ui-toggle-switch labeledBy="settings-find-covers" v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" />
<ui-tooltip :text="$strings.LabelSettingsFindCoversHelp"> <ui-tooltip :text="$strings.LabelSettingsFindCoversHelp">
<p class="pl-4"> <p class="pl-4">
{{ $strings.LabelSettingsFindCovers }} <span id="settings-find-covers">{{ $strings.LabelSettingsFindCovers }}</span>
<span class="material-icons icon-text">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
@@ -109,50 +107,50 @@
</div> </div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerPreferOverdriveMediaMarker" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOverdriveMediaMarker', val)" /> <ui-toggle-switch labeledBy="settings-overdrive-media-markers" v-model="newServerSettings.scannerPreferOverdriveMediaMarker" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOverdriveMediaMarker', val)" />
<ui-tooltip :text="$strings.LabelSettingsOverdriveMediaMarkersHelp"> <ui-tooltip :text="$strings.LabelSettingsOverdriveMediaMarkersHelp">
<p class="pl-4"> <p class="pl-4">
{{ $strings.LabelSettingsOverdriveMediaMarkers }} <span id="settings-overdrive-media-markers">{{ $strings.LabelSettingsOverdriveMediaMarkers }}</span>
<span class="material-icons icon-text">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerPreferAudioMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferAudioMetadata', val)" /> <ui-toggle-switch labeledBy="settings-prefer-audio-metadata" v-model="newServerSettings.scannerPreferAudioMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferAudioMetadata', val)" />
<ui-tooltip :text="$strings.LabelSettingsPreferAudioMetadataHelp"> <ui-tooltip :text="$strings.LabelSettingsPreferAudioMetadataHelp">
<p class="pl-4"> <p class="pl-4">
{{ $strings.LabelSettingsPreferAudioMetadata }} <span id="settings-prefer-audio-metadata">{{ $strings.LabelSettingsPreferAudioMetadata }}</span>
<span class="material-icons icon-text">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerPreferOpfMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOpfMetadata', val)" /> <ui-toggle-switch labeledBy="settings-prefer-opf-metadata" v-model="newServerSettings.scannerPreferOpfMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOpfMetadata', val)" />
<ui-tooltip :text="$strings.LabelSettingsPreferOPFMetadataHelp"> <ui-tooltip :text="$strings.LabelSettingsPreferOPFMetadataHelp">
<p class="pl-4"> <p class="pl-4">
{{ $strings.LabelSettingsPreferOPFMetadata }} <span id="settings-prefer-opf-metadata">{{ $strings.LabelSettingsPreferOPFMetadata }}</span>
<span class="material-icons icon-text">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" /> <ui-toggle-switch labeledBy="settings-prefer-matched-metadata" v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" />
<ui-tooltip :text="$strings.LabelSettingsPreferMatchedMetadataHelp"> <ui-tooltip :text="$strings.LabelSettingsPreferMatchedMetadataHelp">
<p class="pl-4"> <p class="pl-4">
{{ $strings.LabelSettingsPreferMatchedMetadata }} <span id="settings-prefer-matched-metadata">{{ $strings.LabelSettingsPreferMatchedMetadata }}</span>
<span class="material-icons icon-text">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerDisableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', val)" /> <ui-toggle-switch labeledBy="settings-disable-watcher" v-model="newServerSettings.scannerDisableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', val)" />
<ui-tooltip :text="$strings.LabelSettingsDisableWatcherHelp"> <ui-tooltip :text="$strings.LabelSettingsDisableWatcherHelp">
<p class="pl-4"> <p class="pl-4">
{{ $strings.LabelSettingsDisableWatcher }} <span id="settings-disable-watcher">{{ $strings.LabelSettingsDisableWatcher }}</span>
<span class="material-icons icon-text">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
@@ -163,11 +161,11 @@
</div> </div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="showExperimentalFeatures" /> <ui-toggle-switch labeledBy="settings-experimental-features" v-model="showExperimentalFeatures" />
<ui-tooltip :text="$strings.LabelSettingsExperimentalFeaturesHelp"> <ui-tooltip :text="$strings.LabelSettingsExperimentalFeaturesHelp">
<p class="pl-4"> <p class="pl-4">
{{ $strings.LabelSettingsExperimentalFeatures }} <span id="settings-experimental-features">{{ $strings.LabelSettingsExperimentalFeatures }}</span>
<a href="https://github.com/advplyr/audiobookshelf/discussions/75" target="_blank"> <a :aria-label="$strings.LabelSettingsExperimentalFeaturesHelp" href="https://github.com/advplyr/audiobookshelf/discussions/75" target="_blank">
<span class="material-icons icon-text">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</a> </a>
</p> </p>
@@ -175,10 +173,10 @@
</div> </div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.enableEReader" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('enableEReader', val)" /> <ui-toggle-switch labeledBy="settings-enable-e-reader" v-model="newServerSettings.enableEReader" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('enableEReader', val)" />
<ui-tooltip :text="$strings.LabelSettingsEnableEReaderHelp"> <ui-tooltip :text="$strings.LabelSettingsEnableEReaderHelp">
<p class="pl-4"> <p class="pl-4">
{{ $strings.LabelSettingsEnableEReader }} <span id="settings-enable-e-reader">{{ $strings.LabelSettingsEnableEReader }}</span>
<span class="material-icons icon-text">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
@@ -0,0 +1,171 @@
<template>
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8 relative" style="min-height: 200px">
<div class="flex items-center mb-4">
<nuxt-link to="/config/item-metadata-utils" class="w-8 h-8 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center">
<span class="material-icons text-2xl">arrow_back</span>
</nuxt-link>
<h1 class="text-xl mx-2">{{ $strings.HeaderManageGenres }}</h1>
</div>
<p v-if="!genres.length && !loading" class="text-center py-8 text-lg">{{ $strings.MessageNoGenres }}</p>
<div class="border border-white/10">
<template v-for="(genre, index) in genres">
<div :key="genre" class="w-full p-2 flex items-center text-gray-400 hover:text-white" :class="{ 'bg-primary/20': index % 2 === 0 }">
<p v-if="editingGenre !== genre" class="text-sm md:text-base text-gray-100">{{ genre }}</p>
<ui-text-input v-else v-model="newGenreName" />
<div class="flex-grow" />
<template v-if="editingGenre !== genre">
<ui-icon-btn v-if="editingGenre !== genre" icon="edit" borderless :size="8" icon-font-size="1.1rem" class="mx-1" @click="editClick(genre)" />
<ui-icon-btn v-if="editingGenre !== genre" icon="delete" borderless :size="8" icon-font-size="1.1rem" @click="removeClick(genre)" />
</template>
<template v-else>
<ui-btn color="success" small class="mx-2" @click.stop="saveClick">{{ $strings.ButtonSave }}</ui-btn>
<ui-btn small @click.stop="cancelEditClick">{{ $strings.ButtonCancel }}</ui-btn>
</template>
</div>
</template>
</div>
<div v-if="loading" class="absolute top-0 left-0 w-full h-full bg-black/25 rounded-md">
<div class="sticky top-0 left-0 w-full h-full flex items-center justify-center" style="max-height: 80vh">
<ui-loading-indicator />
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
loading: false,
genres: [],
editingGenre: null,
newGenreName: ''
}
},
watch: {},
computed: {},
methods: {
cancelEditClick() {
this.newGenreName = ''
this.editingGenre = null
},
removeClick(genre) {
const payload = {
message: `Are you sure you want to remove genre "${genre}" from all items?`,
callback: (confirmed) => {
if (confirmed) {
this.removeGenre(genre)
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
editClick(genre) {
this.newGenreName = genre
this.editingGenre = genre
},
saveClick() {
this.newGenreName = this.newGenreName.trim()
if (!this.newGenreName) {
return
}
if (this.editingGenre === this.newGenreName) {
this.cancelEditClick()
return
}
const genreNameExists = this.genres.find((g) => g !== this.editingGenre && g === this.newGenreName)
const genreNameExistsOfDifferentCase = !genreNameExists ? this.genres.find((g) => g !== this.editingGenre && g.toLowerCase() === this.newGenreName.toLowerCase()) : null
let message = this.$getString('MessageConfirmRenameGenre', [this.editingGenre, this.newGenreName])
if (genreNameExists) {
message += `<br><span class="text-sm">${this.$strings.MessageConfirmRenameGenreMergeNote}</span>`
} else if (genreNameExistsOfDifferentCase) {
message += `<br><span class="text-warning text-sm">${this.$getString('MessageConfirmRenameGenreWarning', [genreNameExistsOfDifferentCase])}</span>`
}
const payload = {
message,
callback: (confirmed) => {
if (confirmed) {
this.renameGenre()
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
renameGenre() {
this.loading = true
let _newGenreName = this.newGenreName
let _editingGenre = this.editingGenre
const payload = {
genre: _editingGenre,
newGenre: _newGenreName
}
this.$axios
.$post('/api/genres/rename', payload)
.then((res) => {
this.$toast.success(this.$getString('MessageItemsUpdated', [res.numItemsUpdated]))
if (res.genreMerged) {
this.genres = this.genres.filter((g) => g !== _newGenreName)
}
this.genres = this.genres.map((g) => {
if (g === _editingGenre) return _newGenreName
return g
})
this.cancelEditClick()
})
.catch((error) => {
console.error('Failed to rename genre', error)
this.$toast.error('Failed to rename genre')
})
.finally(() => {
this.loading = false
})
},
removeGenre(genre) {
this.loading = true
this.$axios
.$delete(`/api/genres/${this.$encode(genre)}`)
.then((res) => {
this.$toast.success(this.$getString('MessageItemsUpdated', [res.numItemsUpdated]))
this.genres = this.genres.filter((g) => g !== genre)
})
.catch((error) => {
console.error('Failed to remove genre', error)
this.$toast.error('Failed to remove genre')
})
.finally(() => {
this.loading = false
})
},
init() {
this.loading = true
this.$axios
.$get('/api/genres')
.then((data) => {
this.genres = (data.genres || []).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
})
.catch((error) => {
console.error('Failed to load genres', error)
})
.finally(() => {
this.loading = false
})
}
},
mounted() {
this.init()
},
beforeDestroy() {}
}
</script>
@@ -0,0 +1,35 @@
<template>
<div>
<app-settings-content :header-text="'Item Metadata Utils'">
<nuxt-link to="/config/item-metadata-utils/tags" class="block w-full rounded bg-primary/40 hover:bg-primary/60 text-gray-300 hover:text-white p-4 mt-6 mb-2">
<div class="flex justify-between">
<p>{{ $strings.HeaderManageTags }}</p>
<span class="material-icons">arrow_forward</span>
</div>
</nuxt-link>
<nuxt-link to="/config/item-metadata-utils/genres" class="block w-full rounded bg-primary/40 hover:bg-primary/60 text-gray-300 hover:text-white p-4 my-2">
<div class="flex justify-between">
<p>{{ $strings.HeaderManageGenres }}</p>
<span class="material-icons">arrow_forward</span>
</div>
</nuxt-link>
</app-settings-content>
</div>
</template>
<script>
export default {
data() {
return {}
},
watch: {},
computed: {},
methods: {
init() {}
},
mounted() {
this.init()
},
beforeDestroy() {}
}
</script>
@@ -0,0 +1,171 @@
<template>
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8 relative" style="min-height: 200px">
<div class="flex items-center mb-4">
<nuxt-link to="/config/item-metadata-utils" class="w-8 h-8 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center">
<span class="material-icons text-2xl">arrow_back</span>
</nuxt-link>
<h1 class="text-xl mx-2">{{ $strings.HeaderManageTags }}</h1>
</div>
<p v-if="!tags.length && !loading" class="text-center py-8 text-lg">{{ $strings.MessageNoTags }}</p>
<div class="border border-white/10">
<template v-for="(tag, index) in tags">
<div :key="tag" class="w-full p-2 flex items-center text-gray-400 hover:text-white" :class="{ 'bg-primary/20': index % 2 === 0 }">
<p v-if="editingTag !== tag" class="text-sm md:text-base text-gray-100">{{ tag }}</p>
<ui-text-input v-else v-model="newTagName" />
<div class="flex-grow" />
<template v-if="editingTag !== tag">
<ui-icon-btn v-if="editingTag !== tag" icon="edit" borderless :size="8" icon-font-size="1.1rem" class="mx-1" @click="editTagClick(tag)" />
<ui-icon-btn v-if="editingTag !== tag" icon="delete" borderless :size="8" icon-font-size="1.1rem" @click="removeTagClick(tag)" />
</template>
<template v-else>
<ui-btn color="success" small class="mx-2" @click.stop="saveTagClick">{{ $strings.ButtonSave }}</ui-btn>
<ui-btn small @click.stop="cancelEditClick">{{ $strings.ButtonCancel }}</ui-btn>
</template>
</div>
</template>
</div>
<div v-if="loading" class="absolute top-0 left-0 w-full h-full bg-black/25 rounded-md">
<div class="sticky top-0 left-0 w-full h-full flex items-center justify-center" style="max-height: 80vh">
<ui-loading-indicator />
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
loading: false,
tags: [],
editingTag: null,
newTagName: ''
}
},
watch: {},
computed: {},
methods: {
cancelEditClick() {
this.newTagName = ''
this.editingTag = null
},
removeTagClick(tag) {
const payload = {
message: `Are you sure you want to remove tag "${tag}" from all items?`,
callback: (confirmed) => {
if (confirmed) {
this.removeTag(tag)
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
saveTagClick() {
this.newTagName = this.newTagName.trim()
if (!this.newTagName) {
return
}
if (this.editingTag === this.newTagName) {
this.cancelEditClick()
return
}
const tagNameExists = this.tags.find((t) => t !== this.editingTag && t === this.newTagName)
const tagNameExistsOfDifferentCase = !tagNameExists ? this.tags.find((t) => t !== this.editingTag && t.toLowerCase() === this.newTagName.toLowerCase()) : null
let message = this.$getString('MessageConfirmRenameTag', [this.editingTag, this.newTagName])
if (tagNameExists) {
message += `<br><span class="text-sm">${this.$strings.MessageConfirmRenameTagMergeNote}</span>`
} else if (tagNameExistsOfDifferentCase) {
message += `<br><span class="text-warning text-sm">${this.$getString('MessageConfirmRenameTagWarning', [tagNameExistsOfDifferentCase])}</span>`
}
const payload = {
message,
callback: (confirmed) => {
if (confirmed) {
this.renameTag()
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
renameTag() {
this.loading = true
let _newTagName = this.newTagName
let _editingTag = this.editingTag
const payload = {
tag: _editingTag,
newTag: _newTagName
}
this.$axios
.$post('/api/tags/rename', payload)
.then((res) => {
this.$toast.success(this.$getString('MessageItemsUpdated', [res.numItemsUpdated]))
if (res.tagMerged) {
this.tags = this.tags.filter((t) => t !== _newTagName)
}
this.tags = this.tags.map((t) => {
if (t === _editingTag) return _newTagName
return t
})
this.cancelEditClick()
})
.catch((error) => {
console.error('Failed to rename tag', error)
this.$toast.error('Failed to rename tag')
})
.finally(() => {
this.loading = false
})
},
removeTag(tag) {
this.loading = true
this.$axios
.$delete(`/api/tags/${this.$encode(tag)}`)
.then((res) => {
this.$toast.success(this.$getString('MessageItemsUpdated', [res.numItemsUpdated]))
this.tags = this.tags.filter((t) => t !== tag)
})
.catch((error) => {
console.error('Failed to remove tag', error)
this.$toast.error('Failed to remove tag')
})
.finally(() => {
this.loading = false
})
},
editTagClick(tag) {
this.newTagName = tag
this.editingTag = tag
},
init() {
this.loading = true
this.$axios
.$get('/api/tags')
.then((data) => {
this.tags = (data.tags || []).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
})
.catch((error) => {
console.error('Failed to load tags', error)
})
.finally(() => {
this.loading = false
})
}
},
mounted() {
this.init()
},
beforeDestroy() {}
}
</script>
+3 -3
View File
@@ -61,10 +61,10 @@
<script> <script>
export default { export default {
async asyncData({ params, redirect, app }) { async asyncData({ params, redirect, app }) {
var users = await app.$axios const users = await app.$axios
.$get('/api/users') .$get('/api/users')
.then((users) => { .then((res) => {
return users.sort((a, b) => { return res.users.sort((a, b) => {
return a.createdAt - b.createdAt return a.createdAt - b.createdAt
}) })
}) })
+97 -22
View File
@@ -34,6 +34,9 @@
<template v-if="!isVideo"> <template v-if="!isVideo">
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">by {{ podcastAuthor || 'Unknown' }}</p> <p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">by {{ podcastAuthor || 'Unknown' }}</p>
<p v-else-if="musicArtists.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl max-w-[calc(100vw-2rem)] overflow-hidden overflow-ellipsis">
<nuxt-link v-for="(artist, index) in musicArtists" :key="index" :to="`/artist/${$encode(artist)}`" class="hover:underline">{{ artist }}<span v-if="index < musicArtists.length - 1">,&nbsp;</span></nuxt-link>
</p>
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl max-w-[calc(100vw-2rem)] overflow-hidden overflow-ellipsis"> <p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl max-w-[calc(100vw-2rem)] overflow-hidden overflow-ellipsis">
by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link> by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
</p> </p>
@@ -59,6 +62,38 @@
{{ publishedYear }} {{ publishedYear }}
</div> </div>
</div> </div>
<div v-if="musicAlbum" class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Album</span>
</div>
<div>
{{ musicAlbum }}
</div>
</div>
<div v-if="musicAlbumArtist" class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Album Artist</span>
</div>
<div>
{{ musicAlbumArtist }}
</div>
</div>
<div v-if="musicTrackPretty" class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Track</span>
</div>
<div>
{{ musicTrackPretty }}
</div>
</div>
<div v-if="musicDiscPretty" class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Disc</span>
</div>
<div>
{{ musicDiscPretty }}
</div>
</div>
<div class="flex py-0.5" v-if="genres.length"> <div class="flex py-0.5" v-if="genres.length">
<div class="w-32"> <div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelGenres }}</span> <span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelGenres }}</span>
@@ -70,7 +105,7 @@
</template> </template>
</div> </div>
</div> </div>
<div v-if="tracks.length" class="flex py-0.5"> <div v-if="tracks.length || audioFile" class="flex py-0.5">
<div class="w-32"> <div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelDuration }}</span> <span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelDuration }}</span>
</div> </div>
@@ -132,6 +167,7 @@
<span v-show="!isStreaming" class="material-icons text-2xl -ml-2 pr-1 text-white">play_arrow</span> <span v-show="!isStreaming" class="material-icons text-2xl -ml-2 pr-1 text-white">play_arrow</span>
{{ isStreaming ? $strings.ButtonPlaying : $strings.ButtonPlay }} {{ isStreaming ? $strings.ButtonPlaying : $strings.ButtonPlay }}
</ui-btn> </ui-btn>
<ui-btn v-else-if="isMissing || isInvalid" color="error" :padding-x="4" small class="flex items-center h-9 mr-2"> <ui-btn v-else-if="isMissing || isInvalid" color="error" :padding-x="4" small class="flex items-center h-9 mr-2">
<span v-show="!isStreaming" class="material-icons text-2xl -ml-2 pr-1 text-white">error</span> <span v-show="!isStreaming" class="material-icons text-2xl -ml-2 pr-1 text-white">error</span>
{{ isMissing ? $strings.LabelMissing : $strings.LabelIncomplete }} {{ isMissing ? $strings.LabelMissing : $strings.LabelIncomplete }}
@@ -150,11 +186,11 @@
<ui-icon-btn icon="edit" class="mx-0.5" @click="editClick" /> <ui-icon-btn icon="edit" class="mx-0.5" @click="editClick" />
</ui-tooltip> </ui-tooltip>
<ui-tooltip v-if="!isPodcast" :text="userIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="top"> <ui-tooltip v-if="!isPodcast && !isMusic" :text="userIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="top">
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" class="mx-0.5" @click="toggleFinished" /> <ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" class="mx-0.5" @click="toggleFinished" />
</ui-tooltip> </ui-tooltip>
<ui-tooltip v-if="!isPodcast && userCanUpdate" :text="$strings.LabelCollections" direction="top"> <ui-tooltip v-if="showCollectionsButton" :text="$strings.LabelCollections" direction="top">
<ui-icon-btn icon="collections_bookmark" class="mx-0.5" outlined @click="collectionsClick" /> <ui-icon-btn icon="collections_bookmark" class="mx-0.5" outlined @click="collectionsClick" />
</ui-tooltip> </ui-tooltip>
@@ -173,7 +209,7 @@
<!-- RSS feed --> <!-- RSS feed -->
<ui-tooltip v-if="showRssFeedBtn" :text="$strings.LabelOpenRSSFeed" direction="top"> <ui-tooltip v-if="showRssFeedBtn" :text="$strings.LabelOpenRSSFeed" direction="top">
<ui-icon-btn icon="rss_feed" class="mx-0.5" :bg-color="rssFeedUrl ? 'success' : 'primary'" outlined @click="clickRSSFeed" /> <ui-icon-btn icon="rss_feed" class="mx-0.5" :bg-color="rssFeed ? 'success' : 'primary'" outlined @click="clickRSSFeed" />
</ui-tooltip> </ui-tooltip>
</div> </div>
@@ -199,7 +235,6 @@
</div> </div>
<modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" /> <modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" />
<modals-rssfeed-view-modal v-model="showRssFeedModal" :library-item="libraryItem" :feed-url="rssFeedUrl" />
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :library-item-id="libraryItemId" hide-create @select="selectBookmark" /> <modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :library-item-id="libraryItemId" hide-create @select="selectBookmark" />
</div> </div>
</template> </template>
@@ -222,7 +257,7 @@ export default {
} }
return { return {
libraryItem: item, libraryItem: item,
rssFeedUrl: item.rssFeedUrl || null rssFeed: item.rssFeed || null
} }
}, },
data() { data() {
@@ -234,7 +269,6 @@ export default {
podcastFeedEpisodes: [], podcastFeedEpisodes: [],
episodesDownloading: [], episodesDownloading: [],
episodeDownloadsQueued: [], episodeDownloadsQueued: [],
showRssFeedModal: false,
showBookmarksModal: false showBookmarksModal: false
} }
}, },
@@ -263,12 +297,18 @@ export default {
isDeveloperMode() { isDeveloperMode() {
return this.$store.state.developerMode return this.$store.state.developerMode
}, },
isBook() {
return this.libraryItem.mediaType === 'book'
},
isPodcast() { isPodcast() {
return this.libraryItem.mediaType === 'podcast' return this.libraryItem.mediaType === 'podcast'
}, },
isVideo() { isVideo() {
return this.libraryItem.mediaType === 'video' return this.libraryItem.mediaType === 'video'
}, },
isMusic() {
return this.libraryItem.mediaType === 'music'
},
isMissing() { isMissing() {
return this.libraryItem.isMissing return this.libraryItem.isMissing
}, },
@@ -276,11 +316,12 @@ export default {
return this.libraryItem.isInvalid return this.libraryItem.isInvalid
}, },
invalidAudioFiles() { invalidAudioFiles() {
if (this.isPodcast || this.isVideo) return [] if (!this.isBook) return []
return this.libraryItem.media.audioFiles.filter((af) => af.invalid) return this.libraryItem.media.audioFiles.filter((af) => af.invalid)
}, },
showPlayButton() { showPlayButton() {
if (this.isMissing || this.isInvalid) return false if (this.isMissing || this.isInvalid) return false
if (this.isMusic) return !!this.audioFile
if (this.isVideo) return !!this.videoFile if (this.isVideo) return !!this.videoFile
if (this.isPodcast) return this.podcastEpisodes.length if (this.isPodcast) return this.podcastEpisodes.length
return this.tracks.length return this.tracks.length
@@ -338,6 +379,25 @@ export default {
authors() { authors() {
return this.mediaMetadata.authors || [] return this.mediaMetadata.authors || []
}, },
musicArtists() {
return this.mediaMetadata.artists || []
},
musicAlbum() {
return this.mediaMetadata.album || ''
},
musicAlbumArtist() {
return this.mediaMetadata.albumArtist || ''
},
musicTrackPretty() {
if (!this.mediaMetadata.trackNumber) return null
if (!this.mediaMetadata.trackTotal) return this.mediaMetadata.trackNumber
return `${this.mediaMetadata.trackNumber} / ${this.mediaMetadata.trackTotal}`
},
musicDiscPretty() {
if (!this.mediaMetadata.discNumber) return null
if (!this.mediaMetadata.discTotal) return this.mediaMetadata.discNumber
return `${this.mediaMetadata.discNumber} / ${this.mediaMetadata.discTotal}`
},
narrators() { narrators() {
return this.mediaMetadata.narrators || [] return this.mediaMetadata.narrators || []
}, },
@@ -346,7 +406,7 @@ export default {
}, },
seriesList() { seriesList() {
return this.series.map((se) => { return this.series.map((se) => {
var text = se.name let text = se.name
if (se.sequence) text += ` #${se.sequence}` if (se.sequence) text += ` #${se.sequence}`
return { return {
...se, ...se,
@@ -355,11 +415,12 @@ export default {
}) })
}, },
durationPretty() { durationPretty() {
if (!this.tracks.length) return 'N/A' if (!this.tracks.length && !this.audioFile) return 'N/A'
return this.$elapsedPretty(this.media.duration) if (this.audioFile) return this.$elapsedPrettyExtended(this.duration)
return this.$elapsedPretty(this.duration)
}, },
duration() { duration() {
if (!this.tracks.length) return 0 if (!this.tracks.length && !this.audioFile) return 0
return this.media.duration return this.media.duration
}, },
sizePretty() { sizePretty() {
@@ -374,6 +435,10 @@ export default {
videoFile() { videoFile() {
return this.media.videoFile return this.media.videoFile
}, },
audioFile() {
// Music track
return this.media.audioFile
},
showExperimentalReadAlert() { showExperimentalReadAlert() {
return !this.tracks.length && this.ebookFile && !this.showExperimentalFeatures && !this.enableEReader return !this.tracks.length && this.ebookFile && !this.showExperimentalFeatures && !this.enableEReader
}, },
@@ -381,6 +446,7 @@ export default {
return this.mediaMetadata.description || '' return this.mediaMetadata.description || ''
}, },
userMediaProgress() { userMediaProgress() {
if (this.isMusic) return null
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId) return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
}, },
userIsFinished() { userIsFinished() {
@@ -388,7 +454,7 @@ export default {
}, },
userTimeRemaining() { userTimeRemaining() {
if (!this.userMediaProgress) return 0 if (!this.userMediaProgress) return 0
var duration = this.userMediaProgress.duration || this.duration const duration = this.userMediaProgress.duration || this.duration
return duration - this.userMediaProgress.currentTime return duration - this.userMediaProgress.currentTime
}, },
progressPercent() { progressPercent() {
@@ -419,14 +485,17 @@ export default {
return this.$store.getters['user/getUserCanDownload'] return this.$store.getters['user/getUserCanDownload']
}, },
showRssFeedBtn() { showRssFeedBtn() {
if (!this.rssFeedUrl && !this.podcastEpisodes.length && !this.tracks.length) return false // Cannot open RSS feed with no episodes/tracks if (!this.rssFeed && !this.podcastEpisodes.length && !this.tracks.length) return false // Cannot open RSS feed with no episodes/tracks
// If rss feed is open then show feed url to users otherwise just show to admins // If rss feed is open then show feed url to users otherwise just show to admins
return this.userIsAdminOrUp || this.rssFeedUrl return this.userIsAdminOrUp || this.rssFeed
}, },
showQueueBtn() { showQueueBtn() {
if (this.isPodcast || this.isVideo) return false if (!this.isBook) return false
return !this.$store.getters['getIsStreamingFromDifferentLibrary'] && this.streamLibraryItem return !this.$store.getters['getIsStreamingFromDifferentLibrary'] && this.streamLibraryItem
},
showCollectionsButton() {
return this.isBook && this.userCanUpdate
} }
}, },
methods: { methods: {
@@ -531,14 +600,14 @@ export default {
}) })
}, },
playItem(startTime = null) { playItem(startTime = null) {
var episodeId = null let episodeId = null
const queueItems = [] const queueItems = []
if (this.isPodcast) { if (this.isPodcast) {
const episodesInListeningOrder = this.podcastEpisodes.map((ep) => ({ ...ep })).sort((a, b) => String(a.publishedAt).localeCompare(String(b.publishedAt), undefined, { numeric: true, sensitivity: 'base' })) const episodesInListeningOrder = this.podcastEpisodes.map((ep) => ({ ...ep })).sort((a, b) => String(a.publishedAt).localeCompare(String(b.publishedAt), undefined, { numeric: true, sensitivity: 'base' }))
// Find most recent episode unplayed // Find most recent episode unplayed
var episodeIndex = episodesInListeningOrder.findLastIndex((ep) => { let episodeIndex = episodesInListeningOrder.findLastIndex((ep) => {
var podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, ep.id) const podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, ep.id)
return !podcastProgress || !podcastProgress.isFinished return !podcastProgress || !podcastProgress.isFinished
}) })
if (episodeIndex < 0) episodeIndex = 0 if (episodeIndex < 0) episodeIndex = 0
@@ -617,7 +686,13 @@ export default {
this.$store.commit('globals/setShowPlaylistsModal', true) this.$store.commit('globals/setShowPlaylistsModal', true)
}, },
clickRSSFeed() { clickRSSFeed() {
this.showRssFeedModal = true this.$store.commit('globals/setRSSFeedOpenCloseModal', {
id: this.libraryItemId,
name: this.title,
type: 'item',
feed: this.rssFeed,
hasEpisodesWithoutPubDate: this.podcastEpisodes.some((ep) => !ep.pubDate)
})
}, },
episodeDownloadQueued(episodeDownload) { episodeDownloadQueued(episodeDownload) {
if (episodeDownload.libraryItemId === this.libraryItemId) { if (episodeDownload.libraryItemId === this.libraryItemId) {
@@ -639,13 +714,13 @@ export default {
rssFeedOpen(data) { rssFeedOpen(data) {
if (data.entityId === this.libraryItemId) { if (data.entityId === this.libraryItemId) {
console.log('RSS Feed Opened', data) console.log('RSS Feed Opened', data)
this.rssFeedUrl = data.feedUrl this.rssFeed = data
} }
}, },
rssFeedClosed(data) { rssFeedClosed(data) {
if (data.entityId === this.libraryItemId) { if (data.entityId === this.libraryItemId) {
console.log('RSS Feed Closed', data) console.log('RSS Feed Closed', data)
this.rssFeedUrl = null this.rssFeed = null
} }
}, },
queueBtnClick() { queueBtnClick() {
@@ -48,10 +48,13 @@ export default {
}, },
methods: { methods: {
async init() { async init() {
this.authors = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/authors`).catch((error) => { this.authors = await this.$axios
console.error('Failed to load authors', error) .$get(`/api/libraries/${this.currentLibraryId}/authors`)
return [] .then((response) => response.authors)
}) .catch((error) => {
console.error('Failed to load authors', error)
return []
})
this.loading = false this.loading = false
}, },
authorAdded(author) { authorAdded(author) {
@@ -15,17 +15,14 @@ export default {
} }
// Set series sort by // Set series sort by
if (params.id === 'series') { if (query.filter || query.sort || query.desc) {
if (query.sort) { const isSeries = params.id === 'series'
store.commit('libraries/setSeriesSortBy', query.sort) const settingsUpdate = {
store.commit('libraries/setSeriesSortDesc', !!query.desc) [isSeries ? 'seriesFilterBy' : 'filterBy']: query.filter || undefined,
[isSeries ? 'seriesSortBy' : 'orderBy']: query.sort || undefined,
[isSeries ? 'seriesSortDesc' : 'orderDesc']: query.desc == '0' ? false : query.desc == '1' ? true : undefined
} }
if (query.filter) { store.dispatch('user/updateUserSettings', settingsUpdate)
console.log('has filter', query.filter)
store.commit('libraries/setSeriesFilterBy', query.filter)
}
} else if (query.filter) {
store.dispatch('user/updateUserSettings', { filterBy: query.filter })
} }
// Redirect podcast libraries // Redirect podcast libraries
+2 -2
View File
@@ -8,8 +8,8 @@
<script> <script>
export default { export default {
async asyncData({ store, params, redirect }) { async asyncData({ store, params, redirect }) {
var libraryId = params.library const libraryId = params.library
var library = await store.dispatch('libraries/fetch', libraryId) const library = await store.dispatch('libraries/fetch', libraryId)
if (!library) { if (!library) {
return redirect(`/oops?message=Library "${libraryId}" not found`) return redirect(`/oops?message=Library "${libraryId}" not found`)
} }
+3 -3
View File
@@ -8,8 +8,8 @@
<script> <script>
export default { export default {
async asyncData({ store, params, redirect, query, app }) { async asyncData({ store, params, redirect, query, app }) {
var libraryId = params.library const libraryId = params.library
var libraryData = await store.dispatch('libraries/fetch', libraryId) const libraryData = await store.dispatch('libraries/fetch', libraryId)
if (!libraryData) { if (!libraryData) {
return redirect('/oops?message=Library not found') return redirect('/oops?message=Library not found')
} }
@@ -19,7 +19,7 @@ export default {
return redirect(`/library/${libraryId}`) return redirect(`/library/${libraryId}`)
} }
var series = await app.$axios.$get(`/api/series/${params.id}?include=progress`).catch((error) => { const series = await app.$axios.$get(`/api/series/${params.id}?include=progress,rssfeed`).catch((error) => {
console.error('Failed', error) console.error('Failed', error)
return false return false
}) })
+2 -1
View File
@@ -127,7 +127,6 @@ export default {
setUser({ user, userDefaultLibraryId, serverSettings, Source, feeds }) { setUser({ user, userDefaultLibraryId, serverSettings, Source, feeds }) {
this.$store.commit('setServerSettings', serverSettings) this.$store.commit('setServerSettings', serverSettings)
this.$store.commit('setSource', Source) this.$store.commit('setSource', Source)
this.$store.commit('feeds/setFeeds', feeds)
this.$setServerLanguageCode(serverSettings.language) this.$setServerLanguageCode(serverSettings.language)
if (serverSettings.chromecastEnabled) { if (serverSettings.chromecastEnabled) {
@@ -137,6 +136,8 @@ export default {
this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId) this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId)
this.$store.commit('user/setUser', user) this.$store.commit('user/setUser', user)
this.$store.dispatch('user/loadUserSettings')
}, },
async submitForm() { async submitForm() {
this.error = null this.error = null
+18 -10
View File
@@ -17,6 +17,7 @@ export default class PlayerHandler {
this.playerState = 'IDLE' this.playerState = 'IDLE'
this.isHlsTranscode = false this.isHlsTranscode = false
this.isVideo = false this.isVideo = false
this.isMusic = false
this.currentSessionId = null this.currentSessionId = null
this.startTimeOverride = undefined // Used for starting playback at a specific time (i.e. clicking bookmark from library item page) this.startTimeOverride = undefined // Used for starting playback at a specific time (i.e. clicking bookmark from library item page)
this.startTime = 0 this.startTime = 0
@@ -54,10 +55,13 @@ export default class PlayerHandler {
load(libraryItem, episodeId, playWhenReady, playbackRate, startTimeOverride = undefined) { load(libraryItem, episodeId, playWhenReady, playbackRate, startTimeOverride = undefined) {
this.libraryItem = libraryItem this.libraryItem = libraryItem
this.isVideo = libraryItem.mediaType === 'video'
this.isMusic = libraryItem.mediaType === 'music'
this.episodeId = episodeId this.episodeId = episodeId
this.playWhenReady = playWhenReady this.playWhenReady = playWhenReady
this.initialPlaybackRate = playbackRate this.initialPlaybackRate = this.isMusic ? 1 : playbackRate
this.isVideo = libraryItem.mediaType === 'video'
this.startTimeOverride = (startTimeOverride == null || isNaN(startTimeOverride)) ? undefined : Number(startTimeOverride) this.startTimeOverride = (startTimeOverride == null || isNaN(startTimeOverride)) ? undefined : Number(startTimeOverride)
if (!this.player) this.switchPlayer(playWhenReady) if (!this.player) this.switchPlayer(playWhenReady)
@@ -140,12 +144,14 @@ export default class PlayerHandler {
playerStateChange(state) { playerStateChange(state) {
console.log('[PlayerHandler] Player state change', state) console.log('[PlayerHandler] Player state change', state)
this.playerState = state this.playerState = state
if (this.playerState === 'PLAYING') { if (this.playerState === 'PLAYING') {
this.setPlaybackRate(this.initialPlaybackRate) this.setPlaybackRate(this.initialPlaybackRate)
this.startPlayInterval() this.startPlayInterval()
} else { } else {
this.stopPlayInterval() this.stopPlayInterval()
} }
if (this.player) { if (this.player) {
if (this.playerState === 'LOADED' || this.playerState === 'PLAYING') { if (this.playerState === 'LOADED' || this.playerState === 'PLAYING') {
this.ctx.setDuration(this.getDuration()) this.ctx.setDuration(this.getDuration())
@@ -252,14 +258,14 @@ export default class PlayerHandler {
startPlayInterval() { startPlayInterval() {
clearInterval(this.playInterval) clearInterval(this.playInterval)
var lastTick = Date.now() let lastTick = Date.now()
this.playInterval = setInterval(() => { this.playInterval = setInterval(() => {
// Update UI // Update UI
if (!this.player) return if (!this.player) return
var currentTime = this.player.getCurrentTime() const currentTime = this.player.getCurrentTime()
this.ctx.setCurrentTime(currentTime) this.ctx.setCurrentTime(currentTime)
var exactTimeElapsed = ((Date.now() - lastTick) / 1000) const exactTimeElapsed = ((Date.now() - lastTick) / 1000)
lastTick = Date.now() lastTick = Date.now()
this.listeningTimeSinceSync += exactTimeElapsed this.listeningTimeSinceSync += exactTimeElapsed
if (this.listeningTimeSinceSync >= 5) { if (this.listeningTimeSinceSync >= 5) {
@@ -269,9 +275,9 @@ export default class PlayerHandler {
} }
sendCloseSession() { sendCloseSession() {
var syncData = null let syncData = null
if (this.player) { if (this.player) {
var listeningTimeToAdd = Math.max(0, Math.floor(this.listeningTimeSinceSync)) const listeningTimeToAdd = Math.max(0, Math.floor(this.listeningTimeSinceSync))
syncData = { syncData = {
timeListened: listeningTimeToAdd, timeListened: listeningTimeToAdd,
duration: this.getDuration(), duration: this.getDuration(),
@@ -285,12 +291,14 @@ export default class PlayerHandler {
} }
sendProgressSync(currentTime) { sendProgressSync(currentTime) {
var diffSinceLastSync = Math.abs(this.lastSyncTime - currentTime) if (this.isMusic) return
const diffSinceLastSync = Math.abs(this.lastSyncTime - currentTime)
if (diffSinceLastSync < 1) return if (diffSinceLastSync < 1) return
this.lastSyncTime = currentTime this.lastSyncTime = currentTime
var listeningTimeToAdd = Math.max(0, Math.floor(this.listeningTimeSinceSync)) const listeningTimeToAdd = Math.max(0, Math.floor(this.listeningTimeSinceSync))
var syncData = { const syncData = {
timeListened: listeningTimeToAdd, timeListened: listeningTimeToAdd,
duration: this.getDuration(), duration: this.getDuration(),
currentTime currentTime
+34 -20
View File
@@ -5,8 +5,6 @@ import { formatDistance, format, addDays, isDate } from 'date-fns'
Vue.directive('click-outside', vClickOutside.directive) Vue.directive('click-outside', vClickOutside.directive)
Vue.prototype.$eventBus = new Vue()
Vue.prototype.$dateDistanceFromNow = (unixms) => { Vue.prototype.$dateDistanceFromNow = (unixms) => {
if (!unixms) return '' if (!unixms) return ''
return formatDistance(unixms, Date.now(), { addSuffix: true }) return formatDistance(unixms, Date.now(), { addSuffix: true })
@@ -30,23 +28,26 @@ Vue.prototype.$addDaysToDate = (jsdate, daysToAdd) => {
return date return date
} }
Vue.prototype.$sanitizeFilename = (input, colonReplacement = ' - ') => { Vue.prototype.$sanitizeFilename = (filename, colonReplacement = ' - ') => {
if (typeof input !== 'string') { if (typeof filename !== 'string') {
return false return false
} }
// Max is actually 255-260 for windows but this leaves padding incase ext wasnt put on yet // Most file systems use number of bytes for max filename
const MAX_FILENAME_LEN = 240 // to support most filesystems we will use max of 255 bytes in utf-16
// Ref: https://doc.owncloud.com/server/next/admin_manual/troubleshooting/path_filename_length.html
// Issue: https://github.com/advplyr/audiobookshelf/issues/1261
const MAX_FILENAME_BYTES = 255
var replacement = '' const replacement = ''
var illegalRe = /[\/\?<>\\:\*\|"]/g const illegalRe = /[\/\?<>\\:\*\|"]/g
var controlRe = /[\x00-\x1f\x80-\x9f]/g const controlRe = /[\x00-\x1f\x80-\x9f]/g
var reservedRe = /^\.+$/ const reservedRe = /^\.+$/
var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i const windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i
var windowsTrailingRe = /[\. ]+$/ const windowsTrailingRe = /[\. ]+$/
var lineBreaks = /[\n\r]/g const lineBreaks = /[\n\r]/g
var sanitized = input let sanitized = filename
.replace(':', colonReplacement) // Replace first occurrence of a colon .replace(':', colonReplacement) // Replace first occurrence of a colon
.replace(illegalRe, replacement) .replace(illegalRe, replacement)
.replace(controlRe, replacement) .replace(controlRe, replacement)
@@ -55,13 +56,25 @@ Vue.prototype.$sanitizeFilename = (input, colonReplacement = ' - ') => {
.replace(windowsReservedRe, replacement) .replace(windowsReservedRe, replacement)
.replace(windowsTrailingRe, replacement) .replace(windowsTrailingRe, replacement)
// Check if basename is too many bytes
const ext = Path.extname(sanitized) // separate out file extension
const basename = Path.basename(sanitized, ext)
const extByteLength = Buffer.byteLength(ext, 'utf16le')
const basenameByteLength = Buffer.byteLength(basename, 'utf16le')
if (basenameByteLength + extByteLength > MAX_FILENAME_BYTES) {
const MaxBytesForBasename = MAX_FILENAME_BYTES - extByteLength
let totalBytes = 0
let trimmedBasename = ''
if (sanitized.length > MAX_FILENAME_LEN) { // Add chars until max bytes is reached
var lenToRemove = sanitized.length - MAX_FILENAME_LEN for (const char of basename) {
var ext = Path.extname(sanitized) totalBytes += Buffer.byteLength(char, 'utf16le')
var basename = Path.basename(sanitized, ext) if (totalBytes > MaxBytesForBasename) break
basename = basename.slice(0, basename.length - lenToRemove) else trimmedBasename += char
sanitized = basename + ext }
trimmedBasename = trimmedBasename.trim()
sanitized = trimmedBasename + ext
} }
return sanitized return sanitized
@@ -144,6 +157,7 @@ export {
export default ({ app, store }, inject) => { export default ({ app, store }, inject) => {
app.$decode = decode app.$decode = decode
app.$encode = encode app.$encode = encode
inject('eventBus', new Vue())
inject('isDev', process.env.NODE_ENV !== 'production') inject('isDev', process.env.NODE_ENV !== 'production')
store.commit('setRouterBasePath', app.$config.routerBasePath) store.commit('setRouterBasePath', app.$config.routerBasePath)
+4 -4
View File
@@ -54,18 +54,18 @@ Vue.prototype.$elapsedPrettyExtended = (seconds, useDays = true) => {
if (isNaN(seconds) || seconds === null) return '' if (isNaN(seconds) || seconds === null) return ''
seconds = Math.round(seconds) seconds = Math.round(seconds)
var minutes = Math.floor(seconds / 60) let minutes = Math.floor(seconds / 60)
seconds -= minutes * 60 seconds -= minutes * 60
var hours = Math.floor(minutes / 60) let hours = Math.floor(minutes / 60)
minutes -= hours * 60 minutes -= hours * 60
var days = 0 let days = 0
if (useDays || Math.floor(hours / 24) >= 100) { if (useDays || Math.floor(hours / 24) >= 100) {
days = Math.floor(hours / 24) days = Math.floor(hours / 24)
hours -= days * 24 hours -= days * 24
} }
var strs = [] const strs = []
if (days) strs.push(`${days}d`) if (days) strs.push(`${days}d`)
if (hours) strs.push(`${hours}h`) if (hours) strs.push(`${hours}h`)
if (minutes) strs.push(`${minutes}m`) if (minutes) strs.push(`${minutes}m`)
Binary file not shown.
-28
View File
@@ -1,28 +0,0 @@
export const state = () => ({
feeds: []
})
export const getters = {
getFeedForItem: state => id => {
return state.feeds.find(feed => feed.id === id)
}
}
export const actions = {
}
export const mutations = {
addFeed(state, feed) {
var index = state.feeds.findIndex(f => f.id === feed.id)
if (index >= 0) state.feeds.splice(index, 1, feed)
else state.feeds.push(feed)
},
removeFeed(state, feed) {
state.feeds = state.feeds.filter(f => f.id !== feed.id)
},
setFeeds(state, feeds) {
state.feeds = feeds || []
}
}
+17 -10
View File
@@ -1,6 +1,7 @@
export const state = () => ({ export const state = () => ({
isMobile: false, isMobile: false,
isMobileLandscape: false, isMobileLandscape: false,
isMobilePortrait: false,
showBatchCollectionModal: false, showBatchCollectionModal: false,
showCollectionsModal: false, showCollectionsModal: false,
showEditCollectionModal: false, showEditCollectionModal: false,
@@ -8,9 +9,11 @@ export const state = () => ({
showEditPlaylistModal: false, showEditPlaylistModal: false,
showEditPodcastEpisode: false, showEditPodcastEpisode: false,
showViewPodcastEpisodeModal: false, showViewPodcastEpisodeModal: false,
showRSSFeedOpenCloseModal: false,
showConfirmPrompt: false, showConfirmPrompt: false,
confirmPromptOptions: null, confirmPromptOptions: null,
showEditAuthorModal: false, showEditAuthorModal: false,
rssFeedEntity: null,
selectedEpisode: null, selectedEpisode: null,
selectedPlaylistItems: null, selectedPlaylistItems: null,
selectedPlaylist: null, selectedPlaylist: null,
@@ -74,7 +77,8 @@ export const getters = {
export const mutations = { export const mutations = {
updateWindowSize(state, { width, height }) { updateWindowSize(state, { width, height }) {
state.isMobile = width < 640 || height < 640 state.isMobile = width < 640 || height < 640
state.isMobileLandscape = state.isMobile && height > width state.isMobileLandscape = state.isMobile && height < width
state.isMobilePortrait = state.isMobile && height >= width
}, },
setShowCollectionsModal(state, val) { setShowCollectionsModal(state, val) {
state.showBatchCollectionModal = false state.showBatchCollectionModal = false
@@ -99,6 +103,13 @@ export const mutations = {
setShowViewPodcastEpisodeModal(state, val) { setShowViewPodcastEpisodeModal(state, val) {
state.showViewPodcastEpisodeModal = val state.showViewPodcastEpisodeModal = val
}, },
setShowRSSFeedOpenCloseModal(state, val) {
state.showRSSFeedOpenCloseModal = val
},
setRSSFeedOpenCloseModal(state, entity) {
state.rssFeedEntity = entity
state.showRSSFeedOpenCloseModal = true
},
setShowConfirmPrompt(state, val) { setShowConfirmPrompt(state, val) {
state.showConfirmPrompt = val state.showConfirmPrompt = val
}, },
@@ -140,26 +151,22 @@ export const mutations = {
state.showBatchQuickMatchModal = val state.showBatchQuickMatchModal = val
}, },
resetSelectedMediaItems(state) { resetSelectedMediaItems(state) {
// Vue.set(state, 'selectedMediaItems', [])
state.selectedMediaItems = [] state.selectedMediaItems = []
}, },
toggleMediaItemSelected(state, item) { toggleMediaItemSelected(state, item) {
if (state.selectedMediaItems.some(i => i.id === item.id)) { if (state.selectedMediaItems.some(i => i.id === item.id)) {
state.selectedMediaItems = state.selectedMediaItems.filter(i => i.id !== item.id) state.selectedMediaItems = state.selectedMediaItems.filter(i => i.id !== item.id)
} else { } else {
// const newSel = state.selectedMediaItems.concat([{...item}])
// Vue.set(state, 'selectedMediaItems', newSel)
state.selectedMediaItems.push(item) state.selectedMediaItems.push(item)
} }
}, },
setMediaItemSelected(state, { item, selected }) { setMediaItemSelected(state, { item, selected }) {
const index = state.selectedMediaItems.findIndex(i => i.id === item.id) const isAlreadySelected = state.selectedMediaItems.some(i => i.id === item.id)
if (index && !selected) { if (isAlreadySelected && !selected) {
state.selectedMediaItems = state.selectedMediaItems.filter(i => i.id !== item.id) state.selectedMediaItems = state.selectedMediaItems.filter(i => i.id !== item.id)
} else if (selected && !index) {
state.selectedMediaItems.splice(index, 1, item) } else if (selected && !isAlreadySelected) {
// var newSel = state.selectedMediaItems.concat([libraryItemId]) state.selectedMediaItems.push(item)
// Vue.set(state, 'selectedMediaItems', newSel)
} }
} }
} }
+5 -17
View File
@@ -10,9 +10,6 @@ export const state = () => ({
folderLastUpdate: 0, folderLastUpdate: 0,
filterData: null, filterData: null,
numUserPlaylists: 0, numUserPlaylists: 0,
seriesSortBy: 'name',
seriesSortDesc: false,
seriesFilterBy: 'all',
collections: [], collections: [],
userPlaylists: [] userPlaylists: []
}) })
@@ -73,8 +70,8 @@ export const actions = {
}, },
loadFolders({ state, commit }) { loadFolders({ state, commit }) {
if (state.folders.length) { if (state.folders.length) {
var lastCheck = Date.now() - state.folderLastUpdate const lastCheck = Date.now() - state.folderLastUpdate
if (lastCheck < 1000 * 60 * 10) { // 10 minutes if (lastCheck < 1000 * 5) { // 5 seconds
// Folders up to date // Folders up to date
return state.folders return state.folders
} }
@@ -86,8 +83,8 @@ export const actions = {
.$get('/api/filesystem') .$get('/api/filesystem')
.then((res) => { .then((res) => {
console.log('Settings folders', res) console.log('Settings folders', res)
commit('setFolders', res) commit('setFolders', res.directories)
return res return res.directories
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to load dirs', error) console.error('Failed to load dirs', error)
@@ -151,7 +148,7 @@ export const actions = {
this.$axios this.$axios
.$get(`/api/libraries`) .$get(`/api/libraries`)
.then((data) => { .then((data) => {
commit('set', data) commit('set', data.libraries)
commit('setLastLoad') commit('setLastLoad')
}) })
.catch((error) => { .catch((error) => {
@@ -312,15 +309,6 @@ export const mutations = {
} }
} }
}, },
setSeriesSortBy(state, sortBy) {
state.seriesSortBy = sortBy
},
setSeriesSortDesc(state, sortDesc) {
state.seriesSortDesc = sortDesc
},
setSeriesFilterBy(state, filterBy) {
state.seriesFilterBy = filterBy
},
setCollections(state, collections) { setCollections(state, collections) {
state.collections = collections state.collections = collections
}, },
+44 -43
View File
@@ -7,9 +7,12 @@ export const state = () => ({
playbackRate: 1, playbackRate: 1,
bookshelfCoverSize: 120, bookshelfCoverSize: 120,
collapseSeries: false, collapseSeries: false,
collapseBookSeries: false collapseBookSeries: false,
}, useChapterTrack: false,
settingsListeners: [] seriesSortBy: 'name',
seriesSortDesc: false,
seriesFilterBy: 'all'
}
}) })
export const getters = { export const getters = {
@@ -66,7 +69,7 @@ export const getters = {
export const actions = { export const actions = {
// When changing libraries make sure sort and filter is still valid // When changing libraries make sure sort and filter is still valid
checkUpdateLibrarySortFilter({ state, dispatch, commit }, mediaType) { checkUpdateLibrarySortFilter({ state, dispatch, commit }, mediaType) {
var settingsUpdate = {} const settingsUpdate = {}
if (mediaType == 'podcast') { if (mediaType == 'podcast') {
if (state.settings.orderBy == 'media.metadata.authorName' || state.settings.orderBy == 'media.metadata.authorNameLF') { if (state.settings.orderBy == 'media.metadata.authorName' || state.settings.orderBy == 'media.metadata.authorNameLF') {
settingsUpdate.orderBy = 'media.metadata.author' settingsUpdate.orderBy = 'media.metadata.author'
@@ -77,8 +80,8 @@ export const actions = {
if (state.settings.orderBy == 'media.metadata.publishedYear') { if (state.settings.orderBy == 'media.metadata.publishedYear') {
settingsUpdate.orderBy = 'media.metadata.title' settingsUpdate.orderBy = 'media.metadata.title'
} }
var invalidFilters = ['series', 'authors', 'narrators', 'languages', 'progress', 'issues'] const invalidFilters = ['series', 'authors', 'narrators', 'languages', 'progress', 'issues']
var filterByFirstPart = (state.settings.filterBy || '').split('.').shift() const filterByFirstPart = (state.settings.filterBy || '').split('.').shift()
if (invalidFilters.includes(filterByFirstPart)) { if (invalidFilters.includes(filterByFirstPart)) {
settingsUpdate.filterBy = 'all' settingsUpdate.filterBy = 'all'
} }
@@ -94,30 +97,46 @@ export const actions = {
dispatch('updateUserSettings', settingsUpdate) dispatch('updateUserSettings', settingsUpdate)
} }
}, },
updateUserSettings({ commit }, payload) { updateUserSettings({ state, commit }, payload) {
var updatePayload = { if (!payload) return false
...payload
} let hasChanges = false
// Immediately update const existingSettings = { ...state.settings }
commit('setSettings', updatePayload) for (const key in existingSettings) {
return this.$axios.$patch('/api/me/settings', updatePayload).then((result) => { if (payload[key] !== undefined && existingSettings[key] !== payload[key]) {
if (result.success) { hasChanges = true
commit('setSettings', result.settings) existingSettings[key] = payload[key]
return true
} else {
return false
} }
}).catch((error) => { }
console.error('Failed to update settings', error) if (hasChanges) {
return false commit('setSettings', existingSettings)
}) this.$eventBus.$emit('user-settings', state.settings)
}
},
loadUserSettings({ state, commit }) {
// Load settings from local storage
try {
let userSettingsFromLocal = localStorage.getItem('userSettings')
if (userSettingsFromLocal) {
userSettingsFromLocal = JSON.parse(userSettingsFromLocal)
const userSettings = { ...state.settings }
for (const key in userSettings) {
if (userSettingsFromLocal[key] !== undefined) {
userSettings[key] = userSettingsFromLocal[key]
}
}
commit('setSettings', userSettings)
this.$eventBus.$emit('user-settings', state.settings)
}
} catch (error) {
console.error('Failed to load userSettings from local storage', error)
}
} }
} }
export const mutations = { export const mutations = {
setUser(state, user) { setUser(state, user) {
state.user = user state.user = user
state.settings = user.settings
if (user) { if (user) {
if (user.token) localStorage.setItem('token', user.token) if (user.token) localStorage.setItem('token', user.token)
} else { } else {
@@ -143,25 +162,7 @@ export const mutations = {
}, },
setSettings(state, settings) { setSettings(state, settings) {
if (!settings) return if (!settings) return
var hasChanges = false localStorage.setItem('userSettings', JSON.stringify(settings))
for (const key in settings) { state.settings = settings
if (state.settings[key] !== settings[key]) {
hasChanges = true
state.settings[key] = settings[key]
}
}
if (hasChanges) {
state.settingsListeners.forEach((listener) => {
listener.meth(state.settings)
})
}
},
addSettingsListener(state, listener) {
var index = state.settingsListeners.findIndex(l => l.id === listener.id)
if (index >= 0) state.settingsListeners.splice(index, 1, listener)
else state.settingsListeners.push(listener)
},
removeSettingsListener(state, listenerId) {
state.settingsListeners = state.settingsListeners.filter(l => l.id !== listenerId)
} }
} }
+96 -73
View File
@@ -20,9 +20,10 @@
"ButtonCreate": "Ertsellen", "ButtonCreate": "Ertsellen",
"ButtonCreateBackup": "Sicherung erstellen", "ButtonCreateBackup": "Sicherung erstellen",
"ButtonDelete": "Löschen", "ButtonDelete": "Löschen",
"ButtonEdit": "Edit",
"ButtonEditChapters": "Kapitel bearbeiten", "ButtonEditChapters": "Kapitel bearbeiten",
"ButtonEditPodcast": "Podcast bearbeiten", "ButtonEditPodcast": "Podcast bearbeiten",
"ButtonForceReScan": "Erzwinge einen Neu-Scan", "ButtonForceReScan": "Erzwinge kompletten Neu-Scan",
"ButtonFullPath": "Vollständiger Pfad", "ButtonFullPath": "Vollständiger Pfad",
"ButtonHide": "Ausblenden", "ButtonHide": "Ausblenden",
"ButtonHome": "Startseite", "ButtonHome": "Startseite",
@@ -30,21 +31,21 @@
"ButtonLatest": "Neuste", "ButtonLatest": "Neuste",
"ButtonLibrary": "Bibliothek", "ButtonLibrary": "Bibliothek",
"ButtonLogout": "Abmelden", "ButtonLogout": "Abmelden",
"ButtonLookup": "Nachschlagen", "ButtonLookup": "Online-Suche",
"ButtonManageTracks": "Tracks verwalten", "ButtonManageTracks": "Tracks verwalten",
"ButtonMapChapterTitles": "Kapitelüberschriften zuordnen", "ButtonMapChapterTitles": "Kapitelüberschriften zuordnen",
"ButtonMatchAllAuthors": "Online-Abgleich aller Autoren", "ButtonMatchAllAuthors": "Online-Suche für alle Autoren",
"ButtonMatchBooks": "Online-Abgleich aller Hörbücher", "ButtonMatchBooks": "Online-Suche für alle Hörbücher",
"ButtonNevermind": "Vergiss es", "ButtonNevermind": "Vergiss es",
"ButtonOk": "Ok", "ButtonOk": "Ok",
"ButtonOpenFeed": "Feed öffnen", "ButtonOpenFeed": "Feed öffnen",
"ButtonOpenManager": "Manager öffnen", "ButtonOpenManager": "Manager öffnen",
"ButtonPlay": "Abspielen", "ButtonPlay": "Abspielen",
"ButtonPlaying": "Spielt", "ButtonPlaying": "Spielt",
"ButtonPlaylists": "Playlists", "ButtonPlaylists": "Wiedergabelisten",
"ButtonPurgeAllCache": "Bereinige alle Zwischenspeicher", "ButtonPurgeAllCache": "Lösche alle Zwischenspeicher",
"ButtonPurgeItemsCache": "Bereinige den Hörbuch/Podcast-Zwischenspeicher", "ButtonPurgeItemsCache": "Lösche Medien-Zwischenspeicher",
"ButtonPurgeMediaProgress": "Bereinige die Hörfortschritte", "ButtonPurgeMediaProgress": "Lösche Hörfortschritte",
"ButtonQueueAddItem": "Zur Warteschlange hinzufügen", "ButtonQueueAddItem": "Zur Warteschlange hinzufügen",
"ButtonQueueRemoveItem": "Aus der Warteschlange entfernen", "ButtonQueueRemoveItem": "Aus der Warteschlange entfernen",
"ButtonQuickMatch": "Schnellabgleich", "ButtonQuickMatch": "Schnellabgleich",
@@ -60,13 +61,13 @@
"ButtonSave": "Speichern", "ButtonSave": "Speichern",
"ButtonSaveAndClose": "Speichern & Schließen", "ButtonSaveAndClose": "Speichern & Schließen",
"ButtonSaveTracklist": "Speichere die Titelliste", "ButtonSaveTracklist": "Speichere die Titelliste",
"ButtonScan": "Durchsuchen", "ButtonScan": "Scan",
"ButtonScanLibrary": "Bibliothek durchsuchen", "ButtonScanLibrary": "Bibliothek scannen",
"ButtonSearch": "Suchen", "ButtonSearch": "Suchen",
"ButtonSelectFolderPath": "Auswahl Ordnerpfad", "ButtonSelectFolderPath": "Auswahl Ordnerpfad",
"ButtonSeries": "Serien", "ButtonSeries": "Serien",
"ButtonSetChaptersFromTracks": "Set chapters from tracks", "ButtonSetChaptersFromTracks": "Kapitelerstellung aus Audiodateien",
"ButtonShiftTimes": "Arbeitszeiten", "ButtonShiftTimes": "Zeitverschiebung",
"ButtonShow": "Anzeigen", "ButtonShow": "Anzeigen",
"ButtonStartM4BEncode": "M4B-Kodierung starten", "ButtonStartM4BEncode": "M4B-Kodierung starten",
"ButtonStartMetadataEmbed": "Metadateneinbettung starten", "ButtonStartMetadataEmbed": "Metadateneinbettung starten",
@@ -75,13 +76,15 @@
"ButtonUploadBackup": "Sicherung hochladen", "ButtonUploadBackup": "Sicherung hochladen",
"ButtonUploadCover": "Titelbild hochladen", "ButtonUploadCover": "Titelbild hochladen",
"ButtonUploadOPMLFile": "OPML-Datei hochladen", "ButtonUploadOPMLFile": "OPML-Datei hochladen",
"ButtonUserDelete": "Benutzer {0} löschen",
"ButtonUserEdit": "Benutzer {0} editieren",
"ButtonViewAll": "Alles anzeigen", "ButtonViewAll": "Alles anzeigen",
"ButtonYes": "Ja", "ButtonYes": "Ja",
"HeaderAccount": "Konto", "HeaderAccount": "Konto",
"HeaderAdvanced": "Erweitert", "HeaderAdvanced": "Erweitert",
"HeaderAppriseNotificationSettings": "Apprise Benachrichtigungseinstellungen", "HeaderAppriseNotificationSettings": "Apprise Benachrichtigungseinstellungen",
"HeaderAudiobookTools": "Hörbuch-Dateiverwaltungstools", "HeaderAudiobookTools": "Hörbuch-Dateiverwaltungstools",
"HeaderAudioTracks": "Audio-Tracks", "HeaderAudioTracks": "Audiodateien",
"HeaderBackups": "Sicherungen", "HeaderBackups": "Sicherungen",
"HeaderChangePassword": "Passwort ändern", "HeaderChangePassword": "Passwort ändern",
"HeaderChapters": "Kapitel", "HeaderChapters": "Kapitel",
@@ -94,7 +97,8 @@
"HeaderFiles": "Dateien", "HeaderFiles": "Dateien",
"HeaderFindChapters": "Kapitel suchen", "HeaderFindChapters": "Kapitel suchen",
"HeaderIgnoredFiles": "Ignorierte Dateien", "HeaderIgnoredFiles": "Ignorierte Dateien",
"HeaderItemFiles": "Objekt-Dateien", "HeaderItemFiles": "Medien-Dateien",
"HeaderItemMetadataUtils": "Metadaten",
"HeaderLastListeningSession": "Letzte Hörsitzung", "HeaderLastListeningSession": "Letzte Hörsitzung",
"HeaderLatestEpisodes": "Letzte Episoden", "HeaderLatestEpisodes": "Letzte Episoden",
"HeaderLibraries": "Bibliotheken", "HeaderLibraries": "Bibliotheken",
@@ -104,7 +108,10 @@
"HeaderListeningStats": "Hörstatistiken", "HeaderListeningStats": "Hörstatistiken",
"HeaderLogin": "Anmeldung", "HeaderLogin": "Anmeldung",
"HeaderLogs": "Protokolle", "HeaderLogs": "Protokolle",
"HeaderMatch": "Online-Abgleich", "HeaderManageGenres": "Kategorien verwalten",
"HeaderManageTags": "Tags verwalten",
"HeaderMapDetails": "Stapelverarbeitung",
"HeaderMatch": "Online-Suche",
"HeaderMetadataToEmbed": "Einzubettende Metadaten", "HeaderMetadataToEmbed": "Einzubettende Metadaten",
"HeaderNewAccount": "Neues Konto", "HeaderNewAccount": "Neues Konto",
"HeaderNewLibrary": "Neue Bibliothek", "HeaderNewLibrary": "Neue Bibliothek",
@@ -113,8 +120,8 @@
"HeaderOtherFiles": "Sonstige Dateien", "HeaderOtherFiles": "Sonstige Dateien",
"HeaderPermissions": "Berechtigungen", "HeaderPermissions": "Berechtigungen",
"HeaderPlayerQueue": "Spieler Warteschlange", "HeaderPlayerQueue": "Spieler Warteschlange",
"HeaderPlaylist": "Playlist", "HeaderPlaylist": "Wiedergabeliste",
"HeaderPlaylistItems": "Playlist Items", "HeaderPlaylistItems": "Einträge in der Wiedergabeliste",
"HeaderPodcastsToAdd": "Podcasts zum Hinzufügen", "HeaderPodcastsToAdd": "Podcasts zum Hinzufügen",
"HeaderPreviewCover": "Vorschau Titelbild", "HeaderPreviewCover": "Vorschau Titelbild",
"HeaderRemoveEpisode": "Episode löschen", "HeaderRemoveEpisode": "Episode löschen",
@@ -142,7 +149,7 @@
"HeaderUpdateDetails": "Details aktualisieren", "HeaderUpdateDetails": "Details aktualisieren",
"HeaderUpdateLibrary": "Bibliothek aktualisieren", "HeaderUpdateLibrary": "Bibliothek aktualisieren",
"HeaderUsers": "Benutzer", "HeaderUsers": "Benutzer",
"HeaderYourStats": "Eigene Statistik", "HeaderYourStats": "Eigene Statistiken",
"LabelAccountType": "Kontoart", "LabelAccountType": "Kontoart",
"LabelAccountTypeAdmin": "Admin", "LabelAccountTypeAdmin": "Admin",
"LabelAccountTypeGuest": "Gast", "LabelAccountTypeGuest": "Gast",
@@ -150,11 +157,12 @@
"LabelActivity": "Aktivitäten", "LabelActivity": "Aktivitäten",
"LabelAddedAt": "Hinzugefügt am", "LabelAddedAt": "Hinzugefügt am",
"LabelAddToCollection": "Zur Sammlung hinzufügen", "LabelAddToCollection": "Zur Sammlung hinzufügen",
"LabelAddToCollectionBatch": "Füge {0} Bücher der Sammlung hinzu", "LabelAddToCollectionBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Sammlung hinzu",
"LabelAddToPlaylist": "Add to Playlist", "LabelAddToPlaylist": "Zur Wiedergabeliste hinzufügen",
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist", "LabelAddToPlaylistBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Wiedergabeliste hinzu",
"LabelAll": "Alle", "LabelAll": "Alle",
"LabelAllUsers": "Alle Benutzer", "LabelAllUsers": "Alle Benutzer",
"LabelAppend": "Anhängen",
"LabelAuthor": "Autor", "LabelAuthor": "Autor",
"LabelAuthorFirstLast": "Autor (Vorname Nachname)", "LabelAuthorFirstLast": "Autor (Vorname Nachname)",
"LabelAuthorLastFirst": "Autor (Nachname, Vorname)", "LabelAuthorLastFirst": "Autor (Nachname, Vorname)",
@@ -201,7 +209,7 @@
"LabelEpisode": "Episode", "LabelEpisode": "Episode",
"LabelEpisodeTitle": "Episodentitel", "LabelEpisodeTitle": "Episodentitel",
"LabelEpisodeType": "Episodentyp", "LabelEpisodeType": "Episodentyp",
"LabelExplicit": "Explizit <br />(Altersbeschränkung)", "LabelExplicit": "Explizit (Altersbeschränkung)",
"LabelFeedURL": "Feed URL", "LabelFeedURL": "Feed URL",
"LabelFile": "Datei", "LabelFile": "Datei",
"LabelFileBirthtime": "Datei Geburtsdatum", "LabelFileBirthtime": "Datei Geburtsdatum",
@@ -230,7 +238,7 @@
"LabelIntervalEveryDay": "Jeden Tag", "LabelIntervalEveryDay": "Jeden Tag",
"LabelIntervalEveryHour": "Jede Stunde", "LabelIntervalEveryHour": "Jede Stunde",
"LabelInvalidParts": "Ungültige Teile", "LabelInvalidParts": "Ungültige Teile",
"LabelItem": "Hörbuch/Podcast", "LabelItem": "Medium",
"LabelLanguage": "Sprache", "LabelLanguage": "Sprache",
"LabelLanguageDefaultServer": "Standard-Server-Sprache", "LabelLanguageDefaultServer": "Standard-Server-Sprache",
"LabelLastSeen": "Zuletzt angesehen", "LabelLastSeen": "Zuletzt angesehen",
@@ -247,7 +255,6 @@
"LabelLogLevelInfo": "Informationen", "LabelLogLevelInfo": "Informationen",
"LabelLogLevelWarn": "Warnungen", "LabelLogLevelWarn": "Warnungen",
"LabelLookForNewEpisodesAfterDate": "Suchen nach neuen Episoden nach diesem Datum", "LabelLookForNewEpisodesAfterDate": "Suchen nach neuen Episoden nach diesem Datum",
"LabelMarkSeries": "Serien markieren als",
"LabelMediaPlayer": "Mediaplayer", "LabelMediaPlayer": "Mediaplayer",
"LabelMediaType": "Medientyp", "LabelMediaType": "Medientyp",
"LabelMetadataProvider": "Metadatenanbieter", "LabelMetadataProvider": "Metadatenanbieter",
@@ -278,6 +285,7 @@
"LabelNumberOfBooks": "Anzahl der Hörbücher", "LabelNumberOfBooks": "Anzahl der Hörbücher",
"LabelNumberOfEpisodes": "Anzahl der Episoden", "LabelNumberOfEpisodes": "Anzahl der Episoden",
"LabelOpenRSSFeed": "Öffne RSS Feed", "LabelOpenRSSFeed": "Öffne RSS Feed",
"LabelOverwrite": "Überschreiben",
"LabelPassword": "Passwort", "LabelPassword": "Passwort",
"LabelPath": "Pfad", "LabelPath": "Pfad",
"LabelPermissionsAccessAllLibraries": "Zugriff auf alle Bibliotheken", "LabelPermissionsAccessAllLibraries": "Zugriff auf alle Bibliotheken",
@@ -288,11 +296,11 @@
"LabelPermissionsUpdate": "Aktualisieren", "LabelPermissionsUpdate": "Aktualisieren",
"LabelPermissionsUpload": "Hochladen", "LabelPermissionsUpload": "Hochladen",
"LabelPhotoPathURL": "Foto Pfad/URL", "LabelPhotoPathURL": "Foto Pfad/URL",
"LabelPlaylists": "Playlists", "LabelPlaylists": "Wiedergabelisten",
"LabelPlayMethod": "Abspielmethode", "LabelPlayMethod": "Abspielmethode",
"LabelPodcast": "Podcast", "LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts", "LabelPodcasts": "Podcasts",
"LabelPrefixesToIgnore": "Zu ignorierende Vorwort/Artikel (Groß- und Kleinschreibung wird nicht berücksichtigt)", "LabelPrefixesToIgnore": "Zu ignorierende(s) Vorwort(e) (Groß- und Kleinschreibung wird nicht berücksichtigt)",
"LabelProgress": "Fortschritt", "LabelProgress": "Fortschritt",
"LabelProvider": "Anbieter", "LabelProvider": "Anbieter",
"LabelPubDate": "Veröffentlichungsdatum", "LabelPubDate": "Veröffentlichungsdatum",
@@ -315,7 +323,7 @@
"LabelSeriesName": "Serienname", "LabelSeriesName": "Serienname",
"LabelSeriesProgress": "Serienfortschritt", "LabelSeriesProgress": "Serienfortschritt",
"LabelSettingsBookshelfViewHelp": "Skeumorphes Design mit Holzeinlegeböden", "LabelSettingsBookshelfViewHelp": "Skeumorphes Design mit Holzeinlegeböden",
"LabelSettingsChromecastSupport": "Chromecast-unterstützung", "LabelSettingsChromecastSupport": "Chromecastunterstützung",
"LabelSettingsDateFormat": "Datumsformat", "LabelSettingsDateFormat": "Datumsformat",
"LabelSettingsDisableWatcher": "Überwachung deaktivieren", "LabelSettingsDisableWatcher": "Überwachung deaktivieren",
"LabelSettingsDisableWatcherForLibrary": "Ordnerüberwachung für die Bibliothek deaktivieren", "LabelSettingsDisableWatcherForLibrary": "Ordnerüberwachung für die Bibliothek deaktivieren",
@@ -336,18 +344,18 @@
"LabelSettingsPreferAudioMetadataHelp": "In den Audiodateien eingebettete ID3 Metadaten werden für die Metadaten eines Hörbuchs anstelle der Ordnernamen verwendet. Wenn keine ID3 Metadaten zur Verfügung stehen, werden die Ordnernamen verwendet.", "LabelSettingsPreferAudioMetadataHelp": "In den Audiodateien eingebettete ID3 Metadaten werden für die Metadaten eines Hörbuchs anstelle der Ordnernamen verwendet. Wenn keine ID3 Metadaten zur Verfügung stehen, werden die Ordnernamen verwendet.",
"LabelSettingsPreferMatchedMetadata": "Bevorzuge online abgestimmte Metadaten", "LabelSettingsPreferMatchedMetadata": "Bevorzuge online abgestimmte Metadaten",
"LabelSettingsPreferMatchedMetadataHelp": "Bei einem Schnellabgleich überschreiben neu abgestimmte online Metadaten alle schon vorhandenen Metadaten eines Hörbuchs. Standardmäßig werden bei einem Schnellabgleich nur fehlende Metadaten ersetzt.", "LabelSettingsPreferMatchedMetadataHelp": "Bei einem Schnellabgleich überschreiben neu abgestimmte online Metadaten alle schon vorhandenen Metadaten eines Hörbuchs. Standardmäßig werden bei einem Schnellabgleich nur fehlende Metadaten ersetzt.",
"LabelSettingsPreferOPFMetadata": "Bevorzuge OPF-Metadaten", "LabelSettingsPreferOPFMetadata": "Bevorzuge OPF-Metadaten aus dem Hörbuchordner",
"LabelSettingsPreferOPFMetadataHelp": "In OPF Dateien gespeicherte Metadaten werden anstelle der Ordnernamen für die Metadaten eines Hörbuchs verwendet. OPF Datein sind seperate \"Textdateien \" mit der Endung \".abs\" in denen verschiedene Matadaten gespiechert sind. Wenn keine OPF Dateien zur Verfügung stehen, werden die Ordnernamen verwendet.", "LabelSettingsPreferOPFMetadataHelp": "In OPF-Dateien gespeicherte Metadaten werden anstelle der Ordnernamen für die Bereitstellung der Metadaten eines Hörbuchs verwendet. OPF-Datein sind seperate \"Textdateien\" mit der Endung \".abs\" welche in dem gleichen Ordner liegen wie das Hörbuch selber. In dieser sind verschiedene Matadaten (z.B. Titel, Autor, Jahr, Erzähler, Handlung, ISBN, ...) gespeichert. Wenn keine OPF Datei zur Verfügung steht, wird standardmäßig der Ordnername verwendet.",
"LabelSettingsSkipMatchingBooksWithASIN": "Überspringe beim Online-Abgleich alle Bücher die bereits eine ASIN haben", "LabelSettingsSkipMatchingBooksWithASIN": "Überspringe beim Online-Abgleich alle Bücher die bereits eine ASIN haben",
"LabelSettingsSkipMatchingBooksWithISBN": "Überspringe beim Online-Abgleich alle Bücher die bereits eine ISBN haben", "LabelSettingsSkipMatchingBooksWithISBN": "Überspringe beim Online-Abgleich alle Bücher die bereits eine ISBN haben",
"LabelSettingsSortingIgnorePrefixes": "Vorwort/Artikel beim Sortieren ignorieren", "LabelSettingsSortingIgnorePrefixes": "Vorwort/Artikel beim Sortieren ignorieren",
"LabelSettingsSortingIgnorePrefixesHelp": "Beispiel: für den Artikel \"der\" würde der Hörbuchtitel \"Der Buchtitel\" als \"Buchtitel, Der\" sortiert werden.", "LabelSettingsSortingIgnorePrefixesHelp": "Beispiel: für den Artikel \"der\" würde der Hörbuchtitel \"Der Buchtitel\" als \"Buchtitel, Der\" sortiert werden.",
"LabelSettingsSquareBookCovers": "Benutze quadratische Titelbilder", "LabelSettingsSquareBookCovers": "Benutze quadratische Titelbilder",
"LabelSettingsSquareBookCoversHelp": "Bevorzugen quadratische Titelbilder gegenüber den Standardtielbildern im Verhältnis 1,6:1", "LabelSettingsSquareBookCoversHelp": "Bevorzugen quadratische Titelbilder gegenüber den Standardtielbildern im Verhältnis 1,6:1",
"LabelSettingsStoreCoversWithItem": "Titelbilder im Hörbuchordner speichern", "LabelSettingsStoreCoversWithItem": "Titelbilder im Medienordner speichern",
"LabelSettingsStoreCoversWithItemHelp": "Standardmäßig werden die Titelbilder in /metadata/items gespeichert. Wenn diese Option aktiviert wird, werden die Titelbilder in dem selben Ordner, in welchem auch das zugehörige Hörbuch gespeichert ist, gespeichert. Es wird nur eine Datei mit dem Namen \"cover\" gespeichert.", "LabelSettingsStoreCoversWithItemHelp": "Standardmäßig werden die Titelbilder in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Titelbilder als jpg Datei in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet. Es wird immer nur eine Datei mit dem Namen \"cover.jpg\" gespeichert.",
"LabelSettingsStoreMetadataWithItem": "Metadaten als OPF-Datei im Hörbuchordner speichern", "LabelSettingsStoreMetadataWithItem": "Metadaten als OPF-Datei im Medienordner speichern",
"LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert wird, werden die Metadaten in dem selben Ordner, in welchem auch das zugehörige Hörbuch gespeichert ist, gespeichert. Es wird eine Datei mit der Endung \".abs\" gespeichert.", "LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet. Es wird immer nur eine Datei mit dem Namen \"matadata.abs\" gespeichert.",
"LabelShowAll": "Alles anzeigen", "LabelShowAll": "Alles anzeigen",
"LabelSize": "Größe", "LabelSize": "Größe",
"LabelSleepTimer": "Einschlaf-Timer", "LabelSleepTimer": "Einschlaf-Timer",
@@ -363,7 +371,7 @@
"LabelStatsDaysListened": "Gehörte Tage", "LabelStatsDaysListened": "Gehörte Tage",
"LabelStatsHours": "Stunden", "LabelStatsHours": "Stunden",
"LabelStatsInARow": "nacheinander", "LabelStatsInARow": "nacheinander",
"LabelStatsItemsFinished": "Gehörte Hörbücher/Podcasts", "LabelStatsItemsFinished": "Gehörte Medien",
"LabelStatsItemsInLibrary": "Bibliothekseinträge", "LabelStatsItemsInLibrary": "Bibliothekseinträge",
"LabelStatsMinutes": "Minuten", "LabelStatsMinutes": "Minuten",
"LabelStatsMinutesListening": "Gehörte Minuten", "LabelStatsMinutesListening": "Gehörte Minuten",
@@ -390,9 +398,9 @@
"LabelTotalTimeListened": "Gehörte Gesamtzeit", "LabelTotalTimeListened": "Gehörte Gesamtzeit",
"LabelTrackFromFilename": "Titel von Dateiname", "LabelTrackFromFilename": "Titel von Dateiname",
"LabelTrackFromMetadata": "Titel aus Metadaten", "LabelTrackFromMetadata": "Titel aus Metadaten",
"LabelTracks": "Tracks", "LabelTracks": "Dateien",
"LabelTracksMultiTrack": "Multi-track", "LabelTracksMultiTrack": "Mehrfachdatei",
"LabelTracksSingleTrack": "Single-track", "LabelTracksSingleTrack": "Einzeldatei",
"LabelType": "Typ", "LabelType": "Typ",
"LabelUnknown": "Unbekannt", "LabelUnknown": "Unbekannt",
"LabelUpdateCover": "Titelbild aktualisieren", "LabelUpdateCover": "Titelbild aktualisieren",
@@ -402,8 +410,8 @@
"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",
"LabelUseChapterTrack": "Kapitelverfolgung verwenden", "LabelUseChapterTrack": "Kapiteldatei verwenden",
"LabelUseFullTrack": "Gesamten Track verwenden", "LabelUseFullTrack": "Gesamte Datei verwenden",
"LabelUser": "Benutzer", "LabelUser": "Benutzer",
"LabelUsername": "Benutzername", "LabelUsername": "Benutzername",
"LabelValue": "Wert", "LabelValue": "Wert",
@@ -415,30 +423,38 @@
"LabelWeekdaysToRun": "Wochentage für die Ausführung", "LabelWeekdaysToRun": "Wochentage für die Ausführung",
"LabelYourAudiobookDuration": "Laufzeit Ihres Hörbuchs", "LabelYourAudiobookDuration": "Laufzeit Ihres Hörbuchs",
"LabelYourBookmarks": "Lesezeichen", "LabelYourBookmarks": "Lesezeichen",
"LabelYourPlaylists": "Your Playlists", "LabelYourPlaylists": "Eigene Wiedergabelisten",
"LabelYourProgress": "Fortschritt", "LabelYourProgress": "Fortschritt",
"MessageAddToPlayerQueue": "Add to player queue", "MessageAddToPlayerQueue": "Zur Abspielwarteliste hinzufügen",
"MessageAppriseDescription": "Um diese Funktion nutzen zu können, müssen Sie eine Instanz von <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> laufen haben oder eine API verwenden welche dieselbe Anfragen bearbeiten kann. <br />Die Apprise API Url muss der vollständige URL-Pfad sein, an den die Benachrichtigung gesendet werden soll, z.B. wenn Ihre API-Instanz unter <code>http://192.168.1.1:8337</code> läuft, würden Sie <code>http://192.168.1.1:8337/notify</code> eingeben.", "MessageAppriseDescription": "Um diese Funktion nutzen zu können, müssen Sie eine Instanz von <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> laufen haben oder eine API verwenden welche dieselbe Anfragen bearbeiten kann. <br />Die Apprise API Url muss der vollständige URL-Pfad sein, an den die Benachrichtigung gesendet werden soll, z.B. wenn Ihre API-Instanz unter <code>http://192.168.1.1:8337</code> läuft, würden Sie <code>http://192.168.1.1:8337/notify</code> eingeben.",
"MessageBackupsDescription": "In Sicherungen werden Benutzer, Benutzerfortschritte, Details zu den Bibliotheksobjekten, Servereinstellungen und Bilder gespeichert <code>/metadata/items</code> & <code>/metadata/authors</code>. Die Sicherungen enthalten keine Dateien welche in Ihren Bibliotheksordnern gespeichert sind.", "MessageBackupsDescription": "In einer Sicherung werden Benutzer, Benutzerfortschritte, Details zu den Bibliotheksobjekten, Servereinstellungen und Bilder welche in <code>/metadata/items</code> & <code>/metadata/authors</code> gespeichert sind gespeichert. Sicherungen enthalten keine Dateien welche in den einzelnen Bibliotheksordnern (Hörbuch-/Podcastordnern) gespeichert sind.",
"MessageBatchQuickMatchDescription": "Der Schnellabgleich versucht, fehlende Titelbilder und Metadaten für die ausgewählten Artikel hinzuzufügen. Aktivieren Sie die nachstehenden Optionen, damit der Schnellabgleich vorhandene Titelbilder und/oder Metadaten überschreiben kann.", "MessageBatchQuickMatchDescription": "Der Schnellabgleich versucht, fehlende Titelbilder und Metadaten für die ausgewählten Artikel hinzuzufügen. Aktivieren Sie die nachstehenden Optionen, damit der Schnellabgleich vorhandene Titelbilder und/oder Metadaten überschreiben kann.",
"MessageBookshelfNoCollections": "Es wurden noch keine Sammlungen erstellt", "MessageBookshelfNoCollections": "Es wurden noch keine Sammlungen erstellt",
"MessageBookshelfNoResultsForFilter": "Keine Ergebnisse für filter \"{0}: {1}\"", "MessageBookshelfNoResultsForFilter": "Keine Ergebnisse für filter \"{0}: {1}\"",
"MessageBookshelfNoRSSFeeds": "Keine RSS-Feeds geöffnet", "MessageBookshelfNoRSSFeeds": "Keine RSS-Feeds geöffnet",
"MessageBookshelfNoSeries": "Keine Serien vorhanden", "MessageBookshelfNoSeries": "Keine Serien vorhanden",
"MessageChapterEndIsAfter": "Das Kapitelende liegt nach dem Ende Ihres Hörbuchs", "MessageChapterEndIsAfter": "Das Kapitelende liegt nach dem Ende Ihres Hörbuchs",
"MessageChapterErrorFirstNotZero": "First chapter must start at 0", "MessageChapterErrorFirstNotZero": "Das erste Kapitel muss bei 0 beginnen",
"MessageChapterErrorStartGteDuration": "Invalid start time must be less than audiobook duration", "MessageChapterErrorStartGteDuration": "Die ungültige Startzeit darf nicht größer als die gesamte Hörbuchdauer sein",
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time", "MessageChapterErrorStartLtPrev": "Die ungültige Startzeit darf nicht größer oder gleich der Startzeit des vorherigen Kapitels sein",
"MessageChapterStartIsAfter": "Der Kapitelanfang liegt nach dem Ende Ihres Hörbuchs", "MessageChapterStartIsAfter": "Der Kapitelanfang liegt nach dem Ende Ihres Hörbuchs",
"MessageCheckingCron": "Überprüfe cron...", "MessageCheckingCron": "Überprüfe cron...",
"MessageConfirmDeleteBackup": "Sind Sie sicher, dass Sie die Sicherung für {0} löschen wollen?", "MessageConfirmDeleteBackup": "Sind Sie sicher, dass Sie die Sicherung für {0} löschen wollen?",
"MessageConfirmDeleteLibrary": "Sind Sie sicher, dass Sie die Bibliothek \"{0}\" dauerhaft löschen wollen?", "MessageConfirmDeleteLibrary": "Sind Sie sicher, dass Sie die Bibliothek \"{0}\" dauerhaft löschen wollen?",
"MessageConfirmDeleteSession": "Sind Sie sicher, dass Sie diese Sitzung löschen möchten?", "MessageConfirmDeleteSession": "Sind Sie sicher, dass Sie diese Sitzung löschen möchten?",
"MessageConfirmForceReScan": "Sind Sie sicher, dass Sie einen erneuten Scanvorgang erzwingen wollen?", "MessageConfirmForceReScan": "Sind Sie sicher, dass Sie einen erneuten Scanvorgang erzwingen wollen?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
"MessageConfirmRemoveCollection": "Sind Sie sicher, dass Sie die Sammlung \"{0}\" löschen wollen?", "MessageConfirmRemoveCollection": "Sind Sie sicher, dass Sie die Sammlung \"{0}\" löschen wollen?",
"MessageConfirmRemoveEpisode": "Sind Sie sicher, dass Sie die Episode \"{0}\" löschen möchten?", "MessageConfirmRemoveEpisode": "Sind Sie sicher, dass Sie die Episode \"{0}\" löschen möchten?",
"MessageConfirmRemoveEpisodes": "Sind Sie sicher, dass Sie {0} Episoden löschen wollen?", "MessageConfirmRemoveEpisodes": "Sind Sie sicher, dass Sie {0} Episoden löschen wollen?",
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?", "MessageConfirmRemovePlaylist": "Sind Sie sicher, dass Sie die Wiedergabeliste \"{0}\" entfernen möchten?",
"MessageConfirmRenameGenre": "Sind Sie sicher, dass Sie die Kategorie \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts umbenennen wollen?",
"MessageConfirmRenameGenreMergeNote": "Hinweis: Kategorie existiert bereits -> Kategorien werden zusammengelegt.",
"MessageConfirmRenameGenreWarning": "Warnung! Ein ähnliche Kategorie mit einem anderen Wortlaut existiert bereits: \"{0}\".",
"MessageConfirmRenameTag": "Sind Sie sicher, dass Sie den Tag \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts umbenennen wollen?",
"MessageConfirmRenameTagMergeNote": "Hinweis: Tag existiert bereits -> Tags werden zusammengelegt.",
"MessageConfirmRenameTagWarning": "Warnung! Ein ähnlicher Tag mit einem anderen Wortlaut existiert bereits: \"{0}\".",
"MessageDownloadingEpisode": "Episode herunterladen", "MessageDownloadingEpisode": "Episode herunterladen",
"MessageDragFilesIntoTrackOrder": "Verschieben Sie die Dateien in die richtige Reihenfolge", "MessageDragFilesIntoTrackOrder": "Verschieben Sie die Dateien in die richtige Reihenfolge",
"MessageEmbedFinished": "Einbettung abgeschlossen!", "MessageEmbedFinished": "Einbettung abgeschlossen!",
@@ -448,7 +464,8 @@
"MessageForceReScanDescription": "durchsucht alle Dateien neu, wie bei einem frischen Scan. ID3-Tags von Audiodateien, OPF-Dateien und Textdateien werden neu durchsucht.", "MessageForceReScanDescription": "durchsucht alle Dateien neu, wie bei einem frischen Scan. ID3-Tags von Audiodateien, OPF-Dateien und Textdateien werden neu durchsucht.",
"MessageImportantNotice": "Wichtiger Hinweis!", "MessageImportantNotice": "Wichtiger Hinweis!",
"MessageInsertChapterBelow": "Kapitel unten einfügen", "MessageInsertChapterBelow": "Kapitel unten einfügen",
"MessageItemsSelected": "{0} ausgewählte Elemente", "MessageItemsSelected": "{0} ausgewählte Medien",
"MessageItemsUpdated": "{0} Medien aktualisiert",
"MessageJoinUsOn": "Besuchen Sie uns auf", "MessageJoinUsOn": "Besuchen Sie uns auf",
"MessageListeningSessionsInTheLastYear": "{0} Ereignisse im letzten Jahr", "MessageListeningSessionsInTheLastYear": "{0} Ereignisse im letzten Jahr",
"MessageLoading": "Laden...", "MessageLoading": "Laden...",
@@ -459,7 +476,7 @@
"MessageMarkAsFinished": "Als beendet markieren", "MessageMarkAsFinished": "Als beendet markieren",
"MessageMarkAsNotFinished": "Als nicht abgeschlossen markieren", "MessageMarkAsNotFinished": "Als nicht abgeschlossen markieren",
"MessageMatchBooksDescription": "versucht, Bücher in der Bibliothek mit einem Buch des ausgewählten Suchanbieters abzugleichen und leere Details und das Titelbild auszufüllen. Details werden nicht überschrieben.", "MessageMatchBooksDescription": "versucht, Bücher in der Bibliothek mit einem Buch des ausgewählten Suchanbieters abzugleichen und leere Details und das Titelbild auszufüllen. Details werden nicht überschrieben.",
"MessageNoAudioTracks": "Keine Audiotracks", "MessageNoAudioTracks": "Keine Audiodateien",
"MessageNoAuthors": "Keine Autoren", "MessageNoAuthors": "Keine Autoren",
"MessageNoBackups": "Keine Sicherungen", "MessageNoBackups": "Keine Sicherungen",
"MessageNoBookmarks": "Keine Lesezeichen", "MessageNoBookmarks": "Keine Lesezeichen",
@@ -472,8 +489,8 @@
"MessageNoFoldersAvailable": "Keine Ordner verfügbar", "MessageNoFoldersAvailable": "Keine Ordner verfügbar",
"MessageNoGenres": "Keine Kategorien", "MessageNoGenres": "Keine Kategorien",
"MessageNoIssues": "Keine Probleme", "MessageNoIssues": "Keine Probleme",
"MessageNoItems": "Keine Elemente/Einträge", "MessageNoItems": "Keine Medien",
"MessageNoItemsFound": "Keine Elemente/Einträge gefunden", "MessageNoItemsFound": "Keine Medien gefunden",
"MessageNoListeningSessions": "Keine Hörsitzungen", "MessageNoListeningSessions": "Keine Hörsitzungen",
"MessageNoLogs": "Keine Protokolle", "MessageNoLogs": "Keine Protokolle",
"MessageNoMediaProgress": "Kein Medienfortschritt", "MessageNoMediaProgress": "Kein Medienfortschritt",
@@ -481,28 +498,30 @@
"MessageNoPodcastsFound": "Keine Podcasts gefunden", "MessageNoPodcastsFound": "Keine Podcasts gefunden",
"MessageNoResults": "Keine Ergebnisse", "MessageNoResults": "Keine Ergebnisse",
"MessageNoSearchResultsFor": "Keine Suchergebnisse für \"{0}\"", "MessageNoSearchResultsFor": "Keine Suchergebnisse für \"{0}\"",
"MessageNoSeries": "No Series", "MessageNoSeries": "Keine Serien",
"MessageNoTags": "Keine Tags",
"MessageNotYetImplemented": "Noch nicht implementiert", "MessageNotYetImplemented": "Noch nicht implementiert",
"MessageNoUpdateNecessary": "Keine Aktualisierung erforderlich", "MessageNoUpdateNecessary": "Keine Aktualisierung erforderlich",
"MessageNoUpdatesWereNecessary": "Keine Aktualisierungen waren notwendig", "MessageNoUpdatesWereNecessary": "Keine Aktualisierungen waren notwendig",
"MessageNoUserPlaylists": "You have no playlists", "MessageNoUserPlaylists": "Keine Wiedergabelisten vorhanden",
"MessageOr": "oder", "MessageOr": "oder",
"MessagePauseChapter": "Kapitelwiedergabe pausieren", "MessagePauseChapter": "Kapitelwiedergabe pausieren",
"MessagePlayChapter": "Kapitelanfang anhören", "MessagePlayChapter": "Kapitelanfang anhören",
"MessagePlaylistCreateFromCollection": "Erstelle eine Wiedergabeliste aus der Sammlung",
"MessagePodcastHasNoRSSFeedForMatching": "Podcast hat keine RSS-Feed-Url welche für den Online-Abgleich verwendet werden kann", "MessagePodcastHasNoRSSFeedForMatching": "Podcast hat keine RSS-Feed-Url welche für den Online-Abgleich verwendet werden kann",
"MessageQuickMatchDescription": "Füllt leere Details und Titelbilder mit dem ersten Treffer aus '{0}'. Überschreibt keine Details, es sei denn, die Server-Einstellung \"Passende Metadaten bevorzugen\" ist aktiviert.", "MessageQuickMatchDescription": "Füllt leere Details und Titelbilder mit dem ersten Treffer aus '{0}'. Überschreibt keine Details, es sei denn, die Server-Einstellung \"Passende Metadaten bevorzugen\" ist aktiviert.",
"MessageRemoveAllItemsWarning": "WARNUNG! Bei dieser Aktion werden alle Bibliotheksobjekte aus der Datenbank entfernt, einschließlich aller Aktualisierungen oder Online-Abgleichs, die Sie vorgenommen haben. Ihre eigentlichen Dateien bleiben davon unberührt. Sind Sie sicher?", "MessageRemoveAllItemsWarning": "WARNUNG! Bei dieser Aktion werden alle Bibliotheksobjekte aus der Datenbank entfernt, einschließlich aller Aktualisierungen oder Online-Abgleichs, die Sie vorgenommen haben. Ihre eigentlichen Dateien bleiben davon unberührt. Sind Sie sicher?",
"MessageRemoveChapter": "Kapitel löschen", "MessageRemoveChapter": "Kapitel löschen",
"MessageRemoveEpisodes": "Entferne {0} Episode(n)", "MessageRemoveEpisodes": "Entferne {0} Episode(n)",
"MessageRemoveFromPlayerQueue": "Remove from player queue", "MessageRemoveFromPlayerQueue": "Aus der Abspielwarteliste löschen Remove from player queue",
"MessageRemoveUserWarning": "Sind Sie sicher, dass Sie den Benutzer \"{0}\" dauerhaft löschen wollen?", "MessageRemoveUserWarning": "Sind Sie sicher, dass Sie den Benutzer \"{0}\" dauerhaft löschen wollen?",
"MessageReportBugsAndContribute": "Fehler melden, Funktionen anfordern und Beiträge leisten auf", "MessageReportBugsAndContribute": "Fehler melden, Funktionen anfordern und Beiträge leisten auf",
"MessageResetChaptersConfirm": "Are you sure you want to reset chapters and undo the changes you made?", "MessageResetChaptersConfirm": "Sind Sie sicher, dass Sie die Kapitel zurücksetzen und die vorgenommenen Änderungen rückgängig machen wollen?",
"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",
"MessageServerCouldNotBeReached": "Server kann nicht erreicht werden", "MessageServerCouldNotBeReached": "Server kann nicht erreicht werden",
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name", "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}?",
"MessageThinking": "Nachdenken...", "MessageThinking": "Nachdenken...",
"MessageUploaderItemFailed": "Hochladen fehlgeschlagen", "MessageUploaderItemFailed": "Hochladen fehlgeschlagen",
@@ -524,7 +543,7 @@
"NoteUploaderUnsupportedFiles": "Nicht unterstützte Dateien werden ignoriert. Bei der Auswahl oder dem Löschen eines Ordners werden andere Dateien, die sich nicht in einem Elementordner befinden, ignoriert.", "NoteUploaderUnsupportedFiles": "Nicht unterstützte Dateien werden ignoriert. Bei der Auswahl oder dem Löschen eines Ordners werden andere Dateien, die sich nicht in einem Elementordner befinden, ignoriert.",
"PlaceholderNewCollection": "Neuer Sammlungsname", "PlaceholderNewCollection": "Neuer Sammlungsname",
"PlaceholderNewFolderPath": "Neuer Ordnerpfad", "PlaceholderNewFolderPath": "Neuer Ordnerpfad",
"PlaceholderNewPlaylist": "New playlist name", "PlaceholderNewPlaylist": "Neuer Wiedergabelistenname",
"PlaceholderSearch": "Suche...", "PlaceholderSearch": "Suche...",
"ToastAccountUpdateFailed": "Aktualisierung des Kontos fehlgeschlagen", "ToastAccountUpdateFailed": "Aktualisierung des Kontos fehlgeschlagen",
"ToastAccountUpdateSuccess": "Konto aktualisiert", "ToastAccountUpdateSuccess": "Konto aktualisiert",
@@ -549,23 +568,23 @@
"ToastBookmarkRemoveSuccess": "Lesezeichen gelöscht", "ToastBookmarkRemoveSuccess": "Lesezeichen gelöscht",
"ToastBookmarkUpdateFailed": "Lesezeichenaktualisierung fehlgeschlagen", "ToastBookmarkUpdateFailed": "Lesezeichenaktualisierung fehlgeschlagen",
"ToastBookmarkUpdateSuccess": "Lesezeichen aktualisiert", "ToastBookmarkUpdateSuccess": "Lesezeichen aktualisiert",
"ToastChaptersHaveErrors": "Chapters have errors", "ToastChaptersHaveErrors": "Kapitel sind fehlerhaft",
"ToastChaptersMustHaveTitles": "Chapters must have titles", "ToastChaptersMustHaveTitles": "Kapitel benötigen eindeutige Namen",
"ToastCollectionItemsRemoveFailed": "Element(e) konnte(n) nicht aus der Sammlung entfernt werden", "ToastCollectionItemsRemoveFailed": "Fehler beim Entfernen der Medien aus der Sammlung",
"ToastCollectionItemsRemoveSuccess": "Element(e) wurde(n) aus der Sammlung entfernt", "ToastCollectionItemsRemoveSuccess": "Medien aus der Sammlung entfernt",
"ToastCollectionRemoveFailed": "Sammlung konnte nicht entfernt werden", "ToastCollectionRemoveFailed": "Sammlung konnte nicht entfernt werden",
"ToastCollectionRemoveSuccess": "Sammlung gelöscht", "ToastCollectionRemoveSuccess": "Sammlung gelöscht",
"ToastCollectionUpdateFailed": "Sammlung konnte nicht aktualisiert werden", "ToastCollectionUpdateFailed": "Sammlung konnte nicht aktualisiert werden",
"ToastCollectionUpdateSuccess": "Sammlung aktualisiert", "ToastCollectionUpdateSuccess": "Sammlung aktualisiert",
"ToastItemCoverUpdateFailed": "Aktualisierung des Titelbildes fehlgeschlagen", "ToastItemCoverUpdateFailed": "Fehler bei der Aktualisierung des Titelbildes",
"ToastItemCoverUpdateSuccess": "Titelbild aktualisiert", "ToastItemCoverUpdateSuccess": "Titelbild aktualisiert",
"ToastItemDetailsUpdateFailed": "Fehler bei der Aktualisierung der Artikeldetails", "ToastItemDetailsUpdateFailed": "Fehler bei der Aktualisierung der Artikeldetails",
"ToastItemDetailsUpdateSuccess": "Artikeldetails aktualisiert", "ToastItemDetailsUpdateSuccess": "Artikeldetails aktualisiert",
"ToastItemDetailsUpdateUnneeded": "Keine Aktualisierung für Artikeldetails erforderlichs", "ToastItemDetailsUpdateUnneeded": "Keine Aktualisierung für die Artikeldetails erforderlich",
"ToastItemMarkedAsFinishedFailed": "Als \"abgeschlossen zu markieren\" ist fehlgeschlagen", "ToastItemMarkedAsFinishedFailed": "Fehler bei der Markierung des Mediums als \"Beendet\"",
"ToastItemMarkedAsFinishedSuccess": "Artikel/Eintrag als fertig markiert", "ToastItemMarkedAsFinishedSuccess": "Medium als \"Beendet\" markiert",
"ToastItemMarkedAsNotFinishedFailed": "Fehler bei der Markierung als \"Nicht Fertig\"", "ToastItemMarkedAsNotFinishedFailed": "Fehler bei der Markierung des Mediums als \"Nicht Beendet\"",
"ToastItemMarkedAsNotFinishedSuccess": "Artikel/Eintrag als \"Nicht Fertig\" markiert", "ToastItemMarkedAsNotFinishedSuccess": "Medium als \"Nicht Beendet\" markiert",
"ToastLibraryCreateFailed": "Bibliothek konnte nicht erstellt werden", "ToastLibraryCreateFailed": "Bibliothek konnte nicht erstellt werden",
"ToastLibraryCreateSuccess": "Bibliothek \"{0}\" erstellt", "ToastLibraryCreateSuccess": "Bibliothek \"{0}\" erstellt",
"ToastLibraryDeleteFailed": "Bibliothek konnte nicht gelöscht werden", "ToastLibraryDeleteFailed": "Bibliothek konnte nicht gelöscht werden",
@@ -574,16 +593,20 @@
"ToastLibraryScanStarted": "Bibliotheksscan gestartet", "ToastLibraryScanStarted": "Bibliotheksscan gestartet",
"ToastLibraryUpdateFailed": "Aktualisierung der Bibliothek fehlgeschlagen", "ToastLibraryUpdateFailed": "Aktualisierung der Bibliothek fehlgeschlagen",
"ToastLibraryUpdateSuccess": "Bibliothek \"{0}\" aktualisiert", "ToastLibraryUpdateSuccess": "Bibliothek \"{0}\" aktualisiert",
"ToastPlaylistRemoveFailed": "Failed to remove playlist", "ToastPlaylistCreateFailed": "Erstellen der Wiedergabeliste fehlgeschlagen",
"ToastPlaylistRemoveSuccess": "Playlist removed", "ToastPlaylistCreateSuccess": "Wiedergabeliste erstellt",
"ToastPlaylistUpdateFailed": "Failed to update playlist", "ToastPlaylistRemoveFailed": "Löschen der Wiedergabeliste fehlgeschlagen",
"ToastPlaylistUpdateSuccess": "Playlist updated", "ToastPlaylistRemoveSuccess": "Wiedergabeliste gelöscht",
"ToastPlaylistUpdateFailed": "Aktualisieren der Wiedergabeliste fehlgeschlagen",
"ToastPlaylistUpdateSuccess": "Wiedergabeliste aktualisiert",
"ToastPodcastCreateFailed": "Podcast konnte nicht erstellt werden", "ToastPodcastCreateFailed": "Podcast konnte nicht erstellt werden",
"ToastPodcastCreateSuccess": "Podcast erfolgreich erstellt", "ToastPodcastCreateSuccess": "Podcast erstellt",
"ToastRemoveItemFromCollectionFailed": "Element/Eintrag konnte nicht aus der Sammlung entfernt werden", "ToastRemoveItemFromCollectionFailed": "Löschen des Mediums aus der Sammlung fehlgeschlagen",
"ToastRemoveItemFromCollectionSuccess": "Element/Eintrag aus der Sammlung entfernt", "ToastRemoveItemFromCollectionSuccess": "Medium aus der Sammlung gelöscht",
"ToastRSSFeedCloseFailed": "RSS-Feed konnte nicht geschlossen werden", "ToastRSSFeedCloseFailed": "RSS-Feed konnte nicht geschlossen werden",
"ToastRSSFeedCloseSuccess": "RSS-Feed geschlossen", "ToastRSSFeedCloseSuccess": "RSS-Feed geschlossen",
"ToastSeriesUpdateFailed": "Series update failed",
"ToastSeriesUpdateSuccess": "Series update success",
"ToastSessionDeleteFailed": "Sitzung konnte nicht gelöscht werden", "ToastSessionDeleteFailed": "Sitzung konnte nicht gelöscht werden",
"ToastSessionDeleteSuccess": "Sitzung gelöscht", "ToastSessionDeleteSuccess": "Sitzung gelöscht",
"ToastSocketConnected": "Verbindung zum WebSocket hergestellt", "ToastSocketConnected": "Verbindung zum WebSocket hergestellt",
+25 -2
View File
@@ -20,6 +20,7 @@
"ButtonCreate": "Create", "ButtonCreate": "Create",
"ButtonCreateBackup": "Create Backup", "ButtonCreateBackup": "Create Backup",
"ButtonDelete": "Delete", "ButtonDelete": "Delete",
"ButtonEdit": "Edit",
"ButtonEditChapters": "Edit Chapters", "ButtonEditChapters": "Edit Chapters",
"ButtonEditPodcast": "Edit Podcast", "ButtonEditPodcast": "Edit Podcast",
"ButtonForceReScan": "Force Re-Scan", "ButtonForceReScan": "Force Re-Scan",
@@ -75,6 +76,8 @@
"ButtonUploadBackup": "Upload Backup", "ButtonUploadBackup": "Upload Backup",
"ButtonUploadCover": "Upload Cover", "ButtonUploadCover": "Upload Cover",
"ButtonUploadOPMLFile": "Upload OPML File", "ButtonUploadOPMLFile": "Upload OPML File",
"ButtonUserDelete": "Delete user {0}",
"ButtonUserEdit": "Edit user {0}",
"ButtonViewAll": "View All", "ButtonViewAll": "View All",
"ButtonYes": "Yes", "ButtonYes": "Yes",
"HeaderAccount": "Account", "HeaderAccount": "Account",
@@ -95,6 +98,7 @@
"HeaderFindChapters": "Find Chapters", "HeaderFindChapters": "Find Chapters",
"HeaderIgnoredFiles": "Ignored Files", "HeaderIgnoredFiles": "Ignored Files",
"HeaderItemFiles": "Item Files", "HeaderItemFiles": "Item Files",
"HeaderItemMetadataUtils": "Item Metadata Utils",
"HeaderLastListeningSession": "Last Listening Session", "HeaderLastListeningSession": "Last Listening Session",
"HeaderLatestEpisodes": "Latest episodes", "HeaderLatestEpisodes": "Latest episodes",
"HeaderLibraries": "Libraries", "HeaderLibraries": "Libraries",
@@ -104,6 +108,9 @@
"HeaderListeningStats": "Listening Stats", "HeaderListeningStats": "Listening Stats",
"HeaderLogin": "Login", "HeaderLogin": "Login",
"HeaderLogs": "Logs", "HeaderLogs": "Logs",
"HeaderManageGenres": "Manage Genres",
"HeaderManageTags": "Manage Tags",
"HeaderMapDetails": "Map details",
"HeaderMatch": "Match", "HeaderMatch": "Match",
"HeaderMetadataToEmbed": "Metadata to embed", "HeaderMetadataToEmbed": "Metadata to embed",
"HeaderNewAccount": "New Account", "HeaderNewAccount": "New Account",
@@ -155,6 +162,7 @@
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist", "LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
"LabelAll": "All", "LabelAll": "All",
"LabelAllUsers": "All Users", "LabelAllUsers": "All Users",
"LabelAppend": "Append",
"LabelAuthor": "Author", "LabelAuthor": "Author",
"LabelAuthorFirstLast": "Author (First Last)", "LabelAuthorFirstLast": "Author (First Last)",
"LabelAuthorLastFirst": "Author (Last, First)", "LabelAuthorLastFirst": "Author (Last, First)",
@@ -247,7 +255,6 @@
"LabelLogLevelInfo": "Info", "LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Warn", "LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date", "LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date",
"LabelMarkSeries": "Mark Series",
"LabelMediaPlayer": "Media Player", "LabelMediaPlayer": "Media Player",
"LabelMediaType": "Media Type", "LabelMediaType": "Media Type",
"LabelMetadataProvider": "Metadata Provider", "LabelMetadataProvider": "Metadata Provider",
@@ -278,6 +285,7 @@
"LabelNumberOfBooks": "Number of Books", "LabelNumberOfBooks": "Number of Books",
"LabelNumberOfEpisodes": "# of Episodes", "LabelNumberOfEpisodes": "# of Episodes",
"LabelOpenRSSFeed": "Open RSS Feed", "LabelOpenRSSFeed": "Open RSS Feed",
"LabelOverwrite": "Overwrite",
"LabelPassword": "Password", "LabelPassword": "Password",
"LabelPath": "Path", "LabelPath": "Path",
"LabelPermissionsAccessAllLibraries": "Can Access All Libraries", "LabelPermissionsAccessAllLibraries": "Can Access All Libraries",
@@ -342,7 +350,7 @@
"LabelSettingsSkipMatchingBooksWithISBN": "Skip matching books that already have an ISBN", "LabelSettingsSkipMatchingBooksWithISBN": "Skip matching books that already have an ISBN",
"LabelSettingsSortingIgnorePrefixes": "Ignore prefixes when sorting", "LabelSettingsSortingIgnorePrefixes": "Ignore prefixes when sorting",
"LabelSettingsSortingIgnorePrefixesHelp": "i.e. for prefix \"the\" book title \"The Book Title\" would sort as \"Book Title, The\"", "LabelSettingsSortingIgnorePrefixesHelp": "i.e. for prefix \"the\" book title \"The Book Title\" would sort as \"Book Title, The\"",
"LabelSettingsSquareBookCovers": "User square book covers", "LabelSettingsSquareBookCovers": "Use square book covers",
"LabelSettingsSquareBookCoversHelp": "Prefer to use square covers over standard 1.6:1 book covers", "LabelSettingsSquareBookCoversHelp": "Prefer to use square covers over standard 1.6:1 book covers",
"LabelSettingsStoreCoversWithItem": "Store covers with item", "LabelSettingsStoreCoversWithItem": "Store covers with item",
"LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept", "LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept",
@@ -435,10 +443,18 @@
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?", "MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",
"MessageConfirmDeleteSession": "Are you sure you want to delete this session?", "MessageConfirmDeleteSession": "Are you sure you want to delete this session?",
"MessageConfirmForceReScan": "Are you sure you want to force re-scan?", "MessageConfirmForceReScan": "Are you sure you want to force re-scan?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
"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?",
"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?",
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",
"MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".",
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".",
"MessageDownloadingEpisode": "Downloading episode", "MessageDownloadingEpisode": "Downloading episode",
"MessageDragFilesIntoTrackOrder": "Drag files into correct track order", "MessageDragFilesIntoTrackOrder": "Drag files into correct track order",
"MessageEmbedFinished": "Embed Finished!", "MessageEmbedFinished": "Embed Finished!",
@@ -449,6 +465,7 @@
"MessageImportantNotice": "Important Notice!", "MessageImportantNotice": "Important Notice!",
"MessageInsertChapterBelow": "Insert chapter below", "MessageInsertChapterBelow": "Insert chapter below",
"MessageItemsSelected": "{0} Items Selected", "MessageItemsSelected": "{0} Items Selected",
"MessageItemsUpdated": "{0} Items Updated",
"MessageJoinUsOn": "Join us on", "MessageJoinUsOn": "Join us on",
"MessageListeningSessionsInTheLastYear": "{0} listening sessions in the last year", "MessageListeningSessionsInTheLastYear": "{0} listening sessions in the last year",
"MessageLoading": "Loading...", "MessageLoading": "Loading...",
@@ -482,6 +499,7 @@
"MessageNoResults": "No Results", "MessageNoResults": "No Results",
"MessageNoSearchResultsFor": "No search results for \"{0}\"", "MessageNoSearchResultsFor": "No search results for \"{0}\"",
"MessageNoSeries": "No Series", "MessageNoSeries": "No Series",
"MessageNoTags": "No Tags",
"MessageNotYetImplemented": "Not yet implemented", "MessageNotYetImplemented": "Not yet implemented",
"MessageNoUpdateNecessary": "No update necessary", "MessageNoUpdateNecessary": "No update necessary",
"MessageNoUpdatesWereNecessary": "No updates were necessary", "MessageNoUpdatesWereNecessary": "No updates were necessary",
@@ -489,6 +507,7 @@
"MessageOr": "or", "MessageOr": "or",
"MessagePauseChapter": "Pause chapter playback", "MessagePauseChapter": "Pause chapter playback",
"MessagePlayChapter": "Listen to beginning of chapter", "MessagePlayChapter": "Listen to beginning of chapter",
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
"MessagePodcastHasNoRSSFeedForMatching": "Podcast has no RSS feed url to use for matching", "MessagePodcastHasNoRSSFeedForMatching": "Podcast has no RSS feed url to use for matching",
"MessageQuickMatchDescription": "Populate empty item details & cover with first match result from '{0}'. Does not overwrite details unless 'Prefer matched metadata' server setting is enabled.", "MessageQuickMatchDescription": "Populate empty item details & cover with first match result from '{0}'. Does not overwrite details unless 'Prefer matched metadata' server setting is enabled.",
"MessageRemoveAllItemsWarning": "WARNING! This action will remove all library items from the database including any updates or matches you have made. This does not do anything to your actual files. Are you sure?", "MessageRemoveAllItemsWarning": "WARNING! This action will remove all library items from the database including any updates or matches you have made. This does not do anything to your actual files. Are you sure?",
@@ -574,6 +593,8 @@
"ToastLibraryScanStarted": "Library scan started", "ToastLibraryScanStarted": "Library scan started",
"ToastLibraryUpdateFailed": "Failed to update library", "ToastLibraryUpdateFailed": "Failed to update library",
"ToastLibraryUpdateSuccess": "Library \"{0}\" updated", "ToastLibraryUpdateSuccess": "Library \"{0}\" updated",
"ToastPlaylistCreateFailed": "Failed to create playlist",
"ToastPlaylistCreateSuccess": "Playlist created",
"ToastPlaylistRemoveFailed": "Failed to remove playlist", "ToastPlaylistRemoveFailed": "Failed to remove playlist",
"ToastPlaylistRemoveSuccess": "Playlist removed", "ToastPlaylistRemoveSuccess": "Playlist removed",
"ToastPlaylistUpdateFailed": "Failed to update playlist", "ToastPlaylistUpdateFailed": "Failed to update playlist",
@@ -584,6 +605,8 @@
"ToastRemoveItemFromCollectionSuccess": "Item removed from collection", "ToastRemoveItemFromCollectionSuccess": "Item removed from collection",
"ToastRSSFeedCloseFailed": "Failed to close RSS feed", "ToastRSSFeedCloseFailed": "Failed to close RSS feed",
"ToastRSSFeedCloseSuccess": "RSS feed closed", "ToastRSSFeedCloseSuccess": "RSS feed closed",
"ToastSeriesUpdateFailed": "Series update failed",
"ToastSeriesUpdateSuccess": "Series update success",
"ToastSessionDeleteFailed": "Failed to delete session", "ToastSessionDeleteFailed": "Failed to delete session",
"ToastSessionDeleteSuccess": "Session deleted", "ToastSessionDeleteSuccess": "Session deleted",
"ToastSocketConnected": "Socket connected", "ToastSocketConnected": "Socket connected",
+25 -2
View File
@@ -20,6 +20,7 @@
"ButtonCreate": "Create", "ButtonCreate": "Create",
"ButtonCreateBackup": "Create Backup", "ButtonCreateBackup": "Create Backup",
"ButtonDelete": "Delete", "ButtonDelete": "Delete",
"ButtonEdit": "Edit",
"ButtonEditChapters": "Edit Chapters", "ButtonEditChapters": "Edit Chapters",
"ButtonEditPodcast": "Edit Podcast", "ButtonEditPodcast": "Edit Podcast",
"ButtonForceReScan": "Force Re-Scan", "ButtonForceReScan": "Force Re-Scan",
@@ -75,6 +76,8 @@
"ButtonUploadBackup": "Upload Backup", "ButtonUploadBackup": "Upload Backup",
"ButtonUploadCover": "Upload Cover", "ButtonUploadCover": "Upload Cover",
"ButtonUploadOPMLFile": "Upload OPML File", "ButtonUploadOPMLFile": "Upload OPML File",
"ButtonUserDelete": "Delete user {0}",
"ButtonUserEdit": "Edit user {0}",
"ButtonViewAll": "View All", "ButtonViewAll": "View All",
"ButtonYes": "Yes", "ButtonYes": "Yes",
"HeaderAccount": "Account", "HeaderAccount": "Account",
@@ -95,6 +98,7 @@
"HeaderFindChapters": "Find Chapters", "HeaderFindChapters": "Find Chapters",
"HeaderIgnoredFiles": "Ignored Files", "HeaderIgnoredFiles": "Ignored Files",
"HeaderItemFiles": "Item Files", "HeaderItemFiles": "Item Files",
"HeaderItemMetadataUtils": "Item Metadata Utils",
"HeaderLastListeningSession": "Last Listening Session", "HeaderLastListeningSession": "Last Listening Session",
"HeaderLatestEpisodes": "Latest episodes", "HeaderLatestEpisodes": "Latest episodes",
"HeaderLibraries": "Libraries", "HeaderLibraries": "Libraries",
@@ -104,6 +108,9 @@
"HeaderListeningStats": "Listening Stats", "HeaderListeningStats": "Listening Stats",
"HeaderLogin": "Login", "HeaderLogin": "Login",
"HeaderLogs": "Logs", "HeaderLogs": "Logs",
"HeaderManageGenres": "Manage Genres",
"HeaderManageTags": "Manage Tags",
"HeaderMapDetails": "Map details",
"HeaderMatch": "Match", "HeaderMatch": "Match",
"HeaderMetadataToEmbed": "Metadata to embed", "HeaderMetadataToEmbed": "Metadata to embed",
"HeaderNewAccount": "New Account", "HeaderNewAccount": "New Account",
@@ -155,6 +162,7 @@
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist", "LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
"LabelAll": "All", "LabelAll": "All",
"LabelAllUsers": "All Users", "LabelAllUsers": "All Users",
"LabelAppend": "Append",
"LabelAuthor": "Author", "LabelAuthor": "Author",
"LabelAuthorFirstLast": "Author (First Last)", "LabelAuthorFirstLast": "Author (First Last)",
"LabelAuthorLastFirst": "Author (Last, First)", "LabelAuthorLastFirst": "Author (Last, First)",
@@ -247,7 +255,6 @@
"LabelLogLevelInfo": "Info", "LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Warn", "LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date", "LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date",
"LabelMarkSeries": "Mark Series",
"LabelMediaPlayer": "Media Player", "LabelMediaPlayer": "Media Player",
"LabelMediaType": "Media Type", "LabelMediaType": "Media Type",
"LabelMetadataProvider": "Metadata Provider", "LabelMetadataProvider": "Metadata Provider",
@@ -278,6 +285,7 @@
"LabelNumberOfBooks": "Number of Books", "LabelNumberOfBooks": "Number of Books",
"LabelNumberOfEpisodes": "# of Episodes", "LabelNumberOfEpisodes": "# of Episodes",
"LabelOpenRSSFeed": "Open RSS Feed", "LabelOpenRSSFeed": "Open RSS Feed",
"LabelOverwrite": "Overwrite",
"LabelPassword": "Password", "LabelPassword": "Password",
"LabelPath": "Path", "LabelPath": "Path",
"LabelPermissionsAccessAllLibraries": "Can Access All Libraries", "LabelPermissionsAccessAllLibraries": "Can Access All Libraries",
@@ -342,7 +350,7 @@
"LabelSettingsSkipMatchingBooksWithISBN": "Skip matching books that already have an ISBN", "LabelSettingsSkipMatchingBooksWithISBN": "Skip matching books that already have an ISBN",
"LabelSettingsSortingIgnorePrefixes": "Ignore prefixes when sorting", "LabelSettingsSortingIgnorePrefixes": "Ignore prefixes when sorting",
"LabelSettingsSortingIgnorePrefixesHelp": "i.e. for prefix \"the\" book title \"The Book Title\" would sort as \"Book Title, The\"", "LabelSettingsSortingIgnorePrefixesHelp": "i.e. for prefix \"the\" book title \"The Book Title\" would sort as \"Book Title, The\"",
"LabelSettingsSquareBookCovers": "User square book covers", "LabelSettingsSquareBookCovers": "Use square book covers",
"LabelSettingsSquareBookCoversHelp": "Prefer to use square covers over standard 1.6:1 book covers", "LabelSettingsSquareBookCoversHelp": "Prefer to use square covers over standard 1.6:1 book covers",
"LabelSettingsStoreCoversWithItem": "Store covers with item", "LabelSettingsStoreCoversWithItem": "Store covers with item",
"LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept", "LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept",
@@ -435,10 +443,18 @@
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?", "MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",
"MessageConfirmDeleteSession": "Are you sure you want to delete this session?", "MessageConfirmDeleteSession": "Are you sure you want to delete this session?",
"MessageConfirmForceReScan": "Are you sure you want to force re-scan?", "MessageConfirmForceReScan": "Are you sure you want to force re-scan?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
"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?",
"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?",
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",
"MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".",
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".",
"MessageDownloadingEpisode": "Downloading episode", "MessageDownloadingEpisode": "Downloading episode",
"MessageDragFilesIntoTrackOrder": "Drag files into correct track order", "MessageDragFilesIntoTrackOrder": "Drag files into correct track order",
"MessageEmbedFinished": "Embed Finished!", "MessageEmbedFinished": "Embed Finished!",
@@ -449,6 +465,7 @@
"MessageImportantNotice": "Important Notice!", "MessageImportantNotice": "Important Notice!",
"MessageInsertChapterBelow": "Insert chapter below", "MessageInsertChapterBelow": "Insert chapter below",
"MessageItemsSelected": "{0} Items Selected", "MessageItemsSelected": "{0} Items Selected",
"MessageItemsUpdated": "{0} Items Updated",
"MessageJoinUsOn": "Join us on", "MessageJoinUsOn": "Join us on",
"MessageListeningSessionsInTheLastYear": "{0} listening sessions in the last year", "MessageListeningSessionsInTheLastYear": "{0} listening sessions in the last year",
"MessageLoading": "Loading...", "MessageLoading": "Loading...",
@@ -482,6 +499,7 @@
"MessageNoResults": "No Results", "MessageNoResults": "No Results",
"MessageNoSearchResultsFor": "No search results for \"{0}\"", "MessageNoSearchResultsFor": "No search results for \"{0}\"",
"MessageNoSeries": "No Series", "MessageNoSeries": "No Series",
"MessageNoTags": "No Tags",
"MessageNotYetImplemented": "Not yet implemented", "MessageNotYetImplemented": "Not yet implemented",
"MessageNoUpdateNecessary": "No update necessary", "MessageNoUpdateNecessary": "No update necessary",
"MessageNoUpdatesWereNecessary": "No updates were necessary", "MessageNoUpdatesWereNecessary": "No updates were necessary",
@@ -489,6 +507,7 @@
"MessageOr": "or", "MessageOr": "or",
"MessagePauseChapter": "Pause chapter playback", "MessagePauseChapter": "Pause chapter playback",
"MessagePlayChapter": "Listen to beginning of chapter", "MessagePlayChapter": "Listen to beginning of chapter",
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
"MessagePodcastHasNoRSSFeedForMatching": "Podcast has no RSS feed url to use for matching", "MessagePodcastHasNoRSSFeedForMatching": "Podcast has no RSS feed url to use for matching",
"MessageQuickMatchDescription": "Populate empty item details & cover with first match result from '{0}'. Does not overwrite details unless 'Prefer matched metadata' server setting is enabled.", "MessageQuickMatchDescription": "Populate empty item details & cover with first match result from '{0}'. Does not overwrite details unless 'Prefer matched metadata' server setting is enabled.",
"MessageRemoveAllItemsWarning": "WARNING! This action will remove all library items from the database including any updates or matches you have made. This does not do anything to your actual files. Are you sure?", "MessageRemoveAllItemsWarning": "WARNING! This action will remove all library items from the database including any updates or matches you have made. This does not do anything to your actual files. Are you sure?",
@@ -574,6 +593,8 @@
"ToastLibraryScanStarted": "Library scan started", "ToastLibraryScanStarted": "Library scan started",
"ToastLibraryUpdateFailed": "Failed to update library", "ToastLibraryUpdateFailed": "Failed to update library",
"ToastLibraryUpdateSuccess": "Library \"{0}\" updated", "ToastLibraryUpdateSuccess": "Library \"{0}\" updated",
"ToastPlaylistCreateFailed": "Failed to create playlist",
"ToastPlaylistCreateSuccess": "Playlist created",
"ToastPlaylistRemoveFailed": "Failed to remove playlist", "ToastPlaylistRemoveFailed": "Failed to remove playlist",
"ToastPlaylistRemoveSuccess": "Playlist removed", "ToastPlaylistRemoveSuccess": "Playlist removed",
"ToastPlaylistUpdateFailed": "Failed to update playlist", "ToastPlaylistUpdateFailed": "Failed to update playlist",
@@ -584,6 +605,8 @@
"ToastRemoveItemFromCollectionSuccess": "Item removed from collection", "ToastRemoveItemFromCollectionSuccess": "Item removed from collection",
"ToastRSSFeedCloseFailed": "Failed to close RSS feed", "ToastRSSFeedCloseFailed": "Failed to close RSS feed",
"ToastRSSFeedCloseSuccess": "RSS feed closed", "ToastRSSFeedCloseSuccess": "RSS feed closed",
"ToastSeriesUpdateFailed": "Series update failed",
"ToastSeriesUpdateSuccess": "Series update success",
"ToastSessionDeleteFailed": "Failed to delete session", "ToastSessionDeleteFailed": "Failed to delete session",
"ToastSessionDeleteSuccess": "Session deleted", "ToastSessionDeleteSuccess": "Session deleted",
"ToastSocketConnected": "Socket connected", "ToastSocketConnected": "Socket connected",
+71 -48
View File
@@ -20,6 +20,7 @@
"ButtonCreate": "Créer", "ButtonCreate": "Créer",
"ButtonCreateBackup": "Créer une Sauvegarde", "ButtonCreateBackup": "Créer une Sauvegarde",
"ButtonDelete": "Effacer", "ButtonDelete": "Effacer",
"ButtonEdit": "Edit",
"ButtonEditChapters": "Editer Chapitre", "ButtonEditChapters": "Editer Chapitre",
"ButtonEditPodcast": "Editer Podcast", "ButtonEditPodcast": "Editer Podcast",
"ButtonForceReScan": "Forcer un Re-Scan", "ButtonForceReScan": "Forcer un Re-Scan",
@@ -50,16 +51,16 @@
"ButtonQuickMatch": "Recherche Rapide", "ButtonQuickMatch": "Recherche Rapide",
"ButtonRead": "Lire", "ButtonRead": "Lire",
"ButtonRemove": "Supprimer", "ButtonRemove": "Supprimer",
"ButtonRemoveAll": "Supprimer Tout", "ButtonRemoveAll": "Supprimer tout",
"ButtonRemoveAllLibraryItems": "Supprimer Tout les Articles de la Bibliothèque", "ButtonRemoveAllLibraryItems": "Supprimer tous les Articles de la Bibliothèque",
"ButtonRemoveFromContinueListening": "Supprimer de Continuer à Ecouter", "ButtonRemoveFromContinueListening": "Ne plus continuer à écouter",
"ButtonRemoveSeriesFromContinueSeries": "Supprimer la Série de Continuer la Série", "ButtonRemoveSeriesFromContinueSeries": "Ne plus continuer à écouter la Série",
"ButtonReScan": "Re-Scan", "ButtonReScan": "Re-Scan",
"ButtonReset": "Réinitialiser", "ButtonReset": "Réinitialiser",
"ButtonRestore": "Rétablir", "ButtonRestore": "Rétablir",
"ButtonSave": "Sauvegarder", "ButtonSave": "Sauvegarder",
"ButtonSaveAndClose": "Sauvegarder & Fermer", "ButtonSaveAndClose": "Sauvegarder & Fermer",
"ButtonSaveTracklist": "Sauvegarder la Tracklist", "ButtonSaveTracklist": "Sauvegarder la liste de lecture",
"ButtonScan": "Scanner", "ButtonScan": "Scanner",
"ButtonScanLibrary": "Scanner la Bibliothèque", "ButtonScanLibrary": "Scanner la Bibliothèque",
"ButtonSearch": "Rechercher", "ButtonSearch": "Rechercher",
@@ -67,15 +68,17 @@
"ButtonSeries": "Séries", "ButtonSeries": "Séries",
"ButtonSetChaptersFromTracks": "Positionner les Chapitre par rapports aux Pistes", "ButtonSetChaptersFromTracks": "Positionner les Chapitre par rapports aux Pistes",
"ButtonShiftTimes": "Décaler le Temps", "ButtonShiftTimes": "Décaler le Temps",
"ButtonShow": "Montrer", "ButtonShow": "Afficher",
"ButtonStartM4BEncode": "Démarrer l'Encodage M4B", "ButtonStartM4BEncode": "Démarrer l'encodage M4B",
"ButtonStartMetadataEmbed": "Démarrer les Métadonnées Intégrées", "ButtonStartMetadataEmbed": "Démarrer les Métadonnées Intégrées",
"ButtonSubmit": "Soumettre", "ButtonSubmit": "Soumettre",
"ButtonUpload": "Téléverser", "ButtonUpload": "Téléverser",
"ButtonUploadBackup": "Téléverser une Sauvegarde", "ButtonUploadBackup": "Téléverser une Sauvegarde",
"ButtonUploadCover": "Téléverser une Couverture", "ButtonUploadCover": "Téléverser une Couverture",
"ButtonUploadOPMLFile": "Téléverser un Fichier OPML", "ButtonUploadOPMLFile": "Téléverser un Fichier OPML",
"ButtonViewAll": "Voir Tout", "ButtonUserDelete": "Effacer l'utilisateur {0}",
"ButtonUserEdit": "Modifier l'utilisateur {0}",
"ButtonViewAll": "Afficher Tout",
"ButtonYes": "Oui", "ButtonYes": "Oui",
"HeaderAccount": "Compte", "HeaderAccount": "Compte",
"HeaderAdvanced": "Avancé", "HeaderAdvanced": "Avancé",
@@ -83,7 +86,7 @@
"HeaderAudiobookTools": "Outils de Gestion de Fichier Audiobook", "HeaderAudiobookTools": "Outils de Gestion de Fichier Audiobook",
"HeaderAudioTracks": "Pistes Audio", "HeaderAudioTracks": "Pistes Audio",
"HeaderBackups": "Sauvegardes", "HeaderBackups": "Sauvegardes",
"HeaderChangePassword": "Chager le Mot de Passe", "HeaderChangePassword": "Chager le mot de passe",
"HeaderChapters": "Chapitres", "HeaderChapters": "Chapitres",
"HeaderChooseAFolder": "Choisir un Dossier", "HeaderChooseAFolder": "Choisir un Dossier",
"HeaderCollection": "Collection", "HeaderCollection": "Collection",
@@ -95,6 +98,7 @@
"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",
"HeaderLastListeningSession": "Dernière Session d'Ecoute", "HeaderLastListeningSession": "Dernière Session d'Ecoute",
"HeaderLatestEpisodes": "Dernier Episodes", "HeaderLatestEpisodes": "Dernier Episodes",
"HeaderLibraries": "Bibliothèque", "HeaderLibraries": "Bibliothèque",
@@ -104,6 +108,9 @@
"HeaderListeningStats": "Statistiques d'Ecoute", "HeaderListeningStats": "Statistiques d'Ecoute",
"HeaderLogin": "Connexion", "HeaderLogin": "Connexion",
"HeaderLogs": "Fichiers Journaux", "HeaderLogs": "Fichiers Journaux",
"HeaderManageGenres": "Gérer les Genres",
"HeaderManageTags": "Gérer les Etiquettes",
"HeaderMapDetails": "Edition en Masse",
"HeaderMatch": "Rechercher", "HeaderMatch": "Rechercher",
"HeaderMetadataToEmbed": "Métadonnée à Intégrer", "HeaderMetadataToEmbed": "Métadonnée à Intégrer",
"HeaderNewAccount": "Nouveau Compte", "HeaderNewAccount": "Nouveau Compte",
@@ -155,6 +162,7 @@
"LabelAddToPlaylistBatch": "{0} Elements Ajoutés à la Liste de Lecture", "LabelAddToPlaylistBatch": "{0} Elements Ajoutés à la Liste de Lecture",
"LabelAll": "Tout", "LabelAll": "Tout",
"LabelAllUsers": "Tous les Utilisateurs", "LabelAllUsers": "Tous les Utilisateurs",
"LabelAppend": "Ajouter",
"LabelAuthor": "Auteur", "LabelAuthor": "Auteur",
"LabelAuthorFirstLast": "Auteur (Prénom Nom)", "LabelAuthorFirstLast": "Auteur (Prénom Nom)",
"LabelAuthorLastFirst": "Auteur (Nom, Prénom)", "LabelAuthorLastFirst": "Auteur (Nom, Prénom)",
@@ -247,7 +255,6 @@
"LabelLogLevelInfo": "Info", "LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Warn", "LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Rechercher de Nouveaux Episode après cette Date", "LabelLookForNewEpisodesAfterDate": "Rechercher de Nouveaux Episode après cette Date",
"LabelMarkSeries": "Marquer la Série",
"LabelMediaPlayer": "Lecteur Multimédia", "LabelMediaPlayer": "Lecteur Multimédia",
"LabelMediaType": "Type de Média", "LabelMediaType": "Type de Média",
"LabelMetadataProvider": "Fournisseur de Métadonnées", "LabelMetadataProvider": "Fournisseur de Métadonnées",
@@ -278,6 +285,7 @@
"LabelNumberOfBooks": "Nombre de Livres", "LabelNumberOfBooks": "Nombre de Livres",
"LabelNumberOfEpisodes": "Nombre d'Episodes", "LabelNumberOfEpisodes": "Nombre d'Episodes",
"LabelOpenRSSFeed": "Ouvrir le flux RSS", "LabelOpenRSSFeed": "Ouvrir le flux RSS",
"LabelOverwrite": "Ecraser",
"LabelPassword": "Mot de Passe", "LabelPassword": "Mot de Passe",
"LabelPath": "Chemin", "LabelPath": "Chemin",
"LabelPermissionsAccessAllLibraries": "Peut Acceder à Toutes les Bibliothèque", "LabelPermissionsAccessAllLibraries": "Peut Acceder à Toutes les Bibliothèque",
@@ -408,9 +416,9 @@
"LabelUsername": "Nom d'Utilisateur", "LabelUsername": "Nom d'Utilisateur",
"LabelValue": "Valeur", "LabelValue": "Valeur",
"LabelVersion": "Version", "LabelVersion": "Version",
"LabelViewBookmarks": "Voir les Signets", "LabelViewBookmarks": "Afficher les Signets",
"LabelViewChapters": "Voir les Chapitres", "LabelViewChapters": "Afficher les Chapitres",
"LabelViewQueue": "Voir la Liste de Lecture", "LabelViewQueue": "Afficher la Liste de Lecture",
"LabelVolume": "Volume", "LabelVolume": "Volume",
"LabelWeekdaysToRun": "Jours de la semaine à exécuter", "LabelWeekdaysToRun": "Jours de la semaine à exécuter",
"LabelYourAudiobookDuration": "Durée de vos Livres Audios", "LabelYourAudiobookDuration": "Durée de vos Livres Audios",
@@ -435,10 +443,18 @@
"MessageConfirmDeleteLibrary": "Etes vous certain de vouloir supprimer définitivement la bibliothèque \"{0}\"?", "MessageConfirmDeleteLibrary": "Etes vous certain de vouloir supprimer définitivement la bibliothèque \"{0}\"?",
"MessageConfirmDeleteSession": "Etes vous certain de vouloir supprimer cette session?", "MessageConfirmDeleteSession": "Etes vous certain de vouloir supprimer cette session?",
"MessageConfirmForceReScan": "Etes vous certain de vouloir lancer une Analyse Forcée?", "MessageConfirmForceReScan": "Etes vous certain de vouloir lancer une Analyse Forcée?",
"MessageConfirmMarkSeriesFinished": "Etes vous certain de vouloir marquer comme terminé tous les livres de cette série?",
"MessageConfirmMarkSeriesNotFinished": "Etes vous certain de vouloir marquer comme non terminé tous les livres de cette série?",
"MessageConfirmRemoveCollection": "Etes vous certain de vouloir supprimer la collection \"{0}\"?", "MessageConfirmRemoveCollection": "Etes vous certain de vouloir supprimer la collection \"{0}\"?",
"MessageConfirmRemoveEpisode": "Etes vous certain de vouloir supprimer l'épisode \"{0}\"?", "MessageConfirmRemoveEpisode": "Etes vous certain de vouloir supprimer l'épisode \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Etes vous certain de vouloir supprimer {0} épisodes?", "MessageConfirmRemoveEpisodes": "Etes vous certain de vouloir supprimer {0} épisodes?",
"MessageConfirmRemovePlaylist": "Etes vous certain de vouloir supprimer la liste de lecture \"{0}\"?", "MessageConfirmRemovePlaylist": "Etes vous certain de vouloir supprimer la liste de lecture \"{0}\"?",
"MessageConfirmRenameGenre": "Etes vous certain de vouloir renommer le genre \"{0}\" vers \"{1}\" pour tous les articles?",
"MessageConfirmRenameGenreMergeNote": "Information: Ce genre existe déjà et sera fusionné.",
"MessageConfirmRenameGenreWarning": "Attention! Un genre similaire avec une casse différente existe déjà \"{0}\".",
"MessageConfirmRenameTag": "Etes vous certain de vouloir renommer l'étiquette \"{0}\" vers \"{1}\" pour tous les articles?",
"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}\".",
"MessageDownloadingEpisode": "Téléchargement de l'épisode", "MessageDownloadingEpisode": "Téléchargement de l'épisode",
"MessageDragFilesIntoTrackOrder": "Faire glisser les fichiers dans l'ordre correct", "MessageDragFilesIntoTrackOrder": "Faire glisser les fichiers dans l'ordre correct",
"MessageEmbedFinished": "Intégration Terminée!", "MessageEmbedFinished": "Intégration Terminée!",
@@ -449,6 +465,7 @@
"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",
"MessageJoinUsOn": "Rejoignez-nous sur", "MessageJoinUsOn": "Rejoignez-nous sur",
"MessageListeningSessionsInTheLastYear": "{0} sessions d'écoute l'an dernier", "MessageListeningSessionsInTheLastYear": "{0} sessions d'écoute l'an dernier",
"MessageLoading": "Chargement...", "MessageLoading": "Chargement...",
@@ -482,6 +499,7 @@
"MessageNoResults": "Pas de Résultats", "MessageNoResults": "Pas de Résultats",
"MessageNoSearchResultsFor": "Pas de résultats de recherche pour \"{0}\"", "MessageNoSearchResultsFor": "Pas de résultats de recherche pour \"{0}\"",
"MessageNoSeries": "Pas de Séries", "MessageNoSeries": "Pas de Séries",
"MessageNoTags": "Pas d'Etiquettes",
"MessageNotYetImplemented": "Non implémenté", "MessageNotYetImplemented": "Non implémenté",
"MessageNoUpdateNecessary": "Pas de mise à jour nécessaire", "MessageNoUpdateNecessary": "Pas de mise à jour nécessaire",
"MessageNoUpdatesWereNecessary": "Aucune mise à jour n'était nécessaire", "MessageNoUpdatesWereNecessary": "Aucune mise à jour n'était nécessaire",
@@ -489,6 +507,7 @@
"MessageOr": "ou", "MessageOr": "ou",
"MessagePauseChapter": "Suspendre la lecture du chapitre", "MessagePauseChapter": "Suspendre la lecture du chapitre",
"MessagePlayChapter": "Ecouter depuis le début du chapitre", "MessagePlayChapter": "Ecouter depuis le début du chapitre",
"MessagePlaylistCreateFromCollection": "Créer une liste de lecture depuis la collection",
"MessagePodcastHasNoRSSFeedForMatching": "Le Podcast n'a pas d'URL de flux RSS à utiliser pour la correspondance", "MessagePodcastHasNoRSSFeedForMatching": "Le Podcast n'a pas d'URL de flux RSS à utiliser pour la correspondance",
"MessageQuickMatchDescription": "Renseigne les détails manquants ainsi que la couverture avec la première correspondance de '{0}'. N'écrase pas les données présentes à moins que le paramètre 'Préférer les Métadonnées par correspondance' soit activé.", "MessageQuickMatchDescription": "Renseigne les détails manquants ainsi que la couverture avec la première correspondance de '{0}'. N'écrase pas les données présentes à moins que le paramètre 'Préférer les Métadonnées par correspondance' soit activé.",
"MessageRemoveAllItemsWarning": "ATTENTION! Cette action supprimera toute la base de données de la bibliothèque ainsi que les mises à jour ou correspondances qui auraient été effectuées. Cela n'a aucune incidence sur les fichiers de la bibliothèque. Voulez-vous continuer?", "MessageRemoveAllItemsWarning": "ATTENTION! Cette action supprimera toute la base de données de la bibliothèque ainsi que les mises à jour ou correspondances qui auraient été effectuées. Cela n'a aucune incidence sur les fichiers de la bibliothèque. Voulez-vous continuer?",
@@ -504,8 +523,8 @@
"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}?",
"MessageThinking": "On Réfléchit...", "MessageThinking": "On réfléchit...",
"MessageUploaderItemFailed": "Echec du téléversement", "MessageUploaderItemFailed": "Échec du téléversement",
"MessageUploaderItemSuccess": "Téléversement effectué!", "MessageUploaderItemSuccess": "Téléversement effectué!",
"MessageUploading": "Téléversement...", "MessageUploading": "Téléversement...",
"MessageValidCronExpression": "Expression cron valide", "MessageValidCronExpression": "Expression cron valide",
@@ -526,70 +545,74 @@
"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...",
"ToastAccountUpdateFailed": "Echec de la mise à jour du compte", "ToastAccountUpdateFailed": "Échec de la mise à jour du compte",
"ToastAccountUpdateSuccess": "Compte mis à jour", "ToastAccountUpdateSuccess": "Compte mis à jour",
"ToastAuthorImageRemoveFailed": "Echec de la suppression de l'image", "ToastAuthorImageRemoveFailed": "Échec de la suppression de l'image",
"ToastAuthorImageRemoveSuccess": "Image de l'auteur supprimée", "ToastAuthorImageRemoveSuccess": "Image de l'auteur supprimée",
"ToastAuthorUpdateFailed": "Echec de la mise à jour de l'auteur", "ToastAuthorUpdateFailed": "Échec de la mise à jour de l'auteur",
"ToastAuthorUpdateMerged": "Auteur fusionné", "ToastAuthorUpdateMerged": "Auteur fusionné",
"ToastAuthorUpdateSuccess": "Auteur mis à jour", "ToastAuthorUpdateSuccess": "Auteur mis à jour",
"ToastAuthorUpdateSuccessNoImageFound": "Auteur mis à jour (pas d'image trouvée)", "ToastAuthorUpdateSuccessNoImageFound": "Auteur mis à jour (pas d'image trouvée)",
"ToastBackupCreateFailed": "Echec de la création de sauvegarde", "ToastBackupCreateFailed": "Échec de la création de sauvegarde",
"ToastBackupCreateSuccess": "Sauvegarde créée", "ToastBackupCreateSuccess": "Sauvegarde créée",
"ToastBackupDeleteFailed": "Echec de la suppression de sauvegarde", "ToastBackupDeleteFailed": "Échec de la suppression de sauvegarde",
"ToastBackupDeleteSuccess": "Sauvegarde supprimée", "ToastBackupDeleteSuccess": "Sauvegarde supprimée",
"ToastBackupRestoreFailed": "Echec de la restauration de sauvegarde", "ToastBackupRestoreFailed": "Échec de la restauration de sauvegarde",
"ToastBackupUploadFailed": "Echec du téléversement de sauvegarde", "ToastBackupUploadFailed": "Échec du téléversement de sauvegarde",
"ToastBackupUploadSuccess": "Sauvegarde téléversée", "ToastBackupUploadSuccess": "Sauvegarde téléversée",
"ToastBatchUpdateFailed": "Echec de la mise à jour par lot", "ToastBatchUpdateFailed": "Échec de la mise à jour par lot",
"ToastBatchUpdateSuccess": "Mise à jour par lot terminée", "ToastBatchUpdateSuccess": "Mise à jour par lot terminée",
"ToastBookmarkCreateFailed": "Echec de la création de signet", "ToastBookmarkCreateFailed": "Échec de la création de signet",
"ToastBookmarkCreateSuccess": "Signet ajouté", "ToastBookmarkCreateSuccess": "Signet ajouté",
"ToastBookmarkRemoveFailed": "Echec de la suppression de signet", "ToastBookmarkRemoveFailed": "Échec de la suppression de signet",
"ToastBookmarkRemoveSuccess": "Signet supprimé", "ToastBookmarkRemoveSuccess": "Signet supprimé",
"ToastBookmarkUpdateFailed": "Echec de la mise à jour de signet", "ToastBookmarkUpdateFailed": "Échec de la mise à jour de signet",
"ToastBookmarkUpdateSuccess": "Signet mis à jour", "ToastBookmarkUpdateSuccess": "Signet mis à jour",
"ToastChaptersHaveErrors": "Les chapitres contiennent des erreurs", "ToastChaptersHaveErrors": "Les chapitres contiennent des erreurs",
"ToastChaptersMustHaveTitles": "Les chapitre doivent avoir un titre", "ToastChaptersMustHaveTitles": "Les chapitre doivent avoir un titre",
"ToastCollectionItemsRemoveFailed": "Echec de la suppression de(s) article(s) de la collection", "ToastCollectionItemsRemoveFailed": "Échec de la suppression de(s) article(s) de la collection",
"ToastCollectionItemsRemoveSuccess": "Article(s) supprimé(s) de la collection", "ToastCollectionItemsRemoveSuccess": "Article(s) supprimé(s) de la collection",
"ToastCollectionRemoveFailed": "Echec de la suppression de la collection", "ToastCollectionRemoveFailed": "Échec de la suppression de la collection",
"ToastCollectionRemoveSuccess": "Collection supprimée", "ToastCollectionRemoveSuccess": "Collection supprimée",
"ToastCollectionUpdateFailed": "Echec de la mise à jour de la collection", "ToastCollectionUpdateFailed": "Échec de la mise à jour de la collection",
"ToastCollectionUpdateSuccess": "Collection mise à jour", "ToastCollectionUpdateSuccess": "Collection mise à jour",
"ToastItemCoverUpdateFailed": "Echec de la mise à jour de la couverture de l'article", "ToastItemCoverUpdateFailed": "Échec de la mise à jour de la couverture de l'article",
"ToastItemCoverUpdateSuccess": "Couverture de l'article mise à jour", "ToastItemCoverUpdateSuccess": "Couverture de l'article mise à jour",
"ToastItemDetailsUpdateFailed": "Echec de la mise à jour des détails de l'article", "ToastItemDetailsUpdateFailed": "Échec de la mise à jour des détails de l'article",
"ToastItemDetailsUpdateSuccess": "Détails de l'article mis à jour", "ToastItemDetailsUpdateSuccess": "Détails de l'article mis à jour",
"ToastItemDetailsUpdateUnneeded": "Pas de mise à jour nécessaire pour les détails de l'article", "ToastItemDetailsUpdateUnneeded": "Pas de mise à jour nécessaire pour les détails de l'article",
"ToastItemMarkedAsFinishedFailed": "Echec de l'annotation terminée", "ToastItemMarkedAsFinishedFailed": "Échec de l'annotation terminée",
"ToastItemMarkedAsFinishedSuccess": "Article marqué comme terminé", "ToastItemMarkedAsFinishedSuccess": "Article marqué comme terminé",
"ToastItemMarkedAsNotFinishedFailed": "Echec de l'annotation non-terminée", "ToastItemMarkedAsNotFinishedFailed": "Échec de l'annotation non-terminée",
"ToastItemMarkedAsNotFinishedSuccess": "Article marqué comme non-terminé", "ToastItemMarkedAsNotFinishedSuccess": "Article marqué comme non-terminé",
"ToastLibraryCreateFailed": "Echec de la création de bibliothèque", "ToastLibraryCreateFailed": "Échec de la création de bibliothèque",
"ToastLibraryCreateSuccess": "Bibliothèque \"{0}\" créée", "ToastLibraryCreateSuccess": "Bibliothèque \"{0}\" créée",
"ToastLibraryDeleteFailed": "Echec de la suppression de la bibliothèque", "ToastLibraryDeleteFailed": "Échec de la suppression de la bibliothèque",
"ToastLibraryDeleteSuccess": "Bibliothèque supprimée", "ToastLibraryDeleteSuccess": "Bibliothèque supprimée",
"ToastLibraryScanFailedToStart": "Echec du démarrage de l'analyse", "ToastLibraryScanFailedToStart": "Échec du démarrage de l'analyse",
"ToastLibraryScanStarted": "Analyse de la bibliothèque démarrée", "ToastLibraryScanStarted": "Analyse de la bibliothèque démarrée",
"ToastLibraryUpdateFailed": "Echec de la mise à jour de la bibliothèque", "ToastLibraryUpdateFailed": "Échec de la mise à jour de la bibliothèque",
"ToastLibraryUpdateSuccess": "Bibliothèque \"{0}\" mise à jour", "ToastLibraryUpdateSuccess": "Bibliothèque \"{0}\" mise à jour",
"ToastPlaylistRemoveFailed": "Echec de la suppression de la liste de lecture", "ToastPlaylistCreateFailed": "Échec de la création de la liste de lecture",
"ToastPlaylistCreateSuccess": "Liste de lecture créée",
"ToastPlaylistRemoveFailed": "Échec de la suppression de la liste de lecture",
"ToastPlaylistRemoveSuccess": "Liste de lecture supprimée", "ToastPlaylistRemoveSuccess": "Liste de lecture supprimée",
"ToastPlaylistUpdateFailed": "Echec de la mise à jour de la liste de lecture", "ToastPlaylistUpdateFailed": "Échec de la mise à jour de la liste de lecture",
"ToastPlaylistUpdateSuccess": "Liste de lecture mise à jour", "ToastPlaylistUpdateSuccess": "Liste de lecture mise à jour",
"ToastPodcastCreateFailed": "Echec de la création du Podcast", "ToastPodcastCreateFailed": "Échec de la création du Podcast",
"ToastPodcastCreateSuccess": "Podcast créé", "ToastPodcastCreateSuccess": "Podcast créé",
"ToastRemoveItemFromCollectionFailed": "Echec de la suppression de l'article de la collection", "ToastRemoveItemFromCollectionFailed": "Échec de la suppression de l'article de la collection",
"ToastRemoveItemFromCollectionSuccess": "Article supprimé de la collection", "ToastRemoveItemFromCollectionSuccess": "Article supprimé de la collection",
"ToastRSSFeedCloseFailed": "Echec de la fermeture du flux RSS", "ToastRSSFeedCloseFailed": "Échec de la fermeture du flux RSS",
"ToastRSSFeedCloseSuccess": "Flux RSS fermé", "ToastRSSFeedCloseSuccess": "Flux RSS fermé",
"ToastSessionDeleteFailed": "Echec de la suppression de session", "ToastSeriesUpdateFailed": "Echec de la mise à jour de la série",
"ToastSeriesUpdateSuccess": "Mise à jour de la série réussie",
"ToastSessionDeleteFailed": "Échec de la suppression de session",
"ToastSessionDeleteSuccess": "Session supprimée", "ToastSessionDeleteSuccess": "Session supprimée",
"ToastSocketConnected": "WebSocket connectée", "ToastSocketConnected": "WebSocket connecté",
"ToastSocketDisconnected": "WebSocket déconnectée", "ToastSocketDisconnected": "WebSocket déconnecté",
"ToastSocketFailedToConnect": "Echec de la connexion WebSocket", "ToastSocketFailedToConnect": "Échec de la connexion WebSocket",
"ToastUserDeleteFailed": "Echec de la suppression de l'utilisateur", "ToastUserDeleteFailed": "Échec de la suppression de l'utilisateur",
"ToastUserDeleteSuccess": "Utilisateur supprimé", "ToastUserDeleteSuccess": "Utilisateur supprimé",
"WeekdayFriday": "Vendredi", "WeekdayFriday": "Vendredi",
"WeekdayMonday": "Lundi", "WeekdayMonday": "Lundi",
@@ -598,4 +621,4 @@
"WeekdayThursday": "Jeudi", "WeekdayThursday": "Jeudi",
"WeekdayTuesday": "Mardi", "WeekdayTuesday": "Mardi",
"WeekdayWednesday": "Mercredi" "WeekdayWednesday": "Mercredi"
} }
+24 -1
View File
@@ -20,6 +20,7 @@
"ButtonCreate": "Napravi", "ButtonCreate": "Napravi",
"ButtonCreateBackup": "Napravi backup", "ButtonCreateBackup": "Napravi backup",
"ButtonDelete": "Obriši", "ButtonDelete": "Obriši",
"ButtonEdit": "Edit",
"ButtonEditChapters": "Uredi poglavlja", "ButtonEditChapters": "Uredi poglavlja",
"ButtonEditPodcast": "Uredi podcast", "ButtonEditPodcast": "Uredi podcast",
"ButtonForceReScan": "Prisilno ponovno skeniranje", "ButtonForceReScan": "Prisilno ponovno skeniranje",
@@ -75,6 +76,8 @@
"ButtonUploadBackup": "Upload backup", "ButtonUploadBackup": "Upload backup",
"ButtonUploadCover": "Upload Cover", "ButtonUploadCover": "Upload Cover",
"ButtonUploadOPMLFile": "Upload OPML Datoteku", "ButtonUploadOPMLFile": "Upload OPML Datoteku",
"ButtonUserDelete": "Delete user {0}",
"ButtonUserEdit": "Edit user {0}",
"ButtonViewAll": "Prikaži sve", "ButtonViewAll": "Prikaži sve",
"ButtonYes": "Da", "ButtonYes": "Da",
"HeaderAccount": "Korisnički račun", "HeaderAccount": "Korisnički račun",
@@ -95,6 +98,7 @@
"HeaderFindChapters": "Pronađi poglavlja", "HeaderFindChapters": "Pronađi poglavlja",
"HeaderIgnoredFiles": "Zanemarene datoteke", "HeaderIgnoredFiles": "Zanemarene datoteke",
"HeaderItemFiles": "Item Files", "HeaderItemFiles": "Item Files",
"HeaderItemMetadataUtils": "Item Metadata Utils",
"HeaderLastListeningSession": "Posljednja Listening Session", "HeaderLastListeningSession": "Posljednja Listening Session",
"HeaderLatestEpisodes": "Najnovije epizode", "HeaderLatestEpisodes": "Najnovije epizode",
"HeaderLibraries": "Biblioteke", "HeaderLibraries": "Biblioteke",
@@ -104,6 +108,9 @@
"HeaderListeningStats": "Listening Stats", "HeaderListeningStats": "Listening Stats",
"HeaderLogin": "Prijavljivanje", "HeaderLogin": "Prijavljivanje",
"HeaderLogs": "Logs", "HeaderLogs": "Logs",
"HeaderManageGenres": "Manage Genres",
"HeaderManageTags": "Manage Tags",
"HeaderMapDetails": "Map details",
"HeaderMatch": "Match", "HeaderMatch": "Match",
"HeaderMetadataToEmbed": "Metapodatci za ugradnju", "HeaderMetadataToEmbed": "Metapodatci za ugradnju",
"HeaderNewAccount": "Novi korisnički račun", "HeaderNewAccount": "Novi korisnički račun",
@@ -155,6 +162,7 @@
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist", "LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
"LabelAll": "All", "LabelAll": "All",
"LabelAllUsers": "Svi korisnici", "LabelAllUsers": "Svi korisnici",
"LabelAppend": "Append",
"LabelAuthor": "Autor", "LabelAuthor": "Autor",
"LabelAuthorFirstLast": "Author (First Last)", "LabelAuthorFirstLast": "Author (First Last)",
"LabelAuthorLastFirst": "Author (Last, First)", "LabelAuthorLastFirst": "Author (Last, First)",
@@ -247,7 +255,6 @@
"LabelLogLevelInfo": "Info", "LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Warn", "LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Traži nove epizode nakon ovog datuma", "LabelLookForNewEpisodesAfterDate": "Traži nove epizode nakon ovog datuma",
"LabelMarkSeries": "Označi seriju",
"LabelMediaPlayer": "Media Player", "LabelMediaPlayer": "Media Player",
"LabelMediaType": "Media Type", "LabelMediaType": "Media Type",
"LabelMetadataProvider": "Poslužitelj metapodataka ", "LabelMetadataProvider": "Poslužitelj metapodataka ",
@@ -278,6 +285,7 @@
"LabelNumberOfBooks": "Number of Books", "LabelNumberOfBooks": "Number of Books",
"LabelNumberOfEpisodes": "# of Episodes", "LabelNumberOfEpisodes": "# of Episodes",
"LabelOpenRSSFeed": "Otvori RSS Feed", "LabelOpenRSSFeed": "Otvori RSS Feed",
"LabelOverwrite": "Overwrite",
"LabelPassword": "Lozinka", "LabelPassword": "Lozinka",
"LabelPath": "Putanja", "LabelPath": "Putanja",
"LabelPermissionsAccessAllLibraries": "Ima pristup svim bibliotekama", "LabelPermissionsAccessAllLibraries": "Ima pristup svim bibliotekama",
@@ -435,10 +443,18 @@
"MessageConfirmDeleteLibrary": "Jeste li sigurni da želite trajno obrisati biblioteku \"{0}\"?", "MessageConfirmDeleteLibrary": "Jeste li sigurni da želite trajno obrisati biblioteku \"{0}\"?",
"MessageConfirmDeleteSession": "Jeste li sigurni da želite obrisati ovu sesiju?", "MessageConfirmDeleteSession": "Jeste li sigurni da želite obrisati ovu sesiju?",
"MessageConfirmForceReScan": "Jeste li sigurni da želite ponovno skenirati?", "MessageConfirmForceReScan": "Jeste li sigurni da želite ponovno skenirati?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
"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?",
"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?",
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",
"MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".",
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".",
"MessageDownloadingEpisode": "Preuzimam epizodu", "MessageDownloadingEpisode": "Preuzimam epizodu",
"MessageDragFilesIntoTrackOrder": "Povuci datoteke u pravilan redoslijed tracka.", "MessageDragFilesIntoTrackOrder": "Povuci datoteke u pravilan redoslijed tracka.",
"MessageEmbedFinished": "Embed završen!", "MessageEmbedFinished": "Embed završen!",
@@ -449,6 +465,7 @@
"MessageImportantNotice": "Važna obavijest!", "MessageImportantNotice": "Važna obavijest!",
"MessageInsertChapterBelow": "Unesi poglavlje ispod", "MessageInsertChapterBelow": "Unesi poglavlje ispod",
"MessageItemsSelected": "{0} odabranih stavki", "MessageItemsSelected": "{0} odabranih stavki",
"MessageItemsUpdated": "{0} Items Updated",
"MessageJoinUsOn": "Pridruži nam se na", "MessageJoinUsOn": "Pridruži nam se na",
"MessageListeningSessionsInTheLastYear": "{0} slušanja u prošloj godini", "MessageListeningSessionsInTheLastYear": "{0} slušanja u prošloj godini",
"MessageLoading": "Učitavam...", "MessageLoading": "Učitavam...",
@@ -482,6 +499,7 @@
"MessageNoResults": "Nema rezultata", "MessageNoResults": "Nema rezultata",
"MessageNoSearchResultsFor": "Nema rezultata pretragee za \"{0}\"", "MessageNoSearchResultsFor": "Nema rezultata pretragee za \"{0}\"",
"MessageNoSeries": "No Series", "MessageNoSeries": "No Series",
"MessageNoTags": "No Tags",
"MessageNotYetImplemented": "Not yet implemented", "MessageNotYetImplemented": "Not yet implemented",
"MessageNoUpdateNecessary": "Aktualiziranje nije potrebno", "MessageNoUpdateNecessary": "Aktualiziranje nije potrebno",
"MessageNoUpdatesWereNecessary": "Aktualiziranje nije bilo potrebno", "MessageNoUpdatesWereNecessary": "Aktualiziranje nije bilo potrebno",
@@ -489,6 +507,7 @@
"MessageOr": "or", "MessageOr": "or",
"MessagePauseChapter": "Pause chapter playback", "MessagePauseChapter": "Pause chapter playback",
"MessagePlayChapter": "Listen to beginning of chapter", "MessagePlayChapter": "Listen to beginning of chapter",
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
"MessagePodcastHasNoRSSFeedForMatching": "Podcast nema RSS feed url za matchanje", "MessagePodcastHasNoRSSFeedForMatching": "Podcast nema RSS feed url za matchanje",
"MessageQuickMatchDescription": "Popuni prazne detalje stavki i cover sa prvim match rezultato iz '{0}'. Ne briše detalje osim ako 'Prefer matched metadata' server postavka nije uključena.", "MessageQuickMatchDescription": "Popuni prazne detalje stavki i cover sa prvim match rezultato iz '{0}'. Ne briše detalje osim ako 'Prefer matched metadata' server postavka nije uključena.",
"MessageRemoveAllItemsWarning": "UPOZORENJE! Ova radnja briše sve stavke iz biblioteke uključujući bilokakve aktualizacije ili matcheve. Ovo ne mjenja vaše lokalne datoteke. Jeste li sigurni?", "MessageRemoveAllItemsWarning": "UPOZORENJE! Ova radnja briše sve stavke iz biblioteke uključujući bilokakve aktualizacije ili matcheve. Ovo ne mjenja vaše lokalne datoteke. Jeste li sigurni?",
@@ -574,6 +593,8 @@
"ToastLibraryScanStarted": "Sken biblioteke pokrenut", "ToastLibraryScanStarted": "Sken biblioteke pokrenut",
"ToastLibraryUpdateFailed": "Aktualiziranje biblioteke neuspješno", "ToastLibraryUpdateFailed": "Aktualiziranje biblioteke neuspješno",
"ToastLibraryUpdateSuccess": "Biblioteka \"{0}\" aktualizirana", "ToastLibraryUpdateSuccess": "Biblioteka \"{0}\" aktualizirana",
"ToastPlaylistCreateFailed": "Failed to create playlist",
"ToastPlaylistCreateSuccess": "Playlist created",
"ToastPlaylistRemoveFailed": "Failed to remove playlist", "ToastPlaylistRemoveFailed": "Failed to remove playlist",
"ToastPlaylistRemoveSuccess": "Playlist removed", "ToastPlaylistRemoveSuccess": "Playlist removed",
"ToastPlaylistUpdateFailed": "Failed to update playlist", "ToastPlaylistUpdateFailed": "Failed to update playlist",
@@ -584,6 +605,8 @@
"ToastRemoveItemFromCollectionSuccess": "Stavka uklonjena iz kolekcije", "ToastRemoveItemFromCollectionSuccess": "Stavka uklonjena iz kolekcije",
"ToastRSSFeedCloseFailed": "Neuspješno zatvaranje RSS Feeda", "ToastRSSFeedCloseFailed": "Neuspješno zatvaranje RSS Feeda",
"ToastRSSFeedCloseSuccess": "RSS Feed zatvoren", "ToastRSSFeedCloseSuccess": "RSS Feed zatvoren",
"ToastSeriesUpdateFailed": "Series update failed",
"ToastSeriesUpdateSuccess": "Series update success",
"ToastSessionDeleteFailed": "Neuspješno brisanje serije", "ToastSessionDeleteFailed": "Neuspješno brisanje serije",
"ToastSessionDeleteSuccess": "Sesija obrisana", "ToastSessionDeleteSuccess": "Sesija obrisana",
"ToastSocketConnected": "Socket connected", "ToastSocketConnected": "Socket connected",
+53 -30
View File
@@ -20,6 +20,7 @@
"ButtonCreate": "Crea", "ButtonCreate": "Crea",
"ButtonCreateBackup": "Crea un Backup", "ButtonCreateBackup": "Crea un Backup",
"ButtonDelete": "Elimina", "ButtonDelete": "Elimina",
"ButtonEdit": "Edit",
"ButtonEditChapters": "Modifica Capitoli", "ButtonEditChapters": "Modifica Capitoli",
"ButtonEditPodcast": "Modifica Podcast", "ButtonEditPodcast": "Modifica Podcast",
"ButtonForceReScan": "Forza Re-Scan", "ButtonForceReScan": "Forza Re-Scan",
@@ -54,7 +55,7 @@
"ButtonRemoveAllLibraryItems": "Rimuovi tutto il contenuto della libreria", "ButtonRemoveAllLibraryItems": "Rimuovi tutto il contenuto della libreria",
"ButtonRemoveFromContinueListening": "Rimuovi per proseguire l'ascolto", "ButtonRemoveFromContinueListening": "Rimuovi per proseguire l'ascolto",
"ButtonRemoveSeriesFromContinueSeries": "Rimuovi la Serie per Continuarla", "ButtonRemoveSeriesFromContinueSeries": "Rimuovi la Serie per Continuarla",
"ButtonReScan": "Riscansiona", "ButtonReScan": "Ri-scansiona",
"ButtonReset": "Reset", "ButtonReset": "Reset",
"ButtonRestore": "Ripristina", "ButtonRestore": "Ripristina",
"ButtonSave": "Salva", "ButtonSave": "Salva",
@@ -65,7 +66,7 @@
"ButtonSearch": "Cerca", "ButtonSearch": "Cerca",
"ButtonSelectFolderPath": "Seleziona percorso cartella", "ButtonSelectFolderPath": "Seleziona percorso cartella",
"ButtonSeries": "Serie", "ButtonSeries": "Serie",
"ButtonSetChaptersFromTracks": "Set chapters from tracks", "ButtonSetChaptersFromTracks": "Impostare i capitoli dalle tracce",
"ButtonShiftTimes": "Ricerca veloce", "ButtonShiftTimes": "Ricerca veloce",
"ButtonShow": "Mostra", "ButtonShow": "Mostra",
"ButtonStartM4BEncode": "Inizia L'Encoda del M4B", "ButtonStartM4BEncode": "Inizia L'Encoda del M4B",
@@ -75,6 +76,8 @@
"ButtonUploadBackup": "Carica Backup", "ButtonUploadBackup": "Carica Backup",
"ButtonUploadCover": "Carica Cover", "ButtonUploadCover": "Carica Cover",
"ButtonUploadOPMLFile": "Carica File OPML", "ButtonUploadOPMLFile": "Carica File OPML",
"ButtonUserDelete": "Delete user {0}",
"ButtonUserEdit": "Edit user {0}",
"ButtonViewAll": "Mostra Tutto", "ButtonViewAll": "Mostra Tutto",
"ButtonYes": "Si", "ButtonYes": "Si",
"HeaderAccount": "Account", "HeaderAccount": "Account",
@@ -95,6 +98,7 @@
"HeaderFindChapters": "Trova Capitoli", "HeaderFindChapters": "Trova Capitoli",
"HeaderIgnoredFiles": "File Ignorati", "HeaderIgnoredFiles": "File Ignorati",
"HeaderItemFiles": "Files", "HeaderItemFiles": "Files",
"HeaderItemMetadataUtils": "Utilità Metadata oggetti",
"HeaderLastListeningSession": "Ultima sessione di Ascolto", "HeaderLastListeningSession": "Ultima sessione di Ascolto",
"HeaderLatestEpisodes": "Ultimi Episodi", "HeaderLatestEpisodes": "Ultimi Episodi",
"HeaderLibraries": "Librerie", "HeaderLibraries": "Librerie",
@@ -104,6 +108,9 @@
"HeaderListeningStats": "Statistiche di Ascolto", "HeaderListeningStats": "Statistiche di Ascolto",
"HeaderLogin": "Login", "HeaderLogin": "Login",
"HeaderLogs": "Logs", "HeaderLogs": "Logs",
"HeaderManageGenres": "Gestisci Generi",
"HeaderManageTags": "Gestisci Tags",
"HeaderMapDetails": "Mappa Dettagli",
"HeaderMatch": "Trova Corrispondenza", "HeaderMatch": "Trova Corrispondenza",
"HeaderMetadataToEmbed": "Metadata da incorporare", "HeaderMetadataToEmbed": "Metadata da incorporare",
"HeaderNewAccount": "Nuovo Account", "HeaderNewAccount": "Nuovo Account",
@@ -114,7 +121,7 @@
"HeaderPermissions": "Permessi", "HeaderPermissions": "Permessi",
"HeaderPlayerQueue": "Coda Riproduzione", "HeaderPlayerQueue": "Coda Riproduzione",
"HeaderPlaylist": "Playlist", "HeaderPlaylist": "Playlist",
"HeaderPlaylistItems": "Playlist Items", "HeaderPlaylistItems": "Elementi della playlist",
"HeaderPodcastsToAdd": "Podcasts da Aggiungere", "HeaderPodcastsToAdd": "Podcasts da Aggiungere",
"HeaderPreviewCover": "Anteprima Cover", "HeaderPreviewCover": "Anteprima Cover",
"HeaderRemoveEpisode": "Rimuovi Episodi", "HeaderRemoveEpisode": "Rimuovi Episodi",
@@ -151,10 +158,11 @@
"LabelAddedAt": "Aggiunto il", "LabelAddedAt": "Aggiunto il",
"LabelAddToCollection": "Aggiungi alla Raccolta", "LabelAddToCollection": "Aggiungi alla Raccolta",
"LabelAddToCollectionBatch": "Aggiungi {0} Libri alla Raccolta", "LabelAddToCollectionBatch": "Aggiungi {0} Libri alla Raccolta",
"LabelAddToPlaylist": "Add to Playlist", "LabelAddToPlaylist": "aggiungi alla Playlist",
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist", "LabelAddToPlaylistBatch": "Aggiungi {0} file alla Playlist",
"LabelAll": "All", "LabelAll": "Tutti",
"LabelAllUsers": "Tutti gli Utenti", "LabelAllUsers": "Tutti gli Utenti",
"LabelAppend": "Appese",
"LabelAuthor": "Autore", "LabelAuthor": "Autore",
"LabelAuthorFirstLast": "Autore (Per Nome)", "LabelAuthorFirstLast": "Autore (Per Nome)",
"LabelAuthorLastFirst": "Autori (Per Cognome)", "LabelAuthorLastFirst": "Autori (Per Cognome)",
@@ -247,7 +255,6 @@
"LabelLogLevelInfo": "Info", "LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Allarme", "LabelLogLevelWarn": "Allarme",
"LabelLookForNewEpisodesAfterDate": "Cerca nuovi episodi dopo questa data", "LabelLookForNewEpisodesAfterDate": "Cerca nuovi episodi dopo questa data",
"LabelMarkSeries": "Segna Serie",
"LabelMediaPlayer": "Media Player", "LabelMediaPlayer": "Media Player",
"LabelMediaType": "Tipo Media", "LabelMediaType": "Tipo Media",
"LabelMetadataProvider": "Metadata Provider", "LabelMetadataProvider": "Metadata Provider",
@@ -278,6 +285,7 @@
"LabelNumberOfBooks": "Numero di libri", "LabelNumberOfBooks": "Numero di libri",
"LabelNumberOfEpisodes": "# degli episodi", "LabelNumberOfEpisodes": "# degli episodi",
"LabelOpenRSSFeed": "Apri RSS Feed", "LabelOpenRSSFeed": "Apri RSS Feed",
"LabelOverwrite": "Overwrite",
"LabelPassword": "Password", "LabelPassword": "Password",
"LabelPath": "Percorso", "LabelPath": "Percorso",
"LabelPermissionsAccessAllLibraries": "Può accedere a tutte le librerie", "LabelPermissionsAccessAllLibraries": "Può accedere a tutte le librerie",
@@ -341,7 +349,7 @@
"LabelSettingsSkipMatchingBooksWithASIN": "Salta la ricerca dati in internet se è già presente un codice ASIN", "LabelSettingsSkipMatchingBooksWithASIN": "Salta la ricerca dati in internet se è già presente un codice ASIN",
"LabelSettingsSkipMatchingBooksWithISBN": "Salta la ricerca dati in internet se è già presente un codice ISBN", "LabelSettingsSkipMatchingBooksWithISBN": "Salta la ricerca dati in internet se è già presente un codice ISBN",
"LabelSettingsSortingIgnorePrefixes": "Ignora i prefissi nei titoli durante l'aggiunta", "LabelSettingsSortingIgnorePrefixes": "Ignora i prefissi nei titoli durante l'aggiunta",
"LabelSettingsSortingIgnorePrefixesHelp": "Per prefisso si intende ad esempio \"il\" cone nel libro \"Il signore degli anelli\" che verrebbe ordinato come \"signore degli anelli, il\"", "LabelSettingsSortingIgnorePrefixesHelp": "Per prefisso si intende ad esempio \"il\" come nel libro \"Il signore degli anelli\" che verrebbe ordinato come \"signore degli anelli, il\"",
"LabelSettingsSquareBookCovers": "Utilizza le copertine quadrate", "LabelSettingsSquareBookCovers": "Utilizza le copertine quadrate",
"LabelSettingsSquareBookCoversHelp": "Preferisci usare copertine quadrate rispetto a copertine di libri standard 1,6:1", "LabelSettingsSquareBookCoversHelp": "Preferisci usare copertine quadrate rispetto a copertine di libri standard 1,6:1",
"LabelSettingsStoreCoversWithItem": "Archivia le copertine con il file", "LabelSettingsStoreCoversWithItem": "Archivia le copertine con il file",
@@ -390,9 +398,9 @@
"LabelTotalTimeListened": "Tempo totale di Ascolto", "LabelTotalTimeListened": "Tempo totale di Ascolto",
"LabelTrackFromFilename": "Traccia da nome file", "LabelTrackFromFilename": "Traccia da nome file",
"LabelTrackFromMetadata": "Traccia da Metadata", "LabelTrackFromMetadata": "Traccia da Metadata",
"LabelTracks": "Tracks", "LabelTracks": "Traccia",
"LabelTracksMultiTrack": "Multi-track", "LabelTracksMultiTrack": "Multi-traccia",
"LabelTracksSingleTrack": "Single-track", "LabelTracksSingleTrack": "Traccia-singola",
"LabelType": "Tipo", "LabelType": "Tipo",
"LabelUnknown": "Sconosciuto", "LabelUnknown": "Sconosciuto",
"LabelUpdateCover": "Aggiornamento Cover", "LabelUpdateCover": "Aggiornamento Cover",
@@ -415,30 +423,38 @@
"LabelWeekdaysToRun": "Giorni feriali da eseguire", "LabelWeekdaysToRun": "Giorni feriali da eseguire",
"LabelYourAudiobookDuration": "La durata dell'audiolibro", "LabelYourAudiobookDuration": "La durata dell'audiolibro",
"LabelYourBookmarks": "I tuoi Preferiti", "LabelYourBookmarks": "I tuoi Preferiti",
"LabelYourPlaylists": "Your Playlists", "LabelYourPlaylists": "le tue Playlist",
"LabelYourProgress": "Completato al", "LabelYourProgress": "Completato al",
"MessageAddToPlayerQueue": "Add to player queue", "MessageAddToPlayerQueue": "Aggiungi alla coda di riproduzione",
"MessageAppriseDescription": "Per utilizzare questa funzione è necessario disporre di un'istanza di <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> in esecuzione o un'API che gestirà quelle stesse richieste. <br />L'API Url dovrebbe essere il percorso URL completo per inviare la notifica, ad esempio se la tua istanza API è servita cosi .<code>http://192.168.1.1:8337</code> Allora dovrai mettere <code>http://192.168.1.1:8337/notify</code>.", "MessageAppriseDescription": "Per utilizzare questa funzione è necessario disporre di un'istanza di <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> in esecuzione o un'API che gestirà quelle stesse richieste. <br />L'API Url dovrebbe essere il percorso URL completo per inviare la notifica, ad esempio se la tua istanza API è servita cosi .<code>http://192.168.1.1:8337</code> Allora dovrai mettere <code>http://192.168.1.1:8337/notify</code>.",
"MessageBackupsDescription": "I backup includono utenti, progressi degli utenti, dettagli sugli elementi della libreria, impostazioni del server e immagini archiviate in <code>/metadata/items</code> & <code>/metadata/authors</code>. I backup non includono i file archiviati nelle cartelle della libreria.", "MessageBackupsDescription": "I backup includono utenti, progressi degli utenti, dettagli sugli elementi della libreria, impostazioni del server e immagini archiviate in <code>/metadata/items</code> & <code>/metadata/authors</code>. I backup non includono i file archiviati nelle cartelle della libreria.",
"MessageBatchQuickMatchDescription": "Quick Match tenterà di aggiungere copertine e metadati mancanti per gli elementi selezionati. Attiva l'opzione per consentire a Quick Match di sovrascrivere copertine e/o metadati esistenti.", "MessageBatchQuickMatchDescription": "Quick Match tenterà di aggiungere copertine e metadati mancanti per gli elementi selezionati. Attiva l'opzione per consentire a Quick Match di sovrascrivere copertine e/o metadati esistenti.",
"MessageBookshelfNoCollections": "Non hai ancora creato nessuna raccolta ", "MessageBookshelfNoCollections": "Non hai ancora creato nessuna raccolta ",
"MessageBookshelfNoResultsForFilter": "Nessul risultato per il filtro \"{0}: {1}\"", "MessageBookshelfNoResultsForFilter": "Nessul risultato per il filtro \"{0}: {1}\"",
"MessageBookshelfNoRSSFeeds": "Nessun RSS feeds aperto", "MessageBookshelfNoRSSFeeds": "Nessun RSS feeds aperto",
"MessageBookshelfNoSeries": "You have no series", "MessageBookshelfNoSeries": "Non c'è nessuna Serie",
"MessageChapterEndIsAfter": "La fine del capitolo è dopo la fine del tuo audiolibro", "MessageChapterEndIsAfter": "La fine del capitolo è dopo la fine del tuo audiolibro",
"MessageChapterErrorFirstNotZero": "First chapter must start at 0", "MessageChapterErrorFirstNotZero": "Il primo capitolo deve iniziare da 0",
"MessageChapterErrorStartGteDuration": "Invalid start time must be less than audiobook duration", "MessageChapterErrorStartGteDuration": "L'ora di inizio non valida deve essere inferiore alla durata dell'audiolibro",
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time", "MessageChapterErrorStartLtPrev": "L'ora di inizio non valida deve essere maggiore o uguale all'ora di inizio del capitolo precedente",
"MessageChapterStartIsAfter": "L'inizio del capitolo è dopo la fine del tuo audiolibro", "MessageChapterStartIsAfter": "L'inizio del capitolo è dopo la fine del tuo audiolibro",
"MessageCheckingCron": "Controllo cron...", "MessageCheckingCron": "Controllo cron...",
"MessageConfirmDeleteBackup": "Sei sicuro di voler eliminare il backup {0}?", "MessageConfirmDeleteBackup": "Sei sicuro di voler eliminare il backup {0}?",
"MessageConfirmDeleteLibrary": "Sei sicuro di voler eliminare definitivamente la libreria \"{0}\"?", "MessageConfirmDeleteLibrary": "Sei sicuro di voler eliminare definitivamente la libreria \"{0}\"?",
"MessageConfirmDeleteSession": "Sei sicuro di voler eliminare questa sessione?", "MessageConfirmDeleteSession": "Sei sicuro di voler eliminare questa sessione?",
"MessageConfirmForceReScan": "Sei sicuro di voler forzare una nuova scansione?", "MessageConfirmForceReScan": "Sei sicuro di voler forzare una nuova scansione?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
"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?",
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your 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?",
"MessageConfirmRenameGenreMergeNote": "Note: Questo genere esiste già quindi verra unito.",
"MessageConfirmRenameGenreWarning": "Avvertimento! Esiste già un genere simile con un nome simile \"{0}\".",
"MessageConfirmRenameTag": "Sei sicuro di voler rinominare il tag \"{0}\" in \"{1}\" per tutti gli oggetti?",
"MessageConfirmRenameTagMergeNote": "Note: Questo tag esiste già e verrà unito nel vecchio.",
"MessageConfirmRenameTagWarning": "Avvertimento! Esiste già un tag simile con un nome simile \"{0}\".",
"MessageDownloadingEpisode": "Download episodio in corso", "MessageDownloadingEpisode": "Download episodio in corso",
"MessageDragFilesIntoTrackOrder": "Trascina i file nell'ordine di traccia corretto", "MessageDragFilesIntoTrackOrder": "Trascina i file nell'ordine di traccia corretto",
"MessageEmbedFinished": "Incorporamento finito!", "MessageEmbedFinished": "Incorporamento finito!",
@@ -449,6 +465,7 @@
"MessageImportantNotice": "Avviso Importante!", "MessageImportantNotice": "Avviso Importante!",
"MessageInsertChapterBelow": "Inserisci capitolo sotto", "MessageInsertChapterBelow": "Inserisci capitolo sotto",
"MessageItemsSelected": "{0} oggetti Selezionati", "MessageItemsSelected": "{0} oggetti Selezionati",
"MessageItemsUpdated": "{0} Oggetti aggiornati",
"MessageJoinUsOn": "Unisciti a noi su", "MessageJoinUsOn": "Unisciti a noi su",
"MessageListeningSessionsInTheLastYear": "{0} sessioni di ascolto nell'ultimo anno", "MessageListeningSessionsInTheLastYear": "{0} sessioni di ascolto nell'ultimo anno",
"MessageLoading": "Caricamento...", "MessageLoading": "Caricamento...",
@@ -481,28 +498,30 @@
"MessageNoPodcastsFound": "Nessun podcasts trovato", "MessageNoPodcastsFound": "Nessun podcasts trovato",
"MessageNoResults": "Nessun Risultato", "MessageNoResults": "Nessun Risultato",
"MessageNoSearchResultsFor": "Nessun risultato per \"{0}\"", "MessageNoSearchResultsFor": "Nessun risultato per \"{0}\"",
"MessageNoSeries": "No Series", "MessageNoSeries": "Nessuna Serie",
"MessageNoTags": "No Tags",
"MessageNotYetImplemented": "Non Ancora Implementato", "MessageNotYetImplemented": "Non Ancora Implementato",
"MessageNoUpdateNecessary": "Nessun aggiornamento necessario", "MessageNoUpdateNecessary": "Nessun aggiornamento necessario",
"MessageNoUpdatesWereNecessary": "Nessun aggiornamento necessario", "MessageNoUpdatesWereNecessary": "Nessun aggiornamento necessario",
"MessageNoUserPlaylists": "You have no playlists", "MessageNoUserPlaylists": "non hai nessuna Playlist",
"MessageOr": "o", "MessageOr": "o",
"MessagePauseChapter": "Metti in Pausa Capitolo", "MessagePauseChapter": "Metti in Pausa Capitolo",
"MessagePlayChapter": "Ascolta dall'inizio del capitolo", "MessagePlayChapter": "Ascolta dall'inizio del capitolo",
"MessagePlaylistCreateFromCollection": "Crea playlist da una Raccolta",
"MessagePodcastHasNoRSSFeedForMatching": "Podcast non ha l'URL del feed RSS da utilizzare per il match", "MessagePodcastHasNoRSSFeedForMatching": "Podcast non ha l'URL del feed RSS da utilizzare per il match",
"MessageQuickMatchDescription": "Compila i dettagli dell'articolo vuoto e copri con il risultato della prima corrispondenza di '{0}'. Non sovrascrive i dettagli a meno che non sia abilitata l'impostazione del server \"Preferisci metadati corrispondenti\".", "MessageQuickMatchDescription": "Compila i dettagli dell'articolo vuoto e copri con il risultato della prima corrispondenza di '{0}'. Non sovrascrive i dettagli a meno che non sia abilitata l'impostazione del server \"Preferisci metadati corrispondenti\".",
"MessageRemoveAllItemsWarning": "AVVERTIMENTO! Questa azione rimuoverà tutti gli elementi della libreria dal database, inclusi eventuali aggiornamenti o corrispondenze apportate. Questo non fa nulla ai tuoi file effettivi. Sei sicuro?", "MessageRemoveAllItemsWarning": "AVVERTIMENTO! Questa azione rimuoverà tutti gli elementi della libreria dal database, inclusi eventuali aggiornamenti o corrispondenze apportate. Questo non fa nulla ai tuoi file effettivi. Sei sicuro?",
"MessageRemoveChapter": "Rimuovi Capitolo", "MessageRemoveChapter": "Rimuovi Capitolo",
"MessageRemoveEpisodes": "rimuovi {0} episodio(i)", "MessageRemoveEpisodes": "rimuovi {0} episodio(i)",
"MessageRemoveFromPlayerQueue": "Remove from player queue", "MessageRemoveFromPlayerQueue": "Rimuovi dalla coda di riproduzione",
"MessageRemoveUserWarning": "Sei sicuro di voler eliminare definitivamente l'utente \"{0}\"?", "MessageRemoveUserWarning": "Sei sicuro di voler eliminare definitivamente l'utente \"{0}\"?",
"MessageReportBugsAndContribute": "Segnala bug, richiedi funzionalità e contribuisci", "MessageReportBugsAndContribute": "Segnala bug, richiedi funzionalità e contribuisci",
"MessageResetChaptersConfirm": "Are you sure you want to reset chapters and undo the changes you made?", "MessageResetChaptersConfirm": "Sei sicuro di voler reimpostare i capitoli e annullare le modifiche ?",
"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",
"MessageServerCouldNotBeReached": "Impossibile raggiungere il server", "MessageServerCouldNotBeReached": "Impossibile raggiungere il server",
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name", "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}?",
"MessageThinking": "Elaborazione...", "MessageThinking": "Elaborazione...",
"MessageUploaderItemFailed": "Caricamento Fallito", "MessageUploaderItemFailed": "Caricamento Fallito",
@@ -524,7 +543,7 @@
"NoteUploaderUnsupportedFiles": "I file non supportati vengono ignorati. Quando si sceglie o si elimina una cartella, gli altri file che non si trovano in una cartella di elementi vengono ignorati.", "NoteUploaderUnsupportedFiles": "I file non supportati vengono ignorati. Quando si sceglie o si elimina una cartella, gli altri file che non si trovano in una cartella di elementi vengono ignorati.",
"PlaceholderNewCollection": "Nome Nuova Raccolta", "PlaceholderNewCollection": "Nome Nuova Raccolta",
"PlaceholderNewFolderPath": "Nuovo percorso Cartella", "PlaceholderNewFolderPath": "Nuovo percorso Cartella",
"PlaceholderNewPlaylist": "New playlist name", "PlaceholderNewPlaylist": "Nome nuova playlist",
"PlaceholderSearch": "Cerca..", "PlaceholderSearch": "Cerca..",
"ToastAccountUpdateFailed": "Aggiornamento Account Fallito", "ToastAccountUpdateFailed": "Aggiornamento Account Fallito",
"ToastAccountUpdateSuccess": "Account Aggiornato", "ToastAccountUpdateSuccess": "Account Aggiornato",
@@ -549,8 +568,8 @@
"ToastBookmarkRemoveSuccess": "Segnalibro Rimosso", "ToastBookmarkRemoveSuccess": "Segnalibro Rimosso",
"ToastBookmarkUpdateFailed": "Aggiornamento Segnalibro fallito", "ToastBookmarkUpdateFailed": "Aggiornamento Segnalibro fallito",
"ToastBookmarkUpdateSuccess": "Segnalibro aggiornato", "ToastBookmarkUpdateSuccess": "Segnalibro aggiornato",
"ToastChaptersHaveErrors": "Chapters have errors", "ToastChaptersHaveErrors": "I capitoli contengono errori",
"ToastChaptersMustHaveTitles": "Chapters must have titles", "ToastChaptersMustHaveTitles": "I capitoli devono avere titoli",
"ToastCollectionItemsRemoveFailed": "Rimozione oggetti dalla Raccolta fallita", "ToastCollectionItemsRemoveFailed": "Rimozione oggetti dalla Raccolta fallita",
"ToastCollectionItemsRemoveSuccess": "Oggetto(i) rimossi dalla Raccolta", "ToastCollectionItemsRemoveSuccess": "Oggetto(i) rimossi dalla Raccolta",
"ToastCollectionRemoveFailed": "Rimozione Raccolta fallita", "ToastCollectionRemoveFailed": "Rimozione Raccolta fallita",
@@ -574,16 +593,20 @@
"ToastLibraryScanStarted": "Scansione Libreria iniziata", "ToastLibraryScanStarted": "Scansione Libreria iniziata",
"ToastLibraryUpdateFailed": "Errore Aggiornamento libreria", "ToastLibraryUpdateFailed": "Errore Aggiornamento libreria",
"ToastLibraryUpdateSuccess": "Libreria \"{0}\" aggiornata", "ToastLibraryUpdateSuccess": "Libreria \"{0}\" aggiornata",
"ToastPlaylistRemoveFailed": "Failed to remove playlist", "ToastPlaylistCreateFailed": "Errore Creazione playlist",
"ToastPlaylistRemoveSuccess": "Playlist removed", "ToastPlaylistCreateSuccess": "Playlist creata",
"ToastPlaylistUpdateFailed": "Failed to update playlist", "ToastPlaylistRemoveFailed": "Rimozione Playlist Fallita",
"ToastPlaylistUpdateSuccess": "Playlist updated", "ToastPlaylistRemoveSuccess": "Playlist rimossa",
"ToastPlaylistUpdateFailed": "Aggiornamento Playlist Fallita",
"ToastPlaylistUpdateSuccess": "Playlist Aggiornata",
"ToastPodcastCreateFailed": "Errore Creazione podcast", "ToastPodcastCreateFailed": "Errore Creazione podcast",
"ToastPodcastCreateSuccess": "Podcast creato Correttamwnte", "ToastPodcastCreateSuccess": "Podcast creato Correttamwnte",
"ToastRemoveItemFromCollectionFailed": "Errore rimozione file dalla Raccolta", "ToastRemoveItemFromCollectionFailed": "Errore rimozione file dalla Raccolta",
"ToastRemoveItemFromCollectionSuccess": "Oggetto rimosso dalla Raccolta", "ToastRemoveItemFromCollectionSuccess": "Oggetto rimosso dalla Raccolta",
"ToastRSSFeedCloseFailed": "Errore chiusura RSS feed", "ToastRSSFeedCloseFailed": "Errore chiusura RSS feed",
"ToastRSSFeedCloseSuccess": "RSS feed chiuso", "ToastRSSFeedCloseSuccess": "RSS feed chiuso",
"ToastSeriesUpdateFailed": "Series update failed",
"ToastSeriesUpdateSuccess": "Series update success",
"ToastSessionDeleteFailed": "Errore eliminazione sessione", "ToastSessionDeleteFailed": "Errore eliminazione sessione",
"ToastSessionDeleteSuccess": "Sessione cancellata", "ToastSessionDeleteSuccess": "Sessione cancellata",
"ToastSocketConnected": "Socket connesso", "ToastSocketConnected": "Socket connesso",
+24 -1
View File
@@ -20,6 +20,7 @@
"ButtonCreate": "Utwórz", "ButtonCreate": "Utwórz",
"ButtonCreateBackup": "Utwórz kopię zapasową", "ButtonCreateBackup": "Utwórz kopię zapasową",
"ButtonDelete": "Usuń", "ButtonDelete": "Usuń",
"ButtonEdit": "Edit",
"ButtonEditChapters": "Edytuj rozdziały", "ButtonEditChapters": "Edytuj rozdziały",
"ButtonEditPodcast": "Edytuj podcast", "ButtonEditPodcast": "Edytuj podcast",
"ButtonForceReScan": "Wymuś ponowne skanowanie", "ButtonForceReScan": "Wymuś ponowne skanowanie",
@@ -75,6 +76,8 @@
"ButtonUploadBackup": "Wgraj kopię zapasową", "ButtonUploadBackup": "Wgraj kopię zapasową",
"ButtonUploadCover": "Wgraj okładkę", "ButtonUploadCover": "Wgraj okładkę",
"ButtonUploadOPMLFile": "Wgraj plik OPML", "ButtonUploadOPMLFile": "Wgraj plik OPML",
"ButtonUserDelete": "Delete user {0}",
"ButtonUserEdit": "Edit user {0}",
"ButtonViewAll": "Zobacz wszystko", "ButtonViewAll": "Zobacz wszystko",
"ButtonYes": "Tak", "ButtonYes": "Tak",
"HeaderAccount": "Konto", "HeaderAccount": "Konto",
@@ -95,6 +98,7 @@
"HeaderFindChapters": "Wyszukaj rozdziały", "HeaderFindChapters": "Wyszukaj rozdziały",
"HeaderIgnoredFiles": "Zignoruj pliki", "HeaderIgnoredFiles": "Zignoruj pliki",
"HeaderItemFiles": "Pliki", "HeaderItemFiles": "Pliki",
"HeaderItemMetadataUtils": "Item Metadata Utils",
"HeaderLastListeningSession": "Ostatnio odtwarzana sesja", "HeaderLastListeningSession": "Ostatnio odtwarzana sesja",
"HeaderLatestEpisodes": "Najnowsze odcinki", "HeaderLatestEpisodes": "Najnowsze odcinki",
"HeaderLibraries": "Biblioteki", "HeaderLibraries": "Biblioteki",
@@ -104,6 +108,9 @@
"HeaderListeningStats": "Statystyki odtwarzania", "HeaderListeningStats": "Statystyki odtwarzania",
"HeaderLogin": "Zaloguj się", "HeaderLogin": "Zaloguj się",
"HeaderLogs": "Logi", "HeaderLogs": "Logi",
"HeaderManageGenres": "Manage Genres",
"HeaderManageTags": "Manage Tags",
"HeaderMapDetails": "Map details",
"HeaderMatch": "Dopasuj", "HeaderMatch": "Dopasuj",
"HeaderMetadataToEmbed": "Osadź metadane", "HeaderMetadataToEmbed": "Osadź metadane",
"HeaderNewAccount": "Nowe konto", "HeaderNewAccount": "Nowe konto",
@@ -155,6 +162,7 @@
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist", "LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
"LabelAll": "All", "LabelAll": "All",
"LabelAllUsers": "Wszyscy użytkownicy", "LabelAllUsers": "Wszyscy użytkownicy",
"LabelAppend": "Append",
"LabelAuthor": "Autor", "LabelAuthor": "Autor",
"LabelAuthorFirstLast": "Autor (Rosnąco)", "LabelAuthorFirstLast": "Autor (Rosnąco)",
"LabelAuthorLastFirst": "Author (Malejąco)", "LabelAuthorLastFirst": "Author (Malejąco)",
@@ -247,7 +255,6 @@
"LabelLogLevelInfo": "Informacja", "LabelLogLevelInfo": "Informacja",
"LabelLogLevelWarn": "Ostrzeżenie", "LabelLogLevelWarn": "Ostrzeżenie",
"LabelLookForNewEpisodesAfterDate": "Szukaj nowych odcinków po dacie", "LabelLookForNewEpisodesAfterDate": "Szukaj nowych odcinków po dacie",
"LabelMarkSeries": "Oznacz serię",
"LabelMediaPlayer": "Odtwarzacz", "LabelMediaPlayer": "Odtwarzacz",
"LabelMediaType": "Typ mediów", "LabelMediaType": "Typ mediów",
"LabelMetadataProvider": "Dostawca metadanych", "LabelMetadataProvider": "Dostawca metadanych",
@@ -278,6 +285,7 @@
"LabelNumberOfBooks": "Liczba książek", "LabelNumberOfBooks": "Liczba książek",
"LabelNumberOfEpisodes": "# odcinków", "LabelNumberOfEpisodes": "# odcinków",
"LabelOpenRSSFeed": "Otwórz kanał RSS", "LabelOpenRSSFeed": "Otwórz kanał RSS",
"LabelOverwrite": "Overwrite",
"LabelPassword": "Hasło", "LabelPassword": "Hasło",
"LabelPath": "Ścieżka", "LabelPath": "Ścieżka",
"LabelPermissionsAccessAllLibraries": "Ma dostęp do wszystkich bibliotek", "LabelPermissionsAccessAllLibraries": "Ma dostęp do wszystkich bibliotek",
@@ -435,10 +443,18 @@
"MessageConfirmDeleteLibrary": "Czy na pewno chcesz trwale usunąć bibliotekę \"{0}\"?", "MessageConfirmDeleteLibrary": "Czy na pewno chcesz trwale usunąć bibliotekę \"{0}\"?",
"MessageConfirmDeleteSession": "Czy na pewno chcesz usunąć tę sesję?", "MessageConfirmDeleteSession": "Czy na pewno chcesz usunąć tę sesję?",
"MessageConfirmForceReScan": "Czy na pewno chcesz wymusić ponowne skanowanie?", "MessageConfirmForceReScan": "Czy na pewno chcesz wymusić ponowne skanowanie?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
"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?",
"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?",
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",
"MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".",
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".",
"MessageDownloadingEpisode": "Pobieranie odcinka", "MessageDownloadingEpisode": "Pobieranie odcinka",
"MessageDragFilesIntoTrackOrder": "przeciągnij pliki aby ustawić właściwą kolejność utworów", "MessageDragFilesIntoTrackOrder": "przeciągnij pliki aby ustawić właściwą kolejność utworów",
"MessageEmbedFinished": "Osadzanie zakończone!", "MessageEmbedFinished": "Osadzanie zakończone!",
@@ -449,6 +465,7 @@
"MessageImportantNotice": "Ważna informacja!", "MessageImportantNotice": "Ważna informacja!",
"MessageInsertChapterBelow": "Wstaw rozdział poniżej", "MessageInsertChapterBelow": "Wstaw rozdział poniżej",
"MessageItemsSelected": "{0} zaznaczone elementy", "MessageItemsSelected": "{0} zaznaczone elementy",
"MessageItemsUpdated": "{0} Items Updated",
"MessageJoinUsOn": "Dołącz do nas na", "MessageJoinUsOn": "Dołącz do nas na",
"MessageListeningSessionsInTheLastYear": "{0} sesje odsłuchowe w ostatnim roku", "MessageListeningSessionsInTheLastYear": "{0} sesje odsłuchowe w ostatnim roku",
"MessageLoading": "Ładowanie...", "MessageLoading": "Ładowanie...",
@@ -482,6 +499,7 @@
"MessageNoResults": "Brak wyników", "MessageNoResults": "Brak wyników",
"MessageNoSearchResultsFor": "Brak wyników wyszukiwania dla \"{0}\"", "MessageNoSearchResultsFor": "Brak wyników wyszukiwania dla \"{0}\"",
"MessageNoSeries": "No Series", "MessageNoSeries": "No Series",
"MessageNoTags": "No Tags",
"MessageNotYetImplemented": "Jeszcze nie zaimplementowane", "MessageNotYetImplemented": "Jeszcze nie zaimplementowane",
"MessageNoUpdateNecessary": "Brak konieczności aktualizacji", "MessageNoUpdateNecessary": "Brak konieczności aktualizacji",
"MessageNoUpdatesWereNecessary": "Brak aktualizacji", "MessageNoUpdatesWereNecessary": "Brak aktualizacji",
@@ -489,6 +507,7 @@
"MessageOr": "lub", "MessageOr": "lub",
"MessagePauseChapter": "Zatrzymaj odtwarzanie rozdziały", "MessagePauseChapter": "Zatrzymaj odtwarzanie rozdziały",
"MessagePlayChapter": "Rozpocznij odtwarzanie od początku rozdziału", "MessagePlayChapter": "Rozpocznij odtwarzanie od początku rozdziału",
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
"MessagePodcastHasNoRSSFeedForMatching": "Podcast nie ma adresu url kanału RSS, który mógłby zostać użyty do dopasowania", "MessagePodcastHasNoRSSFeedForMatching": "Podcast nie ma adresu url kanału RSS, który mógłby zostać użyty do dopasowania",
"MessageQuickMatchDescription": "Wypełnij puste informacje i okładkę pierwszym wynikiem dopasowania z '{0}'. Nie nadpisuje szczegółów, chyba że włączone jest ustawienie serwera 'Preferuj dopasowane metadane'.", "MessageQuickMatchDescription": "Wypełnij puste informacje i okładkę pierwszym wynikiem dopasowania z '{0}'. Nie nadpisuje szczegółów, chyba że włączone jest ustawienie serwera 'Preferuj dopasowane metadane'.",
"MessageRemoveAllItemsWarning": "UWAGA! Ta akcja usunie wszystkie elementy biblioteki z bazy danych, w tym wszystkie aktualizacje lub dopasowania, które zostały wykonane. Pliki pozostaną niezmienione. Czy jesteś pewien?", "MessageRemoveAllItemsWarning": "UWAGA! Ta akcja usunie wszystkie elementy biblioteki z bazy danych, w tym wszystkie aktualizacje lub dopasowania, które zostały wykonane. Pliki pozostaną niezmienione. Czy jesteś pewien?",
@@ -574,6 +593,8 @@
"ToastLibraryScanStarted": "Rozpoczęto skanowanie biblioteki", "ToastLibraryScanStarted": "Rozpoczęto skanowanie biblioteki",
"ToastLibraryUpdateFailed": "Nie udało się zaktualizować biblioteki", "ToastLibraryUpdateFailed": "Nie udało się zaktualizować biblioteki",
"ToastLibraryUpdateSuccess": "Zaktualizowano \"{0}\" pozycji", "ToastLibraryUpdateSuccess": "Zaktualizowano \"{0}\" pozycji",
"ToastPlaylistCreateFailed": "Failed to create playlist",
"ToastPlaylistCreateSuccess": "Playlist created",
"ToastPlaylistRemoveFailed": "Failed to remove playlist", "ToastPlaylistRemoveFailed": "Failed to remove playlist",
"ToastPlaylistRemoveSuccess": "Playlist removed", "ToastPlaylistRemoveSuccess": "Playlist removed",
"ToastPlaylistUpdateFailed": "Failed to update playlist", "ToastPlaylistUpdateFailed": "Failed to update playlist",
@@ -584,6 +605,8 @@
"ToastRemoveItemFromCollectionSuccess": "Pozycja usunięta z kolekcji", "ToastRemoveItemFromCollectionSuccess": "Pozycja usunięta z kolekcji",
"ToastRSSFeedCloseFailed": "Zamknięcie kanału RSS nie powiodło się", "ToastRSSFeedCloseFailed": "Zamknięcie kanału RSS nie powiodło się",
"ToastRSSFeedCloseSuccess": "Zamknięcie kanału RSS powiodło się", "ToastRSSFeedCloseSuccess": "Zamknięcie kanału RSS powiodło się",
"ToastSeriesUpdateFailed": "Series update failed",
"ToastSeriesUpdateSuccess": "Series update success",
"ToastSessionDeleteFailed": "Nie udało się usunąć sesji", "ToastSessionDeleteFailed": "Nie udało się usunąć sesji",
"ToastSessionDeleteSuccess": "Sesja usunięta", "ToastSessionDeleteSuccess": "Sesja usunięta",
"ToastSocketConnected": "Nawiązano połączenie z serwerem", "ToastSocketConnected": "Nawiązano połączenie z serwerem",
+29 -6
View File
@@ -1,5 +1,5 @@
{ {
"ButtonAdd": "加", "ButtonAdd": "加",
"ButtonAddChapters": "添加章节", "ButtonAddChapters": "添加章节",
"ButtonAddPodcasts": "添加播客", "ButtonAddPodcasts": "添加播客",
"ButtonAddYourFirstLibrary": "添加第一个媒体库", "ButtonAddYourFirstLibrary": "添加第一个媒体库",
@@ -20,6 +20,7 @@
"ButtonCreate": "创建", "ButtonCreate": "创建",
"ButtonCreateBackup": "创建备份", "ButtonCreateBackup": "创建备份",
"ButtonDelete": "删除", "ButtonDelete": "删除",
"ButtonEdit": "Edit",
"ButtonEditChapters": "编辑章节", "ButtonEditChapters": "编辑章节",
"ButtonEditPodcast": "编辑播客", "ButtonEditPodcast": "编辑播客",
"ButtonForceReScan": "强制重新扫描", "ButtonForceReScan": "强制重新扫描",
@@ -66,7 +67,7 @@
"ButtonSelectFolderPath": "选择文件夹路径", "ButtonSelectFolderPath": "选择文件夹路径",
"ButtonSeries": "系列", "ButtonSeries": "系列",
"ButtonSetChaptersFromTracks": "将音轨设置为章节", "ButtonSetChaptersFromTracks": "将音轨设置为章节",
"ButtonShiftTimes": "快速移动时间", "ButtonShiftTimes": "快速调整时间",
"ButtonShow": "显示", "ButtonShow": "显示",
"ButtonStartM4BEncode": "开始 M4B 编码", "ButtonStartM4BEncode": "开始 M4B 编码",
"ButtonStartMetadataEmbed": "开始嵌入元数据", "ButtonStartMetadataEmbed": "开始嵌入元数据",
@@ -75,6 +76,8 @@
"ButtonUploadBackup": "上传备份", "ButtonUploadBackup": "上传备份",
"ButtonUploadCover": "上传封面", "ButtonUploadCover": "上传封面",
"ButtonUploadOPMLFile": "上传 OPML 文件", "ButtonUploadOPMLFile": "上传 OPML 文件",
"ButtonUserDelete": "Delete user {0}",
"ButtonUserEdit": "Edit user {0}",
"ButtonViewAll": "查看全部", "ButtonViewAll": "查看全部",
"ButtonYes": "确定", "ButtonYes": "确定",
"HeaderAccount": "帐户", "HeaderAccount": "帐户",
@@ -95,6 +98,7 @@
"HeaderFindChapters": "查找章节", "HeaderFindChapters": "查找章节",
"HeaderIgnoredFiles": "忽略的文件", "HeaderIgnoredFiles": "忽略的文件",
"HeaderItemFiles": "项目文件", "HeaderItemFiles": "项目文件",
"HeaderItemMetadataUtils": "项目元数据管理程序",
"HeaderLastListeningSession": "最后一次收听会话", "HeaderLastListeningSession": "最后一次收听会话",
"HeaderLatestEpisodes": "最新剧集", "HeaderLatestEpisodes": "最新剧集",
"HeaderLibraries": "媒体库", "HeaderLibraries": "媒体库",
@@ -104,6 +108,9 @@
"HeaderListeningStats": "收听统计数据", "HeaderListeningStats": "收听统计数据",
"HeaderLogin": "登录", "HeaderLogin": "登录",
"HeaderLogs": "日志", "HeaderLogs": "日志",
"HeaderManageGenres": "管理流派",
"HeaderManageTags": "管理标签",
"HeaderMapDetails": "编辑详情",
"HeaderMatch": "匹配", "HeaderMatch": "匹配",
"HeaderMetadataToEmbed": "嵌入元数据", "HeaderMetadataToEmbed": "嵌入元数据",
"HeaderNewAccount": "新建帐户", "HeaderNewAccount": "新建帐户",
@@ -155,6 +162,7 @@
"LabelAddToPlaylistBatch": "添加 {0} 个项目到播放列表", "LabelAddToPlaylistBatch": "添加 {0} 个项目到播放列表",
"LabelAll": "全部", "LabelAll": "全部",
"LabelAllUsers": "所有用户", "LabelAllUsers": "所有用户",
"LabelAppend": "附加",
"LabelAuthor": "作者", "LabelAuthor": "作者",
"LabelAuthorFirstLast": "作者 (姓 名)", "LabelAuthorFirstLast": "作者 (姓 名)",
"LabelAuthorLastFirst": "作者 (名, 姓)", "LabelAuthorLastFirst": "作者 (名, 姓)",
@@ -201,7 +209,7 @@
"LabelEpisode": "剧集", "LabelEpisode": "剧集",
"LabelEpisodeTitle": "剧集标题", "LabelEpisodeTitle": "剧集标题",
"LabelEpisodeType": "剧集类型", "LabelEpisodeType": "剧集类型",
"LabelExplicit": "信息确", "LabelExplicit": "信息确",
"LabelFeedURL": "源 URL", "LabelFeedURL": "源 URL",
"LabelFile": "文件", "LabelFile": "文件",
"LabelFileBirthtime": "文件创建时间", "LabelFileBirthtime": "文件创建时间",
@@ -247,7 +255,6 @@
"LabelLogLevelInfo": "信息", "LabelLogLevelInfo": "信息",
"LabelLogLevelWarn": "警告", "LabelLogLevelWarn": "警告",
"LabelLookForNewEpisodesAfterDate": "在此日期后查找新剧集", "LabelLookForNewEpisodesAfterDate": "在此日期后查找新剧集",
"LabelMarkSeries": "标记系列",
"LabelMediaPlayer": "媒体播放器", "LabelMediaPlayer": "媒体播放器",
"LabelMediaType": "媒体类型", "LabelMediaType": "媒体类型",
"LabelMetadataProvider": "元数据提供者", "LabelMetadataProvider": "元数据提供者",
@@ -278,6 +285,7 @@
"LabelNumberOfBooks": "图书数量", "LabelNumberOfBooks": "图书数量",
"LabelNumberOfEpisodes": "# 集", "LabelNumberOfEpisodes": "# 集",
"LabelOpenRSSFeed": "打开 RSS 源", "LabelOpenRSSFeed": "打开 RSS 源",
"LabelOverwrite": "覆盖",
"LabelPassword": "密码", "LabelPassword": "密码",
"LabelPath": "路径", "LabelPath": "路径",
"LabelPermissionsAccessAllLibraries": "可以访问所有媒体库", "LabelPermissionsAccessAllLibraries": "可以访问所有媒体库",
@@ -378,7 +386,7 @@
"LabelTimeListened": "收听时间", "LabelTimeListened": "收听时间",
"LabelTimeListenedToday": "今日收听的时间", "LabelTimeListenedToday": "今日收听的时间",
"LabelTimeRemaining": "剩余 {0}", "LabelTimeRemaining": "剩余 {0}",
"LabelTimeToShift": "快速移动时间以秒为单位", "LabelTimeToShift": "快速调整时间以秒为单位",
"LabelTitle": "标题", "LabelTitle": "标题",
"LabelToolsEmbedMetadata": "嵌入元数据", "LabelToolsEmbedMetadata": "嵌入元数据",
"LabelToolsEmbedMetadataDescription": "将元数据嵌入音频文件, 包括封面图像和章节.", "LabelToolsEmbedMetadataDescription": "将元数据嵌入音频文件, 包括封面图像和章节.",
@@ -435,10 +443,18 @@
"MessageConfirmDeleteLibrary": "你确定要永久删除媒体库 \"{0}\"?", "MessageConfirmDeleteLibrary": "你确定要永久删除媒体库 \"{0}\"?",
"MessageConfirmDeleteSession": "你确定要删除此会话吗?", "MessageConfirmDeleteSession": "你确定要删除此会话吗?",
"MessageConfirmForceReScan": "你确定要强制重新扫描吗?", "MessageConfirmForceReScan": "你确定要强制重新扫描吗?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
"MessageConfirmRemoveCollection": "您确定要移除收藏 \"{0}\"?", "MessageConfirmRemoveCollection": "您确定要移除收藏 \"{0}\"?",
"MessageConfirmRemoveEpisode": "您确定要移除剧集 \"{0}\"?", "MessageConfirmRemoveEpisode": "您确定要移除剧集 \"{0}\"?",
"MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?", "MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?",
"MessageConfirmRemovePlaylist": "你确定要移除播放列表 \"{0}\"?", "MessageConfirmRemovePlaylist": "你确定要移除播放列表 \"{0}\"?",
"MessageConfirmRenameGenre": "你确定要将所有项目流派 \"{0}\" 重命名到 \"{1}\"?",
"MessageConfirmRenameGenreMergeNote": "注意: 该流派已经存在, 因此它们将被合并.",
"MessageConfirmRenameGenreWarning": "警告! 已经存在有大小写不同的类似流派 \"{0}\".",
"MessageConfirmRenameTag": "你确定要将所有项目标签 \"{0}\" 重命名到 \"{1}\"?",
"MessageConfirmRenameTagMergeNote": "注意: 该标签已经存在, 因此它们将被合并.",
"MessageConfirmRenameTagWarning": "警告! 已经存在有大小写不同的类似标签 \"{0}\".",
"MessageDownloadingEpisode": "正在下载剧集", "MessageDownloadingEpisode": "正在下载剧集",
"MessageDragFilesIntoTrackOrder": "将文件拖动到正确的音轨顺序", "MessageDragFilesIntoTrackOrder": "将文件拖动到正确的音轨顺序",
"MessageEmbedFinished": "嵌入完成!", "MessageEmbedFinished": "嵌入完成!",
@@ -449,6 +465,7 @@
"MessageImportantNotice": "重要通知!", "MessageImportantNotice": "重要通知!",
"MessageInsertChapterBelow": "在下面插入章节", "MessageInsertChapterBelow": "在下面插入章节",
"MessageItemsSelected": "已选定 {0} 个项目", "MessageItemsSelected": "已选定 {0} 个项目",
"MessageItemsUpdated": "已更新 {0} 个项目",
"MessageJoinUsOn": "加入我们", "MessageJoinUsOn": "加入我们",
"MessageListeningSessionsInTheLastYear": "去年收听 {0} 个会话", "MessageListeningSessionsInTheLastYear": "去年收听 {0} 个会话",
"MessageLoading": "加载...", "MessageLoading": "加载...",
@@ -482,6 +499,7 @@
"MessageNoResults": "无结果", "MessageNoResults": "无结果",
"MessageNoSearchResultsFor": "没有搜索到结果 \"{0}\"", "MessageNoSearchResultsFor": "没有搜索到结果 \"{0}\"",
"MessageNoSeries": "无系列", "MessageNoSeries": "无系列",
"MessageNoTags": "无标签",
"MessageNotYetImplemented": "尚未实施", "MessageNotYetImplemented": "尚未实施",
"MessageNoUpdateNecessary": "无需更新", "MessageNoUpdateNecessary": "无需更新",
"MessageNoUpdatesWereNecessary": "无需更新", "MessageNoUpdatesWereNecessary": "无需更新",
@@ -489,6 +507,7 @@
"MessageOr": "或", "MessageOr": "或",
"MessagePauseChapter": "暂停章节播放", "MessagePauseChapter": "暂停章节播放",
"MessagePlayChapter": "开始章节播放", "MessagePlayChapter": "开始章节播放",
"MessagePlaylistCreateFromCollection": "从收藏中创建播放列表",
"MessagePodcastHasNoRSSFeedForMatching": "播客没有可用于匹配 RSS 源的 url", "MessagePodcastHasNoRSSFeedForMatching": "播客没有可用于匹配 RSS 源的 url",
"MessageQuickMatchDescription": "使用来自 '{0}' 的第一个匹配结果填充空白详细信息和封面. 除非启用 '首选匹配元数据' 服务器设置, 否则不会覆盖详细信息.", "MessageQuickMatchDescription": "使用来自 '{0}' 的第一个匹配结果填充空白详细信息和封面. 除非启用 '首选匹配元数据' 服务器设置, 否则不会覆盖详细信息.",
"MessageRemoveAllItemsWarning": "警告! 此操作将从数据库中删除所有的媒体库项, 包括您所做的任何更新或匹配. 这不会对实际文件产生任何影响. 你确定吗?", "MessageRemoveAllItemsWarning": "警告! 此操作将从数据库中删除所有的媒体库项, 包括您所做的任何更新或匹配. 这不会对实际文件产生任何影响. 你确定吗?",
@@ -574,6 +593,8 @@
"ToastLibraryScanStarted": "媒体库扫描已启动", "ToastLibraryScanStarted": "媒体库扫描已启动",
"ToastLibraryUpdateFailed": "更新图书库失败", "ToastLibraryUpdateFailed": "更新图书库失败",
"ToastLibraryUpdateSuccess": "媒体库 \"{0}\" 已更新", "ToastLibraryUpdateSuccess": "媒体库 \"{0}\" 已更新",
"ToastPlaylistCreateFailed": "创建播放列表失败",
"ToastPlaylistCreateSuccess": "已成功创建播放列表",
"ToastPlaylistRemoveFailed": "删除播放列表失败", "ToastPlaylistRemoveFailed": "删除播放列表失败",
"ToastPlaylistRemoveSuccess": "播放列表已删除", "ToastPlaylistRemoveSuccess": "播放列表已删除",
"ToastPlaylistUpdateFailed": "更新播放列表失败", "ToastPlaylistUpdateFailed": "更新播放列表失败",
@@ -584,6 +605,8 @@
"ToastRemoveItemFromCollectionSuccess": "项目已从收藏中删除", "ToastRemoveItemFromCollectionSuccess": "项目已从收藏中删除",
"ToastRSSFeedCloseFailed": "关闭 RSS 源失败", "ToastRSSFeedCloseFailed": "关闭 RSS 源失败",
"ToastRSSFeedCloseSuccess": "RSS 源已关闭", "ToastRSSFeedCloseSuccess": "RSS 源已关闭",
"ToastSeriesUpdateFailed": "Series update failed",
"ToastSeriesUpdateSuccess": "Series update success",
"ToastSessionDeleteFailed": "删除会话失败", "ToastSessionDeleteFailed": "删除会话失败",
"ToastSessionDeleteSuccess": "会话已删除", "ToastSessionDeleteSuccess": "会话已删除",
"ToastSocketConnected": "网络已连接", "ToastSocketConnected": "网络已连接",
@@ -598,4 +621,4 @@
"WeekdayThursday": "星期四", "WeekdayThursday": "星期四",
"WeekdayTuesday": "星期二", "WeekdayTuesday": "星期二",
"WeekdayWednesday": "星期三" "WeekdayWednesday": "星期三"
} }
+4 -1
View File
@@ -20,7 +20,8 @@ module.exports = {
'w-3.5', 'w-3.5',
'h-3.5', 'h-3.5',
'border-warning', 'border-warning',
'mb-px' 'mb-px',
'text-1.5xl'
], ],
}, },
theme: { theme: {
@@ -61,6 +62,7 @@ module.exports = {
'80': '20rem' '80': '20rem'
}, },
spacing: { spacing: {
'18': '4.5rem',
'-54': '-13.5rem' '-54': '-13.5rem'
}, },
rotate: { rotate: {
@@ -94,6 +96,7 @@ module.exports = {
}, },
fontSize: { fontSize: {
xxs: '0.625rem', xxs: '0.625rem',
'1.5xl': '1.375rem',
'2.5xl': '1.6875rem' '2.5xl': '1.6875rem'
}, },
zIndex: { zIndex: {
+6 -6
View File
@@ -5,17 +5,17 @@ const isDev = process.env.NODE_ENV !== 'production'
if (isDev) { if (isDev) {
const devEnv = require('./dev').config const devEnv = require('./dev').config
process.env.NODE_ENV = 'development' process.env.NODE_ENV = 'development'
process.env.PORT = devEnv.Port if (devEnv.Port) process.env.PORT = devEnv.Port
process.env.CONFIG_PATH = devEnv.ConfigPath if (devEnv.ConfigPath) process.env.CONFIG_PATH = devEnv.ConfigPath
process.env.METADATA_PATH = devEnv.MetadataPath if (devEnv.MetadataPath) process.env.METADATA_PATH = devEnv.MetadataPath
process.env.FFMPEG_PATH = devEnv.FFmpegPath if (devEnv.FFmpegPath) process.env.FFMPEG_PATH = devEnv.FFmpegPath
process.env.FFPROBE_PATH = devEnv.FFProbePath if (devEnv.FFProbePath) process.env.FFPROBE_PATH = devEnv.FFProbePath
process.env.SOURCE = 'local' process.env.SOURCE = 'local'
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || '' process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || ''
} }
const PORT = process.env.PORT || 80 const PORT = process.env.PORT || 80
const HOST = process.env.HOST || '0.0.0.0' const HOST = process.env.HOST
const CONFIG_PATH = process.env.CONFIG_PATH || '/config' const CONFIG_PATH = process.env.CONFIG_PATH || '/config'
const METADATA_PATH = process.env.METADATA_PATH || '/metadata' const METADATA_PATH = process.env.METADATA_PATH || '/metadata'
const UID = process.env.AUDIOBOOKSHELF_UID || 99 const UID = process.env.AUDIOBOOKSHELF_UID || 99
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.2.7", "version": "2.2.12",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.2.7", "version": "2.2.12",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"axios": "^0.26.1", "axios": "^0.26.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.2.7", "version": "2.2.12",
"description": "Self-hosted audiobook and podcast server", "description": "Self-hosted audiobook and podcast server",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
+20 -98
View File
@@ -6,7 +6,7 @@
<br /> <br />
<a href="https://audiobookshelf.org/docs">Documentation</a> <a href="https://audiobookshelf.org/docs">Documentation</a>
· ·
<a href="https://audiobookshelf.org/install">Install Guides</a> <a href="https://audiobookshelf.org/guides">User Guides</a>
· ·
<a href="https://audiobookshelf.org/support">Support</a> <a href="https://audiobookshelf.org/support">Support</a>
</p> </p>
@@ -36,14 +36,17 @@ 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/pJsjuNCKRq) 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 (early 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) Available using Test Flight: https://testflight.apple.com/join/wiic7QIW - [Join the discussion](https://github.com/advplyr/audiobookshelf-app/discussions/60)
### Build your own tools & clients
Check out the [API documentation](https://api.audiobookshelf.org/)
<br /> <br />
<img alt="Library Screenshot" src="https://github.com/advplyr/audiobookshelf/raw/master/images/LibraryStreamSquare.png" /> <img alt="Library Screenshot" src="https://github.com/advplyr/audiobookshelf/raw/master/images/LibraryStreamSquare.png" />
@@ -54,106 +57,13 @@ Available using Test Flight: https://testflight.apple.com/join/wiic7QIW - [Join
#### Directory structure and folder names are important to Audiobookshelf! #### Directory structure and folder names are important to Audiobookshelf!
See [documentation](https://audiobookshelf.org/docs) for supported directory structure, folder naming conventions, and audio file metadata usage. See [documentation](https://audiobookshelf.org/docs#book-directory-structure) for supported directory structure, folder naming conventions, and audio file metadata usage.
<br /> <br />
# Installation # Installation
### Docker Install See [install docs](https://www.audiobookshelf.org/docs)
Available in Unraid Community Apps
```bash
docker pull ghcr.io/advplyr/audiobookshelf:latest
docker run -d \
-e AUDIOBOOKSHELF_UID=99 \
-e AUDIOBOOKSHELF_GID=100 \
-p 13378:80 \
-v </path/to/audiobooks>:/audiobooks \
-v </path/to/podcasts>:/podcasts \
-v </path/to/config>:/config \
-v </path/to/metadata>:/metadata \
--name audiobookshelf \
ghcr.io/advplyr/audiobookshelf:latest
```
### Docker Update
```bash
docker stop audiobookshelf
docker rm audiobookshelf
docker pull ghcr.io/advplyr/audiobookshelf:latest
docker start audiobookshelf
```
### Running with Docker Compose
```yaml
### docker-compose.yml ###
services:
audiobookshelf:
container_name: audiobookshelf
image: ghcr.io/advplyr/audiobookshelf:latest
environment:
- AUDIOBOOKSHELF_UID=99
- AUDIOBOOKSHELF_GID=100
ports:
- 13378:80
volumes:
- </path/to/audiobooks>:/audiobooks
- </path/to/podcasts>:/podcasts
- </path/to/config>:/config
- </path/to/metadata>:/metadata
```
### Docker Compose Update
Depending on the version of Docker Compose please run one of the two commands. If not sure on which version you are running you can run the following command and check.
#### Version Check
docker-compose --version or docker compose version
#### v2 Update
```bash
docker compose --file <path/to/config>/docker-compose.yml pull
docker compose --file <path/to/config>/docker-compose.yml up -d
```
#### V1 Update
```bash
docker-compose --file <path/to/config>/docker-compose.yml pull
docker-compose --file <path/to/config>/docker-compose.yml up -d
```
### Linux (amd64) Install
Debian package will use this config file `/etc/default/audiobookshelf` if exists. The install will create a user and group named `audiobookshelf`.
### Ubuntu Install via PPA
A PPA is hosted on [github](https://github.com/advplyr/audiobookshelf-ppa)
See [install docs](https://www.audiobookshelf.org/install/#ubuntu)
### Install via debian package
Get the `deb` file from the [github repo](https://github.com/advplyr/audiobookshelf-ppa).
See [install docs](https://www.audiobookshelf.org/install#debian)
#### Linux file locations
Project directory: `/usr/share/audiobookshelf/`
Config file: `/etc/default/audiobookshelf`
System Service: `/lib/systemd/system/audiobookshelf.service`
Ffmpeg static build: `/usr/lib/audiobookshelf-ffmpeg/`
<br /> <br />
@@ -161,6 +71,8 @@ Ffmpeg static build: `/usr/lib/audiobookshelf-ffmpeg/`
#### Important! Audiobookshelf requires a websocket connection. #### Important! Audiobookshelf requires a websocket connection.
#### Note: Subfolder paths (e.g. /audiobooks) are not supported yet. See [issue](https://github.com/advplyr/audiobookshelf/issues/385)
### NGINX Proxy Manager ### NGINX Proxy Manager
Toggle websockets support. Toggle websockets support.
@@ -261,6 +173,16 @@ Middleware relating to CORS will cause the app to report Unknown Error when logg
From [@Dondochaka](https://discord.com/channels/942908292873723984/942914154254176257/945074590374318170) and [@BeastleeUK](https://discord.com/channels/942908292873723984/942914154254176257/970366039294611506) From [@Dondochaka](https://discord.com/channels/942908292873723984/942914154254176257/945074590374318170) and [@BeastleeUK](https://discord.com/channels/942908292873723984/942914154254176257/970366039294611506)
<br /> <br />
### Example Caddyfile - [Caddy Reverse Proxy](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy)
```
subdomain.domain.com {
encode gzip zstd
reverse_proxy <LOCAL_IP>:<PORT>
}
```
# Run from source # Run from source
[See discussion](https://github.com/advplyr/audiobookshelf/discussions/259#discussioncomment-1869729) [See discussion](https://github.com/advplyr/audiobookshelf/discussions/259#discussioncomment-1869729)
+4 -5
View File
@@ -115,17 +115,16 @@ class Auth {
}) })
} }
getUserLoginResponsePayload(user, feeds) { getUserLoginResponsePayload(user) {
return { return {
user: user.toJSONForBrowser(), user: user.toJSONForBrowser(),
userDefaultLibraryId: user.getDefaultLibraryId(this.db.libraries), userDefaultLibraryId: user.getDefaultLibraryId(this.db.libraries),
serverSettings: this.db.serverSettings.toJSONForBrowser(), serverSettings: this.db.serverSettings.toJSONForBrowser(),
feeds,
Source: global.Source Source: global.Source
} }
} }
async login(req, res, feeds) { async login(req, res) {
const ipAddress = requestIp.getClientIp(req) const ipAddress = requestIp.getClientIp(req)
var username = (req.body.username || '').toLowerCase() var username = (req.body.username || '').toLowerCase()
var password = req.body.password || '' var password = req.body.password || ''
@@ -146,14 +145,14 @@ class Auth {
if (password) { if (password) {
return res.status(401).send('Invalid root password (hint: there is none)') return res.status(401).send('Invalid root password (hint: there is none)')
} else { } else {
return res.json(this.getUserLoginResponsePayload(user, feeds)) return res.json(this.getUserLoginResponsePayload(user))
} }
} }
// Check password match // Check password match
var compare = await bcrypt.compare(password, user.pash) var compare = await bcrypt.compare(password, user.pash)
if (compare) { if (compare) {
res.json(this.getUserLoginResponsePayload(user, feeds)) res.json(this.getUserLoginResponsePayload(user))
} else { } else {
Logger.warn(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit} from ${ipAddress}`) Logger.warn(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit} from ${ipAddress}`)
if (req.rateLimit.remaining <= 2) { if (req.rateLimit.remaining <= 2) {
+14 -10
View File
@@ -2,6 +2,7 @@ const Path = require('path')
const njodb = require('./libs/njodb') const njodb = require('./libs/njodb')
const Logger = require('./Logger') const Logger = require('./Logger')
const { version } = require('../package.json') const { version } = require('../package.json')
const filePerms = require('./utils/filePerms')
const LibraryItem = require('./objects/LibraryItem') const LibraryItem = require('./objects/LibraryItem')
const User = require('./objects/user/User') const User = require('./objects/user/User')
const Collection = require('./objects/Collection') const Collection = require('./objects/Collection')
@@ -106,7 +107,7 @@ class Db {
checkPreviousVersion() { checkPreviousVersion() {
return this.settingsDb.select(() => true).then((results) => { return this.settingsDb.select(() => true).then((results) => {
if (results.data && results.data.length) { if (results.data && results.data.length) {
var serverSettings = results.data.find(s => s.id === 'server-settings') const serverSettings = results.data.find(s => s.id === 'server-settings')
if (serverSettings && serverSettings.version && serverSettings.version !== version) { if (serverSettings && serverSettings.version && serverSettings.version !== version) {
return serverSettings.version return serverSettings.version
} }
@@ -131,6 +132,9 @@ class Db {
async init() { async init() {
await this.load() await this.load()
// Set file ownership for all files created by db
await filePerms.setDefault(global.ConfigPath, true)
if (!this.serverSettings) { // Create first load server settings if (!this.serverSettings) { // Create first load server settings
this.serverSettings = new ServerSettings() this.serverSettings = new ServerSettings()
await this.insertEntity('settings', this.serverSettings) await this.insertEntity('settings', this.serverSettings)
@@ -159,7 +163,7 @@ class Db {
const p4 = this.settingsDb.select(() => true).then(async (results) => { const p4 = this.settingsDb.select(() => true).then(async (results) => {
if (results.data && results.data.length) { if (results.data && results.data.length) {
this.settings = results.data this.settings = results.data
var serverSettings = this.settings.find(s => s.id === 'server-settings') const serverSettings = this.settings.find(s => s.id === 'server-settings')
if (serverSettings) { if (serverSettings) {
this.serverSettings = new ServerSettings(serverSettings) this.serverSettings = new ServerSettings(serverSettings)
@@ -181,7 +185,7 @@ class Db {
} }
} }
var notificationSettings = this.settings.find(s => s.id === 'notification-settings') const notificationSettings = this.settings.find(s => s.id === 'notification-settings')
if (notificationSettings) { if (notificationSettings) {
this.notificationSettings = new NotificationSettings(notificationSettings) this.notificationSettings = new NotificationSettings(notificationSettings)
} }
@@ -229,7 +233,7 @@ class Db {
return null return null
})) }))
var libraryItemIds = libraryItems.map(li => li.id) const libraryItemIds = libraryItems.map(li => li.id)
return this.libraryItemsDb.update((record) => libraryItemIds.includes(record.id), (record) => { return this.libraryItemsDb.update((record) => libraryItemIds.includes(record.id), (record) => {
return libraryItems.find(li => li.id === record.id) return libraryItems.find(li => li.id === record.id)
}).then((results) => { }).then((results) => {
@@ -276,7 +280,7 @@ class Db {
} }
getAllEntities(entityName) { getAllEntities(entityName) {
var entityDb = this.getEntityDb(entityName) const entityDb = this.getEntityDb(entityName)
return entityDb.select(() => true).then((results) => results.data).catch((error) => { return entityDb.select(() => true).then((results) => results.data).catch((error) => {
Logger.error(`[DB] Failed to get all ${entityName}`, error) Logger.error(`[DB] Failed to get all ${entityName}`, error)
return null return null
@@ -367,16 +371,16 @@ class Db {
} }
updateEntity(entityName, entity) { updateEntity(entityName, entity) {
var entityDb = this.getEntityDb(entityName) const entityDb = this.getEntityDb(entityName)
var jsonEntity = entity let jsonEntity = entity
if (entity && entity.toJSON) { if (entity && entity.toJSON) {
jsonEntity = entity.toJSON() jsonEntity = entity.toJSON()
} }
return entityDb.update((record) => record.id === entity.id, () => jsonEntity).then((results) => { return entityDb.update((record) => record.id === entity.id, () => jsonEntity).then((results) => {
Logger.debug(`[DB] Updated ${entityName}: ${results.updated}`) Logger.debug(`[DB] Updated ${entityName}: ${results.updated}`)
var arrayKey = this.getEntityArrayKey(entityName) const arrayKey = this.getEntityArrayKey(entityName)
if (this[arrayKey]) { if (this[arrayKey]) {
this[arrayKey] = this[arrayKey].map(e => { this[arrayKey] = this[arrayKey].map(e => {
return e.id === entity.id ? entity : e return e.id === entity.id ? entity : e
@@ -406,10 +410,10 @@ class Db {
}) })
} }
removeEntities(entityName, selectFunc) { removeEntities(entityName, selectFunc, silent = false) {
var entityDb = this.getEntityDb(entityName) var entityDb = this.getEntityDb(entityName)
return entityDb.delete(selectFunc).then((results) => { return entityDb.delete(selectFunc).then((results) => {
Logger.debug(`[DB] Deleted entities ${entityName}: ${results.deleted}`) if (!silent) Logger.debug(`[DB] Deleted entities ${entityName}: ${results.deleted}`)
var arrayKey = this.getEntityArrayKey(entityName) var arrayKey = this.getEntityArrayKey(entityName)
if (this[arrayKey]) { if (this[arrayKey]) {
this[arrayKey] = this[arrayKey].filter(e => { this[arrayKey] = this[arrayKey].filter(e => {
+15 -5
View File
@@ -22,6 +22,15 @@ class Logger {
return 'UNKNOWN' return 'UNKNOWN'
} }
get source() {
try {
throw new Error()
} catch (error) {
const regex = global.isWin ? /^.*\\([^\\:]*:[0-9]*):[0-9]*\)*/ : /^.*\/([^/:]*:[0-9]*):[0-9]*\)*/
return error.stack.split('\n')[3].replace(regex, '$1')
}
}
getLogLevelString(level) { getLogLevelString(level) {
for (const key in LogLevel) { for (const key in LogLevel) {
if (LogLevel[key] === level) { if (LogLevel[key] === level) {
@@ -55,6 +64,7 @@ class Logger {
handleLog(level, args) { handleLog(level, args) {
const logObj = { const logObj = {
timestamp: this.timestamp, timestamp: this.timestamp,
source: this.source,
message: args.join(' '), message: args.join(' '),
levelName: this.getLogLevelString(level), levelName: this.getLogLevelString(level),
level level
@@ -84,30 +94,30 @@ class Logger {
debug(...args) { debug(...args) {
if (this.logLevel > LogLevel.DEBUG) return if (this.logLevel > LogLevel.DEBUG) return
console.debug(`[${this.timestamp}] DEBUG:`, ...args) console.debug(`[${this.timestamp}] DEBUG:`, ...args, `(${this.source})`)
this.handleLog(LogLevel.DEBUG, args) this.handleLog(LogLevel.DEBUG, args)
} }
info(...args) { info(...args) {
if (this.logLevel > LogLevel.INFO) return if (this.logLevel > LogLevel.INFO) return
console.info(`[${this.timestamp}] INFO:`, ...args) console.info(`[${this.timestamp}] INFO:`, ...args)
this.handleLog(LogLevel.INFO, args) this.handleLog(LogLevel.INFO, args)
} }
warn(...args) { warn(...args) {
if (this.logLevel > LogLevel.WARN) return if (this.logLevel > LogLevel.WARN) return
console.warn(`[${this.timestamp}] WARN:`, ...args) console.warn(`[${this.timestamp}] WARN:`, ...args, `(${this.source})`)
this.handleLog(LogLevel.WARN, args) this.handleLog(LogLevel.WARN, args)
} }
error(...args) { error(...args) {
if (this.logLevel > LogLevel.ERROR) return if (this.logLevel > LogLevel.ERROR) return
console.error(`[${this.timestamp}] ERROR:`, ...args) console.error(`[${this.timestamp}] ERROR:`, ...args, `(${this.source})`)
this.handleLog(LogLevel.ERROR, args) this.handleLog(LogLevel.ERROR, args)
} }
fatal(...args) { fatal(...args) {
console.error(`[${this.timestamp}] FATAL:`, ...args) console.error(`[${this.timestamp}] FATAL:`, ...args, `(${this.source})`)
this.handleLog(LogLevel.FATAL, args) this.handleLog(LogLevel.FATAL, args)
} }
+11 -10
View File
@@ -10,6 +10,7 @@ const { version } = require('../package.json')
// Utils // Utils
const dbMigration = require('./utils/dbMigration') const dbMigration = require('./utils/dbMigration')
const filePerms = require('./utils/filePerms') const filePerms = require('./utils/filePerms')
const fileUtils = require('./utils/fileUtils')
const Logger = require('./Logger') const Logger = require('./Logger')
const Auth = require('./Auth') const Auth = require('./Auth')
@@ -34,24 +35,20 @@ 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 TaskManager = require('./managers/TaskManager') const TaskManager = require('./managers/TaskManager')
const EBookManager = require('./managers/EBookManager')
class Server { class Server {
constructor(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) { constructor(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) {
this.Port = PORT this.Port = PORT
this.Host = HOST this.Host = HOST
global.Source = SOURCE global.Source = SOURCE
global.isWin = process.platform === 'win32'
global.Uid = isNaN(UID) ? 0 : Number(UID) global.Uid = isNaN(UID) ? 0 : Number(UID)
global.Gid = isNaN(GID) ? 0 : Number(GID) global.Gid = isNaN(GID) ? 0 : Number(GID)
global.ConfigPath = Path.normalize(CONFIG_PATH) global.ConfigPath = fileUtils.filePathToPOSIX(Path.normalize(CONFIG_PATH))
global.MetadataPath = Path.normalize(METADATA_PATH) global.MetadataPath = fileUtils.filePathToPOSIX(Path.normalize(METADATA_PATH))
global.RouterBasePath = ROUTER_BASE_PATH global.RouterBasePath = ROUTER_BASE_PATH
// Fix backslash if not on Windows
if (process.platform !== 'win32') {
global.ConfigPath = global.ConfigPath.replace(/\\/g, '/')
global.MetadataPath = global.MetadataPath.replace(/\\/g, '/')
}
if (!fs.pathExistsSync(global.ConfigPath)) { if (!fs.pathExistsSync(global.ConfigPath)) {
fs.mkdirSync(global.ConfigPath) fs.mkdirSync(global.ConfigPath)
filePerms.setDefaultDirSync(global.ConfigPath, false) filePerms.setDefaultDirSync(global.ConfigPath, false)
@@ -77,6 +74,7 @@ class Server {
this.podcastManager = new PodcastManager(this.db, this.watcher, this.notificationManager) this.podcastManager = new PodcastManager(this.db, this.watcher, this.notificationManager)
this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.taskManager) this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.taskManager)
this.rssFeedManager = new RssFeedManager(this.db) this.rssFeedManager = new RssFeedManager(this.db)
this.eBookManager = new EBookManager(this.db)
this.scanner = new Scanner(this.db, this.coverManager) this.scanner = new Scanner(this.db, this.coverManager)
this.cronManager = new CronManager(this.db, this.scanner, this.podcastManager) this.cronManager = new CronManager(this.db, this.scanner, this.podcastManager)
@@ -124,6 +122,7 @@ class Server {
await this.backupManager.init() await this.backupManager.init()
await this.logManager.init() await this.logManager.init()
await this.apiRouter.checkRemoveEmptySeries(this.db.series) // Remove empty series
await this.rssFeedManager.init() await this.rssFeedManager.init()
this.cronManager.init() this.cronManager.init()
@@ -206,12 +205,13 @@ class Server {
'/library/:library/podcast/latest', '/library/:library/podcast/latest',
'/config/users/:id', '/config/users/:id',
'/config/users/:id/sessions', '/config/users/:id/sessions',
'/config/item-metadata-utils/:id',
'/collection/:id', '/collection/:id',
'/playlist/:id' '/playlist/:id'
] ]
dyanimicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html')))) dyanimicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))
router.post('/login', this.getLoginRateLimiter(), (req, res) => this.auth.login(req, res, this.rssFeedManager.feedsArray)) router.post('/login', this.getLoginRateLimiter(), (req, res) => this.auth.login(req, res))
router.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this)) router.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this))
router.post('/init', (req, res) => { router.post('/init', (req, res) => {
if (this.db.hasRootUser) { if (this.db.hasRootUser) {
@@ -240,7 +240,8 @@ class Server {
app.get('/healthcheck', (req, res) => res.sendStatus(200)) app.get('/healthcheck', (req, res) => res.sendStatus(200))
this.server.listen(this.Port, this.Host, () => { this.server.listen(this.Port, 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}`)
}) })
// Start listening for socket connections // Start listening for socket connections
+8 -6
View File
@@ -2,6 +2,8 @@ const EventEmitter = require('events')
const Watcher = require('./libs/watcher/watcher') const Watcher = require('./libs/watcher/watcher')
const Logger = require('./Logger') const Logger = require('./Logger')
const { filePathToPOSIX } = require('./utils/fileUtils')
class FolderWatcher extends EventEmitter { class FolderWatcher extends EventEmitter {
constructor() { constructor() {
super() super()
@@ -143,23 +145,23 @@ class FolderWatcher extends EventEmitter {
} }
addFileUpdate(libraryId, path, type) { addFileUpdate(libraryId, path, type) {
path = path.replace(/\\/g, '/') path = filePathToPOSIX(path)
if (this.pendingFilePaths.includes(path)) return if (this.pendingFilePaths.includes(path)) return
// Get file library // Get file library
var libwatcher = this.libraryWatchers.find(lw => lw.id === libraryId) const libwatcher = this.libraryWatchers.find(lw => lw.id === libraryId)
if (!libwatcher) { if (!libwatcher) {
Logger.error(`[Watcher] Invalid library id from watcher ${libraryId}`) Logger.error(`[Watcher] Invalid library id from watcher ${libraryId}`)
return return
} }
// Get file folder // Get file folder
var folder = libwatcher.folders.find(fold => path.startsWith(fold.fullPath.replace(/\\/g, '/'))) const folder = libwatcher.folders.find(fold => path.startsWith(filePathToPOSIX(fold.fullPath)))
if (!folder) { if (!folder) {
Logger.error(`[Watcher] New file folder not found in library "${libwatcher.name}" with path "${path}"`) Logger.error(`[Watcher] New file folder not found in library "${libwatcher.name}" with path "${path}"`)
return return
} }
var folderFullPath = folder.fullPath.replace(/\\/g, '/') const folderFullPath = filePathToPOSIX(folder.fullPath)
var relPath = path.replace(folderFullPath, '') var relPath = path.replace(folderFullPath, '')
@@ -189,12 +191,12 @@ class FolderWatcher extends EventEmitter {
checkShouldIgnorePath(path) { checkShouldIgnorePath(path) {
return !!this.ignoreDirs.find(dirpath => { return !!this.ignoreDirs.find(dirpath => {
return path.replace(/\\/g, '/').startsWith(dirpath) return filePathToPOSIX(path).startsWith(dirpath)
}) })
} }
cleanDirPath(path) { cleanDirPath(path) {
var path = path.replace(/\\/g, '/') path = filePathToPOSIX(path)
if (path.endsWith('/')) path = path.slice(0, -1) if (path.endsWith('/')) path = path.slice(0, -1)
return path return path
} }
+27 -5
View File
@@ -1,8 +1,11 @@
const fs = require('../libs/fsExtra')
const { createNewSortInstance } = require('../libs/fastSort')
const Logger = require('../Logger') const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
const { reqSupportsWebp } = require('../utils/index') const { reqSupportsWebp } = require('../utils/index')
const { createNewSortInstance } = require('../libs/fastSort')
const naturalSort = createNewSortInstance({ const naturalSort = createNewSortInstance({
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
@@ -77,9 +80,17 @@ class AuthorController {
await this.cacheManager.purgeImageCache(req.author.id) // Purge cache await this.cacheManager.purgeImageCache(req.author.id) // Purge cache
} }
payload.imagePath = imageData.path payload.imagePath = imageData.path
payload.relImagePath = imageData.relPath
hasUpdated = true hasUpdated = true
} }
} else if (payload.imagePath && payload.imagePath !== req.author.imagePath) { // Changing image path locally
if (!await fs.pathExists(payload.imagePath)) { // Make sure image path exists
Logger.error(`[AuthorController] Image path does not exist: "${payload.imagePath}"`)
return res.status(400).send('Author image path does not exist')
}
if (req.author.imagePath) {
await this.cacheManager.purgeImageCache(req.author.id) // Purge cache
}
} }
} }
@@ -117,6 +128,8 @@ class AuthorController {
} }
if (hasUpdated) { if (hasUpdated) {
req.author.updatedAt = Date.now()
if (authorNameUpdate) { // Update author name on all books if (authorNameUpdate) { // Update author name on all books
const itemsWithAuthor = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(req.author.id)) const itemsWithAuthor = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(req.author.id))
itemsWithAuthor.forEach(libraryItem => { itemsWithAuthor.forEach(libraryItem => {
@@ -148,7 +161,9 @@ class AuthorController {
var limit = (req.query.limit && !isNaN(req.query.limit)) ? Number(req.query.limit) : 25 var limit = (req.query.limit && !isNaN(req.query.limit)) ? Number(req.query.limit) : 25
var authors = this.db.authors.filter(au => au.name.toLowerCase().includes(q)) var authors = this.db.authors.filter(au => au.name.toLowerCase().includes(q))
authors = authors.slice(0, limit) authors = authors.slice(0, limit)
res.json(authors) res.json({
results: authors
})
} }
async match(req, res) { async match(req, res) {
@@ -176,7 +191,6 @@ class AuthorController {
var imageData = await this.authorFinder.saveAuthorImage(req.author.id, authorData.image) var imageData = await this.authorFinder.saveAuthorImage(req.author.id, authorData.image)
if (imageData) { if (imageData) {
req.author.imagePath = imageData.path req.author.imagePath = imageData.path
req.author.relImagePath = imageData.relPath
hasUpdates = true hasUpdates = true
} }
} }
@@ -204,7 +218,15 @@ class AuthorController {
// GET api/authors/:id/image // GET api/authors/:id/image
async getImage(req, res) { async getImage(req, res) {
let { query: { width, height, format }, author } = req const { query: { width, height, format, raw }, author } = req
if (raw) { // any value
if (!author.imagePath || !await fs.pathExists(author.imagePath)) {
return res.sendStatus(404)
}
return res.sendFile(author.imagePath)
}
const options = { const options = {
format: format || (reqSupportsWebp(req) ? 'webp' : 'jpeg'), format: format || (reqSupportsWebp(req) ? 'webp' : 'jpeg'),

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