Compare commits

...

99 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
advplyr
bf071be247 Version bump 2.2.7 2022-11-30 17:43:13 -06:00
advplyr
6c05a0af8a Merge pull request #1234 from tomazed/master
[Update] client/strings/fr.json
2022-11-30 17:33:59 -06:00
advplyr
0e292c64c4 Update:Only emit library socket events to users with access to lib 2022-11-30 17:32:59 -06:00
advplyr
725f8eecdb Fix:Batch selecting ebooks showing play button in appbar #1235 2022-11-30 17:09:00 -06:00
Tomazed
521a673094 [Update] client/strings/fr.json 2022-11-30 23:20:29 +01:00
advplyr
d917f0e37d Fix:Ebook reader for ebooks in root folder #1232 2022-11-30 16:15:25 -06:00
advplyr
7ed5b1744f Var cleanup 2022-11-29 18:03:50 -06:00
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
advplyr
64a7cfac3b Merge pull request #1230 from springsunx/patch-1
Update zh-cn.json
2022-11-29 07:57:28 -06:00
SunX
1ee7ba54f8 Update zh-cn.json 2022-11-29 21:45:15 +08:00
advplyr
6bb18f8800 Fix:Purge cache buttons on config page for mobile screens #1228 2022-11-28 17:55:52 -06:00
advplyr
b26b854963 Translation strings for other langs #1166 2022-11-28 17:52:36 -06:00
advplyr
7d58361ced Update:Chapter editor add reset button, cleanup ui, add translation strings #1166 2022-11-28 17:49:58 -06:00
advplyr
a3723f3d06 Update:New translation strings for chapter editor #1166 2022-11-28 17:00:06 -06:00
advplyr
78d1cd0cfb Add:Chapter editor button to set chapters using audio tracks #1229 2022-11-28 16:55:13 -06:00
advplyr
d41366a417 Fix:Playlist API endpoint permissions 2022-11-28 16:29:04 -06:00
advplyr
a2347150a2 Fix:PWA manifest start_url 2022-11-28 16:26:26 -06:00
advplyr
d33f23dede Merge pull request #1225 from springsunx/patch-1
Update zh-cn.json
2022-11-28 09:23:22 -06:00
SunX
cfca2be1b2 Update zh-cn.json 2022-11-28 18:14:08 +08:00
advplyr
73f07c1392 Update:More translation strings for library filters #1166 2022-11-27 17:55:25 -06:00
advplyr
4541e9ddc3 Fix:Library filters when using other language #1166 2022-11-27 17:54:40 -06:00
advplyr
972271a1a9 Add:Library filter for single & multi-track audiobooks #1213 2022-11-27 17:42:02 -06:00
advplyr
e97d92a8ac Fix:Copy to clipboard 2022-11-27 17:10:06 -06:00
97 changed files with 1933 additions and 692 deletions

View File

@@ -11,7 +11,6 @@ ExecReload=/bin/kill -HUP $MAINPID
Restart=always
User=audiobookshelf
Group=audiobookshelf
PermissionsStartOnly=true
[Install]
WantedBy=multi-user.target

View File

