Compare commits

..

77 Commits

Author SHA1 Message Date
advplyr c58a6b9047 Version bump 2.2.9 2022-12-18 15:50:47 -06:00
advplyr b787fb18f3 Merge pull request #1251 from lkiesow/PermissionsStartOnly
No PermissionsStartOnly=true
2022-12-18 15:50:10 -06:00
advplyr 17cce9c914 Merge pull request #1287 from lkiesow/subpath-detection
Fix Sub-path Detection
2022-12-18 15:48:28 -06:00
Lars Kiesow 90299e348c Fix Sub-path Detection
If the scanner detects new files with a path containing part of the name
of an already existing library item, the new item will incorrectly be
detected as being a parent directory of the already existing item and
the import will be aborted.

You can follow these steps to reproduce the issue:

```
❯ mkdir audiobooks/author/

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

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

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

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

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

All Google image links support HTTPS and we can safely switch to HTTOS
to avoid these warnings.
2022-12-18 00:18:11 +01:00
advplyr 9b1f7f566f Fix:On bookshelf view show series name placard on shelf #1239 2022-12-17 16:36:41 -06:00
advplyr 10295b000a Update:Remove HOST default to allow for ipv6 #1256 2022-12-17 15:55:53 -06:00
advplyr c06d734d5e Update:Persist series sort/filter options #1272 2022-12-17 15:10:25 -06:00
advplyr 49a69193d8 Comments where user settings needs to be removed 2022-12-17 14:52:10 -06:00
advplyr 7852804a9c Update:Remove call to server for user settings, user settings stored locally 2022-12-17 14:50:01 -06:00
advplyr 415dda37a4 Update:Match tab persist selected details to use #1276 2022-12-17 10:27:27 -06:00
advplyr 179d339afd Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-12-16 17:58:42 -06:00
advplyr 858c1a7353 Update:Series inner input modal update button Save to Submit #1277 2022-12-16 17:57:46 -06:00
advplyr 0b42b81558 Update:Author modal Submit button to Save #1280 2022-12-16 17:54:00 -06:00
advplyr f9678dec2f Merge pull request #1275 from tomazed/translation-fr
Update fr.json for batch update
2022-12-15 17:58:17 -06:00
advplyr 82642b295c Merge pull request #1271 from tomazed/localization-update
Missing Localization in Appbar.vue
2022-12-15 17:57:52 -06:00
advplyr ba3d84a924 Update client/components/app/Appbar.vue 2022-12-15 17:57:42 -06:00
advplyr 96e2f934a3 Merge pull request #1270 from Hallo951/master
Update de.json
2022-12-15 17:56:53 -06:00
advplyr a68ade2b3d Update:Select largest cover image from Google Books provider #1244 2022-12-15 17:54:02 -06:00
advplyr 4fcdeda447 Add:Book library filter for missing cover image #1243 2022-12-15 17:46:27 -06:00
advplyr dc03835742 Update:Trim whitespace from chapter titles in chapter editor #1248 2022-12-15 17:40:34 -06:00
advplyr 50430e6b27 Update:Audiobook RSS feed track episode pub dates #1253 2022-12-15 17:36:29 -06:00
advplyr d130dd6d5e Fix:Setting file ownership for /config and /metadata/logs #584 2022-12-15 17:30:45 -06:00
advplyr 793cc989de Fix:Overflowing edit library folders #1266 2022-12-15 16:51:37 -06:00
Tomazed 27d8c4d67c Update fr.json for batch update 2022-12-15 23:19:46 +01:00
Tomazed 48f493a9f5 Missing Localization in Appbar.vue 2022-12-15 17:50:13 +01:00
Hallo951 04992ee3fb Update de.json 2022-12-15 16:36:28 +01:00
advplyr 4d8e2a1279 Update:Max filename to 255 bytes in utf-16 #1261 2022-12-13 17:46:18 -06:00
advplyr 2af7b6b6f1 Add translation strings for batch update page #1166 2022-12-13 16:59:46 -06:00
advplyr e59351566d Add:Batch append details #848 2022-12-13 16:28:05 -06:00
advplyr 05d10b73c3 Merge pull request #1231 from k9withabone/server/respond-with-objects
Server respond with objects
2022-12-12 17:53:57 -06:00
advplyr 41e192c6a5 Update more vars 2022-12-12 17:52:20 -06:00
advplyr ea42ab7624 Update get all users route 2022-12-12 17:48:57 -06:00
advplyr 2d9035d90b Update get tags route and revert podcast/books search route 2022-12-12 17:45:51 -06:00
advplyr 0ae853c119 Update library items batch get route 2022-12-12 17:36:53 -06:00
advplyr 3c0fdff7b4 Update libraries reorder and get all authors routes 2022-12-12 17:33:59 -06:00
advplyr eede2bbd46 Update for filesystem and libraries api update and revert personalized shelves route 2022-12-12 17:29:56 -06:00
advplyr 5c31687a0f Merge branch 'master' into server/respond-with-objects 2022-12-12 17:20:14 -06:00
advplyr 6b654d3c2d Update:Starting session for finished item sets the user start time back to 0 2022-12-12 17:18:56 -06:00
Lars Kiesow 91cbe45839 No PermissionsStartOnly=true
This patch removes `PermissionsStartOnly=true` from the systemd unit
file used for packaging. This shouldn't be necessary for any commands
run by the unit.
2022-12-06 00:52:23 +01:00
advplyr 7883d4a97f Merge pull request #1249 from lkiesow/tooltips
Add Missing Tooltips
2022-12-05 17:13:14 -06:00
advplyr 9f4547cff8 Update client/components/app/Appbar.vue 2022-12-05 17:13:03 -06:00
advplyr a98106593d Update client/components/app/Appbar.vue 2022-12-05 17:12:58 -06:00
advplyr c625b3f08c Update client/components/app/Appbar.vue 2022-12-05 17:12:53 -06:00
advplyr 9e7f09c21b Merge pull request #1245 from burghy86/patch-6
Update it.json
2022-12-05 17:03:19 -06:00
Lars Kiesow 616caecdf1 Add Missing Tooltips
This patch adds a few more missing tooltips to the user interface.
2022-12-05 23:16:27 +01:00
burghy86 cee19c5128 Update it.json
fix and add
2022-12-05 16:50:16 +01:00
advplyr 67db41a525 Update:Get item cover API endpoint to allow for returning the raw cover image 2022-12-04 16:23:15 -06:00
advplyr 3ea3e55d17 Fix:Typo in library settings 2022-12-03 17:50:54 -06:00
advplyr 4959a28485 Update:Playlists cover size 2022-12-03 15:44:53 -06:00
advplyr 6d2482a98e Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-12-01 18:08:57 -06:00
advplyr 4b23b842bb Version bump 2.2.8 2022-12-01 18:08:52 -06:00
advplyr 07bebc8808 Merge pull request #1238 from Hallo951/master
Update german language
2022-12-01 18:06:04 -06:00
advplyr 027d7f7a5b Fix:Chapter editor show save button when applying lookup data #1237 2022-12-01 17:42:02 -06:00
advplyr 6baa0fa047 Fix:Multi-select library items using shift key #1236 2022-12-01 17:39:23 -06:00
advplyr 8425fac543 Update PWA workbox 2022-12-01 17:26:22 -06:00
Hallo951 7b2ac7b9e9 Update german language 2022-12-01 11:27:06 +01:00
Paul Nettleton c9ab2a242d Update MiscController.js to respond with objects
Changes:
- `getAllTags` (GET /api/tags)
2022-11-29 12:26:59 -06:00
Paul Nettleton 13532cba14 Update SearchController.js to respond with objects
Changes:
- `findCovers` (GET /api/search/covers)
- `findBooks` (GET /api/search/books)
- `findPodcasts` (GET /api/search/podcast)
2022-11-29 12:23:02 -06:00
Paul Nettleton 3fb2bd3362 Update SeriesController.js to respond with objects
Changes:
- `search` (GET /api/series/search)
2022-11-29 12:08:40 -06:00
Paul Nettleton e80c3a1c5a Update AuthorController.js to respond with objects
Changes:
- `search` (GET /api/authors/search)
2022-11-29 12:04:45 -06:00
Paul Nettleton e04d26307e Update FileSystemController.js to respond with objects
Changes:
- `getPaths` (GET /api/filesystem)
2022-11-29 11:55:22 -06:00
Paul Nettleton b8f74e1c98 Update CollectionController.js to respond with objects
Changes:
- `findAll` (GET /api/collections)
2022-11-29 11:48:21 -06:00
Paul Nettleton 0851050392 Update UserController.js to respond with objects
Changes:
- `findAll` (GET /api/users)
2022-11-29 11:43:39 -06:00
Paul Nettleton b84882d9d1 Update LibraryItemController.js to respond with objects
Changes:
- `batchGet` (POST /api/items/batch/get)
2022-11-29 11:37:45 -06:00
Paul Nettleton cd37a7618e Update LibraryController.js to respond with objects
Changes:
- `findAll` (GET /api/libraries)
- `getLibraryUserPersonalizedOptimal` (GET /api/libraries/<ID>/personalized)
- `getAuthors` (GET /api/libraries/<ID>/authors)
- `reorder` (POST /api/libraries/order)
2022-11-29 11:30:25 -06:00
84 changed files with 1447 additions and 483 deletions
@@ -11,7 +11,6 @@ ExecReload=/bin/kill -HUP $MAINPID
Restart=always Restart=always
User=audiobookshelf User=audiobookshelf
Group=audiobookshelf Group=audiobookshelf
PermissionsStartOnly=true
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+21 -12
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="w-full h-16 bg-primary relative"> <div class="w-full h-16 bg-primary relative">
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-50"> <div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-60">
<div class="flex h-full items-center"> <div class="flex h-full items-center">
<nuxt-link to="/"> <nuxt-link to="/">
<img src="~static/icon.svg" class="w-8 min-w-8 h-8 mr-2 sm:w-12 sm:min-w-12 sm:h-12 sm:mr-4" /> <img src="~static/icon.svg" class="w-8 min-w-8 h-8 mr-2 sm:w-12 sm:min-w-12 sm:h-12 sm:mr-4" />
@@ -25,15 +25,21 @@
</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 text-2xl" aria-label="User Stats" role="button">equalizer</span> <ui-tooltip :text="$strings.HeaderYourStats" direction="bottom" class="flex items-center">
<span class="material-icons text-2xl" aria-label="User Stats" role="button">equalizer</span>
</ui-tooltip>
</nuxt-link> </nuxt-link>
<nuxt-link v-if="userCanUpload && currentLibrary" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1"> <nuxt-link v-if="userCanUpload && currentLibrary" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
<span class="material-icons text-2xl" aria-label="Upload Media" role="button">upload</span> <ui-tooltip :text="$strings.ButtonUpload" direction="bottom" class="flex items-center">
<span class="material-icons text-2xl" aria-label="Upload Media" role="button">upload</span>
</ui-tooltip>
</nuxt-link> </nuxt-link>
<nuxt-link v-if="userIsAdminOrUp" to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1"> <nuxt-link v-if="userIsAdminOrUp" to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
<span class="material-icons text-2xl" aria-label="System Settings" role="button">settings</span> <ui-tooltip :text="$strings.HeaderSettings" direction="bottom" class="flex items-center">
<span class="material-icons text-2xl" aria-label="System Settings" role="button">settings</span>
</ui-tooltip>
</nuxt-link> </nuxt-link>
<nuxt-link to="/account" class="relative w-9 h-9 md:w-32 bg-fg border border-gray-500 rounded shadow-sm ml-1.5 sm:ml-3 md:ml-5 md:pl-3 md:pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer hover:bg-bg hover:bg-opacity-40" aria-haspopup="listbox" aria-expanded="true"> <nuxt-link to="/account" class="relative w-9 h-9 md:w-32 bg-fg border border-gray-500 rounded shadow-sm ml-1.5 sm:ml-3 md:ml-5 md:pl-3 md:pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer hover:bg-bg hover:bg-opacity-40" aria-haspopup="listbox" aria-expanded="true">
@@ -62,7 +68,7 @@
<ui-icon-btn :disabled="processingBatch" icon="collections_bookmark" @click="batchAddToCollectionClick" class="mx-1.5" /> <ui-icon-btn :disabled="processingBatch" icon="collections_bookmark" @click="batchAddToCollectionClick" class="mx-1.5" />
</ui-tooltip> </ui-tooltip>
<template v-if="userCanUpdate"> <template v-if="userCanUpdate">
<ui-tooltip text="Edit" direction="bottom"> <ui-tooltip :text="$strings.LabelEdit" direction="bottom">
<ui-icon-btn :disabled="processingBatch" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" /> <ui-icon-btn :disabled="processingBatch" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
</ui-tooltip> </ui-tooltip>
</template> </template>
@@ -116,7 +122,7 @@ export default {
return this.$store.state.globals.selectedMediaItems return this.$store.state.globals.selectedMediaItems
}, },
selectedMediaItemsArePlayable() { selectedMediaItemsArePlayable() {
return !this.selectedMediaItems.some(i => !i.hasTracks) return !this.selectedMediaItems.some((i) => !i.hasTracks)
}, },
userMediaProgress() { userMediaProgress() {
return this.$store.state.user.user.mediaProgress || [] return this.$store.state.user.user.mediaProgress || []
@@ -158,12 +164,15 @@ export default {
this.$store.commit('setProcessingBatch', true) this.$store.commit('setProcessingBatch', true)
const libraryItemIds = this.selectedMediaItems.map((i) => i.id) const libraryItemIds = this.selectedMediaItems.map((i) => i.id)
const libraryItems = await this.$axios.$post(`/api/items/batch/get`, { libraryItemIds }).catch((error) => { const libraryItems = await this.$axios
const errorMsg = error.response.data || 'Failed to get items' .$post(`/api/items/batch/get`, { libraryItemIds })
console.error(errorMsg, error) .then((res) => res.libraryItems)
this.$toast.error(errorMsg) .catch((error) => {
return [] const errorMsg = error.response.data || 'Failed to get items'
}) console.error(errorMsg, error)
this.$toast.error(errorMsg)
return []
})
if (!libraryItems.length) { if (!libraryItems.length) {
this.$store.commit('setProcessingBatch', false) this.$store.commit('setProcessingBatch', false)
@@ -405,8 +405,6 @@ export default {
} }
}, },
removeListeners() { removeListeners() {
this.$store.commit('user/removeSettingsListener', 'bookshelf')
if (this.$root.socket) { if (this.$root.socket) {
this.$root.socket.off('user_updated', this.userUpdated) this.$root.socket.off('user_updated', this.userUpdated)
this.$root.socket.off('author_updated', this.authorUpdated) this.$root.socket.off('author_updated', this.authorUpdated)
+7 -31
View File
@@ -39,7 +39,7 @@
<p class="text-sm">{{ $strings.ButtonSearch }}</p> <p class="text-sm">{{ $strings.ButtonSearch }}</p>
</nuxt-link> </nuxt-link>
</div> </div>
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-30 flex items-center justify-end md:justify-start px-2 md:px-8"> <div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-40 flex items-center justify-end md:justify-start px-2 md:px-8">
<!-- Series books page --> <!-- Series books page -->
<template v-if="selectedSeries"> <template v-if="selectedSeries">
<p class="pl-2 font-book text-base md:text-lg"> <p class="pl-2 font-book text-base md:text-lg">
@@ -72,8 +72,8 @@
<ui-checkbox v-if="isLibraryPage && !isPodcastLibrary && !isBatchSelecting" v-model="settings.collapseSeries" :label="$strings.LabelCollapseSeries" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseSeries" /> <ui-checkbox v-if="isLibraryPage && !isPodcastLibrary && !isBatchSelecting" v-model="settings.collapseSeries" :label="$strings.LabelCollapseSeries" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseSeries" />
<controls-library-filter-select v-if="isLibraryPage && !isBatchSelecting" v-model="settings.filterBy" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateFilter" /> <controls-library-filter-select v-if="isLibraryPage && !isBatchSelecting" v-model="settings.filterBy" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateFilter" />
<controls-library-sort-select v-if="isLibraryPage && !isBatchSelecting" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateOrder" /> <controls-library-sort-select v-if="isLibraryPage && !isBatchSelecting" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateOrder" />
<controls-library-filter-select v-if="isSeriesPage && !isBatchSelecting" v-model="seriesFilterBy" is-series class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesFilter" /> <controls-library-filter-select v-if="isSeriesPage && !isBatchSelecting" v-model="settings.seriesFilterBy" is-series class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesFilter" />
<controls-sort-select v-if="isSeriesPage && !isBatchSelecting" v-model="seriesSortBy" :descending.sync="seriesSortDesc" :items="seriesSortItems" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesSort" /> <controls-sort-select v-if="isSeriesPage && !isBatchSelecting" v-model="settings.seriesSortBy" :descending.sync="settings.seriesSortDesc" :items="seriesSortItems" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesSort" />
<ui-btn v-if="isIssuesFilter && userCanDelete && !isBatchSelecting" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">{{ $strings.ButtonRemoveAll }} {{ numShowing }} {{ entityName }}</ui-btn> <ui-btn v-if="isIssuesFilter && userCanDelete && !isBatchSelecting" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">{{ $strings.ButtonRemoveAll }} {{ numShowing }} {{ entityName }}</ui-btn>
</template> </template>
@@ -219,30 +219,6 @@ export default {
}, },
isIssuesFilter() { isIssuesFilter() {
return this.filterBy === 'issues' && this.$route.query.filter === 'issues' return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
},
seriesSortBy: {
get() {
return this.$store.state.libraries.seriesSortBy
},
set(val) {
this.$store.commit('libraries/setSeriesSortBy', val)
}
},
seriesSortDesc: {
get() {
return this.$store.state.libraries.seriesSortDesc
},
set(val) {
this.$store.commit('libraries/setSeriesSortDesc', val)
}
},
seriesFilterBy: {
get() {
return this.$store.state.libraries.seriesFilterBy
},
set(val) {
this.$store.commit('libraries/setSeriesFilterBy', val)
}
} }
}, },
methods: { methods: {
@@ -339,10 +315,10 @@ export default {
this.saveSettings() this.saveSettings()
}, },
updateSeriesSort() { updateSeriesSort() {
this.$eventBus.$emit('series-sort-updated') this.saveSettings()
}, },
updateSeriesFilter() { updateSeriesFilter() {
this.$eventBus.$emit('series-sort-updated') this.saveSettings()
}, },
updateCollapseSeries() { updateCollapseSeries() {
this.saveSettings() this.saveSettings()
@@ -367,11 +343,11 @@ export default {
}, },
mounted() { mounted() {
this.init() this.init()
this.$store.commit('user/addSettingsListener', { id: 'bookshelftoolbar', meth: this.settingsUpdated }) this.$eventBus.$on('user-settings', this.settingsUpdated)
this.$eventBus.$on('bookshelf-total-entities', this.setBookshelfTotalEntities) this.$eventBus.$on('bookshelf-total-entities', this.setBookshelfTotalEntities)
}, },
beforeDestroy() { beforeDestroy() {
this.$store.commit('user/removeSettingsListener', 'bookshelftoolbar') this.$eventBus.$off('user-settings', this.settingsUpdated)
this.$eventBus.$off('bookshelf-total-entities', this.setBookshelfTotalEntities) this.$eventBus.$off('bookshelf-total-entities', this.setBookshelfTotalEntities)
} }
} }
+5
View File
@@ -87,6 +87,11 @@ export default {
id: 'config-notifications', id: 'config-notifications',
title: this.$strings.HeaderNotifications, title: this.$strings.HeaderNotifications,
path: '/config/notifications' path: '/config/notifications'
},
{
id: 'config-item-metadata-utils',
title: this.$strings.HeaderItemMetadataUtils,
path: '/config/item-metadata-utils'
} }
] ]
+12 -15
View File
@@ -100,13 +100,13 @@ export default {
return this.page return this.page
}, },
seriesSortBy() { seriesSortBy() {
return this.$store.state.libraries.seriesSortBy return this.$store.getters['user/getUserSetting']('seriesSortBy')
}, },
seriesSortDesc() { seriesSortDesc() {
return this.$store.state.libraries.seriesSortDesc return this.$store.getters['user/getUserSetting']('seriesSortDesc')
}, },
seriesFilterBy() { seriesFilterBy() {
return this.$store.state.libraries.seriesFilterBy return this.$store.getters['user/getUserSetting']('seriesFilterBy')
}, },
orderBy() { orderBy() {
return this.$store.getters['user/getUserSetting']('orderBy') return this.$store.getters['user/getUserSetting']('orderBy')
@@ -163,7 +163,7 @@ export default {
}, },
bookWidth() { bookWidth() {
var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize') var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
if (this.isCoverSquareAspectRatio) return coverSize * 1.6 if (this.isCoverSquareAspectRatio || this.entityName === 'playlists') return coverSize * 1.6
return coverSize return coverSize
}, },
bookHeight() { bookHeight() {
@@ -230,7 +230,7 @@ export default {
}, },
selectEntity(entity, shiftKey) { selectEntity(entity, shiftKey) {
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) const indexOf = this.entities.findIndex((ent) => ent && ent.id === entity.id)
const lastLastItemIndexSelected = this.lastItemIndexSelected const lastLastItemIndexSelected = this.lastItemIndexSelected
if (!this.selectedMediaItems.some((i) => i.id === entity.id)) { if (!this.selectedMediaItems.some((i) => i.id === entity.id)) {
this.lastItemIndexSelected = indexOf this.lastItemIndexSelected = indexOf
@@ -239,14 +239,14 @@ export default {
} }
if (shiftKey && lastLastItemIndexSelected >= 0) { if (shiftKey && lastLastItemIndexSelected >= 0) {
var loopStart = indexOf let loopStart = indexOf
var loopEnd = lastLastItemIndexSelected let loopEnd = lastLastItemIndexSelected
if (indexOf > lastLastItemIndexSelected) { if (indexOf > lastLastItemIndexSelected) {
loopStart = lastLastItemIndexSelected loopStart = lastLastItemIndexSelected
loopEnd = indexOf loopEnd = indexOf
} }
var isSelecting = false let isSelecting = false
// If any items in this range is not selected then select all otherwise unselect all // If any items in this range is not selected then select all otherwise unselect all
for (let i = loopStart; i <= loopEnd; i++) { for (let i = loopStart; i <= loopEnd; i++) {
const thisEntity = this.entities[i] const thisEntity = this.entities[i]
@@ -275,6 +275,7 @@ export default {
mediaType: thisEntity.mediaType, mediaType: thisEntity.mediaType,
hasTracks: thisEntity.mediaType === 'podcast' || thisEntity.media.numTracks || (thisEntity.media.tracks && thisEntity.media.tracks.length) hasTracks: thisEntity.mediaType === 'podcast' || thisEntity.media.numTracks || (thisEntity.media.tracks && thisEntity.media.tracks.length)
} }
console.log('Setting media item selected', mediaItem, 'Num Selected=', this.selectedMediaItems.length)
this.$store.commit('globals/setMediaItemSelected', { item: mediaItem, selected: isSelecting }) this.$store.commit('globals/setMediaItemSelected', { item: mediaItem, selected: isSelecting })
} else { } else {
console.error('Invalid entity index', i) console.error('Invalid entity index', i)
@@ -497,7 +498,7 @@ export default {
} }
}, },
settingsUpdated(settings) { settingsUpdated(settings) {
var wasUpdated = this.checkUpdateSearchParams() const wasUpdated = this.checkUpdateSearchParams()
if (wasUpdated) { if (wasUpdated) {
this.resetEntities() this.resetEntities()
} else if (settings.bookshelfCoverSize !== this.currentBookWidth) { } else if (settings.bookshelfCoverSize !== this.currentBookWidth) {
@@ -666,11 +667,9 @@ export default {
} }
}) })
this.$eventBus.$on('series-sort-updated', this.seriesSortUpdated)
this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities) this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities)
this.$eventBus.$on('socket_init', this.socketInit) this.$eventBus.$on('socket_init', this.socketInit)
this.$eventBus.$on('user-settings', this.settingsUpdated)
this.$store.commit('user/addSettingsListener', { id: 'lazy-bookshelf', meth: this.settingsUpdated })
if (this.$root.socket) { if (this.$root.socket) {
this.$root.socket.on('item_updated', this.libraryItemUpdated) this.$root.socket.on('item_updated', this.libraryItemUpdated)
@@ -695,11 +694,9 @@ export default {
bookshelf.removeEventListener('scroll', this.scroll) bookshelf.removeEventListener('scroll', this.scroll)
} }
this.$eventBus.$off('series-sort-updated', this.seriesSortUpdated)
this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities) this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities)
this.$eventBus.$off('socket_init', this.socketInit) this.$eventBus.$off('socket_init', this.socketInit)
this.$eventBus.$off('user-settings', this.settingsUpdated)
this.$store.commit('user/removeSettingsListener', 'lazy-bookshelf')
if (this.$root.socket) { if (this.$root.socket) {
this.$root.socket.off('item_updated', this.libraryItemUpdated) this.$root.socket.off('item_updated', this.libraryItemUpdated)
+1 -2
View File
@@ -1,6 +1,5 @@
<template> <template>
<!-- <div class="w-20 bg-bg h-full relative box-shadow-side z-40" style="min-width: 80px"> --> <div class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-50" style="min-width: 80px" :style="{ top: offsetTop + 'px' }">
<div class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-40" style="min-width: 80px" :style="{ top: offsetTop + 'px' }">
<!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar --> <!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar -->
<div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" /> <div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
+1 -1
View File
@@ -1,5 +1,5 @@
<template> <template>
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-40 bg-primary px-4 pb-1 md:pb-4 pt-2"> <div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-50 bg-primary px-4 pb-1 md:pb-4 pt-2">
<div id="videoDock" /> <div id="videoDock" />
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-1 sm:left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }"> <nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-1 sm:left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" /> <covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
+6 -2
View File
@@ -13,10 +13,14 @@
<!-- Search icon btn --> <!-- Search icon btn -->
<div v-show="!searching && isHovering && userCanUpdate" class="absolute top-0 left-0 p-2 cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="searchAuthor"> <div v-show="!searching && isHovering && userCanUpdate" class="absolute top-0 left-0 p-2 cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="searchAuthor">
<span class="material-icons text-lg">search</span> <ui-tooltip :text="$strings.ButtonQuickMatch" direction="bottom">
<span class="material-icons text-lg">search</span>
</ui-tooltip>
</div> </div>
<div v-show="isHovering && !searching && userCanUpdate" class="absolute top-0 right-0 p-2 cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="$emit('edit', author)"> <div v-show="isHovering && !searching && userCanUpdate" class="absolute top-0 right-0 p-2 cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="$emit('edit', author)">
<span class="material-icons text-lg">edit</span> <ui-tooltip :text="$strings.LabelEdit" direction="bottom">
<span class="material-icons text-lg">edit</span>
</ui-tooltip>
</div> </div>
<!-- Loading spinner --> <!-- Loading spinner -->
@@ -9,7 +9,7 @@
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span> <span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
</div> </div>
</div> </div>
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }"> <div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }"> <div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p> <p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div> </div>
+1 -1
View File
@@ -9,7 +9,7 @@
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span> <span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
</div> </div>
</div> </div>
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }"> <div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }"> <div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p> <p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div> </div>
+2 -2
View File
@@ -1,5 +1,5 @@
<template> <template>
<div ref="card" :id="`series-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="rounded-sm z-10 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard"> <div ref="card" :id="`series-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" /> <div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
<div class="w-full h-full bg-primary relative rounded overflow-hidden z-0"> <div class="w-full h-full bg-primary relative rounded overflow-hidden z-0">
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="displayTitle" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="displayTitle" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
@@ -13,7 +13,7 @@
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p> <p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
</div> </div>
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }"> <div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-10 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }"> <div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ displayTitle }}</p> <p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ displayTitle }}</p>
</div> </div>
@@ -348,6 +348,10 @@ export default {
{ {
id: 'language', id: 'language',
name: this.$strings.LabelLanguage name: this.$strings.LabelLanguage
},
{
id: 'cover',
name: this.$strings.LabelCover
} }
] ]
}, },
+2 -2
View File
@@ -201,8 +201,8 @@ export default {
this.loadingTags = true this.loadingTags = true
this.$axios this.$axios
.$get(`/api/tags`) .$get(`/api/tags`)
.then((tags) => { .then((res) => {
this.tags = tags this.tags = res.tags
this.loadingTags = false this.loadingTags = false
}) })
.catch((error) => { .catch((error) => {
@@ -15,7 +15,7 @@
</div> </div>
</div> </div>
<div class="flex justify-end mt-2 p-1"> <div class="flex justify-end mt-2 p-1">
<ui-btn type="submit">{{ $strings.ButtonSave }}</ui-btn> <ui-btn type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
</div> </div>
</div> </div>
</form> </form>
+1 -1
View File
@@ -39,7 +39,7 @@ export default {
}, },
zIndex: { zIndex: {
type: Number, type: Number,
default: 50 default: 60
}, },
bgOpacity: { bgOpacity: {
type: Number, type: Number,
@@ -35,7 +35,7 @@
<div class="flex pt-2 px-2"> <div class="flex pt-2 px-2">
<ui-btn type="button" @click="searchAuthor">{{ $strings.ButtonQuickMatch }}</ui-btn> <ui-btn type="button" @click="searchAuthor">{{ $strings.ButtonQuickMatch }}</ui-btn>
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn type="submit">{{ $strings.ButtonSubmit }}</ui-btn> <ui-btn type="submit">{{ $strings.ButtonSave }}</ui-btn>
</div> </div>
</div> </div>
</div> </div>
+8 -5
View File
@@ -303,11 +303,14 @@ export default {
this.persistProvider() this.persistProvider()
this.isProcessing = true this.isProcessing = true
var searchQuery = this.getSearchQuery() const searchQuery = this.getSearchQuery()
var results = await this.$axios.$get(`/api/search/covers?${searchQuery}`).catch((error) => { const results = await this.$axios
console.error('Failed', error) .$get(`/api/search/covers?${searchQuery}`)
return [] .then((res) => res.results)
}) .catch((error) => {
console.error('Failed', error)
return []
})
this.coversFound = results this.coversFound = results
this.isProcessing = false this.isProcessing = false
this.hasSearched = true this.hasSearched = true
+33 -9
View File
@@ -306,13 +306,13 @@ export default {
this.runSearch() this.runSearch()
}, },
async runSearch() { async runSearch() {
var searchQuery = this.getSearchQuery() const searchQuery = this.getSearchQuery()
if (this.lastSearch === searchQuery) return if (this.lastSearch === searchQuery) return
this.searchResults = [] this.searchResults = []
this.isProcessing = true this.isProcessing = true
this.lastSearch = searchQuery this.lastSearch = searchQuery
var searchEntity = this.isPodcast ? 'podcast' : 'books' const searchEntity = this.isPodcast ? 'podcast' : 'books'
var results = await this.$axios.$get(`/api/search/${searchEntity}?${searchQuery}`, { timeout: 20000 }).catch((error) => { let results = await this.$axios.$get(`/api/search/${searchEntity}?${searchQuery}`, { timeout: 20000 }).catch((error) => {
console.error('Failed', error) console.error('Failed', error)
return [] return []
}) })
@@ -335,8 +335,7 @@ export default {
this.isProcessing = false this.isProcessing = false
this.hasSearched = true this.hasSearched = true
}, },
init() { initSelectedMatchUsage() {
this.clearSelectedMatch()
this.selectedMatchUsage = { this.selectedMatchUsage = {
title: true, title: true,
subtitle: true, subtitle: true,
@@ -360,6 +359,27 @@ export default {
releaseDate: true releaseDate: true
} }
// Load saved selected match from local storage
try {
let savedSelectedMatchUsage = localStorage.getItem('selectedMatchUsage')
if (!savedSelectedMatchUsage) return
savedSelectedMatchUsage = JSON.parse(savedSelectedMatchUsage)
for (const key in savedSelectedMatchUsage) {
if (this.selectedMatchUsage[key] !== undefined) {
this.selectedMatchUsage[key] = !!savedSelectedMatchUsage[key]
}
}
} catch (error) {
console.error('Failed to load saved selectedMatchUsage', error)
}
this.checkboxToggled()
},
init() {
this.clearSelectedMatch()
this.initSelectedMatchUsage()
if (this.libraryItem.id !== this.libraryItemId) { if (this.libraryItem.id !== this.libraryItemId) {
this.searchResults = [] this.searchResults = []
this.hasSearched = false this.hasSearched = false
@@ -465,11 +485,14 @@ export default {
console.log('Match payload', updatePayload) console.log('Match payload', updatePayload)
this.isProcessing = true this.isProcessing = true
// Persist in local storage
localStorage.setItem('selectedMatchUsage', JSON.stringify(this.selectedMatchUsage))
if (updatePayload.metadata.cover) { if (updatePayload.metadata.cover) {
var coverPayload = { const coverPayload = {
url: updatePayload.metadata.cover url: updatePayload.metadata.cover
} }
var success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, coverPayload).catch((error) => { const success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, coverPayload).catch((error) => {
console.error('Failed to update', error) console.error('Failed to update', error)
return false return false
}) })
@@ -483,8 +506,8 @@ export default {
} }
if (Object.keys(updatePayload).length) { if (Object.keys(updatePayload).length) {
var mediaUpdatePayload = updatePayload const mediaUpdatePayload = updatePayload
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, mediaUpdatePayload).catch((error) => { const updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, mediaUpdatePayload).catch((error) => {
console.error('Failed to update', error) console.error('Failed to update', error)
return false return false
}) })
@@ -502,6 +525,7 @@ export default {
} else { } else {
this.clearSelectedMatch() this.clearSelectedMatch()
} }
this.isProcessing = false this.isProcessing = false
}, },
clearSelectedMatch() { clearSelectedMatch() {
@@ -1,7 +1,7 @@
<template> <template>
<div class="w-full h-full px-1 md:px-4 py-2 mb-4"> <div class="w-full h-full md:px-4 py-2 mb-4">
<div v-if="!showDirectoryPicker" class="w-full h-full py-4"> <div v-if="!showDirectoryPicker" class="w-full h-full md:py-4">
<div class="flex flex-wrap md:flex-nowrap -mx-1"> <div class="flex flex-wrap md:flex-nowrap -mx-1 mb-2">
<div class="w-2/5 md:w-72 px-1 py-1 md:py-0"> <div class="w-2/5 md:w-72 px-1 py-1 md:py-0">
<ui-dropdown v-model="mediaType" :items="mediaTypes" :label="$strings.LabelMediaType" :disabled="!isNew" small @input="changedMediaType" /> <ui-dropdown v-model="mediaType" :items="mediaTypes" :label="$strings.LabelMediaType" :disabled="!isNew" small @input="changedMediaType" />
</div> </div>
@@ -16,7 +16,7 @@
</div> </div>
</div> </div>
<div class="w-full py-4"> <div class="folders-container overflow-y-auto w-full py-2 mb-2">
<p class="px-1 text-sm font-semibold">{{ $strings.LabelFolders }}</p> <p class="px-1 text-sm font-semibold">{{ $strings.LabelFolders }}</p>
<div v-for="(folder, index) in folders" :key="index" class="w-full flex items-center py-1 px-2"> <div v-for="(folder, index) in folders" :key="index" class="w-full flex items-center py-1 px-2">
<span class="material-icons bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span> <span class="material-icons bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span>
@@ -140,3 +140,14 @@ export default {
} }
} }
</script> </script>
<style>
.folders-container {
max-height: calc(80vh - 192px);
}
@media (max-device-width: 768px) {
.folders-container {
max-height: calc(80vh - 292px);
}
}
</style>
@@ -11,7 +11,7 @@
</template> </template>
</div> </div>
<div class="px-2 md:px-4 w-full text-sm pt-6 pb-20 rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh"> <div class="px-2 md:px-4 w-full text-sm pt-2 md:pt-6 pb-20 rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
<component v-if="libraryCopy && show" ref="tabComponent" :is="tabName" :is-new="!library" :library="libraryCopy" :processing.sync="processing" @update="updateLibrary" @close="show = false" /> <component v-if="libraryCopy && show" ref="tabComponent" :is="tabName" :is-new="!library" :library="libraryCopy" :processing.sync="processing" @update="updateLibrary" @close="show = false" />
<div class="absolute bottom-0 left-0 w-full px-4 py-4 border-t border-white border-opacity-10"> <div class="absolute bottom-0 left-0 w-full px-4 py-4 border-t border-white border-opacity-10">
+8 -10
View File
@@ -234,13 +234,10 @@ export default {
this.showChaptersModal = false this.showChaptersModal = false
}, },
setUseChapterTrack() { setUseChapterTrack() {
var useChapterTrack = !this.useChapterTrack this.useChapterTrack = !this.useChapterTrack
this.useChapterTrack = useChapterTrack if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(useChapterTrack)
this.$store.dispatch('user/updateUserSettings', { useChapterTrack }).catch((err) => { this.$store.dispatch('user/updateUserSettings', { useChapterTrack: this.useChapterTrack })
console.error('Failed to update settings', err)
})
this.updateTimestamp() this.updateTimestamp()
}, },
checkUpdateChapterTrack() { checkUpdateChapterTrack() {
@@ -311,7 +308,7 @@ export default {
init() { init() {
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1 this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
var _useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') || false const _useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') || false
this.useChapterTrack = this.chapters.length ? _useChapterTrack : false this.useChapterTrack = this.chapters.length ? _useChapterTrack : false
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack) if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
@@ -345,13 +342,14 @@ export default {
} }
}, },
mounted() { mounted() {
this.$store.commit('user/addSettingsListener', { id: 'audioplayer', meth: this.settingsUpdated })
this.init()
this.$eventBus.$on('player-hotkey', this.hotkey) this.$eventBus.$on('player-hotkey', this.hotkey)
this.$eventBus.$on('user-settings', this.settingsUpdated)
this.init()
}, },
beforeDestroy() { beforeDestroy() {
this.$store.commit('user/removeSettingsListener', 'audioplayer')
this.$eventBus.$off('player-hotkey', this.hotkey) this.$eventBus.$off('player-hotkey', this.hotkey)
this.$eventBus.$off('user-settings', this.settingsUpdated)
} }
} }
</script> </script>
+1 -1
View File
@@ -1,5 +1,5 @@
<template> <template>
<div v-if="show" class="w-screen h-screen fixed top-0 left-0 z-50 bg-primary text-white"> <div v-if="show" class="w-screen h-screen fixed top-0 left-0 z-60 bg-primary text-white">
<div class="absolute top-4 right-4 z-20"> <div class="absolute top-4 right-4 z-20">
<span class="material-icons cursor-pointer text-4xl" @click="close">close</span> <span class="material-icons cursor-pointer text-4xl" @click="close">close</span>
</div> </div>
+2 -2
View File
@@ -109,8 +109,8 @@ export default {
loadUsers() { loadUsers() {
this.$axios this.$axios
.$get('/api/users') .$get('/api/users')
.then((users) => { .then((res) => {
this.users = users.sort((a, b) => { this.users = res.users.sort((a, b) => {
return a.createdAt - b.createdAt return a.createdAt - b.createdAt
}) })
}) })
@@ -1,5 +1,5 @@
<template> <template>
<div id="librariesTable"> <div>
<draggable v-if="libraryCopies.length" :list="libraryCopies" v-bind="dragOptions" class="list-group" handle=".drag-handle" draggable=".item" tag="div" @start="startDrag" @end="endDrag"> <draggable v-if="libraryCopies.length" :list="libraryCopies" v-bind="dragOptions" class="list-group" handle=".drag-handle" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
<template v-for="library in libraryCopies"> <template v-for="library in libraryCopies">
<div :key="library.id" class="item"> <div :key="library.id" class="item">
@@ -82,10 +82,10 @@ export default {
}) })
var newOrder = libraryOrderData.map((lib) => lib.id).join(',') var newOrder = libraryOrderData.map((lib) => lib.id).join(',')
if (currOrder !== newOrder) { if (currOrder !== newOrder) {
this.$axios.$post('/api/libraries/order', libraryOrderData).then((libraries) => { this.$axios.$post('/api/libraries/order', libraryOrderData).then((response) => {
if (libraries && libraries.length) { if (response.libraries && response.libraries.length) {
this.$toast.success('Library order saved', { timeout: 1500 }) this.$toast.success('Library order saved', { timeout: 1500 })
this.$store.commit('libraries/set', libraries) this.$store.commit('libraries/set', response.libraries)
} }
}) })
} }
@@ -0,0 +1,55 @@
<template>
<div class="relative h-9 w-9" v-click-outside="clickOutsideObj">
<button type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
<span class="material-icons">more_vert</span>
</button>
<transition name="menu">
<div v-show="showMenu" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg max-h-56 w-48 rounded-md py-1 overflow-auto focus:outline-none sm:text-sm">
<template v-for="(item, index) in items">
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.action)">
<p>{{ item.text }}</p>
</div>
</template>
</div>
</transition>
</div>
</template>
<script>
export default {
props: {
disabled: Boolean,
items: {
type: Array,
default: () => []
}
},
data() {
return {
clickOutsideObj: {
handler: this.clickedOutside,
events: ['mousedown'],
isActive: true
},
showMenu: false
}
},
computed: {},
methods: {
clickShowMenu() {
if (this.disabled) return
this.showMenu = !this.showMenu
},
clickedOutside() {
this.showMenu = false
},
clickAction(action) {
if (this.disabled) return
this.showMenu = false
this.$emit('action', action)
}
},
mounted() {}
}
</script>
@@ -113,10 +113,13 @@ export default {
if (this.searching) return if (this.searching) return
this.currentSearch = this.textInput this.currentSearch = this.textInput
this.searching = true this.searching = true
var results = await this.$axios.$get(`/api/${this.endpoint}?q=${this.currentSearch}&limit=15&token=${this.userToken}`).catch((error) => { const results = await this.$axios
console.error('Failed to get search results', error) .$get(`/api/${this.endpoint}?q=${this.currentSearch}&limit=15&token=${this.userToken}`)
return [] .then((res) => res.results || res)
}) .catch((error) => {
console.error('Failed to get search results', error)
return []
})
this.items = results || [] this.items = results || []
this.searching = false this.searching = false
}, },
+25 -8
View File
@@ -137,16 +137,33 @@ export default {
author: (this.details.authors || []).map((au) => au.name).join(', ') author: (this.details.authors || []).map((au) => au.name).join(', ')
} }
}, },
mapBatchDetails(batchDetails) { mapBatchDetails(batchDetails, mapType = 'overwrite') {
for (const key in batchDetails) { for (const key in batchDetails) {
if (key === 'tags') { if (mapType === 'append') {
this.newTags = [...batchDetails.tags] if (key === 'tags') {
} else if (key === 'genres' || key === 'narrators') { // Concat and remove dupes
this.details[key] = [...batchDetails[key]] this.newTags = [...new Set(this.newTags.concat(batchDetails.tags))]
} else if (key === 'authors' || key === 'series') { } else if (key === 'genres' || key === 'narrators') {
this.details[key] = batchDetails[key].map((i) => ({ ...i })) // Concat and remove dupes
this.details[key] = [...new Set(this.details[key].concat(batchDetails[key]))]
} else if (key === 'authors' || key === 'series') {
batchDetails[key].forEach((detail) => {
const existingDetail = this.details[key].find((_d) => _d.name.toLowerCase() == detail.name.toLowerCase().trim() || _d.id == detail.id)
if (!existingDetail) {
this.details[key].push({ ...detail })
}
})
}
} else { } else {
this.details[key] = batchDetails[key] if (key === 'tags') {
this.newTags = [...batchDetails.tags]
} else if (key === 'genres' || key === 'narrators') {
this.details[key] = [...batchDetails[key]]
} else if (key === 'authors' || key === 'series') {
this.details[key] = batchDetails[key].map((i) => ({ ...i }))
} else {
this.details[key] = batchDetails[key]
}
} }
} }
}, },
@@ -107,14 +107,24 @@ export default {
author: this.details.author author: this.details.author
} }
}, },
mapBatchDetails(batchDetails) { mapBatchDetails(batchDetails, mapType = 'overwrite') {
for (const key in batchDetails) { for (const key in batchDetails) {
if (key === 'tags') { if (mapType === 'append') {
this.newTags = [...batchDetails.tags] if (key === 'tags') {
} else if (key === 'genres') { // Concat and remove dupes
this.details[key] = [...batchDetails[key]] this.newTags = [...new Set(this.newTags.concat(batchDetails.tags))]
} else if (key === 'genres') {
// Concat and remove dupes
this.details[key] = [...new Set(this.details[key].concat(batchDetails[key]))]
}
} else { } else {
this.details[key] = batchDetails[key] if (key === 'tags') {
this.newTags = [...batchDetails.tags]
} else if (key === 'genres') {
this.details[key] = [...batchDetails[key]]
} else {
this.details[key] = batchDetails[key]
}
} }
} }
}, },
-1
View File
@@ -280,7 +280,6 @@ export default {
userUpdated(user) { userUpdated(user) {
if (this.$store.state.user.user.id === user.id) { if (this.$store.state.user.user.id === user.id) {
this.$store.commit('user/setUser', user) this.$store.commit('user/setUser', user)
this.$store.commit('user/setSettings', user.settings)
} }
}, },
userOnline(user) { userOnline(user) {
+2
View File
@@ -118,6 +118,8 @@ module.exports = {
] ]
}, },
workbox: { workbox: {
offline: false,
cacheAssets: false,
preCaching: [], preCaching: [],
runtimeCaching: [] runtimeCaching: []
} }
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.2.7", "version": "2.2.9",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.2.7", "version": "2.2.9",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@nuxtjs/axios": "^5.13.6", "@nuxtjs/axios": "^5.13.6",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.2.7", "version": "2.2.9",
"description": "Self-hosted audiobook and podcast client", "description": "Self-hosted audiobook and podcast client",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
+7 -3
View File
@@ -354,6 +354,7 @@ export default {
for (let i = 0; i < this.newChapters.length; i++) { for (let i = 0; i < this.newChapters.length; i++) {
this.newChapters[i].id = i this.newChapters[i].id = i
this.newChapters[i].start = Number(this.newChapters[i].start) this.newChapters[i].start = Number(this.newChapters[i].start)
this.newChapters[i].title = (this.newChapters[i].title || '').trim()
if (i === 0 && this.newChapters[i].start !== 0) { if (i === 0 && this.newChapters[i].start !== 0) {
this.newChapters[i].error = this.$strings.MessageChapterErrorFirstNotZero this.newChapters[i].error = this.$strings.MessageChapterErrorFirstNotZero
@@ -508,22 +509,25 @@ export default {
this.showFindChaptersModal = false this.showFindChaptersModal = false
this.chapterData = null this.chapterData = null
this.checkChapters()
}, },
applyChapterData() { applyChapterData() {
var index = 0 let index = 0
this.newChapters = this.chapterData.chapters this.newChapters = this.chapterData.chapters
.filter((chap) => chap.startOffsetSec < this.mediaDuration) .filter((chap) => chap.startOffsetSec < this.mediaDuration)
.map((chap) => { .map((chap) => {
var chapEnd = Math.min(this.mediaDuration, (chap.startOffsetMs + chap.lengthMs) / 1000)
return { return {
id: index++, id: index++,
start: chap.startOffsetMs / 1000, start: chap.startOffsetMs / 1000,
end: chapEnd, end: Math.min(this.mediaDuration, (chap.startOffsetMs + chap.lengthMs) / 1000),
title: chap.title title: chap.title
} }
}) })
this.showFindChaptersModal = false this.showFindChaptersModal = false
this.chapterData = null this.chapterData = null
this.checkChapters()
}, },
findChapters() { findChapters() {
if (!this.asinInput) { if (!this.asinInput) {
+54 -35
View File
@@ -4,12 +4,23 @@
<div class="flex items-center px-4 py-4 cursor-pointer" @click="openMapOptions = !openMapOptions" @mousedown.prevent @mouseup.prevent> <div class="flex items-center px-4 py-4 cursor-pointer" @click="openMapOptions = !openMapOptions" @mousedown.prevent @mouseup.prevent>
<span class="material-icons text-2xl">{{ openMapOptions ? 'expand_less' : 'expand_more' }}</span> <span class="material-icons text-2xl">{{ openMapOptions ? 'expand_less' : 'expand_more' }}</span>
<p class="ml-4 text-gray-200 text-lg">Map details</p> <p class="ml-4 text-gray-200 text-lg">{{ $strings.HeaderMapDetails }}</p>
<div class="flex-grow" />
<div class="w-64 flex">
<button class="w-32 h-8 rounded-l-md shadow-md border border-gray-600" :class="!isMapOverwrite ? 'bg-bg text-white/30' : 'bg-primary'" @click.stop.prevent="mapDetailsType = 'overwrite'">
<p class="text-sm">{{ $strings.LabelOverwrite }}</p>
</button>
<button class="w-32 h-8 rounded-r-md shadow-md border border-gray-600" :class="!isMapAppend ? 'bg-bg text-white/30' : 'bg-primary'" @click.stop.prevent="mapDetailsType = 'append'">
<p class="text-sm">{{ $strings.LabelAppend }}</p>
</button>
</div>
</div> </div>
<div class="overflow-hidden"> <div class="overflow-hidden">
<transition name="slide"> <transition name="slide">
<div v-if="openMapOptions" class="flex flex-wrap"> <div v-if="openMapOptions" class="flex flex-wrap">
<div v-if="!isPodcastLibrary" class="flex items-center px-4 w-1/2"> <div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.subtitle" /> <ui-checkbox v-model="selectedBatchUsage.subtitle" />
<ui-text-input-with-label ref="subtitleInput" v-model="batchDetails.subtitle" :disabled="!selectedBatchUsage.subtitle" :label="$strings.LabelSubtitle" class="mb-4 ml-4" /> <ui-text-input-with-label ref="subtitleInput" v-model="batchDetails.subtitle" :disabled="!selectedBatchUsage.subtitle" :label="$strings.LabelSubtitle" class="mb-4 ml-4" />
</div> </div>
@@ -18,13 +29,13 @@
<!-- Authors filter only contains authors in this library, use query input to query all authors --> <!-- Authors filter only contains authors in this library, use query input to query all authors -->
<ui-multi-select-query-input ref="authorsSelect" v-model="batchDetails.authors" :disabled="!selectedBatchUsage.authors" :label="$strings.LabelAuthors" endpoint="authors/search" class="mb-4 ml-4" /> <ui-multi-select-query-input ref="authorsSelect" v-model="batchDetails.authors" :disabled="!selectedBatchUsage.authors" :label="$strings.LabelAuthors" endpoint="authors/search" class="mb-4 ml-4" />
</div> </div>
<div v-if="!isPodcastLibrary" class="flex items-center px-4 w-1/2"> <div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.publishedYear" /> <ui-checkbox v-model="selectedBatchUsage.publishedYear" />
<ui-text-input-with-label ref="publishedYearInput" v-model="batchDetails.publishedYear" :disabled="!selectedBatchUsage.publishedYear" :label="$strings.LabelPublishYear" class="mb-4 ml-4" /> <ui-text-input-with-label ref="publishedYearInput" v-model="batchDetails.publishedYear" :disabled="!selectedBatchUsage.publishedYear" :label="$strings.LabelPublishYear" class="mb-4 ml-4" />
</div> </div>
<div v-if="!isPodcastLibrary" class="flex items-center px-4 w-1/2"> <div v-if="!isPodcastLibrary" class="flex items-center px-4 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.series" /> <ui-checkbox v-model="selectedBatchUsage.series" />
<ui-multi-select ref="seriesSelect" v-model="batchDetails.series" :disabled="!selectedBatchUsage.series" :label="$strings.LabelSeries" :items="seriesItems" @newItem="newSeriesItem" @removedItem="removedSeriesItem" class="mb-4 ml-4" /> <ui-multi-select ref="seriesSelect" v-model="batchDetails.series" :disabled="!selectedBatchUsage.series" :label="$strings.LabelSeries" :items="existingSeriesNames" @newItem="newSeriesItem" @removedItem="removedSeriesItem" class="mb-4 ml-4" />
</div> </div>
<div class="flex items-center px-4 w-1/2"> <div class="flex items-center px-4 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.genres" /> <ui-checkbox v-model="selectedBatchUsage.genres" />
@@ -38,15 +49,15 @@
<ui-checkbox v-model="selectedBatchUsage.narrators" /> <ui-checkbox v-model="selectedBatchUsage.narrators" />
<ui-multi-select ref="narratorsSelect" v-model="batchDetails.narrators" :disabled="!selectedBatchUsage.narrators" :label="$strings.LabelNarrators" :items="narratorItems" @newItem="newNarratorItem" @removedItem="removedNarratorItem" class="mb-4 ml-4" /> <ui-multi-select ref="narratorsSelect" v-model="batchDetails.narrators" :disabled="!selectedBatchUsage.narrators" :label="$strings.LabelNarrators" :items="narratorItems" @newItem="newNarratorItem" @removedItem="removedNarratorItem" class="mb-4 ml-4" />
</div> </div>
<div v-if="!isPodcastLibrary" class="flex items-center px-4 w-1/2"> <div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.publisher" /> <ui-checkbox v-model="selectedBatchUsage.publisher" />
<ui-text-input-with-label ref="publisherInput" v-model="batchDetails.publisher" :disabled="!selectedBatchUsage.publisher" :label="$strings.LabelPublisher" class="mb-4 ml-4" /> <ui-text-input-with-label ref="publisherInput" v-model="batchDetails.publisher" :disabled="!selectedBatchUsage.publisher" :label="$strings.LabelPublisher" class="mb-4 ml-4" />
</div> </div>
<div class="flex items-center px-4 w-1/2"> <div v-if="!isMapAppend" class="flex items-center px-4 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.language" /> <ui-checkbox v-model="selectedBatchUsage.language" />
<ui-text-input-with-label ref="languageInput" v-model="batchDetails.language" :disabled="!selectedBatchUsage.language" :label="$strings.LabelLanguage" class="mb-4 ml-4" /> <ui-text-input-with-label ref="languageInput" v-model="batchDetails.language" :disabled="!selectedBatchUsage.language" :label="$strings.LabelLanguage" class="mb-4 ml-4" />
</div> </div>
<div class="flex items-center px-4 w-1/2"> <div v-if="!isMapAppend" class="flex items-center px-4 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.explicit" /> <ui-checkbox v-model="selectedBatchUsage.explicit" />
<div class="ml-4"> <div class="ml-4">
<ui-checkbox <ui-checkbox
@@ -96,11 +107,14 @@ export default {
} }
const libraryItemIds = store.state.globals.selectedMediaItems.map((i) => i.id) const libraryItemIds = store.state.globals.selectedMediaItems.map((i) => i.id)
const libraryItems = await app.$axios.$post(`/api/items/batch/get`, { libraryItemIds }).catch((error) => { const libraryItems = await app.$axios
const errorMsg = error.response.data || 'Failed to get items' .$post(`/api/items/batch/get`, { libraryItemIds })
console.error(errorMsg, error) .then((res) => res.libraryItems)
return [] .catch((error) => {
}) const errorMsg = error.response.data || 'Failed to get items'
console.error(errorMsg, error)
return []
})
return { return {
mediaType: libraryItems[0].mediaType, mediaType: libraryItems[0].mediaType,
libraryItems libraryItems
@@ -111,10 +125,10 @@ export default {
isProcessing: false, isProcessing: false,
libraryItemCopies: [], libraryItemCopies: [],
isScrollable: false, isScrollable: false,
newSeriesNames: [],
newTagItems: [], newTagItems: [],
newGenreItems: [], newGenreItems: [],
newNarratorItems: [], newNarratorItems: [],
mapDetailsType: 'overwrite',
batchDetails: { batchDetails: {
subtitle: null, subtitle: null,
authors: null, authors: null,
@@ -139,10 +153,17 @@ export default {
language: false, language: false,
explicit: false explicit: false
}, },
appendableKeys: ['authors', 'genres', 'tags', 'narrators', 'series'],
openMapOptions: false openMapOptions: false
} }
}, },
computed: { computed: {
isMapOverwrite() {
return this.mapDetailsType === 'overwrite'
},
isMapAppend() {
return this.mapDetailsType === 'append'
},
isPodcastLibrary() { isPodcastLibrary() {
return this.mediaType === 'podcast' return this.mediaType === 'podcast'
}, },
@@ -155,9 +176,6 @@ export default {
tagItems() { tagItems() {
return this.tags.concat(this.newTagItems) return this.tags.concat(this.newTagItems)
}, },
seriesItems() {
return [...this.existingSeriesNames, ...this.newSeriesNames]
},
narratorItems() { narratorItems() {
return [...this.narrators, ...this.newNarratorItems] return [...this.narrators, ...this.newNarratorItems]
}, },
@@ -216,31 +234,32 @@ export default {
mapBatchDetails() { mapBatchDetails() {
this.blurBatchForm() this.blurBatchForm()
var batchMapPayload = {} const batchMapPayload = {}
for (const key in this.selectedBatchUsage) { for (const key in this.selectedBatchUsage) {
if (this.selectedBatchUsage[key]) { if (!this.selectedBatchUsage[key]) continue
if (key === 'series') { if (this.isMapAppend && !this.appendableKeys.includes(key)) continue
// Map string of series to series objects
batchMapPayload[key] = this.batchDetails[key].map((seItem) => { if (key === 'series') {
var existingSeries = this.series.find((se) => se.name.toLowerCase() === seItem.toLowerCase().trim()) // Map string of series to series objects
if (existingSeries) { batchMapPayload[key] = this.batchDetails[key].map((seItem) => {
return existingSeries const existingSeries = this.series.find((se) => se.name.toLowerCase() === seItem.toLowerCase().trim())
} else { if (existingSeries) {
return { return existingSeries
id: `new-${Math.floor(Math.random() * 10000)}`, } else {
name: seItem return {
} id: `new-${Math.floor(Math.random() * 10000)}`,
name: seItem
} }
}) }
} else { })
batchMapPayload[key] = this.batchDetails[key] } else {
} batchMapPayload[key] = this.batchDetails[key]
} }
} }
this.libraryItemCopies.forEach((li) => { this.libraryItemCopies.forEach((li) => {
var ref = this.getEditFormRef(li.id) const ref = this.getEditFormRef(li.id)
ref.mapBatchDetails(batchMapPayload) ref.mapBatchDetails(batchMapPayload, this.mapDetailsType)
}) })
this.$toast.success('Details mapped') this.$toast.success('Details mapped')
}, },
+48 -6
View File
@@ -19,9 +19,11 @@
{{ streaming ? $strings.ButtonPlaying : $strings.ButtonPlay }} {{ streaming ? $strings.ButtonPlaying : $strings.ButtonPlay }}
</ui-btn> </ui-btn>
<ui-icon-btn v-if="userCanUpdate" icon="edit" class="mx-0.5" @click="editClick" /> <button type="button" class="h-9 w-9 flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5 mx-px" @click.stop.prevent="editClick">
<span class="material-icons text-xl">edit</span>
</button>
<ui-icon-btn v-if="userCanDelete" icon="delete" class="mx-0.5" @click="removeClick" /> <ui-context-menu-dropdown :items="contextMenuItems" class="mx-px" @action="contextMenuAction" />
</div> </div>
<div class="my-8 max-w-2xl"> <div class="my-8 max-w-2xl">
@@ -32,7 +34,7 @@
</div> </div>
</div> </div>
</div> </div>
<div v-show="processingRemove" class="absolute top-0 left-0 w-full h-full z-10 bg-black bg-opacity-40 flex items-center justify-center"> <div v-show="processing" class="absolute top-0 left-0 w-full h-full z-10 bg-black bg-opacity-40 flex items-center justify-center">
<ui-loading-indicator /> <ui-loading-indicator />
</div> </div>
</div> </div>
@@ -64,7 +66,7 @@ export default {
}, },
data() { data() {
return { return {
processingRemove: false processing: false
} }
}, },
computed: { computed: {
@@ -102,15 +104,55 @@ export default {
}, },
userCanDelete() { userCanDelete() {
return this.$store.getters['user/getUserCanDelete'] return this.$store.getters['user/getUserCanDelete']
},
contextMenuItems() {
const items = [
{
text: this.$strings.MessagePlaylistCreateFromCollection,
action: 'create-playlist'
}
]
if (this.userCanDelete) {
items.push({
text: this.$strings.ButtonDelete,
action: 'delete'
})
}
return items
} }
}, },
methods: { methods: {
contextMenuAction(action) {
if (action === 'delete') {
this.removeClick()
} else if (action === 'create-playlist') {
this.createPlaylistFromCollection()
}
},
createPlaylistFromCollection() {
this.processing = true
this.$axios
.$post(`/api/playlists/collection/${this.collectionId}`)
.then((playlist) => {
if (playlist) {
this.$toast.success(this.$strings.ToastPlaylistCreateSuccess)
this.$router.push(`/playlist/${playlist.id}`)
}
})
.catch((error) => {
const errMsg = error.response ? error.response.data || '' : ''
this.$toast.error(errMsg || this.$strings.ToastPlaylistCreateFailed)
})
.finally(() => {
this.processing = false
})
},
editClick() { editClick() {
this.$store.commit('globals/setEditCollection', this.collection) this.$store.commit('globals/setEditCollection', this.collection)
}, },
removeClick() { removeClick() {
if (confirm(this.$getString('MessageConfirmRemoveCollection', [this.collectionName]))) { if (confirm(this.$getString('MessageConfirmRemoveCollection', [this.collectionName]))) {
this.processingRemove = true this.processing = true
this.$axios this.$axios
.$delete(`/api/collections/${this.collection.id}`) .$delete(`/api/collections/${this.collection.id}`)
.then(() => { .then(() => {
@@ -121,7 +163,7 @@ export default {
this.$toast.error(this.$strings.ToastCollectionRemoveFailed) this.$toast.error(this.$strings.ToastCollectionRemoveFailed)
}) })
.finally(() => { .finally(() => {
this.processingRemove = false this.processing = false
}) })
} }
}, },
+1
View File
@@ -54,6 +54,7 @@ export default {
else if (pageName === 'stats') return this.$strings.HeaderYourStats else if (pageName === 'stats') return this.$strings.HeaderYourStats
else if (pageName === 'library-stats') return this.$strings.HeaderLibraryStats else if (pageName === 'library-stats') return this.$strings.HeaderLibraryStats
else if (pageName === 'users') return this.$strings.HeaderUsers else if (pageName === 'users') return this.$strings.HeaderUsers
else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils
} }
return this.$strings.HeaderSettings return this.$strings.HeaderSettings
} }
@@ -0,0 +1,169 @@
<template>
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8 relative" style="min-height: 200px">
<div class="flex items-center mb-4">
<nuxt-link to="/config/item-metadata-utils" class="w-8 h-8 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center">
<span class="material-icons text-2xl">arrow_back</span>
</nuxt-link>
<h1 class="text-xl mx-2">{{ $strings.HeaderManageGenres }}</h1>
</div>
<p v-if="!genres.length && !loading" class="text-center py-8 text-lg">{{ $strings.MessageNoGenres }}</p>
<div class="border border-white/10">
<template v-for="(genre, index) in genres">
<div :key="genre" class="w-full p-2 flex items-center text-gray-400 hover:text-white" :class="{ 'bg-primary/20': index % 2 === 0 }">
<p v-if="editingGenre !== genre" class="text-sm md:text-base text-gray-100">{{ genre }}</p>
<ui-text-input v-else v-model="newGenreName" />
<div class="flex-grow" />
<template v-if="editingGenre !== genre">
<ui-icon-btn v-if="editingGenre !== genre" icon="edit" borderless :size="8" icon-font-size="1.1rem" class="mx-1" @click="editClick(genre)" />
<ui-icon-btn v-if="editingGenre !== genre" icon="delete" borderless :size="8" icon-font-size="1.1rem" @click="removeClick(genre)" />
</template>
<template v-else>
<ui-btn color="success" small class="mx-2" @click.stop="saveClick">{{ $strings.ButtonSave }}</ui-btn>
<ui-btn small @click.stop="cancelEditClick">{{ $strings.ButtonCancel }}</ui-btn>
</template>
</div>
</template>
</div>
<div v-if="loading" class="absolute top-0 left-0 w-full h-full bg-black/25 flex items-center justify-center">
<ui-loading-indicator />
</div>
</div>
</template>
<script>
export default {
data() {
return {
loading: false,
genres: [],
editingGenre: null,
newGenreName: ''
}
},
watch: {},
computed: {},
methods: {
cancelEditClick() {
this.newGenreName = ''
this.editingGenre = null
},
removeClick(genre) {
const payload = {
message: `Are you sure you want to remove genre "${genre}" from all items?`,
callback: (confirmed) => {
if (confirmed) {
this.removeGenre(genre)
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
editClick(genre) {
this.newGenreName = genre
this.editingGenre = genre
},
saveClick() {
this.newGenreName = this.newGenreName.trim()
if (!this.newGenreName) {
return
}
if (this.editingGenre === this.newGenreName) {
this.cancelEditClick()
return
}
const genreNameExists = this.genres.find((g) => g !== this.editingGenre && g === this.newGenreName)
const genreNameExistsOfDifferentCase = !genreNameExists ? this.genres.find((g) => g !== this.editingGenre && g.toLowerCase() === this.newGenreName.toLowerCase()) : null
let message = this.$getString('MessageConfirmRenameGenre', [this.editingGenre, this.newGenreName])
if (genreNameExists) {
message += `<br><span class="text-sm">${this.$strings.MessageConfirmRenameGenreMergeNote}</span>`
} else if (genreNameExistsOfDifferentCase) {
message += `<br><span class="text-warning text-sm">${this.$getString('MessageConfirmRenameGenreWarning', [genreNameExistsOfDifferentCase])}</span>`
}
const payload = {
message,
callback: (confirmed) => {
if (confirmed) {
this.renameGenre()
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
renameGenre() {
this.loading = true
let _newGenreName = this.newGenreName
let _editingGenre = this.editingGenre
const payload = {
genre: _editingGenre,
newGenre: _newGenreName
}
this.$axios
.$post('/api/genres/rename', payload)
.then((res) => {
this.$toast.success(this.$getString('MessageItemsUpdated', [res.numItemsUpdated]))
if (res.genreMerged) {
this.genres = this.genres.filter((g) => g !== _newGenreName)
}
this.genres = this.genres.map((g) => {
if (g === _editingGenre) return _newGenreName
return g
})
this.cancelEditClick()
})
.catch((error) => {
console.error('Failed to rename genre', error)
this.$toast.error('Failed to rename genre')
})
.finally(() => {
this.loading = false
})
},
removeGenre(genre) {
this.loading = true
this.$axios
.$delete(`/api/genres/${this.$encode(genre)}`)
.then((res) => {
this.$toast.success(this.$getString('MessageItemsUpdated', [res.numItemsUpdated]))
this.genres = this.genres.filter((g) => g !== genre)
})
.catch((error) => {
console.error('Failed to remove genre', error)
this.$toast.error('Failed to remove genre')
})
.finally(() => {
this.loading = false
})
},
init() {
this.loading = true
this.$axios
.$get('/api/genres')
.then((data) => {
this.genres = (data.genres || []).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
})
.catch((error) => {
console.error('Failed to load genres', error)
})
.finally(() => {
this.loading = false
})
}
},
mounted() {
this.init()
},
beforeDestroy() {}
}
</script>
@@ -0,0 +1,35 @@
<template>
<div>
<app-settings-content :header-text="'Item Metadata Utils'">
<nuxt-link to="/config/item-metadata-utils/tags" class="block w-full rounded bg-primary/40 hover:bg-primary/60 text-gray-300 hover:text-white p-4 mt-6 mb-2">
<div class="flex justify-between">
<p>{{ $strings.HeaderManageTags }}</p>
<span class="material-icons">arrow_forward</span>
</div>
</nuxt-link>
<nuxt-link to="/config/item-metadata-utils/genres" class="block w-full rounded bg-primary/40 hover:bg-primary/60 text-gray-300 hover:text-white p-4 my-2">
<div class="flex justify-between">
<p>{{ $strings.HeaderManageGenres }}</p>
<span class="material-icons">arrow_forward</span>
</div>
</nuxt-link>
</app-settings-content>
</div>
</template>
<script>
export default {
data() {
return {}
},
watch: {},
computed: {},
methods: {
init() {}
},
mounted() {
this.init()
},
beforeDestroy() {}
}
</script>
@@ -0,0 +1,169 @@
<template>
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8 relative" style="min-height: 200px">
<div class="flex items-center mb-4">
<nuxt-link to="/config/item-metadata-utils" class="w-8 h-8 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center">
<span class="material-icons text-2xl">arrow_back</span>
</nuxt-link>
<h1 class="text-xl mx-2">{{ $strings.HeaderManageTags }}</h1>
</div>
<p v-if="!tags.length && !loading" class="text-center py-8 text-lg">{{ $strings.MessageNoTags }}</p>
<div class="border border-white/10">
<template v-for="(tag, index) in tags">
<div :key="tag" class="w-full p-2 flex items-center text-gray-400 hover:text-white" :class="{ 'bg-primary/20': index % 2 === 0 }">
<p v-if="editingTag !== tag" class="text-sm md:text-base text-gray-100">{{ tag }}</p>
<ui-text-input v-else v-model="newTagName" />
<div class="flex-grow" />
<template v-if="editingTag !== tag">
<ui-icon-btn v-if="editingTag !== tag" icon="edit" borderless :size="8" icon-font-size="1.1rem" class="mx-1" @click="editTagClick(tag)" />
<ui-icon-btn v-if="editingTag !== tag" icon="delete" borderless :size="8" icon-font-size="1.1rem" @click="removeTagClick(tag)" />
</template>
<template v-else>
<ui-btn color="success" small class="mx-2" @click.stop="saveTagClick">{{ $strings.ButtonSave }}</ui-btn>
<ui-btn small @click.stop="cancelEditClick">{{ $strings.ButtonCancel }}</ui-btn>
</template>
</div>
</template>
</div>
<div v-if="loading" class="absolute top-0 left-0 w-full h-full bg-black/25 flex items-center justify-center">
<ui-loading-indicator />
</div>
</div>
</template>
<script>
export default {
data() {
return {
loading: false,
tags: [],
editingTag: null,
newTagName: ''
}
},
watch: {},
computed: {},
methods: {
cancelEditClick() {
this.newTagName = ''
this.editingTag = null
},
removeTagClick(tag) {
const payload = {
message: `Are you sure you want to remove tag "${tag}" from all items?`,
callback: (confirmed) => {
if (confirmed) {
this.removeTag(tag)
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
saveTagClick() {
this.newTagName = this.newTagName.trim()
if (!this.newTagName) {
return
}
if (this.editingTag === this.newTagName) {
this.cancelEditClick()
return
}
const tagNameExists = this.tags.find((t) => t !== this.editingTag && t === this.newTagName)
const tagNameExistsOfDifferentCase = !tagNameExists ? this.tags.find((t) => t !== this.editingTag && t.toLowerCase() === this.newTagName.toLowerCase()) : null
let message = this.$getString('MessageConfirmRenameTag', [this.editingTag, this.newTagName])
if (tagNameExists) {
message += `<br><span class="text-sm">${this.$strings.MessageConfirmRenameTagMergeNote}</span>`
} else if (tagNameExistsOfDifferentCase) {
message += `<br><span class="text-warning text-sm">${this.$getString('MessageConfirmRenameTagWarning', [tagNameExistsOfDifferentCase])}</span>`
}
const payload = {
message,
callback: (confirmed) => {
if (confirmed) {
this.renameTag()
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
renameTag() {
this.loading = true
let _newTagName = this.newTagName
let _editingTag = this.editingTag
const payload = {
tag: _editingTag,
newTag: _newTagName
}
this.$axios
.$post('/api/tags/rename', payload)
.then((res) => {
this.$toast.success(this.$getString('MessageItemsUpdated', [res.numItemsUpdated]))
if (res.tagMerged) {
this.tags = this.tags.filter((t) => t !== _newTagName)
}
this.tags = this.tags.map((t) => {
if (t === _editingTag) return _newTagName
return t
})
this.cancelEditClick()
})
.catch((error) => {
console.error('Failed to rename tag', error)
this.$toast.error('Failed to rename tag')
})
.finally(() => {
this.loading = false
})
},
removeTag(tag) {
this.loading = true
this.$axios
.$delete(`/api/tags/${this.$encode(tag)}`)
.then((res) => {
this.$toast.success(this.$getString('MessageItemsUpdated', [res.numItemsUpdated]))
this.tags = this.tags.filter((t) => t !== tag)
})
.catch((error) => {
console.error('Failed to remove tag', error)
this.$toast.error('Failed to remove tag')
})
.finally(() => {
this.loading = false
})
},
editTagClick(tag) {
this.newTagName = tag
this.editingTag = tag
},
init() {
this.loading = true
this.$axios
.$get('/api/tags')
.then((data) => {
this.tags = (data.tags || []).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
})
.catch((error) => {
console.error('Failed to load tags', error)
})
.finally(() => {
this.loading = false
})
}
},
mounted() {
this.init()
},
beforeDestroy() {}
}
</script>
+3 -3
View File
@@ -61,10 +61,10 @@
<script> <script>
export default { export default {
async asyncData({ params, redirect, app }) { async asyncData({ params, redirect, app }) {
var users = await app.$axios const users = await app.$axios
.$get('/api/users') .$get('/api/users')
.then((users) => { .then((res) => {
return users.sort((a, b) => { return res.users.sort((a, b) => {
return a.createdAt - b.createdAt return a.createdAt - b.createdAt
}) })
}) })
@@ -48,10 +48,13 @@ export default {
}, },
methods: { methods: {
async init() { async init() {
this.authors = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/authors`).catch((error) => { this.authors = await this.$axios
console.error('Failed to load authors', error) .$get(`/api/libraries/${this.currentLibraryId}/authors`)
return [] .then((response) => response.authors)
}) .catch((error) => {
console.error('Failed to load authors', error)
return []
})
this.loading = false this.loading = false
}, },
authorAdded(author) { authorAdded(author) {
@@ -15,17 +15,14 @@ export default {
} }
// Set series sort by // Set series sort by
if (params.id === 'series') { if (query.filter || query.sort || query.desc) {
if (query.sort) { const isSeries = params.id === 'series'
store.commit('libraries/setSeriesSortBy', query.sort) const settingsUpdate = {
store.commit('libraries/setSeriesSortDesc', !!query.desc) [isSeries ? 'seriesFilterBy' : 'filterBy']: query.filter || undefined,
[isSeries ? 'seriesSortBy' : 'orderBy']: query.sort || undefined,
[isSeries ? 'seriesSortDesc' : 'orderDesc']: query.desc == '0' ? false : query.desc == '1' ? true : undefined
} }
if (query.filter) { store.dispatch('user/updateUserSettings', settingsUpdate)
console.log('has filter', query.filter)
store.commit('libraries/setSeriesFilterBy', query.filter)
}
} else if (query.filter) {
store.dispatch('user/updateUserSettings', { filterBy: query.filter })
} }
// Redirect podcast libraries // Redirect podcast libraries
+2
View File
@@ -137,6 +137,8 @@ export default {
this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId) this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId)
this.$store.commit('user/setUser', user) this.$store.commit('user/setUser', user)
this.$store.dispatch('user/loadUserSettings')
}, },
async submitForm() { async submitForm() {
this.error = null this.error = null
+34 -20
View File
@@ -5,8 +5,6 @@ import { formatDistance, format, addDays, isDate } from 'date-fns'
Vue.directive('click-outside', vClickOutside.directive) Vue.directive('click-outside', vClickOutside.directive)
Vue.prototype.$eventBus = new Vue()
Vue.prototype.$dateDistanceFromNow = (unixms) => { Vue.prototype.$dateDistanceFromNow = (unixms) => {
if (!unixms) return '' if (!unixms) return ''
return formatDistance(unixms, Date.now(), { addSuffix: true }) return formatDistance(unixms, Date.now(), { addSuffix: true })
@@ -30,23 +28,26 @@ Vue.prototype.$addDaysToDate = (jsdate, daysToAdd) => {
return date return date
} }
Vue.prototype.$sanitizeFilename = (input, colonReplacement = ' - ') => { Vue.prototype.$sanitizeFilename = (filename, colonReplacement = ' - ') => {
if (typeof input !== 'string') { if (typeof filename !== 'string') {
return false return false
} }
// Max is actually 255-260 for windows but this leaves padding incase ext wasnt put on yet // Most file systems use number of bytes for max filename
const MAX_FILENAME_LEN = 240 // to support most filesystems we will use max of 255 bytes in utf-16
// Ref: https://doc.owncloud.com/server/next/admin_manual/troubleshooting/path_filename_length.html
// Issue: https://github.com/advplyr/audiobookshelf/issues/1261
const MAX_FILENAME_BYTES = 255
var replacement = '' const replacement = ''
var illegalRe = /[\/\?<>\\:\*\|"]/g const illegalRe = /[\/\?<>\\:\*\|"]/g
var controlRe = /[\x00-\x1f\x80-\x9f]/g const controlRe = /[\x00-\x1f\x80-\x9f]/g
var reservedRe = /^\.+$/ const reservedRe = /^\.+$/
var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i const windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i
var windowsTrailingRe = /[\. ]+$/ const windowsTrailingRe = /[\. ]+$/
var lineBreaks = /[\n\r]/g const lineBreaks = /[\n\r]/g
var sanitized = input sanitized = filename
.replace(':', colonReplacement) // Replace first occurrence of a colon .replace(':', colonReplacement) // Replace first occurrence of a colon
.replace(illegalRe, replacement) .replace(illegalRe, replacement)
.replace(controlRe, replacement) .replace(controlRe, replacement)
@@ -55,13 +56,25 @@ Vue.prototype.$sanitizeFilename = (input, colonReplacement = ' - ') => {
.replace(windowsReservedRe, replacement) .replace(windowsReservedRe, replacement)
.replace(windowsTrailingRe, replacement) .replace(windowsTrailingRe, replacement)
// Check if basename is too many bytes
const ext = Path.extname(sanitized) // separate out file extension
const basename = Path.basename(sanitized, ext)
const extByteLength = Buffer.byteLength(ext, 'utf16le')
const basenameByteLength = Buffer.byteLength(basename, 'utf16le')
if (basenameByteLength + extByteLength > MAX_FILENAME_BYTES) {
const MaxBytesForBasename = MAX_FILENAME_BYTES - extByteLength
let totalBytes = 0
let trimmedBasename = ''
if (sanitized.length > MAX_FILENAME_LEN) { // Add chars until max bytes is reached
var lenToRemove = sanitized.length - MAX_FILENAME_LEN for (const char of basename) {
var ext = Path.extname(sanitized) totalBytes += Buffer.byteLength(char, 'utf16le')
var basename = Path.basename(sanitized, ext) if (totalBytes > MaxBytesForBasename) break
basename = basename.slice(0, basename.length - lenToRemove) else trimmedBasename += char
sanitized = basename + ext }
trimmedBasename = trimmedBasename.trim()
sanitized = trimmedBasename + ext
} }
return sanitized return sanitized
@@ -144,6 +157,7 @@ export {
export default ({ app, store }, inject) => { export default ({ app, store }, inject) => {
app.$decode = decode app.$decode = decode
app.$encode = encode app.$encode = encode
inject('eventBus', new Vue())
inject('isDev', process.env.NODE_ENV !== 'production') inject('isDev', process.env.NODE_ENV !== 'production')
store.commit('setRouterBasePath', app.$config.routerBasePath) store.commit('setRouterBasePath', app.$config.routerBasePath)
+5 -9
View File
@@ -140,26 +140,22 @@ export const mutations = {
state.showBatchQuickMatchModal = val state.showBatchQuickMatchModal = val
}, },
resetSelectedMediaItems(state) { resetSelectedMediaItems(state) {
// Vue.set(state, 'selectedMediaItems', [])
state.selectedMediaItems = [] state.selectedMediaItems = []
}, },
toggleMediaItemSelected(state, item) { toggleMediaItemSelected(state, item) {
if (state.selectedMediaItems.some(i => i.id === item.id)) { if (state.selectedMediaItems.some(i => i.id === item.id)) {
state.selectedMediaItems = state.selectedMediaItems.filter(i => i.id !== item.id) state.selectedMediaItems = state.selectedMediaItems.filter(i => i.id !== item.id)
} else { } else {
// const newSel = state.selectedMediaItems.concat([{...item}])
// Vue.set(state, 'selectedMediaItems', newSel)
state.selectedMediaItems.push(item) state.selectedMediaItems.push(item)
} }
}, },
setMediaItemSelected(state, { item, selected }) { setMediaItemSelected(state, { item, selected }) {
const index = state.selectedMediaItems.findIndex(i => i.id === item.id) const isAlreadySelected = state.selectedMediaItems.some(i => i.id === item.id)
if (index && !selected) { if (isAlreadySelected && !selected) {
state.selectedMediaItems = state.selectedMediaItems.filter(i => i.id !== item.id) state.selectedMediaItems = state.selectedMediaItems.filter(i => i.id !== item.id)
} else if (selected && !index) {
state.selectedMediaItems.splice(index, 1, item) } else if (selected && !isAlreadySelected) {
// var newSel = state.selectedMediaItems.concat([libraryItemId]) state.selectedMediaItems.push(item)
// Vue.set(state, 'selectedMediaItems', newSel)
} }
} }
} }
+3 -15
View File
@@ -10,9 +10,6 @@ export const state = () => ({
folderLastUpdate: 0, folderLastUpdate: 0,
filterData: null, filterData: null,
numUserPlaylists: 0, numUserPlaylists: 0,
seriesSortBy: 'name',
seriesSortDesc: false,
seriesFilterBy: 'all',
collections: [], collections: [],
userPlaylists: [] userPlaylists: []
}) })
@@ -86,8 +83,8 @@ export const actions = {
.$get('/api/filesystem') .$get('/api/filesystem')
.then((res) => { .then((res) => {
console.log('Settings folders', res) console.log('Settings folders', res)
commit('setFolders', res) commit('setFolders', res.directories)
return res return res.directories
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to load dirs', error) console.error('Failed to load dirs', error)
@@ -151,7 +148,7 @@ export const actions = {
this.$axios this.$axios
.$get(`/api/libraries`) .$get(`/api/libraries`)
.then((data) => { .then((data) => {
commit('set', data) commit('set', data.libraries)
commit('setLastLoad') commit('setLastLoad')
}) })
.catch((error) => { .catch((error) => {
@@ -312,15 +309,6 @@ export const mutations = {
} }
} }
}, },
setSeriesSortBy(state, sortBy) {
state.seriesSortBy = sortBy
},
setSeriesSortDesc(state, sortDesc) {
state.seriesSortDesc = sortDesc
},
setSeriesFilterBy(state, filterBy) {
state.seriesFilterBy = filterBy
},
setCollections(state, collections) { setCollections(state, collections) {
state.collections = collections state.collections = collections
}, },
+44 -43
View File
@@ -7,9 +7,12 @@ export const state = () => ({
playbackRate: 1, playbackRate: 1,
bookshelfCoverSize: 120, bookshelfCoverSize: 120,
collapseSeries: false, collapseSeries: false,
collapseBookSeries: false collapseBookSeries: false,
}, useChapterTrack: false,
settingsListeners: [] seriesSortBy: 'name',
seriesSortDesc: false,
seriesFilterBy: 'all'
}
}) })
export const getters = { export const getters = {
@@ -66,7 +69,7 @@ export const getters = {
export const actions = { export const actions = {
// When changing libraries make sure sort and filter is still valid // When changing libraries make sure sort and filter is still valid
checkUpdateLibrarySortFilter({ state, dispatch, commit }, mediaType) { checkUpdateLibrarySortFilter({ state, dispatch, commit }, mediaType) {
var settingsUpdate = {} const settingsUpdate = {}
if (mediaType == 'podcast') { if (mediaType == 'podcast') {
if (state.settings.orderBy == 'media.metadata.authorName' || state.settings.orderBy == 'media.metadata.authorNameLF') { if (state.settings.orderBy == 'media.metadata.authorName' || state.settings.orderBy == 'media.metadata.authorNameLF') {
settingsUpdate.orderBy = 'media.metadata.author' settingsUpdate.orderBy = 'media.metadata.author'
@@ -77,8 +80,8 @@ export const actions = {
if (state.settings.orderBy == 'media.metadata.publishedYear') { if (state.settings.orderBy == 'media.metadata.publishedYear') {
settingsUpdate.orderBy = 'media.metadata.title' settingsUpdate.orderBy = 'media.metadata.title'
} }
var invalidFilters = ['series', 'authors', 'narrators', 'languages', 'progress', 'issues'] const invalidFilters = ['series', 'authors', 'narrators', 'languages', 'progress', 'issues']
var filterByFirstPart = (state.settings.filterBy || '').split('.').shift() const filterByFirstPart = (state.settings.filterBy || '').split('.').shift()
if (invalidFilters.includes(filterByFirstPart)) { if (invalidFilters.includes(filterByFirstPart)) {
settingsUpdate.filterBy = 'all' settingsUpdate.filterBy = 'all'
} }
@@ -94,30 +97,46 @@ export const actions = {
dispatch('updateUserSettings', settingsUpdate) dispatch('updateUserSettings', settingsUpdate)
} }
}, },
updateUserSettings({ commit }, payload) { updateUserSettings({ state, commit }, payload) {
var updatePayload = { if (!payload) return false
...payload
} let hasChanges = false
// Immediately update const existingSettings = { ...state.settings }
commit('setSettings', updatePayload) for (const key in existingSettings) {
return this.$axios.$patch('/api/me/settings', updatePayload).then((result) => { if (payload[key] !== undefined && existingSettings[key] !== payload[key]) {
if (result.success) { hasChanges = true
commit('setSettings', result.settings) existingSettings[key] = payload[key]
return true
} else {
return false
} }
}).catch((error) => { }
console.error('Failed to update settings', error) if (hasChanges) {
return false commit('setSettings', existingSettings)
}) this.$eventBus.$emit('user-settings', state.settings)
}
},
loadUserSettings({ state, commit }) {
// Load settings from local storage
try {
let userSettingsFromLocal = localStorage.getItem('userSettings')
if (userSettingsFromLocal) {
userSettingsFromLocal = JSON.parse(userSettingsFromLocal)
const userSettings = { ...state.settings }
for (const key in userSettings) {
if (userSettingsFromLocal[key] !== undefined) {
userSettings[key] = userSettingsFromLocal[key]
}
}
commit('setSettings', userSettings)
this.$eventBus.$emit('user-settings', state.settings)
}
} catch (error) {
console.error('Failed to load userSettings from local storage', error)
}
} }
} }
export const mutations = { export const mutations = {
setUser(state, user) { setUser(state, user) {
state.user = user state.user = user
state.settings = user.settings
if (user) { if (user) {
if (user.token) localStorage.setItem('token', user.token) if (user.token) localStorage.setItem('token', user.token)
} else { } else {
@@ -143,25 +162,7 @@ export const mutations = {
}, },
setSettings(state, settings) { setSettings(state, settings) {
if (!settings) return if (!settings) return
var hasChanges = false localStorage.setItem('userSettings', JSON.stringify(settings))
for (const key in settings) { state.settings = settings
if (state.settings[key] !== settings[key]) {
hasChanges = true
state.settings[key] = settings[key]
}
}
if (hasChanges) {
state.settingsListeners.forEach((listener) => {
listener.meth(state.settings)
})
}
},
addSettingsListener(state, listener) {
var index = state.settingsListeners.findIndex(l => l.id === listener.id)
if (index >= 0) state.settingsListeners.splice(index, 1, listener)
else state.settingsListeners.push(listener)
},
removeSettingsListener(state, listenerId) {
state.settingsListeners = state.settingsListeners.filter(l => l.id !== listenerId)
} }
} }
+54 -37
View File
@@ -22,7 +22,7 @@
"ButtonDelete": "Löschen", "ButtonDelete": "Löschen",
"ButtonEditChapters": "Kapitel bearbeiten", "ButtonEditChapters": "Kapitel bearbeiten",
"ButtonEditPodcast": "Podcast bearbeiten", "ButtonEditPodcast": "Podcast bearbeiten",
"ButtonForceReScan": "Erzwinge einen Neu-Scan", "ButtonForceReScan": "Erzwinge kompletten Neu-Scan",
"ButtonFullPath": "Vollständiger Pfad", "ButtonFullPath": "Vollständiger Pfad",
"ButtonHide": "Ausblenden", "ButtonHide": "Ausblenden",
"ButtonHome": "Startseite", "ButtonHome": "Startseite",
@@ -30,11 +30,11 @@
"ButtonLatest": "Neuste", "ButtonLatest": "Neuste",
"ButtonLibrary": "Bibliothek", "ButtonLibrary": "Bibliothek",
"ButtonLogout": "Abmelden", "ButtonLogout": "Abmelden",
"ButtonLookup": "Nachschlagen", "ButtonLookup": "Online-Suche",
"ButtonManageTracks": "Tracks verwalten", "ButtonManageTracks": "Tracks verwalten",
"ButtonMapChapterTitles": "Kapitelüberschriften zuordnen", "ButtonMapChapterTitles": "Kapitelüberschriften zuordnen",
"ButtonMatchAllAuthors": "Online-Abgleich aller Autoren", "ButtonMatchAllAuthors": "Online-Suche für alle Autoren",
"ButtonMatchBooks": "Online-Abgleich aller Hörbücher", "ButtonMatchBooks": "Online-Suche für alle Hörbücher",
"ButtonNevermind": "Vergiss es", "ButtonNevermind": "Vergiss es",
"ButtonOk": "Ok", "ButtonOk": "Ok",
"ButtonOpenFeed": "Feed öffnen", "ButtonOpenFeed": "Feed öffnen",
@@ -60,13 +60,13 @@
"ButtonSave": "Speichern", "ButtonSave": "Speichern",
"ButtonSaveAndClose": "Speichern & Schließen", "ButtonSaveAndClose": "Speichern & Schließen",
"ButtonSaveTracklist": "Speichere die Titelliste", "ButtonSaveTracklist": "Speichere die Titelliste",
"ButtonScan": "Durchsuchen", "ButtonScan": "Scan",
"ButtonScanLibrary": "Bibliothek durchsuchen", "ButtonScanLibrary": "Bibliothek scannen",
"ButtonSearch": "Suchen", "ButtonSearch": "Suchen",
"ButtonSelectFolderPath": "Auswahl Ordnerpfad", "ButtonSelectFolderPath": "Auswahl Ordnerpfad",
"ButtonSeries": "Serien", "ButtonSeries": "Serien",
"ButtonSetChaptersFromTracks": "Set chapters from tracks", "ButtonSetChaptersFromTracks": "Kapitelerstellung aus Audiodateien",
"ButtonShiftTimes": "Arbeitszeiten", "ButtonShiftTimes": "Zeitverschiebung",
"ButtonShow": "Anzeigen", "ButtonShow": "Anzeigen",
"ButtonStartM4BEncode": "M4B-Kodierung starten", "ButtonStartM4BEncode": "M4B-Kodierung starten",
"ButtonStartMetadataEmbed": "Metadateneinbettung starten", "ButtonStartMetadataEmbed": "Metadateneinbettung starten",
@@ -81,7 +81,7 @@
"HeaderAdvanced": "Erweitert", "HeaderAdvanced": "Erweitert",
"HeaderAppriseNotificationSettings": "Apprise Benachrichtigungseinstellungen", "HeaderAppriseNotificationSettings": "Apprise Benachrichtigungseinstellungen",
"HeaderAudiobookTools": "Hörbuch-Dateiverwaltungstools", "HeaderAudiobookTools": "Hörbuch-Dateiverwaltungstools",
"HeaderAudioTracks": "Audio-Tracks", "HeaderAudioTracks": "Audiodateien",
"HeaderBackups": "Sicherungen", "HeaderBackups": "Sicherungen",
"HeaderChangePassword": "Passwort ändern", "HeaderChangePassword": "Passwort ändern",
"HeaderChapters": "Kapitel", "HeaderChapters": "Kapitel",
@@ -95,6 +95,7 @@
"HeaderFindChapters": "Kapitel suchen", "HeaderFindChapters": "Kapitel suchen",
"HeaderIgnoredFiles": "Ignorierte Dateien", "HeaderIgnoredFiles": "Ignorierte Dateien",
"HeaderItemFiles": "Objekt-Dateien", "HeaderItemFiles": "Objekt-Dateien",
"HeaderItemMetadataUtils": "Item Metadata Utils",
"HeaderLastListeningSession": "Letzte Hörsitzung", "HeaderLastListeningSession": "Letzte Hörsitzung",
"HeaderLatestEpisodes": "Letzte Episoden", "HeaderLatestEpisodes": "Letzte Episoden",
"HeaderLibraries": "Bibliotheken", "HeaderLibraries": "Bibliotheken",
@@ -104,7 +105,10 @@
"HeaderListeningStats": "Hörstatistiken", "HeaderListeningStats": "Hörstatistiken",
"HeaderLogin": "Anmeldung", "HeaderLogin": "Anmeldung",
"HeaderLogs": "Protokolle", "HeaderLogs": "Protokolle",
"HeaderMatch": "Online-Abgleich", "HeaderManageGenres": "Manage Genres",
"HeaderManageTags": "Manage Tags",
"HeaderMapDetails": "Stapelverarbeitung",
"HeaderMatch": "Online-Suche",
"HeaderMetadataToEmbed": "Einzubettende Metadaten", "HeaderMetadataToEmbed": "Einzubettende Metadaten",
"HeaderNewAccount": "Neues Konto", "HeaderNewAccount": "Neues Konto",
"HeaderNewLibrary": "Neue Bibliothek", "HeaderNewLibrary": "Neue Bibliothek",
@@ -155,6 +159,7 @@
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist", "LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
"LabelAll": "Alle", "LabelAll": "Alle",
"LabelAllUsers": "Alle Benutzer", "LabelAllUsers": "Alle Benutzer",
"LabelAppend": "Anhängen",
"LabelAuthor": "Autor", "LabelAuthor": "Autor",
"LabelAuthorFirstLast": "Autor (Vorname Nachname)", "LabelAuthorFirstLast": "Autor (Vorname Nachname)",
"LabelAuthorLastFirst": "Autor (Nachname, Vorname)", "LabelAuthorLastFirst": "Autor (Nachname, Vorname)",
@@ -201,7 +206,7 @@
"LabelEpisode": "Episode", "LabelEpisode": "Episode",
"LabelEpisodeTitle": "Episodentitel", "LabelEpisodeTitle": "Episodentitel",
"LabelEpisodeType": "Episodentyp", "LabelEpisodeType": "Episodentyp",
"LabelExplicit": "Explizit <br />(Altersbeschränkung)", "LabelExplicit": "Explizit (Altersbeschränkung)",
"LabelFeedURL": "Feed URL", "LabelFeedURL": "Feed URL",
"LabelFile": "Datei", "LabelFile": "Datei",
"LabelFileBirthtime": "Datei Geburtsdatum", "LabelFileBirthtime": "Datei Geburtsdatum",
@@ -278,6 +283,7 @@
"LabelNumberOfBooks": "Anzahl der Hörbücher", "LabelNumberOfBooks": "Anzahl der Hörbücher",
"LabelNumberOfEpisodes": "Anzahl der Episoden", "LabelNumberOfEpisodes": "Anzahl der Episoden",
"LabelOpenRSSFeed": "Öffne RSS Feed", "LabelOpenRSSFeed": "Öffne RSS Feed",
"LabelOverwrite": "Überschreiben",
"LabelPassword": "Passwort", "LabelPassword": "Passwort",
"LabelPath": "Pfad", "LabelPath": "Pfad",
"LabelPermissionsAccessAllLibraries": "Zugriff auf alle Bibliotheken", "LabelPermissionsAccessAllLibraries": "Zugriff auf alle Bibliotheken",
@@ -292,7 +298,7 @@
"LabelPlayMethod": "Abspielmethode", "LabelPlayMethod": "Abspielmethode",
"LabelPodcast": "Podcast", "LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts", "LabelPodcasts": "Podcasts",
"LabelPrefixesToIgnore": "Zu ignorierende Vorwort/Artikel (Groß- und Kleinschreibung wird nicht berücksichtigt)", "LabelPrefixesToIgnore": "Zu ignorierende(s) Vorwort(e) (Groß- und Kleinschreibung wird nicht berücksichtigt)",
"LabelProgress": "Fortschritt", "LabelProgress": "Fortschritt",
"LabelProvider": "Anbieter", "LabelProvider": "Anbieter",
"LabelPubDate": "Veröffentlichungsdatum", "LabelPubDate": "Veröffentlichungsdatum",
@@ -315,7 +321,7 @@
"LabelSeriesName": "Serienname", "LabelSeriesName": "Serienname",
"LabelSeriesProgress": "Serienfortschritt", "LabelSeriesProgress": "Serienfortschritt",
"LabelSettingsBookshelfViewHelp": "Skeumorphes Design mit Holzeinlegeböden", "LabelSettingsBookshelfViewHelp": "Skeumorphes Design mit Holzeinlegeböden",
"LabelSettingsChromecastSupport": "Chromecast-unterstützung", "LabelSettingsChromecastSupport": "Chromecastunterstützung",
"LabelSettingsDateFormat": "Datumsformat", "LabelSettingsDateFormat": "Datumsformat",
"LabelSettingsDisableWatcher": "Überwachung deaktivieren", "LabelSettingsDisableWatcher": "Überwachung deaktivieren",
"LabelSettingsDisableWatcherForLibrary": "Ordnerüberwachung für die Bibliothek deaktivieren", "LabelSettingsDisableWatcherForLibrary": "Ordnerüberwachung für die Bibliothek deaktivieren",
@@ -336,8 +342,8 @@
"LabelSettingsPreferAudioMetadataHelp": "In den Audiodateien eingebettete ID3 Metadaten werden für die Metadaten eines Hörbuchs anstelle der Ordnernamen verwendet. Wenn keine ID3 Metadaten zur Verfügung stehen, werden die Ordnernamen verwendet.", "LabelSettingsPreferAudioMetadataHelp": "In den Audiodateien eingebettete ID3 Metadaten werden für die Metadaten eines Hörbuchs anstelle der Ordnernamen verwendet. Wenn keine ID3 Metadaten zur Verfügung stehen, werden die Ordnernamen verwendet.",
"LabelSettingsPreferMatchedMetadata": "Bevorzuge online abgestimmte Metadaten", "LabelSettingsPreferMatchedMetadata": "Bevorzuge online abgestimmte Metadaten",
"LabelSettingsPreferMatchedMetadataHelp": "Bei einem Schnellabgleich überschreiben neu abgestimmte online Metadaten alle schon vorhandenen Metadaten eines Hörbuchs. Standardmäßig werden bei einem Schnellabgleich nur fehlende Metadaten ersetzt.", "LabelSettingsPreferMatchedMetadataHelp": "Bei einem Schnellabgleich überschreiben neu abgestimmte online Metadaten alle schon vorhandenen Metadaten eines Hörbuchs. Standardmäßig werden bei einem Schnellabgleich nur fehlende Metadaten ersetzt.",
"LabelSettingsPreferOPFMetadata": "Bevorzuge OPF-Metadaten", "LabelSettingsPreferOPFMetadata": "Bevorzuge OPF-Metadaten aus dem Hörbuchordner",
"LabelSettingsPreferOPFMetadataHelp": "In OPF Dateien gespeicherte Metadaten werden anstelle der Ordnernamen für die Metadaten eines Hörbuchs verwendet. OPF Datein sind seperate \"Textdateien \" mit der Endung \".abs\" in denen verschiedene Matadaten gespiechert sind. Wenn keine OPF Dateien zur Verfügung stehen, werden die Ordnernamen verwendet.", "LabelSettingsPreferOPFMetadataHelp": "In OPF-Dateien gespeicherte Metadaten werden anstelle der Ordnernamen für die Bereitstellung der Metadaten eines Hörbuchs verwendet. OPF-Datein sind seperate \"Textdateien\" mit der Endung \".abs\" welche in dem gleichen Ordner liegen wie das Hörbuch selber. In dieser sind verschiedene Matadaten (z.B. Titel, Autor, Jahr, Erzähler, Handlung, ISBN, ...) gespeichert. Wenn keine OPF Datei zur Verfügung steht, wird standardmäßig der Ordnername verwendet.",
"LabelSettingsSkipMatchingBooksWithASIN": "Überspringe beim Online-Abgleich alle Bücher die bereits eine ASIN haben", "LabelSettingsSkipMatchingBooksWithASIN": "Überspringe beim Online-Abgleich alle Bücher die bereits eine ASIN haben",
"LabelSettingsSkipMatchingBooksWithISBN": "Überspringe beim Online-Abgleich alle Bücher die bereits eine ISBN haben", "LabelSettingsSkipMatchingBooksWithISBN": "Überspringe beim Online-Abgleich alle Bücher die bereits eine ISBN haben",
"LabelSettingsSortingIgnorePrefixes": "Vorwort/Artikel beim Sortieren ignorieren", "LabelSettingsSortingIgnorePrefixes": "Vorwort/Artikel beim Sortieren ignorieren",
@@ -345,9 +351,9 @@
"LabelSettingsSquareBookCovers": "Benutze quadratische Titelbilder", "LabelSettingsSquareBookCovers": "Benutze quadratische Titelbilder",
"LabelSettingsSquareBookCoversHelp": "Bevorzugen quadratische Titelbilder gegenüber den Standardtielbildern im Verhältnis 1,6:1", "LabelSettingsSquareBookCoversHelp": "Bevorzugen quadratische Titelbilder gegenüber den Standardtielbildern im Verhältnis 1,6:1",
"LabelSettingsStoreCoversWithItem": "Titelbilder im Hörbuchordner speichern", "LabelSettingsStoreCoversWithItem": "Titelbilder im Hörbuchordner speichern",
"LabelSettingsStoreCoversWithItemHelp": "Standardmäßig werden die Titelbilder in /metadata/items gespeichert. Wenn diese Option aktiviert wird, werden die Titelbilder in dem selben Ordner, in welchem auch das zugehörige Hörbuch gespeichert ist, gespeichert. Es wird nur eine Datei mit dem Namen \"cover\" gespeichert.", "LabelSettingsStoreCoversWithItemHelp": "Standardmäßig werden die Titelbilder in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Titelbilder als jpg Datei in dem gleichen Ordner gespeichert in welchem sich auch das Hörbuch befindet. Es wird immer nur eine Datei mit dem Namen \"cover.jpg\" gespeichert.",
"LabelSettingsStoreMetadataWithItem": "Metadaten als OPF-Datei im Hörbuchordner speichern", "LabelSettingsStoreMetadataWithItem": "Metadaten als OPF-Datei im Hörbuchordner speichern",
"LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert wird, werden die Metadaten in dem selben Ordner, in welchem auch das zugehörige Hörbuch gespeichert ist, gespeichert. Es wird eine Datei mit der Endung \".abs\" gespeichert.", "LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Hörbuch befindet. Es wird immer nur eine Datei mit dem Namen \"matadata.abs\" gespeichert.",
"LabelShowAll": "Alles anzeigen", "LabelShowAll": "Alles anzeigen",
"LabelSize": "Größe", "LabelSize": "Größe",
"LabelSleepTimer": "Einschlaf-Timer", "LabelSleepTimer": "Einschlaf-Timer",
@@ -390,9 +396,9 @@
"LabelTotalTimeListened": "Gehörte Gesamtzeit", "LabelTotalTimeListened": "Gehörte Gesamtzeit",
"LabelTrackFromFilename": "Titel von Dateiname", "LabelTrackFromFilename": "Titel von Dateiname",
"LabelTrackFromMetadata": "Titel aus Metadaten", "LabelTrackFromMetadata": "Titel aus Metadaten",
"LabelTracks": "Tracks", "LabelTracks": "Dateien",
"LabelTracksMultiTrack": "Multi-track", "LabelTracksMultiTrack": "Mehrfachdatei",
"LabelTracksSingleTrack": "Single-track", "LabelTracksSingleTrack": "Einzeldatei",
"LabelType": "Typ", "LabelType": "Typ",
"LabelUnknown": "Unbekannt", "LabelUnknown": "Unbekannt",
"LabelUpdateCover": "Titelbild aktualisieren", "LabelUpdateCover": "Titelbild aktualisieren",
@@ -402,8 +408,8 @@
"LabelUpdateDetailsHelp": "Erlaube das Überschreiben bestehender Details für die ausgewählten Hörbücher wenn eine Übereinstimmung gefunden wird", "LabelUpdateDetailsHelp": "Erlaube das Überschreiben bestehender Details für die ausgewählten Hörbücher wenn eine Übereinstimmung gefunden wird",
"LabelUploaderDragAndDrop": "Ziehen und Ablegen von Dateien oder Ordnern", "LabelUploaderDragAndDrop": "Ziehen und Ablegen von Dateien oder Ordnern",
"LabelUploaderDropFiles": "Dateien löschen", "LabelUploaderDropFiles": "Dateien löschen",
"LabelUseChapterTrack": "Kapitelverfolgung verwenden", "LabelUseChapterTrack": "Kapiteldatei verwenden",
"LabelUseFullTrack": "Gesamten Track verwenden", "LabelUseFullTrack": "Gesamte Datei verwenden",
"LabelUser": "Benutzer", "LabelUser": "Benutzer",
"LabelUsername": "Benutzername", "LabelUsername": "Benutzername",
"LabelValue": "Wert", "LabelValue": "Wert",
@@ -415,20 +421,20 @@
"LabelWeekdaysToRun": "Wochentage für die Ausführung", "LabelWeekdaysToRun": "Wochentage für die Ausführung",
"LabelYourAudiobookDuration": "Laufzeit Ihres Hörbuchs", "LabelYourAudiobookDuration": "Laufzeit Ihres Hörbuchs",
"LabelYourBookmarks": "Lesezeichen", "LabelYourBookmarks": "Lesezeichen",
"LabelYourPlaylists": "Your Playlists", "LabelYourPlaylists": "Eigene Playlists",
"LabelYourProgress": "Fortschritt", "LabelYourProgress": "Fortschritt",
"MessageAddToPlayerQueue": "Add to player queue", "MessageAddToPlayerQueue": "Zur Abspielwarteliste hinzufügen",
"MessageAppriseDescription": "Um diese Funktion nutzen zu können, müssen Sie eine Instanz von <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> laufen haben oder eine API verwenden welche dieselbe Anfragen bearbeiten kann. <br />Die Apprise API Url muss der vollständige URL-Pfad sein, an den die Benachrichtigung gesendet werden soll, z.B. wenn Ihre API-Instanz unter <code>http://192.168.1.1:8337</code> läuft, würden Sie <code>http://192.168.1.1:8337/notify</code> eingeben.", "MessageAppriseDescription": "Um diese Funktion nutzen zu können, müssen Sie eine Instanz von <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> laufen haben oder eine API verwenden welche dieselbe Anfragen bearbeiten kann. <br />Die Apprise API Url muss der vollständige URL-Pfad sein, an den die Benachrichtigung gesendet werden soll, z.B. wenn Ihre API-Instanz unter <code>http://192.168.1.1:8337</code> läuft, würden Sie <code>http://192.168.1.1:8337/notify</code> eingeben.",
"MessageBackupsDescription": "In Sicherungen werden Benutzer, Benutzerfortschritte, Details zu den Bibliotheksobjekten, Servereinstellungen und Bilder gespeichert <code>/metadata/items</code> & <code>/metadata/authors</code>. Die Sicherungen enthalten keine Dateien welche in Ihren Bibliotheksordnern gespeichert sind.", "MessageBackupsDescription": "In einer Sicherung werden Benutzer, Benutzerfortschritte, Details zu den Bibliotheksobjekten, Servereinstellungen und Bilder welche in <code>/metadata/items</code> & <code>/metadata/authors</code> gespeichert sind gespeichert. Sicherungen enthalten keine Dateien welche in den einzelnen Bibliotheksordnern (Hörbuch-/Podcastordnern) gespeichert sind.",
"MessageBatchQuickMatchDescription": "Der Schnellabgleich versucht, fehlende Titelbilder und Metadaten für die ausgewählten Artikel hinzuzufügen. Aktivieren Sie die nachstehenden Optionen, damit der Schnellabgleich vorhandene Titelbilder und/oder Metadaten überschreiben kann.", "MessageBatchQuickMatchDescription": "Der Schnellabgleich versucht, fehlende Titelbilder und Metadaten für die ausgewählten Artikel hinzuzufügen. Aktivieren Sie die nachstehenden Optionen, damit der Schnellabgleich vorhandene Titelbilder und/oder Metadaten überschreiben kann.",
"MessageBookshelfNoCollections": "Es wurden noch keine Sammlungen erstellt", "MessageBookshelfNoCollections": "Es wurden noch keine Sammlungen erstellt",
"MessageBookshelfNoResultsForFilter": "Keine Ergebnisse für filter \"{0}: {1}\"", "MessageBookshelfNoResultsForFilter": "Keine Ergebnisse für filter \"{0}: {1}\"",
"MessageBookshelfNoRSSFeeds": "Keine RSS-Feeds geöffnet", "MessageBookshelfNoRSSFeeds": "Keine RSS-Feeds geöffnet",
"MessageBookshelfNoSeries": "Keine Serien vorhanden", "MessageBookshelfNoSeries": "Keine Serien vorhanden",
"MessageChapterEndIsAfter": "Das Kapitelende liegt nach dem Ende Ihres Hörbuchs", "MessageChapterEndIsAfter": "Das Kapitelende liegt nach dem Ende Ihres Hörbuchs",
"MessageChapterErrorFirstNotZero": "First chapter must start at 0", "MessageChapterErrorFirstNotZero": "Das erste Kapitel muss bei 0 beginnen",
"MessageChapterErrorStartGteDuration": "Invalid start time must be less than audiobook duration", "MessageChapterErrorStartGteDuration": "Die ungültige Startzeit darf nicht größer als die gesamte Hörbuchdauer sein",
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time", "MessageChapterErrorStartLtPrev": "Die ungültige Startzeit darf nicht größer oder gleich der Startzeit des vorherigen Kapitels sein",
"MessageChapterStartIsAfter": "Der Kapitelanfang liegt nach dem Ende Ihres Hörbuchs", "MessageChapterStartIsAfter": "Der Kapitelanfang liegt nach dem Ende Ihres Hörbuchs",
"MessageCheckingCron": "Überprüfe cron...", "MessageCheckingCron": "Überprüfe cron...",
"MessageConfirmDeleteBackup": "Sind Sie sicher, dass Sie die Sicherung für {0} löschen wollen?", "MessageConfirmDeleteBackup": "Sind Sie sicher, dass Sie die Sicherung für {0} löschen wollen?",
@@ -439,6 +445,12 @@
"MessageConfirmRemoveEpisode": "Sind Sie sicher, dass Sie die Episode \"{0}\" löschen möchten?", "MessageConfirmRemoveEpisode": "Sind Sie sicher, dass Sie die Episode \"{0}\" löschen möchten?",
"MessageConfirmRemoveEpisodes": "Sind Sie sicher, dass Sie {0} Episoden löschen wollen?", "MessageConfirmRemoveEpisodes": "Sind Sie sicher, dass Sie {0} Episoden löschen wollen?",
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?", "MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",
"MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".",
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".",
"MessageDownloadingEpisode": "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!",
@@ -449,6 +461,7 @@
"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",
"MessageItemsUpdated": "{0} Items Updated",
"MessageJoinUsOn": "Besuchen Sie uns auf", "MessageJoinUsOn": "Besuchen Sie uns auf",
"MessageListeningSessionsInTheLastYear": "{0} Ereignisse im letzten Jahr", "MessageListeningSessionsInTheLastYear": "{0} Ereignisse im letzten Jahr",
"MessageLoading": "Laden...", "MessageLoading": "Laden...",
@@ -459,7 +472,7 @@
"MessageMarkAsFinished": "Als beendet markieren", "MessageMarkAsFinished": "Als beendet markieren",
"MessageMarkAsNotFinished": "Als nicht abgeschlossen markieren", "MessageMarkAsNotFinished": "Als nicht abgeschlossen markieren",
"MessageMatchBooksDescription": "versucht, Bücher in der Bibliothek mit einem Buch des ausgewählten Suchanbieters abzugleichen und leere Details und das Titelbild auszufüllen. Details werden nicht überschrieben.", "MessageMatchBooksDescription": "versucht, Bücher in der Bibliothek mit einem Buch des ausgewählten Suchanbieters abzugleichen und leere Details und das Titelbild auszufüllen. Details werden nicht überschrieben.",
"MessageNoAudioTracks": "Keine Audiotracks", "MessageNoAudioTracks": "Keine Audiodateien",
"MessageNoAuthors": "Keine Autoren", "MessageNoAuthors": "Keine Autoren",
"MessageNoBackups": "Keine Sicherungen", "MessageNoBackups": "Keine Sicherungen",
"MessageNoBookmarks": "Keine Lesezeichen", "MessageNoBookmarks": "Keine Lesezeichen",
@@ -481,28 +494,30 @@
"MessageNoPodcastsFound": "Keine Podcasts gefunden", "MessageNoPodcastsFound": "Keine Podcasts gefunden",
"MessageNoResults": "Keine Ergebnisse", "MessageNoResults": "Keine Ergebnisse",
"MessageNoSearchResultsFor": "Keine Suchergebnisse für \"{0}\"", "MessageNoSearchResultsFor": "Keine Suchergebnisse für \"{0}\"",
"MessageNoSeries": "No Series", "MessageNoSeries": "Keine Serien",
"MessageNoTags": "No Tags",
"MessageNotYetImplemented": "Noch nicht implementiert", "MessageNotYetImplemented": "Noch nicht implementiert",
"MessageNoUpdateNecessary": "Keine Aktualisierung erforderlich", "MessageNoUpdateNecessary": "Keine Aktualisierung erforderlich",
"MessageNoUpdatesWereNecessary": "Keine Aktualisierungen waren notwendig", "MessageNoUpdatesWereNecessary": "Keine Aktualisierungen waren notwendig",
"MessageNoUserPlaylists": "You have no playlists", "MessageNoUserPlaylists": "Keine Wiedergabelisten vorhanden",
"MessageOr": "oder", "MessageOr": "oder",
"MessagePauseChapter": "Kapitelwiedergabe pausieren", "MessagePauseChapter": "Kapitelwiedergabe pausieren",
"MessagePlayChapter": "Kapitelanfang anhören", "MessagePlayChapter": "Kapitelanfang anhören",
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
"MessagePodcastHasNoRSSFeedForMatching": "Podcast hat keine RSS-Feed-Url welche für den Online-Abgleich verwendet werden kann", "MessagePodcastHasNoRSSFeedForMatching": "Podcast hat keine RSS-Feed-Url welche für den Online-Abgleich verwendet werden kann",
"MessageQuickMatchDescription": "Füllt leere Details und Titelbilder mit dem ersten Treffer aus '{0}'. Überschreibt keine Details, es sei denn, die Server-Einstellung \"Passende Metadaten bevorzugen\" ist aktiviert.", "MessageQuickMatchDescription": "Füllt leere Details und Titelbilder mit dem ersten Treffer aus '{0}'. Überschreibt keine Details, es sei denn, die Server-Einstellung \"Passende Metadaten bevorzugen\" ist aktiviert.",
"MessageRemoveAllItemsWarning": "WARNUNG! Bei dieser Aktion werden alle Bibliotheksobjekte aus der Datenbank entfernt, einschließlich aller Aktualisierungen oder Online-Abgleichs, die Sie vorgenommen haben. Ihre eigentlichen Dateien bleiben davon unberührt. Sind Sie sicher?", "MessageRemoveAllItemsWarning": "WARNUNG! Bei dieser Aktion werden alle Bibliotheksobjekte aus der Datenbank entfernt, einschließlich aller Aktualisierungen oder Online-Abgleichs, die Sie vorgenommen haben. Ihre eigentlichen Dateien bleiben davon unberührt. Sind Sie sicher?",
"MessageRemoveChapter": "Kapitel löschen", "MessageRemoveChapter": "Kapitel löschen",
"MessageRemoveEpisodes": "Entferne {0} Episode(n)", "MessageRemoveEpisodes": "Entferne {0} Episode(n)",
"MessageRemoveFromPlayerQueue": "Remove from player queue", "MessageRemoveFromPlayerQueue": "Aus der Abspielwarteliste löschen Remove from player queue",
"MessageRemoveUserWarning": "Sind Sie sicher, dass Sie den Benutzer \"{0}\" dauerhaft löschen wollen?", "MessageRemoveUserWarning": "Sind Sie sicher, dass Sie den Benutzer \"{0}\" dauerhaft löschen wollen?",
"MessageReportBugsAndContribute": "Fehler melden, Funktionen anfordern und Beiträge leisten auf", "MessageReportBugsAndContribute": "Fehler melden, Funktionen anfordern und Beiträge leisten auf",
"MessageResetChaptersConfirm": "Are you sure you want to reset chapters and undo the changes you made?", "MessageResetChaptersConfirm": "Sind Sie sicher, dass Sie die Kapitel zurücksetzen und die vorgenommenen Änderungen rückgängig machen wollen?",
"MessageRestoreBackupConfirm": "Sind Sie sicher, dass Sie die Sicherung wiederherstellen wollen, welche am", "MessageRestoreBackupConfirm": "Sind Sie sicher, dass Sie die Sicherung wiederherstellen wollen, welche am",
"MessageRestoreBackupWarning": "Bei der Wiederherstellung einer Sicherung wird die gesamte Datenbank unter /config und die Titelbilder in /metadata/items und /metadata/authors überschrieben.<br /><br />Bei der Sicherung werden keine Dateien in Ihren Bibliotheksordnern verändert. Wenn Sie die Servereinstellungen aktiviert haben, um Cover und Metadaten in Ihren Bibliotheksordnern zu speichern, werden diese nicht gesichert oder überschrieben.<br /><br />Alle Clients, die Ihren Server nutzen, werden automatisch aktualisiert.", "MessageRestoreBackupWarning": "Bei der Wiederherstellung einer Sicherung wird die gesamte Datenbank unter /config und die Titelbilder in /metadata/items und /metadata/authors überschrieben.<br /><br />Bei der Sicherung werden keine Dateien in Ihren Bibliotheksordnern verändert. Wenn Sie die Servereinstellungen aktiviert haben, um Cover und Metadaten in Ihren Bibliotheksordnern zu speichern, werden diese nicht gesichert oder überschrieben.<br /><br />Alle Clients, die Ihren Server nutzen, werden automatisch aktualisiert.",
"MessageSearchResultsFor": "Suchergebnisse für", "MessageSearchResultsFor": "Suchergebnisse für",
"MessageServerCouldNotBeReached": "Server kann nicht erreicht werden", "MessageServerCouldNotBeReached": "Server kann nicht erreicht werden",
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name", "MessageSetChaptersFromTracksDescription": "Kaitelerstellung basiert auf den existierenden einzelnen Audiodateien. Pro existierende Audiodatei wird 1 Kapitel erstellt, wobei deren Kapitelname aus dem Audiodateinamen extrahiert wird",
"MessageStartPlaybackAtTime": "Start der Wiedergabe für \"{0}\" bei {1}?", "MessageStartPlaybackAtTime": "Start der Wiedergabe für \"{0}\" bei {1}?",
"MessageThinking": "Nachdenken...", "MessageThinking": "Nachdenken...",
"MessageUploaderItemFailed": "Hochladen fehlgeschlagen", "MessageUploaderItemFailed": "Hochladen fehlgeschlagen",
@@ -549,8 +564,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", "ToastChaptersHaveErrors": "Kapitel sind fehlerhaft",
"ToastChaptersMustHaveTitles": "Chapters must have titles", "ToastChaptersMustHaveTitles": "Kapitel benötigen eindeutige Namen",
"ToastCollectionItemsRemoveFailed": "Element(e) konnte(n) nicht aus der Sammlung entfernt werden", "ToastCollectionItemsRemoveFailed": "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",
@@ -574,10 +589,12 @@
"ToastLibraryScanStarted": "Bibliotheksscan gestartet", "ToastLibraryScanStarted": "Bibliotheksscan gestartet",
"ToastLibraryUpdateFailed": "Aktualisierung der Bibliothek fehlgeschlagen", "ToastLibraryUpdateFailed": "Aktualisierung der Bibliothek fehlgeschlagen",
"ToastLibraryUpdateSuccess": "Bibliothek \"{0}\" aktualisiert", "ToastLibraryUpdateSuccess": "Bibliothek \"{0}\" aktualisiert",
"ToastPlaylistCreateFailed": "Failed to create playlist",
"ToastPlaylistCreateSuccess": "Playlist created",
"ToastPlaylistRemoveFailed": "Failed to remove playlist", "ToastPlaylistRemoveFailed": "Failed to remove playlist",
"ToastPlaylistRemoveSuccess": "Playlist removed", "ToastPlaylistRemoveSuccess": "Playlist removed",
"ToastPlaylistUpdateFailed": "Failed to update playlist", "ToastPlaylistUpdateFailed": "Failed to update playlist",
"ToastPlaylistUpdateSuccess": "Playlist updated", "ToastPlaylistUpdateSuccess": "Playlist aktualisieren",
"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",
+18 -1
View File
@@ -95,6 +95,7 @@
"HeaderFindChapters": "Find Chapters", "HeaderFindChapters": "Find Chapters",
"HeaderIgnoredFiles": "Ignored Files", "HeaderIgnoredFiles": "Ignored Files",
"HeaderItemFiles": "Item Files", "HeaderItemFiles": "Item Files",
"HeaderItemMetadataUtils": "Item Metadata Utils",
"HeaderLastListeningSession": "Last Listening Session", "HeaderLastListeningSession": "Last Listening Session",
"HeaderLatestEpisodes": "Latest episodes", "HeaderLatestEpisodes": "Latest episodes",
"HeaderLibraries": "Libraries", "HeaderLibraries": "Libraries",
@@ -104,6 +105,9 @@
"HeaderListeningStats": "Listening Stats", "HeaderListeningStats": "Listening Stats",
"HeaderLogin": "Login", "HeaderLogin": "Login",
"HeaderLogs": "Logs", "HeaderLogs": "Logs",
"HeaderManageGenres": "Manage Genres",
"HeaderManageTags": "Manage Tags",
"HeaderMapDetails": "Map details",
"HeaderMatch": "Match", "HeaderMatch": "Match",
"HeaderMetadataToEmbed": "Metadata to embed", "HeaderMetadataToEmbed": "Metadata to embed",
"HeaderNewAccount": "New Account", "HeaderNewAccount": "New Account",
@@ -155,6 +159,7 @@
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist", "LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
"LabelAll": "All", "LabelAll": "All",
"LabelAllUsers": "All Users", "LabelAllUsers": "All Users",
"LabelAppend": "Append",
"LabelAuthor": "Author", "LabelAuthor": "Author",
"LabelAuthorFirstLast": "Author (First Last)", "LabelAuthorFirstLast": "Author (First Last)",
"LabelAuthorLastFirst": "Author (Last, First)", "LabelAuthorLastFirst": "Author (Last, First)",
@@ -278,6 +283,7 @@
"LabelNumberOfBooks": "Number of Books", "LabelNumberOfBooks": "Number of Books",
"LabelNumberOfEpisodes": "# of Episodes", "LabelNumberOfEpisodes": "# of Episodes",
"LabelOpenRSSFeed": "Open RSS Feed", "LabelOpenRSSFeed": "Open RSS Feed",
"LabelOverwrite": "Overwrite",
"LabelPassword": "Password", "LabelPassword": "Password",
"LabelPath": "Path", "LabelPath": "Path",
"LabelPermissionsAccessAllLibraries": "Can Access All Libraries", "LabelPermissionsAccessAllLibraries": "Can Access All Libraries",
@@ -342,7 +348,7 @@
"LabelSettingsSkipMatchingBooksWithISBN": "Skip matching books that already have an ISBN", "LabelSettingsSkipMatchingBooksWithISBN": "Skip matching books that already have an ISBN",
"LabelSettingsSortingIgnorePrefixes": "Ignore prefixes when sorting", "LabelSettingsSortingIgnorePrefixes": "Ignore prefixes when sorting",
"LabelSettingsSortingIgnorePrefixesHelp": "i.e. for prefix \"the\" book title \"The Book Title\" would sort as \"Book Title, The\"", "LabelSettingsSortingIgnorePrefixesHelp": "i.e. for prefix \"the\" book title \"The Book Title\" would sort as \"Book Title, The\"",
"LabelSettingsSquareBookCovers": "User square book covers", "LabelSettingsSquareBookCovers": "Use square book covers",
"LabelSettingsSquareBookCoversHelp": "Prefer to use square covers over standard 1.6:1 book covers", "LabelSettingsSquareBookCoversHelp": "Prefer to use square covers over standard 1.6:1 book covers",
"LabelSettingsStoreCoversWithItem": "Store covers with item", "LabelSettingsStoreCoversWithItem": "Store covers with item",
"LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept", "LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept",
@@ -439,6 +445,12 @@
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?", "MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?", "MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?", "MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",
"MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".",
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".",
"MessageDownloadingEpisode": "Downloading episode", "MessageDownloadingEpisode": "Downloading episode",
"MessageDragFilesIntoTrackOrder": "Drag files into correct track order", "MessageDragFilesIntoTrackOrder": "Drag files into correct track order",
"MessageEmbedFinished": "Embed Finished!", "MessageEmbedFinished": "Embed Finished!",
@@ -449,6 +461,7 @@
"MessageImportantNotice": "Important Notice!", "MessageImportantNotice": "Important Notice!",
"MessageInsertChapterBelow": "Insert chapter below", "MessageInsertChapterBelow": "Insert chapter below",
"MessageItemsSelected": "{0} Items Selected", "MessageItemsSelected": "{0} Items Selected",
"MessageItemsUpdated": "{0} Items Updated",
"MessageJoinUsOn": "Join us on", "MessageJoinUsOn": "Join us on",
"MessageListeningSessionsInTheLastYear": "{0} listening sessions in the last year", "MessageListeningSessionsInTheLastYear": "{0} listening sessions in the last year",
"MessageLoading": "Loading...", "MessageLoading": "Loading...",
@@ -482,6 +495,7 @@
"MessageNoResults": "No Results", "MessageNoResults": "No Results",
"MessageNoSearchResultsFor": "No search results for \"{0}\"", "MessageNoSearchResultsFor": "No search results for \"{0}\"",
"MessageNoSeries": "No Series", "MessageNoSeries": "No Series",
"MessageNoTags": "No Tags",
"MessageNotYetImplemented": "Not yet implemented", "MessageNotYetImplemented": "Not yet implemented",
"MessageNoUpdateNecessary": "No update necessary", "MessageNoUpdateNecessary": "No update necessary",
"MessageNoUpdatesWereNecessary": "No updates were necessary", "MessageNoUpdatesWereNecessary": "No updates were necessary",
@@ -489,6 +503,7 @@
"MessageOr": "or", "MessageOr": "or",
"MessagePauseChapter": "Pause chapter playback", "MessagePauseChapter": "Pause chapter playback",
"MessagePlayChapter": "Listen to beginning of chapter", "MessagePlayChapter": "Listen to beginning of chapter",
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
"MessagePodcastHasNoRSSFeedForMatching": "Podcast has no RSS feed url to use for matching", "MessagePodcastHasNoRSSFeedForMatching": "Podcast has no RSS feed url to use for matching",
"MessageQuickMatchDescription": "Populate empty item details & cover with first match result from '{0}'. Does not overwrite details unless 'Prefer matched metadata' server setting is enabled.", "MessageQuickMatchDescription": "Populate empty item details & cover with first match result from '{0}'. Does not overwrite details unless 'Prefer matched metadata' server setting is enabled.",
"MessageRemoveAllItemsWarning": "WARNING! This action will remove all library items from the database including any updates or matches you have made. This does not do anything to your actual files. Are you sure?", "MessageRemoveAllItemsWarning": "WARNING! This action will remove all library items from the database including any updates or matches you have made. This does not do anything to your actual files. Are you sure?",
@@ -574,6 +589,8 @@
"ToastLibraryScanStarted": "Library scan started", "ToastLibraryScanStarted": "Library scan started",
"ToastLibraryUpdateFailed": "Failed to update library", "ToastLibraryUpdateFailed": "Failed to update library",
"ToastLibraryUpdateSuccess": "Library \"{0}\" updated", "ToastLibraryUpdateSuccess": "Library \"{0}\" updated",
"ToastPlaylistCreateFailed": "Failed to create playlist",
"ToastPlaylistCreateSuccess": "Playlist created",
"ToastPlaylistRemoveFailed": "Failed to remove playlist", "ToastPlaylistRemoveFailed": "Failed to remove playlist",
"ToastPlaylistRemoveSuccess": "Playlist removed", "ToastPlaylistRemoveSuccess": "Playlist removed",
"ToastPlaylistUpdateFailed": "Failed to update playlist", "ToastPlaylistUpdateFailed": "Failed to update playlist",
+18 -1
View File
@@ -95,6 +95,7 @@
"HeaderFindChapters": "Find Chapters", "HeaderFindChapters": "Find Chapters",
"HeaderIgnoredFiles": "Ignored Files", "HeaderIgnoredFiles": "Ignored Files",
"HeaderItemFiles": "Item Files", "HeaderItemFiles": "Item Files",
"HeaderItemMetadataUtils": "Item Metadata Utils",
"HeaderLastListeningSession": "Last Listening Session", "HeaderLastListeningSession": "Last Listening Session",
"HeaderLatestEpisodes": "Latest episodes", "HeaderLatestEpisodes": "Latest episodes",
"HeaderLibraries": "Libraries", "HeaderLibraries": "Libraries",
@@ -104,6 +105,9 @@
"HeaderListeningStats": "Listening Stats", "HeaderListeningStats": "Listening Stats",
"HeaderLogin": "Login", "HeaderLogin": "Login",
"HeaderLogs": "Logs", "HeaderLogs": "Logs",
"HeaderManageGenres": "Manage Genres",
"HeaderManageTags": "Manage Tags",
"HeaderMapDetails": "Map details",
"HeaderMatch": "Match", "HeaderMatch": "Match",
"HeaderMetadataToEmbed": "Metadata to embed", "HeaderMetadataToEmbed": "Metadata to embed",
"HeaderNewAccount": "New Account", "HeaderNewAccount": "New Account",
@@ -155,6 +159,7 @@
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist", "LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
"LabelAll": "All", "LabelAll": "All",
"LabelAllUsers": "All Users", "LabelAllUsers": "All Users",
"LabelAppend": "Append",
"LabelAuthor": "Author", "LabelAuthor": "Author",
"LabelAuthorFirstLast": "Author (First Last)", "LabelAuthorFirstLast": "Author (First Last)",
"LabelAuthorLastFirst": "Author (Last, First)", "LabelAuthorLastFirst": "Author (Last, First)",
@@ -278,6 +283,7 @@
"LabelNumberOfBooks": "Number of Books", "LabelNumberOfBooks": "Number of Books",
"LabelNumberOfEpisodes": "# of Episodes", "LabelNumberOfEpisodes": "# of Episodes",
"LabelOpenRSSFeed": "Open RSS Feed", "LabelOpenRSSFeed": "Open RSS Feed",
"LabelOverwrite": "Overwrite",
"LabelPassword": "Password", "LabelPassword": "Password",
"LabelPath": "Path", "LabelPath": "Path",
"LabelPermissionsAccessAllLibraries": "Can Access All Libraries", "LabelPermissionsAccessAllLibraries": "Can Access All Libraries",
@@ -342,7 +348,7 @@
"LabelSettingsSkipMatchingBooksWithISBN": "Skip matching books that already have an ISBN", "LabelSettingsSkipMatchingBooksWithISBN": "Skip matching books that already have an ISBN",
"LabelSettingsSortingIgnorePrefixes": "Ignore prefixes when sorting", "LabelSettingsSortingIgnorePrefixes": "Ignore prefixes when sorting",
"LabelSettingsSortingIgnorePrefixesHelp": "i.e. for prefix \"the\" book title \"The Book Title\" would sort as \"Book Title, The\"", "LabelSettingsSortingIgnorePrefixesHelp": "i.e. for prefix \"the\" book title \"The Book Title\" would sort as \"Book Title, The\"",
"LabelSettingsSquareBookCovers": "User square book covers", "LabelSettingsSquareBookCovers": "Use square book covers",
"LabelSettingsSquareBookCoversHelp": "Prefer to use square covers over standard 1.6:1 book covers", "LabelSettingsSquareBookCoversHelp": "Prefer to use square covers over standard 1.6:1 book covers",
"LabelSettingsStoreCoversWithItem": "Store covers with item", "LabelSettingsStoreCoversWithItem": "Store covers with item",
"LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept", "LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept",
@@ -439,6 +445,12 @@
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?", "MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?", "MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?", "MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",
"MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".",
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".",
"MessageDownloadingEpisode": "Downloading episode", "MessageDownloadingEpisode": "Downloading episode",
"MessageDragFilesIntoTrackOrder": "Drag files into correct track order", "MessageDragFilesIntoTrackOrder": "Drag files into correct track order",
"MessageEmbedFinished": "Embed Finished!", "MessageEmbedFinished": "Embed Finished!",
@@ -449,6 +461,7 @@
"MessageImportantNotice": "Important Notice!", "MessageImportantNotice": "Important Notice!",
"MessageInsertChapterBelow": "Insert chapter below", "MessageInsertChapterBelow": "Insert chapter below",
"MessageItemsSelected": "{0} Items Selected", "MessageItemsSelected": "{0} Items Selected",
"MessageItemsUpdated": "{0} Items Updated",
"MessageJoinUsOn": "Join us on", "MessageJoinUsOn": "Join us on",
"MessageListeningSessionsInTheLastYear": "{0} listening sessions in the last year", "MessageListeningSessionsInTheLastYear": "{0} listening sessions in the last year",
"MessageLoading": "Loading...", "MessageLoading": "Loading...",
@@ -482,6 +495,7 @@
"MessageNoResults": "No Results", "MessageNoResults": "No Results",
"MessageNoSearchResultsFor": "No search results for \"{0}\"", "MessageNoSearchResultsFor": "No search results for \"{0}\"",
"MessageNoSeries": "No Series", "MessageNoSeries": "No Series",
"MessageNoTags": "No Tags",
"MessageNotYetImplemented": "Not yet implemented", "MessageNotYetImplemented": "Not yet implemented",
"MessageNoUpdateNecessary": "No update necessary", "MessageNoUpdateNecessary": "No update necessary",
"MessageNoUpdatesWereNecessary": "No updates were necessary", "MessageNoUpdatesWereNecessary": "No updates were necessary",
@@ -489,6 +503,7 @@
"MessageOr": "or", "MessageOr": "or",
"MessagePauseChapter": "Pause chapter playback", "MessagePauseChapter": "Pause chapter playback",
"MessagePlayChapter": "Listen to beginning of chapter", "MessagePlayChapter": "Listen to beginning of chapter",
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
"MessagePodcastHasNoRSSFeedForMatching": "Podcast has no RSS feed url to use for matching", "MessagePodcastHasNoRSSFeedForMatching": "Podcast has no RSS feed url to use for matching",
"MessageQuickMatchDescription": "Populate empty item details & cover with first match result from '{0}'. Does not overwrite details unless 'Prefer matched metadata' server setting is enabled.", "MessageQuickMatchDescription": "Populate empty item details & cover with first match result from '{0}'. Does not overwrite details unless 'Prefer matched metadata' server setting is enabled.",
"MessageRemoveAllItemsWarning": "WARNING! This action will remove all library items from the database including any updates or matches you have made. This does not do anything to your actual files. Are you sure?", "MessageRemoveAllItemsWarning": "WARNING! This action will remove all library items from the database including any updates or matches you have made. This does not do anything to your actual files. Are you sure?",
@@ -574,6 +589,8 @@
"ToastLibraryScanStarted": "Library scan started", "ToastLibraryScanStarted": "Library scan started",
"ToastLibraryUpdateFailed": "Failed to update library", "ToastLibraryUpdateFailed": "Failed to update library",
"ToastLibraryUpdateSuccess": "Library \"{0}\" updated", "ToastLibraryUpdateSuccess": "Library \"{0}\" updated",
"ToastPlaylistCreateFailed": "Failed to create playlist",
"ToastPlaylistCreateSuccess": "Playlist created",
"ToastPlaylistRemoveFailed": "Failed to remove playlist", "ToastPlaylistRemoveFailed": "Failed to remove playlist",
"ToastPlaylistRemoveSuccess": "Playlist removed", "ToastPlaylistRemoveSuccess": "Playlist removed",
"ToastPlaylistUpdateFailed": "Failed to update playlist", "ToastPlaylistUpdateFailed": "Failed to update playlist",
+18 -1
View File
@@ -95,6 +95,7 @@
"HeaderFindChapters": "Trouver les Chapitres", "HeaderFindChapters": "Trouver les Chapitres",
"HeaderIgnoredFiles": "Fichiers Ignorés", "HeaderIgnoredFiles": "Fichiers Ignorés",
"HeaderItemFiles": "Fichiers des Articles", "HeaderItemFiles": "Fichiers des Articles",
"HeaderItemMetadataUtils": "Item Metadata Utils",
"HeaderLastListeningSession": "Dernière Session d'Ecoute", "HeaderLastListeningSession": "Dernière Session d'Ecoute",
"HeaderLatestEpisodes": "Dernier Episodes", "HeaderLatestEpisodes": "Dernier Episodes",
"HeaderLibraries": "Bibliothèque", "HeaderLibraries": "Bibliothèque",
@@ -104,6 +105,9 @@
"HeaderListeningStats": "Statistiques d'Ecoute", "HeaderListeningStats": "Statistiques d'Ecoute",
"HeaderLogin": "Connexion", "HeaderLogin": "Connexion",
"HeaderLogs": "Fichiers Journaux", "HeaderLogs": "Fichiers Journaux",
"HeaderManageGenres": "Manage Genres",
"HeaderManageTags": "Manage Tags",
"HeaderMapDetails": "Edition en Masse",
"HeaderMatch": "Rechercher", "HeaderMatch": "Rechercher",
"HeaderMetadataToEmbed": "Métadonnée à Intégrer", "HeaderMetadataToEmbed": "Métadonnée à Intégrer",
"HeaderNewAccount": "Nouveau Compte", "HeaderNewAccount": "Nouveau Compte",
@@ -155,6 +159,7 @@
"LabelAddToPlaylistBatch": "{0} Elements Ajoutés à la Liste de Lecture", "LabelAddToPlaylistBatch": "{0} Elements Ajoutés à la Liste de Lecture",
"LabelAll": "Tout", "LabelAll": "Tout",
"LabelAllUsers": "Tous les Utilisateurs", "LabelAllUsers": "Tous les Utilisateurs",
"LabelAppend": "Ajouter",
"LabelAuthor": "Auteur", "LabelAuthor": "Auteur",
"LabelAuthorFirstLast": "Auteur (Prénom Nom)", "LabelAuthorFirstLast": "Auteur (Prénom Nom)",
"LabelAuthorLastFirst": "Auteur (Nom, Prénom)", "LabelAuthorLastFirst": "Auteur (Nom, Prénom)",
@@ -278,6 +283,7 @@
"LabelNumberOfBooks": "Nombre de Livres", "LabelNumberOfBooks": "Nombre de Livres",
"LabelNumberOfEpisodes": "Nombre d'Episodes", "LabelNumberOfEpisodes": "Nombre d'Episodes",
"LabelOpenRSSFeed": "Ouvrir le flux RSS", "LabelOpenRSSFeed": "Ouvrir le flux RSS",
"LabelOverwrite": "Ecraser",
"LabelPassword": "Mot de Passe", "LabelPassword": "Mot de Passe",
"LabelPath": "Chemin", "LabelPath": "Chemin",
"LabelPermissionsAccessAllLibraries": "Peut Acceder à Toutes les Bibliothèque", "LabelPermissionsAccessAllLibraries": "Peut Acceder à Toutes les Bibliothèque",
@@ -439,6 +445,12 @@
"MessageConfirmRemoveEpisode": "Etes vous certain de vouloir supprimer l'épisode \"{0}\"?", "MessageConfirmRemoveEpisode": "Etes vous certain de vouloir supprimer l'épisode \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Etes vous certain de vouloir supprimer {0} épisodes?", "MessageConfirmRemoveEpisodes": "Etes vous certain de vouloir supprimer {0} épisodes?",
"MessageConfirmRemovePlaylist": "Etes vous certain de vouloir supprimer la liste de lecture \"{0}\"?", "MessageConfirmRemovePlaylist": "Etes vous certain de vouloir supprimer la liste de lecture \"{0}\"?",
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",
"MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".",
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".",
"MessageDownloadingEpisode": "Téléchargement de l'épisode", "MessageDownloadingEpisode": "Téléchargement de l'épisode",
"MessageDragFilesIntoTrackOrder": "Faire glisser les fichiers dans l'ordre correct", "MessageDragFilesIntoTrackOrder": "Faire glisser les fichiers dans l'ordre correct",
"MessageEmbedFinished": "Intégration Terminée!", "MessageEmbedFinished": "Intégration Terminée!",
@@ -449,6 +461,7 @@
"MessageImportantNotice": "Information Importante!", "MessageImportantNotice": "Information Importante!",
"MessageInsertChapterBelow": "Insérer le chapitre ci-dessous", "MessageInsertChapterBelow": "Insérer le chapitre ci-dessous",
"MessageItemsSelected": "{0} Articles Sélectionnés", "MessageItemsSelected": "{0} Articles Sélectionnés",
"MessageItemsUpdated": "{0} Items Updated",
"MessageJoinUsOn": "Rejoignez-nous sur", "MessageJoinUsOn": "Rejoignez-nous sur",
"MessageListeningSessionsInTheLastYear": "{0} sessions d'écoute l'an dernier", "MessageListeningSessionsInTheLastYear": "{0} sessions d'écoute l'an dernier",
"MessageLoading": "Chargement...", "MessageLoading": "Chargement...",
@@ -482,6 +495,7 @@
"MessageNoResults": "Pas de Résultats", "MessageNoResults": "Pas de Résultats",
"MessageNoSearchResultsFor": "Pas de résultats de recherche pour \"{0}\"", "MessageNoSearchResultsFor": "Pas de résultats de recherche pour \"{0}\"",
"MessageNoSeries": "Pas de Séries", "MessageNoSeries": "Pas de Séries",
"MessageNoTags": "No Tags",
"MessageNotYetImplemented": "Non implémenté", "MessageNotYetImplemented": "Non implémenté",
"MessageNoUpdateNecessary": "Pas de mise à jour nécessaire", "MessageNoUpdateNecessary": "Pas de mise à jour nécessaire",
"MessageNoUpdatesWereNecessary": "Aucune mise à jour n'était nécessaire", "MessageNoUpdatesWereNecessary": "Aucune mise à jour n'était nécessaire",
@@ -489,6 +503,7 @@
"MessageOr": "ou", "MessageOr": "ou",
"MessagePauseChapter": "Suspendre la lecture du chapitre", "MessagePauseChapter": "Suspendre la lecture du chapitre",
"MessagePlayChapter": "Ecouter depuis le début du chapitre", "MessagePlayChapter": "Ecouter depuis le début du chapitre",
"MessagePlaylistCreateFromCollection": "Créer une liste de lecture depuis la collection",
"MessagePodcastHasNoRSSFeedForMatching": "Le Podcast n'a pas d'URL de flux RSS à utiliser pour la correspondance", "MessagePodcastHasNoRSSFeedForMatching": "Le Podcast n'a pas d'URL de flux RSS à utiliser pour la correspondance",
"MessageQuickMatchDescription": "Renseigne les détails manquants ainsi que la couverture avec la première correspondance de '{0}'. N'écrase pas les données présentes à moins que le paramètre 'Préférer les Métadonnées par correspondance' soit activé.", "MessageQuickMatchDescription": "Renseigne les détails manquants ainsi que la couverture avec la première correspondance de '{0}'. N'écrase pas les données présentes à moins que le paramètre 'Préférer les Métadonnées par correspondance' soit activé.",
"MessageRemoveAllItemsWarning": "ATTENTION! Cette action supprimera toute la base de données de la bibliothèque ainsi que les mises à jour ou correspondances qui auraient été effectuées. Cela n'a aucune incidence sur les fichiers de la bibliothèque. Voulez-vous continuer?", "MessageRemoveAllItemsWarning": "ATTENTION! Cette action supprimera toute la base de données de la bibliothèque ainsi que les mises à jour ou correspondances qui auraient été effectuées. Cela n'a aucune incidence sur les fichiers de la bibliothèque. Voulez-vous continuer?",
@@ -574,6 +589,8 @@
"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",
"ToastPlaylistCreateFailed": "Echec de la création de la liste de lecture",
"ToastPlaylistCreateSuccess": "Liste de lecture créée",
"ToastPlaylistRemoveFailed": "Echec de la suppression de la liste de lecture", "ToastPlaylistRemoveFailed": "Echec de la suppression de la liste de lecture",
"ToastPlaylistRemoveSuccess": "Liste de lecture supprimée", "ToastPlaylistRemoveSuccess": "Liste de lecture supprimée",
"ToastPlaylistUpdateFailed": "Echec de la mise à jour de la liste de lecture", "ToastPlaylistUpdateFailed": "Echec de la mise à jour de la liste de lecture",
@@ -598,4 +615,4 @@
"WeekdayThursday": "Jeudi", "WeekdayThursday": "Jeudi",
"WeekdayTuesday": "Mardi", "WeekdayTuesday": "Mardi",
"WeekdayWednesday": "Mercredi" "WeekdayWednesday": "Mercredi"
} }
+17
View File
@@ -95,6 +95,7 @@
"HeaderFindChapters": "Pronađi poglavlja", "HeaderFindChapters": "Pronađi poglavlja",
"HeaderIgnoredFiles": "Zanemarene datoteke", "HeaderIgnoredFiles": "Zanemarene datoteke",
"HeaderItemFiles": "Item Files", "HeaderItemFiles": "Item Files",
"HeaderItemMetadataUtils": "Item Metadata Utils",
"HeaderLastListeningSession": "Posljednja Listening Session", "HeaderLastListeningSession": "Posljednja Listening Session",
"HeaderLatestEpisodes": "Najnovije epizode", "HeaderLatestEpisodes": "Najnovije epizode",
"HeaderLibraries": "Biblioteke", "HeaderLibraries": "Biblioteke",
@@ -104,6 +105,9 @@
"HeaderListeningStats": "Listening Stats", "HeaderListeningStats": "Listening Stats",
"HeaderLogin": "Prijavljivanje", "HeaderLogin": "Prijavljivanje",
"HeaderLogs": "Logs", "HeaderLogs": "Logs",
"HeaderManageGenres": "Manage Genres",
"HeaderManageTags": "Manage Tags",
"HeaderMapDetails": "Map details",
"HeaderMatch": "Match", "HeaderMatch": "Match",
"HeaderMetadataToEmbed": "Metapodatci za ugradnju", "HeaderMetadataToEmbed": "Metapodatci za ugradnju",
"HeaderNewAccount": "Novi korisnički račun", "HeaderNewAccount": "Novi korisnički račun",
@@ -155,6 +159,7 @@
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist", "LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
"LabelAll": "All", "LabelAll": "All",
"LabelAllUsers": "Svi korisnici", "LabelAllUsers": "Svi korisnici",
"LabelAppend": "Append",
"LabelAuthor": "Autor", "LabelAuthor": "Autor",
"LabelAuthorFirstLast": "Author (First Last)", "LabelAuthorFirstLast": "Author (First Last)",
"LabelAuthorLastFirst": "Author (Last, First)", "LabelAuthorLastFirst": "Author (Last, First)",
@@ -278,6 +283,7 @@
"LabelNumberOfBooks": "Number of Books", "LabelNumberOfBooks": "Number of Books",
"LabelNumberOfEpisodes": "# of Episodes", "LabelNumberOfEpisodes": "# of Episodes",
"LabelOpenRSSFeed": "Otvori RSS Feed", "LabelOpenRSSFeed": "Otvori RSS Feed",
"LabelOverwrite": "Overwrite",
"LabelPassword": "Lozinka", "LabelPassword": "Lozinka",
"LabelPath": "Putanja", "LabelPath": "Putanja",
"LabelPermissionsAccessAllLibraries": "Ima pristup svim bibliotekama", "LabelPermissionsAccessAllLibraries": "Ima pristup svim bibliotekama",
@@ -439,6 +445,12 @@
"MessageConfirmRemoveEpisode": "Jeste li sigurni da želite obrisati epizodu \"{0}\"?", "MessageConfirmRemoveEpisode": "Jeste li sigurni da želite obrisati epizodu \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Jeste li sigurni da želite obrisati {0} epizoda/-u?", "MessageConfirmRemoveEpisodes": "Jeste li sigurni da želite obrisati {0} epizoda/-u?",
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?", "MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",
"MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".",
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".",
"MessageDownloadingEpisode": "Preuzimam epizodu", "MessageDownloadingEpisode": "Preuzimam epizodu",
"MessageDragFilesIntoTrackOrder": "Povuci datoteke u pravilan redoslijed tracka.", "MessageDragFilesIntoTrackOrder": "Povuci datoteke u pravilan redoslijed tracka.",
"MessageEmbedFinished": "Embed završen!", "MessageEmbedFinished": "Embed završen!",
@@ -449,6 +461,7 @@
"MessageImportantNotice": "Važna obavijest!", "MessageImportantNotice": "Važna obavijest!",
"MessageInsertChapterBelow": "Unesi poglavlje ispod", "MessageInsertChapterBelow": "Unesi poglavlje ispod",
"MessageItemsSelected": "{0} odabranih stavki", "MessageItemsSelected": "{0} odabranih stavki",
"MessageItemsUpdated": "{0} Items Updated",
"MessageJoinUsOn": "Pridruži nam se na", "MessageJoinUsOn": "Pridruži nam se na",
"MessageListeningSessionsInTheLastYear": "{0} slušanja u prošloj godini", "MessageListeningSessionsInTheLastYear": "{0} slušanja u prošloj godini",
"MessageLoading": "Učitavam...", "MessageLoading": "Učitavam...",
@@ -482,6 +495,7 @@
"MessageNoResults": "Nema rezultata", "MessageNoResults": "Nema rezultata",
"MessageNoSearchResultsFor": "Nema rezultata pretragee za \"{0}\"", "MessageNoSearchResultsFor": "Nema rezultata pretragee za \"{0}\"",
"MessageNoSeries": "No Series", "MessageNoSeries": "No Series",
"MessageNoTags": "No Tags",
"MessageNotYetImplemented": "Not yet implemented", "MessageNotYetImplemented": "Not yet implemented",
"MessageNoUpdateNecessary": "Aktualiziranje nije potrebno", "MessageNoUpdateNecessary": "Aktualiziranje nije potrebno",
"MessageNoUpdatesWereNecessary": "Aktualiziranje nije bilo potrebno", "MessageNoUpdatesWereNecessary": "Aktualiziranje nije bilo potrebno",
@@ -489,6 +503,7 @@
"MessageOr": "or", "MessageOr": "or",
"MessagePauseChapter": "Pause chapter playback", "MessagePauseChapter": "Pause chapter playback",
"MessagePlayChapter": "Listen to beginning of chapter", "MessagePlayChapter": "Listen to beginning of chapter",
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
"MessagePodcastHasNoRSSFeedForMatching": "Podcast nema RSS feed url za matchanje", "MessagePodcastHasNoRSSFeedForMatching": "Podcast nema RSS feed url za matchanje",
"MessageQuickMatchDescription": "Popuni prazne detalje stavki i cover sa prvim match rezultato iz '{0}'. Ne briše detalje osim ako 'Prefer matched metadata' server postavka nije uključena.", "MessageQuickMatchDescription": "Popuni prazne detalje stavki i cover sa prvim match rezultato iz '{0}'. Ne briše detalje osim ako 'Prefer matched metadata' server postavka nije uključena.",
"MessageRemoveAllItemsWarning": "UPOZORENJE! Ova radnja briše sve stavke iz biblioteke uključujući bilokakve aktualizacije ili matcheve. Ovo ne mjenja vaše lokalne datoteke. Jeste li sigurni?", "MessageRemoveAllItemsWarning": "UPOZORENJE! Ova radnja briše sve stavke iz biblioteke uključujući bilokakve aktualizacije ili matcheve. Ovo ne mjenja vaše lokalne datoteke. Jeste li sigurni?",
@@ -574,6 +589,8 @@
"ToastLibraryScanStarted": "Sken biblioteke pokrenut", "ToastLibraryScanStarted": "Sken biblioteke pokrenut",
"ToastLibraryUpdateFailed": "Aktualiziranje biblioteke neuspješno", "ToastLibraryUpdateFailed": "Aktualiziranje biblioteke neuspješno",
"ToastLibraryUpdateSuccess": "Biblioteka \"{0}\" aktualizirana", "ToastLibraryUpdateSuccess": "Biblioteka \"{0}\" aktualizirana",
"ToastPlaylistCreateFailed": "Failed to create playlist",
"ToastPlaylistCreateSuccess": "Playlist created",
"ToastPlaylistRemoveFailed": "Failed to remove playlist", "ToastPlaylistRemoveFailed": "Failed to remove playlist",
"ToastPlaylistRemoveSuccess": "Playlist removed", "ToastPlaylistRemoveSuccess": "Playlist removed",
"ToastPlaylistUpdateFailed": "Failed to update playlist", "ToastPlaylistUpdateFailed": "Failed to update playlist",
+44 -27
View File
@@ -54,7 +54,7 @@
"ButtonRemoveAllLibraryItems": "Rimuovi tutto il contenuto della libreria", "ButtonRemoveAllLibraryItems": "Rimuovi tutto il contenuto della libreria",
"ButtonRemoveFromContinueListening": "Rimuovi per proseguire l'ascolto", "ButtonRemoveFromContinueListening": "Rimuovi per proseguire l'ascolto",
"ButtonRemoveSeriesFromContinueSeries": "Rimuovi la Serie per Continuarla", "ButtonRemoveSeriesFromContinueSeries": "Rimuovi la Serie per Continuarla",
"ButtonReScan": "Riscansiona", "ButtonReScan": "Ri-scansiona",
"ButtonReset": "Reset", "ButtonReset": "Reset",
"ButtonRestore": "Ripristina", "ButtonRestore": "Ripristina",
"ButtonSave": "Salva", "ButtonSave": "Salva",
@@ -65,7 +65,7 @@
"ButtonSearch": "Cerca", "ButtonSearch": "Cerca",
"ButtonSelectFolderPath": "Seleziona percorso cartella", "ButtonSelectFolderPath": "Seleziona percorso cartella",
"ButtonSeries": "Serie", "ButtonSeries": "Serie",
"ButtonSetChaptersFromTracks": "Set chapters from tracks", "ButtonSetChaptersFromTracks": "Impostare i capitoli dalle tracce",
"ButtonShiftTimes": "Ricerca veloce", "ButtonShiftTimes": "Ricerca veloce",
"ButtonShow": "Mostra", "ButtonShow": "Mostra",
"ButtonStartM4BEncode": "Inizia L'Encoda del M4B", "ButtonStartM4BEncode": "Inizia L'Encoda del M4B",
@@ -95,6 +95,7 @@
"HeaderFindChapters": "Trova Capitoli", "HeaderFindChapters": "Trova Capitoli",
"HeaderIgnoredFiles": "File Ignorati", "HeaderIgnoredFiles": "File Ignorati",
"HeaderItemFiles": "Files", "HeaderItemFiles": "Files",
"HeaderItemMetadataUtils": "Item Metadata Utils",
"HeaderLastListeningSession": "Ultima sessione di Ascolto", "HeaderLastListeningSession": "Ultima sessione di Ascolto",
"HeaderLatestEpisodes": "Ultimi Episodi", "HeaderLatestEpisodes": "Ultimi Episodi",
"HeaderLibraries": "Librerie", "HeaderLibraries": "Librerie",
@@ -104,6 +105,9 @@
"HeaderListeningStats": "Statistiche di Ascolto", "HeaderListeningStats": "Statistiche di Ascolto",
"HeaderLogin": "Login", "HeaderLogin": "Login",
"HeaderLogs": "Logs", "HeaderLogs": "Logs",
"HeaderManageGenres": "Manage Genres",
"HeaderManageTags": "Manage Tags",
"HeaderMapDetails": "Map details",
"HeaderMatch": "Trova Corrispondenza", "HeaderMatch": "Trova Corrispondenza",
"HeaderMetadataToEmbed": "Metadata da incorporare", "HeaderMetadataToEmbed": "Metadata da incorporare",
"HeaderNewAccount": "Nuovo Account", "HeaderNewAccount": "Nuovo Account",
@@ -114,7 +118,7 @@
"HeaderPermissions": "Permessi", "HeaderPermissions": "Permessi",
"HeaderPlayerQueue": "Coda Riproduzione", "HeaderPlayerQueue": "Coda Riproduzione",
"HeaderPlaylist": "Playlist", "HeaderPlaylist": "Playlist",
"HeaderPlaylistItems": "Playlist Items", "HeaderPlaylistItems": "Elementi della playlist",
"HeaderPodcastsToAdd": "Podcasts da Aggiungere", "HeaderPodcastsToAdd": "Podcasts da Aggiungere",
"HeaderPreviewCover": "Anteprima Cover", "HeaderPreviewCover": "Anteprima Cover",
"HeaderRemoveEpisode": "Rimuovi Episodi", "HeaderRemoveEpisode": "Rimuovi Episodi",
@@ -151,10 +155,11 @@
"LabelAddedAt": "Aggiunto il", "LabelAddedAt": "Aggiunto il",
"LabelAddToCollection": "Aggiungi alla Raccolta", "LabelAddToCollection": "Aggiungi alla Raccolta",
"LabelAddToCollectionBatch": "Aggiungi {0} Libri alla Raccolta", "LabelAddToCollectionBatch": "Aggiungi {0} Libri alla Raccolta",
"LabelAddToPlaylist": "Add to Playlist", "LabelAddToPlaylist": "aggiungi alla Playlist",
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist", "LabelAddToPlaylistBatch": "Aggiungi {0} file alla Playlist",
"LabelAll": "All", "LabelAll": "All",
"LabelAllUsers": "Tutti gli Utenti", "LabelAllUsers": "Tutti gli Utenti",
"LabelAppend": "Append",
"LabelAuthor": "Autore", "LabelAuthor": "Autore",
"LabelAuthorFirstLast": "Autore (Per Nome)", "LabelAuthorFirstLast": "Autore (Per Nome)",
"LabelAuthorLastFirst": "Autori (Per Cognome)", "LabelAuthorLastFirst": "Autori (Per Cognome)",
@@ -278,6 +283,7 @@
"LabelNumberOfBooks": "Numero di libri", "LabelNumberOfBooks": "Numero di libri",
"LabelNumberOfEpisodes": "# degli episodi", "LabelNumberOfEpisodes": "# degli episodi",
"LabelOpenRSSFeed": "Apri RSS Feed", "LabelOpenRSSFeed": "Apri RSS Feed",
"LabelOverwrite": "Overwrite",
"LabelPassword": "Password", "LabelPassword": "Password",
"LabelPath": "Percorso", "LabelPath": "Percorso",
"LabelPermissionsAccessAllLibraries": "Può accedere a tutte le librerie", "LabelPermissionsAccessAllLibraries": "Può accedere a tutte le librerie",
@@ -390,9 +396,9 @@
"LabelTotalTimeListened": "Tempo totale di Ascolto", "LabelTotalTimeListened": "Tempo totale di Ascolto",
"LabelTrackFromFilename": "Traccia da nome file", "LabelTrackFromFilename": "Traccia da nome file",
"LabelTrackFromMetadata": "Traccia da Metadata", "LabelTrackFromMetadata": "Traccia da Metadata",
"LabelTracks": "Tracks", "LabelTracks": "Traccia",
"LabelTracksMultiTrack": "Multi-track", "LabelTracksMultiTrack": "Multi-traccia",
"LabelTracksSingleTrack": "Single-track", "LabelTracksSingleTrack": "Traccia-singola",
"LabelType": "Tipo", "LabelType": "Tipo",
"LabelUnknown": "Sconosciuto", "LabelUnknown": "Sconosciuto",
"LabelUpdateCover": "Aggiornamento Cover", "LabelUpdateCover": "Aggiornamento Cover",
@@ -415,20 +421,20 @@
"LabelWeekdaysToRun": "Giorni feriali da eseguire", "LabelWeekdaysToRun": "Giorni feriali da eseguire",
"LabelYourAudiobookDuration": "La durata dell'audiolibro", "LabelYourAudiobookDuration": "La durata dell'audiolibro",
"LabelYourBookmarks": "I tuoi Preferiti", "LabelYourBookmarks": "I tuoi Preferiti",
"LabelYourPlaylists": "Your Playlists", "LabelYourPlaylists": "le tue Playlist",
"LabelYourProgress": "Completato al", "LabelYourProgress": "Completato al",
"MessageAddToPlayerQueue": "Add to player queue", "MessageAddToPlayerQueue": "Aggiungi alla coda di riproduzione",
"MessageAppriseDescription": "Per utilizzare questa funzione è necessario disporre di un'istanza di <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> in esecuzione o un'API che gestirà quelle stesse richieste. <br />L'API Url dovrebbe essere il percorso URL completo per inviare la notifica, ad esempio se la tua istanza API è servita cosi .<code>http://192.168.1.1:8337</code> Allora dovrai mettere <code>http://192.168.1.1:8337/notify</code>.", "MessageAppriseDescription": "Per utilizzare questa funzione è necessario disporre di un'istanza di <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> in esecuzione o un'API che gestirà quelle stesse richieste. <br />L'API Url dovrebbe essere il percorso URL completo per inviare la notifica, ad esempio se la tua istanza API è servita cosi .<code>http://192.168.1.1:8337</code> Allora dovrai mettere <code>http://192.168.1.1:8337/notify</code>.",
"MessageBackupsDescription": "I backup includono utenti, progressi degli utenti, dettagli sugli elementi della libreria, impostazioni del server e immagini archiviate in <code>/metadata/items</code> & <code>/metadata/authors</code>. I backup non includono i file archiviati nelle cartelle della libreria.", "MessageBackupsDescription": "I backup includono utenti, progressi degli utenti, dettagli sugli elementi della libreria, impostazioni del server e immagini archiviate in <code>/metadata/items</code> & <code>/metadata/authors</code>. I backup non includono i file archiviati nelle cartelle della libreria.",
"MessageBatchQuickMatchDescription": "Quick Match tenterà di aggiungere copertine e metadati mancanti per gli elementi selezionati. Attiva l'opzione per consentire a Quick Match di sovrascrivere copertine e/o metadati esistenti.", "MessageBatchQuickMatchDescription": "Quick Match tenterà di aggiungere copertine e metadati mancanti per gli elementi selezionati. Attiva l'opzione per consentire a Quick Match di sovrascrivere copertine e/o metadati esistenti.",
"MessageBookshelfNoCollections": "Non hai ancora creato nessuna raccolta ", "MessageBookshelfNoCollections": "Non hai ancora creato nessuna raccolta ",
"MessageBookshelfNoResultsForFilter": "Nessul risultato per il filtro \"{0}: {1}\"", "MessageBookshelfNoResultsForFilter": "Nessul risultato per il filtro \"{0}: {1}\"",
"MessageBookshelfNoRSSFeeds": "Nessun RSS feeds aperto", "MessageBookshelfNoRSSFeeds": "Nessun RSS feeds aperto",
"MessageBookshelfNoSeries": "You have no series", "MessageBookshelfNoSeries": "Non c'è nessuna Serie",
"MessageChapterEndIsAfter": "La fine del capitolo è dopo la fine del tuo audiolibro", "MessageChapterEndIsAfter": "La fine del capitolo è dopo la fine del tuo audiolibro",
"MessageChapterErrorFirstNotZero": "First chapter must start at 0", "MessageChapterErrorFirstNotZero": "Il primo capitolo deve iniziare da 0",
"MessageChapterErrorStartGteDuration": "Invalid start time must be less than audiobook duration", "MessageChapterErrorStartGteDuration": "L'ora di inizio non valida deve essere inferiore alla durata dell'audiolibro",
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time", "MessageChapterErrorStartLtPrev": "L'ora di inizio non valida deve essere maggiore o uguale all'ora di inizio del capitolo precedente",
"MessageChapterStartIsAfter": "L'inizio del capitolo è dopo la fine del tuo audiolibro", "MessageChapterStartIsAfter": "L'inizio del capitolo è dopo la fine del tuo audiolibro",
"MessageCheckingCron": "Controllo cron...", "MessageCheckingCron": "Controllo cron...",
"MessageConfirmDeleteBackup": "Sei sicuro di voler eliminare il backup {0}?", "MessageConfirmDeleteBackup": "Sei sicuro di voler eliminare il backup {0}?",
@@ -438,7 +444,13 @@
"MessageConfirmRemoveCollection": "Sei sicuro di voler rimuovere la Raccolta \"{0}\"?", "MessageConfirmRemoveCollection": "Sei sicuro di voler rimuovere la Raccolta \"{0}\"?",
"MessageConfirmRemoveEpisode": "Sei sicuro di voler rimuovere l'episodio \"{0}\"?", "MessageConfirmRemoveEpisode": "Sei sicuro di voler rimuovere l'episodio \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Sei sicuro di voler rimuovere {0} episodi?", "MessageConfirmRemoveEpisodes": "Sei sicuro di voler rimuovere {0} episodi?",
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?", "MessageConfirmRemovePlaylist": "Sei sicuro di voler rimuovere la tua playlist \"{0}\"?",
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",
"MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".",
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".",
"MessageDownloadingEpisode": "Download episodio in corso", "MessageDownloadingEpisode": "Download episodio in corso",
"MessageDragFilesIntoTrackOrder": "Trascina i file nell'ordine di traccia corretto", "MessageDragFilesIntoTrackOrder": "Trascina i file nell'ordine di traccia corretto",
"MessageEmbedFinished": "Incorporamento finito!", "MessageEmbedFinished": "Incorporamento finito!",
@@ -449,6 +461,7 @@
"MessageImportantNotice": "Avviso Importante!", "MessageImportantNotice": "Avviso Importante!",
"MessageInsertChapterBelow": "Inserisci capitolo sotto", "MessageInsertChapterBelow": "Inserisci capitolo sotto",
"MessageItemsSelected": "{0} oggetti Selezionati", "MessageItemsSelected": "{0} oggetti Selezionati",
"MessageItemsUpdated": "{0} Items Updated",
"MessageJoinUsOn": "Unisciti a noi su", "MessageJoinUsOn": "Unisciti a noi su",
"MessageListeningSessionsInTheLastYear": "{0} sessioni di ascolto nell'ultimo anno", "MessageListeningSessionsInTheLastYear": "{0} sessioni di ascolto nell'ultimo anno",
"MessageLoading": "Caricamento...", "MessageLoading": "Caricamento...",
@@ -481,28 +494,30 @@
"MessageNoPodcastsFound": "Nessun podcasts trovato", "MessageNoPodcastsFound": "Nessun podcasts trovato",
"MessageNoResults": "Nessun Risultato", "MessageNoResults": "Nessun Risultato",
"MessageNoSearchResultsFor": "Nessun risultato per \"{0}\"", "MessageNoSearchResultsFor": "Nessun risultato per \"{0}\"",
"MessageNoSeries": "No Series", "MessageNoSeries": "Nessuna Serie",
"MessageNoTags": "No Tags",
"MessageNotYetImplemented": "Non Ancora Implementato", "MessageNotYetImplemented": "Non Ancora Implementato",
"MessageNoUpdateNecessary": "Nessun aggiornamento necessario", "MessageNoUpdateNecessary": "Nessun aggiornamento necessario",
"MessageNoUpdatesWereNecessary": "Nessun aggiornamento necessario", "MessageNoUpdatesWereNecessary": "Nessun aggiornamento necessario",
"MessageNoUserPlaylists": "You have no playlists", "MessageNoUserPlaylists": "non hai nessuna Playlist",
"MessageOr": "o", "MessageOr": "o",
"MessagePauseChapter": "Metti in Pausa Capitolo", "MessagePauseChapter": "Metti in Pausa Capitolo",
"MessagePlayChapter": "Ascolta dall'inizio del capitolo", "MessagePlayChapter": "Ascolta dall'inizio del capitolo",
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
"MessagePodcastHasNoRSSFeedForMatching": "Podcast non ha l'URL del feed RSS da utilizzare per il match", "MessagePodcastHasNoRSSFeedForMatching": "Podcast non ha l'URL del feed RSS da utilizzare per il match",
"MessageQuickMatchDescription": "Compila i dettagli dell'articolo vuoto e copri con il risultato della prima corrispondenza di '{0}'. Non sovrascrive i dettagli a meno che non sia abilitata l'impostazione del server \"Preferisci metadati corrispondenti\".", "MessageQuickMatchDescription": "Compila i dettagli dell'articolo vuoto e copri con il risultato della prima corrispondenza di '{0}'. Non sovrascrive i dettagli a meno che non sia abilitata l'impostazione del server \"Preferisci metadati corrispondenti\".",
"MessageRemoveAllItemsWarning": "AVVERTIMENTO! Questa azione rimuoverà tutti gli elementi della libreria dal database, inclusi eventuali aggiornamenti o corrispondenze apportate. Questo non fa nulla ai tuoi file effettivi. Sei sicuro?", "MessageRemoveAllItemsWarning": "AVVERTIMENTO! Questa azione rimuoverà tutti gli elementi della libreria dal database, inclusi eventuali aggiornamenti o corrispondenze apportate. Questo non fa nulla ai tuoi file effettivi. Sei sicuro?",
"MessageRemoveChapter": "Rimuovi Capitolo", "MessageRemoveChapter": "Rimuovi Capitolo",
"MessageRemoveEpisodes": "rimuovi {0} episodio(i)", "MessageRemoveEpisodes": "rimuovi {0} episodio(i)",
"MessageRemoveFromPlayerQueue": "Remove from player queue", "MessageRemoveFromPlayerQueue": "Rimuovi dalla coda di riproduzione",
"MessageRemoveUserWarning": "Sei sicuro di voler eliminare definitivamente l'utente \"{0}\"?", "MessageRemoveUserWarning": "Sei sicuro di voler eliminare definitivamente l'utente \"{0}\"?",
"MessageReportBugsAndContribute": "Segnala bug, richiedi funzionalità e contribuisci", "MessageReportBugsAndContribute": "Segnala bug, richiedi funzionalità e contribuisci",
"MessageResetChaptersConfirm": "Are you sure you want to reset chapters and undo the changes you made?", "MessageResetChaptersConfirm": "Sei sicuro di voler reimpostare i capitoli e annullare le modifiche ?",
"MessageRestoreBackupConfirm": "Sei sicuro di voler ripristinare il backup creato su", "MessageRestoreBackupConfirm": "Sei sicuro di voler ripristinare il backup creato su",
"MessageRestoreBackupWarning": "Il ripristino di un backup sovrascriverà l'intero database situato in /config e sovrascrive le immagini in /metadata/items & /metadata/authors.<br /><br />I backup non modificano alcun file nelle cartelle della libreria. Se hai abilitato le impostazioni del server per archiviare copertine e metadati nelle cartelle della libreria, questi non vengono sottoposti a backup o sovrascritti.<br /><br />Tutti i client che utilizzano il tuo server verranno aggiornati automaticamente.", "MessageRestoreBackupWarning": "Il ripristino di un backup sovrascriverà l'intero database situato in /config e sovrascrive le immagini in /metadata/items & /metadata/authors.<br /><br />I backup non modificano alcun file nelle cartelle della libreria. Se hai abilitato le impostazioni del server per archiviare copertine e metadati nelle cartelle della libreria, questi non vengono sottoposti a backup o sovrascritti.<br /><br />Tutti i client che utilizzano il tuo server verranno aggiornati automaticamente.",
"MessageSearchResultsFor": "cerca risultati per", "MessageSearchResultsFor": "cerca risultati per",
"MessageServerCouldNotBeReached": "Impossibile raggiungere il server", "MessageServerCouldNotBeReached": "Impossibile raggiungere il server",
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name", "MessageSetChaptersFromTracksDescription": "Impostare i capitoli utilizzando ciascun file audio come capitolo e il titolo del capitolo come nome del file audio",
"MessageStartPlaybackAtTime": "Avvia la riproduzione per \"{0}\" a {1}?", "MessageStartPlaybackAtTime": "Avvia la riproduzione per \"{0}\" a {1}?",
"MessageThinking": "Elaborazione...", "MessageThinking": "Elaborazione...",
"MessageUploaderItemFailed": "Caricamento Fallito", "MessageUploaderItemFailed": "Caricamento Fallito",
@@ -524,7 +539,7 @@
"NoteUploaderUnsupportedFiles": "I file non supportati vengono ignorati. Quando si sceglie o si elimina una cartella, gli altri file che non si trovano in una cartella di elementi vengono ignorati.", "NoteUploaderUnsupportedFiles": "I file non supportati vengono ignorati. Quando si sceglie o si elimina una cartella, gli altri file che non si trovano in una cartella di elementi vengono ignorati.",
"PlaceholderNewCollection": "Nome Nuova Raccolta", "PlaceholderNewCollection": "Nome Nuova Raccolta",
"PlaceholderNewFolderPath": "Nuovo percorso Cartella", "PlaceholderNewFolderPath": "Nuovo percorso Cartella",
"PlaceholderNewPlaylist": "New playlist name", "PlaceholderNewPlaylist": "Nome nuova playlist",
"PlaceholderSearch": "Cerca..", "PlaceholderSearch": "Cerca..",
"ToastAccountUpdateFailed": "Aggiornamento Account Fallito", "ToastAccountUpdateFailed": "Aggiornamento Account Fallito",
"ToastAccountUpdateSuccess": "Account Aggiornato", "ToastAccountUpdateSuccess": "Account Aggiornato",
@@ -549,8 +564,8 @@
"ToastBookmarkRemoveSuccess": "Segnalibro Rimosso", "ToastBookmarkRemoveSuccess": "Segnalibro Rimosso",
"ToastBookmarkUpdateFailed": "Aggiornamento Segnalibro fallito", "ToastBookmarkUpdateFailed": "Aggiornamento Segnalibro fallito",
"ToastBookmarkUpdateSuccess": "Segnalibro aggiornato", "ToastBookmarkUpdateSuccess": "Segnalibro aggiornato",
"ToastChaptersHaveErrors": "Chapters have errors", "ToastChaptersHaveErrors": "I capitoli contengono errori",
"ToastChaptersMustHaveTitles": "Chapters must have titles", "ToastChaptersMustHaveTitles": "I capitoli devono avere titoli",
"ToastCollectionItemsRemoveFailed": "Rimozione oggetti dalla Raccolta fallita", "ToastCollectionItemsRemoveFailed": "Rimozione oggetti dalla Raccolta fallita",
"ToastCollectionItemsRemoveSuccess": "Oggetto(i) rimossi dalla Raccolta", "ToastCollectionItemsRemoveSuccess": "Oggetto(i) rimossi dalla Raccolta",
"ToastCollectionRemoveFailed": "Rimozione Raccolta fallita", "ToastCollectionRemoveFailed": "Rimozione Raccolta fallita",
@@ -574,10 +589,12 @@
"ToastLibraryScanStarted": "Scansione Libreria iniziata", "ToastLibraryScanStarted": "Scansione Libreria iniziata",
"ToastLibraryUpdateFailed": "Errore Aggiornamento libreria", "ToastLibraryUpdateFailed": "Errore Aggiornamento libreria",
"ToastLibraryUpdateSuccess": "Libreria \"{0}\" aggiornata", "ToastLibraryUpdateSuccess": "Libreria \"{0}\" aggiornata",
"ToastPlaylistRemoveFailed": "Failed to remove playlist", "ToastPlaylistCreateFailed": "Failed to create playlist",
"ToastPlaylistRemoveSuccess": "Playlist removed", "ToastPlaylistCreateSuccess": "Playlist created",
"ToastPlaylistUpdateFailed": "Failed to update playlist", "ToastPlaylistRemoveFailed": "Rimozione Playlist Fallita",
"ToastPlaylistUpdateSuccess": "Playlist updated", "ToastPlaylistRemoveSuccess": "Playlist rimossa",
"ToastPlaylistUpdateFailed": "Aggiornamento Playlist Fallita",
"ToastPlaylistUpdateSuccess": "Playlist Aggiornata",
"ToastPodcastCreateFailed": "Errore Creazione podcast", "ToastPodcastCreateFailed": "Errore Creazione podcast",
"ToastPodcastCreateSuccess": "Podcast creato Correttamwnte", "ToastPodcastCreateSuccess": "Podcast creato Correttamwnte",
"ToastRemoveItemFromCollectionFailed": "Errore rimozione file dalla Raccolta", "ToastRemoveItemFromCollectionFailed": "Errore rimozione file dalla Raccolta",
+17
View File
@@ -95,6 +95,7 @@
"HeaderFindChapters": "Wyszukaj rozdziały", "HeaderFindChapters": "Wyszukaj rozdziały",
"HeaderIgnoredFiles": "Zignoruj pliki", "HeaderIgnoredFiles": "Zignoruj pliki",
"HeaderItemFiles": "Pliki", "HeaderItemFiles": "Pliki",
"HeaderItemMetadataUtils": "Item Metadata Utils",
"HeaderLastListeningSession": "Ostatnio odtwarzana sesja", "HeaderLastListeningSession": "Ostatnio odtwarzana sesja",
"HeaderLatestEpisodes": "Najnowsze odcinki", "HeaderLatestEpisodes": "Najnowsze odcinki",
"HeaderLibraries": "Biblioteki", "HeaderLibraries": "Biblioteki",
@@ -104,6 +105,9 @@
"HeaderListeningStats": "Statystyki odtwarzania", "HeaderListeningStats": "Statystyki odtwarzania",
"HeaderLogin": "Zaloguj się", "HeaderLogin": "Zaloguj się",
"HeaderLogs": "Logi", "HeaderLogs": "Logi",
"HeaderManageGenres": "Manage Genres",
"HeaderManageTags": "Manage Tags",
"HeaderMapDetails": "Map details",
"HeaderMatch": "Dopasuj", "HeaderMatch": "Dopasuj",
"HeaderMetadataToEmbed": "Osadź metadane", "HeaderMetadataToEmbed": "Osadź metadane",
"HeaderNewAccount": "Nowe konto", "HeaderNewAccount": "Nowe konto",
@@ -155,6 +159,7 @@
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist", "LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
"LabelAll": "All", "LabelAll": "All",
"LabelAllUsers": "Wszyscy użytkownicy", "LabelAllUsers": "Wszyscy użytkownicy",
"LabelAppend": "Append",
"LabelAuthor": "Autor", "LabelAuthor": "Autor",
"LabelAuthorFirstLast": "Autor (Rosnąco)", "LabelAuthorFirstLast": "Autor (Rosnąco)",
"LabelAuthorLastFirst": "Author (Malejąco)", "LabelAuthorLastFirst": "Author (Malejąco)",
@@ -278,6 +283,7 @@
"LabelNumberOfBooks": "Liczba książek", "LabelNumberOfBooks": "Liczba książek",
"LabelNumberOfEpisodes": "# odcinków", "LabelNumberOfEpisodes": "# odcinków",
"LabelOpenRSSFeed": "Otwórz kanał RSS", "LabelOpenRSSFeed": "Otwórz kanał RSS",
"LabelOverwrite": "Overwrite",
"LabelPassword": "Hasło", "LabelPassword": "Hasło",
"LabelPath": "Ścieżka", "LabelPath": "Ścieżka",
"LabelPermissionsAccessAllLibraries": "Ma dostęp do wszystkich bibliotek", "LabelPermissionsAccessAllLibraries": "Ma dostęp do wszystkich bibliotek",
@@ -439,6 +445,12 @@
"MessageConfirmRemoveEpisode": "Czy na pewno chcesz usunąć odcinek \"{0}\"?", "MessageConfirmRemoveEpisode": "Czy na pewno chcesz usunąć odcinek \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Czy na pewno chcesz usunąć {0} odcinki?", "MessageConfirmRemoveEpisodes": "Czy na pewno chcesz usunąć {0} odcinki?",
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?", "MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",
"MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".",
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".",
"MessageDownloadingEpisode": "Pobieranie odcinka", "MessageDownloadingEpisode": "Pobieranie odcinka",
"MessageDragFilesIntoTrackOrder": "przeciągnij pliki aby ustawić właściwą kolejność utworów", "MessageDragFilesIntoTrackOrder": "przeciągnij pliki aby ustawić właściwą kolejność utworów",
"MessageEmbedFinished": "Osadzanie zakończone!", "MessageEmbedFinished": "Osadzanie zakończone!",
@@ -449,6 +461,7 @@
"MessageImportantNotice": "Ważna informacja!", "MessageImportantNotice": "Ważna informacja!",
"MessageInsertChapterBelow": "Wstaw rozdział poniżej", "MessageInsertChapterBelow": "Wstaw rozdział poniżej",
"MessageItemsSelected": "{0} zaznaczone elementy", "MessageItemsSelected": "{0} zaznaczone elementy",
"MessageItemsUpdated": "{0} Items Updated",
"MessageJoinUsOn": "Dołącz do nas na", "MessageJoinUsOn": "Dołącz do nas na",
"MessageListeningSessionsInTheLastYear": "{0} sesje odsłuchowe w ostatnim roku", "MessageListeningSessionsInTheLastYear": "{0} sesje odsłuchowe w ostatnim roku",
"MessageLoading": "Ładowanie...", "MessageLoading": "Ładowanie...",
@@ -482,6 +495,7 @@
"MessageNoResults": "Brak wyników", "MessageNoResults": "Brak wyników",
"MessageNoSearchResultsFor": "Brak wyników wyszukiwania dla \"{0}\"", "MessageNoSearchResultsFor": "Brak wyników wyszukiwania dla \"{0}\"",
"MessageNoSeries": "No Series", "MessageNoSeries": "No Series",
"MessageNoTags": "No Tags",
"MessageNotYetImplemented": "Jeszcze nie zaimplementowane", "MessageNotYetImplemented": "Jeszcze nie zaimplementowane",
"MessageNoUpdateNecessary": "Brak konieczności aktualizacji", "MessageNoUpdateNecessary": "Brak konieczności aktualizacji",
"MessageNoUpdatesWereNecessary": "Brak aktualizacji", "MessageNoUpdatesWereNecessary": "Brak aktualizacji",
@@ -489,6 +503,7 @@
"MessageOr": "lub", "MessageOr": "lub",
"MessagePauseChapter": "Zatrzymaj odtwarzanie rozdziały", "MessagePauseChapter": "Zatrzymaj odtwarzanie rozdziały",
"MessagePlayChapter": "Rozpocznij odtwarzanie od początku rozdziału", "MessagePlayChapter": "Rozpocznij odtwarzanie od początku rozdziału",
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
"MessagePodcastHasNoRSSFeedForMatching": "Podcast nie ma adresu url kanału RSS, który mógłby zostać użyty do dopasowania", "MessagePodcastHasNoRSSFeedForMatching": "Podcast nie ma adresu url kanału RSS, który mógłby zostać użyty do dopasowania",
"MessageQuickMatchDescription": "Wypełnij puste informacje i okładkę pierwszym wynikiem dopasowania z '{0}'. Nie nadpisuje szczegółów, chyba że włączone jest ustawienie serwera 'Preferuj dopasowane metadane'.", "MessageQuickMatchDescription": "Wypełnij puste informacje i okładkę pierwszym wynikiem dopasowania z '{0}'. Nie nadpisuje szczegółów, chyba że włączone jest ustawienie serwera 'Preferuj dopasowane metadane'.",
"MessageRemoveAllItemsWarning": "UWAGA! Ta akcja usunie wszystkie elementy biblioteki z bazy danych, w tym wszystkie aktualizacje lub dopasowania, które zostały wykonane. Pliki pozostaną niezmienione. Czy jesteś pewien?", "MessageRemoveAllItemsWarning": "UWAGA! Ta akcja usunie wszystkie elementy biblioteki z bazy danych, w tym wszystkie aktualizacje lub dopasowania, które zostały wykonane. Pliki pozostaną niezmienione. Czy jesteś pewien?",
@@ -574,6 +589,8 @@
"ToastLibraryScanStarted": "Rozpoczęto skanowanie biblioteki", "ToastLibraryScanStarted": "Rozpoczęto skanowanie biblioteki",
"ToastLibraryUpdateFailed": "Nie udało się zaktualizować biblioteki", "ToastLibraryUpdateFailed": "Nie udało się zaktualizować biblioteki",
"ToastLibraryUpdateSuccess": "Zaktualizowano \"{0}\" pozycji", "ToastLibraryUpdateSuccess": "Zaktualizowano \"{0}\" pozycji",
"ToastPlaylistCreateFailed": "Failed to create playlist",
"ToastPlaylistCreateSuccess": "Playlist created",
"ToastPlaylistRemoveFailed": "Failed to remove playlist", "ToastPlaylistRemoveFailed": "Failed to remove playlist",
"ToastPlaylistRemoveSuccess": "Playlist removed", "ToastPlaylistRemoveSuccess": "Playlist removed",
"ToastPlaylistUpdateFailed": "Failed to update playlist", "ToastPlaylistUpdateFailed": "Failed to update playlist",
+18 -1
View File
@@ -95,6 +95,7 @@
"HeaderFindChapters": "查找章节", "HeaderFindChapters": "查找章节",
"HeaderIgnoredFiles": "忽略的文件", "HeaderIgnoredFiles": "忽略的文件",
"HeaderItemFiles": "项目文件", "HeaderItemFiles": "项目文件",
"HeaderItemMetadataUtils": "Item Metadata Utils",
"HeaderLastListeningSession": "最后一次收听会话", "HeaderLastListeningSession": "最后一次收听会话",
"HeaderLatestEpisodes": "最新剧集", "HeaderLatestEpisodes": "最新剧集",
"HeaderLibraries": "媒体库", "HeaderLibraries": "媒体库",
@@ -104,6 +105,9 @@
"HeaderListeningStats": "收听统计数据", "HeaderListeningStats": "收听统计数据",
"HeaderLogin": "登录", "HeaderLogin": "登录",
"HeaderLogs": "日志", "HeaderLogs": "日志",
"HeaderManageGenres": "Manage Genres",
"HeaderManageTags": "Manage Tags",
"HeaderMapDetails": "Map details",
"HeaderMatch": "匹配", "HeaderMatch": "匹配",
"HeaderMetadataToEmbed": "嵌入元数据", "HeaderMetadataToEmbed": "嵌入元数据",
"HeaderNewAccount": "新建帐户", "HeaderNewAccount": "新建帐户",
@@ -155,6 +159,7 @@
"LabelAddToPlaylistBatch": "添加 {0} 个项目到播放列表", "LabelAddToPlaylistBatch": "添加 {0} 个项目到播放列表",
"LabelAll": "全部", "LabelAll": "全部",
"LabelAllUsers": "所有用户", "LabelAllUsers": "所有用户",
"LabelAppend": "Append",
"LabelAuthor": "作者", "LabelAuthor": "作者",
"LabelAuthorFirstLast": "作者 (姓 名)", "LabelAuthorFirstLast": "作者 (姓 名)",
"LabelAuthorLastFirst": "作者 (名, 姓)", "LabelAuthorLastFirst": "作者 (名, 姓)",
@@ -278,6 +283,7 @@
"LabelNumberOfBooks": "图书数量", "LabelNumberOfBooks": "图书数量",
"LabelNumberOfEpisodes": "# 集", "LabelNumberOfEpisodes": "# 集",
"LabelOpenRSSFeed": "打开 RSS 源", "LabelOpenRSSFeed": "打开 RSS 源",
"LabelOverwrite": "Overwrite",
"LabelPassword": "密码", "LabelPassword": "密码",
"LabelPath": "路径", "LabelPath": "路径",
"LabelPermissionsAccessAllLibraries": "可以访问所有媒体库", "LabelPermissionsAccessAllLibraries": "可以访问所有媒体库",
@@ -439,6 +445,12 @@
"MessageConfirmRemoveEpisode": "您确定要移除剧集 \"{0}\"?", "MessageConfirmRemoveEpisode": "您确定要移除剧集 \"{0}\"?",
"MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?", "MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?",
"MessageConfirmRemovePlaylist": "你确定要移除播放列表 \"{0}\"?", "MessageConfirmRemovePlaylist": "你确定要移除播放列表 \"{0}\"?",
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",
"MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".",
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?",
"MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".",
"MessageDownloadingEpisode": "正在下载剧集", "MessageDownloadingEpisode": "正在下载剧集",
"MessageDragFilesIntoTrackOrder": "将文件拖动到正确的音轨顺序", "MessageDragFilesIntoTrackOrder": "将文件拖动到正确的音轨顺序",
"MessageEmbedFinished": "嵌入完成!", "MessageEmbedFinished": "嵌入完成!",
@@ -449,6 +461,7 @@
"MessageImportantNotice": "重要通知!", "MessageImportantNotice": "重要通知!",
"MessageInsertChapterBelow": "在下面插入章节", "MessageInsertChapterBelow": "在下面插入章节",
"MessageItemsSelected": "已选定 {0} 个项目", "MessageItemsSelected": "已选定 {0} 个项目",
"MessageItemsUpdated": "{0} Items Updated",
"MessageJoinUsOn": "加入我们", "MessageJoinUsOn": "加入我们",
"MessageListeningSessionsInTheLastYear": "去年收听 {0} 个会话", "MessageListeningSessionsInTheLastYear": "去年收听 {0} 个会话",
"MessageLoading": "加载...", "MessageLoading": "加载...",
@@ -482,6 +495,7 @@
"MessageNoResults": "无结果", "MessageNoResults": "无结果",
"MessageNoSearchResultsFor": "没有搜索到结果 \"{0}\"", "MessageNoSearchResultsFor": "没有搜索到结果 \"{0}\"",
"MessageNoSeries": "无系列", "MessageNoSeries": "无系列",
"MessageNoTags": "No Tags",
"MessageNotYetImplemented": "尚未实施", "MessageNotYetImplemented": "尚未实施",
"MessageNoUpdateNecessary": "无需更新", "MessageNoUpdateNecessary": "无需更新",
"MessageNoUpdatesWereNecessary": "无需更新", "MessageNoUpdatesWereNecessary": "无需更新",
@@ -489,6 +503,7 @@
"MessageOr": "或", "MessageOr": "或",
"MessagePauseChapter": "暂停章节播放", "MessagePauseChapter": "暂停章节播放",
"MessagePlayChapter": "开始章节播放", "MessagePlayChapter": "开始章节播放",
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
"MessagePodcastHasNoRSSFeedForMatching": "播客没有可用于匹配 RSS 源的 url", "MessagePodcastHasNoRSSFeedForMatching": "播客没有可用于匹配 RSS 源的 url",
"MessageQuickMatchDescription": "使用来自 '{0}' 的第一个匹配结果填充空白详细信息和封面. 除非启用 '首选匹配元数据' 服务器设置, 否则不会覆盖详细信息.", "MessageQuickMatchDescription": "使用来自 '{0}' 的第一个匹配结果填充空白详细信息和封面. 除非启用 '首选匹配元数据' 服务器设置, 否则不会覆盖详细信息.",
"MessageRemoveAllItemsWarning": "警告! 此操作将从数据库中删除所有的媒体库项, 包括您所做的任何更新或匹配. 这不会对实际文件产生任何影响. 你确定吗?", "MessageRemoveAllItemsWarning": "警告! 此操作将从数据库中删除所有的媒体库项, 包括您所做的任何更新或匹配. 这不会对实际文件产生任何影响. 你确定吗?",
@@ -574,6 +589,8 @@
"ToastLibraryScanStarted": "媒体库扫描已启动", "ToastLibraryScanStarted": "媒体库扫描已启动",
"ToastLibraryUpdateFailed": "更新图书库失败", "ToastLibraryUpdateFailed": "更新图书库失败",
"ToastLibraryUpdateSuccess": "媒体库 \"{0}\" 已更新", "ToastLibraryUpdateSuccess": "媒体库 \"{0}\" 已更新",
"ToastPlaylistCreateFailed": "Failed to create playlist",
"ToastPlaylistCreateSuccess": "Playlist created",
"ToastPlaylistRemoveFailed": "删除播放列表失败", "ToastPlaylistRemoveFailed": "删除播放列表失败",
"ToastPlaylistRemoveSuccess": "播放列表已删除", "ToastPlaylistRemoveSuccess": "播放列表已删除",
"ToastPlaylistUpdateFailed": "更新播放列表失败", "ToastPlaylistUpdateFailed": "更新播放列表失败",
@@ -598,4 +615,4 @@
"WeekdayThursday": "星期四", "WeekdayThursday": "星期四",
"WeekdayTuesday": "星期二", "WeekdayTuesday": "星期二",
"WeekdayWednesday": "星期三" "WeekdayWednesday": "星期三"
} }
+1 -1
View File
@@ -15,7 +15,7 @@ if (isDev) {
} }
const PORT = process.env.PORT || 80 const PORT = process.env.PORT || 80
const HOST = process.env.HOST || '0.0.0.0' const HOST = process.env.HOST
const CONFIG_PATH = process.env.CONFIG_PATH || '/config' const CONFIG_PATH = process.env.CONFIG_PATH || '/config'
const METADATA_PATH = process.env.METADATA_PATH || '/metadata' const METADATA_PATH = process.env.METADATA_PATH || '/metadata'
const UID = process.env.AUDIOBOOKSHELF_UID || 99 const UID = process.env.AUDIOBOOKSHELF_UID || 99
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.2.7", "version": "2.2.9",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.2.7", "version": "2.2.9",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"axios": "^0.26.1", "axios": "^0.26.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.2.7", "version": "2.2.9",
"description": "Self-hosted audiobook and podcast server", "description": "Self-hosted audiobook and podcast server",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
+5 -1
View File
@@ -2,6 +2,7 @@ const Path = require('path')
const njodb = require('./libs/njodb') const njodb = require('./libs/njodb')
const Logger = require('./Logger') const Logger = require('./Logger')
const { version } = require('../package.json') const { version } = require('../package.json')
const filePerms = require('./utils/filePerms')
const LibraryItem = require('./objects/LibraryItem') const LibraryItem = require('./objects/LibraryItem')
const User = require('./objects/user/User') const User = require('./objects/user/User')
const Collection = require('./objects/Collection') const Collection = require('./objects/Collection')
@@ -131,6 +132,9 @@ class Db {
async init() { async init() {
await this.load() await this.load()
// Set file ownership for all files created by db
await filePerms.setDefault(global.ConfigPath, true)
if (!this.serverSettings) { // Create first load server settings if (!this.serverSettings) { // Create first load server settings
this.serverSettings = new ServerSettings() this.serverSettings = new ServerSettings()
await this.insertEntity('settings', this.serverSettings) await this.insertEntity('settings', this.serverSettings)
@@ -229,7 +233,7 @@ class Db {
return null return null
})) }))
var libraryItemIds = libraryItems.map(li => li.id) const libraryItemIds = libraryItems.map(li => li.id)
return this.libraryItemsDb.update((record) => libraryItemIds.includes(record.id), (record) => { return this.libraryItemsDb.update((record) => libraryItemIds.includes(record.id), (record) => {
return libraryItems.find(li => li.id === record.id) return libraryItems.find(li => li.id === record.id)
}).then((results) => { }).then((results) => {
+3 -1
View File
@@ -206,6 +206,7 @@ class Server {
'/library/:library/podcast/latest', '/library/:library/podcast/latest',
'/config/users/:id', '/config/users/:id',
'/config/users/:id/sessions', '/config/users/:id/sessions',
'/config/item-metadata-utils/:id',
'/collection/:id', '/collection/:id',
'/playlist/:id' '/playlist/:id'
] ]
@@ -240,7 +241,8 @@ class Server {
app.get('/healthcheck', (req, res) => res.sendStatus(200)) app.get('/healthcheck', (req, res) => res.sendStatus(200))
this.server.listen(this.Port, this.Host, () => { this.server.listen(this.Port, this.Host, () => {
Logger.info(`Listening on http://${this.Host}:${this.Port}`) if (this.Host) Logger.info(`Listening on http://${this.Host}:${this.Port}`)
else Logger.info(`Listening on port :${this.Port}`)
}) })
// Start listening for socket connections // Start listening for socket connections
+3 -1
View File
@@ -148,7 +148,9 @@ class AuthorController {
var limit = (req.query.limit && !isNaN(req.query.limit)) ? Number(req.query.limit) : 25 var limit = (req.query.limit && !isNaN(req.query.limit)) ? Number(req.query.limit) : 25
var authors = this.db.authors.filter(au => au.name.toLowerCase().includes(q)) var authors = this.db.authors.filter(au => au.name.toLowerCase().includes(q))
authors = authors.slice(0, limit) authors = authors.slice(0, limit)
res.json(authors) res.json({
results: authors
})
} }
async match(req, res) { async match(req, res) {
+4 -3
View File
@@ -20,8 +20,9 @@ class CollectionController {
} }
findAll(req, res) { findAll(req, res) {
var expandedCollections = this.db.collections.map(c => c.toJSONExpanded(this.db.libraryItems)) res.json({
res.json(expandedCollections) collections: this.db.collections.map(c => c.toJSONExpanded(this.db.libraryItems))
})
} }
findOne(req, res) { findOne(req, res) {
@@ -122,7 +123,7 @@ class CollectionController {
middleware(req, res, next) { middleware(req, res, next) {
if (req.params.id) { if (req.params.id) {
var collection = this.db.collections.find(c => c.id === req.params.id) const collection = this.db.collections.find(c => c.id === req.params.id)
if (!collection) { if (!collection) {
return res.status(404).send('Collection not found') return res.status(404).send('Collection not found')
} }
+3 -2
View File
@@ -19,8 +19,9 @@ class FileSystemController {
}) })
Logger.debug(`[Server] get file system paths, excluded: ${excludedDirs.join(', ')}`) Logger.debug(`[Server] get file system paths, excluded: ${excludedDirs.join(', ')}`)
var dirs = await this.getDirectories(global.appRoot, '/', excludedDirs) res.json({
res.json(dirs) directories: await this.getDirectories(global.appRoot, '/', excludedDirs)
})
} }
} }
module.exports = new FileSystemController() module.exports = new FileSystemController()
+9 -4
View File
@@ -62,7 +62,9 @@ class LibraryController {
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()))
} }
res.json(this.db.libraries.map(lib => lib.toJSON())) res.json({
libraries: this.db.libraries.map(lib => lib.toJSON())
})
} }
async findOne(req, res) { async findOne(req, res) {
@@ -496,8 +498,9 @@ class LibraryController {
Logger.debug(`[LibraryController] Library orders were up to date`) Logger.debug(`[LibraryController] Library orders were up to date`)
} }
var libraries = this.db.libraries.map(lib => lib.toJSON()) res.json({
res.json(libraries) libraries: this.db.libraries.map(lib => lib.toJSON())
})
} }
// GET: Global library search // GET: Global library search
@@ -603,7 +606,9 @@ class LibraryController {
} }
}) })
res.json(naturalSort(Object.values(authors)).asc(au => au.name)) res.json({
authors: naturalSort(Object.values(authors)).asc(au => au.name)
})
} }
async matchAll(req, res) { async matchAll(req, res) {
+15 -4
View File
@@ -1,3 +1,4 @@
const fs = require('../libs/fsExtra')
const Logger = require('../Logger') const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
@@ -178,7 +179,15 @@ class LibraryItemController {
// GET api/items/:id/cover // GET api/items/:id/cover
async getCover(req, res) { async getCover(req, res) {
let { query: { width, height, format }, libraryItem } = req const { query: { width, height, format, raw }, libraryItem } = req
if (raw) { // any value
if (!libraryItem.media.coverPath || !await fs.pathExists(libraryItem.media.coverPath)) {
return res.sendStatus(404)
}
return res.sendFile(libraryItem.media.coverPath)
}
const options = { const options = {
format: format || (reqSupportsWebp(req) ? 'webp' : 'jpeg'), format: format || (reqSupportsWebp(req) ? 'webp' : 'jpeg'),
@@ -299,16 +308,18 @@ class LibraryItemController {
// POST: api/items/batch/get // POST: api/items/batch/get
async batchGet(req, res) { async batchGet(req, res) {
var libraryItemIds = req.body.libraryItemIds || [] const libraryItemIds = req.body.libraryItemIds || []
if (!libraryItemIds.length) { if (!libraryItemIds.length) {
return res.status(403).send('Invalid payload') return res.status(403).send('Invalid payload')
} }
var libraryItems = [] const libraryItems = []
libraryItemIds.forEach((lid) => { libraryItemIds.forEach((lid) => {
const li = this.db.libraryItems.find(_li => _li.id === lid) const li = this.db.libraryItems.find(_li => _li.id === lid)
if (li) libraryItems.push(li.toJSONExpanded()) if (li) libraryItems.push(li.toJSONExpanded())
}) })
res.json(libraryItems) res.json({
libraryItems
})
} }
// POST: api/items/batch/quickmatch // POST: api/items/batch/quickmatch
+1
View File
@@ -167,6 +167,7 @@ class MeController {
this.auth.userChangePassword(req, res) this.auth.userChangePassword(req, res)
} }
// TODO: Remove after mobile release v0.9.61-beta
// PATCH: api/me/settings // PATCH: api/me/settings
async updateSettings(req, res) { async updateSettings(req, res) {
var settingsUpdate = req.body var settingsUpdate = req.body
+160 -2
View File
@@ -1,6 +1,8 @@
const Path = require('path') const Path = require('path')
const fs = require('../libs/fsExtra') const fs = require('../libs/fsExtra')
const Logger = require('../Logger') const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const filePerms = require('../utils/filePerms') const filePerms = require('../utils/filePerms')
const patternValidation = require('../libs/nodeCron/pattern-validation') const patternValidation = require('../libs/nodeCron/pattern-validation')
const { isObject } = require('../utils/index') const { isObject } = require('../utils/index')
@@ -124,12 +126,13 @@ class MiscController {
res.json(userResponse) res.json(userResponse)
} }
// GET: api/tags
getAllTags(req, res) { getAllTags(req, res) {
if (!req.user.isAdminOrUp) { if (!req.user.isAdminOrUp) {
Logger.error(`[MiscController] Non-admin user attempted to getAllTags`) Logger.error(`[MiscController] Non-admin user attempted to getAllTags`)
return res.sendStatus(404) return res.sendStatus(404)
} }
var tags = [] const tags = []
this.db.libraryItems.forEach((li) => { this.db.libraryItems.forEach((li) => {
if (li.media.tags && li.media.tags.length) { if (li.media.tags && li.media.tags.length) {
li.media.tags.forEach((tag) => { li.media.tags.forEach((tag) => {
@@ -137,7 +140,162 @@ class MiscController {
}) })
} }
}) })
res.json(tags) res.json({
tags: tags
})
}
// POST: api/tags/rename
async renameTag(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[MiscController] Non-admin user attempted to renameTag`)
return res.sendStatus(404)
}
const tag = req.body.tag
const newTag = req.body.newTag
if (!tag || !newTag) {
Logger.error(`[MiscController] Invalid request body for renameTag`)
return res.sendStatus(400)
}
let tagMerged = false
let numItemsUpdated = 0
for (const li of this.db.libraryItems) {
if (!li.media.tags || !li.media.tags.length) continue
if (li.media.tags.includes(newTag)) tagMerged = true // new tag is an existing tag so this is a merge
if (li.media.tags.includes(tag)) {
li.media.tags = li.media.tags.filter(t => t !== tag) // Remove old tag
if (!li.media.tags.includes(newTag)) {
li.media.tags.push(newTag) // Add new tag
}
Logger.debug(`[MiscController] Rename tag "${tag}" to "${newTag}" for item "${li.media.metadata.title}"`)
await this.db.updateLibraryItem(li)
SocketAuthority.emitter('item_updated', li.toJSONExpanded())
numItemsUpdated++
}
}
res.json({
tagMerged,
numItemsUpdated
})
}
// DELETE: api/tags/:tag
async deleteTag(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[MiscController] Non-admin user attempted to deleteTag`)
return res.sendStatus(404)
}
const tag = Buffer.from(decodeURIComponent(req.params.tag), 'base64').toString()
let numItemsUpdated = 0
for (const li of this.db.libraryItems) {
if (!li.media.tags || !li.media.tags.length) continue
if (li.media.tags.includes(tag)) {
li.media.tags = li.media.tags.filter(t => t !== tag)
Logger.debug(`[MiscController] Remove tag "${tag}" from item "${li.media.metadata.title}"`)
await this.db.updateLibraryItem(li)
SocketAuthority.emitter('item_updated', li.toJSONExpanded())
numItemsUpdated++
}
}
res.json({
numItemsUpdated
})
}
// GET: api/genres
getAllGenres(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[MiscController] Non-admin user attempted to getAllGenres`)
return res.sendStatus(404)
}
const genres = []
this.db.libraryItems.forEach((li) => {
if (li.media.metadata.genres && li.media.metadata.genres.length) {
li.media.metadata.genres.forEach((genre) => {
if (!genres.includes(genre)) genres.push(genre)
})
}
})
res.json({
genres
})
}
// POST: api/genres/rename
async renameGenre(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[MiscController] Non-admin user attempted to renameGenre`)
return res.sendStatus(404)
}
const genre = req.body.genre
const newGenre = req.body.newGenre
if (!genre || !newGenre) {
Logger.error(`[MiscController] Invalid request body for renameGenre`)
return res.sendStatus(400)
}
let genreMerged = false
let numItemsUpdated = 0
for (const li of this.db.libraryItems) {
if (!li.media.metadata.genres || !li.media.metadata.genres.length) continue
if (li.media.metadata.genres.includes(newGenre)) genreMerged = true // new genre is an existing genre so this is a merge
if (li.media.metadata.genres.includes(genre)) {
li.media.metadata.genres = li.media.metadata.genres.filter(g => g !== genre) // Remove old genre
if (!li.media.metadata.genres.includes(newGenre)) {
li.media.metadata.genres.push(newGenre) // Add new genre
}
Logger.debug(`[MiscController] Rename genre "${genre}" to "${newGenre}" for item "${li.media.metadata.title}"`)
await this.db.updateLibraryItem(li)
SocketAuthority.emitter('item_updated', li.toJSONExpanded())
numItemsUpdated++
}
}
res.json({
genreMerged,
numItemsUpdated
})
}
// DELETE: api/genres/:genre
async deleteGenre(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[MiscController] Non-admin user attempted to deleteGenre`)
return res.sendStatus(404)
}
const genre = Buffer.from(decodeURIComponent(req.params.genre), 'base64').toString()
let numItemsUpdated = 0
for (const li of this.db.libraryItems) {
if (!li.media.metadata.genres || !li.media.metadata.genres.length) continue
if (li.media.metadata.genres.includes(genre)) {
li.media.metadata.genres = li.media.metadata.genres.filter(t => t !== genre)
Logger.debug(`[MiscController] Remove genre "${genre}" from item "${li.media.metadata.title}"`)
await this.db.updateLibraryItem(li)
SocketAuthority.emitter('item_updated', li.toJSONExpanded())
numItemsUpdated++
}
}
res.json({
numItemsUpdated
})
} }
validateCronExpression(req, res) { validateCronExpression(req, res) {
+34 -1
View File
@@ -174,9 +174,42 @@ class PlaylistController {
res.json(jsonExpanded) res.json(jsonExpanded)
} }
// POST: api/playlists/collection/:collectionId
async createFromCollection(req, res) {
let collection = this.db.collections.find(c => c.id === req.params.collectionId)
if (!collection) {
return res.status(404).send('Collection not found')
}
// Expand collection to get library items
collection = collection.toJSONExpanded(this.db.libraryItems)
// Filter out library items not accessible to user
const libraryItems = collection.books.filter(item => req.user.checkCanAccessLibraryItem(item))
if (!libraryItems.length) {
return res.status(400).send('Collection has no books accessible to user')
}
const newPlaylist = new Playlist()
const newPlaylistData = {
userId: req.user.id,
libraryId: collection.libraryId,
name: collection.name,
description: collection.description || null,
items: libraryItems.map(li => ({ libraryItemId: li.id }))
}
newPlaylist.setData(newPlaylistData)
const jsonExpanded = newPlaylist.toJSONExpanded(this.db.libraryItems)
await this.db.insertEntity('playlist', newPlaylist)
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
res.json(jsonExpanded)
}
middleware(req, res, next) { middleware(req, res, next) {
if (req.params.id) { if (req.params.id) {
var playlist = this.db.playlists.find(p => p.id === req.params.id) const playlist = this.db.playlists.find(p => p.id === req.params.id)
if (!playlist) { if (!playlist) {
return res.status(404).send('Playlist not found') return res.status(404).send('Playlist not found')
} }
+18 -16
View File
@@ -4,15 +4,15 @@ class SearchController {
constructor() { } constructor() { }
async findBooks(req, res) { async findBooks(req, res) {
var provider = req.query.provider || 'google' const provider = req.query.provider || 'google'
var title = req.query.title || '' const title = req.query.title || ''
var author = req.query.author || '' const author = req.query.author || ''
var results = await this.bookFinder.search(provider, title, author) const results = await this.bookFinder.search(provider, title, author)
res.json(results) res.json(results)
} }
async findCovers(req, res) { async findCovers(req, res) {
var query = req.query const query = req.query
const podcast = query.podcast == 1 const podcast = query.podcast == 1
if (!query.title) { if (!query.title) {
@@ -20,28 +20,30 @@ class SearchController {
return res.sendStatus(400) return res.sendStatus(400)
} }
var result = null let results = null
if (podcast) result = await this.podcastFinder.findCovers(query.title) if (podcast) results = await this.podcastFinder.findCovers(query.title)
else result = await this.bookFinder.findCovers(query.provider || 'google', query.title, query.author || null) else results = await this.bookFinder.findCovers(query.provider || 'google', query.title, query.author || null)
res.json(result) res.json({
results
})
} }
async findPodcasts(req, res) { async findPodcasts(req, res) {
var term = req.query.term const term = req.query.term
var results = await this.podcastFinder.search(term) const results = await this.podcastFinder.search(term)
res.json(results) res.json(results)
} }
async findAuthor(req, res) { async findAuthor(req, res) {
var query = req.query.q const query = req.query.q
var author = await this.authorFinder.findAuthorByName(query) const author = await this.authorFinder.findAuthorByName(query)
res.json(author) res.json(author)
} }
async findChapters(req, res) { async findChapters(req, res) {
var asin = req.query.asin const asin = req.query.asin
var region = (req.query.region || 'us').toLowerCase() const region = (req.query.region || 'us').toLowerCase()
var chapterData = await this.bookFinder.findChapters(asin, region) const chapterData = await this.bookFinder.findChapters(asin, region)
if (!chapterData) { if (!chapterData) {
return res.json({ error: 'Chapters not found' }) return res.json({ error: 'Chapters not found' })
} }
+3 -1
View File
@@ -32,7 +32,9 @@ class SeriesController {
var limit = (req.query.limit && !isNaN(req.query.limit)) ? Number(req.query.limit) : 25 var limit = (req.query.limit && !isNaN(req.query.limit)) ? Number(req.query.limit) : 25
var series = this.db.series.filter(se => se.name.toLowerCase().includes(q)) var series = this.db.series.filter(se => se.name.toLowerCase().includes(q))
series = series.slice(0, limit) series = series.slice(0, limit)
res.json(series) res.json({
results: series
})
} }
async update(req, res) { async update(req, res) {
+3 -1
View File
@@ -12,7 +12,9 @@ class UserController {
if (!req.user.isAdminOrUp) return res.sendStatus(403) if (!req.user.isAdminOrUp) return res.sendStatus(403)
const hideRootToken = !req.user.isRoot const hideRootToken = !req.user.isRoot
const users = this.db.users.map(u => this.userJsonWithItemProgressDetails(u, hideRootToken)) const users = this.db.users.map(u => this.userJsonWithItemProgressDetails(u, hideRootToken))
res.json(users) res.json({
users: users
})
} }
findOne(req, res) { findOne(req, res) {
+2 -2
View File
@@ -47,7 +47,7 @@ class CacheManager {
res.type(`image/${format}`) res.type(`image/${format}`)
var path = Path.join(this.CoverCachePath, `${libraryItem.id}_${width}${height ? `x${height}` : ''}`) + '.' + format const path = Path.join(this.CoverCachePath, `${libraryItem.id}_${width}${height ? `x${height}` : ''}`) + '.' + format
// Cache exists // Cache exists
if (await fs.pathExists(path)) { if (await fs.pathExists(path)) {
@@ -66,7 +66,7 @@ class CacheManager {
return res.sendStatus(500) return res.sendStatus(500)
} }
let writtenFile = await resizeImage(libraryItem.media.coverPath, path, width, height) const writtenFile = await resizeImage(libraryItem.media.coverPath, path, width, height)
if (!writtenFile) return res.sendStatus(500) if (!writtenFile) return res.sendStatus(500)
// Set owner and permissions of cache image // Set owner and permissions of cache image
+29 -15
View File
@@ -1,5 +1,6 @@
const Path = require('path') const Path = require('path')
const fs = require('../libs/fsExtra') const fs = require('../libs/fsExtra')
const filePerms = require('../utils/filePerms')
const DailyLog = require('../objects/DailyLog') const DailyLog = require('../objects/DailyLog')
@@ -11,8 +12,8 @@ class LogManager {
constructor(db) { constructor(db) {
this.db = db this.db = db
this.logDirPath = Path.join(global.MetadataPath, 'logs') this.DailyLogPath = Path.posix.join(global.MetadataPath, 'logs', 'daily')
this.dailyLogDirPath = Path.join(this.logDirPath, 'daily') this.ScanLogPath = Path.posix.join(global.MetadataPath, 'logs', 'scans')
this.currentDailyLog = null this.currentDailyLog = null
this.dailyLogBuffer = [] this.dailyLogBuffer = []
@@ -27,24 +28,38 @@ class LogManager {
return this.serverSettings.loggerDailyLogsToKeep || 7 return this.serverSettings.loggerDailyLogsToKeep || 7
} }
async ensureLogDirs() {
await fs.ensureDir(this.DailyLogPath)
await fs.ensureDir(this.ScanLogPath)
await filePerms.setDefault(Path.posix.join(global.MetadataPath, 'logs'), true)
}
async ensureScanLogDir() {
if (!(await fs.pathExists(this.ScanLogPath))) {
await fs.mkdir(this.ScanLogPath)
await filePerms.setDefault(this.ScanLogPath)
}
}
async init() { async init() {
await this.ensureLogDirs()
// Load daily logs // Load daily logs
await this.scanLogFiles() await this.scanLogFiles()
// Check remove extra daily logs // Check remove extra daily logs
if (this.dailyLogFiles.length > this.loggerDailyLogsToKeep) { if (this.dailyLogFiles.length > this.loggerDailyLogsToKeep) {
var dailyLogFilesCopy = [...this.dailyLogFiles] const dailyLogFilesCopy = [...this.dailyLogFiles]
for (let i = 0; i < dailyLogFilesCopy.length - this.loggerDailyLogsToKeep; i++) { for (let i = 0; i < dailyLogFilesCopy.length - this.loggerDailyLogsToKeep; i++) {
var logFileToRemove = dailyLogFilesCopy[i] await this.removeLogFile(dailyLogFilesCopy[i])
await this.removeLogFile(logFileToRemove)
} }
} }
var currentDailyLogFilename = DailyLog.getCurrentDailyLogFilename() const currentDailyLogFilename = DailyLog.getCurrentDailyLogFilename()
Logger.info(TAG, `Init current daily log filename: ${currentDailyLogFilename}`) Logger.info(TAG, `Init current daily log filename: ${currentDailyLogFilename}`)
this.currentDailyLog = new DailyLog() this.currentDailyLog = new DailyLog()
this.currentDailyLog.setData({ dailyLogDirPath: this.dailyLogDirPath }) this.currentDailyLog.setData({ dailyLogDirPath: this.DailyLogPath })
if (this.dailyLogFiles.includes(currentDailyLogFilename)) { if (this.dailyLogFiles.includes(currentDailyLogFilename)) {
Logger.debug(TAG, `Daily log file already exists - set in Logger`) Logger.debug(TAG, `Daily log file already exists - set in Logger`)
@@ -63,8 +78,7 @@ class LogManager {
} }
async scanLogFiles() { async scanLogFiles() {
await fs.ensureDir(this.dailyLogDirPath) const dailyFiles = await fs.readdir(this.DailyLogPath)
var dailyFiles = await fs.readdir(this.dailyLogDirPath)
if (dailyFiles && dailyFiles.length) { if (dailyFiles && dailyFiles.length) {
dailyFiles.forEach((logFile) => { dailyFiles.forEach((logFile) => {
if (Path.extname(logFile) === '.txt') { if (Path.extname(logFile) === '.txt') {
@@ -80,13 +94,13 @@ class LogManager {
async removeOldestLog() { async removeOldestLog() {
if (!this.dailyLogFiles.length) return if (!this.dailyLogFiles.length) return
var oldestLog = this.dailyLogFiles[0] const oldestLog = this.dailyLogFiles[0]
return this.removeLogFile(oldestLog) return this.removeLogFile(oldestLog)
} }
async removeLogFile(filename) { async removeLogFile(filename) {
var fullPath = Path.join(this.dailyLogDirPath, filename) const fullPath = Path.join(this.DailyLogPath, filename)
var exists = await fs.pathExists(fullPath) const exists = await fs.pathExists(fullPath)
if (!exists) { if (!exists) {
Logger.error(TAG, 'Invalid log dne ' + fullPath) Logger.error(TAG, 'Invalid log dne ' + fullPath)
this.dailyLogFiles = this.dailyLogFiles.filter(dlf => dlf.filename !== filename) this.dailyLogFiles = this.dailyLogFiles.filter(dlf => dlf.filename !== filename)
@@ -109,8 +123,8 @@ class LogManager {
// Check log rolls to next day // Check log rolls to next day
if (this.currentDailyLog.id !== DailyLog.getCurrentDateString()) { if (this.currentDailyLog.id !== DailyLog.getCurrentDateString()) {
var newDailyLog = new DailyLog() const newDailyLog = new DailyLog()
newDailyLog.setData({ dailyLogDirPath: this.dailyLogDirPath }) newDailyLog.setData({ dailyLogDirPath: this.DailyLogPath })
this.currentDailyLog = newDailyLog this.currentDailyLog = newDailyLog
if (this.dailyLogFiles.length > this.loggerDailyLogsToKeep) { if (this.dailyLogFiles.length > this.loggerDailyLogsToKeep) {
this.removeOldestLog() this.removeOldestLog()
@@ -126,7 +140,7 @@ class LogManager {
return return
} }
var lastLogs = this.currentDailyLog.logs.slice(-5000) const lastLogs = this.currentDailyLog.logs.slice(-5000)
socket.emit('daily_logs', lastLogs) socket.emit('daily_logs', lastLogs)
} }
} }
+28 -21
View File
@@ -31,7 +31,7 @@ class PlaybackSessionManager {
return this.sessions.find(s => s.userId === userId) return this.sessions.find(s => s.userId === userId)
} }
getStream(sessionId) { getStream(sessionId) {
var session = this.getSession(sessionId) const session = this.getSession(sessionId)
return session ? session.stream : null return session ? session.stream : null
} }
@@ -54,7 +54,7 @@ class PlaybackSessionManager {
} }
async syncSessionRequest(user, session, payload, res) { async syncSessionRequest(user, session, payload, res) {
var result = await this.syncSession(user, session, payload) const result = await this.syncSession(user, session, payload)
if (result) { if (result) {
res.json(session.toJSONForClient(result.libraryItem)) res.json(session.toJSONForClient(result.libraryItem))
} }
@@ -66,7 +66,7 @@ class PlaybackSessionManager {
return res.status(500).send('Local session is locked and already syncing') return res.status(500).send('Local session is locked and already syncing')
} }
var libraryItem = this.db.getLibraryItem(sessionJson.libraryItemId) const libraryItem = this.db.getLibraryItem(sessionJson.libraryItemId)
if (!libraryItem) { if (!libraryItem) {
Logger.error(`[PlaybackSessionManager] syncLocalSessionRequest: Library item not found for session "${sessionJson.libraryItemId}"`) Logger.error(`[PlaybackSessionManager] syncLocalSessionRequest: Library item not found for session "${sessionJson.libraryItemId}"`)
return res.status(500).send('Library item not found') return res.status(500).send('Library item not found')
@@ -74,7 +74,7 @@ class PlaybackSessionManager {
this.localSessionLock[sessionJson.id] = true // Lock local session this.localSessionLock[sessionJson.id] = true // Lock local session
var session = await this.db.getPlaybackSession(sessionJson.id) let session = await this.db.getPlaybackSession(sessionJson.id)
if (!session) { if (!session) {
// New session from local // New session from local
session = new PlaybackSession(sessionJson) session = new PlaybackSession(sessionJson)
@@ -96,10 +96,10 @@ class PlaybackSessionManager {
progress: session.progress, progress: session.progress,
lastUpdate: session.updatedAt // Keep media progress update times the same as local lastUpdate: session.updatedAt // Keep media progress update times the same as local
} }
var wasUpdated = user.createUpdateMediaProgress(libraryItem, itemProgressUpdate, session.episodeId) const wasUpdated = user.createUpdateMediaProgress(libraryItem, itemProgressUpdate, session.episodeId)
if (wasUpdated) { if (wasUpdated) {
await this.db.updateEntity('user', user) await this.db.updateEntity('user', user)
var itemProgress = user.getMediaProgress(session.libraryItemId, session.episodeId) const itemProgress = user.getMediaProgress(session.libraryItemId, session.episodeId)
SocketAuthority.clientEmitter(user.id, 'user_item_progress_updated', { SocketAuthority.clientEmitter(user.id, 'user_item_progress_updated', {
id: itemProgress.id, id: itemProgress.id,
data: itemProgress.toJSON() data: itemProgress.toJSON()
@@ -118,18 +118,25 @@ class PlaybackSessionManager {
async startSession(user, deviceInfo, libraryItem, episodeId, options) { async startSession(user, deviceInfo, libraryItem, episodeId, options) {
// Close any sessions already open for user // Close any sessions already open for user
var userSessions = this.sessions.filter(playbackSession => playbackSession.userId === user.id) const userSessions = this.sessions.filter(playbackSession => playbackSession.userId === user.id)
for (const session of userSessions) { for (const session of userSessions) {
Logger.info(`[PlaybackSessionManager] startSession: Closing open session "${session.displayTitle}" for user "${user.username}"`) Logger.info(`[PlaybackSessionManager] startSession: Closing open session "${session.displayTitle}" for user "${user.username}"`)
await this.closeSession(user, session, null) await this.closeSession(user, session, null)
} }
var shouldDirectPlay = options.forceDirectPlay || (!options.forceTranscode && libraryItem.media.checkCanDirectPlay(options, episodeId)) const shouldDirectPlay = options.forceDirectPlay || (!options.forceTranscode && libraryItem.media.checkCanDirectPlay(options, episodeId))
var mediaPlayer = options.mediaPlayer || 'unknown' const mediaPlayer = options.mediaPlayer || 'unknown'
const userProgress = user.getMediaProgress(libraryItem.id, episodeId) const userProgress = user.getMediaProgress(libraryItem.id, episodeId)
var userStartTime = 0 let userStartTime = 0
if (userProgress) userStartTime = Number.parseFloat(userProgress.currentTime) || 0 if (userProgress) {
if (userProgress.isFinished) {
Logger.info(`[PlaybackSessionManager] Starting session for user "${user.username}" and resetting progress for finished item "${libraryItem.media.metadata.title}"`)
// Keep userStartTime as 0 so the client restarts the media
} else {
userStartTime = Number.parseFloat(userProgress.currentTime) || 0
}
}
const newPlaybackSession = new PlaybackSession() const newPlaybackSession = new PlaybackSession()
newPlaybackSession.setData(libraryItem, user, mediaPlayer, deviceInfo, userStartTime, episodeId) newPlaybackSession.setData(libraryItem, user, mediaPlayer, deviceInfo, userStartTime, episodeId)
@@ -142,14 +149,14 @@ class PlaybackSessionManager {
// HLS not supported for video yet // HLS not supported for video yet
} }
} else { } else {
var audioTracks = [] let audioTracks = []
if (shouldDirectPlay) { if (shouldDirectPlay) {
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}"`) Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}"`)
audioTracks = libraryItem.getDirectPlayTracklist(episodeId) audioTracks = libraryItem.getDirectPlayTracklist(episodeId)
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
} else { } else {
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting stream session for item "${libraryItem.id}"`) Logger.debug(`[PlaybackSessionManager] "${user.username}" starting stream session for item "${libraryItem.id}"`)
var stream = new Stream(newPlaybackSession.id, this.StreamsPath, user, libraryItem, episodeId, userStartTime) const stream = new Stream(newPlaybackSession.id, this.StreamsPath, user, libraryItem, episodeId, userStartTime)
await stream.generatePlaylist() await stream.generatePlaylist()
stream.start() // Start transcode stream.start() // Start transcode
@@ -175,7 +182,7 @@ class PlaybackSessionManager {
} }
async syncSession(user, session, syncData) { async syncSession(user, session, syncData) {
var libraryItem = this.db.libraryItems.find(li => li.id === session.libraryItemId) const libraryItem = this.db.libraryItems.find(li => li.id === session.libraryItemId)
if (!libraryItem) { if (!libraryItem) {
Logger.error(`[PlaybackSessionManager] syncSession Library Item not found "${session.libraryItemId}"`) Logger.error(`[PlaybackSessionManager] syncSession Library Item not found "${session.libraryItemId}"`)
return null return null
@@ -190,11 +197,11 @@ class PlaybackSessionManager {
currentTime: syncData.currentTime, currentTime: syncData.currentTime,
progress: session.progress progress: session.progress
} }
var wasUpdated = user.createUpdateMediaProgress(libraryItem, itemProgressUpdate, session.episodeId) const wasUpdated = user.createUpdateMediaProgress(libraryItem, itemProgressUpdate, session.episodeId)
if (wasUpdated) { if (wasUpdated) {
await this.db.updateEntity('user', user) await this.db.updateEntity('user', user)
var itemProgress = user.getMediaProgress(session.libraryItemId, session.episodeId) const itemProgress = user.getMediaProgress(session.libraryItemId, session.episodeId)
SocketAuthority.clientEmitter(user.id, 'user_item_progress_updated', { SocketAuthority.clientEmitter(user.id, 'user_item_progress_updated', {
id: itemProgress.id, id: itemProgress.id,
data: itemProgress.toJSON() data: itemProgress.toJSON()
@@ -229,7 +236,7 @@ class PlaybackSessionManager {
} }
async removeSession(sessionId) { async removeSession(sessionId) {
var session = this.sessions.find(s => s.id === sessionId) const session = this.sessions.find(s => s.id === sessionId)
if (!session) return if (!session) return
if (session.stream) { if (session.stream) {
await session.stream.close() await session.stream.close()
@@ -242,13 +249,13 @@ class PlaybackSessionManager {
async removeOrphanStreams() { async removeOrphanStreams() {
await fs.ensureDir(this.StreamsPath) await fs.ensureDir(this.StreamsPath)
try { try {
var streamsInPath = await fs.readdir(this.StreamsPath) const streamsInPath = await fs.readdir(this.StreamsPath)
for (let i = 0; i < streamsInPath.length; i++) { for (let i = 0; i < streamsInPath.length; i++) {
var streamId = streamsInPath[i] const streamId = streamsInPath[i]
if (streamId.startsWith('play_')) { // Make sure to only remove folders that are a stream if (streamId.startsWith('play_')) { // Make sure to only remove folders that are a stream
var session = this.sessions.find(se => se.id === streamId) const session = this.sessions.find(se => se.id === streamId)
if (!session) { if (!session) {
var streamPath = Path.join(this.StreamsPath, streamId) const streamPath = Path.join(this.StreamsPath, streamId)
Logger.debug(`[PlaybackSessionManager] Removing orphan stream "${streamPath}"`) Logger.debug(`[PlaybackSessionManager] Removing orphan stream "${streamPath}"`)
await fs.remove(streamPath) await fs.remove(streamPath)
} }
+3 -3
View File
@@ -38,10 +38,10 @@ class Collection {
} }
toJSONExpanded(libraryItems, minifiedBooks = false) { toJSONExpanded(libraryItems, minifiedBooks = false) {
var json = this.toJSON() const json = this.toJSON()
json.books = json.books.map(bookId => { json.books = json.books.map(bookId => {
var _ab = libraryItems.find(li => li.id === bookId) const book = libraryItems.find(li => li.id === bookId)
return _ab ? minifiedBooks ? _ab.toJSONMinified() : _ab.toJSONExpanded() : null return book ? minifiedBooks ? book.toJSONMinified() : book.toJSONExpanded() : null
}).filter(b => !!b) }).filter(b => !!b)
return json return json
} }
+2 -1
View File
@@ -86,7 +86,8 @@ class FeedEpisode {
setFromAudiobookTrack(libraryItem, serverAddress, slug, audioTrack, meta) { setFromAudiobookTrack(libraryItem, serverAddress, slug, audioTrack, meta) {
// Example: <pubDate>Fri, 04 Feb 2015 00:00:00 GMT</pubDate> // Example: <pubDate>Fri, 04 Feb 2015 00:00:00 GMT</pubDate>
const timeOffset = isNaN(audioTrack.index) ? 0 : (Number(audioTrack.index) * 1000) // Offset pubdate to ensure correct order const timeOffset = isNaN(audioTrack.index) ? 0 : (Number(audioTrack.index) * 1000) // Offset pubdate to ensure correct order
const audiobookPubDate = date.format(new Date(libraryItem.addedAt - timeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]') // e.g. Track 1 will have a pub date before Track 2
const audiobookPubDate = date.format(new Date(libraryItem.addedAt + timeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
const contentUrl = `/feed/${slug}/item/${audioTrack.index}/${audioTrack.metadata.filename}` const contentUrl = `/feed/${slug}/item/${audioTrack.index}/${audioTrack.metadata.filename}`
const media = libraryItem.media const media = libraryItem.media
+7 -11
View File
@@ -18,7 +18,7 @@ class User {
this.seriesHideFromContinueListening = [] // Series IDs that should not show on home page continue listening this.seriesHideFromContinueListening = [] // Series IDs that should not show on home page continue listening
this.bookmarks = [] this.bookmarks = []
this.settings = {} this.settings = {} // TODO: Remove after mobile release v0.9.61-beta
this.permissions = {} this.permissions = {}
this.librariesAccessible = [] // Library IDs (Empty if ALL libraries) this.librariesAccessible = [] // Library IDs (Empty if ALL libraries)
this.itemTagsAccessible = [] // Empty if ALL item tags accessible this.itemTagsAccessible = [] // Empty if ALL item tags accessible
@@ -59,17 +59,12 @@ class User {
return !!this.pash && !!this.pash.length return !!this.pash && !!this.pash.length
} }
// TODO: Remove after mobile release v0.9.61-beta
getDefaultUserSettings() { getDefaultUserSettings() {
return { return {
mobileOrderBy: 'recent', mobileOrderBy: 'recent',
mobileOrderDesc: true, mobileOrderDesc: true,
mobileFilterBy: 'all', mobileFilterBy: 'all'
orderBy: 'media.metadata.title',
orderDesc: false,
filterBy: 'all',
playbackRate: 1,
bookshelfCoverSize: 120,
collapseSeries: false
} }
} }
@@ -99,7 +94,7 @@ class User {
isLocked: this.isLocked, isLocked: this.isLocked,
lastSeen: this.lastSeen, lastSeen: this.lastSeen,
createdAt: this.createdAt, createdAt: this.createdAt,
settings: this.settings, settings: this.settings, // TODO: Remove after mobile release v0.9.61-beta
permissions: this.permissions, permissions: this.permissions,
librariesAccessible: [...this.librariesAccessible], librariesAccessible: [...this.librariesAccessible],
itemTagsAccessible: [...this.itemTagsAccessible] itemTagsAccessible: [...this.itemTagsAccessible]
@@ -119,7 +114,7 @@ class User {
isLocked: this.isLocked, isLocked: this.isLocked,
lastSeen: this.lastSeen, lastSeen: this.lastSeen,
createdAt: this.createdAt, createdAt: this.createdAt,
settings: this.settings, settings: this.settings, // TODO: Remove after mobile release v0.9.61-beta
permissions: this.permissions, permissions: this.permissions,
librariesAccessible: [...this.librariesAccessible], librariesAccessible: [...this.librariesAccessible],
itemTagsAccessible: [...this.itemTagsAccessible] itemTagsAccessible: [...this.itemTagsAccessible]
@@ -171,7 +166,7 @@ class User {
this.isLocked = user.type === 'root' ? false : !!user.isLocked this.isLocked = user.type === 'root' ? false : !!user.isLocked
this.lastSeen = user.lastSeen || null this.lastSeen = user.lastSeen || null
this.createdAt = user.createdAt || Date.now() this.createdAt = user.createdAt || Date.now()
this.settings = user.settings || this.getDefaultUserSettings() this.settings = user.settings || this.getDefaultUserSettings() // TODO: Remove after mobile release v0.9.61-beta
this.permissions = user.permissions || this.getDefaultUserPermissions() this.permissions = user.permissions || this.getDefaultUserPermissions()
// Upload permission added v1.1.13, make sure root user has upload permissions // Upload permission added v1.1.13, make sure root user has upload permissions
if (this.type === 'root' && !this.permissions.upload) this.permissions.upload = true if (this.type === 'root' && !this.permissions.upload) this.permissions.upload = true
@@ -348,6 +343,7 @@ class User {
return true return true
} }
// TODO: Remove after mobile release v0.9.61-beta
// Returns Boolean If update was made // Returns Boolean If update was made
updateSettings(settings) { updateSettings(settings) {
if (!this.settings) { if (!this.settings) {
+9 -2
View File
@@ -15,7 +15,14 @@ class GoogleBooks {
cleanResult(item) { cleanResult(item) {
var { id, volumeInfo } = item var { id, volumeInfo } = item
if (!volumeInfo) return null if (!volumeInfo) return null
var { title, subtitle, authors, publisher, publisherDate, description, industryIdentifiers, categories, imageLinks } = volumeInfo const { title, subtitle, authors, publisher, publisherDate, description, industryIdentifiers, categories, imageLinks } = volumeInfo
let cover = null
// Selects the largest cover assuming the largest is the last key in the object
if (imageLinks && Object.keys(imageLinks).length) {
cover = imageLinks[Object.keys(imageLinks).pop()]
cover = cover?.replace(/^http:/, 'https:') || null
}
return { return {
id, id,
@@ -25,7 +32,7 @@ class GoogleBooks {
publisher, publisher,
publishedYear: publisherDate ? publisherDate.split('-')[0] : null, publishedYear: publisherDate ? publisherDate.split('-')[0] : null,
description, description,
cover: imageLinks && imageLinks.thumbnail ? imageLinks.thumbnail : null, cover,
genres: categories && Array.isArray(categories) ? [...categories] : null, genres: categories && Array.isArray(categories) ? [...categories] : null,
isbn: this.extractIsbn(industryIdentifiers) isbn: this.extractIsbn(industryIdentifiers)
} }
+8 -2
View File
@@ -143,7 +143,7 @@ class ApiRouter {
// //
// Playlist Routes // Playlist Routes
// //
this.router.post('/playlists', PlaylistController.middleware.bind(this), PlaylistController.create.bind(this)) this.router.post('/playlists', PlaylistController.create.bind(this))
this.router.get('/playlists', PlaylistController.findAllForUser.bind(this)) this.router.get('/playlists', PlaylistController.findAllForUser.bind(this))
this.router.get('/playlists/:id', PlaylistController.middleware.bind(this), PlaylistController.findOne.bind(this)) this.router.get('/playlists/:id', PlaylistController.middleware.bind(this), PlaylistController.findOne.bind(this))
this.router.patch('/playlists/:id', PlaylistController.middleware.bind(this), PlaylistController.update.bind(this)) this.router.patch('/playlists/:id', PlaylistController.middleware.bind(this), PlaylistController.update.bind(this))
@@ -152,6 +152,7 @@ class ApiRouter {
this.router.delete('/playlists/:id/item/:libraryItemId/:episodeId?', PlaylistController.middleware.bind(this), PlaylistController.removeItem.bind(this)) this.router.delete('/playlists/:id/item/:libraryItemId/:episodeId?', PlaylistController.middleware.bind(this), PlaylistController.removeItem.bind(this))
this.router.post('/playlists/:id/batch/add', PlaylistController.middleware.bind(this), PlaylistController.addBatch.bind(this)) this.router.post('/playlists/:id/batch/add', PlaylistController.middleware.bind(this), PlaylistController.addBatch.bind(this))
this.router.post('/playlists/:id/batch/remove', PlaylistController.middleware.bind(this), PlaylistController.removeBatch.bind(this)) this.router.post('/playlists/:id/batch/remove', PlaylistController.middleware.bind(this), PlaylistController.removeBatch.bind(this))
this.router.post('/playlists/collection/:collectionId', PlaylistController.createFromCollection.bind(this))
// //
// Current User Routes (Me) // Current User Routes (Me)
@@ -168,7 +169,7 @@ class ApiRouter {
this.router.patch('/me/item/:id/bookmark', MeController.updateBookmark.bind(this)) this.router.patch('/me/item/:id/bookmark', MeController.updateBookmark.bind(this))
this.router.delete('/me/item/:id/bookmark/:time', MeController.removeBookmark.bind(this)) this.router.delete('/me/item/:id/bookmark/:time', MeController.removeBookmark.bind(this))
this.router.patch('/me/password', MeController.updatePassword.bind(this)) this.router.patch('/me/password', MeController.updatePassword.bind(this))
this.router.patch('/me/settings', MeController.updateSettings.bind(this)) this.router.patch('/me/settings', MeController.updateSettings.bind(this)) // TODO: Remove after mobile release v0.9.61-beta
this.router.post('/me/sync-local-progress', MeController.syncLocalMediaProgress.bind(this)) this.router.post('/me/sync-local-progress', MeController.syncLocalMediaProgress.bind(this))
this.router.get('/me/items-in-progress', MeController.getAllLibraryItemsInProgress.bind(this)) this.router.get('/me/items-in-progress', MeController.getAllLibraryItemsInProgress.bind(this))
this.router.get('/me/series/:id/remove-from-continue-listening', MeController.removeSeriesFromContinueListening.bind(this)) this.router.get('/me/series/:id/remove-from-continue-listening', MeController.removeSeriesFromContinueListening.bind(this))
@@ -271,6 +272,11 @@ class ApiRouter {
this.router.patch('/settings', MiscController.updateServerSettings.bind(this)) this.router.patch('/settings', MiscController.updateServerSettings.bind(this))
this.router.post('/authorize', MiscController.authorize.bind(this)) this.router.post('/authorize', MiscController.authorize.bind(this))
this.router.get('/tags', MiscController.getAllTags.bind(this)) this.router.get('/tags', MiscController.getAllTags.bind(this))
this.router.post('/tags/rename', MiscController.renameTag.bind(this))
this.router.delete('/tags/:tag', MiscController.deleteTag.bind(this))
this.router.get('/genres', MiscController.getAllGenres.bind(this))
this.router.post('/genres/rename', MiscController.renameGenre.bind(this))
this.router.delete('/genres/:genre', MiscController.deleteGenre.bind(this))
this.router.post('/validate-cron', MiscController.validateCronExpression.bind(this)) this.router.post('/validate-cron', MiscController.validateCronExpression.bind(this))
} }
+10 -5
View File
@@ -5,6 +5,7 @@ const date = require('../libs/dateAndTime')
const Logger = require('../Logger') const Logger = require('../Logger')
const Folder = require('../objects/Folder') const Folder = require('../objects/Folder')
const { LogLevel } = require('../utils/constants') const { LogLevel } = require('../utils/constants')
const filePerms = require('../utils/filePerms')
const { getId, secondsToTimestamp } = require('../utils/index') const { getId, secondsToTimestamp } = require('../utils/index')
class LibraryScan { class LibraryScan {
@@ -61,7 +62,7 @@ class LibraryScan {
get totalResults() { get totalResults() {
return this.resultsAdded + this.resultsUpdated + this.resultsMissing return this.resultsAdded + this.resultsUpdated + this.resultsMissing
} }
get getLogFilename() { get logFilename() {
return date.format(new Date(), 'YYYY-MM-DD') + '_' + this.id + '.txt' return date.format(new Date(), 'YYYY-MM-DD') + '_' + this.id + '.txt'
} }
@@ -124,14 +125,18 @@ class LibraryScan {
this.logs.push(logObj) this.logs.push(logObj)
} }
async saveLog(logDir) { async saveLog() {
await fs.ensureDir(logDir) await Logger.logManager.ensureScanLogDir()
var outputPath = Path.join(logDir, this.getLogFilename)
var logLines = [JSON.stringify(this.toJSON())] const logDir = Path.join(global.MetadataPath, 'logs', 'scans')
const outputPath = Path.join(logDir, this.logFilename)
const logLines = [JSON.stringify(this.toJSON())]
this.logs.forEach(l => { this.logs.forEach(l => {
logLines.push(JSON.stringify(l)) logLines.push(JSON.stringify(l))
}) })
await fs.writeFile(outputPath, logLines.join('\n') + '\n') await fs.writeFile(outputPath, logLines.join('\n') + '\n')
await filePerms.setDefault(outputPath)
Logger.info(`[LibraryScan] Scan log saved "${outputPath}"`) Logger.info(`[LibraryScan] Scan log saved "${outputPath}"`)
} }
} }
+2 -4
View File
@@ -22,8 +22,6 @@ const Series = require('../objects/entities/Series')
class Scanner { class Scanner {
constructor(db, coverManager) { constructor(db, coverManager) {
this.ScanLogPath = Path.posix.join(global.MetadataPath, 'logs', 'scans')
this.db = db this.db = db
this.coverManager = coverManager this.coverManager = coverManager
@@ -165,7 +163,7 @@ class Scanner {
SocketAuthority.emitter('scan_complete', libraryScan.getScanEmitData) SocketAuthority.emitter('scan_complete', libraryScan.getScanEmitData)
if (libraryScan.totalResults) { if (libraryScan.totalResults) {
libraryScan.saveLog(this.ScanLogPath) libraryScan.saveLog()
} }
} }
@@ -616,7 +614,7 @@ class Scanner {
} }
// Check if a library item is a subdirectory of this dir // Check if a library item is a subdirectory of this dir
var childItem = this.db.libraryItems.find(li => li.path.startsWith(fullPath)) var childItem = this.db.libraryItems.find(li => (li.path + '/').startsWith(fullPath + '/'))
if (childItem) { if (childItem) {
Logger.warn(`[Scanner] Files were modified in a parent directory of a library item "${childItem.media.metadata.title}" - ignoring`) Logger.warn(`[Scanner] Files were modified in a parent directory of a library item "${childItem.media.metadata.title}" - ignoring`)
itemGroupingResults[itemDir] = ScanResult.NOTHING itemGroupingResults[itemDir] = ScanResult.NOTHING
+31 -15
View File
@@ -183,16 +183,19 @@ module.exports.sanitizeFilename = (filename, colonReplacement = ' - ') => {
return false return false
} }
// Max is actually 255-260 for windows but this leaves padding incase ext wasnt put on yet // Most file systems use number of bytes for max filename
const MAX_FILENAME_LEN = 240 // to support most filesystems we will use max of 255 bytes in utf-16
// Ref: https://doc.owncloud.com/server/next/admin_manual/troubleshooting/path_filename_length.html
// Issue: https://github.com/advplyr/audiobookshelf/issues/1261
const MAX_FILENAME_BYTES = 255
var replacement = '' const replacement = ''
var illegalRe = /[\/\?<>\\:\*\|"]/g const illegalRe = /[\/\?<>\\:\*\|"]/g
var controlRe = /[\x00-\x1f\x80-\x9f]/g const controlRe = /[\x00-\x1f\x80-\x9f]/g
var reservedRe = /^\.+$/ const reservedRe = /^\.+$/
var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i const windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i
var windowsTrailingRe = /[\. ]+$/ const windowsTrailingRe = /[\. ]+$/
var lineBreaks = /[\n\r]/g const lineBreaks = /[\n\r]/g
sanitized = filename sanitized = filename
.replace(':', colonReplacement) // Replace first occurrence of a colon .replace(':', colonReplacement) // Replace first occurrence of a colon
@@ -203,12 +206,25 @@ module.exports.sanitizeFilename = (filename, colonReplacement = ' - ') => {
.replace(windowsReservedRe, replacement) .replace(windowsReservedRe, replacement)
.replace(windowsTrailingRe, replacement) .replace(windowsTrailingRe, replacement)
if (sanitized.length > MAX_FILENAME_LEN) { // Check if basename is too many bytes
var lenToRemove = sanitized.length - MAX_FILENAME_LEN const ext = Path.extname(sanitized) // separate out file extension
var ext = Path.extname(sanitized) const basename = Path.basename(sanitized, ext)
var basename = Path.basename(sanitized, ext) const extByteLength = Buffer.byteLength(ext, 'utf16le')
basename = basename.slice(0, basename.length - lenToRemove) const basenameByteLength = Buffer.byteLength(basename, 'utf16le')
sanitized = basename + ext if (basenameByteLength + extByteLength > MAX_FILENAME_BYTES) {
const MaxBytesForBasename = MAX_FILENAME_BYTES - extByteLength
let totalBytes = 0
let trimmedBasename = ''
// Add chars until max bytes is reached
for (const char of basename) {
totalBytes += Buffer.byteLength(char, 'utf16le')
if (totalBytes > MaxBytesForBasename) break
else trimmedBasename += char
}
trimmedBasename = trimmedBasename.trim()
sanitized = trimmedBasename + ext
} }
return sanitized return sanitized
+13 -12
View File
@@ -39,18 +39,19 @@ module.exports = {
} else if (group == 'missing') { } else if (group == 'missing') {
filtered = filtered.filter(li => { filtered = filtered.filter(li => {
if (li.isBook) { if (li.isBook) {
if (filter === 'asin' && li.media.metadata.asin === null) return true if (filter === 'asin' && !li.media.metadata.asin) return true
if (filter === 'isbn' && li.media.metadata.isbn === null) return true if (filter === 'isbn' && !li.media.metadata.isbn) return true
if (filter === 'subtitle' && li.media.metadata.subtitle === null) return true if (filter === 'subtitle' && !li.media.metadata.subtitle) return true
if (filter === 'authors' && li.media.metadata.authors.length === 0) return true if (filter === 'authors' && !li.media.metadata.authors.length) return true
if (filter === 'publishedYear' && li.media.metadata.publishedYear === null) return true if (filter === 'publishedYear' && !li.media.metadata.publishedYear) return true
if (filter === 'series' && li.media.metadata.series.length === 0) return true if (filter === 'series' && !li.media.metadata.series.length) return true
if (filter === 'description' && li.media.metadata.description === null) return true if (filter === 'description' && !li.media.metadata.description) return true
if (filter === 'genres' && li.media.metadata.genres.length === 0) return true if (filter === 'genres' && !li.media.metadata.genres.length) return true
if (filter === 'tags' && li.media.tags.length === 0) return true if (filter === 'tags' && !li.media.tags.length) return true
if (filter === 'narrators' && li.media.metadata.narrators.length === 0) return true if (filter === 'narrators' && !li.media.metadata.narrators.length) return true
if (filter === 'publisher' && li.media.metadata.publisher === null) return true if (filter === 'publisher' && !li.media.metadata.publisher) return true
if (filter === 'language' && li.media.metadata.language === null) return true if (filter === 'language' && !li.media.metadata.language) return true
if (filter === 'cover' && !li.media.coverPath) return true
} else { } else {
return false return false
} }