Compare commits

..

131 Commits

Author SHA1 Message Date
advplyr bf071be247 Version bump 2.2.7 2022-11-30 17:43:13 -06:00
advplyr 6c05a0af8a Merge pull request #1234 from tomazed/master
[Update] client/strings/fr.json
2022-11-30 17:33:59 -06:00
advplyr 0e292c64c4 Update:Only emit library socket events to users with access to lib 2022-11-30 17:32:59 -06:00
advplyr 725f8eecdb Fix:Batch selecting ebooks showing play button in appbar #1235 2022-11-30 17:09:00 -06:00
Tomazed 521a673094 [Update] client/strings/fr.json 2022-11-30 23:20:29 +01:00
advplyr d917f0e37d Fix:Ebook reader for ebooks in root folder #1232 2022-11-30 16:15:25 -06:00
advplyr 7ed5b1744f Var cleanup 2022-11-29 18:03:50 -06:00
advplyr 64a7cfac3b Merge pull request #1230 from springsunx/patch-1
Update zh-cn.json
2022-11-29 07:57:28 -06:00
SunX 1ee7ba54f8 Update zh-cn.json 2022-11-29 21:45:15 +08:00
advplyr 6bb18f8800 Fix:Purge cache buttons on config page for mobile screens #1228 2022-11-28 17:55:52 -06:00
advplyr b26b854963 Translation strings for other langs #1166 2022-11-28 17:52:36 -06:00
advplyr 7d58361ced Update:Chapter editor add reset button, cleanup ui, add translation strings #1166 2022-11-28 17:49:58 -06:00
advplyr a3723f3d06 Update:New translation strings for chapter editor #1166 2022-11-28 17:00:06 -06:00
advplyr 78d1cd0cfb Add:Chapter editor button to set chapters using audio tracks #1229 2022-11-28 16:55:13 -06:00
advplyr d41366a417 Fix:Playlist API endpoint permissions 2022-11-28 16:29:04 -06:00
advplyr a2347150a2 Fix:PWA manifest start_url 2022-11-28 16:26:26 -06:00
advplyr d33f23dede Merge pull request #1225 from springsunx/patch-1
Update zh-cn.json
2022-11-28 09:23:22 -06:00
SunX cfca2be1b2 Update zh-cn.json 2022-11-28 18:14:08 +08:00
advplyr 73f07c1392 Update:More translation strings for library filters #1166 2022-11-27 17:55:25 -06:00
advplyr 4541e9ddc3 Fix:Library filters when using other language #1166 2022-11-27 17:54:40 -06:00
advplyr 972271a1a9 Add:Library filter for single & multi-track audiobooks #1213 2022-11-27 17:42:02 -06:00
advplyr e97d92a8ac Fix:Copy to clipboard 2022-11-27 17:10:06 -06:00
advplyr 9a73e352d1 Version bump 2.2.6 2022-11-27 16:07:26 -06:00
advplyr 08f09f81fa Fix:Updating authors image 2022-11-27 15:35:47 -06:00
advplyr c72609013c Update:i18n strings for playlists 2022-11-27 15:14:28 -06:00
advplyr 29a6434fdc Playlist and collections cleanup 2022-11-27 15:12:55 -06:00
advplyr eb2ea9950a Remove playlists for user when removing user 2022-11-27 14:54:17 -06:00
advplyr e307ded192 Remove item from playlist when removing item, update PlaylistController socket events to emit to playlist userId 2022-11-27 14:49:21 -06:00
advplyr 2d6c997b38 Cleanup collections add/create modal 2022-11-27 14:39:29 -06:00
advplyr 232a80a848 Fix playlist cover for single item playlsit 2022-11-27 14:35:55 -06:00
advplyr 083f8faa46 Update:Fetch library API to return numUserPlaylists, only display playlists in siderail if user has playlists 2022-11-27 14:34:27 -06:00
advplyr 0fcf978ffe Add /playlist/:id to dynamic routes 2022-11-27 14:23:28 -06:00
advplyr c1360267c6 Fix:Collection page set library on refresh 2022-11-27 14:22:46 -06:00
advplyr 084bea6b15 Update:Collections i18n strings 2022-11-27 14:19:22 -06:00
advplyr 2032dd88ba Hide add to playlist buttons for ebooks 2022-11-27 14:16:22 -06:00
advplyr b11b1be432 Cleanup playlist cover component 2022-11-27 14:13:31 -06:00
advplyr b743b34fab Update playlist cover to square of 4 items 2022-11-27 14:11:17 -06:00
advplyr 950d10091d Add playlists to bookshelf item context menu 2022-11-27 13:44:54 -06:00
advplyr af0e02b9a2 Update lazybookshelf playlist socket events 2022-11-27 13:38:08 -06:00
advplyr 1332147c4a Update playlist icons 2022-11-27 13:34:50 -06:00
advplyr f07cb1e7a3 Fix player UI icon buttons from pr #1187 2022-11-27 12:36:10 -06:00
advplyr 53dbdd115f Update:Playlists for podcasts 2022-11-27 12:33:38 -06:00
advplyr a217ed5574 Update:Handle edit playlist item 2022-11-27 12:17:58 -06:00
advplyr 531f947754 Update:Remove playlist if all items are removed 2022-11-27 12:04:49 -06:00
advplyr c957e9483e Update:Playlist edit modal 2022-11-27 11:53:48 -06:00
advplyr 623a706555 Update:Create playlist items table 2022-11-26 17:58:52 -06:00
advplyr 7e171576e0 Update:Add libraries playlists API endpoint, add lazy playlists card 2022-11-26 17:24:46 -06:00
advplyr 0979b3e03d Update:Playlist cover & json expanded 2022-11-26 16:45:54 -06:00
advplyr 1131bfa751 Update:Creating user playlists modal 2022-11-26 16:25:14 -06:00
advplyr f9b87b94bf Add:Playlist API endpoints 2022-11-26 15:14:45 -06:00
advplyr 59ed2ec87f Merge branch 'master' into playlists 2022-11-26 12:53:30 -06:00
advplyr 7b0b79e3a1 Merge pull request #1221 from jmt-gh/settings_page_component
Implement a Settings Content component
2022-11-26 11:07:09 -06:00
advplyr 53f73e1201 Settings content updates 2022-11-26 11:08:09 -06:00
jmt-gh c62a1dfff0 Convert Backup page to use new settings component
This commit moves the Backup page to use the new settings component.

As part of this, I had to do a little bit of modification for some of
the strings, as I cleaned up the way the strings are laid out on the
page.
2022-11-25 21:12:23 -08:00
jmt-gh 61f8055493 convert notifications page to use new settings component 2022-11-25 21:12:12 -08:00
jmt-gh 000d7fd249 convert library stats page to use new settings component 2022-11-25 21:11:45 -08:00
jmt-gh 087de03a1f convert log page to use new settings component 2022-11-25 21:11:32 -08:00
jmt-gh a3ca6159fb convert stats page to use new settings component 2022-11-25 21:11:17 -08:00
jmt-gh 5de6ee136a Convert Users settings page to use new component
This commit moves the Users settings page to use the new Settings
Content component. Similar to the Libraries page, this one is already a
component. I mimiced the behavior of the existing libraries component to
get the same functionality needed here
2022-11-25 21:10:05 -08:00
jmt-gh d5a19f2b42 Convert Library settings page to use new component
This commit moves the library settings page over to use the new
component. This page is 1 of 2 that actually has a component for itself,
so it was mostly just modifying that existing component and wrapping it
2022-11-25 21:08:54 -08:00
jmt-gh e3ec5dd506 convert sessions page to use settings content component 2022-11-25 21:08:10 -08:00
jmt-gh 762748225d convert settings page to use settings content component 2022-11-25 21:07:41 -08:00
jmt-gh 4db34e0c56 Add new settings content component
This commit adds a new settings conent component. This is a container
component for the settings pages, so that they all get the same
formatting by default. It handles the header text, description text, and
any "add new" plus button as needed.
2022-11-25 21:06:18 -08:00
advplyr fb078d05bc Merge pull request #1218 from jmt-gh/fix_notifications_ui
Update notification settings UI to match other pages
2022-11-25 14:01:00 -06:00
jmt-gh f59edffa43 update notification settings to match other pages 2022-11-25 11:04:11 -08:00
advplyr 7aa0ddb71f Merge branch 'master' into playlists 2022-11-25 08:09:46 -06:00
advplyr a0a6256c7a Merge pull request #1212 from Weldawadyathink/master
Improve title naming for single file audiobooks when opening RSS feed
2022-11-25 06:21:55 -06:00
advplyr df7e331605 Update server/objects/FeedEpisode.js 2022-11-25 06:21:50 -06:00
Spenser Bushey 8c23704e17 Merge branch 'advplyr:master' into master 2022-11-24 23:12:55 -08:00
Spenser Bushey 12abb1731c Single file audiobook rss feed naming logic moved to FeedEpisode.js 2022-11-24 23:10:20 -08:00
advplyr 180293ebc1 Update:Cleanup socket usage & add func for emitting events to admin users 2022-11-24 16:35:26 -06:00
advplyr e2af33e136 Update:Refactor socket connection management into SocketAuthority 2022-11-24 15:53:58 -06:00
advplyr 42e68edc65 Fix:Users table activity & cleanup 2022-11-24 14:44:09 -06:00
advplyr 47e732c213 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-11-24 13:51:53 -06:00
advplyr 77a86d92f4 Update:Socket event for getting online users & test event for messaging all online users 2022-11-24 13:51:41 -06:00
advplyr 64a8a046c1 Update:Backups API endpoints, add get all backups route, update socket init event payload 2022-11-24 13:14:29 -06:00
Spenser Bushey 1f02cbddd3 Merge branch 'advplyr:master' into master 2022-11-23 22:37:02 -08:00
Spenser Bushey 5e7bca02b3 RSS feeds for single file audiobooks now use book title 2022-11-23 22:36:07 -08:00
advplyr 097f9549b1 Merge pull request #1211 from lkiesow/started-at
Fix startedAt in progress API
2022-11-23 17:37:40 -06:00
Lars Kiesow 45434b16e0 Fix startedAt in progress API
If no progress had been set before, setting `startedAt` did not work and
it would always been set to `finishedAt` or `Date.now()`. Settings this
if any progress had already been recorded did work.

This fixes the problem so that setting `startedAt` it properly works in
both cases.
2022-11-24 00:16:20 +01:00
advplyr 6af5ac2be1 Merge pull request #1208 from konradorlinski/polish-translation
Add more polish translation
2022-11-23 16:08:20 -06:00
advplyr 34ff7efa27 Merge pull request #1205 from lkiesow/api-start-end-date
Allow specifying start and end of progress via API
2022-11-23 16:07:53 -06:00
ko 8f4391003f Add more polish translation 2022-11-23 18:29:35 +01:00
advplyr ecefb30f3d Merge pull request #1206 from lkiesow/400-bad-request
Respond with bad request to unvalid request data
2022-11-23 07:27:18 -06:00
Lars Kiesow a8162b57ba Respond with bad request to unvalid request data
This patch updates the batch progress update endpoint to respond with a
`400 Bad Request` instead of a `500 Internal Server Error` if a user
sends an invalid request with no body. This is a user error after all.

```
❯ curl -i -X PATCH \
  'http://127.0.0.1:3333/api/me/progress/batch/update' \
  -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5Q_MoRptP0oI' \
  -H 'Content-Type: application/json'
HTTP/1.1 400 Bad Request
…

Missing request payload
```
2022-11-23 02:15:36 +01:00
Lars Kiesow b0edac4234 Allow specifying start and end of progress via API
This patch is a minor extension to the update progress and batch update
progress API and allows you to specify `finishedAt` and `startedAt` when
updating a progress.

If not specified, both values are still automatically set to the current
time. If just `finishedAt` is specified, `startedAt` is set to the same
value.

Example API request:

```
❯ curl -i -X PATCH \
  'http://127.0.0.1:3333/api/me/progress/li_ywupqxw5d22adcadpa' \
  -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJyb290IiwidXNlcm5hbWUiOiJyb290IiwiaWF0IjoxNjY4OTYxNjAxfQ._NbilCoFy_hfoqy7uvbV4E_0X6qgLYapQ_MoRptP0oI' \
  -H 'Content-Type: application/json' \
  --data-raw '{"isFinished":true, "finishedAt": 1668556852000, "startedAt": 1668056852000}'
```
2022-11-23 01:32:52 +01:00
advplyr 98c4045a71 Setting up Playlist model 2022-11-22 18:08:11 -06:00
advplyr 24e90e2ead Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-11-22 16:58:05 -06:00
advplyr 145e0217b6 Update:Media session show next/prev track buttons #1201 2022-11-22 16:57:18 -06:00
advplyr e5925fb1b6 Create config.yml 2022-11-22 16:27:59 -06:00
advplyr 9e416d02bd Merge pull request #1200 from springsunx/patch-1
Update _id.vue
2022-11-22 04:33:56 -06:00
SunX 82b7068130 Update _id.vue 2022-11-22 12:51:37 +08:00
advplyr 579ee36857 Merge pull request #1197 from Hallo951/patch-1
Update de.json
2022-11-21 10:08:57 -06:00
advplyr 4f2d7a519d Update client/strings/de.json 2022-11-21 10:08:51 -06:00
advplyr a3642d92c5 Update client/strings/de.json 2022-11-21 10:08:45 -06:00
advplyr 224f36164f Update client/strings/de.json 2022-11-21 10:08:39 -06:00
Hallo951 638c220ae8 Update de.json 2022-11-21 16:27:41 +01:00
advplyr 51070b3e7b Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-11-21 07:52:38 -06:00
advplyr 0aa2723063 Update:API status codes and update server settings response payload 2022-11-21 07:52:33 -06:00
advplyr 1af66c8e8b Merge pull request #1196 from tomazed/master
Update fr.json
2022-11-21 07:18:51 -06:00
advplyr 7df8795d52 Fix:Icon sizes 2022-11-21 07:18:10 -06:00
Tomazed a0e9ae7092 Merge branch 'advplyr:master' into master 2022-11-21 13:54:48 +01:00
Tomazed 0f0d8e317a Update fr.json 2022-11-21 13:54:05 +01:00
advplyr 3d5ca7d5c4 Merge pull request #1192 from lkiesow/update-edit-author-modal
Update Author Modal on Changes
2022-11-21 06:43:32 -06:00
advplyr e33104fa2b Merge pull request #1193 from lkiesow/no-feed-no-error
No feed log level
2022-11-21 06:40:38 -06:00
advplyr a2f1723642 Update log level for RSS feed requests 2022-11-21 06:39:32 -06:00
advplyr 93357cf280 Merge pull request #1195 from springsunx/patch-1
Update zh-cn.json
2022-11-21 06:28:53 -06:00
advplyr 767427c787 Merge pull request #1194 from burghy86/patch-5
Update it.json
2022-11-21 06:27:52 -06:00
SunX 9377631896 Update zh-cn.json 2022-11-21 19:01:34 +08:00
burghy86 d08af094b8 Update it.json
fix and add new string
2022-11-21 11:40:40 +01:00
Lars Kiesow c307b1e6fb No feed log level
This patch drops the log level for logging requests to non-existing
feeds from error to debug. The reasoning behind this is that this is a
client error and a client error is returned and can be handled by the
client.

Otherwise anyone can easily spam the logs with error messages by just
requesting non-existing feeds.
2022-11-21 01:54:25 +01:00
Lars Kiesow d387d5b758 Update Author Modal on Changes
If you are on the home page and open the edit author modal, you can
automatically update all data by clicking “Quick Match” or you can
remove a set image by clicking 🗑.

Both options will update the actual data, but not the data in the open
modal. This means that, for example, a picture is still shown in the
modal after deleting it. That's confusing.

This patch fixes the bug and makes sure the modal is updated if the data
is updated.
2022-11-21 01:48:19 +01:00
advplyr c285dd666d Fix:Australian audible TLD #1191 2022-11-20 17:17:25 -06:00
advplyr b37b382ea7 Update:More translation strings #1103 #1166 2022-11-20 17:11:51 -06:00
advplyr a2cd755ffa Merge pull request #1188 from lkiesow/a11y-tooltip
Make Tooltips Accessible
2022-11-20 16:47:27 -06:00
advplyr 340aedfe13 Merge pull request #1187 from lkiesow/tooltip
Add a few tooltips
2022-11-20 16:44:02 -06:00
advplyr 6fafa7a75e Update other string files with new strings #1103 2022-11-20 16:45:11 -06:00
advplyr 03df5aaf42 Update client/strings/en-us.json 2022-11-20 16:41:51 -06:00
advplyr 6d84db08a8 Update client/strings/en-us.json 2022-11-20 16:41:47 -06:00
advplyr 1a5e0d2a5e Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-11-20 16:12:38 -06:00
advplyr 70d887bada Update:API status codes and default provider for findCovers route 2022-11-20 16:12:30 -06:00
Lars Kiesow ee0ac00f80 Make Tooltips Accessible
When using accessibility tools like screen magnifiers, dynamic screen
content can be quite problematic. In particular content, which only
appears if you interact with elements somewhere else on the screen. That
is the case, for example, with the current implementation of tooltips
used by audiobookshelf.

This patch provides a slight adjustment, keeping the tooltips open if
you hover over them. This allows users to have better access to the
content.
2022-11-20 20:02:31 +01:00
Lars Kiesow fdfb07ff2c Add a few tooltips
Starting to use audiobookshelf, the function of some buttons weren't
very clear to me and while some buttons have tooltips, others have not.

This patch adds some additional tooltips to the user interface,
further explaining some of the functionality.
2022-11-20 18:50:34 +01:00
advplyr b648155170 Merge pull request #1185 from springsunx/patch-1
Update zh-cn.json
2022-11-20 08:01:25 -06:00
SunX 59dc5299b1 Update zh-cn.json 2022-11-20 21:30:55 +08:00
advplyr 357a63a4d9 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-11-19 17:57:34 -06:00
advplyr 94912c7542 Update:Enable PWA workbox #354 2022-11-19 17:57:26 -06:00
advplyr fae182b328 Merge pull request #1182 from tomazed/master
Update fr.json from new strings in  advplyr/audiobookshelf@d24ed98bcd
2022-11-19 15:41:36 -06:00
Tomazed 9ba2f3e33a Merge branch 'advplyr:master' into master 2022-11-19 22:40:29 +01:00
Tomazed 442687b198 Fix: Incorrect translation in fr.json 2022-11-19 21:49:27 +01:00
Tomazed 7e400d3e9c Update fr.json from new strings in advplyr/audiobookshelf@d24ed98bcd 2022-11-19 21:48:17 +01:00
123 changed files with 3852 additions and 1463 deletions
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Discord
url: https://discord.gg/pJsjuNCKRq
about: Ask questions, get help troubleshooting, and join the Abs community here.
- name: Matrix
url: https://matrix.to/#/#audiobookshelf:matrix.org
about: Ask questions, get help troubleshooting, and join the Abs community here.
+29 -4
View File
@@ -20,42 +20,67 @@
transition-timing-function: cubic-bezier(0, 1, 0.5, 1); transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
} }
.slide-enter-to, .slide-leave { .slide-enter-to,
.slide-leave {
max-height: 600px; max-height: 600px;
overflow: hidden; overflow: hidden;
} }
.slide-enter, .slide-leave-to { .slide-enter,
.slide-leave-to {
overflow: hidden; overflow: hidden;
max-height: 0; max-height: 0;
} }
.menu-enter, .menu-leave-active { .menu-enter,
.menu-leave-active {
transform: translateY(-15px); transform: translateY(-15px);
} }
.menu-enter-active { .menu-enter-active {
transition: all 0.2s; transition: all 0.2s;
} }
.menu-leave-active { .menu-leave-active {
transition: all 0.1s; transition: all 0.1s;
} }
.menu-enter, .menu-enter,
.menu-leave-active { .menu-leave-active {
opacity: 0; opacity: 0;
} }
.menux-enter, .menux-leave-active { .menux-enter,
.menux-leave-active {
transform: translateX(15px); transform: translateX(15px);
} }
.menux-enter-active { .menux-enter-active {
transition: all 0.2s; transition: all 0.2s;
} }
.menux-leave-active { .menux-leave-active {
transition: all 0.1s; transition: all 0.1s;
} }
.menux-enter, .menux-enter,
.menux-leave-active { .menux-leave-active {
opacity: 0; opacity: 0;
}
.list-complete-item {
transition: all 0.8s ease;
}
.list-complete-enter-from,
.list-complete-leave-to {
opacity: 0;
transform: translateY(30px);
}
.list-complete-leave-active {
position: absolute;
} }
+30 -26
View File
@@ -18,22 +18,22 @@
<widgets-notification-widget class="hidden md:block" /> <widgets-notification-widget class="hidden md:block" />
<ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center"> <ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center">
<span class="material-icons-outlined text-warning text-opacity-50"> cast </span> <span class="material-icons-outlined text-2xl text-warning text-opacity-50"> cast </span>
</ui-tooltip> </ui-tooltip>
<div v-if="isChromecastInitialized" class="w-6 min-w-6 h-6 ml-2 mr-1 sm:mx-2 cursor-pointer"> <div v-if="isChromecastInitialized" class="w-6 min-w-6 h-6 ml-2 mr-1 sm:mx-2 cursor-pointer">
<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="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 hidden sm:flex items-center justify-center mx-1">
<span class="material-icons" aria-label="User Stats" role="button">equalizer</span> <span class="material-icons text-2xl" aria-label="User Stats" role="button">equalizer</span>
</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="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
<span class="material-icons" aria-label="Upload Media" role="button">upload</span> <span class="material-icons text-2xl" aria-label="Upload Media" role="button">upload</span>
</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="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
<span class="material-icons" aria-label="System Settings" role="button">settings</span> <span class="material-icons text-2xl" aria-label="System Settings" role="button">settings</span>
</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 focus:outline-none sm:text-sm cursor-pointer hover:bg-bg hover:bg-opacity-40" aria-haspopup="listbox" aria-expanded="true">
@@ -45,11 +45,11 @@
</span> </span>
</nuxt-link> </nuxt-link>
</div> </div>
<div v-show="numLibraryItemsSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center"> <div v-show="numMediaItemsSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
<h1 class="text-lg md:text-2xl px-4">{{ $getString('MessageItemsSelected', [numLibraryItemsSelected]) }}</h1> <h1 class="text-lg md:text-2xl px-4">{{ $getString('MessageItemsSelected', [numMediaItemsSelected]) }}</h1>
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn v-if="!isPodcastLibrary" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="playSelectedItems"> <ui-btn v-if="!isPodcastLibrary && selectedMediaItemsArePlayable" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="playSelectedItems">
<span class="material-icons -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 && !isPodcastLibrary" :text="$strings.ButtonQuickMatch" direction="bottom">
@@ -109,11 +109,14 @@ export default {
username() { username() {
return this.user ? this.user.username : 'err' return this.user ? this.user.username : 'err'
}, },
numLibraryItemsSelected() { numMediaItemsSelected() {
return this.selectedLibraryItems.length return this.selectedMediaItems.length
}, },
selectedLibraryItems() { selectedMediaItems() {
return this.$store.state.selectedLibraryItems return this.$store.state.globals.selectedMediaItems
},
selectedMediaItemsArePlayable() {
return !this.selectedMediaItems.some(i => !i.hasTracks)
}, },
userMediaProgress() { userMediaProgress() {
return this.$store.state.user.user.mediaProgress || [] return this.$store.state.user.user.mediaProgress || []
@@ -129,8 +132,8 @@ export default {
}, },
selectedIsFinished() { selectedIsFinished() {
// Find an item that is not finished, if none then all items finished // Find an item that is not finished, if none then all items finished
return !this.selectedLibraryItems.find((libraryItemId) => { return !this.selectedMediaItems.find((item) => {
var itemProgress = this.userMediaProgress.find((lip) => lip.libraryItemId === libraryItemId) const itemProgress = this.userMediaProgress.find((lip) => lip.libraryItemId === item.id)
return !itemProgress || !itemProgress.isFinished return !itemProgress || !itemProgress.isFinished
}) })
}, },
@@ -154,8 +157,9 @@ export default {
async playSelectedItems() { async playSelectedItems() {
this.$store.commit('setProcessingBatch', true) this.$store.commit('setProcessingBatch', true)
var libraryItems = await this.$axios.$post(`/api/items/batch/get`, { libraryItemIds: this.selectedLibraryItems }).catch((error) => { const libraryItemIds = this.selectedMediaItems.map((i) => i.id)
var errorMsg = error.response.data || 'Failed to get items' const libraryItems = await this.$axios.$post(`/api/items/batch/get`, { libraryItemIds }).catch((error) => {
const errorMsg = error.response.data || 'Failed to get items'
console.error(errorMsg, error) console.error(errorMsg, error)
this.$toast.error(errorMsg) this.$toast.error(errorMsg)
return [] return []
@@ -185,20 +189,20 @@ export default {
queueItems queueItems
}) })
this.$store.commit('setProcessingBatch', false) this.$store.commit('setProcessingBatch', false)
this.$store.commit('setSelectedLibraryItems', []) this.$store.commit('globals/resetSelectedMediaItems', [])
this.$eventBus.$emit('bookshelf_clear_selection') this.$eventBus.$emit('bookshelf_clear_selection')
}, },
cancelSelectionMode() { cancelSelectionMode() {
if (this.processingBatch) return if (this.processingBatch) return
this.$store.commit('setSelectedLibraryItems', []) this.$store.commit('globals/resetSelectedMediaItems', [])
this.$eventBus.$emit('bookshelf_clear_selection') this.$eventBus.$emit('bookshelf_clear_selection')
}, },
toggleBatchRead() { toggleBatchRead() {
this.$store.commit('setProcessingBatch', true) this.$store.commit('setProcessingBatch', true)
var newIsFinished = !this.selectedIsFinished const newIsFinished = !this.selectedIsFinished
var updateProgressPayloads = this.selectedLibraryItems.map((lid) => { const updateProgressPayloads = this.selectedMediaItems.map((item) => {
return { return {
libraryItemId: lid, libraryItemId: item.id,
isFinished: newIsFinished isFinished: newIsFinished
} }
}) })
@@ -208,7 +212,7 @@ export default {
.then(() => { .then(() => {
this.$toast.success('Batch update success!') this.$toast.success('Batch update success!')
this.$store.commit('setProcessingBatch', false) this.$store.commit('setProcessingBatch', false)
this.$store.commit('setSelectedLibraryItems', []) this.$store.commit('globals/resetSelectedMediaItems', [])
this.$eventBus.$emit('bookshelf_clear_selection') this.$eventBus.$emit('bookshelf_clear_selection')
}) })
.catch((error) => { .catch((error) => {
@@ -218,18 +222,18 @@ export default {
}) })
}, },
batchDeleteClick() { batchDeleteClick() {
var audiobookText = this.numLibraryItemsSelected > 1 ? `these ${this.numLibraryItemsSelected} items` : 'this item' const audiobookText = this.numMediaItemsSelected > 1 ? `these ${this.numMediaItemsSelected} items` : 'this item'
var confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the items from Audiobookshelf` const confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the items from Audiobookshelf`
if (confirm(confirmMsg)) { if (confirm(confirmMsg)) {
this.$store.commit('setProcessingBatch', true) this.$store.commit('setProcessingBatch', true)
this.$axios this.$axios
.$post(`/api/items/batch/delete`, { .$post(`/api/items/batch/delete`, {
libraryItemIds: this.selectedLibraryItems libraryItemIds: this.selectedMediaItems.map((i) => i.id)
}) })
.then(() => { .then(() => {
this.$toast.success('Batch delete success!') this.$toast.success('Batch delete success!')
this.$store.commit('setProcessingBatch', false) this.$store.commit('setProcessingBatch', false)
this.$store.commit('setSelectedLibraryItems', []) this.$store.commit('globals/resetSelectedMediaItems', [])
this.$eventBus.$emit('bookshelf_clear_selection') this.$eventBus.$emit('bookshelf_clear_selection')
}) })
.catch((error) => { .catch((error) => {
+19 -9
View File
@@ -89,8 +89,8 @@ export default {
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120 var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
return this.bookCoverWidth / baseSize return this.bookCoverWidth / baseSize
}, },
selectedLibraryItems() { selectedMediaItems() {
return this.$store.state.selectedLibraryItems || [] return this.$store.state.globals.selectedMediaItems || []
} }
}, },
methods: { methods: {
@@ -100,15 +100,15 @@ export default {
const indexOf = shelf.shelfStartIndex + entityShelfIndex const indexOf = shelf.shelfStartIndex + entityShelfIndex
const lastLastItemIndexSelected = this.lastItemIndexSelected const lastLastItemIndexSelected = this.lastItemIndexSelected
if (!this.selectedLibraryItems.includes(entity.id)) { if (!this.selectedMediaItems.some((i) => i.id === entity.id)) {
this.lastItemIndexSelected = indexOf this.lastItemIndexSelected = indexOf
} else { } else {
this.lastItemIndexSelected = -1 this.lastItemIndexSelected = -1
} }
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
@@ -117,12 +117,12 @@ export default {
const flattenedEntitiesArray = [] const flattenedEntitiesArray = []
this.shelves.map((s) => flattenedEntitiesArray.push(...s.entities)) this.shelves.map((s) => flattenedEntitiesArray.push(...s.entities))
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 = flattenedEntitiesArray[i] const thisEntity = flattenedEntitiesArray[i]
if (thisEntity) { if (thisEntity) {
if (!this.selectedLibraryItems.includes(thisEntity.id)) { if (!this.selectedMediaItems.some((i) => i.id === thisEntity.id)) {
isSelecting = true isSelecting = true
break break
} }
@@ -133,13 +133,23 @@ export default {
for (let i = loopStart; i <= loopEnd; i++) { for (let i = loopStart; i <= loopEnd; i++) {
const thisEntity = flattenedEntitiesArray[i] const thisEntity = flattenedEntitiesArray[i]
if (thisEntity) { if (thisEntity) {
this.$store.commit('setLibraryItemSelected', { libraryItemId: thisEntity.id, selected: isSelecting }) const mediaItem = {
id: thisEntity.id,
mediaType: thisEntity.mediaType,
hasTracks: thisEntity.mediaType === 'podcast' || thisEntity.media.numTracks || (thisEntity.media.tracks && thisEntity.media.tracks.length)
}
this.$store.commit('globals/setMediaItemSelected', { item: mediaItem, selected: isSelecting })
} else { } else {
console.error('Invalid entity index', i) console.error('Invalid entity index', i)
} }
} }
} else { } else {
this.$store.commit('toggleLibraryItemSelected', entity.id) const mediaItem = {
id: entity.id,
mediaType: entity.mediaType,
hasTracks: entity.mediaType === 'podcast' || entity.media.numTracks || (entity.media.tracks && entity.media.tracks.length)
}
this.$store.commit('globals/toggleMediaItemSelected', mediaItem)
} }
this.$nextTick(() => { this.$nextTick(() => {
+4 -4
View File
@@ -98,7 +98,7 @@ export default {
return this.$store.state.libraries.currentLibraryId return this.$store.state.libraries.currentLibraryId
}, },
isSelectionMode() { isSelectionMode() {
return this.$store.getters['getNumLibraryItemsSelected'] > 0 return this.$store.getters['globals/getIsBatchSelectingMediaItems']
} }
}, },
methods: { methods: {
@@ -119,14 +119,14 @@ export default {
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true) this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
}, },
updateSelectionMode(val) { updateSelectionMode(val) {
var selectedLibraryItems = this.$store.state.selectedLibraryItems const selectedMediaItems = this.$store.state.globals.selectedMediaItems
if (this.shelf.type === 'book' || this.shelf.type === 'podcast') { if (this.shelf.type === 'book' || this.shelf.type === 'podcast') {
this.shelf.entities.forEach((ent) => { this.shelf.entities.forEach((ent) => {
var component = this.$refs[`shelf-book-${ent.id}`] var component = this.$refs[`shelf-book-${ent.id}`]
if (!component || !component.length) return if (!component || !component.length) return
component = component[0] component = component[0]
component.setSelectionMode(val) component.setSelectionMode(val)
component.selected = selectedLibraryItems.includes(ent.id) component.selected = selectedMediaItems.some((i) => i.id === ent.id)
}) })
} else if (this.shelf.type === 'episode') { } else if (this.shelf.type === 'episode') {
this.shelf.entities.forEach((ent) => { this.shelf.entities.forEach((ent) => {
@@ -134,7 +134,7 @@ export default {
if (!component || !component.length) return if (!component || !component.length) return
component = component[0] component = component[0]
component.setSelectionMode(val) component.setSelectionMode(val)
component.selected = selectedLibraryItems.includes(ent.id) component.selected = selectedMediaItems.some((i) => i.id === ent.id)
}) })
} }
}, },
+26 -20
View File
@@ -114,28 +114,30 @@ export default {
totalEntities: 0, totalEntities: 0,
processingSeries: false, processingSeries: false,
processingIssues: false, processingIssues: false,
processingAuthors: false, processingAuthors: false
seriesSortItems: [
{
text: 'Name',
value: 'name'
},
{
text: 'Number of Books',
value: 'numBooks'
},
{
text: 'Date Added',
value: 'addedAt'
},
{
text: 'Total Duration',
value: 'totalDuration'
}
]
} }
}, },
computed: { computed: {
seriesSortItems() {
return [
{
text: this.$strings.LabelName,
value: 'name'
},
{
text: this.$strings.LabelNumberOfBooks,
value: 'numBooks'
},
{
text: this.$strings.LabelAddedAt,
value: 'addedAt'
},
{
text: this.$strings.LabelTotalDuration,
value: 'totalDuration'
}
]
},
userIsAdminOrUp() { userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp'] return this.$store.getters['user/getIsAdminOrUp']
}, },
@@ -163,6 +165,9 @@ export default {
isCollectionsPage() { isCollectionsPage() {
return this.page === 'collections' return this.page === 'collections'
}, },
isPlaylistsPage() {
return this.page === 'playlists'
},
isHomePage() { isHomePage() {
return this.$route.name === 'library-library' return this.$route.name === 'library-library'
}, },
@@ -183,6 +188,7 @@ export default {
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
if (this.isCollectionsPage) return this.$strings.LabelCollections if (this.isCollectionsPage) return this.$strings.LabelCollections
if (this.isPlaylistsPage) return this.$strings.LabelPlaylists
return '' return ''
}, },
seriesId() { seriesId() {
@@ -199,7 +205,7 @@ export default {
return this.seriesProgress.libraryItemIds || [] return this.seriesProgress.libraryItemIds || []
}, },
isBatchSelecting() { isBatchSelecting() {
return this.$store.state.selectedLibraryItems.length return this.$store.getters['globals/getIsBatchSelectingMediaItems']
}, },
isSeriesFinished() { isSeriesFinished() {
return this.seriesProgress && !!this.seriesProgress.isFinished return this.seriesProgress && !!this.seriesProgress.isFinished
+59 -13
View File
@@ -87,11 +87,11 @@ export default {
emptyMessage() { emptyMessage() {
if (this.page === 'series') return this.$strings.MessageBookshelfNoSeries if (this.page === 'series') return this.$strings.MessageBookshelfNoSeries
if (this.page === 'collections') return this.$strings.MessageBookshelfNoCollections if (this.page === 'collections') return this.$strings.MessageBookshelfNoCollections
if (this.page === 'playlists') return this.$strings.MessageNoUserPlaylists
if (this.hasFilter) { if (this.hasFilter) {
if (this.filterName === 'Issues') return this.$strings.MessageNoIssues if (this.filterName === 'Issues') return this.$strings.MessageNoIssues
else if (this.filterName === 'Feed-open') return this.$strings.MessageBookshelfNoRSSFeeds else if (this.filterName === 'Feed-open') return this.$strings.MessageBookshelfNoRSSFeeds
return this.$getString('MessageBookshelfNoResultsForFilter', [this.filterName, this.filterValue]) return this.$getString('MessageBookshelfNoResultsForFilter', [this.filterName, this.filterValue])
// return `No Results for filter "${this.filterName}: ${this.filterValue}"`
} }
return this.$strings.MessageNoResults return this.$strings.MessageNoResults
}, },
@@ -167,7 +167,7 @@ export default {
return coverSize return coverSize
}, },
bookHeight() { bookHeight() {
if (this.isCoverSquareAspectRatio) return this.bookWidth if (this.isCoverSquareAspectRatio || this.entityName === 'playlists') return this.bookWidth
return this.bookWidth * 1.6 return this.bookWidth * 1.6
}, },
shelfPadding() { shelfPadding() {
@@ -201,8 +201,8 @@ export default {
// Includes margin // Includes margin
return this.entityWidth + 24 return this.entityWidth + 24
}, },
selectedLibraryItems() { selectedMediaItems() {
return this.$store.state.selectedLibraryItems || [] return this.$store.state.globals.selectedMediaItems || []
}, },
sizeMultiplier() { sizeMultiplier() {
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120 var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
@@ -220,6 +220,8 @@ export default {
this.$store.commit('showEditModal', entity) this.$store.commit('showEditModal', entity)
} else if (this.entityName === 'collections') { } else if (this.entityName === 'collections') {
this.$store.commit('globals/setEditCollection', entity) this.$store.commit('globals/setEditCollection', entity)
} else if (this.entityName === 'playlists') {
this.$store.commit('globals/setEditPlaylist', entity)
} }
}, },
clearSelectedEntities() { clearSelectedEntities() {
@@ -230,7 +232,7 @@ export default {
if (this.entityName === 'books' || this.entityName === 'series-books') { if (this.entityName === 'books' || this.entityName === 'series-books') {
var indexOf = this.entities.findIndex((ent) => ent && ent.id === entity.id) var indexOf = this.entities.findIndex((ent) => ent && ent.id === entity.id)
const lastLastItemIndexSelected = this.lastItemIndexSelected const lastLastItemIndexSelected = this.lastItemIndexSelected
if (!this.selectedLibraryItems.includes(entity.id)) { if (!this.selectedMediaItems.some((i) => i.id === entity.id)) {
this.lastItemIndexSelected = indexOf this.lastItemIndexSelected = indexOf
} else { } else {
this.lastItemIndexSelected = -1 this.lastItemIndexSelected = -1
@@ -249,7 +251,7 @@ export default {
for (let i = loopStart; i <= loopEnd; i++) { for (let i = loopStart; i <= loopEnd; i++) {
const thisEntity = this.entities[i] const thisEntity = this.entities[i]
if (thisEntity && !thisEntity.collapsedSeries) { if (thisEntity && !thisEntity.collapsedSeries) {
if (!this.selectedLibraryItems.includes(thisEntity.id)) { if (!this.selectedMediaItems.some((i) => i.id === thisEntity.id)) {
isSelecting = true isSelecting = true
break break
} }
@@ -267,16 +269,27 @@ export default {
const entityComponentRef = this.entityComponentRefs[i] const entityComponentRef = this.entityComponentRefs[i]
if (thisEntity && entityComponentRef) { if (thisEntity && entityComponentRef) {
entityComponentRef.selected = isSelecting entityComponentRef.selected = isSelecting
this.$store.commit('setLibraryItemSelected', { libraryItemId: thisEntity.id, selected: isSelecting })
const mediaItem = {
id: thisEntity.id,
mediaType: thisEntity.mediaType,
hasTracks: thisEntity.mediaType === 'podcast' || thisEntity.media.numTracks || (thisEntity.media.tracks && thisEntity.media.tracks.length)
}
this.$store.commit('globals/setMediaItemSelected', { item: mediaItem, selected: isSelecting })
} else { } else {
console.error('Invalid entity index', i) console.error('Invalid entity index', i)
} }
} }
} else { } else {
this.$store.commit('toggleLibraryItemSelected', entity.id) const mediaItem = {
id: entity.id,
mediaType: entity.mediaType,
hasTracks: entity.mediaType === 'podcast' || entity.media.numTracks || (entity.media.tracks && entity.media.tracks.length)
}
this.$store.commit('globals/toggleMediaItemSelected', mediaItem)
} }
var newIsSelectionMode = !!this.selectedLibraryItems.length const newIsSelectionMode = !!this.selectedMediaItems.length
if (this.isSelectionMode !== newIsSelectionMode) { if (this.isSelectionMode !== newIsSelectionMode) {
this.isSelectionMode = newIsSelectionMode this.isSelectionMode = newIsSelectionMode
this.updateBookSelectionMode(newIsSelectionMode) this.updateBookSelectionMode(newIsSelectionMode)
@@ -302,11 +315,11 @@ export default {
this.currentSFQueryString = this.buildSearchParams() this.currentSFQueryString = this.buildSearchParams()
} }
var entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName const entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? 'items' : this.entityName
var sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : '' const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
var fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1` const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1`
var 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)
return null return null
}) })
@@ -561,6 +574,33 @@ export default {
this.executeRebuild() this.executeRebuild()
} }
}, },
playlistAdded(playlist) {
if (this.entityName !== 'playlists') return
console.log(`[LazyBookshelf] playlistAdded ${playlist.id}`, playlist)
this.resetEntities()
},
playlistUpdated(playlist) {
if (this.entityName !== 'playlists') return
console.log(`[LazyBookshelf] playlistUpdated ${playlist.id}`, playlist)
var indexOf = this.entities.findIndex((ent) => ent && ent.id === playlist.id)
if (indexOf >= 0) {
this.entities[indexOf] = playlist
if (this.entityComponentRefs[indexOf]) {
this.entityComponentRefs[indexOf].setEntity(playlist)
}
}
},
playlistRemoved(playlist) {
if (this.entityName !== 'playlists') return
console.log(`[LazyBookshelf] playlistRemoved ${playlist.id}`, playlist)
var indexOf = this.entities.findIndex((ent) => ent && ent.id === playlist.id)
if (indexOf >= 0) {
this.entities = this.entities.filter((ent) => ent.id !== playlist.id)
this.totalEntities--
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
this.executeRebuild()
}
},
initSizeData(_bookshelf) { initSizeData(_bookshelf) {
var bookshelf = _bookshelf || document.getElementById('bookshelf') var bookshelf = _bookshelf || document.getElementById('bookshelf')
if (!bookshelf) { if (!bookshelf) {
@@ -641,6 +681,9 @@ export default {
this.$root.socket.on('collection_added', this.collectionAdded) this.$root.socket.on('collection_added', this.collectionAdded)
this.$root.socket.on('collection_updated', this.collectionUpdated) this.$root.socket.on('collection_updated', this.collectionUpdated)
this.$root.socket.on('collection_removed', this.collectionRemoved) this.$root.socket.on('collection_removed', this.collectionRemoved)
this.$root.socket.on('playlist_added', this.playlistAdded)
this.$root.socket.on('playlist_updated', this.playlistUpdated)
this.$root.socket.on('playlist_removed', this.playlistRemoved)
} else { } else {
console.error('Bookshelf - Socket not initialized') console.error('Bookshelf - Socket not initialized')
} }
@@ -667,6 +710,9 @@ export default {
this.$root.socket.off('collection_added', this.collectionAdded) this.$root.socket.off('collection_added', this.collectionAdded)
this.$root.socket.off('collection_updated', this.collectionUpdated) this.$root.socket.off('collection_updated', this.collectionUpdated)
this.$root.socket.off('collection_removed', this.collectionRemoved) this.$root.socket.off('collection_removed', this.collectionRemoved)
this.$root.socket.off('playlist_added', this.playlistAdded)
this.$root.socket.off('playlist_updated', this.playlistUpdated)
this.$root.socket.off('playlist_removed', this.playlistRemoved)
} else { } else {
console.error('Bookshelf - Socket not initialized') console.error('Bookshelf - Socket not initialized')
} }
+48
View File
@@ -0,0 +1,48 @@
<template>
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
<div class="flex items-center mb-2">
<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">
<span class="material-icons" style="font-size: 1.4rem">add</span>
</div>
</div>
<p v-if="description" id="settings-description" class="mb-6 text-gray-200" v-html="description" />
<slot></slot>
</div>
</template>
<script>
export default {
props: {
headerText: String,
description: String,
note: String,
showAddButton: Boolean
},
methods: {
clicked() {
this.$emit('clicked')
}
}
}
</script>
<style>
#settings-description a {
color: rgb(96 165 250);
}
#settings-description a:hover {
color: rgb(147 197 253);
text-decoration-line: underline;
}
#settings-description code {
font-size: 0.875rem;
border-radius: 6px;
background-color: rgb(82, 82, 82);
color: white;
padding: 2px 4px;
}
</style>
+16 -2
View File
@@ -15,7 +15,7 @@
</nuxt-link> </nuxt-link>
<nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/latest`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastLatestPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> <nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/latest`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastLatestPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons">format_list_bulleted</span> <span class="material-icons text-2xl">format_list_bulleted</span>
<p class="font-book pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLatest }}</p> <p class="font-book pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLatest }}</p>
@@ -43,7 +43,7 @@
</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="!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'">
<span class="material-icons-outlined">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>
@@ -71,6 +71,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="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>
<p class="font-book pt-0.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonPlaylists }}</p>
<div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" 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-opacity-40 cursor-pointer relative" :class="showingIssues ? 'bg-error bg-opacity-40' : ' bg-error bg-opacity-20'"> <nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" 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-opacity-40 cursor-pointer relative" :class="showingIssues ? 'bg-error bg-opacity-40' : ' bg-error bg-opacity-20'">
<span class="material-icons text-2xl">warning</span> <span class="material-icons text-2xl">warning</span>
@@ -143,6 +151,9 @@ export default {
isAuthorsPage() { isAuthorsPage() {
return this.$route.name === 'library-library-authors' return this.$route.name === 'library-library-authors'
}, },
isPlaylistsPage() {
return this.paramId === 'playlists'
},
libraryBookshelfPage() { libraryBookshelfPage() {
return this.$route.name === 'library-library-bookshelf-id' return this.$route.name === 'library-library-bookshelf-id'
}, },
@@ -173,6 +184,9 @@ export default {
}, },
streamLibraryItem() { streamLibraryItem() {
return this.$store.state.streamLibraryItem return this.$store.state.streamLibraryItem
},
showPlaylists() {
return this.$store.state.libraries.numUserPlaylists > 0
} }
}, },
methods: { methods: {
+17 -4
View File
@@ -24,7 +24,9 @@
</div> </div>
</div> </div>
<div class="flex-grow" /> <div class="flex-grow" />
<span class="material-icons sm:px-2 py-1 md:p-4 cursor-pointer text-xl sm:text-2xl" @click="closePlayer">close</span> <ui-tooltip direction="top" :text="$strings.LabelClosePlayer">
<span class="material-icons sm:px-2 py-1 md:p-4 cursor-pointer text-xl sm:text-2xl" @click="closePlayer">close</span>
</ui-tooltip>
</div> </div>
<player-ui <player-ui
ref="audioPlayer" ref="audioPlayer"
@@ -297,6 +299,16 @@ export default {
this.playerHandler.seek(e.seekTime) this.playerHandler.seek(e.seekTime)
} }
}, },
mediaSessionPreviousTrack() {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.prevChapter()
}
},
mediaSessionNextTrack() {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.nextChapter()
}
},
updateMediaSessionPlaybackState() { updateMediaSessionPlaybackState() {
if ('mediaSession' in navigator) { if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = this.isPlaying ? 'playing' : 'paused' navigator.mediaSession.playbackState = this.isPlaying ? 'playing' : 'paused'
@@ -330,8 +342,9 @@ export default {
navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward) navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward)
navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward) navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward)
navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo) navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo)
// navigator.mediaSession.setActionHandler('previoustrack') navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionPreviousTrack)
// navigator.mediaSession.setActionHandler('nexttrack') const hasNextChapter = this.$refs.audioPlayer && this.$refs.audioPlayer.hasNextChapter
navigator.mediaSession.setActionHandler('nexttrack', hasNextChapter ? this.mediaSessionNextTrack : null)
} else { } else {
console.warn('Media session not available') console.warn('Media session not available')
} }
@@ -365,7 +378,7 @@ export default {
} }
}, },
streamReady() { streamReady() {
console.log(`[STREAM-CONTAINER] Stream Ready`) console.log(`[StreamContainer] Stream Ready`)
if (this.$refs.audioPlayer) { if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setStreamReady() this.$refs.audioPlayer.setStreamReady()
} else { } else {
+14
View File
@@ -410,6 +410,10 @@ export default {
{ {
func: 'toggleFinished', func: 'toggleFinished',
text: this.itemIsFinished ? this.$strings.MessageMarkAsNotFinished : this.$strings.MessageMarkAsFinished text: this.itemIsFinished ? this.$strings.MessageMarkAsNotFinished : this.$strings.MessageMarkAsFinished
},
{
func: 'openPlaylists',
text: this.$strings.LabelAddToPlaylist
} }
] ]
if (this.continueListeningShelf) { if (this.continueListeningShelf) {
@@ -448,6 +452,12 @@ export default {
text: this.$strings.LabelAddToCollection text: this.$strings.LabelAddToCollection
}) })
} }
if (this.numTracks) {
items.push({
func: 'openPlaylists',
text: this.$strings.LabelAddToPlaylist
})
}
} }
if (this.userCanUpdate) { if (this.userCanUpdate) {
items.push({ items.push({
@@ -739,6 +749,10 @@ export default {
this.store.commit('setSelectedLibraryItem', this.libraryItem) this.store.commit('setSelectedLibraryItem', this.libraryItem)
this.store.commit('globals/setShowCollectionsModal', true) this.store.commit('globals/setShowCollectionsModal', true)
}, },
openPlaylists() {
this.store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.recentEpisode }])
this.store.commit('globals/setShowPlaylistsModal', true)
},
createMoreMenu() { createMoreMenu() {
if (!this.$refs.moreIcon) return if (!this.$refs.moreIcon) return
@@ -0,0 +1,115 @@
<template>
<div ref="card" :id="`playlist-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-playlist-cover ref="cover" :items="items" :width="width" :height="height" />
</div>
<div v-show="isHovering && userCanUpdate" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
<div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickEdit">
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
</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 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>
</div>
</div>
</template>
<script>
export default {
props: {
index: Number,
width: Number,
height: Number,
bookCoverAspectRatio: Number,
bookshelfView: {
type: Number,
default: 0
},
playlistMount: {
type: Object,
default: () => null
}
},
data() {
return {
playlist: null,
isSelectionMode: false,
selected: false,
isHovering: false
}
},
computed: {
labelFontSize() {
if (this.width < 160) return 0.75
return 0.875
},
sizeMultiplier() {
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
return this.width / 240
},
title() {
return this.playlist ? this.playlist.name : ''
},
items() {
return this.playlist ? this.playlist.items || [] : []
},
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
},
userCanUpdate() {
return this.store.getters['user/getUserCanUpdate']
}
},
methods: {
setEntity(playlist) {
this.playlist = playlist
},
setSelectionMode(val) {
this.isSelectionMode = val
},
mouseover() {
this.isHovering = true
},
mouseleave() {
this.isHovering = false
},
clickCard() {
if (!this.playlist) return
var router = this.$router || this.$nuxt.$router
router.push(`/playlist/${this.playlist.id}`)
},
clickEdit() {
this.$emit('edit', this.playlist)
},
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.playlistMount) {
this.setEntity(this.playlistMount)
}
}
}
</script>
@@ -22,7 +22,7 @@
<span class="font-normal ml-3 block truncate text-sm md:text-base">{{ item.text }}</span> <span class="font-normal ml-3 block truncate text-sm md:text-base">{{ item.text }}</span>
</div> </div>
<div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center"> <div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center">
<span class="material-icons">arrow_right</span> <span class="material-icons text-2xl">arrow_right</span>
</div> </div>
</li> </li>
</template> </template>
@@ -30,7 +30,7 @@
<ul v-show="sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <ul v-show="sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-black-400" role="option" @click="sublist = null"> <li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-black-400" role="option" @click="sublist = null">
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center"> <div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
<span class="material-icons">arrow_left</span> <span class="material-icons text-2xl">arrow_left</span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="font-normal ml-3 block truncate">Back</span> <span class="font-normal ml-3 block truncate">Back</span>
@@ -41,9 +41,9 @@
<span class="font-normal block truncate py-2">No {{ sublist }}</span> <span class="font-normal block truncate py-2">No {{ sublist }}</span>
</div> </div>
</li> </li>
<li v-else-if="sublist === 'series'" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" role="option" @click="clickedSublistOption($encode('No Series'))"> <li v-else-if="sublist === 'series'" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" role="option" @click="clickedSublistOption($encode('no-series'))">
<div class="flex items-center"> <div class="flex items-center">
<span class="font-normal block truncate py-2 text-xs text-white text-opacity-80">No Series</span> <span class="font-normal block truncate py-2 text-xs text-white text-opacity-80">{{ $strings.MessageNoSeries }}</span>
</div> </div>
</li> </li>
<template v-for="item in sublistItems"> <template v-for="item in sublistItems">
@@ -174,6 +174,11 @@ export default {
value: 'missing', value: 'missing',
sublist: true sublist: true
}, },
{
text: this.$strings.LabelTracks,
value: 'tracks',
sublist: true
},
{ {
text: this.$strings.ButtonIssues, text: this.$strings.ButtonIssues,
value: 'issues', value: 'issues',
@@ -263,10 +268,88 @@ export default {
return this.filterData.languages || [] return this.filterData.languages || []
}, },
progress() { progress() {
return [this.$strings.LabelFinished, this.$strings.LabelInProgress, this.$strings.LabelNotStarted, this.$strings.LabelNotFinished] return [
{
id: 'finished',
name: this.$strings.LabelFinished
},
{
id: 'in-progress',
name: this.$strings.LabelInProgress
},
{
id: 'not-started',
name: this.$strings.LabelNotStarted
},
{
id: 'not-finished',
name: this.$strings.LabelNotFinished
}
]
},
tracks() {
return [
{
id: 'single',
name: this.$strings.LabelTracksSingleTrack
},
{
id: 'multi',
name: this.$strings.LabelTracksMultiTrack
}
]
}, },
missing() { missing() {
return ['ASIN', 'ISBN', this.$strings.LabelSubtitle, this.$strings.LabelAuthor, this.$strings.LabelPublishYear, this.$strings.LabelSeries, this.$strings.LabelDescription, this.$strings.LabelGenres, this.$strings.LabelTags, this.$strings.LabelNarrator, this.$strings.LabelPublisher, this.$strings.LabelLanguage] return [
{
id: 'asin',
name: 'ASIN'
},
{
id: 'isbn',
name: 'ISBN'
},
{
id: 'subtitle',
name: this.$strings.LabelSubtitle
},
{
id: 'authors',
name: this.$strings.LabelAuthor
},
{
id: 'publishedYear',
name: this.$strings.LabelPublishYear
},
{
id: 'series',
name: this.$strings.LabelSeries
},
{
id: 'description',
name: this.$strings.LabelDescription
},
{
id: 'genres',
name: this.$strings.LabelGenres
},
{
id: 'tags',
name: this.$strings.LabelTags
},
{
id: 'narrators',
name: this.$strings.LabelNarrator
},
{
id: 'publisher',
name: this.$strings.LabelPublisher
},
{
id: 'language',
name: this.$strings.LabelLanguage
}
]
}, },
sublistItems() { sublistItems() {
return (this[this.sublist] || []).map((item) => { return (this[this.sublist] || []).map((item) => {
@@ -0,0 +1,51 @@
<template>
<div class="relative rounded-sm overflow-hidden" :style="{ width: width + 'px', height: height + 'px' }">
<div v-if="items.length" class="flex flex-wrap justify-center h-full relative bg-primary bg-opacity-95 rounded-sm">
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
<covers-book-cover v-for="(li, index) in libraryItemCovers" :key="index" :library-item="li" :width="itemCoverWidth" :book-cover-aspect-ratio="1" />
</div>
<div v-else class="relative w-full h-full flex items-center justify-center p-2 bg-primary rounded-sm">
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
</div>
</div>
</template>
<script>
export default {
props: {
items: {
type: Array,
default: () => []
},
width: Number,
height: Number
},
data() {
return {}
},
computed: {
sizeMultiplier() {
return this.width / (120 * 1.6 * 2)
},
itemCoverWidth() {
if (this.libraryItemCovers.length === 1) return this.width
return this.width / 2
},
libraryItemCovers() {
if (!this.items.length) return []
if (this.items.length === 1) return [this.items[0].libraryItem]
const covers = []
for (let i = 0; i < 4; i++) {
let index = i % this.items.length
if (this.items.length === 2 && i >= 2) index = (i + 1) % 2 // for playlists with 2 items show covers in checker pattern
covers.push(this.items[index].libraryItem)
}
return covers
}
},
methods: {},
mounted() {}
}
</script>
@@ -19,7 +19,7 @@
<ui-tooltip :text="$strings.LabelUpdateCoverHelp"> <ui-tooltip :text="$strings.LabelUpdateCoverHelp">
<p class="pl-4"> <p class="pl-4">
{{ $strings.LabelUpdateCover }} {{ $strings.LabelUpdateCover }}
<span class="material-icons icon-text text-sm">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
@@ -28,7 +28,7 @@
<ui-tooltip :text="$strings.LabelUpdateDetailsHelp"> <ui-tooltip :text="$strings.LabelUpdateDetailsHelp">
<p class="pl-4"> <p class="pl-4">
{{ $strings.LabelUpdateDetails }} {{ $strings.LabelUpdateDetails }}
<span class="material-icons icon-text text-sm">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
@@ -82,7 +82,7 @@ export default {
return this.$store.state.globals.showBatchQuickMatchModal return this.$store.state.globals.showBatchQuickMatchModal
}, },
selectedBookIds() { selectedBookIds() {
return this.$store.state.selectedLibraryItems || [] return (this.$store.state.globals.selectedMediaItems || []).map((i) => i.id)
}, },
currentLibraryId() { currentLibraryId() {
return this.$store.state.libraries.currentLibraryId return this.$store.state.libraries.currentLibraryId
+1 -1
View File
@@ -24,7 +24,7 @@
<div class="flex-grow px-2"> <div class="flex-grow px-2">
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full" /> <ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full" />
</div> </div>
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-icons -mt-px">add</span></ui-btn> <ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-icons text-2xl -mt-px">add</span></ui-btn>
</div> </div>
</form> </form>
</div> </div>
@@ -136,6 +136,7 @@ export default {
}) })
if (result && result.updated) { if (result && result.updated) {
this.$toast.success(this.$strings.ToastAuthorImageRemoveSuccess) this.$toast.success(this.$strings.ToastAuthorImageRemoveSuccess)
this.$store.commit('globals/showEditAuthorModal', result.author)
} }
this.processing = false this.processing = false
}, },
@@ -157,7 +158,10 @@ export default {
if (!response) { if (!response) {
this.$toast.error('Author not found') this.$toast.error('Author not found')
} else if (response.updated) { } else if (response.updated) {
if (response.author.imagePath) this.$toast.success(this.$strings.ToastAuthorUpdateSuccess) if (response.author.imagePath) {
this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)
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')
@@ -12,7 +12,7 @@
<div class="flex-grow pr-2"> <div class="flex-grow pr-2">
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full" /> <ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full" />
</div> </div>
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-icons -mt-px">forward</span></ui-btn> <ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-icons text-2xl -mt-px">forward</span></ui-btn>
<div class="pl-2 flex items-center"> <div class="pl-2 flex items-center">
<span class="material-icons text-3xl text-white text-opacity-70 hover:text-opacity-95 cursor-pointer" @click.stop.prevent="cancelEditing">close</span> <span class="material-icons text-3xl text-white text-opacity-70 hover:text-opacity-95 cursor-pointer" @click.stop.prevent="cancelEditing">close</span>
</div> </div>
@@ -15,7 +15,7 @@
<div class="w-full overflow-y-auto overflow-x-hidden max-h-96"> <div class="w-full overflow-y-auto overflow-x-hidden max-h-96">
<transition-group name="list-complete" tag="div"> <transition-group name="list-complete" tag="div">
<template v-for="collection in sortedCollections"> <template v-for="collection in sortedCollections">
<modals-collections-user-collection-item :key="collection.id" :collection="collection" :book-cover-aspect-ratio="bookCoverAspectRatio" class="list-complete-item" @add="addToCollection" @remove="removeFromCollection" @close="show = false" /> <modals-collections-collection-item :key="collection.id" :collection="collection" :book-cover-aspect-ratio="bookCoverAspectRatio" class="list-complete-item" @add="addToCollection" @remove="removeFromCollection" @close="show = false" />
</template> </template>
</transition-group> </transition-group>
</div> </div>
@@ -104,7 +104,7 @@ export default {
return this.$store.state.globals.showBatchCollectionModal return this.$store.state.globals.showBatchCollectionModal
}, },
selectedBookIds() { selectedBookIds() {
return this.$store.state.selectedLibraryItems || [] return (this.$store.state.globals.selectedMediaItems || []).map((i) => i.id)
}, },
currentLibraryId() { currentLibraryId() {
return this.$store.state.libraries.currentLibraryId return this.$store.state.libraries.currentLibraryId
@@ -112,23 +112,21 @@ export default {
}, },
methods: { methods: {
loadCollections() { loadCollections() {
if (!this.collections.length) { this.processing = true
this.processing = true this.$axios
this.$axios .$get(`/api/libraries/${this.currentLibraryId}/collections`)
.$get(`/api/libraries/${this.currentLibraryId}/collections`) .then((data) => {
.then((data) => { if (data.results) {
if (data.results) { this.$store.commit('libraries/setCollections', data.results || [])
this.$store.commit('libraries/setCollections', data.results || []) }
} })
}) .catch((error) => {
.catch((error) => { console.error('Failed to get collections', error)
console.error('Failed to get collections', error) this.$toast.error('Failed to load collections')
this.$toast.error('Failed to load collections') })
}) .finally(() => {
.finally(() => { this.processing = false
this.processing = false })
})
}
}, },
removeFromCollection(collection) { removeFromCollection(collection) {
if (!this.selectedLibraryItemId && !this.selectedBookIds.length) return if (!this.selectedLibraryItemId && !this.selectedBookIds.length) return
@@ -231,19 +229,3 @@ export default {
mounted() {} mounted() {}
} }
</script> </script>
<style>
.list-complete-item {
transition: all 0.8s ease;
}
.list-complete-enter-from,
.list-complete-leave-to {
opacity: 0;
transform: translateY(30px);
}
.list-complete-leave-active {
position: absolute;
}
</style>
@@ -0,0 +1,58 @@
<template>
<div class="flex items-center px-4 py-2 justify-start relative hover:bg-bg" @mouseover="mouseover" @mouseleave="mouseleave">
<div v-if="isBookIncluded" class="absolute top-0 left-0 h-full w-1 bg-success z-10" />
<div class="w-20 max-w-20 text-center">
<covers-collection-cover :book-items="books" :width="80" :height="40 * bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
<div class="flex-grow overflow-hidden px-2">
<nuxt-link :to="`/collection/${collection.id}`" class="pl-2 pr-2 truncate hover:underline cursor-pointer" @click.native="clickNuxtLink">{{ collection.name }}</nuxt-link>
</div>
<div class="h-full flex items-center justify-end transform" :class="isHovering ? 'transition-transform translate-0 w-16' : 'translate-x-40 w-0'">
<ui-btn v-if="!isBookIncluded" color="success" :padding-x="3" small class="h-9" @click.stop="clickAdd"><span class="material-icons text-2xl pt-px">add</span></ui-btn>
<ui-btn v-else color="error" :padding-x="3" class="h-9" small @click.stop="clickRem"><span class="material-icons text-2xl pt-px">remove</span></ui-btn>
</div>
</div>
</template>
<script>
export default {
props: {
collection: {
type: Object,
default: () => {}
},
bookCoverAspectRatio: Number
},
data() {
return {
isHovering: false
}
},
computed: {
isBookIncluded() {
return !!this.collection.isBookIncluded
},
books() {
return this.collection.books || []
}
},
methods: {
clickNuxtLink() {
this.$emit('close')
},
mouseover() {
this.isHovering = true
},
mouseleave() {
this.isHovering = false
},
clickAdd() {
this.$emit('add', this.collection)
},
clickRem() {
this.$emit('remove', this.collection)
}
},
mounted() {}
}
</script>
@@ -1,95 +0,0 @@
<template>
<div class="flex items-center px-4 py-2 justify-start relative hover:bg-bg" :class="wrapperClass" @mouseover="mouseover" @mouseleave="mouseleave">
<div v-if="isBookIncluded" class="absolute top-0 left-0 h-full w-1 bg-success z-10" />
<!-- <span class="material-icons" :class="highlight ? 'text-success' : 'text-white text-opacity-80'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</span> -->
<div class="w-20 max-w-20 text-center">
<!-- <img src="/Logo.png" /> -->
<covers-collection-cover :book-items="books" :width="80" :height="40 * bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
<div class="flex-grow overflow-hidden px-2">
<!-- <template v-if="isEditing">
<form @submit.prevent="submitUpdate">
<div class="flex items-center">
<div class="flex-grow pr-2">
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full" />
</div>
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-icons -mt-px">forward</span></ui-btn>
<div class="pl-2 flex items-center">
<span class="material-icons text-3xl text-white text-opacity-70 hover:text-opacity-95 cursor-pointer" @click.stop.prevent="cancelEditing">close</span>
</div>
</div>
</form>
</template> -->
<nuxt-link :to="`/collection/${collection.id}`" class="pl-2 pr-2 truncate hover:underline cursor-pointer" @click.native="clickNuxtLink">{{ collection.name }}</nuxt-link>
</div>
<div v-if="!isEditing" class="h-full flex items-center justify-end transform" :class="isHovering ? 'transition-transform translate-0 w-16' : 'translate-x-40 w-0'">
<ui-btn v-if="!isBookIncluded" color="success" :padding-x="3" small class="h-9" @click.stop="clickAdd"><span class="material-icons pt-px">add</span></ui-btn>
<ui-btn v-else color="error" :padding-x="3" class="h-9" small @click.stop="clickRem"><span class="material-icons pt-px">remove</span></ui-btn>
<!-- <span class="material-icons text-xl mr-2 text-gray-200 hover:text-yellow-400" @click.stop="editClick">edit</span>
<span class="material-icons text-xl text-gray-200 hover:text-error cursor-pointer" @click.stop="deleteClick">delete</span> -->
</div>
</div>
</template>
<script>
export default {
props: {
collection: {
type: Object,
default: () => {}
},
highlight: Boolean,
bookCoverAspectRatio: Number
},
data() {
return {
isHovering: false,
isEditing: false
}
},
computed: {
isBookIncluded() {
return !!this.collection.isBookIncluded
},
wrapperClass() {
var classes = []
if (this.highlight) classes.push('bg-bg bg-opacity-60')
if (!this.isEditing) classes.push('cursor-pointer')
return classes.join(' ')
},
books() {
return this.collection.books || []
}
},
methods: {
clickNuxtLink() {
this.$emit('close')
},
mouseover() {
if (this.isEditing) return
this.isHovering = true
},
mouseleave() {
this.isHovering = false
},
clickAdd() {
this.$emit('add', this.collection)
},
clickRem() {
this.$emit('remove', this.collection)
},
deleteClick() {
if (this.isEditing) return
this.$emit('delete', this.collection)
},
editClick() {
this.isEditing = true
this.isHovering = false
},
cancelEditing() {
this.isEditing = false
}
},
mounted() {}
}
</script>
+4 -2
View File
@@ -7,7 +7,9 @@
<div v-if="media.coverPath" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100"> <div v-if="media.coverPath" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
<div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" /> <div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" />
<div class="p-1 absolute top-1 right-1 text-red-500 rounded-full w-8 h-8 cursor-pointer hover:text-red-400 shadow-sm" @click="removeCover"> <div class="p-1 absolute top-1 right-1 text-red-500 rounded-full w-8 h-8 cursor-pointer hover:text-red-400 shadow-sm" @click="removeCover">
<span class="material-icons">delete</span> <ui-tooltip direction="top" :text="$strings.LabelRemoveCover">
<span class="material-icons text-2xl">delete</span>
</ui-tooltip>
</div> </div>
</div> </div>
</div> </div>
@@ -16,7 +18,7 @@
<div v-if="userCanUpload" class="w-10 md:w-40 pr-2 pt-4 md:min-w-32"> <div v-if="userCanUpload" class="w-10 md:w-40 pr-2 pt-4 md:min-w-32">
<ui-file-input ref="fileInput" @change="fileUploadSelected" <ui-file-input ref="fileInput" @change="fileUploadSelected"
><span class="hidden md:inline-block">{{ $strings.ButtonUploadCover }}</span ><span class="hidden md:inline-block">{{ $strings.ButtonUploadCover }}</span
><span class="material-icons inline-block md:!hidden">upload</span></ui-file-input ><span class="material-icons text-2xl inline-block md:!hidden">upload</span></ui-file-input
> >
</div> </div>
<form @submit.prevent="submitForm" class="flex flex-grow"> <form @submit.prevent="submitForm" class="flex flex-grow">
@@ -15,7 +15,7 @@
<ui-tooltip text="Value of 0 sets no max limit. After a new episode is auto-downloaded this will delete the oldest episode if you have more than X episodes. <br>This will only delete 1 episode per new download."> <ui-tooltip text="Value of 0 sets no max limit. After a new episode is auto-downloaded this will delete the oldest episode if you have more than X episodes. <br>This will only delete 1 episode per new download.">
<p class="pl-4 text-base"> <p class="pl-4 text-base">
Max episodes to keep Max episodes to keep
<span class="material-icons icon-text text-sm">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
@@ -24,7 +24,7 @@
<ui-tooltip text="Value of 0 sets no max limit. When checking for new episodes this is the max number of episodes that will be downloaded."> <ui-tooltip text="Value of 0 sets no max limit. When checking for new episodes this is the max number of episodes that will be downloaded.">
<p class="pl-4 text-base"> <p class="pl-4 text-base">
Max new episodes to download per check Max new episodes to download per check
<span class="material-icons icon-text text-sm">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
@@ -21,7 +21,7 @@
<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>
<ui-editable-text v-model="folder.fullPath" readonly type="text" class="w-full" /> <ui-editable-text v-model="folder.fullPath" readonly type="text" class="w-full" />
<span v-show="folders.length > 1" class="material-icons ml-2 cursor-pointer hover:text-error" @click="removeFolder(folder)">close</span> <span v-show="folders.length > 1" class="material-icons text-2xl ml-2 cursor-pointer hover:text-error" @click="removeFolder(folder)">close</span>
</div> </div>
<div class="flex py-1 px-2 items-center w-full"> <div class="flex py-1 px-2 items-center w-full">
<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>
@@ -10,10 +10,10 @@
<p v-if="isOpenInPlayer" class="text-sm text-right text-gray-400">{{ $strings.ButtonPlaying }}</p> <p v-if="isOpenInPlayer" class="text-sm text-right text-gray-400">{{ $strings.ButtonPlaying }}</p>
<div v-else-if="isHovering" class="flex items-center justify-end -mx-1"> <div v-else-if="isHovering" class="flex items-center justify-end -mx-1">
<button class="outline-none mx-1 flex items-center" @click.stop="playClick"> <button class="outline-none mx-1 flex items-center" @click.stop="playClick">
<span class="material-icons text-success">play_arrow</span> <span class="material-icons text-2xl text-success">play_arrow</span>
</button> </button>
<button class="outline-none mx-1 flex items-center" @click.stop="removeClick"> <button class="outline-none mx-1 flex items-center" @click.stop="removeClick">
<span class="material-icons text-error">close</span> <span class="material-icons text-2xl text-error">close</span>
</button> </button>
</div> </div>
<p v-else class="text-gray-400 text-sm text-right">{{ durationPretty }}</p> <p v-else class="text-gray-400 text-sm text-right">{{ durationPretty }}</p>
@@ -0,0 +1,191 @@
<template>
<modals-modal v-model="show" name="playlists" :processing="processing" :width="500" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<div v-if="show" class="w-full h-full">
<div class="py-4 px-4">
<h1 v-if="!isBatch" class="text-2xl">{{ $strings.LabelAddToPlaylist }}</h1>
<h1 v-else class="text-2xl">{{ $getString('LabelAddToPlaylistBatch', [selectedPlaylistItems.length]) }}</h1>
</div>
<div class="w-full overflow-y-auto overflow-x-hidden max-h-96">
<transition-group name="list-complete" tag="div">
<template v-for="playlist in sortedPlaylists">
<modals-playlists-user-playlist-item :key="playlist.id" :playlist="playlist" class="list-complete-item" @add="addToPlaylist" @remove="removeFromPlaylist" @close="show = false" />
</template>
</transition-group>
</div>
<div v-if="!playlists.length" class="flex h-32 items-center justify-center">
<p class="text-xl">{{ $strings.MessageNoUserPlaylists }}</p>
</div>
<div class="w-full h-px bg-white bg-opacity-10" />
<form @submit.prevent="submitCreatePlaylist">
<div class="flex px-4 py-2 items-center text-center border-b border-white border-opacity-10 text-white text-opacity-80">
<div class="flex-grow px-2">
<ui-text-input v-model="newPlaylistName" :placeholder="$strings.PlaceholderNewPlaylist" class="w-full" />
</div>
<ui-btn type="submit" color="success" :padding-x="4" class="h-10">{{ $strings.ButtonCreate }}</ui-btn>
</div>
</form>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
data() {
return {
newPlaylistName: '',
processing: false
}
},
watch: {
show(newVal) {
if (newVal) {
this.loadPlaylists()
this.newPlaylistName = ''
} else {
this.$store.commit('globals/setSelectedPlaylistItems', null)
}
}
},
computed: {
show: {
get() {
return this.$store.state.globals.showPlaylistsModal
},
set(val) {
this.$store.commit('globals/setShowPlaylistsModal', val)
}
},
title() {
if (!this.selectedPlaylistItems.length) return ''
if (this.isBatch) {
return this.$getString('MessageItemsSelected', [this.selectedPlaylistItems.length])
}
const selectedPlaylistItem = this.selectedPlaylistItems[0]
if (selectedPlaylistItem.episode) {
return selectedPlaylistItem.episode.title
}
return selectedPlaylistItem.libraryItem.media.metadata.title || ''
},
playlists() {
return this.$store.state.libraries.userPlaylists || []
},
sortedPlaylists() {
return this.playlists
.map((playlist) => {
const includesItem = !this.selectedPlaylistItems.some((item) => !this.checkIsItemInPlaylist(playlist, item))
return {
isItemIncluded: includesItem,
...playlist
}
})
.sort((a, b) => (a.isItemIncluded ? -1 : 1))
},
isBatch() {
return this.selectedPlaylistItems.length > 1
},
selectedPlaylistItems() {
return this.$store.state.globals.selectedPlaylistItems || []
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
}
},
methods: {
checkIsItemInPlaylist(playlist, item) {
if (item.episode) {
return playlist.items.some((i) => i.libraryItemId === item.libraryItem.id && i.episodeId === item.episode.id)
}
return playlist.items.some((i) => i.libraryItemId === item.libraryItem.id)
},
loadPlaylists() {
this.processing = true
this.$axios
.$get(`/api/libraries/${this.currentLibraryId}/playlists`)
.then((data) => {
this.$store.commit('libraries/setUserPlaylists', data.results || [])
})
.catch((error) => {
console.error('Failed to get playlists', error)
this.$toast.error('Failed to load user playlists')
})
.finally(() => {
this.processing = false
})
},
removeFromPlaylist(playlist) {
if (!this.selectedPlaylistItems.length) return
this.processing = true
const itemObjects = this.selectedPlaylistItems.map((pi) => ({ libraryItemId: pi.libraryItem.id, episodeId: pi.episode ? pi.episode.id : null }))
this.$axios
.$post(`/api/playlists/${playlist.id}/batch/remove`, { items: itemObjects })
.then((updatedPlaylist) => {
console.log(`Items removed from playlist`, updatedPlaylist)
this.$toast.success('Playlist item(s) removed')
this.processing = false
})
.catch((error) => {
console.error('Failed to remove items from playlist', error)
this.$toast.error('Failed to remove playlist item(s)')
this.processing = false
})
},
addToPlaylist(playlist) {
if (!this.selectedPlaylistItems.length) return
this.processing = true
const itemObjects = this.selectedPlaylistItems.map((pi) => ({ libraryItemId: pi.libraryItem.id, episodeId: pi.episode ? pi.episode.id : null }))
this.$axios
.$post(`/api/playlists/${playlist.id}/batch/add`, { items: itemObjects })
.then((updatedPlaylist) => {
console.log(`Items added to playlist`, updatedPlaylist)
this.$toast.success('Items added to playlist')
this.processing = false
})
.catch((error) => {
console.error('Failed to add items to playlist', error)
this.$toast.error('Failed to add items to playlist')
this.processing = false
})
},
submitCreatePlaylist() {
if (!this.newPlaylistName || !this.selectedPlaylistItems.length) {
return
}
this.processing = true
const itemObjects = this.selectedPlaylistItems.map((pi) => ({ libraryItemId: pi.libraryItem.id, episodeId: pi.episode ? pi.episode.id : null }))
const newPlaylist = {
items: itemObjects,
libraryId: this.currentLibraryId,
name: this.newPlaylistName
}
this.$axios
.$post('/api/playlists', newPlaylist)
.then((data) => {
console.log('New playlist created', data)
this.$toast.success(`Playlist "${data.name}" created`)
this.processing = false
this.newPlaylistName = ''
})
.catch((error) => {
console.error('Failed to create playlist', error)
var errMsg = error.response ? error.response.data || '' : ''
this.$toast.error(`Failed to create playlist: ${errMsg}`)
this.processing = false
})
}
},
mounted() {}
}
</script>
@@ -0,0 +1,125 @@
<template>
<modals-modal v-model="show" name="edit-playlist" :width="700" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ $strings.HeaderPlaylist }}</p>
</div>
</template>
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
<form @submit.prevent="submitForm">
<div class="flex">
<div>
<covers-playlist-cover :items="items" :width="200" :height="200" />
</div>
<div class="flex-grow px-4">
<ui-text-input-with-label v-model="newPlaylistName" :label="$strings.LabelName" class="mb-2" />
<ui-textarea-with-label v-model="newPlaylistDescription" :label="$strings.LabelDescription" />
</div>
</div>
<div class="absolute bottom-0 left-0 right-0 w-full py-2 px-4 flex">
<ui-btn v-if="userCanDelete" small color="error" type="button" @click.stop="removeClick">{{ $strings.ButtonRemove }}</ui-btn>
<div class="flex-grow" />
<ui-btn color="success" type="submit">{{ $strings.ButtonSave }}</ui-btn>
</div>
</form>
</div>
</modals-modal>
</template>
<script>
export default {
data() {
return {
processing: false,
newPlaylistName: null,
newPlaylistDescription: null,
showImageUploader: false
}
},
watch: {
show: {
handler(newVal) {
if (newVal) {
this.init()
}
}
}
},
computed: {
show: {
get() {
return this.$store.state.globals.showEditPlaylistModal
},
set(val) {
this.$store.commit('globals/setShowEditPlaylistModal', val)
}
},
playlist() {
return this.$store.state.globals.selectedPlaylist || {}
},
playlistName() {
return this.playlist.name
},
items() {
return this.playlist.items || []
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
}
},
methods: {
init() {
this.newPlaylistName = this.playlistName
this.newPlaylistDescription = this.playlist.description || ''
},
removeClick() {
if (confirm(this.$getString('MessageConfirmRemovePlaylist', [this.playlistName]))) {
this.processing = true
this.$axios
.$delete(`/api/playlists/${this.playlist.id}`)
.then(() => {
this.processing = false
this.show = false
this.$toast.success(this.$strings.ToastPlaylistRemoveSuccess)
})
.catch((error) => {
console.error('Failed to remove playlist', error)
this.processing = false
this.$toast.error(this.$strings.ToastPlaylistRemoveFailed)
})
}
},
submitForm() {
if (this.newPlaylistName === this.playlistName && this.newPlaylistDescription === this.playlist.description) {
return
}
if (!this.newPlaylistName) {
return this.$toast.error('Playlist must have a name')
}
this.processing = true
var playlistUpdate = {
name: this.newPlaylistName,
description: this.newPlaylistDescription || null
}
this.$axios
.$patch(`/api/playlists/${this.playlist.id}`, playlistUpdate)
.then((playlist) => {
console.log('Playlist Updated', playlist)
this.processing = false
this.show = false
this.$toast.success(this.$strings.ToastPlaylistUpdateSuccess)
})
.catch((error) => {
console.error('Failed to update playlist', error)
this.processing = false
this.$toast.error(this.$strings.ToastPlaylistUpdateFailed)
})
}
},
mounted() {},
beforeDestroy() {}
}
</script>
@@ -0,0 +1,57 @@
<template>
<div class="flex items-center px-4 py-2 justify-start relative hover:bg-bg" @mouseover="mouseover" @mouseleave="mouseleave">
<div v-if="isItemIncluded" class="absolute top-0 left-0 h-full w-1 bg-success z-10" />
<div class="w-16 max-w-16 text-center">
<covers-playlist-cover :items="items" :width="64" :height="64" />
</div>
<div class="flex-grow overflow-hidden px-2">
<nuxt-link :to="`/playlist/${playlist.id}`" class="pl-2 pr-2 truncate hover:underline cursor-pointer" @click.native="clickNuxtLink">{{ playlist.name }}</nuxt-link>
</div>
<div class="h-full flex items-center justify-end transform" :class="isHovering ? 'transition-transform translate-0 w-16' : 'translate-x-40 w-0'">
<ui-btn v-if="!isItemIncluded" color="success" :padding-x="3" small class="h-9" @click.stop="clickAdd"><span class="material-icons text-2xl pt-px">add</span></ui-btn>
<ui-btn v-else color="error" :padding-x="3" class="h-9" small @click.stop="clickRem"><span class="material-icons text-2xl pt-px">remove</span></ui-btn>
</div>
</div>
</template>
<script>
export default {
props: {
playlist: {
type: Object,
default: () => {}
}
},
data() {
return {
isHovering: false
}
},
computed: {
isItemIncluded() {
return !!this.playlist.isItemIncluded
},
items() {
return this.playlist.items || []
}
},
methods: {
clickNuxtLink() {
this.$emit('close')
},
mouseover() {
this.isHovering = true
},
mouseleave() {
this.isHovering = false
},
clickAdd() {
this.$emit('add', this.playlist)
},
clickRem() {
this.$emit('remove', this.playlist)
}
},
mounted() {}
}
</script>
@@ -9,7 +9,7 @@
<span class="material-icons text-2xl sm:text-3xl">replay_10</span> <span class="material-icons text-2xl sm:text-3xl">replay_10</span>
</div> </div>
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-4 md:mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause"> <div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-4 md:mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
<span class="material-icons">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span> <span class="material-icons text-2xl">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
</div> </div>
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward"> <div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
<span class="material-icons text-2xl sm:text-3xl">forward_10</span> <span class="material-icons text-2xl sm:text-3xl">forward_10</span>
+26 -16
View File
@@ -4,27 +4,37 @@
<div class="absolute -top-10 md:top-0 right-0 lg:right-2 flex items-center h-full"> <div class="absolute -top-10 md:top-0 right-0 lg:right-2 flex items-center h-full">
<!-- <span class="material-icons text-2xl cursor-pointer" @click="toggleFullscreen(true)">expand_less</span> --> <!-- <span class="material-icons text-2xl cursor-pointer" @click="toggleFullscreen(true)">expand_less</span> -->
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" class="mx-2 hidden md:block" /> <ui-tooltip direction="top" :text="$strings.LabelVolume">
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" class="mx-2 hidden md:block" />
</ui-tooltip>
<div class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showSleepTimer')"> <ui-tooltip direction="top" :text="$strings.LabelSleepTimer">
<span v-if="!sleepTimerSet" class="material-icons text-2xl">snooze</span> <div class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showSleepTimer')">
<div v-else class="flex items-center"> <span v-if="!sleepTimerSet" class="material-icons text-2xl">snooze</span>
<span class="material-icons text-lg text-warning">snooze</span> <div v-else class="flex items-center">
<p class="text-xl text-warning font-mono font-semibold text-center px-0.5 pb-0.5" style="min-width: 30px">{{ sleepTimerRemainingString }}</p> <span class="material-icons text-lg text-warning">snooze</span>
<p class="text-xl text-warning font-mono font-semibold text-center px-0.5 pb-0.5" style="min-width: 30px">{{ sleepTimerRemainingString }}</p>
</div>
</div> </div>
</div> </ui-tooltip>
<div v-if="!isPodcast" class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showBookmarks')"> <ui-tooltip v-if="!isPodcast" direction="top" :text="$strings.LabelViewBookmarks">
<span class="material-icons text-2xl">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span> <div class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showBookmarks')">
</div> <span class="material-icons text-2xl">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
</div>
</ui-tooltip>
<div v-if="chapters.length" class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="showChapters"> <ui-tooltip v-if="chapters.length" direction="top" :text="$strings.LabelViewChapters">
<span class="material-icons text-2xl">format_list_bulleted</span> <div class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
</div> <span class="material-icons text-2xl">format_list_bulleted</span>
</div>
</ui-tooltip>
<button v-if="playerQueueItems.length" class="outline-none text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showPlayerQueueItems')"> <ui-tooltip v-if="playerQueueItems.length" direction="top" :text="$strings.LabelViewQueue">
<span class="material-icons text-2.5xl sm:text-3xl">queue_music</span> <button class="outline-none text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showPlayerQueueItems')">
</button> <span class="material-icons text-2.5xl sm:text-3xl">playlist_play</span>
</button>
</ui-tooltip>
<ui-tooltip v-if="chapters.length" direction="top" :text="useChapterTrack ? $strings.LabelUseFullTrack : $strings.LabelUseChapterTrack"> <ui-tooltip v-if="chapters.length" direction="top" :text="useChapterTrack ? $strings.LabelUseFullTrack : $strings.LabelUseChapterTrack">
<div class="cursor-pointer text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="setUseChapterTrack"> <div class="cursor-pointer text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="setUseChapterTrack">
+11 -6
View File
@@ -92,13 +92,18 @@ export default {
}, },
ebookUrl() { ebookUrl() {
if (!this.ebookFile) return null if (!this.ebookFile) return null
var itemRelPath = this.selectedLibraryItem.relPath let filepath = ''
if (itemRelPath.startsWith('/')) itemRelPath = itemRelPath.slice(1) if (this.selectedLibraryItem.isFile) {
var relPath = this.ebookFile.metadata.relPath filepath = this.$encodeUriPath(this.ebookFile.metadata.filename)
if (relPath.startsWith('/')) relPath = relPath.slice(1) } else {
const itemRelPath = this.selectedLibraryItem.relPath
if (itemRelPath.startsWith('/')) itemRelPath = itemRelPath.slice(1)
const relPath = this.ebookFile.metadata.relPath
if (relPath.startsWith('/')) relPath = relPath.slice(1)
const relRelPath = this.$encodeUriPath(`${itemRelPath}/${relPath}`) filepath = this.$encodeUriPath(`${itemRelPath}/${relPath}`)
return `/ebook/${this.libraryId}/${this.folderId}/${relRelPath}` }
return `/ebook/${this.libraryId}/${this.folderId}/${filepath}`
}, },
userToken() { userToken() {
return this.$store.getters['user/getToken'] return this.$store.getters['user/getToken']
+29 -13
View File
@@ -25,7 +25,7 @@
<a v-if="backup.serverVersion" :href="`/metadata/${$encodeUriPath(backup.path)}?token=${userToken}`" class="mx-1 pt-1 hover:text-opacity-100 text-opacity-70 text-white" download><span class="material-icons text-xl">download</span></a> <a v-if="backup.serverVersion" :href="`/metadata/${$encodeUriPath(backup.path)}?token=${userToken}`" class="mx-1 pt-1 hover:text-opacity-100 text-opacity-70 text-white" download><span class="material-icons text-xl">download</span></a>
<ui-tooltip v-else text="This backup was created with an old version of audiobookshelf no longer supported" direction="bottom" class="mx-2 flex items-center"> <ui-tooltip v-else text="This backup was created with an old version of audiobookshelf no longer supported" direction="bottom" class="mx-2 flex items-center">
<span class="material-icons-outlined text-error">error_outline</span> <span class="material-icons-outlined text-2xl text-error">error_outline</span>
</ui-tooltip> </ui-tooltip>
<span class="material-icons text-xl hover:text-error hover:text-opacity-100 text-opacity-70 text-white cursor-pointer mx-1" @click="deleteBackupClick(backup)">delete</span> <span class="material-icons text-xl hover:text-error hover:text-opacity-100 text-opacity-70 text-white cursor-pointer mx-1" @click="deleteBackupClick(backup)">delete</span>
@@ -64,13 +64,11 @@ export default {
showConfirmApply: false, showConfirmApply: false,
selectedBackup: null, selectedBackup: null,
isBackingUp: false, isBackingUp: false,
processing: false processing: false,
backups: []
} }
}, },
computed: { computed: {
backups() {
return this.$store.state.backups || []
},
userToken() { userToken() {
return this.$store.getters['user/getToken'] return this.$store.getters['user/getToken']
} }
@@ -96,9 +94,8 @@ export default {
this.processing = true this.processing = true
this.$axios this.$axios
.$delete(`/api/backups/${backup.id}`) .$delete(`/api/backups/${backup.id}`)
.then((backups) => { .then((data) => {
console.log('Backup deleted', backups) this.setBackups(data.backups || [])
this.$store.commit('setBackups', backups)
this.$toast.success(this.$strings.ToastBackupDeleteSuccess) this.$toast.success(this.$strings.ToastBackupDeleteSuccess)
this.processing = false this.processing = false
}) })
@@ -117,10 +114,10 @@ export default {
this.isBackingUp = true this.isBackingUp = true
this.$axios this.$axios
.$post('/api/backups') .$post('/api/backups')
.then((backups) => { .then((data) => {
this.isBackingUp = false this.isBackingUp = false
this.$toast.success(this.$strings.ToastBackupCreateSuccess) this.$toast.success(this.$strings.ToastBackupCreateSuccess)
this.$store.commit('setBackups', backups) this.setBackups(data.backups || [])
}) })
.catch((error) => { .catch((error) => {
this.isBackingUp = false this.isBackingUp = false
@@ -136,9 +133,8 @@ export default {
this.$axios this.$axios
.$post('/api/backups/upload', form) .$post('/api/backups/upload', form)
.then((result) => { .then((data) => {
console.log('Upload backup result', result) this.setBackups(data.backups || [])
this.$store.commit('setBackups', result)
this.$toast.success(this.$strings.ToastBackupUploadSuccess) this.$toast.success(this.$strings.ToastBackupUploadSuccess)
this.processing = false this.processing = false
}) })
@@ -148,9 +144,29 @@ export default {
this.$toast.error(errorMessage) this.$toast.error(errorMessage)
this.processing = false this.processing = false
}) })
},
setBackups(backups) {
backups.sort((a, b) => b.createdAt - a.createdAt)
this.backups = backups
},
loadBackups() {
this.processing = true
this.$axios
.$get('/api/backups')
.then((data) => {
this.setBackups(data.backups || [])
})
.catch((error) => {
console.error('Failed to load backups', error)
this.$toast.error('Failed to load backups')
})
.finally(() => {
this.processing = false
})
} }
}, },
mounted() { mounted() {
this.loadBackups()
if (this.$route.query.backup) { if (this.$route.query.backup) {
this.$toast.success('Backup applied successfully') this.$toast.success('Backup applied successfully')
this.$router.replace('/config') this.$router.replace('/config')
@@ -0,0 +1,119 @@
<template>
<div class="w-full bg-primary bg-opacity-40">
<div class="w-full h-14 flex items-center px-4 md:px-6 py-2 bg-primary">
<p class="pr-4">{{ $strings.HeaderPlaylistItems }}</p>
<div class="w-6 h-6 md:w-7 md:h-7 bg-white bg-opacity-10 rounded-full flex items-center justify-center">
<span class="text-xs md:text-sm font-mono leading-none">{{ items.length }}</span>
</div>
<div class="flex-grow" />
<p v-if="totalDuration" class="text-sm text-gray-200">{{ totalDurationPretty }}</p>
</div>
<draggable v-model="itemsCopy" v-bind="dragOptions" class="list-group" handle=".drag-handle" draggable=".item" tag="div" @start="drag = true" @end="drag = false" @update="draggableUpdate">
<transition-group type="transition" :name="!drag ? 'playlist-item' : null">
<template v-for="(item, index) in itemsCopy">
<tables-playlist-item-table-row :key="index" :is-dragging="drag" :item="item" :playlist-id="playlistId" :book-cover-aspect-ratio="bookCoverAspectRatio" class="item" :class="drag ? '' : 'playlist-item-item'" @edit="editItem" />
</template>
</transition-group>
</draggable>
</div>
</template>
<script>
import draggable from 'vuedraggable'
export default {
components: {
draggable
},
props: {
playlistId: String,
items: {
type: Array,
default: () => []
}
},
data() {
return {
drag: false,
dragOptions: {
animation: 200,
group: 'description',
ghostClass: 'ghost'
},
itemsCopy: []
}
},
watch: {
items: {
handler(newVal) {
this.init()
}
}
},
computed: {
bookCoverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
totalDuration() {
var _total = 0
this.items.forEach((item) => {
if (item.episode) _total += item.episode.duration
else _total += item.libraryItem.media.duration
})
return _total
},
totalDurationPretty() {
return this.$elapsedPrettyExtended(this.totalDuration)
}
},
methods: {
editItem(playlistItem) {
if (playlistItem.episode) {
this.$store.commit('globals/setSelectedEpisode', playlist.episode)
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
} else {
const itemIds = this.items.map((i) => i.libraryItemId)
this.$store.commit('setBookshelfBookIds', itemIds)
this.$store.commit('showEditModal', playlistItem.libraryItem)
}
},
draggableUpdate() {
var playlistUpdate = {
items: this.itemsCopy.map((i) => ({ libraryItemId: i.libraryItemId, episodeId: i.episodeId }))
}
this.$axios
.$patch(`/api/playlists/${this.playlistId}`, playlistUpdate)
.then((playlist) => {
console.log('Playlist updated', playlist)
})
.catch((error) => {
console.error('Failed to update playlist', error)
this.$toast.error('Failed to save playlist items order')
})
},
init() {
this.itemsCopy = this.items.map((i) => ({ ...i }))
}
},
mounted() {
this.init()
}
}
</script>
<style>
.playlist-item-item {
transition: all 0.4s ease;
}
.playlist-item-enter-from,
.playlist-item-leave-to {
opacity: 0;
transform: translateX(30px);
}
.playlist-item-leave-active {
position: absolute;
}
</style>
+5 -14
View File
@@ -1,12 +1,5 @@
<template> <template>
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8"> <div>
<div class="flex items-center mb-2">
<h1 class="text-xl">{{ $strings.HeaderUsers }}</h1>
<div 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="clickAddUser">
<span class="material-icons" style="font-size: 1.4rem">add</span>
</div>
</div>
<div class="text-center"> <div class="text-center">
<table id="accounts"> <table id="accounts">
<tr> <tr>
@@ -26,11 +19,9 @@
</td> </td>
<td class="text-sm">{{ user.type }}</td> <td class="text-sm">{{ user.type }}</td>
<td class="hidden lg:table-cell"> <td class="hidden lg:table-cell">
<div v-if="usersOnline[user.id] && usersOnline[user.id].session && usersOnline[user.id].session.libraryItem && usersOnline[user.id].session.libraryItem.media"> <div v-if="usersOnline[user.id]">
<p class="truncate text-xs">Listening: {{ usersOnline[user.id].session.libraryItem.media.metadata.title || '' }}</p> <p v-if="usersOnline[user.id].session && usersOnline[user.id].session.libraryItem" class="truncate text-xs">Listening: {{ usersOnline[user.id].session.libraryItem.media.metadata.title || '' }}</p>
</div> <p v-else-if="usersOnline[user.id].mostRecent && usersOnline[user.id].mostRecent.media" class="truncate text-xs">Last: {{ usersOnline[user.id].mostRecent.media.metadata.title }}</p>
<div v-else-if="user.mostRecent">
<p class="truncate text-xs">Last: {{ user.mostRecent.metadata.title }}</p>
</div> </div>
</td> </td>
<td class="text-xs font-mono hidden sm:table-cell"> <td class="text-xs font-mono hidden sm:table-cell">
@@ -81,7 +72,7 @@ export default {
}, },
usersOnline() { usersOnline() {
var usermap = {} var usermap = {}
this.$store.state.users.users.forEach((u) => (usermap[u.id] = { online: true, session: u.session })) this.$store.state.users.usersOnline.forEach((u) => (usermap[u.id] = u))
return usermap return usermap
} }
}, },
@@ -10,7 +10,7 @@
<covers-book-cover :library-item="book" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-book-cover :library-item="book" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div class="absolute top-0 left-0 bg-black bg-opacity-50 flex items-center justify-center h-full w-full z-10" v-show="isHovering && showPlayBtn"> <div class="absolute top-0 left-0 bg-black bg-opacity-50 flex items-center justify-center h-full w-full z-10" v-show="isHovering && showPlayBtn">
<div class="w-8 h-8 bg-white bg-opacity-20 rounded-full flex items-center justify-center hover:bg-opacity-40 cursor-pointer" @click="playClick"> <div class="w-8 h-8 bg-white bg-opacity-20 rounded-full flex items-center justify-center hover:bg-opacity-40 cursor-pointer" @click="playClick">
<span class="material-icons">play_arrow</span> <span class="material-icons text-2xl">play_arrow</span>
</div> </div>
</div> </div>
</div> </div>
@@ -1,11 +1,5 @@
<template> <template>
<div id="librariesTable" class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8"> <div id="librariesTable">
<div class="flex items-center mb-2">
<h1 class="text-xl">{{ $strings.HeaderLibraries }}</h1>
<div 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="clickAddLibrary">
<span class="material-icons" style="font-size: 1.4rem">add</span>
</div>
</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">
@@ -0,0 +1,237 @@
<template>
<div class="w-full px-1 md:px-2 py-2 overflow-hidden relative" @mouseover="mouseover" @mouseleave="mouseleave" :class="isHovering ? 'bg-white bg-opacity-5' : ''">
<div v-if="item" class="flex h-16 md:h-20">
<div class="w-10 min-w-10 md:w-16 md:max-w-16 h-full">
<div class="flex h-full items-center justify-center">
<span class="material-icons drag-handle text-lg md:text-xl">menu</span>
</div>
</div>
<div class="h-full relative flex items-center" :style="{ width: coverWidth + 'px', minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }">
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div class="absolute top-0 left-0 bg-black bg-opacity-50 flex items-center justify-center h-full w-full z-10" v-show="isHovering && showPlayBtn">
<div class="w-8 h-8 bg-white bg-opacity-20 rounded-full flex items-center justify-center hover:bg-opacity-40 cursor-pointer" @click="playClick">
<span class="material-icons text-2xl">play_arrow</span>
</div>
</div>
</div>
<div class="flex-grow overflow-hidden max-w-48 md:max-w-md h-full flex items-center px-2 md:px-3">
<div>
<div class="truncate max-w-48 md:max-w-md">
<nuxt-link :to="`/item/${libraryItem.id}`" class="truncate hover:underline text-sm md:text-base">{{ itemTitle }}</nuxt-link>
</div>
<div class="truncate max-w-48 md:max-w-md text-xs md:text-sm text-gray-300">
<template v-for="(author, index) in bookAuthors">
<nuxt-link :key="author.id" :to="`/author/${author.id}`" class="truncate hover:underline">{{ author.name }}</nuxt-link
><span :key="author.id + '-comma'" v-if="index < bookAuthors.length - 1">,&nbsp;</span>
</template>
<nuxt-link v-if="episode" :to="`/item/${libraryItem.id}`" class="truncate hover:underline">{{ mediaMetadata.title }}</nuxt-link>
</div>
<p class="text-xs md:text-sm text-gray-400">{{ itemDuration }}</p>
</div>
</div>
</div>
<div class="w-40 absolute top-0 -right-24 h-full transform transition-transform" :class="!isHovering ? 'translate-x-0' : translateDistance">
<div class="flex h-full items-center">
<ui-tooltip :text="userIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="top">
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
</ui-tooltip>
<div v-if="userCanUpdate" class="mx-1" :class="isHovering ? '' : 'ml-6'">
<ui-icon-btn icon="edit" borderless @click="clickEdit" />
</div>
<div v-if="userCanDelete" class="mx-1">
<ui-icon-btn icon="close" borderless @click="removeClick" />
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
playlistId: String,
item: {
type: Object,
default: () => {}
},
isDragging: Boolean,
bookCoverAspectRatio: Number
},
data() {
return {
isProcessingReadUpdate: false,
processingRemove: false,
isHovering: false
}
},
watch: {
isDragging: {
handler(newVal) {
if (newVal) {
this.isHovering = false
}
}
}
},
computed: {
translateDistance() {
if (!this.userCanUpdate && !this.userCanDelete) return 'translate-x-0'
else if (!this.userCanUpdate || !this.userCanDelete) return '-translate-x-12'
return '-translate-x-24'
},
libraryItem() {
return this.item.libraryItem || {}
},
episode() {
return this.item.episode
},
episodeId() {
return this.episode ? this.episode.id : null
},
media() {
return this.libraryItem.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
tracks() {
if (this.episode) return []
return this.media.tracks || []
},
itemTitle() {
if (this.episode) return this.episode.title
return this.mediaMetadata.title || ''
},
bookAuthors() {
if (this.episode) return []
return this.mediaMetadata.authors || []
},
itemDuration() {
if (this.episode) return this.$elapsedPretty(this.episode.duration)
return this.$elapsedPretty(this.media.duration)
},
isMissing() {
return this.libraryItem.isMissing
},
isInvalid() {
return this.libraryItem.isInvalid
},
isStreaming() {
return this.$store.getters['getIsMediaStreaming'](this.libraryItem.id, this.episodeId)
},
showPlayBtn() {
return !this.isMissing && !this.isInvalid && !this.isStreaming && (this.tracks.length || this.episode)
},
itemProgress() {
return this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, this.episodeId)
},
userIsFinished() {
return this.itemProgress ? !!this.itemProgress.isFinished : false
},
coverSize() {
return this.$store.state.globals.isMobile ? 30 : 50
},
coverWidth() {
if (this.bookCoverAspectRatio === 1) return this.coverSize * 1.6
return this.coverSize
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
}
},
methods: {
mouseover() {
if (this.isDragging) return
this.isHovering = true
},
mouseleave() {
this.isHovering = false
},
playClick() {
let queueItem = null
if (this.episode) {
queueItem = {
libraryItemId: this.libraryItem.id,
libraryId: this.libraryItem.libraryId,
episodeId: this.episodeId,
title: this.itemTitle,
subtitle: this.mediaMetadata.title,
caption: '',
duration: this.media.duration || null,
coverPath: this.media.coverPath || null
}
} else {
queueItem = {
libraryItemId: this.libraryItem.id,
libraryId: this.libraryItem.libraryId,
episodeId: null,
title: this.itemTitle,
subtitle: this.bookAuthors.map((au) => au.name).join(', '),
caption: '',
duration: this.media.duration || null,
coverPath: this.media.coverPath || null
}
}
this.$eventBus.$emit('play-item', {
libraryItemId: this.libraryItem.id,
episodeId: this.episodeId,
queueItems: [queueItem]
})
},
clickEdit() {
this.$emit('edit', this.item)
},
toggleFinished() {
var updatePayload = {
isFinished: !this.userIsFinished
}
this.isProcessingReadUpdate = true
let routepath = `/api/me/progress/${this.libraryItem.id}`
if (this.episodeId) routepath += `/${this.episodeId}`
this.$axios
.$patch(routepath, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
this.$toast.success(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedSuccess : this.$strings.ToastItemMarkedAsNotFinishedSuccess)
})
.catch((error) => {
console.error('Failed', error)
this.isProcessingReadUpdate = false
this.$toast.error(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedFailed : this.$strings.ToastItemMarkedAsNotFinishedFailed)
})
},
removeClick() {
this.processingRemove = true
let routepath = `/api/playlists/${this.playlistId}/item/${this.libraryItem.id}`
if (this.episodeId) routepath += `/${this.episodeId}`
this.$axios
.$delete(routepath)
.then((updatedPlaylist) => {
if (!updatedPlaylist.items.length) {
console.log(`All items removed so playlist was removed`, updatedPlaylist)
this.$toast.success(this.$strings.ToastPlaylistRemoveSuccess)
} else {
console.log(`Item removed from playlist`, updatedPlaylist)
this.$toast.success('Item removed from playlist')
}
})
.catch((error) => {
console.error('Failed to remove item from playlist', error)
this.$toast.error('Failed to remove item from playlist')
})
.finally(() => {
this.processingRemove = false
})
}
},
mounted() {}
}
</script>
@@ -16,18 +16,26 @@
<div class="flex items-center pt-2"> <div class="flex items-center pt-2">
<button class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer focus:outline-none" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick"> <button class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer focus:outline-none" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick">
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span> <span class="material-icons text-2xl" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
<p class="pl-2 pr-1 text-sm font-semibold">{{ timeRemaining }}</p> <p class="pl-2 pr-1 text-sm font-semibold">{{ timeRemaining }}</p>
</button> </button>
<button v-if="libraryItemIdStreaming && !isStreamingFromDifferentLibrary" class="h-8 w-8 flex justify-center items-center mx-2" :class="isQueued ? 'text-success' : ''" @click.stop="queueBtnClick"> <!-- <button v-if="libraryItemIdStreaming && !isStreamingFromDifferentLibrary" class="h-8 w-8 flex justify-center items-center mx-2" :class="isQueued ? 'text-success' : ''" @click.stop="queueBtnClick">
<span class="material-icons-outlined">{{ isQueued ? 'playlist_add_check' : 'playlist_add' }}</span> <span class="material-icons-outlined">{{ isQueued ? 'playlist_add_check' : 'queue' }}</span>
</button> </button> -->
<ui-tooltip v-if="libraryItemIdStreaming && !isStreamingFromDifferentLibrary" :text="isQueued ? $strings.MessageRemoveFromPlayerQueue : $strings.MessageAddToPlayerQueue" :class="isQueued ? 'text-success' : ''" direction="top">
<ui-icon-btn :icon="isQueued ? 'playlist_add_check' : 'playlist_play'" borderless @click="queueBtnClick" />
</ui-tooltip>
<ui-tooltip :text="userIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="top"> <ui-tooltip :text="userIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="top">
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" /> <ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
</ui-tooltip> </ui-tooltip>
<ui-tooltip :text="$strings.LabelYourPlaylists" direction="top">
<ui-icon-btn icon="playlist_add" borderless @click="clickAddToPlaylist" />
</ui-tooltip>
<ui-icon-btn v-if="userCanUpdate" icon="edit" borderless @click="clickEdit" /> <ui-icon-btn v-if="userCanUpdate" icon="edit" borderless @click="clickEdit" />
<ui-icon-btn v-if="userCanDelete" icon="close" borderless @click="removeClick" /> <ui-icon-btn v-if="userCanDelete" icon="close" borderless @click="removeClick" />
</div> </div>
@@ -123,6 +131,9 @@ export default {
} }
}, },
methods: { methods: {
clickAddToPlaylist() {
this.$emit('addToPlaylist', this.episode)
},
clickedEpisode() { clickedEpisode() {
this.$emit('view', this.episode) this.$emit('view', this.episode)
}, },
@@ -17,7 +17,7 @@
</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>
<template v-for="episode in episodesSorted"> <template v-for="episode in episodesSorted">
<tables-podcast-episode-table-row ref="episodeRow" :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" :selection-mode="isSelectionMode" class="item" @play="playEpisode" @remove="removeEpisode" @edit="editEpisode" @view="viewEpisode" @selected="episodeSelected" @addToQueue="addEpisodeToQueue" /> <tables-podcast-episode-table-row ref="episodeRow" :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" :selection-mode="isSelectionMode" class="item" @play="playEpisode" @remove="removeEpisode" @edit="editEpisode" @view="viewEpisode" @selected="episodeSelected" @addToQueue="addEpisodeToQueue" @addToPlaylist="addToPlaylist" />
</template> </template>
<modals-podcast-remove-episode v-model="showPodcastRemoveModal" @input="removeEpisodeModalToggled" :library-item="libraryItem" :episodes="episodesToRemove" @clearSelected="clearSelected" /> <modals-podcast-remove-episode v-model="showPodcastRemoveModal" @input="removeEpisodeModalToggled" :library-item="libraryItem" :episodes="episodesToRemove" @clearSelected="clearSelected" />
@@ -131,6 +131,10 @@ export default {
} }
}, },
methods: { methods: {
addToPlaylist(episode) {
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode }])
this.$store.commit('globals/setShowPlaylistsModal', true)
},
addEpisodeToQueue(episode) { addEpisodeToQueue(episode) {
const queueItem = { const queueItem = {
libraryItemId: this.libraryItem.id, libraryItemId: this.libraryItem.id,
-2
View File
@@ -2,7 +2,6 @@
<nuxt-link v-if="to" :to="to" class="btn outline-none rounded-md shadow-md relative border border-gray-600 text-center" :disabled="disabled || loading" :class="classList"> <nuxt-link v-if="to" :to="to" class="btn outline-none rounded-md shadow-md relative border border-gray-600 text-center" :disabled="disabled || loading" :class="classList">
<slot /> <slot />
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100"> <div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<!-- <span class="material-icons animate-spin">refresh</span> -->
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24"> <svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" /> <path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg> </svg>
@@ -11,7 +10,6 @@
<button v-else class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @mousedown.prevent @click="click"> <button v-else class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @mousedown.prevent @click="click">
<slot /> <slot />
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100"> <div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<!-- <span class="material-icons animate-spin">refresh</span> -->
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24"> <svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" /> <path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg> </svg>
+1 -1
View File
@@ -8,7 +8,7 @@
<span v-if="selectedSubtext" class="font-normal block truncate font-sans text-sm text-gray-400">{{ selectedSubtext }}</span> <span v-if="selectedSubtext" class="font-normal block truncate font-sans text-sm text-gray-400">{{ selectedSubtext }}</span>
</span> </span>
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none"> <span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<span class="material-icons">expand_more</span> <span class="material-icons text-2xl">expand_more</span>
</span> </span>
</button> </button>
+1 -1
View File
@@ -5,7 +5,7 @@
<span class="block truncate">{{ label }}</span> <span class="block truncate">{{ label }}</span>
</span> </span>
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none"> <span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<span class="material-icons text-gray-100" aria-label="User Account" role="button">person</span> <span class="material-icons text-2xl text-gray-100" aria-label="User Account" role="button">person</span>
</span> </span>
</button> </button>
@@ -6,7 +6,7 @@
<div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-0.5" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent> <div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-0.5" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
<div v-for="item in selected" :key="item.id" class="rounded-full px-2 py-0.5 m-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center justify-center relative min-w-12"> <div v-for="item in selected" :key="item.id" class="rounded-full px-2 py-0.5 m-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center justify-center relative min-w-12">
<div v-if="!disabled" class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer"> <div v-if="!disabled" class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer">
<span v-if="showEdit" class="material-icons text-white hover:text-warning mr-1" style="font-size: 1rem" @click.stop="editItem(item)">edit</span> <span v-if="showEdit" class="material-icons text-base text-white hover:text-warning mr-1" @click.stop="editItem(item)">edit</span>
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item.id)">close</span> <span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item.id)">close</span>
</div> </div>
{{ item[textKey] }} {{ item[textKey] }}
+12 -3
View File
@@ -21,7 +21,8 @@ export default {
return { return {
tooltip: null, tooltip: null,
tooltipId: null, tooltipId: null,
isShowing: false isShowing: false,
hideTimeout: null
} }
}, },
watch: { watch: {
@@ -46,10 +47,12 @@ export default {
var tooltip = document.createElement('div') var tooltip = document.createElement('div')
this.tooltipId = String(Math.floor(Math.random() * 10000)) this.tooltipId = String(Math.floor(Math.random() * 10000))
tooltip.id = this.tooltipId tooltip.id = this.tooltipId
tooltip.className = 'tooltip-wrapper absolute px-2 py-1 text-white pointer-events-none text-xs rounded shadow-lg max-w-xs text-center hidden sm:block' tooltip.className = 'tooltip-wrapper absolute px-2 py-1 text-white text-xs rounded shadow-lg max-w-xs text-center hidden sm:block'
tooltip.style.zIndex = 100 tooltip.style.zIndex = 100
tooltip.style.backgroundColor = 'rgba(0,0,0,0.85)' tooltip.style.backgroundColor = 'rgba(0,0,0,0.85)'
tooltip.innerHTML = this.text tooltip.innerHTML = this.text
tooltip.addEventListener('mouseover', this.cancelHide);
tooltip.addEventListener('mouseleave', this.hideTooltip);
this.setTooltipPosition(tooltip) this.setTooltipPosition(tooltip)
@@ -95,6 +98,7 @@ export default {
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} }
this.isShowing = true this.isShowing = true
}, },
hideTooltip() { hideTooltip() {
@@ -102,11 +106,16 @@ export default {
this.tooltip.remove() this.tooltip.remove()
this.isShowing = false this.isShowing = false
}, },
cancelHide() {
if (this.hideTimeout) clearTimeout(this.hideTimeout);
},
mouseover() { mouseover() {
if (!this.isShowing) this.showTooltip() if (!this.isShowing) this.showTooltip()
}, },
mouseleave() { mouseleave() {
if (this.isShowing) this.hideTooltip() if (this.isShowing) {
this.hideTimeout = setTimeout(this.hideTooltip, 100)
}
} }
}, },
beforeDestroy() { beforeDestroy() {
+1 -1
View File
@@ -61,7 +61,7 @@ export default {
return Math.floor(this.clientWidth / (this.cardWidth + 16)) return Math.floor(this.clientWidth / (this.cardWidth + 16))
}, },
isSelectionMode() { isSelectionMode() {
return this.$store.getters['getNumLibraryItemsSelected'] > 0 return this.$store.getters['globals/getIsBatchSelectingMediaItems']
} }
}, },
methods: { methods: {
@@ -101,35 +101,35 @@ export default {
intervalOptions() { intervalOptions() {
return [ return [
{ {
text: 'Custom daily/weekly', text: this.$strings.LabelIntervalCustomDailyWeekly,
value: 'custom' value: 'custom'
}, },
{ {
text: 'Every day', text: this.$strings.LabelIntervalEveryDay,
value: 'daily' value: 'daily'
}, },
{ {
text: 'Every 12 hours', text: this.$strings.LabelIntervalEvery12Hours,
value: '0 */12 * * *' value: '0 */12 * * *'
}, },
{ {
text: 'Every 6 hours', text: this.$strings.LabelIntervalEvery6Hours,
value: '0 */6 * * *' value: '0 */6 * * *'
}, },
{ {
text: 'Every 2 hours', text: this.$strings.LabelIntervalEvery2Hours,
value: '0 */2 * * *' value: '0 */2 * * *'
}, },
{ {
text: 'Every hour', text: this.$strings.LabelIntervalEveryHour,
value: '0 * * * *' value: '0 * * * *'
}, },
{ {
text: 'Every 30 minutes', text: this.$strings.LabelIntervalEvery30Minutes,
value: '*/30 * * * *' value: '*/30 * * * *'
}, },
{ {
text: 'Every 15 minutes', text: this.$strings.LabelIntervalEvery15Minutes,
value: '*/15 * * * *' value: '*/15 * * * *'
} }
] ]
+4 -4
View File
@@ -77,7 +77,7 @@ export default {
return Math.floor(this.clientWidth / (this.cardWidth + 16)) return Math.floor(this.clientWidth / (this.cardWidth + 16))
}, },
isSelectionMode() { isSelectionMode() {
return this.$store.getters['getNumLibraryItemsSelected'] > 0 return this.$store.getters['globals/getIsBatchSelectingMediaItems']
} }
}, },
methods: { methods: {
@@ -101,14 +101,14 @@ export default {
this.updateSelectionMode(this.isSelectionMode) this.updateSelectionMode(this.isSelectionMode)
}, },
updateSelectionMode(val) { updateSelectionMode(val) {
var selectedLibraryItems = this.$store.state.selectedLibraryItems const selectedMediaItems = this.$store.state.globals.selectedMediaItems
this.items.forEach((ent) => { this.items.forEach((ent) => {
var component = this.$refs[`slider-episode-${ent.recentEpisode.id}`] let component = this.$refs[`slider-episode-${ent.recentEpisode.id}`]
if (!component || !component.length) return if (!component || !component.length) return
component = component[0] component = component[0]
component.setSelectionMode(val) component.setSelectionMode(val)
component.selected = selectedLibraryItems.includes(ent.id) component.selected = selectedMediaItems.some((i) => i.id === ent.id)
}) })
}, },
scrolled() { scrolled() {
+4 -4
View File
@@ -63,7 +63,7 @@ export default {
return Math.floor(this.clientWidth / (this.cardWidth + 16)) return Math.floor(this.clientWidth / (this.cardWidth + 16))
}, },
isSelectionMode() { isSelectionMode() {
return this.$store.getters['getNumLibraryItemsSelected'] > 0 return this.$store.getters['globals/getIsBatchSelectingMediaItems']
} }
}, },
methods: { methods: {
@@ -82,14 +82,14 @@ export default {
this.updateSelectionMode(this.isSelectionMode) this.updateSelectionMode(this.isSelectionMode)
}, },
updateSelectionMode(val) { updateSelectionMode(val) {
var selectedLibraryItems = this.$store.state.selectedLibraryItems const selectedMediaItems = this.$store.state.globals.selectedMediaItems
this.items.forEach((item) => { this.items.forEach((item) => {
var component = this.$refs[`slider-item-${item.id}`] let component = this.$refs[`slider-item-${item.id}`]
if (!component || !component.length) return if (!component || !component.length) return
component = component[0] component = component[0]
component.setSelectionMode(val) component.setSelectionMode(val)
component.selected = selectedLibraryItems.includes(item.id) component.selected = selectedMediaItems.some((i) => i.id === item.id)
}) })
}, },
scrolled() { scrolled() {
+1 -1
View File
@@ -61,7 +61,7 @@ export default {
return Math.floor(this.clientWidth / (this.cardWidth + 16)) return Math.floor(this.clientWidth / (this.cardWidth + 16))
}, },
isSelectionMode() { isSelectionMode() {
return this.$store.getters['getNumLibraryItemsSelected'] > 0 return this.$store.getters['globals/getIsBatchSelectingMediaItems']
} }
}, },
methods: { methods: {
+52 -21
View File
@@ -11,7 +11,9 @@
<modals-item-edit-modal /> <modals-item-edit-modal />
<modals-collections-add-create-modal /> <modals-collections-add-create-modal />
<modals-edit-collection-modal /> <modals-collections-edit-modal />
<modals-playlists-add-create-modal />
<modals-playlists-edit-modal />
<modals-podcast-edit-episode /> <modals-podcast-edit-episode />
<modals-podcast-view-episode /> <modals-podcast-view-episode />
<modals-authors-edit-modal /> <modals-authors-edit-modal />
@@ -40,9 +42,8 @@ export default {
if (this.$store.state.showEditModal) { if (this.$store.state.showEditModal) {
this.$store.commit('setShowEditModal', false) this.$store.commit('setShowEditModal', false)
} }
if (this.$store.state.selectedLibraryItems) {
this.$store.commit('setSelectedLibraryItems', []) this.$store.commit('globals/resetSelectedMediaItems', [])
}
this.updateBodyClass() this.updateBodyClass()
} }
}, },
@@ -53,9 +54,12 @@ export default {
isCasting() { isCasting() {
return this.$store.state.globals.isCasting return this.$store.state.globals.isCasting
}, },
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
isShowingSideRail() { isShowingSideRail() {
if (!this.$route.name) return false if (!this.$route.name) return false
return !this.$route.name.startsWith('config') && this.$store.state.libraries.currentLibraryId return !this.$route.name.startsWith('config') && this.currentLibraryId
}, },
isShowingToolbar() { isShowingToolbar() {
return this.isShowingSideRail && this.$route.name !== 'upload' && this.$route.name !== 'account' return this.isShowingSideRail && this.$route.name !== 'upload' && this.$route.name !== 'account'
@@ -132,14 +136,8 @@ export default {
} }
}) })
if (payload.backups && payload.backups.length) {
this.$store.commit('setBackups', payload.backups)
}
if (payload.usersOnline) { if (payload.usersOnline) {
this.$store.commit('users/resetUsers') this.$store.commit('users/setUsersOnline', payload.usersOnline)
payload.usersOnline.forEach((user) => {
this.$store.commit('users/updateUser', user)
})
} }
this.$eventBus.$emit('socket_init') this.$eventBus.$emit('socket_init')
@@ -174,7 +172,7 @@ export default {
this.$store.commit('libraries/remove', library) this.$store.commit('libraries/remove', library)
// When removed currently selected library then set next accessible library // When removed currently selected library then set next accessible library
const currLibraryId = this.$store.state.libraries.currentLibraryId const currLibraryId = this.currentLibraryId
if (currLibraryId === library.id) { if (currLibraryId === library.id) {
var nextLibrary = this.$store.getters['libraries/getNextAccessibleLibrary'] var nextLibrary = this.$store.getters['libraries/getNextAccessibleLibrary']
if (nextLibrary) { if (nextLibrary) {
@@ -213,7 +211,7 @@ export default {
libraryItemRemoved(item) { libraryItemRemoved(item) {
if (this.$route.name.startsWith('item')) { if (this.$route.name.startsWith('item')) {
if (this.$route.params.id === item.id) { if (this.$route.params.id === item.id) {
this.$router.replace(`/library/${this.$store.state.libraries.currentLibraryId}`) this.$router.replace(`/library/${this.currentLibraryId}`)
} }
} }
}, },
@@ -286,31 +284,52 @@ export default {
} }
}, },
userOnline(user) { userOnline(user) {
this.$store.commit('users/updateUser', user) this.$store.commit('users/updateUserOnline', user)
}, },
userOffline(user) { userOffline(user) {
this.$store.commit('users/removeUser', user) this.$store.commit('users/removeUserOnline', user)
}, },
userStreamUpdate(user) { userStreamUpdate(user) {
this.$store.commit('users/updateUser', user) this.$store.commit('users/updateUserOnline', user)
}, },
userMediaProgressUpdate(payload) { userMediaProgressUpdate(payload) {
this.$store.commit('user/updateMediaProgress', payload) this.$store.commit('user/updateMediaProgress', payload)
}, },
collectionAdded(collection) { collectionAdded(collection) {
if (this.currentLibraryId !== collection.libraryId) return
this.$store.commit('libraries/addUpdateCollection', collection) this.$store.commit('libraries/addUpdateCollection', collection)
}, },
collectionUpdated(collection) { collectionUpdated(collection) {
if (this.currentLibraryId !== collection.libraryId) return
this.$store.commit('libraries/addUpdateCollection', collection) this.$store.commit('libraries/addUpdateCollection', collection)
}, },
collectionRemoved(collection) { collectionRemoved(collection) {
if (this.currentLibraryId !== collection.libraryId) return
if (this.$route.name.startsWith('collection')) { if (this.$route.name.startsWith('collection')) {
if (this.$route.params.id === collection.id) { if (this.$route.params.id === collection.id) {
this.$router.replace(`/library/${this.$store.state.libraries.currentLibraryId}/bookshelf/collections`) this.$router.replace(`/library/${this.currentLibraryId}/bookshelf/collections`)
} }
} }
this.$store.commit('libraries/removeCollection', collection) this.$store.commit('libraries/removeCollection', collection)
}, },
playlistAdded(playlist) {
if (playlist.userId !== this.user.id || this.currentLibraryId !== playlist.libraryId) return
this.$store.commit('libraries/addUpdateUserPlaylist', playlist)
},
playlistUpdated(playlist) {
if (playlist.userId !== this.user.id || this.currentLibraryId !== playlist.libraryId) return
this.$store.commit('libraries/addUpdateUserPlaylist', playlist)
},
playlistRemoved(playlist) {
if (playlist.userId !== this.user.id || this.currentLibraryId !== playlist.libraryId) return
if (this.$route.name.startsWith('playlist')) {
if (this.$route.params.id === playlist.id) {
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf/playlists`)
}
}
this.$store.commit('libraries/removeUserPlaylist', playlist)
},
rssFeedOpen(data) { rssFeedOpen(data) {
this.$store.commit('feeds/addFeed', data) this.$store.commit('feeds/addFeed', data)
}, },
@@ -333,6 +352,9 @@ export default {
this.$toast.info(toast) this.$toast.info(toast)
} }
}, },
adminMessageEvt(message) {
this.$toast.info(message)
},
initializeSocket() { initializeSocket() {
this.socket = this.$nuxtSocket({ this.socket = this.$nuxtSocket({
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod', name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
@@ -345,6 +367,7 @@ export default {
this.$root.socket = this.socket this.$root.socket = this.socket
console.log('Socket initialized') console.log('Socket initialized')
// Pre-defined socket events
this.socket.on('connect', this.connect) this.socket.on('connect', this.connect)
this.socket.on('connect_error', this.connectError) this.socket.on('connect_error', this.connectError)
this.socket.on('disconnect', this.disconnect) this.socket.on('disconnect', this.disconnect)
@@ -353,6 +376,7 @@ export default {
this.socket.io.on('reconnect_error', this.reconnectError) this.socket.io.on('reconnect_error', this.reconnectError)
this.socket.io.on('reconnect_failed', this.reconnectFailed) this.socket.io.on('reconnect_failed', this.reconnectFailed)
// Event received after authorizing socket
this.socket.on('init', this.init) this.socket.on('init', this.init)
// Stream Listeners // Stream Listeners
@@ -382,11 +406,16 @@ export default {
this.socket.on('user_stream_update', this.userStreamUpdate) this.socket.on('user_stream_update', this.userStreamUpdate)
this.socket.on('user_item_progress_updated', this.userMediaProgressUpdate) this.socket.on('user_item_progress_updated', this.userMediaProgressUpdate)
// User Collection Listeners // Collection Listeners
this.socket.on('collection_added', this.collectionAdded) this.socket.on('collection_added', this.collectionAdded)
this.socket.on('collection_updated', this.collectionUpdated) this.socket.on('collection_updated', this.collectionUpdated)
this.socket.on('collection_removed', this.collectionRemoved) this.socket.on('collection_removed', this.collectionRemoved)
// User Playlist Listeners
this.socket.on('playlist_added', this.playlistAdded)
this.socket.on('playlist_updated', this.playlistUpdated)
this.socket.on('playlist_removed', this.playlistRemoved)
// Scan Listeners // Scan Listeners
this.socket.on('scan_start', this.scanStart) this.socket.on('scan_start', this.scanStart)
this.socket.on('scan_complete', this.scanComplete) this.socket.on('scan_complete', this.scanComplete)
@@ -403,6 +432,8 @@ export default {
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)
this.socket.on('admin_message', this.adminMessageEvt)
}, },
showUpdateToast(versionData) { showUpdateToast(versionData) {
var ignoreVersion = localStorage.getItem('ignoreVersion') var ignoreVersion = localStorage.getItem('ignoreVersion')
@@ -472,9 +503,9 @@ export default {
} }
// Batch selecting // Batch selecting
if (this.$store.getters['getNumLibraryItemsSelected'] && name === 'Escape') { if (this.$store.getters['globals/getIsBatchSelectingMediaItems'] && name === 'Escape') {
// ESCAPE key cancels batch selection // ESCAPE key cancels batch selection
this.$store.commit('setSelectedLibraryItems', []) this.$store.commit('globals/resetSelectedMediaItems', [])
this.$eventBus.$emit('bookshelf_clear_selection') this.$eventBus.$emit('bookshelf_clear_selection')
e.preventDefault() e.preventDefault()
return return
+4 -2
View File
@@ -2,6 +2,7 @@ import Vue from 'vue'
import LazyBookCard from '@/components/cards/LazyBookCard' 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'
export default { export default {
data() { data() {
@@ -15,6 +16,7 @@ export default {
getComponentClass() { getComponentClass() {
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)
return Vue.extend(LazyBookCard) return Vue.extend(LazyBookCard)
}, },
async mountEntityCard(index) { async mountEntityCard(index) {
@@ -30,7 +32,7 @@ export default {
shelfEl.appendChild(bookComponent.$el) shelfEl.appendChild(bookComponent.$el)
if (this.isSelectionMode) { if (this.isSelectionMode) {
bookComponent.setSelectionMode(true) bookComponent.setSelectionMode(true)
if (this.selectedLibraryItems.includes(bookComponent.libraryItemId) || this.isSelectAll) { if (this.selectedMediaItems.some(i => i.id === bookComponent.libraryItemId) || this.isSelectAll) {
bookComponent.selected = true bookComponent.selected = true
} else { } else {
bookComponent.selected = false bookComponent.selected = false
@@ -87,7 +89,7 @@ export default {
} }
if (this.isSelectionMode) { if (this.isSelectionMode) {
instance.setSelectionMode(true) instance.setSelectionMode(true)
if (instance.libraryItemId && this.selectedLibraryItems.includes(instance.libraryItemId) || this.isSelectAll) { if (instance.libraryItemId && this.selectedMediaItems.some(i => i.id === instance.libraryItemId) || this.isSelectAll) {
instance.selected = true instance.selected = true
} }
} }
+2 -1
View File
@@ -118,7 +118,8 @@ module.exports = {
] ]
}, },
workbox: { workbox: {
enabled: false, preCaching: [],
runtimeCaching: []
} }
}, },
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.2.5", "version": "2.2.7",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.2.5", "version": "2.2.7",
"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.5", "version": "2.2.7",
"description": "Self-hosted audiobook and podcast client", "description": "Self-hosted audiobook and podcast client",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
+2
View File
@@ -85,6 +85,8 @@ export default {
if (localStorage.getItem('token')) { if (localStorage.getItem('token')) {
localStorage.removeItem('token') localStorage.removeItem('token')
} }
this.$store.commit('libraries/setUserPlaylists', [])
this.$store.commit('libraries/setCollections', [])
this.$router.push('/login') this.$router.push('/login')
}, },
resetForm() { resetForm() {
+117 -53
View File
@@ -1,37 +1,40 @@
<template> <template>
<div id="page-wrapper" class="bg-bg page overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''"> <div id="page-wrapper" class="bg-bg page overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
<div class="flex items-center py-4 max-w-7xl mx-auto"> <div class="flex items-center py-4 px-2 md:px-0 max-w-7xl mx-auto">
<nuxt-link :to="`/item/${libraryItem.id}`" class="hover:underline"> <nuxt-link :to="`/item/${libraryItem.id}`" class="hover:underline">
<h1 class="text-xl">{{ title }}</h1> <h1 class="text-lg lg:text-xl">{{ title }}</h1>
</nuxt-link> </nuxt-link>
<button class="w-7 h-7 flex items-center justify-center mx-4 hover:scale-110 duration-100 transform text-gray-200 hover:text-white" @click="editItem"> <button class="w-7 h-7 flex items-center justify-center mx-4 hover:scale-110 duration-100 transform text-gray-200 hover:text-white" @click="editItem">
<span class="material-icons text-base">edit</span> <span class="material-icons text-base">edit</span>
</button> </button>
<div class="flex-grow" /> <div class="flex-grow hidden md:block" />
<p class="text-base">{{ $strings.LabelDuration }}:</p> <p class="text-base hidden md:block">{{ $strings.LabelDuration }}:</p>
<p class="text-base font-mono ml-8">{{ $secondsToTimestamp(mediaDurationRounded) }}</p> <p class="text-base font-mono ml-4 hidden md:block">{{ $secondsToTimestamp(mediaDurationRounded) }}</p>
</div> </div>
<div class="flex flex-wrap-reverse justify-center py-4"> <div class="flex flex-wrap-reverse justify-center py-4 px-2">
<div class="w-full max-w-3xl py-4"> <div class="w-full max-w-3xl py-4">
<div class="flex items-center"> <div class="flex items-center">
<div class="w-12 hidden lg:block" />
<p class="text-lg mb-4 font-semibold">{{ $strings.HeaderChapters }}</p> <p class="text-lg mb-4 font-semibold">{{ $strings.HeaderChapters }}</p>
<div class="flex-grow" /> <div class="flex-grow" />
<ui-checkbox v-model="showSecondInputs" checkbox-bg="primary" small label-class="text-sm text-gray-200 pl-1" label="Show seconds" class="mx-2" /> <ui-checkbox v-model="showSecondInputs" checkbox-bg="primary" small label-class="text-sm text-gray-200 pl-1" label="Show seconds" class="mx-2" />
<div class="w-40" /> <div class="w-32 hidden lg:block" />
</div> </div>
<div class="flex items-center mb-3 py-1"> <div class="flex items-center mb-3 py-1">
<div class="flex-grow" /> <div class="w-12 hidden lg:block" />
<ui-btn v-if="newChapters.length > 1" :color="showShiftTimes ? 'bg' : 'primary'" small @click="showShiftTimes = !showShiftTimes">{{ $strings.ButtonShiftTimes }}</ui-btn> <ui-btn v-if="newChapters.length > 1" :color="showShiftTimes ? 'bg' : 'primary'" small @click="showShiftTimes = !showShiftTimes">{{ $strings.ButtonShiftTimes }}</ui-btn>
<ui-btn color="primary" small class="mx-2" @click="showFindChaptersModal = true">{{ $strings.ButtonLookup }}</ui-btn> <ui-btn color="primary" small class="mx-2" @click="showFindChaptersModal = true">{{ $strings.ButtonLookup }}</ui-btn>
<ui-btn color="success" small @click="saveChapters">{{ $strings.ButtonSave }}</ui-btn> <div class="flex-grow" />
<div class="w-40" /> <ui-btn v-if="hasChanges" small class="mx-2" @click.stop="resetChapters">{{ $strings.ButtonReset }}</ui-btn>
<ui-btn v-if="hasChanges" color="success" :disabled="!hasChanges" small @click="saveChapters">{{ $strings.ButtonSave }}</ui-btn>
<div class="w-32 hidden lg:block" />
</div> </div>
<div class="overflow-hidden"> <div class="overflow-hidden">
<transition name="slide"> <transition name="slide">
<div v-if="showShiftTimes" class="flex mb-4"> <div v-if="showShiftTimes" class="flex mb-4">
<div class="w-12"></div> <div class="w-12 hidden lg:block" />
<div class="flex-grow"> <div class="flex-grow">
<div class="flex items-center"> <div class="flex items-center">
<p class="text-sm mb-1 font-semibold pr-2">{{ $strings.LabelTimeToShift }}</p> <p class="text-sm mb-1 font-semibold pr-2">{{ $strings.LabelTimeToShift }}</p>
@@ -42,32 +45,34 @@
</div> </div>
<p class="text-xs py-1.5 text-gray-300 max-w-md">{{ $strings.NoteChapterEditorTimes }}</p> <p class="text-xs py-1.5 text-gray-300 max-w-md">{{ $strings.NoteChapterEditorTimes }}</p>
</div> </div>
<div class="w-40"></div> <div class="w-32 hidden lg:block" />
</div> </div>
</transition> </transition>
</div> </div>
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2"> <div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
<div class="w-12"></div> <div class="w-8 min-w-8 md:w-12 md:min-w-12"></div>
<div class="w-32 px-2">{{ $strings.LabelStart }}</div> <div class="w-24 min-w-24 md:w-32 md:min-w-32 px-2">{{ $strings.LabelStart }}</div>
<div class="flex-grow px-2">{{ $strings.LabelTitle }}</div> <div class="flex-grow px-2">{{ $strings.LabelTitle }}</div>
<div class="w-40"></div> <div class="w-32"></div>
</div> </div>
<template v-for="chapter in newChapters"> <template v-for="chapter in newChapters">
<div :key="chapter.id" class="flex py-1"> <div :key="chapter.id" class="flex py-1">
<div class="w-12">#{{ chapter.id + 1 }}</div> <div class="w-8 min-w-8 md:w-12 md:min-w-12">#{{ chapter.id + 1 }}</div>
<div class="w-32 px-1"> <div class="w-24 min-w-24 md:w-32 md:min-w-32 px-1">
<ui-text-input v-if="showSecondInputs" v-model="chapter.start" type="number" class="text-xs" @change="checkChapters" /> <ui-text-input v-if="showSecondInputs" v-model="chapter.start" type="number" class="text-xs" @change="checkChapters" />
<ui-time-picker v-else class="text-xs" v-model="chapter.start" :show-three-digit-hour="mediaDuration >= 360000" @change="checkChapters" /> <ui-time-picker v-else class="text-xs" v-model="chapter.start" :show-three-digit-hour="mediaDuration >= 360000" @change="checkChapters" />
</div> </div>
<div class="flex-grow px-1"> <div class="flex-grow px-1">
<ui-text-input v-model="chapter.title" class="text-xs" /> <ui-text-input v-model="chapter.title" @change="checkChapters" class="text-xs" />
</div> </div>
<div class="w-40 px-2 py-1"> <div class="w-32 min-w-32 px-2 py-1">
<div class="flex items-center"> <div class="flex items-center">
<button v-if="newChapters.length > 1" class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-error transform hover:scale-110 duration-150" @click="removeChapter(chapter)"> <ui-tooltip :text="$strings.MessageRemoveChapter" direction="bottom">
<span class="material-icons-outlined text-base">remove</span> <button v-if="newChapters.length > 1" class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-error transform hover:scale-110 duration-150" @click="removeChapter(chapter)">
</button> <span class="material-icons-outlined text-base">remove</span>
</button>
</ui-tooltip>
<ui-tooltip :text="$strings.MessageInsertChapterBelow" direction="bottom"> <ui-tooltip :text="$strings.MessageInsertChapterBelow" direction="bottom">
<button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-success transform hover:scale-110 duration-150" @click="addChapter(chapter)"> <button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-success transform hover:scale-110 duration-150" @click="addChapter(chapter)">
@@ -75,11 +80,13 @@
</button> </button>
</ui-tooltip> </ui-tooltip>
<button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-white transform hover:scale-110 duration-150" @click="playChapter(chapter)"> <ui-tooltip :text="selectedChapterId === chapter.id && isPlayingChapter ? $strings.MessagePauseChapter : $strings.MessagePlayChapter" direction="bottom">
<widgets-loading-spinner v-if="selectedChapterId === chapter.id && isLoadingChapter" /> <button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-white transform hover:scale-110 duration-150" @click="playChapter(chapter)">
<span v-else-if="selectedChapterId === chapter.id && isPlayingChapter" class="material-icons-outlined text-base">pause</span> <widgets-loading-spinner v-if="selectedChapterId === chapter.id && isLoadingChapter" />
<span v-else class="material-icons-outlined text-base">play_arrow</span> <span v-else-if="selectedChapterId === chapter.id && isPlayingChapter" class="material-icons-outlined text-base">pause</span>
</button> <span v-else class="material-icons-outlined text-base">play_arrow</span>
</button>
</ui-tooltip>
<ui-tooltip v-if="chapter.error" :text="chapter.error" direction="left"> <ui-tooltip v-if="chapter.error" :text="chapter.error" direction="left">
<button class="w-7 h-7 rounded-full flex items-center justify-center text-error"> <button class="w-7 h-7 rounded-full flex items-center justify-center text-error">
@@ -92,8 +99,15 @@
</template> </template>
</div> </div>
<div class="w-full max-w-xl py-4"> <div class="w-full max-w-xl py-4 px-2">
<p class="text-lg mb-4 font-semibold py-1">{{ $strings.HeaderAudioTracks }}</p> <div class="flex items-center mb-4 py-1">
<p class="text-lg font-semibold">{{ $strings.HeaderAudioTracks }}</p>
<div class="flex-grow" />
<ui-btn small @click="setChaptersFromTracks">{{ $strings.ButtonSetChaptersFromTracks }}</ui-btn>
<ui-tooltip :text="$strings.MessageSetChaptersFromTracksDescription" direction="top" class="flex items-center mx-1 cursor-default">
<span class="material-icons-outlined text-xl text-gray-200">info</span>
</ui-tooltip>
</div>
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2"> <div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
<div class="flex-grow">{{ $strings.LabelFilename }}</div> <div class="flex-grow">{{ $strings.LabelFilename }}</div>
<div class="w-20">{{ $strings.LabelDuration }}</div> <div class="w-20">{{ $strings.LabelDuration }}</div>
@@ -173,8 +187,8 @@
</div> </div>
<div class="flex items-center pt-2"> <div class="flex items-center pt-2">
<ui-btn small color="primary" class="mr-1" @click="applyChapterNamesOnly">{{ $strings.ButtonMapChapterTitles }}</ui-btn> <ui-btn small color="primary" class="mr-1" @click="applyChapterNamesOnly">{{ $strings.ButtonMapChapterTitles }}</ui-btn>
<ui-tooltip :text="$strings.MessageMapChapterTitles" direction="top"> <ui-tooltip :text="$strings.MessageMapChapterTitles" direction="top" class="flex items-center">
<span class="material-icons-outlined">info</span> <span class="material-icons-outlined text-xl text-gray-200">info</span>
</ui-tooltip> </ui-tooltip>
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn small color="success" @click="applyChapterData">{{ $strings.ButtonApplyChapters }}</ui-btn> <ui-btn small color="success" @click="applyChapterData">{{ $strings.ButtonApplyChapters }}</ui-btn>
@@ -186,6 +200,8 @@
</template> </template>
<script> <script>
import path from 'path'
export default { export default {
async asyncData({ store, params, app, redirect, from }) { async asyncData({ store, params, app, redirect, from }) {
if (!store.getters['user/getUserCanUpdate']) { if (!store.getters['user/getUserCanUpdate']) {
@@ -228,7 +244,8 @@ export default {
showFindChaptersModal: false, showFindChaptersModal: false,
chapterData: null, chapterData: null,
showSecondInputs: false, showSecondInputs: false,
audibleRegions: ['US', 'CA', 'UK', 'AU', 'FR', 'DE', 'JP', 'IT', 'IN', 'ES'] audibleRegions: ['US', 'CA', 'UK', 'AU', 'FR', 'DE', 'JP', 'IT', 'IN', 'ES'],
hasChanges: false
} }
}, },
computed: { computed: {
@@ -270,6 +287,23 @@ export default {
} }
}, },
methods: { methods: {
setChaptersFromTracks() {
let currentStartTime = 0
let index = 0
const chapters = []
for (const track of this.audioTracks) {
chapters.push({
id: index++,
title: path.basename(track.metadata.filename, path.extname(track.metadata.filename)),
start: currentStartTime,
end: currentStartTime + track.duration
})
currentStartTime += track.duration
}
this.newChapters = chapters
this.checkChapters()
},
shiftChapterTimes() { shiftChapterTimes() {
if (!this.shiftAmount || isNaN(this.shiftAmount) || this.newChapters.length <= 1) { if (!this.shiftAmount || isNaN(this.shiftAmount) || this.newChapters.length <= 1) {
return return
@@ -300,7 +334,6 @@ export default {
this.$store.commit('showEditModal', this.libraryItem) this.$store.commit('showEditModal', this.libraryItem)
}, },
addChapter(chapter) { addChapter(chapter) {
console.log('Add chapter', chapter)
const newChapter = { const newChapter = {
id: chapter.id + 1, id: chapter.id + 1,
start: chapter.start, start: chapter.start,
@@ -315,22 +348,40 @@ export default {
this.checkChapters() this.checkChapters()
}, },
checkChapters() { checkChapters() {
var previousStart = 0 let previousStart = 0
let hasChanges = this.newChapters.length !== this.chapters.length
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)
if (i === 0 && this.newChapters[i].start !== 0) { if (i === 0 && this.newChapters[i].start !== 0) {
this.newChapters[i].error = 'First chapter must start at 0' this.newChapters[i].error = this.$strings.MessageChapterErrorFirstNotZero
} else if (this.newChapters[i].start <= previousStart && i > 0) { } else if (this.newChapters[i].start <= previousStart && i > 0) {
this.newChapters[i].error = 'Invalid start time must be >= previous chapter start time' this.newChapters[i].error = this.$strings.MessageChapterErrorStartLtPrev
} else if (this.newChapters[i].start >= this.mediaDuration) { } else if (this.newChapters[i].start >= this.mediaDuration) {
this.newChapters[i].error = 'Invalid start time must be < duration' this.newChapters[i].error = this.$strings.MessageChapterErrorStartGteDuration
} else { } else {
this.newChapters[i].error = null this.newChapters[i].error = null
} }
previousStart = this.newChapters[i].start previousStart = this.newChapters[i].start
if (hasChanges) {
continue
}
const existingChapter = this.chapters[i]
if (existingChapter) {
const { start, end, title } = this.newChapters[i]
if (start !== existingChapter.start || end !== existingChapter.end || title !== existingChapter.title) {
hasChanges = true
}
} else {
hasChanges = true
}
} }
this.hasChanges = hasChanges
}, },
playChapter(chapter) { playChapter(chapter) {
console.log('Play Chapter', chapter.id) console.log('Play Chapter', chapter.id)
@@ -349,8 +400,6 @@ export default {
const audioTrack = this.tracks.find((at) => { const audioTrack = this.tracks.find((at) => {
return chapter.start >= at.startOffset && chapter.start < at.startOffset + at.duration return chapter.start >= at.startOffset && chapter.start < at.startOffset + at.duration
}) })
console.log('audio track', audioTrack)
this.selectedChapter = chapter this.selectedChapter = chapter
this.isLoadingChapter = true this.isLoadingChapter = true
@@ -365,7 +414,6 @@ export default {
if (this.$isDev) { if (this.$isDev) {
src = `http://localhost:3333${this.$config.routerBasePath}${src}` src = `http://localhost:3333${this.$config.routerBasePath}${src}`
} }
console.log('src', src)
audioEl.src = src audioEl.src = src
audioEl.id = 'chapter-audio' audioEl.id = 'chapter-audio'
@@ -409,11 +457,11 @@ export default {
for (let i = 0; i < this.newChapters.length; i++) { for (let i = 0; i < this.newChapters.length; i++) {
if (this.newChapters[i].error) { if (this.newChapters[i].error) {
this.$toast.error('Chapters have errors') this.$toast.error(this.$strings.ToastChaptersHaveErrors)
return return
} }
if (!this.newChapters[i].title) { if (!this.newChapters[i].title) {
this.$toast.error('Chapters must have titles') this.$toast.error(this.$strings.ToastChaptersMustHaveTitles)
return return
} }
@@ -509,22 +557,38 @@ export default {
this.$toast.error('Failed to find chapters') this.$toast.error('Failed to find chapters')
this.showFindChaptersModal = false this.showFindChaptersModal = false
}) })
},
resetChapters() {
const payload = {
message: this.$strings.MessageResetChaptersConfirm,
callback: (confirmed) => {
if (confirmed) {
this.initChapters()
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
initChapters() {
this.newChapters = this.chapters.map((c) => ({ ...c }))
if (!this.newChapters.length) {
this.newChapters = [
{
id: 0,
start: 0,
end: this.mediaDuration,
title: ''
}
]
}
this.checkChapters()
} }
}, },
mounted() { mounted() {
this.regionInput = localStorage.getItem('audibleRegion') || 'US' this.regionInput = localStorage.getItem('audibleRegion') || 'US'
this.asinInput = this.mediaMetadata.asin || null this.asinInput = this.mediaMetadata.asin || null
this.newChapters = this.chapters.map((c) => ({ ...c })) this.initChapters()
if (!this.newChapters.length) {
this.newChapters = [
{
id: 0,
start: 0,
end: this.mediaDuration,
title: ''
}
]
}
}, },
beforeDestroy() { beforeDestroy() {
this.destroyAudioEl() this.destroyAudioEl()
+6 -4
View File
@@ -2,7 +2,7 @@
<div ref="page" id="page-wrapper" class="page px-6 pt-6 pb-52 overflow-y-auto" :class="streamLibraryItem ? 'streaming' : ''"> <div ref="page" id="page-wrapper" class="page px-6 pt-6 pb-52 overflow-y-auto" :class="streamLibraryItem ? 'streaming' : ''">
<div class="border border-white border-opacity-10 max-w-7xl mx-auto mb-10 mt-5"> <div class="border border-white border-opacity-10 max-w-7xl mx-auto mb-10 mt-5">
<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">{{ 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">Map details</p>
</div> </div>
@@ -91,11 +91,13 @@
<script> <script>
export default { export default {
async asyncData({ store, redirect, app }) { async asyncData({ store, redirect, app }) {
if (!store.state.selectedLibraryItems.length) { if (!store.state.globals.selectedMediaItems.length) {
return redirect('/') return redirect('/')
} }
var libraryItems = await app.$axios.$post(`/api/items/batch/get`, { libraryItemIds: store.state.selectedLibraryItems }).catch((error) => {
var errorMsg = error.response.data || 'Failed to get items' const libraryItemIds = store.state.globals.selectedMediaItems.map((i) => i.id)
const libraryItems = await app.$axios.$post(`/api/items/batch/get`, { libraryItemIds }).catch((error) => {
const errorMsg = error.response.data || 'Failed to get items'
console.error(errorMsg, error) console.error(errorMsg, error)
return [] return []
}) })
+14 -10
View File
@@ -15,7 +15,7 @@
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn v-if="showPlayButton" :disabled="streaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="clickPlay"> <ui-btn v-if="showPlayButton" :disabled="streaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="clickPlay">
<span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span> <span v-show="!streaming" class="material-icons text-2xl -ml-2 pr-1 text-white">play_arrow</span>
{{ streaming ? $strings.ButtonPlaying : $strings.ButtonPlay }} {{ streaming ? $strings.ButtonPlaying : $strings.ButtonPlay }}
</ui-btn> </ui-btn>
@@ -52,6 +52,11 @@ export default {
return redirect('/') return redirect('/')
} }
// If collection is a different library then set library as current
if (collection.libraryId !== store.state.libraries.currentLibraryId) {
await store.dispatch('libraries/fetch', collection.libraryId)
}
store.commit('libraries/addUpdateCollection', collection) store.commit('libraries/addUpdateCollection', collection)
return { return {
collectionId: collection.id collectionId: collection.id
@@ -59,8 +64,7 @@ export default {
}, },
data() { data() {
return { return {
processingRemove: false, processingRemove: false
collectionCopy: {}
} }
}, },
computed: { computed: {
@@ -88,7 +92,7 @@ export default {
}) })
}, },
streaming() { streaming() {
return !!this.playableBooks.find((b) => b.id === this.$store.getters['getLibraryItemIdStreaming']) return !!this.playableBooks.some((b) => b.id === this.$store.getters['getLibraryItemIdStreaming'])
}, },
showPlayButton() { showPlayButton() {
return this.playableBooks.length return this.playableBooks.length
@@ -105,19 +109,19 @@ export default {
this.$store.commit('globals/setEditCollection', this.collection) this.$store.commit('globals/setEditCollection', this.collection)
}, },
removeClick() { removeClick() {
if (confirm(`Are you sure you want to remove collection "${this.collectionName}"?`)) { if (confirm(this.$getString('MessageConfirmRemoveCollection', [this.collectionName]))) {
this.processingRemove = true this.processingRemove = true
var collectionName = this.collectionName
this.$axios this.$axios
.$delete(`/api/collections/${this.collection.id}`) .$delete(`/api/collections/${this.collection.id}`)
.then(() => { .then(() => {
this.processingRemove = false this.$toast.success(this.$strings.ToastCollectionRemoveSuccess)
this.$toast.success(`Collection "${collectionName}" Removed`)
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to remove collection', error) console.error('Failed to remove collection', error)
this.$toast.error(this.$strings.ToastCollectionRemoveFailed)
})
.finally(() => {
this.processingRemove = false this.processingRemove = false
this.$toast.error(`Failed to remove collection`)
}) })
} }
}, },
@@ -165,4 +169,4 @@ export default {
mounted() {}, mounted() {},
beforeDestroy() {} beforeDestroy() {}
} }
</script> </script>
+14 -4
View File
@@ -3,7 +3,7 @@
<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="isMobile" class="w-full pb-4 px-2 flex border-b border-white border-opacity-10 mb-2">
<span class="material-icons cursor-pointer" @click.stop.prevent="showMore">more_vert</span> <span class="material-icons text-2xl cursor-pointer" @click.stop.prevent="showMore">more_vert</span>
<p class="pl-3 capitalize">{{ currentPage }}</p> <p class="pl-3 capitalize">{{ currentPage }}</p>
</div> </div>
<nuxt-child /> <nuxt-child />
@@ -42,10 +42,20 @@ export default {
return this.$store.state.streamLibraryItem return this.$store.state.streamLibraryItem
}, },
currentPage() { currentPage() {
if (!this.$route.name) return 'Settings' if (!this.$route.name) return this.$strings.HeaderSettings
var routeName = this.$route.name.split('-') var routeName = this.$route.name.split('-')
if (routeName.length > 0) return routeName.slice(1).join('-') if (routeName.length > 0) {
return 'Settings' const pageName = routeName.slice(1).join('-')
if (pageName === 'log') return this.$strings.HeaderLogs
else if (pageName === 'backups') return this.$strings.HeaderBackups
else if (pageName === 'libraries') return this.$strings.HeaderLibraries
else if (pageName === 'notifications') return this.$strings.HeaderNotifications
else if (pageName === 'sessions') return this.$strings.HeaderListeningSessions
else if (pageName === 'stats') return this.$strings.HeaderYourStats
else if (pageName === 'library-stats') return this.$strings.HeaderLibraryStats
else if (pageName === 'users') return this.$strings.HeaderUsers
}
return this.$strings.HeaderSettings
} }
}, },
methods: { methods: {
+4 -13
View File
@@ -1,13 +1,6 @@
<template> <template>
<div class="w-full h-full"> <div>
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8"> <app-settings-content :header-text="$strings.HeaderBackups" :description="$strings.MessageBackupsDescription">
<div class="flex items-center mb-2">
<h1 class="text-xl">{{ $strings.HeaderBackups }}</h1>
</div>
<p class="text-base mb-2 text-gray-300">{{ $strings.MessageBackupsDescription }} <span class="font-mono text-gray-100">/metadata/items</span> & <span class="font-mono text-gray-100">/metadata/authors</span>.</p>
<p class="text-base mb-4 text-gray-300">{{ $strings.MessageBackupsNote }}</p>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="enableBackups" small :disabled="updatingServerSettings" @input="updateBackupsSettings" /> <ui-toggle-switch v-model="enableBackups" small :disabled="updatingServerSettings" @input="updateBackupsSettings" />
<ui-tooltip :text="$strings.LabelBackupsEnableAutomaticBackupsHelp"> <ui-tooltip :text="$strings.LabelBackupsEnableAutomaticBackupsHelp">
@@ -17,7 +10,7 @@
<div v-if="enableBackups" class="mb-6"> <div v-if="enableBackups" class="mb-6">
<div class="flex items-center pl-6"> <div class="flex items-center pl-6">
<span class="material-icons-outlined text-black-50">schedule</span> <span class="material-icons-outlined text-2xl text-black-50">schedule</span>
<p class="text-gray-100 px-2">{{ scheduleDescription }}</p> <p class="text-gray-100 px-2">{{ scheduleDescription }}</p>
<span class="material-icons text-lg text-black-50 hover:text-yellow-500 cursor-pointer" @click="showCronBuilder = !showCronBuilder">edit</span> <span class="material-icons text-lg text-black-50 hover:text-yellow-500 cursor-pointer" @click="showCronBuilder = !showCronBuilder">edit</span>
</div> </div>
@@ -40,9 +33,7 @@
</div> </div>
<tables-backups-table /> <tables-backups-table />
</div> </app-settings-content>
<modals-backup-schedule-modal v-model="showCronBuilder" :cron-expression.sync="cronExpression" />
</div> </div>
</template> </template>
+21 -24
View File
@@ -1,10 +1,6 @@
<template> <template>
<div> <div>
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-2"> <app-settings-content :header-text="$strings.HeaderSettings">
<div class="mb-2">
<h1 class="text-xl">{{ $strings.HeaderSettings }}</h1>
</div>
<div class="lg:flex"> <div class="lg:flex">
<div class="flex-1"> <div class="flex-1">
<div class="pt-4"> <div class="pt-4">
@@ -15,7 +11,7 @@
<ui-tooltip :text="$strings.LabelSettingsStoreCoversWithItemHelp"> <ui-tooltip :text="$strings.LabelSettingsStoreCoversWithItemHelp">
<p class="pl-4"> <p class="pl-4">
{{ $strings.LabelSettingsStoreCoversWithItem }} {{ $strings.LabelSettingsStoreCoversWithItem }}
<span class="material-icons icon-text text-sm">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
@@ -25,7 +21,7 @@
<ui-tooltip :text="$strings.LabelSettingsStoreMetadataWithItemHelp"> <ui-tooltip :text="$strings.LabelSettingsStoreMetadataWithItemHelp">
<p class="pl-4"> <p class="pl-4">
{{ $strings.LabelSettingsStoreMetadataWithItem }} {{ $strings.LabelSettingsStoreMetadataWithItem }}
<span class="material-icons icon-text text-sm">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
@@ -35,7 +31,7 @@
<ui-tooltip :text="$strings.LabelSettingsSortingIgnorePrefixesHelp"> <ui-tooltip :text="$strings.LabelSettingsSortingIgnorePrefixesHelp">
<p class="pl-4"> <p class="pl-4">
{{ $strings.LabelSettingsSortingIgnorePrefixes }} {{ $strings.LabelSettingsSortingIgnorePrefixes }}
<span class="material-icons icon-text text-sm">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
@@ -57,7 +53,7 @@
<ui-tooltip :text="$strings.LabelSettingsBookshelfViewHelp"> <ui-tooltip :text="$strings.LabelSettingsBookshelfViewHelp">
<p class="pl-4"> <p class="pl-4">
{{ $strings.LabelSettingsHomePageBookshelfView }} {{ $strings.LabelSettingsHomePageBookshelfView }}
<span class="material-icons icon-text text-sm">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
@@ -67,7 +63,7 @@
<ui-tooltip :text="$strings.LabelSettingsBookshelfViewHelp"> <ui-tooltip :text="$strings.LabelSettingsBookshelfViewHelp">
<p class="pl-4"> <p class="pl-4">
{{ $strings.LabelSettingsLibraryBookshelfView }} {{ $strings.LabelSettingsLibraryBookshelfView }}
<span class="material-icons icon-text text-sm">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
@@ -93,7 +89,7 @@
<ui-tooltip :text="$strings.LabelSettingsParseSubtitlesHelp"> <ui-tooltip :text="$strings.LabelSettingsParseSubtitlesHelp">
<p class="pl-4"> <p class="pl-4">
{{ $strings.LabelSettingsParseSubtitles }} {{ $strings.LabelSettingsParseSubtitles }}
<span class="material-icons icon-text text-sm">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
@@ -103,7 +99,7 @@
<ui-tooltip :text="$strings.LabelSettingsFindCoversHelp"> <ui-tooltip :text="$strings.LabelSettingsFindCoversHelp">
<p class="pl-4"> <p class="pl-4">
{{ $strings.LabelSettingsFindCovers }} {{ $strings.LabelSettingsFindCovers }}
<span class="material-icons icon-text text-sm">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
<div class="flex-grow" /> <div class="flex-grow" />
@@ -117,7 +113,7 @@
<ui-tooltip :text="$strings.LabelSettingsOverdriveMediaMarkersHelp"> <ui-tooltip :text="$strings.LabelSettingsOverdriveMediaMarkersHelp">
<p class="pl-4"> <p class="pl-4">
{{ $strings.LabelSettingsOverdriveMediaMarkers }} {{ $strings.LabelSettingsOverdriveMediaMarkers }}
<span class="material-icons icon-text text-sm">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
@@ -127,7 +123,7 @@
<ui-tooltip :text="$strings.LabelSettingsPreferAudioMetadataHelp"> <ui-tooltip :text="$strings.LabelSettingsPreferAudioMetadataHelp">
<p class="pl-4"> <p class="pl-4">
{{ $strings.LabelSettingsPreferAudioMetadata }} {{ $strings.LabelSettingsPreferAudioMetadata }}
<span class="material-icons icon-text text-sm">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
@@ -137,7 +133,7 @@
<ui-tooltip :text="$strings.LabelSettingsPreferOPFMetadataHelp"> <ui-tooltip :text="$strings.LabelSettingsPreferOPFMetadataHelp">
<p class="pl-4"> <p class="pl-4">
{{ $strings.LabelSettingsPreferOPFMetadata }} {{ $strings.LabelSettingsPreferOPFMetadata }}
<span class="material-icons icon-text text-sm">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
@@ -147,7 +143,7 @@
<ui-tooltip :text="$strings.LabelSettingsPreferMatchedMetadataHelp"> <ui-tooltip :text="$strings.LabelSettingsPreferMatchedMetadataHelp">
<p class="pl-4"> <p class="pl-4">
{{ $strings.LabelSettingsPreferMatchedMetadata }} {{ $strings.LabelSettingsPreferMatchedMetadata }}
<span class="material-icons icon-text text-sm">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
@@ -157,7 +153,7 @@
<ui-tooltip :text="$strings.LabelSettingsDisableWatcherHelp"> <ui-tooltip :text="$strings.LabelSettingsDisableWatcherHelp">
<p class="pl-4"> <p class="pl-4">
{{ $strings.LabelSettingsDisableWatcher }} {{ $strings.LabelSettingsDisableWatcher }}
<span class="material-icons icon-text text-sm">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
@@ -172,7 +168,7 @@
<p class="pl-4"> <p class="pl-4">
{{ $strings.LabelSettingsExperimentalFeatures }} {{ $strings.LabelSettingsExperimentalFeatures }}
<a href="https://github.com/advplyr/audiobookshelf/discussions/75" target="_blank"> <a href="https://github.com/advplyr/audiobookshelf/discussions/75" target="_blank">
<span class="material-icons icon-text text-sm">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</a> </a>
</p> </p>
</ui-tooltip> </ui-tooltip>
@@ -183,7 +179,7 @@
<ui-tooltip :text="$strings.LabelSettingsEnableEReaderHelp"> <ui-tooltip :text="$strings.LabelSettingsEnableEReaderHelp">
<p class="pl-4"> <p class="pl-4">
{{ $strings.LabelSettingsEnableEReader }} {{ $strings.LabelSettingsEnableEReader }}
<span class="material-icons icon-text text-sm">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
@@ -193,22 +189,23 @@
<ui-tooltip text="Tone library for metadata"> <ui-tooltip text="Tone library for metadata">
<p class="pl-4"> <p class="pl-4">
Use Tone library for metadata Use Tone library for metadata
<span class="material-icons icon-text text-sm">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> --> </div> -->
</div> </div>
</div> </div>
</div> </app-settings-content>
<div class="h-0.5 bg-primary bg-opacity-30 w-full" /> <div class="h-0.5 bg-primary bg-opacity-30 w-full" />
<div class="flex items-center py-4"> <div class="flex items-center py-4">
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn color="bg" small :padding-x="4" class="hidden lg:block mr-2" :loading="isPurgingCache" @click.stop="purgeCache">{{ $strings.ButtonPurgeAllCache }}</ui-btn> <ui-btn color="bg" small :padding-x="4" class="mr-2 text-xs md:text-sm" :loading="isPurgingCache" @click.stop="purgeCache">{{ $strings.ButtonPurgeAllCache }}</ui-btn>
<ui-btn color="bg" small :padding-x="4" class="hidden lg:block mr-2" :loading="isPurgingCache" @click.stop="purgeItemsCache">{{ $strings.ButtonPurgeItemsCache }}</ui-btn> <ui-btn color="bg" small :padding-x="4" class="mr-2 text-xs md:text-sm" :loading="isPurgingCache" @click.stop="purgeItemsCache">{{ $strings.ButtonPurgeItemsCache }}</ui-btn>
<ui-btn color="bg" small :padding-x="4" class="hidden lg:block mr-2" :loading="isResettingLibraryItems" @click="resetLibraryItems">{{ $strings.ButtonRemoveAllLibraryItems }}</ui-btn> <ui-btn color="bg" small :padding-x="4" class="mr-2 text-xs md:text-sm" :loading="isResettingLibraryItems" @click="resetLibraryItems">{{ $strings.ButtonRemoveAllLibraryItems }}</ui-btn>
</div> </div>
<div class="flex items-center py-4"> <div class="flex items-center py-4">
<div class="flex-grow" /> <div class="flex-grow" />
<p class="pr-2 text-sm font-book text-yellow-400"> <p class="pr-2 text-sm font-book text-yellow-400">
+3 -2
View File
@@ -1,7 +1,8 @@
<template> <template>
<div> <div>
<tables-library-libraries-table @showLibraryModal="setShowLibraryModal" /> <app-settings-content :header-text="$strings.HeaderLibraries" show-add-button @clicked="setShowLibraryModal">
<tables-library-libraries-table @showLibraryModal="setShowLibraryModal" />
</app-settings-content>
<modals-libraries-edit-modal v-model="showLibraryModal" :library="selectedLibrary" /> <modals-libraries-edit-modal v-model="showLibraryModal" :library="selectedLibrary" />
</div> </div>
</template> </template>
+59 -58
View File
@@ -1,66 +1,67 @@
<template> <template>
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8"> <div>
<h1 class="text-xl">{{ $strings.HeaderLibraryStats }}: {{ currentLibraryName }}</h1> <app-settings-content :header-text="$strings.HeaderLibraryStats + ': ' + currentLibraryName">
<stats-preview-icons v-if="totalItems" :library-stats="libraryStats" /> <stats-preview-icons v-if="totalItems" :library-stats="libraryStats" />
<div class="flex lg:flex-row flex-wrap justify-between flex-col mt-8"> <div class="flex lg:flex-row flex-wrap justify-between flex-col mt-8">
<div class="w-80 my-6 mx-auto"> <div class="w-80 my-6 mx-auto">
<h1 class="text-2xl mb-4 font-book">{{ $strings.HeaderStatsTop5Genres }}</h1> <h1 class="text-2xl mb-4 font-book">{{ $strings.HeaderStatsTop5Genres }}</h1>
<p v-if="!top5Genres.length">{{ $strings.MessageNoGenres }}</p> <p v-if="!top5Genres.length">{{ $strings.MessageNoGenres }}</p>
<template v-for="genre in top5Genres"> <template v-for="genre in top5Genres">
<div :key="genre.genre" class="w-full py-2"> <div :key="genre.genre" class="w-full py-2">
<div class="flex items-end mb-1"> <div class="flex items-end mb-1">
<p class="text-2xl font-bold">{{ Math.round((100 * genre.count) / totalItems) }}&nbsp;%</p> <p class="text-2xl font-bold">{{ Math.round((100 * genre.count) / totalItems) }}&nbsp;%</p>
<div class="flex-grow" /> <div class="flex-grow" />
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=genres.${$encode(genre.genre)}`" class="text-base font-book text-white text-opacity-70 hover:underline"> <nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=genres.${$encode(genre.genre)}`" class="text-base font-book text-white text-opacity-70 hover:underline">
{{ genre.genre }} {{ genre.genre }}
</nuxt-link> </nuxt-link>
</div>
<div class="w-full rounded-full h-3 bg-primary bg-opacity-50 overflow-hidden">
<div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * genre.count) / totalItems) + '%' }" />
</div>
</div> </div>
<div class="w-full rounded-full h-3 bg-primary bg-opacity-50 overflow-hidden"> </template>
<div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * genre.count) / totalItems) + '%' }" /> </div>
<div class="w-80 my-6 mx-auto">
<h1 class="text-2xl mb-4 font-book">{{ $strings.HeaderStatsTop10Authors }}</h1>
<p v-if="!top10Authors.length">{{ $strings.MessageNoAuthors }}</p>
<template v-for="(author, index) in top10Authors">
<div :key="author.id" class="w-full py-2">
<div class="flex items-center mb-1">
<p class="text-sm font-book text-white text-opacity-70 w-36 pr-2 truncate">
{{ index + 1 }}.&nbsp;&nbsp;&nbsp;&nbsp;<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(author.id)}`" class="hover:underline">{{ author.name }}</nuxt-link>
</p>
<div class="flex-grow rounded-full h-2.5 bg-primary bg-opacity-0 overflow-hidden">
<div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * author.count) / mostUsedAuthorCount) + '%' }" />
</div>
<div class="w-4 ml-3">
<p class="text-sm font-bold">{{ author.count }}</p>
</div>
</div>
</div> </div>
</div> </template>
</template> </div>
<div class="w-80 my-6 mx-auto">
<h1 class="text-2xl mb-4 font-book">{{ $strings.HeaderStatsLongestItems }}</h1>
<p v-if="!top10LongestItems.length">{{ $strings.MessageNoItems }}</p>
<template v-for="(ab, index) in top10LongestItems">
<div :key="index" class="w-full py-2">
<div class="flex items-center mb-1">
<p class="text-sm font-book text-white text-opacity-70 w-44 pr-2 truncate">
{{ index + 1 }}.&nbsp;&nbsp;&nbsp;&nbsp;<nuxt-link :to="`/item/${ab.id}`" class="hover:underline">{{ ab.title }}</nuxt-link>
</p>
<div class="flex-grow rounded-full h-2.5 bg-primary bg-opacity-0 overflow-hidden">
<div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * ab.duration) / longestItemDuration) + '%' }" />
</div>
<div class="w-4 ml-3">
<p class="text-sm font-bold">{{ (ab.duration / 3600).toFixed(1) }}</p>
</div>
</div>
</div>
</template>
</div>
</div> </div>
<div class="w-80 my-6 mx-auto"> </app-settings-content>
<h1 class="text-2xl mb-4 font-book">{{ $strings.HeaderStatsTop10Authors }}</h1>
<p v-if="!top10Authors.length">{{ $strings.MessageNoAuthors }}</p>
<template v-for="(author, index) in top10Authors">
<div :key="author.id" class="w-full py-2">
<div class="flex items-center mb-1">
<p class="text-sm font-book text-white text-opacity-70 w-36 pr-2 truncate">
{{ index + 1 }}.&nbsp;&nbsp;&nbsp;&nbsp;<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(author.id)}`" class="hover:underline">{{ author.name }}</nuxt-link>
</p>
<div class="flex-grow rounded-full h-2.5 bg-primary bg-opacity-0 overflow-hidden">
<div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * author.count) / mostUsedAuthorCount) + '%' }" />
</div>
<div class="w-4 ml-3">
<p class="text-sm font-bold">{{ author.count }}</p>
</div>
</div>
</div>
</template>
</div>
<div class="w-80 my-6 mx-auto">
<h1 class="text-2xl mb-4 font-book">{{ $strings.HeaderStatsLongestItems }}</h1>
<p v-if="!top10LongestItems.length">{{ $strings.MessageNoItems }}</p>
<template v-for="(ab, index) in top10LongestItems">
<div :key="index" class="w-full py-2">
<div class="flex items-center mb-1">
<p class="text-sm font-book text-white text-opacity-70 w-44 pr-2 truncate">
{{ index + 1 }}.&nbsp;&nbsp;&nbsp;&nbsp;<nuxt-link :to="`/item/${ab.id}`" class="hover:underline">{{ ab.title }}</nuxt-link>
</p>
<div class="flex-grow rounded-full h-2.5 bg-primary bg-opacity-0 overflow-hidden">
<div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * ab.duration) / longestItemDuration) + '%' }" />
</div>
<div class="w-4 ml-3">
<p class="text-sm font-bold">{{ (ab.duration / 3600).toFixed(1) }}</p>
</div>
</div>
</div>
</template>
</div>
</div>
</div> </div>
</template> </template>
+19 -20
View File
@@ -1,9 +1,6 @@
<template> <template>
<div class="w-full h-full"> <div>
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8"> <app-settings-content :header-text="$strings.HeaderLogs">
<div class="flex items-center mb-2">
<h1 class="text-xl">{{ $strings.HeaderLogs }}</h1>
</div>
<div class="flex justify-between mb-2 place-items-end"> <div class="flex justify-between mb-2 place-items-end">
<ui-text-input ref="input" v-model="search" placeholder="Search filter.." @input="inputUpdate" clearable class="w-full sm:w-40 h-8 text-sm sm:mb-0" /> <ui-text-input ref="input" v-model="search" placeholder="Search filter.." @input="inputUpdate" clearable class="w-full sm:w-40 h-8 text-sm sm:mb-0" />
@@ -25,7 +22,7 @@
<p class="text-xl text-gray-200 mb-2">{{ $strings.MessageNoLogs }}</p> <p class="text-xl text-gray-200 mb-2">{{ $strings.MessageNoLogs }}</p>
</div> </div>
</div> </div>
</div> </app-settings-content>
</div> </div>
</template> </template>
@@ -38,20 +35,6 @@ export default {
searchText: null, searchText: null,
newServerSettings: {}, newServerSettings: {},
logColors: ['yellow-200', 'gray-400', 'info', 'warning', 'error', 'red-800', 'blue-400'], logColors: ['yellow-200', 'gray-400', 'info', 'warning', 'error', 'red-800', 'blue-400'],
logLevels: [
{
value: 1,
text: 'Debug'
},
{
value: 2,
text: 'Info'
},
{
value: 3,
text: 'Warn'
}
],
loadedLogs: [] loadedLogs: []
} }
}, },
@@ -66,6 +49,22 @@ export default {
} }
}, },
computed: { computed: {
logLevels() {
return [
{
value: 1,
text: this.$strings.LabelLogLevelDebug
},
{
value: 2,
text: this.$strings.LabelLogLevelInfo
},
{
value: 3,
text: this.$strings.LabelLogLevelWarn
}
]
},
logLevelItems() { logLevelItems() {
if (process.env.NODE_ENV === 'production') return this.logLevels if (process.env.NODE_ENV === 'production') return this.logLevels
this.logLevels.unshift({ text: 'Trace', value: 0 }) this.logLevels.unshift({ text: 'Trace', value: 0 })
+3 -23
View File
@@ -1,9 +1,6 @@
<template> <template>
<div> <div>
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-3 md:p-8 mb-2 max-w-3xl mx-auto"> <app-settings-content :header-text="$strings.HeaderAppriseNotificationSettings" :description="$strings.MessageAppriseDescription">
<h2 class="text-xl font-semibold mb-4">{{ $strings.HeaderAppriseNotificationSettings }}</h2>
<p id="appriseDescription" class="mb-6 text-gray-200" v-html="$strings.MessageAppriseDescription" />
<form @submit.prevent="submitForm"> <form @submit.prevent="submitForm">
<ui-text-input-with-label ref="apiUrlInput" v-model="appriseApiUrl" :disabled="savingSettings" label="Apprise API Url" class="mb-2" /> <ui-text-input-with-label ref="apiUrlInput" v-model="appriseApiUrl" :disabled="savingSettings" label="Apprise API Url" class="mb-2" />
@@ -41,7 +38,7 @@
<template v-for="notification in notifications"> <template v-for="notification in notifications">
<cards-notification-card :key="notification.id" :notification="notification" @update="updateSettings" @edit="editNotification" /> <cards-notification-card :key="notification.id" :notification="notification" @update="updateSettings" @edit="editNotification" />
</template> </template>
</div> </app-settings-content>
<modals-notification-edit-modal v-model="showEditModal" :notification="selectedNotification" :notification-data="notificationData" @update="updateSettings" /> <modals-notification-edit-modal v-model="showEditModal" :notification="selectedNotification" :notification-data="notificationData" @update="updateSettings" />
</div> </div>
@@ -170,21 +167,4 @@ export default {
this.$root.socket.off('notifications_updated', this.notificationsUpdated) this.$root.socket.off('notifications_updated', this.notificationsUpdated)
} }
} }
</script> </script>
<style>
#appriseDescription a {
color: rgb(96 165 250);
}
#appriseDescription a:hover {
color: rgb(147 197 253);
text-decoration-line: underline;
}
#appriseDescription code {
font-size: 0.875rem;
border-radius: 6px;
background-color: rgb(82, 82, 82);
color: white;
padding: 2px 4px;
}
</style>
+4 -8
View File
@@ -1,10 +1,6 @@
<template> <template>
<div class="w-full h-full"> <div>
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8"> <app-settings-content :header-text="$strings.HeaderListeningSessions">
<div class="flex items-center mb-2">
<h1 class="text-xl">{{ $strings.HeaderListeningSessions }}</h1>
</div>
<div class="flex justify-end mb-2"> <div class="flex justify-end mb-2">
<ui-dropdown v-model="selectedUser" :items="userItems" :label="$strings.LabelFilterByUser" small class="max-w-48" @input="updateUserFilter" /> <ui-dropdown v-model="selectedUser" :items="userItems" :label="$strings.LabelFilterByUser" small class="max-w-48" @input="updateUserFilter" />
</div> </div>
@@ -56,7 +52,7 @@
</div> </div>
</div> </div>
<p v-else class="text-white text-opacity-50">{{ $strings.MessageNoListeningSessions }}</p> <p v-else class="text-white text-opacity-50">{{ $strings.MessageNoListeningSessions }}</p>
</div> </app-settings-content>
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" @removedSession="removedSession" /> <modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" @removedSession="removedSession" />
</div> </div>
@@ -102,7 +98,7 @@ export default {
return this.$store.getters['users/getIsUserOnline'](this.user.id) return this.$store.getters['users/getIsUserOnline'](this.user.id)
}, },
userItems() { userItems() {
var userItems = [{ value: '', text: 'All Users' }] var userItems = [{ value: '', text: this.$strings.LabelAllUsers }]
return userItems.concat(this.users.map((u) => ({ value: u.id, text: u.username }))) return userItems.concat(this.users.map((u) => ({ value: u.id, text: u.username })))
}, },
filteredUserUsername() { filteredUserUsername() {
+58 -59
View File
@@ -1,69 +1,68 @@
<template> <template>
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8"> <div>
<h1 class="text-xl">{{ $strings.HeaderYourStats }}</h1> <app-settings-content :header-text="$strings.HeaderYourStats">
<div class="flex justify-center">
<div class="flex p-2">
<svg class="hidden sm:block h-14 w-14 lg:h-18 lg:w-18" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M19 1L14 6V17L19 12.5V1M21 5V18.5C19.9 18.15 18.7 18 17.5 18C15.8 18 13.35 18.65 12 19.5V6C10.55 4.9 8.45 4.5 6.5 4.5C4.55 4.5 2.45 4.9 1 6V20.65C1 20.9 1.25 21.15 1.5 21.15C1.6 21.15 1.65 21.1 1.75 21.1C3.1 20.45 5.05 20 6.5 20C8.45 20 10.55 20.4 12 21.5C13.35 20.65 15.8 20 17.5 20C19.15 20 20.85 20.3 22.25 21.05C22.35 21.1 22.4 21.1 22.5 21.1C22.75 21.1 23 20.85 23 20.6V6C22.4 5.55 21.75 5.25 21 5M10 18.41C8.75 18.09 7.5 18 6.5 18C5.44 18 4.18 18.19 3 18.5V7.13C3.91 6.73 5.14 6.5 6.5 6.5C7.86 6.5 9.09 6.73 10 7.13V18.41Z"
/>
</svg>
<div class="px-3">
<p class="text-4xl md:text-5xl font-bold">{{ userItemsFinished.length }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsItemsFinished }}</p>
</div>
</div>
<div class="flex justify-center"> <div class="flex p-2">
<div class="flex p-2"> <div class="hidden sm:block">
<svg class="hidden sm:block h-14 w-14 lg:h-18 lg:w-18" viewBox="0 0 24 24"> <span class="hidden sm:block material-icons-outlined text-5xl lg:text-6xl">event</span>
<path </div>
fill="currentColor" <div class="px-1">
d="M19 1L14 6V17L19 12.5V1M21 5V18.5C19.9 18.15 18.7 18 17.5 18C15.8 18 13.35 18.65 12 19.5V6C10.55 4.9 8.45 4.5 6.5 4.5C4.55 4.5 2.45 4.9 1 6V20.65C1 20.9 1.25 21.15 1.5 21.15C1.6 21.15 1.65 21.1 1.75 21.1C3.1 20.45 5.05 20 6.5 20C8.45 20 10.55 20.4 12 21.5C13.35 20.65 15.8 20 17.5 20C19.15 20 20.85 20.3 22.25 21.05C22.35 21.1 22.4 21.1 22.5 21.1C22.75 21.1 23 20.85 23 20.6V6C22.4 5.55 21.75 5.25 21 5M10 18.41C8.75 18.09 7.5 18 6.5 18C5.44 18 4.18 18.19 3 18.5V7.13C3.91 6.73 5.14 6.5 6.5 6.5C7.86 6.5 9.09 6.73 10 7.13V18.41Z" <p class="text-4xl md:text-5xl font-bold">{{ totalDaysListened }}</p>
/> <p class="font-book text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsDaysListened }}</p>
</svg> </div>
<div class="px-3"> </div>
<p class="text-4xl md:text-5xl font-bold">{{ userItemsFinished.length }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsItemsFinished }}</p> <div class="flex p-2">
<div class="hidden sm:block">
<span class="material-icons-outlined text-5xl lg:text-6xl">watch_later</span>
</div>
<div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ totalMinutesListening }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsMinutesListening }}</p>
</div>
</div> </div>
</div> </div>
<div class="flex flex-col md:flex-row overflow-hidden max-w-full">
<div class="flex p-2"> <stats-daily-listening-chart :listening-stats="listeningStats" class="origin-top-left transform scale-75 lg:scale-100" />
<div class="hidden sm:block"> <div class="w-80 my-6 mx-auto">
<span class="hidden sm:block material-icons-outlined text-5xl lg:text-6xl">event</span> <div class="flex mb-4 items-center">
</div> <h1 class="text-2xl font-book">{{ $strings.HeaderStatsRecentSessions }}</h1>
<div class="px-1"> <div class="flex-grow" />
<p class="text-4xl md:text-5xl font-bold">{{ totalDaysListened }}</p> <ui-btn :to="`/config/users/${user.id}/sessions`" class="text-xs" :padding-x="1.5" :padding-y="1">{{ $strings.ButtonViewAll }}</ui-btn>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsDaysListened }}</p> </div>
</div> <p v-if="!mostRecentListeningSessions.length">{{ $strings.MessageNoListeningSessions }}</p>
</div> <template v-for="(item, index) in mostRecentListeningSessions">
<div :key="item.id" class="w-full py-0.5">
<div class="flex p-2"> <div class="flex items-center mb-1">
<div class="hidden sm:block"> <p class="text-sm font-book text-white text-opacity-70 w-8">{{ index + 1 }}.&nbsp;</p>
<span class="material-icons-outlined text-5xl lg:text-6xl">watch_later</span> <div class="w-56">
</div> <p class="text-sm font-book text-white text-opacity-80 truncate">{{ item.mediaMetadata ? item.mediaMetadata.title : '' }}</p>
<div class="px-1"> <p class="text-xs text-white text-opacity-50">{{ $dateDistanceFromNow(item.updatedAt) }}</p>
<p class="text-4xl md:text-5xl font-bold">{{ totalMinutesListening }}</p> </div>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsMinutesListening }}</p> <div class="flex-grow" />
</div> <div class="w-18 text-right">
</div> <p class="text-sm font-bold">{{ $elapsedPretty(item.timeListening) }}</p>
</div> </div>
<div class="flex flex-col md:flex-row overflow-hidden max-w-full">
<stats-daily-listening-chart :listening-stats="listeningStats" class="origin-top-left transform scale-75 lg:scale-100" />
<div class="w-80 my-6 mx-auto">
<div class="flex mb-4 items-center">
<h1 class="text-2xl font-book">{{ $strings.HeaderStatsRecentSessions }}</h1>
<div class="flex-grow" />
<ui-btn :to="`/config/users/${user.id}/sessions`" class="text-xs" :padding-x="1.5" :padding-y="1">{{ $strings.ButtonViewAll }}</ui-btn>
</div>
<p v-if="!mostRecentListeningSessions.length">{{ $strings.MessageNoListeningSessions }}</p>
<template v-for="(item, index) in mostRecentListeningSessions">
<div :key="item.id" class="w-full py-0.5">
<div class="flex items-center mb-1">
<p class="text-sm font-book text-white text-opacity-70 w-8">{{ index + 1 }}.&nbsp;</p>
<div class="w-56">
<p class="text-sm font-book text-white text-opacity-80 truncate">{{ item.mediaMetadata ? item.mediaMetadata.title : '' }}</p>
<p class="text-xs text-white text-opacity-50">{{ $dateDistanceFromNow(item.updatedAt) }}</p>
</div>
<div class="flex-grow" />
<div class="w-18 text-right">
<p class="text-sm font-bold">{{ $elapsedPretty(item.timeListening) }}</p>
</div> </div>
</div> </div>
</div> </template>
</template> </div>
</div> </div>
</div> <stats-heatmap v-if="listeningStats" :days-listening="listeningStats.days" class="my-2" />
</app-settings-content>
<stats-heatmap v-if="listeningStats" :days-listening="listeningStats.days" class="my-2" />
</div> </div>
</template> </template>
+14 -3
View File
@@ -1,16 +1,27 @@
<template> <template>
<div> <div>
<tables-users-table /> <app-settings-content :header-text="$strings.HeaderUsers" show-add-button @clicked="setShowUserModal">
<tables-users-table />
</app-settings-content>
<modals-account-modal ref="accountModal" v-model="showAccountModal" :account="selectedAccount" />
</div> </div>
</template> </template>
<script> <script>
export default { export default {
data() { data() {
return {} return {
selectedAccount: null,
showAccountModal: false
}
}, },
computed: {}, computed: {},
methods: {}, methods: {
setShowUserModal(selectedAccount) {
this.selectedAccount = selectedAccount
this.showAccountModal = true
}
},
mounted() {} mounted() {}
} }
</script> </script>
+12 -4
View File
@@ -129,20 +129,20 @@
<!-- Icon buttons --> <!-- Icon buttons -->
<div class="flex items-center justify-center md:justify-start pt-4"> <div class="flex items-center justify-center md:justify-start pt-4">
<ui-btn v-if="showPlayButton" :disabled="isStreaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="playItem"> <ui-btn v-if="showPlayButton" :disabled="isStreaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="playItem">
<span v-show="!isStreaming" class="material-icons -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 -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 }}
</ui-btn> </ui-btn>
<ui-tooltip v-if="showQueueBtn" :text="isQueued ? $strings.ButtonQueueRemoveItem : $strings.ButtonQueueAddItem" direction="top"> <ui-tooltip v-if="showQueueBtn" :text="isQueued ? $strings.ButtonQueueRemoveItem : $strings.ButtonQueueAddItem" direction="top">
<ui-icon-btn :icon="isQueued ? 'playlist_add_check' : 'playlist_add'" class="mx-0.5" :class="isQueued ? 'text-success' : ''" @click="queueBtnClick" /> <ui-icon-btn :icon="isQueued ? 'playlist_add_check' : 'playlist_play'" :bg-color="isQueued ? 'primary' : 'success bg-opacity-60'" class="mx-0.5" :class="isQueued ? 'text-success' : ''" @click="queueBtnClick" />
</ui-tooltip> </ui-tooltip>
<ui-btn v-if="showReadButton" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook"> <ui-btn v-if="showReadButton" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
<span class="material-icons -ml-2 pr-2 text-white">auto_stories</span> <span class="material-icons text-2xl -ml-2 pr-2 text-white">auto_stories</span>
{{ $strings.ButtonRead }} {{ $strings.ButtonRead }}
</ui-btn> </ui-btn>
@@ -158,6 +158,10 @@
<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>
<ui-tooltip v-if="!isPodcast && tracks.length" :text="$strings.LabelYourPlaylists" direction="top">
<ui-icon-btn icon="playlist_add" class="mx-0.5" outlined @click="playlistsClick" />
</ui-tooltip>
<!-- Only admin or root user can download new episodes --> <!-- Only admin or root user can download new episodes -->
<ui-tooltip v-if="isPodcast && userIsAdminOrUp" :text="$strings.LabelFindEpisodes" direction="top"> <ui-tooltip v-if="isPodcast && userIsAdminOrUp" :text="$strings.LabelFindEpisodes" direction="top">
<ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" /> <ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
@@ -608,6 +612,10 @@ export default {
this.$store.commit('setSelectedLibraryItem', this.libraryItem) this.$store.commit('setSelectedLibraryItem', this.libraryItem)
this.$store.commit('globals/setShowCollectionsModal', true) this.$store.commit('globals/setShowCollectionsModal', true)
}, },
playlistsClick() {
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem }])
this.$store.commit('globals/setShowPlaylistsModal', true)
},
clickRSSFeed() { clickRSSFeed() {
this.showRssFeedModal = true this.showRssFeedModal = true
}, },
@@ -7,7 +7,7 @@
<script> <script>
export default { export default {
async asyncData({ params, query, store, app, redirect }) { async asyncData({ params, query, store, redirect }) {
var libraryId = params.library var libraryId = params.library
var libraryData = await store.dispatch('libraries/fetch', libraryId) var libraryData = await store.dispatch('libraries/fetch', libraryId)
if (!libraryData) { if (!libraryData) {
@@ -16,7 +16,6 @@ export default {
// Set series sort by // Set series sort by
if (params.id === 'series') { if (params.id === 'series') {
console.log('Series page', query)
if (query.sort) { if (query.sort) {
store.commit('libraries/setSeriesSortBy', query.sort) store.commit('libraries/setSeriesSortBy', query.sort)
store.commit('libraries/setSeriesSortDesc', !!query.desc) store.commit('libraries/setSeriesSortDesc', !!query.desc)
@@ -32,13 +32,13 @@
<div class="flex items-center"> <div class="flex items-center">
<button class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer focus:outline-none" :class="episode.progress && episode.progress.isFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick(episode)"> <button class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer focus:outline-none" :class="episode.progress && episode.progress.isFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick(episode)">
<span v-if="episodeIdStreaming === episode.id" class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span> <span v-if="episodeIdStreaming === episode.id" class="material-icons text-2xl" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
<span v-else class="material-icons text-success">play_arrow</span> <span v-else class="material-icons text-2xl text-success">play_arrow</span>
<p class="pl-2 pr-1 text-sm font-semibold">{{ getButtonText(episode) }}</p> <p class="pl-2 pr-1 text-sm font-semibold">{{ getButtonText(episode) }}</p>
</button> </button>
<button v-if="libraryItemIdStreaming && !isStreamingFromDifferentLibrary" class="h-8 w-8 flex justify-center items-center mx-2" :class="playerQueueEpisodeIdMap[episode.id] ? 'text-success' : ''" @click.stop="queueBtnClick(episode)"> <button v-if="libraryItemIdStreaming && !isStreamingFromDifferentLibrary" class="h-8 w-8 flex justify-center items-center mx-2" :class="playerQueueEpisodeIdMap[episode.id] ? 'text-success' : ''" @click.stop="queueBtnClick(episode)">
<span class="material-icons-outlined">{{ playerQueueEpisodeIdMap[episode.id] ? 'playlist_add_check' : 'playlist_add' }}</span> <span class="material-icons-outlined text-2xl">{{ playerQueueEpisodeIdMap[episode.id] ? 'playlist_add_check' : 'playlist_add' }}</span>
</button> </button>
</div> </div>
</div> </div>
+187
View File
@@ -0,0 +1,187 @@
<template>
<div id="page-wrapper" class="bg-bg page overflow-hidden" :class="streamLibraryItem ? 'streaming' : ''">
<div class="w-full h-full overflow-y-auto px-2 py-6 md:p-8">
<div class="flex flex-col sm:flex-row max-w-6xl mx-auto">
<div class="w-full flex justify-center md:block sm:w-32 md:w-52" style="min-width: 200px">
<div class="relative" style="height: fit-content">
<covers-playlist-cover :items="playlistItems" :width="200" :height="200" />
</div>
</div>
<div class="flex-grow px-2 py-6 md:py-0 md:px-10">
<div class="flex items-end flex-row flex-wrap md:flex-nowrap">
<h1 class="text-2xl md:text-3xl font-sans w-full md:w-fit mb-4 md:mb-0">
{{ playlistName }}
</h1>
<div class="flex-grow" />
<ui-btn v-if="showPlayButton" :disabled="streaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="clickPlay">
<span v-show="!streaming" class="material-icons text-2xl -ml-2 pr-1 text-white">play_arrow</span>
{{ streaming ? $strings.ButtonPlaying : $strings.ButtonPlay }}
</ui-btn>
<ui-icon-btn v-if="userCanUpdate" icon="edit" class="mx-0.5" @click="editClick" />
<ui-icon-btn v-if="userCanDelete" icon="delete" class="mx-0.5" @click="removeClick" />
</div>
<div class="my-8 max-w-2xl">
<p class="text-base text-gray-100">{{ description }}</p>
</div>
<tables-playlist-items-table :items="playlistItems" :playlist-id="playlistId" />
</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">
<ui-loading-indicator />
</div>
</div>
</template>
<script>
export default {
async asyncData({ store, params, app, redirect, route }) {
if (!store.state.user.user) {
return redirect(`/login?redirect=${route.path}`)
}
var playlist = await app.$axios.$get(`/api/playlists/${params.id}`).catch((error) => {
console.error('Failed', error)
return false
})
if (!playlist) {
return redirect('/')
}
// If playlist is a different library then set library as current
if (playlist.libraryId !== store.state.libraries.currentLibraryId) {
await store.dispatch('libraries/fetch', playlist.libraryId)
}
store.commit('libraries/addUpdateUserPlaylist', playlist)
return {
playlistId: playlist.id
}
},
data() {
return {
processingRemove: false
}
},
computed: {
streamLibraryItem() {
return this.$store.state.streamLibraryItem
},
playlistItems() {
return this.playlist.items || []
},
playlistName() {
return this.playlist.name || ''
},
description() {
return this.playlist.description || ''
},
playlist() {
return this.$store.getters['libraries/getPlaylist'](this.playlistId) || {}
},
playableItems() {
return this.playlistItems.filter((item) => {
const libraryItem = item.libraryItem
if (libraryItem.isMissing || libraryItem.isInvalid) return false
if (item.episode) return item.episode.audioFile
return libraryItem.media.tracks.length
})
},
streaming() {
return !!this.playableItems.find((i) => this.$store.getters['getIsMediaStreaming'](i.libraryItemId, i.episodeId))
},
showPlayButton() {
return this.playableItems.length
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
}
},
methods: {
editClick() {
this.$store.commit('globals/setEditPlaylist', this.playlist)
},
removeClick() {
if (confirm(`Are you sure you want to remove playlist "${this.playlistName}"?`)) {
this.processingRemove = true
var playlistName = this.playlistName
this.$axios
.$delete(`/api/playlists/${this.playlist.id}`)
.then(() => {
this.processingRemove = false
this.$toast.success(`Playlist "${playlistName}" Removed`)
})
.catch((error) => {
console.error('Failed to remove playlist', error)
this.processingRemove = false
this.$toast.error(`Failed to remove playlist`)
})
}
},
clickPlay() {
const queueItems = []
// Playlist queue will start at the first unfinished item
// if all items are finished then entire playlist is queued
const itemsWithProgress = this.playableItems.map((item) => {
return {
...item,
progress: this.$store.getters['user/getUserMediaProgress'](item.libraryItemId, item.episodeId)
}
})
const hasUnfinishedItems = itemsWithProgress.some((i) => !i.progress || !i.progress.isFinished)
if (!hasUnfinishedItems) {
console.warn('All items in playlist are finished - starting at first item')
}
for (let i = 0; i < itemsWithProgress.length; i++) {
const playlistItem = itemsWithProgress[i]
if (!hasUnfinishedItems || !playlistItem.progress || !playlistItem.progress.isFinished) {
const libraryItem = playlistItem.libraryItem
if (playlistItem.episode) {
queueItems.push({
libraryItemId: libraryItem.id,
libraryId: libraryItem.libraryId,
episodeId: playlistItem.episode.id,
title: playlistItem.episode.title,
subtitle: libraryItem.media.metadata.title,
caption: '',
duration: playlistItem.episode.duration || null,
coverPath: libraryItem.media.coverPath || null
})
} else {
queueItems.push({
libraryItemId: libraryItem.id,
libraryId: libraryItem.libraryId,
episodeId: null,
title: libraryItem.media.metadata.title,
subtitle: libraryItem.media.metadata.authors.map((au) => au.name).join(', '),
caption: '',
duration: libraryItem.media.duration || null,
coverPath: libraryItem.media.coverPath || null
})
}
}
}
if (queueItems.length >= 0) {
this.$eventBus.$emit('play-item', {
libraryItemId: queueItems[0].libraryItemId,
episodeId: queueItems[0].episodeId,
queueItems
})
}
}
},
mounted() {},
beforeDestroy() {}
}
</script>
+1 -3
View File
@@ -94,13 +94,11 @@ Vue.prototype.$sanitizeSlug = (str) => {
Vue.prototype.$copyToClipboard = (str, ctx) => { Vue.prototype.$copyToClipboard = (str, ctx) => {
return new Promise((resolve) => { return new Promise((resolve) => {
if (!navigator.clipboard) { if (navigator.clipboard) {
navigator.clipboard.writeText(str).then(() => { navigator.clipboard.writeText(str).then(() => {
if (ctx) ctx.$toast.success('Copied to clipboard') if (ctx) ctx.$toast.success('Copied to clipboard')
resolve(true)
}, (err) => { }, (err) => {
console.error('Clipboard copy failed', str, err) console.error('Clipboard copy failed', str, err)
resolve(false)
}) })
} else { } else {
const el = document.createElement('textarea') const el = document.createElement('textarea')
+45 -1
View File
@@ -4,16 +4,21 @@ export const state = () => ({
showBatchCollectionModal: false, showBatchCollectionModal: false,
showCollectionsModal: false, showCollectionsModal: false,
showEditCollectionModal: false, showEditCollectionModal: false,
showPlaylistsModal: false,
showEditPlaylistModal: false,
showEditPodcastEpisode: false, showEditPodcastEpisode: false,
showViewPodcastEpisodeModal: false, showViewPodcastEpisodeModal: false,
showConfirmPrompt: false, showConfirmPrompt: false,
confirmPromptOptions: null, confirmPromptOptions: null,
showEditAuthorModal: false, showEditAuthorModal: false,
selectedEpisode: null, selectedEpisode: null,
selectedPlaylistItems: null,
selectedPlaylist: null,
selectedCollection: null, selectedCollection: null,
selectedAuthor: null, selectedAuthor: null,
selectedMediaItems: [],
isCasting: false, // Actively casting isCasting: false, // Actively casting
isChromecastInitialized: false, // Script loaded isChromecastInitialized: false, // Script loadeds
showBatchQuickMatchModal: false, showBatchQuickMatchModal: false,
dateFormats: [ dateFormats: [
{ {
@@ -60,6 +65,9 @@ export const getters = {
return `http://localhost:3333${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}` return `http://localhost:3333${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}`
} }
return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}` return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}`
},
getIsBatchSelectingMediaItems: (state) => {
return state.selectedMediaItems.length
} }
} }
@@ -79,6 +87,12 @@ export const mutations = {
setShowEditCollectionModal(state, val) { setShowEditCollectionModal(state, val) {
state.showEditCollectionModal = val state.showEditCollectionModal = val
}, },
setShowPlaylistsModal(state, val) {
state.showPlaylistsModal = val
},
setShowEditPlaylistModal(state, val) {
state.showEditPlaylistModal = val
},
setShowEditPodcastEpisodeModal(state, val) { setShowEditPodcastEpisodeModal(state, val) {
state.showEditPodcastEpisode = val state.showEditPodcastEpisode = val
}, },
@@ -96,9 +110,16 @@ export const mutations = {
state.selectedCollection = collection state.selectedCollection = collection
state.showEditCollectionModal = true state.showEditCollectionModal = true
}, },
setEditPlaylist(state, playlist) {
state.selectedPlaylist = playlist
state.showEditPlaylistModal = true
},
setSelectedEpisode(state, episode) { setSelectedEpisode(state, episode) {
state.selectedEpisode = episode state.selectedEpisode = episode
}, },
setSelectedPlaylistItems(state, items) {
state.selectedPlaylistItems = items
},
showEditAuthorModal(state, author) { showEditAuthorModal(state, author) {
state.selectedAuthor = author state.selectedAuthor = author
state.showEditAuthorModal = true state.showEditAuthorModal = true
@@ -117,5 +138,28 @@ export const mutations = {
}, },
setShowBatchQuickMatchModal(state, val) { setShowBatchQuickMatchModal(state, val) {
state.showBatchQuickMatchModal = val state.showBatchQuickMatchModal = val
},
resetSelectedMediaItems(state) {
// Vue.set(state, 'selectedMediaItems', [])
state.selectedMediaItems = []
},
toggleMediaItemSelected(state, item) {
if (state.selectedMediaItems.some(i => i.id === item.id)) {
state.selectedMediaItems = state.selectedMediaItems.filter(i => i.id !== item.id)
} else {
// const newSel = state.selectedMediaItems.concat([{...item}])
// Vue.set(state, 'selectedMediaItems', newSel)
state.selectedMediaItems.push(item)
}
},
setMediaItemSelected(state, { item, selected }) {
const index = state.selectedMediaItems.findIndex(i => i.id === item.id)
if (index && !selected) {
state.selectedMediaItems = state.selectedMediaItems.filter(i => i.id !== item.id)
} else if (selected && !index) {
state.selectedMediaItems.splice(index, 1, item)
// var newSel = state.selectedMediaItems.concat([libraryItemId])
// Vue.set(state, 'selectedMediaItems', newSel)
}
} }
} }
-29
View File
@@ -17,11 +17,9 @@ export const state = () => ({
showEReader: false, showEReader: false,
selectedLibraryItem: null, selectedLibraryItem: null,
developerMode: false, developerMode: false,
selectedLibraryItems: [],
processingBatch: false, processingBatch: false,
previousPath: '/', previousPath: '/',
showExperimentalFeatures: false, showExperimentalFeatures: false,
backups: [],
bookshelfBookIds: [], bookshelfBookIds: [],
openModal: null, openModal: null,
innerModalOpen: false, innerModalOpen: false,
@@ -30,14 +28,10 @@ export const state = () => ({
}) })
export const getters = { export const getters = {
getIsLibraryItemSelected: state => libraryItemId => {
return !!state.selectedLibraryItems.includes(libraryItemId)
},
getServerSetting: state => key => { getServerSetting: state => key => {
if (!state.serverSettings) return null if (!state.serverSettings) return null
return state.serverSettings[key] return state.serverSettings[key]
}, },
getNumLibraryItemsSelected: state => state.selectedLibraryItems.length,
getLibraryItemIdStreaming: state => { getLibraryItemIdStreaming: state => {
return state.streamLibraryItem ? state.streamLibraryItem.id : null return state.streamLibraryItem ? state.streamLibraryItem.id : null
}, },
@@ -218,26 +212,6 @@ export const mutations = {
setSelectedLibraryItem(state, val) { setSelectedLibraryItem(state, val) {
Vue.set(state, 'selectedLibraryItem', val) Vue.set(state, 'selectedLibraryItem', val)
}, },
setSelectedLibraryItems(state, items) {
Vue.set(state, 'selectedLibraryItems', items)
},
toggleLibraryItemSelected(state, itemId) {
if (state.selectedLibraryItems.includes(itemId)) {
state.selectedLibraryItems = state.selectedLibraryItems.filter(a => a !== itemId)
} else {
var newSel = state.selectedLibraryItems.concat([itemId])
Vue.set(state, 'selectedLibraryItems', newSel)
}
},
setLibraryItemSelected(state, { libraryItemId, selected }) {
var isThere = state.selectedLibraryItems.includes(libraryItemId)
if (isThere && !selected) {
state.selectedLibraryItems = state.selectedLibraryItems.filter(a => a !== libraryItemId)
} else if (selected && !isThere) {
var newSel = state.selectedLibraryItems.concat([libraryItemId])
Vue.set(state, 'selectedLibraryItems', newSel)
}
},
setProcessingBatch(state, val) { setProcessingBatch(state, val) {
state.processingBatch = val state.processingBatch = val
}, },
@@ -245,9 +219,6 @@ export const mutations = {
state.showExperimentalFeatures = val state.showExperimentalFeatures = val
localStorage.setItem('experimental', val ? 1 : 0) localStorage.setItem('experimental', val ? 1 : 0)
}, },
setBackups(state, val) {
state.backups = val.sort((a, b) => b.createdAt - a.createdAt)
},
setOpenModal(state, val) { setOpenModal(state, val) {
state.openModal = val state.openModal = val
}, },
+36 -5
View File
@@ -9,10 +9,12 @@ export const state = () => ({
issues: 0, issues: 0,
folderLastUpdate: 0, folderLastUpdate: 0,
filterData: null, filterData: null,
numUserPlaylists: 0,
seriesSortBy: 'name', seriesSortBy: 'name',
seriesSortDesc: false, seriesSortDesc: false,
seriesFilterBy: 'all', seriesFilterBy: 'all',
collections: [] collections: [],
userPlaylists: []
}) })
export const getters = { export const getters = {
@@ -59,6 +61,9 @@ export const getters = {
}, },
getCollection: state => id => { getCollection: state => id => {
return state.collections.find(c => c.id === id) return state.collections.find(c => c.id === id)
},
getPlaylist: state => id => {
return state.userPlaylists.find(p => p.id === id)
} }
} }
@@ -102,20 +107,26 @@ export const actions = {
return false return false
} }
const libraryChanging = state.currentLibraryId !== libraryId
return this.$axios return this.$axios
.$get(`/api/libraries/${libraryId}?include=filterdata`) .$get(`/api/libraries/${libraryId}?include=filterdata`)
.then((data) => { .then((data) => {
var library = data.library const library = data.library
var filterData = data.filterdata const filterData = data.filterdata
var issues = data.issues || 0 const issues = data.issues || 0
const numUserPlaylists = data.numUserPlaylists
dispatch('user/checkUpdateLibrarySortFilter', library.mediaType, { root: true }) dispatch('user/checkUpdateLibrarySortFilter', library.mediaType, { root: true })
commit('addUpdate', library) commit('addUpdate', library)
commit('setLibraryIssues', issues) commit('setLibraryIssues', issues)
commit('setLibraryFilterData', filterData) commit('setLibraryFilterData', filterData)
commit('setNumUserPlaylists', numUserPlaylists)
commit('setCurrentLibrary', libraryId) commit('setCurrentLibrary', libraryId)
commit('setCollections', []) if (libraryChanging) {
commit('setCollections', [])
commit('setUserPlaylists', [])
}
return data return data
}) })
.catch((error) => { .catch((error) => {
@@ -219,6 +230,9 @@ export const mutations = {
setLibraryFilterData(state, filterData) { setLibraryFilterData(state, filterData) {
state.filterData = filterData state.filterData = filterData
}, },
setNumUserPlaylists(state, numUserPlaylists) {
state.numUserPlaylists = numUserPlaylists
},
updateFilterDataWithItem(state, libraryItem) { updateFilterDataWithItem(state, libraryItem) {
if (!libraryItem || !state.filterData) return if (!libraryItem || !state.filterData) return
if (state.currentLibraryId !== libraryItem.libraryId) return if (state.currentLibraryId !== libraryItem.libraryId) return
@@ -320,5 +334,22 @@ export const mutations = {
}, },
removeCollection(state, collection) { removeCollection(state, collection) {
state.collections = state.collections.filter(c => c.id !== collection.id) state.collections = state.collections.filter(c => c.id !== collection.id)
},
setUserPlaylists(state, playlists) {
state.userPlaylists = playlists
state.numUserPlaylists = playlists.length
},
addUpdateUserPlaylist(state, playlist) {
const index = state.userPlaylists.findIndex(p => p.id === playlist.id)
if (index >= 0) {
state.userPlaylists.splice(index, 1, playlist)
} else {
state.userPlaylists.push(playlist)
state.numUserPlaylists++
}
},
removeUserPlaylist(state, playlist) {
state.userPlaylists = state.userPlaylists.filter(p => p.id !== playlist.id)
state.numUserPlaylists = state.userPlaylists.length
} }
} }
+1 -1
View File
@@ -26,7 +26,7 @@ export const state = () => ({
value: 'audible.uk' value: 'audible.uk'
}, },
{ {
text: 'Audible.co.au', text: 'Audible.com.au',
value: 'audible.au' value: 'audible.au'
}, },
{ {
+10 -10
View File
@@ -1,11 +1,11 @@
export const state = () => ({ export const state = () => ({
users: [] usersOnline: []
}) })
export const getters = { export const getters = {
getIsUserOnline: state => id => { getIsUserOnline: state => id => {
return state.users.find(u => u.id === id) return state.usersOnline.find(u => u.id === id)
} }
} }
@@ -14,18 +14,18 @@ export const actions = {
} }
export const mutations = { export const mutations = {
resetUsers(state) { setUsersOnline(state, usersOnline) {
state.users = [] state.usersOnline = usersOnline
}, },
updateUser(state, user) { updateUserOnline(state, user) {
var index = state.users.findIndex(u => u.id === user.id) var index = state.usersOnline.findIndex(u => u.id === user.id)
if (index >= 0) { if (index >= 0) {
state.users.splice(index, 1, user) state.usersOnline.splice(index, 1, user)
} else { } else {
state.users.push(user) state.usersOnline.push(user)
} }
}, },
removeUser(state, user) { removeUserOnline(state, user) {
state.users = state.users.filter(u => u.id !== user.id) state.usersOnline = state.usersOnline.filter(u => u.id !== user.id)
} }
} }
+131 -81
View File
@@ -5,7 +5,7 @@
"ButtonAddYourFirstLibrary": "Erstelle deine erste Bibliothek", "ButtonAddYourFirstLibrary": "Erstelle deine erste Bibliothek",
"ButtonApply": "Anwenden", "ButtonApply": "Anwenden",
"ButtonApplyChapters": "Kapitel anwenden", "ButtonApplyChapters": "Kapitel anwenden",
"ButtonAuthors": "Autor", "ButtonAuthors": "Autoren",
"ButtonBrowseForFolder": "Ordnersuche", "ButtonBrowseForFolder": "Ordnersuche",
"ButtonCancel": "Abbrechen", "ButtonCancel": "Abbrechen",
"ButtonCancelEncode": "Abbruch der Verschlüsselung", "ButtonCancelEncode": "Abbruch der Verschlüsselung",
@@ -13,10 +13,10 @@
"ButtonCheckAndDownloadNewEpisodes": "Überprüfe & lade neue Episoden herunter", "ButtonCheckAndDownloadNewEpisodes": "Überprüfe & lade neue Episoden herunter",
"ButtonChooseAFolder": "Wähle einen Ordner", "ButtonChooseAFolder": "Wähle einen Ordner",
"ButtonChooseFiles": "Wähle eine Datei", "ButtonChooseFiles": "Wähle eine Datei",
"ButtonClearFilter": "Clear Filter", "ButtonClearFilter": "Filter löschen",
"ButtonCloseFeed": "Feed schließen", "ButtonCloseFeed": "Feed schließen",
"ButtonCollections": "Sammlungen", "ButtonCollections": "Sammlungen",
"ButtonConfigureScanner": "Configure Scanner", "ButtonConfigureScanner": "Scannereinstellungen",
"ButtonCreate": "Ertsellen", "ButtonCreate": "Ertsellen",
"ButtonCreateBackup": "Sicherung erstellen", "ButtonCreateBackup": "Sicherung erstellen",
"ButtonDelete": "Löschen", "ButtonDelete": "Löschen",
@@ -33,19 +33,20 @@
"ButtonLookup": "Nachschlagen", "ButtonLookup": "Nachschlagen",
"ButtonManageTracks": "Tracks verwalten", "ButtonManageTracks": "Tracks verwalten",
"ButtonMapChapterTitles": "Kapitelüberschriften zuordnen", "ButtonMapChapterTitles": "Kapitelüberschriften zuordnen",
"ButtonMatchAllAuthors": "Abgleich aller Autoren", "ButtonMatchAllAuthors": "Online-Abgleich aller Autoren",
"ButtonMatchBooks": "Abgleich der Bücher", "ButtonMatchBooks": "Online-Abgleich aller 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": "Play", "ButtonPlay": "Abspielen",
"ButtonPlaying": "Playing", "ButtonPlaying": "Spielt",
"ButtonPlaylists": "Playlists",
"ButtonPurgeAllCache": "Bereinige alle Zwischenspeicher", "ButtonPurgeAllCache": "Bereinige alle Zwischenspeicher",
"ButtonPurgeItemsCache": "Bereinige den Hörbuch/Podcast-Zwischenspeicher", "ButtonPurgeItemsCache": "Bereinige den Hörbuch/Podcast-Zwischenspeicher",
"ButtonPurgeMediaProgress": "Bereinige die Hörfortschritte", "ButtonPurgeMediaProgress": "Bereinige die Hörfortschritte",
"ButtonQueueAddItem": "Add to queue", "ButtonQueueAddItem": "Zur Warteschlange hinzufügen",
"ButtonQueueRemoveItem": "Remove from queue", "ButtonQueueRemoveItem": "Aus der Warteschlange entfernen",
"ButtonQuickMatch": "Schnellabgleich", "ButtonQuickMatch": "Schnellabgleich",
"ButtonRead": "Lese", "ButtonRead": "Lese",
"ButtonRemove": "Löschen", "ButtonRemove": "Löschen",
@@ -59,19 +60,20 @@
"ButtonSave": "Speichern", "ButtonSave": "Speichern",
"ButtonSaveAndClose": "Speichern & Schließen", "ButtonSaveAndClose": "Speichern & Schließen",
"ButtonSaveTracklist": "Speichere die Titelliste", "ButtonSaveTracklist": "Speichere die Titelliste",
"ButtonScan": "Scan", "ButtonScan": "Durchsuchen",
"ButtonScanLibrary": "Scan Library", "ButtonScanLibrary": "Bibliothek durchsuchen",
"ButtonSearch": "Suchen", "ButtonSearch": "Suchen",
"ButtonSelectFolderPath": "Auswahl Ordnerpfad", "ButtonSelectFolderPath": "Auswahl Ordnerpfad",
"ButtonSeries": "Serie", "ButtonSeries": "Serien",
"ButtonSetChaptersFromTracks": "Set chapters from tracks",
"ButtonShiftTimes": "Arbeitszeiten", "ButtonShiftTimes": "Arbeitszeiten",
"ButtonShow": "Anzeigen", "ButtonShow": "Anzeigen",
"ButtonStartM4BEncode": "M4B-Kodierung starten", "ButtonStartM4BEncode": "M4B-Kodierung starten",
"ButtonStartMetadataEmbed": "Metadateneinbettung starten", "ButtonStartMetadataEmbed": "Metadateneinbettung starten",
"ButtonSubmit": "Absenden", "ButtonSubmit": "Ok",
"ButtonUpload": "Hochladen", "ButtonUpload": "Hochladen",
"ButtonUploadBackup": "Sicherung hochladen", "ButtonUploadBackup": "Sicherung hochladen",
"ButtonUploadCover": "Cover hochladen", "ButtonUploadCover": "Titelbild hochladen",
"ButtonUploadOPMLFile": "OPML-Datei hochladen", "ButtonUploadOPMLFile": "OPML-Datei hochladen",
"ButtonViewAll": "Alles anzeigen", "ButtonViewAll": "Alles anzeigen",
"ButtonYes": "Ja", "ButtonYes": "Ja",
@@ -86,7 +88,7 @@
"HeaderChooseAFolder": "Wähle einen Ordner", "HeaderChooseAFolder": "Wähle einen Ordner",
"HeaderCollection": "Sammlungen", "HeaderCollection": "Sammlungen",
"HeaderCollectionItems": "Sammlungseinträge", "HeaderCollectionItems": "Sammlungseinträge",
"HeaderCover": "Cover", "HeaderCover": "Titelbild",
"HeaderDetails": "Details", "HeaderDetails": "Details",
"HeaderEpisodes": "Episoden", "HeaderEpisodes": "Episoden",
"HeaderFiles": "Dateien", "HeaderFiles": "Dateien",
@@ -96,13 +98,13 @@
"HeaderLastListeningSession": "Letzte Hörsitzung", "HeaderLastListeningSession": "Letzte Hörsitzung",
"HeaderLatestEpisodes": "Letzte Episoden", "HeaderLatestEpisodes": "Letzte Episoden",
"HeaderLibraries": "Bibliotheken", "HeaderLibraries": "Bibliotheken",
"HeaderLibraryFiles": "Bibliotheksdateien", "HeaderLibraryFiles": "Alle Dateien",
"HeaderLibraryStats": "Bibliotheksstatistiken", "HeaderLibraryStats": "Bibliotheksstatistiken",
"HeaderListeningSessions": "Hörsitzungen", "HeaderListeningSessions": "Ereignisse",
"HeaderListeningStats": "Hörstatistiken", "HeaderListeningStats": "Hörstatistiken",
"HeaderLogin": "Anmeldung", "HeaderLogin": "Anmeldung",
"HeaderLogs": "Protokolle", "HeaderLogs": "Protokolle",
"HeaderMatch": "Übereinstimmung", "HeaderMatch": "Online-Abgleich",
"HeaderMetadataToEmbed": "Einzubettende Metadaten", "HeaderMetadataToEmbed": "Einzubettende Metadaten",
"HeaderNewAccount": "Neues Konto", "HeaderNewAccount": "Neues Konto",
"HeaderNewLibrary": "Neue Bibliothek", "HeaderNewLibrary": "Neue Bibliothek",
@@ -110,9 +112,11 @@
"HeaderOpenRSSFeed": "RSS-Feed öffnen", "HeaderOpenRSSFeed": "RSS-Feed öffnen",
"HeaderOtherFiles": "Sonstige Dateien", "HeaderOtherFiles": "Sonstige Dateien",
"HeaderPermissions": "Berechtigungen", "HeaderPermissions": "Berechtigungen",
"HeaderPlayerQueue": "Player Queue", "HeaderPlayerQueue": "Spieler Warteschlange",
"HeaderPlaylist": "Playlist",
"HeaderPlaylistItems": "Playlist Items",
"HeaderPodcastsToAdd": "Podcasts zum Hinzufügen", "HeaderPodcastsToAdd": "Podcasts zum Hinzufügen",
"HeaderPreviewCover": "Vorschau Cover", "HeaderPreviewCover": "Vorschau Titelbild",
"HeaderRemoveEpisode": "Episode löschen", "HeaderRemoveEpisode": "Episode löschen",
"HeaderRemoveEpisodes": "Lösche {0} Episoden", "HeaderRemoveEpisodes": "Lösche {0} Episoden",
"HeaderRSSFeedIsOpen": "RSS-Feed ist geöffnet", "HeaderRSSFeedIsOpen": "RSS-Feed ist geöffnet",
@@ -129,7 +133,7 @@
"HeaderSleepTimer": "Einschlaf-Timer", "HeaderSleepTimer": "Einschlaf-Timer",
"HeaderStatsLongestItems": "Längste Einträge (h)", "HeaderStatsLongestItems": "Längste Einträge (h)",
"HeaderStatsMinutesListeningChart": "Hörminuten (letzte 7 Tage)", "HeaderStatsMinutesListeningChart": "Hörminuten (letzte 7 Tage)",
"HeaderStatsRecentSessions": "Neueste Sitzungen", "HeaderStatsRecentSessions": "Neueste Ereignisse",
"HeaderStatsTop10Authors": "Top 10 Autoren", "HeaderStatsTop10Authors": "Top 10 Autoren",
"HeaderStatsTop5Genres": "Top 5 Kategorien", "HeaderStatsTop5Genres": "Top 5 Kategorien",
"HeaderTools": "Werkzeuge", "HeaderTools": "Werkzeuge",
@@ -138,20 +142,22 @@
"HeaderUpdateDetails": "Details aktualisieren", "HeaderUpdateDetails": "Details aktualisieren",
"HeaderUpdateLibrary": "Bibliothek aktualisieren", "HeaderUpdateLibrary": "Bibliothek aktualisieren",
"HeaderUsers": "Benutzer", "HeaderUsers": "Benutzer",
"HeaderYourStats": "Deine Statistiken", "HeaderYourStats": "Eigene Statistik",
"LabelAccountType": "Kontoart", "LabelAccountType": "Kontoart",
"LabelAccountTypeAdmin": "Admin", "LabelAccountTypeAdmin": "Admin",
"LabelAccountTypeGuest": "Gast", "LabelAccountTypeGuest": "Gast",
"LabelAccountTypeUser": "Benutzer", "LabelAccountTypeUser": "Benutzer",
"LabelActivity": "Aktivitäten", "LabelActivity": "Aktivitäten",
"LabelAddedAt": "Added At", "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} Bücher der Sammlung hinzu",
"LabelAll": "All", "LabelAddToPlaylist": "Add to Playlist",
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
"LabelAll": "Alle",
"LabelAllUsers": "Alle Benutzer", "LabelAllUsers": "Alle Benutzer",
"LabelAuthor": "Autor", "LabelAuthor": "Autor",
"LabelAuthorFirstLast": "Author (First Last)", "LabelAuthorFirstLast": "Autor (Vorname Nachname)",
"LabelAuthorLastFirst": "Author (Last, First)", "LabelAuthorLastFirst": "Autor (Nachname, Vorname)",
"LabelAuthors": "Autoren", "LabelAuthors": "Autoren",
"LabelAutoDownloadEpisodes": "Episoden automatisch herunterladen", "LabelAutoDownloadEpisodes": "Episoden automatisch herunterladen",
"LabelBackToUser": "Zurück zum Benutzer", "LabelBackToUser": "Zurück zum Benutzer",
@@ -165,6 +171,7 @@
"LabelChangePassword": "Passwort ändern", "LabelChangePassword": "Passwort ändern",
"LabelChaptersFound": "gefundene Kapitel", "LabelChaptersFound": "gefundene Kapitel",
"LabelChapterTitle": "Kapitelüberschrift", "LabelChapterTitle": "Kapitelüberschrift",
"LabelClosePlayer": "Player schließen",
"LabelCollapseSeries": "Serien zusammenfassen", "LabelCollapseSeries": "Serien zusammenfassen",
"LabelCollections": "Sammlungen", "LabelCollections": "Sammlungen",
"LabelComplete": "Vollständig", "LabelComplete": "Vollständig",
@@ -194,18 +201,18 @@
"LabelEpisode": "Episode", "LabelEpisode": "Episode",
"LabelEpisodeTitle": "Episodentitel", "LabelEpisodeTitle": "Episodentitel",
"LabelEpisodeType": "Episodentyp", "LabelEpisodeType": "Episodentyp",
"LabelExplicit": "Ausdrücklich", "LabelExplicit": "Explizit <br />(Altersbeschränkung)",
"LabelFeedURL": "Feed URL", "LabelFeedURL": "Feed URL",
"LabelFile": "Datei", "LabelFile": "Datei",
"LabelFileBirthtime": "File Birthtime", "LabelFileBirthtime": "Datei Geburtsdatum",
"LabelFileModified": "File Modified", "LabelFileModified": "Datei geändert",
"LabelFilename": "Dateiname", "LabelFilename": "Dateiname",
"LabelFilterByUser": "Nach Benutzern filtern", "LabelFilterByUser": "Nach Benutzern filtern",
"LabelFindEpisodes": "Episoden suchen", "LabelFindEpisodes": "Episoden suchen",
"LabelFinished": "Beendet", "LabelFinished": "beendet",
"LabelFolder": "Ordner", "LabelFolder": "Ordner",
"LabelFolders": "Verzeichnisse", "LabelFolders": "Verzeichnisse",
"LabelGenre": "Genre", "LabelGenre": "Kategorie",
"LabelGenres": "Kategorien", "LabelGenres": "Kategorien",
"LabelHardDeleteFile": "Datei dauerhaft löschen", "LabelHardDeleteFile": "Datei dauerhaft löschen",
"LabelHour": "Stunde", "LabelHour": "Stunde",
@@ -214,8 +221,16 @@
"LabelIncomplete": "Unvollständig", "LabelIncomplete": "Unvollständig",
"LabelInProgress": "In Bearbeitung", "LabelInProgress": "In Bearbeitung",
"LabelInterval": "Intervall", "LabelInterval": "Intervall",
"LabelIntervalCustomDailyWeekly": "Benutzerdefiniert Täglich/Wöchentlich",
"LabelIntervalEvery12Hours": "Alle 12 Stunden",
"LabelIntervalEvery15Minutes": "Alle 15 Minuten",
"LabelIntervalEvery2Hours": "Alle 2 Stunden",
"LabelIntervalEvery30Minutes": "Alle 30 Minuten",
"LabelIntervalEvery6Hours": "Alle 6 Stunden",
"LabelIntervalEveryDay": "Jeden Tag",
"LabelIntervalEveryHour": "Jede Stunde",
"LabelInvalidParts": "Ungültige Teile", "LabelInvalidParts": "Ungültige Teile",
"LabelItem": "Element/Eintrag", "LabelItem": "Hörbuch/Podcast",
"LabelLanguage": "Sprache", "LabelLanguage": "Sprache",
"LabelLanguageDefaultServer": "Standard-Server-Sprache", "LabelLanguageDefaultServer": "Standard-Server-Sprache",
"LabelLastSeen": "Zuletzt angesehen", "LabelLastSeen": "Zuletzt angesehen",
@@ -228,8 +243,11 @@
"LabelLibraryName": "Bibliotheksname", "LabelLibraryName": "Bibliotheksname",
"LabelLimit": "Begrenzung", "LabelLimit": "Begrenzung",
"LabelListenAgain": "Erneut anhören", "LabelListenAgain": "Erneut anhören",
"LabelLogLevelDebug": "Fehlersuche",
"LabelLogLevelInfo": "Informationen",
"LabelLogLevelWarn": "Warnungen",
"LabelLookForNewEpisodesAfterDate": "Suchen nach neuen Episoden nach diesem Datum", "LabelLookForNewEpisodesAfterDate": "Suchen nach neuen Episoden nach diesem Datum",
"LabelMarkSeries": "Serien markieren", "LabelMarkSeries": "Serien markieren als",
"LabelMediaPlayer": "Mediaplayer", "LabelMediaPlayer": "Mediaplayer",
"LabelMediaType": "Medientyp", "LabelMediaType": "Medientyp",
"LabelMetadataProvider": "Metadatenanbieter", "LabelMetadataProvider": "Metadatenanbieter",
@@ -239,14 +257,14 @@
"LabelMissingParts": "Fehlende Teile", "LabelMissingParts": "Fehlende Teile",
"LabelMore": "Mehr", "LabelMore": "Mehr",
"LabelName": "Name", "LabelName": "Name",
"LabelNarrator": "Narrator", "LabelNarrator": "Erzähler",
"LabelNarrators": "Erzähler", "LabelNarrators": "Erzähler",
"LabelNew": "Neu", "LabelNew": "Neu",
"LabelNewestAuthors": "Neuste Autoren", "LabelNewestAuthors": "Neuste Autoren",
"LabelNewestEpisodes": "Neueste Episoden", "LabelNewestEpisodes": "Neueste Episoden",
"LabelNewPassword": "Neues Passwort", "LabelNewPassword": "Neues Passwort",
"LabelNotes": "Hinweise", "LabelNotes": "Hinweise",
"LabelNotFinished": "Nicht Beendet", "LabelNotFinished": "nicht beendet",
"LabelNotificationAppriseURL": "Apprise URL(s)", "LabelNotificationAppriseURL": "Apprise URL(s)",
"LabelNotificationAvailableVariables": "Verfügbare Variablen", "LabelNotificationAvailableVariables": "Verfügbare Variablen",
"LabelNotificationBodyTemplate": "Textvorlage", "LabelNotificationBodyTemplate": "Textvorlage",
@@ -256,19 +274,21 @@
"LabelNotificationsMaxQueueSize": "Maximale Größe der Warteschlange für die Benachrichtigungsereignisse", "LabelNotificationsMaxQueueSize": "Maximale Größe der Warteschlange für die Benachrichtigungsereignisse",
"LabelNotificationsMaxQueueSizeHelp": "Es wird nur 1 Ereignis pro Sekunde ausgelöst. Ereignisse werden ignoriert, wenn die Warteschlange die maximale Größe erreicht hat. Dies verhindert Benachrichtigungsspamming.", "LabelNotificationsMaxQueueSizeHelp": "Es wird nur 1 Ereignis pro Sekunde ausgelöst. Ereignisse werden ignoriert, wenn die Warteschlange die maximale Größe erreicht hat. Dies verhindert Benachrichtigungsspamming.",
"LabelNotificationTitleTemplate": "Titelvorlage", "LabelNotificationTitleTemplate": "Titelvorlage",
"LabelNotStarted": "Not Started", "LabelNotStarted": "Nicht begonnen",
"LabelNumberOfEpisodes": "# of Episodes", "LabelNumberOfBooks": "Anzahl der Hörbücher",
"LabelNumberOfEpisodes": "Anzahl der Episoden",
"LabelOpenRSSFeed": "Öffne RSS Feed", "LabelOpenRSSFeed": "Öffne RSS Feed",
"LabelPassword": "Passwort", "LabelPassword": "Passwort",
"LabelPath": "Pfad", "LabelPath": "Pfad",
"LabelPermissionsAccessAllLibraries": "Darf auf alle Bibliotheken zugreifen", "LabelPermissionsAccessAllLibraries": "Zugriff auf alle Bibliotheken",
"LabelPermissionsAccessAllTags": "Darf auf alle Schlagwörter zugreifen", "LabelPermissionsAccessAllTags": "Zugriff auf alle Schlagwörter",
"LabelPermissionsAccessExplicitContent": "Darf auf explizite Inhalte zugreifen", "LabelPermissionsAccessExplicitContent": "Zugriff auf explizite (alterbeschränkte) Inhalte",
"LabelPermissionsDelete": "Darf löschen", "LabelPermissionsDelete": "Löschen",
"LabelPermissionsDownload": "Darf herunterladen", "LabelPermissionsDownload": "Herunterladen",
"LabelPermissionsUpdate": "Darf aktualisieren", "LabelPermissionsUpdate": "Aktualisieren",
"LabelPermissionsUpload": "Darf hochladen", "LabelPermissionsUpload": "Hochladen",
"LabelPhotoPathURL": "Foto Pfad/URL", "LabelPhotoPathURL": "Foto Pfad/URL",
"LabelPlaylists": "Playlists",
"LabelPlayMethod": "Abspielmethode", "LabelPlayMethod": "Abspielmethode",
"LabelPodcast": "Podcast", "LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts", "LabelPodcasts": "Podcasts",
@@ -277,22 +297,23 @@
"LabelProvider": "Anbieter", "LabelProvider": "Anbieter",
"LabelPubDate": "Veröffentlichungsdatum", "LabelPubDate": "Veröffentlichungsdatum",
"LabelPublisher": "Herausgeber", "LabelPublisher": "Herausgeber",
"LabelPublishYear": "Erscheinungsjahr", "LabelPublishYear": "Jahr",
"LabelRecentlyAdded": "Kürzlich hinzugefügt", "LabelRecentlyAdded": "Kürzlich hinzugefügt",
"LabelRecentSeries": "Aktuelle Serien", "LabelRecentSeries": "Aktuelle Serien",
"LabelRegion": "Region", "LabelRegion": "Region",
"LabelReleaseDate": "Veröffentlichungsdatum", "LabelReleaseDate": "Veröffentlichungsdatum",
"LabelRSSFeedOpen": "RSS Feed Open", "LabelRemoveCover": "Lösche Titelbild",
"LabelRSSFeedOpen": "RSS Feed Offen",
"LabelRSSFeedSlug": "RSS Feed Schlagwort", "LabelRSSFeedSlug": "RSS Feed Schlagwort",
"LabelRSSFeedURL": "RSS Feed URL", "LabelRSSFeedURL": "RSS Feed URL",
"LabelSearchTerm": "Begriff suchen", "LabelSearchTerm": "Begriff suchen",
"LabelSearchTitle": "Titel suchen", "LabelSearchTitle": "Titel",
"LabelSearchTitleOrASIN": "Titel oder ASIN", "LabelSearchTitleOrASIN": "Titel oder ASIN",
"LabelSeason": "Staffel", "LabelSeason": "Staffel",
"LabelSequence": "Reihenfolge", "LabelSequence": "Reihenfolge",
"LabelSeries": "Serie", "LabelSeries": "Serien",
"LabelSeriesName": "Serienname", "LabelSeriesName": "Serienname",
"LabelSeriesProgress": "Series Progress", "LabelSeriesProgress": "Serienfortschritt",
"LabelSettingsBookshelfViewHelp": "Skeumorphes Design mit Holzeinlegeböden", "LabelSettingsBookshelfViewHelp": "Skeumorphes Design mit Holzeinlegeböden",
"LabelSettingsChromecastSupport": "Chromecast-unterstützung", "LabelSettingsChromecastSupport": "Chromecast-unterstützung",
"LabelSettingsDateFormat": "Datumsformat", "LabelSettingsDateFormat": "Datumsformat",
@@ -304,7 +325,7 @@
"LabelSettingsExperimentalFeatures": "Experimentelle Funktionen", "LabelSettingsExperimentalFeatures": "Experimentelle Funktionen",
"LabelSettingsExperimentalFeaturesHelp": "Funktionen welche sich in der Entwicklung befinden, benötigen Ihr Feedback und Ihre Hilfe beim Testen. Klicken Sie hier, um die Github-Diskussion zu öffnen.", "LabelSettingsExperimentalFeaturesHelp": "Funktionen welche sich in der Entwicklung befinden, benötigen Ihr Feedback und Ihre Hilfe beim Testen. Klicken Sie hier, um die Github-Diskussion zu öffnen.",
"LabelSettingsFindCovers": "Suche Titelbilder", "LabelSettingsFindCovers": "Suche Titelbilder",
"LabelSettingsFindCoversHelp": "Wenn Ihr Hörbuch kein eingebettetes Cover oder ein Coverbild im Ordner hat, versucht der Scanner, ein Cover zu finden.<br>Hinweis: Dies verlängert die Scandauer", "LabelSettingsFindCoversHelp": "Wenn Ihr Hörbuch kein eingebettetes Titelbild oder kein Titelbild im Ordner hat, versucht der Scanner, ein Titelbild online zu finden.<br>Hinweis: Dies verlängert die Scandauer",
"LabelSettingsHomePageBookshelfView": "Starseite verwendet die Bücherregalansicht", "LabelSettingsHomePageBookshelfView": "Starseite verwendet die Bücherregalansicht",
"LabelSettingsLibraryBookshelfView": "Bibliothek verwendet die Bücherregalansicht", "LabelSettingsLibraryBookshelfView": "Bibliothek verwendet die Bücherregalansicht",
"LabelSettingsOverdriveMediaMarkers": "Verwende Overdrive Media Marker für Kapitel", "LabelSettingsOverdriveMediaMarkers": "Verwende Overdrive Media Marker für Kapitel",
@@ -317,8 +338,8 @@
"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",
"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 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.",
"LabelSettingsSkipMatchingBooksWithASIN": "Überspringe beim 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 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",
@@ -329,11 +350,12 @@
"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 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.",
"LabelShowAll": "Alles anzeigen", "LabelShowAll": "Alles anzeigen",
"LabelSize": "Größe", "LabelSize": "Größe",
"LabelSleepTimer": "Einschlaf-Timer",
"LabelStart": "Start", "LabelStart": "Start",
"LabelStarted": "Gestartet", "LabelStarted": "Gestartet",
"LabelStartedAt": "Gestartet am", "LabelStartedAt": "Gestartet am",
"LabelStartTime": "Startzeit", "LabelStartTime": "Startzeit",
"LabelStatsAudioTracks": "Audio Tracks", "LabelStatsAudioTracks": "Audiodateien",
"LabelStatsAuthors": "Autoren", "LabelStatsAuthors": "Autoren",
"LabelStatsBestDay": "Bester Tag", "LabelStatsBestDay": "Bester Tag",
"LabelStatsDailyAverage": "Tagesdurchschnitt", "LabelStatsDailyAverage": "Tagesdurchschnitt",
@@ -345,8 +367,8 @@
"LabelStatsItemsInLibrary": "Bibliothekseinträge", "LabelStatsItemsInLibrary": "Bibliothekseinträge",
"LabelStatsMinutes": "Minuten", "LabelStatsMinutes": "Minuten",
"LabelStatsMinutesListening": "Gehörte Minuten", "LabelStatsMinutesListening": "Gehörte Minuten",
"LabelStatsOverallDays": "Overall Days", "LabelStatsOverallDays": "Gesamte Tage",
"LabelStatsOverallHours": "Overall Hours", "LabelStatsOverallHours": "Gesamte Stunden",
"LabelStatsWeekListening": "Gehörte Wochen", "LabelStatsWeekListening": "Gehörte Wochen",
"LabelSubtitle": "Untertitel", "LabelSubtitle": "Untertitel",
"LabelSupportedFileTypes": "Unterstützte Dateitypen", "LabelSupportedFileTypes": "Unterstützte Dateitypen",
@@ -358,15 +380,19 @@
"LabelTimeRemaining": "{0} verbleibend", "LabelTimeRemaining": "{0} verbleibend",
"LabelTimeToShift": "Zeit bis zum Wechsel in Sekunden", "LabelTimeToShift": "Zeit bis zum Wechsel in Sekunden",
"LabelTitle": "Titel", "LabelTitle": "Titel",
"LabelToolsEmbedMetadata": "Embed Metadata", "LabelToolsEmbedMetadata": "Metadaten einbetten",
"LabelToolsEmbedMetadataDescription": "Embed metadata into audio files including cover image and chapters.", "LabelToolsEmbedMetadataDescription": "Bettet die Metadaten einschließlich des Titelbildes und der Kapitel in die Audiodatein ein.",
"LabelToolsMakeM4b": "Make M4B Audiobook File", "LabelToolsMakeM4b": "M4B-Hörbuchdatei erstellen",
"LabelToolsMakeM4bDescription": "Generate a .M4B audiobook file with embedded metadata, cover image, and chapters.", "LabelToolsMakeM4bDescription": "Erstellt eine M4B-Hörbuchdatei mit eingebetteten Metadaten, Titelbild und Kapiteln.",
"LabelToolsSplitM4b": "Split M4B to MP3's", "LabelToolsSplitM4b": "M4B in MP3's aufteilen",
"LabelToolsSplitM4bDescription": "Create MP3's from an M4B split by chapters with embedded metadata, cover image, and chapters.", "LabelToolsSplitM4bDescription": "Erstellt aus einer mit Metadaten und nach Kapiteln aufgeteilten M4B-Hörbuchdastei seperate MP3's mit eingebetteten Metadaten, Coverbild und Kapiteln.",
"LabelTotalDuration": "Gesamtdauer",
"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",
"LabelTracksMultiTrack": "Multi-track",
"LabelTracksSingleTrack": "Single-track",
"LabelType": "Typ", "LabelType": "Typ",
"LabelUnknown": "Unbekannt", "LabelUnknown": "Unbekannt",
"LabelUpdateCover": "Titelbild aktualisieren", "LabelUpdateCover": "Titelbild aktualisieren",
@@ -382,19 +408,27 @@
"LabelUsername": "Benutzername", "LabelUsername": "Benutzername",
"LabelValue": "Wert", "LabelValue": "Wert",
"LabelVersion": "Version", "LabelVersion": "Version",
"LabelViewBookmarks": "Lesezeichen anzeigen",
"LabelViewChapters": "Kapitel anzeigen",
"LabelViewQueue": "Spieler-Warteschlange anzeigen",
"LabelVolume": "Volume",
"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": "Ihre Lesezeichen", "LabelYourBookmarks": "Lesezeichen",
"LabelYourProgress": "Ihre Fortschritte", "LabelYourPlaylists": "Your Playlists",
"MessageAppriseDescription": "To use this feature you will need to have an instance of <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> running or an api that will handle those same requests. <br />The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at <code>http://192.168.1.1:8337</code> then you would put <code>http://192.168.1.1:8337/notify</code>.", "LabelYourProgress": "Fortschritt",
"MessageBackupsDescription": "In Sicherungen werden Benutzer, Benutzerfortschritte, Details zu den Bibliotheksobjekten, Servereinstellungen und Bilder gespeichert", "MessageAddToPlayerQueue": "Add to player queue",
"MessageBackupsNote": "Die Sicherungen enthalten keine Dateien welche in Ihren Bibliotheksordnern gespeichert sind.", "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.",
"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": "You haven't made any collections yet", "MessageBookshelfNoCollections": "Es wurden noch keine Sammlungen erstellt",
"MessageBookshelfNoResultsForFilter": "No Results for filter \"{0}: {1}\"", "MessageBookshelfNoResultsForFilter": "Keine Ergebnisse für filter \"{0}: {1}\"",
"MessageBookshelfNoRSSFeeds": "No RSS feeds are open", "MessageBookshelfNoRSSFeeds": "Keine RSS-Feeds geöffnet",
"MessageBookshelfNoSeries": "You have no series", "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",
"MessageChapterErrorStartGteDuration": "Invalid start time must be less than audiobook duration",
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
"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?",
@@ -404,18 +438,19 @@
"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}\"?",
"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!",
"MessageEpisodesQueuedForDownload": "{0} Episode(n) in der Warteschlange zum Herunterladen", "MessageEpisodesQueuedForDownload": "{0} Episode(n) in der Warteschlange zum Herunterladen",
"MessageFeedURLWillBe": "Feed-URL wird {0} sein", "MessageFeedURLWillBe": "Feed-URL wird {0} sein",
"MessageFetching": "Abrufen...", "MessageFetching": "Abrufen...",
"MessageForceReScanDescription": "scannt alle Dateien neu, wie bei einem frischen Scan. ID3-Tags von Audiodateien, OPF-Dateien und Textdateien werden neu gescannt.", "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 Elemente",
"MessageJoinUsOn": "Besuchen Sie uns auf", "MessageJoinUsOn": "Besuchen Sie uns auf",
"MessageListeningSessionsInTheLastYear": "{0} Hörsitzungen im letzten Jahr", "MessageListeningSessionsInTheLastYear": "{0} Ereignisse im letzten Jahr",
"MessageLoading": "Laden...", "MessageLoading": "Laden...",
"MessageLoadingFolders": "Lade Ordner...", "MessageLoadingFolders": "Lade Ordner...",
"MessageM4BFailed": "M4B fehlgeschlagen!", "MessageM4BFailed": "M4B fehlgeschlagen!",
@@ -436,7 +471,7 @@
"MessageNoEpisodes": "Keine Episoden", "MessageNoEpisodes": "Keine Episoden",
"MessageNoFoldersAvailable": "Keine Ordner verfügbar", "MessageNoFoldersAvailable": "Keine Ordner verfügbar",
"MessageNoGenres": "Keine Kategorien", "MessageNoGenres": "Keine Kategorien",
"MessageNoIssues": "No Issues", "MessageNoIssues": "Keine Probleme",
"MessageNoItems": "Keine Elemente/Einträge", "MessageNoItems": "Keine Elemente/Einträge",
"MessageNoItemsFound": "Keine Elemente/Einträge gefunden", "MessageNoItemsFound": "Keine Elemente/Einträge gefunden",
"MessageNoListeningSessions": "Keine Hörsitzungen", "MessageNoListeningSessions": "Keine Hörsitzungen",
@@ -446,20 +481,28 @@
"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}\"",
"MessageNotYetImplemented": "Not yet implemented", "MessageNoSeries": "No Series",
"MessageNotYetImplemented": "Noch nicht implementiert",
"MessageNoUpdateNecessary": "Keine Aktualisierung erforderlich", "MessageNoUpdateNecessary": "Keine Aktualisierung erforderlich",
"MessageNoUpdatesWereNecessary": "Keine Aktualisierungen waren notwendig", "MessageNoUpdatesWereNecessary": "Keine Aktualisierungen waren notwendig",
"MessageOr": "or", "MessageNoUserPlaylists": "You have no playlists",
"MessagePodcastHasNoRSSFeedForMatching": "Podcast hat keine RSS-Feed-Url welche für den Abgleich verwendet werden kann", "MessageOr": "oder",
"MessagePauseChapter": "Kapitelwiedergabe pausieren",
"MessagePlayChapter": "Kapitelanfang anhören",
"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 Übereinstimmungen, 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",
"MessageRemoveEpisodes": "Entferne {0} Episode(n)", "MessageRemoveEpisodes": "Entferne {0} Episode(n)",
"MessageRemoveFromPlayerQueue": "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?",
"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",
"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",
@@ -467,7 +510,7 @@
"MessageUploading": "Hochladen...", "MessageUploading": "Hochladen...",
"MessageValidCronExpression": "Gültiger cron-ausdruck", "MessageValidCronExpression": "Gültiger cron-ausdruck",
"MessageWatcherIsDisabledGlobally": "Überwachung ist in den Servereinstellungen global deaktiviert", "MessageWatcherIsDisabledGlobally": "Überwachung ist in den Servereinstellungen global deaktiviert",
"MessageXLibraryIsEmpty": "{0} Library is empty!", "MessageXLibraryIsEmpty": "{0} Bibliothek ist leer!",
"MessageYourAudiobookDurationIsLonger": "Die Dauer Ihres Hörbuchs ist länger als die gefundene Dauer", "MessageYourAudiobookDurationIsLonger": "Die Dauer Ihres Hörbuchs ist länger als die gefundene Dauer",
"MessageYourAudiobookDurationIsShorter": "Die Dauer Ihres Hörbuchs ist kürzer als die gefundene Dauer", "MessageYourAudiobookDurationIsShorter": "Die Dauer Ihres Hörbuchs ist kürzer als die gefundene Dauer",
"NoteChangeRootPassword": "Der Root-Benutzer (Hauptbenutzer) ist der einzige Benutzer, der ein leeres Passwort haben kann", "NoteChangeRootPassword": "Der Root-Benutzer (Hauptbenutzer) ist der einzige Benutzer, der ein leeres Passwort haben kann",
@@ -481,6 +524,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",
"PlaceholderSearch": "Suche...", "PlaceholderSearch": "Suche...",
"ToastAccountUpdateFailed": "Aktualisierung des Kontos fehlgeschlagen", "ToastAccountUpdateFailed": "Aktualisierung des Kontos fehlgeschlagen",
"ToastAccountUpdateSuccess": "Konto aktualisiert", "ToastAccountUpdateSuccess": "Konto aktualisiert",
@@ -505,6 +549,8 @@
"ToastBookmarkRemoveSuccess": "Lesezeichen gelöscht", "ToastBookmarkRemoveSuccess": "Lesezeichen gelöscht",
"ToastBookmarkUpdateFailed": "Lesezeichenaktualisierung fehlgeschlagen", "ToastBookmarkUpdateFailed": "Lesezeichenaktualisierung fehlgeschlagen",
"ToastBookmarkUpdateSuccess": "Lesezeichen aktualisiert", "ToastBookmarkUpdateSuccess": "Lesezeichen aktualisiert",
"ToastChaptersHaveErrors": "Chapters have errors",
"ToastChaptersMustHaveTitles": "Chapters must have titles",
"ToastCollectionItemsRemoveFailed": "Element(e) konnte(n) nicht aus der Sammlung entfernt werden", "ToastCollectionItemsRemoveFailed": "Element(e) konnte(n) nicht aus der Sammlung entfernt werden",
"ToastCollectionItemsRemoveSuccess": "Element(e) wurde(n) aus der Sammlung entfernt", "ToastCollectionItemsRemoveSuccess": "Element(e) wurde(n) aus der Sammlung entfernt",
"ToastCollectionRemoveFailed": "Sammlung konnte nicht entfernt werden", "ToastCollectionRemoveFailed": "Sammlung konnte nicht entfernt werden",
@@ -528,6 +574,10 @@
"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",
"ToastPlaylistRemoveSuccess": "Playlist removed",
"ToastPlaylistUpdateFailed": "Failed to update playlist",
"ToastPlaylistUpdateSuccess": "Playlist updated",
"ToastPodcastCreateFailed": "Podcast konnte nicht erstellt werden", "ToastPodcastCreateFailed": "Podcast konnte nicht erstellt werden",
"ToastPodcastCreateSuccess": "Podcast erfolgreich erstellt", "ToastPodcastCreateSuccess": "Podcast erfolgreich erstellt",
"ToastRemoveItemFromCollectionFailed": "Element/Eintrag konnte nicht aus der Sammlung entfernt werden", "ToastRemoveItemFromCollectionFailed": "Element/Eintrag konnte nicht aus der Sammlung entfernt werden",
@@ -536,9 +586,9 @@
"ToastRSSFeedCloseSuccess": "RSS-Feed geschlossen", "ToastRSSFeedCloseSuccess": "RSS-Feed geschlossen",
"ToastSessionDeleteFailed": "Sitzung konnte nicht gelöscht werden", "ToastSessionDeleteFailed": "Sitzung konnte nicht gelöscht werden",
"ToastSessionDeleteSuccess": "Sitzung gelöscht", "ToastSessionDeleteSuccess": "Sitzung gelöscht",
"ToastSocketConnected": "Socket connected", "ToastSocketConnected": "Verbindung zum WebSocket hergestellt",
"ToastSocketDisconnected": "Socket disconnected", "ToastSocketDisconnected": "Verbindung zum WebSocket verloren",
"ToastSocketFailedToConnect": "Socket failed to connect", "ToastSocketFailedToConnect": "Verbindung zum WebSocket fehlgeschlagen",
"ToastUserDeleteFailed": "Benutzer konnte nicht gelöscht werden", "ToastUserDeleteFailed": "Benutzer konnte nicht gelöscht werden",
"ToastUserDeleteSuccess": "Benutzer gelöscht", "ToastUserDeleteSuccess": "Benutzer gelöscht",
"WeekdayFriday": "Freitag", "WeekdayFriday": "Freitag",
+52 -2
View File
@@ -41,6 +41,7 @@
"ButtonOpenManager": "Open Manager", "ButtonOpenManager": "Open Manager",
"ButtonPlay": "Play", "ButtonPlay": "Play",
"ButtonPlaying": "Playing", "ButtonPlaying": "Playing",
"ButtonPlaylists": "Playlists",
"ButtonPurgeAllCache": "Purge All Cache", "ButtonPurgeAllCache": "Purge All Cache",
"ButtonPurgeItemsCache": "Purge Items Cache", "ButtonPurgeItemsCache": "Purge Items Cache",
"ButtonPurgeMediaProgress": "Purge Media Progress", "ButtonPurgeMediaProgress": "Purge Media Progress",
@@ -64,6 +65,7 @@
"ButtonSearch": "Search", "ButtonSearch": "Search",
"ButtonSelectFolderPath": "Select Folder Path", "ButtonSelectFolderPath": "Select Folder Path",
"ButtonSeries": "Series", "ButtonSeries": "Series",
"ButtonSetChaptersFromTracks": "Set chapters from tracks",
"ButtonShiftTimes": "Shift Times", "ButtonShiftTimes": "Shift Times",
"ButtonShow": "Show", "ButtonShow": "Show",
"ButtonStartM4BEncode": "Start M4B Encode", "ButtonStartM4BEncode": "Start M4B Encode",
@@ -111,6 +113,8 @@
"HeaderOtherFiles": "Other Files", "HeaderOtherFiles": "Other Files",
"HeaderPermissions": "Permissions", "HeaderPermissions": "Permissions",
"HeaderPlayerQueue": "Player Queue", "HeaderPlayerQueue": "Player Queue",
"HeaderPlaylist": "Playlist",
"HeaderPlaylistItems": "Playlist Items",
"HeaderPodcastsToAdd": "Podcasts to Add", "HeaderPodcastsToAdd": "Podcasts to Add",
"HeaderPreviewCover": "Preview Cover", "HeaderPreviewCover": "Preview Cover",
"HeaderRemoveEpisode": "Remove Episode", "HeaderRemoveEpisode": "Remove Episode",
@@ -147,6 +151,8 @@
"LabelAddedAt": "Added At", "LabelAddedAt": "Added At",
"LabelAddToCollection": "Add to Collection", "LabelAddToCollection": "Add to Collection",
"LabelAddToCollectionBatch": "Add {0} Books to Collection", "LabelAddToCollectionBatch": "Add {0} Books to Collection",
"LabelAddToPlaylist": "Add to Playlist",
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
"LabelAll": "All", "LabelAll": "All",
"LabelAllUsers": "All Users", "LabelAllUsers": "All Users",
"LabelAuthor": "Author", "LabelAuthor": "Author",
@@ -165,6 +171,7 @@
"LabelChangePassword": "Change Password", "LabelChangePassword": "Change Password",
"LabelChaptersFound": "chapters found", "LabelChaptersFound": "chapters found",
"LabelChapterTitle": "Chapter Title", "LabelChapterTitle": "Chapter Title",
"LabelClosePlayer": "Close player",
"LabelCollapseSeries": "Collapse Series", "LabelCollapseSeries": "Collapse Series",
"LabelCollections": "Collections", "LabelCollections": "Collections",
"LabelComplete": "Complete", "LabelComplete": "Complete",
@@ -214,6 +221,14 @@
"LabelIncomplete": "Incomplete", "LabelIncomplete": "Incomplete",
"LabelInProgress": "In Progress", "LabelInProgress": "In Progress",
"LabelInterval": "Interval", "LabelInterval": "Interval",
"LabelIntervalCustomDailyWeekly": "Custom daily/weekly",
"LabelIntervalEvery12Hours": "Every 12 hours",
"LabelIntervalEvery15Minutes": "Every 15 minutes",
"LabelIntervalEvery2Hours": "Every 2 hours",
"LabelIntervalEvery30Minutes": "Every 30 minutes",
"LabelIntervalEvery6Hours": "Every 6 hours",
"LabelIntervalEveryDay": "Every day",
"LabelIntervalEveryHour": "Every hour",
"LabelInvalidParts": "Invalid Parts", "LabelInvalidParts": "Invalid Parts",
"LabelItem": "Item", "LabelItem": "Item",
"LabelLanguage": "Language", "LabelLanguage": "Language",
@@ -228,6 +243,9 @@
"LabelLibraryName": "Library Name", "LabelLibraryName": "Library Name",
"LabelLimit": "Limit", "LabelLimit": "Limit",
"LabelListenAgain": "Listen Again", "LabelListenAgain": "Listen Again",
"LabelLogLevelDebug": "Debug",
"LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date", "LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date",
"LabelMarkSeries": "Mark Series", "LabelMarkSeries": "Mark Series",
"LabelMediaPlayer": "Media Player", "LabelMediaPlayer": "Media Player",
@@ -257,6 +275,7 @@
"LabelNotificationsMaxQueueSizeHelp": "Events are limited to firing 1 per second. Events will be ignored if the queue is at max size. This prevents notification spamming.", "LabelNotificationsMaxQueueSizeHelp": "Events are limited to firing 1 per second. Events will be ignored if the queue is at max size. This prevents notification spamming.",
"LabelNotificationTitleTemplate": "Title Template", "LabelNotificationTitleTemplate": "Title Template",
"LabelNotStarted": "Not Started", "LabelNotStarted": "Not Started",
"LabelNumberOfBooks": "Number of Books",
"LabelNumberOfEpisodes": "# of Episodes", "LabelNumberOfEpisodes": "# of Episodes",
"LabelOpenRSSFeed": "Open RSS Feed", "LabelOpenRSSFeed": "Open RSS Feed",
"LabelPassword": "Password", "LabelPassword": "Password",
@@ -269,6 +288,7 @@
"LabelPermissionsUpdate": "Can Update", "LabelPermissionsUpdate": "Can Update",
"LabelPermissionsUpload": "Can Upload", "LabelPermissionsUpload": "Can Upload",
"LabelPhotoPathURL": "Photo Path/URL", "LabelPhotoPathURL": "Photo Path/URL",
"LabelPlaylists": "Playlists",
"LabelPlayMethod": "Play Method", "LabelPlayMethod": "Play Method",
"LabelPodcast": "Podcast", "LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts", "LabelPodcasts": "Podcasts",
@@ -282,6 +302,7 @@
"LabelRecentSeries": "Recent Series", "LabelRecentSeries": "Recent Series",
"LabelRegion": "Region", "LabelRegion": "Region",
"LabelReleaseDate": "Release Date", "LabelReleaseDate": "Release Date",
"LabelRemoveCover": "Remove cover",
"LabelRSSFeedOpen": "RSS Feed Open", "LabelRSSFeedOpen": "RSS Feed Open",
"LabelRSSFeedSlug": "RSS Feed Slug", "LabelRSSFeedSlug": "RSS Feed Slug",
"LabelRSSFeedURL": "RSS Feed URL", "LabelRSSFeedURL": "RSS Feed URL",
@@ -329,6 +350,7 @@
"LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension", "LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension",
"LabelShowAll": "Show All", "LabelShowAll": "Show All",
"LabelSize": "Size", "LabelSize": "Size",
"LabelSleepTimer": "Sleep timer",
"LabelStart": "Start", "LabelStart": "Start",
"LabelStarted": "Started", "LabelStarted": "Started",
"LabelStartedAt": "Started At", "LabelStartedAt": "Started At",
@@ -364,9 +386,13 @@
"LabelToolsMakeM4bDescription": "Generate a .M4B audiobook file with embedded metadata, cover image, and chapters.", "LabelToolsMakeM4bDescription": "Generate a .M4B audiobook file with embedded metadata, cover image, and chapters.",
"LabelToolsSplitM4b": "Split M4B to MP3's", "LabelToolsSplitM4b": "Split M4B to MP3's",
"LabelToolsSplitM4bDescription": "Create MP3's from an M4B split by chapters with embedded metadata, cover image, and chapters.", "LabelToolsSplitM4bDescription": "Create MP3's from an M4B split by chapters with embedded metadata, cover image, and chapters.",
"LabelTotalDuration": "Total Duration",
"LabelTotalTimeListened": "Total Time Listened", "LabelTotalTimeListened": "Total Time Listened",
"LabelTrackFromFilename": "Track from Filename", "LabelTrackFromFilename": "Track from Filename",
"LabelTrackFromMetadata": "Track from Metadata", "LabelTrackFromMetadata": "Track from Metadata",
"LabelTracks": "Tracks",
"LabelTracksMultiTrack": "Multi-track",
"LabelTracksSingleTrack": "Single-track",
"LabelType": "Type", "LabelType": "Type",
"LabelUnknown": "Unknown", "LabelUnknown": "Unknown",
"LabelUpdateCover": "Update Cover", "LabelUpdateCover": "Update Cover",
@@ -382,19 +408,27 @@
"LabelUsername": "Username", "LabelUsername": "Username",
"LabelValue": "Value", "LabelValue": "Value",
"LabelVersion": "Version", "LabelVersion": "Version",
"LabelViewBookmarks": "View bookmarks",
"LabelViewChapters": "View chapters",
"LabelViewQueue": "View player queue",
"LabelVolume": "Volume",
"LabelWeekdaysToRun": "Weekdays to run", "LabelWeekdaysToRun": "Weekdays to run",
"LabelYourAudiobookDuration": "Your audiobook duration", "LabelYourAudiobookDuration": "Your audiobook duration",
"LabelYourBookmarks": "Your Bookmarks", "LabelYourBookmarks": "Your Bookmarks",
"LabelYourPlaylists": "Your Playlists",
"LabelYourProgress": "Your Progress", "LabelYourProgress": "Your Progress",
"MessageAddToPlayerQueue": "Add to player queue",
"MessageAppriseDescription": "To use this feature you will need to have an instance of <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> running or an api that will handle those same requests. <br />The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at <code>http://192.168.1.1:8337</code> then you would put <code>http://192.168.1.1:8337/notify</code>.", "MessageAppriseDescription": "To use this feature you will need to have an instance of <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> running or an api that will handle those same requests. <br />The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at <code>http://192.168.1.1:8337</code> then you would put <code>http://192.168.1.1:8337/notify</code>.",
"MessageBackupsDescription": "Backups include users, user progress, library item details, server settings, and images stored in", "MessageBackupsDescription": "Backups include users, user progress, library item details, server settings, and images stored in <code>/metadata/items</code> & <code>/metadata/authors</code>. Backups <strong>do not</strong> include any files stored in your library folders.",
"MessageBackupsNote": "Backups do not include any files stored in your library folders.",
"MessageBatchQuickMatchDescription": "Quick Match will attempt to add missing covers and metadata for the selected items. Enable the options below to allow Quick Match to overwrite existing covers and/or metadata.", "MessageBatchQuickMatchDescription": "Quick Match will attempt to add missing covers and metadata for the selected items. Enable the options below to allow Quick Match to overwrite existing covers and/or metadata.",
"MessageBookshelfNoCollections": "You haven't made any collections yet", "MessageBookshelfNoCollections": "You haven't made any collections yet",
"MessageBookshelfNoResultsForFilter": "No Results for filter \"{0}: {1}\"", "MessageBookshelfNoResultsForFilter": "No Results for filter \"{0}: {1}\"",
"MessageBookshelfNoRSSFeeds": "No RSS feeds are open", "MessageBookshelfNoRSSFeeds": "No RSS feeds are open",
"MessageBookshelfNoSeries": "You have no series", "MessageBookshelfNoSeries": "You have no series",
"MessageChapterEndIsAfter": "Chapter end is after the end of your audiobook", "MessageChapterEndIsAfter": "Chapter end is after the end of your audiobook",
"MessageChapterErrorFirstNotZero": "First chapter must start at 0",
"MessageChapterErrorStartGteDuration": "Invalid start time must be less than audiobook duration",
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
"MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook", "MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook",
"MessageCheckingCron": "Checking cron...", "MessageCheckingCron": "Checking cron...",
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?", "MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
@@ -404,6 +438,7 @@
"MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?", "MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?",
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?", "MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?", "MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{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!",
@@ -446,20 +481,28 @@
"MessageNoPodcastsFound": "No podcasts found", "MessageNoPodcastsFound": "No podcasts found",
"MessageNoResults": "No Results", "MessageNoResults": "No Results",
"MessageNoSearchResultsFor": "No search results for \"{0}\"", "MessageNoSearchResultsFor": "No search results for \"{0}\"",
"MessageNoSeries": "No Series",
"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",
"MessageNoUserPlaylists": "You have no playlists",
"MessageOr": "or", "MessageOr": "or",
"MessagePauseChapter": "Pause chapter playback",
"MessagePlayChapter": "Listen to beginning of chapter",
"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?",
"MessageRemoveChapter": "Remove chapter",
"MessageRemoveEpisodes": "Remove {0} episode(s)", "MessageRemoveEpisodes": "Remove {0} episode(s)",
"MessageRemoveFromPlayerQueue": "Remove from player queue",
"MessageRemoveUserWarning": "Are you sure you want to permanently delete user \"{0}\"?", "MessageRemoveUserWarning": "Are you sure you want to permanently delete user \"{0}\"?",
"MessageReportBugsAndContribute": "Report bugs, request features, and contribute on", "MessageReportBugsAndContribute": "Report bugs, request features, and contribute on",
"MessageResetChaptersConfirm": "Are you sure you want to reset chapters and undo the changes you made?",
"MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on", "MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on",
"MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.", "MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.",
"MessageSearchResultsFor": "Search results for", "MessageSearchResultsFor": "Search results for",
"MessageServerCouldNotBeReached": "Server could not be reached", "MessageServerCouldNotBeReached": "Server could not be reached",
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
"MessageStartPlaybackAtTime": "Start playback for \"{0}\" at {1}?", "MessageStartPlaybackAtTime": "Start playback for \"{0}\" at {1}?",
"MessageThinking": "Thinking...", "MessageThinking": "Thinking...",
"MessageUploaderItemFailed": "Failed to upload", "MessageUploaderItemFailed": "Failed to upload",
@@ -481,6 +524,7 @@
"NoteUploaderUnsupportedFiles": "Unsupported files are ignored. When choosing or dropping a folder, other files that are not in an item folder are ignored.", "NoteUploaderUnsupportedFiles": "Unsupported files are ignored. When choosing or dropping a folder, other files that are not in an item folder are ignored.",
"PlaceholderNewCollection": "New collection name", "PlaceholderNewCollection": "New collection name",
"PlaceholderNewFolderPath": "New folder path", "PlaceholderNewFolderPath": "New folder path",
"PlaceholderNewPlaylist": "New playlist name",
"PlaceholderSearch": "Search..", "PlaceholderSearch": "Search..",
"ToastAccountUpdateFailed": "Failed to update account", "ToastAccountUpdateFailed": "Failed to update account",
"ToastAccountUpdateSuccess": "Account updated", "ToastAccountUpdateSuccess": "Account updated",
@@ -505,6 +549,8 @@
"ToastBookmarkRemoveSuccess": "Bookmark removed", "ToastBookmarkRemoveSuccess": "Bookmark removed",
"ToastBookmarkUpdateFailed": "Failed to update bookmark", "ToastBookmarkUpdateFailed": "Failed to update bookmark",
"ToastBookmarkUpdateSuccess": "Bookmark updated", "ToastBookmarkUpdateSuccess": "Bookmark updated",
"ToastChaptersHaveErrors": "Chapters have errors",
"ToastChaptersMustHaveTitles": "Chapters must have titles",
"ToastCollectionItemsRemoveFailed": "Failed to remove item(s) from collection", "ToastCollectionItemsRemoveFailed": "Failed to remove item(s) from collection",
"ToastCollectionItemsRemoveSuccess": "Item(s) removed from collection", "ToastCollectionItemsRemoveSuccess": "Item(s) removed from collection",
"ToastCollectionRemoveFailed": "Failed to remove collection", "ToastCollectionRemoveFailed": "Failed to remove collection",
@@ -528,6 +574,10 @@
"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",
"ToastPlaylistRemoveFailed": "Failed to remove playlist",
"ToastPlaylistRemoveSuccess": "Playlist removed",
"ToastPlaylistUpdateFailed": "Failed to update playlist",
"ToastPlaylistUpdateSuccess": "Playlist updated",
"ToastPodcastCreateFailed": "Failed to create podcast", "ToastPodcastCreateFailed": "Failed to create podcast",
"ToastPodcastCreateSuccess": "Podcast created successfully", "ToastPodcastCreateSuccess": "Podcast created successfully",
"ToastRemoveItemFromCollectionFailed": "Failed to remove item from collection", "ToastRemoveItemFromCollectionFailed": "Failed to remove item from collection",
+52 -2
View File
@@ -41,6 +41,7 @@
"ButtonOpenManager": "Open Manager", "ButtonOpenManager": "Open Manager",
"ButtonPlay": "Play", "ButtonPlay": "Play",
"ButtonPlaying": "Playing", "ButtonPlaying": "Playing",
"ButtonPlaylists": "Playlists",
"ButtonPurgeAllCache": "Purge All Cache", "ButtonPurgeAllCache": "Purge All Cache",
"ButtonPurgeItemsCache": "Purge Items Cache", "ButtonPurgeItemsCache": "Purge Items Cache",
"ButtonPurgeMediaProgress": "Purge Media Progress", "ButtonPurgeMediaProgress": "Purge Media Progress",
@@ -64,6 +65,7 @@
"ButtonSearch": "Search", "ButtonSearch": "Search",
"ButtonSelectFolderPath": "Select Folder Path", "ButtonSelectFolderPath": "Select Folder Path",
"ButtonSeries": "Series", "ButtonSeries": "Series",
"ButtonSetChaptersFromTracks": "Set chapters from tracks",
"ButtonShiftTimes": "Shift Times", "ButtonShiftTimes": "Shift Times",
"ButtonShow": "Show", "ButtonShow": "Show",
"ButtonStartM4BEncode": "Start M4B Encode", "ButtonStartM4BEncode": "Start M4B Encode",
@@ -111,6 +113,8 @@
"HeaderOtherFiles": "Other Files", "HeaderOtherFiles": "Other Files",
"HeaderPermissions": "Permissions", "HeaderPermissions": "Permissions",
"HeaderPlayerQueue": "Player Queue", "HeaderPlayerQueue": "Player Queue",
"HeaderPlaylist": "Playlist",
"HeaderPlaylistItems": "Playlist Items",
"HeaderPodcastsToAdd": "Podcasts to Add", "HeaderPodcastsToAdd": "Podcasts to Add",
"HeaderPreviewCover": "Preview Cover", "HeaderPreviewCover": "Preview Cover",
"HeaderRemoveEpisode": "Remove Episode", "HeaderRemoveEpisode": "Remove Episode",
@@ -147,6 +151,8 @@
"LabelAddedAt": "Added At", "LabelAddedAt": "Added At",
"LabelAddToCollection": "Add to Collection", "LabelAddToCollection": "Add to Collection",
"LabelAddToCollectionBatch": "Add {0} Books to Collection", "LabelAddToCollectionBatch": "Add {0} Books to Collection",
"LabelAddToPlaylist": "Add to Playlist",
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
"LabelAll": "All", "LabelAll": "All",
"LabelAllUsers": "All Users", "LabelAllUsers": "All Users",
"LabelAuthor": "Author", "LabelAuthor": "Author",
@@ -165,6 +171,7 @@
"LabelChangePassword": "Change Password", "LabelChangePassword": "Change Password",
"LabelChaptersFound": "chapters found", "LabelChaptersFound": "chapters found",
"LabelChapterTitle": "Chapter Title", "LabelChapterTitle": "Chapter Title",
"LabelClosePlayer": "Close player",
"LabelCollapseSeries": "Collapse Series", "LabelCollapseSeries": "Collapse Series",
"LabelCollections": "Collections", "LabelCollections": "Collections",
"LabelComplete": "Complete", "LabelComplete": "Complete",
@@ -214,6 +221,14 @@
"LabelIncomplete": "Incomplete", "LabelIncomplete": "Incomplete",
"LabelInProgress": "In Progress", "LabelInProgress": "In Progress",
"LabelInterval": "Interval", "LabelInterval": "Interval",
"LabelIntervalCustomDailyWeekly": "Custom daily/weekly",
"LabelIntervalEvery12Hours": "Every 12 hours",
"LabelIntervalEvery15Minutes": "Every 15 minutes",
"LabelIntervalEvery2Hours": "Every 2 hours",
"LabelIntervalEvery30Minutes": "Every 30 minutes",
"LabelIntervalEvery6Hours": "Every 6 hours",
"LabelIntervalEveryDay": "Every day",
"LabelIntervalEveryHour": "Every hour",
"LabelInvalidParts": "Invalid Parts", "LabelInvalidParts": "Invalid Parts",
"LabelItem": "Item", "LabelItem": "Item",
"LabelLanguage": "Language", "LabelLanguage": "Language",
@@ -228,6 +243,9 @@
"LabelLibraryName": "Library Name", "LabelLibraryName": "Library Name",
"LabelLimit": "Limit", "LabelLimit": "Limit",
"LabelListenAgain": "Listen Again", "LabelListenAgain": "Listen Again",
"LabelLogLevelDebug": "Debug",
"LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date", "LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date",
"LabelMarkSeries": "Mark Series", "LabelMarkSeries": "Mark Series",
"LabelMediaPlayer": "Media Player", "LabelMediaPlayer": "Media Player",
@@ -257,6 +275,7 @@
"LabelNotificationsMaxQueueSizeHelp": "Events are limited to firing 1 per second. Events will be ignored if the queue is at max size. This prevents notification spamming.", "LabelNotificationsMaxQueueSizeHelp": "Events are limited to firing 1 per second. Events will be ignored if the queue is at max size. This prevents notification spamming.",
"LabelNotificationTitleTemplate": "Title Template", "LabelNotificationTitleTemplate": "Title Template",
"LabelNotStarted": "Not Started", "LabelNotStarted": "Not Started",
"LabelNumberOfBooks": "Number of Books",
"LabelNumberOfEpisodes": "# of Episodes", "LabelNumberOfEpisodes": "# of Episodes",
"LabelOpenRSSFeed": "Open RSS Feed", "LabelOpenRSSFeed": "Open RSS Feed",
"LabelPassword": "Password", "LabelPassword": "Password",
@@ -269,6 +288,7 @@
"LabelPermissionsUpdate": "Can Update", "LabelPermissionsUpdate": "Can Update",
"LabelPermissionsUpload": "Can Upload", "LabelPermissionsUpload": "Can Upload",
"LabelPhotoPathURL": "Photo Path/URL", "LabelPhotoPathURL": "Photo Path/URL",
"LabelPlaylists": "Playlists",
"LabelPlayMethod": "Play Method", "LabelPlayMethod": "Play Method",
"LabelPodcast": "Podcast", "LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts", "LabelPodcasts": "Podcasts",
@@ -282,6 +302,7 @@
"LabelRecentSeries": "Recent Series", "LabelRecentSeries": "Recent Series",
"LabelRegion": "Region", "LabelRegion": "Region",
"LabelReleaseDate": "Release Date", "LabelReleaseDate": "Release Date",
"LabelRemoveCover": "Remove cover",
"LabelRSSFeedOpen": "RSS Feed Open", "LabelRSSFeedOpen": "RSS Feed Open",
"LabelRSSFeedSlug": "RSS Feed Slug", "LabelRSSFeedSlug": "RSS Feed Slug",
"LabelRSSFeedURL": "RSS Feed URL", "LabelRSSFeedURL": "RSS Feed URL",
@@ -329,6 +350,7 @@
"LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension", "LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension",
"LabelShowAll": "Show All", "LabelShowAll": "Show All",
"LabelSize": "Size", "LabelSize": "Size",
"LabelSleepTimer": "Sleep timer",
"LabelStart": "Start", "LabelStart": "Start",
"LabelStarted": "Started", "LabelStarted": "Started",
"LabelStartedAt": "Started At", "LabelStartedAt": "Started At",
@@ -364,9 +386,13 @@
"LabelToolsMakeM4bDescription": "Generate a .M4B audiobook file with embedded metadata, cover image, and chapters.", "LabelToolsMakeM4bDescription": "Generate a .M4B audiobook file with embedded metadata, cover image, and chapters.",
"LabelToolsSplitM4b": "Split M4B to MP3's", "LabelToolsSplitM4b": "Split M4B to MP3's",
"LabelToolsSplitM4bDescription": "Create MP3's from an M4B split by chapters with embedded metadata, cover image, and chapters.", "LabelToolsSplitM4bDescription": "Create MP3's from an M4B split by chapters with embedded metadata, cover image, and chapters.",
"LabelTotalDuration": "Total Duration",
"LabelTotalTimeListened": "Total Time Listened", "LabelTotalTimeListened": "Total Time Listened",
"LabelTrackFromFilename": "Track from Filename", "LabelTrackFromFilename": "Track from Filename",
"LabelTrackFromMetadata": "Track from Metadata", "LabelTrackFromMetadata": "Track from Metadata",
"LabelTracks": "Tracks",
"LabelTracksMultiTrack": "Multi-track",
"LabelTracksSingleTrack": "Single-track",
"LabelType": "Type", "LabelType": "Type",
"LabelUnknown": "Unknown", "LabelUnknown": "Unknown",
"LabelUpdateCover": "Update Cover", "LabelUpdateCover": "Update Cover",
@@ -382,19 +408,27 @@
"LabelUsername": "Username", "LabelUsername": "Username",
"LabelValue": "Value", "LabelValue": "Value",
"LabelVersion": "Version", "LabelVersion": "Version",
"LabelViewBookmarks": "View bookmarks",
"LabelViewChapters": "View chapters",
"LabelViewQueue": "View player queue",
"LabelVolume": "Volume",
"LabelWeekdaysToRun": "Weekdays to run", "LabelWeekdaysToRun": "Weekdays to run",
"LabelYourAudiobookDuration": "Your audiobook duration", "LabelYourAudiobookDuration": "Your audiobook duration",
"LabelYourBookmarks": "Your Bookmarks", "LabelYourBookmarks": "Your Bookmarks",
"LabelYourPlaylists": "Your Playlists",
"LabelYourProgress": "Your Progress", "LabelYourProgress": "Your Progress",
"MessageAddToPlayerQueue": "Add to player queue",
"MessageAppriseDescription": "To use this feature you will need to have an instance of <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> running or an api that will handle those same requests. <br />The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at <code>http://192.168.1.1:8337</code> then you would put <code>http://192.168.1.1:8337/notify</code>.", "MessageAppriseDescription": "To use this feature you will need to have an instance of <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> running or an api that will handle those same requests. <br />The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at <code>http://192.168.1.1:8337</code> then you would put <code>http://192.168.1.1:8337/notify</code>.",
"MessageBackupsDescription": "Backups include users, user progress, library item details, server settings, and images stored in", "MessageBackupsDescription": "Backups include users, user progress, library item details, server settings, and images stored in <code>/metadata/items</code> & <code>/metadata/authors</code>. Backups <strong>do not</strong> include any files stored in your library folders.",
"MessageBackupsNote": "Backups do not include any files stored in your library folders.",
"MessageBatchQuickMatchDescription": "Quick Match will attempt to add missing covers and metadata for the selected items. Enable the options below to allow Quick Match to overwrite existing covers and/or metadata.", "MessageBatchQuickMatchDescription": "Quick Match will attempt to add missing covers and metadata for the selected items. Enable the options below to allow Quick Match to overwrite existing covers and/or metadata.",
"MessageBookshelfNoCollections": "You haven't made any collections yet", "MessageBookshelfNoCollections": "You haven't made any collections yet",
"MessageBookshelfNoResultsForFilter": "No Results for filter \"{0}: {1}\"", "MessageBookshelfNoResultsForFilter": "No Results for filter \"{0}: {1}\"",
"MessageBookshelfNoRSSFeeds": "No RSS feeds are open", "MessageBookshelfNoRSSFeeds": "No RSS feeds are open",
"MessageBookshelfNoSeries": "You have no series", "MessageBookshelfNoSeries": "You have no series",
"MessageChapterEndIsAfter": "Chapter end is after the end of your audiobook", "MessageChapterEndIsAfter": "Chapter end is after the end of your audiobook",
"MessageChapterErrorFirstNotZero": "First chapter must start at 0",
"MessageChapterErrorStartGteDuration": "Invalid start time must be less than audiobook duration",
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
"MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook", "MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook",
"MessageCheckingCron": "Checking cron...", "MessageCheckingCron": "Checking cron...",
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?", "MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
@@ -404,6 +438,7 @@
"MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?", "MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?",
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?", "MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?", "MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{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!",
@@ -446,20 +481,28 @@
"MessageNoPodcastsFound": "No podcasts found", "MessageNoPodcastsFound": "No podcasts found",
"MessageNoResults": "No Results", "MessageNoResults": "No Results",
"MessageNoSearchResultsFor": "No search results for \"{0}\"", "MessageNoSearchResultsFor": "No search results for \"{0}\"",
"MessageNoSeries": "No Series",
"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",
"MessageNoUserPlaylists": "You have no playlists",
"MessageOr": "or", "MessageOr": "or",
"MessagePauseChapter": "Pause chapter playback",
"MessagePlayChapter": "Listen to beginning of chapter",
"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?",
"MessageRemoveChapter": "Remove chapter",
"MessageRemoveEpisodes": "Remove {0} episode(s)", "MessageRemoveEpisodes": "Remove {0} episode(s)",
"MessageRemoveFromPlayerQueue": "Remove from player queue",
"MessageRemoveUserWarning": "Are you sure you want to permanently delete user \"{0}\"?", "MessageRemoveUserWarning": "Are you sure you want to permanently delete user \"{0}\"?",
"MessageReportBugsAndContribute": "Report bugs, request features, and contribute on", "MessageReportBugsAndContribute": "Report bugs, request features, and contribute on",
"MessageResetChaptersConfirm": "Are you sure you want to reset chapters and undo the changes you made?",
"MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on", "MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on",
"MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.", "MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.",
"MessageSearchResultsFor": "Search results for", "MessageSearchResultsFor": "Search results for",
"MessageServerCouldNotBeReached": "Server could not be reached", "MessageServerCouldNotBeReached": "Server could not be reached",
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
"MessageStartPlaybackAtTime": "Start playback for \"{0}\" at {1}?", "MessageStartPlaybackAtTime": "Start playback for \"{0}\" at {1}?",
"MessageThinking": "Thinking...", "MessageThinking": "Thinking...",
"MessageUploaderItemFailed": "Failed to upload", "MessageUploaderItemFailed": "Failed to upload",
@@ -481,6 +524,7 @@
"NoteUploaderUnsupportedFiles": "Unsupported files are ignored. When choosing or dropping a folder, other files that are not in an item folder are ignored.", "NoteUploaderUnsupportedFiles": "Unsupported files are ignored. When choosing or dropping a folder, other files that are not in an item folder are ignored.",
"PlaceholderNewCollection": "New collection name", "PlaceholderNewCollection": "New collection name",
"PlaceholderNewFolderPath": "New folder path", "PlaceholderNewFolderPath": "New folder path",
"PlaceholderNewPlaylist": "New playlist name",
"PlaceholderSearch": "Search..", "PlaceholderSearch": "Search..",
"ToastAccountUpdateFailed": "Failed to update account", "ToastAccountUpdateFailed": "Failed to update account",
"ToastAccountUpdateSuccess": "Account updated", "ToastAccountUpdateSuccess": "Account updated",
@@ -505,6 +549,8 @@
"ToastBookmarkRemoveSuccess": "Bookmark removed", "ToastBookmarkRemoveSuccess": "Bookmark removed",
"ToastBookmarkUpdateFailed": "Failed to update bookmark", "ToastBookmarkUpdateFailed": "Failed to update bookmark",
"ToastBookmarkUpdateSuccess": "Bookmark updated", "ToastBookmarkUpdateSuccess": "Bookmark updated",
"ToastChaptersHaveErrors": "Chapters have errors",
"ToastChaptersMustHaveTitles": "Chapters must have titles",
"ToastCollectionItemsRemoveFailed": "Failed to remove item(s) from collection", "ToastCollectionItemsRemoveFailed": "Failed to remove item(s) from collection",
"ToastCollectionItemsRemoveSuccess": "Item(s) removed from collection", "ToastCollectionItemsRemoveSuccess": "Item(s) removed from collection",
"ToastCollectionRemoveFailed": "Failed to remove collection", "ToastCollectionRemoveFailed": "Failed to remove collection",
@@ -528,6 +574,10 @@
"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",
"ToastPlaylistRemoveFailed": "Failed to remove playlist",
"ToastPlaylistRemoveSuccess": "Playlist removed",
"ToastPlaylistUpdateFailed": "Failed to update playlist",
"ToastPlaylistUpdateSuccess": "Playlist updated",
"ToastPodcastCreateFailed": "Failed to create podcast", "ToastPodcastCreateFailed": "Failed to create podcast",
"ToastPodcastCreateSuccess": "Podcast created successfully", "ToastPodcastCreateSuccess": "Podcast created successfully",
"ToastRemoveItemFromCollectionFailed": "Failed to remove item from collection", "ToastRemoveItemFromCollectionFailed": "Failed to remove item from collection",
+68 -18
View File
@@ -13,10 +13,10 @@
"ButtonCheckAndDownloadNewEpisodes": "Vérifier & Télécharger de Nouveaux Episodes", "ButtonCheckAndDownloadNewEpisodes": "Vérifier & Télécharger de Nouveaux Episodes",
"ButtonChooseAFolder": "Choisir un Dossier", "ButtonChooseAFolder": "Choisir un Dossier",
"ButtonChooseFiles": "Choisir les Fichiers", "ButtonChooseFiles": "Choisir les Fichiers",
"ButtonClearFilter": "Clear Filter", "ButtonClearFilter": "Effacer le Filtre",
"ButtonCloseFeed": "Fermer le Flux", "ButtonCloseFeed": "Fermer le Flux",
"ButtonCollections": "Collections", "ButtonCollections": "Collections",
"ButtonConfigureScanner": "Configure Scanner", "ButtonConfigureScanner": "Configurer le Scan",
"ButtonCreate": "Créer", "ButtonCreate": "Créer",
"ButtonCreateBackup": "Créer une Sauvegarde", "ButtonCreateBackup": "Créer une Sauvegarde",
"ButtonDelete": "Effacer", "ButtonDelete": "Effacer",
@@ -26,7 +26,7 @@
"ButtonFullPath": "Chemin Complet", "ButtonFullPath": "Chemin Complet",
"ButtonHide": "Cacher", "ButtonHide": "Cacher",
"ButtonHome": "Accueil", "ButtonHome": "Accueil",
"ButtonIssues": "Problèmes", "ButtonIssues": "Parutions",
"ButtonLatest": "Dernière Version", "ButtonLatest": "Dernière Version",
"ButtonLibrary": "Bibliothèque", "ButtonLibrary": "Bibliothèque",
"ButtonLogout": "Se Déconnecter", "ButtonLogout": "Se Déconnecter",
@@ -41,6 +41,7 @@
"ButtonOpenManager": "Ouvrir le Gestionnaire", "ButtonOpenManager": "Ouvrir le Gestionnaire",
"ButtonPlay": "Ecouter", "ButtonPlay": "Ecouter",
"ButtonPlaying": "En Lecture", "ButtonPlaying": "En Lecture",
"ButtonPlaylists": "Listes de Lecture",
"ButtonPurgeAllCache": "Purger Tout le Cache", "ButtonPurgeAllCache": "Purger Tout le Cache",
"ButtonPurgeItemsCache": "Purger le Cache des Articles", "ButtonPurgeItemsCache": "Purger le Cache des Articles",
"ButtonPurgeMediaProgress": "Purger la Progression des Médias", "ButtonPurgeMediaProgress": "Purger la Progression des Médias",
@@ -60,10 +61,11 @@
"ButtonSaveAndClose": "Sauvegarder & Fermer", "ButtonSaveAndClose": "Sauvegarder & Fermer",
"ButtonSaveTracklist": "Sauvegarder la Tracklist", "ButtonSaveTracklist": "Sauvegarder la Tracklist",
"ButtonScan": "Scanner", "ButtonScan": "Scanner",
"ButtonScanLibrary": "Scan Library", "ButtonScanLibrary": "Scanner la Bibliothèque",
"ButtonSearch": "Rechercher", "ButtonSearch": "Rechercher",
"ButtonSelectFolderPath": "Sélectionner le Chemin du Dossier", "ButtonSelectFolderPath": "Sélectionner le Chemin du Dossier",
"ButtonSeries": "Séries", "ButtonSeries": "Séries",
"ButtonSetChaptersFromTracks": "Positionner les Chapitre par rapports aux Pistes",
"ButtonShiftTimes": "Décaler le Temps", "ButtonShiftTimes": "Décaler le Temps",
"ButtonShow": "Montrer", "ButtonShow": "Montrer",
"ButtonStartM4BEncode": "Démarrer l'Encodage M4B", "ButtonStartM4BEncode": "Démarrer l'Encodage M4B",
@@ -111,6 +113,8 @@
"HeaderOtherFiles": "Autres Fichiers", "HeaderOtherFiles": "Autres Fichiers",
"HeaderPermissions": "Permissions", "HeaderPermissions": "Permissions",
"HeaderPlayerQueue": "Liste d'Ecoute", "HeaderPlayerQueue": "Liste d'Ecoute",
"HeaderPlaylist": "Liste de Lecture",
"HeaderPlaylistItems": "Elements de la Liste de Lecture",
"HeaderPodcastsToAdd": "Podcasts à Ajouter", "HeaderPodcastsToAdd": "Podcasts à Ajouter",
"HeaderPreviewCover": "Prévisualiser la Couverture", "HeaderPreviewCover": "Prévisualiser la Couverture",
"HeaderRemoveEpisode": "Supprimer l'Episode", "HeaderRemoveEpisode": "Supprimer l'Episode",
@@ -147,7 +151,9 @@
"LabelAddedAt": "Date d'Ajout", "LabelAddedAt": "Date d'Ajout",
"LabelAddToCollection": "Ajouter à la Collection", "LabelAddToCollection": "Ajouter à la Collection",
"LabelAddToCollectionBatch": "Ajout de {0} Livres à la Collection", "LabelAddToCollectionBatch": "Ajout de {0} Livres à la Collection",
"LabelAll": "All", "LabelAddToPlaylist": "Ajouter à la Liste de Lecture",
"LabelAddToPlaylistBatch": "{0} Elements Ajoutés à la Liste de Lecture",
"LabelAll": "Tout",
"LabelAllUsers": "Tous les Utilisateurs", "LabelAllUsers": "Tous les Utilisateurs",
"LabelAuthor": "Auteur", "LabelAuthor": "Auteur",
"LabelAuthorFirstLast": "Auteur (Prénom Nom)", "LabelAuthorFirstLast": "Auteur (Prénom Nom)",
@@ -165,6 +171,7 @@
"LabelChangePassword": "Changer le Mot de Passe", "LabelChangePassword": "Changer le Mot de Passe",
"LabelChaptersFound": "Chapitres Trouvés", "LabelChaptersFound": "Chapitres Trouvés",
"LabelChapterTitle": "Titres du Chapitre", "LabelChapterTitle": "Titres du Chapitre",
"LabelClosePlayer": "Fermer le Lecteur",
"LabelCollapseSeries": "Réduire les Séries", "LabelCollapseSeries": "Réduire les Séries",
"LabelCollections": "Collections", "LabelCollections": "Collections",
"LabelComplete": "Complet", "LabelComplete": "Complet",
@@ -202,7 +209,7 @@
"LabelFilename": "Nom de Fichier", "LabelFilename": "Nom de Fichier",
"LabelFilterByUser": "Filtrer par l'Utilisateur", "LabelFilterByUser": "Filtrer par l'Utilisateur",
"LabelFindEpisodes": "Trouver des Episodes", "LabelFindEpisodes": "Trouver des Episodes",
"LabelFinished": "Finis", "LabelFinished": "Fini(e)",
"LabelFolder": "Dossier", "LabelFolder": "Dossier",
"LabelFolders": "Dossiers", "LabelFolders": "Dossiers",
"LabelGenre": "Genre", "LabelGenre": "Genre",
@@ -214,6 +221,14 @@
"LabelIncomplete": "Incomplet", "LabelIncomplete": "Incomplet",
"LabelInProgress": "En Cours", "LabelInProgress": "En Cours",
"LabelInterval": "Interval", "LabelInterval": "Interval",
"LabelIntervalCustomDailyWeekly": "Journalier/Hebdomadaire Personnalisé",
"LabelIntervalEvery12Hours": "Toutes les 12 heures",
"LabelIntervalEvery15Minutes": "Toutes les 15 minutes",
"LabelIntervalEvery2Hours": "Toutes les 2 heures",
"LabelIntervalEvery30Minutes": "Toutes les 30 minutes",
"LabelIntervalEvery6Hours": "Toutes les 6 heures",
"LabelIntervalEveryDay": "Tous les jours",
"LabelIntervalEveryHour": "Toutes les heures",
"LabelInvalidParts": "Parties Invalides", "LabelInvalidParts": "Parties Invalides",
"LabelItem": "Article", "LabelItem": "Article",
"LabelLanguage": "Langue", "LabelLanguage": "Langue",
@@ -228,6 +243,9 @@
"LabelLibraryName": "Nom de Bibliothèque", "LabelLibraryName": "Nom de Bibliothèque",
"LabelLimit": "Limite", "LabelLimit": "Limite",
"LabelListenAgain": "Ecouter à Nouveau", "LabelListenAgain": "Ecouter à Nouveau",
"LabelLogLevelDebug": "Debug",
"LabelLogLevelInfo": "Info",
"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", "LabelMarkSeries": "Marquer la Série",
"LabelMediaPlayer": "Lecteur Multimédia", "LabelMediaPlayer": "Lecteur Multimédia",
@@ -246,7 +264,7 @@
"LabelNewestEpisodes": "Derniers Episodes", "LabelNewestEpisodes": "Derniers Episodes",
"LabelNewPassword": "Nouveau Mot de Passe", "LabelNewPassword": "Nouveau Mot de Passe",
"LabelNotes": "Notes", "LabelNotes": "Notes",
"LabelNotFinished": "Non Terminés", "LabelNotFinished": "Non Terminé(e)",
"LabelNotificationAppriseURL": "URL(s) d'Apprise", "LabelNotificationAppriseURL": "URL(s) d'Apprise",
"LabelNotificationAvailableVariables": "Variables Disponibles", "LabelNotificationAvailableVariables": "Variables Disponibles",
"LabelNotificationBodyTemplate": "Modèle de Message", "LabelNotificationBodyTemplate": "Modèle de Message",
@@ -256,7 +274,8 @@
"LabelNotificationsMaxQueueSize": "Nombres de notifications maximum à mettre en attente", "LabelNotificationsMaxQueueSize": "Nombres de notifications maximum à mettre en attente",
"LabelNotificationsMaxQueueSizeHelp": "La limite de notification est de un évènement par seconde. Le notification seront ignorées si la file d'attente est à son maximum. Cela empêche un flot trop important.", "LabelNotificationsMaxQueueSizeHelp": "La limite de notification est de un évènement par seconde. Le notification seront ignorées si la file d'attente est à son maximum. Cela empêche un flot trop important.",
"LabelNotificationTitleTemplate": "Modèle de Titre", "LabelNotificationTitleTemplate": "Modèle de Titre",
"LabelNotStarted": "Not Started", "LabelNotStarted": "Non Démarré(e)",
"LabelNumberOfBooks": "Nombre de Livres",
"LabelNumberOfEpisodes": "Nombre d'Episodes", "LabelNumberOfEpisodes": "Nombre d'Episodes",
"LabelOpenRSSFeed": "Ouvrir le flux RSS", "LabelOpenRSSFeed": "Ouvrir le flux RSS",
"LabelPassword": "Mot de Passe", "LabelPassword": "Mot de Passe",
@@ -269,6 +288,7 @@
"LabelPermissionsUpdate": "Peut Mettre à Jour", "LabelPermissionsUpdate": "Peut Mettre à Jour",
"LabelPermissionsUpload": "Peut Téléverser", "LabelPermissionsUpload": "Peut Téléverser",
"LabelPhotoPathURL": "Chemin/URL des photos", "LabelPhotoPathURL": "Chemin/URL des photos",
"LabelPlaylists": "Listes de Lecture",
"LabelPlayMethod": "Méthode d'Ecoute", "LabelPlayMethod": "Méthode d'Ecoute",
"LabelPodcast": "Podcast", "LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts", "LabelPodcasts": "Podcasts",
@@ -282,6 +302,7 @@
"LabelRecentSeries": "Séries Récentes", "LabelRecentSeries": "Séries Récentes",
"LabelRegion": "Région", "LabelRegion": "Région",
"LabelReleaseDate": "Date de Parution", "LabelReleaseDate": "Date de Parution",
"LabelRemoveCover": "Supprimer la Couverture",
"LabelRSSFeedOpen": "Flux RSS Ouvert", "LabelRSSFeedOpen": "Flux RSS Ouvert",
"LabelRSSFeedSlug": "Flux RSS Slug", "LabelRSSFeedSlug": "Flux RSS Slug",
"LabelRSSFeedURL": "URL du Flux RSS", "LabelRSSFeedURL": "URL du Flux RSS",
@@ -290,7 +311,7 @@
"LabelSearchTitleOrASIN": "Recherche du Titre ou ASIN", "LabelSearchTitleOrASIN": "Recherche du Titre ou ASIN",
"LabelSeason": "Saison", "LabelSeason": "Saison",
"LabelSequence": "Séquence", "LabelSequence": "Séquence",
"LabelSeries": "Série", "LabelSeries": "Séries",
"LabelSeriesName": "Nom de la Série", "LabelSeriesName": "Nom de la Série",
"LabelSeriesProgress": "Progression de Séries", "LabelSeriesProgress": "Progression de Séries",
"LabelSettingsBookshelfViewHelp": "Design Skeumorphic avec une Etagère en Bois", "LabelSettingsBookshelfViewHelp": "Design Skeumorphic avec une Etagère en Bois",
@@ -329,6 +350,7 @@
"LabelSettingsStoreMetadataWithItemHelp": "Par défaut, les métadonnées sont enregistrées dans /metadata/items. Activer ce paramètre enregistrera les métadonnées dans le dossier de l'article avec une extension \".abs\".", "LabelSettingsStoreMetadataWithItemHelp": "Par défaut, les métadonnées sont enregistrées dans /metadata/items. Activer ce paramètre enregistrera les métadonnées dans le dossier de l'article avec une extension \".abs\".",
"LabelShowAll": "Afficher Tout", "LabelShowAll": "Afficher Tout",
"LabelSize": "Taille", "LabelSize": "Taille",
"LabelSleepTimer": "Minuterie",
"LabelStart": "Démarrer", "LabelStart": "Démarrer",
"LabelStarted": "Démarré", "LabelStarted": "Démarré",
"LabelStartedAt": "Démarré à", "LabelStartedAt": "Démarré à",
@@ -364,9 +386,13 @@
"LabelToolsMakeM4bDescription": "Génère un fichier Livre Audio .M4B avec intégration des métadonnées, image de couverture et les chapitres.", "LabelToolsMakeM4bDescription": "Génère un fichier Livre Audio .M4B avec intégration des métadonnées, image de couverture et les chapitres.",
"LabelToolsSplitM4b": "Scinde le fichier M4B en fichiers MP3", "LabelToolsSplitM4b": "Scinde le fichier M4B en fichiers MP3",
"LabelToolsSplitM4bDescription": "Créer plusieurs fichier MP3 à partir du découpage par chapitre, en incluant les métadonnées, l'image de couverture et les chapitres.", "LabelToolsSplitM4bDescription": "Créer plusieurs fichier MP3 à partir du découpage par chapitre, en incluant les métadonnées, l'image de couverture et les chapitres.",
"LabelTotalDuration": "Durée Totale",
"LabelTotalTimeListened": "Temps d'Ecoute Total", "LabelTotalTimeListened": "Temps d'Ecoute Total",
"LabelTrackFromFilename": "Piste depuis le Fichier", "LabelTrackFromFilename": "Piste depuis le Fichier",
"LabelTrackFromMetadata": "Piste depuis les Métadonnées", "LabelTrackFromMetadata": "Piste depuis les Métadonnées",
"LabelTracks": "Pistes",
"LabelTracksMultiTrack": "Piste Multiple",
"LabelTracksSingleTrack": "Piste Simple",
"LabelType": "Type", "LabelType": "Type",
"LabelUnknown": "Inconnu", "LabelUnknown": "Inconnu",
"LabelUpdateCover": "Mettre à jour la Couverture", "LabelUpdateCover": "Mettre à jour la Couverture",
@@ -382,19 +408,27 @@
"LabelUsername": "Nom d'Utilisateur", "LabelUsername": "Nom d'Utilisateur",
"LabelValue": "Valeur", "LabelValue": "Valeur",
"LabelVersion": "Version", "LabelVersion": "Version",
"LabelViewBookmarks": "Voir les Signets",
"LabelViewChapters": "Voir les Chapitres",
"LabelViewQueue": "Voir la Liste de Lecture",
"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",
"LabelYourBookmarks": "Vos Signets", "LabelYourBookmarks": "Vos Signets",
"LabelYourPlaylists": "Vos Listes de Lecture",
"LabelYourProgress": "Votre Progression", "LabelYourProgress": "Votre Progression",
"MessageAddToPlayerQueue": "Ajouter en Queue d'Ecoute",
"MessageAppriseDescription": "Nécessite une instance d'<a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">API Apprise</a> pour utiliser cette fonctionnalité ou une api qui prend en charge les mêmes requêtes. <br />L'URL de l'API Apprise doit comprendre le chemin complet pour envoyer la notification. Par exemple, si votre instance écoute sur <code>http://192.168.1.1:8337</code> alors vous devez mettre <code>http://192.168.1.1:8337/notify</code>.", "MessageAppriseDescription": "Nécessite une instance d'<a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">API Apprise</a> pour utiliser cette fonctionnalité ou une api qui prend en charge les mêmes requêtes. <br />L'URL de l'API Apprise doit comprendre le chemin complet pour envoyer la notification. Par exemple, si votre instance écoute sur <code>http://192.168.1.1:8337</code> alors vous devez mettre <code>http://192.168.1.1:8337/notify</code>.",
"MessageBackupsDescription": "Les Sauvegardes incluent les utilisateurs, la progression de lecture par utilisateur, les détails des articles des bibliothèques, les paramètres du serveur et les images sauvegardées.", "MessageBackupsDescription": "Les Sauvegardes incluent les utilisateurs, la progression de lecture par utilisateur, les détails des articles des bibliothèques, les paramètres du serveur et les images sauvegardées. Les Sauvegardes n'incluent pas les fichiers de votre bibliothèque.",
"MessageBackupsNote": "Les Sauvegardes n'incluent pas les fichiers de votre bibliothèque.",
"MessageBatchQuickMatchDescription": "La Recherche par Correspondance Rapide tentera d'ajouter les couvertures et les métadonnées manquantes pour les articles sélectionnés. Activer l'option suivante pour autoriser la Recherche par Correspondance à écraser les données existantes.", "MessageBatchQuickMatchDescription": "La Recherche par Correspondance Rapide tentera d'ajouter les couvertures et les métadonnées manquantes pour les articles sélectionnés. Activer l'option suivante pour autoriser la Recherche par Correspondance à écraser les données existantes.",
"MessageBookshelfNoCollections": "You haven't made any collections yet", "MessageBookshelfNoCollections": "Vous n'avez pas encore de collections",
"MessageBookshelfNoResultsForFilter": "No Results for filter \"{0}: {1}\"", "MessageBookshelfNoResultsForFilter": "Aucun résultat pour le filtre \"{0}: {1}\"",
"MessageBookshelfNoRSSFeeds": "No RSS feeds are open", "MessageBookshelfNoRSSFeeds": "Aucun flux RSS n'est ouvert",
"MessageBookshelfNoSeries": "You have no series", "MessageBookshelfNoSeries": "Vous n'avez aucune séries",
"MessageChapterEndIsAfter": "Le Chapitre Fin est situé à la fin de votre Livre Audio", "MessageChapterEndIsAfter": "Le Chapitre Fin est situé à la fin de votre Livre Audio",
"MessageChapterErrorFirstNotZero": "Le premier capitre doit débuter à 0",
"MessageChapterErrorStartGteDuration": "Horodatage invalide car il doit débuter avant la fin du livre",
"MessageChapterErrorStartLtPrev": "Horodatage invalide car il doit débuter au moins après le précédent chapitre",
"MessageChapterStartIsAfter": "Le Chapitre Début est situé au début de votre Livre Audio", "MessageChapterStartIsAfter": "Le Chapitre Début est situé au début de votre Livre Audio",
"MessageCheckingCron": "Vérification du cron...", "MessageCheckingCron": "Vérification du cron...",
"MessageConfirmDeleteBackup": "Etes vous certain de vouloir supprimer la Sauvegarde de {0}?", "MessageConfirmDeleteBackup": "Etes vous certain de vouloir supprimer la Sauvegarde de {0}?",
@@ -404,6 +438,7 @@
"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}\"?",
"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!",
@@ -436,7 +471,7 @@
"MessageNoEpisodes": "Pas d'Episodes", "MessageNoEpisodes": "Pas d'Episodes",
"MessageNoFoldersAvailable": "Pas de Dossiers Disponibles", "MessageNoFoldersAvailable": "Pas de Dossiers Disponibles",
"MessageNoGenres": "Pas de Genres", "MessageNoGenres": "Pas de Genres",
"MessageNoIssues": "No Issues", "MessageNoIssues": "Pas de Parution",
"MessageNoItems": "Pas d'Articles", "MessageNoItems": "Pas d'Articles",
"MessageNoItemsFound": "Pas d'Articles Trouvés", "MessageNoItemsFound": "Pas d'Articles Trouvés",
"MessageNoListeningSessions": "Pas de Sessions d'Ecoutes", "MessageNoListeningSessions": "Pas de Sessions d'Ecoutes",
@@ -446,20 +481,28 @@
"MessageNoPodcastsFound": "Pas de podcasts trouvés", "MessageNoPodcastsFound": "Pas de podcasts trouvés",
"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",
"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",
"MessageNoUserPlaylists": "Vous n'avez aucune liste de lecture",
"MessageOr": "ou", "MessageOr": "ou",
"MessagePauseChapter": "Suspendre la lecture du chapitre",
"MessagePlayChapter": "Ecouter depuis le début du chapitre",
"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?",
"MessageRemoveChapter": "Supprimer le chapitre",
"MessageRemoveEpisodes": "Suppression de {0} épisode(s)", "MessageRemoveEpisodes": "Suppression de {0} épisode(s)",
"MessageRemoveFromPlayerQueue": "Supprimer de la liste d'écoute",
"MessageRemoveUserWarning": "Etes-vous certain de vouloir supprimer définitivement l'utilisateur \"{0}\"?", "MessageRemoveUserWarning": "Etes-vous certain de vouloir supprimer définitivement l'utilisateur \"{0}\"?",
"MessageReportBugsAndContribute": "Remonter des anomalies, demander des fonctionnalités et contribuer sur", "MessageReportBugsAndContribute": "Remonter des anomalies, demander des fonctionnalités et contribuer sur",
"MessageResetChaptersConfirm": "Etes-vous certain de vouloir réinitialiser les chapitres et annuler les changements effectués?",
"MessageRestoreBackupConfirm": "Etes-vous certain de vouloir restaurer la sauvegarde créée le", "MessageRestoreBackupConfirm": "Etes-vous certain de vouloir restaurer la sauvegarde créée le",
"MessageRestoreBackupWarning": "Restaurer la sauvegarde écrasera la base de donnée située dans le dossier /config ainsi que les images sur /metadata/items & /metadata/authors.<br /><br />Les sauvegardes ne touchent pas aux fichiers de la bibliothèque. Si vous avez activé le paramètre pour sauvegarder les métadonnées et les images de couverture dans le même dossier que les fichiers, ceux-ci ne ni sauvegardés, ni écrasés lors de la restauration.<br /><br />Tous les clients utilisant votre serveur seront automatiquement mis à jour.", "MessageRestoreBackupWarning": "Restaurer la sauvegarde écrasera la base de donnée située dans le dossier /config ainsi que les images sur /metadata/items & /metadata/authors.<br /><br />Les sauvegardes ne touchent pas aux fichiers de la bibliothèque. Si vous avez activé le paramètre pour sauvegarder les métadonnées et les images de couverture dans le même dossier que les fichiers, ceux-ci ne ni sauvegardés, ni écrasés lors de la restauration.<br /><br />Tous les clients utilisant votre serveur seront automatiquement mis à jour.",
"MessageSearchResultsFor": "Résultats de recherche pour", "MessageSearchResultsFor": "Résultats de recherche pour",
"MessageServerCouldNotBeReached": "Serveur inaccessible", "MessageServerCouldNotBeReached": "Serveur inaccessible",
"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": "Echec du téléversement",
@@ -467,7 +510,7 @@
"MessageUploading": "Téléversement...", "MessageUploading": "Téléversement...",
"MessageValidCronExpression": "Expression cron valide", "MessageValidCronExpression": "Expression cron valide",
"MessageWatcherIsDisabledGlobally": "La Surveillance est désactivée par un paramètre global du serveur", "MessageWatcherIsDisabledGlobally": "La Surveillance est désactivée par un paramètre global du serveur",
"MessageXLibraryIsEmpty": "{0} Library is empty!", "MessageXLibraryIsEmpty": "La Bibliothèque {0} est vide!",
"MessageYourAudiobookDurationIsLonger": "La durée de votre Livre Audio est plus longue que la durée trouvée", "MessageYourAudiobookDurationIsLonger": "La durée de votre Livre Audio est plus longue que la durée trouvée",
"MessageYourAudiobookDurationIsShorter": "La durée de votre Livre Audio est plus courte que la durée trouvée", "MessageYourAudiobookDurationIsShorter": "La durée de votre Livre Audio est plus courte que la durée trouvée",
"NoteChangeRootPassword": "L'utilisateur Root est le seul a pouvoir utiliser un mote de passe vide", "NoteChangeRootPassword": "L'utilisateur Root est le seul a pouvoir utiliser un mote de passe vide",
@@ -481,6 +524,7 @@
"NoteUploaderUnsupportedFiles": "Les fichiers non-supportés seront ignorés. En sélectionnant ou déponsant un dossier, les autres fichiers qui ne sont pas un dossier contenant un article seront ignorés.", "NoteUploaderUnsupportedFiles": "Les fichiers non-supportés seront ignorés. En sélectionnant ou déponsant un dossier, les autres fichiers qui ne sont pas un dossier contenant un article seront ignorés.",
"PlaceholderNewCollection": "Nom de la nouvelle collection", "PlaceholderNewCollection": "Nom de la nouvelle collection",
"PlaceholderNewFolderPath": "Nouveau chemin de dossier", "PlaceholderNewFolderPath": "Nouveau chemin de dossier",
"PlaceholderNewPlaylist": "Nouveau nom de liste de lecture",
"PlaceholderSearch": "Recherche...", "PlaceholderSearch": "Recherche...",
"ToastAccountUpdateFailed": "Echec de la mise à jour du compte", "ToastAccountUpdateFailed": "Echec de la mise à jour du compte",
"ToastAccountUpdateSuccess": "Compte mis à jour", "ToastAccountUpdateSuccess": "Compte mis à jour",
@@ -505,6 +549,8 @@
"ToastBookmarkRemoveSuccess": "Signet supprimé", "ToastBookmarkRemoveSuccess": "Signet supprimé",
"ToastBookmarkUpdateFailed": "Echec de la mise à jour de signet", "ToastBookmarkUpdateFailed": "Echec de la mise à jour de signet",
"ToastBookmarkUpdateSuccess": "Signet mis à jour", "ToastBookmarkUpdateSuccess": "Signet mis à jour",
"ToastChaptersHaveErrors": "Les chapitres contiennent des erreurs",
"ToastChaptersMustHaveTitles": "Les chapitre doivent avoir un titre",
"ToastCollectionItemsRemoveFailed": "Echec de la suppression de(s) article(s) de la collection", "ToastCollectionItemsRemoveFailed": "Echec 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": "Echec de la suppression de la collection",
@@ -528,6 +574,10 @@
"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": "Echec 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",
"ToastPlaylistRemoveSuccess": "Liste de lecture supprimée",
"ToastPlaylistUpdateFailed": "Echec de la mise à jour de la liste de lecture",
"ToastPlaylistUpdateSuccess": "Liste de lecture mise à jour",
"ToastPodcastCreateFailed": "Echec de la création du Podcast", "ToastPodcastCreateFailed": "Echec de la création du Podcast",
"ToastPodcastCreateSuccess": "Podcast créé", "ToastPodcastCreateSuccess": "Podcast créé",
"ToastRemoveItemFromCollectionFailed": "Echec de la suppression de l'article de la collection", "ToastRemoveItemFromCollectionFailed": "Echec de la suppression de l'article de la collection",
@@ -548,4 +598,4 @@
"WeekdayThursday": "Jeudi", "WeekdayThursday": "Jeudi",
"WeekdayTuesday": "Mardi", "WeekdayTuesday": "Mardi",
"WeekdayWednesday": "Mercredi" "WeekdayWednesday": "Mercredi"
} }
+52 -2
View File
@@ -41,6 +41,7 @@
"ButtonOpenManager": "Otvori menadžera", "ButtonOpenManager": "Otvori menadžera",
"ButtonPlay": "Pokreni", "ButtonPlay": "Pokreni",
"ButtonPlaying": "Playing", "ButtonPlaying": "Playing",
"ButtonPlaylists": "Playlists",
"ButtonPurgeAllCache": "Isprazni sav cache", "ButtonPurgeAllCache": "Isprazni sav cache",
"ButtonPurgeItemsCache": "Isprazni Items Cache", "ButtonPurgeItemsCache": "Isprazni Items Cache",
"ButtonPurgeMediaProgress": "Purge Media Progress", "ButtonPurgeMediaProgress": "Purge Media Progress",
@@ -64,6 +65,7 @@
"ButtonSearch": "Traži", "ButtonSearch": "Traži",
"ButtonSelectFolderPath": "Odaberi putanju do folder", "ButtonSelectFolderPath": "Odaberi putanju do folder",
"ButtonSeries": "Serije", "ButtonSeries": "Serije",
"ButtonSetChaptersFromTracks": "Set chapters from tracks",
"ButtonShiftTimes": "Pomakni vremena", "ButtonShiftTimes": "Pomakni vremena",
"ButtonShow": "Prikaži", "ButtonShow": "Prikaži",
"ButtonStartM4BEncode": "Pokreni M4B kodiranje", "ButtonStartM4BEncode": "Pokreni M4B kodiranje",
@@ -111,6 +113,8 @@
"HeaderOtherFiles": "Druge datoteke", "HeaderOtherFiles": "Druge datoteke",
"HeaderPermissions": "Dozvole", "HeaderPermissions": "Dozvole",
"HeaderPlayerQueue": "Player Queue", "HeaderPlayerQueue": "Player Queue",
"HeaderPlaylist": "Playlist",
"HeaderPlaylistItems": "Playlist Items",
"HeaderPodcastsToAdd": "Podcasti za dodati", "HeaderPodcastsToAdd": "Podcasti za dodati",
"HeaderPreviewCover": "Pregledaj Cover", "HeaderPreviewCover": "Pregledaj Cover",
"HeaderRemoveEpisode": "Ukloni epizodu", "HeaderRemoveEpisode": "Ukloni epizodu",
@@ -147,6 +151,8 @@
"LabelAddedAt": "Added At", "LabelAddedAt": "Added At",
"LabelAddToCollection": "Dodaj u kolekciju", "LabelAddToCollection": "Dodaj u kolekciju",
"LabelAddToCollectionBatch": "Add {0} Books to Collection", "LabelAddToCollectionBatch": "Add {0} Books to Collection",
"LabelAddToPlaylist": "Add to Playlist",
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
"LabelAll": "All", "LabelAll": "All",
"LabelAllUsers": "Svi korisnici", "LabelAllUsers": "Svi korisnici",
"LabelAuthor": "Autor", "LabelAuthor": "Autor",
@@ -165,6 +171,7 @@
"LabelChangePassword": "Promijeni lozinku", "LabelChangePassword": "Promijeni lozinku",
"LabelChaptersFound": "poglavlja pronađena", "LabelChaptersFound": "poglavlja pronađena",
"LabelChapterTitle": "Ime poglavlja", "LabelChapterTitle": "Ime poglavlja",
"LabelClosePlayer": "Close player",
"LabelCollapseSeries": "Collapse Series", "LabelCollapseSeries": "Collapse Series",
"LabelCollections": "Kolekcije", "LabelCollections": "Kolekcije",
"LabelComplete": "Complete", "LabelComplete": "Complete",
@@ -214,6 +221,14 @@
"LabelIncomplete": "Nepotpuno", "LabelIncomplete": "Nepotpuno",
"LabelInProgress": "U tijeku", "LabelInProgress": "U tijeku",
"LabelInterval": "Interval", "LabelInterval": "Interval",
"LabelIntervalCustomDailyWeekly": "Custom daily/weekly",
"LabelIntervalEvery12Hours": "Every 12 hours",
"LabelIntervalEvery15Minutes": "Every 15 minutes",
"LabelIntervalEvery2Hours": "Every 2 hours",
"LabelIntervalEvery30Minutes": "Every 30 minutes",
"LabelIntervalEvery6Hours": "Every 6 hours",
"LabelIntervalEveryDay": "Every day",
"LabelIntervalEveryHour": "Every hour",
"LabelInvalidParts": "Nevaljajuči dijelovi", "LabelInvalidParts": "Nevaljajuči dijelovi",
"LabelItem": "Stavka", "LabelItem": "Stavka",
"LabelLanguage": "Jezik", "LabelLanguage": "Jezik",
@@ -228,6 +243,9 @@
"LabelLibraryName": "Ime biblioteke", "LabelLibraryName": "Ime biblioteke",
"LabelLimit": "Limit", "LabelLimit": "Limit",
"LabelListenAgain": "Slušaj ponovno", "LabelListenAgain": "Slušaj ponovno",
"LabelLogLevelDebug": "Debug",
"LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Traži nove epizode nakon ovog datuma", "LabelLookForNewEpisodesAfterDate": "Traži nove epizode nakon ovog datuma",
"LabelMarkSeries": "Označi seriju", "LabelMarkSeries": "Označi seriju",
"LabelMediaPlayer": "Media Player", "LabelMediaPlayer": "Media Player",
@@ -257,6 +275,7 @@
"LabelNotificationsMaxQueueSizeHelp": "Samo 1 event po sekundi može biti pokrenut. Eventi će biti ignorirani ako je queue na maksimalnoj veličini. To spriječava spammanje s obavijestima.", "LabelNotificationsMaxQueueSizeHelp": "Samo 1 event po sekundi može biti pokrenut. Eventi će biti ignorirani ako je queue na maksimalnoj veličini. To spriječava spammanje s obavijestima.",
"LabelNotificationTitleTemplate": "Title Template", "LabelNotificationTitleTemplate": "Title Template",
"LabelNotStarted": "Not Started", "LabelNotStarted": "Not Started",
"LabelNumberOfBooks": "Number of Books",
"LabelNumberOfEpisodes": "# of Episodes", "LabelNumberOfEpisodes": "# of Episodes",
"LabelOpenRSSFeed": "Otvori RSS Feed", "LabelOpenRSSFeed": "Otvori RSS Feed",
"LabelPassword": "Lozinka", "LabelPassword": "Lozinka",
@@ -269,6 +288,7 @@
"LabelPermissionsUpdate": "Smije aktualizirati", "LabelPermissionsUpdate": "Smije aktualizirati",
"LabelPermissionsUpload": "Smije uploadati", "LabelPermissionsUpload": "Smije uploadati",
"LabelPhotoPathURL": "Slika putanja/URL", "LabelPhotoPathURL": "Slika putanja/URL",
"LabelPlaylists": "Playlists",
"LabelPlayMethod": "Vrsta reprodukcije", "LabelPlayMethod": "Vrsta reprodukcije",
"LabelPodcast": "Podcast", "LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts", "LabelPodcasts": "Podcasts",
@@ -282,6 +302,7 @@
"LabelRecentSeries": "Nedavne serije", "LabelRecentSeries": "Nedavne serije",
"LabelRegion": "Regija", "LabelRegion": "Regija",
"LabelReleaseDate": "Datum izlaska", "LabelReleaseDate": "Datum izlaska",
"LabelRemoveCover": "Remove cover",
"LabelRSSFeedOpen": "RSS Feed Open", "LabelRSSFeedOpen": "RSS Feed Open",
"LabelRSSFeedSlug": "RSS Feed Slug", "LabelRSSFeedSlug": "RSS Feed Slug",
"LabelRSSFeedURL": "RSS Feed URL", "LabelRSSFeedURL": "RSS Feed URL",
@@ -329,6 +350,7 @@
"LabelSettingsStoreMetadataWithItemHelp": "Po defaultu metapodatci su spremljeni u /metadata/items, uključujućite li ovu postavku, metapodatci će biti spremljeni u folderima od biblioteke. Koristi .abs ekstenziju.", "LabelSettingsStoreMetadataWithItemHelp": "Po defaultu metapodatci su spremljeni u /metadata/items, uključujućite li ovu postavku, metapodatci će biti spremljeni u folderima od biblioteke. Koristi .abs ekstenziju.",
"LabelShowAll": "Prikaži sve", "LabelShowAll": "Prikaži sve",
"LabelSize": "Veličina", "LabelSize": "Veličina",
"LabelSleepTimer": "Sleep timer",
"LabelStart": "Pokreni", "LabelStart": "Pokreni",
"LabelStarted": "Pokrenuto", "LabelStarted": "Pokrenuto",
"LabelStartedAt": "Pokrenuto", "LabelStartedAt": "Pokrenuto",
@@ -364,9 +386,13 @@
"LabelToolsMakeM4bDescription": "Generate a .M4B audiobook file with embedded metadata, cover image, and chapters.", "LabelToolsMakeM4bDescription": "Generate a .M4B audiobook file with embedded metadata, cover image, and chapters.",
"LabelToolsSplitM4b": "Split M4B to MP3's", "LabelToolsSplitM4b": "Split M4B to MP3's",
"LabelToolsSplitM4bDescription": "Create MP3's from an M4B split by chapters with embedded metadata, cover image, and chapters.", "LabelToolsSplitM4bDescription": "Create MP3's from an M4B split by chapters with embedded metadata, cover image, and chapters.",
"LabelTotalDuration": "Total Duration",
"LabelTotalTimeListened": "Sveukupno vrijeme slušanja", "LabelTotalTimeListened": "Sveukupno vrijeme slušanja",
"LabelTrackFromFilename": "Track iz imena datoteke", "LabelTrackFromFilename": "Track iz imena datoteke",
"LabelTrackFromMetadata": "Track iz metapodataka", "LabelTrackFromMetadata": "Track iz metapodataka",
"LabelTracks": "Tracks",
"LabelTracksMultiTrack": "Multi-track",
"LabelTracksSingleTrack": "Single-track",
"LabelType": "Tip", "LabelType": "Tip",
"LabelUnknown": "Nepoznato", "LabelUnknown": "Nepoznato",
"LabelUpdateCover": "Aktualiziraj Cover", "LabelUpdateCover": "Aktualiziraj Cover",
@@ -382,19 +408,27 @@
"LabelUsername": "Korisničko ime", "LabelUsername": "Korisničko ime",
"LabelValue": "Vrijednost", "LabelValue": "Vrijednost",
"LabelVersion": "Verzija", "LabelVersion": "Verzija",
"LabelViewBookmarks": "View bookmarks",
"LabelViewChapters": "View chapters",
"LabelViewQueue": "View player queue",
"LabelVolume": "Volume",
"LabelWeekdaysToRun": "Radnih dana da radi", "LabelWeekdaysToRun": "Radnih dana da radi",
"LabelYourAudiobookDuration": "Tvoje trajanje audiobooka", "LabelYourAudiobookDuration": "Tvoje trajanje audiobooka",
"LabelYourBookmarks": "Tvoje knjižne oznake", "LabelYourBookmarks": "Tvoje knjižne oznake",
"LabelYourPlaylists": "Your Playlists",
"LabelYourProgress": "Tvoj napredak", "LabelYourProgress": "Tvoj napredak",
"MessageAddToPlayerQueue": "Add to player queue",
"MessageAppriseDescription": "To use this feature you will need to have an instance of <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> running or an api that will handle those same requests. <br />The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at <code>http://192.168.1.1:8337</code> then you would put <code>http://192.168.1.1:8337/notify</code>.", "MessageAppriseDescription": "To use this feature you will need to have an instance of <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> running or an api that will handle those same requests. <br />The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at <code>http://192.168.1.1:8337</code> then you would put <code>http://192.168.1.1:8337/notify</code>.",
"MessageBackupsDescription": "Backups uključuju korisnike, korisnikov napredak, detalje stavki iz biblioteke, postavke server i slike iz", "MessageBackupsDescription": "Backups uključuju korisnike, korisnikov napredak, detalje stavki iz biblioteke, postavke server i slike iz <code>/metadata/items</code> & <code>/metadata/authors</code>. Backups ne uključuju nijedne datoteke koje su u folderima biblioteke.",
"MessageBackupsNote": "Backups ne uključuju nijedne datoteke koje su u folderima biblioteke.",
"MessageBatchQuickMatchDescription": "Quick Match će probati dodati nedostale covere i metapodatke za odabrane stavke. Uključi postavke ispod da omočutie Quick Mathchu da zamijeni postojeće covere i/ili metapodatke.", "MessageBatchQuickMatchDescription": "Quick Match će probati dodati nedostale covere i metapodatke za odabrane stavke. Uključi postavke ispod da omočutie Quick Mathchu da zamijeni postojeće covere i/ili metapodatke.",
"MessageBookshelfNoCollections": "You haven't made any collections yet", "MessageBookshelfNoCollections": "You haven't made any collections yet",
"MessageBookshelfNoResultsForFilter": "No Results for filter \"{0}: {1}\"", "MessageBookshelfNoResultsForFilter": "No Results for filter \"{0}: {1}\"",
"MessageBookshelfNoRSSFeeds": "No RSS feeds are open", "MessageBookshelfNoRSSFeeds": "No RSS feeds are open",
"MessageBookshelfNoSeries": "You have no series", "MessageBookshelfNoSeries": "You have no series",
"MessageChapterEndIsAfter": "Kraj poglavlja je nakon kraja audioknjige.", "MessageChapterEndIsAfter": "Kraj poglavlja je nakon kraja audioknjige.",
"MessageChapterErrorFirstNotZero": "First chapter must start at 0",
"MessageChapterErrorStartGteDuration": "Invalid start time must be less than audiobook duration",
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
"MessageChapterStartIsAfter": "Početak poglavlja je nakon kraja audioknjige.", "MessageChapterStartIsAfter": "Početak poglavlja je nakon kraja audioknjige.",
"MessageCheckingCron": "Provjeravam cron...", "MessageCheckingCron": "Provjeravam cron...",
"MessageConfirmDeleteBackup": "Jeste li sigurni da želite obrisati backup za {0}?", "MessageConfirmDeleteBackup": "Jeste li sigurni da želite obrisati backup za {0}?",
@@ -404,6 +438,7 @@
"MessageConfirmRemoveCollection": "AJeste li sigurni da želite obrisati kolekciju \"{0}\"?", "MessageConfirmRemoveCollection": "AJeste li sigurni da želite obrisati kolekciju \"{0}\"?",
"MessageConfirmRemoveEpisode": "Jeste li sigurni da želite obrisati epizodu \"{0}\"?", "MessageConfirmRemoveEpisode": "Jeste li sigurni da želite obrisati epizodu \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Jeste li sigurni da želite obrisati {0} epizoda/-u?", "MessageConfirmRemoveEpisodes": "Jeste li sigurni da želite obrisati {0} epizoda/-u?",
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{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!",
@@ -446,20 +481,28 @@
"MessageNoPodcastsFound": "Nijedan podcast pronađen", "MessageNoPodcastsFound": "Nijedan podcast pronađen",
"MessageNoResults": "Nema rezultata", "MessageNoResults": "Nema rezultata",
"MessageNoSearchResultsFor": "Nema rezultata pretragee za \"{0}\"", "MessageNoSearchResultsFor": "Nema rezultata pretragee za \"{0}\"",
"MessageNoSeries": "No Series",
"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",
"MessageNoUserPlaylists": "You have no playlists",
"MessageOr": "or", "MessageOr": "or",
"MessagePauseChapter": "Pause chapter playback",
"MessagePlayChapter": "Listen to beginning of chapter",
"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?",
"MessageRemoveChapter": "Remove chapter",
"MessageRemoveEpisodes": "ukloni {0} epizoda/-e", "MessageRemoveEpisodes": "ukloni {0} epizoda/-e",
"MessageRemoveFromPlayerQueue": "Remove from player queue",
"MessageRemoveUserWarning": "Jeste li sigurni da želite trajno obrisati korisnika \"{0}\"?", "MessageRemoveUserWarning": "Jeste li sigurni da želite trajno obrisati korisnika \"{0}\"?",
"MessageReportBugsAndContribute": "Prijavte bugove, zatržite featurese i doprinosite na", "MessageReportBugsAndContribute": "Prijavte bugove, zatržite featurese i doprinosite na",
"MessageResetChaptersConfirm": "Are you sure you want to reset chapters and undo the changes you made?",
"MessageRestoreBackupConfirm": "Jeste li sigurni da želite povratiti backup kreiran", "MessageRestoreBackupConfirm": "Jeste li sigurni da želite povratiti backup kreiran",
"MessageRestoreBackupWarning": "Povračanje backupa će zamijeniti postoječu bazu podataka u /config i slike covera u /metadata/items i /metadata/authors.<br /><br />Backups ne modificiraju nikakve datoteke u folderu od biblioteke. Ako imate uključene server postavke da spremate cover i metapodtake u folderu od biblioteke, onda oni neće biti backupani ili overwritten.<br /><br />Svi klijenti koji koriste tvoj server će biti automatski osvježeni.", "MessageRestoreBackupWarning": "Povračanje backupa će zamijeniti postoječu bazu podataka u /config i slike covera u /metadata/items i /metadata/authors.<br /><br />Backups ne modificiraju nikakve datoteke u folderu od biblioteke. Ako imate uključene server postavke da spremate cover i metapodtake u folderu od biblioteke, onda oni neće biti backupani ili overwritten.<br /><br />Svi klijenti koji koriste tvoj server će biti automatski osvježeni.",
"MessageSearchResultsFor": "Traži rezultate za", "MessageSearchResultsFor": "Traži rezultate za",
"MessageServerCouldNotBeReached": "Server ne može biti kontaktiran", "MessageServerCouldNotBeReached": "Server ne može biti kontaktiran",
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
"MessageStartPlaybackAtTime": "Pokreni reprodukciju za \"{0}\" na {1}?", "MessageStartPlaybackAtTime": "Pokreni reprodukciju za \"{0}\" na {1}?",
"MessageThinking": "Razmišljam...", "MessageThinking": "Razmišljam...",
"MessageUploaderItemFailed": "Upload neuspješan", "MessageUploaderItemFailed": "Upload neuspješan",
@@ -481,6 +524,7 @@
"NoteUploaderUnsupportedFiles": "Nepodržane datoteke su ignorirane. Kada birate ili ubacujete folder, ostale datoteke koje nisu folder će biti ignorirane.", "NoteUploaderUnsupportedFiles": "Nepodržane datoteke su ignorirane. Kada birate ili ubacujete folder, ostale datoteke koje nisu folder će biti ignorirane.",
"PlaceholderNewCollection": "Ime nove kolekcije", "PlaceholderNewCollection": "Ime nove kolekcije",
"PlaceholderNewFolderPath": "Nova folder putanja", "PlaceholderNewFolderPath": "Nova folder putanja",
"PlaceholderNewPlaylist": "New playlist name",
"PlaceholderSearch": "Traži...", "PlaceholderSearch": "Traži...",
"ToastAccountUpdateFailed": "Neuspješno aktualiziranje korisničkog računa", "ToastAccountUpdateFailed": "Neuspješno aktualiziranje korisničkog računa",
"ToastAccountUpdateSuccess": "Korisnički račun aktualiziran", "ToastAccountUpdateSuccess": "Korisnički račun aktualiziran",
@@ -505,6 +549,8 @@
"ToastBookmarkRemoveSuccess": "Knjižnja bilješka uklonjena", "ToastBookmarkRemoveSuccess": "Knjižnja bilješka uklonjena",
"ToastBookmarkUpdateFailed": "Aktualizacija knjižne bilješke neuspješna", "ToastBookmarkUpdateFailed": "Aktualizacija knjižne bilješke neuspješna",
"ToastBookmarkUpdateSuccess": "Knjižna bilješka aktualizirana", "ToastBookmarkUpdateSuccess": "Knjižna bilješka aktualizirana",
"ToastChaptersHaveErrors": "Chapters have errors",
"ToastChaptersMustHaveTitles": "Chapters must have titles",
"ToastCollectionItemsRemoveFailed": "Neuspješno brisanje stavke/-i iz kolekcije", "ToastCollectionItemsRemoveFailed": "Neuspješno brisanje stavke/-i iz kolekcije",
"ToastCollectionItemsRemoveSuccess": "Stavka/-e obrisane iz kolekcije", "ToastCollectionItemsRemoveSuccess": "Stavka/-e obrisane iz kolekcije",
"ToastCollectionRemoveFailed": "Brisanje kolekcije neuspješno", "ToastCollectionRemoveFailed": "Brisanje kolekcije neuspješno",
@@ -528,6 +574,10 @@
"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",
"ToastPlaylistRemoveFailed": "Failed to remove playlist",
"ToastPlaylistRemoveSuccess": "Playlist removed",
"ToastPlaylistUpdateFailed": "Failed to update playlist",
"ToastPlaylistUpdateSuccess": "Playlist updated",
"ToastPodcastCreateFailed": "Neuspješno kreiranje podcasta", "ToastPodcastCreateFailed": "Neuspješno kreiranje podcasta",
"ToastPodcastCreateSuccess": "Podcast uspješno kreiran", "ToastPodcastCreateSuccess": "Podcast uspješno kreiran",
"ToastRemoveItemFromCollectionFailed": "Neuspješno uklanjanje stavke iz kolekcije", "ToastRemoveItemFromCollectionFailed": "Neuspješno uklanjanje stavke iz kolekcije",
+78 -28
View File
@@ -13,10 +13,10 @@
"ButtonCheckAndDownloadNewEpisodes": "Controlla & scarica i nuovi episodi", "ButtonCheckAndDownloadNewEpisodes": "Controlla & scarica i nuovi episodi",
"ButtonChooseAFolder": "Seleziona la Cartella", "ButtonChooseAFolder": "Seleziona la Cartella",
"ButtonChooseFiles": "Seleziona i File", "ButtonChooseFiles": "Seleziona i File",
"ButtonClearFilter": "Clear Filter", "ButtonClearFilter": "Elimina Filtri",
"ButtonCloseFeed": "Chudi i Feed", "ButtonCloseFeed": "Chudi i Feed",
"ButtonCollections": "Raccolte", "ButtonCollections": "Raccolte",
"ButtonConfigureScanner": "Configure Scanner", "ButtonConfigureScanner": "Configura Scanner",
"ButtonCreate": "Crea", "ButtonCreate": "Crea",
"ButtonCreateBackup": "Crea un Backup", "ButtonCreateBackup": "Crea un Backup",
"ButtonDelete": "Elimina", "ButtonDelete": "Elimina",
@@ -26,7 +26,7 @@
"ButtonFullPath": "Percorso Completo", "ButtonFullPath": "Percorso Completo",
"ButtonHide": "Nascondi", "ButtonHide": "Nascondi",
"ButtonHome": "Home", "ButtonHome": "Home",
"ButtonIssues": "Problematiche", "ButtonIssues": "Errori",
"ButtonLatest": "Ultimi", "ButtonLatest": "Ultimi",
"ButtonLibrary": "Libreria", "ButtonLibrary": "Libreria",
"ButtonLogout": "Disconnetti", "ButtonLogout": "Disconnetti",
@@ -41,6 +41,7 @@
"ButtonOpenManager": "Apri Manager", "ButtonOpenManager": "Apri Manager",
"ButtonPlay": "Play", "ButtonPlay": "Play",
"ButtonPlaying": "In Riproduzione", "ButtonPlaying": "In Riproduzione",
"ButtonPlaylists": "Playlists",
"ButtonPurgeAllCache": "Elimina tutta la Cache", "ButtonPurgeAllCache": "Elimina tutta la Cache",
"ButtonPurgeItemsCache": "Elimina la Cache selezionata", "ButtonPurgeItemsCache": "Elimina la Cache selezionata",
"ButtonPurgeMediaProgress": "Elimina info dei media ascoltati", "ButtonPurgeMediaProgress": "Elimina info dei media ascoltati",
@@ -59,11 +60,12 @@
"ButtonSave": "Salva", "ButtonSave": "Salva",
"ButtonSaveAndClose": "Salva & Chiudi", "ButtonSaveAndClose": "Salva & Chiudi",
"ButtonSaveTracklist": "Salva Tracklist", "ButtonSaveTracklist": "Salva Tracklist",
"ButtonScan": "Scan", "ButtonScan": "Scansiona",
"ButtonScanLibrary": "Scan Library", "ButtonScanLibrary": "Scansiona Libreria",
"ButtonSearch": "Cerca", "ButtonSearch": "Cerca",
"ButtonSelectFolderPath": "Seleziona percorso cartella", "ButtonSelectFolderPath": "Seleziona percorso cartella",
"ButtonSeries": "Serie", "ButtonSeries": "Serie",
"ButtonSetChaptersFromTracks": "Set chapters from tracks",
"ButtonShiftTimes": "Ricerca veloce", "ButtonShiftTimes": "Ricerca veloce",
"ButtonShow": "Mostra", "ButtonShow": "Mostra",
"ButtonStartM4BEncode": "Inizia L'Encoda del M4B", "ButtonStartM4BEncode": "Inizia L'Encoda del M4B",
@@ -110,14 +112,16 @@
"HeaderOpenRSSFeed": "Apri RSS Feed", "HeaderOpenRSSFeed": "Apri RSS Feed",
"HeaderOtherFiles": "Altri File", "HeaderOtherFiles": "Altri File",
"HeaderPermissions": "Permessi", "HeaderPermissions": "Permessi",
"HeaderPlayerQueue": "Player Queue", "HeaderPlayerQueue": "Coda Riproduzione",
"HeaderPlaylist": "Playlist",
"HeaderPlaylistItems": "Playlist Items",
"HeaderPodcastsToAdd": "Podcasts da Aggiungere", "HeaderPodcastsToAdd": "Podcasts da Aggiungere",
"HeaderPreviewCover": "Anteprima Cover", "HeaderPreviewCover": "Anteprima Cover",
"HeaderRemoveEpisode": "Rimuovi Episodi", "HeaderRemoveEpisode": "Rimuovi Episodi",
"HeaderRemoveEpisodes": "Rimuovi {0} Episodi", "HeaderRemoveEpisodes": "Rimuovi {0} Episodi",
"HeaderRSSFeedIsOpen": "RSS Feed è aperto", "HeaderRSSFeedIsOpen": "RSS Feed è aperto",
"HeaderSavedMediaProgress": "Progressi salvati", "HeaderSavedMediaProgress": "Progressi salvati",
"HeaderSchedule": "schedula", "HeaderSchedule": "Schedula",
"HeaderScheduleLibraryScans": "Schedula la scansione della libreria", "HeaderScheduleLibraryScans": "Schedula la scansione della libreria",
"HeaderSession": "Sessione", "HeaderSession": "Sessione",
"HeaderSetBackupSchedule": "Imposta programmazione Backup", "HeaderSetBackupSchedule": "Imposta programmazione Backup",
@@ -144,14 +148,16 @@
"LabelAccountTypeGuest": "Ospite", "LabelAccountTypeGuest": "Ospite",
"LabelAccountTypeUser": "Utente", "LabelAccountTypeUser": "Utente",
"LabelActivity": "Attività", "LabelActivity": "Attività",
"LabelAddedAt": "Aggiunto a", "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",
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
"LabelAll": "All", "LabelAll": "All",
"LabelAllUsers": "Tutti gli Utenti", "LabelAllUsers": "Tutti gli Utenti",
"LabelAuthor": "Autore", "LabelAuthor": "Autore",
"LabelAuthorFirstLast": "Autori (Prima l'ultimo)", "LabelAuthorFirstLast": "Autore (Per Nome)",
"LabelAuthorLastFirst": "Autori (Prima il Primo)", "LabelAuthorLastFirst": "Autori (Per Cognome)",
"LabelAuthors": "Autori", "LabelAuthors": "Autori",
"LabelAutoDownloadEpisodes": "Auto Download Episodi", "LabelAutoDownloadEpisodes": "Auto Download Episodi",
"LabelBackToUser": "Torna a Utenti", "LabelBackToUser": "Torna a Utenti",
@@ -165,6 +171,7 @@
"LabelChangePassword": "Cambia Password", "LabelChangePassword": "Cambia Password",
"LabelChaptersFound": "Capitoli Trovati", "LabelChaptersFound": "Capitoli Trovati",
"LabelChapterTitle": "Titoli dei Capitoli", "LabelChapterTitle": "Titoli dei Capitoli",
"LabelClosePlayer": "Chiudi player",
"LabelCollapseSeries": "Comprimi Serie", "LabelCollapseSeries": "Comprimi Serie",
"LabelCollections": "Raccolte", "LabelCollections": "Raccolte",
"LabelComplete": "Completo", "LabelComplete": "Completo",
@@ -197,8 +204,8 @@
"LabelExplicit": "Esplicito", "LabelExplicit": "Esplicito",
"LabelFeedURL": "Feed URL", "LabelFeedURL": "Feed URL",
"LabelFile": "File", "LabelFile": "File",
"LabelFileBirthtime": "File Creato", "LabelFileBirthtime": "Data Creazione",
"LabelFileModified": "File Modificato", "LabelFileModified": "Ultima modifica",
"LabelFilename": "Nome File", "LabelFilename": "Nome File",
"LabelFilterByUser": "Filtro per Utente", "LabelFilterByUser": "Filtro per Utente",
"LabelFindEpisodes": "Trova Episodi", "LabelFindEpisodes": "Trova Episodi",
@@ -214,6 +221,14 @@
"LabelIncomplete": "Incompleta", "LabelIncomplete": "Incompleta",
"LabelInProgress": "In Corso", "LabelInProgress": "In Corso",
"LabelInterval": "Intervallo", "LabelInterval": "Intervallo",
"LabelIntervalCustomDailyWeekly": "Personalizza giorni/settimane",
"LabelIntervalEvery12Hours": "EOgni 12 Ore",
"LabelIntervalEvery15Minutes": "Ogni 15 Minuti",
"LabelIntervalEvery2Hours": "Ogni 2 Ore",
"LabelIntervalEvery30Minutes": "Ogni 30 Minuti",
"LabelIntervalEvery6Hours": "Ogni 6 ore",
"LabelIntervalEveryDay": "Ogni Giorno",
"LabelIntervalEveryHour": "Ogni ora",
"LabelInvalidParts": "Parti Invalide", "LabelInvalidParts": "Parti Invalide",
"LabelItem": "Oggetti", "LabelItem": "Oggetti",
"LabelLanguage": "Lingua", "LabelLanguage": "Lingua",
@@ -228,6 +243,9 @@
"LabelLibraryName": "Nome Libreria", "LabelLibraryName": "Nome Libreria",
"LabelLimit": "Limiti", "LabelLimit": "Limiti",
"LabelListenAgain": "Ri-ascolta", "LabelListenAgain": "Ri-ascolta",
"LabelLogLevelDebug": "Debug",
"LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Allarme",
"LabelLookForNewEpisodesAfterDate": "Cerca nuovi episodi dopo questa data", "LabelLookForNewEpisodesAfterDate": "Cerca nuovi episodi dopo questa data",
"LabelMarkSeries": "Segna Serie", "LabelMarkSeries": "Segna Serie",
"LabelMediaPlayer": "Media Player", "LabelMediaPlayer": "Media Player",
@@ -235,7 +253,7 @@
"LabelMetadataProvider": "Metadata Provider", "LabelMetadataProvider": "Metadata Provider",
"LabelMetaTag": "Meta Tag", "LabelMetaTag": "Meta Tag",
"LabelMinute": "Minuto", "LabelMinute": "Minuto",
"LabelMissing": "Rimanente", "LabelMissing": "Altro",
"LabelMissingParts": "Parti rimantenti", "LabelMissingParts": "Parti rimantenti",
"LabelMore": "Espandi", "LabelMore": "Espandi",
"LabelName": "Nome", "LabelName": "Nome",
@@ -257,6 +275,7 @@
"LabelNotificationsMaxQueueSizeHelp": "Le notifiche sono limitate per 1 al secondo, per evitare lo spamming le notifiche verrano ignorare se superano la coda", "LabelNotificationsMaxQueueSizeHelp": "Le notifiche sono limitate per 1 al secondo, per evitare lo spamming le notifiche verrano ignorare se superano la coda",
"LabelNotificationTitleTemplate": "Template del titolo", "LabelNotificationTitleTemplate": "Template del titolo",
"LabelNotStarted": "Non iniziato", "LabelNotStarted": "Non iniziato",
"LabelNumberOfBooks": "Numero di libri",
"LabelNumberOfEpisodes": "# degli episodi", "LabelNumberOfEpisodes": "# degli episodi",
"LabelOpenRSSFeed": "Apri RSS Feed", "LabelOpenRSSFeed": "Apri RSS Feed",
"LabelPassword": "Password", "LabelPassword": "Password",
@@ -268,12 +287,13 @@
"LabelPermissionsDownload": "Può Scaricare", "LabelPermissionsDownload": "Può Scaricare",
"LabelPermissionsUpdate": "Può Aggiornare", "LabelPermissionsUpdate": "Può Aggiornare",
"LabelPermissionsUpload": "Può caricare", "LabelPermissionsUpload": "Può caricare",
"LabelPhotoPathURL": "Photo Path/URL", "LabelPhotoPathURL": "foto Path/URL",
"LabelPlaylists": "Playlists",
"LabelPlayMethod": "Metodo di riproduzione", "LabelPlayMethod": "Metodo di riproduzione",
"LabelPodcast": "Podcast", "LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts", "LabelPodcasts": "Podcasts",
"LabelPrefixesToIgnore": "Suffissi da ignorare (specificando maiuscole e minuscole)", "LabelPrefixesToIgnore": "Suffissi da ignorare (specificando maiuscole e minuscole)",
"LabelProgress": "Progresso", "LabelProgress": "Cominciati",
"LabelProvider": "Provider", "LabelProvider": "Provider",
"LabelPubDate": "Data Pubblicazione", "LabelPubDate": "Data Pubblicazione",
"LabelPublisher": "Editore", "LabelPublisher": "Editore",
@@ -282,6 +302,7 @@
"LabelRecentSeries": "Serie Recenti", "LabelRecentSeries": "Serie Recenti",
"LabelRegion": "Regione", "LabelRegion": "Regione",
"LabelReleaseDate": "Data Release", "LabelReleaseDate": "Data Release",
"LabelRemoveCover": "Remove cover",
"LabelRSSFeedOpen": "RSS Feed Aperto", "LabelRSSFeedOpen": "RSS Feed Aperto",
"LabelRSSFeedSlug": "RSS Feed Slug", "LabelRSSFeedSlug": "RSS Feed Slug",
"LabelRSSFeedURL": "RSS Feed URL", "LabelRSSFeedURL": "RSS Feed URL",
@@ -292,7 +313,7 @@
"LabelSequence": "Sequenza", "LabelSequence": "Sequenza",
"LabelSeries": "Serie", "LabelSeries": "Serie",
"LabelSeriesName": "Nome Serie", "LabelSeriesName": "Nome Serie",
"LabelSeriesProgress": "Progressi", "LabelSeriesProgress": "Cominciato",
"LabelSettingsBookshelfViewHelp": "Design con scaffali in legno", "LabelSettingsBookshelfViewHelp": "Design con scaffali in legno",
"LabelSettingsChromecastSupport": "Supporto a Chromecast", "LabelSettingsChromecastSupport": "Supporto a Chromecast",
"LabelSettingsDateFormat": "Formato Data", "LabelSettingsDateFormat": "Formato Data",
@@ -329,6 +350,7 @@
"LabelSettingsStoreMetadataWithItemHelp": "Di default, i metadati sono salvati dentro /metadata/items, abilitando questa opzione si memorizzeranno i metadata nella cartella della libreria. I file avranno estensione .abs", "LabelSettingsStoreMetadataWithItemHelp": "Di default, i metadati sono salvati dentro /metadata/items, abilitando questa opzione si memorizzeranno i metadata nella cartella della libreria. I file avranno estensione .abs",
"LabelShowAll": "Mostra Tutto", "LabelShowAll": "Mostra Tutto",
"LabelSize": "Dimensione", "LabelSize": "Dimensione",
"LabelSleepTimer": "Sleep timer",
"LabelStart": "Inizo", "LabelStart": "Inizo",
"LabelStarted": "Iniziato", "LabelStarted": "Iniziato",
"LabelStartedAt": "Iniziato al", "LabelStartedAt": "Iniziato al",
@@ -361,12 +383,16 @@
"LabelToolsEmbedMetadata": "Incorpora Metadata", "LabelToolsEmbedMetadata": "Incorpora Metadata",
"LabelToolsEmbedMetadataDescription": "Incorpora i metadati nei file audio, inclusi l'immagine di copertina e i capitoli.", "LabelToolsEmbedMetadataDescription": "Incorpora i metadati nei file audio, inclusi l'immagine di copertina e i capitoli.",
"LabelToolsMakeM4b": "Crea un file M4B", "LabelToolsMakeM4b": "Crea un file M4B",
"LabelToolsMakeM4bDescription": "Genera un file audiolibro .M4B con metadati incorporati, immagine di copertina e capitoli.", "LabelToolsMakeM4bDescription": "Genera un file audiolibro M4B con metadati incorporati, immagine di copertina e capitoli.",
"LabelToolsSplitM4b": "Converti M4B to MP3's", "LabelToolsSplitM4b": "Converti M4B in MP3's",
"LabelToolsSplitM4bDescription": "Crea MP3 da un M4B diviso per capitoli con metadati incorporati, immagine di copertina e capitoli.", "LabelToolsSplitM4bDescription": "Crea MP3 da un M4B diviso per capitoli con metadati incorporati, immagine di copertina e capitoli.",
"LabelTotalDuration": "Durata Totale",
"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",
"LabelTracksMultiTrack": "Multi-track",
"LabelTracksSingleTrack": "Single-track",
"LabelType": "Tipo", "LabelType": "Tipo",
"LabelUnknown": "Sconosciuto", "LabelUnknown": "Sconosciuto",
"LabelUpdateCover": "Aggiornamento Cover", "LabelUpdateCover": "Aggiornamento Cover",
@@ -382,19 +408,27 @@
"LabelUsername": "Username", "LabelUsername": "Username",
"LabelValue": "Valore", "LabelValue": "Valore",
"LabelVersion": "Versione", "LabelVersion": "Versione",
"LabelViewBookmarks": "Visualizza i Segnalibri",
"LabelViewChapters": "Visualizza i Capitoli",
"LabelViewQueue": "Visualizza coda",
"LabelVolume": "Volume",
"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",
"LabelYourProgress": "I tuoi Progressi", "LabelYourPlaylists": "Your Playlists",
"LabelYourProgress": "Completato al",
"MessageAddToPlayerQueue": "Add to player queue",
"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", "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.",
"MessageBackupsNote": "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": "You haven't made any collections yet", "MessageBookshelfNoCollections": "Non hai ancora creato nessuna raccolta ",
"MessageBookshelfNoResultsForFilter": "No Results for filter \"{0}: {1}\"", "MessageBookshelfNoResultsForFilter": "Nessul risultato per il filtro \"{0}: {1}\"",
"MessageBookshelfNoRSSFeeds": "No RSS feeds are open", "MessageBookshelfNoRSSFeeds": "Nessun RSS feeds aperto",
"MessageBookshelfNoSeries": "You have no series", "MessageBookshelfNoSeries": "You have no series",
"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",
"MessageChapterErrorStartGteDuration": "Invalid start time must be less than audiobook duration",
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
"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}?",
@@ -404,6 +438,7 @@
"MessageConfirmRemoveCollection": "Sei sicuro di voler rimuovere la Raccolta \"{0}\"?", "MessageConfirmRemoveCollection": "Sei sicuro di voler rimuovere la Raccolta \"{0}\"?",
"MessageConfirmRemoveEpisode": "Sei sicuro di voler rimuovere l'episodio \"{0}\"?", "MessageConfirmRemoveEpisode": "Sei sicuro di voler rimuovere l'episodio \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Sei sicuro di voler rimuovere {0} episodi?", "MessageConfirmRemoveEpisodes": "Sei sicuro di voler rimuovere {0} episodi?",
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{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!",
@@ -436,7 +471,7 @@
"MessageNoEpisodes": "Nessun Episodio", "MessageNoEpisodes": "Nessun Episodio",
"MessageNoFoldersAvailable": "Nessuna Cartella disponibile", "MessageNoFoldersAvailable": "Nessuna Cartella disponibile",
"MessageNoGenres": "Nessun Genere", "MessageNoGenres": "Nessun Genere",
"MessageNoIssues": "No Issues", "MessageNoIssues": "Nessun Errore",
"MessageNoItems": "Nessun Oggetto", "MessageNoItems": "Nessun Oggetto",
"MessageNoItemsFound": "Nessun Oggetto trovato", "MessageNoItemsFound": "Nessun Oggetto trovato",
"MessageNoListeningSessions": "Nessuna sessione di ascolto", "MessageNoListeningSessions": "Nessuna sessione di ascolto",
@@ -446,20 +481,28 @@
"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",
"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",
"MessageOr": "o", "MessageOr": "o",
"MessagePauseChapter": "Metti in Pausa Capitolo",
"MessagePlayChapter": "Ascolta dall'inizio del capitolo",
"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",
"MessageRemoveEpisodes": "rimuovi {0} episodio(i)", "MessageRemoveEpisodes": "rimuovi {0} episodio(i)",
"MessageRemoveFromPlayerQueue": "Remove from player queue",
"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?",
"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",
"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",
@@ -467,7 +510,7 @@
"MessageUploading": "Caricamento...", "MessageUploading": "Caricamento...",
"MessageValidCronExpression": "Espressione Cron Valida", "MessageValidCronExpression": "Espressione Cron Valida",
"MessageWatcherIsDisabledGlobally": "Watcher è disabilitato a livello globale nelle impostazioni del server", "MessageWatcherIsDisabledGlobally": "Watcher è disabilitato a livello globale nelle impostazioni del server",
"MessageXLibraryIsEmpty": "{0} Library is empty!", "MessageXLibraryIsEmpty": "{0} libreria vuota!",
"MessageYourAudiobookDurationIsLonger": "La durata dell'audiolibro è più lunga della durata trovata", "MessageYourAudiobookDurationIsLonger": "La durata dell'audiolibro è più lunga della durata trovata",
"MessageYourAudiobookDurationIsShorter": "La durata dell'audiolibro è inferiore alla durata trovata", "MessageYourAudiobookDurationIsShorter": "La durata dell'audiolibro è inferiore alla durata trovata",
"NoteChangeRootPassword": "L'utente root è l'unico utente che può avere una password vuota", "NoteChangeRootPassword": "L'utente root è l'unico utente che può avere una password vuota",
@@ -481,6 +524,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",
"PlaceholderSearch": "Cerca..", "PlaceholderSearch": "Cerca..",
"ToastAccountUpdateFailed": "Aggiornamento Account Fallito", "ToastAccountUpdateFailed": "Aggiornamento Account Fallito",
"ToastAccountUpdateSuccess": "Account Aggiornato", "ToastAccountUpdateSuccess": "Account Aggiornato",
@@ -505,6 +549,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",
"ToastChaptersMustHaveTitles": "Chapters must have titles",
"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",
@@ -528,12 +574,16 @@
"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",
"ToastPlaylistRemoveSuccess": "Playlist removed",
"ToastPlaylistUpdateFailed": "Failed to update playlist",
"ToastPlaylistUpdateSuccess": "Playlist updated",
"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": "Failed to close RSS feed", "ToastRSSFeedCloseFailed": "Errore chiusura RSS feed",
"ToastRSSFeedCloseSuccess": "RSS feed closed", "ToastRSSFeedCloseSuccess": "RSS feed chiuso",
"ToastSessionDeleteFailed": "Errore eliminazione sessione", "ToastSessionDeleteFailed": "Errore eliminazione sessione",
"ToastSessionDeleteSuccess": "Sessione cancellata", "ToastSessionDeleteSuccess": "Sessione cancellata",
"ToastSocketConnected": "Socket connesso", "ToastSocketConnected": "Socket connesso",
+92 -42
View File
@@ -13,7 +13,7 @@
"ButtonCheckAndDownloadNewEpisodes": "Sprawdź i pobierz nowe odcinki", "ButtonCheckAndDownloadNewEpisodes": "Sprawdź i pobierz nowe odcinki",
"ButtonChooseAFolder": "Wybierz folder", "ButtonChooseAFolder": "Wybierz folder",
"ButtonChooseFiles": "Wybierz pliki", "ButtonChooseFiles": "Wybierz pliki",
"ButtonClearFilter": "Clear Filter", "ButtonClearFilter": "Wyczyść filtr",
"ButtonCloseFeed": "Zamknij kanał", "ButtonCloseFeed": "Zamknij kanał",
"ButtonCollections": "Kolekcje", "ButtonCollections": "Kolekcje",
"ButtonConfigureScanner": "Configure Scanner", "ButtonConfigureScanner": "Configure Scanner",
@@ -41,11 +41,12 @@
"ButtonOpenManager": "Otwórz menadżera", "ButtonOpenManager": "Otwórz menadżera",
"ButtonPlay": "Odtwarzaj", "ButtonPlay": "Odtwarzaj",
"ButtonPlaying": "Odtwarzane", "ButtonPlaying": "Odtwarzane",
"ButtonPlaylists": "Playlists",
"ButtonPurgeAllCache": "Wyczyść dane tymczasowe", "ButtonPurgeAllCache": "Wyczyść dane tymczasowe",
"ButtonPurgeItemsCache": "Wyczyść dane tymczasowe pozycji", "ButtonPurgeItemsCache": "Wyczyść dane tymczasowe pozycji",
"ButtonPurgeMediaProgress": "Wyczyść postęp", "ButtonPurgeMediaProgress": "Wyczyść postęp",
"ButtonQueueAddItem": "Add to queue", "ButtonQueueAddItem": "Dodaj do kolejki",
"ButtonQueueRemoveItem": "Remove from queue", "ButtonQueueRemoveItem": "Usuń z kolejki",
"ButtonQuickMatch": "Szybkie dopasowanie", "ButtonQuickMatch": "Szybkie dopasowanie",
"ButtonRead": "Czytaj", "ButtonRead": "Czytaj",
"ButtonRemove": "Usuń", "ButtonRemove": "Usuń",
@@ -64,11 +65,12 @@
"ButtonSearch": "Szukaj", "ButtonSearch": "Szukaj",
"ButtonSelectFolderPath": "Wybierz ścieżkę folderu", "ButtonSelectFolderPath": "Wybierz ścieżkę folderu",
"ButtonSeries": "Seria", "ButtonSeries": "Seria",
"ButtonSetChaptersFromTracks": "Set chapters from tracks",
"ButtonShiftTimes": "Przesunięcie czasowe", "ButtonShiftTimes": "Przesunięcie czasowe",
"ButtonShow": "Pokaż", "ButtonShow": "Pokaż",
"ButtonStartM4BEncode": "Eksportuj jako plik M4B", "ButtonStartM4BEncode": "Eksportuj jako plik M4B",
"ButtonStartMetadataEmbed": "Osadź metadane", "ButtonStartMetadataEmbed": "Osadź metadane",
"ButtonSubmit": "Zgłoś", "ButtonSubmit": "Zaloguj",
"ButtonUpload": "Wgraj", "ButtonUpload": "Wgraj",
"ButtonUploadBackup": "Wgraj kopię zapasową", "ButtonUploadBackup": "Wgraj kopię zapasową",
"ButtonUploadCover": "Wgraj okładkę", "ButtonUploadCover": "Wgraj okładkę",
@@ -96,7 +98,7 @@
"HeaderLastListeningSession": "Ostatnio odtwarzana sesja", "HeaderLastListeningSession": "Ostatnio odtwarzana sesja",
"HeaderLatestEpisodes": "Najnowsze odcinki", "HeaderLatestEpisodes": "Najnowsze odcinki",
"HeaderLibraries": "Biblioteki", "HeaderLibraries": "Biblioteki",
"HeaderLibraryFiles": "Library Files", "HeaderLibraryFiles": "Pliki w bibliotece",
"HeaderLibraryStats": "Statystyki biblioteki", "HeaderLibraryStats": "Statystyki biblioteki",
"HeaderListeningSessions": "Sesje słuchania", "HeaderListeningSessions": "Sesje słuchania",
"HeaderListeningStats": "Statystyki odtwarzania", "HeaderListeningStats": "Statystyki odtwarzania",
@@ -111,6 +113,8 @@
"HeaderOtherFiles": "Inne pliki", "HeaderOtherFiles": "Inne pliki",
"HeaderPermissions": "Uprawnienia", "HeaderPermissions": "Uprawnienia",
"HeaderPlayerQueue": "Player Queue", "HeaderPlayerQueue": "Player Queue",
"HeaderPlaylist": "Playlist",
"HeaderPlaylistItems": "Playlist Items",
"HeaderPodcastsToAdd": "Podcasty do dodania", "HeaderPodcastsToAdd": "Podcasty do dodania",
"HeaderPreviewCover": "Podgląd okładki", "HeaderPreviewCover": "Podgląd okładki",
"HeaderRemoveEpisode": "Usuń odcinek", "HeaderRemoveEpisode": "Usuń odcinek",
@@ -144,14 +148,16 @@
"LabelAccountTypeGuest": "Gość", "LabelAccountTypeGuest": "Gość",
"LabelAccountTypeUser": "Użytkownik", "LabelAccountTypeUser": "Użytkownik",
"LabelActivity": "Aktywność", "LabelActivity": "Aktywność",
"LabelAddedAt": "Added At", "LabelAddedAt": "Dodano",
"LabelAddToCollection": "Dodaj do kolekcji", "LabelAddToCollection": "Dodaj do kolekcji",
"LabelAddToCollectionBatch": "Dodaj {0} książki do kolekcji", "LabelAddToCollectionBatch": "Dodaj {0} książki do kolekcji",
"LabelAddToPlaylist": "Add to Playlist",
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
"LabelAll": "All", "LabelAll": "All",
"LabelAllUsers": "Wszyscy użytkownicy", "LabelAllUsers": "Wszyscy użytkownicy",
"LabelAuthor": "Autor", "LabelAuthor": "Autor",
"LabelAuthorFirstLast": "Author (First Last)", "LabelAuthorFirstLast": "Autor (Rosnąco)",
"LabelAuthorLastFirst": "Author (Last, First)", "LabelAuthorLastFirst": "Author (Malejąco)",
"LabelAuthors": "Autorzy", "LabelAuthors": "Autorzy",
"LabelAutoDownloadEpisodes": "Automatyczne pobieranie odcinków", "LabelAutoDownloadEpisodes": "Automatyczne pobieranie odcinków",
"LabelBackToUser": "Powrót", "LabelBackToUser": "Powrót",
@@ -165,6 +171,7 @@
"LabelChangePassword": "Zmień hasło", "LabelChangePassword": "Zmień hasło",
"LabelChaptersFound": "Znalezione rozdziały", "LabelChaptersFound": "Znalezione rozdziały",
"LabelChapterTitle": "Tytuł rozdziału", "LabelChapterTitle": "Tytuł rozdziału",
"LabelClosePlayer": "Zamknij odtwarzacz",
"LabelCollapseSeries": "Podsumuj serię", "LabelCollapseSeries": "Podsumuj serię",
"LabelCollections": "Kolekcje", "LabelCollections": "Kolekcje",
"LabelComplete": "Ukończone", "LabelComplete": "Ukończone",
@@ -197,15 +204,15 @@
"LabelExplicit": "Nieprzyzwoite", "LabelExplicit": "Nieprzyzwoite",
"LabelFeedURL": "URL kanału", "LabelFeedURL": "URL kanału",
"LabelFile": "Plik", "LabelFile": "Plik",
"LabelFileBirthtime": "File Birthtime", "LabelFileBirthtime": "Data utworzenia pliku",
"LabelFileModified": "File Modified", "LabelFileModified": "Data modyfikacji pliku",
"LabelFilename": "Nazwa pliku", "LabelFilename": "Nazwa pliku",
"LabelFilterByUser": "Filtruj według danego użytkownika", "LabelFilterByUser": "Filtruj według danego użytkownika",
"LabelFindEpisodes": "Znajdź odcinki", "LabelFindEpisodes": "Znajdź odcinki",
"LabelFinished": "Zakończone", "LabelFinished": "Zakończone",
"LabelFolder": "Folder", "LabelFolder": "Folder",
"LabelFolders": "Foldery", "LabelFolders": "Foldery",
"LabelGenre": "Genre", "LabelGenre": "Gatunek",
"LabelGenres": "Gatunki", "LabelGenres": "Gatunki",
"LabelHardDeleteFile": "Usuń trwale plik", "LabelHardDeleteFile": "Usuń trwale plik",
"LabelHour": "Godzina", "LabelHour": "Godzina",
@@ -214,6 +221,14 @@
"LabelIncomplete": "Nieukończone", "LabelIncomplete": "Nieukończone",
"LabelInProgress": "W trakcie", "LabelInProgress": "W trakcie",
"LabelInterval": "Interwał", "LabelInterval": "Interwał",
"LabelIntervalCustomDailyWeekly": "Niestandardowy dzienny/tygodniowy",
"LabelIntervalEvery12Hours": "Co 12 godzin",
"LabelIntervalEvery15Minutes": "Co 15 minut",
"LabelIntervalEvery2Hours": "Co 2 godziny",
"LabelIntervalEvery30Minutes": "Co 30 minut",
"LabelIntervalEvery6Hours": "Co 6 godzin",
"LabelIntervalEveryDay": "Każdego dnia",
"LabelIntervalEveryHour": "Każdej godziny",
"LabelInvalidParts": "Nieprawidłowe części", "LabelInvalidParts": "Nieprawidłowe części",
"LabelItem": "Pozycja", "LabelItem": "Pozycja",
"LabelLanguage": "Język", "LabelLanguage": "Język",
@@ -228,6 +243,9 @@
"LabelLibraryName": "Nazwa biblioteki", "LabelLibraryName": "Nazwa biblioteki",
"LabelLimit": "Limit", "LabelLimit": "Limit",
"LabelListenAgain": "Słuchaj ponownie", "LabelListenAgain": "Słuchaj ponownie",
"LabelLogLevelDebug": "Debug",
"LabelLogLevelInfo": "Informacja",
"LabelLogLevelWarn": "Ostrzeżenie",
"LabelLookForNewEpisodesAfterDate": "Szukaj nowych odcinków po dacie", "LabelLookForNewEpisodesAfterDate": "Szukaj nowych odcinków po dacie",
"LabelMarkSeries": "Oznacz serię", "LabelMarkSeries": "Oznacz serię",
"LabelMediaPlayer": "Odtwarzacz", "LabelMediaPlayer": "Odtwarzacz",
@@ -256,8 +274,9 @@
"LabelNotificationsMaxQueueSize": "Maksymalny rozmiar kolejki dla powiadomień", "LabelNotificationsMaxQueueSize": "Maksymalny rozmiar kolejki dla powiadomień",
"LabelNotificationsMaxQueueSizeHelp": "Zdarzenia są ograniczone do 1 na sekundę. Zdarzenia będą ignorowane jeśli kolejka ma maksymalny rozmiar. Zapobiega to spamowaniu powiadomieniami.", "LabelNotificationsMaxQueueSizeHelp": "Zdarzenia są ograniczone do 1 na sekundę. Zdarzenia będą ignorowane jeśli kolejka ma maksymalny rozmiar. Zapobiega to spamowaniu powiadomieniami.",
"LabelNotificationTitleTemplate": "Szablon tytułu powiadmienia", "LabelNotificationTitleTemplate": "Szablon tytułu powiadmienia",
"LabelNotStarted": "Not Started", "LabelNotStarted": "Nie rozpoęczto",
"LabelNumberOfEpisodes": "# of Episodes", "LabelNumberOfBooks": "Liczba książek",
"LabelNumberOfEpisodes": "# odcinków",
"LabelOpenRSSFeed": "Otwórz kanał RSS", "LabelOpenRSSFeed": "Otwórz kanał RSS",
"LabelPassword": "Hasło", "LabelPassword": "Hasło",
"LabelPath": "Ścieżka", "LabelPath": "Ścieżka",
@@ -269,6 +288,7 @@
"LabelPermissionsUpdate": "Ma możliwość aktualizowania", "LabelPermissionsUpdate": "Ma możliwość aktualizowania",
"LabelPermissionsUpload": "Ma możliwość dodawania", "LabelPermissionsUpload": "Ma możliwość dodawania",
"LabelPhotoPathURL": "Scieżka/URL do zdjęcia", "LabelPhotoPathURL": "Scieżka/URL do zdjęcia",
"LabelPlaylists": "Playlists",
"LabelPlayMethod": "Metoda odtwarzania", "LabelPlayMethod": "Metoda odtwarzania",
"LabelPodcast": "Podcast", "LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasty", "LabelPodcasts": "Podcasty",
@@ -282,7 +302,8 @@
"LabelRecentSeries": "Ostatnie serie", "LabelRecentSeries": "Ostatnie serie",
"LabelRegion": "Region", "LabelRegion": "Region",
"LabelReleaseDate": "Data wydania", "LabelReleaseDate": "Data wydania",
"LabelRSSFeedOpen": "RSS Feed Open", "LabelRemoveCover": "Remove cover",
"LabelRSSFeedOpen": "RSS Feed otwarty",
"LabelRSSFeedSlug": "RSS Feed Slug", "LabelRSSFeedSlug": "RSS Feed Slug",
"LabelRSSFeedURL": "URL kanały RSS", "LabelRSSFeedURL": "URL kanały RSS",
"LabelSearchTerm": "Wyszukiwanie frazy", "LabelSearchTerm": "Wyszukiwanie frazy",
@@ -292,7 +313,7 @@
"LabelSequence": "Kolejność", "LabelSequence": "Kolejność",
"LabelSeries": "Serie", "LabelSeries": "Serie",
"LabelSeriesName": "Nazwy serii", "LabelSeriesName": "Nazwy serii",
"LabelSeriesProgress": "Series Progress", "LabelSeriesProgress": "Postęp w serii",
"LabelSettingsBookshelfViewHelp": "Widok półki z ksiązkami", "LabelSettingsBookshelfViewHelp": "Widok półki z ksiązkami",
"LabelSettingsChromecastSupport": "Wsparcie Chromecast", "LabelSettingsChromecastSupport": "Wsparcie Chromecast",
"LabelSettingsDateFormat": "Format daty", "LabelSettingsDateFormat": "Format daty",
@@ -329,6 +350,7 @@
"LabelSettingsStoreMetadataWithItemHelp": "Domyślnie metadane są przechowywane w folderze /metadata/items, włączenie tej opcji spowoduje, że okładka będzie przechowywana w folderze ksiązki. Tylko jedna okładka o nazwie pliku \"cover\" będzie przechowywana. Rozszerzenie pliku metadanych: .abs", "LabelSettingsStoreMetadataWithItemHelp": "Domyślnie metadane są przechowywane w folderze /metadata/items, włączenie tej opcji spowoduje, że okładka będzie przechowywana w folderze ksiązki. Tylko jedna okładka o nazwie pliku \"cover\" będzie przechowywana. Rozszerzenie pliku metadanych: .abs",
"LabelShowAll": "Pokaż wszystko", "LabelShowAll": "Pokaż wszystko",
"LabelSize": "Rozmiar", "LabelSize": "Rozmiar",
"LabelSleepTimer": "Wyłącznik czasowy",
"LabelStart": "Rozpocznij", "LabelStart": "Rozpocznij",
"LabelStarted": "Rozpoczęty", "LabelStarted": "Rozpoczęty",
"LabelStartedAt": "Rozpoczęto", "LabelStartedAt": "Rozpoczęto",
@@ -345,8 +367,8 @@
"LabelStatsItemsInLibrary": "Pozycje w bibliotece", "LabelStatsItemsInLibrary": "Pozycje w bibliotece",
"LabelStatsMinutes": "Minuty", "LabelStatsMinutes": "Minuty",
"LabelStatsMinutesListening": "Minuty odtwarzania", "LabelStatsMinutesListening": "Minuty odtwarzania",
"LabelStatsOverallDays": "Overall Days", "LabelStatsOverallDays": "Całkowity czas (dni)",
"LabelStatsOverallHours": "Overall Hours", "LabelStatsOverallHours": "Całkowity czas (godziny)",
"LabelStatsWeekListening": "Tydzień odtwarzania", "LabelStatsWeekListening": "Tydzień odtwarzania",
"LabelSubtitle": "Podtytuł", "LabelSubtitle": "Podtytuł",
"LabelSupportedFileTypes": "Obsługiwane typy plików", "LabelSupportedFileTypes": "Obsługiwane typy plików",
@@ -358,43 +380,55 @@
"LabelTimeRemaining": "Pozostało {0}", "LabelTimeRemaining": "Pozostało {0}",
"LabelTimeToShift": "Czas do przesunięcia w sekundach", "LabelTimeToShift": "Czas do przesunięcia w sekundach",
"LabelTitle": "Tytuł", "LabelTitle": "Tytuł",
"LabelToolsEmbedMetadata": "Embed Metadata", "LabelToolsEmbedMetadata": "Załącz metadane",
"LabelToolsEmbedMetadataDescription": "Embed metadata into audio files including cover image and chapters.", "LabelToolsEmbedMetadataDescription": "Załącz metadane do plików audio (okładkę oraz znaczniki rozdziałów)",
"LabelToolsMakeM4b": "Make M4B Audiobook File", "LabelToolsMakeM4b": "Generuj plik M4B",
"LabelToolsMakeM4bDescription": "Generate a .M4B audiobook file with embedded metadata, cover image, and chapters.", "LabelToolsMakeM4bDescription": "Tworzy plik w formacie .M4B, który zawiera metadane, okładkę oraz rozdziały.",
"LabelToolsSplitM4b": "Split M4B to MP3's", "LabelToolsSplitM4b": "Podziel plik .M4B na pliki .MP3",
"LabelToolsSplitM4bDescription": "Create MP3's from an M4B split by chapters with embedded metadata, cover image, and chapters.", "LabelToolsSplitM4bDescription": "Podziel plik .M4B na pliki .MP3 na rozdziały z załączonymi metadanymi oraz okładką.",
"LabelTotalDuration": "TCałkowita długość",
"LabelTotalTimeListened": "Całkowity czas odtwarzania", "LabelTotalTimeListened": "Całkowity czas odtwarzania",
"LabelTrackFromFilename": "Ścieżka z nazwy pliku", "LabelTrackFromFilename": "Ścieżka z nazwy pliku",
"LabelTrackFromMetadata": "Ścieżka z metadanych", "LabelTrackFromMetadata": "Ścieżka z metadanych",
"LabelTracks": "Tracks",
"LabelTracksMultiTrack": "Multi-track",
"LabelTracksSingleTrack": "Single-track",
"LabelType": "Typ", "LabelType": "Typ",
"LabelUnknown": "Nieznany", "LabelUnknown": "Nieznany",
"LabelUpdateCover": "Zaktalizuj odkładkę", "LabelUpdateCover": "Zaktalizuj odkładkę",
"LabelUpdateCoverHelp": "Umożliwienie nadpisania istniejących okładek dla wybranych książek w przypadku znalezienia dopasowania", "LabelUpdateCoverHelp": "Umożliwienie nadpisania istniejących okładek dla wybranych książek w przypadku znalezienia dopasowania",
"LabelUpdatedAt": "Zaktualizaowano", "LabelUpdatedAt": "Zaktualizowano",
"LabelUpdateDetails": "Zaktualizuj szczegóły", "LabelUpdateDetails": "Zaktualizuj szczegóły",
"LabelUpdateDetailsHelp": "Umożliwienie nadpisania istniejących szczegółów dla wybranych książek w przypadku znalezienia dopasowania", "LabelUpdateDetailsHelp": "Umożliwienie nadpisania istniejących szczegółów dla wybranych książek w przypadku znalezienia dopasowania",
"LabelUploaderDragAndDrop": "Przeciągnij i puść foldery lub pliki", "LabelUploaderDragAndDrop": "Przeciągnij i puść foldery lub pliki",
"LabelUploaderDropFiles": "Puść pliki", "LabelUploaderDropFiles": "Puść pliki",
"LabelUseChapterTrack": "Use chapter track", "LabelUseChapterTrack": "Użyj ścieżki rozdziału",
"LabelUseFullTrack": "Użycie ścieżki rozdziału", "LabelUseFullTrack": "Użycie ścieżki rozdziału",
"LabelUser": "Użytkownik", "LabelUser": "Użytkownik",
"LabelUsername": "Nazwa użytkownika", "LabelUsername": "Nazwa użytkownika",
"LabelValue": "Wartość", "LabelValue": "Wartość",
"LabelVersion": "Wersja", "LabelVersion": "Wersja",
"LabelViewBookmarks": "Wyświetlaj zakładki",
"LabelViewChapters": "Wyświetlaj rozdziały",
"LabelViewQueue": "Wyświetlaj kolejkę odtwarzania",
"LabelVolume": "Głośność",
"LabelWeekdaysToRun": "Dni tygodnia", "LabelWeekdaysToRun": "Dni tygodnia",
"LabelYourAudiobookDuration": "Czas trwania audiobooka", "LabelYourAudiobookDuration": "Czas trwania audiobooka",
"LabelYourBookmarks": "Twoje zakładki", "LabelYourBookmarks": "Twoje zakładki",
"LabelYourPlaylists": "Your Playlists",
"LabelYourProgress": "Twój postęp", "LabelYourProgress": "Twój postęp",
"MessageAppriseDescription": "To use this feature you will need to have an instance of <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> running or an api that will handle those same requests. <br />The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at <code>http://192.168.1.1:8337</code> then you would put <code>http://192.168.1.1:8337/notify</code>.", "MessageAddToPlayerQueue": "Add to player queue",
"MessageBackupsDescription": "Kopie zapasowe obejmują użytkowników, postępy użytkowników, szczegóły pozycji biblioteki, ustawienia serwera i obrazy przechowywane w", "MessageAppriseDescription": "Aby użyć tej funkcji, konieczne jest posiadanie instancji <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> albo innego rozwiązania, które obsługuje schemat zapytań Apprise. <br />URL do interfejsu API powinno być całkowitą ścieżką, np., jeśli Twoje API do powiadomień jest dostępne pod adresem <code>http://192.168.1.1:8337</code> to wpisany tutaj URL powinien mieć postać: <code>http://192.168.1.1:8337/notify</code>.",
"MessageBackupsNote": "Kopie zapasowe nie obejmują żadnych plików przechowywanych w folderach biblioteki.", "MessageBackupsDescription": "Kopie zapasowe obejmują użytkowników, postępy użytkowników, szczegóły pozycji biblioteki, ustawienia serwera i obrazy przechowywane w <code>/metadata/items</code> & <code>/metadata/authors</code>. Kopie zapasowe nie obejmują żadnych plików przechowywanych w folderach biblioteki.",
"MessageBatchQuickMatchDescription": "Quick Match będzie próbował dodać brakujące okładki i metadane dla wybranych elementów. Włącz poniższe opcje, aby umożliwić Quick Match nadpisanie istniejących okładek i/lub metadanych.", "MessageBatchQuickMatchDescription": "Quick Match będzie próbował dodać brakujące okładki i metadane dla wybranych elementów. Włącz poniższe opcje, aby umożliwić Quick Match nadpisanie istniejących okładek i/lub metadanych.",
"MessageBookshelfNoCollections": "You haven't made any collections yet", "MessageBookshelfNoCollections": "Nie posiadasz jeszcze żadnych kolekcji",
"MessageBookshelfNoResultsForFilter": "No Results for filter \"{0}: {1}\"", "MessageBookshelfNoResultsForFilter": "Nie znaleziono żadnych pozycji przy aktualnym filtrowaniu \"{0}: {1}\"",
"MessageBookshelfNoRSSFeeds": "No RSS feeds are open", "MessageBookshelfNoRSSFeeds": "Nie posiadasz żadnych otwartych feedów RSS",
"MessageBookshelfNoSeries": "You have no series", "MessageBookshelfNoSeries": "Nie masz jeszcze żadnych serii",
"MessageChapterEndIsAfter": "Koniec rozdziału następuje po zakończeniu audiobooka", "MessageChapterEndIsAfter": "Koniec rozdziału następuje po zakończeniu audiobooka",
"MessageChapterErrorFirstNotZero": "First chapter must start at 0",
"MessageChapterErrorStartGteDuration": "Invalid start time must be less than audiobook duration",
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
"MessageChapterStartIsAfter": "Początek rozdziału następuje po zakończeniu audiobooka", "MessageChapterStartIsAfter": "Początek rozdziału następuje po zakończeniu audiobooka",
"MessageCheckingCron": "Sprawdzanie cron...", "MessageCheckingCron": "Sprawdzanie cron...",
"MessageConfirmDeleteBackup": "Czy na pewno chcesz usunąć kopię zapasową dla {0}?", "MessageConfirmDeleteBackup": "Czy na pewno chcesz usunąć kopię zapasową dla {0}?",
@@ -404,6 +438,7 @@
"MessageConfirmRemoveCollection": "Czy na pewno chcesz usunąć kolekcję \"{0}\"?", "MessageConfirmRemoveCollection": "Czy na pewno chcesz usunąć kolekcję \"{0}\"?",
"MessageConfirmRemoveEpisode": "Czy na pewno chcesz usunąć odcinek \"{0}\"?", "MessageConfirmRemoveEpisode": "Czy na pewno chcesz usunąć odcinek \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Czy na pewno chcesz usunąć {0} odcinki?", "MessageConfirmRemoveEpisodes": "Czy na pewno chcesz usunąć {0} odcinki?",
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{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!",
@@ -436,7 +471,7 @@
"MessageNoEpisodes": "Brak odcinków", "MessageNoEpisodes": "Brak odcinków",
"MessageNoFoldersAvailable": "Brak dostępnych folderów", "MessageNoFoldersAvailable": "Brak dostępnych folderów",
"MessageNoGenres": "Brak gatunków", "MessageNoGenres": "Brak gatunków",
"MessageNoIssues": "No Issues", "MessageNoIssues": "Brak problemów",
"MessageNoItems": "Brak elementów", "MessageNoItems": "Brak elementów",
"MessageNoItemsFound": "Nie znaleziono żadnych elemntów", "MessageNoItemsFound": "Nie znaleziono żadnych elemntów",
"MessageNoListeningSessions": "Brak sesji odtwarzania", "MessageNoListeningSessions": "Brak sesji odtwarzania",
@@ -446,20 +481,28 @@
"MessageNoPodcastsFound": "Nie znaleziono podcastów", "MessageNoPodcastsFound": "Nie znaleziono podcastów",
"MessageNoResults": "Brak wyników", "MessageNoResults": "Brak wyników",
"MessageNoSearchResultsFor": "Brak wyników wyszukiwania dla \"{0}\"", "MessageNoSearchResultsFor": "Brak wyników wyszukiwania dla \"{0}\"",
"MessageNotYetImplemented": "Not yet implemented", "MessageNoSeries": "No Series",
"MessageNotYetImplemented": "Jeszcze nie zaimplementowane",
"MessageNoUpdateNecessary": "Brak konieczności aktualizacji", "MessageNoUpdateNecessary": "Brak konieczności aktualizacji",
"MessageNoUpdatesWereNecessary": "Brak aktualizacji", "MessageNoUpdatesWereNecessary": "Brak aktualizacji",
"MessageOr": "or", "MessageNoUserPlaylists": "You have no playlists",
"MessageOr": "lub",
"MessagePauseChapter": "Zatrzymaj odtwarzanie rozdziały",
"MessagePlayChapter": "Rozpocznij odtwarzanie od początku rozdziału",
"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?",
"MessageRemoveChapter": "Usuń rozdział",
"MessageRemoveEpisodes": "Usuń {0} odcinków", "MessageRemoveEpisodes": "Usuń {0} odcinków",
"MessageRemoveFromPlayerQueue": "Remove from player queue",
"MessageRemoveUserWarning": "Czy na pewno chcesz trwale usunąć użytkownika \"{0}\"?", "MessageRemoveUserWarning": "Czy na pewno chcesz trwale usunąć użytkownika \"{0}\"?",
"MessageReportBugsAndContribute": "Zgłoś błędy, pomysły i pomóż rozwijać aplikację na", "MessageReportBugsAndContribute": "Zgłoś błędy, pomysły i pomóż rozwijać aplikację na",
"MessageResetChaptersConfirm": "Are you sure you want to reset chapters and undo the changes you made?",
"MessageRestoreBackupConfirm": "Czy na pewno chcesz przywrócić kopię zapasową utworzoną w dniu", "MessageRestoreBackupConfirm": "Czy na pewno chcesz przywrócić kopię zapasową utworzoną w dniu",
"MessageRestoreBackupWarning": "Przywrócenie kopii zapasowej spowoduje nadpisane bazy danych w folderze /config oraz okładke w folderze /metadata/items & /metadata/authors.<br /><br />Kopie zapasowe nie modyfikują żadnego pliku w folderach z plikami audio. Jeśli włączyłeś ustawienia serwera, aby przechowywać okładki i metadane w folderach biblioteki, to nie są one zapisywane w kopii zapasowej lub nadpisywane<br /><br />Wszyscy klienci korzystający z Twojego serwera będą automatycznie odświeżani", "MessageRestoreBackupWarning": "Przywrócenie kopii zapasowej spowoduje nadpisane bazy danych w folderze /config oraz okładke w folderze /metadata/items & /metadata/authors.<br /><br />Kopie zapasowe nie modyfikują żadnego pliku w folderach z plikami audio. Jeśli włączyłeś ustawienia serwera, aby przechowywać okładki i metadane w folderach biblioteki, to nie są one zapisywane w kopii zapasowej lub nadpisywane<br /><br />Wszyscy klienci korzystający z Twojego serwera będą automatycznie odświeżani",
"MessageSearchResultsFor": "Wyniki wyszukiwania dla", "MessageSearchResultsFor": "Wyniki wyszukiwania dla",
"MessageServerCouldNotBeReached": "Nie udało się uzyskać połączenia z serwerem", "MessageServerCouldNotBeReached": "Nie udało się uzyskać połączenia z serwerem",
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
"MessageStartPlaybackAtTime": "Rozpoczęcie odtwarzania \"{0}\" od {1}?", "MessageStartPlaybackAtTime": "Rozpoczęcie odtwarzania \"{0}\" od {1}?",
"MessageThinking": "Myślę...", "MessageThinking": "Myślę...",
"MessageUploaderItemFailed": "Nie udało się przesłać", "MessageUploaderItemFailed": "Nie udało się przesłać",
@@ -467,12 +510,12 @@
"MessageUploading": "Przesyłanie...", "MessageUploading": "Przesyłanie...",
"MessageValidCronExpression": "Sprawdź wyrażenie CRON", "MessageValidCronExpression": "Sprawdź wyrażenie CRON",
"MessageWatcherIsDisabledGlobally": "Watcher jest wyłączony globalnie w ustawieniach serwera", "MessageWatcherIsDisabledGlobally": "Watcher jest wyłączony globalnie w ustawieniach serwera",
"MessageXLibraryIsEmpty": "{0} Library is empty!", "MessageXLibraryIsEmpty": "{0} Biblioteka jest pusta!",
"MessageYourAudiobookDurationIsLonger": "Czas trwania Twojego audiobooka jest dłuższy niż znaleziony czas trwania", "MessageYourAudiobookDurationIsLonger": "Czas trwania Twojego audiobooka jest dłuższy niż znaleziony czas trwania",
"MessageYourAudiobookDurationIsShorter": "Czas trwania Twojego audiobooka jest krótszy niż znaleziony czas trwania", "MessageYourAudiobookDurationIsShorter": "Czas trwania Twojego audiobooka jest krótszy niż znaleziony czas trwania",
"NoteChangeRootPassword": "Tylko użytkownik root, może posiadać puste hasło", "NoteChangeRootPassword": "Tylko użytkownik root, może posiadać puste hasło",
"NoteChapterEditorTimes": "Uwaga: Czas rozpoczęcia pierwszego rozdziału musi pozostać na poziomie 0:00, a czas rozpoczęcia ostatniego rozdziału nie może przekroczyć czasu trwania audiobooka.", "NoteChapterEditorTimes": "Uwaga: Czas rozpoczęcia pierwszego rozdziału musi pozostać na poziomie 0:00, a czas rozpoczęcia ostatniego rozdziału nie może przekroczyć czasu trwania audiobooka.",
"NoteFolderPicker": "Note: folders already mapped will not be shown", "NoteFolderPicker": "Uwaga: dotychczas zmapowane foldery nie zostaną wyświetlone",
"NoteFolderPickerDebian": "Uwaga: Wybór folderu w instalcji opartej o system debian nie jest w pełni zaimplementowany. Powinieneś wprowadzić ścieżkę do swojej biblioteki bezpośrednio.", "NoteFolderPickerDebian": "Uwaga: Wybór folderu w instalcji opartej o system debian nie jest w pełni zaimplementowany. Powinieneś wprowadzić ścieżkę do swojej biblioteki bezpośrednio.",
"NoteRSSFeedPodcastAppsHttps": "Ostrzeżenie: Większość aplikacji do obsługi podcastów wymaga, aby adres URL kanału RSS korzystał z protokołu HTTPS.", "NoteRSSFeedPodcastAppsHttps": "Ostrzeżenie: Większość aplikacji do obsługi podcastów wymaga, aby adres URL kanału RSS korzystał z protokołu HTTPS.",
"NoteRSSFeedPodcastAppsPubDate": "Ostrzeżenie: 1 lub więcej odcinków nie ma daty publikacji. Niektóre aplikacje do słuchania podcastów tego wymagają.", "NoteRSSFeedPodcastAppsPubDate": "Ostrzeżenie: 1 lub więcej odcinków nie ma daty publikacji. Niektóre aplikacje do słuchania podcastów tego wymagają.",
@@ -481,6 +524,7 @@
"NoteUploaderUnsupportedFiles": "Nieobsługiwane pliki są ignorowane. Podczas dodawania folderu, inne pliki, które nie znajdują się w folderze elementu, są ignorowane.", "NoteUploaderUnsupportedFiles": "Nieobsługiwane pliki są ignorowane. Podczas dodawania folderu, inne pliki, które nie znajdują się w folderze elementu, są ignorowane.",
"PlaceholderNewCollection": "Nowa nazwa kolekcji", "PlaceholderNewCollection": "Nowa nazwa kolekcji",
"PlaceholderNewFolderPath": "Nowa ścieżka folderu", "PlaceholderNewFolderPath": "Nowa ścieżka folderu",
"PlaceholderNewPlaylist": "New playlist name",
"PlaceholderSearch": "Szukanie..", "PlaceholderSearch": "Szukanie..",
"ToastAccountUpdateFailed": "Nie udało się zaktualizować konta", "ToastAccountUpdateFailed": "Nie udało się zaktualizować konta",
"ToastAccountUpdateSuccess": "Zaktualizowano konto", "ToastAccountUpdateSuccess": "Zaktualizowano konto",
@@ -492,8 +536,8 @@
"ToastAuthorUpdateSuccessNoImageFound": "Autor zaktualizowany (nie znaleziono obrazu)", "ToastAuthorUpdateSuccessNoImageFound": "Autor zaktualizowany (nie znaleziono obrazu)",
"ToastBackupCreateFailed": "Nie udało się utworzyć kopii zapasowej", "ToastBackupCreateFailed": "Nie udało się utworzyć kopii zapasowej",
"ToastBackupCreateSuccess": "Utworzono kopię zapasową", "ToastBackupCreateSuccess": "Utworzono kopię zapasową",
"ToastBackupDeleteFailed": "Failed to delete backup", "ToastBackupDeleteFailed": "Nie udało się usunąć kopii zapasowej",
"ToastBackupDeleteSuccess": "Nie udało się usunąć kopii zapasowej", "ToastBackupDeleteSuccess": "Udało się usunąć kopie zapasowej",
"ToastBackupRestoreFailed": "Nie udało się przywrócić kopii zapasowej", "ToastBackupRestoreFailed": "Nie udało się przywrócić kopii zapasowej",
"ToastBackupUploadFailed": "Nie udało się przesłać kopii zapasowej", "ToastBackupUploadFailed": "Nie udało się przesłać kopii zapasowej",
"ToastBackupUploadSuccess": "Kopia zapasowa została przesłana", "ToastBackupUploadSuccess": "Kopia zapasowa została przesłana",
@@ -505,6 +549,8 @@
"ToastBookmarkRemoveSuccess": "Zakładka została usunięta", "ToastBookmarkRemoveSuccess": "Zakładka została usunięta",
"ToastBookmarkUpdateFailed": "Nie udało się zaktualizować zakładki", "ToastBookmarkUpdateFailed": "Nie udało się zaktualizować zakładki",
"ToastBookmarkUpdateSuccess": "Zaktualizowano zakładkę", "ToastBookmarkUpdateSuccess": "Zaktualizowano zakładkę",
"ToastChaptersHaveErrors": "Chapters have errors",
"ToastChaptersMustHaveTitles": "Chapters must have titles",
"ToastCollectionItemsRemoveFailed": "Nie udało się usunąć pozycji z kolekcji", "ToastCollectionItemsRemoveFailed": "Nie udało się usunąć pozycji z kolekcji",
"ToastCollectionItemsRemoveSuccess": "Przedmiot(y) zostały usunięte z kolekcji", "ToastCollectionItemsRemoveSuccess": "Przedmiot(y) zostały usunięte z kolekcji",
"ToastCollectionRemoveFailed": "Nie udało się usunąć kolekcji", "ToastCollectionRemoveFailed": "Nie udało się usunąć kolekcji",
@@ -528,6 +574,10 @@
"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",
"ToastPlaylistRemoveFailed": "Failed to remove playlist",
"ToastPlaylistRemoveSuccess": "Playlist removed",
"ToastPlaylistUpdateFailed": "Failed to update playlist",
"ToastPlaylistUpdateSuccess": "Playlist updated",
"ToastPodcastCreateFailed": "Nie udało się utworzyć podcastu", "ToastPodcastCreateFailed": "Nie udało się utworzyć podcastu",
"ToastPodcastCreateSuccess": "Podcast został pomyślnie utworzony", "ToastPodcastCreateSuccess": "Podcast został pomyślnie utworzony",
"ToastRemoveItemFromCollectionFailed": "Nie udało się usunąć elementu z kolekcji", "ToastRemoveItemFromCollectionFailed": "Nie udało się usunąć elementu z kolekcji",
@@ -536,9 +586,9 @@
"ToastRSSFeedCloseSuccess": "Zamknięcie kanału RSS powiodło się", "ToastRSSFeedCloseSuccess": "Zamknięcie kanału RSS powiodło się",
"ToastSessionDeleteFailed": "Nie udało się usunąć sesji", "ToastSessionDeleteFailed": "Nie udało się usunąć sesji",
"ToastSessionDeleteSuccess": "Sesja usunięta", "ToastSessionDeleteSuccess": "Sesja usunięta",
"ToastSocketConnected": "Socket connected", "ToastSocketConnected": "Nawiązano połączenie z serwerem",
"ToastSocketDisconnected": "Socket disconnected", "ToastSocketDisconnected": "Połączenie z serwerem zostało zamknięte",
"ToastSocketFailedToConnect": "Socket failed to connect", "ToastSocketFailedToConnect": "Poączenie z serwerem nie powiodło się",
"ToastUserDeleteFailed": "Nie udało się usunąć użytkownika", "ToastUserDeleteFailed": "Nie udało się usunąć użytkownika",
"ToastUserDeleteSuccess": "Użytkownik usunięty", "ToastUserDeleteSuccess": "Użytkownik usunięty",
"WeekdayFriday": "Piątek", "WeekdayFriday": "Piątek",
+108 -58
View File
@@ -2,7 +2,7 @@
"ButtonAdd": "添加", "ButtonAdd": "添加",
"ButtonAddChapters": "添加章节", "ButtonAddChapters": "添加章节",
"ButtonAddPodcasts": "添加播客", "ButtonAddPodcasts": "添加播客",
"ButtonAddYourFirstLibrary": "添加第一个图书库", "ButtonAddYourFirstLibrary": "添加第一个媒体库",
"ButtonApply": "应用", "ButtonApply": "应用",
"ButtonApplyChapters": "应用到章节", "ButtonApplyChapters": "应用到章节",
"ButtonAuthors": "作者", "ButtonAuthors": "作者",
@@ -13,10 +13,10 @@
"ButtonCheckAndDownloadNewEpisodes": "检查并下载新剧集", "ButtonCheckAndDownloadNewEpisodes": "检查并下载新剧集",
"ButtonChooseAFolder": "选择文件夹", "ButtonChooseAFolder": "选择文件夹",
"ButtonChooseFiles": "选择文件", "ButtonChooseFiles": "选择文件",
"ButtonClearFilter": "Clear Filter", "ButtonClearFilter": "清除过滤器",
"ButtonCloseFeed": "关闭源", "ButtonCloseFeed": "关闭源",
"ButtonCollections": "收藏", "ButtonCollections": "收藏",
"ButtonConfigureScanner": "Configure Scanner", "ButtonConfigureScanner": "配置扫描",
"ButtonCreate": "创建", "ButtonCreate": "创建",
"ButtonCreateBackup": "创建备份", "ButtonCreateBackup": "创建备份",
"ButtonDelete": "删除", "ButtonDelete": "删除",
@@ -26,9 +26,9 @@
"ButtonFullPath": "完整路径", "ButtonFullPath": "完整路径",
"ButtonHide": "隐藏", "ButtonHide": "隐藏",
"ButtonHome": "首页", "ButtonHome": "首页",
"ButtonIssues": "反馈问题", "ButtonIssues": "问题",
"ButtonLatest": "最新", "ButtonLatest": "最新",
"ButtonLibrary": "图书库", "ButtonLibrary": "媒体库",
"ButtonLogout": "注销", "ButtonLogout": "注销",
"ButtonLookup": "查找", "ButtonLookup": "查找",
"ButtonManageTracks": "管理音轨", "ButtonManageTracks": "管理音轨",
@@ -41,6 +41,7 @@
"ButtonOpenManager": "打开管理器", "ButtonOpenManager": "打开管理器",
"ButtonPlay": "播放", "ButtonPlay": "播放",
"ButtonPlaying": "正在播放", "ButtonPlaying": "正在播放",
"ButtonPlaylists": "播放列表",
"ButtonPurgeAllCache": "清理所有缓存", "ButtonPurgeAllCache": "清理所有缓存",
"ButtonPurgeItemsCache": "清理项目缓存", "ButtonPurgeItemsCache": "清理项目缓存",
"ButtonPurgeMediaProgress": "清理媒体进度", "ButtonPurgeMediaProgress": "清理媒体进度",
@@ -50,7 +51,7 @@
"ButtonRead": "读取", "ButtonRead": "读取",
"ButtonRemove": "移除", "ButtonRemove": "移除",
"ButtonRemoveAll": "移除所有", "ButtonRemoveAll": "移除所有",
"ButtonRemoveAllLibraryItems": "移除所有图书项目", "ButtonRemoveAllLibraryItems": "移除所有媒体库项目",
"ButtonRemoveFromContinueListening": "从继续收听中删除", "ButtonRemoveFromContinueListening": "从继续收听中删除",
"ButtonRemoveSeriesFromContinueSeries": "从继续收听系列中删除", "ButtonRemoveSeriesFromContinueSeries": "从继续收听系列中删除",
"ButtonReScan": "重新扫描", "ButtonReScan": "重新扫描",
@@ -60,10 +61,11 @@
"ButtonSaveAndClose": "保存并关闭", "ButtonSaveAndClose": "保存并关闭",
"ButtonSaveTracklist": "保存音轨列表", "ButtonSaveTracklist": "保存音轨列表",
"ButtonScan": "扫描", "ButtonScan": "扫描",
"ButtonScanLibrary": "Scan Library", "ButtonScanLibrary": "扫描库",
"ButtonSearch": "查找", "ButtonSearch": "查找",
"ButtonSelectFolderPath": "选择文件夹路径", "ButtonSelectFolderPath": "选择文件夹路径",
"ButtonSeries": "系列", "ButtonSeries": "系列",
"ButtonSetChaptersFromTracks": "将音轨设置为章节",
"ButtonShiftTimes": "快速移动时间", "ButtonShiftTimes": "快速移动时间",
"ButtonShow": "显示", "ButtonShow": "显示",
"ButtonStartM4BEncode": "开始 M4B 编码", "ButtonStartM4BEncode": "开始 M4B 编码",
@@ -95,9 +97,9 @@
"HeaderItemFiles": "项目文件", "HeaderItemFiles": "项目文件",
"HeaderLastListeningSession": "最后一次收听会话", "HeaderLastListeningSession": "最后一次收听会话",
"HeaderLatestEpisodes": "最新剧集", "HeaderLatestEpisodes": "最新剧集",
"HeaderLibraries": "图书库", "HeaderLibraries": "媒体库",
"HeaderLibraryFiles": "图书库文件", "HeaderLibraryFiles": "媒体库文件",
"HeaderLibraryStats": "图书库统计数据", "HeaderLibraryStats": "媒体库统计数据",
"HeaderListeningSessions": "收听会话", "HeaderListeningSessions": "收听会话",
"HeaderListeningStats": "收听统计数据", "HeaderListeningStats": "收听统计数据",
"HeaderLogin": "登录", "HeaderLogin": "登录",
@@ -105,12 +107,14 @@
"HeaderMatch": "匹配", "HeaderMatch": "匹配",
"HeaderMetadataToEmbed": "嵌入元数据", "HeaderMetadataToEmbed": "嵌入元数据",
"HeaderNewAccount": "新建帐户", "HeaderNewAccount": "新建帐户",
"HeaderNewLibrary": "新建图书库", "HeaderNewLibrary": "新建媒体库",
"HeaderNotifications": "通知", "HeaderNotifications": "通知",
"HeaderOpenRSSFeed": "打开 RSS 源", "HeaderOpenRSSFeed": "打开 RSS 源",
"HeaderOtherFiles": "其他文件", "HeaderOtherFiles": "其他文件",
"HeaderPermissions": "权限", "HeaderPermissions": "权限",
"HeaderPlayerQueue": "播放列", "HeaderPlayerQueue": "播放列",
"HeaderPlaylist": "播放列表",
"HeaderPlaylistItems": "播放列表项目",
"HeaderPodcastsToAdd": "要添加的播客", "HeaderPodcastsToAdd": "要添加的播客",
"HeaderPreviewCover": "预览封面", "HeaderPreviewCover": "预览封面",
"HeaderRemoveEpisode": "移除剧集", "HeaderRemoveEpisode": "移除剧集",
@@ -118,7 +122,7 @@
"HeaderRSSFeedIsOpen": "RSS 源已打开", "HeaderRSSFeedIsOpen": "RSS 源已打开",
"HeaderSavedMediaProgress": "保存媒体进度", "HeaderSavedMediaProgress": "保存媒体进度",
"HeaderSchedule": "计划任务", "HeaderSchedule": "计划任务",
"HeaderScheduleLibraryScans": "自动扫描图书库", "HeaderScheduleLibraryScans": "自动扫描媒体库",
"HeaderSession": "会话", "HeaderSession": "会话",
"HeaderSetBackupSchedule": "设置备份计划任务", "HeaderSetBackupSchedule": "设置备份计划任务",
"HeaderSettings": "设置", "HeaderSettings": "设置",
@@ -136,7 +140,7 @@
"HeaderUpdateAccount": "更新帐户", "HeaderUpdateAccount": "更新帐户",
"HeaderUpdateAuthor": "更新作者", "HeaderUpdateAuthor": "更新作者",
"HeaderUpdateDetails": "更新详情", "HeaderUpdateDetails": "更新详情",
"HeaderUpdateLibrary": "更新图书库", "HeaderUpdateLibrary": "更新媒体库",
"HeaderUsers": "用户", "HeaderUsers": "用户",
"HeaderYourStats": "你的统计数据", "HeaderYourStats": "你的统计数据",
"LabelAccountType": "帐户类型", "LabelAccountType": "帐户类型",
@@ -146,7 +150,9 @@
"LabelActivity": "活动", "LabelActivity": "活动",
"LabelAddedAt": "添加于", "LabelAddedAt": "添加于",
"LabelAddToCollection": "添加到收藏", "LabelAddToCollection": "添加到收藏",
"LabelAddToCollectionBatch": "添加 {0} 图书到收藏", "LabelAddToCollectionBatch": "批量添加 {0} 个媒体到收藏",
"LabelAddToPlaylist": "添加到播放列表",
"LabelAddToPlaylistBatch": "添加 {0} 个项目到播放列表",
"LabelAll": "全部", "LabelAll": "全部",
"LabelAllUsers": "所有用户", "LabelAllUsers": "所有用户",
"LabelAuthor": "作者", "LabelAuthor": "作者",
@@ -165,9 +171,10 @@
"LabelChangePassword": "修改密码", "LabelChangePassword": "修改密码",
"LabelChaptersFound": "找到的章节", "LabelChaptersFound": "找到的章节",
"LabelChapterTitle": "章节标题", "LabelChapterTitle": "章节标题",
"LabelClosePlayer": "关闭播放器",
"LabelCollapseSeries": "折叠系列", "LabelCollapseSeries": "折叠系列",
"LabelCollections": "收藏", "LabelCollections": "收藏",
"LabelComplete": "已完", "LabelComplete": "已完",
"LabelConfirmPassword": "确认密码", "LabelConfirmPassword": "确认密码",
"LabelContinueListening": "继续收听", "LabelContinueListening": "继续收听",
"LabelContinueSeries": "继续收听系列", "LabelContinueSeries": "继续收听系列",
@@ -194,7 +201,7 @@
"LabelEpisode": "剧集", "LabelEpisode": "剧集",
"LabelEpisodeTitle": "剧集标题", "LabelEpisodeTitle": "剧集标题",
"LabelEpisodeType": "剧集类型", "LabelEpisodeType": "剧集类型",
"LabelExplicit": "显式", "LabelExplicit": "信息明确",
"LabelFeedURL": "源 URL", "LabelFeedURL": "源 URL",
"LabelFile": "文件", "LabelFile": "文件",
"LabelFileBirthtime": "文件创建时间", "LabelFileBirthtime": "文件创建时间",
@@ -202,7 +209,7 @@
"LabelFilename": "文件名", "LabelFilename": "文件名",
"LabelFilterByUser": "按用户筛选", "LabelFilterByUser": "按用户筛选",
"LabelFindEpisodes": "查找剧集", "LabelFindEpisodes": "查找剧集",
"LabelFinished": "听完", "LabelFinished": "听完",
"LabelFolder": "文件夹", "LabelFolder": "文件夹",
"LabelFolders": "文件夹", "LabelFolders": "文件夹",
"LabelGenre": "流派", "LabelGenre": "流派",
@@ -212,8 +219,16 @@
"LabelIcon": "图标", "LabelIcon": "图标",
"LabelIncludeInTracklist": "包含在音轨列表中", "LabelIncludeInTracklist": "包含在音轨列表中",
"LabelIncomplete": "未听完", "LabelIncomplete": "未听完",
"LabelInProgress": "正在听", "LabelInProgress": "正在听",
"LabelInterval": "间隔", "LabelInterval": "间隔",
"LabelIntervalCustomDailyWeekly": "自定义 每天 / 每周",
"LabelIntervalEvery12Hours": "每 12 小时",
"LabelIntervalEvery15Minutes": "每 15 分钟",
"LabelIntervalEvery2Hours": "每 2 小时",
"LabelIntervalEvery30Minutes": "每 30 分钟",
"LabelIntervalEvery6Hours": "每 6 小时",
"LabelIntervalEveryDay": "每天",
"LabelIntervalEveryHour": "每小时",
"LabelInvalidParts": "无效部件", "LabelInvalidParts": "无效部件",
"LabelItem": "项目", "LabelItem": "项目",
"LabelLanguage": "语言", "LabelLanguage": "语言",
@@ -222,12 +237,15 @@
"LabelLastTime": "最近一次", "LabelLastTime": "最近一次",
"LabelLastUpdate": "最近更新", "LabelLastUpdate": "最近更新",
"LabelLess": "较少", "LabelLess": "较少",
"LabelLibrariesAccessibleToUser": "用户可访问的图书库", "LabelLibrariesAccessibleToUser": "用户可访问的媒体库",
"LabelLibrary": "图书库", "LabelLibrary": "媒体库",
"LabelLibraryItem": "图书库项目", "LabelLibraryItem": "媒体库项目",
"LabelLibraryName": "图书库名称", "LabelLibraryName": "媒体库名称",
"LabelLimit": "限制", "LabelLimit": "限制",
"LabelListenAgain": "再次收听", "LabelListenAgain": "再次收听",
"LabelLogLevelDebug": "调试",
"LabelLogLevelInfo": "信息",
"LabelLogLevelWarn": "警告",
"LabelLookForNewEpisodesAfterDate": "在此日期后查找新剧集", "LabelLookForNewEpisodesAfterDate": "在此日期后查找新剧集",
"LabelMarkSeries": "标记系列", "LabelMarkSeries": "标记系列",
"LabelMediaPlayer": "媒体播放器", "LabelMediaPlayer": "媒体播放器",
@@ -246,7 +264,7 @@
"LabelNewestEpisodes": "最新剧集", "LabelNewestEpisodes": "最新剧集",
"LabelNewPassword": "新密码", "LabelNewPassword": "新密码",
"LabelNotes": "注释", "LabelNotes": "注释",
"LabelNotFinished": "未完", "LabelNotFinished": "未完",
"LabelNotificationAppriseURL": "通知 URL(s)", "LabelNotificationAppriseURL": "通知 URL(s)",
"LabelNotificationAvailableVariables": "可用变量", "LabelNotificationAvailableVariables": "可用变量",
"LabelNotificationBodyTemplate": "正文模板", "LabelNotificationBodyTemplate": "正文模板",
@@ -256,12 +274,13 @@
"LabelNotificationsMaxQueueSize": "通知事件的最大队列大小", "LabelNotificationsMaxQueueSize": "通知事件的最大队列大小",
"LabelNotificationsMaxQueueSizeHelp": "通知事件被限制为每秒触发 1 个. 如果队列处于最大大小, 则将忽略事件. 这可以防止通知垃圾邮件.", "LabelNotificationsMaxQueueSizeHelp": "通知事件被限制为每秒触发 1 个. 如果队列处于最大大小, 则将忽略事件. 这可以防止通知垃圾邮件.",
"LabelNotificationTitleTemplate": "标题模板", "LabelNotificationTitleTemplate": "标题模板",
"LabelNotStarted": "Not Started", "LabelNotStarted": "未开始",
"LabelNumberOfBooks": "图书数量",
"LabelNumberOfEpisodes": "# 集", "LabelNumberOfEpisodes": "# 集",
"LabelOpenRSSFeed": "打开 RSS 源", "LabelOpenRSSFeed": "打开 RSS 源",
"LabelPassword": "密码", "LabelPassword": "密码",
"LabelPath": "路径", "LabelPath": "路径",
"LabelPermissionsAccessAllLibraries": "可以访问所有图书库", "LabelPermissionsAccessAllLibraries": "可以访问所有媒体库",
"LabelPermissionsAccessAllTags": "可以访问所有标签", "LabelPermissionsAccessAllTags": "可以访问所有标签",
"LabelPermissionsAccessExplicitContent": "可以访问显式内容", "LabelPermissionsAccessExplicitContent": "可以访问显式内容",
"LabelPermissionsDelete": "可以删除", "LabelPermissionsDelete": "可以删除",
@@ -269,6 +288,7 @@
"LabelPermissionsUpdate": "可以更新", "LabelPermissionsUpdate": "可以更新",
"LabelPermissionsUpload": "可以上传", "LabelPermissionsUpload": "可以上传",
"LabelPhotoPathURL": "图片路径或 URL", "LabelPhotoPathURL": "图片路径或 URL",
"LabelPlaylists": "播放列表",
"LabelPlayMethod": "播放方法", "LabelPlayMethod": "播放方法",
"LabelPodcast": "播客", "LabelPodcast": "播客",
"LabelPodcasts": "播客", "LabelPodcasts": "播客",
@@ -282,6 +302,7 @@
"LabelRecentSeries": "最近添加系列", "LabelRecentSeries": "最近添加系列",
"LabelRegion": "区域", "LabelRegion": "区域",
"LabelReleaseDate": "发布日期", "LabelReleaseDate": "发布日期",
"LabelRemoveCover": "移除封面",
"LabelRSSFeedOpen": "打开 RSS 源", "LabelRSSFeedOpen": "打开 RSS 源",
"LabelRSSFeedSlug": "RSS 源段", "LabelRSSFeedSlug": "RSS 源段",
"LabelRSSFeedURL": "RSS 源 URL", "LabelRSSFeedURL": "RSS 源 URL",
@@ -297,7 +318,7 @@
"LabelSettingsChromecastSupport": "Chromecast 支持", "LabelSettingsChromecastSupport": "Chromecast 支持",
"LabelSettingsDateFormat": "日期格式", "LabelSettingsDateFormat": "日期格式",
"LabelSettingsDisableWatcher": "禁用监视程序", "LabelSettingsDisableWatcher": "禁用监视程序",
"LabelSettingsDisableWatcherForLibrary": "禁用图书库的文件夹监视程序", "LabelSettingsDisableWatcherForLibrary": "禁用媒体库的文件夹监视程序",
"LabelSettingsDisableWatcherHelp": "检测到文件更改时禁用自动添加和更新项目. *需要重启服务器", "LabelSettingsDisableWatcherHelp": "检测到文件更改时禁用自动添加和更新项目. *需要重启服务器",
"LabelSettingsEnableEReader": "为所有用户启用电子阅读器", "LabelSettingsEnableEReader": "为所有用户启用电子阅读器",
"LabelSettingsEnableEReaderHelp": "电子阅读器仍在开发中,但可以使用此设置向所有用户打开它(或使用 \"实验功能\" 切换仅供你使用)", "LabelSettingsEnableEReaderHelp": "电子阅读器仍在开发中,但可以使用此设置向所有用户打开它(或使用 \"实验功能\" 切换仅供你使用)",
@@ -306,17 +327,17 @@
"LabelSettingsFindCovers": "查找封面", "LabelSettingsFindCovers": "查找封面",
"LabelSettingsFindCoversHelp": "如果你的有声读物在文件夹中没有嵌入封面或封面图像, 扫描将尝试查找封面.<br>注意: 这将延长扫描时间", "LabelSettingsFindCoversHelp": "如果你的有声读物在文件夹中没有嵌入封面或封面图像, 扫描将尝试查找封面.<br>注意: 这将延长扫描时间",
"LabelSettingsHomePageBookshelfView": "首页使用书架视图", "LabelSettingsHomePageBookshelfView": "首页使用书架视图",
"LabelSettingsLibraryBookshelfView": "图书库使用书架视图", "LabelSettingsLibraryBookshelfView": "媒体库使用书架视图",
"LabelSettingsOverdriveMediaMarkers": "对章节使用 Overdrive 媒体标记", "LabelSettingsOverdriveMediaMarkers": "对章节使用 Overdrive 媒体标记",
"LabelSettingsOverdriveMediaMarkersHelp": "Overdrive 的 MP3 文件带有作为自定义元数据嵌入的章节时间. 启用此功能将自动将这些标签用于章节计时", "LabelSettingsOverdriveMediaMarkersHelp": "Overdrive 的 MP3 文件带有作为自定义元数据嵌入的章节时间. 启用此功能将自动将这些标签用于章节计时",
"LabelSettingsParseSubtitles": "解析副标题", "LabelSettingsParseSubtitles": "解析副标题",
"LabelSettingsParseSubtitlesHelp": "从有声读物文件夹中提取副标题.<br>副标题必须用 \" - \" 分隔.<br>例: \"书名 - 这里是副标题\" 则显示副标题 \"这里是副标题\"", "LabelSettingsParseSubtitlesHelp": "从有声读物文件夹中提取副标题.<br>副标题必须用 \" - \" 分隔.<br>例: \"书名 - 这里是副标题\" 则显示副标题 \"这里是副标题\"",
"LabelSettingsPreferAudioMetadata": "首选音频元数据", "LabelSettingsPreferAudioMetadata": "首选音频元数据",
"LabelSettingsPreferAudioMetadataHelp": "音频文件 ID3 元标记将用于文件夹名称上图书的详细信息", "LabelSettingsPreferAudioMetadataHelp": "音频文件 ID3 元标记将用于文件夹名称上媒体的详细信息",
"LabelSettingsPreferMatchedMetadata": "首选匹配的元数据", "LabelSettingsPreferMatchedMetadata": "首选匹配的元数据",
"LabelSettingsPreferMatchedMetadataHelp": "使用快速匹配时, 匹配的数据将覆盖项目详细信息. 默认情况下, 快速匹配将只填充缺少的详细信息.", "LabelSettingsPreferMatchedMetadataHelp": "使用快速匹配时, 匹配的数据将覆盖项目详细信息. 默认情况下, 快速匹配将只填充缺少的详细信息.",
"LabelSettingsPreferOPFMetadata": "首选 OPF 元数据", "LabelSettingsPreferOPFMetadata": "首选 OPF 元数据",
"LabelSettingsPreferOPFMetadataHelp": "OPF 文件元数据将用于文件夹名称上图书的详细信息", "LabelSettingsPreferOPFMetadataHelp": "OPF 文件元数据将用于文件夹名称上媒体的详细信息",
"LabelSettingsSkipMatchingBooksWithASIN": "跳过匹配已有 ASIN 的图书", "LabelSettingsSkipMatchingBooksWithASIN": "跳过匹配已有 ASIN 的图书",
"LabelSettingsSkipMatchingBooksWithISBN": "跳过匹配已有 ISBN 的图书", "LabelSettingsSkipMatchingBooksWithISBN": "跳过匹配已有 ISBN 的图书",
"LabelSettingsSortingIgnorePrefixes": "排序时忽略前缀", "LabelSettingsSortingIgnorePrefixes": "排序时忽略前缀",
@@ -324,11 +345,12 @@
"LabelSettingsSquareBookCovers": "用户方形图书封面", "LabelSettingsSquareBookCovers": "用户方形图书封面",
"LabelSettingsSquareBookCoversHelp": "比起标准的 1.6:1 图书封面,更喜欢使用方形封面", "LabelSettingsSquareBookCoversHelp": "比起标准的 1.6:1 图书封面,更喜欢使用方形封面",
"LabelSettingsStoreCoversWithItem": "存储项目封面", "LabelSettingsStoreCoversWithItem": "存储项目封面",
"LabelSettingsStoreCoversWithItemHelp": "默认情况下封面存储在/metadata/items文件夹中, 启用此设置将存储封面在你图书项目文件夹中. 只保留一个名为 \"cover\" 的文件", "LabelSettingsStoreCoversWithItemHelp": "默认情况下封面存储在/metadata/items文件夹中, 启用此设置将存储封面在你媒体项目文件夹中. 只保留一个名为 \"cover\" 的文件",
"LabelSettingsStoreMetadataWithItem": "存储项目元数据", "LabelSettingsStoreMetadataWithItem": "存储项目元数据",
"LabelSettingsStoreMetadataWithItemHelp": "默认情况下元数据文件存储在/metadata/items文件夹中, 启用此设置将存储元数据在你图书项目文件夹中. 使 .abs 文件护展名", "LabelSettingsStoreMetadataWithItemHelp": "默认情况下元数据文件存储在/metadata/items文件夹中, 启用此设置将存储元数据在你媒体项目文件夹中. 使 .abs 文件护展名",
"LabelShowAll": "全部显示", "LabelShowAll": "全部显示",
"LabelSize": "大小", "LabelSize": "文件大小",
"LabelSleepTimer": "睡眠定时",
"LabelStart": "开始", "LabelStart": "开始",
"LabelStarted": "开始于", "LabelStarted": "开始于",
"LabelStartedAt": "从这开始", "LabelStartedAt": "从这开始",
@@ -342,7 +364,7 @@
"LabelStatsHours": "小时", "LabelStatsHours": "小时",
"LabelStatsInARow": "在一行", "LabelStatsInARow": "在一行",
"LabelStatsItemsFinished": "已完成的项目", "LabelStatsItemsFinished": "已完成的项目",
"LabelStatsItemsInLibrary": "图书库中的项目", "LabelStatsItemsInLibrary": "媒体库中的项目",
"LabelStatsMinutes": "分钟", "LabelStatsMinutes": "分钟",
"LabelStatsMinutesListening": "收听分钟数", "LabelStatsMinutesListening": "收听分钟数",
"LabelStatsOverallDays": "总计天数", "LabelStatsOverallDays": "总计天数",
@@ -364,9 +386,13 @@
"LabelToolsMakeM4bDescription": "生成带有嵌入元数据, 封面图像和章节的 .M4B 有声读物文件.", "LabelToolsMakeM4bDescription": "生成带有嵌入元数据, 封面图像和章节的 .M4B 有声读物文件.",
"LabelToolsSplitM4b": "将 M4B 文件拆分为 MP3 文件", "LabelToolsSplitM4b": "将 M4B 文件拆分为 MP3 文件",
"LabelToolsSplitM4bDescription": "从 M4B 文件创建 MP3 文件, 按章节分割, 并嵌入元数据, 封面图像和章节.", "LabelToolsSplitM4bDescription": "从 M4B 文件创建 MP3 文件, 按章节分割, 并嵌入元数据, 封面图像和章节.",
"LabelTotalDuration": "总持续时间",
"LabelTotalTimeListened": "总收听时间", "LabelTotalTimeListened": "总收听时间",
"LabelTrackFromFilename": "从文件名获取音轨", "LabelTrackFromFilename": "从文件名获取音轨",
"LabelTrackFromMetadata": "从源数据获取音轨", "LabelTrackFromMetadata": "从源数据获取音轨",
"LabelTracks": "音轨",
"LabelTracksMultiTrack": "多轨",
"LabelTracksSingleTrack": "单轨",
"LabelType": "类型", "LabelType": "类型",
"LabelUnknown": "未知", "LabelUnknown": "未知",
"LabelUpdateCover": "更新封面", "LabelUpdateCover": "更新封面",
@@ -382,28 +408,37 @@
"LabelUsername": "用户名", "LabelUsername": "用户名",
"LabelValue": "值", "LabelValue": "值",
"LabelVersion": "版本", "LabelVersion": "版本",
"LabelViewBookmarks": "查看书签",
"LabelViewChapters": "查看章节",
"LabelViewQueue": "查看播放列表",
"LabelVolume": "音量",
"LabelWeekdaysToRun": "工作日运行", "LabelWeekdaysToRun": "工作日运行",
"LabelYourAudiobookDuration": "你的有声读物持续时间", "LabelYourAudiobookDuration": "你的有声读物持续时间",
"LabelYourBookmarks": "你的书签", "LabelYourBookmarks": "你的书签",
"LabelYourPlaylists": "你的播放列表",
"LabelYourProgress": "你的进度", "LabelYourProgress": "你的进度",
"MessageAddToPlayerQueue": "添加到播放队列",
"MessageAppriseDescription": "要使用此功能,您需要运行一个 <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> 实例或一个可以处理这些相同请求的 API. <br />Apprise API Url 应该是发送通知的完整 URL 路径, 例如: 如果你的 API 实例运行在 <code>http://192.168.1.1:8337</code>, 那么你可以输入 <code>http://192.168.1.1:8337/notify</code>.", "MessageAppriseDescription": "要使用此功能,您需要运行一个 <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> 实例或一个可以处理这些相同请求的 API. <br />Apprise API Url 应该是发送通知的完整 URL 路径, 例如: 如果你的 API 实例运行在 <code>http://192.168.1.1:8337</code>, 那么你可以输入 <code>http://192.168.1.1:8337/notify</code>.",
"MessageBackupsDescription": "备份包括用户, 用户进度, 图书库项目详细信息, 服务器设置和图像, 存储在", "MessageBackupsDescription": "备份包括用户, 用户进度, 媒体库项目详细信息, 服务器设置和图像, 存储在 <code>/metadata/items</code> & <code>/metadata/authors</code>. 备份不包括存储在您的媒体库文件夹中的任何文件.",
"MessageBackupsNote": "备份不包括存储在您的图书库文件夹中的任何文件.",
"MessageBatchQuickMatchDescription": "快速匹配将尝试为所选项目添加缺少的封面和元数据. 启用以下选项以允许快速匹配覆盖现有封面和或元数据.", "MessageBatchQuickMatchDescription": "快速匹配将尝试为所选项目添加缺少的封面和元数据. 启用以下选项以允许快速匹配覆盖现有封面和或元数据.",
"MessageBookshelfNoCollections": "You haven't made any collections yet", "MessageBookshelfNoCollections": "你尚未进行任何收藏",
"MessageBookshelfNoResultsForFilter": "No Results for filter \"{0}: {1}\"", "MessageBookshelfNoResultsForFilter": "过滤器无结果 \"{0}: {1}\"",
"MessageBookshelfNoRSSFeeds": "No RSS feeds are open", "MessageBookshelfNoRSSFeeds": "没有打开的 RSS 源",
"MessageBookshelfNoSeries": "You have no series", "MessageBookshelfNoSeries": "你没有系列",
"MessageChapterEndIsAfter": "章节结束是在有声读物结束之后", "MessageChapterEndIsAfter": "章节结束是在有声读物结束之后",
"MessageChapterErrorFirstNotZero": "第一章节必须从 0 开始",
"MessageChapterErrorStartGteDuration": "无效的开始时间, 必须小于有声读物持续时间",
"MessageChapterErrorStartLtPrev": "无效的开始时间, 必须大于或等于上一章节的开始时间",
"MessageChapterStartIsAfter": "章节开始是在有声读物结束之后", "MessageChapterStartIsAfter": "章节开始是在有声读物结束之后",
"MessageCheckingCron": "检查计划任务...", "MessageCheckingCron": "检查计划任务...",
"MessageConfirmDeleteBackup": "你确定要删除备份 {0}?", "MessageConfirmDeleteBackup": "你确定要删除备份 {0}?",
"MessageConfirmDeleteLibrary": "你确定要永久删除图书库 \"{0}\"?", "MessageConfirmDeleteLibrary": "你确定要永久删除媒体库 \"{0}\"?",
"MessageConfirmDeleteSession": "你确定要删除此会话吗?", "MessageConfirmDeleteSession": "你确定要删除此会话吗?",
"MessageConfirmForceReScan": "你确定要强制重新扫描吗?", "MessageConfirmForceReScan": "你确定要强制重新扫描吗?",
"MessageConfirmRemoveCollection": "您确定要移除收藏 \"{0}\"?", "MessageConfirmRemoveCollection": "您确定要移除收藏 \"{0}\"?",
"MessageConfirmRemoveEpisode": "您确定要移除剧集 \"{0}\"?", "MessageConfirmRemoveEpisode": "您确定要移除剧集 \"{0}\"?",
"MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?", "MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?",
"MessageConfirmRemovePlaylist": "你确定要移除播放列表 \"{0}\"?",
"MessageDownloadingEpisode": "正在下载剧集", "MessageDownloadingEpisode": "正在下载剧集",
"MessageDragFilesIntoTrackOrder": "将文件拖动到正确的音轨顺序", "MessageDragFilesIntoTrackOrder": "将文件拖动到正确的音轨顺序",
"MessageEmbedFinished": "嵌入完成!", "MessageEmbedFinished": "嵌入完成!",
@@ -423,7 +458,7 @@
"MessageMapChapterTitles": "将章节标题映射到现有的有声读物章节, 无需调整时间戳", "MessageMapChapterTitles": "将章节标题映射到现有的有声读物章节, 无需调整时间戳",
"MessageMarkAsFinished": "标记为已听完", "MessageMarkAsFinished": "标记为已听完",
"MessageMarkAsNotFinished": "标记为未听完", "MessageMarkAsNotFinished": "标记为未听完",
"MessageMatchBooksDescription": "尝试将图书库中的图书与所选搜索提供商的图书进行匹配, 并填写空白的详细信息和封面. 不覆盖详细信息.", "MessageMatchBooksDescription": "尝试将媒体库中的图书与所选搜索提供商的图书进行匹配, 并填写空白的详细信息和封面. 不覆盖详细信息.",
"MessageNoAudioTracks": "没有音轨", "MessageNoAudioTracks": "没有音轨",
"MessageNoAuthors": "没有作者", "MessageNoAuthors": "没有作者",
"MessageNoBackups": "没有备份", "MessageNoBackups": "没有备份",
@@ -436,7 +471,7 @@
"MessageNoEpisodes": "没有剧集", "MessageNoEpisodes": "没有剧集",
"MessageNoFoldersAvailable": "没有可用文件夹", "MessageNoFoldersAvailable": "没有可用文件夹",
"MessageNoGenres": "无流派", "MessageNoGenres": "无流派",
"MessageNoIssues": "No Issues", "MessageNoIssues": "无问题",
"MessageNoItems": "无项目", "MessageNoItems": "无项目",
"MessageNoItemsFound": "未找到任何项目", "MessageNoItemsFound": "未找到任何项目",
"MessageNoListeningSessions": "无收听会话", "MessageNoListeningSessions": "无收听会话",
@@ -446,41 +481,50 @@
"MessageNoPodcastsFound": "未找到播客", "MessageNoPodcastsFound": "未找到播客",
"MessageNoResults": "无结果", "MessageNoResults": "无结果",
"MessageNoSearchResultsFor": "没有搜索到结果 \"{0}\"", "MessageNoSearchResultsFor": "没有搜索到结果 \"{0}\"",
"MessageNoSeries": "无系列",
"MessageNotYetImplemented": "尚未实施", "MessageNotYetImplemented": "尚未实施",
"MessageNoUpdateNecessary": "无需更新", "MessageNoUpdateNecessary": "无需更新",
"MessageNoUpdatesWereNecessary": "无需更新", "MessageNoUpdatesWereNecessary": "无需更新",
"MessageNoUserPlaylists": "你没有播放列表",
"MessageOr": "或", "MessageOr": "或",
"MessagePauseChapter": "暂停章节播放",
"MessagePlayChapter": "开始章节播放",
"MessagePodcastHasNoRSSFeedForMatching": "播客没有可用于匹配 RSS 源的 url", "MessagePodcastHasNoRSSFeedForMatching": "播客没有可用于匹配 RSS 源的 url",
"MessageQuickMatchDescription": "使用来自 '{0}' 的第一个匹配结果填充空白详细信息和封面. 除非启用 '首选匹配元数据' 服务器设置, 否则不会覆盖详细信息.", "MessageQuickMatchDescription": "使用来自 '{0}' 的第一个匹配结果填充空白详细信息和封面. 除非启用 '首选匹配元数据' 服务器设置, 否则不会覆盖详细信息.",
"MessageRemoveAllItemsWarning": "警告! 此操作将从数据库中删除所有的图书库项, 包括您所做的任何更新或匹配. 这不会对实际文件产生任何影响. 你确定吗?", "MessageRemoveAllItemsWarning": "警告! 此操作将从数据库中删除所有的媒体库项, 包括您所做的任何更新或匹配. 这不会对实际文件产生任何影响. 你确定吗?",
"MessageRemoveChapter": "移除章节",
"MessageRemoveEpisodes": "移除 {0} 剧集", "MessageRemoveEpisodes": "移除 {0} 剧集",
"MessageRemoveFromPlayerQueue": "从播放队列中移除",
"MessageRemoveUserWarning": "是否确实要永久删除用户 \"{0}\"?", "MessageRemoveUserWarning": "是否确实要永久删除用户 \"{0}\"?",
"MessageReportBugsAndContribute": "报告错误、请求功能和贡献在", "MessageReportBugsAndContribute": "报告错误、请求功能和贡献在",
"MessageRestoreBackupConfirm": "确定要恢复创建的这个备份", "MessageResetChaptersConfirm": "确定要重置章节并撤消你所做的更改吗?",
"MessageRestoreBackupWarning": "恢复备份将覆盖位于 /config 的整个数据库并覆盖 /metadata/items & /metadata/authors 中的图像.<br /><br />备份不会修改图书库文件夹中的任何文件. 如果您已启用服务器设置将封面和元数据存储在库文件夹中,则不会备份或覆盖这些内容.<br /><br />将自动刷新使用服务器的所有客户端.", "MessageRestoreBackupConfirm": "你确定要恢复创建的这个备份",
"MessageRestoreBackupWarning": "恢复备份将覆盖位于 /config 的整个数据库并覆盖 /metadata/items & /metadata/authors 中的图像.<br /><br />备份不会修改媒体库文件夹中的任何文件. 如果您已启用服务器设置将封面和元数据存储在库文件夹中,则不会备份或覆盖这些内容.<br /><br />将自动刷新使用服务器的所有客户端.",
"MessageSearchResultsFor": "搜索结果", "MessageSearchResultsFor": "搜索结果",
"MessageServerCouldNotBeReached": "无法访问服务器", "MessageServerCouldNotBeReached": "无法访问服务器",
"MessageSetChaptersFromTracksDescription": "把每个音频文件设置为章节并将章节标题设置为音频文件名",
"MessageStartPlaybackAtTime": "开始播放 \"{0}\" 在 {1}?", "MessageStartPlaybackAtTime": "开始播放 \"{0}\" 在 {1}?",
"MessageThinking": "思考...", "MessageThinking": "正在查找...",
"MessageUploaderItemFailed": "上传失败", "MessageUploaderItemFailed": "上传失败",
"MessageUploaderItemSuccess": "上传成功!", "MessageUploaderItemSuccess": "上传成功!",
"MessageUploading": "正在上传...", "MessageUploading": "正在上传...",
"MessageValidCronExpression": "有效的计划任务表达式", "MessageValidCronExpression": "有效的计划任务表达式",
"MessageWatcherIsDisabledGlobally": "在服务器设置中禁用全局监视程序", "MessageWatcherIsDisabledGlobally": "在服务器设置中禁用全局监视程序",
"MessageXLibraryIsEmpty": "{0} Library is empty!", "MessageXLibraryIsEmpty": "{0} 库为空!",
"MessageYourAudiobookDurationIsLonger": "您的有声读物持续时间比找到的持续时间长", "MessageYourAudiobookDurationIsLonger": "您的有声读物持续时间比找到的持续时间长",
"MessageYourAudiobookDurationIsShorter": "您的有声读物持续时间比找到的持续时间短", "MessageYourAudiobookDurationIsShorter": "您的有声读物持续时间比找到的持续时间短",
"NoteChangeRootPassword": "Root 是唯一可以拥有空密码的用户", "NoteChangeRootPassword": "Root 是唯一可以拥有空密码的用户",
"NoteChapterEditorTimes": "注意: 第一章开始时间必须保持在 0:00, 最后一章开始时间不能超过有声读物持续时间.", "NoteChapterEditorTimes": "注意: 第一章开始时间必须保持在 0:00, 最后一章开始时间不能超过有声读物持续时间.",
"NoteFolderPicker": "注意: 将不显示已映射的文件夹", "NoteFolderPicker": "注意: 将不显示已映射的文件夹",
"NoteFolderPickerDebian": "注意: debian 安装的文件夹选择器尚未完全实现. 您应该直接输入图书库的路径.", "NoteFolderPickerDebian": "注意: debian 安装的文件夹选择器尚未完全实现. 您应该直接输入媒体库的路径.",
"NoteRSSFeedPodcastAppsHttps": "警告: 大多数播客应用程序都需要 RSS 源 URL 使用 HTTPS", "NoteRSSFeedPodcastAppsHttps": "警告: 大多数播客应用程序都需要 RSS 源 URL 使用 HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "警告: 您的一集或多集没有发布日期. 一些播客应用程序要求这样做.", "NoteRSSFeedPodcastAppsPubDate": "警告: 您的一集或多集没有发布日期. 一些播客应用程序要求这样做.",
"NoteUploaderFoldersWithMediaFiles": "包含媒体文件的文件夹将作为单独的图书库项目处理.", "NoteUploaderFoldersWithMediaFiles": "包含媒体文件的文件夹将作为单独的媒体库项目处理.",
"NoteUploaderOnlyAudioFiles": "如果只上传音频文件, 则每个音频文件将作为单独的有声读物处理.", "NoteUploaderOnlyAudioFiles": "如果只上传音频文件, 则每个音频文件将作为单独的有声读物处理.",
"NoteUploaderUnsupportedFiles": "不支持的文件将被忽略. 选择或删除文件夹时, 将忽略不在项目文件夹中的其他文件.", "NoteUploaderUnsupportedFiles": "不支持的文件将被忽略. 选择或删除文件夹时, 将忽略不在项目文件夹中的其他文件.",
"PlaceholderNewCollection": "新建收藏夹名称", "PlaceholderNewCollection": "输入收藏夹名称",
"PlaceholderNewFolderPath": "输入文件夹路径", "PlaceholderNewFolderPath": "输入文件夹路径",
"PlaceholderNewPlaylist": "输入播放列表名称",
"PlaceholderSearch": "查找..", "PlaceholderSearch": "查找..",
"ToastAccountUpdateFailed": "账户更新失败", "ToastAccountUpdateFailed": "账户更新失败",
"ToastAccountUpdateSuccess": "帐户已更新", "ToastAccountUpdateSuccess": "帐户已更新",
@@ -505,6 +549,8 @@
"ToastBookmarkRemoveSuccess": "书签已删除", "ToastBookmarkRemoveSuccess": "书签已删除",
"ToastBookmarkUpdateFailed": "书签更新失败", "ToastBookmarkUpdateFailed": "书签更新失败",
"ToastBookmarkUpdateSuccess": "书签已更新", "ToastBookmarkUpdateSuccess": "书签已更新",
"ToastChaptersHaveErrors": "章节有错误",
"ToastChaptersMustHaveTitles": "章节必须有标题",
"ToastCollectionItemsRemoveFailed": "从收藏夹移除项目失败", "ToastCollectionItemsRemoveFailed": "从收藏夹移除项目失败",
"ToastCollectionItemsRemoveSuccess": "项目从收藏夹移除", "ToastCollectionItemsRemoveSuccess": "项目从收藏夹移除",
"ToastCollectionRemoveFailed": "删除收藏夹失败", "ToastCollectionRemoveFailed": "删除收藏夹失败",
@@ -520,14 +566,18 @@
"ToastItemMarkedAsFinishedSuccess": "标记为听完的项目", "ToastItemMarkedAsFinishedSuccess": "标记为听完的项目",
"ToastItemMarkedAsNotFinishedFailed": "标记为未听完失败", "ToastItemMarkedAsNotFinishedFailed": "标记为未听完失败",
"ToastItemMarkedAsNotFinishedSuccess": "标记为未听完的项目", "ToastItemMarkedAsNotFinishedSuccess": "标记为未听完的项目",
"ToastLibraryCreateFailed": "创建图书库失败", "ToastLibraryCreateFailed": "创建媒体库失败",
"ToastLibraryCreateSuccess": "图书库 \"{0}\" 创建成功", "ToastLibraryCreateSuccess": "媒体库 \"{0}\" 创建成功",
"ToastLibraryDeleteFailed": "删除图书库失败", "ToastLibraryDeleteFailed": "删除媒体库失败",
"ToastLibraryDeleteSuccess": "图书库已删除", "ToastLibraryDeleteSuccess": "媒体库已删除",
"ToastLibraryScanFailedToStart": "无法启动扫描", "ToastLibraryScanFailedToStart": "无法启动扫描",
"ToastLibraryScanStarted": "图书库扫描已启动", "ToastLibraryScanStarted": "媒体库扫描已启动",
"ToastLibraryUpdateFailed": "更新图书库失败", "ToastLibraryUpdateFailed": "更新图书库失败",
"ToastLibraryUpdateSuccess": "图书库 \"{0}\" 已更新", "ToastLibraryUpdateSuccess": "媒体库 \"{0}\" 已更新",
"ToastPlaylistRemoveFailed": "删除播放列表失败",
"ToastPlaylistRemoveSuccess": "播放列表已删除",
"ToastPlaylistUpdateFailed": "更新播放列表失败",
"ToastPlaylistUpdateSuccess": "播放列表已更新",
"ToastPodcastCreateFailed": "创建播客失败", "ToastPodcastCreateFailed": "创建播客失败",
"ToastPodcastCreateSuccess": "已成功创建播客", "ToastPodcastCreateSuccess": "已成功创建播客",
"ToastRemoveItemFromCollectionFailed": "从收藏中删除项目失败", "ToastRemoveItemFromCollectionFailed": "从收藏中删除项目失败",
@@ -548,4 +598,4 @@
"WeekdayThursday": "星期四", "WeekdayThursday": "星期四",
"WeekdayTuesday": "星期二", "WeekdayTuesday": "星期二",
"WeekdayWednesday": "星期三" "WeekdayWednesday": "星期三"
} }
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.2.5", "version": "2.2.7",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.2.5", "version": "2.2.7",
"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.5", "version": "2.2.7",
"description": "Self-hosted audiobook and podcast server", "description": "Self-hosted audiobook and podcast server",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
+1 -1
View File
@@ -109,7 +109,7 @@ class Auth {
Logger.error('JWT Verify Token Failed', err) Logger.error('JWT Verify Token Failed', err)
return resolve(null) return resolve(null)
} }
var user = this.users.find(u => u.id === payload.userId && u.username === payload.username) const user = this.users.find(u => u.id === payload.userId && u.username === payload.username)
resolve(user || null) resolve(user || null)
}) })
}) })
+29 -34
View File
@@ -5,6 +5,7 @@ const { version } = require('../package.json')
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')
const Playlist = require('./objects/Playlist')
const Library = require('./objects/Library') const Library = require('./objects/Library')
const Author = require('./objects/entities/Author') const Author = require('./objects/entities/Author')
const Series = require('./objects/entities/Series') const Series = require('./objects/entities/Series')
@@ -20,6 +21,7 @@ class Db {
this.LibrariesPath = Path.join(global.ConfigPath, 'libraries') this.LibrariesPath = Path.join(global.ConfigPath, 'libraries')
this.SettingsPath = Path.join(global.ConfigPath, 'settings') this.SettingsPath = Path.join(global.ConfigPath, 'settings')
this.CollectionsPath = Path.join(global.ConfigPath, 'collections') this.CollectionsPath = Path.join(global.ConfigPath, 'collections')
this.PlaylistsPath = Path.join(global.ConfigPath, 'playlists')
this.AuthorsPath = Path.join(global.ConfigPath, 'authors') this.AuthorsPath = Path.join(global.ConfigPath, 'authors')
this.SeriesPath = Path.join(global.ConfigPath, 'series') this.SeriesPath = Path.join(global.ConfigPath, 'series')
this.FeedsPath = Path.join(global.ConfigPath, 'feeds') this.FeedsPath = Path.join(global.ConfigPath, 'feeds')
@@ -31,6 +33,7 @@ class Db {
this.librariesDb = new njodb.Database(this.LibrariesPath, { datastores: 2, lockoptions: { stale: staleTime } }) this.librariesDb = new njodb.Database(this.LibrariesPath, { datastores: 2, lockoptions: { stale: staleTime } })
this.settingsDb = new njodb.Database(this.SettingsPath, { datastores: 2, lockoptions: { stale: staleTime } }) this.settingsDb = new njodb.Database(this.SettingsPath, { datastores: 2, lockoptions: { stale: staleTime } })
this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2, lockoptions: { stale: staleTime } }) this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2, lockoptions: { stale: staleTime } })
this.playlistsDb = new njodb.Database(this.PlaylistsPath, { datastores: 2, lockoptions: { stale: staleTime } })
this.authorsDb = new njodb.Database(this.AuthorsPath, { lockoptions: { stale: staleTime } }) this.authorsDb = new njodb.Database(this.AuthorsPath, { lockoptions: { stale: staleTime } })
this.seriesDb = new njodb.Database(this.SeriesPath, { datastores: 2, lockoptions: { stale: staleTime } }) this.seriesDb = new njodb.Database(this.SeriesPath, { datastores: 2, lockoptions: { stale: staleTime } })
this.feedsDb = new njodb.Database(this.FeedsPath, { datastores: 2, lockoptions: { stale: staleTime } }) this.feedsDb = new njodb.Database(this.FeedsPath, { datastores: 2, lockoptions: { stale: staleTime } })
@@ -40,6 +43,7 @@ class Db {
this.libraries = [] this.libraries = []
this.settings = [] this.settings = []
this.collections = [] this.collections = []
this.playlists = []
this.authors = [] this.authors = []
this.series = [] this.series = []
@@ -61,6 +65,7 @@ class Db {
else if (entityName === 'library') return this.librariesDb else if (entityName === 'library') return this.librariesDb
else if (entityName === 'settings') return this.settingsDb else if (entityName === 'settings') return this.settingsDb
else if (entityName === 'collection') return this.collectionsDb else if (entityName === 'collection') return this.collectionsDb
else if (entityName === 'playlist') return this.playlistsDb
else if (entityName === 'author') return this.authorsDb else if (entityName === 'author') return this.authorsDb
else if (entityName === 'series') return this.seriesDb else if (entityName === 'series') return this.seriesDb
else if (entityName === 'feed') return this.feedsDb else if (entityName === 'feed') return this.feedsDb
@@ -74,6 +79,7 @@ class Db {
else if (entityName === 'library') return 'libraries' else if (entityName === 'library') return 'libraries'
else if (entityName === 'settings') return 'settings' else if (entityName === 'settings') return 'settings'
else if (entityName === 'collection') return 'collections' else if (entityName === 'collection') return 'collections'
else if (entityName === 'playlist') return 'playlists'
else if (entityName === 'author') return 'authors' else if (entityName === 'author') return 'authors'
else if (entityName === 'series') return 'series' else if (entityName === 'series') return 'series'
else if (entityName === 'feed') return 'feeds' else if (entityName === 'feed') return 'feeds'
@@ -81,15 +87,17 @@ class Db {
} }
reinit() { reinit() {
this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath) const staleTime = 1000 * 60 * 2
this.usersDb = new njodb.Database(this.UsersPath) this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath, { lockoptions: { stale: staleTime } })
this.sessionsDb = new njodb.Database(this.SessionsPath) this.usersDb = new njodb.Database(this.UsersPath, { lockoptions: { stale: staleTime } })
this.librariesDb = new njodb.Database(this.LibrariesPath, { datastores: 2 }) this.sessionsDb = new njodb.Database(this.SessionsPath, { lockoptions: { stale: staleTime } })
this.settingsDb = new njodb.Database(this.SettingsPath, { datastores: 2 }) this.librariesDb = new njodb.Database(this.LibrariesPath, { datastores: 2, lockoptions: { stale: staleTime } })
this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2 }) this.settingsDb = new njodb.Database(this.SettingsPath, { datastores: 2, lockoptions: { stale: staleTime } })
this.authorsDb = new njodb.Database(this.AuthorsPath) this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2, lockoptions: { stale: staleTime } })
this.seriesDb = new njodb.Database(this.SeriesPath, { datastores: 2 }) this.playlistsDb = new njodb.Database(this.PlaylistsPath, { datastores: 2, lockoptions: { stale: staleTime } })
this.feedsDb = new njodb.Database(this.FeedsPath, { datastores: 2 }) this.authorsDb = new njodb.Database(this.AuthorsPath, { lockoptions: { stale: staleTime } })
this.seriesDb = new njodb.Database(this.SeriesPath, { datastores: 2, lockoptions: { stale: staleTime } })
this.feedsDb = new njodb.Database(this.FeedsPath, { datastores: 2, lockoptions: { stale: staleTime } })
return this.init() return this.init()
} }
@@ -135,20 +143,20 @@ class Db {
} }
async load() { async load() {
var p1 = this.libraryItemsDb.select(() => true).then((results) => { const p1 = this.libraryItemsDb.select(() => true).then((results) => {
this.libraryItems = results.data.map(a => new LibraryItem(a)) this.libraryItems = results.data.map(a => new LibraryItem(a))
Logger.info(`[DB] ${this.libraryItems.length} Library Items Loaded`) Logger.info(`[DB] ${this.libraryItems.length} Library Items Loaded`)
}) })
var p2 = this.usersDb.select(() => true).then((results) => { const p2 = this.usersDb.select(() => true).then((results) => {
this.users = results.data.map(u => new User(u)) this.users = results.data.map(u => new User(u))
Logger.info(`[DB] ${this.users.length} Users Loaded`) Logger.info(`[DB] ${this.users.length} Users Loaded`)
}) })
var p3 = this.librariesDb.select(() => true).then((results) => { const p3 = this.librariesDb.select(() => true).then((results) => {
this.libraries = results.data.map(l => new Library(l)) this.libraries = results.data.map(l => new Library(l))
this.libraries.sort((a, b) => a.displayOrder - b.displayOrder) this.libraries.sort((a, b) => a.displayOrder - b.displayOrder)
Logger.info(`[DB] ${this.libraries.length} Libraries Loaded`) Logger.info(`[DB] ${this.libraries.length} Libraries Loaded`)
}) })
var 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') var serverSettings = this.settings.find(s => s.id === 'server-settings')
@@ -179,19 +187,23 @@ class Db {
} }
} }
}) })
var p5 = this.collectionsDb.select(() => true).then((results) => { const p5 = this.collectionsDb.select(() => true).then((results) => {
this.collections = results.data.map(l => new Collection(l)) this.collections = results.data.map(l => new Collection(l))
Logger.info(`[DB] ${this.collections.length} Collections Loaded`) Logger.info(`[DB] ${this.collections.length} Collections Loaded`)
}) })
var p6 = this.authorsDb.select(() => true).then((results) => { const p6 = this.playlistsDb.select(() => true).then((results) => {
this.playlists = results.data.map(l => new Playlist(l))
Logger.info(`[DB] ${this.playlists.length} Playlists Loaded`)
})
const p7 = this.authorsDb.select(() => true).then((results) => {
this.authors = results.data.map(l => new Author(l)) this.authors = results.data.map(l => new Author(l))
Logger.info(`[DB] ${this.authors.length} Authors Loaded`) Logger.info(`[DB] ${this.authors.length} Authors Loaded`)
}) })
var p7 = this.seriesDb.select(() => true).then((results) => { const p8 = this.seriesDb.select(() => true).then((results) => {
this.series = results.data.map(l => new Series(l)) this.series = results.data.map(l => new Series(l))
Logger.info(`[DB] ${this.series.length} Series Loaded`) Logger.info(`[DB] ${this.series.length} Series Loaded`)
}) })
await Promise.all([p1, p2, p3, p4, p5, p6, p7]) await Promise.all([p1, p2, p3, p4, p5, p6, p7, p8])
// Update server version in server settings // Update server version in server settings
if (this.previousVersion) { if (this.previousVersion) {
@@ -258,23 +270,6 @@ class Db {
}) })
} }
updateUserStream(userId, streamId) {
return this.usersDb.update((record) => record.id === userId, (user) => {
user.stream = streamId
return user
}).then((results) => {
Logger.debug(`[DB] Updated user ${results.updated}`)
this.users = this.users.map(u => {
if (u.id === userId) {
u.stream = streamId
}
return u
})
}).catch((error) => {
Logger.error(`[DB] Update user Failed ${error}`)
})
}
updateServerSettings() { updateServerSettings() {
global.ServerSettings = this.serverSettings.toJSON() global.ServerSettings = this.serverSettings.toJSON()
return this.updateEntity('settings', this.serverSettings) return this.updateEntity('settings', this.serverSettings)
+31 -169
View File
@@ -1,7 +1,6 @@
const Path = require('path') const Path = require('path')
const express = require('express') const express = require('express')
const http = require('http') const http = require('http')
const SocketIO = require('socket.io')
const fs = require('./libs/fsExtra') const fs = require('./libs/fsExtra')
const fileUpload = require('./libs/expressFileupload') const fileUpload = require('./libs/expressFileupload')
const rateLimit = require('./libs/expressRateLimit') const rateLimit = require('./libs/expressRateLimit')
@@ -13,11 +12,11 @@ const dbMigration = require('./utils/dbMigration')
const filePerms = require('./utils/filePerms') const filePerms = require('./utils/filePerms')
const Logger = require('./Logger') const Logger = require('./Logger')
// Classes
const Auth = require('./Auth') const Auth = require('./Auth')
const Watcher = require('./Watcher') const Watcher = require('./Watcher')
const Scanner = require('./scanner/Scanner') const Scanner = require('./scanner/Scanner')
const Db = require('./Db') const Db = require('./Db')
const SocketAuthority = require('./SocketAuthority')
const ApiRouter = require('./routers/ApiRouter') const ApiRouter = require('./routers/ApiRouter')
const HlsRouter = require('./routers/HlsRouter') const HlsRouter = require('./routers/HlsRouter')
@@ -67,59 +66,30 @@ class Server {
this.auth = new Auth(this.db) this.auth = new Auth(this.db)
// Managers // Managers
this.taskManager = new TaskManager(this.emitter.bind(this)) this.taskManager = new TaskManager()
this.notificationManager = new NotificationManager(this.db, this.emitter.bind(this)) this.notificationManager = new NotificationManager(this.db)
this.backupManager = new BackupManager(this.db, this.emitter.bind(this)) this.backupManager = new BackupManager(this.db)
this.logManager = new LogManager(this.db) this.logManager = new LogManager(this.db)
this.cacheManager = new CacheManager() this.cacheManager = new CacheManager()
this.abMergeManager = new AbMergeManager(this.db, this.taskManager, this.clientEmitter.bind(this)) this.abMergeManager = new AbMergeManager(this.db, this.taskManager)
this.playbackSessionManager = new PlaybackSessionManager(this.db, this.emitter.bind(this), this.clientEmitter.bind(this)) this.playbackSessionManager = new PlaybackSessionManager(this.db)
this.coverManager = new CoverManager(this.db, this.cacheManager) this.coverManager = new CoverManager(this.db, this.cacheManager)
this.podcastManager = new PodcastManager(this.db, this.watcher, this.emitter.bind(this), this.notificationManager) this.podcastManager = new PodcastManager(this.db, this.watcher, this.notificationManager)
this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.taskManager, this.emitter.bind(this), this.clientEmitter.bind(this)) this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.taskManager)
this.rssFeedManager = new RssFeedManager(this.db, this.emitter.bind(this)) this.rssFeedManager = new RssFeedManager(this.db)
this.scanner = new Scanner(this.db, this.coverManager, this.emitter.bind(this)) 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)
// Routers // Routers
this.apiRouter = new ApiRouter(this.db, this.auth, this.scanner, this.playbackSessionManager, this.abMergeManager, this.coverManager, this.backupManager, this.watcher, this.cacheManager, this.podcastManager, this.audioMetadataManager, this.rssFeedManager, this.cronManager, this.notificationManager, this.taskManager, this.getUsersOnline.bind(this), this.emitter.bind(this), this.clientEmitter.bind(this)) this.apiRouter = new ApiRouter(this)
this.hlsRouter = new HlsRouter(this.db, this.auth, this.playbackSessionManager, this.emitter.bind(this)) this.hlsRouter = new HlsRouter(this.db, this.auth, this.playbackSessionManager)
this.staticRouter = new StaticRouter(this.db) this.staticRouter = new StaticRouter(this.db)
Logger.logManager = this.logManager Logger.logManager = this.logManager
this.server = null this.server = null
this.io = null this.io = null
this.clients = {}
}
getUsersOnline() {
return Object.values(this.clients).filter(c => c.user).map(client => {
return client.user.toJSONForPublic(this.playbackSessionManager.sessions, this.db.libraryItems)
})
}
getClientsForUser(userId) {
return Object.values(this.clients).filter(c => c.user && c.user.id === userId)
}
emitter(ev, data) {
// Logger.debug('EMITTER', ev)
this.io.emit(ev, data)
}
clientEmitter(userId, ev, data) {
var clients = this.getClientsForUser(userId)
if (!clients.length) {
return Logger.debug(`[Server] clientEmitter - no clients found for user ${userId}`)
}
clients.forEach((client) => {
if (client.socket) {
client.socket.emit(ev, data)
}
})
} }
authMiddleware(req, res, next) { authMiddleware(req, res, next) {
@@ -130,7 +100,7 @@ class Server {
Logger.info('[Server] Init v' + version) Logger.info('[Server] Init v' + version)
await this.playbackSessionManager.removeOrphanStreams() await this.playbackSessionManager.removeOrphanStreams()
var previousVersion = await this.db.checkPreviousVersion() // Returns null if same server version const previousVersion = await this.db.checkPreviousVersion() // Returns null if same server version
if (previousVersion) { if (previousVersion) {
Logger.debug(`[Server] Upgraded from previous version ${previousVersion}`) Logger.debug(`[Server] Upgraded from previous version ${previousVersion}`)
} }
@@ -197,13 +167,13 @@ class Server {
// EBook static file routes // EBook static file routes
router.get('/ebook/:library/:folder/*', (req, res) => { router.get('/ebook/:library/:folder/*', (req, res) => {
var library = this.db.libraries.find(lib => lib.id === req.params.library) const library = this.db.libraries.find(lib => lib.id === req.params.library)
if (!library) return res.sendStatus(404) if (!library) return res.sendStatus(404)
var folder = library.folders.find(fol => fol.id === req.params.folder) const folder = library.folders.find(fol => fol.id === req.params.folder)
if (!folder) return res.status(404).send('Folder not found') if (!folder) return res.status(404).send('Folder not found')
var remainingPath = req.params['0'] const remainingPath = req.params['0']
var fullPath = Path.join(folder.fullPath, remainingPath) const fullPath = Path.join(folder.fullPath, remainingPath)
res.sendFile(fullPath) res.sendFile(fullPath)
}) })
@@ -236,7 +206,8 @@ class Server {
'/library/:library/podcast/latest', '/library/:library/podcast/latest',
'/config/users/:id', '/config/users/:id',
'/config/users/:id/sessions', '/config/users/:id/sessions',
'/collection/:id' '/collection/: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'))))
@@ -272,58 +243,8 @@ class Server {
Logger.info(`Listening on http://${this.Host}:${this.Port}`) Logger.info(`Listening on http://${this.Host}:${this.Port}`)
}) })
this.io = new SocketIO.Server(this.server, { // Start listening for socket connections
cors: { SocketAuthority.initialize(this)
origin: '*',
methods: ["GET", "POST"]
}
})
this.io.on('connection', (socket) => {
this.clients[socket.id] = {
id: socket.id,
socket,
connected_at: Date.now()
}
socket.sheepClient = this.clients[socket.id]
Logger.info('[Server] Socket Connected', socket.id)
socket.on('auth', (token) => this.authenticateSocket(socket, token))
// Scanning
socket.on('cancel_scan', this.cancelScan.bind(this))
// Logs
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))
socket.on('fetch_daily_logs', () => this.logManager.socketRequestDailyLogs(socket))
socket.on('ping', () => {
var client = this.clients[socket.id] || {}
var user = client.user || {}
Logger.debug(`[Server] Received ping from socket ${user.username || 'No User'}`)
socket.emit('pong')
})
socket.on('disconnect', (reason) => {
Logger.removeSocketListener(socket.id)
var _client = this.clients[socket.id]
if (!_client) {
Logger.warn(`[Server] Socket ${socket.id} disconnect, no client (Reason: ${reason})`)
} else if (!_client.user) {
Logger.info(`[Server] Unauth socket ${socket.id} disconnected (Reason: ${reason})`)
delete this.clients[socket.id]
} else {
Logger.debug('[Server] User Offline ' + _client.user.username)
this.io.emit('user_offline', _client.user.toJSONForPublic(this.playbackSessionManager.sessions, this.db.libraryItems))
const disconnectTime = Date.now() - _client.connected_at
Logger.info(`[Server] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`)
delete this.clients[socket.id]
}
})
})
} }
async initializeServer(req, res) { async initializeServer(req, res) {
@@ -342,22 +263,17 @@ class Server {
await this.scanner.scanFilesChanged(fileUpdates) await this.scanner.scanFilesChanged(fileUpdates)
} }
cancelScan(id) {
Logger.debug('[Server] Cancel scan', id)
this.scanner.setCancelLibraryScan(id)
}
// Remove unused /metadata/items/{id} folders // Remove unused /metadata/items/{id} folders
async purgeMetadata() { async purgeMetadata() {
var itemsMetadata = Path.join(global.MetadataPath, 'items') const itemsMetadata = Path.join(global.MetadataPath, 'items')
if (!(await fs.pathExists(itemsMetadata))) return if (!(await fs.pathExists(itemsMetadata))) return
var foldersInItemsMetadata = await fs.readdir(itemsMetadata) const foldersInItemsMetadata = await fs.readdir(itemsMetadata)
var purged = 0 let purged = 0
await Promise.all(foldersInItemsMetadata.map(async foldername => { await Promise.all(foldersInItemsMetadata.map(async foldername => {
var hasMatchingItem = this.db.libraryItems.find(ab => ab.id === foldername) const hasMatchingItem = this.db.libraryItems.find(ab => ab.id === foldername)
if (!hasMatchingItem) { if (!hasMatchingItem) {
var folderPath = Path.join(itemsMetadata, foldername) const folderPath = Path.join(itemsMetadata, foldername)
Logger.debug(`[Server] Purging unused metadata ${folderPath}`) Logger.debug(`[Server] Purging unused metadata ${folderPath}`)
await fs.remove(folderPath).then(() => { await fs.remove(folderPath).then(() => {
@@ -376,8 +292,8 @@ class Server {
// Remove user media progress with items that no longer exist & remove seriesHideFrom that no longer exist // Remove user media progress with items that no longer exist & remove seriesHideFrom that no longer exist
async cleanUserData() { async cleanUserData() {
for (let i = 0; i < this.db.users.length; i++) { for (let i = 0; i < this.db.users.length; i++) {
var _user = this.db.users[i] const _user = this.db.users[i]
var hasUpdated = false let hasUpdated = false
if (_user.mediaProgress.length) { if (_user.mediaProgress.length) {
const lengthBefore = _user.mediaProgress.length const lengthBefore = _user.mediaProgress.length
_user.mediaProgress = _user.mediaProgress.filter(mp => { _user.mediaProgress = _user.mediaProgress.filter(mp => {
@@ -423,68 +339,14 @@ class Server {
} }
logout(req, res) { logout(req, res) {
var { socketId } = req.body if (req.body.socketId) {
Logger.info(`[Server] User ${req.user ? req.user.username : 'Unknown'} is logging out with socket ${socketId}`) Logger.info(`[Server] User ${req.user ? req.user.username : 'Unknown'} is logging out with socket ${req.body.socketId}`)
SocketAuthority.logout(req.body.socketId)
// Strip user and client from client and client socket
if (socketId && this.clients[socketId]) {
var client = this.clients[socketId]
var clientSocket = client.socket
Logger.debug(`[Server] Found user client ${clientSocket.id}, Has user: ${!!client.user}, Socket has client: ${!!clientSocket.sheepClient}`)
if (client.user) {
Logger.debug('[Server] User Offline ' + client.user.username)
this.io.emit('user_offline', client.user.toJSONForPublic(null, this.db.libraryItems))
}
delete this.clients[socketId].user
if (clientSocket && clientSocket.sheepClient) delete this.clients[socketId].socket.sheepClient
} else if (socketId) {
Logger.warn(`[Server] No client for socket ${socketId}`)
} }
res.sendStatus(200) res.sendStatus(200)
} }
async authenticateSocket(socket, token) {
var user = await this.auth.authenticateUser(token)
if (!user) {
Logger.error('Cannot validate socket - invalid token')
return socket.emit('invalid_token')
}
var client = this.clients[socket.id]
if (client.user !== undefined) {
Logger.debug(`[Server] Authenticating socket client already has user`, client.user.username)
}
client.user = user
if (!client.user.toJSONForBrowser) {
Logger.error('Invalid user...', client.user)
return
}
Logger.debug(`[Server] User Online ${client.user.username}`)
this.io.emit('user_online', client.user.toJSONForPublic(this.playbackSessionManager.sessions, this.db.libraryItems))
user.lastSeen = Date.now()
await this.db.updateEntity('user', user)
const initialPayload = {
metadataPath: global.MetadataPath,
configPath: global.ConfigPath,
user: client.user.toJSONForBrowser(),
librariesScanning: this.scanner.librariesScanning,
backups: (this.backupManager.backups || []).map(b => b.toJSON())
}
if (user.type === 'root') {
initialPayload.usersOnline = this.getUsersOnline()
}
client.socket.emit('init', initialPayload)
}
async stop() { async stop() {
await this.watcher.close() await this.watcher.close()
Logger.info('Watcher Closed') Logger.info('Watcher Closed')
+204
View File
@@ -0,0 +1,204 @@
const SocketIO = require('socket.io')
const Logger = require('./Logger')
class SocketAuthority {
constructor() {
this.Server = null
this.io = null
this.clients = {}
}
// returns an array of User.toJSONForPublic with `connections` for the # of socket connections
// a user can have many socket connections
getUsersOnline() {
const onlineUsersMap = {}
Object.values(this.clients).filter(c => c.user).forEach(client => {
if (onlineUsersMap[client.user.id]) {
onlineUsersMap[client.user.id].connections++
} else {
onlineUsersMap[client.user.id] = {
...client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, this.Server.db.libraryItems),
connections: 1
}
}
})
return Object.values(onlineUsersMap)
}
getClientsForUser(userId) {
return Object.values(this.clients).filter(c => c.user && c.user.id === userId)
}
// Emits event to all authorized clients
// optional filter function to only send event to specific users
// TODO: validate that filter is actually a function
emitter(evt, data, filter = null) {
for (const socketId in this.clients) {
if (this.clients[socketId].user) {
if (filter && !filter(this.clients[socketId].user)) continue
this.clients[socketId].socket.emit(evt, data)
}
}
}
// Emits event to all clients for a specific user
clientEmitter(userId, evt, data) {
const clients = this.getClientsForUser(userId)
if (!clients.length) {
return Logger.debug(`[Server] clientEmitter - no clients found for user ${userId}`)
}
clients.forEach((client) => {
if (client.socket) {
client.socket.emit(evt, data)
}
})
}
// Emits event to all admin user clients
adminEmitter(evt, data) {
for (const socketId in this.clients) {
if (this.clients[socketId].user && this.clients[socketId].user.isAdminOrUp) {
this.clients[socketId].socket.emit(evt, data)
}
}
}
initialize(Server) {
this.Server = Server
this.io = new SocketIO.Server(this.Server.server, {
cors: {
origin: '*',
methods: ["GET", "POST"]
}
})
this.io.on('connection', (socket) => {
this.clients[socket.id] = {
id: socket.id,
socket,
connected_at: Date.now()
}
socket.sheepClient = this.clients[socket.id]
Logger.info('[Server] Socket Connected', socket.id)
// Required for associating a User with a socket
socket.on('auth', (token) => this.authenticateSocket(socket, token))
// Scanning
socket.on('cancel_scan', this.cancelScan.bind(this))
// Logs
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))
socket.on('fetch_daily_logs', () => this.Server.logManager.socketRequestDailyLogs(socket))
// Sent automatically from socket.io clients
socket.on('disconnect', (reason) => {
Logger.removeSocketListener(socket.id)
const _client = this.clients[socket.id]
if (!_client) {
Logger.warn(`[Server] Socket ${socket.id} disconnect, no client (Reason: ${reason})`)
} else if (!_client.user) {
Logger.info(`[Server] Unauth socket ${socket.id} disconnected (Reason: ${reason})`)
delete this.clients[socket.id]
} else {
Logger.debug('[Server] User Offline ' + _client.user.username)
this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, this.Server.db.libraryItems))
const disconnectTime = Date.now() - _client.connected_at
Logger.info(`[Server] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`)
delete this.clients[socket.id]
}
})
//
// Events for testing
//
socket.on('message_all_users', (payload) => {
// admin user can send a message to all authenticated users
// displays on the web app as a toast
const client = this.clients[socket.id] || {}
if (client.user && client.user.isAdminOrUp) {
this.emitter('admin_message', payload.message || '')
} else {
Logger.error(`[Server] Non-admin user sent the message_all_users event`)
}
})
socket.on('ping', () => {
const client = this.clients[socket.id] || {}
const user = client.user || {}
Logger.debug(`[Server] Received ping from socket ${user.username || 'No User'}`)
socket.emit('pong')
})
})
}
// When setting up a socket connection the user needs to be associated with a socket id
// for this the client will send a 'auth' event that includes the users API token
async authenticateSocket(socket, token) {
const user = await this.Server.auth.authenticateUser(token)
if (!user) {
Logger.error('Cannot validate socket - invalid token')
return socket.emit('invalid_token')
}
const client = this.clients[socket.id]
if (client.user !== undefined) {
Logger.debug(`[Server] Authenticating socket client already has user`, client.user.username)
}
client.user = user
if (!client.user.toJSONForBrowser) {
Logger.error('Invalid user...', client.user)
return
}
Logger.debug(`[Server] User Online ${client.user.username}`)
this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, this.Server.db.libraryItems))
// Update user lastSeen
user.lastSeen = Date.now()
await this.Server.db.updateEntity('user', user)
const initialPayload = {
userId: client.user.id,
username: client.user.username,
librariesScanning: this.Server.scanner.librariesScanning
}
if (user.isAdminOrUp) {
initialPayload.usersOnline = this.getUsersOnline()
}
client.socket.emit('init', initialPayload)
}
logout(socketId) {
// Strip user and client from client and client socket
if (socketId && this.clients[socketId]) {
const client = this.clients[socketId]
const clientSocket = client.socket
Logger.debug(`[Server] Found user client ${clientSocket.id}, Has user: ${!!client.user}, Socket has client: ${!!clientSocket.sheepClient}`)
if (client.user) {
Logger.debug('[Server] User Offline ' + client.user.username)
this.adminEmitter('user_offline', client.user.toJSONForPublic(null, this.Server.db.libraryItems))
}
delete this.clients[socketId].user
if (clientSocket && clientSocket.sheepClient) delete this.clients[socketId].socket.sheepClient
} else if (socketId) {
Logger.warn(`[Server] No client for socket ${socketId}`)
}
}
cancelScan(id) {
Logger.debug('[Server] Cancel scan', id)
this.Server.scanner.setCancelLibraryScan(id)
}
}
module.exports = new SocketAuthority()
+31 -28
View File
@@ -1,4 +1,6 @@
const Logger = require('../Logger') const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const { reqSupportsWebp } = require('../utils/index') const { reqSupportsWebp } = require('../utils/index')
const { createNewSortInstance } = require('../libs/fastSort') const { createNewSortInstance } = require('../libs/fastSort')
@@ -60,76 +62,77 @@ class AuthorController {
} }
async update(req, res) { async update(req, res) {
var payload = req.body const payload = req.body
let hasUpdated = false
// If updating or removing cover image then clear cache // Updating/removing cover image
if (payload.imagePath !== undefined && req.author.imagePath && payload.imagePath !== req.author.imagePath) { if (payload.imagePath !== undefined && payload.imagePath !== req.author.imagePath) {
this.cacheManager.purgeImageCache(req.author.id) if (!payload.imagePath && req.author.imagePath) { // If removing image then remove file
await this.cacheManager.purgeImageCache(req.author.id) // Purge cache
if (!payload.imagePath) { // If removing image then remove file await this.coverManager.removeFile(req.author.imagePath)
var currentImagePath = req.author.imagePath
await this.coverManager.removeFile(currentImagePath)
} else if (payload.imagePath.startsWith('http')) { // Check if image path is a url } else if (payload.imagePath.startsWith('http')) { // Check if image path is a url
var imageData = await this.authorFinder.saveAuthorImage(req.author.id, payload.imagePath) const imageData = await this.authorFinder.saveAuthorImage(req.author.id, payload.imagePath)
if (imageData) { if (imageData) {
req.author.imagePath = imageData.path if (req.author.imagePath) {
req.author.relImagePath = imageData.relPath await this.cacheManager.purgeImageCache(req.author.id) // Purge cache
hasUpdated = hasUpdated || true; }
} else { payload.imagePath = imageData.path
req.author.imagePath = null payload.relImagePath = imageData.relPath
req.author.relImagePath = null hasUpdated = true
} }
} }
} }
var authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name const authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name
// Check if author name matches another author and merge the authors // Check if author name matches another author and merge the authors
var existingAuthor = authorNameUpdate ? this.db.authors.find(au => au.id !== req.author.id && payload.name === au.name) : false const existingAuthor = authorNameUpdate ? this.db.authors.find(au => au.id !== req.author.id && payload.name === au.name) : false
if (existingAuthor) { if (existingAuthor) {
var 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 => { // Replace old author with merging author for each book itemsWithAuthor.forEach(libraryItem => { // Replace old author with merging author for each book
libraryItem.media.metadata.replaceAuthor(req.author, existingAuthor) libraryItem.media.metadata.replaceAuthor(req.author, existingAuthor)
}) })
if (itemsWithAuthor.length) { if (itemsWithAuthor.length) {
await this.db.updateLibraryItems(itemsWithAuthor) await this.db.updateLibraryItems(itemsWithAuthor)
this.emitter('items_updated', itemsWithAuthor.map(li => li.toJSONExpanded())) SocketAuthority.emitter('items_updated', itemsWithAuthor.map(li => li.toJSONExpanded()))
} }
// Remove old author // Remove old author
await this.db.removeEntity('author', req.author.id) await this.db.removeEntity('author', req.author.id)
this.emitter('author_removed', req.author.toJSON()) SocketAuthority.emitter('author_removed', req.author.toJSON())
// Send updated num books for merged author // Send updated num books for merged author
var numBooks = this.db.libraryItems.filter(li => { const numBooks = this.db.libraryItems.filter(li => {
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(existingAuthor.id) return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(existingAuthor.id)
}).length }).length
this.emitter('author_updated', existingAuthor.toJSONExpanded(numBooks)) SocketAuthority.emitter('author_updated', existingAuthor.toJSONExpanded(numBooks))
res.json({ res.json({
author: existingAuthor.toJSON(), author: existingAuthor.toJSON(),
merged: true merged: true
}) })
} else { // Regular author update } else { // Regular author update
var hasUpdated = req.author.update(payload) if (req.author.update(payload)) {
hasUpdated = true
}
if (hasUpdated) { if (hasUpdated) {
if (authorNameUpdate) { // Update author name on all books if (authorNameUpdate) { // Update author name on all books
var 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 => {
libraryItem.media.metadata.updateAuthor(req.author) libraryItem.media.metadata.updateAuthor(req.author)
}) })
if (itemsWithAuthor.length) { if (itemsWithAuthor.length) {
await this.db.updateLibraryItems(itemsWithAuthor) await this.db.updateLibraryItems(itemsWithAuthor)
this.emitter('items_updated', itemsWithAuthor.map(li => li.toJSONExpanded())) SocketAuthority.emitter('items_updated', itemsWithAuthor.map(li => li.toJSONExpanded()))
} }
} }
await this.db.updateEntity('author', req.author) await this.db.updateEntity('author', req.author)
var numBooks = this.db.libraryItems.filter(li => { const numBooks = this.db.libraryItems.filter(li => {
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id) return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id)
}).length }).length
this.emitter('author_updated', req.author.toJSONExpanded(numBooks)) SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))
} }
res.json({ res.json({
@@ -190,7 +193,7 @@ class AuthorController {
var numBooks = this.db.libraryItems.filter(li => { var numBooks = this.db.libraryItems.filter(li => {
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id) return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id)
}).length }).length
this.emitter('author_updated', req.author.toJSONExpanded(numBooks)) SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))
} }
res.json({ res.json({
+19 -18
View File
@@ -3,32 +3,29 @@ const Logger = require('../Logger')
class BackupController { class BackupController {
constructor() { } constructor() { }
async create(req, res) { getAll(req, res) {
if (!req.user.isAdminOrUp) { res.json({
Logger.error(`[BackupController] Non-admin user attempting to craete backup`, req.user) backups: this.backupManager.backups.map(b => b.toJSON())
return res.sendStatus(403) })
} }
create(req, res) {
this.backupManager.requestCreateBackup(res) this.backupManager.requestCreateBackup(res)
} }
async delete(req, res) { async delete(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[BackupController] Non-admin user attempting to delete backup`, req.user)
return res.sendStatus(403)
}
var backup = this.backupManager.backups.find(b => b.id === req.params.id) var backup = this.backupManager.backups.find(b => b.id === req.params.id)
if (!backup) { if (!backup) {
return res.sendStatus(404) return res.sendStatus(404)
} }
await this.backupManager.removeBackup(backup) await this.backupManager.removeBackup(backup)
res.json(this.backupManager.backups.map(b => b.toJSON()))
res.json({
backups: this.backupManager.backups.map(b => b.toJSON())
})
} }
async upload(req, res) { async upload(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[BackupController] Non-admin user attempting to upload backup`, req.user)
return res.sendStatus(403)
}
if (!req.files.file) { if (!req.files.file) {
Logger.error('[BackupController] Upload backup invalid') Logger.error('[BackupController] Upload backup invalid')
return res.sendStatus(500) return res.sendStatus(500)
@@ -37,10 +34,6 @@ class BackupController {
} }
async apply(req, res) { async apply(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[BackupController] Non-admin user attempting to apply backup`, req.user)
return res.sendStatus(403)
}
var backup = this.backupManager.backups.find(b => b.id === req.params.id) var backup = this.backupManager.backups.find(b => b.id === req.params.id)
if (!backup) { if (!backup) {
return res.sendStatus(404) return res.sendStatus(404)
@@ -48,5 +41,13 @@ class BackupController {
await this.backupManager.requestApplyBackup(backup) await this.backupManager.requestApplyBackup(backup)
res.sendStatus(200) res.sendStatus(200)
} }
middleware(req, res, next) {
if (!req.user.isAdminOrUp) {
Logger.error(`[BackupController] Non-admin user attempting to access backups`, req.user)
return res.sendStatus(403)
}
next()
}
} }
module.exports = new BackupController() module.exports = new BackupController()
+9 -7
View File
@@ -1,4 +1,6 @@
const Logger = require('../Logger') const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Collection = require('../objects/Collection') const Collection = require('../objects/Collection')
class CollectionController { class CollectionController {
@@ -13,7 +15,7 @@ class CollectionController {
} }
var jsonExpanded = newCollection.toJSONExpanded(this.db.libraryItems) var jsonExpanded = newCollection.toJSONExpanded(this.db.libraryItems)
await this.db.insertEntity('collection', newCollection) await this.db.insertEntity('collection', newCollection)
this.emitter('collection_added', jsonExpanded) SocketAuthority.emitter('collection_added', jsonExpanded)
res.json(jsonExpanded) res.json(jsonExpanded)
} }
@@ -32,7 +34,7 @@ class CollectionController {
var jsonExpanded = collection.toJSONExpanded(this.db.libraryItems) var jsonExpanded = collection.toJSONExpanded(this.db.libraryItems)
if (wasUpdated) { if (wasUpdated) {
await this.db.updateEntity('collection', collection) await this.db.updateEntity('collection', collection)
this.emitter('collection_updated', jsonExpanded) SocketAuthority.emitter('collection_updated', jsonExpanded)
} }
res.json(jsonExpanded) res.json(jsonExpanded)
} }
@@ -41,7 +43,7 @@ class CollectionController {
const collection = req.collection const collection = req.collection
var jsonExpanded = collection.toJSONExpanded(this.db.libraryItems) var jsonExpanded = collection.toJSONExpanded(this.db.libraryItems)
await this.db.removeEntity('collection', collection.id) await this.db.removeEntity('collection', collection.id)
this.emitter('collection_removed', jsonExpanded) SocketAuthority.emitter('collection_removed', jsonExpanded)
res.sendStatus(200) res.sendStatus(200)
} }
@@ -60,7 +62,7 @@ class CollectionController {
collection.addBook(req.body.id) collection.addBook(req.body.id)
var jsonExpanded = collection.toJSONExpanded(this.db.libraryItems) var jsonExpanded = collection.toJSONExpanded(this.db.libraryItems)
await this.db.updateEntity('collection', collection) await this.db.updateEntity('collection', collection)
this.emitter('collection_updated', jsonExpanded) SocketAuthority.emitter('collection_updated', jsonExpanded)
res.json(jsonExpanded) res.json(jsonExpanded)
} }
@@ -71,7 +73,7 @@ class CollectionController {
collection.removeBook(req.params.bookId) collection.removeBook(req.params.bookId)
var jsonExpanded = collection.toJSONExpanded(this.db.libraryItems) var jsonExpanded = collection.toJSONExpanded(this.db.libraryItems)
await this.db.updateEntity('collection', collection) await this.db.updateEntity('collection', collection)
this.emitter('collection_updated', jsonExpanded) SocketAuthority.emitter('collection_updated', jsonExpanded)
} }
res.json(collection.toJSONExpanded(this.db.libraryItems)) res.json(collection.toJSONExpanded(this.db.libraryItems))
} }
@@ -92,7 +94,7 @@ class CollectionController {
} }
if (hasUpdated) { if (hasUpdated) {
await this.db.updateEntity('collection', collection) await this.db.updateEntity('collection', collection)
this.emitter('collection_updated', collection.toJSONExpanded(this.db.libraryItems)) SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(this.db.libraryItems))
} }
res.json(collection.toJSONExpanded(this.db.libraryItems)) res.json(collection.toJSONExpanded(this.db.libraryItems))
} }
@@ -113,7 +115,7 @@ class CollectionController {
} }
if (hasUpdated) { if (hasUpdated) {
await this.db.updateEntity('collection', collection) await this.db.updateEntity('collection', collection)
this.emitter('collection_updated', collection.toJSONExpanded(this.db.libraryItems)) SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(this.db.libraryItems))
} }
res.json(collection.toJSONExpanded(this.db.libraryItems)) res.json(collection.toJSONExpanded(this.db.libraryItems))
} }
+62 -30
View File
@@ -2,6 +2,7 @@ const Path = require('path')
const fs = require('../libs/fsExtra') const fs = require('../libs/fsExtra')
const filePerms = require('../utils/filePerms') const filePerms = require('../utils/filePerms')
const Logger = require('../Logger') const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Library = require('../objects/Library') const Library = require('../objects/Library')
const libraryHelpers = require('../utils/libraryHelpers') const libraryHelpers = require('../utils/libraryHelpers')
const { sort, createNewSortInstance } = require('../libs/fastSort') const { sort, createNewSortInstance } = require('../libs/fastSort')
@@ -12,7 +13,7 @@ class LibraryController {
constructor() { } constructor() { }
async create(req, res) { async create(req, res) {
var newLibraryPayload = { const newLibraryPayload = {
...req.body ...req.body
} }
if (!newLibraryPayload.name || !newLibraryPayload.folders || !newLibraryPayload.folders.length) { if (!newLibraryPayload.name || !newLibraryPayload.folders || !newLibraryPayload.folders.length) {
@@ -25,9 +26,9 @@ class LibraryController {
f.fullPath = Path.resolve(f.fullPath) f.fullPath = Path.resolve(f.fullPath)
return f return f
}) })
for (var folder of newLibraryPayload.folders) { for (const folder of newLibraryPayload.folders) {
try { try {
var direxists = await fs.pathExists(folder.fullPath) const direxists = await fs.pathExists(folder.fullPath)
if (!direxists) { // If folder does not exist try to make it and set file permissions/owner if (!direxists) { // If folder does not exist try to make it and set file permissions/owner
await fs.mkdir(folder.fullPath) await fs.mkdir(folder.fullPath)
await filePerms.setDefault(folder.fullPath) await filePerms.setDefault(folder.fullPath)
@@ -38,12 +39,16 @@ class LibraryController {
} }
} }
var library = new Library() const library = new Library()
newLibraryPayload.displayOrder = this.db.libraries.length + 1 newLibraryPayload.displayOrder = this.db.libraries.length + 1
library.setData(newLibraryPayload) library.setData(newLibraryPayload)
await this.db.insertEntity('library', library) await this.db.insertEntity('library', library)
// TODO: Only emit to users that have access
this.emitter('library_added', library.toJSON()) // Only emit to users with access to library
const userFilter = (user) => {
return user.checkCanAccessLibrary && user.checkCanAccessLibrary(library.id)
}
SocketAuthority.emitter('library_added', library.toJSON(), userFilter)
// Add library watcher // Add library watcher
this.watcher.addLibrary(library) this.watcher.addLibrary(library)
@@ -52,7 +57,7 @@ class LibraryController {
} }
findAll(req, res) { findAll(req, res) {
var librariesAccessible = req.user.librariesAccessible || [] const librariesAccessible = req.user.librariesAccessible || []
if (librariesAccessible && librariesAccessible.length) { if (librariesAccessible && librariesAccessible.length) {
return res.json(this.db.libraries.filter(lib => librariesAccessible.includes(lib.id)).map(lib => lib.toJSON())) return res.json(this.db.libraries.filter(lib => librariesAccessible.includes(lib.id)).map(lib => lib.toJSON()))
} }
@@ -61,10 +66,12 @@ class LibraryController {
} }
async findOne(req, res) { async findOne(req, res) {
if (req.query.include && req.query.include === 'filterdata') { const includeArray = (req.query.include || '').split(',')
if (includeArray.includes('filterdata')) {
return res.json({ return res.json({
filterdata: libraryHelpers.getDistinctFilterDataNew(req.libraryItems), filterdata: libraryHelpers.getDistinctFilterDataNew(req.libraryItems),
issues: req.libraryItems.filter(li => li.hasIssues).length, issues: req.libraryItems.filter(li => li.hasIssues).length,
numUserPlaylists: this.db.playlists.filter(p => p.userId === req.user.id && p.libraryId === req.library.id).length,
library: req.library library: req.library
}) })
} }
@@ -72,12 +79,12 @@ class LibraryController {
} }
async update(req, res) { async update(req, res) {
var library = req.library const library = req.library
// Validate new folder paths exist or can be created & resolve rel paths // Validate new folder paths exist or can be created & resolve rel paths
// returns 400 if a new folder fails to access // returns 400 if a new folder fails to access
if (req.body.folders) { if (req.body.folders) {
var newFolderPaths = [] const newFolderPaths = []
req.body.folders = req.body.folders.map(f => { req.body.folders = req.body.folders.map(f => {
if (!f.id) { if (!f.id) {
f.fullPath = Path.resolve(f.fullPath) f.fullPath = Path.resolve(f.fullPath)
@@ -85,11 +92,11 @@ class LibraryController {
} }
return f return f
}) })
for (var path of newFolderPaths) { for (const path of newFolderPaths) {
var pathExists = await fs.pathExists(path) const pathExists = await fs.pathExists(path)
if (!pathExists) { if (!pathExists) {
// Ensure dir will recursively create directories which might be preferred over mkdir // Ensure dir will recursively create directories which might be preferred over mkdir
var success = await fs.ensureDir(path).then(() => true).catch((error) => { const success = await fs.ensureDir(path).then(() => true).catch((error) => {
Logger.error(`[LibraryController] Failed to ensure folder dir "${path}"`, error) Logger.error(`[LibraryController] Failed to ensure folder dir "${path}"`, error)
return false return false
}) })
@@ -102,7 +109,7 @@ class LibraryController {
} }
} }
var hasUpdates = library.update(req.body) const hasUpdates = library.update(req.body)
// TODO: Should check if this is an update to folder paths or name only // TODO: Should check if this is an update to folder paths or name only
if (hasUpdates) { if (hasUpdates) {
// Update watcher // Update watcher
@@ -112,7 +119,7 @@ class LibraryController {
this.cronManager.updateLibraryScanCron(library) this.cronManager.updateLibraryScanCron(library)
// Remove libraryItems no longer in library // Remove libraryItems no longer in library
var itemsToRemove = this.db.libraryItems.filter(li => li.libraryId === library.id && !library.checkFullPathInLibrary(li.path)) const itemsToRemove = this.db.libraryItems.filter(li => li.libraryId === library.id && !library.checkFullPathInLibrary(li.path))
if (itemsToRemove.length) { if (itemsToRemove.length) {
Logger.info(`[Scanner] Updating library, removing ${itemsToRemove.length} items`) Logger.info(`[Scanner] Updating library, removing ${itemsToRemove.length} items`)
for (let i = 0; i < itemsToRemove.length; i++) { for (let i = 0; i < itemsToRemove.length; i++) {
@@ -120,34 +127,39 @@ class LibraryController {
} }
} }
await this.db.updateEntity('library', library) await this.db.updateEntity('library', library)
this.emitter('library_updated', library.toJSON())
// Only emit to users with access to library
const userFilter = (user) => {
return user.checkCanAccessLibrary && user.checkCanAccessLibrary(library.id)
}
SocketAuthority.emitter('library_updated', library.toJSON(), userFilter)
} }
return res.json(library.toJSON()) return res.json(library.toJSON())
} }
async delete(req, res) { async delete(req, res) {
var library = req.library const library = req.library
// Remove library watcher // Remove library watcher
this.watcher.removeLibrary(library) this.watcher.removeLibrary(library)
// Remove collections for library // Remove collections for library
var collections = this.db.collections.filter(c => c.libraryId === library.id) const collections = this.db.collections.filter(c => c.libraryId === library.id)
for (const collection of collections) { for (const collection of collections) {
Logger.info(`[Server] deleting collection "${collection.name}" for library "${library.name}"`) Logger.info(`[Server] deleting collection "${collection.name}" for library "${library.name}"`)
await this.db.removeEntity('collection', collection.id) await this.db.removeEntity('collection', collection.id)
} }
// Remove items in this library // Remove items in this library
var libraryItems = this.db.libraryItems.filter(li => li.libraryId === library.id) const libraryItems = this.db.libraryItems.filter(li => li.libraryId === library.id)
Logger.info(`[Server] deleting library "${library.name}" with ${libraryItems.length} items"`) Logger.info(`[Server] deleting library "${library.name}" with ${libraryItems.length} items"`)
for (let i = 0; i < libraryItems.length; i++) { for (let i = 0; i < libraryItems.length; i++) {
await this.handleDeleteLibraryItem(libraryItems[i]) await this.handleDeleteLibraryItem(libraryItems[i])
} }
var libraryJson = library.toJSON() const libraryJson = library.toJSON()
await this.db.removeEntity('library', library.id) await this.db.removeEntity('library', library.id)
this.emitter('library_removed', libraryJson) SocketAuthority.emitter('library_removed', libraryJson)
return res.json(libraryJson) return res.json(libraryJson)
} }
@@ -167,17 +179,17 @@ class LibraryController {
minified: req.query.minified === '1', minified: req.query.minified === '1',
collapseseries: req.query.collapseseries === '1' collapseseries: req.query.collapseseries === '1'
} }
var mediaIsBook = payload.mediaType === 'book' const mediaIsBook = payload.mediaType === 'book'
// Step 1 - Filter the retrieved library items // Step 1 - Filter the retrieved library items
var filterSeries = null let filterSeries = null
if (payload.filterBy) { if (payload.filterBy) {
libraryItems = libraryHelpers.getFilteredLibraryItems(libraryItems, payload.filterBy, req.user, this.rssFeedManager.feedsArray) libraryItems = libraryHelpers.getFilteredLibraryItems(libraryItems, payload.filterBy, req.user, this.rssFeedManager.feedsArray)
payload.total = libraryItems.length payload.total = libraryItems.length
// Determining if we are filtering titles by a series, and if so, which series // Determining if we are filtering titles by a series, and if so, which series
filterSeries = (mediaIsBook && payload.filterBy.startsWith('series.')) ? libraryHelpers.decode(payload.filterBy.replace('series.', '')) : null filterSeries = (mediaIsBook && payload.filterBy.startsWith('series.')) ? libraryHelpers.decode(payload.filterBy.replace('series.', '')) : null
if (filterSeries === 'No Series') filterSeries = null if (filterSeries === 'no-series') filterSeries = null
} }
// Step 2 - If selected, collapse library items by the series they belong to. // Step 2 - If selected, collapse library items by the series they belong to.
@@ -213,7 +225,7 @@ class LibraryController {
if (payload.sortBy) { if (payload.sortBy) {
// old sort key TODO: should be mutated in dbMigration // old sort key TODO: should be mutated in dbMigration
var sortKey = payload.sortBy let sortKey = payload.sortBy
if (sortKey.startsWith('book.')) { if (sortKey.startsWith('book.')) {
sortKey = sortKey.replace('book.', 'media.metadata.') sortKey = sortKey.replace('book.', 'media.metadata.')
} }
@@ -243,7 +255,7 @@ class LibraryController {
} }
// Sort series based on the sortBy attribute // Sort series based on the sortBy attribute
var direction = payload.sortDesc ? 'desc' : 'asc' const direction = payload.sortDesc ? 'desc' : 'asc'
sortArray.push({ sortArray.push({
[direction]: (li) => { [direction]: (li) => {
if (mediaIsBook && sortBySequence) { if (mediaIsBook && sortBySequence) {
@@ -329,7 +341,7 @@ class LibraryController {
} }
async removeLibraryItemsWithIssues(req, res) { async removeLibraryItemsWithIssues(req, res) {
var libraryItemsWithIssues = req.libraryItems.filter(li => li.hasIssues) const libraryItemsWithIssues = req.libraryItems.filter(li => li.hasIssues)
if (!libraryItemsWithIssues.length) { if (!libraryItemsWithIssues.length) {
Logger.warn(`[LibraryController] No library items have issues`) Logger.warn(`[LibraryController] No library items have issues`)
return res.sendStatus(200) return res.sendStatus(200)
@@ -346,8 +358,8 @@ class LibraryController {
// api/libraries/:id/series // api/libraries/:id/series
async getAllSeriesForLibrary(req, res) { async getAllSeriesForLibrary(req, res) {
var libraryItems = req.libraryItems const libraryItems = req.libraryItems
var payload = { const payload = {
results: [], results: [],
total: 0, total: 0,
limit: req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 0, limit: req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 0,
@@ -358,7 +370,7 @@ class LibraryController {
minified: req.query.minified === '1' minified: req.query.minified === '1'
} }
var series = libraryHelpers.getSeriesFromBooks(libraryItems, this.db.series, null, payload.filterBy, req.user, payload.minified) let series = libraryHelpers.getSeriesFromBooks(libraryItems, this.db.series, null, payload.filterBy, req.user, payload.minified)
const direction = payload.sortDesc ? 'desc' : 'asc' const direction = payload.sortDesc ? 'desc' : 'asc'
series = naturalSort(series).by([ series = naturalSort(series).by([
@@ -421,6 +433,26 @@ class LibraryController {
res.json(payload) res.json(payload)
} }
// api/libraries/:id/playlists
async getUserPlaylistsForLibrary(req, res) {
let playlistsForUser = this.db.playlists.filter(p => p.userId === req.user.id && p.libraryId === req.library.id).map(p => p.toJSONExpanded(this.db.libraryItems))
const payload = {
results: [],
total: playlistsForUser.length,
limit: req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 0,
page: req.query.page && !isNaN(req.query.page) ? Number(req.query.page) : 0
}
if (payload.limit) {
const startIndex = payload.page * payload.limit
playlistsForUser = playlistsForUser.slice(startIndex, startIndex + payload.limit)
}
payload.results = playlistsForUser
res.json(payload)
}
async getLibraryFilterData(req, res) { async getLibraryFilterData(req, res) {
res.json(libraryHelpers.getDistinctFilterDataNew(req.libraryItems)) res.json(libraryHelpers.getDistinctFilterDataNew(req.libraryItems))
} }
+11 -9
View File
@@ -1,4 +1,6 @@
const Logger = require('../Logger') const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const { reqSupportsWebp, isNullOrNaN } = require('../utils/index') const { reqSupportsWebp, isNullOrNaN } = require('../utils/index')
const { ScanResult } = require('../utils/constants') const { ScanResult } = require('../utils/constants')
@@ -53,7 +55,7 @@ class LibraryItemController {
if (hasUpdates) { if (hasUpdates) {
Logger.debug(`[LibraryItemController] Updated now saving`) Logger.debug(`[LibraryItemController] Updated now saving`)
await this.db.updateLibraryItem(libraryItem) await this.db.updateLibraryItem(libraryItem)
this.emitter('item_updated', libraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
} }
res.json(libraryItem.toJSON()) res.json(libraryItem.toJSON())
} }
@@ -97,7 +99,7 @@ class LibraryItemController {
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`) Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
await this.db.updateLibraryItem(libraryItem) await this.db.updateLibraryItem(libraryItem)
this.emitter('item_updated', libraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
} }
res.json({ res.json({
updated: hasUpdates, updated: hasUpdates,
@@ -132,7 +134,7 @@ class LibraryItemController {
} }
await this.db.updateLibraryItem(libraryItem) await this.db.updateLibraryItem(libraryItem)
this.emitter('item_updated', libraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
res.json({ res.json({
success: true, success: true,
cover: result.cover cover: result.cover
@@ -152,7 +154,7 @@ class LibraryItemController {
} }
if (validationResult.updated) { if (validationResult.updated) {
await this.db.updateLibraryItem(libraryItem) await this.db.updateLibraryItem(libraryItem)
this.emitter('item_updated', libraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
} }
res.json({ res.json({
success: true, success: true,
@@ -168,7 +170,7 @@ class LibraryItemController {
libraryItem.updateMediaCover('') libraryItem.updateMediaCover('')
await this.cacheManager.purgeCoverCache(libraryItem.id) await this.cacheManager.purgeCoverCache(libraryItem.id)
await this.db.updateLibraryItem(libraryItem) await this.db.updateLibraryItem(libraryItem)
this.emitter('item_updated', libraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
} }
res.sendStatus(200) res.sendStatus(200)
@@ -228,7 +230,7 @@ class LibraryItemController {
} }
libraryItem.media.updateAudioTracks(orderedFileData) libraryItem.media.updateAudioTracks(orderedFileData)
await this.db.updateLibraryItem(libraryItem) await this.db.updateLibraryItem(libraryItem)
this.emitter('item_updated', libraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
res.json(libraryItem.toJSON()) res.json(libraryItem.toJSON())
} }
@@ -284,7 +286,7 @@ class LibraryItemController {
if (hasUpdates) { if (hasUpdates) {
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`) Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
await this.db.updateLibraryItem(libraryItem) await this.db.updateLibraryItem(libraryItem)
this.emitter('item_updated', libraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
itemsUpdated++ itemsUpdated++
} }
} }
@@ -342,7 +344,7 @@ class LibraryItemController {
updates: itemsUpdated, updates: itemsUpdated,
unmatched: itemsUnmatched unmatched: itemsUnmatched
} }
this.clientEmitter(req.user.id, 'batch_quickmatch_complete', result) SocketAuthority.clientEmitter(req.user.id, 'batch_quickmatch_complete', result)
} }
// DELETE: api/items/all // DELETE: api/items/all
@@ -410,7 +412,7 @@ class LibraryItemController {
const wasUpdated = req.libraryItem.media.updateChapters(chapters) const wasUpdated = req.libraryItem.media.updateChapters(chapters)
if (wasUpdated) { if (wasUpdated) {
await this.db.updateLibraryItem(req.libraryItem) await this.db.updateLibraryItem(req.libraryItem)
this.emitter('item_updated', req.libraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
} }
res.json({ res.json({
+13 -12
View File
@@ -1,4 +1,5 @@
const Logger = require('../Logger') const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const { sort } = require('../libs/fastSort') const { sort } = require('../libs/fastSort')
const { isObject, toNumber } = require('../utils/index') const { isObject, toNumber } = require('../utils/index')
@@ -48,7 +49,7 @@ class MeController {
return res.sendStatus(200) return res.sendStatus(200)
} }
await this.db.updateEntity('user', req.user) await this.db.updateEntity('user', req.user)
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser()) SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
res.sendStatus(200) res.sendStatus(200)
} }
@@ -62,7 +63,7 @@ class MeController {
var wasUpdated = req.user.createUpdateMediaProgress(libraryItem, req.body) var wasUpdated = req.user.createUpdateMediaProgress(libraryItem, req.body)
if (wasUpdated) { if (wasUpdated) {
await this.db.updateEntity('user', req.user) await this.db.updateEntity('user', req.user)
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser()) SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
} }
res.sendStatus(200) res.sendStatus(200)
} }
@@ -82,7 +83,7 @@ class MeController {
var wasUpdated = req.user.createUpdateMediaProgress(libraryItem, req.body, episodeId) var wasUpdated = req.user.createUpdateMediaProgress(libraryItem, req.body, episodeId)
if (wasUpdated) { if (wasUpdated) {
await this.db.updateEntity('user', req.user) await this.db.updateEntity('user', req.user)
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser()) SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
} }
res.sendStatus(200) res.sendStatus(200)
} }
@@ -91,7 +92,7 @@ class MeController {
async batchUpdateMediaProgress(req, res) { async batchUpdateMediaProgress(req, res) {
var itemProgressPayloads = req.body var itemProgressPayloads = req.body
if (!itemProgressPayloads || !itemProgressPayloads.length) { if (!itemProgressPayloads || !itemProgressPayloads.length) {
return res.sendStatus(500) return res.status(400).send('Missing request payload')
} }
var shouldUpdate = false var shouldUpdate = false
@@ -107,7 +108,7 @@ class MeController {
if (shouldUpdate) { if (shouldUpdate) {
await this.db.updateEntity('user', req.user) await this.db.updateEntity('user', req.user)
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser()) SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
} }
res.sendStatus(200) res.sendStatus(200)
@@ -120,7 +121,7 @@ class MeController {
const { time, title } = req.body const { time, title } = req.body
var bookmark = req.user.createBookmark(libraryItem.id, time, title) var bookmark = req.user.createBookmark(libraryItem.id, time, title)
await this.db.updateEntity('user', req.user) await this.db.updateEntity('user', req.user)
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser()) SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
res.json(bookmark) res.json(bookmark)
} }
@@ -136,7 +137,7 @@ class MeController {
var bookmark = req.user.updateBookmark(libraryItem.id, time, title) var bookmark = req.user.updateBookmark(libraryItem.id, time, title)
if (!bookmark) return res.sendStatus(500) if (!bookmark) return res.sendStatus(500)
await this.db.updateEntity('user', req.user) await this.db.updateEntity('user', req.user)
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser()) SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
res.json(bookmark) res.json(bookmark)
} }
@@ -153,7 +154,7 @@ class MeController {
} }
req.user.removeBookmark(libraryItem.id, time) req.user.removeBookmark(libraryItem.id, time)
await this.db.updateEntity('user', req.user) await this.db.updateEntity('user', req.user)
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser()) SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
res.sendStatus(200) res.sendStatus(200)
} }
@@ -233,7 +234,7 @@ class MeController {
Logger.debug(`[MeController] syncLocalMediaProgress server updates = ${numServerProgressUpdates}, local updates = ${updatedLocalMediaProgress.length}`) Logger.debug(`[MeController] syncLocalMediaProgress server updates = ${numServerProgressUpdates}, local updates = ${updatedLocalMediaProgress.length}`)
if (numServerProgressUpdates > 0) { if (numServerProgressUpdates > 0) {
await this.db.updateEntity('user', req.user) await this.db.updateEntity('user', req.user)
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser()) SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
} }
res.json({ res.json({
@@ -288,7 +289,7 @@ class MeController {
const hasUpdated = req.user.addSeriesToHideFromContinueListening(req.params.id) const hasUpdated = req.user.addSeriesToHideFromContinueListening(req.params.id)
if (hasUpdated) { if (hasUpdated) {
await this.db.updateEntity('user', req.user) await this.db.updateEntity('user', req.user)
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser()) SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
} }
res.json(req.user.toJSONForBrowser()) res.json(req.user.toJSONForBrowser())
} }
@@ -304,7 +305,7 @@ class MeController {
const hasUpdated = req.user.removeSeriesFromHideFromContinueListening(req.params.id) const hasUpdated = req.user.removeSeriesFromHideFromContinueListening(req.params.id)
if (hasUpdated) { if (hasUpdated) {
await this.db.updateEntity('user', req.user) await this.db.updateEntity('user', req.user)
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser()) SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
} }
res.json(req.user.toJSONForBrowser()) res.json(req.user.toJSONForBrowser())
} }
@@ -314,7 +315,7 @@ class MeController {
const hasUpdated = req.user.removeProgressFromContinueListening(req.params.id) const hasUpdated = req.user.removeProgressFromContinueListening(req.params.id)
if (hasUpdated) { if (hasUpdated) {
await this.db.updateEntity('user', req.user) await this.db.updateEntity('user', req.user)
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser()) SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
} }
res.json(req.user.toJSONForBrowser()) res.json(req.user.toJSONForBrowser())
} }
+3 -3
View File
@@ -26,11 +26,11 @@ class MiscController {
var library = this.db.libraries.find(lib => lib.id === libraryId) var library = this.db.libraries.find(lib => lib.id === libraryId)
if (!library) { if (!library) {
return res.status(500).send(`Library not found with id ${libraryId}`) return res.status(404).send(`Library not found with id ${libraryId}`)
} }
var folder = library.folders.find(fold => fold.id === folderId) var folder = library.folders.find(fold => fold.id === folderId)
if (!folder) { if (!folder) {
return res.status(500).send(`Folder not found with id ${folderId} in library ${library.name}`) return res.status(404).send(`Folder not found with id ${folderId} in library ${library.name}`)
} }
if (!files.length || !title) { if (!files.length || !title) {
@@ -111,7 +111,7 @@ class MiscController {
} }
return res.json({ return res.json({
success: true, success: true,
serverSettings: this.db.serverSettings serverSettings: this.db.serverSettings.toJSONForBrowser()
}) })
} }

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