@@ -1,6 +1,6 @@
<template>
<div class="w-full h-16 bg-primary relative">
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-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">
<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" />
@@ -25,15 +25,21 @@
</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">
<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 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 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 to="/account" class="relative w-9 h-9 md:w-32 bg-fg border border-gray-500 rounded shadow-sm ml-1.5 sm:ml-3 md:ml-5 md:pl-3 md:pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer hover:bg-bg hover:bg-opacity-40" aria-haspopup="listbox" aria-expanded="true">
@@ -45,10 +51,10 @@
</span>
</nuxt-link>
</div>
<div v-show="numLibraryItemsSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
<h1 class="text-lg md:text-2xl px-4">{{ $getString('MessageItemsSelected', [numLibraryItemsSelected]) }}</h1>
<div v-show="numMediaItemsSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
<h1 class="text-lg md:text-2xl px-4">{{ $getString('MessageItemsSelected', [numMediaItemsSelected]) }}</h1>
<div class="flex-grow" />
<ui-btn v-if="!isPodcastLibrary" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="playSelectedItems">
<ui-btn v-if="!isPodcastLibrary && selectedMediaItemsArePlayable" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="playSelectedItems">
<span class="material-icons text-2xl -ml-2 pr-1 text-white">play_arrow</span>
{{ $strings.ButtonPlay }}
</ui-btn>
@@ -62,7 +68,7 @@
<ui-icon-btn :disabled="processingBatch" icon="collections_bookmark" @click="batchAddToCollectionClick" class="mx-1.5" />
</ui-tooltip>
<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-tooltip>
</template>
@@ -109,11 +115,14 @@ export default {
username() {
return this.user ? this.user.username : 'err'
},
numLibraryItemsSelected() {
return this.selectedLibraryItems.length
numMediaItemsSelected() {
return this.selectedMediaItems.length
},
selectedLibraryItems() {
return this.$store.state.selectedLibraryItems
selectedMediaItems() {
return this.$store.state.globals.selectedMediaItems
},
selectedMediaItemsArePlayable() {
return !this.selectedMediaItems.some((i) => !i.hasTracks)
},
userMediaProgress() {
return this.$store.state.user.user.mediaProgress || []
@@ -129,8 +138,8 @@ export default {
},
selectedIsFinished() {
// Find an item that is not finished, if none then all items finished
return !this.selectedLibraryItems.find((libraryItemId) => {
var itemProgress = this.userMediaProgress.find((lip) => lip.libraryItemId === libraryItemId)
return !this.selectedMediaItems.find((item) => {
const itemProgress = this.userMediaProgress.find((lip) => lip.libraryItemId === item.id)
return !itemProgress || !itemProgress.isFinished
})
},
@@ -154,12 +163,16 @@ export default {
async playSelectedItems() {
this.$store.commit('setProcessingBatch', true)
var libraryItems = await this.$axios.$post(`/api/items/batch/get`, { libraryItemIds: this.selectedLibraryItems }).catch((error) => {
var errorMsg = error.response.data || 'Failed to get items'
console.error(errorMsg, error)
this.$toast.error(errorMsg)
return []
})
const libraryItemIds = this.selectedMediaItems.map((i) => i.id)
const libraryItems = await this.$axios
.$post(`/api/items/batch/get`, { libraryItemIds })
.then((res) => res.libraryItems)
.catch((error) => {
const errorMsg = error.response.data || 'Failed to get items'
console.error(errorMsg, error)
this.$toast.error(errorMsg)
return []
})
if (!libraryItems.length) {
this.$store.commit('setProcessingBatch', false)
@@ -185,20 +198,20 @@ export default {
queueItems
})
this.$store.commit('setProcessingBatch', false)
this.$store.commit('setSelectedLibraryItems', [])
this.$store.commit('globals/resetSelectedMediaItems', [])
this.$eventBus.$emit('bookshelf_clear_selection')
},
cancelSelectionMode() {
if (this.processingBatch) return
this.$store.commit('setSelectedLibraryItems', [])
this.$store.commit('globals/resetSelectedMediaItems', [])
this.$eventBus.$emit('bookshelf_clear_selection')
},
toggleBatchRead() {
this.$store.commit('setProcessingBatch', true)
var newIsFinished = !this.selectedIsFinished
var updateProgressPayloads = this.selectedLibraryItems.map((lid) => {
const newIsFinished = !this.selectedIsFinished
const updateProgressPayloads = this.selectedMediaItems.map((item) => {
return {
libraryItemId: lid,
libraryItemId: item.id,
isFinished: newIsFinished
}
})
@@ -208,7 +221,7 @@ export default {
.then(() => {
this.$toast.success('Batch update success!')
this.$store.commit('setProcessingBatch', false)
this.$store.commit('setSelectedLibraryItems', [])
this.$store.commit('globals/resetSelectedMediaItems', [])
this.$eventBus.$emit('bookshelf_clear_selection')
})
.catch((error) => {
@@ -218,18 +231,18 @@ export default {
})
},
batchDeleteClick() {
var audiobookText = this.numLibraryItemsSelected > 1 ? `these ${this.numLibraryItemsSelected} items` : 'this item'
var confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the items from Audiobookshelf`
const audiobookText = this.numMediaItemsSelected > 1 ? `these ${this.numMediaItemsSelected} items` : 'this item'
const confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the items from Audiobookshelf`
if (confirm(confirmMsg)) {
this.$store.commit('setProcessingBatch', true)
this.$axios
.$post(`/api/items/batch/delete`, {
libraryItemIds: this.selectedLibraryItems
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
})
.then(() => {
this.$toast.success('Batch delete success!')
this.$store.commit('setProcessingBatch', false)
this.$store.commit('setSelectedLibraryItems', [])
this.$store.commit('globals/resetSelectedMediaItems', [])
this.$eventBus.$emit('bookshelf_clear_selection')
})
.catch((error) => {

View File

@@ -89,8 +89,8 @@ export default {
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
return this.bookCoverWidth / baseSize
},
selectedLibraryItems() {
return this.$store.state.selectedLibraryItems || []
selectedMediaItems() {
return this.$store.state.globals.selectedMediaItems || []
}
},
methods: {
@@ -100,15 +100,15 @@ export default {
const indexOf = shelf.shelfStartIndex + entityShelfIndex
const lastLastItemIndexSelected = this.lastItemIndexSelected
if (!this.selectedLibraryItems.includes(entity.id)) {
if (!this.selectedMediaItems.some((i) => i.id === entity.id)) {
this.lastItemIndexSelected = indexOf
} else {
this.lastItemIndexSelected = -1
}
if (shiftKey && lastLastItemIndexSelected >= 0) {
var loopStart = indexOf
var loopEnd = lastLastItemIndexSelected
let loopStart = indexOf
let loopEnd = lastLastItemIndexSelected
if (indexOf > lastLastItemIndexSelected) {
loopStart = lastLastItemIndexSelected
loopEnd = indexOf
@@ -117,12 +117,12 @@ export default {
const flattenedEntitiesArray = []
this.shelves.map((s) => flattenedEntitiesArray.push(...s.entities))
var isSelecting = false
let isSelecting = false
// If any items in this range is not selected then select all otherwise unselect all
for (let i = loopStart; i <= loopEnd; i++) {
const thisEntity = flattenedEntitiesArray[i]
if (thisEntity) {
if (!this.selectedLibraryItems.includes(thisEntity.id)) {
if (!this.selectedMediaItems.some((i) => i.id === thisEntity.id)) {
isSelecting = true
break
}
@@ -133,13 +133,23 @@ export default {
for (let i = loopStart; i <= loopEnd; i++) {
const thisEntity = flattenedEntitiesArray[i]
if (thisEntity) {
this.$store.commit('setLibraryItemSelected', { libraryItemId: thisEntity.id, selected: isSelecting })
const mediaItem = {
id: thisEntity.id,
mediaType: thisEntity.mediaType,
hasTracks: thisEntity.mediaType === 'podcast' || thisEntity.media.numTracks || (thisEntity.media.tracks && thisEntity.media.tracks.length)
}
this.$store.commit('globals/setMediaItemSelected', { item: mediaItem, selected: isSelecting })
} else {
console.error('Invalid entity index', i)
}
}
} else {
this.$store.commit('toggleLibraryItemSelected', entity.id)
const mediaItem = {
id: entity.id,
mediaType: entity.mediaType,
hasTracks: entity.mediaType === 'podcast' || entity.media.numTracks || (entity.media.tracks && entity.media.tracks.length)
}
this.$store.commit('globals/toggleMediaItemSelected', mediaItem)
}
this.$nextTick(() => {
@@ -395,8 +405,6 @@ export default {
}
},
removeListeners() {
this.$store.commit('user/removeSettingsListener', 'bookshelf')
if (this.$root.socket) {
this.$root.socket.off('user_updated', this.userUpdated)
this.$root.socket.off('author_updated', this.authorUpdated)

View File

@@ -98,7 +98,7 @@ export default {
return this.$store.state.libraries.currentLibraryId
},
isSelectionMode() {
return this.$store.getters['getNumLibraryItemsSelected'] > 0
return this.$store.getters['globals/getIsBatchSelectingMediaItems']
}
},
methods: {
@@ -119,14 +119,14 @@ export default {
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
},
updateSelectionMode(val) {
var selectedLibraryItems = this.$store.state.selectedLibraryItems
const selectedMediaItems = this.$store.state.globals.selectedMediaItems
if (this.shelf.type === 'book' || this.shelf.type === 'podcast') {
this.shelf.entities.forEach((ent) => {
var component = this.$refs[`shelf-book-${ent.id}`]
if (!component || !component.length) return
component = component[0]
component.setSelectionMode(val)
component.selected = selectedLibraryItems.includes(ent.id)
component.selected = selectedMediaItems.some((i) => i.id === ent.id)
})
} else if (this.shelf.type === 'episode') {
this.shelf.entities.forEach((ent) => {
@@ -134,7 +134,7 @@ export default {
if (!component || !component.length) return
component = component[0]
component.setSelectionMode(val)
component.selected = selectedLibraryItems.includes(ent.id)
component.selected = selectedMediaItems.some((i) => i.id === ent.id)
})
}
},

View File

@@ -39,7 +39,7 @@
<p class="text-sm">{{ $strings.ButtonSearch }}</p>
</nuxt-link>
</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 -->
<template v-if="selectedSeries">
<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" />
<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-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-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-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="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>
</template>
@@ -205,7 +205,7 @@ export default {
return this.seriesProgress.libraryItemIds || []
},
isBatchSelecting() {
return this.$store.state.selectedLibraryItems.length
return this.$store.getters['globals/getIsBatchSelectingMediaItems']
},
isSeriesFinished() {
return this.seriesProgress && !!this.seriesProgress.isFinished
@@ -219,30 +219,6 @@ export default {
},
isIssuesFilter() {
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: {
@@ -339,10 +315,10 @@ export default {
this.saveSettings()
},
updateSeriesSort() {
this.$eventBus.$emit('series-sort-updated')
this.saveSettings()
},
updateSeriesFilter() {
this.$eventBus.$emit('series-sort-updated')
this.saveSettings()
},
updateCollapseSeries() {
this.saveSettings()
@@ -367,11 +343,11 @@ export default {
},
mounted() {
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)
},
beforeDestroy() {
this.$store.commit('user/removeSettingsListener', 'bookshelftoolbar')
this.$eventBus.$off('user-settings', this.settingsUpdated)
this.$eventBus.$off('bookshelf-total-entities', this.setBookshelfTotalEntities)
}
}

View File

@@ -87,6 +87,11 @@ export default {
id: 'config-notifications',
title: this.$strings.HeaderNotifications,
path: '/config/notifications'
},
{
id: 'config-item-metadata-utils',
title: this.$strings.HeaderItemMetadataUtils,
path: '/config/item-metadata-utils'
}
]

View File

@@ -100,13 +100,13 @@ export default {
return this.page
},
seriesSortBy() {
return this.$store.state.libraries.seriesSortBy
return this.$store.getters['user/getUserSetting']('seriesSortBy')
},
seriesSortDesc() {
return this.$store.state.libraries.seriesSortDesc
return this.$store.getters['user/getUserSetting']('seriesSortDesc')
},
seriesFilterBy() {
return this.$store.state.libraries.seriesFilterBy
return this.$store.getters['user/getUserSetting']('seriesFilterBy')
},
orderBy() {
return this.$store.getters['user/getUserSetting']('orderBy')
@@ -163,7 +163,7 @@ export default {
},
bookWidth() {
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
},
bookHeight() {
@@ -201,8 +201,8 @@ export default {
// Includes margin
return this.entityWidth + 24
},
selectedLibraryItems() {
return this.$store.state.selectedLibraryItems || []
selectedMediaItems() {
return this.$store.state.globals.selectedMediaItems || []
},
sizeMultiplier() {
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
@@ -230,28 +230,28 @@ export default {
},
selectEntity(entity, shiftKey) {
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
if (!this.selectedLibraryItems.includes(entity.id)) {
if (!this.selectedMediaItems.some((i) => i.id === entity.id)) {
this.lastItemIndexSelected = indexOf
} else {
this.lastItemIndexSelected = -1
}
if (shiftKey && lastLastItemIndexSelected >= 0) {
var loopStart = indexOf
var loopEnd = lastLastItemIndexSelected
let loopStart = indexOf
let loopEnd = lastLastItemIndexSelected
if (indexOf > lastLastItemIndexSelected) {
loopStart = lastLastItemIndexSelected
loopEnd = indexOf
}
var isSelecting = false
let isSelecting = false
// If any items in this range is not selected then select all otherwise unselect all
for (let i = loopStart; i <= loopEnd; i++) {
const thisEntity = this.entities[i]
if (thisEntity && !thisEntity.collapsedSeries) {
if (!this.selectedLibraryItems.includes(thisEntity.id)) {
if (!this.selectedMediaItems.some((i) => i.id === thisEntity.id)) {
isSelecting = true
break
}
@@ -269,16 +269,28 @@ export default {
const entityComponentRef = this.entityComponentRefs[i]
if (thisEntity && entityComponentRef) {
entityComponentRef.selected = isSelecting
this.$store.commit('setLibraryItemSelected', { libraryItemId: thisEntity.id, selected: isSelecting })
const mediaItem = {
id: thisEntity.id,
mediaType: thisEntity.mediaType,
hasTracks: thisEntity.mediaType === 'podcast' || thisEntity.media.numTracks || (thisEntity.media.tracks && thisEntity.media.tracks.length)
}
console.log('Setting media item selected', mediaItem, 'Num Selected=', this.selectedMediaItems.length)
this.$store.commit('globals/setMediaItemSelected', { item: mediaItem, selected: isSelecting })
} else {
console.error('Invalid entity index', i)
}
}
} else {
this.$store.commit('toggleLibraryItemSelected', entity.id)
const mediaItem = {
id: entity.id,
mediaType: entity.mediaType,
hasTracks: entity.mediaType === 'podcast' || entity.media.numTracks || (entity.media.tracks && entity.media.tracks.length)
}
this.$store.commit('globals/toggleMediaItemSelected', mediaItem)
}
var newIsSelectionMode = !!this.selectedLibraryItems.length
const newIsSelectionMode = !!this.selectedMediaItems.length
if (this.isSelectionMode !== newIsSelectionMode) {
this.isSelectionMode = newIsSelectionMode
this.updateBookSelectionMode(newIsSelectionMode)
@@ -486,7 +498,7 @@ export default {
}
},
settingsUpdated(settings) {
var wasUpdated = this.checkUpdateSearchParams()
const wasUpdated = this.checkUpdateSearchParams()
if (wasUpdated) {
this.resetEntities()
} else if (settings.bookshelfCoverSize !== this.currentBookWidth) {
@@ -655,11 +667,9 @@ export default {
}
})
this.$eventBus.$on('series-sort-updated', this.seriesSortUpdated)
this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities)
this.$eventBus.$on('socket_init', this.socketInit)
this.$store.commit('user/addSettingsListener', { id: 'lazy-bookshelf', meth: this.settingsUpdated })
this.$eventBus.$on('user-settings', this.settingsUpdated)
if (this.$root.socket) {
this.$root.socket.on('item_updated', this.libraryItemUpdated)
@@ -684,11 +694,9 @@ export default {
bookshelf.removeEventListener('scroll', this.scroll)
}
this.$eventBus.$off('series-sort-updated', this.seriesSortUpdated)
this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities)
this.$eventBus.$off('socket_init', this.socketInit)
this.$store.commit('user/removeSettingsListener', 'lazy-bookshelf')
this.$eventBus.$off('user-settings', this.settingsUpdated)
if (this.$root.socket) {
this.$root.socket.off('item_updated', this.libraryItemUpdated)

View File

@@ -1,6 +1,5 @@
<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-40" style="min-width: 80px" :style="{ top: offsetTop + 'px' }">
<div class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-50" style="min-width: 80px" :style="{ top: offsetTop + 'px' }">
<!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar -->
<div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />

View File

@@ -1,5 +1,5 @@
<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" />
<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" />

View File

@@ -13,10 +13,14 @@
<!-- 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">
<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 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>
<!-- Loading spinner -->

View File

@@ -9,7 +9,7 @@
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
</div>
</div>
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div>

View File

@@ -9,7 +9,7 @@
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
</div>
</div>
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div>

View File

@@ -1,5 +1,5 @@
<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="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" />
@@ -13,7 +13,7 @@
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
</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` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ displayTitle }}</p>
</div>

View File

@@ -41,9 +41,9 @@
<span class="font-normal block truncate py-2">No {{ sublist }}</span>
</div>
</li>
<li v-else-if="sublist === 'series'" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" role="option" @click="clickedSublistOption($encode('No Series'))">
<li v-else-if="sublist === 'series'" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" role="option" @click="clickedSublistOption($encode('no-series'))">
<div class="flex items-center">
<span class="font-normal block truncate py-2 text-xs text-white text-opacity-80">No Series</span>
<span class="font-normal block truncate py-2 text-xs text-white text-opacity-80">{{ $strings.MessageNoSeries }}</span>
</div>
</li>
<template v-for="item in sublistItems">
@@ -174,6 +174,11 @@ export default {
value: 'missing',
sublist: true
},
{
text: this.$strings.LabelTracks,
value: 'tracks',
sublist: true
},
{
text: this.$strings.ButtonIssues,
value: 'issues',
@@ -263,10 +268,92 @@ export default {
return this.filterData.languages || []
},
progress() {
return [this.$strings.LabelFinished, this.$strings.LabelInProgress, this.$strings.LabelNotStarted, this.$strings.LabelNotFinished]
return [
{
id: 'finished',
name: this.$strings.LabelFinished
},
{
id: 'in-progress',
name: this.$strings.LabelInProgress
},
{
id: 'not-started',
name: this.$strings.LabelNotStarted
},
{
id: 'not-finished',
name: this.$strings.LabelNotFinished
}
]
},
tracks() {
return [
{
id: 'single',
name: this.$strings.LabelTracksSingleTrack
},
{
id: 'multi',
name: this.$strings.LabelTracksMultiTrack
}
]
},
missing() {
return ['ASIN', 'ISBN', this.$strings.LabelSubtitle, this.$strings.LabelAuthor, this.$strings.LabelPublishYear, this.$strings.LabelSeries, this.$strings.LabelDescription, this.$strings.LabelGenres, this.$strings.LabelTags, this.$strings.LabelNarrator, this.$strings.LabelPublisher, this.$strings.LabelLanguage]
return [
{
id: 'asin',
name: 'ASIN'
},
{
id: 'isbn',
name: 'ISBN'
},
{
id: 'subtitle',
name: this.$strings.LabelSubtitle
},
{
id: 'authors',
name: this.$strings.LabelAuthor
},
{
id: 'publishedYear',
name: this.$strings.LabelPublishYear
},
{
id: 'series',
name: this.$strings.LabelSeries
},
{
id: 'description',
name: this.$strings.LabelDescription
},
{
id: 'genres',
name: this.$strings.LabelGenres
},
{
id: 'tags',
name: this.$strings.LabelTags
},
{
id: 'narrators',
name: this.$strings.LabelNarrator
},
{
id: 'publisher',
name: this.$strings.LabelPublisher
},
{
id: 'language',
name: this.$strings.LabelLanguage
},
{
id: 'cover',
name: this.$strings.LabelCover
}
]
},
sublistItems() {
return (this[this.sublist] || []).map((item) => {

View File

@@ -201,8 +201,8 @@ export default {
this.loadingTags = true
this.$axios
.$get(`/api/tags`)
.then((tags) => {
this.tags = tags
.then((res) => {
this.tags = res.tags
this.loadingTags = false
})
.catch((error) => {

View File

@@ -82,7 +82,7 @@ export default {
return this.$store.state.globals.showBatchQuickMatchModal
},
selectedBookIds() {
return this.$store.state.selectedLibraryItems || []
return (this.$store.state.globals.selectedMediaItems || []).map((i) => i.id)
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId

View File

@@ -15,7 +15,7 @@
</div>
</div>
<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>
</form>

View File

@@ -39,7 +39,7 @@ export default {
},
zIndex: {
type: Number,
default: 50
default: 60
},
bgOpacity: {
type: Number,

View File

@@ -35,7 +35,7 @@
<div class="flex pt-2 px-2">
<ui-btn type="button" @click="searchAuthor">{{ $strings.ButtonQuickMatch }}</ui-btn>
<div class="flex-grow" />
<ui-btn type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
<ui-btn type="submit">{{ $strings.ButtonSave }}</ui-btn>
</div>
</div>
</div>

View File

@@ -104,7 +104,7 @@ export default {
return this.$store.state.globals.showBatchCollectionModal
},
selectedBookIds() {
return this.$store.state.selectedLibraryItems || []
return (this.$store.state.globals.selectedMediaItems || []).map((i) => i.id)
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId

View File

@@ -303,11 +303,14 @@ export default {
this.persistProvider()
this.isProcessing = true
var searchQuery = this.getSearchQuery()
var results = await this.$axios.$get(`/api/search/covers?${searchQuery}`).catch((error) => {
console.error('Failed', error)
return []
})
const searchQuery = this.getSearchQuery()
const results = await this.$axios
.$get(`/api/search/covers?${searchQuery}`)
.then((res) => res.results)
.catch((error) => {
console.error('Failed', error)
return []
})
this.coversFound = results
this.isProcessing = false
this.hasSearched = true

View File

@@ -306,13 +306,13 @@ export default {
this.runSearch()
},
async runSearch() {
var searchQuery = this.getSearchQuery()
const searchQuery = this.getSearchQuery()
if (this.lastSearch === searchQuery) return
this.searchResults = []
this.isProcessing = true
this.lastSearch = searchQuery
var searchEntity = this.isPodcast ? 'podcast' : 'books'
var results = await this.$axios.$get(`/api/search/${searchEntity}?${searchQuery}`, { timeout: 20000 }).catch((error) => {
const searchEntity = this.isPodcast ? 'podcast' : 'books'
let results = await this.$axios.$get(`/api/search/${searchEntity}?${searchQuery}`, { timeout: 20000 }).catch((error) => {
console.error('Failed', error)
return []
})
@@ -335,8 +335,7 @@ export default {
this.isProcessing = false
this.hasSearched = true
},
init() {
this.clearSelectedMatch()
initSelectedMatchUsage() {
this.selectedMatchUsage = {
title: true,
subtitle: true,
@@ -360,6 +359,27 @@ export default {
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) {
this.searchResults = []
this.hasSearched = false
@@ -465,11 +485,14 @@ export default {
console.log('Match payload', updatePayload)
this.isProcessing = true
// Persist in local storage
localStorage.setItem('selectedMatchUsage', JSON.stringify(this.selectedMatchUsage))
if (updatePayload.metadata.cover) {
var coverPayload = {
const coverPayload = {
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)
return false
})
@@ -483,8 +506,8 @@ export default {
}
if (Object.keys(updatePayload).length) {
var mediaUpdatePayload = updatePayload
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, mediaUpdatePayload).catch((error) => {
const mediaUpdatePayload = updatePayload
const updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, mediaUpdatePayload).catch((error) => {
console.error('Failed to update', error)
return false
})
@@ -502,6 +525,7 @@ export default {
} else {
this.clearSelectedMatch()
}
this.isProcessing = false
},
clearSelectedMatch() {

View File

@@ -1,7 +1,7 @@
<template>
<div class="w-full h-full px-1 md:px-4 py-2 mb-4">
<div v-if="!showDirectoryPicker" class="w-full h-full py-4">
<div class="flex flex-wrap md:flex-nowrap -mx-1">
<div class="w-full h-full md:px-4 py-2 mb-4">
<div v-if="!showDirectoryPicker" class="w-full h-full md:py-4">
<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">
<ui-dropdown v-model="mediaType" :items="mediaTypes" :label="$strings.LabelMediaType" :disabled="!isNew" small @input="changedMediaType" />
</div>
@@ -16,7 +16,7 @@
</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>
<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>
@@ -140,3 +140,14 @@ export default {
}
}
</script>
<style>
.folders-container {
max-height: calc(80vh - 192px);
}
@media (max-device-width: 768px) {
.folders-container {
max-height: calc(80vh - 292px);
}
}
</style>

View File

@@ -11,7 +11,7 @@
</template>
</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" />
<div class="absolute bottom-0 left-0 w-full px-4 py-4 border-t border-white border-opacity-10">

View File

@@ -234,13 +234,10 @@ export default {
this.showChaptersModal = false
},
setUseChapterTrack() {
var useChapterTrack = !this.useChapterTrack
this.useChapterTrack = useChapterTrack
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(useChapterTrack)
this.useChapterTrack = !this.useChapterTrack
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
this.$store.dispatch('user/updateUserSettings', { useChapterTrack }).catch((err) => {
console.error('Failed to update settings', err)
})
this.$store.dispatch('user/updateUserSettings', { useChapterTrack: this.useChapterTrack })
this.updateTimestamp()
},
checkUpdateChapterTrack() {
@@ -311,7 +308,7 @@ export default {
init() {
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
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
@@ -345,13 +342,14 @@ export default {
}
},
mounted() {
this.$store.commit('user/addSettingsListener', { id: 'audioplayer', meth: this.settingsUpdated })
this.init()
this.$eventBus.$on('player-hotkey', this.hotkey)
this.$eventBus.$on('user-settings', this.settingsUpdated)
this.init()
},
beforeDestroy() {
this.$store.commit('user/removeSettingsListener', 'audioplayer')
this.$eventBus.$off('player-hotkey', this.hotkey)
this.$eventBus.$off('user-settings', this.settingsUpdated)
}
}
</script>

View File

@@ -1,5 +1,5 @@
<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">
<span class="material-icons cursor-pointer text-4xl" @click="close">close</span>
</div>
@@ -92,13 +92,18 @@ export default {
},
ebookUrl() {
if (!this.ebookFile) return null
var itemRelPath = this.selectedLibraryItem.relPath
if (itemRelPath.startsWith('/')) itemRelPath = itemRelPath.slice(1)
var relPath = this.ebookFile.metadata.relPath
if (relPath.startsWith('/')) relPath = relPath.slice(1)
let filepath = ''
if (this.selectedLibraryItem.isFile) {
filepath = this.$encodeUriPath(this.ebookFile.metadata.filename)
} else {
const itemRelPath = this.selectedLibraryItem.relPath
if (itemRelPath.startsWith('/')) itemRelPath = itemRelPath.slice(1)
const relPath = this.ebookFile.metadata.relPath
if (relPath.startsWith('/')) relPath = relPath.slice(1)
const relRelPath = this.$encodeUriPath(`${itemRelPath}/${relPath}`)
return `/ebook/${this.libraryId}/${this.folderId}/${relRelPath}`
filepath = this.$encodeUriPath(`${itemRelPath}/${relPath}`)
}
return `/ebook/${this.libraryId}/${this.folderId}/${filepath}`
},
userToken() {
return this.$store.getters['user/getToken']

View File

@@ -109,8 +109,8 @@ export default {
loadUsers() {
this.$axios
.$get('/api/users')
.then((users) => {
this.users = users.sort((a, b) => {
.then((res) => {
this.users = res.users.sort((a, b) => {
return a.createdAt - b.createdAt
})
})

View File

@@ -1,5 +1,5 @@
<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">
<template v-for="library in libraryCopies">
<div :key="library.id" class="item">
@@ -82,10 +82,10 @@ export default {
})
var newOrder = libraryOrderData.map((lib) => lib.id).join(',')
if (currOrder !== newOrder) {
this.$axios.$post('/api/libraries/order', libraryOrderData).then((libraries) => {
if (libraries && libraries.length) {
this.$axios.$post('/api/libraries/order', libraryOrderData).then((response) => {
if (response.libraries && response.libraries.length) {
this.$toast.success('Library order saved', { timeout: 1500 })
this.$store.commit('libraries/set', libraries)
this.$store.commit('libraries/set', response.libraries)
}
})
}

View File

@@ -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>

View File

@@ -113,10 +113,13 @@ export default {
if (this.searching) return
this.currentSearch = this.textInput
this.searching = true
var results = await this.$axios.$get(`/api/${this.endpoint}?q=${this.currentSearch}&limit=15&token=${this.userToken}`).catch((error) => {
console.error('Failed to get search results', error)
return []
})
const results = await this.$axios
.$get(`/api/${this.endpoint}?q=${this.currentSearch}&limit=15&token=${this.userToken}`)
.then((res) => res.results || res)
.catch((error) => {
console.error('Failed to get search results', error)
return []
})
this.items = results || []
this.searching = false
},

View File

@@ -61,7 +61,7 @@ export default {
return Math.floor(this.clientWidth / (this.cardWidth + 16))
},
isSelectionMode() {
return this.$store.getters['getNumLibraryItemsSelected'] > 0
return this.$store.getters['globals/getIsBatchSelectingMediaItems']
}
},
methods: {

View File

@@ -137,16 +137,33 @@ export default {
author: (this.details.authors || []).map((au) => au.name).join(', ')
}
},
mapBatchDetails(batchDetails) {
mapBatchDetails(batchDetails, mapType = 'overwrite') {
for (const key in batchDetails) {
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 }))
if (mapType === 'append') {
if (key === 'tags') {
// Concat and remove dupes
this.newTags = [...new Set(this.newTags.concat(batchDetails.tags))]
} else if (key === 'genres' || key === 'narrators') {
// 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 {
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]
}
}
}
},

View File

@@ -77,7 +77,7 @@ export default {
return Math.floor(this.clientWidth / (this.cardWidth + 16))
},
isSelectionMode() {
return this.$store.getters['getNumLibraryItemsSelected'] > 0
return this.$store.getters['globals/getIsBatchSelectingMediaItems']
}
},
methods: {
@@ -101,14 +101,14 @@ export default {
this.updateSelectionMode(this.isSelectionMode)
},
updateSelectionMode(val) {
var selectedLibraryItems = this.$store.state.selectedLibraryItems
const selectedMediaItems = this.$store.state.globals.selectedMediaItems
this.items.forEach((ent) => {
var component = this.$refs[`slider-episode-${ent.recentEpisode.id}`]
let component = this.$refs[`slider-episode-${ent.recentEpisode.id}`]
if (!component || !component.length) return
component = component[0]
component.setSelectionMode(val)
component.selected = selectedLibraryItems.includes(ent.id)
component.selected = selectedMediaItems.some((i) => i.id === ent.id)
})
},
scrolled() {

View File

@@ -63,7 +63,7 @@ export default {
return Math.floor(this.clientWidth / (this.cardWidth + 16))
},
isSelectionMode() {
return this.$store.getters['getNumLibraryItemsSelected'] > 0
return this.$store.getters['globals/getIsBatchSelectingMediaItems']
}
},
methods: {
@@ -82,14 +82,14 @@ export default {
this.updateSelectionMode(this.isSelectionMode)
},
updateSelectionMode(val) {
var selectedLibraryItems = this.$store.state.selectedLibraryItems
const selectedMediaItems = this.$store.state.globals.selectedMediaItems
this.items.forEach((item) => {
var component = this.$refs[`slider-item-${item.id}`]
let component = this.$refs[`slider-item-${item.id}`]
if (!component || !component.length) return
component = component[0]
component.setSelectionMode(val)
component.selected = selectedLibraryItems.includes(item.id)
component.selected = selectedMediaItems.some((i) => i.id === item.id)
})
},
scrolled() {

View File

@@ -107,14 +107,24 @@ export default {
author: this.details.author
}
},
mapBatchDetails(batchDetails) {
mapBatchDetails(batchDetails, mapType = 'overwrite') {
for (const key in batchDetails) {
if (key === 'tags') {
this.newTags = [...batchDetails.tags]
} else if (key === 'genres') {
this.details[key] = [...batchDetails[key]]
if (mapType === 'append') {
if (key === 'tags') {
// Concat and remove dupes
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 {
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]
}
}
}
},

View File

@@ -61,7 +61,7 @@ export default {
return Math.floor(this.clientWidth / (this.cardWidth + 16))
},
isSelectionMode() {
return this.$store.getters['getNumLibraryItemsSelected'] > 0
return this.$store.getters['globals/getIsBatchSelectingMediaItems']
}
},
methods: {

View File

@@ -42,9 +42,8 @@ export default {
if (this.$store.state.showEditModal) {
this.$store.commit('setShowEditModal', false)
}
if (this.$store.state.selectedLibraryItems) {
this.$store.commit('setSelectedLibraryItems', [])
}
this.$store.commit('globals/resetSelectedMediaItems', [])
this.updateBodyClass()
}
},
@@ -281,7 +280,6 @@ export default {
userUpdated(user) {
if (this.$store.state.user.user.id === user.id) {
this.$store.commit('user/setUser', user)
this.$store.commit('user/setSettings', user.settings)
}
},
userOnline(user) {
@@ -504,9 +502,9 @@ export default {
}
// Batch selecting
if (this.$store.getters['getNumLibraryItemsSelected'] && name === 'Escape') {
if (this.$store.getters['globals/getIsBatchSelectingMediaItems'] && name === 'Escape') {
// ESCAPE key cancels batch selection
this.$store.commit('setSelectedLibraryItems', [])
this.$store.commit('globals/resetSelectedMediaItems', [])
this.$eventBus.$emit('bookshelf_clear_selection')
e.preventDefault()
return

View File

@@ -32,7 +32,7 @@ export default {
shelfEl.appendChild(bookComponent.$el)
if (this.isSelectionMode) {
bookComponent.setSelectionMode(true)
if (this.selectedLibraryItems.includes(bookComponent.libraryItemId) || this.isSelectAll) {
if (this.selectedMediaItems.some(i => i.id === bookComponent.libraryItemId) || this.isSelectAll) {
bookComponent.selected = true
} else {
bookComponent.selected = false
@@ -89,7 +89,7 @@ export default {
}
if (this.isSelectionMode) {
instance.setSelectionMode(true)
if (instance.libraryItemId && this.selectedLibraryItems.includes(instance.libraryItemId) || this.isSelectAll) {
if (instance.libraryItemId && this.selectedMediaItems.some(i => i.id === instance.libraryItemId) || this.isSelectAll) {
instance.selected = true
}
}

View File

@@ -110,7 +110,6 @@ module.exports = {
short_name: 'Audiobookshelf',
display: 'standalone',
background_color: '#373838',
start_url: '',
icons: [
{
src: (process.env.ROUTER_BASE_PATH || '') + '/icon.svg',
@@ -119,6 +118,8 @@ module.exports = {
]
},
workbox: {
offline: false,
cacheAssets: false,
preCaching: [],
runtimeCaching: []
}

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "2.2.6",
"version": "2.2.9",
"description": "Self-hosted audiobook and podcast client",
"main": "index.js",
"scripts": {

View File

@@ -1,37 +1,40 @@
<template>
<div id="page-wrapper" class="bg-bg page overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
<div class="flex items-center py-4 max-w-7xl mx-auto">
<div class="flex items-center py-4 px-2 md:px-0 max-w-7xl mx-auto">
<nuxt-link :to="`/item/${libraryItem.id}`" class="hover:underline">
<h1 class="text-xl">{{ title }}</h1>
<h1 class="text-lg lg:text-xl">{{ title }}</h1>
</nuxt-link>
<button class="w-7 h-7 flex items-center justify-center mx-4 hover:scale-110 duration-100 transform text-gray-200 hover:text-white" @click="editItem">
<span class="material-icons text-base">edit</span>
</button>
<div class="flex-grow" />
<p class="text-base">{{ $strings.LabelDuration }}:</p>
<p class="text-base font-mono ml-8">{{ $secondsToTimestamp(mediaDurationRounded) }}</p>
<div class="flex-grow hidden md:block" />
<p class="text-base hidden md:block">{{ $strings.LabelDuration }}:</p>
<p class="text-base font-mono ml-4 hidden md:block">{{ $secondsToTimestamp(mediaDurationRounded) }}</p>
</div>
<div class="flex flex-wrap-reverse justify-center py-4">
<div class="flex flex-wrap-reverse justify-center py-4 px-2">
<div class="w-full max-w-3xl py-4">
<div class="flex items-center">
<div class="w-12 hidden lg:block" />
<p class="text-lg mb-4 font-semibold">{{ $strings.HeaderChapters }}</p>
<div class="flex-grow" />
<ui-checkbox v-model="showSecondInputs" checkbox-bg="primary" small label-class="text-sm text-gray-200 pl-1" label="Show seconds" class="mx-2" />
<div class="w-40" />
<div class="w-32 hidden lg:block" />
</div>
<div class="flex items-center mb-3 py-1">
<div class="flex-grow" />
<div class="w-12 hidden lg:block" />
<ui-btn v-if="newChapters.length > 1" :color="showShiftTimes ? 'bg' : 'primary'" small @click="showShiftTimes = !showShiftTimes">{{ $strings.ButtonShiftTimes }}</ui-btn>
<ui-btn color="primary" small class="mx-2" @click="showFindChaptersModal = true">{{ $strings.ButtonLookup }}</ui-btn>
<ui-btn color="success" small @click="saveChapters">{{ $strings.ButtonSave }}</ui-btn>
<div class="w-40" />
<div class="flex-grow" />
<ui-btn v-if="hasChanges" small class="mx-2" @click.stop="resetChapters">{{ $strings.ButtonReset }}</ui-btn>
<ui-btn v-if="hasChanges" color="success" :disabled="!hasChanges" small @click="saveChapters">{{ $strings.ButtonSave }}</ui-btn>
<div class="w-32 hidden lg:block" />
</div>
<div class="overflow-hidden">
<transition name="slide">
<div v-if="showShiftTimes" class="flex mb-4">
<div class="w-12"></div>
<div class="w-12 hidden lg:block" />
<div class="flex-grow">
<div class="flex items-center">
<p class="text-sm mb-1 font-semibold pr-2">{{ $strings.LabelTimeToShift }}</p>
@@ -42,28 +45,28 @@
</div>
<p class="text-xs py-1.5 text-gray-300 max-w-md">{{ $strings.NoteChapterEditorTimes }}</p>
</div>
<div class="w-40"></div>
<div class="w-32 hidden lg:block" />
</div>
</transition>
</div>
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
<div class="w-12"></div>
<div class="w-32 px-2">{{ $strings.LabelStart }}</div>
<div class="w-8 min-w-8 md:w-12 md:min-w-12"></div>
<div class="w-24 min-w-24 md:w-32 md:min-w-32 px-2">{{ $strings.LabelStart }}</div>
<div class="flex-grow px-2">{{ $strings.LabelTitle }}</div>
<div class="w-40"></div>
<div class="w-32"></div>
</div>
<template v-for="chapter in newChapters">
<div :key="chapter.id" class="flex py-1">
<div class="w-12">#{{ chapter.id + 1 }}</div>
<div class="w-32 px-1">
<div class="w-8 min-w-8 md:w-12 md:min-w-12">#{{ chapter.id + 1 }}</div>
<div class="w-24 min-w-24 md:w-32 md:min-w-32 px-1">
<ui-text-input v-if="showSecondInputs" v-model="chapter.start" type="number" class="text-xs" @change="checkChapters" />
<ui-time-picker v-else class="text-xs" v-model="chapter.start" :show-three-digit-hour="mediaDuration >= 360000" @change="checkChapters" />
</div>
<div class="flex-grow px-1">
<ui-text-input v-model="chapter.title" class="text-xs" />
<ui-text-input v-model="chapter.title" @change="checkChapters" class="text-xs" />
</div>
<div class="w-40 px-2 py-1">
<div class="w-32 min-w-32 px-2 py-1">
<div class="flex items-center">
<ui-tooltip :text="$strings.MessageRemoveChapter" direction="bottom">
<button v-if="newChapters.length > 1" class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-error transform hover:scale-110 duration-150" @click="removeChapter(chapter)">
@@ -96,8 +99,15 @@
</template>
</div>
<div class="w-full max-w-xl py-4">
<p class="text-lg mb-4 font-semibold py-1">{{ $strings.HeaderAudioTracks }}</p>
<div class="w-full max-w-xl py-4 px-2">
<div class="flex items-center mb-4 py-1">
<p class="text-lg font-semibold">{{ $strings.HeaderAudioTracks }}</p>
<div class="flex-grow" />
<ui-btn small @click="setChaptersFromTracks">{{ $strings.ButtonSetChaptersFromTracks }}</ui-btn>
<ui-tooltip :text="$strings.MessageSetChaptersFromTracksDescription" direction="top" class="flex items-center mx-1 cursor-default">
<span class="material-icons-outlined text-xl text-gray-200">info</span>
</ui-tooltip>
</div>
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
<div class="flex-grow">{{ $strings.LabelFilename }}</div>
<div class="w-20">{{ $strings.LabelDuration }}</div>
@@ -177,8 +187,8 @@
</div>
<div class="flex items-center pt-2">
<ui-btn small color="primary" class="mr-1" @click="applyChapterNamesOnly">{{ $strings.ButtonMapChapterTitles }}</ui-btn>
<ui-tooltip :text="$strings.MessageMapChapterTitles" direction="top">
<span class="material-icons-outlined">info</span>
<ui-tooltip :text="$strings.MessageMapChapterTitles" direction="top" class="flex items-center">
<span class="material-icons-outlined text-xl text-gray-200">info</span>
</ui-tooltip>
<div class="flex-grow" />
<ui-btn small color="success" @click="applyChapterData">{{ $strings.ButtonApplyChapters }}</ui-btn>
@@ -190,6 +200,8 @@
</template>
<script>
import path from 'path'
export default {
async asyncData({ store, params, app, redirect, from }) {
if (!store.getters['user/getUserCanUpdate']) {
@@ -232,7 +244,8 @@ export default {
showFindChaptersModal: false,
chapterData: null,
showSecondInputs: false,
audibleRegions: ['US', 'CA', 'UK', 'AU', 'FR', 'DE', 'JP', 'IT', 'IN', 'ES']
audibleRegions: ['US', 'CA', 'UK', 'AU', 'FR', 'DE', 'JP', 'IT', 'IN', 'ES'],
hasChanges: false
}
},
computed: {
@@ -274,6 +287,23 @@ export default {
}
},
methods: {
setChaptersFromTracks() {
let currentStartTime = 0
let index = 0
const chapters = []
for (const track of this.audioTracks) {
chapters.push({
id: index++,
title: path.basename(track.metadata.filename, path.extname(track.metadata.filename)),
start: currentStartTime,
end: currentStartTime + track.duration
})
currentStartTime += track.duration
}
this.newChapters = chapters
this.checkChapters()
},
shiftChapterTimes() {
if (!this.shiftAmount || isNaN(this.shiftAmount) || this.newChapters.length <= 1) {
return
@@ -304,7 +334,6 @@ export default {
this.$store.commit('showEditModal', this.libraryItem)
},
addChapter(chapter) {
console.log('Add chapter', chapter)
const newChapter = {
id: chapter.id + 1,
start: chapter.start,
@@ -319,22 +348,41 @@ export default {
this.checkChapters()
},
checkChapters() {
var previousStart = 0
let previousStart = 0
let hasChanges = this.newChapters.length !== this.chapters.length
for (let i = 0; i < this.newChapters.length; i++) {
this.newChapters[i].id = i
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) {
this.newChapters[i].error = 'First chapter must start at 0'
this.newChapters[i].error = this.$strings.MessageChapterErrorFirstNotZero
} else if (this.newChapters[i].start <= previousStart && i > 0) {
this.newChapters[i].error = 'Invalid start time must be >= previous chapter start time'
this.newChapters[i].error = this.$strings.MessageChapterErrorStartLtPrev
} else if (this.newChapters[i].start >= this.mediaDuration) {
this.newChapters[i].error = 'Invalid start time must be < duration'
this.newChapters[i].error = this.$strings.MessageChapterErrorStartGteDuration
} else {
this.newChapters[i].error = null
}
previousStart = this.newChapters[i].start
if (hasChanges) {
continue
}
const existingChapter = this.chapters[i]
if (existingChapter) {
const { start, end, title } = this.newChapters[i]
if (start !== existingChapter.start || end !== existingChapter.end || title !== existingChapter.title) {
hasChanges = true
}
} else {
hasChanges = true
}
}
this.hasChanges = hasChanges
},
playChapter(chapter) {
console.log('Play Chapter', chapter.id)
@@ -353,8 +401,6 @@ export default {
const audioTrack = this.tracks.find((at) => {
return chapter.start >= at.startOffset && chapter.start < at.startOffset + at.duration
})
console.log('audio track', audioTrack)
this.selectedChapter = chapter
this.isLoadingChapter = true
@@ -369,7 +415,6 @@ export default {
if (this.$isDev) {
src = `http://localhost:3333${this.$config.routerBasePath}${src}`
}
console.log('src', src)
audioEl.src = src
audioEl.id = 'chapter-audio'
@@ -413,11 +458,11 @@ export default {
for (let i = 0; i < this.newChapters.length; i++) {
if (this.newChapters[i].error) {
this.$toast.error('Chapters have errors')
this.$toast.error(this.$strings.ToastChaptersHaveErrors)
return
}
if (!this.newChapters[i].title) {
this.$toast.error('Chapters must have titles')
this.$toast.error(this.$strings.ToastChaptersMustHaveTitles)
return
}
@@ -464,22 +509,25 @@ export default {
this.showFindChaptersModal = false
this.chapterData = null
this.checkChapters()
},
applyChapterData() {
var index = 0
let index = 0
this.newChapters = this.chapterData.chapters
.filter((chap) => chap.startOffsetSec < this.mediaDuration)
.map((chap) => {
var chapEnd = Math.min(this.mediaDuration, (chap.startOffsetMs + chap.lengthMs) / 1000)
return {
id: index++,
start: chap.startOffsetMs / 1000,
end: chapEnd,
end: Math.min(this.mediaDuration, (chap.startOffsetMs + chap.lengthMs) / 1000),
title: chap.title
}
})
this.showFindChaptersModal = false
this.chapterData = null
this.checkChapters()
},
findChapters() {
if (!this.asinInput) {
@@ -513,22 +561,38 @@ export default {
this.$toast.error('Failed to find chapters')
this.showFindChaptersModal = false
})
},
resetChapters() {
const payload = {
message: this.$strings.MessageResetChaptersConfirm,
callback: (confirmed) => {
if (confirmed) {
this.initChapters()
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
initChapters() {
this.newChapters = this.chapters.map((c) => ({ ...c }))
if (!this.newChapters.length) {
this.newChapters = [
{
id: 0,
start: 0,
end: this.mediaDuration,
title: ''
}
]
}
this.checkChapters()
}
},
mounted() {
this.regionInput = localStorage.getItem('audibleRegion') || 'US'
this.asinInput = this.mediaMetadata.asin || null
this.newChapters = this.chapters.map((c) => ({ ...c }))
if (!this.newChapters.length) {
this.newChapters = [
{
id: 0,
start: 0,
end: this.mediaDuration,
title: ''
}
]
}
this.initChapters()
},
beforeDestroy() {
this.destroyAudioEl()

View File

@@ -4,12 +4,23 @@
<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>
<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 class="overflow-hidden">
<transition name="slide">
<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-text-input-with-label ref="subtitleInput" v-model="batchDetails.subtitle" :disabled="!selectedBatchUsage.subtitle" :label="$strings.LabelSubtitle" class="mb-4 ml-4" />
</div>
@@ -18,13 +29,13 @@
<!-- 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" />
</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-text-input-with-label ref="publishedYearInput" v-model="batchDetails.publishedYear" :disabled="!selectedBatchUsage.publishedYear" :label="$strings.LabelPublishYear" class="mb-4 ml-4" />
</div>
<div v-if="!isPodcastLibrary" class="flex items-center px-4 w-1/2">
<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 class="flex items-center px-4 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.genres" />
@@ -38,15 +49,15 @@
<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" />
</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-text-input-with-label ref="publisherInput" v-model="batchDetails.publisher" :disabled="!selectedBatchUsage.publisher" :label="$strings.LabelPublisher" class="mb-4 ml-4" />
</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-text-input-with-label ref="languageInput" v-model="batchDetails.language" :disabled="!selectedBatchUsage.language" :label="$strings.LabelLanguage" class="mb-4 ml-4" />
</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" />
<div class="ml-4">
<ui-checkbox
@@ -91,14 +102,19 @@
<script>
export default {
async asyncData({ store, redirect, app }) {
if (!store.state.selectedLibraryItems.length) {
if (!store.state.globals.selectedMediaItems.length) {
return redirect('/')
}
var libraryItems = await app.$axios.$post(`/api/items/batch/get`, { libraryItemIds: store.state.selectedLibraryItems }).catch((error) => {
var errorMsg = error.response.data || 'Failed to get items'
console.error(errorMsg, error)
return []
})
const libraryItemIds = store.state.globals.selectedMediaItems.map((i) => i.id)
const libraryItems = await app.$axios
.$post(`/api/items/batch/get`, { libraryItemIds })
.then((res) => res.libraryItems)
.catch((error) => {
const errorMsg = error.response.data || 'Failed to get items'
console.error(errorMsg, error)
return []
})
return {
mediaType: libraryItems[0].mediaType,
libraryItems
@@ -109,10 +125,10 @@ export default {
isProcessing: false,
libraryItemCopies: [],
isScrollable: false,
newSeriesNames: [],
newTagItems: [],
newGenreItems: [],
newNarratorItems: [],
mapDetailsType: 'overwrite',
batchDetails: {
subtitle: null,
authors: null,
@@ -137,10 +153,17 @@ export default {
language: false,
explicit: false
},
appendableKeys: ['authors', 'genres', 'tags', 'narrators', 'series'],
openMapOptions: false
}
},
computed: {
isMapOverwrite() {
return this.mapDetailsType === 'overwrite'
},
isMapAppend() {
return this.mapDetailsType === 'append'
},
isPodcastLibrary() {
return this.mediaType === 'podcast'
},
@@ -153,9 +176,6 @@ export default {
tagItems() {
return this.tags.concat(this.newTagItems)
},
seriesItems() {
return [...this.existingSeriesNames, ...this.newSeriesNames]
},
narratorItems() {
return [...this.narrators, ...this.newNarratorItems]
},
@@ -214,31 +234,32 @@ export default {
mapBatchDetails() {
this.blurBatchForm()
var batchMapPayload = {}
const batchMapPayload = {}
for (const key in this.selectedBatchUsage) {
if (this.selectedBatchUsage[key]) {
if (key === 'series') {
// Map string of series to series objects
batchMapPayload[key] = this.batchDetails[key].map((seItem) => {
var existingSeries = this.series.find((se) => se.name.toLowerCase() === seItem.toLowerCase().trim())
if (existingSeries) {
return existingSeries
} else {
return {
id: `new-${Math.floor(Math.random() * 10000)}`,
name: seItem
}
if (!this.selectedBatchUsage[key]) continue
if (this.isMapAppend && !this.appendableKeys.includes(key)) continue
if (key === 'series') {
// Map string of series to series objects
batchMapPayload[key] = this.batchDetails[key].map((seItem) => {
const existingSeries = this.series.find((se) => se.name.toLowerCase() === seItem.toLowerCase().trim())
if (existingSeries) {
return existingSeries
} else {
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) => {
var ref = this.getEditFormRef(li.id)
ref.mapBatchDetails(batchMapPayload)
const ref = this.getEditFormRef(li.id)
ref.mapBatchDetails(batchMapPayload, this.mapDetailsType)
})
this.$toast.success('Details mapped')
},

View File

@@ -19,9 +19,11 @@
{{ streaming ? $strings.ButtonPlaying : $strings.ButtonPlay }}
</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 class="my-8 max-w-2xl">
@@ -32,7 +34,7 @@
</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 />
</div>
</div>
@@ -64,7 +66,7 @@ export default {
},
data() {
return {
processingRemove: false
processing: false
}
},
computed: {
@@ -102,15 +104,55 @@ export default {
},
userCanDelete() {
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: {
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() {
this.$store.commit('globals/setEditCollection', this.collection)
},
removeClick() {
if (confirm(this.$getString('MessageConfirmRemoveCollection', [this.collectionName]))) {
this.processingRemove = true
this.processing = true
this.$axios
.$delete(`/api/collections/${this.collection.id}`)
.then(() => {
@@ -121,7 +163,7 @@ export default {
this.$toast.error(this.$strings.ToastCollectionRemoveFailed)
})
.finally(() => {
this.processingRemove = false
this.processing = false
})
}
},

View File

@@ -54,6 +54,7 @@ export default {
else if (pageName === 'stats') return this.$strings.HeaderYourStats
else if (pageName === 'library-stats') return this.$strings.HeaderLibraryStats
else if (pageName === 'users') return this.$strings.HeaderUsers
else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils
}
return this.$strings.HeaderSettings
}

View File

@@ -201,9 +201,9 @@
<div class="flex items-center py-4">
<div class="flex-grow" />
<ui-btn color="bg" small :padding-x="4" class="hidden lg:block mr-2" :loading="isPurgingCache" @click.stop="purgeCache">{{ $strings.ButtonPurgeAllCache }}</ui-btn>
<ui-btn color="bg" small :padding-x="4" class="hidden lg:block mr-2" :loading="isPurgingCache" @click.stop="purgeItemsCache">{{ $strings.ButtonPurgeItemsCache }}</ui-btn>
<ui-btn color="bg" small :padding-x="4" class="hidden lg:block mr-2" :loading="isResettingLibraryItems" @click="resetLibraryItems">{{ $strings.ButtonRemoveAllLibraryItems }}</ui-btn>
<ui-btn color="bg" small :padding-x="4" class="mr-2 text-xs md:text-sm" :loading="isPurgingCache" @click.stop="purgeCache">{{ $strings.ButtonPurgeAllCache }}</ui-btn>
<ui-btn color="bg" small :padding-x="4" class="mr-2 text-xs md:text-sm" :loading="isPurgingCache" @click.stop="purgeItemsCache">{{ $strings.ButtonPurgeItemsCache }}</ui-btn>
<ui-btn color="bg" small :padding-x="4" class="mr-2 text-xs md:text-sm" :loading="isResettingLibraryItems" @click="resetLibraryItems">{{ $strings.ButtonRemoveAllLibraryItems }}</ui-btn>
</div>
<div class="flex items-center py-4">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -61,10 +61,10 @@
<script>
export default {
async asyncData({ params, redirect, app }) {
var users = await app.$axios
const users = await app.$axios
.$get('/api/users')
.then((users) => {
return users.sort((a, b) => {
.then((res) => {
return res.users.sort((a, b) => {
return a.createdAt - b.createdAt
})
})

View File

@@ -48,10 +48,13 @@ export default {
},
methods: {
async init() {
this.authors = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/authors`).catch((error) => {
console.error('Failed to load authors', error)
return []
})
this.authors = await this.$axios
.$get(`/api/libraries/${this.currentLibraryId}/authors`)
.then((response) => response.authors)
.catch((error) => {
console.error('Failed to load authors', error)
return []
})
this.loading = false
},
authorAdded(author) {

View File

@@ -15,17 +15,14 @@ export default {
}
// Set series sort by
if (params.id === 'series') {
if (query.sort) {
store.commit('libraries/setSeriesSortBy', query.sort)
store.commit('libraries/setSeriesSortDesc', !!query.desc)
if (query.filter || query.sort || query.desc) {
const isSeries = params.id === 'series'
const settingsUpdate = {
[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) {
console.log('has filter', query.filter)
store.commit('libraries/setSeriesFilterBy', query.filter)
}
} else if (query.filter) {
store.dispatch('user/updateUserSettings', { filterBy: query.filter })
store.dispatch('user/updateUserSettings', settingsUpdate)
}
// Redirect podcast libraries

View File

@@ -137,6 +137,8 @@ export default {
this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId)
this.$store.commit('user/setUser', user)
this.$store.dispatch('user/loadUserSettings')
},
async submitForm() {
this.error = null

View File

@@ -5,8 +5,6 @@ import { formatDistance, format, addDays, isDate } from 'date-fns'
Vue.directive('click-outside', vClickOutside.directive)
Vue.prototype.$eventBus = new Vue()
Vue.prototype.$dateDistanceFromNow = (unixms) => {
if (!unixms) return ''
return formatDistance(unixms, Date.now(), { addSuffix: true })
@@ -30,23 +28,26 @@ Vue.prototype.$addDaysToDate = (jsdate, daysToAdd) => {
return date
}
Vue.prototype.$sanitizeFilename = (input, colonReplacement = ' - ') => {
if (typeof input !== 'string') {
Vue.prototype.$sanitizeFilename = (filename, colonReplacement = ' - ') => {
if (typeof filename !== 'string') {
return false
}
// Max is actually 255-260 for windows but this leaves padding incase ext wasnt put on yet
const MAX_FILENAME_LEN = 240
// Most file systems use number of bytes for max filename
// 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 = ''
var illegalRe = /[\/\?<>\\:\*\|"]/g
var controlRe = /[\x00-\x1f\x80-\x9f]/g
var reservedRe = /^\.+$/
var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i
var windowsTrailingRe = /[\. ]+$/
var lineBreaks = /[\n\r]/g
const replacement = ''
const illegalRe = /[\/\?<>\\:\*\|"]/g
const controlRe = /[\x00-\x1f\x80-\x9f]/g
const reservedRe = /^\.+$/
const windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i
const windowsTrailingRe = /[\. ]+$/
const lineBreaks = /[\n\r]/g
var sanitized = input
sanitized = filename
.replace(':', colonReplacement) // Replace first occurrence of a colon
.replace(illegalRe, replacement)
.replace(controlRe, replacement)
@@ -55,13 +56,25 @@ Vue.prototype.$sanitizeFilename = (input, colonReplacement = ' - ') => {
.replace(windowsReservedRe, 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) {
var lenToRemove = sanitized.length - MAX_FILENAME_LEN
var ext = Path.extname(sanitized)
var basename = Path.basename(sanitized, ext)
basename = basename.slice(0, basename.length - lenToRemove)
sanitized = basename + ext
// 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
@@ -94,13 +107,11 @@ Vue.prototype.$sanitizeSlug = (str) => {
Vue.prototype.$copyToClipboard = (str, ctx) => {
return new Promise((resolve) => {
if (!navigator.clipboard) {
if (navigator.clipboard) {
navigator.clipboard.writeText(str).then(() => {
if (ctx) ctx.$toast.success('Copied to clipboard')
resolve(true)
}, (err) => {
console.error('Clipboard copy failed', str, err)
resolve(false)
})
} else {
const el = document.createElement('textarea')
@@ -146,6 +157,7 @@ export {
export default ({ app, store }, inject) => {
app.$decode = decode
app.$encode = encode
inject('eventBus', new Vue())
inject('isDev', process.env.NODE_ENV !== 'production')
store.commit('setRouterBasePath', app.$config.routerBasePath)

View File

@@ -16,6 +16,7 @@ export const state = () => ({
selectedPlaylist: null,
selectedCollection: null,
selectedAuthor: null,
selectedMediaItems: [],
isCasting: false, // Actively casting
isChromecastInitialized: false, // Script loadeds
showBatchQuickMatchModal: false,
@@ -64,6 +65,9 @@ export const getters = {
return `http://localhost:3333${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}`
}
return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}`
},
getIsBatchSelectingMediaItems: (state) => {
return state.selectedMediaItems.length
}
}
@@ -134,5 +138,24 @@ export const mutations = {
},
setShowBatchQuickMatchModal(state, val) {
state.showBatchQuickMatchModal = val
},
resetSelectedMediaItems(state) {
state.selectedMediaItems = []
},
toggleMediaItemSelected(state, item) {
if (state.selectedMediaItems.some(i => i.id === item.id)) {
state.selectedMediaItems = state.selectedMediaItems.filter(i => i.id !== item.id)
} else {
state.selectedMediaItems.push(item)
}
},
setMediaItemSelected(state, { item, selected }) {
const isAlreadySelected = state.selectedMediaItems.some(i => i.id === item.id)
if (isAlreadySelected && !selected) {
state.selectedMediaItems = state.selectedMediaItems.filter(i => i.id !== item.id)
} else if (selected && !isAlreadySelected) {
state.selectedMediaItems.push(item)
}
}
}

View File

@@ -17,7 +17,6 @@ export const state = () => ({
showEReader: false,
selectedLibraryItem: null,
developerMode: false,
selectedLibraryItems: [],
processingBatch: false,
previousPath: '/',
showExperimentalFeatures: false,
@@ -29,14 +28,10 @@ export const state = () => ({
})
export const getters = {
getIsLibraryItemSelected: state => libraryItemId => {
return !!state.selectedLibraryItems.includes(libraryItemId)
},
getServerSetting: state => key => {
if (!state.serverSettings) return null
return state.serverSettings[key]
},
getNumLibraryItemsSelected: state => state.selectedLibraryItems.length,
getLibraryItemIdStreaming: state => {
return state.streamLibraryItem ? state.streamLibraryItem.id : null
},
@@ -217,26 +212,6 @@ export const mutations = {
setSelectedLibraryItem(state, val) {
Vue.set(state, 'selectedLibraryItem', val)
},
setSelectedLibraryItems(state, items) {
Vue.set(state, 'selectedLibraryItems', items)
},
toggleLibraryItemSelected(state, itemId) {
if (state.selectedLibraryItems.includes(itemId)) {
state.selectedLibraryItems = state.selectedLibraryItems.filter(a => a !== itemId)
} else {
var newSel = state.selectedLibraryItems.concat([itemId])
Vue.set(state, 'selectedLibraryItems', newSel)
}
},
setLibraryItemSelected(state, { libraryItemId, selected }) {
var isThere = state.selectedLibraryItems.includes(libraryItemId)
if (isThere && !selected) {
state.selectedLibraryItems = state.selectedLibraryItems.filter(a => a !== libraryItemId)
} else if (selected && !isThere) {
var newSel = state.selectedLibraryItems.concat([libraryItemId])
Vue.set(state, 'selectedLibraryItems', newSel)
}
},
setProcessingBatch(state, val) {
state.processingBatch = val
},

View File

@@ -10,9 +10,6 @@ export const state = () => ({
folderLastUpdate: 0,
filterData: null,
numUserPlaylists: 0,
seriesSortBy: 'name',
seriesSortDesc: false,
seriesFilterBy: 'all',
collections: [],
userPlaylists: []
})
@@ -86,8 +83,8 @@ export const actions = {
.$get('/api/filesystem')
.then((res) => {
console.log('Settings folders', res)
commit('setFolders', res)
return res
commit('setFolders', res.directories)
return res.directories
})
.catch((error) => {
console.error('Failed to load dirs', error)
@@ -151,7 +148,7 @@ export const actions = {
this.$axios
.$get(`/api/libraries`)
.then((data) => {
commit('set', data)
commit('set', data.libraries)
commit('setLastLoad')
})
.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) {
state.collections = collections
},

View File

@@ -7,9 +7,12 @@ export const state = () => ({
playbackRate: 1,
bookshelfCoverSize: 120,
collapseSeries: false,
collapseBookSeries: false
},
settingsListeners: []
collapseBookSeries: false,
useChapterTrack: false,
seriesSortBy: 'name',
seriesSortDesc: false,
seriesFilterBy: 'all'
}
})
export const getters = {
@@ -66,7 +69,7 @@ export const getters = {
export const actions = {
// When changing libraries make sure sort and filter is still valid
checkUpdateLibrarySortFilter({ state, dispatch, commit }, mediaType) {
var settingsUpdate = {}
const settingsUpdate = {}
if (mediaType == 'podcast') {
if (state.settings.orderBy == 'media.metadata.authorName' || state.settings.orderBy == 'media.metadata.authorNameLF') {
settingsUpdate.orderBy = 'media.metadata.author'
@@ -77,8 +80,8 @@ export const actions = {
if (state.settings.orderBy == 'media.metadata.publishedYear') {
settingsUpdate.orderBy = 'media.metadata.title'
}
var invalidFilters = ['series', 'authors', 'narrators', 'languages', 'progress', 'issues']
var filterByFirstPart = (state.settings.filterBy || '').split('.').shift()
const invalidFilters = ['series', 'authors', 'narrators', 'languages', 'progress', 'issues']
const filterByFirstPart = (state.settings.filterBy || '').split('.').shift()
if (invalidFilters.includes(filterByFirstPart)) {
settingsUpdate.filterBy = 'all'
}
@@ -94,30 +97,46 @@ export const actions = {
dispatch('updateUserSettings', settingsUpdate)
}
},
updateUserSettings({ commit }, payload) {
var updatePayload = {
...payload
}
// Immediately update
commit('setSettings', updatePayload)
return this.$axios.$patch('/api/me/settings', updatePayload).then((result) => {
if (result.success) {
commit('setSettings', result.settings)
return true
} else {
return false
updateUserSettings({ state, commit }, payload) {
if (!payload) return false
let hasChanges = false
const existingSettings = { ...state.settings }
for (const key in existingSettings) {
if (payload[key] !== undefined && existingSettings[key] !== payload[key]) {
hasChanges = true
existingSettings[key] = payload[key]
}
}).catch((error) => {
console.error('Failed to update settings', error)
return false
})
}
if (hasChanges) {
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 = {
setUser(state, user) {
state.user = user
state.settings = user.settings
if (user) {
if (user.token) localStorage.setItem('token', user.token)
} else {
@@ -143,25 +162,7 @@ export const mutations = {
},
setSettings(state, settings) {
if (!settings) return
var hasChanges = false
for (const key in 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)
localStorage.setItem('userSettings', JSON.stringify(settings))
state.settings = settings
}
}

View File

@@ -22,7 +22,7 @@
"ButtonDelete": "Löschen",
"ButtonEditChapters": "Kapitel bearbeiten",
"ButtonEditPodcast": "Podcast bearbeiten",
"ButtonForceReScan": "Erzwinge einen Neu-Scan",
"ButtonForceReScan": "Erzwinge kompletten Neu-Scan",
"ButtonFullPath": "Vollständiger Pfad",
"ButtonHide": "Ausblenden",
"ButtonHome": "Startseite",
@@ -30,11 +30,11 @@
"ButtonLatest": "Neuste",
"ButtonLibrary": "Bibliothek",
"ButtonLogout": "Abmelden",
"ButtonLookup": "Nachschlagen",
"ButtonLookup": "Online-Suche",
"ButtonManageTracks": "Tracks verwalten",
"ButtonMapChapterTitles": "Kapitelüberschriften zuordnen",
"ButtonMatchAllAuthors": "Online-Abgleich aller Autoren",
"ButtonMatchBooks": "Online-Abgleich aller Hörbücher",
"ButtonMatchAllAuthors": "Online-Suche für alle Autoren",
"ButtonMatchBooks": "Online-Suche für alle Hörbücher",
"ButtonNevermind": "Vergiss es",
"ButtonOk": "Ok",
"ButtonOpenFeed": "Feed öffnen",
@@ -60,12 +60,13 @@
"ButtonSave": "Speichern",
"ButtonSaveAndClose": "Speichern & Schließen",
"ButtonSaveTracklist": "Speichere die Titelliste",
"ButtonScan": "Durchsuchen",
"ButtonScanLibrary": "Bibliothek durchsuchen",
"ButtonScan": "Scan",
"ButtonScanLibrary": "Bibliothek scannen",
"ButtonSearch": "Suchen",
"ButtonSelectFolderPath": "Auswahl Ordnerpfad",
"ButtonSeries": "Serien",
"ButtonShiftTimes": "Arbeitszeiten",
"ButtonSetChaptersFromTracks": "Kapitelerstellung aus Audiodateien",
"ButtonShiftTimes": "Zeitverschiebung",
"ButtonShow": "Anzeigen",
"ButtonStartM4BEncode": "M4B-Kodierung starten",
"ButtonStartMetadataEmbed": "Metadateneinbettung starten",
@@ -80,7 +81,7 @@
"HeaderAdvanced": "Erweitert",
"HeaderAppriseNotificationSettings": "Apprise Benachrichtigungseinstellungen",
"HeaderAudiobookTools": "Hörbuch-Dateiverwaltungstools",
"HeaderAudioTracks": "Audio-Tracks",
"HeaderAudioTracks": "Audiodateien",
"HeaderBackups": "Sicherungen",
"HeaderChangePassword": "Passwort ändern",
"HeaderChapters": "Kapitel",
@@ -94,6 +95,7 @@
"HeaderFindChapters": "Kapitel suchen",
"HeaderIgnoredFiles": "Ignorierte Dateien",
"HeaderItemFiles": "Objekt-Dateien",
"HeaderItemMetadataUtils": "Item Metadata Utils",
"HeaderLastListeningSession": "Letzte Hörsitzung",
"HeaderLatestEpisodes": "Letzte Episoden",
"HeaderLibraries": "Bibliotheken",
@@ -103,7 +105,10 @@
"HeaderListeningStats": "Hörstatistiken",
"HeaderLogin": "Anmeldung",
"HeaderLogs": "Protokolle",
"HeaderMatch": "Online-Abgleich",
"HeaderManageGenres": "Manage Genres",
"HeaderManageTags": "Manage Tags",
"HeaderMapDetails": "Stapelverarbeitung",
"HeaderMatch": "Online-Suche",
"HeaderMetadataToEmbed": "Einzubettende Metadaten",
"HeaderNewAccount": "Neues Konto",
"HeaderNewLibrary": "Neue Bibliothek",
@@ -154,6 +159,7 @@
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
"LabelAll": "Alle",
"LabelAllUsers": "Alle Benutzer",
"LabelAppend": "Anhängen",
"LabelAuthor": "Autor",
"LabelAuthorFirstLast": "Autor (Vorname Nachname)",
"LabelAuthorLastFirst": "Autor (Nachname, Vorname)",
@@ -200,7 +206,7 @@
"LabelEpisode": "Episode",
"LabelEpisodeTitle": "Episodentitel",
"LabelEpisodeType": "Episodentyp",
"LabelExplicit": "Explizit <br />(Altersbeschränkung)",
"LabelExplicit": "Explizit (Altersbeschränkung)",
"LabelFeedURL": "Feed URL",
"LabelFile": "Datei",
"LabelFileBirthtime": "Datei Geburtsdatum",
@@ -277,6 +283,7 @@
"LabelNumberOfBooks": "Anzahl der Hörbücher",
"LabelNumberOfEpisodes": "Anzahl der Episoden",
"LabelOpenRSSFeed": "Öffne RSS Feed",
"LabelOverwrite": "Überschreiben",
"LabelPassword": "Passwort",
"LabelPath": "Pfad",
"LabelPermissionsAccessAllLibraries": "Zugriff auf alle Bibliotheken",
@@ -291,7 +298,7 @@
"LabelPlayMethod": "Abspielmethode",
"LabelPodcast": "Podcast",
"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",
"LabelProvider": "Anbieter",
"LabelPubDate": "Veröffentlichungsdatum",
@@ -314,7 +321,7 @@
"LabelSeriesName": "Serienname",
"LabelSeriesProgress": "Serienfortschritt",
"LabelSettingsBookshelfViewHelp": "Skeumorphes Design mit Holzeinlegeböden",
"LabelSettingsChromecastSupport": "Chromecast-unterstützung",
"LabelSettingsChromecastSupport": "Chromecastunterstützung",
"LabelSettingsDateFormat": "Datumsformat",
"LabelSettingsDisableWatcher": "Überwachung deaktivieren",
"LabelSettingsDisableWatcherForLibrary": "Ordnerüberwachung für die Bibliothek deaktivieren",
@@ -335,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.",
"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.",
"LabelSettingsPreferOPFMetadata": "Bevorzuge OPF-Metadaten",
"LabelSettingsPreferOPFMetadataHelp": "In OPF Dateien gespeicherte Metadaten werden anstelle der Ordnernamen für die Metadaten eines Hörbuchs verwendet. OPF Datein sind seperate \"Textdateien \" mit der Endung \".abs\" in denen verschiedene Matadaten gespiechert sind. Wenn keine OPF Dateien zur Verfügung stehen, werden die Ordnernamen verwendet.",
"LabelSettingsPreferOPFMetadata": "Bevorzuge OPF-Metadaten aus dem Hörbuchordner",
"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",
"LabelSettingsSkipMatchingBooksWithISBN": "Überspringe beim Online-Abgleich alle Bücher die bereits eine ISBN haben",
"LabelSettingsSortingIgnorePrefixes": "Vorwort/Artikel beim Sortieren ignorieren",
@@ -344,9 +351,9 @@
"LabelSettingsSquareBookCovers": "Benutze quadratische Titelbilder",
"LabelSettingsSquareBookCoversHelp": "Bevorzugen quadratische Titelbilder gegenüber den Standardtielbildern im Verhältnis 1,6:1",
"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",
"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",
"LabelSize": "Größe",
"LabelSleepTimer": "Einschlaf-Timer",
@@ -389,6 +396,9 @@
"LabelTotalTimeListened": "Gehörte Gesamtzeit",
"LabelTrackFromFilename": "Titel von Dateiname",
"LabelTrackFromMetadata": "Titel aus Metadaten",
"LabelTracks": "Dateien",
"LabelTracksMultiTrack": "Mehrfachdatei",
"LabelTracksSingleTrack": "Einzeldatei",
"LabelType": "Typ",
"LabelUnknown": "Unbekannt",
"LabelUpdateCover": "Titelbild aktualisieren",
@@ -398,8 +408,8 @@
"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",
"LabelUploaderDropFiles": "Dateien löschen",
"LabelUseChapterTrack": "Kapitelverfolgung verwenden",
"LabelUseFullTrack": "Gesamten Track verwenden",
"LabelUseChapterTrack": "Kapiteldatei verwenden",
"LabelUseFullTrack": "Gesamte Datei verwenden",
"LabelUser": "Benutzer",
"LabelUsername": "Benutzername",
"LabelValue": "Wert",
@@ -411,17 +421,20 @@
"LabelWeekdaysToRun": "Wochentage für die Ausführung",
"LabelYourAudiobookDuration": "Laufzeit Ihres Hörbuchs",
"LabelYourBookmarks": "Lesezeichen",
"LabelYourPlaylists": "Your Playlists",
"LabelYourPlaylists": "Eigene Playlists",
"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.",
"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.",
"MessageBookshelfNoCollections": "Es wurden noch keine Sammlungen erstellt",
"MessageBookshelfNoResultsForFilter": "Keine Ergebnisse für filter \"{0}: {1}\"",
"MessageBookshelfNoRSSFeeds": "Keine RSS-Feeds geöffnet",
"MessageBookshelfNoSeries": "Keine Serien vorhanden",
"MessageChapterEndIsAfter": "Das Kapitelende liegt nach dem Ende Ihres Hörbuchs",
"MessageChapterErrorFirstNotZero": "Das erste Kapitel muss bei 0 beginnen",
"MessageChapterErrorStartGteDuration": "Die ungültige Startzeit darf nicht größer als die gesamte Hörbuchdauer sein",
"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",
"MessageCheckingCron": "Überprüfe cron...",
"MessageConfirmDeleteBackup": "Sind Sie sicher, dass Sie die Sicherung für {0} löschen wollen?",
@@ -432,6 +445,12 @@
"MessageConfirmRemoveEpisode": "Sind Sie sicher, dass Sie die Episode \"{0}\" löschen möchten?",
"MessageConfirmRemoveEpisodes": "Sind Sie sicher, dass Sie {0} Episoden löschen wollen?",
"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",
"MessageDragFilesIntoTrackOrder": "Verschieben Sie die Dateien in die richtige Reihenfolge",
"MessageEmbedFinished": "Einbettung abgeschlossen!",
@@ -442,6 +461,7 @@
"MessageImportantNotice": "Wichtiger Hinweis!",
"MessageInsertChapterBelow": "Kapitel unten einfügen",
"MessageItemsSelected": "{0} ausgewählte Elemente",
"MessageItemsUpdated": "{0} Items Updated",
"MessageJoinUsOn": "Besuchen Sie uns auf",
"MessageListeningSessionsInTheLastYear": "{0} Ereignisse im letzten Jahr",
"MessageLoading": "Laden...",
@@ -452,7 +472,7 @@
"MessageMarkAsFinished": "Als beendet 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.",
"MessageNoAudioTracks": "Keine Audiotracks",
"MessageNoAudioTracks": "Keine Audiodateien",
"MessageNoAuthors": "Keine Autoren",
"MessageNoBackups": "Keine Sicherungen",
"MessageNoBookmarks": "Keine Lesezeichen",
@@ -474,25 +494,30 @@
"MessageNoPodcastsFound": "Keine Podcasts gefunden",
"MessageNoResults": "Keine Ergebnisse",
"MessageNoSearchResultsFor": "Keine Suchergebnisse für \"{0}\"",
"MessageNoSeries": "Keine Serien",
"MessageNoTags": "No Tags",
"MessageNotYetImplemented": "Noch nicht implementiert",
"MessageNoUpdateNecessary": "Keine Aktualisierung erforderlich",
"MessageNoUpdatesWereNecessary": "Keine Aktualisierungen waren notwendig",
"MessageNoUserPlaylists": "You have no playlists",
"MessageNoUserPlaylists": "Keine Wiedergabelisten vorhanden",
"MessageOr": "oder",
"MessagePauseChapter": "Kapitelwiedergabe pausieren",
"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",
"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?",
"MessageRemoveChapter": "Kapitel löschen",
"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?",
"MessageReportBugsAndContribute": "Fehler melden, Funktionen anfordern und Beiträge leisten auf",
"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",
"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",
"MessageServerCouldNotBeReached": "Server kann nicht erreicht werden",
"MessageSetChaptersFromTracksDescription": "Kaitelerstellung basiert auf den existierenden einzelnen Audiodateien. Pro existierende Audiodatei wird 1 Kapitel erstellt, wobei deren Kapitelname aus dem Audiodateinamen extrahiert wird",
"MessageStartPlaybackAtTime": "Start der Wiedergabe für \"{0}\" bei {1}?",
"MessageThinking": "Nachdenken...",
"MessageUploaderItemFailed": "Hochladen fehlgeschlagen",
@@ -539,6 +564,8 @@
"ToastBookmarkRemoveSuccess": "Lesezeichen gelöscht",
"ToastBookmarkUpdateFailed": "Lesezeichenaktualisierung fehlgeschlagen",
"ToastBookmarkUpdateSuccess": "Lesezeichen aktualisiert",
"ToastChaptersHaveErrors": "Kapitel sind fehlerhaft",
"ToastChaptersMustHaveTitles": "Kapitel benötigen eindeutige Namen",
"ToastCollectionItemsRemoveFailed": "Element(e) konnte(n) nicht aus der Sammlung entfernt werden",
"ToastCollectionItemsRemoveSuccess": "Element(e) wurde(n) aus der Sammlung entfernt",
"ToastCollectionRemoveFailed": "Sammlung konnte nicht entfernt werden",
@@ -562,10 +589,12 @@
"ToastLibraryScanStarted": "Bibliotheksscan gestartet",
"ToastLibraryUpdateFailed": "Aktualisierung der Bibliothek fehlgeschlagen",
"ToastLibraryUpdateSuccess": "Bibliothek \"{0}\" aktualisiert",
"ToastPlaylistCreateFailed": "Failed to create playlist",
"ToastPlaylistCreateSuccess": "Playlist created",
"ToastPlaylistRemoveFailed": "Failed to remove playlist",
"ToastPlaylistRemoveSuccess": "Playlist removed",
"ToastPlaylistUpdateFailed": "Failed to update playlist",
"ToastPlaylistUpdateSuccess": "Playlist updated",
"ToastPlaylistUpdateSuccess": "Playlist aktualisieren",
"ToastPodcastCreateFailed": "Podcast konnte nicht erstellt werden",
"ToastPodcastCreateSuccess": "Podcast erfolgreich erstellt",
"ToastRemoveItemFromCollectionFailed": "Element/Eintrag konnte nicht aus der Sammlung entfernt werden",

View File

@@ -65,6 +65,7 @@
"ButtonSearch": "Search",
"ButtonSelectFolderPath": "Select Folder Path",
"ButtonSeries": "Series",
"ButtonSetChaptersFromTracks": "Set chapters from tracks",
"ButtonShiftTimes": "Shift Times",
"ButtonShow": "Show",
"ButtonStartM4BEncode": "Start M4B Encode",
@@ -94,6 +95,7 @@
"HeaderFindChapters": "Find Chapters",
"HeaderIgnoredFiles": "Ignored Files",
"HeaderItemFiles": "Item Files",
"HeaderItemMetadataUtils": "Item Metadata Utils",
"HeaderLastListeningSession": "Last Listening Session",
"HeaderLatestEpisodes": "Latest episodes",
"HeaderLibraries": "Libraries",
@@ -103,6 +105,9 @@
"HeaderListeningStats": "Listening Stats",
"HeaderLogin": "Login",
"HeaderLogs": "Logs",
"HeaderManageGenres": "Manage Genres",
"HeaderManageTags": "Manage Tags",
"HeaderMapDetails": "Map details",
"HeaderMatch": "Match",
"HeaderMetadataToEmbed": "Metadata to embed",
"HeaderNewAccount": "New Account",
@@ -154,6 +159,7 @@
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
"LabelAll": "All",
"LabelAllUsers": "All Users",
"LabelAppend": "Append",
"LabelAuthor": "Author",
"LabelAuthorFirstLast": "Author (First Last)",
"LabelAuthorLastFirst": "Author (Last, First)",
@@ -277,6 +283,7 @@
"LabelNumberOfBooks": "Number of Books",
"LabelNumberOfEpisodes": "# of Episodes",
"LabelOpenRSSFeed": "Open RSS Feed",
"LabelOverwrite": "Overwrite",
"LabelPassword": "Password",
"LabelPath": "Path",
"LabelPermissionsAccessAllLibraries": "Can Access All Libraries",
@@ -341,7 +348,7 @@
"LabelSettingsSkipMatchingBooksWithISBN": "Skip matching books that already have an ISBN",
"LabelSettingsSortingIgnorePrefixes": "Ignore prefixes when sorting",
"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",
"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",
@@ -389,6 +396,9 @@
"LabelTotalTimeListened": "Total Time Listened",
"LabelTrackFromFilename": "Track from Filename",
"LabelTrackFromMetadata": "Track from Metadata",
"LabelTracks": "Tracks",
"LabelTracksMultiTrack": "Multi-track",
"LabelTracksSingleTrack": "Single-track",
"LabelType": "Type",
"LabelUnknown": "Unknown",
"LabelUpdateCover": "Update Cover",
@@ -422,6 +432,9 @@
"MessageBookshelfNoRSSFeeds": "No RSS feeds are open",
"MessageBookshelfNoSeries": "You have no series",
"MessageChapterEndIsAfter": "Chapter end is after the end of your audiobook",
"MessageChapterErrorFirstNotZero": "First chapter must start at 0",
"MessageChapterErrorStartGteDuration": "Invalid start time must be less than audiobook duration",
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
"MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook",
"MessageCheckingCron": "Checking cron...",
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
@@ -432,6 +445,12 @@
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
"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",
"MessageDragFilesIntoTrackOrder": "Drag files into correct track order",
"MessageEmbedFinished": "Embed Finished!",
@@ -442,6 +461,7 @@
"MessageImportantNotice": "Important Notice!",
"MessageInsertChapterBelow": "Insert chapter below",
"MessageItemsSelected": "{0} Items Selected",
"MessageItemsUpdated": "{0} Items Updated",
"MessageJoinUsOn": "Join us on",
"MessageListeningSessionsInTheLastYear": "{0} listening sessions in the last year",
"MessageLoading": "Loading...",
@@ -474,6 +494,8 @@
"MessageNoPodcastsFound": "No podcasts found",
"MessageNoResults": "No Results",
"MessageNoSearchResultsFor": "No search results for \"{0}\"",
"MessageNoSeries": "No Series",
"MessageNoTags": "No Tags",
"MessageNotYetImplemented": "Not yet implemented",
"MessageNoUpdateNecessary": "No update necessary",
"MessageNoUpdatesWereNecessary": "No updates were necessary",
@@ -481,6 +503,7 @@
"MessageOr": "or",
"MessagePauseChapter": "Pause chapter playback",
"MessagePlayChapter": "Listen to beginning of chapter",
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
"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.",
"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?",
@@ -489,10 +512,12 @@
"MessageRemoveFromPlayerQueue": "Remove from player queue",
"MessageRemoveUserWarning": "Are you sure you want to permanently delete user \"{0}\"?",
"MessageReportBugsAndContribute": "Report bugs, request features, and contribute on",
"MessageResetChaptersConfirm": "Are you sure you want to reset chapters and undo the changes you made?",
"MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on",
"MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.",
"MessageSearchResultsFor": "Search results for",
"MessageServerCouldNotBeReached": "Server could not be reached",
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
"MessageStartPlaybackAtTime": "Start playback for \"{0}\" at {1}?",
"MessageThinking": "Thinking...",
"MessageUploaderItemFailed": "Failed to upload",
@@ -539,6 +564,8 @@
"ToastBookmarkRemoveSuccess": "Bookmark removed",
"ToastBookmarkUpdateFailed": "Failed to update bookmark",
"ToastBookmarkUpdateSuccess": "Bookmark updated",
"ToastChaptersHaveErrors": "Chapters have errors",
"ToastChaptersMustHaveTitles": "Chapters must have titles",
"ToastCollectionItemsRemoveFailed": "Failed to remove item(s) from collection",
"ToastCollectionItemsRemoveSuccess": "Item(s) removed from collection",
"ToastCollectionRemoveFailed": "Failed to remove collection",
@@ -562,6 +589,8 @@
"ToastLibraryScanStarted": "Library scan started",
"ToastLibraryUpdateFailed": "Failed to update library",
"ToastLibraryUpdateSuccess": "Library \"{0}\" updated",
"ToastPlaylistCreateFailed": "Failed to create playlist",
"ToastPlaylistCreateSuccess": "Playlist created",
"ToastPlaylistRemoveFailed": "Failed to remove playlist",
"ToastPlaylistRemoveSuccess": "Playlist removed",
"ToastPlaylistUpdateFailed": "Failed to update playlist",

View File

@@ -65,6 +65,7 @@
"ButtonSearch": "Search",
"ButtonSelectFolderPath": "Select Folder Path",
"ButtonSeries": "Series",
"ButtonSetChaptersFromTracks": "Set chapters from tracks",
"ButtonShiftTimes": "Shift Times",
"ButtonShow": "Show",
"ButtonStartM4BEncode": "Start M4B Encode",
@@ -94,6 +95,7 @@
"HeaderFindChapters": "Find Chapters",
"HeaderIgnoredFiles": "Ignored Files",
"HeaderItemFiles": "Item Files",
"HeaderItemMetadataUtils": "Item Metadata Utils",
"HeaderLastListeningSession": "Last Listening Session",
"HeaderLatestEpisodes": "Latest episodes",
"HeaderLibraries": "Libraries",
@@ -103,6 +105,9 @@
"HeaderListeningStats": "Listening Stats",
"HeaderLogin": "Login",
"HeaderLogs": "Logs",
"HeaderManageGenres": "Manage Genres",
"HeaderManageTags": "Manage Tags",
"HeaderMapDetails": "Map details",
"HeaderMatch": "Match",
"HeaderMetadataToEmbed": "Metadata to embed",
"HeaderNewAccount": "New Account",
@@ -154,6 +159,7 @@
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
"LabelAll": "All",
"LabelAllUsers": "All Users",
"LabelAppend": "Append",
"LabelAuthor": "Author",
"LabelAuthorFirstLast": "Author (First Last)",
"LabelAuthorLastFirst": "Author (Last, First)",
@@ -277,6 +283,7 @@
"LabelNumberOfBooks": "Number of Books",
"LabelNumberOfEpisodes": "# of Episodes",
"LabelOpenRSSFeed": "Open RSS Feed",
"LabelOverwrite": "Overwrite",
"LabelPassword": "Password",
"LabelPath": "Path",
"LabelPermissionsAccessAllLibraries": "Can Access All Libraries",
@@ -341,7 +348,7 @@
"LabelSettingsSkipMatchingBooksWithISBN": "Skip matching books that already have an ISBN",
"LabelSettingsSortingIgnorePrefixes": "Ignore prefixes when sorting",
"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",
"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",
@@ -389,6 +396,9 @@
"LabelTotalTimeListened": "Total Time Listened",
"LabelTrackFromFilename": "Track from Filename",
"LabelTrackFromMetadata": "Track from Metadata",
"LabelTracks": "Tracks",
"LabelTracksMultiTrack": "Multi-track",
"LabelTracksSingleTrack": "Single-track",
"LabelType": "Type",
"LabelUnknown": "Unknown",
"LabelUpdateCover": "Update Cover",
@@ -422,6 +432,9 @@
"MessageBookshelfNoRSSFeeds": "No RSS feeds are open",
"MessageBookshelfNoSeries": "You have no series",
"MessageChapterEndIsAfter": "Chapter end is after the end of your audiobook",
"MessageChapterErrorFirstNotZero": "First chapter must start at 0",
"MessageChapterErrorStartGteDuration": "Invalid start time must be less than audiobook duration",
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
"MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook",
"MessageCheckingCron": "Checking cron...",
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
@@ -432,6 +445,12 @@
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
"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",
"MessageDragFilesIntoTrackOrder": "Drag files into correct track order",
"MessageEmbedFinished": "Embed Finished!",
@@ -442,6 +461,7 @@
"MessageImportantNotice": "Important Notice!",
"MessageInsertChapterBelow": "Insert chapter below",
"MessageItemsSelected": "{0} Items Selected",
"MessageItemsUpdated": "{0} Items Updated",
"MessageJoinUsOn": "Join us on",
"MessageListeningSessionsInTheLastYear": "{0} listening sessions in the last year",
"MessageLoading": "Loading...",
@@ -474,6 +494,8 @@
"MessageNoPodcastsFound": "No podcasts found",
"MessageNoResults": "No Results",
"MessageNoSearchResultsFor": "No search results for \"{0}\"",
"MessageNoSeries": "No Series",
"MessageNoTags": "No Tags",
"MessageNotYetImplemented": "Not yet implemented",
"MessageNoUpdateNecessary": "No update necessary",
"MessageNoUpdatesWereNecessary": "No updates were necessary",
@@ -481,6 +503,7 @@
"MessageOr": "or",
"MessagePauseChapter": "Pause chapter playback",
"MessagePlayChapter": "Listen to beginning of chapter",
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
"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.",
"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?",
@@ -489,10 +512,12 @@
"MessageRemoveFromPlayerQueue": "Remove from player queue",
"MessageRemoveUserWarning": "Are you sure you want to permanently delete user \"{0}\"?",
"MessageReportBugsAndContribute": "Report bugs, request features, and contribute on",
"MessageResetChaptersConfirm": "Are you sure you want to reset chapters and undo the changes you made?",
"MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on",
"MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.",
"MessageSearchResultsFor": "Search results for",
"MessageServerCouldNotBeReached": "Server could not be reached",
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
"MessageStartPlaybackAtTime": "Start playback for \"{0}\" at {1}?",
"MessageThinking": "Thinking...",
"MessageUploaderItemFailed": "Failed to upload",
@@ -539,6 +564,8 @@
"ToastBookmarkRemoveSuccess": "Bookmark removed",
"ToastBookmarkUpdateFailed": "Failed to update bookmark",
"ToastBookmarkUpdateSuccess": "Bookmark updated",
"ToastChaptersHaveErrors": "Chapters have errors",
"ToastChaptersMustHaveTitles": "Chapters must have titles",
"ToastCollectionItemsRemoveFailed": "Failed to remove item(s) from collection",
"ToastCollectionItemsRemoveSuccess": "Item(s) removed from collection",
"ToastCollectionRemoveFailed": "Failed to remove collection",
@@ -562,6 +589,8 @@
"ToastLibraryScanStarted": "Library scan started",
"ToastLibraryUpdateFailed": "Failed to update library",
"ToastLibraryUpdateSuccess": "Library \"{0}\" updated",
"ToastPlaylistCreateFailed": "Failed to create playlist",
"ToastPlaylistCreateSuccess": "Playlist created",
"ToastPlaylistRemoveFailed": "Failed to remove playlist",
"ToastPlaylistRemoveSuccess": "Playlist removed",
"ToastPlaylistUpdateFailed": "Failed to update playlist",

View File

@@ -41,7 +41,7 @@
"ButtonOpenManager": "Ouvrir le Gestionnaire",
"ButtonPlay": "Ecouter",
"ButtonPlaying": "En Lecture",
"ButtonPlaylists": "Playlists",
"ButtonPlaylists": "Listes de Lecture",
"ButtonPurgeAllCache": "Purger Tout le Cache",
"ButtonPurgeItemsCache": "Purger le Cache des Articles",
"ButtonPurgeMediaProgress": "Purger la Progression des Médias",
@@ -65,6 +65,7 @@
"ButtonSearch": "Rechercher",
"ButtonSelectFolderPath": "Sélectionner le Chemin du Dossier",
"ButtonSeries": "Séries",
"ButtonSetChaptersFromTracks": "Positionner les Chapitre par rapports aux Pistes",
"ButtonShiftTimes": "Décaler le Temps",
"ButtonShow": "Montrer",
"ButtonStartM4BEncode": "Démarrer l'Encodage M4B",
@@ -94,6 +95,7 @@
"HeaderFindChapters": "Trouver les Chapitres",
"HeaderIgnoredFiles": "Fichiers Ignorés",
"HeaderItemFiles": "Fichiers des Articles",
"HeaderItemMetadataUtils": "Item Metadata Utils",
"HeaderLastListeningSession": "Dernière Session d'Ecoute",
"HeaderLatestEpisodes": "Dernier Episodes",
"HeaderLibraries": "Bibliothèque",
@@ -103,6 +105,9 @@
"HeaderListeningStats": "Statistiques d'Ecoute",
"HeaderLogin": "Connexion",
"HeaderLogs": "Fichiers Journaux",
"HeaderManageGenres": "Manage Genres",
"HeaderManageTags": "Manage Tags",
"HeaderMapDetails": "Edition en Masse",
"HeaderMatch": "Rechercher",
"HeaderMetadataToEmbed": "Métadonnée à Intégrer",
"HeaderNewAccount": "Nouveau Compte",
@@ -112,8 +117,8 @@
"HeaderOtherFiles": "Autres Fichiers",
"HeaderPermissions": "Permissions",
"HeaderPlayerQueue": "Liste d'Ecoute",
"HeaderPlaylist": "Playlist",
"HeaderPlaylistItems": "Playlist Items",
"HeaderPlaylist": "Liste de Lecture",
"HeaderPlaylistItems": "Elements de la Liste de Lecture",
"HeaderPodcastsToAdd": "Podcasts à Ajouter",
"HeaderPreviewCover": "Prévisualiser la Couverture",
"HeaderRemoveEpisode": "Supprimer l'Episode",
@@ -150,10 +155,11 @@
"LabelAddedAt": "Date d'Ajout",
"LabelAddToCollection": "Ajouter à la Collection",
"LabelAddToCollectionBatch": "Ajout de {0} Livres à la Collection",
"LabelAddToPlaylist": "Add to Playlist",
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
"LabelAddToPlaylist": "Ajouter à la Liste de Lecture",
"LabelAddToPlaylistBatch": "{0} Elements Ajoutés à la Liste de Lecture",
"LabelAll": "Tout",
"LabelAllUsers": "Tous les Utilisateurs",
"LabelAppend": "Ajouter",
"LabelAuthor": "Auteur",
"LabelAuthorFirstLast": "Auteur (Prénom Nom)",
"LabelAuthorLastFirst": "Auteur (Nom, Prénom)",
@@ -277,6 +283,7 @@
"LabelNumberOfBooks": "Nombre de Livres",
"LabelNumberOfEpisodes": "Nombre d'Episodes",
"LabelOpenRSSFeed": "Ouvrir le flux RSS",
"LabelOverwrite": "Ecraser",
"LabelPassword": "Mot de Passe",
"LabelPath": "Chemin",
"LabelPermissionsAccessAllLibraries": "Peut Acceder à Toutes les Bibliothèque",
@@ -287,7 +294,7 @@
"LabelPermissionsUpdate": "Peut Mettre à Jour",
"LabelPermissionsUpload": "Peut Téléverser",
"LabelPhotoPathURL": "Chemin/URL des photos",
"LabelPlaylists": "Playlists",
"LabelPlaylists": "Listes de Lecture",
"LabelPlayMethod": "Méthode d'Ecoute",
"LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts",
@@ -389,6 +396,9 @@
"LabelTotalTimeListened": "Temps d'Ecoute Total",
"LabelTrackFromFilename": "Piste depuis le Fichier",
"LabelTrackFromMetadata": "Piste depuis les Métadonnées",
"LabelTracks": "Pistes",
"LabelTracksMultiTrack": "Piste Multiple",
"LabelTracksSingleTrack": "Piste Simple",
"LabelType": "Type",
"LabelUnknown": "Inconnu",
"LabelUpdateCover": "Mettre à jour la Couverture",
@@ -411,9 +421,9 @@
"LabelWeekdaysToRun": "Jours de la semaine à exécuter",
"LabelYourAudiobookDuration": "Durée de vos Livres Audios",
"LabelYourBookmarks": "Vos Signets",
"LabelYourPlaylists": "Your Playlists",
"LabelYourPlaylists": "Vos Listes de Lecture",
"LabelYourProgress": "Votre Progression",
"MessageAddToPlayerQueue": "Add to player queue",
"MessageAddToPlayerQueue": "Ajouter en Queue d'Ecoute",
"MessageAppriseDescription": "Nécessite une instance d'<a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">API Apprise</a> pour utiliser cette fonctionnalité ou une api qui prend en charge les mêmes requêtes. <br />L'URL de l'API Apprise doit comprendre le chemin complet pour envoyer la notification. Par exemple, si votre instance écoute sur <code>http://192.168.1.1:8337</code> alors vous devez mettre <code>http://192.168.1.1:8337/notify</code>.",
"MessageBackupsDescription": "Les Sauvegardes incluent les utilisateurs, la progression de lecture par utilisateur, les détails des articles des bibliothèques, les paramètres du serveur et les images sauvegardées. Les Sauvegardes n'incluent pas les fichiers de votre bibliothèque.",
"MessageBatchQuickMatchDescription": "La Recherche par Correspondance Rapide tentera d'ajouter les couvertures et les métadonnées manquantes pour les articles sélectionnés. Activer l'option suivante pour autoriser la Recherche par Correspondance à écraser les données existantes.",
@@ -422,6 +432,9 @@
"MessageBookshelfNoRSSFeeds": "Aucun flux RSS n'est ouvert",
"MessageBookshelfNoSeries": "Vous n'avez aucune séries",
"MessageChapterEndIsAfter": "Le Chapitre Fin est situé à la fin de votre Livre Audio",
"MessageChapterErrorFirstNotZero": "Le premier capitre doit débuter à 0",
"MessageChapterErrorStartGteDuration": "Horodatage invalide car il doit débuter avant la fin du livre",
"MessageChapterErrorStartLtPrev": "Horodatage invalide car il doit débuter au moins après le précédent chapitre",
"MessageChapterStartIsAfter": "Le Chapitre Début est situé au début de votre Livre Audio",
"MessageCheckingCron": "Vérification du cron...",
"MessageConfirmDeleteBackup": "Etes vous certain de vouloir supprimer la Sauvegarde de {0}?",
@@ -431,7 +444,13 @@
"MessageConfirmRemoveCollection": "Etes vous certain de vouloir supprimer la collection \"{0}\"?",
"MessageConfirmRemoveEpisode": "Etes vous certain de vouloir supprimer l'épisode \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Etes vous certain de vouloir supprimer {0} épisodes?",
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{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",
"MessageDragFilesIntoTrackOrder": "Faire glisser les fichiers dans l'ordre correct",
"MessageEmbedFinished": "Intégration Terminée!",
@@ -442,6 +461,7 @@
"MessageImportantNotice": "Information Importante!",
"MessageInsertChapterBelow": "Insérer le chapitre ci-dessous",
"MessageItemsSelected": "{0} Articles Sélectionnés",
"MessageItemsUpdated": "{0} Items Updated",
"MessageJoinUsOn": "Rejoignez-nous sur",
"MessageListeningSessionsInTheLastYear": "{0} sessions d'écoute l'an dernier",
"MessageLoading": "Chargement...",
@@ -474,25 +494,30 @@
"MessageNoPodcastsFound": "Pas de podcasts trouvés",
"MessageNoResults": "Pas de Résultats",
"MessageNoSearchResultsFor": "Pas de résultats de recherche pour \"{0}\"",
"MessageNoSeries": "Pas de Séries",
"MessageNoTags": "No Tags",
"MessageNotYetImplemented": "Non implémenté",
"MessageNoUpdateNecessary": "Pas de mise à jour nécessaire",
"MessageNoUpdatesWereNecessary": "Aucune mise à jour n'était nécessaire",
"MessageNoUserPlaylists": "You have no playlists",
"MessageNoUserPlaylists": "Vous n'avez aucune liste de lecture",
"MessageOr": "ou",
"MessagePauseChapter": "Suspendre la lecture 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",
"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?",
"MessageRemoveChapter": "Supprimer le chapitre",
"MessageRemoveEpisodes": "Suppression de {0} épisode(s)",
"MessageRemoveFromPlayerQueue": "Remove from player queue",
"MessageRemoveFromPlayerQueue": "Supprimer de la liste d'écoute",
"MessageRemoveUserWarning": "Etes-vous certain de vouloir supprimer définitivement l'utilisateur \"{0}\"?",
"MessageReportBugsAndContribute": "Remonter des anomalies, demander des fonctionnalités et contribuer sur",
"MessageResetChaptersConfirm": "Etes-vous certain de vouloir réinitialiser les chapitres et annuler les changements effectués?",
"MessageRestoreBackupConfirm": "Etes-vous certain de vouloir restaurer la sauvegarde créée le",
"MessageRestoreBackupWarning": "Restaurer la sauvegarde écrasera la base de donnée située dans le dossier /config ainsi que les images sur /metadata/items & /metadata/authors.<br /><br />Les sauvegardes ne touchent pas aux fichiers de la bibliothèque. Si vous avez activé le paramètre pour sauvegarder les métadonnées et les images de couverture dans le même dossier que les fichiers, ceux-ci ne ni sauvegardés, ni écrasés lors de la restauration.<br /><br />Tous les clients utilisant votre serveur seront automatiquement mis à jour.",
"MessageSearchResultsFor": "Résultats de recherche pour",
"MessageServerCouldNotBeReached": "Serveur inaccessible",
"MessageSetChaptersFromTracksDescription": "Positionne un chapitre par fichier audio, avec le titre du fichier comme titre de chapitre",
"MessageStartPlaybackAtTime": "Démarrer la lecture pour \"{0}\" à {1}?",
"MessageThinking": "On Réfléchit...",
"MessageUploaderItemFailed": "Echec du téléversement",
@@ -514,7 +539,7 @@
"NoteUploaderUnsupportedFiles": "Les fichiers non-supportés seront ignorés. En sélectionnant ou déponsant un dossier, les autres fichiers qui ne sont pas un dossier contenant un article seront ignorés.",
"PlaceholderNewCollection": "Nom de la nouvelle collection",
"PlaceholderNewFolderPath": "Nouveau chemin de dossier",
"PlaceholderNewPlaylist": "New playlist name",
"PlaceholderNewPlaylist": "Nouveau nom de liste de lecture",
"PlaceholderSearch": "Recherche...",
"ToastAccountUpdateFailed": "Echec de la mise à jour du compte",
"ToastAccountUpdateSuccess": "Compte mis à jour",
@@ -539,6 +564,8 @@
"ToastBookmarkRemoveSuccess": "Signet supprimé",
"ToastBookmarkUpdateFailed": "Echec de la mise à jour de signet",
"ToastBookmarkUpdateSuccess": "Signet mis à jour",
"ToastChaptersHaveErrors": "Les chapitres contiennent des erreurs",
"ToastChaptersMustHaveTitles": "Les chapitre doivent avoir un titre",
"ToastCollectionItemsRemoveFailed": "Echec de la suppression de(s) article(s) de la collection",
"ToastCollectionItemsRemoveSuccess": "Article(s) supprimé(s) de la collection",
"ToastCollectionRemoveFailed": "Echec de la suppression de la collection",
@@ -562,10 +589,12 @@
"ToastLibraryScanStarted": "Analyse de la bibliothèque démarrée",
"ToastLibraryUpdateFailed": "Echec de la mise à jour de la bibliothèque",
"ToastLibraryUpdateSuccess": "Bibliothèque \"{0}\" mise à jour",
"ToastPlaylistRemoveFailed": "Failed to remove playlist",
"ToastPlaylistRemoveSuccess": "Playlist removed",
"ToastPlaylistUpdateFailed": "Failed to update playlist",
"ToastPlaylistUpdateSuccess": "Playlist updated",
"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",
"ToastPlaylistRemoveSuccess": "Liste de lecture supprimée",
"ToastPlaylistUpdateFailed": "Echec de la mise à jour de la liste de lecture",
"ToastPlaylistUpdateSuccess": "Liste de lecture mise à jour",
"ToastPodcastCreateFailed": "Echec de la création du Podcast",
"ToastPodcastCreateSuccess": "Podcast créé",
"ToastRemoveItemFromCollectionFailed": "Echec de la suppression de l'article de la collection",

View File

@@ -65,6 +65,7 @@
"ButtonSearch": "Traži",
"ButtonSelectFolderPath": "Odaberi putanju do folder",
"ButtonSeries": "Serije",
"ButtonSetChaptersFromTracks": "Set chapters from tracks",
"ButtonShiftTimes": "Pomakni vremena",
"ButtonShow": "Prikaži",
"ButtonStartM4BEncode": "Pokreni M4B kodiranje",
@@ -94,6 +95,7 @@
"HeaderFindChapters": "Pronađi poglavlja",
"HeaderIgnoredFiles": "Zanemarene datoteke",
"HeaderItemFiles": "Item Files",
"HeaderItemMetadataUtils": "Item Metadata Utils",
"HeaderLastListeningSession": "Posljednja Listening Session",
"HeaderLatestEpisodes": "Najnovije epizode",
"HeaderLibraries": "Biblioteke",
@@ -103,6 +105,9 @@
"HeaderListeningStats": "Listening Stats",
"HeaderLogin": "Prijavljivanje",
"HeaderLogs": "Logs",
"HeaderManageGenres": "Manage Genres",
"HeaderManageTags": "Manage Tags",
"HeaderMapDetails": "Map details",
"HeaderMatch": "Match",
"HeaderMetadataToEmbed": "Metapodatci za ugradnju",
"HeaderNewAccount": "Novi korisnički račun",
@@ -154,6 +159,7 @@
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
"LabelAll": "All",
"LabelAllUsers": "Svi korisnici",
"LabelAppend": "Append",
"LabelAuthor": "Autor",
"LabelAuthorFirstLast": "Author (First Last)",
"LabelAuthorLastFirst": "Author (Last, First)",
@@ -277,6 +283,7 @@
"LabelNumberOfBooks": "Number of Books",
"LabelNumberOfEpisodes": "# of Episodes",
"LabelOpenRSSFeed": "Otvori RSS Feed",
"LabelOverwrite": "Overwrite",
"LabelPassword": "Lozinka",
"LabelPath": "Putanja",
"LabelPermissionsAccessAllLibraries": "Ima pristup svim bibliotekama",
@@ -389,6 +396,9 @@
"LabelTotalTimeListened": "Sveukupno vrijeme slušanja",
"LabelTrackFromFilename": "Track iz imena datoteke",
"LabelTrackFromMetadata": "Track iz metapodataka",
"LabelTracks": "Tracks",
"LabelTracksMultiTrack": "Multi-track",
"LabelTracksSingleTrack": "Single-track",
"LabelType": "Tip",
"LabelUnknown": "Nepoznato",
"LabelUpdateCover": "Aktualiziraj Cover",
@@ -422,6 +432,9 @@
"MessageBookshelfNoRSSFeeds": "No RSS feeds are open",
"MessageBookshelfNoSeries": "You have no series",
"MessageChapterEndIsAfter": "Kraj poglavlja je nakon kraja audioknjige.",
"MessageChapterErrorFirstNotZero": "First chapter must start at 0",
"MessageChapterErrorStartGteDuration": "Invalid start time must be less than audiobook duration",
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
"MessageChapterStartIsAfter": "Početak poglavlja je nakon kraja audioknjige.",
"MessageCheckingCron": "Provjeravam cron...",
"MessageConfirmDeleteBackup": "Jeste li sigurni da želite obrisati backup za {0}?",
@@ -432,6 +445,12 @@
"MessageConfirmRemoveEpisode": "Jeste li sigurni da želite obrisati epizodu \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Jeste li sigurni da želite obrisati {0} epizoda/-u?",
"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",
"MessageDragFilesIntoTrackOrder": "Povuci datoteke u pravilan redoslijed tracka.",
"MessageEmbedFinished": "Embed završen!",
@@ -442,6 +461,7 @@
"MessageImportantNotice": "Važna obavijest!",
"MessageInsertChapterBelow": "Unesi poglavlje ispod",
"MessageItemsSelected": "{0} odabranih stavki",
"MessageItemsUpdated": "{0} Items Updated",
"MessageJoinUsOn": "Pridruži nam se na",
"MessageListeningSessionsInTheLastYear": "{0} slušanja u prošloj godini",
"MessageLoading": "Učitavam...",
@@ -474,6 +494,8 @@
"MessageNoPodcastsFound": "Nijedan podcast pronađen",
"MessageNoResults": "Nema rezultata",
"MessageNoSearchResultsFor": "Nema rezultata pretragee za \"{0}\"",
"MessageNoSeries": "No Series",
"MessageNoTags": "No Tags",
"MessageNotYetImplemented": "Not yet implemented",
"MessageNoUpdateNecessary": "Aktualiziranje nije potrebno",
"MessageNoUpdatesWereNecessary": "Aktualiziranje nije bilo potrebno",
@@ -481,6 +503,7 @@
"MessageOr": "or",
"MessagePauseChapter": "Pause chapter playback",
"MessagePlayChapter": "Listen to beginning of chapter",
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
"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.",
"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?",
@@ -489,10 +512,12 @@
"MessageRemoveFromPlayerQueue": "Remove from player queue",
"MessageRemoveUserWarning": "Jeste li sigurni da želite trajno obrisati korisnika \"{0}\"?",
"MessageReportBugsAndContribute": "Prijavte bugove, zatržite featurese i doprinosite na",
"MessageResetChaptersConfirm": "Are you sure you want to reset chapters and undo the changes you made?",
"MessageRestoreBackupConfirm": "Jeste li sigurni da želite povratiti backup kreiran",
"MessageRestoreBackupWarning": "Povračanje backupa će zamijeniti postoječu bazu podataka u /config i slike covera u /metadata/items i /metadata/authors.<br /><br />Backups ne modificiraju nikakve datoteke u folderu od biblioteke. Ako imate uključene server postavke da spremate cover i metapodtake u folderu od biblioteke, onda oni neće biti backupani ili overwritten.<br /><br />Svi klijenti koji koriste tvoj server će biti automatski osvježeni.",
"MessageSearchResultsFor": "Traži rezultate za",
"MessageServerCouldNotBeReached": "Server ne može biti kontaktiran",
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
"MessageStartPlaybackAtTime": "Pokreni reprodukciju za \"{0}\" na {1}?",
"MessageThinking": "Razmišljam...",
"MessageUploaderItemFailed": "Upload neuspješan",
@@ -539,6 +564,8 @@
"ToastBookmarkRemoveSuccess": "Knjižnja bilješka uklonjena",
"ToastBookmarkUpdateFailed": "Aktualizacija knjižne bilješke neuspješna",
"ToastBookmarkUpdateSuccess": "Knjižna bilješka aktualizirana",
"ToastChaptersHaveErrors": "Chapters have errors",
"ToastChaptersMustHaveTitles": "Chapters must have titles",
"ToastCollectionItemsRemoveFailed": "Neuspješno brisanje stavke/-i iz kolekcije",
"ToastCollectionItemsRemoveSuccess": "Stavka/-e obrisane iz kolekcije",
"ToastCollectionRemoveFailed": "Brisanje kolekcije neuspješno",
@@ -562,6 +589,8 @@
"ToastLibraryScanStarted": "Sken biblioteke pokrenut",
"ToastLibraryUpdateFailed": "Aktualiziranje biblioteke neuspješno",
"ToastLibraryUpdateSuccess": "Biblioteka \"{0}\" aktualizirana",
"ToastPlaylistCreateFailed": "Failed to create playlist",
"ToastPlaylistCreateSuccess": "Playlist created",
"ToastPlaylistRemoveFailed": "Failed to remove playlist",
"ToastPlaylistRemoveSuccess": "Playlist removed",
"ToastPlaylistUpdateFailed": "Failed to update playlist",

View File

@@ -54,7 +54,7 @@
"ButtonRemoveAllLibraryItems": "Rimuovi tutto il contenuto della libreria",
"ButtonRemoveFromContinueListening": "Rimuovi per proseguire l'ascolto",
"ButtonRemoveSeriesFromContinueSeries": "Rimuovi la Serie per Continuarla",
"ButtonReScan": "Riscansiona",
"ButtonReScan": "Ri-scansiona",
"ButtonReset": "Reset",
"ButtonRestore": "Ripristina",
"ButtonSave": "Salva",
@@ -65,6 +65,7 @@
"ButtonSearch": "Cerca",
"ButtonSelectFolderPath": "Seleziona percorso cartella",
"ButtonSeries": "Serie",
"ButtonSetChaptersFromTracks": "Impostare i capitoli dalle tracce",
"ButtonShiftTimes": "Ricerca veloce",
"ButtonShow": "Mostra",
"ButtonStartM4BEncode": "Inizia L'Encoda del M4B",
@@ -94,6 +95,7 @@
"HeaderFindChapters": "Trova Capitoli",
"HeaderIgnoredFiles": "File Ignorati",
"HeaderItemFiles": "Files",
"HeaderItemMetadataUtils": "Item Metadata Utils",
"HeaderLastListeningSession": "Ultima sessione di Ascolto",
"HeaderLatestEpisodes": "Ultimi Episodi",
"HeaderLibraries": "Librerie",
@@ -103,6 +105,9 @@
"HeaderListeningStats": "Statistiche di Ascolto",
"HeaderLogin": "Login",
"HeaderLogs": "Logs",
"HeaderManageGenres": "Manage Genres",
"HeaderManageTags": "Manage Tags",
"HeaderMapDetails": "Map details",
"HeaderMatch": "Trova Corrispondenza",
"HeaderMetadataToEmbed": "Metadata da incorporare",
"HeaderNewAccount": "Nuovo Account",
@@ -113,7 +118,7 @@
"HeaderPermissions": "Permessi",
"HeaderPlayerQueue": "Coda Riproduzione",
"HeaderPlaylist": "Playlist",
"HeaderPlaylistItems": "Playlist Items",
"HeaderPlaylistItems": "Elementi della playlist",
"HeaderPodcastsToAdd": "Podcasts da Aggiungere",
"HeaderPreviewCover": "Anteprima Cover",
"HeaderRemoveEpisode": "Rimuovi Episodi",
@@ -150,10 +155,11 @@
"LabelAddedAt": "Aggiunto il",
"LabelAddToCollection": "Aggiungi alla Raccolta",
"LabelAddToCollectionBatch": "Aggiungi {0} Libri alla Raccolta",
"LabelAddToPlaylist": "Add to Playlist",
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
"LabelAddToPlaylist": "aggiungi alla Playlist",
"LabelAddToPlaylistBatch": "Aggiungi {0} file alla Playlist",
"LabelAll": "All",
"LabelAllUsers": "Tutti gli Utenti",
"LabelAppend": "Append",
"LabelAuthor": "Autore",
"LabelAuthorFirstLast": "Autore (Per Nome)",
"LabelAuthorLastFirst": "Autori (Per Cognome)",
@@ -277,6 +283,7 @@
"LabelNumberOfBooks": "Numero di libri",
"LabelNumberOfEpisodes": "# degli episodi",
"LabelOpenRSSFeed": "Apri RSS Feed",
"LabelOverwrite": "Overwrite",
"LabelPassword": "Password",
"LabelPath": "Percorso",
"LabelPermissionsAccessAllLibraries": "Può accedere a tutte le librerie",
@@ -389,6 +396,9 @@
"LabelTotalTimeListened": "Tempo totale di Ascolto",
"LabelTrackFromFilename": "Traccia da nome file",
"LabelTrackFromMetadata": "Traccia da Metadata",
"LabelTracks": "Traccia",
"LabelTracksMultiTrack": "Multi-traccia",
"LabelTracksSingleTrack": "Traccia-singola",
"LabelType": "Tipo",
"LabelUnknown": "Sconosciuto",
"LabelUpdateCover": "Aggiornamento Cover",
@@ -411,17 +421,20 @@
"LabelWeekdaysToRun": "Giorni feriali da eseguire",
"LabelYourAudiobookDuration": "La durata dell'audiolibro",
"LabelYourBookmarks": "I tuoi Preferiti",
"LabelYourPlaylists": "Your Playlists",
"LabelYourPlaylists": "le tue Playlist",
"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>.",
"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.",
"MessageBookshelfNoCollections": "Non hai ancora creato nessuna raccolta ",
"MessageBookshelfNoResultsForFilter": "Nessul risultato per il filtro \"{0}: {1}\"",
"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",
"MessageChapterErrorFirstNotZero": "Il primo capitolo deve iniziare da 0",
"MessageChapterErrorStartGteDuration": "L'ora di inizio non valida deve essere inferiore alla durata dell'audiolibro",
"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",
"MessageCheckingCron": "Controllo cron...",
"MessageConfirmDeleteBackup": "Sei sicuro di voler eliminare il backup {0}?",
@@ -431,7 +444,13 @@
"MessageConfirmRemoveCollection": "Sei sicuro di voler rimuovere la Raccolta \"{0}\"?",
"MessageConfirmRemoveEpisode": "Sei sicuro di voler rimuovere l'episodio \"{0}\"?",
"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",
"MessageDragFilesIntoTrackOrder": "Trascina i file nell'ordine di traccia corretto",
"MessageEmbedFinished": "Incorporamento finito!",
@@ -442,6 +461,7 @@
"MessageImportantNotice": "Avviso Importante!",
"MessageInsertChapterBelow": "Inserisci capitolo sotto",
"MessageItemsSelected": "{0} oggetti Selezionati",
"MessageItemsUpdated": "{0} Items Updated",
"MessageJoinUsOn": "Unisciti a noi su",
"MessageListeningSessionsInTheLastYear": "{0} sessioni di ascolto nell'ultimo anno",
"MessageLoading": "Caricamento...",
@@ -474,25 +494,30 @@
"MessageNoPodcastsFound": "Nessun podcasts trovato",
"MessageNoResults": "Nessun Risultato",
"MessageNoSearchResultsFor": "Nessun risultato per \"{0}\"",
"MessageNoSeries": "Nessuna Serie",
"MessageNoTags": "No Tags",
"MessageNotYetImplemented": "Non Ancora Implementato",
"MessageNoUpdateNecessary": "Nessun aggiornamento necessario",
"MessageNoUpdatesWereNecessary": "Nessun aggiornamento necessario",
"MessageNoUserPlaylists": "You have no playlists",
"MessageNoUserPlaylists": "non hai nessuna Playlist",
"MessageOr": "o",
"MessagePauseChapter": "Metti in Pausa 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",
"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?",
"MessageRemoveChapter": "Rimuovi Capitolo",
"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}\"?",
"MessageReportBugsAndContribute": "Segnala bug, richiedi funzionalità e contribuisci",
"MessageResetChaptersConfirm": "Sei sicuro di voler reimpostare i capitoli e annullare le modifiche ?",
"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.",
"MessageSearchResultsFor": "cerca risultati per",
"MessageServerCouldNotBeReached": "Impossibile raggiungere il server",
"MessageSetChaptersFromTracksDescription": "Impostare i capitoli utilizzando ciascun file audio come capitolo e il titolo del capitolo come nome del file audio",
"MessageStartPlaybackAtTime": "Avvia la riproduzione per \"{0}\" a {1}?",
"MessageThinking": "Elaborazione...",
"MessageUploaderItemFailed": "Caricamento Fallito",
@@ -514,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.",
"PlaceholderNewCollection": "Nome Nuova Raccolta",
"PlaceholderNewFolderPath": "Nuovo percorso Cartella",
"PlaceholderNewPlaylist": "New playlist name",
"PlaceholderNewPlaylist": "Nome nuova playlist",
"PlaceholderSearch": "Cerca..",
"ToastAccountUpdateFailed": "Aggiornamento Account Fallito",
"ToastAccountUpdateSuccess": "Account Aggiornato",
@@ -539,6 +564,8 @@
"ToastBookmarkRemoveSuccess": "Segnalibro Rimosso",
"ToastBookmarkUpdateFailed": "Aggiornamento Segnalibro fallito",
"ToastBookmarkUpdateSuccess": "Segnalibro aggiornato",
"ToastChaptersHaveErrors": "I capitoli contengono errori",
"ToastChaptersMustHaveTitles": "I capitoli devono avere titoli",
"ToastCollectionItemsRemoveFailed": "Rimozione oggetti dalla Raccolta fallita",
"ToastCollectionItemsRemoveSuccess": "Oggetto(i) rimossi dalla Raccolta",
"ToastCollectionRemoveFailed": "Rimozione Raccolta fallita",
@@ -562,10 +589,12 @@
"ToastLibraryScanStarted": "Scansione Libreria iniziata",
"ToastLibraryUpdateFailed": "Errore Aggiornamento libreria",
"ToastLibraryUpdateSuccess": "Libreria \"{0}\" aggiornata",
"ToastPlaylistRemoveFailed": "Failed to remove playlist",
"ToastPlaylistRemoveSuccess": "Playlist removed",
"ToastPlaylistUpdateFailed": "Failed to update playlist",
"ToastPlaylistUpdateSuccess": "Playlist updated",
"ToastPlaylistCreateFailed": "Failed to create playlist",
"ToastPlaylistCreateSuccess": "Playlist created",
"ToastPlaylistRemoveFailed": "Rimozione Playlist Fallita",
"ToastPlaylistRemoveSuccess": "Playlist rimossa",
"ToastPlaylistUpdateFailed": "Aggiornamento Playlist Fallita",
"ToastPlaylistUpdateSuccess": "Playlist Aggiornata",
"ToastPodcastCreateFailed": "Errore Creazione podcast",
"ToastPodcastCreateSuccess": "Podcast creato Correttamwnte",
"ToastRemoveItemFromCollectionFailed": "Errore rimozione file dalla Raccolta",

View File

@@ -65,6 +65,7 @@
"ButtonSearch": "Szukaj",
"ButtonSelectFolderPath": "Wybierz ścieżkę folderu",
"ButtonSeries": "Seria",
"ButtonSetChaptersFromTracks": "Set chapters from tracks",
"ButtonShiftTimes": "Przesunięcie czasowe",
"ButtonShow": "Pokaż",
"ButtonStartM4BEncode": "Eksportuj jako plik M4B",
@@ -94,6 +95,7 @@
"HeaderFindChapters": "Wyszukaj rozdziały",
"HeaderIgnoredFiles": "Zignoruj pliki",
"HeaderItemFiles": "Pliki",
"HeaderItemMetadataUtils": "Item Metadata Utils",
"HeaderLastListeningSession": "Ostatnio odtwarzana sesja",
"HeaderLatestEpisodes": "Najnowsze odcinki",
"HeaderLibraries": "Biblioteki",
@@ -103,6 +105,9 @@
"HeaderListeningStats": "Statystyki odtwarzania",
"HeaderLogin": "Zaloguj się",
"HeaderLogs": "Logi",
"HeaderManageGenres": "Manage Genres",
"HeaderManageTags": "Manage Tags",
"HeaderMapDetails": "Map details",
"HeaderMatch": "Dopasuj",
"HeaderMetadataToEmbed": "Osadź metadane",
"HeaderNewAccount": "Nowe konto",
@@ -154,6 +159,7 @@
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
"LabelAll": "All",
"LabelAllUsers": "Wszyscy użytkownicy",
"LabelAppend": "Append",
"LabelAuthor": "Autor",
"LabelAuthorFirstLast": "Autor (Rosnąco)",
"LabelAuthorLastFirst": "Author (Malejąco)",
@@ -277,6 +283,7 @@
"LabelNumberOfBooks": "Liczba książek",
"LabelNumberOfEpisodes": "# odcinków",
"LabelOpenRSSFeed": "Otwórz kanał RSS",
"LabelOverwrite": "Overwrite",
"LabelPassword": "Hasło",
"LabelPath": "Ścieżka",
"LabelPermissionsAccessAllLibraries": "Ma dostęp do wszystkich bibliotek",
@@ -389,6 +396,9 @@
"LabelTotalTimeListened": "Całkowity czas odtwarzania",
"LabelTrackFromFilename": "Ścieżka z nazwy pliku",
"LabelTrackFromMetadata": "Ścieżka z metadanych",
"LabelTracks": "Tracks",
"LabelTracksMultiTrack": "Multi-track",
"LabelTracksSingleTrack": "Single-track",
"LabelType": "Typ",
"LabelUnknown": "Nieznany",
"LabelUpdateCover": "Zaktalizuj odkładkę",
@@ -422,6 +432,9 @@
"MessageBookshelfNoRSSFeeds": "Nie posiadasz żadnych otwartych feedów RSS",
"MessageBookshelfNoSeries": "Nie masz jeszcze żadnych serii",
"MessageChapterEndIsAfter": "Koniec rozdziału następuje po zakończeniu audiobooka",
"MessageChapterErrorFirstNotZero": "First chapter must start at 0",
"MessageChapterErrorStartGteDuration": "Invalid start time must be less than audiobook duration",
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
"MessageChapterStartIsAfter": "Początek rozdziału następuje po zakończeniu audiobooka",
"MessageCheckingCron": "Sprawdzanie cron...",
"MessageConfirmDeleteBackup": "Czy na pewno chcesz usunąć kopię zapasową dla {0}?",
@@ -432,6 +445,12 @@
"MessageConfirmRemoveEpisode": "Czy na pewno chcesz usunąć odcinek \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Czy na pewno chcesz usunąć {0} odcinki?",
"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",
"MessageDragFilesIntoTrackOrder": "przeciągnij pliki aby ustawić właściwą kolejność utworów",
"MessageEmbedFinished": "Osadzanie zakończone!",
@@ -442,6 +461,7 @@
"MessageImportantNotice": "Ważna informacja!",
"MessageInsertChapterBelow": "Wstaw rozdział poniżej",
"MessageItemsSelected": "{0} zaznaczone elementy",
"MessageItemsUpdated": "{0} Items Updated",
"MessageJoinUsOn": "Dołącz do nas na",
"MessageListeningSessionsInTheLastYear": "{0} sesje odsłuchowe w ostatnim roku",
"MessageLoading": "Ładowanie...",
@@ -474,6 +494,8 @@
"MessageNoPodcastsFound": "Nie znaleziono podcastów",
"MessageNoResults": "Brak wyników",
"MessageNoSearchResultsFor": "Brak wyników wyszukiwania dla \"{0}\"",
"MessageNoSeries": "No Series",
"MessageNoTags": "No Tags",
"MessageNotYetImplemented": "Jeszcze nie zaimplementowane",
"MessageNoUpdateNecessary": "Brak konieczności aktualizacji",
"MessageNoUpdatesWereNecessary": "Brak aktualizacji",
@@ -481,6 +503,7 @@
"MessageOr": "lub",
"MessagePauseChapter": "Zatrzymaj odtwarzanie rozdziały",
"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",
"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?",
@@ -489,10 +512,12 @@
"MessageRemoveFromPlayerQueue": "Remove from player queue",
"MessageRemoveUserWarning": "Czy na pewno chcesz trwale usunąć użytkownika \"{0}\"?",
"MessageReportBugsAndContribute": "Zgłoś błędy, pomysły i pomóż rozwijać aplikację na",
"MessageResetChaptersConfirm": "Are you sure you want to reset chapters and undo the changes you made?",
"MessageRestoreBackupConfirm": "Czy na pewno chcesz przywrócić kopię zapasową utworzoną w dniu",
"MessageRestoreBackupWarning": "Przywrócenie kopii zapasowej spowoduje nadpisane bazy danych w folderze /config oraz okładke w folderze /metadata/items & /metadata/authors.<br /><br />Kopie zapasowe nie modyfikują żadnego pliku w folderach z plikami audio. Jeśli włączyłeś ustawienia serwera, aby przechowywać okładki i metadane w folderach biblioteki, to nie są one zapisywane w kopii zapasowej lub nadpisywane<br /><br />Wszyscy klienci korzystający z Twojego serwera będą automatycznie odświeżani",
"MessageSearchResultsFor": "Wyniki wyszukiwania dla",
"MessageServerCouldNotBeReached": "Nie udało się uzyskać połączenia z serwerem",
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
"MessageStartPlaybackAtTime": "Rozpoczęcie odtwarzania \"{0}\" od {1}?",
"MessageThinking": "Myślę...",
"MessageUploaderItemFailed": "Nie udało się przesłać",
@@ -539,6 +564,8 @@
"ToastBookmarkRemoveSuccess": "Zakładka została usunięta",
"ToastBookmarkUpdateFailed": "Nie udało się zaktualizować zakładki",
"ToastBookmarkUpdateSuccess": "Zaktualizowano zakładkę",
"ToastChaptersHaveErrors": "Chapters have errors",
"ToastChaptersMustHaveTitles": "Chapters must have titles",
"ToastCollectionItemsRemoveFailed": "Nie udało się usunąć pozycji z kolekcji",
"ToastCollectionItemsRemoveSuccess": "Przedmiot(y) zostały usunięte z kolekcji",
"ToastCollectionRemoveFailed": "Nie udało się usunąć kolekcji",
@@ -562,6 +589,8 @@
"ToastLibraryScanStarted": "Rozpoczęto skanowanie biblioteki",
"ToastLibraryUpdateFailed": "Nie udało się zaktualizować biblioteki",
"ToastLibraryUpdateSuccess": "Zaktualizowano \"{0}\" pozycji",
"ToastPlaylistCreateFailed": "Failed to create playlist",
"ToastPlaylistCreateSuccess": "Playlist created",
"ToastPlaylistRemoveFailed": "Failed to remove playlist",
"ToastPlaylistRemoveSuccess": "Playlist removed",
"ToastPlaylistUpdateFailed": "Failed to update playlist",

View File

@@ -41,7 +41,7 @@
"ButtonOpenManager": "打开管理器",
"ButtonPlay": "播放",
"ButtonPlaying": "正在播放",
"ButtonPlaylists": "Playlists",
"ButtonPlaylists": "播放列表",
"ButtonPurgeAllCache": "清理所有缓存",
"ButtonPurgeItemsCache": "清理项目缓存",
"ButtonPurgeMediaProgress": "清理媒体进度",
@@ -65,6 +65,7 @@
"ButtonSearch": "查找",
"ButtonSelectFolderPath": "选择文件夹路径",
"ButtonSeries": "系列",
"ButtonSetChaptersFromTracks": "将音轨设置为章节",
"ButtonShiftTimes": "快速移动时间",
"ButtonShow": "显示",
"ButtonStartM4BEncode": "开始 M4B 编码",
@@ -94,6 +95,7 @@
"HeaderFindChapters": "查找章节",
"HeaderIgnoredFiles": "忽略的文件",
"HeaderItemFiles": "项目文件",
"HeaderItemMetadataUtils": "Item Metadata Utils",
"HeaderLastListeningSession": "最后一次收听会话",
"HeaderLatestEpisodes": "最新剧集",
"HeaderLibraries": "媒体库",
@@ -103,6 +105,9 @@
"HeaderListeningStats": "收听统计数据",
"HeaderLogin": "登录",
"HeaderLogs": "日志",
"HeaderManageGenres": "Manage Genres",
"HeaderManageTags": "Manage Tags",
"HeaderMapDetails": "Map details",
"HeaderMatch": "匹配",
"HeaderMetadataToEmbed": "嵌入元数据",
"HeaderNewAccount": "新建帐户",
@@ -111,9 +116,9 @@
"HeaderOpenRSSFeed": "打开 RSS 源",
"HeaderOtherFiles": "其他文件",
"HeaderPermissions": "权限",
"HeaderPlayerQueue": "播放列",
"HeaderPlaylist": "Playlist",
"HeaderPlaylistItems": "Playlist Items",
"HeaderPlayerQueue": "播放列",
"HeaderPlaylist": "播放列表",
"HeaderPlaylistItems": "播放列表项目",
"HeaderPodcastsToAdd": "要添加的播客",
"HeaderPreviewCover": "预览封面",
"HeaderRemoveEpisode": "移除剧集",
@@ -150,10 +155,11 @@
"LabelAddedAt": "添加于",
"LabelAddToCollection": "添加到收藏",
"LabelAddToCollectionBatch": "批量添加 {0} 个媒体到收藏",
"LabelAddToPlaylist": "Add to Playlist",
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
"LabelAddToPlaylist": "添加到播放列表",
"LabelAddToPlaylistBatch": "添加 {0} 个项目到播放列表",
"LabelAll": "全部",
"LabelAllUsers": "所有用户",
"LabelAppend": "Append",
"LabelAuthor": "作者",
"LabelAuthorFirstLast": "作者 (姓 名)",
"LabelAuthorLastFirst": "作者 (名, 姓)",
@@ -166,7 +172,7 @@
"LabelBackupsMaxBackupSizeHelp": "为了防止错误配置, 如果备份超过配置的大小, 备份将失败.",
"LabelBackupsNumberToKeep": "要保留的备份个数",
"LabelBackupsNumberToKeepHelp": "一次只能删除一个备份, 因此如果你已经有超过此数量的备份, 则应手动删除它们.",
"LabelBooks": "媒体",
"LabelBooks": "图书",
"LabelChangePassword": "修改密码",
"LabelChaptersFound": "找到的章节",
"LabelChapterTitle": "章节标题",
@@ -200,7 +206,7 @@
"LabelEpisode": "剧集",
"LabelEpisodeTitle": "剧集标题",
"LabelEpisodeType": "剧集类型",
"LabelExplicit": "显式",
"LabelExplicit": "信息明确",
"LabelFeedURL": "源 URL",
"LabelFile": "文件",
"LabelFileBirthtime": "文件创建时间",
@@ -277,6 +283,7 @@
"LabelNumberOfBooks": "图书数量",
"LabelNumberOfEpisodes": "# 集",
"LabelOpenRSSFeed": "打开 RSS 源",
"LabelOverwrite": "Overwrite",
"LabelPassword": "密码",
"LabelPath": "路径",
"LabelPermissionsAccessAllLibraries": "可以访问所有媒体库",
@@ -287,7 +294,7 @@
"LabelPermissionsUpdate": "可以更新",
"LabelPermissionsUpload": "可以上传",
"LabelPhotoPathURL": "图片路径或 URL",
"LabelPlaylists": "Playlists",
"LabelPlaylists": "播放列表",
"LabelPlayMethod": "播放方法",
"LabelPodcast": "播客",
"LabelPodcasts": "播客",
@@ -389,6 +396,9 @@
"LabelTotalTimeListened": "总收听时间",
"LabelTrackFromFilename": "从文件名获取音轨",
"LabelTrackFromMetadata": "从源数据获取音轨",
"LabelTracks": "音轨",
"LabelTracksMultiTrack": "多轨",
"LabelTracksSingleTrack": "单轨",
"LabelType": "类型",
"LabelUnknown": "未知",
"LabelUpdateCover": "更新封面",
@@ -411,9 +421,9 @@
"LabelWeekdaysToRun": "工作日运行",
"LabelYourAudiobookDuration": "你的有声读物持续时间",
"LabelYourBookmarks": "你的书签",
"LabelYourPlaylists": "Your Playlists",
"LabelYourPlaylists": "你的播放列表",
"LabelYourProgress": "你的进度",
"MessageAddToPlayerQueue": "Add to player queue",
"MessageAddToPlayerQueue": "添加到播放队列",
"MessageAppriseDescription": "要使用此功能,您需要运行一个 <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> 实例或一个可以处理这些相同请求的 API. <br />Apprise API Url 应该是发送通知的完整 URL 路径, 例如: 如果你的 API 实例运行在 <code>http://192.168.1.1:8337</code>, 那么你可以输入 <code>http://192.168.1.1:8337/notify</code>.",
"MessageBackupsDescription": "备份包括用户, 用户进度, 媒体库项目详细信息, 服务器设置和图像, 存储在 <code>/metadata/items</code> & <code>/metadata/authors</code>. 备份不包括存储在您的媒体库文件夹中的任何文件.",
"MessageBatchQuickMatchDescription": "快速匹配将尝试为所选项目添加缺少的封面和元数据. 启用以下选项以允许快速匹配覆盖现有封面和或元数据.",
@@ -422,6 +432,9 @@
"MessageBookshelfNoRSSFeeds": "没有打开的 RSS 源",
"MessageBookshelfNoSeries": "你没有系列",
"MessageChapterEndIsAfter": "章节结束是在有声读物结束之后",
"MessageChapterErrorFirstNotZero": "第一章节必须从 0 开始",
"MessageChapterErrorStartGteDuration": "无效的开始时间, 必须小于有声读物持续时间",
"MessageChapterErrorStartLtPrev": "无效的开始时间, 必须大于或等于上一章节的开始时间",
"MessageChapterStartIsAfter": "章节开始是在有声读物结束之后",
"MessageCheckingCron": "检查计划任务...",
"MessageConfirmDeleteBackup": "你确定要删除备份 {0}?",
@@ -431,7 +444,13 @@
"MessageConfirmRemoveCollection": "您确定要移除收藏 \"{0}\"?",
"MessageConfirmRemoveEpisode": "您确定要移除剧集 \"{0}\"?",
"MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?",
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{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": "正在下载剧集",
"MessageDragFilesIntoTrackOrder": "将文件拖动到正确的音轨顺序",
"MessageEmbedFinished": "嵌入完成!",
@@ -442,6 +461,7 @@
"MessageImportantNotice": "重要通知!",
"MessageInsertChapterBelow": "在下面插入章节",
"MessageItemsSelected": "已选定 {0} 个项目",
"MessageItemsUpdated": "{0} Items Updated",
"MessageJoinUsOn": "加入我们",
"MessageListeningSessionsInTheLastYear": "去年收听 {0} 个会话",
"MessageLoading": "加载...",
@@ -474,25 +494,30 @@
"MessageNoPodcastsFound": "未找到播客",
"MessageNoResults": "无结果",
"MessageNoSearchResultsFor": "没有搜索到结果 \"{0}\"",
"MessageNoSeries": "无系列",
"MessageNoTags": "No Tags",
"MessageNotYetImplemented": "尚未实施",
"MessageNoUpdateNecessary": "无需更新",
"MessageNoUpdatesWereNecessary": "无需更新",
"MessageNoUserPlaylists": "You have no playlists",
"MessageNoUserPlaylists": "你没有播放列表",
"MessageOr": "或",
"MessagePauseChapter": "暂停章节播放",
"MessagePlayChapter": "开始章节播放",
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
"MessagePodcastHasNoRSSFeedForMatching": "播客没有可用于匹配 RSS 源的 url",
"MessageQuickMatchDescription": "使用来自 '{0}' 的第一个匹配结果填充空白详细信息和封面. 除非启用 '首选匹配元数据' 服务器设置, 否则不会覆盖详细信息.",
"MessageRemoveAllItemsWarning": "警告! 此操作将从数据库中删除所有的媒体库项, 包括您所做的任何更新或匹配. 这不会对实际文件产生任何影响. 你确定吗?",
"MessageRemoveChapter": "移除章节",
"MessageRemoveEpisodes": "移除 {0} 剧集",
"MessageRemoveFromPlayerQueue": "Remove from player queue",
"MessageRemoveFromPlayerQueue": "从播放队列中移除",
"MessageRemoveUserWarning": "是否确实要永久删除用户 \"{0}\"?",
"MessageReportBugsAndContribute": "报告错误、请求功能和贡献在",
"MessageRestoreBackupConfirm": "确定要恢复创建的这个备份",
"MessageResetChaptersConfirm": "确定要重置章节并撤消你所做的更改吗?",
"MessageRestoreBackupConfirm": "你确定要恢复创建的这个备份",
"MessageRestoreBackupWarning": "恢复备份将覆盖位于 /config 的整个数据库并覆盖 /metadata/items & /metadata/authors 中的图像.<br /><br />备份不会修改媒体库文件夹中的任何文件. 如果您已启用服务器设置将封面和元数据存储在库文件夹中,则不会备份或覆盖这些内容.<br /><br />将自动刷新使用服务器的所有客户端.",
"MessageSearchResultsFor": "搜索结果",
"MessageServerCouldNotBeReached": "无法访问服务器",
"MessageSetChaptersFromTracksDescription": "把每个音频文件设置为章节并将章节标题设置为音频文件名",
"MessageStartPlaybackAtTime": "开始播放 \"{0}\" 在 {1}?",
"MessageThinking": "正在查找...",
"MessageUploaderItemFailed": "上传失败",
@@ -512,9 +537,9 @@
"NoteUploaderFoldersWithMediaFiles": "包含媒体文件的文件夹将作为单独的媒体库项目处理.",
"NoteUploaderOnlyAudioFiles": "如果只上传音频文件, 则每个音频文件将作为单独的有声读物处理.",
"NoteUploaderUnsupportedFiles": "不支持的文件将被忽略. 选择或删除文件夹时, 将忽略不在项目文件夹中的其他文件.",
"PlaceholderNewCollection": "新建收藏夹名称",
"PlaceholderNewCollection": "输入收藏夹名称",
"PlaceholderNewFolderPath": "输入文件夹路径",
"PlaceholderNewPlaylist": "New playlist name",
"PlaceholderNewPlaylist": "输入播放列表名称",
"PlaceholderSearch": "查找..",
"ToastAccountUpdateFailed": "账户更新失败",
"ToastAccountUpdateSuccess": "帐户已更新",
@@ -539,6 +564,8 @@
"ToastBookmarkRemoveSuccess": "书签已删除",
"ToastBookmarkUpdateFailed": "书签更新失败",
"ToastBookmarkUpdateSuccess": "书签已更新",
"ToastChaptersHaveErrors": "章节有错误",
"ToastChaptersMustHaveTitles": "章节必须有标题",
"ToastCollectionItemsRemoveFailed": "从收藏夹移除项目失败",
"ToastCollectionItemsRemoveSuccess": "项目从收藏夹移除",
"ToastCollectionRemoveFailed": "删除收藏夹失败",
@@ -562,10 +589,12 @@
"ToastLibraryScanStarted": "媒体库扫描已启动",
"ToastLibraryUpdateFailed": "更新图书库失败",
"ToastLibraryUpdateSuccess": "媒体库 \"{0}\" 已更新",
"ToastPlaylistRemoveFailed": "Failed to remove playlist",
"ToastPlaylistRemoveSuccess": "Playlist removed",
"ToastPlaylistUpdateFailed": "Failed to update playlist",
"ToastPlaylistUpdateSuccess": "Playlist updated",
"ToastPlaylistCreateFailed": "Failed to create playlist",
"ToastPlaylistCreateSuccess": "Playlist created",
"ToastPlaylistRemoveFailed": "删除播放列表失败",
"ToastPlaylistRemoveSuccess": "播放列表已删除",
"ToastPlaylistUpdateFailed": "更新播放列表失败",
"ToastPlaylistUpdateSuccess": "播放列表已更新",
"ToastPodcastCreateFailed": "创建播客失败",
"ToastPodcastCreateSuccess": "已成功创建播客",
"ToastRemoveItemFromCollectionFailed": "从收藏中删除项目失败",

View File

@@ -15,7 +15,7 @@ if (isDev) {
}
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 METADATA_PATH = process.env.METADATA_PATH || '/metadata'
const UID = process.env.AUDIOBOOKSHELF_UID || 99

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "audiobookshelf",
"version": "2.2.6",
"version": "2.2.9",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf",
"version": "2.2.6",
"version": "2.2.9",
"license": "GPL-3.0",
"dependencies": {
"axios": "^0.26.1",

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "2.2.6",
"version": "2.2.9",
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",
"scripts": {

View File

@@ -109,7 +109,7 @@ class Auth {
Logger.error('JWT Verify Token Failed', err)
return resolve(null)
}
var user = this.users.find(u => u.id === payload.userId && u.username === payload.username)
const user = this.users.find(u => u.id === payload.userId && u.username === payload.username)
resolve(user || null)
})
})

View File

@@ -2,6 +2,7 @@ const Path = require('path')
const njodb = require('./libs/njodb')
const Logger = require('./Logger')
const { version } = require('../package.json')
const filePerms = require('./utils/filePerms')
const LibraryItem = require('./objects/LibraryItem')
const User = require('./objects/user/User')
const Collection = require('./objects/Collection')
@@ -131,6 +132,9 @@ class Db {
async init() {
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
this.serverSettings = new ServerSettings()
await this.insertEntity('settings', this.serverSettings)
@@ -229,7 +233,7 @@ class Db {
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 libraryItems.find(li => li.id === record.id)
}).then((results) => {

View File

@@ -206,6 +206,7 @@ class Server {
'/library/:library/podcast/latest',
'/config/users/:id',
'/config/users/:id/sessions',
'/config/item-metadata-utils/:id',
'/collection/:id',
'/playlist/:id'
]
@@ -240,7 +241,8 @@ class Server {
app.get('/healthcheck', (req, res) => res.sendStatus(200))
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

View File

@@ -31,9 +31,13 @@ class SocketAuthority {
}
// Emits event to all authorized clients
emitter(evt, data) {
// optional filter function to only send event to specific users
// TODO: validate that filter is actually a function
emitter(evt, data, filter = null) {
for (const socketId in this.clients) {
if (this.clients[socketId].user) {
if (filter && !filter(this.clients[socketId].user)) continue
this.clients[socketId].socket.emit(evt, data)
}
}

View File

@@ -148,7 +148,9 @@ class AuthorController {
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))
authors = authors.slice(0, limit)
res.json(authors)
res.json({
results: authors
})
}
async match(req, res) {

View File

@@ -20,8 +20,9 @@ class CollectionController {
}
findAll(req, res) {
var expandedCollections = this.db.collections.map(c => c.toJSONExpanded(this.db.libraryItems))
res.json(expandedCollections)
res.json({
collections: this.db.collections.map(c => c.toJSONExpanded(this.db.libraryItems))
})
}
findOne(req, res) {
@@ -122,7 +123,7 @@ class CollectionController {
middleware(req, res, next) {
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) {
return res.status(404).send('Collection not found')
}

View File

@@ -19,8 +19,9 @@ class FileSystemController {
})
Logger.debug(`[Server] get file system paths, excluded: ${excludedDirs.join(', ')}`)
var dirs = await this.getDirectories(global.appRoot, '/', excludedDirs)
res.json(dirs)
res.json({
directories: await this.getDirectories(global.appRoot, '/', excludedDirs)
})
}
}
module.exports = new FileSystemController()

View File

@@ -13,7 +13,7 @@ class LibraryController {
constructor() { }
async create(req, res) {
var newLibraryPayload = {
const newLibraryPayload = {
...req.body
}
if (!newLibraryPayload.name || !newLibraryPayload.folders || !newLibraryPayload.folders.length) {
@@ -26,9 +26,9 @@ class LibraryController {
f.fullPath = Path.resolve(f.fullPath)
return f
})
for (var folder of newLibraryPayload.folders) {
for (const folder of newLibraryPayload.folders) {
try {
var direxists = await fs.pathExists(folder.fullPath)
const direxists = await fs.pathExists(folder.fullPath)
if (!direxists) { // If folder does not exist try to make it and set file permissions/owner
await fs.mkdir(folder.fullPath)
await filePerms.setDefault(folder.fullPath)
@@ -39,12 +39,16 @@ class LibraryController {
}
}
var library = new Library()
const library = new Library()
newLibraryPayload.displayOrder = this.db.libraries.length + 1
library.setData(newLibraryPayload)
await this.db.insertEntity('library', library)
// TODO: Only emit to users that have access
SocketAuthority.emitter('library_added', library.toJSON())
// Only emit to users with access to library
const userFilter = (user) => {
return user.checkCanAccessLibrary && user.checkCanAccessLibrary(library.id)
}
SocketAuthority.emitter('library_added', library.toJSON(), userFilter)
// Add library watcher
this.watcher.addLibrary(library)
@@ -53,12 +57,14 @@ class LibraryController {
}
findAll(req, res) {
var librariesAccessible = req.user.librariesAccessible || []
const librariesAccessible = req.user.librariesAccessible || []
if (librariesAccessible && librariesAccessible.length) {
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) {
@@ -75,12 +81,12 @@ class LibraryController {
}
async update(req, res) {
var library = req.library
const library = req.library
// Validate new folder paths exist or can be created & resolve rel paths
// returns 400 if a new folder fails to access
if (req.body.folders) {
var newFolderPaths = []
const newFolderPaths = []
req.body.folders = req.body.folders.map(f => {
if (!f.id) {
f.fullPath = Path.resolve(f.fullPath)
@@ -88,11 +94,11 @@ class LibraryController {
}
return f
})
for (var path of newFolderPaths) {
var pathExists = await fs.pathExists(path)
for (const path of newFolderPaths) {
const pathExists = await fs.pathExists(path)
if (!pathExists) {
// Ensure dir will recursively create directories which might be preferred over mkdir
var success = await fs.ensureDir(path).then(() => true).catch((error) => {
const success = await fs.ensureDir(path).then(() => true).catch((error) => {
Logger.error(`[LibraryController] Failed to ensure folder dir "${path}"`, error)
return false
})
@@ -105,7 +111,7 @@ class LibraryController {
}
}
var hasUpdates = library.update(req.body)
const hasUpdates = library.update(req.body)
// TODO: Should check if this is an update to folder paths or name only
if (hasUpdates) {
// Update watcher
@@ -115,7 +121,7 @@ class LibraryController {
this.cronManager.updateLibraryScanCron(library)
// Remove libraryItems no longer in library
var itemsToRemove = this.db.libraryItems.filter(li => li.libraryId === library.id && !library.checkFullPathInLibrary(li.path))
const itemsToRemove = this.db.libraryItems.filter(li => li.libraryId === library.id && !library.checkFullPathInLibrary(li.path))
if (itemsToRemove.length) {
Logger.info(`[Scanner] Updating library, removing ${itemsToRemove.length} items`)
for (let i = 0; i < itemsToRemove.length; i++) {
@@ -123,32 +129,37 @@ class LibraryController {
}
}
await this.db.updateEntity('library', library)
SocketAuthority.emitter('library_updated', library.toJSON())
// Only emit to users with access to library
const userFilter = (user) => {
return user.checkCanAccessLibrary && user.checkCanAccessLibrary(library.id)
}
SocketAuthority.emitter('library_updated', library.toJSON(), userFilter)
}
return res.json(library.toJSON())
}
async delete(req, res) {
var library = req.library
const library = req.library
// Remove library watcher
this.watcher.removeLibrary(library)
// Remove collections for library
var collections = this.db.collections.filter(c => c.libraryId === library.id)
const collections = this.db.collections.filter(c => c.libraryId === library.id)
for (const collection of collections) {
Logger.info(`[Server] deleting collection "${collection.name}" for library "${library.name}"`)
await this.db.removeEntity('collection', collection.id)
}
// Remove items in this library
var libraryItems = this.db.libraryItems.filter(li => li.libraryId === library.id)
const libraryItems = this.db.libraryItems.filter(li => li.libraryId === library.id)
Logger.info(`[Server] deleting library "${library.name}" with ${libraryItems.length} items"`)
for (let i = 0; i < libraryItems.length; i++) {
await this.handleDeleteLibraryItem(libraryItems[i])
}
var libraryJson = library.toJSON()
const libraryJson = library.toJSON()
await this.db.removeEntity('library', library.id)
SocketAuthority.emitter('library_removed', libraryJson)
return res.json(libraryJson)
@@ -170,17 +181,17 @@ class LibraryController {
minified: req.query.minified === '1',
collapseseries: req.query.collapseseries === '1'
}
var mediaIsBook = payload.mediaType === 'book'
const mediaIsBook = payload.mediaType === 'book'
// Step 1 - Filter the retrieved library items
var filterSeries = null
let filterSeries = null
if (payload.filterBy) {
libraryItems = libraryHelpers.getFilteredLibraryItems(libraryItems, payload.filterBy, req.user, this.rssFeedManager.feedsArray)
payload.total = libraryItems.length
// Determining if we are filtering titles by a series, and if so, which series
filterSeries = (mediaIsBook && payload.filterBy.startsWith('series.')) ? libraryHelpers.decode(payload.filterBy.replace('series.', '')) : null
if (filterSeries === 'No Series') filterSeries = null
if (filterSeries === 'no-series') filterSeries = null
}
// Step 2 - If selected, collapse library items by the series they belong to.
@@ -216,7 +227,7 @@ class LibraryController {
if (payload.sortBy) {
// old sort key TODO: should be mutated in dbMigration
var sortKey = payload.sortBy
let sortKey = payload.sortBy
if (sortKey.startsWith('book.')) {
sortKey = sortKey.replace('book.', 'media.metadata.')
}
@@ -246,7 +257,7 @@ class LibraryController {
}
// Sort series based on the sortBy attribute
var direction = payload.sortDesc ? 'desc' : 'asc'
const direction = payload.sortDesc ? 'desc' : 'asc'
sortArray.push({
[direction]: (li) => {
if (mediaIsBook && sortBySequence) {
@@ -332,7 +343,7 @@ class LibraryController {
}
async removeLibraryItemsWithIssues(req, res) {
var libraryItemsWithIssues = req.libraryItems.filter(li => li.hasIssues)
const libraryItemsWithIssues = req.libraryItems.filter(li => li.hasIssues)
if (!libraryItemsWithIssues.length) {
Logger.warn(`[LibraryController] No library items have issues`)
return res.sendStatus(200)
@@ -349,8 +360,8 @@ class LibraryController {
// api/libraries/:id/series
async getAllSeriesForLibrary(req, res) {
var libraryItems = req.libraryItems
var payload = {
const libraryItems = req.libraryItems
const payload = {
results: [],
total: 0,
limit: req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 0,
@@ -361,7 +372,7 @@ class LibraryController {
minified: req.query.minified === '1'
}
var series = libraryHelpers.getSeriesFromBooks(libraryItems, this.db.series, null, payload.filterBy, req.user, payload.minified)
let series = libraryHelpers.getSeriesFromBooks(libraryItems, this.db.series, null, payload.filterBy, req.user, payload.minified)
const direction = payload.sortDesc ? 'desc' : 'asc'
series = naturalSort(series).by([
@@ -487,8 +498,9 @@ class LibraryController {
Logger.debug(`[LibraryController] Library orders were up to date`)
}
var libraries = this.db.libraries.map(lib => lib.toJSON())
res.json(libraries)
res.json({
libraries: this.db.libraries.map(lib => lib.toJSON())
})
}
// GET: Global library search
@@ -594,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) {

View File

@@ -1,3 +1,4 @@
const fs = require('../libs/fsExtra')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
@@ -178,7 +179,15 @@ class LibraryItemController {
// GET api/items/:id/cover
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 = {
format: format || (reqSupportsWebp(req) ? 'webp' : 'jpeg'),
@@ -299,16 +308,18 @@ class LibraryItemController {
// POST: api/items/batch/get
async batchGet(req, res) {
var libraryItemIds = req.body.libraryItemIds || []
const libraryItemIds = req.body.libraryItemIds || []
if (!libraryItemIds.length) {
return res.status(403).send('Invalid payload')
}
var libraryItems = []
const libraryItems = []
libraryItemIds.forEach((lid) => {
const li = this.db.libraryItems.find(_li => _li.id === lid)
if (li) libraryItems.push(li.toJSONExpanded())
})
res.json(libraryItems)
res.json({
libraryItems
})
}
// POST: api/items/batch/quickmatch

View File

@@ -167,6 +167,7 @@ class MeController {
this.auth.userChangePassword(req, res)
}
// TODO: Remove after mobile release v0.9.61-beta
// PATCH: api/me/settings
async updateSettings(req, res) {
var settingsUpdate = req.body

View File

@@ -1,6 +1,8 @@
const Path = require('path')
const fs = require('../libs/fsExtra')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const filePerms = require('../utils/filePerms')
const patternValidation = require('../libs/nodeCron/pattern-validation')
const { isObject } = require('../utils/index')
@@ -124,12 +126,13 @@ class MiscController {
res.json(userResponse)
}
// GET: api/tags
getAllTags(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[MiscController] Non-admin user attempted to getAllTags`)
return res.sendStatus(404)
}
var tags = []
const tags = []
this.db.libraryItems.forEach((li) => {
if (li.media.tags && li.media.tags.length) {
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) {

View File

@@ -174,9 +174,42 @@ class PlaylistController {
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) {
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) {
return res.status(404).send('Playlist not found')
}
@@ -187,14 +220,6 @@ class PlaylistController {
req.playlist = playlist
}
if (req.method == 'DELETE' && !req.user.canDelete) {
Logger.warn(`[PlaylistController] User attempted to delete without permission`, req.user.username)
return res.sendStatus(403)
} else if ((req.method == 'PATCH' || req.method == 'POST') && !req.user.canUpdate) {
Logger.warn('[PlaylistController] User attempted to update without permission', req.user.username)
return res.sendStatus(403)
}
next()
}
}

View File

@@ -4,15 +4,15 @@ class SearchController {
constructor() { }
async findBooks(req, res) {
var provider = req.query.provider || 'google'
var title = req.query.title || ''
var author = req.query.author || ''
var results = await this.bookFinder.search(provider, title, author)
const provider = req.query.provider || 'google'
const title = req.query.title || ''
const author = req.query.author || ''
const results = await this.bookFinder.search(provider, title, author)
res.json(results)
}
async findCovers(req, res) {
var query = req.query
const query = req.query
const podcast = query.podcast == 1
if (!query.title) {
@@ -20,28 +20,30 @@ class SearchController {
return res.sendStatus(400)
}
var result = null
if (podcast) result = await this.podcastFinder.findCovers(query.title)
else result = await this.bookFinder.findCovers(query.provider || 'google', query.title, query.author || null)
res.json(result)
let results = null
if (podcast) results = await this.podcastFinder.findCovers(query.title)
else results = await this.bookFinder.findCovers(query.provider || 'google', query.title, query.author || null)
res.json({
results
})
}
async findPodcasts(req, res) {
var term = req.query.term
var results = await this.podcastFinder.search(term)
const term = req.query.term
const results = await this.podcastFinder.search(term)
res.json(results)
}
async findAuthor(req, res) {
var query = req.query.q
var author = await this.authorFinder.findAuthorByName(query)
const query = req.query.q
const author = await this.authorFinder.findAuthorByName(query)
res.json(author)
}
async findChapters(req, res) {
var asin = req.query.asin
var region = (req.query.region || 'us').toLowerCase()
var chapterData = await this.bookFinder.findChapters(asin, region)
const asin = req.query.asin
const region = (req.query.region || 'us').toLowerCase()
const chapterData = await this.bookFinder.findChapters(asin, region)
if (!chapterData) {
return res.json({ error: 'Chapters not found' })
}

View File

@@ -32,7 +32,9 @@ class SeriesController {
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))
series = series.slice(0, limit)
res.json(series)
res.json({
results: series
})
}
async update(req, res) {

View File

@@ -12,7 +12,9 @@ class UserController {
if (!req.user.isAdminOrUp) return res.sendStatus(403)
const hideRootToken = !req.user.isRoot
const users = this.db.users.map(u => this.userJsonWithItemProgressDetails(u, hideRootToken))
res.json(users)
res.json({
users: users
})
}
findOne(req, res) {

View File

@@ -47,7 +47,7 @@ class CacheManager {
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
if (await fs.pathExists(path)) {
@@ -66,7 +66,7 @@ class CacheManager {
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)
// Set owner and permissions of cache image

View File

@@ -1,5 +1,6 @@
const Path = require('path')
const fs = require('../libs/fsExtra')
const filePerms = require('../utils/filePerms')
const DailyLog = require('../objects/DailyLog')
@@ -11,8 +12,8 @@ class LogManager {
constructor(db) {
this.db = db
this.logDirPath = Path.join(global.MetadataPath, 'logs')
this.dailyLogDirPath = Path.join(this.logDirPath, 'daily')
this.DailyLogPath = Path.posix.join(global.MetadataPath, 'logs', 'daily')
this.ScanLogPath = Path.posix.join(global.MetadataPath, 'logs', 'scans')
this.currentDailyLog = null
this.dailyLogBuffer = []
@@ -27,24 +28,38 @@ class LogManager {
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() {
await this.ensureLogDirs()
// Load daily logs
await this.scanLogFiles()
// Check remove extra daily logs
if (this.dailyLogFiles.length > this.loggerDailyLogsToKeep) {
var dailyLogFilesCopy = [...this.dailyLogFiles]
const dailyLogFilesCopy = [...this.dailyLogFiles]
for (let i = 0; i < dailyLogFilesCopy.length - this.loggerDailyLogsToKeep; i++) {
var logFileToRemove = dailyLogFilesCopy[i]
await this.removeLogFile(logFileToRemove)
await this.removeLogFile(dailyLogFilesCopy[i])
}
}
var currentDailyLogFilename = DailyLog.getCurrentDailyLogFilename()
const currentDailyLogFilename = DailyLog.getCurrentDailyLogFilename()
Logger.info(TAG, `Init current daily log filename: ${currentDailyLogFilename}`)
this.currentDailyLog = new DailyLog()
this.currentDailyLog.setData({ dailyLogDirPath: this.dailyLogDirPath })
this.currentDailyLog.setData({ dailyLogDirPath: this.DailyLogPath })
if (this.dailyLogFiles.includes(currentDailyLogFilename)) {
Logger.debug(TAG, `Daily log file already exists - set in Logger`)
@@ -63,8 +78,7 @@ class LogManager {
}
async scanLogFiles() {
await fs.ensureDir(this.dailyLogDirPath)
var dailyFiles = await fs.readdir(this.dailyLogDirPath)
const dailyFiles = await fs.readdir(this.DailyLogPath)
if (dailyFiles && dailyFiles.length) {
dailyFiles.forEach((logFile) => {
if (Path.extname(logFile) === '.txt') {
@@ -80,13 +94,13 @@ class LogManager {
async removeOldestLog() {
if (!this.dailyLogFiles.length) return
var oldestLog = this.dailyLogFiles[0]
const oldestLog = this.dailyLogFiles[0]
return this.removeLogFile(oldestLog)
}
async removeLogFile(filename) {
var fullPath = Path.join(this.dailyLogDirPath, filename)
var exists = await fs.pathExists(fullPath)
const fullPath = Path.join(this.DailyLogPath, filename)
const exists = await fs.pathExists(fullPath)
if (!exists) {
Logger.error(TAG, 'Invalid log dne ' + fullPath)
this.dailyLogFiles = this.dailyLogFiles.filter(dlf => dlf.filename !== filename)
@@ -109,8 +123,8 @@ class LogManager {
// Check log rolls to next day
if (this.currentDailyLog.id !== DailyLog.getCurrentDateString()) {
var newDailyLog = new DailyLog()
newDailyLog.setData({ dailyLogDirPath: this.dailyLogDirPath })
const newDailyLog = new DailyLog()
newDailyLog.setData({ dailyLogDirPath: this.DailyLogPath })
this.currentDailyLog = newDailyLog
if (this.dailyLogFiles.length > this.loggerDailyLogsToKeep) {
this.removeOldestLog()
@@ -126,7 +140,7 @@ class LogManager {
return
}
var lastLogs = this.currentDailyLog.logs.slice(-5000)
const lastLogs = this.currentDailyLog.logs.slice(-5000)
socket.emit('daily_logs', lastLogs)
}
}

View File

@@ -31,7 +31,7 @@ class PlaybackSessionManager {
return this.sessions.find(s => s.userId === userId)
}
getStream(sessionId) {
var session = this.getSession(sessionId)
const session = this.getSession(sessionId)
return session ? session.stream : null
}
@@ -54,7 +54,7 @@ class PlaybackSessionManager {
}
async syncSessionRequest(user, session, payload, res) {
var result = await this.syncSession(user, session, payload)
const result = await this.syncSession(user, session, payload)
if (result) {
res.json(session.toJSONForClient(result.libraryItem))
}
@@ -66,7 +66,7 @@ class PlaybackSessionManager {
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) {
Logger.error(`[PlaybackSessionManager] syncLocalSessionRequest: Library item not found for session "${sessionJson.libraryItemId}"`)
return res.status(500).send('Library item not found')
@@ -74,7 +74,7 @@ class PlaybackSessionManager {
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) {
// New session from local
session = new PlaybackSession(sessionJson)
@@ -96,10 +96,10 @@ class PlaybackSessionManager {
progress: session.progress,
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) {
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', {
id: itemProgress.id,
data: itemProgress.toJSON()
@@ -118,18 +118,25 @@ class PlaybackSessionManager {
async startSession(user, deviceInfo, libraryItem, episodeId, options) {
// 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) {
Logger.info(`[PlaybackSessionManager] startSession: Closing open session "${session.displayTitle}" for user "${user.username}"`)
await this.closeSession(user, session, null)
}
var shouldDirectPlay = options.forceDirectPlay || (!options.forceTranscode && libraryItem.media.checkCanDirectPlay(options, episodeId))
var mediaPlayer = options.mediaPlayer || 'unknown'
const shouldDirectPlay = options.forceDirectPlay || (!options.forceTranscode && libraryItem.media.checkCanDirectPlay(options, episodeId))
const mediaPlayer = options.mediaPlayer || 'unknown'
const userProgress = user.getMediaProgress(libraryItem.id, episodeId)
var userStartTime = 0
if (userProgress) userStartTime = Number.parseFloat(userProgress.currentTime) || 0
let userStartTime = 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()
newPlaybackSession.setData(libraryItem, user, mediaPlayer, deviceInfo, userStartTime, episodeId)
@@ -142,14 +149,14 @@ class PlaybackSessionManager {
// HLS not supported for video yet
}
} else {
var audioTracks = []
let audioTracks = []
if (shouldDirectPlay) {
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}"`)
audioTracks = libraryItem.getDirectPlayTracklist(episodeId)
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
} else {
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()
stream.start() // Start transcode
@@ -175,7 +182,7 @@ class PlaybackSessionManager {
}
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) {
Logger.error(`[PlaybackSessionManager] syncSession Library Item not found "${session.libraryItemId}"`)
return null
@@ -190,11 +197,11 @@ class PlaybackSessionManager {
currentTime: syncData.currentTime,
progress: session.progress
}
var wasUpdated = user.createUpdateMediaProgress(libraryItem, itemProgressUpdate, session.episodeId)
const wasUpdated = user.createUpdateMediaProgress(libraryItem, itemProgressUpdate, session.episodeId)
if (wasUpdated) {
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', {
id: itemProgress.id,
data: itemProgress.toJSON()
@@ -229,7 +236,7 @@ class PlaybackSessionManager {
}
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.stream) {
await session.stream.close()
@@ -242,13 +249,13 @@ class PlaybackSessionManager {
async removeOrphanStreams() {
await fs.ensureDir(this.StreamsPath)
try {
var streamsInPath = await fs.readdir(this.StreamsPath)
const streamsInPath = await fs.readdir(this.StreamsPath)
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
var session = this.sessions.find(se => se.id === streamId)
const session = this.sessions.find(se => se.id === streamId)
if (!session) {
var streamPath = Path.join(this.StreamsPath, streamId)
const streamPath = Path.join(this.StreamsPath, streamId)
Logger.debug(`[PlaybackSessionManager] Removing orphan stream "${streamPath}"`)
await fs.remove(streamPath)
}

View File

@@ -38,10 +38,10 @@ class Collection {
}
toJSONExpanded(libraryItems, minifiedBooks = false) {
var json = this.toJSON()
const json = this.toJSON()
json.books = json.books.map(bookId => {
var _ab = libraryItems.find(li => li.id === bookId)
return _ab ? minifiedBooks ? _ab.toJSONMinified() : _ab.toJSONExpanded() : null
const book = libraryItems.find(li => li.id === bookId)
return book ? minifiedBooks ? book.toJSONMinified() : book.toJSONExpanded() : null
}).filter(b => !!b)
return json
}

View File

@@ -86,7 +86,8 @@ class FeedEpisode {
setFromAudiobookTrack(libraryItem, serverAddress, slug, audioTrack, meta) {
// 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 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 media = libraryItem.media

View File

@@ -18,7 +18,7 @@ class User {
this.seriesHideFromContinueListening = [] // Series IDs that should not show on home page continue listening
this.bookmarks = []
this.settings = {}
this.settings = {} // TODO: Remove after mobile release v0.9.61-beta
this.permissions = {}
this.librariesAccessible = [] // Library IDs (Empty if ALL libraries)
this.itemTagsAccessible = [] // Empty if ALL item tags accessible
@@ -59,17 +59,12 @@ class User {
return !!this.pash && !!this.pash.length
}
// TODO: Remove after mobile release v0.9.61-beta
getDefaultUserSettings() {
return {
mobileOrderBy: 'recent',
mobileOrderDesc: true,
mobileFilterBy: 'all',
orderBy: 'media.metadata.title',
orderDesc: false,
filterBy: 'all',
playbackRate: 1,
bookshelfCoverSize: 120,
collapseSeries: false
mobileFilterBy: 'all'
}
}
@@ -99,7 +94,7 @@ class User {
isLocked: this.isLocked,
lastSeen: this.lastSeen,
createdAt: this.createdAt,
settings: this.settings,
settings: this.settings, // TODO: Remove after mobile release v0.9.61-beta
permissions: this.permissions,
librariesAccessible: [...this.librariesAccessible],
itemTagsAccessible: [...this.itemTagsAccessible]
@@ -119,7 +114,7 @@ class User {
isLocked: this.isLocked,
lastSeen: this.lastSeen,
createdAt: this.createdAt,
settings: this.settings,
settings: this.settings, // TODO: Remove after mobile release v0.9.61-beta
permissions: this.permissions,
librariesAccessible: [...this.librariesAccessible],
itemTagsAccessible: [...this.itemTagsAccessible]
@@ -171,7 +166,7 @@ class User {
this.isLocked = user.type === 'root' ? false : !!user.isLocked
this.lastSeen = user.lastSeen || null
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()
// Upload permission added v1.1.13, make sure root user has upload permissions
if (this.type === 'root' && !this.permissions.upload) this.permissions.upload = true
@@ -348,6 +343,7 @@ class User {
return true
}
// TODO: Remove after mobile release v0.9.61-beta
// Returns Boolean If update was made
updateSettings(settings) {
if (!this.settings) {

View File

@@ -15,7 +15,14 @@ class GoogleBooks {
cleanResult(item) {
var { id, volumeInfo } = item
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 {
id,
@@ -25,7 +32,7 @@ class GoogleBooks {
publisher,
publishedYear: publisherDate ? publisherDate.split('-')[0] : null,
description,
cover: imageLinks && imageLinks.thumbnail ? imageLinks.thumbnail : null,
cover,
genres: categories && Array.isArray(categories) ? [...categories] : null,
isbn: this.extractIsbn(industryIdentifiers)
}

View File

@@ -143,7 +143,7 @@ class ApiRouter {
//
// 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/:id', PlaylistController.middleware.bind(this), PlaylistController.findOne.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.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/collection/:collectionId', PlaylistController.createFromCollection.bind(this))
//
// Current User Routes (Me)
@@ -168,7 +169,7 @@ class ApiRouter {
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.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.get('/me/items-in-progress', MeController.getAllLibraryItemsInProgress.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.post('/authorize', MiscController.authorize.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))
}

View File

@@ -5,6 +5,7 @@ const date = require('../libs/dateAndTime')
const Logger = require('../Logger')
const Folder = require('../objects/Folder')
const { LogLevel } = require('../utils/constants')
const filePerms = require('../utils/filePerms')
const { getId, secondsToTimestamp } = require('../utils/index')
class LibraryScan {
@@ -61,7 +62,7 @@ class LibraryScan {
get totalResults() {
return this.resultsAdded + this.resultsUpdated + this.resultsMissing
}
get getLogFilename() {
get logFilename() {
return date.format(new Date(), 'YYYY-MM-DD') + '_' + this.id + '.txt'
}
@@ -124,14 +125,18 @@ class LibraryScan {
this.logs.push(logObj)
}
async saveLog(logDir) {
await fs.ensureDir(logDir)
var outputPath = Path.join(logDir, this.getLogFilename)
var logLines = [JSON.stringify(this.toJSON())]
async saveLog() {
await Logger.logManager.ensureScanLogDir()
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 => {
logLines.push(JSON.stringify(l))
})
await fs.writeFile(outputPath, logLines.join('\n') + '\n')
await filePerms.setDefault(outputPath)
Logger.info(`[LibraryScan] Scan log saved "${outputPath}"`)
}
}

View File

@@ -14,7 +14,7 @@ class MediaFileScanner {
getTrackAndDiscNumberFromFilename(mediaMetadataFromScan, audioLibraryFile) {
const { title, author, series, publishedYear } = mediaMetadataFromScan
const { filename, path } = audioLibraryFile.metadata
var partbasename = Path.basename(filename, Path.extname(filename))
let partbasename = Path.basename(filename, Path.extname(filename))
// Remove title, author, series, and publishedYear from filename if there
if (title) partbasename = partbasename.replace(title, '')
@@ -23,8 +23,8 @@ class MediaFileScanner {
if (publishedYear) partbasename = partbasename.replace(publishedYear)
// Look for disc number
var discNumber = null
var discMatch = partbasename.match(/\b(disc|cd) ?(\d\d?)\b/i)
let discNumber = null
const discMatch = partbasename.match(/\b(disc|cd) ?(\d\d?)\b/i)
if (discMatch && discMatch.length > 2 && discMatch[2]) {
if (!isNaN(discMatch[2])) {
discNumber = Number(discMatch[2])
@@ -35,14 +35,14 @@ class MediaFileScanner {
}
// Look for disc number in folder path e.g. /Book Title/CD01/audiofile.mp3
var pathdir = Path.dirname(path).split('/').pop()
const pathdir = Path.dirname(path).split('/').pop()
if (pathdir && /^cd\d{1,3}$/i.test(pathdir)) {
var discFromFolder = Number(pathdir.replace(/cd/i, ''))
const discFromFolder = Number(pathdir.replace(/cd/i, ''))
if (!isNaN(discFromFolder) && discFromFolder !== null) discNumber = discFromFolder
}
var numbersinpath = partbasename.match(/\d{1,4}/g)
var trackNumber = numbersinpath && numbersinpath.length ? parseInt(numbersinpath[0]) : null
const numbersinpath = partbasename.match(/\d{1,4}/g)
const trackNumber = numbersinpath && numbersinpath.length ? parseInt(numbersinpath[0]) : null
return {
trackNumber,
discNumber
@@ -51,7 +51,7 @@ class MediaFileScanner {
getAverageScanDurationMs(results) {
if (!results.length) return 0
var total = 0
let total = 0
for (let i = 0; i < results.length; i++) total += results[i].elapsed
return Math.floor(total / results.length)
}

View File

@@ -22,8 +22,6 @@ const Series = require('../objects/entities/Series')
class Scanner {
constructor(db, coverManager) {
this.ScanLogPath = Path.posix.join(global.MetadataPath, 'logs', 'scans')
this.db = db
this.coverManager = coverManager
@@ -165,7 +163,7 @@ class Scanner {
SocketAuthority.emitter('scan_complete', libraryScan.getScanEmitData)
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
var childItem = this.db.libraryItems.find(li => li.path.startsWith(fullPath))
var childItem = this.db.libraryItems.find(li => (li.path + '/').startsWith(fullPath + '/'))
if (childItem) {
Logger.warn(`[Scanner] Files were modified in a parent directory of a library item "${childItem.media.metadata.title}" - ignoring`)
itemGroupingResults[itemDir] = ScanResult.NOTHING

View File

@@ -183,16 +183,19 @@ module.exports.sanitizeFilename = (filename, colonReplacement = ' - ') => {
return false
}
// Max is actually 255-260 for windows but this leaves padding incase ext wasnt put on yet
const MAX_FILENAME_LEN = 240
// Most file systems use number of bytes for max filename
// 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 = ''
var illegalRe = /[\/\?<>\\:\*\|"]/g
var controlRe = /[\x00-\x1f\x80-\x9f]/g
var reservedRe = /^\.+$/
var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i
var windowsTrailingRe = /[\. ]+$/
var lineBreaks = /[\n\r]/g
const replacement = ''
const illegalRe = /[\/\?<>\\:\*\|"]/g
const controlRe = /[\x00-\x1f\x80-\x9f]/g
const reservedRe = /^\.+$/
const windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i
const windowsTrailingRe = /[\. ]+$/
const lineBreaks = /[\n\r]/g
sanitized = filename
.replace(':', colonReplacement) // Replace first occurrence of a colon
@@ -203,12 +206,25 @@ module.exports.sanitizeFilename = (filename, colonReplacement = ' - ') => {
.replace(windowsReservedRe, replacement)
.replace(windowsTrailingRe, replacement)
if (sanitized.length > MAX_FILENAME_LEN) {
var lenToRemove = sanitized.length - MAX_FILENAME_LEN
var ext = Path.extname(sanitized)
var basename = Path.basename(sanitized, ext)
basename = basename.slice(0, basename.length - lenToRemove)
sanitized = basename + ext
// 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 = ''
// 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

View File

@@ -10,53 +10,57 @@ module.exports = {
},
getFilteredLibraryItems(libraryItems, filterBy, user, feedsArray) {
var filtered = libraryItems
let filtered = libraryItems
var searchGroups = ['genres', 'tags', 'series', 'authors', 'progress', 'narrators', 'missing', 'languages']
var group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
const searchGroups = ['genres', 'tags', 'series', 'authors', 'progress', 'narrators', 'missing', 'languages', 'tracks']
const group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
if (group) {
var filterVal = filterBy.replace(`${group}.`, '')
var filter = this.decode(filterVal)
const filterVal = filterBy.replace(`${group}.`, '')
const filter = this.decode(filterVal)
if (group === 'genres') filtered = filtered.filter(li => li.media.metadata && li.media.metadata.genres.includes(filter))
else if (group === 'tags') filtered = filtered.filter(li => li.media.tags.includes(filter))
else if (group === 'series') {
if (filter === 'No Series') filtered = filtered.filter(li => li.mediaType === 'book' && !li.media.metadata.series.length)
if (filter === 'no-series') filtered = filtered.filter(li => li.isBook && !li.media.metadata.series.length)
else {
filtered = filtered.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(filter))
filtered = filtered.filter(li => li.isBook && li.media.metadata.hasSeries(filter))
}
}
else if (group === 'authors') filtered = filtered.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(filter))
else if (group === 'narrators') filtered = filtered.filter(li => li.mediaType === 'book' && li.media.metadata.hasNarrator(filter))
else if (group === 'authors') filtered = filtered.filter(li => li.isBook && li.media.metadata.hasAuthor(filter))
else if (group === 'narrators') filtered = filtered.filter(li => li.isBook && li.media.metadata.hasNarrator(filter))
else if (group === 'progress') {
filtered = filtered.filter(li => {
var itemProgress = user.getMediaProgress(li.id)
if (filter === 'Finished' && (itemProgress && itemProgress.isFinished)) return true
if (filter === 'Not Started' && !itemProgress) return true
if (filter === 'Not Finished' && (!itemProgress || !itemProgress.isFinished)) return true
if (filter === 'In Progress' && (itemProgress && itemProgress.inProgress)) return true
const itemProgress = user.getMediaProgress(li.id)
if (filter === 'finished' && (itemProgress && itemProgress.isFinished)) return true
if (filter === 'not-started' && !itemProgress) return true
if (filter === 'not-finished' && (!itemProgress || !itemProgress.isFinished)) return true
if (filter === 'in-progress' && (itemProgress && itemProgress.inProgress)) return true
return false
})
} else if (group == 'missing') {
filtered = filtered.filter(li => {
if (li.mediaType === 'book') {
if (filter === 'ASIN' && li.media.metadata.asin === null) return true;
if (filter === 'ISBN' && li.media.metadata.isbn === null) return true;
if (filter === 'Subtitle' && li.media.metadata.subtitle === null) return true;
if (filter === 'Author' && li.media.metadata.authors.length === 0) return true;
if (filter === 'Publish Year' && li.media.metadata.publishedYear === null) return true;
if (filter === 'Series' && li.media.metadata.series.length === 0) return true;
if (filter === 'Description' && li.media.metadata.description === null) return true;
if (filter === 'Genres' && li.media.metadata.genres.length === 0) return true;
if (filter === 'Tags' && li.media.tags.length === 0) return true;
if (filter === 'Narrator' && li.media.metadata.narrators.length === 0) return true;
if (filter === 'Publisher' && li.media.metadata.publisher === null) return true;
if (filter === 'Language' && li.media.metadata.language === null) return true;
if (li.isBook) {
if (filter === 'asin' && !li.media.metadata.asin) return true
if (filter === 'isbn' && !li.media.metadata.isbn) return true
if (filter === 'subtitle' && !li.media.metadata.subtitle) return true
if (filter === 'authors' && !li.media.metadata.authors.length) return true
if (filter === 'publishedYear' && !li.media.metadata.publishedYear) return true
if (filter === 'series' && !li.media.metadata.series.length) return true
if (filter === 'description' && !li.media.metadata.description) return true
if (filter === 'genres' && !li.media.metadata.genres.length) return true
if (filter === 'tags' && !li.media.tags.length) return true
if (filter === 'narrators' && !li.media.metadata.narrators.length) return true
if (filter === 'publisher' && !li.media.metadata.publisher) return true
if (filter === 'language' && !li.media.metadata.language) return true
if (filter === 'cover' && !li.media.coverPath) return true
} else {
return false
}
})
} else if (group === 'languages') {
filtered = filtered.filter(li => li.media.metadata && li.media.metadata.language === filter)
} else if (group === 'tracks') {
if (filter === 'single') filtered = filtered.filter(li => li.isBook && li.media.numTracks === 1)
else if (filter === 'multi') filtered = filtered.filter(li => li.isBook && li.media.numTracks > 1)
}
} else if (filterBy === 'issues') {
filtered = filtered.filter(li => li.hasIssues)