Compare commits

...

68 Commits

Author SHA1 Message Date
advplyr
ea60f19e7a Version bump 2.2.13 2023-01-31 16:35:49 -06:00
advplyr
cefc75a4ed Fix:Edit library modal blur inputs on submit #1427 2023-01-31 16:04:30 -06:00
advplyr
d8753aafb9 Fix:Series collapse series filter out empty sequences #1450 2023-01-31 15:53:04 -06:00
advplyr
ba5ad228cc Merge pull request #1456 from jramer/master
Fixes m4b chapters only taken from first file.
2023-01-30 17:53:48 -06:00
advplyr
0203f9cc1b Update server/objects/mediaTypes/Book.js 2023-01-30 17:50:50 -06:00
advplyr
4770be5a39 Update server/objects/mediaTypes/Book.js 2023-01-30 17:50:45 -06:00
advplyr
1bac395bed Merge pull request #1453 from lkiesow/bookshelf-view
One Default Bookshelf View
2023-01-30 17:29:44 -06:00
advplyr
e818f270cd Merge pull request #1457 from tomazed/LocalizedDateFns
Localized date fns
2023-01-30 08:44:06 -06:00
advplyr
c4e2726622 Update client/plugins/i18n.js 2023-01-30 08:42:56 -06:00
Tomazed
74d8a09f31 Merge branch 'master' into LocalizedDateFns 2023-01-30 15:22:59 +01:00
Tomazed
618338165e Removed unused strings from translations 2023-01-30 15:17:42 +01:00
Tomazed
db494001a2 Import all locales from date-fns/locale 2023-01-30 15:17:04 +01:00
Tomazed
a67213fb7b Simplified languageCodeMap maintainability 2023-01-30 15:09:11 +01:00
Joakim Ramer
5d96b2cc6e Logs correctly and simplifies for single audio file. 2023-01-30 12:56:22 +01:00
Joakim Ramer
72d0b097ab Reverts change of file ending 2023-01-30 12:48:50 +01:00
Joakim Ramer
36d2957fb4 Adds check for duplicated chapter data 2023-01-30 12:46:41 +01:00
Tomazed
b5de517aad Generate days from date-fns locale 2023-01-30 01:49:30 +01:00
Tomazed
41db0e3bb1 Use Date-Fns locale to generate month and day labels 2023-01-30 01:25:35 +01:00
Tomazed
e8d582269b localized date-fns library 2023-01-30 00:52:28 +01:00
Joakim Ramer
80ef8ee890 Fixes m4b chapters only taken from first file. 2023-01-30 00:42:02 +01:00
Lars Kiesow
a65859f575 One Default Bookshelf View
This patch updates the default value of the home page bookshelf view so
that it is identical to the default library view. Having different
styles by default seems odd. I picked the non-wooden view as default
based on the recent discussion on Matrx/Discord that that one looks more
modern.
2023-01-28 23:17:31 +01:00
advplyr
5724887785 Merge pull request #1451 from springsunx/patch-1
Update zh-cn.json
2023-01-28 15:04:21 -06:00
advplyr
8908aa7a82 Fix:Podcast RSS feeds update on new/updated episodes #1435 2023-01-28 14:58:10 -06:00
advplyr
f83dd29213 Update:syncLocalMediaProgress API response payload 2023-01-28 14:46:01 -06:00
SunX
99d90778f4 Update zh-cn.json 2023-01-28 20:36:16 +08:00
advplyr
49279430fc Add:Recommended book home page shelf 2023-01-27 17:59:06 -06:00
advplyr
030c20b12e Merge pull request #1438 from tomazed/LocalizedStatsPage
Localized stats page
2023-01-24 17:30:32 -06:00
Tomazed
5e943ef152 Fix Localized Days in Your Stats page 2023-01-24 19:19:42 +01:00
Tomazed
4ae057f626 Fix Localized Months displaying in heatmap 2023-01-24 18:03:32 +01:00
advplyr
9ebe4b55dd Merge pull request #1431 from lkiesow/x-accel
Implement X-Accel Redirect
2023-01-23 17:27:23 -06:00
advplyr
2f7403adec Merge pull request #1432 from jmt-gh/issue_1410
Align "Series Name" and "Sequence" inputs on Series Editor modal
2023-01-23 17:19:50 -06:00
jmt-gh
2777b496ad change the label to be a label instead of a p 2023-01-22 20:44:39 -08:00
advplyr
f7a3dbf209 Fix:Embed metadata tool embeds cover image 2023-01-22 18:02:57 -06:00
advplyr
d900093976 Fix:Make m4b embed cover image 2023-01-22 18:00:35 -06:00
Lars Kiesow
08250e266e Implement X-Accel Redirect
This patch implements [X-Accel](https://www.nginx.com/resources/wiki/start/topics/examples/x-accel/)
redirect headers as an optional way for offloading static file delivery
from Express to Nginx, which is far better optimized for static file
delivery.

This provides a really easy to configure way for getting a huge
performance boost over delivering all files through Audiobookshelf.

How it works
------------

The way this works is basically that Audiobookshelf gets an HTTP request
for delivering a static file (let's say an audiobook). It will first
check the user is authorized and then convert the API path to a local
file path.

Now, instead of reading and delivering the file, Audiobookshelf will
return just the HTTP header with an additional `X-Accel-Redirect`
pointing to the file location on the file syste.

This header is picked up by Nginx which will then deliver the file.

Configuration
-------------

The configuration for this is very simple. You need to run Nginx as
reverse proxy and it must have access to your Audiobookshelf data
folder.

You then configure Audiobookshelf to use X-Accel by setting
`USE_X_ACCEL=/protected`. The path is the internal redirect path used by
Nginx.

In the Nginx configuration you then configure this location and map it
to the storage area to serve like this:

```
location /protected/ {
  internal;
  alias /;
}
```

That's all.

Impact
------

I just did a very simple performance test, downloading a 1170620819
bytes large audiobook file from another machine on the same network
like this, using `time -p` to measure how log the process took:

```sh
URL='https://url to audiobook…'

for i in `seq 1 50`
do
  echo "$i"
  curl -s -o /dev/null "${URL}"
done
```

This sequential test with 50 iterations and without x-accel resulted in:

```
real 413.42
user 197.11
sys 82.04
```

That is an average download speed of about 1080 MBit/s.

With X-Accel enabled, serving the files through Nginx, the same test
yielded the following results:

```
real 200.37
user 86.95
sys 29.79
```

That is an average download speed of about 2229 MBit/s, more than
doubling the previous speed.

I have also run the same test with 4 parallel processes and 25 downloads
each. Without x-accel, that test resulted in:

```
real 364.89
user 273.09
sys 112.75
```

That is an average speed of about 2448 MBit/s.

With X-Accel enabled, the parallel test also shows a significant
speedup:

```
real 167.19
user 195.62
sys 78.61
```

That is an average speed of about 5342 MBit/s.

While doing that, I also peaked at the system load which was a bit lower
when using X-Accel. Even though the system was delivering far more data.
But I just looked at the `load1` values and did not build a proper test
for that. That means, I cant provide any definitive data.

Supported Media
---------------

The current implementation works for audio files and book covers. There
are other media files which would benefit from this mechanism like feed
covers or author pictures.

But that's something for a future developer ;-)
2023-01-23 00:02:27 +01:00
advplyr
da2d1455d7 Merge pull request #1420 from Bostrolicious/master
Fix HTTP links not working in podcast show notes.
2023-01-22 08:07:55 -06:00
advplyr
b6c6c4c939 Merge pull request #1423 from lkiesow/prod-opts
Ensure default are similar in different environments
2023-01-22 08:05:30 -06:00
advplyr
22179d82b8 Merge branch 'master' into prod-opts 2023-01-22 08:05:25 -06:00
advplyr
343ce312f1 Merge pull request #1422 from tomazed/translation-fr
Update fr.json Months and Days
2023-01-22 08:03:44 -06:00
advplyr
10677d6fb0 Merge pull request #1421 from lkiesow/fingerprinting
Reduce Fingerprinting
2023-01-22 08:03:17 -06:00
advplyr
49a8aead9b Merge pull request #1425 from lkiesow/undefined-uid-gid
Skip AUDIOBOOKSHELF_UID/GID if undefined
2023-01-22 08:02:53 -06:00
Lars Kiesow
274b0e48be Skip AUDIOBOOKSHELF_UID/GID if undefined
This patch slightly changes the behavior of the `AUDIOBOOKSHELF_UID` and
`AUDIOBOOKSHELF_GID` options. Instead of defining a default user and
group, trying to modify files and silently failing if the filesystem
mode cannot be changed, this patch will just skip the entire process in
the first place.

If these options are defined, Audiobookshelf should behave exactly as
before. If they are not defined, Audiobookshelf will now cause fewer
file modifications (or less failures when trying to modify files).

If this patch gets applied, it should probably be highlighted in the
release notes. This usually shouldn't cause problems for migrations
since the Docker guides explicitly configure the options and the
package installations do not seem to use this at all, but there is still
a change that it will and users should be aware of that.

If a problem arises, users can easily fix the problem by either setting
the permissions once manually to the audiobookshelf user or by simply
defining the `AUDIOBOOKSHELF_UID/GID` options.
2023-01-22 12:30:36 +01:00
Lars Kiesow
4d8ffc5d99 Ensure default are similar in different environments
This patch tries to make sure that defaults and options work in the same
way regardless of users using Docker or a package deployment.

- Commit 10295b0 updated `Host` to be empty by default to better support
  IPv6. This fixes a missed occurrence of the old default.
- This makes sure the `SOURCE` environment variable is honored when
  installing the packages. The `--source` option will still take
  precedence though.
2023-01-22 00:53:10 +01:00
Tomazed
4f3029e5b2 Update fr.json Months and Days 2023-01-22 00:39:23 +01:00
Lars Kiesow
a1b49f5fcf Reduce Fingerprinting
As DieselTech#6997 pointed out in Matrix, it is a good idea to reduce
fingerprinting by removing the `X-Powered-By` response header as pointed
out by the Express security best practices:

See http://expressjs.com/en/advanced/best-practice-security.html#reduce-fingerprinting
2023-01-21 23:18:06 +01:00
Martin Boström
89d497a305 Fix mailto links not working in podcast show notes. 2023-01-21 22:46:38 +01:00
Martin Boström
9e095a4bc1 Fix HTTP links not working in podcast show notes. 2023-01-21 21:51:05 +01:00
advplyr
024d052a7b Update:Show total duration of episodes on podcast page 2023-01-19 17:55:40 -06:00
advplyr
c312979aec Update:Heatmap day of week translations 2023-01-18 18:04:03 -06:00
advplyr
773e621944 Merge pull request #1404 from burghy86/patch-8
update it
2023-01-18 17:50:42 -06:00
advplyr
ed4f33b565 Merge pull request #1364 from lkiesow/update-server-libs
Update Server Libraries
2023-01-18 17:36:20 -06:00
advplyr
f8a0852dfc Merge pull request #1403 from k9withabone/fix-banner
Fix banner view box
2023-01-18 17:35:28 -06:00
advplyr
6dec750d3e Fix:Close open playback session on server when local playback session syncing from mobile 2023-01-15 15:00:18 -06:00
burghy86
3c98a5fb24 update
and add a month and day a abbreviated for annual table
2023-01-14 23:37:07 +01:00
advplyr
702ee3d350 Merge pull request #1398 from lkiesow/dont-list-book-twice
Don't list book twice in continue series
2023-01-13 14:02:53 -06:00
advplyr
fcc2f3650b Merge pull request #1400 from springsunx/master
Update zh-cn.json
2023-01-13 13:54:46 -06:00
Paul Nettleton
e4ad622c01 Fix banner view box 2023-01-13 02:51:20 -06:00
SunX
458403eec9 Update zh-cn.json 2023-01-13 10:53:05 +08:00
Lars Kiesow
aaede2752c Don't list book twice in continue series
Sometimes, a book belongs to more than one series. If you listen to and
finish such a book, Audiobookshelf will list the next book in “Continue
Series” twice, right next to each other. That is not helpful.

This patch fixes the problem by not adding books to the list if they are
already in the list.
2023-01-13 00:50:04 +01:00
advplyr
39d8c2cf04 Merge pull request #1385 from Hallo951/master
Update de.json
2023-01-11 04:51:52 -06:00
advplyr
dd5c940d36 Merge pull request #1388 from lkiesow/continue-listening
Show next book only if previous book is finished
2023-01-10 16:39:59 -06:00
advplyr
277f024bbc Merge pull request #1390 from lkiesow/button-no-submit
Toggle switch shouldn't submit form
2023-01-10 16:32:14 -06:00
Lars Kiesow
59ad1e5e36 Toggle switch shouldn't submit form
This patch fixes the problem that toggling one of the options in the
user account dialog will automatically submit the form.

The problem got introduced as a combination of the recent accessibility
fixes where some elements got turned into HTML button elements to make
them keyboard accessible. Doing that, I did not realize that the default
type of a button is `submit` [1]. This causes no problems at most places,
but will cause problem within a form (e.g. the user account settings)
where toggling an option is now identical to clicking submit.

This patch fixes the issue by setting the `type` attribute to `button`.
Not only for the toggle switch, but also for a few other elements which
have been recently converted to buttons.

[1] https://www.w3.org/TR/2011/WD-html5-20110525/the-button-element.html#attr-button-type
2023-01-10 22:58:20 +01:00
Lars Kiesow
02c4b21d3f Show next book only if previous book is finished
This patch changes the books displayed in “Continue Series”, avoiding
books if another book from the series is played back right now. This
prevents Audiobookshelf suggesting books to which users will not listen
to because they are still listening to the last one.

Once a book is finished, the next book in the series will pop still be
suggested to the user.

This fixes  #1382
2023-01-10 21:50:33 +01:00
Hallo951
33ae5445be Update 2023-01-10 10:06:57 +01:00
advplyr
5ed06871b6 Merge pull request #1381 from tomazed/translation-fr
Update fr.json => ButtonEdit
2023-01-09 18:18:41 -06:00
Tomazed
e98eb8f1eb Update fr.json => ButtonEdit 2023-01-09 22:31:24 +01:00
Lars Kiesow
f095d89980 Update Server Libraries
This patch updates the server libraries to their latest state.
2023-01-05 00:46:23 +01:00
47 changed files with 652 additions and 317 deletions

View File

@@ -4,7 +4,7 @@
<h1 class="text-xl">{{ headerText }}</h1>
<div v-if="showAddButton" class="mx-2 w-7 h-7 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center" @click="clicked">
<button class="material-icons" :aria-label="$strings.ButtonAdd + ': ' + headerText" style="font-size: 1.4rem">add</button>
<button type="button" class="material-icons" :aria-label="$strings.ButtonAdd + ': ' + headerText" style="font-size: 1.4rem">add</button>
</div>
</div>

View File

@@ -361,13 +361,15 @@ export default {
}
},
streamProgress(data) {
if (!data.numSegments) return
var chunks = data.chunks
console.log(`[StreamContainer] Stream Progress ${data.percent}`)
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
} else {
console.error('No Audio Ref')
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === data.stream) {
if (!data.numSegments) return
var chunks = data.chunks
console.log(`[StreamContainer] Stream Progress ${data.percent}`)
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
} else {
console.error('No Audio Ref')
}
}
},
sessionOpen(session) {

View File

@@ -820,7 +820,6 @@ export default {
return null
})
if (!libraryItem) return
console.log('Got library itemn', libraryItem)
this.store.commit('showEReader', libraryItem)
},
selectBtnClick(evt) {

View File

@@ -6,7 +6,7 @@
<ui-dropdown v-model="mediaType" :items="mediaTypes" :label="$strings.LabelMediaType" :disabled="!isNew" small @input="changedMediaType" />
</div>
<div class="w-full md:flex-grow px-1 py-1 md:py-0">
<ui-text-input-with-label v-model="name" :label="$strings.LabelLibraryName" @blur="nameBlurred" />
<ui-text-input-with-label ref="nameInput" v-model="name" :label="$strings.LabelLibraryName" @blur="nameBlurred" />
</div>
<div class="w-1/5 md:w-18 px-1 py-1 md:py-0">
<ui-media-icon-picker v-model="icon" :label="$strings.LabelIcon" @input="iconChanged" />
@@ -20,12 +20,12 @@
<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>
<ui-editable-text v-model="folder.fullPath" readonly type="text" class="w-full" />
<ui-editable-text ref="folderInput" v-model="folder.fullPath" readonly type="text" class="w-full" />
<span v-show="folders.length > 1" class="material-icons text-2xl ml-2 cursor-pointer hover:text-error" @click="removeFolder(folder)">close</span>
</div>
<div class="flex py-1 px-2 items-center w-full">
<span class="material-icons bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span>
<ui-editable-text v-model="newFolderPath" :placeholder="$strings.PlaceholderNewFolderPath" type="text" class="w-full" @blur="newFolderInputBlurred" />
<ui-editable-text ref="newFolderInput" v-model="newFolderPath" :placeholder="$strings.PlaceholderNewFolderPath" type="text" class="w-full" @blur="newFolderInputBlurred" />
</div>
<ui-btn class="w-full mt-2" color="primary" @click="browseForFolder">{{ $strings.ButtonBrowseForFolder }}</ui-btn>
@@ -82,6 +82,19 @@ export default {
}
},
methods: {
checkBlurExpressionInput() {
if (this.$refs.nameInput) {
this.$refs.nameInput.blur()
}
if (this.$refs.folderInput && this.$refs.folderInput.length) {
this.$refs.folderInput.forEach((input) => {
if (input.blur) input.blur()
})
}
if (this.$refs.newFolderInput) {
this.$refs.newFolderInput.blur()
}
},
browseForFolder() {
this.showDirectoryPicker = true
},

View File

@@ -144,8 +144,6 @@ export default {
return true
},
submit() {
if (!this.validate()) return
// If custom expression input is focused then unfocus it instead of submitting
if (this.$refs.tabComponent && this.$refs.tabComponent.checkBlurExpressionInput) {
if (this.$refs.tabComponent.checkBlurExpressionInput()) {
@@ -153,6 +151,8 @@ export default {
}
}
if (!this.validate()) return
if (this.library) {
this.submitUpdateLibrary()
} else {

View File

@@ -27,7 +27,7 @@
<div class="absolute -bottom-2 left-0 flex ml-6">
<template v-for="dayObj in last7Days">
<div :key="dayObj.date" :style="{ width: daySpacing + daySpacing / 14 + 'px' }">
<p class="text-sm font-book">{{ dayObj.dayOfWeek.slice(0, 3) }}</p>
<p class="text-sm font-book">{{ dayObj.dayOfWeekAbbr }}</p>
</div>
</template>
</div>
@@ -108,6 +108,7 @@ export default {
var _date = this.$addDaysToToday(i * -1)
days.push({
dayOfWeek: this.$formatJsDate(_date, 'EEEE'),
dayOfWeekAbbr: this.$formatJsDate(_date, 'EEE'),
date: this.$formatJsDate(_date, 'yyyy-MM-dd')
})
}
@@ -218,4 +219,4 @@ export default {
},
mounted() {}
}
</script>
</script>

View File

@@ -68,7 +68,7 @@ export default {
dayLabels() {
return [
{
label: 'Mon',
label: this.$formatJsDate(new Date(2023, 0, 2), 'EEE'),
style: {
transform: `translate(${-25}px, ${13}px)`,
lineHeight: '10px',
@@ -76,7 +76,7 @@ export default {
}
},
{
label: 'Wed',
label: this.$formatJsDate(new Date(2023, 0, 4), 'EEE'),
style: {
transform: `translate(${-25}px, ${13 * 3}px)`,
lineHeight: '10px',
@@ -84,7 +84,7 @@ export default {
}
},
{
label: 'Fri',
label: this.$formatJsDate(new Date(2023, 0, 6), 'EEE'),
style: {
transform: `translate(${-25}px, ${13 * 5}px)`,
lineHeight: '10px',
@@ -270,4 +270,4 @@ export default {
},
beforeDestroy() {}
}
</script>
</script>

View File

@@ -38,10 +38,10 @@
<div class="w-full flex justify-left">
<!-- Dont show edit for non-root users -->
<div v-if="user.type !== 'root' || userIsRoot" class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-opacity-100 cursor-pointer" @click.stop="editUser(user)">
<button :aria-label="$getString('ButtonUserEdit', [user.username])" class="material-icons text-base">edit</button>
<button type="button" :aria-label="$getString('ButtonUserEdit', [user.username])" class="material-icons text-base">edit</button>
</div>
<div v-show="user.type !== 'root'" class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-error cursor-pointer" @click.stop="deleteUserClick(user)">
<button :aria-label="$getString('ButtonUserDelete', [user.username])" class="material-icons text-base">delete</button>
<button type="button" :aria-label="$getString('ButtonUserDelete', [user.username])" class="material-icons text-base">delete</button>
</div>
</div>
</td>

View File

@@ -1,6 +1,6 @@
<template>
<div class="w-full">
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
<label class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</label>
<div ref="wrapper" class="relative">
<form @submit.prevent="submitForm">
<div ref="inputWrapper" class="input-wrapper flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-2" :class="disabled ? 'pointer-events-none bg-black-300 text-gray-400' : 'bg-primary'">

View File

@@ -1,6 +1,6 @@
<template>
<div>
<button :aria-labelledby="labeledBy" role="checkbox" class="border rounded-full border-black-100 flex items-center cursor-pointer w-10 justify-start" :aria-checked="toggleValue" :class="className" @click="clickToggle">
<button :aria-labelledby="labeledBy" role="checkbox" type="button" class="border rounded-full border-black-100 flex items-center cursor-pointer w-10 justify-start" :aria-checked="toggleValue" :class="className" @click="clickToggle">
<span class="rounded-full border w-5 h-5 border-black-50 shadow transform transition-transform duration-100" :class="switchClassName"></span>
</button>
</div>

View File

@@ -137,31 +137,31 @@ export default {
weekdays() {
return [
{
text: this.$strings.WeekdaySunday,
text: this.$formatJsDate(new Date(2023, 0, 1), 'EEEE'),
value: 0
},
{
text: this.$strings.WeekdayMonday,
text: this.$formatJsDate(new Date(2023, 0, 2), 'EEEE'),
value: 1
},
{
text: this.$strings.WeekdayTuesday,
text: this.$formatJsDate(new Date(2023, 0, 3), 'EEEE'),
value: 2
},
{
text: this.$strings.WeekdayWednesday,
text: this.$formatJsDate(new Date(2023, 0, 4), 'EEEE'),
value: 3
},
{
text: this.$strings.WeekdayThursday,
text: this.$formatJsDate(new Date(2023, 0, 5), 'EEEE'),
value: 4
},
{
text: this.$strings.WeekdayFriday,
text: this.$formatJsDate(new Date(2023, 0, 6), 'EEEE'),
value: 5
},
{
text: this.$strings.WeekdaySaturday,
text: this.$formatJsDate(new Date(2023, 0, 7), 'EEEE'),
value: 6
}
]

View File

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

View File

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

View File

@@ -105,7 +105,7 @@
</template>
</div>
</div>
<div v-if="tracks.length || audioFile" class="flex py-0.5">
<div v-if="tracks.length || audioFile || (isPodcast && totalPodcastDuration)" class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelDuration }}</span>
</div>
@@ -415,6 +415,8 @@ export default {
})
},
durationPretty() {
if (this.isPodcast) return this.$elapsedPrettyExtended(this.totalPodcastDuration)
if (!this.tracks.length && !this.audioFile) return 'N/A'
if (this.audioFile) return this.$elapsedPrettyExtended(this.duration)
return this.$elapsedPretty(this.duration)
@@ -423,6 +425,12 @@ export default {
if (!this.tracks.length && !this.audioFile) return 0
return this.media.duration
},
totalPodcastDuration() {
if (!this.podcastEpisodes.length) return 0
let totalDuration = 0
this.podcastEpisodes.forEach((ep) => (totalDuration += ep.duration || 0))
return totalDuration
},
sizePretty() {
return this.$bytesPretty(this.media.size)
},

View File

@@ -5,18 +5,18 @@ import { supplant } from './utils'
const defaultCode = 'en-us'
const languageCodeMap = {
'de': 'Deutsch',
'en-us': 'English',
// 'es': 'Español',
'fr': 'Français',
'hr': 'Hrvatski',
'it': 'Italiano',
'pl': 'Polski',
'zh-cn': '简体中文 (Simplified Chinese)'
'de': { label: 'Deutsch', dateFnsLocale: 'de' },
'en-us': { label: 'English', dateFnsLocale: 'enUS' },
// 'es': { label: 'Español', dateFnsLocale: 'es' },
'fr': { label: 'Français', dateFnsLocale: 'fr' },
'hr': { label: 'Hrvatski', dateFnsLocale: 'hr' },
'it': { label: 'Italiano', dateFnsLocale: 'it' },
'pl': { label: 'Polski', dateFnsLocale: 'pl' },
'zh-cn': { label: '简体中文 (Simplified Chinese)', dateFnsLocale: 'zhCN' },
}
Vue.prototype.$languageCodeOptions = Object.keys(languageCodeMap).map(code => {
return {
text: languageCodeMap[code],
text: languageCodeMap[code].label,
value: code
}
})
@@ -73,6 +73,8 @@ async function loadi18n(code) {
for (const key in Vue.prototype.$strings) {
Vue.prototype.$strings[key] = strings[key] || translations[defaultCode][key]
}
console.log(`dateFnsLocale = ${languageCodeMap[code].dateFnsLocale}`)
Vue.prototype.$setDateFnsLocale(languageCodeMap[code].dateFnsLocale)
console.log('i18n strings=', Vue.prototype.$strings)
Vue.prototype.$eventBus.$emit('change-lang', code)

View File

@@ -1,10 +1,16 @@
import Vue from 'vue'
import Path from 'path'
import vClickOutside from 'v-click-outside'
import { formatDistance, format, addDays, isDate } from 'date-fns'
import { formatDistance, format, addDays, isDate, setDefaultOptions } from 'date-fns'
import * as locale from 'date-fns/locale'
Vue.directive('click-outside', vClickOutside.directive)
Vue.prototype.$setDateFnsLocale = (localeString) => {
if (!locale[localeString]) return 0
return setDefaultOptions({ locale: locale[localeString] })
}
Vue.prototype.$dateDistanceFromNow = (unixms) => {
if (!unixms) return ''
return formatDistance(unixms, Date.now(), { addSuffix: true })

View File

@@ -20,10 +20,10 @@
"ButtonCreate": "Ertsellen",
"ButtonCreateBackup": "Sicherung erstellen",
"ButtonDelete": "Löschen",
"ButtonEdit": "Edit",
"ButtonEdit": "Bearbeiten",
"ButtonEditChapters": "Kapitel bearbeiten",
"ButtonEditPodcast": "Podcast bearbeiten",
"ButtonForceReScan": "Erzwinge kompletten Neu-Scan",
"ButtonForceReScan": "Komplett-Scan (alle Medien)",
"ButtonFullPath": "Vollständiger Pfad",
"ButtonHide": "Ausblenden",
"ButtonHome": "Startseite",
@@ -34,8 +34,8 @@
"ButtonLookup": "Online-Suche",
"ButtonManageTracks": "Tracks verwalten",
"ButtonMapChapterTitles": "Kapitelüberschriften zuordnen",
"ButtonMatchAllAuthors": "Online-Suche für alle Autoren",
"ButtonMatchBooks": "Online-Suche für alle Hörbücher",
"ButtonMatchAllAuthors": "Online Metadaten-Abgleich (alle Autoren)",
"ButtonMatchBooks": "Online Metadaten-Abgleich (alle Medien)",
"ButtonNevermind": "Vergiss es",
"ButtonOk": "Ok",
"ButtonOpenFeed": "Feed öffnen",
@@ -61,7 +61,7 @@
"ButtonSave": "Speichern",
"ButtonSaveAndClose": "Speichern & Schließen",
"ButtonSaveTracklist": "Speichere die Titelliste",
"ButtonScan": "Scan",
"ButtonScan": "Partial-Scan (nur geänderte/neue Medien)",
"ButtonScanLibrary": "Bibliothek scannen",
"ButtonSearch": "Suchen",
"ButtonSelectFolderPath": "Auswahl Ordnerpfad",
@@ -77,7 +77,7 @@
"ButtonUploadCover": "Titelbild hochladen",
"ButtonUploadOPMLFile": "OPML-Datei hochladen",
"ButtonUserDelete": "Benutzer {0} löschen",
"ButtonUserEdit": "Benutzer {0} editieren",
"ButtonUserEdit": "Benutzer {0} bearbeiten",
"ButtonViewAll": "Alles anzeigen",
"ButtonYes": "Ja",
"HeaderAccount": "Konto",
@@ -111,7 +111,7 @@
"HeaderManageGenres": "Kategorien verwalten",
"HeaderManageTags": "Tags verwalten",
"HeaderMapDetails": "Stapelverarbeitung",
"HeaderMatch": "Online-Suche",
"HeaderMatch": "Metadaten",
"HeaderMetadataToEmbed": "Einzubettende Metadaten",
"HeaderNewAccount": "Neues Konto",
"HeaderNewLibrary": "Neue Bibliothek",
@@ -308,6 +308,7 @@
"LabelPublishYear": "Jahr",
"LabelRecentlyAdded": "Kürzlich hinzugefügt",
"LabelRecentSeries": "Aktuelle Serien",
"LabelRecommended": "Recommended",
"LabelRegion": "Region",
"LabelReleaseDate": "Veröffentlichungsdatum",
"LabelRemoveCover": "Lösche Titelbild",
@@ -333,23 +334,23 @@
"LabelSettingsExperimentalFeatures": "Experimentelle Funktionen",
"LabelSettingsExperimentalFeaturesHelp": "Funktionen welche sich in der Entwicklung befinden, benötigen Ihr Feedback und Ihre Hilfe beim Testen. Klicken Sie hier, um die Github-Diskussion zu öffnen.",
"LabelSettingsFindCovers": "Suche Titelbilder",
"LabelSettingsFindCoversHelp": "Wenn Ihr Hörbuch kein eingebettetes Titelbild oder kein Titelbild im Ordner hat, versucht der Scanner, ein Titelbild online zu finden.<br>Hinweis: Dies verlängert die Scandauer",
"LabelSettingsFindCoversHelp": "Wenn Ihr Medium kein eingebettetes Titelbild oder kein Titelbild im Ordner hat, versucht der Scanner, ein Titelbild online zu finden.<br>Hinweis: Dies verlängert die Scandauer",
"LabelSettingsHomePageBookshelfView": "Starseite verwendet die Bücherregalansicht",
"LabelSettingsLibraryBookshelfView": "Bibliothek verwendet die Bücherregalansicht",
"LabelSettingsOverdriveMediaMarkers": "Verwende Overdrive Media Marker für Kapitel",
"LabelSettingsOverdriveMediaMarkersHelp": "MP3-Dateien von Overdrive werden mit eingebetteten Kapitel-Timings als benutzerdefinierte Metadaten geliefert. Wenn Sie dies aktivieren, werden diese Markierungen automatisch für die Kapiteltaktung verwendet",
"LabelSettingsParseSubtitles": "Analysiere Untertitel",
"LabelSettingsParseSubtitlesHelp": "Extrahiere den Untertitel von Hörbuchordnernamen.<br>Untertitel müssen vom eigentlichem Titel durch ein \" - \" getrennt sein. <br>Beispiel: \"Titel - Untertitel\"",
"LabelSettingsParseSubtitlesHelp": "Extrahiere den Untertitel von Medium-Ordnernamen.<br>Untertitel müssen vom eigentlichem Titel durch ein \" - \" getrennt sein. <br>Beispiel: \"Titel - Untertitel\"",
"LabelSettingsPreferAudioMetadata": "Bevorzuge lokale ID3-Audiometadaten",
"LabelSettingsPreferAudioMetadataHelp": "In den Audiodateien eingebettete ID3 Metadaten werden für die Metadaten eines Hörbuchs anstelle der Ordnernamen verwendet. Wenn keine ID3 Metadaten zur Verfügung stehen, werden die Ordnernamen verwendet.",
"LabelSettingsPreferAudioMetadataHelp": "In den Audiodateien eingebettete ID3 Tags werden anstelle der Ordnernamen für die Bereitstellung der Metadaten verwendet. Wenn keine ID3 Tags 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 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.",
"LabelSettingsPreferMatchedMetadataHelp": "Bei einem Schnellabgleich überschreiben online neu abgestimmte Metadaten alle schon vorhandenen Metadaten eines Mediums. 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 Bereitstellung der Metadaten verwendet. OPF-Datein sind seperate \"Textdateien\" mit der Endung \".abs\" welche in dem gleichen Ordner liegen wie das Medium selber. In dieser sind verschiedene Metadaten (z.B. Titel, Autor, Jahr, Erzähler, Handlung, ISBN, ...) gespeichert. Wenn keine OPF Datei zur Verfügung steht, wird 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",
"LabelSettingsSortingIgnorePrefixesHelp": "Beispiel: für den Artikel \"der\" würde der Hörbuchtitel \"Der Buchtitel\" als \"Buchtitel, Der\" sortiert werden.",
"LabelSettingsSortingIgnorePrefixesHelp": "Beispiel: für den Artikel \"der\" würde der Mediumtitel \"Der Buchtitel\" als \"Buchtitel, Der\" sortiert werden.",
"LabelSettingsSquareBookCovers": "Benutze quadratische Titelbilder",
"LabelSettingsSquareBookCoversHelp": "Bevorzugen quadratische Titelbilder gegenüber den Standardtielbildern im Verhältnis 1,6:1",
"LabelSettingsStoreCoversWithItem": "Titelbilder im Medienordner speichern",
@@ -390,10 +391,10 @@
"LabelTitle": "Titel",
"LabelToolsEmbedMetadata": "Metadaten einbetten",
"LabelToolsEmbedMetadataDescription": "Bettet die Metadaten einschließlich des Titelbildes und der Kapitel in die Audiodatein ein.",
"LabelToolsMakeM4b": "M4B-Hörbuchdatei erstellen",
"LabelToolsMakeM4bDescription": "Erstellt eine M4B-Hörbuchdatei mit eingebetteten Metadaten, Titelbild und Kapiteln.",
"LabelToolsMakeM4b": "M4B-Datei erstellen",
"LabelToolsMakeM4bDescription": "Erstellt eine M4B-Datei (Endung \".m4b\") welche mehrere mp3-Dateien in einer einzigen Datei inkl. derer Metadaten (Beschreibung, Titelbild, Kapitel, ....) zusammenfasst. M4B-Datei können darüber hinaus Lesezeichen speichern und mit einem Abspielschutz (Passwort) versehen werden.",
"LabelToolsSplitM4b": "M4B in MP3's aufteilen",
"LabelToolsSplitM4bDescription": "Erstellt aus einer mit Metadaten und nach Kapiteln aufgeteilten M4B-Hörbuchdastei seperate MP3's mit eingebetteten Metadaten, Coverbild und Kapiteln.",
"LabelToolsSplitM4bDescription": "Erstellt aus einer mit Metadaten und nach Kapiteln aufgeteilten M4B-Datei seperate MP3's mit eingebetteten Metadaten, Coverbild und Kapiteln.",
"LabelTotalDuration": "Gesamtdauer",
"LabelTotalTimeListened": "Gehörte Gesamtzeit",
"LabelTrackFromFilename": "Titel von Dateiname",
@@ -421,30 +422,30 @@
"LabelViewQueue": "Spieler-Warteschlange anzeigen",
"LabelVolume": "Volume",
"LabelWeekdaysToRun": "Wochentage für die Ausführung",
"LabelYourAudiobookDuration": "Laufzeit Ihres Hörbuchs",
"LabelYourAudiobookDuration": "Laufzeit Ihres Mediums",
"LabelYourBookmarks": "Lesezeichen",
"LabelYourPlaylists": "Eigene Wiedergabelisten",
"LabelYourProgress": "Fortschritt",
"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 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.",
"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 (Medien-Ordnern) 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",
"MessageChapterEndIsAfter": "Ungültige Kapitelendzeit: Kapitelende > Mediumende (Kapitelende liegt nach dem Ende des Mediums)",
"MessageChapterErrorFirstNotZero": "Ungültige Kapitelstartzeit: Das erste Kapitel muss bei 0 beginnen",
"MessageChapterErrorStartGteDuration": "Ungültige Kapitelstartzeit: Kapitelanfang > Mediumlänge (Kapitelanfang liegt zeitlich nach dem Ende des Mediums -> Lösung: Kapitelanfang < Mediumlänge)",
"MessageChapterErrorStartLtPrev": "Ungültige Kapitelstartzeit: Kapitelanfang < Kapitelanfang vorheriges Kapitel (Kapitelanfang liegt zeitlich vor dem Beginn des vorherigen Kapitels -> Lösung: Kapitelanfang >= Startzeit des vorherigen Kapitels)",
"MessageChapterStartIsAfter": "Ungültige Kapitelstartzeit: Kapitelanfang > Mediumende (Kapitelanfang liegt nach dem Ende des Mediums)",
"MessageCheckingCron": "Überprüfe cron...",
"MessageConfirmDeleteBackup": "Sind Sie sicher, dass Sie die Sicherung für {0} löschen wollen?",
"MessageConfirmDeleteLibrary": "Sind Sie sicher, dass Sie die Bibliothek \"{0}\" dauerhaft löschen wollen?",
"MessageConfirmDeleteSession": "Sind Sie sicher, dass Sie diese Sitzung löschen möchten?",
"MessageConfirmForceReScan": "Sind Sie sicher, dass Sie einen erneuten Scanvorgang erzwingen wollen?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
"MessageConfirmMarkSeriesFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als abgeschlossen markieren wollen?",
"MessageConfirmMarkSeriesNotFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als nicht abgeschlossen markieren wollen?",
"MessageConfirmRemoveCollection": "Sind Sie sicher, dass Sie die Sammlung \"{0}\" löschen wollen?",
"MessageConfirmRemoveEpisode": "Sind Sie sicher, dass Sie die Episode \"{0}\" löschen möchten?",
"MessageConfirmRemoveEpisodes": "Sind Sie sicher, dass Sie {0} Episoden löschen wollen?",
@@ -472,7 +473,7 @@
"MessageLoadingFolders": "Lade Ordner...",
"MessageM4BFailed": "M4B fehlgeschlagen!",
"MessageM4BFinished": "M4B beendet!",
"MessageMapChapterTitles": "Zuordnen von Kapiteltiteln zu Ihren vorhandenen Hörbuchkapiteln ohne Anpassung der Zeitangaben",
"MessageMapChapterTitles": "Zuordnen von Kapiteltiteln zu Ihren vorhandenen Medienkapiteln ohne Anpassung der Zeitangaben",
"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.",
@@ -530,16 +531,16 @@
"MessageValidCronExpression": "Gültiger cron-ausdruck",
"MessageWatcherIsDisabledGlobally": "Überwachung ist in den Servereinstellungen global deaktiviert",
"MessageXLibraryIsEmpty": "{0} Bibliothek ist leer!",
"MessageYourAudiobookDurationIsLonger": "Die Dauer Ihres Hörbuchs ist länger als die gefundene Dauer",
"MessageYourAudiobookDurationIsShorter": "Die Dauer Ihres Hörbuchs ist kürzer als die gefundene Dauer",
"MessageYourAudiobookDurationIsLonger": "Die Dauer Ihres Mediums ist länger als die gefundene Dauer",
"MessageYourAudiobookDurationIsShorter": "Die Dauer Ihres Mediums ist kürzer als die gefundene Dauer",
"NoteChangeRootPassword": "Der Root-Benutzer (Hauptbenutzer) ist der einzige Benutzer, der ein leeres Passwort haben kann",
"NoteChapterEditorTimes": "Hinweis: Die Anfangszeit des ersten Kapitels muss bei 0:00 beginnen und die Anfangszeit des letzten Kapitels darf die Dauer des Hörbuchs nicht überschreiten.",
"NoteChapterEditorTimes": "Hinweis: Die Anfangszeit des ersten Kapitels muss bei 0:00 beginnen und die Anfangszeit des letzten Kapitels darf die Dauer des Mediums nicht überschreiten.",
"NoteFolderPicker": "Hinweis: Bereits zugeordnete Ordner werden nicht angezeigt.",
"NoteFolderPickerDebian": "Hinweis: Der Ordnerauswahldialog für die Debian-Installation ist nicht vollständig implementiert. Sie sollten den Pfad zu Ihrer Bibliothek direkt eingeben.",
"NoteRSSFeedPodcastAppsHttps": "Warnung: Die meisten Podcast-Apps verlangen, dass die URL des RSS-Feeds HTTPS verwendet.",
"NoteRSSFeedPodcastAppsPubDate": "Warnung: 1 oder mehrere Ihrer Episoden haben kein Veröffentlichungsdatum. Einige Podcast-Apps verlangen dies.",
"NoteUploaderFoldersWithMediaFiles": "Ordner mit Mediendateien werden als separate Bibliothekselemente behandelt.",
"NoteUploaderOnlyAudioFiles": "Wenn Sie nur Audiodateien hochladen, wird jede Audiodatei als ein separates Hörbuch behandelt.",
"NoteUploaderOnlyAudioFiles": "Wenn Sie nur Audiodateien hochladen, wird jede Audiodatei als ein separates Medium behandelt.",
"NoteUploaderUnsupportedFiles": "Nicht unterstützte Dateien werden ignoriert. Bei der Auswahl oder dem Löschen eines Ordners werden andere Dateien, die sich nicht in einem Elementordner befinden, ignoriert.",
"PlaceholderNewCollection": "Neuer Sammlungsname",
"PlaceholderNewFolderPath": "Neuer Ordnerpfad",
@@ -605,20 +606,13 @@
"ToastRemoveItemFromCollectionSuccess": "Medium aus der Sammlung gelöscht",
"ToastRSSFeedCloseFailed": "RSS-Feed konnte nicht geschlossen werden",
"ToastRSSFeedCloseSuccess": "RSS-Feed geschlossen",
"ToastSeriesUpdateFailed": "Series update failed",
"ToastSeriesUpdateSuccess": "Series update success",
"ToastSeriesUpdateFailed": "Aktualisierung der Serien fehlgeschlagen",
"ToastSeriesUpdateSuccess": "Serien aktualisiert",
"ToastSessionDeleteFailed": "Sitzung konnte nicht gelöscht werden",
"ToastSessionDeleteSuccess": "Sitzung gelöscht",
"ToastSocketConnected": "Verbindung zum WebSocket hergestellt",
"ToastSocketDisconnected": "Verbindung zum WebSocket verloren",
"ToastSocketFailedToConnect": "Verbindung zum WebSocket fehlgeschlagen",
"ToastUserDeleteFailed": "Benutzer konnte nicht gelöscht werden",
"ToastUserDeleteSuccess": "Benutzer gelöscht",
"WeekdayFriday": "Freitag",
"WeekdayMonday": "Montag",
"WeekdaySaturday": "Samstag",
"WeekdaySunday": "Sonntag",
"WeekdayThursday": "Donnerstag",
"WeekdayTuesday": "Dienstag",
"WeekdayWednesday": "Mittwoch"
"ToastUserDeleteSuccess": "Benutzer gelöscht"
}

View File

@@ -308,6 +308,7 @@
"LabelPublishYear": "Publish Year",
"LabelRecentlyAdded": "Recently Added",
"LabelRecentSeries": "Recent Series",
"LabelRecommended": "Recommended",
"LabelRegion": "Region",
"LabelReleaseDate": "Release Date",
"LabelRemoveCover": "Remove cover",
@@ -613,12 +614,5 @@
"ToastSocketDisconnected": "Socket disconnected",
"ToastSocketFailedToConnect": "Socket failed to connect",
"ToastUserDeleteFailed": "Failed to delete user",
"ToastUserDeleteSuccess": "User deleted",
"WeekdayFriday": "Friday",
"WeekdayMonday": "Monday",
"WeekdaySaturday": "Saturday",
"WeekdaySunday": "Sunday",
"WeekdayThursday": "Thursday",
"WeekdayTuesday": "Tuesday",
"WeekdayWednesday": "Wednesday"
"ToastUserDeleteSuccess": "User deleted"
}

View File

@@ -308,6 +308,7 @@
"LabelPublishYear": "Publish Year",
"LabelRecentlyAdded": "Recently Added",
"LabelRecentSeries": "Recent Series",
"LabelRecommended": "Recommended",
"LabelRegion": "Region",
"LabelReleaseDate": "Release Date",
"LabelRemoveCover": "Remove cover",
@@ -613,12 +614,5 @@
"ToastSocketDisconnected": "Socket disconnected",
"ToastSocketFailedToConnect": "Socket failed to connect",
"ToastUserDeleteFailed": "Failed to delete user",
"ToastUserDeleteSuccess": "User deleted",
"WeekdayFriday": "Friday",
"WeekdayMonday": "Monday",
"WeekdaySaturday": "Saturday",
"WeekdaySunday": "Sunday",
"WeekdayThursday": "Thursday",
"WeekdayTuesday": "Tuesday",
"WeekdayWednesday": "Wednesday"
"ToastUserDeleteSuccess": "User deleted"
}

View File

@@ -20,7 +20,7 @@
"ButtonCreate": "Créer",
"ButtonCreateBackup": "Créer une Sauvegarde",
"ButtonDelete": "Effacer",
"ButtonEdit": "Edit",
"ButtonEdit": "Editer",
"ButtonEditChapters": "Editer Chapitre",
"ButtonEditPodcast": "Editer Podcast",
"ButtonForceReScan": "Forcer un Re-Scan",
@@ -308,6 +308,7 @@
"LabelPublishYear": "Année d'Edition",
"LabelRecentlyAdded": "Derniers Ajouts",
"LabelRecentSeries": "Séries Récentes",
"LabelRecommended": "Recommended",
"LabelRegion": "Région",
"LabelReleaseDate": "Date de Parution",
"LabelRemoveCover": "Supprimer la Couverture",
@@ -613,12 +614,5 @@
"ToastSocketDisconnected": "WebSocket déconnecté",
"ToastSocketFailedToConnect": "Échec de la connexion WebSocket",
"ToastUserDeleteFailed": "Échec de la suppression de l'utilisateur",
"ToastUserDeleteSuccess": "Utilisateur supprimé",
"WeekdayFriday": "Vendredi",
"WeekdayMonday": "Lundi",
"WeekdaySaturday": "Samedi",
"WeekdaySunday": "Dimanche",
"WeekdayThursday": "Jeudi",
"WeekdayTuesday": "Mardi",
"WeekdayWednesday": "Mercredi"
"ToastUserDeleteSuccess": "Utilisateur supprimé"
}

View File

@@ -308,6 +308,7 @@
"LabelPublishYear": "Godina izdavanja",
"LabelRecentlyAdded": "Nedavno dodano",
"LabelRecentSeries": "Nedavne serije",
"LabelRecommended": "Recommended",
"LabelRegion": "Regija",
"LabelReleaseDate": "Datum izlaska",
"LabelRemoveCover": "Remove cover",
@@ -613,12 +614,5 @@
"ToastSocketDisconnected": "Socket disconnected",
"ToastSocketFailedToConnect": "Socket failed to connect",
"ToastUserDeleteFailed": "Neuspješno brisanje korisnika",
"ToastUserDeleteSuccess": "Korisnik obrisan",
"WeekdayFriday": "Petak",
"WeekdayMonday": "Ponedjeljak",
"WeekdaySaturday": "Subota",
"WeekdaySunday": "Nedjelja",
"WeekdayThursday": "Četvrtak",
"WeekdayTuesday": "Utorak",
"WeekdayWednesday": "Srijeda"
"ToastUserDeleteSuccess": "Korisnik obrisan"
}

View File

@@ -76,8 +76,8 @@
"ButtonUploadBackup": "Carica Backup",
"ButtonUploadCover": "Carica Cover",
"ButtonUploadOPMLFile": "Carica File OPML",
"ButtonUserDelete": "Delete user {0}",
"ButtonUserEdit": "Edit user {0}",
"ButtonUserDelete": "Cancella Utente {0}",
"ButtonUserEdit": "Modifica Utente {0}",
"ButtonViewAll": "Mostra Tutto",
"ButtonYes": "Si",
"HeaderAccount": "Account",
@@ -244,7 +244,7 @@
"LabelLastSeen": "Ultimi Visti",
"LabelLastTime": "Ultima Volta",
"LabelLastUpdate": "Ultimo Aggiornamento",
"LabelLess": "Meno",
"LabelLess": "Poco",
"LabelLibrariesAccessibleToUser": "Librerie Accessibili agli Utenti",
"LabelLibrary": "Libreria",
"LabelLibraryItem": "Elementi della Library",
@@ -262,7 +262,7 @@
"LabelMinute": "Minuto",
"LabelMissing": "Altro",
"LabelMissingParts": "Parti rimantenti",
"LabelMore": "Espandi",
"LabelMore": "Molto",
"LabelName": "Nome",
"LabelNarrator": "Narratore",
"LabelNarrators": "Narratori",
@@ -308,6 +308,7 @@
"LabelPublishYear": "Anno Pubblicazione",
"LabelRecentlyAdded": "Aggiunti Recentemente",
"LabelRecentSeries": "Serie Recenti",
"LabelRecommended": "Recommended",
"LabelRegion": "Regione",
"LabelReleaseDate": "Data Release",
"LabelRemoveCover": "Remove cover",
@@ -430,7 +431,7 @@
"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}\"",
"MessageBookshelfNoResultsForFilter": "Nessun risultato per il filtro \"{0}: {1}\"",
"MessageBookshelfNoRSSFeeds": "Nessun RSS feeds aperto",
"MessageBookshelfNoSeries": "Non c'è nessuna Serie",
"MessageChapterEndIsAfter": "La fine del capitolo è dopo la fine del tuo audiolibro",
@@ -443,8 +444,8 @@
"MessageConfirmDeleteLibrary": "Sei sicuro di voler eliminare definitivamente la libreria \"{0}\"?",
"MessageConfirmDeleteSession": "Sei sicuro di voler eliminare questa sessione?",
"MessageConfirmForceReScan": "Sei sicuro di voler forzare una nuova scansione?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
"MessageConfirmMarkSeriesFinished": "Sei sicuro di voler contrassegnare tutti i libri di questa serie come completati?",
"MessageConfirmMarkSeriesNotFinished": "Sei sicuro di voler contrassegnare tutti i libri di questa serie come non completati?",
"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?",
@@ -453,7 +454,7 @@
"MessageConfirmRenameGenreMergeNote": "Note: Questo genere esiste già quindi verra unito.",
"MessageConfirmRenameGenreWarning": "Avvertimento! Esiste già un genere simile con un nome simile \"{0}\".",
"MessageConfirmRenameTag": "Sei sicuro di voler rinominare il tag \"{0}\" in \"{1}\" per tutti gli oggetti?",
"MessageConfirmRenameTagMergeNote": "Note: Questo tag esiste già e verrà unito nel vecchio.",
"MessageConfirmRenameTagMergeNote": "Nota: Questo tag esiste già e verrà unito nel vecchio.",
"MessageConfirmRenameTagWarning": "Avvertimento! Esiste già un tag simile con un nome simile \"{0}\".",
"MessageDownloadingEpisode": "Download episodio in corso",
"MessageDragFilesIntoTrackOrder": "Trascina i file nell'ordine di traccia corretto",
@@ -600,25 +601,18 @@
"ToastPlaylistUpdateFailed": "Aggiornamento Playlist Fallita",
"ToastPlaylistUpdateSuccess": "Playlist Aggiornata",
"ToastPodcastCreateFailed": "Errore Creazione podcast",
"ToastPodcastCreateSuccess": "Podcast creato Correttamwnte",
"ToastPodcastCreateSuccess": "Podcast creato Correttamente",
"ToastRemoveItemFromCollectionFailed": "Errore rimozione file dalla Raccolta",
"ToastRemoveItemFromCollectionSuccess": "Oggetto rimosso dalla Raccolta",
"ToastRSSFeedCloseFailed": "Errore chiusura RSS feed",
"ToastRSSFeedCloseSuccess": "RSS feed chiuso",
"ToastSeriesUpdateFailed": "Series update failed",
"ToastSeriesUpdateSuccess": "Series update success",
"ToastSeriesUpdateFailed": "Aggiornaemnto Serie Fallito",
"ToastSeriesUpdateSuccess": "Serie Aggornate",
"ToastSessionDeleteFailed": "Errore eliminazione sessione",
"ToastSessionDeleteSuccess": "Sessione cancellata",
"ToastSocketConnected": "Socket connesso",
"ToastSocketDisconnected": "Socket disconnesso",
"ToastSocketFailedToConnect": "Socket non riesce a connettersi",
"ToastUserDeleteFailed": "Errore eliminazione utente",
"ToastUserDeleteSuccess": "Utente eliminato",
"WeekdayFriday": "Venerdì",
"WeekdayMonday": "Lunedì",
"WeekdaySaturday": "Sabato",
"WeekdaySunday": "Domenica",
"WeekdayThursday": "Giovedi",
"WeekdayTuesday": "Martedì",
"WeekdayWednesday": "Mercoledì"
"ToastUserDeleteSuccess": "Utente eliminato"
}

View File

@@ -308,6 +308,7 @@
"LabelPublishYear": "Rok publikacji",
"LabelRecentlyAdded": "Niedawno dodany",
"LabelRecentSeries": "Ostatnie serie",
"LabelRecommended": "Recommended",
"LabelRegion": "Region",
"LabelReleaseDate": "Data wydania",
"LabelRemoveCover": "Remove cover",
@@ -613,12 +614,5 @@
"ToastSocketDisconnected": "Połączenie z serwerem zostało zamknięte",
"ToastSocketFailedToConnect": "Poączenie z serwerem nie powiodło się",
"ToastUserDeleteFailed": "Nie udało się usunąć użytkownika",
"ToastUserDeleteSuccess": "Użytkownik usunięty",
"WeekdayFriday": "Piątek",
"WeekdayMonday": "Poniedziałek",
"WeekdaySaturday": "Sobota",
"WeekdaySunday": "Niedziela",
"WeekdayThursday": "Czwartek",
"WeekdayTuesday": "Wtorek",
"WeekdayWednesday": "Środa"
"ToastUserDeleteSuccess": "Użytkownik usunięty"
}

View File

@@ -20,7 +20,7 @@
"ButtonCreate": "创建",
"ButtonCreateBackup": "创建备份",
"ButtonDelete": "删除",
"ButtonEdit": "Edit",
"ButtonEdit": "编辑",
"ButtonEditChapters": "编辑章节",
"ButtonEditPodcast": "编辑播客",
"ButtonForceReScan": "强制重新扫描",
@@ -76,8 +76,8 @@
"ButtonUploadBackup": "上传备份",
"ButtonUploadCover": "上传封面",
"ButtonUploadOPMLFile": "上传 OPML 文件",
"ButtonUserDelete": "Delete user {0}",
"ButtonUserEdit": "Edit user {0}",
"ButtonUserDelete": "删除用户 {0}",
"ButtonUserEdit": "编辑用户 {0}",
"ButtonViewAll": "查看全部",
"ButtonYes": "确定",
"HeaderAccount": "帐户",
@@ -308,6 +308,7 @@
"LabelPublishYear": "发布年份",
"LabelRecentlyAdded": "最近添加",
"LabelRecentSeries": "最近添加系列",
"LabelRecommended": "推荐内容",
"LabelRegion": "区域",
"LabelReleaseDate": "发布日期",
"LabelRemoveCover": "移除封面",
@@ -443,8 +444,8 @@
"MessageConfirmDeleteLibrary": "你确定要永久删除媒体库 \"{0}\"?",
"MessageConfirmDeleteSession": "你确定要删除此会话吗?",
"MessageConfirmForceReScan": "你确定要强制重新扫描吗?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
"MessageConfirmMarkSeriesFinished": "你确定要将此系列中的所有书籍都标记为已听完吗?",
"MessageConfirmMarkSeriesNotFinished": "你确定要将此系列中的所有书籍都标记为未听完吗?",
"MessageConfirmRemoveCollection": "您确定要移除收藏 \"{0}\"?",
"MessageConfirmRemoveEpisode": "您确定要移除剧集 \"{0}\"?",
"MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?",
@@ -605,20 +606,13 @@
"ToastRemoveItemFromCollectionSuccess": "项目已从收藏中删除",
"ToastRSSFeedCloseFailed": "关闭 RSS 源失败",
"ToastRSSFeedCloseSuccess": "RSS 源已关闭",
"ToastSeriesUpdateFailed": "Series update failed",
"ToastSeriesUpdateSuccess": "Series update success",
"ToastSeriesUpdateFailed": "更新系列失败",
"ToastSeriesUpdateSuccess": "系列已更新",
"ToastSessionDeleteFailed": "删除会话失败",
"ToastSessionDeleteSuccess": "会话已删除",
"ToastSocketConnected": "网络已连接",
"ToastSocketDisconnected": "网络已断开",
"ToastSocketFailedToConnect": "网络连接失败",
"ToastUserDeleteFailed": "删除用户失败",
"ToastUserDeleteSuccess": "用户已删除",
"WeekdayFriday": "星期五",
"WeekdayMonday": "星期一",
"WeekdaySaturday": "星期六",
"WeekdaySunday": "星期日",
"WeekdayThursday": "星期四",
"WeekdayTuesday": "星期二",
"WeekdayWednesday": "星期三"
"ToastUserDeleteSuccess": "用户已删除"
}

View File

@@ -1,8 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Adobe Illustrator 24.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 6702.7 1277.4" style="enable-background:new 0 0 6702.7 1277.4;" xml:space="preserve">
<style type="text/css">
<svg
version="1.1"
id="Layer_1"
x="0px"
y="0px"
viewBox="0 0 7501.0735 1237.1999"
xml:space="preserve"
width="7501.0737"
height="1237.2"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"><defs
id="defs104" />
<style
type="text/css"
id="style70">
.st0{fill:#FFFFFF;}
.st1{fill:url(#SVGID_1_);}
.st2{fill:#C9C9C9;}
@@ -12,38 +28,82 @@
.st6{font-family:'GentiumBasic';}
.st7{font-size:305px;}
</style>
<title>bgAsset 6</title>
<g id="Layer_2_1_">
<g id="Layer_2-2">
<g id="Layer_4">
<g id="Layer_5">
<circle class="st0" cx="618.6" cy="618.6" r="618.6"/>
<title
id="title72">bgAsset 6</title>
<g
id="Layer_2_1_">
<g
id="Layer_2-2">
<g
id="Layer_4">
<g
id="Layer_5">
<circle
class="st0"
cx="618.59998"
cy="618.59998"
r="618.59998"
id="circle74" />
</g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="617.37" y1="1257.3" x2="617.37" y2="61.4399" gradientTransform="matrix(1 0 0 -1 0 1278)">
<stop offset="0.32" style="stop-color:#CD9D49"/>
<stop offset="0.99" style="stop-color:#875D27"/>
<linearGradient
id="SVGID_1_"
gradientUnits="userSpaceOnUse"
x1="617.37"
y1="1257.3"
x2="617.37"
y2="61.439899"
gradientTransform="matrix(1,0,0,-1,0,1278)">
<stop
offset="0.32"
style="stop-color:#CD9D49"
id="stop77" />
<stop
offset="0.99"
style="stop-color:#875D27"
id="stop79" />
</linearGradient>
<circle class="st1" cx="617.4" cy="618.6" r="597.9"/>
<circle
class="st1"
cx="617.40002"
cy="618.59998"
r="597.90002"
id="circle82"
style="fill:url(#SVGID_1_)" />
</g>
<path class="st0" d="M1005.6,574.1c-4.8-4-12.4-10-22.6-17v-79.2c0-201.9-163.7-365.6-365.6-365.6l0,0
c-201.9,0-365.6,163.7-365.6,365.6v79.2c-10.2,7-17.7,13-22.6,17c-4.1,3.4-6.5,8.5-6.5,13.9v94.9c0,5.4,2.4,10.5,6.5,14
c11.3,9.4,37.2,29.1,77.5,49.3v9.2c0,24.9,16,45,35.8,45l0,0c19.8,0,35.8-20.2,35.8-45V527.8c0-24.9-16-45-35.8-45l0,0
c-19,0-34.5,18.5-35.8,41.9h-0.1v-46.9c0-171.6,139.1-310.7,310.7-310.7l0,0C789,167.2,928,306.3,928,477.9v46.9H928
c-1.3-23.4-16.8-41.9-35.8-41.9l0,0c-19.8,0-35.8,20.2-35.8,45v227.6c0,24.9,16,45,35.8,45l0,0c19.8,0,35.8-20.2,35.8-45v-9.2
c40.3-20.2,66.2-39.9,77.5-49.3c4.2-3.5,6.5-8.6,6.5-14V588C1012.1,582.6,1009.7,577.5,1005.6,574.1z"/>
<path class="st0" d="M489.9,969.7c23.9,0,43.3-19.4,43.3-43.3V441.6c0-23.9-19.4-43.3-43.3-43.3h-44.7
c-23.9,0-43.3,19.4-43.3,43.3v484.8c0,23.9,19.4,43.3,43.3,43.3L489.9,969.7z M418.2,514.6h98.7v10.3h-98.7V514.6z"/>
<path class="st0" d="M639.7,969.7c23.9,0,43.3-19.4,43.3-43.3V441.6c0-23.9-19.4-43.3-43.3-43.3H595c-23.9,0-43.3,19.4-43.3,43.3
v484.8c0,23.9,19.4,43.3,43.3,43.3H639.7z M568,514.6h98.7v10.3H568V514.6z"/>
<path class="st0" d="M789.6,969.7c23.9,0,43.3-19.4,43.3-43.3V441.6c0-23.9-19.4-43.3-43.3-43.3h-44.7
c-23.9,0-43.3,19.4-43.3,43.3v484.8c0,23.9,19.4,43.3,43.3,43.3L789.6,969.7z M717.9,514.6h98.7v10.3h-98.7V514.6z"/>
<path class="st0" d="M327.1,984.7h580.5c18,0,32.6,14.6,32.6,32.6v0c0,18-14.6,32.6-32.6,32.6H327.1c-18,0-32.6-14.6-32.6-32.6v0
C294.5,999.3,309.1,984.7,327.1,984.7z"/>
<path
class="st0"
d="m 1005.6,574.1 c -4.8,-4 -12.4,-10 -22.6,-17 V 477.9 C 983,276 819.3,112.3 617.4,112.3 v 0 C 415.5,112.3 251.8,276 251.8,477.9 v 79.2 c -10.2,7 -17.7,13 -22.6,17 -4.1,3.4 -6.5,8.5 -6.5,13.9 v 94.9 c 0,5.4 2.4,10.5 6.5,14 11.3,9.4 37.2,29.1 77.5,49.3 v 9.2 c 0,24.9 16,45 35.8,45 v 0 c 19.8,0 35.8,-20.2 35.8,-45 V 527.8 c 0,-24.9 -16,-45 -35.8,-45 v 0 c -19,0 -34.5,18.5 -35.8,41.9 h -0.1 v -46.9 c 0,-171.6 139.1,-310.7 310.7,-310.7 v 0 C 789,167.2 928,306.3 928,477.9 v 46.9 0 c -1.3,-23.4 -16.8,-41.9 -35.8,-41.9 v 0 c -19.8,0 -35.8,20.2 -35.8,45 v 227.6 c 0,24.9 16,45 35.8,45 v 0 c 19.8,0 35.8,-20.2 35.8,-45 v -9.2 c 40.3,-20.2 66.2,-39.9 77.5,-49.3 4.2,-3.5 6.5,-8.6 6.5,-14 v -95 c 0.1,-5.4 -2.3,-10.5 -6.4,-13.9 z"
id="path85" />
<path
class="st0"
d="m 489.9,969.7 c 23.9,0 43.3,-19.4 43.3,-43.3 V 441.6 c 0,-23.9 -19.4,-43.3 -43.3,-43.3 h -44.7 c -23.9,0 -43.3,19.4 -43.3,43.3 v 484.8 c 0,23.9 19.4,43.3 43.3,43.3 z M 418.2,514.6 h 98.7 v 10.3 h -98.7 z"
id="path87" />
<path
class="st0"
d="m 639.7,969.7 c 23.9,0 43.3,-19.4 43.3,-43.3 V 441.6 c 0,-23.9 -19.4,-43.3 -43.3,-43.3 H 595 c -23.9,0 -43.3,19.4 -43.3,43.3 v 484.8 c 0,23.9 19.4,43.3 43.3,43.3 z M 568,514.6 h 98.7 v 10.3 H 568 Z"
id="path89" />
<path
class="st0"
d="m 789.6,969.7 c 23.9,0 43.3,-19.4 43.3,-43.3 V 441.6 c 0,-23.9 -19.4,-43.3 -43.3,-43.3 h -44.7 c -23.9,0 -43.3,19.4 -43.3,43.3 v 484.8 c 0,23.9 19.4,43.3 43.3,43.3 z M 717.9,514.6 h 98.7 v 10.3 h -98.7 z"
id="path91" />
<path
class="st0"
d="m 327.1,984.7 h 580.5 c 18,0 32.6,14.6 32.6,32.6 v 0 c 0,18 -14.6,32.6 -32.6,32.6 H 327.1 c -18,0 -32.6,-14.6 -32.6,-32.6 v 0 c 0,-18 14.6,-32.6 32.6,-32.6 z"
id="path93" />
</g>
<g id="Layer_6">
<text transform="matrix(1 0 0 1 1492.27 735.42)" class="st2 st3 st4">audiobookshelf</text>
<text id="self-hosted_audiobook_and_podcast_server" transform="matrix(1 0 0 1 1492.27 1103.6899)" class="st5 st6 st7">self-hosted audiobook and podcast server</text>
<g
id="Layer_6">
<text
transform="translate(1492.27,735.42)"
class="st2 st3 st4"
id="text96">audiobookshelf</text>
<text
id="self-hosted_audiobook_and_podcast_server"
transform="translate(1492.27,1103.6899)"
class="st5 st6 st7">self-hosted audiobook and podcast server</text>
</g>
</g>
</svg>
<metadata
id="metadata210"><rdf:RDF><cc:Work
rdf:about=""><dc:title>bgAsset 6</dc:title></cc:Work></rdf:RDF></metadata></svg>

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -18,8 +18,8 @@ const PORT = process.env.PORT || 80
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
const GID = process.env.AUDIOBOOKSHELF_GID || 100
const UID = process.env.AUDIOBOOKSHELF_UID
const GID = process.env.AUDIOBOOKSHELF_GID
const SOURCE = process.env.SOURCE || 'docker'
const ROUTER_BASE_PATH = process.env.ROUTER_BASE_PATH || ''

189
package-lock.json generated
View File

@@ -1,20 +1,20 @@
{
"name": "audiobookshelf",
"version": "2.2.12",
"version": "2.2.13",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf",
"version": "2.2.12",
"version": "2.2.13",
"license": "GPL-3.0",
"dependencies": {
"axios": "^0.26.1",
"axios": "^1.2.2",
"express": "^4.17.1",
"graceful-fs": "^4.2.10",
"htmlparser2": "^8.0.1",
"node-tone": "^1.0.1",
"socket.io": "^4.4.1",
"socket.io": "^4.5.4",
"xml2js": "^0.4.23"
},
"bin": {
@@ -35,14 +35,17 @@
"integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q=="
},
"node_modules/@types/cors": {
"version": "2.8.12",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.12.tgz",
"integrity": "sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw=="
"version": "2.8.13",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.13.tgz",
"integrity": "sha512-RG8AStHlUiV5ysZQKq97copd2UmVYw3/pRMLefISZ3S1hK104Cwm7iLQ3fTKx+lsUH2CE8FlLaYeEA2LSeqYUA==",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/node": {
"version": "18.11.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.9.tgz",
"integrity": "sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg=="
"version": "18.11.18",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz",
"integrity": "sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA=="
},
"node_modules/abbrev": {
"version": "1.1.1",
@@ -63,9 +66,9 @@
}
},
"node_modules/anymatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
"integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==",
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"dependencies": {
"normalize-path": "^3.0.0",
@@ -80,12 +83,19 @@
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/axios": {
"version": "0.26.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz",
"integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.2.2.tgz",
"integrity": "sha512-bz/J4gS2S3I7mpN/YZfGFTqhXTYzRho8Ay38w2otuuDR322KzFIWm/4W2K6gIwvWaws5n+mnb7D1lN9uD+QH6Q==",
"dependencies": {
"follow-redirects": "^1.14.8"
"follow-redirects": "^1.15.0",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": {
@@ -203,6 +213,17 @@
"fsevents": "~2.3.2"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -261,6 +282,14 @@
"ms": "2.0.0"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -343,9 +372,9 @@
}
},
"node_modules/engine.io": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.2.0.tgz",
"integrity": "sha512-4KzwW3F3bk+KlzSOY57fj/Jx6LyRQ1nbcyIadehl+AnXjKT7gDO0ORdRi/84ixvMKTym6ZKuxvbzN62HDDU1Lg==",
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.2.1.tgz",
"integrity": "sha512-ECceEFcAaNRybd3lsGQKas3ZlMVjN3cyWwMP25D2i0zWfyiytVbTpRPa34qrr+FHddtpBVOmq4H/DCv1O0lZRA==",
"dependencies": {
"@types/cookie": "^0.4.1",
"@types/cors": "^2.8.12",
@@ -512,6 +541,19 @@
}
}
},
"node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -925,6 +967,11 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"node_modules/pstree.remy": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
@@ -1078,9 +1125,9 @@
}
},
"node_modules/simple-update-notifier": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.0.7.tgz",
"integrity": "sha512-BBKgR84BJQJm6WjWFMHgLVuo61FBDSj1z/xSFUIozqO6wO7ii0JxCqlIud7Enr/+LhlbNI0whErq96P2qHNWew==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz",
"integrity": "sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==",
"dev": true,
"dependencies": {
"semver": "~7.0.0"
@@ -1099,16 +1146,16 @@
}
},
"node_modules/socket.io": {
"version": "4.5.3",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.5.3.tgz",
"integrity": "sha512-zdpnnKU+H6mOp7nYRXH4GNv1ux6HL6+lHL8g7Ds7Lj8CkdK1jJK/dlwsKDculbyOHifcJ0Pr/yeXnZQ5GeFrcg==",
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.5.4.tgz",
"integrity": "sha512-m3GC94iK9MfIEeIBfbhJs5BqFibMtkRk8ZpKwG2QwxV0m/eEhPIV4ara6XCF1LWNAus7z58RodiZlAH71U3EhQ==",
"dependencies": {
"accepts": "~1.3.4",
"base64id": "~2.0.0",
"debug": "~4.3.2",
"engine.io": "~6.2.0",
"engine.io": "~6.2.1",
"socket.io-adapter": "~2.4.0",
"socket.io-parser": "~4.2.0"
"socket.io-parser": "~4.2.1"
},
"engines": {
"node": ">=10.0.0"
@@ -1320,14 +1367,17 @@
"integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q=="
},
"@types/cors": {
"version": "2.8.12",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.12.tgz",
"integrity": "sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw=="
"version": "2.8.13",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.13.tgz",
"integrity": "sha512-RG8AStHlUiV5ysZQKq97copd2UmVYw3/pRMLefISZ3S1hK104Cwm7iLQ3fTKx+lsUH2CE8FlLaYeEA2LSeqYUA==",
"requires": {
"@types/node": "*"
}
},
"@types/node": {
"version": "18.11.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.9.tgz",
"integrity": "sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg=="
"version": "18.11.18",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz",
"integrity": "sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA=="
},
"abbrev": {
"version": "1.1.1",
@@ -1345,9 +1395,9 @@
}
},
"anymatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
"integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==",
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"requires": {
"normalize-path": "^3.0.0",
@@ -1359,12 +1409,19 @@
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
},
"asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"axios": {
"version": "0.26.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz",
"integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.2.2.tgz",
"integrity": "sha512-bz/J4gS2S3I7mpN/YZfGFTqhXTYzRho8Ay38w2otuuDR322KzFIWm/4W2K6gIwvWaws5n+mnb7D1lN9uD+QH6Q==",
"requires": {
"follow-redirects": "^1.14.8"
"follow-redirects": "^1.15.0",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"balanced-match": {
@@ -1452,6 +1509,14 @@
"readdirp": "~3.6.0"
}
},
"combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"requires": {
"delayed-stream": "~1.0.0"
}
},
"concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -1498,6 +1563,11 @@
"ms": "2.0.0"
}
},
"delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
},
"depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -1552,9 +1622,9 @@
"integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="
},
"engine.io": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.2.0.tgz",
"integrity": "sha512-4KzwW3F3bk+KlzSOY57fj/Jx6LyRQ1nbcyIadehl+AnXjKT7gDO0ORdRi/84ixvMKTym6ZKuxvbzN62HDDU1Lg==",
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.2.1.tgz",
"integrity": "sha512-ECceEFcAaNRybd3lsGQKas3ZlMVjN3cyWwMP25D2i0zWfyiytVbTpRPa34qrr+FHddtpBVOmq4H/DCv1O0lZRA==",
"requires": {
"@types/cookie": "^0.4.1",
"@types/cors": "^2.8.12",
@@ -1674,6 +1744,16 @@
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
"integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA=="
},
"form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
}
},
"forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -1966,6 +2046,11 @@
"ipaddr.js": "1.9.1"
}
},
"proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"pstree.remy": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
@@ -2080,9 +2165,9 @@
}
},
"simple-update-notifier": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.0.7.tgz",
"integrity": "sha512-BBKgR84BJQJm6WjWFMHgLVuo61FBDSj1z/xSFUIozqO6wO7ii0JxCqlIud7Enr/+LhlbNI0whErq96P2qHNWew==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz",
"integrity": "sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==",
"dev": true,
"requires": {
"semver": "~7.0.0"
@@ -2097,16 +2182,16 @@
}
},
"socket.io": {
"version": "4.5.3",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.5.3.tgz",
"integrity": "sha512-zdpnnKU+H6mOp7nYRXH4GNv1ux6HL6+lHL8g7Ds7Lj8CkdK1jJK/dlwsKDculbyOHifcJ0Pr/yeXnZQ5GeFrcg==",
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.5.4.tgz",
"integrity": "sha512-m3GC94iK9MfIEeIBfbhJs5BqFibMtkRk8ZpKwG2QwxV0m/eEhPIV4ara6XCF1LWNAus7z58RodiZlAH71U3EhQ==",
"requires": {
"accepts": "~1.3.4",
"base64id": "~2.0.0",
"debug": "~4.3.2",
"engine.io": "~6.2.0",
"engine.io": "~6.2.1",
"socket.io-adapter": "~2.4.0",
"socket.io-parser": "~4.2.0"
"socket.io-parser": "~4.2.1"
},
"dependencies": {
"debug": {

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "2.2.12",
"version": "2.2.13",
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",
"scripts": {
@@ -30,12 +30,12 @@
"author": "advplyr",
"license": "GPL-3.0",
"dependencies": {
"axios": "^0.26.1",
"axios": "^1.2.2",
"express": "^4.17.1",
"graceful-fs": "^4.2.10",
"htmlparser2": "^8.0.1",
"node-tone": "^1.0.1",
"socket.io": "^4.4.1",
"socket.io": "^4.5.4",
"xml2js": "^0.4.23"
},
"devDependencies": {

View File

@@ -20,12 +20,13 @@ var inputConfig = options.config ? Path.resolve(options.config) : null
var inputMetadata = options.metadata ? Path.resolve(options.metadata) : null
const PORT = options.port || process.env.PORT || 3333
const HOST = options.host || process.env.HOST || "0.0.0.0"
const HOST = options.host || process.env.HOST
const CONFIG_PATH = inputConfig || process.env.CONFIG_PATH || Path.resolve('config')
const METADATA_PATH = inputMetadata || process.env.METADATA_PATH || Path.resolve('metadata')
const UID = 99
const GID = 100
const SOURCE = options.source || 'debian'
const UID = process.env.AUDIOBOOKSHELF_UID
const GID = process.env.AUDIOBOOKSHELF_GID
const SOURCE = options.source || process.env.SOURCE || 'debian'
const ROUTER_BASE_PATH = process.env.ROUTER_BASE_PATH || ''
console.log(process.env.NODE_ENV, 'Config', CONFIG_PATH, METADATA_PATH)

View File

@@ -43,11 +43,12 @@ class Server {
this.Host = HOST
global.Source = SOURCE
global.isWin = process.platform === 'win32'
global.Uid = isNaN(UID) ? 0 : Number(UID)
global.Gid = isNaN(GID) ? 0 : Number(GID)
global.Uid = isNaN(UID) ? undefined : Number(UID)
global.Gid = isNaN(GID) ? undefined : Number(GID)
global.ConfigPath = fileUtils.filePathToPOSIX(Path.normalize(CONFIG_PATH))
global.MetadataPath = fileUtils.filePathToPOSIX(Path.normalize(METADATA_PATH))
global.RouterBasePath = ROUTER_BASE_PATH
global.XAccel = process.env.USE_X_ACCEL
if (!fs.pathExistsSync(global.ConfigPath)) {
fs.mkdirSync(global.ConfigPath)
@@ -142,6 +143,7 @@ class Server {
const app = express()
const router = express.Router()
app.use(global.RouterBasePath, router)
app.disable('x-powered-by')
this.server = http.createServer(app)

View File

@@ -319,7 +319,7 @@ class LibraryController {
// series represents in the filtered series
if (filterSeries) {
json.collapsedSeries.seriesSequenceList =
naturalSort(li.collapsedSeries.books.map(b => b.filterSeriesSequence)).asc()
naturalSort(li.collapsedSeries.books.filter(b => b.filterSeriesSequence).map(b => b.filterSeriesSequence)).asc()
.reduce((ranges, currentSequence) => {
let lastRange = ranges.at(-1)
let isNumber = /^(\d+|\d+\.\d*|\d*\.\d+)$/.test(currentSequence)

View File

@@ -201,6 +201,10 @@ class LibraryItemController {
return res.sendStatus(404)
}
if (global.XAccel) {
Logger.debug(`Use X-Accel to serve static file ${libraryItem.media.coverPath}`)
return res.status(204).header({'X-Accel-Redirect': global.XAccel + libraryItem.media.coverPath}).send()
}
return res.sendFile(libraryItem.media.coverPath)
}

View File

@@ -192,7 +192,8 @@ class MeController {
}
const updatedLocalMediaProgress = []
var numServerProgressUpdates = 0
var localMediaProgress = req.body.localMediaProgress || []
const updatedServerMediaProgress = []
const localMediaProgress = req.body.localMediaProgress || []
localMediaProgress.forEach(localProgress => {
if (!localProgress.libraryItemId) {
@@ -205,18 +206,22 @@ class MeController {
return
}
var mediaProgress = req.user.getMediaProgress(localProgress.libraryItemId, localProgress.episodeId)
let mediaProgress = req.user.getMediaProgress(localProgress.libraryItemId, localProgress.episodeId)
if (!mediaProgress) {
// New media progress from mobile
Logger.debug(`[MeController] syncLocalMediaProgress local progress is new - creating ${localProgress.id}`)
req.user.createUpdateMediaProgress(libraryItem, localProgress, localProgress.episodeId)
mediaProgress = req.user.getMediaProgress(localProgress.libraryItemId, localProgress.episodeId)
updatedServerMediaProgress.push(mediaProgress)
numServerProgressUpdates++
} else if (mediaProgress.lastUpdate < localProgress.lastUpdate) {
Logger.debug(`[MeController] syncLocalMediaProgress local progress is more recent - updating ${mediaProgress.id}`)
req.user.createUpdateMediaProgress(libraryItem, localProgress, localProgress.episodeId)
mediaProgress = req.user.getMediaProgress(localProgress.libraryItemId, localProgress.episodeId)
updatedServerMediaProgress.push(mediaProgress)
numServerProgressUpdates++
} else if (mediaProgress.lastUpdate > localProgress.lastUpdate) {
var updateTimeDifference = mediaProgress.lastUpdate - localProgress.lastUpdate
const updateTimeDifference = mediaProgress.lastUpdate - localProgress.lastUpdate
Logger.debug(`[MeController] syncLocalMediaProgress server progress is more recent by ${updateTimeDifference}ms - ${mediaProgress.id}`)
for (const key in localProgress) {
@@ -240,7 +245,8 @@ class MeController {
res.json({
numServerProgressUpdates,
localProgressUpdates: updatedLocalMediaProgress
localProgressUpdates: updatedLocalMediaProgress, // Array of LocalMediaProgress that were updated from server (server more recent)
serverProgressUpdates: updatedServerMediaProgress // Array of MediaProgress that made updates to server (local more recent)
})
}

View File

@@ -141,6 +141,10 @@ class AbMergeManager {
'TrackNumber': 1,
}
if (libraryItem.media.coverPath) {
task.data.toneJsonObject['CoverFile'] = libraryItem.media.coverPath
}
const workerData = {
inputs: ffmpegInputs,
options: ffmpegOptions,

View File

@@ -60,7 +60,7 @@ class AudioMetadataMangaer {
const results = []
for (const af of audioFiles) {
const result = await this.updateAudioFileMetadataWithTone(libraryItem.id, af, toneJsonPath, itemCacheDir, backupFiles)
const result = await this.updateAudioFileMetadataWithTone(libraryItem, af, toneJsonPath, itemCacheDir, backupFiles)
results.push(result)
}
@@ -82,9 +82,9 @@ class AudioMetadataMangaer {
SocketAuthority.emitter('audio_metadata_finished', itemAudioMetadataPayload)
}
async updateAudioFileMetadataWithTone(libraryItemId, audioFile, toneJsonPath, itemCacheDir, backupFiles) {
async updateAudioFileMetadataWithTone(libraryItem, audioFile, toneJsonPath, itemCacheDir, backupFiles) {
const resultPayload = {
libraryItemId,
libraryItemId: libraryItem.id,
index: audioFile.index,
ino: audioFile.ino,
filename: audioFile.metadata.filename
@@ -107,6 +107,10 @@ class AudioMetadataMangaer {
'TrackNumber': audioFile.index,
}
if (libraryItem.media.coverPath) {
_toneMetadataObject['CoverFile'] = libraryItem.media.coverPath
}
resultPayload.success = await toneHelpers.tagAudioFile(audioFile.metadata.path, _toneMetadataObject)
if (resultPayload.success) {
Logger.info(`[AudioMetadataManager] Successfully tagged audio file "${audioFile.metadata.path}"`)

View File

@@ -51,6 +51,11 @@ class CacheManager {
// Cache exists
if (await fs.pathExists(path)) {
if (global.XAccel) {
Logger.debug(`Use X-Accel to serve static file ${path}`)
return res.status(204).header({'X-Accel-Redirect': global.XAccel + path}).send()
}
const r = fs.createReadStream(path)
const ps = new stream.PassThrough()
stream.pipeline(r, ps, (err) => {
@@ -72,6 +77,11 @@ class CacheManager {
// Set owner and permissions of cache image
await filePerms.setDefault(path)
if (global.XAccel) {
Logger.debug(`Use X-Accel to serve static file ${writtenFile}`)
return res.status(204).header({'X-Accel-Redirect': global.XAccel + writtenFile}).send()
}
var readStream = fs.createReadStream(writtenFile)
readStream.pipe(res)
}

View File

@@ -73,6 +73,17 @@ class PlaybackSessionManager {
return res.status(500).send('Library item not found')
}
// If server session is open for this same media item then close it
const userSessionForThisItem = this.sessions.find(playbackSession => {
if (playbackSession.userId !== user.id) return false
if (sessionJson.episodeId) return playbackSession.episodeId !== sessionJson.episodeId
return playbackSession.libraryItemId === sessionJson.libraryItemId
})
if (userSessionForThisItem) {
Logger.info(`[PlaybackSessionManager] syncLocalSessionRequest: Closing open session "${userSessionForThisItem.displayTitle}" for user "${user.username}"`)
await this.closeSession(user, userSessionForThisItem, null)
}
this.localSessionLock[sessionJson.id] = true // Lock local session
let session = await this.db.getPlaybackSession(sessionJson.id)

View File

@@ -81,9 +81,17 @@ class RssFeedManager {
}
// Check if feed needs to be updated
if (feed.entityType === 'item') {
if (feed.entityType === 'libraryItem') {
const libraryItem = this.db.getLibraryItem(feed.entityId)
if (libraryItem && (!feed.entityUpdatedAt || libraryItem.updatedAt > feed.entityUpdatedAt)) {
let mostRecentlyUpdatedAt = libraryItem.updatedAt
if (libraryItem.isPodcast) {
libraryItem.media.episodes.forEach((episode) => {
if (episode.updatedAt > mostRecentlyUpdatedAt) mostRecentlyUpdatedAt = episode.updatedAt
})
}
if (libraryItem && (!feed.entityUpdatedAt || mostRecentlyUpdatedAt > feed.entityUpdatedAt)) {
Logger.debug(`[RssFeedManager] Updating RSS feed for item ${libraryItem.id} "${libraryItem.media.metadata.title}"`)
feed.updateFromItem(libraryItem)
await this.db.updateEntity('feed', feed)

View File

@@ -110,13 +110,15 @@ class Feed {
this.episodes = []
if (isPodcast) { // PODCAST EPISODES
media.episodes.forEach((episode) => {
var feedEpisode = new FeedEpisode()
if (episode.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = episode.updatedAt
const feedEpisode = new FeedEpisode()
feedEpisode.setFromPodcastEpisode(libraryItem, serverAddress, slug, episode, this.meta)
this.episodes.push(feedEpisode)
})
} else { // AUDIOBOOK EPISODES
media.tracks.forEach((audioTrack) => {
var feedEpisode = new FeedEpisode()
const feedEpisode = new FeedEpisode()
feedEpisode.setFromAudiobookTrack(libraryItem, serverAddress, slug, audioTrack, this.meta)
this.episodes.push(feedEpisode)
})
@@ -144,13 +146,15 @@ class Feed {
this.episodes = []
if (isPodcast) { // PODCAST EPISODES
media.episodes.forEach((episode) => {
var feedEpisode = new FeedEpisode()
if (episode.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = episode.updatedAt
const feedEpisode = new FeedEpisode()
feedEpisode.setFromPodcastEpisode(libraryItem, this.serverAddress, this.slug, episode, this.meta)
this.episodes.push(feedEpisode)
})
} else { // AUDIOBOOK EPISODES
media.tracks.forEach((audioTrack) => {
var feedEpisode = new FeedEpisode()
const feedEpisode = new FeedEpisode()
feedEpisode.setFromAudiobookTrack(libraryItem, this.serverAddress, this.slug, audioTrack, this.meta)
this.episodes.push(feedEpisode)
})

View File

@@ -420,10 +420,38 @@ class Book {
}
}
// IF first audio file has embedded chapters then use embedded chapters
if (includedAudioFiles[0].chapters && includedAudioFiles[0].chapters.length) {
Logger.debug(`[Book] setChapters: Using embedded chapters in audio file ${includedAudioFiles[0].metadata.path}`)
this.chapters = includedAudioFiles[0].chapters.map(c => ({ ...c }))
// If first audio file has embedded chapters then use embedded chapters
if (includedAudioFiles[0].chapters?.length) {
// If all files chapters are the same, then only make chapters for the first file
if (
includedAudioFiles.length === 1 ||
includedAudioFiles.length > 1 &&
includedAudioFiles[0].chapters.length === includedAudioFiles[1].chapters?.length &&
includedAudioFiles[0].chapters.every((c, i) => c.title === includedAudioFiles[1].chapters[i].title)
) {
Logger.debug(`[Book] setChapters: Using embedded chapters in first audio file ${includedAudioFiles[0].metadata?.path}`)
this.chapters = includedAudioFiles[0].chapters.map((c) => ({ ...c }))
} else {
Logger.debug(`[Book] setChapters: Using embedded chapters from all audio files ${includedAudioFiles[0].metadata?.path}`)
this.chapters = []
let currChapterId = 0
let currStartTime = 0
includedAudioFiles.forEach((file) => {
if (file.duration) {
const chapters = file.chapters?.map((c) => ({
...c,
id: c.id + currChapterId,
start: c.start + currStartTime,
end: c.end + currStartTime,
})) ?? []
this.chapters = this.chapters.concat(chapters)
currChapterId += file.chapters?.length ?? 0
currStartTime += file.duration
}
})
}
} else if (includedAudioFiles.length > 1) {
// Build chapters from audio files
this.chapters = []

View File

@@ -37,7 +37,7 @@ class ServerSettings {
this.loggerScannerLogsToKeep = 2
// Bookshelf Display
this.homeBookshelfView = BookshelfView.STANDARD
this.homeBookshelfView = BookshelfView.DETAIL
this.bookshelfView = BookshelfView.DETAIL
// Podcasts

View File

@@ -72,7 +72,7 @@ class MediaProgress {
}
update(payload) {
var hasUpdates = false
let hasUpdates = false
for (const key in payload) {
if (this[key] !== undefined && payload[key] !== this[key]) {
if (key === 'isFinished') {

View File

@@ -314,18 +314,18 @@ class User {
}
createUpdateMediaProgress(libraryItem, updatePayload, episodeId = null) {
var itemProgress = this.mediaProgress.find(li => {
const itemProgress = this.mediaProgress.find(li => {
if (episodeId && li.episodeId !== episodeId) return false
return li.libraryItemId === libraryItem.id
})
if (!itemProgress) {
var newItemProgress = new MediaProgress()
const newItemProgress = new MediaProgress()
newItemProgress.setData(libraryItem.id, updatePayload, episodeId)
this.mediaProgress.push(newItemProgress)
return true
}
var wasUpdated = itemProgress.update(updatePayload)
const wasUpdated = itemProgress.update(updatePayload)
if (updatePayload.lastUpdate) itemProgress.lastUpdate = updatePayload.lastUpdate // For local to keep update times in sync
return wasUpdated

View File

@@ -1,5 +1,6 @@
const express = require('express')
const Path = require('path')
const Logger = require('../Logger')
const { getAudioMimeTypeFromExtname } = require('../utils/fileUtils')
class StaticRouter {
@@ -13,13 +14,18 @@ class StaticRouter {
init() {
// Library Item static file routes
this.router.get('/item/:id/*', (req, res) => {
var item = this.db.libraryItems.find(ab => ab.id === req.params.id)
const item = this.db.libraryItems.find(ab => ab.id === req.params.id)
if (!item) return res.status(404).send('Item not found with id ' + req.params.id)
var remainingPath = req.params['0']
var fullPath = null
if (item.isFile) fullPath = item.path
else fullPath = Path.join(item.path, remainingPath)
const remainingPath = req.params['0']
const fullPath = item.isFile ? item.path : Path.join(item.path, remainingPath)
// Allow reverse proxy to serve files directly
// See: https://www.nginx.com/resources/wiki/start/topics/examples/x-accel/
if (global.XAccel) {
Logger.debug(`Use X-Accel to serve static file ${fullPath}`)
return res.status(204).header({'X-Accel-Redirect': global.XAccel + fullPath}).send()
}
var opts = {}

View File

@@ -91,7 +91,11 @@ module.exports.setDefault = (path, silent = false) => {
const uid = global.Uid
const gid = global.Gid
return new Promise((resolve) => {
if (!silent) Logger.debug(`[FilePerms] Setting permission "${mode}" for uid ${uid} and gid ${gid} | "${path}"`)
if (isNaN(uid) || isNaN(gid)) {
if (!silent) Logger.debug('Not modifying permissions since no uid/gid is specified')
return resolve()
}
if (!silent) Logger.debug(`Setting permission "${mode}" for uid ${uid} and gid ${gid} | "${path}"`)
chmodr(path, mode, uid, gid, resolve)
})
}
@@ -102,6 +106,10 @@ module.exports.setDefaultDirSync = (path, silent = false) => {
const mode = 0o744
const uid = global.Uid
const gid = global.Gid
if (isNaN(uid) || isNaN(gid)) {
if (!silent) Logger.debug('Not modifying permissions since no uid/gid is specified')
return true
}
if (!silent) Logger.debug(`[FilePerms] Setting dir permission "${mode}" for uid ${uid} and gid ${gid} | "${path}"`)
try {
fs.chmodSync(path, mode)

View File

@@ -10,7 +10,7 @@ function sanitize(html) {
allowedAttributes: {
a: ['href', 'name', 'target']
},
allowedSchemes: ['https'],
allowedSchemes: ['http', 'https', 'mailto'],
allowProtocolRelative: false
}

View File

@@ -339,6 +339,14 @@ module.exports = {
entities: [],
category: 'continueSeries'
},
{
id: 'episodes-recently-added',
label: 'Newest Episodes',
labelStringKey: 'LabelNewestEpisodes',
type: 'episode',
entities: [],
category: 'newestEpisodes'
},
{
id: 'recently-added',
label: 'Recently Added',
@@ -347,14 +355,6 @@ module.exports = {
entities: [],
category: 'newestItems'
},
{
id: 'listen-again',
label: 'Listen Again',
labelStringKey: 'LabelListenAgain',
type: isPodcastLibrary ? 'episode' : mediaType,
entities: [],
category: 'recentlyFinished'
},
{
id: 'recent-series',
label: 'Recent Series',
@@ -363,6 +363,22 @@ module.exports = {
entities: [],
category: 'newestSeries'
},
{
id: 'recommended',
label: 'Recommended',
labelStringKey: 'LabelRecommended',
type: mediaType,
entities: [],
category: 'recommended'
},
{
id: 'listen-again',
label: 'Listen Again',
labelStringKey: 'LabelListenAgain',
type: isPodcastLibrary ? 'episode' : mediaType,
entities: [],
category: 'recentlyFinished'
},
{
id: 'newest-authors',
label: 'Newest Authors',
@@ -370,22 +386,13 @@ module.exports = {
type: 'authors',
entities: [],
category: 'newestAuthors'
},
{
id: 'episodes-recently-added',
label: 'Newest Episodes',
labelStringKey: 'LabelNewestEpisodes',
type: 'episode',
entities: [],
category: 'newestEpisodes'
}
]
const categories = ['recentlyListened', 'continueSeries', 'newestEpisodes', 'newestItems', 'newestSeries', 'recentlyFinished', 'newestAuthors']
const categoryMap = {}
categories.forEach((cat) => {
categoryMap[cat] = {
category: cat,
shelves.forEach((shelf) => {
categoryMap[shelf.category] = {
category: shelf.category,
biggest: 0,
smallest: 0,
items: []
@@ -395,6 +402,12 @@ module.exports = {
const seriesMap = {}
const authorMap = {}
// For use with recommended
const topGenresListened = {}
const topAuthorsListened = {}
const topTagsListened = {}
const notStartedBooks = []
for (const libraryItem of libraryItems) {
if (libraryItem.addedAt > categoryMap.newestItems.smallest) {
@@ -494,11 +507,30 @@ module.exports = {
} else if (libraryItem.isBook) {
// Book categories
const mediaProgress = allItemProgress.length ? allItemProgress[0] : null
// Used for recommended. Tally up most listened to authors/genres/tags
if (mediaProgress && (mediaProgress.inProgress || mediaProgress.isFinished)) {
libraryItem.media.metadata.authors.forEach((author) => {
topAuthorsListened[author.id] = (topAuthorsListened[author.id] || 0) + 1
})
libraryItem.media.metadata.genres.forEach((genre) => {
topGenresListened[genre] = (topGenresListened[genre] || 0) + 1
})
libraryItem.media.tags.forEach((tag) => {
topTagsListened[tag] = (topTagsListened[tag] || 0) + 1
})
} else {
// Insert in random position to add randomization to equal weighted items
notStartedBooks.splice(Math.floor(Math.random() * (notStartedBooks.length + 1)), 0, libraryItem)
}
// Newest series
if (libraryItem.media.metadata.series.length) {
for (const librarySeries of libraryItem.media.metadata.series) {
const mediaProgress = allItemProgress.length ? allItemProgress[0] : null
const bookInProgress = mediaProgress && (mediaProgress.inProgress || mediaProgress.isFinished)
const bookActive = mediaProgress && mediaProgress.inProgress && !mediaProgress.isFinished
const libraryItemJson = libraryItem.toJSONMinified()
libraryItemJson.seriesSequence = librarySeries.sequence
@@ -511,6 +543,7 @@ module.exports = {
...seriesObj.toJSON(),
books: [libraryItemJson],
inProgress: bookInProgress,
hasActiveBook: bookActive,
hideFromContinueListening,
bookInProgressLastUpdate: bookInProgress ? mediaProgress.lastUpdate : null,
firstBookUnread: bookInProgress ? null : libraryItemJson
@@ -553,6 +586,11 @@ module.exports = {
seriesMap[librarySeries.id].firstBookUnread = libraryItemJson
}
}
// Update if series has an active (progress < 100%) book
if (bookActive) {
seriesMap[librarySeries.id].hasActiveBook = true
}
}
}
}
@@ -595,7 +633,6 @@ module.exports = {
}
// Book listening and finished
var mediaProgress = allItemProgress.length ? allItemProgress[0] : null
if (mediaProgress) {
// Handle most recently finished
if (mediaProgress.isFinished) {
@@ -605,7 +642,7 @@ module.exports = {
finishedAt: mediaProgress.finishedAt
}
var indexToPut = categoryMap.recentlyFinished.items.findIndex(i => mediaProgress.finishedAt > i.finishedAt)
const indexToPut = categoryMap.recentlyFinished.items.findIndex(i => mediaProgress.finishedAt > i.finishedAt)
if (indexToPut >= 0) {
categoryMap.recentlyFinished.items.splice(indexToPut, 0, libraryItemObj)
} else {
@@ -625,7 +662,7 @@ module.exports = {
progressLastUpdate: mediaProgress.lastUpdate
}
var indexToPut = categoryMap.recentlyListened.items.findIndex(i => mediaProgress.lastUpdate > i.progressLastUpdate)
const indexToPut = categoryMap.recentlyListened.items.findIndex(i => mediaProgress.lastUpdate > i.progressLastUpdate)
if (indexToPut >= 0) {
categoryMap.recentlyListened.items.splice(indexToPut, 0, libraryItemObj)
} else { // Should only happen when array is < max
@@ -645,13 +682,15 @@ module.exports = {
// For Continue Series - Find next book in series for series that are in progress
for (const seriesId in seriesMap) {
if (seriesMap[seriesId].inProgress && !seriesMap[seriesId].hideFromContinueListening) {
seriesMap[seriesId].books = naturalSort(seriesMap[seriesId].books).asc(li => li.seriesSequence)
seriesMap[seriesId].books = naturalSort(seriesMap[seriesId].books).asc(li => li.seriesSequence)
// NEW implementation takes the first book unread with the smallest series sequence
if (seriesMap[seriesId].inProgress && !seriesMap[seriesId].hideFromContinueListening) {
// take the first book unread with the smallest series sequence
// unless the user is already listening to a book from this series
const hasActiveBook = seriesMap[seriesId].hasActiveBook
const nextBookInSeries = seriesMap[seriesId].firstBookUnread
if (nextBookInSeries) {
if (!hasActiveBook && nextBookInSeries) {
const bookForContinueSeries = {
...nextBookInSeries,
prevBookInProgressLastUpdate: seriesMap[seriesId].bookInProgressLastUpdate
@@ -663,10 +702,82 @@ module.exports = {
}
const indexToPut = categoryMap.continueSeries.items.findIndex(i => i.prevBookInProgressLastUpdate < bookForContinueSeries.prevBookInProgressLastUpdate)
if (!categoryMap.continueSeries.items.find(book => book.id === bookForContinueSeries.id)) {
if (indexToPut >= 0) {
categoryMap.continueSeries.items.splice(indexToPut, 0, bookForContinueSeries)
} else if (categoryMap.continueSeries.items.length < 10) { // Max 10 books
categoryMap.continueSeries.items.push(bookForContinueSeries)
}
}
}
}
}
// For recommended
if (!isPodcastLibrary && notStartedBooks.length) {
const genresCount = Object.values(topGenresListened).reduce((a, b) => a + b, 0)
const authorsCount = Object.values(topAuthorsListened).reduce((a, b) => a + b, 0)
const tagsCount = Object.values(topTagsListened).reduce((a, b) => a + b, 0)
for (const libraryItem of notStartedBooks) {
// dont include books in an unfinished series and books that are not first in an unstarted series
let shouldContinue = !libraryItem.media.metadata.series.length
libraryItem.media.metadata.series.forEach((se) => {
if (seriesMap[se.id]) {
if (seriesMap[se.id].inProgress) {
shouldContinue = false
return
} else if (seriesMap[se.id].books[0].id === libraryItem.id) {
shouldContinue = true
}
}
})
if (!shouldContinue) {
continue;
}
let totalWeight = 0
if (authorsCount > 0) {
libraryItem.media.metadata.authors.forEach((author) => {
if (topAuthorsListened[author.id]) {
totalWeight += topAuthorsListened[author.id] / authorsCount
}
})
}
if (genresCount > 0) {
libraryItem.media.metadata.genres.forEach((genre) => {
if (topGenresListened[genre]) {
totalWeight += topGenresListened[genre] / genresCount
}
})
}
if (tagsCount > 0) {
libraryItem.media.tags.forEach((tag) => {
if (topTagsListened[tag]) {
totalWeight += topTagsListened[tag] / tagsCount
}
})
}
if (!categoryMap.recommended.smallest || totalWeight > categoryMap.recommended.smallest) {
const libraryItemObj = {
...libraryItem.toJSONMinified(),
weight: totalWeight
}
const indexToPut = categoryMap.recommended.items.findIndex(i => totalWeight > i.weight)
if (indexToPut >= 0) {
categoryMap.continueSeries.items.splice(indexToPut, 0, bookForContinueSeries)
} else if (categoryMap.continueSeries.items.length < 10) { // Max 10 books
categoryMap.continueSeries.items.push(bookForContinueSeries)
categoryMap.recommended.items.splice(indexToPut, 0, libraryItemObj)
} else {
categoryMap.recommended.items.push(libraryItemObj)
}
if (categoryMap.recommended.items.length > maxEntitiesPerShelf) {
categoryMap.recommended.items.pop()
categoryMap.recommended.smallest = categoryMap.recommended.items[categoryMap.recommended.items.length - 1].weight
}
}
}