mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-24 21:48:45 +02:00
Compare commits
144
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c900676be7 | ||
|
|
a6cde926ee | ||
|
|
e8db14fe33 | ||
|
|
e9b533637f | ||
|
|
8b97da500d | ||
|
|
37b42601ef | ||
|
|
5da4ec51ce | ||
|
|
4c65a87d5c | ||
|
|
0e5618dcd8 | ||
|
|
d063d851e1 | ||
|
|
6458e6d3d2 | ||
|
|
70953d8c82 | ||
|
|
2d9f63963e | ||
|
|
f12abca3fc | ||
|
|
bced308462 | ||
|
|
528d03fef3 | ||
|
|
b93c3133a9 | ||
|
|
8d9db8d103 | ||
|
|
08bbf9cd9d | ||
|
|
95af4812cd | ||
|
|
8ec547e302 | ||
|
|
32e4da8c92 | ||
|
|
0ce37004af | ||
|
|
527745b54e | ||
|
|
3c016314e6 | ||
|
|
59c7d0275c | ||
|
|
4883d0f60c | ||
|
|
9d9ab49664 | ||
|
|
82aec5f60c | ||
|
|
7ee5e1bdf6 | ||
|
|
3bb53d939d | ||
|
|
6f03467f35 | ||
|
|
b2f593f1c4 | ||
|
|
58dcda36a5 | ||
|
|
354e0bf793 | ||
|
|
0784373b61 | ||
|
|
2f219ea3cc | ||
|
|
56e60b8420 | ||
|
|
9b92b5de34 | ||
|
|
3417c0c721 | ||
|
|
cbda0360aa | ||
|
|
036bc081f0 | ||
|
|
e70e4b9d40 | ||
|
|
aacdcc47ec | ||
|
|
499b52b4dd | ||
|
|
1bad2d9072 | ||
|
|
c009db9f28 | ||
|
|
325469c5a5 | ||
|
|
c97b36e11c | ||
|
|
e944b2a2f5 | ||
|
|
2d0a5462d2 | ||
|
|
72dc75482f | ||
|
|
cac74f3477 | ||
|
|
1ad11b2b9e | ||
|
|
50eeca2e0f | ||
|
|
4f21fc023c | ||
|
|
52a485d135 | ||
|
|
3b025076e8 | ||
|
|
6d5d89429d | ||
|
|
c010f0e1eb | ||
|
|
eee377e081 | ||
|
|
b0aaa24660 | ||
|
|
47ea6b5092 | ||
|
|
4b060febc2 | ||
|
|
40869bcf39 | ||
|
|
3942805129 | ||
|
|
dc446862c1 | ||
|
|
379f6c716a | ||
|
|
47457ee1e7 | ||
|
|
cb6ff9eedf | ||
|
|
5dc01261c1 | ||
|
|
cbc103cf05 | ||
|
|
e79256d0fb | ||
|
|
f8ef56c6bc | ||
|
|
62d7097e23 | ||
|
|
92df92ec99 | ||
|
|
1c229e0627 | ||
|
|
f8a71cc514 | ||
|
|
63de5bb2d5 | ||
|
|
2c3108a1fa | ||
|
|
928051744a | ||
|
|
3ccdcaec1a | ||
|
|
f47bbc7886 | ||
|
|
7c0ca44727 | ||
|
|
d6a2e5596b | ||
|
|
a5362de9cc | ||
|
|
9ab35ef418 | ||
|
|
79cc9765cf | ||
|
|
5b2a788cfc | ||
|
|
80b39abaa2 | ||
|
|
b41db23994 | ||
|
|
125f265f55 | ||
|
|
aa4a191567 | ||
|
|
e431ea0472 | ||
|
|
e3388d4446 | ||
|
|
88879f1409 | ||
|
|
3e0099e8d9 | ||
|
|
f558182d94 | ||
|
|
a30fe15b10 | ||
|
|
0bbf8bde5c | ||
|
|
0e2cdde731 | ||
|
|
bc6bfbe804 | ||
|
|
2755204168 | ||
|
|
2d4df273f0 | ||
|
|
d73b64a19c | ||
|
|
b7e8a0474a | ||
|
|
39adefb632 | ||
|
|
24cab79c66 | ||
|
|
b27f21fd95 | ||
|
|
09fa0b38f5 | ||
|
|
455e605162 | ||
|
|
88667d00a1 | ||
|
|
94c426bd97 | ||
|
|
522b9735e2 | ||
|
|
5a6b3d8e61 | ||
|
|
64cbf59609 | ||
|
|
fda1a6ea9b | ||
|
|
c4c8b8d0f2 | ||
|
|
ab3bd6f4a1 | ||
|
|
093124aac6 | ||
|
|
5de92d08f9 | ||
|
|
8b89b27654 | ||
|
|
3faa6f3e7d | ||
|
|
9821c31f8e | ||
|
|
efe2a22674 | ||
|
|
9634c46bc5 | ||
|
|
5f8db24b96 | ||
|
|
e781ff5eae | ||
|
|
32a17c0044 | ||
|
|
f84831d6f1 | ||
|
|
dc54d42dcf | ||
|
|
15af7407ff | ||
|
|
5d9682410a | ||
|
|
4bdd76d94c | ||
|
|
7c0d9efe91 | ||
|
|
874e9e1856 | ||
|
|
d9355ac3aa | ||
|
|
a9e12657f5 | ||
|
|
cfeb6bd502 | ||
|
|
077b523bd6 | ||
|
|
b8a2d113f0 | ||
|
|
e1ae4f2d31 | ||
|
|
7aa2f84daa | ||
|
|
da0a64daed |
@@ -196,6 +196,7 @@ export default {
|
||||
requestBatchQuickEmbed() {
|
||||
const payload = {
|
||||
message: this.$strings.MessageConfirmQuickEmbed,
|
||||
allowHtml: true,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.$axios
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<div v-if="entityName === 'collections' || entityName === 'playlists'" class="flex justify-center mt-4">
|
||||
{{ emptyMessageHelp }}
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/collections" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/playlists" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<div class="px-6 py-8 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<div class="absolute top-0 right-0 p-4">
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/media-item-shares" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/public-shares#media-item-shares" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<div class="text-sm flex items-center justify-center text-gray-200">
|
||||
<p>{{ $strings.MessageBookshelfNoCollectionsHelp }}</p>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/collections" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/playlists" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
@@ -227,7 +227,7 @@ export default {
|
||||
.catch((error) => {
|
||||
console.error('Failed to create collection', error)
|
||||
var errMsg = error.response ? error.response.data || '' : ''
|
||||
this.$toast.error(this.$strings.ToastCollectionCreateFailed + ': ' + errMsg)
|
||||
this.$toast.error(errMsg)
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -158,6 +158,8 @@ export default {
|
||||
this.isProcessing = true
|
||||
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, updatePayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
const errorMessage = typeof error?.response?.data === 'string' ? error?.response?.data : null
|
||||
this.$toast.error(errorMessage || this.$strings.ToastFailedToUpdate)
|
||||
return false
|
||||
})
|
||||
this.isProcessing = false
|
||||
|
||||
@@ -107,6 +107,7 @@ export default {
|
||||
quickEmbed() {
|
||||
const payload = {
|
||||
message: this.$strings.MessageConfirmQuickEmbed,
|
||||
allowHtml: true,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.$axios
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<div class="flex items-center justify-between md:justify-start mb-4">
|
||||
<p class="text-sm text-gray-300 pr-2">{{ $strings.LabelMetadataOrderOfPrecedenceDescription }}</p>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex">
|
||||
<a href="https://www.audiobookshelf.org/guides/book-scanner" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/libraries/book-library/book-metadata" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<div class="text-sm flex items-center justify-center text-gray-200">
|
||||
<p>{{ $strings.MessageNoUserPlaylistsHelp }}</p>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/collections" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/playlists" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-linear-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
||||
<div ref="content" class="relative text-white" :style="{ height: modalHeight, width: modalWidth }" v-click-outside="clickedOutside">
|
||||
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
|
||||
<p id="confirm-prompt-message" class="text-lg mb-6 mt-2 px-1" v-html="message" />
|
||||
<p v-if="allowHtmlMessage" id="confirm-prompt-message" class="text-lg mb-6 mt-2 px-1" v-html="sanitizedMessage" />
|
||||
<p v-else id="confirm-prompt-message" class="text-lg mb-6 mt-2 px-1">{{ message }}</p>
|
||||
|
||||
<ui-checkbox v-if="checkboxLabel" v-model="checkboxValue" checkbox-bg="bg" :label="checkboxLabel" label-class="pl-2 text-base" class="mb-6 px-1" />
|
||||
|
||||
@@ -52,6 +53,17 @@ export default {
|
||||
message() {
|
||||
return this.confirmPromptOptions.message || ''
|
||||
},
|
||||
allowHtmlMessage() {
|
||||
return !!this.confirmPromptOptions.allowHtml
|
||||
},
|
||||
sanitizedMessage() {
|
||||
if (!this.allowHtmlMessage) return this.message
|
||||
|
||||
return this.escapeHtml(this.message)
|
||||
.replace(/<br\s*\/?>/gi, '<br>')
|
||||
.replace(/<code>/gi, '<code>')
|
||||
.replace(/<\/code>/gi, '</code>')
|
||||
},
|
||||
callback() {
|
||||
return this.confirmPromptOptions.callback
|
||||
},
|
||||
@@ -103,6 +115,14 @@ export default {
|
||||
if (this.callback) this.callback(true, this.checkboxValue)
|
||||
this.show = false
|
||||
},
|
||||
escapeHtml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
},
|
||||
setShow() {
|
||||
this.checkboxValue = this.checkboxDefaultValue
|
||||
this.$eventBus.$emit('showing-prompt', true)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="relative">
|
||||
<input :id="inputId" :name="inputName" ref="input" v-model="inputValue" :type="actualType" :step="step" :min="min" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" dir="auto" class="rounded-sm bg-primary text-gray-200 focus:bg-bg focus:outline-hidden border h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
|
||||
<input :id="inputId" :name="inputName" ref="input" v-model="inputValue" :type="actualType" :step="step" :min="min" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" :autocomplete="autocomplete" dir="auto" class="rounded-sm bg-primary text-gray-200 focus:bg-bg focus:outline-hidden border h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
|
||||
<div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center">
|
||||
<span class="material-symbols text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
|
||||
</div>
|
||||
@@ -41,7 +41,8 @@ export default {
|
||||
step: [String, Number],
|
||||
min: [String, Number],
|
||||
customInputClass: String,
|
||||
trimWhitespace: Boolean
|
||||
trimWhitespace: Boolean,
|
||||
autocomplete: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
|
||||
</label>
|
||||
</slot>
|
||||
<ui-text-input :placeholder="placeholder || label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" :min="min" :show-copy="showCopy" class="w-full" :class="inputClass" :trim-whitespace="trimWhitespace" @blur="inputBlurred" />
|
||||
<ui-text-input :placeholder="placeholder || label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" :min="min" :show-copy="showCopy" :autocomplete="autocomplete" class="w-full" :class="inputClass" :trim-whitespace="trimWhitespace" @blur="inputBlurred" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -26,7 +26,8 @@ export default {
|
||||
disabled: Boolean,
|
||||
inputClass: String,
|
||||
showCopy: Boolean,
|
||||
trimWhitespace: Boolean
|
||||
trimWhitespace: Boolean,
|
||||
autocomplete: String
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.33.0",
|
||||
"version": "2.35.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.33.0",
|
||||
"version": "2.35.1",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.33.0",
|
||||
"version": "2.35.1",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast client",
|
||||
"main": "index.js",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</div>
|
||||
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/api-keys" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/server-management/api-keys" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<ui-checkbox v-model="enableOpenIDAuth" checkbox-bg="bg" />
|
||||
<p class="text-lg pl-4">{{ $strings.HeaderOpenIDConnectAuthentication }}</p>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/oidc_authentication" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/server-management/oidc-authentication" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<app-settings-content :header-text="$strings.HeaderEmailSettings" :description="''">
|
||||
<template #header-items>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/send_to_ereader" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/libraries/book-library/ebooks" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
||||
@@ -390,8 +390,8 @@ export default {
|
||||
},
|
||||
purgeItemsCache() {
|
||||
const payload = {
|
||||
// message: `This will delete the entire folder at <code>/metadata/cache/items</code>.<br />Are you sure you want to purge items cache?`,
|
||||
message: this.$strings.MessageConfirmPurgeItemsCache,
|
||||
allowHtml: true,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.sendPurgeItemsCache()
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</template>
|
||||
<template #header-items>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/custom-metadata-providers" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/community/community-providers" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
||||
@@ -90,9 +90,9 @@ export default {
|
||||
|
||||
let message = this.$getString('MessageConfirmRenameGenre', [this.editingGenre, this.newGenreName])
|
||||
if (genreNameExists) {
|
||||
message += `<br><span class="text-sm">${this.$strings.MessageConfirmRenameGenreMergeNote}</span>`
|
||||
message += ` ${this.$strings.MessageConfirmRenameGenreMergeNote}`
|
||||
} else if (genreNameExistsOfDifferentCase) {
|
||||
message += `<br><span class="text-warning text-sm">${this.$getString('MessageConfirmRenameGenreWarning', [genreNameExistsOfDifferentCase])}</span>`
|
||||
message += ` ${this.$getString('MessageConfirmRenameGenreWarning', [genreNameExistsOfDifferentCase])}`
|
||||
}
|
||||
|
||||
const payload = {
|
||||
|
||||
@@ -86,9 +86,9 @@ export default {
|
||||
|
||||
let message = this.$getString('MessageConfirmRenameTag', [this.editingTag, this.newTagName])
|
||||
if (tagNameExists) {
|
||||
message += `<br><span class="text-sm">${this.$strings.MessageConfirmRenameTagMergeNote}</span>`
|
||||
message += ` ${this.$strings.MessageConfirmRenameTagMergeNote}`
|
||||
} else if (tagNameExistsOfDifferentCase) {
|
||||
message += `<br><span class="text-warning text-sm">${this.$getString('MessageConfirmRenameTagWarning', [tagNameExistsOfDifferentCase])}</span>`
|
||||
message += ` ${this.$getString('MessageConfirmRenameTagWarning', [tagNameExistsOfDifferentCase])}`
|
||||
}
|
||||
|
||||
const payload = {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<app-settings-content :header-text="$strings.HeaderLibraries">
|
||||
<template #header-items>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/library_creation" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/overview" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<app-settings-content :header-text="$strings.HeaderLogs" :description="$strings.MessageLogsDescription">
|
||||
<template #header-items>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/server_logs" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/server-management/server-logs" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<app-settings-content :header-text="$strings.HeaderRSSFeeds">
|
||||
<template #header-items>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/rss_feeds" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/public-shares#rss-feed" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</div>
|
||||
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/users" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/server-management/user-management" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
|
||||
<form @submit.prevent="submitServerSetup">
|
||||
<p class="text-lg font-semibold mb-2 pl-1 text-center">Create Root User</p>
|
||||
<ui-text-input-with-label v-model.trim="newRoot.username" label="Username" :disabled="processing" class="w-full mb-3 text-sm" />
|
||||
<ui-text-input-with-label v-model="newRoot.password" label="Password" type="password" :disabled="processing" class="w-full mb-3 text-sm" />
|
||||
<ui-text-input-with-label v-model="confirmPassword" label="Confirm Password" type="password" :disabled="processing" class="w-full mb-3 text-sm" />
|
||||
<ui-text-input-with-label v-model.trim="newRoot.username" label="Username" autocomplete="username" :disabled="processing" class="w-full mb-3 text-sm" />
|
||||
<ui-text-input-with-label v-model="newRoot.password" label="Password" type="password" autocomplete="new-password" :disabled="processing" class="w-full mb-3 text-sm" />
|
||||
<ui-text-input-with-label v-model="confirmPassword" label="Confirm Password" type="password" autocomplete="new-password" :disabled="processing" class="w-full mb-3 text-sm" />
|
||||
|
||||
<p class="text-lg font-semibold mt-6 mb-2 pl-1 text-center">Directory Paths</p>
|
||||
<ui-text-input-with-label v-model="ConfigPath" label="Config Path" disabled class="w-full mb-3 text-sm" />
|
||||
@@ -51,10 +51,10 @@
|
||||
|
||||
<form v-show="login_local" @submit.prevent="submitForm">
|
||||
<label class="text-xs text-gray-300 uppercase">{{ $strings.LabelUsername }}</label>
|
||||
<ui-text-input v-model.trim="username" :disabled="processing" class="mb-3 w-full" inputName="username" />
|
||||
<ui-text-input v-model.trim="username" autocomplete="username" :disabled="processing" class="mb-3 w-full" inputName="username" />
|
||||
|
||||
<label class="text-xs text-gray-300 uppercase">{{ $strings.LabelPassword }}</label>
|
||||
<ui-text-input v-model.trim="password" type="password" :disabled="processing" class="w-full mb-3" inputName="password" />
|
||||
<ui-text-input v-model.trim="password" type="password" autocomplete="current-password" :disabled="processing" class="w-full mb-3" inputName="password" />
|
||||
<div class="w-full flex justify-end py-3">
|
||||
<ui-btn type="submit" :disabled="processing" color="bg-primary" class="leading-none">{{ processing ? 'Checking...' : $strings.ButtonSubmit }}</ui-btn>
|
||||
</div>
|
||||
|
||||
@@ -364,6 +364,7 @@ export default {
|
||||
}
|
||||
|
||||
const startTime = this.playbackSession.currentTime || 0
|
||||
|
||||
this.localAudioPlayer.set(null, this.audioTracks, false, startTime, false)
|
||||
this.localAudioPlayer.on('stateChange', this.playerStateChange.bind(this))
|
||||
this.localAudioPlayer.on('timeupdate', this.playerTimeUpdate.bind(this))
|
||||
|
||||
@@ -46,7 +46,20 @@ export default class LocalAudioPlayer extends EventEmitter {
|
||||
this.player.addEventListener('loadedmetadata', this.evtLoadedMetadata.bind(this))
|
||||
this.player.addEventListener('timeupdate', this.evtTimeupdate.bind(this))
|
||||
|
||||
var mimeTypes = ['audio/flac', 'audio/mpeg', 'audio/mp4', 'audio/ogg', 'audio/aac', 'audio/x-ms-wma', 'audio/x-aiff', 'audio/webm']
|
||||
var mimeTypes = [
|
||||
'audio/flac',
|
||||
'audio/mpeg',
|
||||
'audio/mp4',
|
||||
'audio/ogg',
|
||||
'audio/aac',
|
||||
'audio/x-ms-wma',
|
||||
'audio/x-aiff',
|
||||
'audio/webm',
|
||||
// `audio/matroska` is the correct mimetype, but the server still uses `audio/x-matroska`
|
||||
// ref: https://www.iana.org/assignments/media-types/media-types.xhtml
|
||||
'audio/matroska',
|
||||
'audio/x-matroska'
|
||||
]
|
||||
var mimeTypeCanPlayMap = {}
|
||||
mimeTypes.forEach((mt) => {
|
||||
var canPlay = this.player.canPlayType(mt)
|
||||
|
||||
@@ -21,6 +21,7 @@ const languageCodeMap = {
|
||||
he: { label: 'עברית', dateFnsLocale: 'he' },
|
||||
hr: { label: 'Hrvatski', dateFnsLocale: 'hr' },
|
||||
it: { label: 'Italiano', dateFnsLocale: 'it' },
|
||||
ja: { label: '日本語', dateFnsLocale: 'ja' },
|
||||
lt: { label: 'Lietuvių', dateFnsLocale: 'lt' },
|
||||
hu: { label: 'Magyar', dateFnsLocale: 'hu' },
|
||||
ko: { label: '한국어', dateFnsLocale: 'ko' },
|
||||
@@ -60,6 +61,7 @@ const podcastSearchRegionMap = {
|
||||
hr: { label: 'Hrvatska' },
|
||||
il: { label: 'ישראל / إسرائيل' },
|
||||
it: { label: 'Italia' },
|
||||
jp: { label: '日本' },
|
||||
lu: { label: 'Luxembourg / Luxemburg / Lëtezebuerg' },
|
||||
hu: { label: 'Magyarország' },
|
||||
nl: { label: 'Nederland' },
|
||||
|
||||
@@ -244,6 +244,8 @@
|
||||
"LabelAlreadyInYourLibrary": "موجود بالفعل في مكتبتك",
|
||||
"LabelApiKeyCreated": "تم إنشاء مفتاح API \"{0}\" بنجاح.",
|
||||
"LabelApiKeyCreatedDescription": "تأكد من نسخ مفتاح API الآن، لن تتمكن من رؤيته مرة أخرى.",
|
||||
"LabelApiKeyUser": "التصرف بالنيابة عن مستخدم",
|
||||
"LabelApiKeyUserDescription": "مفتاح API سيمتلك نفس صلاحيات المستخدم الذي ينوب عنه ، سيظهر بالسجلات وكأن المستخدم قام بالطلب.",
|
||||
"LabelApiToken": "رمز API",
|
||||
"LabelAppend": "إلحاق",
|
||||
"LabelAudioBitrate": "معدل بت الصوت (على سبيل المثال 128 كيلو بايت)",
|
||||
@@ -293,6 +295,7 @@
|
||||
"LabelContinueListening": "استمرار الاستماع",
|
||||
"LabelContinueReading": "استمرار القراءة",
|
||||
"LabelContinueSeries": "استمرار المسلسلات",
|
||||
"LabelCorsAllowed": "CORS Origins مسموح",
|
||||
"LabelCover": "الغلاف",
|
||||
"LabelCoverImageURL": "رابط صورة الغلاف",
|
||||
"LabelCoverProvider": "مزود الغلاف",
|
||||
@@ -426,6 +429,9 @@
|
||||
"LabelLibraryFilterSublistEmpty": "لا يوجد {0}",
|
||||
"LabelLibraryItem": "عنصر المكتبة",
|
||||
"LabelLibraryName": "اسم المكتبة",
|
||||
"LabelLibrarySortByProgress": "المرحلة: الأحدث",
|
||||
"LabelLibrarySortByProgressFinished": "المرحلة: تم الانتهاء",
|
||||
"LabelLibrarySortByProgressStarted": "المرحلة: تم البدء",
|
||||
"LabelLimit": "حد",
|
||||
"LabelLineSpacing": "تباعد الأسطر",
|
||||
"LabelListenAgain": "الاستماع مجدداً",
|
||||
|
||||
+45
-45
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"ButtonAdd": "Дадаць",
|
||||
"ButtonAddApiKey": "Дадаць API-ключ",
|
||||
"ButtonAddApiKey": "Дадаць ключ API",
|
||||
"ButtonAddChapters": "Дадаць раздзелы",
|
||||
"ButtonAddDevice": "Дадаць прыладу",
|
||||
"ButtonAddLibrary": "Дадаць бібліятэку",
|
||||
@@ -16,7 +16,7 @@
|
||||
"ButtonBrowseForFolder": "Агляд папак",
|
||||
"ButtonCancel": "Скасаваць",
|
||||
"ButtonCancelEncode": "Скасаваць кадзіраванне",
|
||||
"ButtonChangeRootPassword": "Зменіце Root пароль",
|
||||
"ButtonChangeRootPassword": "Змяніць пароль root",
|
||||
"ButtonCheckAndDownloadNewEpisodes": "Праверыць і спампаваць новыя выпускі",
|
||||
"ButtonChooseAFolder": "Выбраць папку",
|
||||
"ButtonChooseFiles": "Выбраць файлы",
|
||||
@@ -50,7 +50,7 @@
|
||||
"ButtonManageTracks": "Кіраванне трэкамі",
|
||||
"ButtonMapChapterTitles": "Супаставіць загалоўкі раздзелаў",
|
||||
"ButtonMatchAllAuthors": "Супадзенне ўсіх аўтараў",
|
||||
"ButtonMatchBooks": "Падбор кніг",
|
||||
"ButtonMatchBooks": "Параўнаць кнігі",
|
||||
"ButtonNevermind": "Няважна",
|
||||
"ButtonNext": "Далей",
|
||||
"ButtonNextChapter": "Наступны раздзел",
|
||||
@@ -81,14 +81,14 @@
|
||||
"ButtonRemove": "Выдаліць",
|
||||
"ButtonRemoveAll": "Выдаліць усе",
|
||||
"ButtonRemoveAllLibraryItems": "Выдаліць усе элементы бібліятэкі",
|
||||
"ButtonRemoveFromContinueListening": "Выдаліць з Працягваць слухаць",
|
||||
"ButtonRemoveFromContinueListening": "Выдаліць з Працяг праслухоўвання",
|
||||
"ButtonRemoveFromContinueReading": "Выдаліць з Працягваць чытанне",
|
||||
"ButtonRemoveSeriesFromContinueSeries": "Выдаліць серыю з Працягваць серыю",
|
||||
"ButtonReset": "Скінуць",
|
||||
"ButtonResetToDefault": "Скінуць да прадвызначаных",
|
||||
"ButtonRestore": "Аднавіць",
|
||||
"ButtonSave": "Захаваць",
|
||||
"ButtonSaveAndClose": "Захаваць і зачыніць",
|
||||
"ButtonSaveAndClose": "Захаваць і закрыць",
|
||||
"ButtonSaveTracklist": "Захаваць спіс трэкаў",
|
||||
"ButtonScan": "Сканаваць",
|
||||
"ButtonScanLibrary": "Сканіраваць бібліятэку",
|
||||
@@ -107,10 +107,10 @@
|
||||
"ButtonSubmit": "Адправіць",
|
||||
"ButtonTest": "Тэст",
|
||||
"ButtonUnlinkOpenId": "Адвязаць OpenID",
|
||||
"ButtonUpload": "Загрузіць",
|
||||
"ButtonUploadBackup": "Загрузіць рэзервовую копію",
|
||||
"ButtonUploadCover": "Загрузіць вокладку",
|
||||
"ButtonUploadOPMLFile": "Загрузіць файл OPML",
|
||||
"ButtonUpload": "Запампаваць",
|
||||
"ButtonUploadBackup": "Запампаваць рэзервовую копію",
|
||||
"ButtonUploadCover": "Запампаваць вокладку",
|
||||
"ButtonUploadOPMLFile": "Запампаваць файл OPML",
|
||||
"ButtonUserDelete": "Выдаліць карыстальніка {0}",
|
||||
"ButtonUserEdit": "Рэдагаваць карыстальніка {0}",
|
||||
"ButtonViewAll": "Прагледзець усе",
|
||||
@@ -121,7 +121,7 @@
|
||||
"HeaderAccount": "Уліковы запіс",
|
||||
"HeaderAddCustomMetadataProvider": "Дадаванне карыстальніцкага пастаўшчыка метаданых",
|
||||
"HeaderAdvanced": "Дадаткова",
|
||||
"HeaderApiKeys": "API-ключы",
|
||||
"HeaderApiKeys": "Ключы API",
|
||||
"HeaderAppriseNotificationSettings": "Налады апавяшчэнняў Apprise",
|
||||
"HeaderAudioTracks": "Аўдыятрэкі",
|
||||
"HeaderAudiobookTools": "Сродкі кіравання файламі аўдыякніг",
|
||||
@@ -149,7 +149,7 @@
|
||||
"HeaderFindChapters": "Знайсці раздзелы",
|
||||
"HeaderIgnoredFiles": "Ігнараваныя файлы",
|
||||
"HeaderItemFiles": "Файлы элементаў",
|
||||
"HeaderItemMetadataUtils": "Утыліты для метаданых элементаў",
|
||||
"HeaderItemMetadataUtils": "Утыліты для метаданых",
|
||||
"HeaderLastListeningSession": "Апошні сеанс праслухоўвання",
|
||||
"HeaderLatestEpisodes": "Апошнія выпускі",
|
||||
"HeaderLibraries": "Бібліятэкі",
|
||||
@@ -166,7 +166,7 @@
|
||||
"HeaderMetadataOrderOfPrecedence": "Парадак прыярытэту метаданых",
|
||||
"HeaderMetadataToEmbed": "Метаданыя для ўбудавання",
|
||||
"HeaderNewAccount": "Новы ўліковы запіс",
|
||||
"HeaderNewApiKey": "Новы API-ключ",
|
||||
"HeaderNewApiKey": "Новы ключ API",
|
||||
"HeaderNewLibrary": "Новая бібліятэка",
|
||||
"HeaderNotificationCreate": "Стварыць апавяшчэнне",
|
||||
"HeaderNotificationUpdate": "Абнавіць апавяшчэнне",
|
||||
@@ -196,7 +196,7 @@
|
||||
"HeaderSession": "Сеанс",
|
||||
"HeaderSetBackupSchedule": "Наладзіць расклад рэзервовага капіравання",
|
||||
"HeaderSettings": "Налады",
|
||||
"HeaderSettingsDisplay": "Дысплей",
|
||||
"HeaderSettingsDisplay": "Выгляд",
|
||||
"HeaderSettingsExperimental": "Эксперыментальныя функцыі",
|
||||
"HeaderSettingsGeneral": "Агульныя",
|
||||
"HeaderSettingsScanner": "Сканер",
|
||||
@@ -207,12 +207,12 @@
|
||||
"HeaderStatsLongestItems": "Найдаўжэйшыя элементы (гадзіны)",
|
||||
"HeaderStatsMinutesListeningChart": "Хвілін праслухоўвання (апошнія 7 дзён)",
|
||||
"HeaderStatsRecentSessions": "Апошнія сеансы",
|
||||
"HeaderStatsTop10Authors": "10 лепшых аўтараў",
|
||||
"HeaderStatsTop5Genres": "5 лепшых жанраў",
|
||||
"HeaderStatsTop10Authors": "Топ 10 аўтараў",
|
||||
"HeaderStatsTop5Genres": "Топ 5 жанраў",
|
||||
"HeaderTableOfContents": "Змест",
|
||||
"HeaderTools": "Інструменты",
|
||||
"HeaderUpdateAccount": "Абнавіць уліковы запіс",
|
||||
"HeaderUpdateApiKey": "Абнавіць API-ключ",
|
||||
"HeaderUpdateApiKey": "Абнавіць ключ API",
|
||||
"HeaderUpdateAuthor": "Абнавіць аўтара",
|
||||
"HeaderUpdateDetails": "Абнавіць падрабязнасці",
|
||||
"HeaderUpdateLibrary": "Абнавіць бібліятэку",
|
||||
@@ -239,21 +239,21 @@
|
||||
"LabelAll": "Усе",
|
||||
"LabelAllEpisodesDownloaded": "Усе выпускі спампаваныя",
|
||||
"LabelAllUsers": "Усе карыстальнікі",
|
||||
"LabelAllUsersExcludingGuests": "Усе карыстальнікі, акрамя гасцей",
|
||||
"LabelAllUsersIncludingGuests": "Усе карыстальнікі, уключаючы гасцей",
|
||||
"LabelAllUsersExcludingGuests": "Усіх карыстальнікаў, акрамя гасцей",
|
||||
"LabelAllUsersIncludingGuests": "Усіх карыстальнікаў, уключаючы гасцей",
|
||||
"LabelAlreadyInYourLibrary": "Ужо ў вашай бібліятэцы",
|
||||
"LabelApiKeyCreated": "API-ключ \"{0}\" паспяхова створаны.",
|
||||
"LabelApiKeyCreatedDescription": "Пераканайцеся, што вы скапіявалі API-ключ зараз, бо паўторна яго ўбачыць не атрымаецца.",
|
||||
"LabelApiKeyCreated": "Ключ API \"{0}\" паспяхова створаны.",
|
||||
"LabelApiKeyCreatedDescription": "Абавязкова скапіюйце ключ API зараз, бо паўторна яго ўбачыць не атрымаецца.",
|
||||
"LabelApiKeyUser": "Дзейнічаць ад імя карыстальніка",
|
||||
"LabelApiKeyUserDescription": "Гэты API-ключ будзе мець тыя ж правы, што і карыстальнік, ад імя якога ён дзейнічае. У журналах гэта будзе выглядаць так, быццам запыт робіць сам карыстальнік.",
|
||||
"LabelApiKeyUserDescription": "Гэты ключ API будзе мець тыя ж правы, што і карыстальнік, ад імя якога ён дзейнічае. У журналах гэта будзе выглядаць так, быццам запыт робіць сам карыстальнік.",
|
||||
"LabelApiToken": "Токен API",
|
||||
"LabelAppend": "Дадаць",
|
||||
"LabelAudioBitrate": "Бітрэйт аўдыя (напрыклад, 128к)",
|
||||
"LabelAudioChannels": "Аўдыяканалы (1 або 2)",
|
||||
"LabelAudioCodec": "Аўдыякодэк",
|
||||
"LabelAuthor": "Аўтар",
|
||||
"LabelAuthorFirstLast": "Аўтар (Імя Прозвішча)",
|
||||
"LabelAuthorLastFirst": "Аўтар (Прозвішча, Імя)",
|
||||
"LabelAuthorFirstLast": "Аўтар (імя, прозвішча)",
|
||||
"LabelAuthorLastFirst": "Аўтар (прозвішча, імя)",
|
||||
"LabelAuthors": "Аўтары",
|
||||
"LabelAutoDownloadEpisodes": "Аўтаматычна спампоўваць выпускі",
|
||||
"LabelAutoFetchMetadata": "Аўтаматычнае атрыманне метаданых",
|
||||
@@ -264,7 +264,7 @@
|
||||
"LabelAutoRegisterDescription": "Аўтаматычна ствараць новых карыстальнікаў пасля ўваходу ў сістэму",
|
||||
"LabelBackToUser": "Вярнуцца да карыстальніка",
|
||||
"LabelBackupAudioFiles": "Рэзервовае капіраванне аўдыяфайлаў",
|
||||
"LabelBackupLocation": "Месцазнаходжанне рэзервовых копій",
|
||||
"LabelBackupLocation": "Размяшчэнне рэзервовых копій",
|
||||
"LabelBackupsEnableAutomaticBackups": "Аўтаматычнае рэзервовае капіраванне",
|
||||
"LabelBackupsEnableAutomaticBackupsHelp": "Рэзервовыя копіі захаваныя ў /metadata/backups",
|
||||
"LabelBackupsMaxBackupSize": "Максімальны памер рэзервовай копіі (у ГБ) (0 — неабмежавана)",
|
||||
@@ -284,7 +284,7 @@
|
||||
"LabelChaptersFound": "раздзелаў знойдзена",
|
||||
"LabelClickForMoreInfo": "Націсніце для больш падрабязнай інфармацыі",
|
||||
"LabelClickToUseCurrentValue": "Націсніце, каб выкарыстоўваць бягучае значэнне",
|
||||
"LabelClosePlayer": "Зачыніць прайгравальнік",
|
||||
"LabelClosePlayer": "Закрыць прайгравальнік",
|
||||
"LabelCodec": "Кодэк",
|
||||
"LabelCollapseSeries": "Згарнуць серыі",
|
||||
"LabelCollapseSubSeries": "Згарнуць падсерыі",
|
||||
@@ -292,7 +292,7 @@
|
||||
"LabelCollections": "Калекцыі",
|
||||
"LabelComplete": "Завяршыць",
|
||||
"LabelConfirmPassword": "Пацвердзіце пароль",
|
||||
"LabelContinueListening": "Працягваць слухаць",
|
||||
"LabelContinueListening": "Працяг праслухоўвання",
|
||||
"LabelContinueReading": "Працягнуць чытанне",
|
||||
"LabelContinueSeries": "Працягнуць серыі",
|
||||
"LabelCorsAllowed": "Дазволеныя крыніцы CORS",
|
||||
@@ -316,7 +316,7 @@
|
||||
"LabelDirectory": "Каталог",
|
||||
"LabelDiscFromFilename": "Дыск з файла",
|
||||
"LabelDiscFromMetadata": "Дыск з метаданых",
|
||||
"LabelDiscover": "Знайсці",
|
||||
"LabelDiscover": "Знаходкі",
|
||||
"LabelDownload": "Спампаваць",
|
||||
"LabelDownloadNEpisodes": "Спампавана {0} выпускаў",
|
||||
"LabelDownloadable": "Спампоўваецца",
|
||||
@@ -332,7 +332,7 @@
|
||||
"LabelEmailSettingsFromAddress": "Адрас адпраўніка",
|
||||
"LabelEmailSettingsRejectUnauthorized": "Адхіляць неаўтарызаваныя сертыфікаты",
|
||||
"LabelEmailSettingsRejectUnauthorizedHelp": "Адключэнне праверкі SSL-сертыфіката можа зрабіць ваша злучэнне ўразлівым перад пагрозамі бяспекі, такімі як атакі \"чалавек пасярэдзіне\". Адключайце гэтую опцыю толькі калі цалкам разумееце наступствы і ўпэўнены ў надзейнасці паштовага сервера.",
|
||||
"LabelEmailSettingsSecure": "Бяспечныя",
|
||||
"LabelEmailSettingsSecure": "Бяспечна",
|
||||
"LabelEmailSettingsSecureHelp": "Калі ўключана, злучэнне будзе выкарыстоўваць TLS пры падключэнні да сервера. Калі выключана, TLS будзе выкарыстоўвацца толькі ў выпадку падтрымкі пашырэння STARTTLS на серверы. У большасці выпадкаў усталюйце значэнне true пры падключэнні да порта 465. Для партоў 587 або 25 не ўключайце яго. (інфармацыя з nodemailer.com/smtp/#authentication)",
|
||||
"LabelEmailSettingsTestAddress": "Тэставы адрас",
|
||||
"LabelEmbeddedCover": "Убудаваная вокладка",
|
||||
@@ -424,7 +424,7 @@
|
||||
"LabelLastBookAdded": "Апошняя дададзеная кніга",
|
||||
"LabelLastBookUpdated": "Апошняя абноўленая кніга",
|
||||
"LabelLastProgressDate": "Апошні прагрэс: {0}",
|
||||
"LabelLastSeen": "Апошні прагляд",
|
||||
"LabelLastSeen": "Апошняя актыўнасць",
|
||||
"LabelLastTime": "Апошні раз",
|
||||
"LabelLastUpdate": "Апошняе абнаўленне",
|
||||
"LabelLayout": "Знешні выгляд",
|
||||
@@ -545,7 +545,7 @@
|
||||
"LabelRSSFeedSlug": "Ідэнтыфікатар RSS-стужкі",
|
||||
"LabelRSSFeedURL": "URL RSS-стужкі",
|
||||
"LabelRandomly": "Выпадкова",
|
||||
"LabelReAddSeriesToContinueListening": "Дадаць серыю зноў у Працягваць слухаць",
|
||||
"LabelReAddSeriesToContinueListening": "Дадаць серыю зноў у Працяг праслухоўвання",
|
||||
"LabelRead": "Чытаць",
|
||||
"LabelReadAgain": "Чытаць зноў",
|
||||
"LabelReadEbookWithoutProgress": "Чытаць электронную кнігу без захавання прагрэсу",
|
||||
@@ -588,23 +588,23 @@
|
||||
"LabelSettingsBookshelfViewHelp": "Рэалістычны дызайн з драўлянымі паліцамі",
|
||||
"LabelSettingsChromecastSupport": "Падтрымка Chromecast",
|
||||
"LabelSettingsDateFormat": "Фарматы даты",
|
||||
"LabelSettingsEnableWatcher": "Аўтаматычна сачыць за зменамі ў бібліятэцках",
|
||||
"LabelSettingsEnableWatcher": "Аўтаматычна сачыць за зменамі ў бібліятэках",
|
||||
"LabelSettingsEnableWatcherForLibrary": "Аўтаматычна сачыць за зменамі ў бібліятэцы",
|
||||
"LabelSettingsEnableWatcherHelp": "Адключае аўтаматычнае дадаванне/абнаўленне элементаў пры выяўленні змен у файлах. *Патрабуецца перазапуск сервера",
|
||||
"LabelSettingsEpubsAllowScriptedContent": "Дазваляць скрыпты ў файлах EPUB",
|
||||
"LabelSettingsEpubsAllowScriptedContentHelp": "Дазволіць файлам EPUB выконваць скрыпты. Рэкамендуецца пакінуць гэтую наладу выключанай, калі вы не давяраеце крыніцы файлаў EPUB.",
|
||||
"LabelSettingsExperimentalFeatures": "Эксперыментальныя функцыі",
|
||||
"LabelSettingsExperimentalFeaturesHelp": "Функцыі ў распрацоўцы, для якіх вашы водгукі і дапамога ў тэставанні будуць карыснымі. Націсніце, каб адкрыць абмеркаванне на GitHub.",
|
||||
"LabelSettingsFindCovers": "Знайсці вокладкі",
|
||||
"LabelSettingsFindCovers": "Шукаць вокладкі",
|
||||
"LabelSettingsFindCoversHelp": "Калі ў вашай аўдыякнізе няма ўбудаванай вокладкі або відарыса вокладкі ў папцы, сканер паспрабуе знайсці вокладку.<br>Заўвага: гэта павялічыць час сканіравання",
|
||||
"LabelSettingsHideSingleBookSeries": "Схаваць серыі з адной кнігай",
|
||||
"LabelSettingsHideSingleBookSeriesHelp": "Серыі, якія змяшчаюць толькі адну кнігу, будуць схаваны са старонкі серый і паліц на галоўнай старонцы.",
|
||||
"LabelSettingsHomePageBookshelfView": "Галоўная старонка выкарыстоўвае выгляд кніжнай паліцы",
|
||||
"LabelSettingsLibraryBookshelfView": "Бібліятэка выкарыстоўвае выгляд кніжнай паліцы",
|
||||
"LabelSettingsHomePageBookshelfView": "Кніжныя паліцы на галоўнай старонцы",
|
||||
"LabelSettingsLibraryBookshelfView": "Кніжныя паліцы ў бібліятэцы",
|
||||
"LabelSettingsLibraryMarkAsFinishedPercentComplete": "Працэнт завяршэння большы за",
|
||||
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Час, што застаўся, менш за (секунд)",
|
||||
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Час, што застаўся, меншы за (секунд)",
|
||||
"LabelSettingsLibraryMarkAsFinishedWhen": "Пазначыць элемент медыя як завершаны, калі",
|
||||
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Пропусціць папярэднія кнігі ў \"Працягнуць серыю\"",
|
||||
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Прапусціць папярэднія кнігі ў \"Працягнуць серыю\"",
|
||||
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Паліца \"Працягнуць серыю\" на галоўнай старонцы паказвае першую не пачатую кнігу ў серыях, дзе завершана хаця б адна кніга і няма кніг у працэсе чытання. Уключэнне гэтай налады дазволіць працягваць серыю з самай апошняй завершанай кнігі замест першай не пачатай.",
|
||||
"LabelSettingsParseSubtitles": "Аналізаваць падзагалоўкі",
|
||||
"LabelSettingsParseSubtitlesHelp": "Выдзяляць падзагаловак з назваў папак аўдыякніг.<br>Падзагаловак павінен быць аддзелены сімвалам \" - \".<br>Напрыклад, \"Назва кнігі - Падзагаловак тут\" мае падзагаловак \"Падзагаловак тут\"",
|
||||
@@ -618,7 +618,7 @@
|
||||
"LabelSettingsSquareBookCoversHelp": "Аддаваць перавагу квадратным вокладкам замест стандартных вокладак з суадносінамі бакоў 1.6:1",
|
||||
"LabelSettingsStoreCoversWithItem": "Захоўваць вокладкі з элементам",
|
||||
"LabelSettingsStoreCoversWithItemHelp": "Прадвызначана вокладкі захоўваюцца ў /metadata/items, уключэнне гэтай опцыі забяспечыць захоўванне вокладак у папцы элемента бібліятэкі. Захоўвацца будзе толькі адзін файл з назвай \"cover\"",
|
||||
"LabelSettingsStoreMetadataWithItem": "Захоўваць метаданыя разам з элементам",
|
||||
"LabelSettingsStoreMetadataWithItem": "Захоўваць метаданыя з элементам",
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "Прадвызначана метаданыя захоўваюцца ў /metadata/items. Пры ўключэнні гэтай опцыі файлаў метаданых будуць захоўвацца ў папках элементаў бібліятэкі",
|
||||
"LabelSettingsTimeFormat": "Фармат часу",
|
||||
"LabelShare": "Абагуліць",
|
||||
@@ -634,14 +634,14 @@
|
||||
"LabelSortAscending": "Па ўзрастанні",
|
||||
"LabelSortDescending": "Па ўбыванні",
|
||||
"LabelSortPubDate": "Сартаваць па даце публікацыі",
|
||||
"LabelStart": "Пачаць",
|
||||
"LabelStart": "Пачатак",
|
||||
"LabelStartTime": "Час пачатку",
|
||||
"LabelStarted": "Пачата",
|
||||
"LabelStartedAt": "Пачата ў",
|
||||
"LabelStartedDate": "Пачата {0}",
|
||||
"LabelStatsAudioTracks": "Аўдыятрэкі",
|
||||
"LabelStatsAuthors": "Аўтары",
|
||||
"LabelStatsBestDay": "Лепшы дзень",
|
||||
"LabelStatsAuthors": "Аўтараў",
|
||||
"LabelStatsBestDay": "Найлепшы дзень",
|
||||
"LabelStatsDailyAverage": "У сярэднім за дзень",
|
||||
"LabelStatsDays": "Дзён",
|
||||
"LabelStatsDaysListened": "Дзён праслухана",
|
||||
@@ -655,7 +655,7 @@
|
||||
"LabelStatsOverallHours": "Агульная колькасць гадзін",
|
||||
"LabelStatsWeekListening": "Праслухана за тыдзень",
|
||||
"LabelSubtitle": "Падзагаловак",
|
||||
"LabelSupportedFileTypes": "Падтрымліваемыя тыпы файлаў",
|
||||
"LabelSupportedFileTypes": "Падтрымліваюцца тыпы файлаў",
|
||||
"LabelTag": "Метка",
|
||||
"LabelTags": "Меткі",
|
||||
"LabelTagsAccessibleToUser": "Меткі, даступныя карыстальніку",
|
||||
@@ -742,7 +742,7 @@
|
||||
"MessageAuthenticationSecurityMessage": "Дзеля бяспекі была палепшана аўтэнтыфікацыя. Усім карыстальнікам трэба паўторна ўвайсці ў сістэму.",
|
||||
"MessageBackupsDescription": "Рэзервовыя копіі ўключаюць карыстальнікаў, іх прагрэс, падрабязнасці элементаў бібліятэкі, налады сервера і відарысы, якія захоўваюцца ў <code>/metadata/items</code> і <code>/metadata/authors</code>. Рэзервовыя копіі <strong>не</strong> ўключаюць файлы, якія захоўваюцца ў папках бібліятэкі.",
|
||||
"MessageBackupsLocationEditNote": "Заўвага: Абнаўленне месцазнаходжання рэзервовых копій не перамяшчае і не змяняе існуючыя рэзервовыя копіі",
|
||||
"MessageBackupsLocationNoEditNote": "Заўвага: Месцазнаходжанне рэзервовых копій задаецца праз зменную асяроддзя і не можа быць зменена тут.",
|
||||
"MessageBackupsLocationNoEditNote": "Заўвага: Размяшчэнне рэзервовых копій задаецца праз зменную асяроддзя і не можа быць зменена тут.",
|
||||
"MessageBackupsLocationPathEmpty": "Шлях да месцазнаходжання рэзервовых копій не можа быць пустым",
|
||||
"MessageBatchEditPopulateMapDetailsAllHelp": "Запоўніце ўключаныя палі данымі з усіх элементаў. Палі з некалькімі значэннямі будуць аб'яднаны",
|
||||
"MessageBatchEditPopulateMapDetailsItemHelp": "Запоўніце ўключаныя палі падрабязнасцей карты данымі з гэтага элемента",
|
||||
@@ -884,7 +884,7 @@
|
||||
"MessageRemoveEpisodes": "Выдаліць выпускі ({0})",
|
||||
"MessageRemoveFromPlayerQueue": "Выдаліць з чаргі прагравання",
|
||||
"MessageRemoveUserWarning": "Вы ўпэўнены, што хочаце назаўжды выдаліць карыстальніка \"{0}\"?",
|
||||
"MessageReportBugsAndContribute": "Паведамляйце пра памылкі, прапануйце новыя функцыі і ўдзельнічайце на",
|
||||
"MessageReportBugsAndContribute": "Паведамляйце пра памылкі, прапануйце функцыі і ўносьце свой уклад на",
|
||||
"MessageResetChaptersConfirm": "Вы ўпэўнены, што хочаце скінуць раздзелы і адрабіць зробленыя вамі змены?",
|
||||
"MessageRestoreBackupConfirm": "Вы ўпэўнены, што хочаце аднавіць рэзервовую копію, створаную",
|
||||
"MessageRestoreBackupWarning": "Аднаўленне рэзервовай копіі перазапіша ўсю базу даных, размешчаную ў /config, а таксама відарысы вокладкі ў /metadata/items і /metadata/authors. <br /><br /> Рэзервовыя копіі не змяняюць файлы ў папках бібліятэкі. Калі вы ўключылі налады сервера для захоўвання воклак і метаданых у папках бібліятэкі, гэтыя файлы не будуць захаваныя ў рэзервовых копіях і не зменяцца. <br /><br /> Усе кліенты, якія карыстаюцца вашым серверам, будуць аўтаматычна абноўлены.",
|
||||
@@ -968,7 +968,7 @@
|
||||
"StatsBooksAdditional": "Некаторыя дапаўненні ўключаюць…",
|
||||
"StatsBooksFinished": "завершана кніг",
|
||||
"StatsBooksFinishedThisYear": "Некаторыя кнігі завершаны ў гэтым годзе…",
|
||||
"StatsBooksListenedTo": "кнігі, якія былі праслуханы",
|
||||
"StatsBooksListenedTo": "кніг праслухана",
|
||||
"StatsCollectionGrewTo": "Ваша калекцыя кніг павялічылася да…",
|
||||
"StatsSessions": "сеансаў",
|
||||
"StatsSpentListening": "праслухана",
|
||||
@@ -1147,7 +1147,7 @@
|
||||
"ToastUnlinkOpenIdFailed": "Не ўдалося адвязаць карыстальніка ад OpenID",
|
||||
"ToastUnlinkOpenIdSuccess": "Карыстальнік адвязаны ад OpenID",
|
||||
"ToastUploaderFilepathExistsError": "Файл \"{0}\" ужо існуе на серверы",
|
||||
"ToastUploaderItemExistsInSubdirectoryError": "Элемент \"{0}\" выкарыстоўвае падкаталог шляху загрузкі.",
|
||||
"ToastUploaderItemExistsInSubdirectoryError": "Элемент \"{0}\" выкарыстоўвае падкаталог шляху запампоўкі.",
|
||||
"ToastUserDeleteFailed": "Не ўдалося выдаліць карыстальніка",
|
||||
"ToastUserDeleteSuccess": "Карыстальнік выдалены",
|
||||
"ToastUserPasswordChangeSuccess": "Пароль паспяхова зменены",
|
||||
|
||||
+139
-4
@@ -436,7 +436,7 @@
|
||||
"LabelLibraryFilterSublistEmpty": "Не {0}",
|
||||
"LabelLibraryItem": "Елемент на Библиотека",
|
||||
"LabelLibraryName": "Име на Библиотека",
|
||||
"LabelLibrarySortByProgress": "Прогрес: Последно Обновен",
|
||||
"LabelLibrarySortByProgress": "Прогрес: Последно обновление",
|
||||
"LabelLibrarySortByProgressFinished": "Прогрес: Приключено",
|
||||
"LabelLibrarySortByProgressStarted": "Прогрес: Започнато",
|
||||
"LabelLimit": "Лимит",
|
||||
@@ -752,7 +752,7 @@
|
||||
"MessageBookshelfNoRSSFeeds": "Няма отворени RSS feed-ове",
|
||||
"MessageBookshelfNoResultsForFilter": "Няма резултат за филтер \"{0}: {1}\"",
|
||||
"MessageBookshelfNoResultsForQuery": "Няма резултати от заявката",
|
||||
"MessageBookshelfNoSeries": "Нямаш сеЗЙ",
|
||||
"MessageBookshelfNoSeries": "Нямате поредица",
|
||||
"MessageBulkChapterPattern": "Колко глави искате да добавите, използвайки тази схема за номериране?",
|
||||
"MessageChapterEndIsAfter": "Краят на главата е след края на вашата аудиокнига",
|
||||
"MessageChapterErrorFirstNotZero": "Първата глава трябва да започва от 0",
|
||||
@@ -892,7 +892,7 @@
|
||||
"MessageScheduleRunEveryWeekdayAtTime": "Изпълни всеки {0} в {1}",
|
||||
"MessageSearchResultsFor": "Резултати от търсенето за",
|
||||
"MessageSelected": "{0} избрани",
|
||||
"MessageSeriesSequenceCannotContainSpaces": "Подредбата в серия не може да съдържа шпации.",
|
||||
"MessageSeriesSequenceCannotContainSpaces": "Подредбата в серия не може да съдържа шпации",
|
||||
"MessageServerCouldNotBeReached": "Сървърът не може да бъде достигнат",
|
||||
"MessageSetChaptersFromTracksDescription": "Задайте глави, като използвате всеки аудио файл като глава и заглавие на главата като име на аудио файла",
|
||||
"MessageShareExpirationWillBe": "Изтичането ще бъде на <strong>{0}</strong>",
|
||||
@@ -956,6 +956,8 @@
|
||||
"NotificationOnEpisodeDownloadedDescription": "Изпълнява се при автоматично изтегляне на подкаст епизод",
|
||||
"NotificationOnRSSFeedDisabledDescription": "Изпълнява се, когато автоматичното изтегляне на епизодите е деактивирано, поради твърде много неуспешни опити",
|
||||
"NotificationOnRSSFeedFailedDescription": "Пуска се когато заявката за RSS фийд е неуспешна за автоматично сваляне на епизод",
|
||||
"NotificationOnTestDescription": "Event за тестване на системата за нотификации",
|
||||
"PlaceholderBulkChapterInput": "Въведете име на глава или използвайте номериране (прим. 'Епизод 1', 'Глава 10', '1.')",
|
||||
"PlaceholderNewCollection": "Ново име на колекцията",
|
||||
"PlaceholderNewFolderPath": "Нов път на папката",
|
||||
"PlaceholderNewPlaylist": "Ново име на плейлиста",
|
||||
@@ -963,39 +965,103 @@
|
||||
"PlaceholderSearchEpisode": "Търсене на Епизоди...",
|
||||
"StatsAuthorsAdded": "добаврени автори",
|
||||
"StatsBooksAdded": "добавени книги",
|
||||
"StatsBooksAdditional": "Някой от вкючените добавки…",
|
||||
"StatsBooksFinished": "завършени книги",
|
||||
"StatsBooksFinishedThisYear": "Някой от книгите приключени тази година…",
|
||||
"StatsBooksListenedTo": "слушани книги",
|
||||
"StatsCollectionGrewTo": "Твоята книжна колекция израсна до…",
|
||||
"StatsSessions": "сесии",
|
||||
"StatsSpentListening": "прекарано в слушане",
|
||||
"StatsTopAuthor": "ТОП АВТОР",
|
||||
"StatsTopAuthors": "ТОП АВТОРИ",
|
||||
"StatsTopGenre": "ТОП ЖАНР",
|
||||
"StatsTopGenres": "ТОП ЖАНРА",
|
||||
"StatsTopMonth": "ТОП МЕСЕЦ",
|
||||
"StatsTopNarrator": "ТОП РАЗКАЗВАЧ",
|
||||
"StatsTopNarrators": "ТОП РАЗКАЗВАЧИ",
|
||||
"StatsTotalDuration": "С пълно времетраене…",
|
||||
"StatsYearInReview": "ГОДИНАТА В ПРЕГЛЕД",
|
||||
"ToastAccountUpdateSuccess": "Успешно обновяване на акаунта",
|
||||
"ToastAppriseUrlRequired": "Трябва да въведете Apprise URL",
|
||||
"ToastAsinRequired": "ASIN-а е задължителен",
|
||||
"ToastAuthorImageRemoveSuccess": "Авторската снимка е премахната",
|
||||
"ToastAuthorNotFound": "Автор \"{0}\" не е намерен",
|
||||
"ToastAuthorRemoveSuccess": "Арторът е премахнат",
|
||||
"ToastAuthorSearchNotFound": "Авторът не е намерен",
|
||||
"ToastAuthorUpdateMerged": "Обновяване на автора сливано",
|
||||
"ToastAuthorUpdateSuccess": "Автора обновен",
|
||||
"ToastAuthorUpdateSuccessNoImageFound": "Автор обновен (не е намерена снимка)",
|
||||
"ToastBackupAppliedSuccess": "Архивът е приложен",
|
||||
"ToastBackupCreateFailed": "Неуспешно създаване на архив",
|
||||
"ToastBackupCreateSuccess": "Архивът е създаден",
|
||||
"ToastBackupDeleteFailed": "Неуспешно изтриване на архив",
|
||||
"ToastBackupDeleteSuccess": "Архивът е изтрит",
|
||||
"ToastBackupInvalidMaxKeep": "Невалиден брой за архиви за запазване",
|
||||
"ToastBackupInvalidMaxSize": "Невалиден максимален рамер на архив",
|
||||
"ToastBackupRestoreFailed": "Неуспешно възстановяване на архив",
|
||||
"ToastBackupUploadFailed": "Неуспешно качване на архив",
|
||||
"ToastBackupUploadSuccess": "Архивът е качен",
|
||||
"ToastBatchApplyDetailsToItemsSuccess": "Детайли приложени на предмети",
|
||||
"ToastBatchDeleteFailed": "Груповото изтриване се провали",
|
||||
"ToastBatchDeleteSuccess": "Успешно групово изтриване",
|
||||
"ToastBatchQuickMatchFailed": "Груповото Бързо Съвпадение се провали!",
|
||||
"ToastBatchQuickMatchStarted": "Груповото Бързо Съвпадение на {0} книги започна!",
|
||||
"ToastBatchUpdateFailed": "Неуспешно групово актуализиране",
|
||||
"ToastBatchUpdateSuccess": "Успешно групово актуализиране",
|
||||
"ToastBookmarkCreateFailed": "Неуспешно създаване на отметка",
|
||||
"ToastBookmarkCreateSuccess": "Отметката е създадена",
|
||||
"ToastBookmarkRemoveSuccess": "Отметката е премахната",
|
||||
"ToastBulkChapterInvalidCount": "Въведете число между 1 и 150",
|
||||
"ToastCachePurgeFailed": "Неуспешно изчистване на кеша",
|
||||
"ToastCachePurgeSuccess": "Успешно изчистване на кеша",
|
||||
"ToastChapterLocked": "Главата е заключена.",
|
||||
"ToastChapterStartTimeAdjusted": "Начално време на главате е настоено с {0} секунди",
|
||||
"ToastChaptersAllLocked": "Всички глави са заключени. Оключете някой глави за да преместите техните времена.",
|
||||
"ToastChaptersHaveErrors": "Главите имат грешки",
|
||||
"ToastChaptersInvalidShiftAmountLast": "Невалидно време за преместване. Началният час на последната глава ще превиши общата продължителност на аудиокнигата.",
|
||||
"ToastChaptersInvalidShiftAmountStart": "Невалидно време за преместване. Първата глава ще има нулева или отрицателна дължина и ще бъде презаписана от втората глава. Увеличете началното време на втората глава.",
|
||||
"ToastChaptersMustHaveTitles": "Главите трябва да имат заглавия",
|
||||
"ToastChaptersRemoved": "Главите са премахнати",
|
||||
"ToastChaptersUpdated": "Главите са актуализирани",
|
||||
"ToastCollectionItemsAddFailed": "Неуспешно добавяне на елемент(и) към колекцията",
|
||||
"ToastCollectionRemoveSuccess": "Колекцията е премахната",
|
||||
"ToastCollectionUpdateSuccess": "Колекцията е обновена",
|
||||
"ToastConnectionNotAvailable": "Няма връзка. Моля, опитайте отново по-късно",
|
||||
"ToastCoverSearchFailed": "Търсенето на корица е неуспешно",
|
||||
"ToastCoverUpdateFailed": "Обновяването на корицата е неуспешно",
|
||||
"ToastDateTimeInvalidOrIncomplete": "Датата и часът са невалидни или непълни",
|
||||
"ToastDeleteFileFailed": "Неуспешно изтриване на файла",
|
||||
"ToastDeleteFileSuccess": "Успешно изтриване на файла",
|
||||
"ToastDeviceAddFailed": "Неуспешно добавяне на устройство",
|
||||
"ToastDeviceNameAlreadyExists": "Вече съществува четец с това име",
|
||||
"ToastDeviceTestEmailFailed": "Неуспешно изпращане на тестов имейл",
|
||||
"ToastDeviceTestEmailSuccess": "Тестовият имейл е изпратен",
|
||||
"ToastEmailSettingsUpdateSuccess": "Имейл настройките са актуализирани",
|
||||
"ToastEncodeCancelFailed": "Неуспешно отменяне на кодирането",
|
||||
"ToastEncodeCancelSucces": "Кодирането е отменено",
|
||||
"ToastEpisodeDownloadQueueClearFailed": "Неуспешно изчистване на опашката",
|
||||
"ToastEpisodeDownloadQueueClearSuccess": "Опашката за изтегляне на епизоди е изчистена",
|
||||
"ToastEpisodeUpdateSuccess": "{0} епизода са актуализирани",
|
||||
"ToastErrorCannotShare": "Не може да се споделя директно от това устройство",
|
||||
"ToastFailedToCreate": "Неуспешно създаване",
|
||||
"ToastFailedToDelete": "Неуспешно изтриване",
|
||||
"ToastFailedToLoadData": "Неуспешно зареждане на данни",
|
||||
"ToastFailedToMatch": "Неуспешно съвпадение",
|
||||
"ToastFailedToShare": "Неуспешно споделяне",
|
||||
"ToastFailedToUpdate": "Неуспешно актуализиране",
|
||||
"ToastInvalidImageUrl": "Невалиден URL адрес на изображение",
|
||||
"ToastInvalidMaxEpisodesToDownload": "Невалиден максимален брой епизоди за изтегляне",
|
||||
"ToastInvalidUrl": "Невалиден URL адрес",
|
||||
"ToastInvalidUrls": "Един или повече URL адреси са невалидни",
|
||||
"ToastItemCoverUpdateSuccess": "Корицата на елемента е обновена",
|
||||
"ToastItemDeletedFailed": "Неуспешно изтриване на елемента",
|
||||
"ToastItemDeletedSuccess": "Елементът е изтрит",
|
||||
"ToastItemDetailsUpdateSuccess": "Детайлите на елемента са обновени",
|
||||
"ToastItemMarkedAsFinishedFailed": "Неуспешно маркиране като Завършено",
|
||||
"ToastItemMarkedAsFinishedSuccess": "Елементът е маркиран като завършен",
|
||||
"ToastItemMarkedAsNotFinishedFailed": "Неуспешно маркиране като Незавършено",
|
||||
"ToastItemMarkedAsNotFinishedSuccess": "Елементът е маркиран като незавършен",
|
||||
"ToastItemUpdateSuccess": "Елементът е актуализиран",
|
||||
"ToastLibraryCreateFailed": "Неуспешно създаване на библиотека",
|
||||
"ToastLibraryCreateSuccess": "Библиотеката \"{0}\" е създадена",
|
||||
"ToastLibraryDeleteFailed": "Неуспешно изтриване на библиотека",
|
||||
@@ -1003,28 +1069,97 @@
|
||||
"ToastLibraryScanFailedToStart": "Неуспешно стартиране на сканиране",
|
||||
"ToastLibraryScanStarted": "Сканирането на библиотеката е стартирано",
|
||||
"ToastLibraryUpdateSuccess": "Библиотеката \"{0}\" е обновена",
|
||||
"ToastMatchAllAuthorsFailed": "Неуспешно съвпадение на всички автори",
|
||||
"ToastMetadataFilesRemovedError": "Грешка при премахване на metadata.{0} файлове",
|
||||
"ToastMetadataFilesRemovedNoneFound": "Не са намерени metadata.{0} файлове в библиотеката",
|
||||
"ToastMetadataFilesRemovedNoneRemoved": "Не са премахнати metadata.{0} файлове",
|
||||
"ToastMetadataFilesRemovedSuccess": "Премахнати са {0} файла metadata.{1}",
|
||||
"ToastMustHaveAtLeastOnePath": "Трябва да има поне един път",
|
||||
"ToastNameEmailRequired": "Изискват се име и имейл",
|
||||
"ToastNameRequired": "Изисква се име",
|
||||
"ToastNewApiKeyUserError": "Трябва да изберете потребител",
|
||||
"ToastNewEpisodesFound": "Намерени са {0} нови епизода",
|
||||
"ToastNewUserCreatedFailed": "Неуспешно създаване на акаунт: „{0}“",
|
||||
"ToastNewUserCreatedSuccess": "Създаден е нов акаунт",
|
||||
"ToastNewUserLibraryError": "Трябва да изберете поне една библиотека",
|
||||
"ToastNewUserPasswordError": "Трябва да има парола; само root потребителят може да бъде с празна парола",
|
||||
"ToastNewUserTagError": "Трябва да изберете поне един етикет",
|
||||
"ToastNewUserUsernameError": "Въведете потребителско име",
|
||||
"ToastNoNewEpisodesFound": "Не са намерени нови епизоди",
|
||||
"ToastNoRSSFeed": "Подкастът няма RSS емисия",
|
||||
"ToastNoUpdatesNecessary": "Не са необходими актуализации",
|
||||
"ToastNotificationCreateFailed": "Неуспешно създаване на известие",
|
||||
"ToastNotificationDeleteFailed": "Неуспешно изтриване на известието",
|
||||
"ToastNotificationFailedMaximum": "Максималният брой неуспешни опити трябва да бъде >= 0",
|
||||
"ToastNotificationQueueMaximum": "Максималната опашка за известия трябва да бъде >= 0",
|
||||
"ToastNotificationSettingsUpdateSuccess": "Настройките за известия са актуализирани",
|
||||
"ToastNotificationTestTriggerFailed": "Неуспешно задействане на тестово известие",
|
||||
"ToastNotificationTestTriggerSuccess": "Тестовото известие е задействано",
|
||||
"ToastNotificationUpdateSuccess": "Известието е актуализирано",
|
||||
"ToastPlaylistCreateFailed": "Неуспешно създаване на плейлист",
|
||||
"ToastPlaylistCreateSuccess": "Плейлистът е създаден",
|
||||
"ToastPlaylistRemoveSuccess": "Плейлистът е премахнат",
|
||||
"ToastPlaylistUpdateSuccess": "Плейлистът е обновен",
|
||||
"ToastPodcastCreateFailed": "Неуспешно създаване на подкаст",
|
||||
"ToastPodcastCreateSuccess": "Подкаст успешно създаден",
|
||||
"ToastPodcastEpisodeUpdated": "Епизодът е актуализиран",
|
||||
"ToastPodcastGetFeedFailed": "Неуспешно извличане на емисията на подкаста",
|
||||
"ToastPodcastNoEpisodesInFeed": "Не са намерени епизоди в RSS емисията",
|
||||
"ToastPodcastNoRssFeed": "Подкастът няма RSS емисия",
|
||||
"ToastProgressIsNotBeingSynced": "Напредъкът не се синхронизира, рестартирайте възпроизвеждането",
|
||||
"ToastProviderCreatedFailed": "Неуспешно добавяне на доставчик",
|
||||
"ToastProviderCreatedSuccess": "Добавен е нов доставчик",
|
||||
"ToastProviderNameAndUrlRequired": "Изискват се име и URL адрес",
|
||||
"ToastProviderRemoveSuccess": "Доставчикът е премахнат",
|
||||
"ToastRSSFeedCloseFailed": "Неуспешно затваряне на RSS емисията",
|
||||
"ToastRSSFeedCloseSuccess": "RSS емисията е затворена",
|
||||
"ToastRemoveFailed": "Неуспешно премахване",
|
||||
"ToastRemoveItemFromCollectionFailed": "Неуспешно премахване на елемент от колекция",
|
||||
"ToastRemoveItemFromCollectionSuccess": "Елементът е премахнат от колекция",
|
||||
"ToastRemoveItemsWithIssuesFailed": "Неуспешно премахване на елементите от библиотеката с проблеми",
|
||||
"ToastRemoveItemsWithIssuesSuccess": "Елементите от библиотеката с проблеми са премахнати",
|
||||
"ToastRenameFailed": "Неуспешно преименуване",
|
||||
"ToastRescanFailed": "Повторното сканиране е неуспешно за {0}",
|
||||
"ToastRescanRemoved": "Повторното сканиране завърши: елементът е премахнат",
|
||||
"ToastRescanUpToDate": "Повторното сканиране завърши: елементът вече е актуален",
|
||||
"ToastRescanUpdated": "Повторното сканиране завърши: елементът е актуализиран",
|
||||
"ToastScanFailed": "Неуспешно сканиране на елемент от библиотеката",
|
||||
"ToastSelectAtLeastOneUser": "Изберете поне един потребител",
|
||||
"ToastSendEbookToDeviceFailed": "Неуспешно изпращане на електронна книга до устройство",
|
||||
"ToastSendEbookToDeviceSuccess": "Електронната книга е изпратена до устройство \"{0}\"",
|
||||
"ToastSeriesSubmitFailedSameName": "Не могат да бъдат добавени два сериала с едно и също име",
|
||||
"ToastSeriesUpdateFailed": "Неуспешно обновяване на серия",
|
||||
"ToastSeriesUpdateSuccess": "Серията е обновена",
|
||||
"ToastServerSettingsUpdateSuccess": "Настройките на сървъра са актуализирани",
|
||||
"ToastSessionCloseFailed": "Неуспешно затваряне на сесията",
|
||||
"ToastSessionDeleteFailed": "Неуспешно изтриване на сесия",
|
||||
"ToastSessionDeleteSuccess": "Сесията е изтрита",
|
||||
"ToastSleepTimerDone": "Таймерът за заспиване приключи... zZzzZz",
|
||||
"ToastSlugMustChange": "Краткият URL (slug) съдържа невалидни символи",
|
||||
"ToastSlugRequired": "Изисква се кратък URL (slug)",
|
||||
"ToastSocketConnected": "Свързан сокет",
|
||||
"ToastSocketDisconnected": "Сокетът е прекъснат",
|
||||
"ToastSocketFailedToConnect": "Неуспешно свързване на сокет",
|
||||
"ToastSortingPrefixesEmptyError": "Трябва да има поне 1 префикс за сортиране",
|
||||
"ToastSortingPrefixesUpdateSuccess": "Префиксите за сортиране са актуализирани ({0} елемента)",
|
||||
"ToastTitleRequired": "Изисква се заглавие",
|
||||
"ToastUnknownError": "Неизвестна грешка",
|
||||
"ToastUnlinkOpenIdFailed": "Неуспешно прекъсване на връзката на потребителя с OpenID",
|
||||
"ToastUnlinkOpenIdSuccess": "Връзката на потребителя с OpenID е прекъсната",
|
||||
"ToastUploaderFilepathExistsError": "Файловият път „{0}“ вече съществува на сървъра",
|
||||
"ToastUploaderItemExistsInSubdirectoryError": "Елементът „{0}“ използва поддиректория на пътя за качване.",
|
||||
"ToastUserDeleteFailed": "Неуспешно изтриване на потребител",
|
||||
"ToastUserDeleteSuccess": "Потребителят е изтрит"
|
||||
"ToastUserDeleteSuccess": "Потребителят е изтрит",
|
||||
"ToastUserPasswordChangeSuccess": "Паролата е променена успешно",
|
||||
"ToastUserPasswordMismatch": "Паролите не съвпадат",
|
||||
"ToastUserPasswordMustChange": "Новата парола не може да бъде същата като старата",
|
||||
"ToastUserRootRequireName": "Трябва да въведете root потребителско име",
|
||||
"TooltipAddChapters": "Добавяне на глава(и)",
|
||||
"TooltipAddOneSecond": "Добавяне на 1 секунда",
|
||||
"TooltipAdjustChapterStart": "Кликнете за коригиране на началния час",
|
||||
"TooltipLockAllChapters": "Заключване на всички глави",
|
||||
"TooltipLockChapter": "Заключване на глава (Shift+клик за диапазон)",
|
||||
"TooltipSubtractOneSecond": "Изваждане на 1 секунда",
|
||||
"TooltipUnlockAllChapters": "Отключване на всички глави",
|
||||
"TooltipUnlockChapter": "Отключване на глава (Shift+клик за диапазон)"
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
"ButtonViewAll": "Alles anzeigen",
|
||||
"ButtonYes": "Ja",
|
||||
"ErrorUploadFetchMetadataAPI": "Fehler beim Abrufen der Metadaten",
|
||||
"ErrorUploadFetchMetadataNoResults": "Metadaten konnten nicht abgerufen werden. Versuche, den Titel und/oder den Autor zu aktualisieren.",
|
||||
"ErrorUploadFetchMetadataNoResults": "Metadaten konnten nicht abgerufen werden - versuche den Titel und/oder den Autor zu aktualisieren",
|
||||
"ErrorUploadLacksTitle": "Es muss ein Titel eingegeben werden",
|
||||
"HeaderAccount": "Konto",
|
||||
"HeaderAddCustomMetadataProvider": "Benutzerdefinierten Metadatenanbieter hinzufügen",
|
||||
@@ -331,7 +331,7 @@
|
||||
"LabelEmail": "E-Mail",
|
||||
"LabelEmailSettingsFromAddress": "Sender",
|
||||
"LabelEmailSettingsRejectUnauthorized": "Nicht autorisierte Zertifikate ablehnen",
|
||||
"LabelEmailSettingsRejectUnauthorizedHelp": "Durch das Deaktivieren der SSL-Zertifikatsüberprüfung kann deine Verbindung Sicherheitsrisiken wie Man-in-the-Middle-Angriffen ausgesetzt sein. Deaktiviere diese Option nur, wenn due die Auswirkungen verstehst und dem E-Mail-Server vertraust, mit dem eine Verbindung hergestellt wird.",
|
||||
"LabelEmailSettingsRejectUnauthorizedHelp": "Durch das Deaktivieren der SSL-Zertifikatsüberprüfung kann deine Verbindung Sicherheitsrisiken wie Man-in-the-Middle-Angriffen ausgesetzt sein. Deaktiviere diese Option nur, wenn du die Auswirkungen verstehst und dem E-Mail-Server vertraust, mit dem eine Verbindung hergestellt wird.",
|
||||
"LabelEmailSettingsSecure": "Sicher",
|
||||
"LabelEmailSettingsSecureHelp": "Wenn an, verwendet die Verbindung TLS, wenn du eine Verbindung zum Server herstellst. Bei „aus“ wird TLS verwendet, wenn der Server die STARTTLS-Erweiterung unterstützt. In den meisten Fällen solltest du diesen Wert auf „an“ schalten, wenn du eine Verbindung zu Port 465 herstellst. Für Port 587 oder 25 behalte den Wert „aus“ bei. (von nodemailer.com/smtp/#authentication)",
|
||||
"LabelEmailSettingsTestAddress": "Test-Adresse",
|
||||
@@ -622,7 +622,7 @@
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet",
|
||||
"LabelSettingsTimeFormat": "Zeitformat",
|
||||
"LabelShare": "Freigeben",
|
||||
"LabelShareDownloadableHelp": "Erlaubt es einem Nutzer, mit dem Link, die Dateien des Mediums als ZIP herunterzuladen.",
|
||||
"LabelShareDownloadableHelp": "Erlaubt es einem Nutzer, mit dem Link die Dateien des Mediums als ZIP herunterzuladen.",
|
||||
"LabelShareOpen": "Freigeben",
|
||||
"LabelShareURL": "Freigabe URL",
|
||||
"LabelShowAll": "Alles anzeigen",
|
||||
@@ -737,7 +737,7 @@
|
||||
"MessageAddToPlayerQueue": "Zur Abspielwarteliste hinzufügen",
|
||||
"MessageAppriseDescription": "Um diese Funktion nutzen zu können, musst du 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ürdest du <code>http://192.168.1.1:8337/notify</code> eingeben.",
|
||||
"MessageAsinCheck": "Stelle sicher, dass die ASIN aus der richtigen Audible Region verwendet wird, nicht Amazon.",
|
||||
"MessageAuthenticationLegacyTokenWarning": "Alte API-Token werden in Zukunft entfernt. Benutze stattdessen <a href=\"/config/api-keys\">API Keys</a>.",
|
||||
"MessageAuthenticationLegacyTokenWarning": "Nicht mehr unterstützte API tokens werden in der Zukunft entfernt. Nutze stattdessen <a href=\"/config/api-keys\">API Schlüssel</a>.",
|
||||
"MessageAuthenticationOIDCChangesRestart": "Nach dem Speichern muss der Server neugestartet werden um die OIDC Änderungen zu übernehmen.",
|
||||
"MessageAuthenticationSecurityMessage": "Die Anmeldung wurde abgesichert. Benutzersitzungen werden getrennt, alle Benutzer müssen sich erneut anmelden.",
|
||||
"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.",
|
||||
@@ -816,7 +816,7 @@
|
||||
"MessageFeedURLWillBe": "Feed-URL wird {0} sein",
|
||||
"MessageFetching": "Wird abgerufen …",
|
||||
"MessageForceReScanDescription": "Durchsucht alle Dateien erneut, wie bei einem frischen Scan. ID3-Tags von Audiodateien, OPF-Dateien und Textdateien werden neu durchsucht.",
|
||||
"MessageHeatmapListeningTimeTooltip": "<strong>{0} </strong> auf {1} gehört",
|
||||
"MessageHeatmapListeningTimeTooltip": "<strong>{0} gehört</strong> auf {1}",
|
||||
"MessageHeatmapNoListeningSessions": "Keine Hörsitzungen am {0}",
|
||||
"MessageImportantNotice": "Wichtiger Hinweis!",
|
||||
"MessageInsertChapterBelow": "Kapitel unten einfügen",
|
||||
@@ -1103,7 +1103,7 @@
|
||||
"ToastPodcastCreateFailed": "Podcast konnte nicht erstellt werden",
|
||||
"ToastPodcastCreateSuccess": "Podcast erstellt",
|
||||
"ToastPodcastEpisodeUpdated": "Podcast-Folge aktualisiert",
|
||||
"ToastPodcastGetFeedFailed": "Fehler beim abrufen des Podcast-Feeds",
|
||||
"ToastPodcastGetFeedFailed": "Fehler beim Abrufen des Podcast Feeds",
|
||||
"ToastPodcastNoEpisodesInFeed": "Keine Episoden in RSS Feed gefunden",
|
||||
"ToastPodcastNoRssFeed": "Podcast enthält keinen RSS Feed",
|
||||
"ToastProgressIsNotBeingSynced": "Fortschritt wird nicht synchronisiert, Wiedergabe wird neu gestartet",
|
||||
|
||||
+122
-98
@@ -20,10 +20,10 @@
|
||||
"ButtonCheckAndDownloadNewEpisodes": "Comprobar y descargar episodios nuevos",
|
||||
"ButtonChooseAFolder": "Elegir una carpeta",
|
||||
"ButtonChooseFiles": "Elegir archivos",
|
||||
"ButtonClearFilter": "Quitar filtros",
|
||||
"ButtonClearFilter": "Vaciar filtro",
|
||||
"ButtonClose": "Cerrar",
|
||||
"ButtonCloseFeed": "Cerrar suministro",
|
||||
"ButtonCloseSession": "Cerrar la sesión abierta",
|
||||
"ButtonCloseSession": "Cerrar sesión abierta",
|
||||
"ButtonCollections": "Colecciones",
|
||||
"ButtonConfigureScanner": "Configurar Escáner",
|
||||
"ButtonCreate": "Crear",
|
||||
@@ -33,27 +33,27 @@
|
||||
"ButtonEdit": "Editar",
|
||||
"ButtonEditChapters": "Editar capítulos",
|
||||
"ButtonEditPodcast": "Editar pódcast",
|
||||
"ButtonEnable": "Permitir",
|
||||
"ButtonEnable": "Habilitar",
|
||||
"ButtonFireAndFail": "Ejecutado y fallido",
|
||||
"ButtonFireOnTest": "Activar evento de prueba",
|
||||
"ButtonForceReScan": "Forzar Re-Escaneo",
|
||||
"ButtonFullPath": "Ruta completa",
|
||||
"ButtonHide": "Ocultar",
|
||||
"ButtonHome": "Inicio",
|
||||
"ButtonIssues": "Problemas",
|
||||
"ButtonIssues": "Incidencias",
|
||||
"ButtonJumpBackward": "Retroceder",
|
||||
"ButtonJumpForward": "Adelantar",
|
||||
"ButtonLatest": "Más recientes",
|
||||
"ButtonLibrary": "Biblioteca",
|
||||
"ButtonLogout": "Salir",
|
||||
"ButtonLookup": "Buscar",
|
||||
"ButtonLogout": "Cerrar Sesión",
|
||||
"ButtonLookup": "Averiguar",
|
||||
"ButtonManageTracks": "Gestionar pistas",
|
||||
"ButtonMapChapterTitles": "Asignar Títulos a Capítulos",
|
||||
"ButtonMatchAllAuthors": "Encontrar Todos los Autores",
|
||||
"ButtonMatchBooks": "Encontrar Libros",
|
||||
"ButtonMatchBooks": "Cotejar Libros",
|
||||
"ButtonNevermind": "Olvidar",
|
||||
"ButtonNext": "Siguiente",
|
||||
"ButtonNextChapter": "Siguiente Capítulo",
|
||||
"ButtonNextChapter": "Siguiente capítulo",
|
||||
"ButtonNextItemInQueue": "El siguiente elemento en cola",
|
||||
"ButtonOk": "Aceptar",
|
||||
"ButtonOpenFeed": "Abrir suministro",
|
||||
@@ -64,26 +64,26 @@
|
||||
"ButtonPlaying": "Reproduciendo",
|
||||
"ButtonPlaylists": "Listas de reproducción",
|
||||
"ButtonPrevious": "Anterior",
|
||||
"ButtonPreviousChapter": "Capítulo Anterior",
|
||||
"ButtonProbeAudioFile": "Examinar archivo de audio",
|
||||
"ButtonPurgeAllCache": "Purgar toda la antememoria",
|
||||
"ButtonPurgeItemsCache": "Purgar antememoria de elementos",
|
||||
"ButtonQueueAddItem": "Añadir a la cola",
|
||||
"ButtonQueueRemoveItem": "Quitar de la cola",
|
||||
"ButtonPreviousChapter": "Capítulo anterior",
|
||||
"ButtonProbeAudioFile": "Sonda del archivo de audio",
|
||||
"ButtonPurgeAllCache": "Purgar toda la caché",
|
||||
"ButtonPurgeItemsCache": "Purgar caché de elementos",
|
||||
"ButtonQueueAddItem": "Añadir a cola",
|
||||
"ButtonQueueRemoveItem": "Quitar de cola",
|
||||
"ButtonQuickEmbed": "Inserción rápida",
|
||||
"ButtonQuickEmbedMetadata": "Agregue metadatos rápidamente",
|
||||
"ButtonQuickMatch": "Encontrar Rápido",
|
||||
"ButtonQuickEmbedMetadata": "Empotrar metadatos rápidamente",
|
||||
"ButtonQuickMatch": "Cotejo Rápido",
|
||||
"ButtonReScan": "Re-Escanear",
|
||||
"ButtonRead": "Leer",
|
||||
"ButtonReadLess": "Leer menos",
|
||||
"ButtonReadMore": "Leer más",
|
||||
"ButtonRefresh": "Actualizar",
|
||||
"ButtonRefresh": "Recargar",
|
||||
"ButtonRemove": "Quitar",
|
||||
"ButtonRemoveAll": "Quitar todo",
|
||||
"ButtonRemoveAllLibraryItems": "Quitar todos los elementos de la biblioteca",
|
||||
"ButtonRemoveFromContinueListening": "Quitar de Continuar escuchando",
|
||||
"ButtonRemoveFromContinueReading": "Quitar de Continuar leyendo",
|
||||
"ButtonRemoveSeriesFromContinueSeries": "Quitar serie de Continuar serie",
|
||||
"ButtonRemoveFromContinueListening": "Quitar desde Escucha Continua",
|
||||
"ButtonRemoveFromContinueReading": "Quitar desde Continuar Leyendo",
|
||||
"ButtonRemoveSeriesFromContinueSeries": "Quitar Series desde Series Continuas",
|
||||
"ButtonReset": "Restablecer",
|
||||
"ButtonResetToDefault": "Restaurar valores predeterminados",
|
||||
"ButtonRestore": "Restaurar",
|
||||
@@ -92,47 +92,47 @@
|
||||
"ButtonSaveTracklist": "Guardar lista de pistas",
|
||||
"ButtonScan": "Escanear",
|
||||
"ButtonScanLibrary": "Escanear biblioteca",
|
||||
"ButtonScrollLeft": "Desplazarse hacia la izquierda",
|
||||
"ButtonScrollRight": "Desplazarse hacia la derecha",
|
||||
"ButtonScrollLeft": "Desplazarse a la izquierda",
|
||||
"ButtonScrollRight": "Desplazarse a la derecha",
|
||||
"ButtonSearch": "Buscar",
|
||||
"ButtonSelectFolderPath": "Seleccionar ruta de carpeta",
|
||||
"ButtonSeries": "Series",
|
||||
"ButtonSetChaptersFromTracks": "Seleccionar Capítulos Según las Pistas",
|
||||
"ButtonSetChaptersFromTracks": "Establecer capítulos según las pistas",
|
||||
"ButtonShare": "Compartir",
|
||||
"ButtonShiftTimes": "Desplazar Tiempos",
|
||||
"ButtonShiftTimes": "Veces de Desplazo",
|
||||
"ButtonShow": "Mostrar",
|
||||
"ButtonStartM4BEncode": "Iniciar Codificación M4B",
|
||||
"ButtonStartMetadataEmbed": "Iniciar la Inserción de Metadata",
|
||||
"ButtonStartMetadataEmbed": "Iniciar Inserción de Metadatos",
|
||||
"ButtonStats": "Estadísticas",
|
||||
"ButtonSubmit": "Enviar",
|
||||
"ButtonSubmit": "Entregar",
|
||||
"ButtonTest": "Prueba",
|
||||
"ButtonUnlinkOpenId": "Desvincular OpenID",
|
||||
"ButtonUpload": "Cargar",
|
||||
"ButtonUploadBackup": "Cargar respaldo",
|
||||
"ButtonUploadCover": "Cargar cubierta",
|
||||
"ButtonUploadOPMLFile": "Cargar archivo OPML",
|
||||
"ButtonUnlinkOpenId": "Desenlazar OpenID",
|
||||
"ButtonUpload": "Subir",
|
||||
"ButtonUploadBackup": "Subir Respaldo",
|
||||
"ButtonUploadCover": "Subir Cubierta",
|
||||
"ButtonUploadOPMLFile": "Subir archivo OPML",
|
||||
"ButtonUserDelete": "Eliminar usuario {0}",
|
||||
"ButtonUserEdit": "Editar usuario {0}",
|
||||
"ButtonViewAll": "Ver todo",
|
||||
"ButtonYes": "Sí",
|
||||
"ErrorUploadFetchMetadataAPI": "Error al recuperar los metadatos",
|
||||
"ErrorUploadFetchMetadataNoResults": "No se pudieron recuperar los metadatos; pruebe a actualizar el título o autor",
|
||||
"ErrorUploadLacksTitle": "Debe tener título",
|
||||
"ErrorUploadFetchMetadataNoResults": "No se pudieron recuperar los metadatos; pruebe a actualizar el título y/o autor",
|
||||
"ErrorUploadLacksTitle": "Debe tener un título",
|
||||
"HeaderAccount": "Cuenta",
|
||||
"HeaderAddCustomMetadataProvider": "Añadir proveedor de metadatos personalizado",
|
||||
"HeaderAdvanced": "Avanzado",
|
||||
"HeaderApiKeys": "Claves API",
|
||||
"HeaderAppriseNotificationSettings": "Configuración de notificaciones de Apprise",
|
||||
"HeaderAudioTracks": "Pistas de audio",
|
||||
"HeaderAppriseNotificationSettings": "Ajustes de notificaciones de Apprise",
|
||||
"HeaderAudioTracks": "Pistas de Audio",
|
||||
"HeaderAudiobookTools": "Herramientas de Gestión de Archivos de Audiolibro",
|
||||
"HeaderAuthentication": "Autenticación",
|
||||
"HeaderBackups": "Respaldos",
|
||||
"HeaderBulkChapterModal": "Añadir Múltiples Capítulos",
|
||||
"HeaderChangePassword": "Cambiar contraseña",
|
||||
"HeaderChangePassword": "Cambiar Contraseña",
|
||||
"HeaderChapters": "Capítulos",
|
||||
"HeaderChooseAFolder": "Escoger una Carpeta",
|
||||
"HeaderCollection": "Colección",
|
||||
"HeaderCollectionItems": "Elementos en la colección",
|
||||
"HeaderCollectionItems": "Elementos de colección",
|
||||
"HeaderCover": "Cubierta",
|
||||
"HeaderCurrentDownloads": "Descargas actuales",
|
||||
"HeaderCustomMessageOnLogin": "Mensaje personalizado al acceder",
|
||||
@@ -140,49 +140,49 @@
|
||||
"HeaderDetails": "Detalles",
|
||||
"HeaderDownloadQueue": "Cola de descargas",
|
||||
"HeaderEbookFiles": "Archivos de libros digitales",
|
||||
"HeaderEmail": "Correo electrónico",
|
||||
"HeaderEmailSettings": "Configuración de correo electrónico",
|
||||
"HeaderEmail": "Correo-e",
|
||||
"HeaderEmailSettings": "Ajustes de correo-e",
|
||||
"HeaderEpisodes": "Episodios",
|
||||
"HeaderEreaderDevices": "Dispositivos Ereader",
|
||||
"HeaderEreaderSettings": "Configuración del lector",
|
||||
"HeaderEreaderDevices": "Dispositivos Lector-e",
|
||||
"HeaderEreaderSettings": "Ajustes del Lector-e",
|
||||
"HeaderFiles": "Archivos",
|
||||
"HeaderFindChapters": "Buscar capítulos",
|
||||
"HeaderIgnoredFiles": "Archivos ignorados",
|
||||
"HeaderItemFiles": "Archivos de elementos",
|
||||
"HeaderItemMetadataUtils": "Utilidades de metadatos de elementos",
|
||||
"HeaderItemFiles": "Archivos del elemento",
|
||||
"HeaderItemMetadataUtils": "Utilidades de metadatos del elemento",
|
||||
"HeaderLastListeningSession": "Última sesión de escucha",
|
||||
"HeaderLatestEpisodes": "Episodios más recientes",
|
||||
"HeaderLibraries": "Bibliotecas",
|
||||
"HeaderLibraryFiles": "Archivos de biblioteca",
|
||||
"HeaderLibraryStats": "Estadísticas de biblioteca",
|
||||
"HeaderListeningSessions": "Sesión",
|
||||
"HeaderListeningSessions": "Sesiones Listadas",
|
||||
"HeaderListeningStats": "Estadísticas de Tiempo Escuchado",
|
||||
"HeaderLogin": "Acceder",
|
||||
"HeaderLogs": "Registros",
|
||||
"HeaderLogin": "Inicio de Sesión",
|
||||
"HeaderLogs": "Bitácoras",
|
||||
"HeaderManageGenres": "Gestionar géneros",
|
||||
"HeaderManageTags": "Gestionar etiquetas",
|
||||
"HeaderMapDetails": "Asignar Detalles",
|
||||
"HeaderMatch": "Encontrar",
|
||||
"HeaderMatch": "Coincidir",
|
||||
"HeaderMetadataOrderOfPrecedence": "Orden de precedencia de metadatos",
|
||||
"HeaderMetadataToEmbed": "Metadatos para Insertar",
|
||||
"HeaderNewAccount": "Cuenta nueva",
|
||||
"HeaderMetadataToEmbed": "Metadatos para empotrar",
|
||||
"HeaderNewAccount": "Crear Cuenta",
|
||||
"HeaderNewApiKey": "Nueva clave API",
|
||||
"HeaderNewLibrary": "Biblioteca nueva",
|
||||
"HeaderNotificationCreate": "Crear notificación",
|
||||
"HeaderNotificationUpdate": "Notificación de actualización",
|
||||
"HeaderNotificationCreate": "Crear Notificación",
|
||||
"HeaderNotificationUpdate": "Notificación de Actualización",
|
||||
"HeaderNotifications": "Notificaciones",
|
||||
"HeaderOpenIDConnectAuthentication": "Autenticación OpenID Connect",
|
||||
"HeaderOpenListeningSessions": "Sesiones públicas de escucha",
|
||||
"HeaderOpenListeningSessions": "Abrir escucha de sesiones",
|
||||
"HeaderOpenRSSFeed": "Abrir suministro RSS",
|
||||
"HeaderOtherFiles": "Otros archivos",
|
||||
"HeaderPasswordAuthentication": "Autenticación por contraseña",
|
||||
"HeaderPermissions": "Permisos",
|
||||
"HeaderPlayerQueue": "Cola del reproductor",
|
||||
"HeaderPlayerSettings": "Configuración del reproductor",
|
||||
"HeaderPlayerSettings": "Ajustes del reproductor",
|
||||
"HeaderPlaylist": "Lista de reproducción",
|
||||
"HeaderPlaylistItems": "Elementos de lista de reproducción",
|
||||
"HeaderPodcastsToAdd": "Pódcast para añadir",
|
||||
"HeaderPresets": "Preconfiguraciones",
|
||||
"HeaderPresets": "Preajustes",
|
||||
"HeaderPreviewCover": "Previsualizar cubierta",
|
||||
"HeaderRSSFeedGeneral": "Detalles de RSS",
|
||||
"HeaderRSSFeedIsOpen": "El suministro RSS está abierto",
|
||||
@@ -191,18 +191,18 @@
|
||||
"HeaderRemoveEpisodes": "Quitar {0} episodios",
|
||||
"HeaderSavedMediaProgress": "Guardar Progreso de Multimedia",
|
||||
"HeaderSchedule": "Horario",
|
||||
"HeaderScheduleEpisodeDownloads": "Programar descargas automáticas de episodios",
|
||||
"HeaderScheduleLibraryScans": "Programar Escaneo Automático de Biblioteca",
|
||||
"HeaderScheduleEpisodeDownloads": "Planificador de auto‐descargas de episodios",
|
||||
"HeaderScheduleLibraryScans": "Planificar Auto‐Escaneo de Biblioteca",
|
||||
"HeaderSession": "Sesión",
|
||||
"HeaderSetBackupSchedule": "Programar Respaldo",
|
||||
"HeaderSettings": "Configuración",
|
||||
"HeaderSetBackupSchedule": "Establecer Planificación de Respaldo",
|
||||
"HeaderSettings": "Ajustes",
|
||||
"HeaderSettingsDisplay": "Interfaz",
|
||||
"HeaderSettingsExperimental": "Funcionalidades experimentales",
|
||||
"HeaderSettingsExperimental": "Características experimentales",
|
||||
"HeaderSettingsGeneral": "Generales",
|
||||
"HeaderSettingsScanner": "Escáner",
|
||||
"HeaderSettingsSecurity": "Seguridad",
|
||||
"HeaderSettingsWebClient": "Cliente web",
|
||||
"HeaderSleepTimer": "Temporizador de apagado",
|
||||
"HeaderSleepTimer": "Cronómetro de dormida",
|
||||
"HeaderStatsLargestItems": "Elementos más grandes",
|
||||
"HeaderStatsLongestItems": "Elementos más extensos (h)",
|
||||
"HeaderStatsMinutesListeningChart": "Minutos escuchando (últimos 7 días)",
|
||||
@@ -233,29 +233,29 @@
|
||||
"LabelAddToCollectionBatch": "Añadir {0} libros a colección",
|
||||
"LabelAddToPlaylist": "Añadir a lista de reproducción",
|
||||
"LabelAddToPlaylistBatch": "Añadir {0} elementos a lista de reproducción",
|
||||
"LabelAddedAt": "Añadido",
|
||||
"LabelAddedDate": "{0} Añadido",
|
||||
"LabelAddedAt": "Añadido en",
|
||||
"LabelAddedDate": "Añadido {0}",
|
||||
"LabelAdminUsersOnly": "Solamente usuarios administradores",
|
||||
"LabelAll": "Todos",
|
||||
"LabelAllEpisodesDownloaded": "Todos los episodios descargados",
|
||||
"LabelAllUsers": "Todos los usuarios",
|
||||
"LabelAllUsersExcludingGuests": "Todos los usuarios excepto invitados",
|
||||
"LabelAllUsersIncludingGuests": "Todos los usuarios e invitados",
|
||||
"LabelAlreadyInYourLibrary": "Ya existe en la Biblioteca",
|
||||
"LabelApiKeyCreated": "La clave de API “{0}” se ha creado con éxito.",
|
||||
"LabelAlreadyInYourLibrary": "Ya dentro de tu biblioteca",
|
||||
"LabelApiKeyCreated": "La clave de API “{0}” se ha creado correctamente.",
|
||||
"LabelApiKeyCreatedDescription": "Asegúrate de copiar la clave de API ahora, no la volverás a ver otra vez.",
|
||||
"LabelApiKeyUser": "Actuar en nombre del usuario",
|
||||
"LabelApiKeyUserDescription": "Esta clave de API tendrá los mismos permisos que el usuario al que representa. En los registros se verá como si la solicitud la hubiera hecho el usuario directamente.",
|
||||
"LabelApiToken": "Token de la API",
|
||||
"LabelApiToken": "Vale del API",
|
||||
"LabelAppend": "Adjuntar",
|
||||
"LabelAudioBitrate": "Tasa de bits del audio (por ejemplo, 128k)",
|
||||
"LabelAudioBitrate": "Tasa de bit del audio (p.ej., 128k)",
|
||||
"LabelAudioChannels": "Canales de audio (1 o 2)",
|
||||
"LabelAudioCodec": "Códec de audio",
|
||||
"LabelAuthor": "Autor",
|
||||
"LabelAuthorFirstLast": "Autor (Nombre Apellido)",
|
||||
"LabelAuthorLastFirst": "Autor (Apellido, Nombre)",
|
||||
"LabelAuthors": "Autores",
|
||||
"LabelAutoDownloadEpisodes": "Descargar episodios automáticamente",
|
||||
"LabelAutoDownloadEpisodes": "Auto‐Descargar episodios",
|
||||
"LabelAutoFetchMetadata": "Recuperar metadatos automáticamente",
|
||||
"LabelAutoFetchMetadataHelp": "Obtiene metadatos de título, autor y serie para agilizar la carga. Es posible que haya que cotejar metadatos adicionales después de la carga.",
|
||||
"LabelAutoLaunch": "Lanzamiento automático",
|
||||
@@ -275,7 +275,7 @@
|
||||
"LabelBonus": "Bonus",
|
||||
"LabelBooks": "Libros",
|
||||
"LabelButtonText": "Texto del botón",
|
||||
"LabelByAuthor": "por",
|
||||
"LabelByAuthor": "por {0}",
|
||||
"LabelChangePassword": "Cambiar contraseña",
|
||||
"LabelChannels": "Canales",
|
||||
"LabelChapterCount": "{0} capítulos",
|
||||
@@ -286,13 +286,13 @@
|
||||
"LabelClickToUseCurrentValue": "Pulse para utilizar el valor actual",
|
||||
"LabelClosePlayer": "Cerrar reproductor",
|
||||
"LabelCodec": "Codec",
|
||||
"LabelCollapseSeries": "Colapsar serie",
|
||||
"LabelCollapseSeries": "Colapsar Series",
|
||||
"LabelCollapseSubSeries": "Contraer la subserie",
|
||||
"LabelCollection": "Colección",
|
||||
"LabelCollections": "Colecciones",
|
||||
"LabelComplete": "Completo",
|
||||
"LabelConfirmPassword": "Confirmar contraseña",
|
||||
"LabelContinueListening": "Seguir escuchando",
|
||||
"LabelContinueListening": "Seguir Escuchando",
|
||||
"LabelContinueReading": "Continuar leyendo",
|
||||
"LabelContinueSeries": "Continuar series",
|
||||
"LabelCorsAllowed": "Orígenes CORS Permitidos",
|
||||
@@ -325,14 +325,14 @@
|
||||
"LabelDurationComparisonLonger": "({0} más largo)",
|
||||
"LabelDurationComparisonShorter": "({0} más corto)",
|
||||
"LabelDurationFound": "Duración Comprobada:",
|
||||
"LabelEbook": "Libro electrónico",
|
||||
"LabelEbooks": "Libros electrónicos",
|
||||
"LabelEbook": "Libro-e",
|
||||
"LabelEbooks": "Libros-e",
|
||||
"LabelEdit": "Editar",
|
||||
"LabelEmail": "Correo electrónico",
|
||||
"LabelEmailSettingsFromAddress": "Remitente",
|
||||
"LabelEmailSettingsRejectUnauthorized": "Rechazar certificados no autorizados",
|
||||
"LabelEmailSettingsRejectUnauthorizedHelp": "Desactivar la validación de certificados SSL puede exponer su conexión a riesgos de seguridad, como los ataques por intermediario. Desactive esta opción solo si conoce las implicaciones y confía en el servidor de correo al que se conecta.",
|
||||
"LabelEmailSettingsSecure": "Seguridad",
|
||||
"LabelEmailSettingsSecure": "Seguro",
|
||||
"LabelEmailSettingsSecureHelp": "Si está activado, se usará TLS para conectarse al servidor. Si está apagado, se usará TLS si su servidor tiene soporte para la extensión STARTTLS. En la mayoría de los casos, puede dejar esta opción activada si se está conectando al puerto 465. Apáguela en el caso de usar los puertos 587 o 25. (de nodemailer.com/smtp/#authentication)",
|
||||
"LabelEmailSettingsTestAddress": "Probar dirección",
|
||||
"LabelEmbeddedCover": "Cubierta incrustada",
|
||||
@@ -377,11 +377,12 @@
|
||||
"LabelFilename": "Nombre del archivo",
|
||||
"LabelFilterByUser": "Filtrar por Usuario",
|
||||
"LabelFindEpisodes": "Buscar Episodio",
|
||||
"LabelFinished": "Terminado",
|
||||
"LabelFinished": "Finalizado",
|
||||
"LabelFinishedDate": "Finalizado {0}",
|
||||
"LabelFolder": "Carpeta",
|
||||
"LabelFolders": "Carpetas",
|
||||
"LabelFontBold": "Negrilla",
|
||||
"LabelFontBoldness": "Peso tipográfico",
|
||||
"LabelFontBoldness": "Tipográfico sin Negrita",
|
||||
"LabelFontFamily": "Familia tipográfica",
|
||||
"LabelFontItalic": "Itálica",
|
||||
"LabelFontScale": "Escala de letra",
|
||||
@@ -391,8 +392,8 @@
|
||||
"LabelGenre": "Género",
|
||||
"LabelGenres": "Géneros",
|
||||
"LabelHardDeleteFile": "Eliminar Definitivamente",
|
||||
"LabelHasEbook": "Tiene un libro",
|
||||
"LabelHasSupplementaryEbook": "Tiene un libro complementario",
|
||||
"LabelHasEbook": "Tiene libro-e",
|
||||
"LabelHasSupplementaryEbook": "Tiene un libro-e suplementario",
|
||||
"LabelHideSubtitles": "Ocultar subtítulos",
|
||||
"LabelHighestPriority": "Mayor prioridad",
|
||||
"LabelHost": "Anfitrión",
|
||||
@@ -422,6 +423,7 @@
|
||||
"LabelLanguages": "Idiomas",
|
||||
"LabelLastBookAdded": "Último libro añadido",
|
||||
"LabelLastBookUpdated": "Último libro actualizado",
|
||||
"LabelLastProgressDate": "Último progreso: {0}",
|
||||
"LabelLastSeen": "Última Vez Visto",
|
||||
"LabelLastTime": "Última Vez",
|
||||
"LabelLastUpdate": "Última Actualización",
|
||||
@@ -434,6 +436,9 @@
|
||||
"LabelLibraryFilterSublistEmpty": "Sin {0}",
|
||||
"LabelLibraryItem": "Elemento de Biblioteca",
|
||||
"LabelLibraryName": "Nombre de Biblioteca",
|
||||
"LabelLibrarySortByProgress": "Progreso: Último actualizado",
|
||||
"LabelLibrarySortByProgressFinished": "Progreso: Finalizado",
|
||||
"LabelLibrarySortByProgressStarted": "Progreso: Iniciado",
|
||||
"LabelLimit": "Limites",
|
||||
"LabelLineSpacing": "Interlineado",
|
||||
"LabelListenAgain": "Volver a escuchar",
|
||||
@@ -442,6 +447,7 @@
|
||||
"LabelLogLevelWarn": "Advertencia",
|
||||
"LabelLookForNewEpisodesAfterDate": "Buscar Nuevos Episodios a partir de esta Fecha",
|
||||
"LabelLowestPriority": "Menor prioridad",
|
||||
"LabelMatchConfidence": "Confidencia",
|
||||
"LabelMatchExistingUsersBy": "Emparejar a los usuarios existentes por",
|
||||
"LabelMatchExistingUsersByDescription": "Se utiliza para conectar usuarios existentes. Una vez conectados, los usuarios serán emparejados por un identificador único de su proveedor de SSO",
|
||||
"LabelMaxEpisodesToDownload": "Número máximo # de episodios para descargar. Usa 0 para descargar una cantidad ilimitada.",
|
||||
@@ -460,7 +466,7 @@
|
||||
"LabelMissingEbook": "No tiene libro electrónico",
|
||||
"LabelMissingSupplementaryEbook": "No tiene libro electrónico suplementario",
|
||||
"LabelMobileRedirectURIs": "URIs de redirección a móviles permitidos",
|
||||
"LabelMobileRedirectURIsDescription": "Esta es una lista blanca de URI de redireccionamiento válidos para aplicaciones móviles. El predeterminado es <code> audiobookshelf</code> , que puede eliminar o complementar con URI adicionales para la integración de aplicaciones de terceros. Usando un asterisco (<code> *</code> ) como única entrada que permite cualquier URI.",
|
||||
"LabelMobileRedirectURIsDescription": "Esta es una lista en blanco de las URI de re‐direccionamiento válidos para aplicaciones móviles. El predeterminado es <code>audiobookshelf</code> , que puede retirar o sustituir con las URI adicionales para la integración de aplicaciones de terceros. Usando un asterisco (<code>*</code> ) como única entrada que permite cualquier URI.",
|
||||
"LabelMore": "Más",
|
||||
"LabelMoreInfo": "Más información",
|
||||
"LabelName": "Nombre",
|
||||
@@ -473,9 +479,10 @@
|
||||
"LabelNextBackupDate": "Fecha del siguiente respaldo",
|
||||
"LabelNextChapters": "Los próximos capítulos serán:",
|
||||
"LabelNextScheduledRun": "Próxima ejecución programada",
|
||||
"LabelNoApiKeys": "Sin claves API",
|
||||
"LabelNoCustomMetadataProviders": "Sin proveedores de metadatos personalizados",
|
||||
"LabelNoEpisodesSelected": "Ningún Episodio Seleccionado",
|
||||
"LabelNotFinished": "No terminado",
|
||||
"LabelNotFinished": "No finalizado",
|
||||
"LabelNotStarted": "Sin iniciar",
|
||||
"LabelNotes": "Notas",
|
||||
"LabelNotificationAppriseURL": "URL(s) de Apprise",
|
||||
@@ -489,7 +496,7 @@
|
||||
"LabelNotificationsMaxQueueSizeHelp": "Las notificaciones están limitadas a 1 por segundo. Las notificaciones serán ignoradas si llegan al numero máximo de cola para prevenir spam de eventos.",
|
||||
"LabelNumberOfBooks": "Número de libros",
|
||||
"LabelNumberOfChapters": "Número de capítulos:",
|
||||
"LabelNumberOfEpisodes": "N.º de episodios",
|
||||
"LabelNumberOfEpisodes": "Nº de episodios",
|
||||
"LabelOpenIDAdvancedPermsClaimDescription": "Nombre de la notificación de OpenID que contiene permisos avanzados para acciones de usuario dentro de la aplicación que se aplicarán a roles que no sean de administrador (<b>si están configurados</b>). Si el reclamo no aparece en la respuesta, se denegará el acceso a ABS. Si falta una sola opción, se tratará como <code>falsa</code>. Asegúrese de que la notificación del proveedor de identidades coincida con la estructura esperada:",
|
||||
"LabelOpenIDClaims": "Deje las siguientes opciones vacías para desactivar la asignación avanzada de grupos y permisos, lo que asignaría de manera automática al grupo «Usuario».",
|
||||
"LabelOpenIDGroupClaimDescription": "Nombre de la declaración OpenID que contiene una lista de grupos del usuario. Comúnmente conocidos como <code>grupos</code>. <b>Si se configura</b>, la aplicación asignará automáticamente roles en función de la pertenencia a grupos del usuario, siempre que estos grupos se denominen \"admin\", \"user\" o \"guest\" en la notificación. La solicitud debe contener una lista, y si un usuario pertenece a varios grupos, la aplicación asignará el rol correspondiente al mayor nivel de acceso. Si ningún grupo coincide, se denegará el acceso.",
|
||||
@@ -519,7 +526,7 @@
|
||||
"LabelPodcasts": "Pódcast",
|
||||
"LabelPort": "Puerto",
|
||||
"LabelPrefixesToIgnore": "Prefijos para ignorar (no distingue entre mayúsculas y minúsculas)",
|
||||
"LabelPreventIndexing": "Evite que los directorios de pódcast de iTunes y Google indicen su suministro",
|
||||
"LabelPreventIndexing": "Evite que los directorios de pódcast de iTunes y Google indexen su suministro",
|
||||
"LabelPrimaryEbook": "Libro electrónico principal",
|
||||
"LabelProgress": "Progreso",
|
||||
"LabelProvider": "Proveedor",
|
||||
@@ -531,11 +538,11 @@
|
||||
"LabelPublishedDecades": "Décadas publicadas",
|
||||
"LabelPublisher": "Editor",
|
||||
"LabelPublishers": "Editores",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Correo electrónico de dueño personalizado",
|
||||
"LabelRSSFeedCustomOwnerName": "Nombre de dueño personalizado",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Correo-e de propietario personalizado",
|
||||
"LabelRSSFeedCustomOwnerName": "Nombre de propietario personalizado",
|
||||
"LabelRSSFeedOpen": "Fuente RSS Abierta",
|
||||
"LabelRSSFeedPreventIndexing": "Evitar indización",
|
||||
"LabelRSSFeedSlug": "«Slug» de suministro RSS",
|
||||
"LabelRSSFeedSlug": "Ficha de suministro RSS",
|
||||
"LabelRSSFeedURL": "URL de suministro RSS",
|
||||
"LabelRandomly": "Aleatorio",
|
||||
"LabelReAddSeriesToContinueListening": "Volver a agregar la serie para continuar escuchándola",
|
||||
@@ -563,11 +570,12 @@
|
||||
"LabelSelectAll": "Seleccionar todo",
|
||||
"LabelSelectAllEpisodes": "Seleccionar todos los episodios",
|
||||
"LabelSelectEpisodesShowing": "Seleccionar los {0} episodios visibles",
|
||||
"LabelSelectUser": "Seleccionar usuario",
|
||||
"LabelSelectUsers": "Seleccionar usuarios",
|
||||
"LabelSendEbookToDevice": "Enviar libro electrónico a...",
|
||||
"LabelSequence": "Secuencia",
|
||||
"LabelSerial": "En serie",
|
||||
"LabelSeries": "Serie",
|
||||
"LabelSeries": "Series",
|
||||
"LabelSeriesName": "Nombre de la serie",
|
||||
"LabelSeriesProgress": "Progreso de la serie",
|
||||
"LabelServerLogLevel": "Nivel de registro del servidor",
|
||||
@@ -580,8 +588,8 @@
|
||||
"LabelSettingsBookshelfViewHelp": "Diseño Esqueuomorfo con Estantes de Madera",
|
||||
"LabelSettingsChromecastSupport": "Compatibilidad con Chromecast",
|
||||
"LabelSettingsDateFormat": "Formato de Fecha",
|
||||
"LabelSettingsEnableWatcher": "Buscar cambios automáticamente en las bibliotecas",
|
||||
"LabelSettingsEnableWatcherForLibrary": "Buscar cambios automáticamente en la biblioteca",
|
||||
"LabelSettingsEnableWatcher": "Vigilar automáticamente los cambios en bibliotecas",
|
||||
"LabelSettingsEnableWatcherForLibrary": "Vigilar automáticamente los cambios de biblioteca",
|
||||
"LabelSettingsEnableWatcherHelp": "Permite agregar/actualizar elementos automáticamente cuando se detectan cambios en los archivos. *Requiere reiniciar el servidor",
|
||||
"LabelSettingsEpubsAllowScriptedContent": "Permitir scripts en epubs",
|
||||
"LabelSettingsEpubsAllowScriptedContentHelp": "Permitir que los archivos epub ejecuten scripts. Se recomienda mantener esta opción desactivada a menos que confíe en el origen de los archivos epub.",
|
||||
@@ -614,14 +622,14 @@
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "Por defecto, los archivos de metadatos se almacenan en /metadata/items. Si habilita esta opción, los archivos de metadatos se guardarán en la carpeta de elementos de su biblioteca",
|
||||
"LabelSettingsTimeFormat": "Formato de Tiempo",
|
||||
"LabelShare": "Compartir",
|
||||
"LabelShareDownloadableHelp": "Permite a quienes posean el enlace de compartición descargar un archivo ZIP del elemento de la biblioteca.",
|
||||
"LabelShareDownloadableHelp": "Permite a quienes posean el enlace de compartición descargar un archivo zip del elemento de la biblioteca.",
|
||||
"LabelShareOpen": "abrir un recurso compartido",
|
||||
"LabelShareURL": "Compartir la URL",
|
||||
"LabelShowAll": "Mostrar todo",
|
||||
"LabelShowSeconds": "Mostrar segundos",
|
||||
"LabelShowSubtitles": "Mostrar subtítulos",
|
||||
"LabelSize": "Tamaño",
|
||||
"LabelSleepTimer": "Temporizador de apagado",
|
||||
"LabelSleepTimer": "Temporizador de dormida",
|
||||
"LabelSlug": "Slug",
|
||||
"LabelSortAscending": "Ascendente",
|
||||
"LabelSortDescending": "Descendente",
|
||||
@@ -630,6 +638,7 @@
|
||||
"LabelStartTime": "Tiempo de Inicio",
|
||||
"LabelStarted": "Iniciado",
|
||||
"LabelStartedAt": "Iniciado En",
|
||||
"LabelStartedDate": "Iniciado {0}",
|
||||
"LabelStatsAudioTracks": "Pistas de Audio",
|
||||
"LabelStatsAuthors": "Autores",
|
||||
"LabelStatsBestDay": "Mejor día",
|
||||
@@ -659,6 +668,7 @@
|
||||
"LabelTheme": "Tema",
|
||||
"LabelThemeDark": "Oscuro",
|
||||
"LabelThemeLight": "Claro",
|
||||
"LabelThemeSepia": "Sepia",
|
||||
"LabelTimeBase": "Tiempo Base",
|
||||
"LabelTimeDurationXHours": "{0} horas",
|
||||
"LabelTimeDurationXMinutes": "{0} minutos",
|
||||
@@ -727,8 +737,10 @@
|
||||
"MessageAddToPlayerQueue": "Agregar a fila del Reproductor",
|
||||
"MessageAppriseDescription": "Para usar esta función deberás tener <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">la API de Apprise</a> corriendo o una API que maneje los mismos resultados. <br/>La URL de la API de Apprise debe tener la misma ruta de archivos que donde se envían las notificaciones. Por ejemplo: si su API esta en <code>http://192.168.1.1:8337</code> entonces pondría <code>http://192.168.1.1:8337/notify</code>.",
|
||||
"MessageAsinCheck": "Cerciórese de usar el ASIN de la región correcta de Audible, no de Amazon.",
|
||||
"MessageAuthenticationLegacyTokenWarning": "Los vales de API heredados serán retirados en el futuro. Utilice las <a href=\"/config/api-keys\">claves de API</a> en su lugar.",
|
||||
"MessageAuthenticationOIDCChangesRestart": "Reinicie el servidor tras el guardado para aplicar los cambios de OIDC.",
|
||||
"MessageBackupsDescription": "Los respaldos incluyen: usuarios, el progreso del los usuarios, los detalles de los elementos de la biblioteca, la configuración del servidor y las imágenes en <code>/metadata/items</code> y <code>/metadata/authors</code>. Los Respaldos <strong>NO</strong> incluyen ningún archivo guardado en la carpeta de tu biblioteca.",
|
||||
"MessageAuthenticationSecurityMessage": "La autenticación ha sido mejorada para seguridad. Todos los usuarios requieren reiniciar sesión.",
|
||||
"MessageBackupsDescription": "Los respaldos incluyen: usuarios, el progreso del los usuarios, los detalles de los elementos de la biblioteca, la configuración del servidor y las imágenes en <code>/metadata/items</code> y <code>/metadata/authors</code>. Los Respaldos <strong>no</strong> incluyen ningún archivo guardado en la carpeta de tu biblioteca.",
|
||||
"MessageBackupsLocationEditNote": "Nota: actualizar la ubicación de la copia de respaldo no moverá ni modificará los respaldos existentes",
|
||||
"MessageBackupsLocationNoEditNote": "Nota: la ubicación de la copia de respaldo se establece a través de una variable de entorno y no se puede cambiar aquí.",
|
||||
"MessageBackupsLocationPathEmpty": "La ruta de la copia de seguridad no puede estar vacía",
|
||||
@@ -750,6 +762,7 @@
|
||||
"MessageChaptersNotFound": "Capítulos no encontrados",
|
||||
"MessageCheckingCron": "Revisando cron...",
|
||||
"MessageConfirmCloseFeed": "¿Confirma que quiere cerrar este suministro?",
|
||||
"MessageConfirmDeleteApiKey": "¿Está seguro que desea eliminar la clave API «{0}»?",
|
||||
"MessageConfirmDeleteBackup": "¿Confirma que quiere eliminar el respaldo de {0}?",
|
||||
"MessageConfirmDeleteDevice": "¿Confirma que quiere eliminar el lector electrónico «{0}»?",
|
||||
"MessageConfirmDeleteFile": "Esto eliminará el archivo del sistema de archivos. ¿Quiere continuar?",
|
||||
@@ -768,8 +781,8 @@
|
||||
"MessageConfirmMarkSeriesFinished": "¿Confirma que quiere marcar todos los libros de esta serie como terminados?",
|
||||
"MessageConfirmMarkSeriesNotFinished": "¿Confirma que quiere marcar todos los libros de esta serie como no terminados?",
|
||||
"MessageConfirmNotificationTestTrigger": "¿Activar esta notificación con datos de prueba?",
|
||||
"MessageConfirmPurgeCache": "Purgar la antememoria eliminará el directorio completo ubicado en <code>/metadata/cache</code>. <br /><br />¿Confirma que quiere eliminar el directorio de antememoria?",
|
||||
"MessageConfirmPurgeItemsCache": "Purgar la antememoria de elementos eliminará el directorio completo ubicado en <code>/metadata/cache/items</code>.<br />¿Lo confirma?",
|
||||
"MessageConfirmPurgeCache": "La purga del caché eliminará el directorio completo en <code>/metadata/cache</code>. <br /><br />¿Confirma que desea quitar el directorio de caché?",
|
||||
"MessageConfirmPurgeItemsCache": "Purgar el caché de elementos eliminará el directorio completo ubicado en <code>/metadata/cache/items</code>.<br />¿Lo confirma?",
|
||||
"MessageConfirmQuickEmbed": "Atención: la incrustación rápida no realiza copias de respaldo a ninguno de sus archivos de audio. Cerciórese de haber realizado una copia de los mismos previamente. <br><br>¿Quiere continuar?",
|
||||
"MessageConfirmQuickMatchEpisodes": "El reconocimiento rápido de extensiones sobrescribirá los detalles si se encuentra una coincidencia. Se actualizarán las extensiones no reconocidas. ¿Quiere continuar?",
|
||||
"MessageConfirmReScanLibraryItems": "¿Confirma que quiere volver a analizar {0} elementos?",
|
||||
@@ -795,14 +808,16 @@
|
||||
"MessageDaysListenedInTheLastYear": "{0} días escuchados el año pasado",
|
||||
"MessageDownloadingEpisode": "Descargando episodio",
|
||||
"MessageDragFilesIntoTrackOrder": "Arrastre los archivos al orden correcto de las pistas",
|
||||
"MessageEmbedFailed": "Falló la incrustación.",
|
||||
"MessageEmbedFinished": "Finalizó la incrustación.",
|
||||
"MessageEmbedFailed": "Incorporación incorrecta.",
|
||||
"MessageEmbedFinished": "Incorporación finalizada.",
|
||||
"MessageEmbedQueue": "En cola para incrustar metadatos ({0} en cola)",
|
||||
"MessageEpisodesQueuedForDownload": "{0} episodio(s) en cola para descargar",
|
||||
"MessageEreaderDevices": "Para garantizar la entrega de libros electrónicos, es posible que tenga que agregar la dirección de correo electrónico anterior como remitente válido para cada dispositivo enumerado a continuación.",
|
||||
"MessageFeedURLWillBe": "El URL del suministro será {0}",
|
||||
"MessageFetching": "Recuperando...",
|
||||
"MessageForceReScanDescription": "Escaneará todos los archivos como un nuevo escaneo. Archivos de audio con etiquetas ID3, archivos OPF y archivos de texto serán escaneados como nuevos.",
|
||||
"MessageHeatmapListeningTimeTooltip": "<strong>{0} escuchando</strong> en {1}",
|
||||
"MessageHeatmapNoListeningSessions": "No enumera sesiones en {0}",
|
||||
"MessageImportantNotice": "¡Notificación importante!",
|
||||
"MessageInsertChapterBelow": "Insertar capítulo debajo",
|
||||
"MessageInvalidAsin": "ASIN no válido",
|
||||
@@ -835,7 +850,7 @@
|
||||
"MessageNoEpisodes": "Ningún episodio",
|
||||
"MessageNoFoldersAvailable": "Ninguna carpeta disponible",
|
||||
"MessageNoGenres": "Ningún género",
|
||||
"MessageNoIssues": "Ningún número",
|
||||
"MessageNoIssues": "Sin incidencias",
|
||||
"MessageNoItems": "Ningún elemento",
|
||||
"MessageNoItemsFound": "Ningún elemento encontrado",
|
||||
"MessageNoListeningSessions": "Ninguna sesión de escucha",
|
||||
@@ -873,7 +888,7 @@
|
||||
"MessageResetChaptersConfirm": "¿Confirma que quiere deshacer los cambios y restablecer los capítulos a su estado original?",
|
||||
"MessageRestoreBackupConfirm": "¿Confirma que quiere restaurar el respaldo creado el",
|
||||
"MessageRestoreBackupWarning": "Restaurar sobrescribirá toda la base de datos localizada en /config y las imágenes de portadas en /metadata/items y /metadata/authors.<br /><br />El respaldo no modifica ningún archivo en las carpetas de su biblioteca. Si ha habilitado la opción del servidor para almacenar portadas y metadata en las carpetas de su biblioteca, esos archivos no se respaldan o sobrescriben.<br /><br />Todos los clientes que usen su servidor se actualizarán automáticamente.",
|
||||
"MessageScheduleLibraryScanNote": "Para la mayoría de los usuarios, se recomienda dejar esta función desactivada y mantener activada la configuración del observador de carpetas. El observador de carpetas detectará automáticamente los cambios en las carpetas de la biblioteca. El observador de carpetas no funciona para todos los sistemas de archivos (como NFS), por lo que se pueden utilizar exploraciones programadas de la biblioteca en su lugar.",
|
||||
"MessageScheduleLibraryScanNote": "Para muchos usuarios, es recomendado dejar esta característica inhabilitada y mantener habilitados los ajustes de la «Vigía automática de cambio de biblioteca»: detectará automáticamente los cambios en sus carpetas de bibliotecas. Habilitar esta características si «Vigía automática de cambio de biblioteca» no funciona en su sistema de archivo (como NFS).",
|
||||
"MessageScheduleRunEveryWeekdayAtTime": "Ejecutar cada {0} a las {1}",
|
||||
"MessageSearchResultsFor": "Resultados de la búsqueda de",
|
||||
"MessageSelected": "{0} seleccionado(s)",
|
||||
@@ -999,6 +1014,8 @@
|
||||
"ToastBulkChapterInvalidCount": "Por favor ingrese un número válido entre 1 y 150",
|
||||
"ToastCachePurgeFailed": "No se pudo purgar la antememoria",
|
||||
"ToastCachePurgeSuccess": "Se purgó la antememoria correctamente",
|
||||
"ToastChapterLocked": "El capítulo está bloqueado.",
|
||||
"ToastChapterStartTimeAdjusted": "El capítulo inicia el tiempo ajustado en {0} segundos",
|
||||
"ToastChaptersAllLocked": "Todos los capítulos están bloqueados. Desbloquee algunos capítulos para cambiar sus tiempos.",
|
||||
"ToastChaptersHaveErrors": "Los capítulos tienen errores",
|
||||
"ToastChaptersInvalidShiftAmountLast": "Cantidad de desplazamiento no válida. La hora de inicio del último capítulo se extendería más allá de la duración de este audiolibro.",
|
||||
@@ -1009,6 +1026,8 @@
|
||||
"ToastCollectionItemsAddFailed": "Artículo(s) añadido(s) a la colección fallido(s)",
|
||||
"ToastCollectionRemoveSuccess": "Colección quitada",
|
||||
"ToastCollectionUpdateSuccess": "Colección actualizada",
|
||||
"ToastConnectionNotAvailable": "Conexión no disponible. Intenta de nuevo más tarde",
|
||||
"ToastCoverSearchFailed": "Cobertura de búsqueda incorrecta",
|
||||
"ToastCoverUpdateFailed": "Error al actualizar la cubierta",
|
||||
"ToastDateTimeInvalidOrIncomplete": "Fecha y hora no válidas o incompletas",
|
||||
"ToastDeleteFileFailed": "Falló la eliminación del archivo",
|
||||
@@ -1024,6 +1043,8 @@
|
||||
"ToastEpisodeDownloadQueueClearSuccess": "Se borró la cola de descargas de los episodios",
|
||||
"ToastEpisodeUpdateSuccess": "{0} episodio(s) actualizado(s)",
|
||||
"ToastErrorCannotShare": "No se puede compartir de forma nativa en este dispositivo",
|
||||
"ToastFailedToCreate": "Ha fallado al crear",
|
||||
"ToastFailedToDelete": "Ha fallado al eliminar",
|
||||
"ToastFailedToLoadData": "Error al cargar data",
|
||||
"ToastFailedToMatch": "Error al emparejar",
|
||||
"ToastFailedToShare": "Error al compartir",
|
||||
@@ -1031,6 +1052,7 @@
|
||||
"ToastInvalidImageUrl": "URL de la imagen no válida",
|
||||
"ToastInvalidMaxEpisodesToDownload": "Número máximo de episodios para descargar no válidos",
|
||||
"ToastInvalidUrl": "URL no válida",
|
||||
"ToastInvalidUrls": "Una o más URL son inválidas",
|
||||
"ToastItemCoverUpdateSuccess": "Cubierta del elemento actualizada",
|
||||
"ToastItemDeletedFailed": "Error al eliminar el elemento",
|
||||
"ToastItemDeletedSuccess": "Elemento borrado",
|
||||
@@ -1055,6 +1077,7 @@
|
||||
"ToastMustHaveAtLeastOnePath": "Debe tener al menos una ruta",
|
||||
"ToastNameEmailRequired": "Son obligatorios el nombre y el correo electrónico",
|
||||
"ToastNameRequired": "Nombre obligatorio",
|
||||
"ToastNewApiKeyUserError": "Debe seleccionar un usuario",
|
||||
"ToastNewEpisodesFound": "{0} nuevo(s) episodio(s) encontrado(s)",
|
||||
"ToastNewUserCreatedFailed": "No se pudo crear la cuenta: «{0}»",
|
||||
"ToastNewUserCreatedSuccess": "Nueva cuenta creada",
|
||||
@@ -1093,8 +1116,8 @@
|
||||
"ToastRemoveFailed": "Error al eliminar",
|
||||
"ToastRemoveItemFromCollectionFailed": "Error al eliminar el elemento de la colección",
|
||||
"ToastRemoveItemFromCollectionSuccess": "Elemento eliminado de la colección",
|
||||
"ToastRemoveItemsWithIssuesFailed": "Error en la eliminación de artículos de biblioteca incorrectos",
|
||||
"ToastRemoveItemsWithIssuesSuccess": "Se eliminaron artículos de biblioteca incorrectos",
|
||||
"ToastRemoveItemsWithIssuesFailed": "Error en la eliminación de artículos de biblioteca con incidencias",
|
||||
"ToastRemoveItemsWithIssuesSuccess": "Se eliminaron artículos de biblioteca con incidencias",
|
||||
"ToastRenameFailed": "Error al cambiar el nombre",
|
||||
"ToastRescanFailed": "Error al volver a escanear para {0}",
|
||||
"ToastRescanRemoved": "Se eliminó el elemento reescaneado",
|
||||
@@ -1133,6 +1156,7 @@
|
||||
"ToastUserRootRequireName": "Debe introducir un nombre de usuario administrativo",
|
||||
"TooltipAddChapters": "Añadir capítulo(s)",
|
||||
"TooltipAddOneSecond": "Añadir 1 segundo",
|
||||
"TooltipAdjustChapterStart": "Pulse para ajustar la hora de inicio",
|
||||
"TooltipLockAllChapters": "Bloquear todos los capítulos",
|
||||
"TooltipLockChapter": "Bloquear capítulo (Mayús+clic para rango)",
|
||||
"TooltipSubtractOneSecond": "Restar 1 segundo",
|
||||
|
||||
@@ -622,7 +622,7 @@
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "Par défaut, les fichiers de métadonnées sont stockés dans /metadata/items. En activant ce paramètre, les fichiers de métadonnées seront stockés dans les dossiers des éléments de votre bibliothèque",
|
||||
"LabelSettingsTimeFormat": "Format d’heure",
|
||||
"LabelShare": "Partager",
|
||||
"LabelShareDownloadableHelp": "Permet aux utilisateurs de télécharger un fichier ZIP de l'élément de la bibliothèque.",
|
||||
"LabelShareDownloadableHelp": "Permet aux utilisateurs disposant du lien de partage de télécharger un fichier zip contenant l'élément de la bibliothèque.",
|
||||
"LabelShareOpen": "Ouvrir le partage",
|
||||
"LabelShareURL": "Partager l’URL",
|
||||
"LabelShowAll": "Tout afficher",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"ButtonBrowseForFolder": "Mappa keresése",
|
||||
"ButtonCancel": "Mégse",
|
||||
"ButtonCancelEncode": "Kódolás megszakítása",
|
||||
"ButtonChangeRootPassword": "Gyökérjelszó megváltoztatása",
|
||||
"ButtonChangeRootPassword": "Root jelszó megváltoztatása",
|
||||
"ButtonCheckAndDownloadNewEpisodes": "Új epizódok ellenőrzése és letöltése",
|
||||
"ButtonChooseAFolder": "Válassz egy mappát",
|
||||
"ButtonChooseFiles": "Fájlok kiválasztása",
|
||||
|
||||
+12
-12
@@ -34,7 +34,7 @@
|
||||
"ButtonEditChapters": "Modifica Capitoli",
|
||||
"ButtonEditPodcast": "Modifica Podcast",
|
||||
"ButtonEnable": "Abilita",
|
||||
"ButtonFireAndFail": "Fire and Fail",
|
||||
"ButtonFireAndFail": "Centro e fallimento",
|
||||
"ButtonFireOnTest": "Fire onTest event",
|
||||
"ButtonForceReScan": "Forza Re-Scan",
|
||||
"ButtonFullPath": "Percorso Completo",
|
||||
@@ -182,7 +182,7 @@
|
||||
"HeaderPlaylist": "Playlist",
|
||||
"HeaderPlaylistItems": "Elementi della playlist",
|
||||
"HeaderPodcastsToAdd": "Podcasts da Aggiungere",
|
||||
"HeaderPresets": "Presets",
|
||||
"HeaderPresets": "Preimpostazioni",
|
||||
"HeaderPreviewCover": "Anteprima Cover",
|
||||
"HeaderRSSFeedGeneral": "Dettagli RSS",
|
||||
"HeaderRSSFeedIsOpen": "RSS Feed è aperto",
|
||||
@@ -306,7 +306,7 @@
|
||||
"LabelCustomCronExpression": "Espressione Cron personalizzata:",
|
||||
"LabelDatetime": "Data & Ora",
|
||||
"LabelDays": "Giorni",
|
||||
"LabelDeleteFromFileSystemCheckbox": "Elimina dal file system (togli la spunta per eliminarla solo dal DB)",
|
||||
"LabelDeleteFromFileSystemCheckbox": "Elimina dal file system (despunta per rimuoverla solo dal database)",
|
||||
"LabelDescription": "Descrizione",
|
||||
"LabelDeselectAll": "Deseleziona Tutto",
|
||||
"LabelDetectedPattern": "Trovato pattern:",
|
||||
@@ -436,9 +436,9 @@
|
||||
"LabelLibraryFilterSublistEmpty": "Nessuno {0}",
|
||||
"LabelLibraryItem": "Elementi della biblioteca",
|
||||
"LabelLibraryName": "Nome della biblioteca",
|
||||
"LabelLibrarySortByProgress": "Progressi: Ultimi aggiornamenti",
|
||||
"LabelLibrarySortByProgressFinished": "Progressi: Completati",
|
||||
"LabelLibrarySortByProgressStarted": "Progressi: Iniziati",
|
||||
"LabelLibrarySortByProgress": "Progresso: ultimo aggiornamento",
|
||||
"LabelLibrarySortByProgressFinished": "Progresso: finito",
|
||||
"LabelLibrarySortByProgressStarted": "Progresso: iniziato",
|
||||
"LabelLimit": "Limiti",
|
||||
"LabelLineSpacing": "Interlinea",
|
||||
"LabelListenAgain": "Ascolta ancora",
|
||||
@@ -497,7 +497,7 @@
|
||||
"LabelNumberOfBooks": "Numero di libri",
|
||||
"LabelNumberOfChapters": "Numero di capitoli:",
|
||||
"LabelNumberOfEpisodes": "Numero di episodi",
|
||||
"LabelOpenIDAdvancedPermsClaimDescription": "Nome dell'attestazione OpenID che contiene autorizzazioni avanzate per le azioni dell'utente all'interno dell'applicazione che verranno applicate ai ruoli non amministratori (<b>se configurato</b>). Se il reclamo manca nella risposta, l'accesso ad ABS verrà negato. Se manca una singola opzione, verrà trattata come<code>falsa</code>. Assicurati che l'attestazione del provider di identità corrisponda alla struttura prevista:",
|
||||
"LabelOpenIDAdvancedPermsClaimDescription": "Nome dell'attestazione OpenID che contiene autorizzazioni avanzate per le azioni dell'utente all'interno dell'applicazione che verranno applicate ai ruoli non amministrativi (<b>se configurato</b>). Se il reclamo manca nella risposta, l'accesso ad ABS verrà negato. Se manca una singola opzione, verrà trattata come <code>falso</code>. Assicurati che l'attestazione del provider di identità corrisponda alla struttura prevista:",
|
||||
"LabelOpenIDClaims": "Lasciare vuote le seguenti opzioni per disabilitare l'assegnazione avanzata di gruppi e autorizzazioni, assegnando quindi automaticamente il gruppo \"Utente\".",
|
||||
"LabelOpenIDGroupClaimDescription": "Nome dell'attestazione OpenID che contiene un elenco dei gruppi dell'utente. Comunemente indicato come <code>gruppo</code>. <b>se configurato</b>, l'applicazione assegnerà automaticamente i ruoli in base alle appartenenze ai gruppi dell'utente, a condizione che tali gruppi siano denominati \"admin\", \"utente\" o \"ospite\" senza distinzione tra maiuscole e minuscole nell'attestazione. L'attestazione deve contenere un elenco e, se un utente appartiene a più gruppi, l'applicazione assegnerà il ruolo corrispondente al livello di accesso più alto. Se nessun gruppo corrisponde, l'accesso verrà negato.",
|
||||
"LabelOpenRSSFeed": "Apri RSS Feed",
|
||||
@@ -530,7 +530,7 @@
|
||||
"LabelPrimaryEbook": "Libro principale",
|
||||
"LabelProgress": "Cominciati",
|
||||
"LabelProvider": "Fornitore",
|
||||
"LabelProviderAuthorizationValue": "Authorization Header Value",
|
||||
"LabelProviderAuthorizationValue": "Valore intestazione di autorizzazione",
|
||||
"LabelPubDate": "Data di pubblicazione",
|
||||
"LabelPublishYear": "Anno di pubblicazione",
|
||||
"LabelPublishedDate": "Pubblicati {0}",
|
||||
@@ -674,7 +674,7 @@
|
||||
"LabelTimeDurationXMinutes": "{0} minuti",
|
||||
"LabelTimeDurationXSeconds": "{0} secondi",
|
||||
"LabelTimeInMinutes": "Tempo in minuti",
|
||||
"LabelTimeLeft": "{0} sinistra",
|
||||
"LabelTimeLeft": "{0} rimasti",
|
||||
"LabelTimeListened": "Tempo di Ascolto",
|
||||
"LabelTimeListenedToday": "Tempo di Ascolto Oggi",
|
||||
"LabelTimeRemaining": "{0} rimanente",
|
||||
@@ -682,7 +682,7 @@
|
||||
"LabelTitle": "Titolo",
|
||||
"LabelToolsEmbedMetadata": "Incorpora Metadata",
|
||||
"LabelToolsEmbedMetadataDescription": "Incorpora i metadati nei file audio, inclusi l'immagine di copertina e i capitoli.",
|
||||
"LabelToolsM4bEncoder": "M4B Encoder",
|
||||
"LabelToolsM4bEncoder": "Codificatore M4B",
|
||||
"LabelToolsMakeM4b": "Crea un file M4B",
|
||||
"LabelToolsMakeM4bDescription": "Genera un file audiolibro M4B con metadati incorporati, immagine di copertina e capitoli.",
|
||||
"LabelToolsSplitM4b": "Converti M4B in MP3",
|
||||
@@ -854,7 +854,7 @@
|
||||
"MessageNoItems": "Nessun oggetto",
|
||||
"MessageNoItemsFound": "Nessun oggetto trovato",
|
||||
"MessageNoListeningSessions": "Nessuna sessione di ascolto",
|
||||
"MessageNoLogs": "Nessun Log",
|
||||
"MessageNoLogs": "Nessun rapporto",
|
||||
"MessageNoMediaProgress": "Nessun progresso multimediale",
|
||||
"MessageNoNotifications": "Nessuna notifica",
|
||||
"MessageNoPodcastFeed": "Podcast non valido: nessun feed",
|
||||
@@ -1109,7 +1109,7 @@
|
||||
"ToastProgressIsNotBeingSynced": "L'avanzamento non è sincronizzato, riavviare la riproduzione",
|
||||
"ToastProviderCreatedFailed": "Impossibile aggiungere il provider",
|
||||
"ToastProviderCreatedSuccess": "Aggiunto nuovo provider",
|
||||
"ToastProviderNameAndUrlRequired": "Nome e URL richiesti",
|
||||
"ToastProviderNameAndUrlRequired": "Nome e Url richiesti",
|
||||
"ToastProviderRemoveSuccess": "Provider rimosso",
|
||||
"ToastRSSFeedCloseFailed": "Errore chiusura flusso RSS",
|
||||
"ToastRSSFeedCloseSuccess": "Flusso RSS chiuso",
|
||||
|
||||
+930
-36
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
{}
|
||||
+12
-10
@@ -2,7 +2,7 @@
|
||||
"ButtonAdd": "Toevoegen",
|
||||
"ButtonAddApiKey": "API Key toevoegen",
|
||||
"ButtonAddChapters": "Hoofdstukken toevoegen",
|
||||
"ButtonAddDevice": "Toestel toevoegen",
|
||||
"ButtonAddDevice": "Apparaat toevoegen",
|
||||
"ButtonAddLibrary": "Bibliotheek toevoegen",
|
||||
"ButtonAddPodcasts": "Podcasts toevoegen",
|
||||
"ButtonAddUser": "Gebruiker toevoegen",
|
||||
@@ -139,7 +139,7 @@
|
||||
"HeaderCustomMetadataProviders": "Aangepaste Metadata Providers",
|
||||
"HeaderDetails": "Details",
|
||||
"HeaderDownloadQueue": "Download-wachtrij",
|
||||
"HeaderEbookFiles": "Ebook bestanden",
|
||||
"HeaderEbookFiles": "E-book bestanden",
|
||||
"HeaderEmail": "E-mail",
|
||||
"HeaderEmailSettings": "E-mail instellingen",
|
||||
"HeaderEpisodes": "Afleveringen",
|
||||
@@ -275,7 +275,7 @@
|
||||
"LabelBonus": "Bonus",
|
||||
"LabelBooks": "Boeken",
|
||||
"LabelButtonText": "Knop Tekst",
|
||||
"LabelByAuthor": "Door {0}",
|
||||
"LabelByAuthor": "door {0}",
|
||||
"LabelChangePassword": "Wachtwoord wijzigen",
|
||||
"LabelChannels": "Kanalen",
|
||||
"LabelChapterCount": "{0} Hoofdstukken",
|
||||
@@ -383,7 +383,7 @@
|
||||
"LabelFolders": "Mappen",
|
||||
"LabelFontBold": "Vetgedrukt",
|
||||
"LabelFontBoldness": "Lettertype Dikte",
|
||||
"LabelFontFamily": "Lettertypefamilie",
|
||||
"LabelFontFamily": "Letterfamilie",
|
||||
"LabelFontItalic": "Cursief",
|
||||
"LabelFontScale": "Lettertype schaal",
|
||||
"LabelFontStrikethrough": "Doorgestreept",
|
||||
@@ -436,9 +436,9 @@
|
||||
"LabelLibraryFilterSublistEmpty": "Nee {0}",
|
||||
"LabelLibraryItem": "Bibliotheekonderdeel",
|
||||
"LabelLibraryName": "Bibliotheeknaam",
|
||||
"LabelLibrarySortByProgress": "Voortuigang geüpdatet",
|
||||
"LabelLibrarySortByProgressFinished": "Datum voltooid",
|
||||
"LabelLibrarySortByProgressStarted": "Datum gestart",
|
||||
"LabelLibrarySortByProgress": "Voortgang: Laatst geüpdatet",
|
||||
"LabelLibrarySortByProgressFinished": "Voortgang: Voltooid",
|
||||
"LabelLibrarySortByProgressStarted": "Voortgang: Gestart",
|
||||
"LabelLimit": "Limiet",
|
||||
"LabelLineSpacing": "Regelruimte",
|
||||
"LabelListenAgain": "Opnieuw Beluisteren",
|
||||
@@ -588,8 +588,8 @@
|
||||
"LabelSettingsBookshelfViewHelp": "Skeumorphisch design met houten planken",
|
||||
"LabelSettingsChromecastSupport": "Chromecast ondersteuning",
|
||||
"LabelSettingsDateFormat": "Datumnotatie",
|
||||
"LabelSettingsEnableWatcher": "Bibliotheken automatisch scannen op wijzigingen",
|
||||
"LabelSettingsEnableWatcherForLibrary": "Bibliotheek automatisch scannen op wijzigingen",
|
||||
"LabelSettingsEnableWatcher": "Bibliotheken automatisch monitoren op wijzigingen",
|
||||
"LabelSettingsEnableWatcherForLibrary": "Bibliotheek automatisch monitoren op wijzigingen",
|
||||
"LabelSettingsEnableWatcherHelp": "Zorgt voor het automatisch toevoegen/bijwerken van onderdelen als bestandswijzigingen worden gedetecteerd. *Vereist herstarten van server",
|
||||
"LabelSettingsEpubsAllowScriptedContent": "Sta scripted content toe in epubs",
|
||||
"LabelSettingsEpubsAllowScriptedContentHelp": "Sta toe dat epub-bestanden scripts uitvoeren. Het wordt aanbevolen om deze instelling uitgeschakeld te houden, tenzij u de bron van de epub-bestanden vertrouwt.",
|
||||
@@ -888,7 +888,7 @@
|
||||
"MessageResetChaptersConfirm": "Weet je zeker dat je de hoofdstukken wil resetten en de wijzigingen die je gemaakt hebt ongedaan wil maken?",
|
||||
"MessageRestoreBackupConfirm": "Weet je zeker dat je wil herstellen met behulp van de back-up gemaakt op",
|
||||
"MessageRestoreBackupWarning": "Een back-up herstellen zal de volledige database in /config en de omslagen in /metadata/items & /metadata/authors overschrijven.<br /><br />Back-ups wijzigen geen bestanden in je bibliotheekmappen. Als je de serverinstelling gebruikt om omslagen en metadata in je bibliotheekmappen te bewaren dan worden deze niet geback-upt of overschreven.<br /><br />Alle apparaten die je server gebruiken, worden automatisch ververst.",
|
||||
"MessageScheduleLibraryScanNote": "Voor de meeste gebruikers is het raadzaam om deze functie uitgeschakeld te laten en de folder watcher-instelling ingeschakeld te houden. De folder watcher detecteert automatisch wijzigingen in uw bibliotheekmappen. De folder watcher werkt niet voor elk bestandssysteem (zoals NFS), dus geplande bibliotheekscans kunnen in plaats daarvan worden gebruikt.",
|
||||
"MessageScheduleLibraryScanNote": "Voor de meeste gebruikers is het aangeraden om deze functie uitgeschakeld te laten en de \"Bibliotheek automatisch monitoren op wijzigingen\" instelling ingeschakeld te houden - deze detecteert automatisch wijzigingen in uw bibliotheekmappen. Activeer deze instelling als \"Bibliotheek automatisch monitoren op wijzigingen\" niet werkt voor uw bestandssysteem (zoals NFS).",
|
||||
"MessageScheduleRunEveryWeekdayAtTime": "Elke {0} uitvoeren op {1}",
|
||||
"MessageSearchResultsFor": "Zoekresultaten voor",
|
||||
"MessageSelected": "{0} geselecteerd",
|
||||
@@ -1026,6 +1026,8 @@
|
||||
"ToastCollectionItemsAddFailed": "Item(s) toegevoegd aan collectie mislukt",
|
||||
"ToastCollectionRemoveSuccess": "Collectie verwijderd",
|
||||
"ToastCollectionUpdateSuccess": "Collectie bijgewerkt",
|
||||
"ToastConnectionNotAvailable": "Verbinding niet beschikbaar. Gelieve later opnieuw te proberen",
|
||||
"ToastCoverSearchFailed": "Omslag zoeken mislukt",
|
||||
"ToastCoverUpdateFailed": "Omslag bijwerken mislukt",
|
||||
"ToastDateTimeInvalidOrIncomplete": "Datum en tijd ongeldig of onvolledig",
|
||||
"ToastDeleteFileFailed": "Bestand verwijderen mislukt",
|
||||
|
||||
@@ -951,6 +951,11 @@
|
||||
"NoteUploaderFoldersWithMediaFiles": "Foldery z plikami multimedialnymi będą traktowane jako osobne elementy w bibliotece.",
|
||||
"NoteUploaderOnlyAudioFiles": "Jeśli przesyłasz tylko pliki audio, każdy plik audio będzie traktowany jako osobny audiobook.",
|
||||
"NoteUploaderUnsupportedFiles": "Nieobsługiwane pliki są ignorowane. Podczas dodawania folderu, inne pliki, które nie znajdują się w folderze elementu, są ignorowane.",
|
||||
"NotificationOnBackupCompletedDescription": "Wyzwalane po zakończeniu tworzenia kopii zapasowej",
|
||||
"NotificationOnBackupFailedDescription": "Wyzwalane w przypadku gdy stworzenie kopii zapasowej rzuci błąd",
|
||||
"NotificationOnEpisodeDownloadedDescription": "Wyzwalane, gdy odcinek podcastu zostanie automatycznie pobrany",
|
||||
"NotificationOnRSSFeedDisabledDescription": "Wyzwalane, gdy automatyczne pobieranie odcinków jest wyłączone z powodu zbyt wielu nieudanych prób",
|
||||
"NotificationOnRSSFeedFailedDescription": "Wyzwalane, gdy żądanie kanału RSS dotyczące automatycznego pobrania odcinka nie powiedzie się",
|
||||
"NotificationOnTestDescription": "Zdarzenie używane do testowania systemu powiadomień",
|
||||
"PlaceholderBulkChapterInput": "Wpisz tytuł rozdziału lub użyj numeracji (np. „Odcinek 1”, „Rozdział 10”, „1.”)",
|
||||
"PlaceholderNewCollection": "Nowa nazwa kolekcji",
|
||||
@@ -960,6 +965,7 @@
|
||||
"PlaceholderSearchEpisode": "Szukanie odcinka..",
|
||||
"StatsAuthorsAdded": "dodano autorów",
|
||||
"StatsBooksAdded": "dodano książki",
|
||||
"StatsBooksAdditional": "Niektóre dodatki obejmują…",
|
||||
"StatsBooksFinished": "ukończone książki",
|
||||
"StatsBooksFinishedThisYear": "Wybrane książki ukończone w tym roku…",
|
||||
"StatsBooksListenedTo": "książki wysłuchane",
|
||||
@@ -976,6 +982,7 @@
|
||||
"StatsTotalDuration": "O sumarycznej długości…",
|
||||
"StatsYearInReview": "PRZEGLĄD ROKU",
|
||||
"ToastAccountUpdateSuccess": "Zaktualizowano konto",
|
||||
"ToastAppriseUrlRequired": "Należy wprowadzić adres URL Apprise",
|
||||
"ToastAsinRequired": "ASIN jest wymagany",
|
||||
"ToastAuthorImageRemoveSuccess": "Zdjęcie autora usunięte",
|
||||
"ToastAuthorNotFound": "Autor \"{0}\" nie został znaleziony",
|
||||
@@ -994,8 +1001,11 @@
|
||||
"ToastBackupRestoreFailed": "Nie udało się przywrócić kopii zapasowej",
|
||||
"ToastBackupUploadFailed": "Nie udało się przesłać kopii zapasowej",
|
||||
"ToastBackupUploadSuccess": "Kopia zapasowa została przesłana",
|
||||
"ToastBatchApplyDetailsToItemsSuccess": "Szczegóły zastosowane do elementów",
|
||||
"ToastBatchDeleteFailed": "Usuwanie zbiorcze nie powiodło się",
|
||||
"ToastBatchDeleteSuccess": "Usuwanie zbiorcze powiodło się",
|
||||
"ToastBatchQuickMatchFailed": "Szybkie dopasowanie partii nie powiodło się!",
|
||||
"ToastBatchQuickMatchStarted": "Rozpoczęto partię szybkiego dopasowania {0} książek!",
|
||||
"ToastBatchUpdateFailed": "Aktualizacja zbiorcza nie powiodła się",
|
||||
"ToastBatchUpdateSuccess": "Aktualizacja zbiorcza powiodła się",
|
||||
"ToastBookmarkCreateFailed": "Nie udało się utworzyć zakładki",
|
||||
@@ -1033,7 +1043,14 @@
|
||||
"ToastEpisodeDownloadQueueClearSuccess": "Wyczyszczono kolejkę epizodów do ściągnięcia",
|
||||
"ToastEpisodeUpdateSuccess": "Zaktualizowano {0} odcinków",
|
||||
"ToastErrorCannotShare": "Nie można udostępniać natywnie na tym urządzeniu.",
|
||||
"ToastFailedToCreate": "Nie udało się utworzyć",
|
||||
"ToastFailedToDelete": "Nie udało się usunąć",
|
||||
"ToastFailedToLoadData": "Nie udało się załadować danych",
|
||||
"ToastFailedToMatch": "Nie udało się dopasować",
|
||||
"ToastFailedToShare": "Nie udało się udostępnić",
|
||||
"ToastFailedToUpdate": "Nie udało się zaktualizować",
|
||||
"ToastInvalidImageUrl": "Nieprawidłowy URL obrazu",
|
||||
"ToastInvalidMaxEpisodesToDownload": "Nieprawidłowa maksymalna liczba odcinków do pobrania",
|
||||
"ToastInvalidUrl": "Nieprawidłowy URL",
|
||||
"ToastInvalidUrls": "Jeden lub więcej URL-i są nieprawidłowe",
|
||||
"ToastItemCoverUpdateSuccess": "Zaktualizowano okładkę",
|
||||
@@ -1044,6 +1061,7 @@
|
||||
"ToastItemMarkedAsFinishedSuccess": "Pozycja oznaczona jako ukończona",
|
||||
"ToastItemMarkedAsNotFinishedFailed": "Oznaczenie pozycji jako ukończonej nie powiodło się",
|
||||
"ToastItemMarkedAsNotFinishedSuccess": "Pozycja oznaczona jako nieukończona",
|
||||
"ToastItemUpdateSuccess": "Element zaktualizowany",
|
||||
"ToastLibraryCreateFailed": "Nie udało się utworzyć biblioteki",
|
||||
"ToastLibraryCreateSuccess": "Biblioteka \"{0}\" stworzona",
|
||||
"ToastLibraryDeleteFailed": "Nie udało się usunąć biblioteki",
|
||||
@@ -1052,6 +1070,10 @@
|
||||
"ToastLibraryScanStarted": "Rozpoczęto skanowanie biblioteki",
|
||||
"ToastLibraryUpdateSuccess": "Zaktualizowano \"{0}\" pozycji",
|
||||
"ToastMatchAllAuthorsFailed": "Nie udało się dopasować wszystkich autorów",
|
||||
"ToastMetadataFilesRemovedError": "Błąd podczas usuwania metadata.{0} plików",
|
||||
"ToastMetadataFilesRemovedNoneFound": "Nie znaleziono metadata.{0} plików w bibliotece",
|
||||
"ToastMetadataFilesRemovedNoneRemoved": "Nie usunięto żadnego metadata.{0} pliku",
|
||||
"ToastMetadataFilesRemovedSuccess": "{0} metadata.{0} plików usunięto",
|
||||
"ToastMustHaveAtLeastOnePath": "Musi mieć przynajmniej jedną ścieżkę",
|
||||
"ToastNameEmailRequired": "Nazwa i email są wymagane",
|
||||
"ToastNameRequired": "Imię jest wymagane",
|
||||
@@ -1065,7 +1087,15 @@
|
||||
"ToastNewUserUsernameError": "Wprowadź nazwę użytkownika",
|
||||
"ToastNoNewEpisodesFound": "Nie znaleziono nowych odcinków",
|
||||
"ToastNoRSSFeed": "Podcast nie posiada RSS Feed",
|
||||
"ToastNoUpdatesNecessary": "Brak konieczności aktualizacji",
|
||||
"ToastNotificationCreateFailed": "Nie udało się utworzyć powiadomienia",
|
||||
"ToastNotificationDeleteFailed": "Nie udało się usunąć powiadomienia",
|
||||
"ToastNotificationFailedMaximum": "Maks. ilość nieudanych prób musi być >= 0",
|
||||
"ToastNotificationQueueMaximum": "Maksymalna liczba powiadomień w kolejce musi być >= 0",
|
||||
"ToastNotificationSettingsUpdateSuccess": "Zaktualizowano ustawienia powiadomień",
|
||||
"ToastNotificationTestTriggerFailed": "Nie udało się wywołać powiadomienia testowego",
|
||||
"ToastNotificationTestTriggerSuccess": "Wyzwolono powiadomienie testowe",
|
||||
"ToastNotificationUpdateSuccess": "Powiadomienie zaktualizowane",
|
||||
"ToastPlaylistCreateFailed": "Nie udało się utworzyć playlisty",
|
||||
"ToastPlaylistCreateSuccess": "Playlista utworzona",
|
||||
"ToastPlaylistRemoveSuccess": "Playlista usunięta",
|
||||
@@ -1073,8 +1103,17 @@
|
||||
"ToastPodcastCreateFailed": "Nie udało się utworzyć podcastu",
|
||||
"ToastPodcastCreateSuccess": "Podcast został pomyślnie utworzony",
|
||||
"ToastPodcastEpisodeUpdated": "Zaktualizowano odcinki",
|
||||
"ToastPodcastGetFeedFailed": "Nie udało się pobrać kanału podcastu",
|
||||
"ToastPodcastNoEpisodesInFeed": "Nie znaleziono żadnych odcinków w kanale RSS",
|
||||
"ToastPodcastNoRssFeed": "Podcast nie ma kanału RSS",
|
||||
"ToastProgressIsNotBeingSynced": "Postęp nie jest synchronizowany, uruchom ponownie odtwarzanie",
|
||||
"ToastProviderCreatedFailed": "Nie udało się dodać dostawcy",
|
||||
"ToastProviderCreatedSuccess": "Dodano nowego dostawcę",
|
||||
"ToastProviderNameAndUrlRequired": "Wymagane jest podanie nazwy i adresu URL",
|
||||
"ToastProviderRemoveSuccess": "Dostawca usunięty",
|
||||
"ToastRSSFeedCloseFailed": "Zamknięcie kanału RSS nie powiodło się",
|
||||
"ToastRSSFeedCloseSuccess": "Zamknięcie kanału RSS powiodło się",
|
||||
"ToastRemoveFailed": "Nie udało się usunąć",
|
||||
"ToastRemoveItemFromCollectionFailed": "Nie udało się usunąć elementu z kolekcji",
|
||||
"ToastRemoveItemFromCollectionSuccess": "Pozycja usunięta z kolekcji",
|
||||
"ToastRemoveItemsWithIssuesFailed": "Nie udało się usunąć wadliwych elementów z biblioteki",
|
||||
@@ -1096,16 +1135,25 @@
|
||||
"ToastSessionDeleteFailed": "Nie udało się usunąć sesji",
|
||||
"ToastSessionDeleteSuccess": "Sesja usunięta",
|
||||
"ToastSleepTimerDone": "Słodkich snów... zZzzZz",
|
||||
"ToastSlugMustChange": "Slug zawiera nieprawidłowe znaki",
|
||||
"ToastSlugRequired": "Slug jest wymagany",
|
||||
"ToastSocketConnected": "Nawiązano połączenie z serwerem",
|
||||
"ToastSocketDisconnected": "Połączenie z serwerem zostało zamknięte",
|
||||
"ToastSocketFailedToConnect": "Poączenie z serwerem nie powiodło się",
|
||||
"ToastSortingPrefixesEmptyError": "Musi mieć co najmniej 1 prefiks sortowania",
|
||||
"ToastSortingPrefixesUpdateSuccess": "Zaktualizowano prefiksy sortowania ({0} elementów)",
|
||||
"ToastTitleRequired": "Tytuł jest wymagany",
|
||||
"ToastUnknownError": "Nieznany błąd",
|
||||
"ToastUnlinkOpenIdFailed": "Nie udało się odpiąć użytkownika z OpenID",
|
||||
"ToastUnlinkOpenIdSuccess": "Użytkownik odpięty z OpenID",
|
||||
"ToastUploaderFilepathExistsError": "Ścieżka \"{0}\" już istnieje na serwerze",
|
||||
"ToastUploaderItemExistsInSubdirectoryError": "Element \"{0}\" używa podkatalogu ścieżki przesyłania.",
|
||||
"ToastUserDeleteFailed": "Nie udało się usunąć użytkownika",
|
||||
"ToastUserDeleteSuccess": "Użytkownik usunięty",
|
||||
"ToastUserPasswordChangeSuccess": "Hasło zostało pomyślnie zmienione",
|
||||
"ToastUserPasswordMismatch": "Hasła nie są zgodne",
|
||||
"ToastUserPasswordMustChange": "Nowe hasło nie może być takie samo jak stare hasło",
|
||||
"ToastUserRootRequireName": "Należy wprowadzić nazwę użytkownika root",
|
||||
"TooltipAddChapters": "Dodaj rozdział(y)",
|
||||
"TooltipAddOneSecond": "Dodaj sekundę",
|
||||
"TooltipAdjustChapterStart": "Kliknij, aby skorygować czas początkowy",
|
||||
|
||||
@@ -392,7 +392,7 @@
|
||||
"LabelGenre": "Жанр",
|
||||
"LabelGenres": "Жанры",
|
||||
"LabelHardDeleteFile": "Жесткое удаление файла",
|
||||
"LabelHasEbook": "Есть e-книга",
|
||||
"LabelHasEbook": "Есть электронная книга",
|
||||
"LabelHasSupplementaryEbook": "Есть дополнительная e-книга",
|
||||
"LabelHideSubtitles": "Скрыть серии",
|
||||
"LabelHighestPriority": "Наивысший приоритет",
|
||||
@@ -437,8 +437,8 @@
|
||||
"LabelLibraryItem": "Элемент библиотеки",
|
||||
"LabelLibraryName": "Имя библиотеки",
|
||||
"LabelLibrarySortByProgress": "Прогресс: Последнее обновление",
|
||||
"LabelLibrarySortByProgressFinished": "Прогресс: Завершено",
|
||||
"LabelLibrarySortByProgressStarted": "Прогресс: Начато",
|
||||
"LabelLibrarySortByProgressFinished": "Прогресс: Закончена",
|
||||
"LabelLibrarySortByProgressStarted": "Прогресс: Начата",
|
||||
"LabelLimit": "Лимит",
|
||||
"LabelLineSpacing": "Межстрочный интервал",
|
||||
"LabelListenAgain": "Послушать снова",
|
||||
|
||||
@@ -275,7 +275,7 @@
|
||||
"LabelBonus": "Bonus",
|
||||
"LabelBooks": "Knihy",
|
||||
"LabelButtonText": "Text tlačidla",
|
||||
"LabelByAuthor": "od",
|
||||
"LabelByAuthor": "od {0}",
|
||||
"LabelChangePassword": "Zmeniť heslo",
|
||||
"LabelChannels": "Kanály",
|
||||
"LabelChapterCount": "{0} kapitol",
|
||||
|
||||
@@ -31,6 +31,11 @@ if (isDev || options['prod-with-dev-env']) {
|
||||
if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1'
|
||||
if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath
|
||||
if (devEnv.ReactClientPath) process.env.REACT_CLIENT_PATH = devEnv.ReactClientPath
|
||||
if (devEnv.AllowedDevOrigins) {
|
||||
process.env.ALLOWED_DEV_ORIGINS = Array.isArray(devEnv.AllowedDevOrigins)
|
||||
? devEnv.AllowedDevOrigins.join(',')
|
||||
: String(devEnv.AllowedDevOrigins)
|
||||
}
|
||||
process.env.SOURCE = 'local'
|
||||
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath ?? '/audiobookshelf'
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.33.0",
|
||||
"version": "2.35.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.33.0",
|
||||
"version": "2.35.1",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"axios": "^0.27.2",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.33.0",
|
||||
"version": "2.35.1",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast server",
|
||||
"main": "index.js",
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<br />
|
||||
<a href="https://audiobookshelf.org/docs">Documentation</a>
|
||||
·
|
||||
<a href="https://audiobookshelf.org/guides">User Guides</a>
|
||||
·
|
||||
<a href="https://audiobookshelf.org/support">Support</a>
|
||||
·
|
||||
<a href="https://audiobooks.dev/">Demo</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -57,21 +57,17 @@ Try it out on the [Google Play Store](https://play.google.com/store/apps/details
|
||||
|
||||
Using Test Flight: https://testflight.apple.com/join/wiic7QIW **_(beta is full)_**
|
||||
|
||||
### Build your own tools & clients
|
||||
|
||||
Check out the [API documentation](https://api.audiobookshelf.org/)
|
||||
|
||||
<br />
|
||||
|
||||
<img alt="Library Screenshot" src="https://github.com/advplyr/audiobookshelf/raw/master/images/DemoLibrary.png" />
|
||||
|
||||
<br />
|
||||
|
||||
# Organizing your audiobooks
|
||||
# Organizing your media
|
||||
|
||||
#### Directory structure and folder names are important to Audiobookshelf!
|
||||
|
||||
See [documentation](https://audiobookshelf.org/docs#book-directory-structure) for supported directory structure, folder naming conventions, and audio file metadata usage.
|
||||
See [library docs](https://audiobookshelf.org/docs/category/libraries) for supported directory structures, folder naming conventions, and audio file metadata usage.
|
||||
|
||||
<br />
|
||||
|
||||
@@ -87,275 +83,24 @@ See [install docs](https://www.audiobookshelf.org/docs)
|
||||
|
||||
#### Note: Using a subfolder is supported with no additional changes but the path must be `/audiobookshelf` (this is not changeable). See [discussion](https://github.com/advplyr/audiobookshelf/discussions/3535)
|
||||
|
||||
### NGINX Proxy Manager
|
||||
See [reverse proxy docs](https://audiobookshelf.org/docs/category/reverse-proxy)
|
||||
|
||||
Toggle websockets support.
|
||||
|
||||
<img alt="NGINX Web socket" src="https://user-images.githubusercontent.com/67830747/153679106-b2a7f5b9-0702-48c6-9740-b26b401986e9.png" />
|
||||
|
||||
### NGINX Reverse Proxy
|
||||
|
||||
Add this to the site config file on your nginx server after you have changed the relevant parts in the <> brackets, and inserted your certificate paths.
|
||||
|
||||
```bash
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name <sub>.<domain>.<tld>;
|
||||
|
||||
access_log /var/log/nginx/audiobookshelf.access.log;
|
||||
error_log /var/log/nginx/audiobookshelf.error.log;
|
||||
|
||||
ssl_certificate /path/to/certificate;
|
||||
ssl_certificate_key /path/to/key;
|
||||
|
||||
location / {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_pass http://<URL_to_forward_to>;
|
||||
proxy_redirect http:// https://;
|
||||
|
||||
# Prevent 413 Request Entity Too Large error
|
||||
# by increasing the maximum allowed size of the client request body
|
||||
# For example, set it to 10 GiB
|
||||
client_max_body_size 10240M;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Apache Reverse Proxy
|
||||
|
||||
Add this to the site config file on your Apache server after you have changed the relevant parts in the <> brackets, and inserted your certificate paths.
|
||||
|
||||
For this to work you must enable at least the following mods using `a2enmod`:
|
||||
|
||||
- `ssl`
|
||||
- `proxy`
|
||||
- `proxy_http`
|
||||
- `proxy_balancer`
|
||||
- `proxy_wstunnel`
|
||||
- `rewrite`
|
||||
|
||||
```bash
|
||||
<IfModule mod_ssl.c>
|
||||
<VirtualHost *:443>
|
||||
ServerName <sub>.<domain>.<tld>
|
||||
|
||||
ErrorLog ${APACHE_LOG_DIR}/error.log
|
||||
CustomLog ${APACHE_LOG_DIR}/access.log combined
|
||||
|
||||
ProxyPreserveHost On
|
||||
ProxyPass / http://localhost:<audiobookshelf_port>/
|
||||
RewriteEngine on
|
||||
RewriteCond %{HTTP:Upgrade} websocket [NC]
|
||||
RewriteCond %{HTTP:Connection} upgrade [NC]
|
||||
RewriteRule ^/?(.*) "ws://localhost:<audiobookshelf_port>/$1" [P,L]
|
||||
|
||||
# unless you're doing something special this should be generated by a
|
||||
# tool like certbot by let's encrypt
|
||||
SSLCertificateFile /path/to/cert/file
|
||||
SSLCertificateKeyFile /path/to/key/file
|
||||
</VirtualHost>
|
||||
</IfModule>
|
||||
```
|
||||
|
||||
If using Apache >= 2.4.47 you can use the following, without having to use any of the `RewriteEngine`, `RewriteCond`, or `RewriteRule` directives. For example:
|
||||
|
||||
```xml
|
||||
<Location /audiobookshelf>
|
||||
ProxyPreserveHost on
|
||||
ProxyPass http://localhost:<audiobookshelf_port>/audiobookshelf upgrade=websocket
|
||||
ProxyPassReverse http://localhost:<audiobookshelf_port>/audiobookshelf
|
||||
</Location>
|
||||
```
|
||||
|
||||
Some SSL certificates like those signed by Let's Encrypt require ACME validation. To allow Let's Encrypt to write and confirm the ACME challenge, edit your VirtualHost definition to prevent proxying traffic that queries `/.well-known` and instead serve that directly:
|
||||
|
||||
```bash
|
||||
<VirtualHost *:443>
|
||||
# ...
|
||||
|
||||
# create the directory structure /.well-known/acme-challenges
|
||||
# within DocumentRoot and give the HTTP user recursive write
|
||||
# access to it.
|
||||
DocumentRoot /path/to/local/directory
|
||||
|
||||
ProxyPreserveHost On
|
||||
ProxyPass /.well-known !
|
||||
ProxyPass / http://localhost:<audiobookshelf_port>/
|
||||
|
||||
# ...
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
### SWAG Reverse Proxy
|
||||
|
||||
[See LinuxServer.io config sample](https://github.com/linuxserver/reverse-proxy-confs/blob/master/audiobookshelf.subdomain.conf.sample)
|
||||
|
||||
### Synology NAS Reverse Proxy Setup (DSM 7+/Quickconnect)
|
||||
|
||||
1. **Open Control Panel**
|
||||
|
||||
- Navigate to `Login Portal > Advanced`.
|
||||
|
||||
2. **General Tab**
|
||||
|
||||
- Click `Reverse Proxy` > `Create`.
|
||||
|
||||
| Setting | Value |
|
||||
| ------------------ | -------------- |
|
||||
| Reverse Proxy Name | audiobookshelf |
|
||||
|
||||
3. **Source Configuration**
|
||||
|
||||
| Setting | Value |
|
||||
| ---------------------- | ---------------------------------------- |
|
||||
| Protocol | HTTPS |
|
||||
| Hostname | `<sub>.<quickconnectdomain>.synology.me` |
|
||||
| Port | 443 |
|
||||
| Access Control Profile | Leave as is |
|
||||
|
||||
- Example Hostname: `audiobookshelf.mydomain.synology.me`
|
||||
|
||||
4. **Destination Configuration**
|
||||
|
||||
| Setting | Value |
|
||||
| -------- | ----------- |
|
||||
| Protocol | HTTP |
|
||||
| Hostname | Your NAS IP |
|
||||
| Port | 13378 |
|
||||
|
||||
5. **Custom Header Tab**
|
||||
|
||||
- Go to `Create > Websocket`.
|
||||
- Configure Headers (leave as is):
|
||||
|
||||
| Header Name | Value |
|
||||
| ----------- | --------------------- |
|
||||
| Upgrade | `$http_upgrade` |
|
||||
| Connection | `$connection_upgrade` |
|
||||
|
||||
6. **Advanced Settings Tab**
|
||||
- Leave as is.
|
||||
|
||||
### [Traefik Reverse Proxy](https://doc.traefik.io/traefik/)
|
||||
|
||||
Middleware relating to CORS will cause the app to report Unknown Error when logging in. To prevent this don't apply any of the following headers to the router for this site:
|
||||
|
||||
<ul>
|
||||
<li>accessControlAllowMethods</li>
|
||||
<li>accessControlAllowOriginList</li>
|
||||
<li>accessControlMaxAge</li>
|
||||
</ul>
|
||||
|
||||
From [@Dondochaka](https://discord.com/channels/942908292873723984/942914154254176257/945074590374318170) and [@BeastleeUK](https://discord.com/channels/942908292873723984/942914154254176257/970366039294611506) <br />
|
||||
|
||||
### Example Caddyfile - [Caddy Reverse Proxy](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy)
|
||||
|
||||
```
|
||||
subdomain.domain.com {
|
||||
encode gzip zstd
|
||||
reverse_proxy <LOCAL_IP>:<PORT>
|
||||
}
|
||||
```
|
||||
|
||||
### HAProxy
|
||||
|
||||
Below is a generic HAProxy config, using `audiobookshelf.YOUR_DOMAIN.COM`.
|
||||
|
||||
To use `http2`, `ssl` is needed.
|
||||
|
||||
```make
|
||||
global
|
||||
# ... (your global settings go here)
|
||||
|
||||
defaults
|
||||
mode http
|
||||
# ... (your default settings go here)
|
||||
|
||||
frontend my_frontend
|
||||
# Bind to port 443, enable SSL, and specify the certificate list file
|
||||
bind :443 name :443 ssl crt-list /path/to/cert.crt_list alpn h2,http/1.1
|
||||
mode http
|
||||
|
||||
# Define an ACL for subdomains starting with "audiobookshelf"
|
||||
acl is_audiobookshelf hdr_beg(host) -i audiobookshelf
|
||||
|
||||
# Use the ACL to route traffic to audiobookshelf_backend if the condition is met,
|
||||
# otherwise, use the default_backend
|
||||
use_backend audiobookshelf_backend if is_audiobookshelf
|
||||
default_backend default_backend
|
||||
|
||||
backend audiobookshelf_backend
|
||||
mode http
|
||||
# ... (backend settings for audiobookshelf go here)
|
||||
|
||||
# Define the server for the audiobookshelf backend
|
||||
server audiobookshelf_server 127.0.0.99:13378
|
||||
|
||||
backend default_backend
|
||||
mode http
|
||||
# ... (default backend settings go here)
|
||||
|
||||
# Define the server for the default backend
|
||||
server default_server 127.0.0.123:8081
|
||||
|
||||
```
|
||||
|
||||
### pfSense and HAProxy
|
||||
|
||||
For pfSense the inputs are graphical, and `Health checking` is enabled.
|
||||
|
||||
#### Frontend, Default backend, access control lists and actions
|
||||
|
||||
##### Access Control lists
|
||||
|
||||
| Name | Expression | CS | Not | Value |
|
||||
| :------------: | :---------------: | :-: | :-: | :-------------: |
|
||||
| audiobookshelf | Host starts with: | | | audiobookshelf. |
|
||||
|
||||
##### Actions
|
||||
|
||||
The `condition acl names` needs to match the name above `audiobookshelf`.
|
||||
|
||||
| Action | Parameters | Condition acl names |
|
||||
| :-----------: | :------------: | :-----------------: |
|
||||
| `Use Backend` | audiobookshelf | audiobookshelf |
|
||||
|
||||
#### Backend
|
||||
|
||||
The `Name` needs to match the `Parameters` above `audiobookshelf`.
|
||||
|
||||
| Name | audiobookshelf |
|
||||
| ---- | -------------- |
|
||||
|
||||
##### Server list:
|
||||
|
||||
| Name | Expression | CS | Not | Value |
|
||||
| :------------: | :---------------: | :-: | :-: | :-------------: |
|
||||
| audiobookshelf | Host starts with: | | | audiobookshelf. |
|
||||
|
||||
##### Health checking:
|
||||
|
||||
Health checking is enabled by default. `Http check method` of `OPTIONS` is not supported on Audiobookshelf. If Health check fails, data will not be forwared. Need to do one of following:
|
||||
|
||||
- To disable: Change `Health check method` to `none`.
|
||||
- To make Health checking function: Change `Http check method` to `HEAD` or `GET`.
|
||||
|
||||
# Run from source
|
||||
<br />
|
||||
|
||||
# Contributing
|
||||
|
||||
This application is built using [NodeJs](https://nodejs.org/).
|
||||
See [contributing docs](https://audiobookshelf.org/docs/contributing/general/)
|
||||
|
||||
### Localization
|
||||
|
||||
Thank you to [Weblate](https://hosted.weblate.org/engage/audiobookshelf/) for hosting our localization infrastructure pro-bono. If you want to see Audiobookshelf in your language, please help us localize. Additional information on helping with the translations [here](https://www.audiobookshelf.org/faq#how-do-i-help-with-translations). <a href="https://hosted.weblate.org/engage/audiobookshelf/"> <img src="https://hosted.weblate.org/widget/audiobookshelf/abs-web-client/multi-auto.svg" alt="Translation status" /> </a>
|
||||
Thank you to [Weblate](https://hosted.weblate.org/engage/audiobookshelf/) for hosting our localization infrastructure pro-bono. If you want to see Audiobookshelf in your language, please help us localize. Additional information on helping with the translations [here](https://www.audiobookshelf.org/faq#how-do-i-help-with-translations).
|
||||
<a href="https://hosted.weblate.org/engage/audiobookshelf/"> <img src="https://hosted.weblate.org/widget/audiobookshelf/abs-web-client/multi-auto.svg" alt="Translation status" /> </a>
|
||||
|
||||
<br />
|
||||
|
||||
# Run from source
|
||||
|
||||
This application is built using [NodeJs](https://nodejs.org/).
|
||||
|
||||
### Dev Container Setup
|
||||
|
||||
@@ -447,6 +192,3 @@ If you are using VSCode, this project includes a couple of pre-defined targets t
|
||||
- `Debug client (nuxt)`—Run the client with live reload.
|
||||
- `Debug server and client (nuxt)`—Runs both the preceding two debug targets.
|
||||
|
||||
# How to Support
|
||||
|
||||
[See the incomplete "How to Support" page](https://www.audiobookshelf.org/support)
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@ class Auth {
|
||||
* @param {import('./models/User')} user
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @returns {Promise<string>} accessToken only if user is current user and refresh token is valid
|
||||
* @returns {Promise<{ accessToken:string, refreshToken:string }|null>} new tokens for the current session if kept alive
|
||||
*/
|
||||
async invalidateJwtSessionsForUser(user, req, res) {
|
||||
return this.tokenManager.invalidateJwtSessionsForUser(user, req, res)
|
||||
|
||||
+5
-3
@@ -14,6 +14,7 @@ const { version } = require('../package.json')
|
||||
const is = require('./libs/requestIp/isJs')
|
||||
const fileUtils = require('./utils/fileUtils')
|
||||
const { toNumber } = require('./utils/index')
|
||||
const { getRequestOrigin } = require('./utils/requestUtils')
|
||||
const Logger = require('./Logger')
|
||||
|
||||
const Auth = require('./Auth')
|
||||
@@ -288,10 +289,9 @@ class Server {
|
||||
// if RouterBasePath is set, modify all requests to include the base path
|
||||
app.use((req, res, next) => {
|
||||
const urlStartsWithRouterBasePath = req.url.startsWith(global.RouterBasePath)
|
||||
const host = req.get('host')
|
||||
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
|
||||
const { origin } = getRequestOrigin(req)
|
||||
const prefix = urlStartsWithRouterBasePath ? global.RouterBasePath : ''
|
||||
req.originalHostPrefix = `${protocol}://${host}${prefix}`
|
||||
req.originalHostPrefix = `${origin}${prefix}`
|
||||
if (!urlStartsWithRouterBasePath) {
|
||||
req.url = `${global.RouterBasePath}${req.url}`
|
||||
}
|
||||
@@ -302,7 +302,9 @@ class Server {
|
||||
|
||||
this.server = http.createServer(app)
|
||||
|
||||
// Skip file upload parsing for internal-api routes (Next.js proxies read multipart bodies).
|
||||
router.use(
|
||||
/^(?!\/internal-api).*/,
|
||||
fileUpload({
|
||||
defCharset: 'utf8',
|
||||
defParamCharset: 'utf8',
|
||||
|
||||
@@ -3,6 +3,7 @@ const Logger = require('./Logger')
|
||||
const Database = require('./Database')
|
||||
const TokenManager = require('./auth/TokenManager')
|
||||
const CoverSearchManager = require('./managers/CoverSearchManager')
|
||||
const { LogLevel } = require('./utils/constants')
|
||||
|
||||
/**
|
||||
* @typedef SocketClient
|
||||
@@ -85,6 +86,14 @@ class SocketAuthority {
|
||||
}
|
||||
}
|
||||
|
||||
requireAdminSocket(socket, eventName) {
|
||||
const client = this.clients[socket.id]
|
||||
if (client?.user?.isAdminOrUp) return true
|
||||
|
||||
Logger.warn(`[SocketAuthority] Unauthorized ${eventName} socket event from socket ${socket.id}`)
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits event with library item to all clients that can access the library item
|
||||
* Note: Emits toOldJSONExpanded()
|
||||
@@ -179,14 +188,25 @@ class SocketAuthority {
|
||||
socket.on('auth', (token) => this.authenticateSocket(socket, token))
|
||||
|
||||
// Scanning
|
||||
socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId))
|
||||
socket.on('cancel_scan', (libraryId) => {
|
||||
if (!this.requireAdminSocket(socket, 'cancel_scan')) return
|
||||
this.cancelScan(libraryId)
|
||||
})
|
||||
|
||||
// Cover search streaming
|
||||
socket.on('search_covers', (payload) => this.handleCoverSearch(socket, payload))
|
||||
socket.on('cancel_cover_search', (requestId) => this.handleCancelCoverSearch(socket, requestId))
|
||||
|
||||
// Logs
|
||||
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
|
||||
socket.on('set_log_listener', (level) => {
|
||||
if (!this.requireAdminSocket(socket, 'set_log_listener')) return
|
||||
|
||||
if (!Number.isInteger(level) || !Object.values(LogLevel).includes(level)) {
|
||||
Logger.warn(`[SocketAuthority] Invalid set_log_listener level from socket ${socket.id}`)
|
||||
return
|
||||
}
|
||||
Logger.addSocketListener(socket, level)
|
||||
})
|
||||
socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))
|
||||
|
||||
// Sent automatically from socket.io clients
|
||||
|
||||
@@ -4,6 +4,7 @@ const OpenIDClient = require('openid-client')
|
||||
const axios = require('axios')
|
||||
const Database = require('../Database')
|
||||
const Logger = require('../Logger')
|
||||
const { getRequestOrigin } = require('../utils/requestUtils')
|
||||
|
||||
/**
|
||||
* OpenID Connect authentication strategy
|
||||
@@ -289,8 +290,8 @@ class OidcAuthStrategy {
|
||||
const sessionKey = strategy._key
|
||||
|
||||
try {
|
||||
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
|
||||
const hostUrl = new URL(`${protocol}://${req.get('host')}`)
|
||||
const { origin } = getRequestOrigin(req)
|
||||
const hostUrl = new URL(origin)
|
||||
const isMobileFlow = req.query.response_type === 'code' || req.query.redirect_uri || req.query.code_challenge
|
||||
|
||||
// Only allow code flow (for mobile clients)
|
||||
@@ -394,11 +395,10 @@ class OidcAuthStrategy {
|
||||
let postLogoutRedirectUri = null
|
||||
|
||||
if (authMethod === 'openid') {
|
||||
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
|
||||
const host = req.get('host')
|
||||
const { origin } = getRequestOrigin(req)
|
||||
// TODO: ABS does currently not support subfolders for installation
|
||||
// If we want to support it we need to include a config for the serverurl
|
||||
postLogoutRedirectUri = `${protocol}://${host}${global.RouterBasePath}/login`
|
||||
postLogoutRedirectUri = `${origin}${global.RouterBasePath}/login`
|
||||
}
|
||||
// else for openid-mobile we keep postLogoutRedirectUri on null
|
||||
// nice would be to redirect to the app here, but for example Authentik does not implement
|
||||
@@ -515,42 +515,33 @@ class OidcAuthStrategy {
|
||||
if (!callbackUrl) return false
|
||||
|
||||
try {
|
||||
// Handle relative URLs - these are always safe if they start with router base path
|
||||
if (callbackUrl.startsWith('/')) {
|
||||
// Only allow relative paths that start with the router base path
|
||||
if (callbackUrl.startsWith(global.RouterBasePath + '/')) {
|
||||
return true
|
||||
}
|
||||
// Reject protocol-relative (//host) and backslash-prefixed (/\host) values,
|
||||
// which browsers resolve to a cross-origin absolute URL.
|
||||
if (callbackUrl.startsWith('//') || callbackUrl.startsWith('/\\')) {
|
||||
Logger.warn(`[OidcAuth] Rejected protocol-relative callback URL: ${callbackUrl}`)
|
||||
return false
|
||||
}
|
||||
|
||||
const { origin: serverOrigin } = getRequestOrigin(req)
|
||||
const resolvedUrl = callbackUrl.startsWith('/') ? new URL(callbackUrl, serverOrigin) : new URL(callbackUrl)
|
||||
|
||||
if (resolvedUrl.origin !== serverOrigin) {
|
||||
Logger.warn(`[OidcAuth] Rejected callback URL to different origin: ${callbackUrl} (expected ${serverOrigin})`)
|
||||
return false
|
||||
}
|
||||
|
||||
const pathname = decodeURIComponent(resolvedUrl.pathname)
|
||||
if (pathname.startsWith('//') || pathname.startsWith('/\\')) {
|
||||
Logger.warn(`[OidcAuth] Rejected protocol-relative callback URL path: ${callbackUrl}`)
|
||||
return false
|
||||
}
|
||||
|
||||
if (!resolvedUrl.pathname.startsWith(global.RouterBasePath + '/')) {
|
||||
Logger.warn(`[OidcAuth] Rejected callback URL outside router base path: ${callbackUrl}`)
|
||||
return false
|
||||
}
|
||||
|
||||
// For absolute URLs, ensure they point to the same origin
|
||||
const callbackUrlObj = new URL(callbackUrl)
|
||||
// NPM appends both http and https in x-forwarded-proto sometimes, so we need to check for both
|
||||
const xfp = (req.get('x-forwarded-proto') || '').toLowerCase()
|
||||
const currentProtocol =
|
||||
req.secure ||
|
||||
xfp
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.includes('https')
|
||||
? 'https'
|
||||
: 'http'
|
||||
const currentHost = req.get('host')
|
||||
|
||||
// Check if protocol and host match exactly
|
||||
if (callbackUrlObj.protocol === currentProtocol + ':' && callbackUrlObj.host === currentHost) {
|
||||
// Additional check: ensure path starts with router base path
|
||||
if (callbackUrlObj.pathname.startsWith(global.RouterBasePath + '/')) {
|
||||
return true
|
||||
}
|
||||
Logger.warn(`[OidcAuth] Rejected same-origin callback URL outside router base path: ${callbackUrl}`)
|
||||
return false
|
||||
}
|
||||
|
||||
Logger.warn(`[OidcAuth] Rejected callback URL to different origin: ${callbackUrl} (expected ${currentProtocol}://${currentHost})`)
|
||||
return false
|
||||
return true
|
||||
} catch (error) {
|
||||
Logger.error(`[OidcAuth] Invalid callback URL format: ${callbackUrl}`, error)
|
||||
return false
|
||||
|
||||
+141
-28
@@ -1,10 +1,12 @@
|
||||
const { Op } = require('sequelize')
|
||||
const uuid = require('uuid')
|
||||
|
||||
const Database = require('../Database')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
const requestIp = require('../libs/requestIp')
|
||||
const jwt = require('../libs/jsonwebtoken')
|
||||
const { isRequestSecure } = require('../utils/requestUtils')
|
||||
|
||||
class TokenManager {
|
||||
/** @type {string} JWT secret key */
|
||||
@@ -15,6 +17,8 @@ class TokenManager {
|
||||
this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 30 * 24 * 60 * 60 // 30 days
|
||||
/** @type {number} Access token expiry in seconds */
|
||||
this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 1 * 60 * 60 // 1 hour
|
||||
/** @type {number} Grace period in seconds during which a rotated (old) refresh token is still accepted */
|
||||
this.RefreshTokenGracePeriod = parseInt(process.env.REFRESH_TOKEN_GRACE_PERIOD) || 10 * 60 // 10 minutes
|
||||
|
||||
if (parseInt(process.env.REFRESH_TOKEN_EXPIRY) > 0) {
|
||||
Logger.info(`[TokenManager] Refresh token expiry set from ENV variable to ${this.RefreshTokenExpiry} seconds`)
|
||||
@@ -22,6 +26,9 @@ class TokenManager {
|
||||
if (parseInt(process.env.ACCESS_TOKEN_EXPIRY) > 0) {
|
||||
Logger.info(`[TokenManager] Access token expiry set from ENV variable to ${this.AccessTokenExpiry} seconds`)
|
||||
}
|
||||
if (parseInt(process.env.REFRESH_TOKEN_GRACE_PERIOD) > 0) {
|
||||
Logger.info(`[TokenManager] Refresh token grace period set from ENV variable to ${this.RefreshTokenGracePeriod} seconds`)
|
||||
}
|
||||
}
|
||||
|
||||
get TokenSecret() {
|
||||
@@ -58,13 +65,25 @@ class TokenManager {
|
||||
setRefreshTokenCookie(req, res, refreshToken) {
|
||||
res.cookie('refresh_token', refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: req.secure || req.get('x-forwarded-proto') === 'https',
|
||||
secure: isRequestSecure(req),
|
||||
sameSite: 'lax',
|
||||
maxAge: this.RefreshTokenExpiry * 1000,
|
||||
path: '/'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a decoded JWT payload may authenticate API/socket requests (not refresh-only credentials).
|
||||
*
|
||||
* @param {Object} decoded
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static isBearerAccessTokenPayload(decoded) {
|
||||
if (!decoded?.userId) return false
|
||||
if (decoded.type === 'refresh') return false
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to validate a jwt token for a given user
|
||||
* Used to authenticate socket connections
|
||||
@@ -75,7 +94,9 @@ class TokenManager {
|
||||
*/
|
||||
static validateAccessToken(token) {
|
||||
try {
|
||||
return jwt.verify(token, TokenManager.TokenSecret)
|
||||
const decoded = jwt.verify(token, TokenManager.TokenSecret)
|
||||
if (!TokenManager.isBearerAccessTokenPayload(decoded)) return null
|
||||
return decoded
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
@@ -115,6 +136,7 @@ class TokenManager {
|
||||
const payload = {
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
jti: uuid.v4(),
|
||||
type: 'access'
|
||||
}
|
||||
const options = {
|
||||
@@ -138,6 +160,7 @@ class TokenManager {
|
||||
const payload = {
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
jti: uuid.v4(),
|
||||
type: 'refresh'
|
||||
}
|
||||
const options = {
|
||||
@@ -183,20 +206,60 @@ class TokenManager {
|
||||
* @param {import('../models/User')} user
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
* @param {boolean} gracePeriod - whether to use the grace period
|
||||
* @returns {Promise<{ accessToken:string, refreshToken:string }>}
|
||||
*/
|
||||
async rotateTokensForSession(session, user, req, res) {
|
||||
// Generate new tokens
|
||||
async rotateTokensForSession(session, user, req, res, gracePeriod = true) {
|
||||
const previousRefreshToken = session.refreshToken
|
||||
const newAccessToken = this.generateTempAccessToken(user)
|
||||
const newRefreshToken = this.generateRefreshToken(user)
|
||||
|
||||
// Calculate new expiration time
|
||||
let newRefreshToken = this.generateRefreshToken(user)
|
||||
const newExpiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000)
|
||||
|
||||
// Update the session with the new refresh token and expiration
|
||||
session.refreshToken = newRefreshToken
|
||||
session.expiresAt = newExpiresAt
|
||||
await session.save()
|
||||
let lastRefreshToken = null
|
||||
let lastRefreshTokenExpiresAt = null
|
||||
if (gracePeriod) {
|
||||
// Set grace period of old refresh token in case of race condition in token rotation.
|
||||
// During this window a retry with the old refresh token returns the already-rotated
|
||||
// current token instead of failing, so a client that never received the rotation
|
||||
// response (e.g. dropped/suspended mobile request) can still recover the session.
|
||||
// Configurable via REFRESH_TOKEN_GRACE_PERIOD; may need to be longer if fetching the
|
||||
// user data takes longer due to large progress objects.
|
||||
lastRefreshToken = previousRefreshToken
|
||||
lastRefreshTokenExpiresAt = new Date(Date.now() + this.RefreshTokenGracePeriod * 1000)
|
||||
}
|
||||
|
||||
// Only update if this session row still has the refresh token we read
|
||||
const [numUpdated] = await Database.sessionModel.update(
|
||||
{
|
||||
refreshToken: newRefreshToken,
|
||||
expiresAt: newExpiresAt,
|
||||
lastRefreshToken,
|
||||
lastRefreshTokenExpiresAt
|
||||
},
|
||||
{
|
||||
where: {
|
||||
id: session.id,
|
||||
refreshToken: previousRefreshToken
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (numUpdated === 0) {
|
||||
Logger.debug(`[TokenManager] Race condition in rotateTokensForSession for user ${user.id}, getting new token`)
|
||||
|
||||
const updatedSession = await Database.sessionModel.findOne({ where: { id: session.id } })
|
||||
|
||||
newRefreshToken = updatedSession.refreshToken
|
||||
session.refreshToken = updatedSession.refreshToken
|
||||
session.expiresAt = updatedSession.expiresAt
|
||||
session.lastRefreshToken = updatedSession.lastRefreshToken
|
||||
session.lastRefreshTokenExpiresAt = updatedSession.lastRefreshTokenExpiresAt
|
||||
} else {
|
||||
session.refreshToken = newRefreshToken
|
||||
session.expiresAt = newExpiresAt
|
||||
session.lastRefreshToken = lastRefreshToken
|
||||
session.lastRefreshTokenExpiresAt = lastRefreshTokenExpiresAt
|
||||
}
|
||||
|
||||
// Set new refresh token cookie
|
||||
this.setRefreshTokenCookie(req, res, newRefreshToken)
|
||||
@@ -234,9 +297,20 @@ class TokenManager {
|
||||
}
|
||||
|
||||
const user = await Database.userModel.getUserById(apiKey.userId)
|
||||
|
||||
if (!user?.isActive) {
|
||||
// deny login
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
|
||||
done(null, user)
|
||||
} else {
|
||||
// JWT based authentication
|
||||
// JWT based authentication — refresh tokens are only valid at POST /auth/refresh
|
||||
if (!TokenManager.isBearerAccessTokenPayload(jwt_payload)) {
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the jwt is expired
|
||||
if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) {
|
||||
@@ -287,23 +361,40 @@ class TokenManager {
|
||||
}
|
||||
}
|
||||
|
||||
const session = await Database.sessionModel.findOne({
|
||||
where: { refreshToken: refreshToken }
|
||||
let session = await Database.sessionModel.findOne({
|
||||
where: {
|
||||
[Op.or]: [{ refreshToken: refreshToken }, { lastRefreshToken: refreshToken }]
|
||||
}
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
Logger.error(`[TokenManager] Failed to refresh token. Session not found for refresh token: ${refreshToken}`)
|
||||
Logger.error(`[TokenManager] Failed to refresh token. Session not found`)
|
||||
return {
|
||||
error: 'Invalid refresh token'
|
||||
}
|
||||
}
|
||||
|
||||
// Check if session is expired in database
|
||||
if (session.expiresAt < new Date()) {
|
||||
Logger.info(`[TokenManager] Session expired in database, cleaning up`)
|
||||
await session.destroy()
|
||||
return {
|
||||
error: 'Refresh token expired'
|
||||
let isGracePeriod = false
|
||||
if (session.refreshToken !== refreshToken) {
|
||||
// Token matched lastRefreshToken
|
||||
if (session.lastRefreshTokenExpiresAt && session.lastRefreshTokenExpiresAt > new Date()) {
|
||||
isGracePeriod = true
|
||||
Logger.debug(`[TokenManager] Grace period hit for user ${session.userId}`)
|
||||
} else {
|
||||
Logger.debug(`[TokenManager] Grace period expired for user ${session.userId}`)
|
||||
return {
|
||||
error: 'Invalid refresh token'
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Token matched current refreshToken
|
||||
// Check if session is expired in database
|
||||
if (session.expiresAt < new Date()) {
|
||||
Logger.info(`[TokenManager] Session expired in database, cleaning up`)
|
||||
await session.destroy()
|
||||
return {
|
||||
error: 'Refresh token expired'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,6 +406,20 @@ class TokenManager {
|
||||
}
|
||||
}
|
||||
|
||||
if (isGracePeriod) {
|
||||
// Return the already rotated refresh token store in the database,
|
||||
// and generate a new access token without changing the refresh token
|
||||
// again
|
||||
const accessToken = this.generateTempAccessToken(user)
|
||||
this.setRefreshTokenCookie(req, res, session.refreshToken)
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: session.refreshToken,
|
||||
user
|
||||
}
|
||||
}
|
||||
|
||||
const newTokens = await this.rotateTokensForSession(session, user, req, res)
|
||||
return {
|
||||
accessToken: newTokens.accessToken,
|
||||
@@ -359,16 +464,21 @@ class TokenManager {
|
||||
* @param {import('../models/User')} user
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
* @returns {Promise<string>} accessToken only if user is current user and refresh token is valid
|
||||
* @returns {Promise<{ accessToken:string, refreshToken:string }|null>} new tokens for the current session if kept alive
|
||||
*/
|
||||
async invalidateJwtSessionsForUser(user, req, res) {
|
||||
const currentRefreshToken = req.cookies.refresh_token
|
||||
const currentRefreshToken = req.cookies.refresh_token || req.headers['x-refresh-token']
|
||||
if (req.user.id === user.id && currentRefreshToken) {
|
||||
// Current user is the same as the user to invalidate sessions for
|
||||
// So rotate token for current session
|
||||
const currentSession = await Database.sessionModel.findOne({ where: { refreshToken: currentRefreshToken } })
|
||||
const currentSession = await Database.sessionModel.findOne({
|
||||
where: {
|
||||
userId: user.id,
|
||||
[Op.or]: [{ refreshToken: currentRefreshToken }, { lastRefreshToken: currentRefreshToken }]
|
||||
}
|
||||
})
|
||||
if (currentSession) {
|
||||
const newTokens = await this.rotateTokensForSession(currentSession, user, req, res)
|
||||
const newTokens = await this.rotateTokensForSession(currentSession, user, req, res, false)
|
||||
|
||||
// Invalidate all sessions for the user except the current one
|
||||
await Database.sessionModel.destroy({
|
||||
@@ -380,9 +490,12 @@ class TokenManager {
|
||||
}
|
||||
})
|
||||
|
||||
return newTokens.accessToken
|
||||
return {
|
||||
accessToken: newTokens.accessToken,
|
||||
refreshToken: newTokens.refreshToken
|
||||
}
|
||||
} else {
|
||||
Logger.error(`[TokenManager] No session found to rotate tokens for refresh token ${currentRefreshToken}`)
|
||||
Logger.error(`[TokenManager] No session found to rotate tokens`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,7 +519,7 @@ class TokenManager {
|
||||
|
||||
try {
|
||||
const numDeleted = await Database.sessionModel.destroy({ where: { refreshToken: refreshToken } })
|
||||
Logger.info(`[TokenManager] Refresh token ${refreshToken} invalidated, ${numDeleted} sessions deleted`)
|
||||
Logger.info(`[TokenManager] Refresh token invalidated, ${numDeleted} sessions deleted`)
|
||||
return true
|
||||
} catch (error) {
|
||||
Logger.error(`[TokenManager] Error invalidating refresh token: ${error.message}`)
|
||||
|
||||
@@ -10,7 +10,7 @@ const CacheManager = require('../managers/CacheManager')
|
||||
const CoverManager = require('../managers/CoverManager')
|
||||
const AuthorFinder = require('../finders/AuthorFinder')
|
||||
|
||||
const { reqSupportsWebp, isValidASIN } = require('../utils/index')
|
||||
const { reqSupportsWebp, isValidASIN, clampPositiveInt } = require('../utils/index')
|
||||
|
||||
const naturalSort = createNewSortInstance({
|
||||
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
|
||||
@@ -149,7 +149,7 @@ class AuthorController {
|
||||
})
|
||||
if (libraryItems.length) {
|
||||
await Database.bookAuthorModel.removeByIds(req.author.id) // Remove all old BookAuthor
|
||||
await Database.bookAuthorModel.bulkCreate(bookAuthorsToCreate) // Create all new BookAuthor
|
||||
await Database.bookAuthorModel.bulkCreate(bookAuthorsToCreate, { ignoreDuplicates: true }) // Create all new unique BookAuthor
|
||||
for (const libraryItem of libraryItems) {
|
||||
await libraryItem.saveMetadataFile()
|
||||
}
|
||||
@@ -412,8 +412,8 @@ class AuthorController {
|
||||
|
||||
const options = {
|
||||
format: format || (reqSupportsWebp(req) ? 'webp' : 'jpeg'),
|
||||
height: height ? parseInt(height) : null,
|
||||
width: width ? parseInt(width) : null
|
||||
height: clampPositiveInt(height ? parseInt(height) : null, 4096),
|
||||
width: clampPositiveInt(width ? parseInt(width) : null, 4096)
|
||||
}
|
||||
return CacheManager.handleAuthorCache(res, authorId, options)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ const Sequelize = require('sequelize')
|
||||
const Logger = require('../Logger')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const Database = require('../Database')
|
||||
const htmlSanitizer = require('../utils/htmlSanitizer')
|
||||
|
||||
const RssFeedManager = require('../managers/RssFeedManager')
|
||||
|
||||
@@ -31,13 +32,19 @@ class CollectionController {
|
||||
async create(req, res) {
|
||||
const reqBody = req.body || {}
|
||||
|
||||
const nameCleaned = htmlSanitizer.stripAllTags(reqBody.name)
|
||||
|
||||
// Validation
|
||||
if (!reqBody.name || !reqBody.libraryId) {
|
||||
if (!nameCleaned || !reqBody.libraryId) {
|
||||
return res.status(400).send('Invalid collection data')
|
||||
}
|
||||
if (reqBody.description && typeof reqBody.description !== 'string') {
|
||||
return res.status(400).send('Invalid collection description')
|
||||
}
|
||||
if (!req.user.checkCanAccessLibrary(reqBody.libraryId)) {
|
||||
Logger.warn(`[CollectionController] User "${req.user.username}" attempted to create collection in inaccessible library ${reqBody.libraryId}`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
const libraryItemIds = (reqBody.books || []).filter((b) => !!b && typeof b == 'string')
|
||||
if (!libraryItemIds.length) {
|
||||
return res.status(400).send('Invalid collection data. No books')
|
||||
@@ -65,7 +72,7 @@ class CollectionController {
|
||||
newCollection = await Database.collectionModel.create(
|
||||
{
|
||||
libraryId: reqBody.libraryId,
|
||||
name: reqBody.name,
|
||||
name: nameCleaned,
|
||||
description: reqBody.description || null
|
||||
},
|
||||
{ transaction }
|
||||
@@ -106,8 +113,9 @@ class CollectionController {
|
||||
*/
|
||||
async findAll(req, res) {
|
||||
const collectionsExpanded = await Database.collectionModel.getOldCollectionsJsonExpanded(req.user)
|
||||
const accessibleCollections = collectionsExpanded.filter((c) => req.user.checkCanAccessLibrary(c.libraryId))
|
||||
res.json({
|
||||
collections: collectionsExpanded
|
||||
collections: accessibleCollections
|
||||
})
|
||||
}
|
||||
|
||||
@@ -145,9 +153,12 @@ class CollectionController {
|
||||
collectionUpdatePayload.description = req.body.description
|
||||
wasUpdated = true
|
||||
}
|
||||
if (req.body.name !== undefined && req.body.name !== req.collection.name) {
|
||||
collectionUpdatePayload.name = req.body.name
|
||||
wasUpdated = true
|
||||
if (req.body.name !== undefined && typeof req.body.name === 'string') {
|
||||
const nameCleaned = htmlSanitizer.stripAllTags(req.body.name)
|
||||
if (nameCleaned !== req.collection.name) {
|
||||
collectionUpdatePayload.name = nameCleaned
|
||||
wasUpdated = true
|
||||
}
|
||||
}
|
||||
|
||||
if (wasUpdated) {
|
||||
@@ -425,6 +436,10 @@ class CollectionController {
|
||||
if (!collection) {
|
||||
return res.status(404).send('Collection not found')
|
||||
}
|
||||
if (!req.user.checkCanAccessLibrary(collection.libraryId)) {
|
||||
Logger.warn(`[CollectionController] User "${req.user.username}" attempted to access collection ${collection.id} in inaccessible library ${collection.libraryId}`)
|
||||
return res.status(404).send('Collection not found')
|
||||
}
|
||||
req.collection = collection
|
||||
}
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ class FileSystemController {
|
||||
filepath = fileUtils.filePathToPOSIX(filepath)
|
||||
|
||||
// Ensure filepath is inside library folder (prevents directory traversal)
|
||||
if (!filepath.startsWith(libraryFolder.path)) {
|
||||
if (!fileUtils.isSameOrSubPath(libraryFolder.path, filepath)) {
|
||||
Logger.error(`[FileSystemController] Filepath is not inside library folder: ${filepath}`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
@@ -462,7 +462,7 @@ class LibraryController {
|
||||
}
|
||||
}
|
||||
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from folder "${folder.path}"`)
|
||||
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
|
||||
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds, req.library.id)
|
||||
}
|
||||
|
||||
if (authorIds.length) {
|
||||
@@ -563,7 +563,7 @@ class LibraryController {
|
||||
mediaItemIds.push(libraryItem.mediaId)
|
||||
}
|
||||
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from library "${req.library.name}"`)
|
||||
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
|
||||
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds, req.library.id)
|
||||
}
|
||||
|
||||
// Set PlaybackSessions libraryId to null
|
||||
@@ -714,7 +714,7 @@ class LibraryController {
|
||||
}
|
||||
}
|
||||
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" with issue`)
|
||||
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
|
||||
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds, req.library.id)
|
||||
}
|
||||
|
||||
if (authorIds.length) {
|
||||
@@ -1432,13 +1432,21 @@ class LibraryController {
|
||||
|
||||
const itemIds = req.query.ids.split(',')
|
||||
|
||||
const libraryItems = await Database.libraryItemModel.findAll({
|
||||
attributes: ['id', 'libraryId', 'path', 'isFile'],
|
||||
where: {
|
||||
id: itemIds
|
||||
}
|
||||
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
|
||||
id: itemIds,
|
||||
libraryId: req.library.id
|
||||
})
|
||||
|
||||
for (const libraryItem of libraryItems) {
|
||||
if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
}
|
||||
|
||||
if (libraryItems.length < itemIds.length) {
|
||||
Logger.warn(`[LibraryController] User "${req.user.username}" requested ${itemIds.length} items but only ${libraryItems.length} are in library "${req.library.id}"`)
|
||||
}
|
||||
|
||||
Logger.info(`[LibraryController] User "${req.user.username}" requested download for items "${itemIds}"`)
|
||||
|
||||
const filename = `LibraryItems-${Date.now()}.zip`
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
const { Request, Response, NextFunction } = require('express')
|
||||
const Path = require('path')
|
||||
const fs = require('../libs/fsExtra')
|
||||
const cron = require('../libs/nodeCron')
|
||||
const uaParserJs = require('../libs/uaParser')
|
||||
const Logger = require('../Logger')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const Database = require('../Database')
|
||||
|
||||
const zipHelpers = require('../utils/zipHelpers')
|
||||
const { reqSupportsWebp } = require('../utils/index')
|
||||
const { reqSupportsWebp, clampPositiveInt } = require('../utils/index')
|
||||
const { ScanResult, AudioMimeType } = require('../utils/constants')
|
||||
const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUtils')
|
||||
const LibraryItemScanner = require('../scanner/LibraryItemScanner')
|
||||
@@ -36,6 +37,24 @@ const ShareManager = require('../managers/ShareManager')
|
||||
* @typedef {RequestWithUser & RequestEntityObject & RequestLibraryFileObject} LibraryItemControllerRequestWithFile
|
||||
*/
|
||||
|
||||
/**
|
||||
* Enforce per-item access for batch item routes
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
* @param {import('../models/LibraryItem')[]} libraryItems
|
||||
* @returns {boolean} true if the user may access every item; false if 403 was sent
|
||||
*/
|
||||
function ensureUserCanAccessLibraryItemsForBatch(req, res, libraryItems) {
|
||||
for (const libraryItem of libraryItems) {
|
||||
if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
|
||||
res.sendStatus(403)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
class LibraryItemController {
|
||||
constructor() {}
|
||||
|
||||
@@ -111,7 +130,7 @@ class LibraryItemController {
|
||||
}
|
||||
}
|
||||
|
||||
await this.handleDeleteLibraryItem(req.libraryItem.id, mediaItemIds)
|
||||
await this.handleDeleteLibraryItem(req.libraryItem.id, mediaItemIds, req.libraryItem.libraryId)
|
||||
if (hardDelete) {
|
||||
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
|
||||
await fs.remove(libraryItemPath).catch((error) => {
|
||||
@@ -202,6 +221,11 @@ class LibraryItemController {
|
||||
} else if (mediaPayload.autoDownloadSchedule !== undefined && req.libraryItem.media.autoDownloadSchedule !== mediaPayload.autoDownloadSchedule) {
|
||||
isPodcastAutoDownloadUpdated = true
|
||||
}
|
||||
|
||||
if (mediaPayload.autoDownloadSchedule && !cron.validate(mediaPayload.autoDownloadSchedule)) {
|
||||
Logger.error(`[LibraryItemController] Invalid auto download schedule cron expression "${mediaPayload.autoDownloadSchedule}" for library item "${req.libraryItem.media.title}"`)
|
||||
return res.status(400).send('Invalid auto download schedule cron expression')
|
||||
}
|
||||
}
|
||||
|
||||
let hasUpdates = (await req.libraryItem.media.updateFromRequest(mediaPayload)) || mediaPayload.url
|
||||
@@ -398,8 +422,8 @@ class LibraryItemController {
|
||||
|
||||
const options = {
|
||||
format: format || (reqSupportsWebp(req) ? 'webp' : 'jpeg'),
|
||||
height: height ? parseInt(height) : null,
|
||||
width: width ? parseInt(width) : null
|
||||
height: clampPositiveInt(height ? parseInt(height) : null, 4096),
|
||||
width: clampPositiveInt(width ? parseInt(width) : null, 4096)
|
||||
}
|
||||
return CacheManager.handleCoverCache(res, libraryItemId, options)
|
||||
}
|
||||
@@ -547,7 +571,13 @@ class LibraryItemController {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
// Ensure user has permission to delete these library items
|
||||
if (!ensureUserCanAccessLibraryItemsForBatch(req, res, itemsToDelete)) {
|
||||
return
|
||||
}
|
||||
|
||||
const libraryId = itemsToDelete[0].libraryId
|
||||
|
||||
for (const libraryItem of itemsToDelete) {
|
||||
const libraryItemPath = libraryItem.path
|
||||
Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.title}" with id "${libraryItem.id}"`)
|
||||
@@ -565,7 +595,7 @@ class LibraryItemController {
|
||||
authorIds.push(...libraryItem.media.authors.map((au) => au.id))
|
||||
}
|
||||
}
|
||||
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
|
||||
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds, libraryItem.libraryId)
|
||||
if (hardDelete) {
|
||||
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
|
||||
await fs.remove(libraryItemPath).catch((error) => {
|
||||
@@ -581,6 +611,7 @@ class LibraryItemController {
|
||||
}
|
||||
|
||||
await Database.resetLibraryIssuesFilterData(libraryId)
|
||||
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
@@ -593,6 +624,11 @@ class LibraryItemController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async batchUpdate(req, res) {
|
||||
if (!req.user.canUpdate) {
|
||||
Logger.warn(`[LibraryItemController] User "${req.user.username}" attempted to batch update without permission`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const updatePayloads = req.body
|
||||
if (!Array.isArray(updatePayloads) || !updatePayloads.length) {
|
||||
Logger.error(`[LibraryItemController] Batch update failed. Invalid payload`)
|
||||
@@ -615,6 +651,11 @@ class LibraryItemController {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
// Ensure user has permission to update these library items
|
||||
if (!ensureUserCanAccessLibraryItemsForBatch(req, res, libraryItems)) {
|
||||
return
|
||||
}
|
||||
|
||||
let itemsUpdated = 0
|
||||
|
||||
const seriesIdsRemoved = []
|
||||
@@ -624,6 +665,11 @@ class LibraryItemController {
|
||||
const mediaPayload = updatePayload.mediaPayload
|
||||
const libraryItem = libraryItems.find((li) => li.id === updatePayload.id)
|
||||
|
||||
if (libraryItem.isPodcast && mediaPayload.autoDownloadSchedule && !cron.validate(mediaPayload.autoDownloadSchedule)) {
|
||||
Logger.warn(`[LibraryItemController] Invalid auto download schedule cron expression "${mediaPayload.autoDownloadSchedule}" for library item "${libraryItem.media.title}" - skipping update`)
|
||||
continue
|
||||
}
|
||||
|
||||
let hasUpdates = await libraryItem.media.updateFromRequest(mediaPayload)
|
||||
|
||||
if (libraryItem.isBook && Array.isArray(mediaPayload.metadata?.series)) {
|
||||
@@ -695,6 +741,10 @@ class LibraryItemController {
|
||||
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
|
||||
id: libraryItemIds
|
||||
})
|
||||
// Ensure user has permission to access these library items
|
||||
if (!ensureUserCanAccessLibraryItemsForBatch(req, res, libraryItems)) {
|
||||
return
|
||||
}
|
||||
res.json({
|
||||
libraryItems: libraryItems.map((li) => li.toOldJSONExpanded())
|
||||
})
|
||||
|
||||
@@ -26,6 +26,49 @@ class MeController {
|
||||
res.json(req.user.toOldJSONForBrowser())
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/me/progress
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
getAllMediaProgress(req, res) {
|
||||
const mediaProgress = req.user.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || []
|
||||
res.json({ mediaProgress })
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/me/bookmarks
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
getAllBookmarks(req, res) {
|
||||
const bookmarks = req.user.bookmarks?.map((bookmark) => ({ ...bookmark })) || []
|
||||
res.json({ bookmarks })
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/me/bookmarks/:libraryItemId
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async getBookmarksForLibraryItem(req, res) {
|
||||
const libraryItem = await Database.libraryItemModel.getExpandedById(req.params.libraryItemId)
|
||||
if (!libraryItem) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
|
||||
Logger.error(`[MeController] User "${req.user.username}" attempted to access bookmarks for library item "${req.params.libraryItemId}" without access`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const bookmarks = req.user.bookmarks?.filter((bookmark) => bookmark.libraryItemId === libraryItem.id).map((bookmark) => ({ ...bookmark })) || []
|
||||
res.json({ bookmarks })
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/me/listening-sessions
|
||||
*
|
||||
@@ -308,6 +351,8 @@ class MeController {
|
||||
* User change password. Requires current password.
|
||||
* Guest users cannot change password.
|
||||
*
|
||||
* Invalidates all other JWT sessions for the user. If using x-refresh-token, returns new tokens for the current session.
|
||||
*
|
||||
* @this import('../routers/ApiRouter')
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
@@ -330,6 +375,24 @@ class MeController {
|
||||
return res.status(400).send(result.error)
|
||||
}
|
||||
|
||||
const shouldReturnTokens = !!req.headers['x-refresh-token']
|
||||
const newTokens = await this.auth.invalidateJwtSessionsForUser(req.user, req, res)
|
||||
|
||||
if (newTokens?.accessToken) {
|
||||
Logger.info(`[MeController] Invalidated other JWT sessions for user ${req.user.username} after password change`)
|
||||
if (shouldReturnTokens) {
|
||||
return res.json({
|
||||
success: true,
|
||||
user: {
|
||||
accessToken: newTokens.accessToken,
|
||||
refreshToken: newTokens.refreshToken
|
||||
}
|
||||
})
|
||||
}
|
||||
} else {
|
||||
Logger.info(`[MeController] Invalidated all JWT sessions for user ${req.user.username} after password change`)
|
||||
}
|
||||
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ const Database = require('../Database')
|
||||
const Watcher = require('../Watcher')
|
||||
|
||||
const libraryItemFilters = require('../utils/queries/libraryItemFilters')
|
||||
const patternValidation = require('../libs/nodeCron/pattern-validation')
|
||||
const cron = require('../libs/nodeCron')
|
||||
const { isObject, getTitleIgnorePrefix } = require('../utils/index')
|
||||
const { sanitizeFilename } = require('../utils/fileUtils')
|
||||
|
||||
@@ -605,13 +605,11 @@ class MiscController {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
try {
|
||||
patternValidation(expression)
|
||||
res.sendStatus(200)
|
||||
} catch (error) {
|
||||
Logger.warn(`[MiscController] Invalid cron expression ${expression}`, error.message)
|
||||
res.status(400).send(error.message)
|
||||
if (!cron.validate(expression)) {
|
||||
Logger.warn(`[MiscController] Invalid cron expression ${expression}`)
|
||||
return res.status(400).send('Invalid cron expression')
|
||||
}
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@ const { Request, Response, NextFunction } = require('express')
|
||||
const Logger = require('../Logger')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const Database = require('../Database')
|
||||
const htmlSanitizer = require('../utils/htmlSanitizer')
|
||||
|
||||
/**
|
||||
* @typedef RequestUserObject
|
||||
@@ -29,12 +30,17 @@ class PlaylistController {
|
||||
const reqBody = req.body || {}
|
||||
|
||||
// Validation
|
||||
if (!reqBody.name || !reqBody.libraryId) {
|
||||
const nameCleaned = htmlSanitizer.stripAllTags(reqBody.name)
|
||||
if (!nameCleaned || !reqBody.libraryId) {
|
||||
return res.status(400).send('Invalid playlist data')
|
||||
}
|
||||
if (reqBody.description && typeof reqBody.description !== 'string') {
|
||||
return res.status(400).send('Invalid playlist description')
|
||||
}
|
||||
if (!req.user.checkCanAccessLibrary(reqBody.libraryId)) {
|
||||
Logger.warn(`[PlaylistController] User "${req.user.username}" attempted to create playlist in inaccessible library ${reqBody.libraryId}`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
const items = reqBody.items || []
|
||||
const isPodcast = items.some((i) => i.episodeId)
|
||||
const libraryItemIds = new Set()
|
||||
@@ -84,7 +90,7 @@ class PlaylistController {
|
||||
{
|
||||
libraryId: reqBody.libraryId,
|
||||
userId: req.user.id,
|
||||
name: reqBody.name,
|
||||
name: nameCleaned,
|
||||
description: reqBody.description || null
|
||||
},
|
||||
{ transaction }
|
||||
@@ -131,8 +137,9 @@ class PlaylistController {
|
||||
*/
|
||||
async findAllForUser(req, res) {
|
||||
const playlistsForUser = await Database.playlistModel.getOldPlaylistsForUserAndLibrary(req.user.id)
|
||||
const accessiblePlaylists = playlistsForUser.filter((p) => req.user.checkCanAccessLibrary(p.libraryId))
|
||||
res.json({
|
||||
playlists: playlistsForUser
|
||||
playlists: accessiblePlaylists
|
||||
})
|
||||
}
|
||||
|
||||
@@ -174,7 +181,11 @@ class PlaylistController {
|
||||
}
|
||||
|
||||
const playlistUpdatePayload = {}
|
||||
if (reqBody.name) playlistUpdatePayload.name = reqBody.name
|
||||
|
||||
const nameCleaned = htmlSanitizer.stripAllTags(reqBody.name)
|
||||
if (nameCleaned) {
|
||||
playlistUpdatePayload.name = nameCleaned
|
||||
}
|
||||
if (reqBody.description) playlistUpdatePayload.description = reqBody.description
|
||||
|
||||
// Update name and description
|
||||
@@ -502,6 +513,10 @@ class PlaylistController {
|
||||
if (!collection) {
|
||||
return res.status(404).send('Collection not found')
|
||||
}
|
||||
if (!req.user.checkCanAccessLibrary(collection.libraryId)) {
|
||||
Logger.warn(`[PlaylistController] User "${req.user.username}" attempted to create playlist from collection ${collection.id} in inaccessible library ${collection.libraryId}`)
|
||||
return res.status(404).send('Collection not found')
|
||||
}
|
||||
// Expand collection to get library items
|
||||
const collectionExpanded = await collection.getOldJsonExpanded(req.user)
|
||||
if (!collectionExpanded) {
|
||||
@@ -567,6 +582,10 @@ class PlaylistController {
|
||||
Logger.warn(`[PlaylistController] Playlist ${req.params.id} requested by user ${req.user.id} that is not the owner`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
if (!req.user.checkCanAccessLibrary(playlist.libraryId)) {
|
||||
Logger.warn(`[PlaylistController] User "${req.user.username}" attempted to access playlist ${playlist.id} in inaccessible library ${playlist.libraryId}`)
|
||||
return res.status(404).send('Playlist not found')
|
||||
}
|
||||
req.playlist = playlist
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,10 @@ const SocketAuthority = require('../SocketAuthority')
|
||||
const Database = require('../Database')
|
||||
|
||||
const fs = require('../libs/fsExtra')
|
||||
const cron = require('../libs/nodeCron')
|
||||
|
||||
const { getPodcastFeed, findMatchingEpisodes } = require('../utils/podcastUtils')
|
||||
const { getFileTimestampsWithIno, filePathToPOSIX } = require('../utils/fileUtils')
|
||||
const { getFileTimestampsWithIno, filePathToPOSIX, isSameOrSubPath } = require('../utils/fileUtils')
|
||||
const { validateUrl } = require('../utils/index')
|
||||
const htmlSanitizer = require('../utils/htmlSanitizer')
|
||||
|
||||
@@ -46,6 +47,11 @@ class PodcastController {
|
||||
return res.status(400).send('Invalid request body. "media" and "media.metadata" are required')
|
||||
}
|
||||
|
||||
if (payload.media.autoDownloadSchedule && !cron.validate(payload.media.autoDownloadSchedule)) {
|
||||
Logger.error(`[PodcastController] Invalid auto download schedule cron expression "${payload.media.autoDownloadSchedule}"`)
|
||||
return res.status(400).send('Invalid auto download schedule cron expression')
|
||||
}
|
||||
|
||||
const library = await Database.libraryModel.findByIdWithFolders(payload.libraryId)
|
||||
if (!library) {
|
||||
Logger.error(`[PodcastController] Create: Library not found "${payload.libraryId}"`)
|
||||
@@ -58,8 +64,18 @@ class PodcastController {
|
||||
return res.status(404).send('Folder not found')
|
||||
}
|
||||
|
||||
if (typeof payload.path !== 'string' || !payload.path.trim()) {
|
||||
return res.status(400).send('Invalid request body. "path" must be a non-empty string')
|
||||
}
|
||||
|
||||
const libraryFolderPath = filePathToPOSIX(folder.path)
|
||||
const podcastPath = filePathToPOSIX(payload.path)
|
||||
|
||||
if (!isSameOrSubPath(libraryFolderPath, podcastPath)) {
|
||||
Logger.error(`[PodcastController] Create: Podcast path is outside library folder "${libraryFolderPath}": "${podcastPath}"`)
|
||||
return res.status(400).send('Podcast path must be inside the selected library folder')
|
||||
}
|
||||
|
||||
// Check if a library item with this podcast folder exists already
|
||||
const existingLibraryItem =
|
||||
(await Database.libraryItemModel.count({
|
||||
@@ -83,7 +99,7 @@ class PodcastController {
|
||||
|
||||
const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath)
|
||||
|
||||
let relPath = payload.path.replace(folder.fullPath, '')
|
||||
let relPath = podcastPath.replace(libraryFolderPath, '')
|
||||
if (relPath.startsWith('/')) relPath = relPath.slice(1)
|
||||
|
||||
let newLibraryItem = null
|
||||
@@ -412,9 +428,26 @@ class PodcastController {
|
||||
Logger.debug(`[PodcastController] Sanitized description from "${req.body[key]}" to "${sanitizedDescription}"`)
|
||||
req.body[key] = sanitizedDescription
|
||||
}
|
||||
} else if (key === 'subtitle' && req.body[key]) {
|
||||
const sanitizedSubtitle = htmlSanitizer.sanitize(req.body[key])
|
||||
if (sanitizedSubtitle !== req.body[key]) {
|
||||
Logger.debug(`[PodcastController] Sanitized subtitle from "${req.body[key]}" to "${sanitizedSubtitle}"`)
|
||||
req.body[key] = sanitizedSubtitle
|
||||
}
|
||||
}
|
||||
|
||||
updatePayload[key] = req.body[key]
|
||||
} else if (key === 'enclosure') {
|
||||
const enclosure = req.body.enclosure
|
||||
if (enclosure === null) {
|
||||
updatePayload.enclosureURL = null
|
||||
updatePayload.enclosureSize = null
|
||||
updatePayload.enclosureType = null
|
||||
} else if (typeof enclosure === 'object' && typeof enclosure.url === 'string') {
|
||||
updatePayload.enclosureURL = enclosure.url
|
||||
updatePayload.enclosureType = typeof enclosure.type === 'string' ? enclosure.type : null
|
||||
updatePayload.enclosureSize = enclosure.length !== undefined && enclosure.length !== null ? enclosure.length : null
|
||||
}
|
||||
} else if (key === 'chapters' && Array.isArray(req.body[key]) && req.body[key].every((ch) => typeof ch === 'object' && ch.title && ch.start)) {
|
||||
updatePayload[key] = req.body[key]
|
||||
} else if (key === 'publishedAt' && typeof req.body[key] === 'number') {
|
||||
|
||||
@@ -53,6 +53,10 @@ class ShareController {
|
||||
if (playbackSession) {
|
||||
if (mediaItemShare.id === playbackSession.mediaItemShareId) {
|
||||
Logger.debug(`[ShareController] Found share playback session ${req.cookies.share_session_id}`)
|
||||
// If ?t was provided, override the cached currentTime
|
||||
if (startTime > 0 && startTime < playbackSession.duration) {
|
||||
playbackSession.currentTime = startTime
|
||||
}
|
||||
mediaItemShare.playbackSession = playbackSession.toJSONForClient()
|
||||
return res.json(mediaItemShare)
|
||||
} else {
|
||||
|
||||
@@ -253,6 +253,7 @@ class UserController {
|
||||
// Updating password
|
||||
if (updatePayload.password) {
|
||||
user.pash = await this.auth.localAuthStrategy.hashPassword(updatePayload.password)
|
||||
shouldInvalidateJwtSessions = true
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
@@ -331,14 +332,11 @@ class UserController {
|
||||
Logger.info(`[UserController] User ${user.username} has generated a new api token`)
|
||||
}
|
||||
|
||||
// Handle JWT session invalidation for username changes
|
||||
// Handle JWT session invalidation for username/password changes
|
||||
if (shouldInvalidateJwtSessions) {
|
||||
const newAccessToken = await this.auth.invalidateJwtSessionsForUser(user, req, res)
|
||||
if (newAccessToken) {
|
||||
user.accessToken = newAccessToken
|
||||
// Refresh tokens are only returned for mobile clients
|
||||
// Mobile apps currently do not use this API endpoint so always set to null
|
||||
user.refreshToken = null
|
||||
const newTokens = await this.auth.invalidateJwtSessionsForUser(user, req, res)
|
||||
if (newTokens) {
|
||||
// Note: for admin users changing their own password they should use MeController.updatePassword instead. This endpoint does not return tokens
|
||||
Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username} and rotated tokens for current session`)
|
||||
} else {
|
||||
Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username}`)
|
||||
@@ -363,15 +361,16 @@ class UserController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async delete(req, res) {
|
||||
if (req.params.id === 'root') {
|
||||
Logger.error('[UserController] Attempt to delete root user. Root user cannot be deleted')
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
const user = req.reqUser
|
||||
|
||||
if (req.user.id === req.params.id) {
|
||||
Logger.error(`[UserController] User ${req.user.username} is attempting to delete self`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
const user = req.reqUser
|
||||
if (user.isRoot) {
|
||||
Logger.error(`[UserController] Admin user "${req.user.username}" attempted to delete root user`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
// Todo: check if user is logged in and cancel streams
|
||||
|
||||
|
||||
@@ -42,11 +42,14 @@ class ApiCacheManager {
|
||||
}
|
||||
|
||||
clearUserProgressSlices(modelName, hook) {
|
||||
const removedPersonalized = this.modelsInvalidatingPersonalized.has(modelName) ? this.clearByUrlPattern(/^\/libraries\/[^/]+\/personalized/) : 0
|
||||
let removedPersonalized = 0
|
||||
let removedRecentEpisodes = 0
|
||||
if (this.modelsInvalidatingPersonalized.has(modelName)) {
|
||||
removedPersonalized = this.clearByUrlPattern(/^\/libraries\/[^/]+\/personalized/)
|
||||
removedRecentEpisodes = this.clearByUrlPattern(/^\/libraries\/[^/]+\/recent-episodes/)
|
||||
}
|
||||
const removedMe = this.modelsInvalidatingMe.has(modelName) ? this.clearByUrlPattern(/^\/me(\/|\?|$)/) : 0
|
||||
Logger.debug(
|
||||
`[ApiCacheManager] ${modelName}.${hook}: cleared user-progress cache slices (personalized=${removedPersonalized}, me=${removedMe})`
|
||||
)
|
||||
Logger.debug(`[ApiCacheManager] ${modelName}.${hook}: cleared user-progress cache slices (personalized=${removedPersonalized}, recentEpisodes=${removedRecentEpisodes}, me=${removedMe})`)
|
||||
}
|
||||
|
||||
clear(model, hook) {
|
||||
|
||||
@@ -126,13 +126,31 @@ class BackupManager {
|
||||
} catch (error) {
|
||||
// Not a valid zip file
|
||||
Logger.error('[BackupManager] Failed to read backup file - backup might not be a valid .zip file', tempPath, error)
|
||||
await zip.close().catch(() => {})
|
||||
await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err))
|
||||
return res.status(400).send('Failed to read backup file - backup might not be a valid .zip file')
|
||||
}
|
||||
if (!Object.keys(entries).includes('absdatabase.sqlite')) {
|
||||
if (!entries['absdatabase.sqlite']) {
|
||||
Logger.error(`[BackupManager] Invalid backup with no absdatabase.sqlite file - might be a backup created on an old Audiobookshelf server.`)
|
||||
await zip.close().catch(() => {})
|
||||
await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err))
|
||||
return res.status(500).send('Invalid backup with no absdatabase.sqlite file - might be a backup created on an old Audiobookshelf server.')
|
||||
}
|
||||
|
||||
const detailsEntry = entries['details']
|
||||
if (!detailsEntry) {
|
||||
Logger.error('[BackupManager] Invalid backup - missing details entry')
|
||||
await zip.close().catch(() => {})
|
||||
await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err))
|
||||
return res.status(400).send('Invalid backup file - missing details entry')
|
||||
}
|
||||
if (detailsEntry.size > 1024 * 1024) {
|
||||
Logger.error(`[BackupManager] Backup details entry too large: ${detailsEntry.size} bytes`)
|
||||
await zip.close().catch(() => {})
|
||||
await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err))
|
||||
return res.status(400).send('Invalid backup file - details entry too large')
|
||||
}
|
||||
|
||||
const data = await zip.entryData('details')
|
||||
const details = data.toString('utf8').split('\n')
|
||||
|
||||
@@ -140,9 +158,13 @@ class BackupManager {
|
||||
|
||||
if (!backup.serverVersion) {
|
||||
Logger.error(`[BackupManager] Invalid backup with no server version - might be a backup created before version 2.0.0`)
|
||||
await zip.close().catch(() => {})
|
||||
await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err))
|
||||
return res.status(500).send('Invalid backup. Might be a backup created before version 2.0.0.')
|
||||
}
|
||||
|
||||
await zip.close().catch(() => {})
|
||||
|
||||
backup.fileSize = await getFileSize(backup.fullPath)
|
||||
|
||||
const existingBackupIndex = this.backups.findIndex((b) => b.id === backup.id)
|
||||
@@ -257,9 +279,24 @@ class BackupManager {
|
||||
let data = null
|
||||
try {
|
||||
zip = new StreamZip.async({ file: fullFilePath })
|
||||
const entries = await zip.entries()
|
||||
|
||||
const detailsEntry = entries['details']
|
||||
if (!detailsEntry) {
|
||||
Logger.error(`[BackupManager] Backup "${fullFilePath}" missing details entry - skipping`)
|
||||
await zip.close().catch(() => {})
|
||||
continue
|
||||
}
|
||||
if (detailsEntry.size > 1024 * 1024) {
|
||||
Logger.error(`[BackupManager] Backup "${fullFilePath}" details entry too large (${detailsEntry.size} bytes) - skipping`)
|
||||
await zip.close().catch(() => {})
|
||||
continue
|
||||
}
|
||||
|
||||
data = await zip.entryData('details')
|
||||
} catch (error) {
|
||||
Logger.error(`[BackupManager] Failed to unzip backup "${fullFilePath}"`, error)
|
||||
if (zip) await zip.close().catch(() => {})
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -153,6 +153,11 @@ class CronManager {
|
||||
|
||||
startPodcastCron(expression, libraryItemIds) {
|
||||
try {
|
||||
if (!cron.validate(expression)) {
|
||||
Logger.error(`[CronManager] Invalid auto download schedule cron expression "${expression}" - not starting podcast episode check cron`)
|
||||
return
|
||||
}
|
||||
|
||||
Logger.debug(`[CronManager] Scheduling podcast episode check cron "${expression}" for ${libraryItemIds.length} item(s)`)
|
||||
const task = cron.schedule(expression, () => {
|
||||
if (this.podcastCronExpressionsExecuting.includes(expression)) {
|
||||
@@ -167,7 +172,7 @@ class CronManager {
|
||||
task
|
||||
})
|
||||
} catch (error) {
|
||||
Logger.error(`[PodcastManager] Failed to schedule podcast cron ${this.serverSettings.podcastEpisodeSchedule}`, error)
|
||||
Logger.error(`[PodcastManager] Failed to schedule podcast cron ${expression}`, error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ const { Request, Response } = require('express')
|
||||
const Path = require('path')
|
||||
|
||||
const Logger = require('../Logger')
|
||||
const { getAudioMimeTypeFromExtname } = require('../utils/fileUtils')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const Database = require('../Database')
|
||||
|
||||
@@ -216,6 +217,11 @@ class RssFeedManager {
|
||||
res.sendStatus(404)
|
||||
return
|
||||
}
|
||||
// Express does not set the correct mimetype for m4b files so use our defined mimetypes if available
|
||||
const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(episodePath))
|
||||
if (audioMimeType) {
|
||||
res.setHeader('Content-Type', audioMimeType)
|
||||
}
|
||||
res.sendFile(episodePath)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @typedef MigrationContext
|
||||
* @property {import('sequelize').QueryInterface} queryInterface - a Sequelize QueryInterface object.
|
||||
* @property {import('../Logger')} logger - a Logger object.
|
||||
*
|
||||
* @typedef MigrationOptions
|
||||
* @property {MigrationContext} context - an object containing the migration context.
|
||||
*/
|
||||
|
||||
const migrationVersion = '2.35.0'
|
||||
const migrationName = `${migrationVersion}-add-last-refresh-token`
|
||||
const loggerPrefix = `[${migrationVersion} migration]`
|
||||
|
||||
/**
|
||||
* This migration script adds lastRefreshToken and lastRefreshTokenExpiresAt columns to the sessions table.
|
||||
*
|
||||
* @param {MigrationOptions} options - an object containing the migration context.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function up({ context: { queryInterface, logger } }) {
|
||||
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
|
||||
|
||||
if (await queryInterface.tableExists('sessions')) {
|
||||
const tableDescription = await queryInterface.describeTable('sessions')
|
||||
|
||||
if (!tableDescription.lastRefreshToken) {
|
||||
logger.info(`${loggerPrefix} Adding lastRefreshToken column to sessions table`)
|
||||
await queryInterface.addColumn('sessions', 'lastRefreshToken', {
|
||||
type: queryInterface.sequelize.Sequelize.DataTypes.STRING,
|
||||
allowNull: true
|
||||
})
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} lastRefreshToken column already exists in sessions table`)
|
||||
}
|
||||
|
||||
if (!tableDescription.lastRefreshTokenExpiresAt) {
|
||||
logger.info(`${loggerPrefix} Adding lastRefreshTokenExpiresAt column to sessions table`)
|
||||
await queryInterface.addColumn('sessions', 'lastRefreshTokenExpiresAt', {
|
||||
type: queryInterface.sequelize.Sequelize.DataTypes.DATE,
|
||||
allowNull: true
|
||||
})
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} lastRefreshTokenExpiresAt column already exists in sessions table`)
|
||||
}
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} sessions table does not exist`)
|
||||
}
|
||||
|
||||
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* This migration script removes the lastRefreshToken and lastRefreshTokenExpiresAt columns from the sessions table.
|
||||
*
|
||||
* @param {MigrationOptions} options - an object containing the migration context.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function down({ context: { queryInterface, logger } }) {
|
||||
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
|
||||
|
||||
if (await queryInterface.tableExists('sessions')) {
|
||||
const tableDescription = await queryInterface.describeTable('sessions')
|
||||
|
||||
if (tableDescription.lastRefreshToken) {
|
||||
logger.info(`${loggerPrefix} Removing lastRefreshToken column from sessions table`)
|
||||
await queryInterface.removeColumn('sessions', 'lastRefreshToken')
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} lastRefreshToken column does not exist in sessions table`)
|
||||
}
|
||||
|
||||
if (tableDescription.lastRefreshTokenExpiresAt) {
|
||||
logger.info(`${loggerPrefix} Removing lastRefreshTokenExpiresAt column from sessions table`)
|
||||
await queryInterface.removeColumn('sessions', 'lastRefreshTokenExpiresAt')
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} lastRefreshTokenExpiresAt column does not exist in sessions table`)
|
||||
}
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} sessions table does not exist`)
|
||||
}
|
||||
|
||||
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
module.exports = { up, down }
|
||||
@@ -111,16 +111,17 @@ class Author extends Model {
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {string} libraryId
|
||||
* @returns {Promise<Author>}
|
||||
* @returns {Promise<{ author: Author, created: boolean }>}
|
||||
*/
|
||||
static async findOrCreateByNameAndLibrary(name, libraryId) {
|
||||
const author = await this.getByNameAndLibrary(name, libraryId)
|
||||
if (author) return author
|
||||
return this.create({
|
||||
if (author) return { author, created: false }
|
||||
const newAuthor = await this.create({
|
||||
name,
|
||||
lastFirst: this.getLastFirst(name),
|
||||
libraryId
|
||||
})
|
||||
return { author: newAuthor, created: true }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+25
-7
@@ -4,6 +4,7 @@ const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils')
|
||||
const parseNameString = require('../utils/parsers/parseNameString')
|
||||
const htmlSanitizer = require('../utils/htmlSanitizer')
|
||||
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
|
||||
/**
|
||||
* @typedef EBookFileObject
|
||||
@@ -470,13 +471,23 @@ class Book extends Model {
|
||||
|
||||
for (const author of authorsRemoved) {
|
||||
await bookAuthorModel.removeByIds(author.id, this.id)
|
||||
const numBooks = await bookAuthorModel.getCountForAuthor(author.id)
|
||||
if (numBooks > 0) {
|
||||
SocketAuthority.emitter('author_updated', author.toOldJSONExpanded(numBooks))
|
||||
}
|
||||
Logger.debug(`[Book] "${this.title}" Removed author "${author.name}"`)
|
||||
this.authors = this.authors.filter((au) => au.id !== author.id)
|
||||
}
|
||||
const authorsAdded = []
|
||||
for (const authorName of newAuthorNames) {
|
||||
const author = await authorModel.findOrCreateByNameAndLibrary(authorName, libraryId)
|
||||
const { author, created } = await authorModel.findOrCreateByNameAndLibrary(authorName, libraryId)
|
||||
await bookAuthorModel.create({ bookId: this.id, authorId: author.id })
|
||||
if (created) {
|
||||
SocketAuthority.emitter('author_added', author.toOldJSON())
|
||||
} else {
|
||||
const numBooks = await bookAuthorModel.getCountForAuthor(author.id)
|
||||
SocketAuthority.emitter('author_updated', author.toOldJSONExpanded(numBooks))
|
||||
}
|
||||
Logger.debug(`[Book] "${this.title}" Added author "${author.name}"`)
|
||||
this.authors.push(author)
|
||||
authorsAdded.push(author)
|
||||
@@ -636,6 +647,11 @@ class Book extends Model {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minified book JSON for list/shelf endpoints.
|
||||
* `toOldJSONExpanded()` must be a strict superset: every key here must exist in expanded
|
||||
* with the same value semantics. Only additive changes to expanded; never remove or rename keys.
|
||||
*/
|
||||
toOldJSONMinified() {
|
||||
if (!this.authors) {
|
||||
throw new Error(`[Book] Cannot convert to old JSON because authors are not loaded`)
|
||||
@@ -658,6 +674,12 @@ class Book extends Model {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expanded book JSON for item detail and socket events.
|
||||
* Must be a strict superset of `toOldJSONMinified()` — built by spreading minified, then adding expanded-only fields.
|
||||
*
|
||||
* @param {string} libraryItemId
|
||||
*/
|
||||
toOldJSONExpanded(libraryItemId) {
|
||||
if (!libraryItemId) {
|
||||
throw new Error(`[Book] Cannot convert to old JSON because libraryItemId is not provided`)
|
||||
@@ -670,16 +692,12 @@ class Book extends Model {
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
libraryItemId: libraryItemId,
|
||||
...this.toOldJSONMinified(),
|
||||
libraryItemId,
|
||||
metadata: this.oldMetadataToJSONExpanded(),
|
||||
coverPath: this.coverPath,
|
||||
tags: [...(this.tags || [])],
|
||||
audioFiles: structuredClone(this.audioFiles),
|
||||
chapters: structuredClone(this.chapters),
|
||||
ebookFile: structuredClone(this.ebookFile),
|
||||
duration: this.duration,
|
||||
size: this.size,
|
||||
tracks: this.getTracklist(libraryItemId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { DataTypes, Model } = require('sequelize')
|
||||
const { DataTypes, Model, fn, col } = require('sequelize')
|
||||
|
||||
class BookAuthor extends Model {
|
||||
constructor(values, options) {
|
||||
@@ -37,6 +37,32 @@ class BookAuthor extends Model {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of books for each author
|
||||
*
|
||||
* @param {string[]} authorIds
|
||||
* @returns {Promise<Record<string, number>>}
|
||||
*/
|
||||
static async getCountsForAuthors(authorIds) {
|
||||
if (!authorIds.length) return {}
|
||||
|
||||
const rows = await this.findAll({
|
||||
attributes: ['authorId', [fn('COUNT', col('id')), 'count']],
|
||||
where: {
|
||||
authorId: authorIds
|
||||
},
|
||||
group: ['authorId'],
|
||||
raw: true
|
||||
})
|
||||
|
||||
/** @type {Record<string, number>} */
|
||||
const counts = {}
|
||||
for (const row of rows) {
|
||||
counts[row.authorId] = Number(row.count)
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize model
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
|
||||
@@ -1004,6 +1004,11 @@ class LibraryItem extends Model {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minified library item JSON for list/shelf endpoints.
|
||||
* `toOldJSONExpanded()` must be a strict superset: every key here must exist in expanded
|
||||
* with the same value semantics. Only additive changes to expanded; never remove or rename keys.
|
||||
*/
|
||||
toOldJSONMinified() {
|
||||
if (!this.media) {
|
||||
throw new Error(`[LibraryItem] Cannot convert to old JSON without media for library item "${this.id}"`)
|
||||
@@ -1032,30 +1037,18 @@ class LibraryItem extends Model {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expanded library item JSON for item detail and socket events.
|
||||
* Must be a strict superset of `toOldJSONMinified()` — built by spreading minified, then adding expanded-only fields.
|
||||
*/
|
||||
toOldJSONExpanded() {
|
||||
return {
|
||||
id: this.id,
|
||||
ino: this.ino,
|
||||
oldLibraryItemId: this.extraData?.oldLibraryItemId || null,
|
||||
libraryId: this.libraryId,
|
||||
folderId: this.libraryFolderId,
|
||||
path: this.path,
|
||||
relPath: this.relPath,
|
||||
isFile: this.isFile,
|
||||
mtimeMs: this.mtime?.valueOf(),
|
||||
ctimeMs: this.ctime?.valueOf(),
|
||||
birthtimeMs: this.birthtime?.valueOf(),
|
||||
addedAt: this.createdAt.valueOf(),
|
||||
updatedAt: this.updatedAt.valueOf(),
|
||||
...this.toOldJSONMinified(),
|
||||
lastScan: this.lastScan?.valueOf(),
|
||||
scanVersion: this.lastScanVersion,
|
||||
isMissing: !!this.isMissing,
|
||||
isInvalid: !!this.isInvalid,
|
||||
mediaType: this.mediaType,
|
||||
media: this.media.toOldJSONExpanded(this.id),
|
||||
// LibraryFile JSON includes a fileType property that may not be saved in libraryFiles column in the database
|
||||
libraryFiles: this.getLibraryFilesJson(),
|
||||
size: this.size
|
||||
libraryFiles: this.getLibraryFilesJson()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+20
-13
@@ -78,6 +78,7 @@ class Podcast extends Model {
|
||||
*/
|
||||
static async createFromRequest(payload, transaction) {
|
||||
const title = typeof payload.metadata.title === 'string' ? payload.metadata.title : null
|
||||
// cron expression validated in controller
|
||||
const autoDownloadSchedule = typeof payload.autoDownloadSchedule === 'string' ? payload.autoDownloadSchedule : null
|
||||
const genres = Array.isArray(payload.metadata.genres) && payload.metadata.genres.every((g) => typeof g === 'string' && g.length) ? payload.metadata.genres : []
|
||||
const tags = Array.isArray(payload.tags) && payload.tags.every((t) => typeof t === 'string' && t.length) ? payload.tags : []
|
||||
@@ -89,6 +90,9 @@ class Podcast extends Model {
|
||||
}
|
||||
})
|
||||
|
||||
const rawDescription = typeof payload.metadata.description === 'string' ? payload.metadata.description : null
|
||||
const description = rawDescription ? htmlSanitizer.sanitize(rawDescription) : null
|
||||
|
||||
return this.create(
|
||||
{
|
||||
title,
|
||||
@@ -97,7 +101,7 @@ class Podcast extends Model {
|
||||
releaseDate: typeof payload.metadata.releaseDate === 'string' ? payload.metadata.releaseDate : null,
|
||||
feedURL: typeof payload.metadata.feedUrl === 'string' ? payload.metadata.feedUrl : null,
|
||||
imageURL: typeof payload.metadata.imageUrl === 'string' ? payload.metadata.imageUrl : null,
|
||||
description: typeof payload.metadata.description === 'string' ? payload.metadata.description : null,
|
||||
description,
|
||||
itunesPageURL: typeof payload.metadata.itunesPageUrl === 'string' ? payload.metadata.itunesPageUrl : null,
|
||||
itunesId: typeof payload.metadata.itunesId === 'string' ? payload.metadata.itunesId : null,
|
||||
itunesArtistId: typeof payload.metadata.itunesArtistId === 'string' ? payload.metadata.itunesArtistId : null,
|
||||
@@ -270,6 +274,7 @@ class Podcast extends Model {
|
||||
hasUpdates = true
|
||||
}
|
||||
if (typeof payload.autoDownloadSchedule === 'string' && payload.autoDownloadSchedule !== this.autoDownloadSchedule) {
|
||||
// cron expression validated in controller
|
||||
this.autoDownloadSchedule = payload.autoDownloadSchedule
|
||||
hasUpdates = true
|
||||
}
|
||||
@@ -446,6 +451,11 @@ class Podcast extends Model {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minified podcast JSON for list/shelf endpoints.
|
||||
* `toOldJSONExpanded()` must be a strict superset: every key here must exist in expanded
|
||||
* with the same value semantics. Only additive changes to expanded; never remove or rename keys.
|
||||
*/
|
||||
toOldJSONMinified() {
|
||||
return {
|
||||
id: this.id,
|
||||
@@ -463,6 +473,12 @@ class Podcast extends Model {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expanded podcast JSON for item detail and socket events.
|
||||
* Must be a strict superset of `toOldJSONMinified()` — built by spreading minified, then adding expanded-only fields.
|
||||
*
|
||||
* @param {string} libraryItemId
|
||||
*/
|
||||
toOldJSONExpanded(libraryItemId) {
|
||||
if (!libraryItemId) {
|
||||
throw new Error(`[Podcast] Cannot convert to old JSON because libraryItemId is not provided`)
|
||||
@@ -472,18 +488,9 @@ class Podcast extends Model {
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
libraryItemId: libraryItemId,
|
||||
metadata: this.oldMetadataToJSONExpanded(),
|
||||
coverPath: this.coverPath,
|
||||
tags: [...(this.tags || [])],
|
||||
episodes: this.podcastEpisodes.map((e) => e.toOldJSONExpanded(libraryItemId)),
|
||||
autoDownloadEpisodes: this.autoDownloadEpisodes,
|
||||
autoDownloadSchedule: this.autoDownloadSchedule,
|
||||
lastEpisodeCheck: this.lastEpisodeCheck?.valueOf() || null,
|
||||
maxEpisodesToKeep: this.maxEpisodesToKeep,
|
||||
maxNewEpisodesToDownload: this.maxNewEpisodesToDownload,
|
||||
size: this.size
|
||||
...this.toOldJSONMinified(),
|
||||
libraryItemId,
|
||||
episodes: this.podcastEpisodes.map((e) => e.toOldJSONExpanded(libraryItemId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ class Session extends Model {
|
||||
this.userId
|
||||
/** @type {Date} */
|
||||
this.expiresAt
|
||||
/** @type {string} */
|
||||
this.lastRefreshToken
|
||||
/** @type {Date} */
|
||||
this.lastRefreshTokenExpiresAt
|
||||
|
||||
// Expanded properties
|
||||
|
||||
@@ -66,6 +70,14 @@ class Session extends Model {
|
||||
expiresAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
lastRefreshToken: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
lastRefreshTokenExpiresAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+10
-11
@@ -352,11 +352,7 @@ class User extends Model {
|
||||
if (cachedUser) return cachedUser
|
||||
|
||||
const user = await this.findOne({
|
||||
where: {
|
||||
username: {
|
||||
[sequelize.Op.like]: username
|
||||
}
|
||||
},
|
||||
where: sequelize.where(sequelize.fn('lower', sequelize.col('username')), username.toLowerCase()),
|
||||
include: this.sequelize.models.mediaProgress
|
||||
})
|
||||
|
||||
@@ -377,11 +373,7 @@ class User extends Model {
|
||||
if (cachedUser) return cachedUser
|
||||
|
||||
const user = await this.findOne({
|
||||
where: {
|
||||
email: {
|
||||
[sequelize.Op.like]: email
|
||||
}
|
||||
},
|
||||
where: sequelize.where(sequelize.fn('lower', sequelize.col('email')), email.toLowerCase()),
|
||||
include: this.sequelize.models.mediaProgress
|
||||
})
|
||||
|
||||
@@ -538,7 +530,14 @@ class User extends Model {
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
modelName: 'user'
|
||||
modelName: 'user',
|
||||
hooks: {
|
||||
beforeDestroy(user) {
|
||||
if (user.type === 'root') {
|
||||
throw new Error('Root user cannot be deleted')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -96,7 +96,12 @@ class DeviceInfo {
|
||||
this.clientVersion = stripAllTags(clientDeviceInfo?.clientVersion) || serverVersion
|
||||
this.manufacturer = stripAllTags(clientDeviceInfo?.manufacturer) || null
|
||||
this.model = stripAllTags(clientDeviceInfo?.model) || null
|
||||
this.sdkVersion = stripAllTags(clientDeviceInfo?.sdkVersion) || null
|
||||
|
||||
if (typeof clientDeviceInfo?.sdkVersion === 'number') {
|
||||
this.sdkVersion = clientDeviceInfo.sdkVersion.toString()
|
||||
} else {
|
||||
this.sdkVersion = stripAllTags(clientDeviceInfo?.sdkVersion) || null
|
||||
}
|
||||
|
||||
this.clientName = stripAllTags(clientDeviceInfo?.clientName) || null
|
||||
if (this.sdkVersion) {
|
||||
|
||||
@@ -110,7 +110,8 @@ class PlaybackSession {
|
||||
startedAt: this.startedAt,
|
||||
updatedAt: this.updatedAt,
|
||||
audioTracks: this.audioTracks.map((at) => at.toJSON?.() || { ...at }),
|
||||
libraryItem: libraryItem?.toOldJSONExpanded() || null
|
||||
libraryItem: libraryItem?.toOldJSONExpanded() || null,
|
||||
coverAspectRatio: this.coverAspectRatio !== null ? this.coverAspectRatio : undefined // Used for share sessions
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ class Stream extends EventEmitter {
|
||||
return [AudioMimeType.FLAC, AudioMimeType.OPUS, AudioMimeType.WMA, AudioMimeType.AIFF, AudioMimeType.WEBM, AudioMimeType.WEBMA, AudioMimeType.AWB, AudioMimeType.CAF]
|
||||
}
|
||||
get codecsToForceAAC() {
|
||||
return ['alac', 'ac3', 'eac3']
|
||||
return ['alac', 'ac3', 'eac3', 'opus']
|
||||
}
|
||||
get userToken() {
|
||||
return this.user.token
|
||||
|
||||
@@ -260,6 +260,18 @@ class ServerSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Host timezone used by cron schedulers (not persisted in settings)
|
||||
* @returns {string} IANA timezone name, e.g. "America/New_York"
|
||||
*/
|
||||
static getHostTimeZone() {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
|
||||
} catch {
|
||||
return 'UTC'
|
||||
}
|
||||
}
|
||||
|
||||
toJSONForBrowser() {
|
||||
const json = this.toJSON()
|
||||
delete json.tokenSecret
|
||||
@@ -268,6 +280,7 @@ class ServerSettings {
|
||||
delete json.authOpenIDMobileRedirectURIs
|
||||
delete json.authOpenIDGroupClaim
|
||||
delete json.authOpenIDAdvancedPermsClaim
|
||||
json.timeZone = ServerSettings.getHostTimeZone()
|
||||
return json
|
||||
}
|
||||
|
||||
|
||||
@@ -171,6 +171,9 @@ class ApiRouter {
|
||||
// Current User Routes (Me)
|
||||
//
|
||||
this.router.get('/me', MeController.getCurrentUser.bind(this))
|
||||
this.router.get('/me/progress', MeController.getAllMediaProgress.bind(this))
|
||||
this.router.get('/me/bookmarks', MeController.getAllBookmarks.bind(this))
|
||||
this.router.get('/me/bookmarks/:libraryItemId', MeController.getBookmarksForLibraryItem.bind(this))
|
||||
this.router.get('/me/listening-sessions', MeController.getListeningSessions.bind(this))
|
||||
this.router.get('/me/item/listening-sessions/:libraryItemId/:episodeId?', MeController.getItemListeningSessions.bind(this))
|
||||
this.router.get('/me/listening-stats', MeController.getListeningStats.bind(this))
|
||||
@@ -363,8 +366,9 @@ class ApiRouter {
|
||||
* Remove library item and associated entities
|
||||
* @param {string} libraryItemId
|
||||
* @param {string[]} mediaItemIds array of bookId or podcastEpisodeId
|
||||
* @param {string} libraryId
|
||||
*/
|
||||
async handleDeleteLibraryItem(libraryItemId, mediaItemIds) {
|
||||
async handleDeleteLibraryItem(libraryItemId, mediaItemIds, libraryId) {
|
||||
const numProgressRemoved = await Database.mediaProgressModel.destroy({
|
||||
where: {
|
||||
mediaItemId: mediaItemIds
|
||||
@@ -395,7 +399,8 @@ class ApiRouter {
|
||||
await Database.libraryItemModel.removeById(libraryItemId)
|
||||
|
||||
SocketAuthority.emitter('item_removed', {
|
||||
id: libraryItemId
|
||||
id: libraryItemId,
|
||||
libraryId
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -5,15 +5,15 @@ const { LogLevel } = require('../utils/constants')
|
||||
const abmetadataGenerator = require('../utils/generators/abmetadataGenerator')
|
||||
|
||||
class AbsMetadataFileScanner {
|
||||
constructor() { }
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Check for metadata.json file and set book metadata
|
||||
*
|
||||
* @param {import('./LibraryScan')} libraryScan
|
||||
* @param {import('./LibraryItemScanData')} libraryItemData
|
||||
* @param {Object} bookMetadata
|
||||
* @param {string} [existingLibraryItemId]
|
||||
*
|
||||
* @param {import('./LibraryScan')} libraryScan
|
||||
* @param {import('./LibraryItemScanData')} libraryItemData
|
||||
* @param {Object} bookMetadata
|
||||
* @param {string} [existingLibraryItemId]
|
||||
*/
|
||||
async scanBookMetadataFile(libraryScan, libraryItemData, bookMetadata, existingLibraryItemId = null) {
|
||||
const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile
|
||||
@@ -32,7 +32,8 @@ class AbsMetadataFileScanner {
|
||||
|
||||
if (metadataText) {
|
||||
libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}"`)
|
||||
const abMetadata = abmetadataGenerator.parseJson(metadataText) || {}
|
||||
const abMetadata = abmetadataGenerator.parseJson(metadataText, 'book') || {}
|
||||
|
||||
for (const key in abMetadata) {
|
||||
// TODO: When to override with null or empty arrays?
|
||||
if (abMetadata[key] === undefined || abMetadata[key] === null) continue
|
||||
@@ -48,11 +49,11 @@ class AbsMetadataFileScanner {
|
||||
|
||||
/**
|
||||
* Check for metadata.json file and set podcast metadata
|
||||
*
|
||||
* @param {import('./LibraryScan')} libraryScan
|
||||
* @param {import('./LibraryItemScanData')} libraryItemData
|
||||
* @param {Object} podcastMetadata
|
||||
* @param {string} [existingLibraryItemId]
|
||||
*
|
||||
* @param {import('./LibraryScan')} libraryScan
|
||||
* @param {import('./LibraryItemScanData')} libraryItemData
|
||||
* @param {Object} podcastMetadata
|
||||
* @param {string} [existingLibraryItemId]
|
||||
*/
|
||||
async scanPodcastMetadataFile(libraryScan, libraryItemData, podcastMetadata, existingLibraryItemId = null) {
|
||||
const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile
|
||||
@@ -71,7 +72,7 @@ class AbsMetadataFileScanner {
|
||||
|
||||
if (metadataText) {
|
||||
libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}"`)
|
||||
const abMetadata = abmetadataGenerator.parseJson(metadataText) || {}
|
||||
const abMetadata = abmetadataGenerator.parseJson(metadataText, 'podcast') || {}
|
||||
for (const key in abMetadata) {
|
||||
if (abMetadata[key] === undefined || abMetadata[key] === null) continue
|
||||
if (key === 'tags' && !abMetadata.tags?.length) continue
|
||||
@@ -81,4 +82,4 @@ class AbsMetadataFileScanner {
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = new AbsMetadataFileScanner()
|
||||
module.exports = new AbsMetadataFileScanner()
|
||||
|
||||
@@ -7,6 +7,7 @@ const parseNameString = require('../utils/parsers/parseNameString')
|
||||
const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata')
|
||||
const globals = require('../utils/globals')
|
||||
const { readTextFile, filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')
|
||||
const htmlSanitizer = require('../utils/htmlSanitizer')
|
||||
|
||||
const AudioFileScanner = require('./AudioFileScanner')
|
||||
const Database = require('../Database')
|
||||
@@ -227,6 +228,7 @@ class BookScanner {
|
||||
bookId: media.id,
|
||||
authorId: existingAuthorId
|
||||
})
|
||||
libraryScan.authorsNumBooksChangedIds.add(existingAuthorId)
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" added author "${authorName}"`)
|
||||
authorsUpdated = true
|
||||
} else {
|
||||
@@ -237,6 +239,7 @@ class BookScanner {
|
||||
})
|
||||
await media.addAuthor(newAuthor)
|
||||
Database.addAuthorToFilterData(libraryItemData.libraryId, newAuthor.name, newAuthor.id)
|
||||
SocketAuthority.emitter('author_added', newAuthor.toOldJSON())
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" added new author "${authorName}"`)
|
||||
authorsUpdated = true
|
||||
}
|
||||
@@ -246,6 +249,7 @@ class BookScanner {
|
||||
for (const author of media.authors) {
|
||||
if (!bookMetadata.authors.includes(author.name)) {
|
||||
await author.bookAuthor.destroy()
|
||||
libraryScan.authorsNumBooksChangedIds.add(author.id)
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" removed author "${author.name}"`)
|
||||
authorsUpdated = true
|
||||
bookAuthorsRemoved.push(author.id)
|
||||
@@ -477,6 +481,7 @@ class BookScanner {
|
||||
}
|
||||
|
||||
const createdAtTimestamp = new Date().getTime()
|
||||
const newAuthorNames = new Set()
|
||||
if (bookMetadata.authors.length) {
|
||||
for (const authorName of bookMetadata.authors) {
|
||||
const matchingAuthorId = await Database.getAuthorIdByName(libraryItemData.libraryId, authorName)
|
||||
@@ -484,6 +489,8 @@ class BookScanner {
|
||||
bookObject.bookAuthors.push({
|
||||
authorId: matchingAuthorId
|
||||
})
|
||||
// Existing author — only numBooks changes; batch emit at scan end
|
||||
libraryScan.authorsNumBooksChangedIds.add(matchingAuthorId)
|
||||
} else {
|
||||
// New author
|
||||
bookObject.bookAuthors.push({
|
||||
@@ -495,6 +502,7 @@ class BookScanner {
|
||||
lastFirst: Database.authorModel.getLastFirst(authorName)
|
||||
}
|
||||
})
|
||||
newAuthorNames.add(authorName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -592,6 +600,9 @@ class BookScanner {
|
||||
for (const ba of libraryItem.book.bookAuthors) {
|
||||
if (ba.author) {
|
||||
Database.addAuthorToFilterData(libraryItemData.libraryId, ba.author.name, ba.author.id)
|
||||
if (newAuthorNames.has(ba.author.name)) {
|
||||
SocketAuthority.emitter('author_added', ba.author.toOldJSON())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -688,6 +699,10 @@ class BookScanner {
|
||||
|
||||
bookMetadata.titleIgnorePrefix = getTitleIgnorePrefix(bookMetadata.title)
|
||||
|
||||
if (typeof bookMetadata.description === 'string' && bookMetadata.description) {
|
||||
bookMetadata.description = htmlSanitizer.sanitize(bookMetadata.description)
|
||||
}
|
||||
|
||||
return bookMetadata
|
||||
}
|
||||
|
||||
@@ -820,6 +835,9 @@ class BookScanner {
|
||||
|
||||
const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`)
|
||||
|
||||
/**
|
||||
* Keys must match abmetadataGenerator.js
|
||||
*/
|
||||
const jsonObject = {
|
||||
tags: libraryItem.media.tags || [],
|
||||
chapters: libraryItem.media.chapters?.map((c) => ({ ...c })) || [],
|
||||
@@ -883,6 +901,34 @@ class BookScanner {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit authors_num_books_updated for authors whose book count changed during a scan (linked or unlinked).
|
||||
* Authors with no book links are omitted — orphans are handled by author_removed.
|
||||
* @param {string} libraryId
|
||||
* @param {import('./LibraryScan')|import('./ScanLogger')} scanLogger
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async emitAuthorsNumBooksUpdated(libraryId, scanLogger) {
|
||||
if (!scanLogger.authorsNumBooksChangedIds?.size) return
|
||||
|
||||
const authorIds = [...scanLogger.authorsNumBooksChangedIds]
|
||||
scanLogger.authorsNumBooksChangedIds.clear()
|
||||
|
||||
const countsByAuthorId = await Database.bookAuthorModel.getCountsForAuthors(authorIds)
|
||||
|
||||
const authors = Object.entries(countsByAuthorId).map(([id, numBooks]) => ({
|
||||
id,
|
||||
numBooks
|
||||
}))
|
||||
|
||||
if (!authors.length) return
|
||||
|
||||
SocketAuthority.emitter('authors_num_books_updated', {
|
||||
libraryId,
|
||||
authors
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check authors that were removed from a book and remove them if they no longer have any books
|
||||
* keep authors without books that have a asin, description or imagePath
|
||||
|
||||
@@ -66,7 +66,7 @@ class LibraryItemScanner {
|
||||
if (libraryItemDataUpdated || wasUpdated) {
|
||||
SocketAuthority.libraryItemEmitter('item_updated', expandedLibraryItem)
|
||||
|
||||
await this.checkAuthorsAndSeriesRemovedFromBooks(library.id, scanLogger)
|
||||
await this.finalizeAuthorsAndSeriesFromScan(library.id, scanLogger)
|
||||
|
||||
return ScanResult.UPDATED
|
||||
}
|
||||
@@ -76,12 +76,13 @@ class LibraryItemScanner {
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove empty authors and series
|
||||
* Emit author book count updates, then remove empty authors/series left after the scan
|
||||
* @param {string} libraryId
|
||||
* @param {ScanLogger} scanLogger
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async checkAuthorsAndSeriesRemovedFromBooks(libraryId, scanLogger) {
|
||||
async finalizeAuthorsAndSeriesFromScan(libraryId, scanLogger) {
|
||||
await BookScanner.emitAuthorsNumBooksUpdated(libraryId, scanLogger)
|
||||
if (scanLogger.authorsRemovedFromBooks.length) {
|
||||
await BookScanner.checkAuthorsRemovedFromBooks(libraryId, scanLogger)
|
||||
}
|
||||
@@ -215,7 +216,10 @@ class LibraryItemScanner {
|
||||
scanLogger.verbose = true
|
||||
scanLogger.setData('libraryItem', libraryItemScanData.relPath)
|
||||
|
||||
return this.scanNewLibraryItem(libraryItemScanData, library.settings, scanLogger)
|
||||
const newLibraryItem = await this.scanNewLibraryItem(libraryItemScanData, library.settings, scanLogger)
|
||||
// Watcher path does not go through full-library scan cleanup — emit author book count updates here
|
||||
await BookScanner.emitAuthorsNumBooksUpdated(library.id, scanLogger)
|
||||
return newLibraryItem
|
||||
}
|
||||
}
|
||||
module.exports = new LibraryItemScanner()
|
||||
|
||||
@@ -24,6 +24,8 @@ class LibraryScan {
|
||||
|
||||
/** @type {string[]} */
|
||||
this.authorsRemovedFromBooks = []
|
||||
/** @type {Set<string>} */
|
||||
this.authorsNumBooksChangedIds = new Set()
|
||||
/** @type {string[]} */
|
||||
this.seriesRemovedFromBooks = []
|
||||
|
||||
|
||||
@@ -234,8 +234,7 @@ class LibraryScanner {
|
||||
SocketAuthority.libraryItemsEmitter('items_updated', libraryItemsUpdated)
|
||||
}
|
||||
|
||||
// Authors and series that were removed from books should be removed if they are now empty
|
||||
await LibraryItemScanner.checkAuthorsAndSeriesRemovedFromBooks(libraryScan.libraryId, libraryScan)
|
||||
if (this.shouldCancelScan(libraryScan)) return true
|
||||
|
||||
// Update missing library items
|
||||
if (libraryItemIdsMissing.length) {
|
||||
@@ -281,6 +280,9 @@ class LibraryScanner {
|
||||
}
|
||||
}
|
||||
|
||||
// Author book count updates and empty authors/series cleanup after all items are processed
|
||||
await LibraryItemScanner.finalizeAuthorsAndSeriesFromScan(libraryScan.libraryId, libraryScan)
|
||||
|
||||
libraryScan.addLog(LogLevel.INFO, `Scan completed. ${libraryScan.resultStats}`)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const LibraryFile = require('../objects/files/LibraryFile')
|
||||
const fsExtra = require('../libs/fsExtra')
|
||||
const PodcastEpisode = require('../models/PodcastEpisode')
|
||||
const AbsMetadataFileScanner = require('./AbsMetadataFileScanner')
|
||||
const htmlSanitizer = require('../utils/htmlSanitizer')
|
||||
|
||||
/**
|
||||
* Metadata for podcasts pulled from files
|
||||
@@ -398,6 +399,10 @@ class PodcastScanner {
|
||||
|
||||
podcastMetadata.titleIgnorePrefix = getTitleIgnorePrefix(podcastMetadata.title)
|
||||
|
||||
if (typeof podcastMetadata.description === 'string' && podcastMetadata.description) {
|
||||
podcastMetadata.description = htmlSanitizer.sanitize(podcastMetadata.description)
|
||||
}
|
||||
|
||||
return podcastMetadata
|
||||
}
|
||||
|
||||
@@ -420,6 +425,9 @@ class PodcastScanner {
|
||||
|
||||
const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`)
|
||||
|
||||
/**
|
||||
* Keys must match abmetadataGenerator.js
|
||||
*/
|
||||
const jsonObject = {
|
||||
tags: libraryItem.media.tags || [],
|
||||
title: libraryItem.media.title,
|
||||
|
||||
@@ -14,6 +14,8 @@ class ScanLogger {
|
||||
|
||||
/** @type {string[]} */
|
||||
this.authorsRemovedFromBooks = []
|
||||
/** @type {Set<string>} */
|
||||
this.authorsNumBooksChangedIds = new Set()
|
||||
/** @type {string[]} */
|
||||
this.seriesRemovedFromBooks = []
|
||||
|
||||
|
||||
@@ -48,6 +48,8 @@ module.exports.AudioMimeType = {
|
||||
AIF: 'audio/x-aiff',
|
||||
WEBM: 'audio/webm',
|
||||
WEBMA: 'audio/webm',
|
||||
// TODO: Switch to `audio/matroska`? marked as deprecated in IANA registry
|
||||
// ref: https://datatracker.ietf.org/doc/html/rfc9559
|
||||
MKA: 'audio/x-matroska',
|
||||
AWB: 'audio/amr-wb',
|
||||
CAF: 'audio/x-caf',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const axios = require('axios')
|
||||
const ssrfFilter = require('ssrf-req-filter')
|
||||
const Ffmpeg = require('../libs/fluentFfmpeg')
|
||||
const ffmpgegUtils = require('../libs/fluentFfmpeg/utils')
|
||||
const fs = require('../libs/fsExtra')
|
||||
@@ -97,6 +98,8 @@ async function resizeImage(filePath, outputPath, width, height) {
|
||||
module.exports.resizeImage = resizeImage
|
||||
|
||||
/**
|
||||
* Download podcast episode
|
||||
* Uses SSRF filter to prevent internal URLs
|
||||
*
|
||||
* @param {import('../objects/PodcastEpisodeDownload')} podcastEpisodeDownload
|
||||
* @returns {Promise<{success: boolean, isRequestError?: boolean}>}
|
||||
@@ -121,7 +124,9 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
|
||||
Accept: '*/*',
|
||||
'User-Agent': userAgent
|
||||
},
|
||||
timeout: global.PodcastDownloadTimeout
|
||||
timeout: global.PodcastDownloadTimeout,
|
||||
httpAgent: global.DisableSsrfRequestFilter?.(podcastEpisodeDownload.url) ? null : ssrfFilter(podcastEpisodeDownload.url),
|
||||
httpsAgent: global.DisableSsrfRequestFilter?.(podcastEpisodeDownload.url) ? null : ssrfFilter(podcastEpisodeDownload.url)
|
||||
})
|
||||
|
||||
Logger.debug(`[ffmpegHelpers] Successfully connected with User-Agent: ${userAgent}`)
|
||||
|
||||
@@ -1,7 +1,51 @@
|
||||
const Logger = require('../../Logger')
|
||||
const parseSeriesString = require('../parsers/parseSeriesString')
|
||||
|
||||
function parseJsonMetadataText(text) {
|
||||
const mediaTypeKeys = {
|
||||
book: {
|
||||
tags: 'stringArray',
|
||||
title: 'string',
|
||||
subtitle: 'string',
|
||||
authors: 'stringArray',
|
||||
narrators: 'stringArray',
|
||||
series: 'stringArray',
|
||||
genres: 'stringArray',
|
||||
publishedYear: 'string',
|
||||
publishedDate: 'string',
|
||||
publisher: 'string',
|
||||
description: 'string',
|
||||
isbn: 'string',
|
||||
asin: 'string',
|
||||
language: 'string',
|
||||
explicit: 'boolean',
|
||||
abridged: 'boolean'
|
||||
},
|
||||
podcast: {
|
||||
tags: 'stringArray',
|
||||
title: 'string',
|
||||
author: 'string',
|
||||
description: 'string',
|
||||
releaseDate: 'string',
|
||||
genres: 'stringArray',
|
||||
feedURL: 'string',
|
||||
imageURL: 'string',
|
||||
itunesPageURL: 'string',
|
||||
itunesId: 'string',
|
||||
itunesArtistId: 'string',
|
||||
asin: 'string',
|
||||
language: 'string',
|
||||
explicit: 'boolean',
|
||||
podcastType: 'string'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} text
|
||||
* @param {"book" | "podcast"} mediaType
|
||||
* @returns {Object}
|
||||
*/
|
||||
function parseJsonMetadataText(text, mediaType) {
|
||||
try {
|
||||
const abmetadataData = JSON.parse(text)
|
||||
|
||||
@@ -19,28 +63,41 @@ function parseJsonMetadataText(text) {
|
||||
}
|
||||
delete abmetadataData.metadata
|
||||
|
||||
if (abmetadataData.series?.length) {
|
||||
abmetadataData.series = [...new Set(abmetadataData.series.map((t) => t?.trim()).filter((t) => t))]
|
||||
abmetadataData.series = abmetadataData.series.map((series) => parseSeriesString.parse(series))
|
||||
const expectedKeys = mediaTypeKeys[mediaType]
|
||||
if (!expectedKeys) {
|
||||
Logger.error(`[abmetadataGenerator] Invalid media type "${mediaType}"`)
|
||||
return null
|
||||
}
|
||||
// clean tags & remove dupes
|
||||
if (abmetadataData.tags?.length) {
|
||||
abmetadataData.tags = [...new Set(abmetadataData.tags.map((t) => t?.trim()).filter((t) => t))]
|
||||
|
||||
const validated = {}
|
||||
for (const key in expectedKeys) {
|
||||
const expectedType = expectedKeys[key]
|
||||
if (!(key in abmetadataData)) continue
|
||||
|
||||
const validatedValue = validateMetadataValue(key, abmetadataData[key], expectedType)
|
||||
if (validatedValue !== undefined) {
|
||||
validated[key] = validatedValue
|
||||
}
|
||||
}
|
||||
if (abmetadataData.chapters?.length) {
|
||||
abmetadataData.chapters = cleanChaptersArray(abmetadataData.chapters, abmetadataData.title)
|
||||
|
||||
if (validated.series?.length) {
|
||||
validated.series = validated.series.map((series) => parseSeriesString.parse(series)).filter(Boolean)
|
||||
}
|
||||
// clean remove dupes
|
||||
if (abmetadataData.authors?.length) {
|
||||
abmetadataData.authors = [...new Set(abmetadataData.authors.map((t) => t?.trim()).filter((t) => t))]
|
||||
|
||||
if (mediaType === 'book' && 'chapters' in abmetadataData) {
|
||||
if (abmetadataData.chapters === null) {
|
||||
validated.chapters = []
|
||||
} else if (Array.isArray(abmetadataData.chapters)) {
|
||||
const cleanedChapters = cleanChaptersArray(abmetadataData.chapters, validated.title ?? abmetadataData.title)
|
||||
if (cleanedChapters) {
|
||||
validated.chapters = cleanedChapters
|
||||
}
|
||||
} else {
|
||||
Logger.warn(`[abmetadataGenerator] Invalid metadata key "chapters" expected array, got ${typeof abmetadataData.chapters}`)
|
||||
}
|
||||
}
|
||||
if (abmetadataData.narrators?.length) {
|
||||
abmetadataData.narrators = [...new Set(abmetadataData.narrators.map((t) => t?.trim()).filter((t) => t))]
|
||||
}
|
||||
if (abmetadataData.genres?.length) {
|
||||
abmetadataData.genres = [...new Set(abmetadataData.genres.map((t) => t?.trim()).filter((t) => t))]
|
||||
}
|
||||
return abmetadataData
|
||||
|
||||
return validated
|
||||
} catch (error) {
|
||||
Logger.error(`[abmetadataGenerator] Invalid metadata.json JSON`, error)
|
||||
return null
|
||||
@@ -48,6 +105,54 @@ function parseJsonMetadataText(text) {
|
||||
}
|
||||
module.exports.parseJson = parseJsonMetadataText
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {*} value
|
||||
* @param {string} expectedType
|
||||
* @returns {*|undefined} undefined excludes the key
|
||||
*/
|
||||
function validateMetadataValue(key, value, expectedType) {
|
||||
if (expectedType === 'string') {
|
||||
if (value === null) return null
|
||||
if (typeof value === 'number') return String(value)
|
||||
if (typeof value === 'string') return value
|
||||
Logger.warn(`[abmetadataGenerator] Invalid metadata key "${key}" expected string, got ${typeof value}`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (expectedType === 'boolean') {
|
||||
if (value === null) return null
|
||||
if (typeof value === 'boolean') return value
|
||||
if (typeof value === 'string') {
|
||||
const lower = value.toLowerCase()
|
||||
if (lower === 'true') return true
|
||||
if (lower === 'false') return false
|
||||
}
|
||||
Logger.warn(`[abmetadataGenerator] Invalid metadata key "${key}" expected boolean, got ${typeof value}`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Filter empty strings and deduplicate
|
||||
if (expectedType === 'stringArray') {
|
||||
if (value === null) return []
|
||||
if (!Array.isArray(value)) {
|
||||
Logger.warn(`[abmetadataGenerator] Invalid metadata key "${key}" expected string array, got ${typeof value}`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
const cleanedArray = value.filter((t) => typeof t === 'string')
|
||||
return [...new Set(cleanedArray.map((t) => t.trim()).filter((t) => t))]
|
||||
}
|
||||
|
||||
Logger.warn(`[abmetadataGenerator] Unknown expected type "${expectedType}" for key "${key}"`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object[]} chaptersArray
|
||||
* @param {string} mediaTitle
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
function cleanChaptersArray(chaptersArray, mediaTitle) {
|
||||
const chapters = []
|
||||
let index = 0
|
||||
|
||||
@@ -54,6 +54,16 @@ module.exports.isNullOrNaN = (num) => {
|
||||
return num === null || isNaN(num)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number|null|undefined} value
|
||||
* @param {number} max
|
||||
* @returns {number|null}
|
||||
*/
|
||||
module.exports.clampPositiveInt = (value, max) => {
|
||||
if (value == null || !Number.isFinite(value) || value <= 0) return null
|
||||
return Math.min(Math.floor(value), max)
|
||||
}
|
||||
|
||||
const xmlToJSON = (xml) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
parseString(xml, (err, results) => {
|
||||
|
||||
@@ -100,7 +100,7 @@ module.exports.notificationData = {
|
||||
variables: ['version'],
|
||||
defaults: {
|
||||
title: 'Test Notification on Abs {{version}}',
|
||||
body: 'Test notificataion body for abs {{version}}.'
|
||||
body: 'Test notification body for abs {{version}}.'
|
||||
},
|
||||
testData: {
|
||||
version: 'v' + version
|
||||
|
||||
@@ -217,6 +217,10 @@ function extractEpisodeData(item) {
|
||||
episode[cleanKey] = extractFirstArrayItemString(item, key)
|
||||
})
|
||||
|
||||
if (episode.subtitle) {
|
||||
episode.subtitle = htmlSanitizer.sanitize(episode.subtitle.trim())
|
||||
}
|
||||
|
||||
// Extract psc:chapters if duration is set
|
||||
episode.durationSeconds = episode.duration ? timestampToSeconds(episode.duration) : null
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Whether the request was made over HTTPS.
|
||||
* Uses Express `req.secure` and `x-forwarded-proto`
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isRequestSecure(req) {
|
||||
if (req.secure) return true
|
||||
const xfp = (req.get('x-forwarded-proto') || '').toLowerCase()
|
||||
// Nginx Proxy Manager sends "http, https"; see https://github.com/advplyr/audiobookshelf/pull/4635
|
||||
return (
|
||||
xfp === 'https' ||
|
||||
xfp
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.includes('https')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('express').Request} req
|
||||
* @returns {'https' | 'http'}
|
||||
*/
|
||||
function getRequestProtocol(req) {
|
||||
return isRequestSecure(req) ? 'https' : 'http'
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('express').Request} req
|
||||
* @returns {{ protocol: 'https' | 'http', host: string, origin: string }}
|
||||
*/
|
||||
function getRequestOrigin(req) {
|
||||
const protocol = getRequestProtocol(req)
|
||||
const host = req.get('host')
|
||||
return { protocol, host, origin: `${protocol}://${host}` }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isRequestSecure,
|
||||
getRequestProtocol,
|
||||
getRequestOrigin
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
const { expect } = require('chai')
|
||||
const sinon = require('sinon')
|
||||
|
||||
// Load Database first so Auth resolves OidcAuthStrategy before the circular require completes.
|
||||
require('../../../server/Database')
|
||||
const OidcAuthStrategy = require('../../../server/auth/OidcAuthStrategy')
|
||||
const Logger = require('../../../server/Logger')
|
||||
|
||||
describe('OidcAuthStrategy - isValidWebCallbackUrl', () => {
|
||||
/** @type {OidcAuthStrategy} */
|
||||
let strategy
|
||||
|
||||
beforeEach(() => {
|
||||
global.RouterBasePath = ''
|
||||
strategy = new OidcAuthStrategy()
|
||||
sinon.stub(Logger, 'warn')
|
||||
sinon.stub(Logger, 'error')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
function mockReq({ secure = false, host = 'books.example.com', xForwardedProto = null } = {}) {
|
||||
return {
|
||||
secure,
|
||||
get(header) {
|
||||
if (header === 'host') return host
|
||||
if (header === 'x-forwarded-proto') return xForwardedProto
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('accepts a same-origin relative path when router base path is empty', () => {
|
||||
expect(strategy.isValidWebCallbackUrl('/library', mockReq())).to.equal(true)
|
||||
})
|
||||
|
||||
it('accepts a same-origin absolute https URL', () => {
|
||||
const req = mockReq({ secure: true })
|
||||
expect(strategy.isValidWebCallbackUrl('https://books.example.com/library', req)).to.equal(true)
|
||||
})
|
||||
|
||||
it('rejects protocol-relative URLs', () => {
|
||||
expect(strategy.isValidWebCallbackUrl('//evil.example/capture', mockReq())).to.equal(false)
|
||||
})
|
||||
|
||||
it('rejects backslash-prefixed URLs', () => {
|
||||
expect(strategy.isValidWebCallbackUrl('/\\evil.example/capture', mockReq())).to.equal(false)
|
||||
})
|
||||
|
||||
it('rejects absolute external URLs', () => {
|
||||
expect(strategy.isValidWebCallbackUrl('http://evil.example/capture', mockReq())).to.equal(false)
|
||||
})
|
||||
|
||||
it('rejects encoded protocol-relative path segments', () => {
|
||||
expect(strategy.isValidWebCallbackUrl('/%2F%2Fevil.example/capture', mockReq())).to.equal(false)
|
||||
})
|
||||
|
||||
it('rejects same-origin URLs outside router base path', () => {
|
||||
global.RouterBasePath = '/audiobookshelf'
|
||||
expect(strategy.isValidWebCallbackUrl('/login', mockReq())).to.equal(false)
|
||||
expect(strategy.isValidWebCallbackUrl('/audiobookshelf/login', mockReq())).to.equal(true)
|
||||
})
|
||||
|
||||
it('rejects empty and malformed callback URLs', () => {
|
||||
expect(strategy.isValidWebCallbackUrl('', mockReq())).to.equal(false)
|
||||
expect(strategy.isValidWebCallbackUrl(null, mockReq())).to.equal(false)
|
||||
expect(strategy.isValidWebCallbackUrl('not a url', mockReq())).to.equal(false)
|
||||
})
|
||||
|
||||
it('uses x-forwarded-proto when determining same-origin https URLs', () => {
|
||||
const req = mockReq({ xForwardedProto: 'https' })
|
||||
expect(strategy.isValidWebCallbackUrl('https://books.example.com/login', req)).to.equal(true)
|
||||
expect(strategy.isValidWebCallbackUrl('http://books.example.com/login', req)).to.equal(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
const { expect } = require('chai')
|
||||
const sinon = require('sinon')
|
||||
const { Op } = require('sequelize')
|
||||
|
||||
const Database = require('../../../server/Database')
|
||||
const jwt = require('../../../server/libs/jsonwebtoken')
|
||||
|
||||
// Database → Auth → TokenManager circular require can leave TokenManager with a partial Database reference; reload before each test
|
||||
function loadTokenManager() {
|
||||
delete require.cache[require.resolve('../../../server/auth/TokenManager')]
|
||||
return require('../../../server/auth/TokenManager')
|
||||
}
|
||||
|
||||
describe('TokenManager', () => {
|
||||
const secret = 'test-jwt-secret'
|
||||
const userId = 'user-uuid-1'
|
||||
let TokenManager
|
||||
let tokenManager
|
||||
|
||||
beforeEach(() => {
|
||||
TokenManager = loadTokenManager()
|
||||
TokenManager.TokenSecret = secret
|
||||
tokenManager = new TokenManager()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
describe('validateAccessToken', () => {
|
||||
it('rejects refresh tokens', () => {
|
||||
const refreshToken = jwt.sign({ userId, type: 'refresh' }, secret, { expiresIn: 3600 })
|
||||
expect(TokenManager.validateAccessToken(refreshToken)).to.equal(null)
|
||||
})
|
||||
|
||||
it('accepts access tokens', () => {
|
||||
const accessToken = jwt.sign({ userId, type: 'access' }, secret, { expiresIn: 3600 })
|
||||
const decoded = TokenManager.validateAccessToken(accessToken)
|
||||
expect(decoded.userId).to.equal(userId)
|
||||
expect(decoded.type).to.equal('access')
|
||||
})
|
||||
})
|
||||
|
||||
describe('jwtAuthCheck', () => {
|
||||
const user = { id: userId, username: 'testuser', isActive: true }
|
||||
|
||||
it('rejects refresh tokens for API auth', async () => {
|
||||
const refreshToken = tokenManager.generateRefreshToken(user)
|
||||
const decoded = jwt.verify(refreshToken, secret)
|
||||
const done = sinon.spy()
|
||||
|
||||
await tokenManager.jwtAuthCheck(decoded, done)
|
||||
|
||||
expect(done.calledWith(null, null)).to.be.true
|
||||
})
|
||||
|
||||
it('allows access tokens for active users', async () => {
|
||||
sinon.stub(Database, 'userModel').get(() => ({
|
||||
getUserByIdOrOldId: sinon.stub().resolves(user)
|
||||
}))
|
||||
const decoded = jwt.verify(tokenManager.generateTempAccessToken(user), secret)
|
||||
const done = sinon.spy()
|
||||
|
||||
await tokenManager.jwtAuthCheck(decoded, done)
|
||||
|
||||
expect(done.calledWith(null, user)).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalidateJwtSessionsForUser', () => {
|
||||
const targetUser = { id: userId, username: 'testuser' }
|
||||
const currentSession = {
|
||||
id: 'session-current',
|
||||
userId,
|
||||
refreshToken: 'refresh-current'
|
||||
}
|
||||
|
||||
/** Minimal req/res for session invalidation (ApiRouter provides auth on real requests). */
|
||||
function makeReq({ requestUserId = userId, refreshToken = null, cookieRefreshToken = null } = {}) {
|
||||
return {
|
||||
user: { id: requestUserId },
|
||||
cookies: cookieRefreshToken ? { refresh_token: cookieRefreshToken } : {},
|
||||
headers: refreshToken ? { 'x-refresh-token': refreshToken } : {}
|
||||
}
|
||||
}
|
||||
|
||||
let sessionFindOne
|
||||
let sessionDestroy
|
||||
let rotateStub
|
||||
|
||||
beforeEach(() => {
|
||||
sessionFindOne = sinon.stub().resolves(currentSession)
|
||||
sessionDestroy = sinon.stub().resolves(1)
|
||||
sinon.stub(Database, 'sessionModel').get(() => ({
|
||||
findOne: sessionFindOne,
|
||||
destroy: sessionDestroy
|
||||
}))
|
||||
rotateStub = sinon.stub(tokenManager, 'rotateTokensForSession').resolves({
|
||||
accessToken: 'access-new',
|
||||
refreshToken: 'refresh-new'
|
||||
})
|
||||
})
|
||||
|
||||
it('self password change: keeps current session, deletes others', async () => {
|
||||
const req = makeReq({ refreshToken: 'refresh-current' })
|
||||
const res = { cookie: sinon.spy() }
|
||||
|
||||
const result = await tokenManager.invalidateJwtSessionsForUser(targetUser, req, res)
|
||||
|
||||
// Found this device's session using the x-refresh-token header
|
||||
expect(sessionFindOne.calledOnce).to.be.true
|
||||
const findWhere = sessionFindOne.firstCall.args[0].where
|
||||
expect(findWhere.userId).to.equal(userId)
|
||||
expect(findWhere[Op.or]).to.deep.equal([{ refreshToken: 'refresh-current' }, { lastRefreshToken: 'refresh-current' }])
|
||||
|
||||
// Rotated in place (no grace period) so the caller keeps a valid session
|
||||
expect(rotateStub.calledOnceWith(currentSession, targetUser, req, res, false)).to.be.true
|
||||
|
||||
// Deleted all other sessions, but not this one
|
||||
expect(sessionDestroy.calledOnce).to.be.true
|
||||
const destroyWhere = sessionDestroy.firstCall.args[0].where
|
||||
expect(destroyWhere.userId).to.equal(userId)
|
||||
expect(destroyWhere.id[Op.ne]).to.equal(currentSession.id)
|
||||
|
||||
expect(result).to.deep.equal({ accessToken: 'access-new', refreshToken: 'refresh-new' })
|
||||
})
|
||||
|
||||
it('admin password reset: deletes all target sessions', async () => {
|
||||
const req = makeReq({ requestUserId: 'admin-id', refreshToken: 'refresh-current' })
|
||||
const res = { cookie: sinon.spy() }
|
||||
|
||||
const result = await tokenManager.invalidateJwtSessionsForUser(targetUser, req, res)
|
||||
|
||||
// Token rotation did not happen because target is a different user
|
||||
expect(sessionFindOne.called).to.be.false
|
||||
expect(rotateStub.called).to.be.false
|
||||
|
||||
expect(sessionDestroy.calledOnceWith(sinon.match({ where: { userId } }))).to.be.true
|
||||
expect(result).to.equal(null)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
const { expect } = require('chai')
|
||||
const { Sequelize } = require('sequelize')
|
||||
const sinon = require('sinon')
|
||||
|
||||
const Database = require('../../../server/Database')
|
||||
const LibraryController = require('../../../server/controllers/LibraryController')
|
||||
const zipHelpers = require('../../../server/utils/zipHelpers')
|
||||
const Logger = require('../../../server/Logger')
|
||||
|
||||
describe('LibraryController.downloadMultiple', () => {
|
||||
let library
|
||||
let libraryFolder
|
||||
let allowedItemId
|
||||
let explicitItemId
|
||||
let taggedItemId
|
||||
let restrictedUser
|
||||
let libraryRecord
|
||||
|
||||
beforeEach(async () => {
|
||||
global.ServerSettings = {}
|
||||
Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
||||
Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')
|
||||
await Database.buildModels()
|
||||
|
||||
library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
|
||||
libraryFolder = await Database.libraryFolderModel.create({ path: '/test-lib', libraryId: library.id })
|
||||
libraryRecord = await Database.libraryModel.findByIdWithFolders(library.id)
|
||||
|
||||
const allowedBook = await Database.bookModel.create({
|
||||
title: 'Allowed Book',
|
||||
explicit: false,
|
||||
audioFiles: [],
|
||||
tags: ['allowed-tag'],
|
||||
narrators: [],
|
||||
genres: [],
|
||||
chapters: []
|
||||
})
|
||||
const allowedItem = await Database.libraryItemModel.create({
|
||||
path: '/test-lib/allowed',
|
||||
isFile: false,
|
||||
libraryFiles: [],
|
||||
mediaId: allowedBook.id,
|
||||
mediaType: 'book',
|
||||
libraryId: library.id,
|
||||
libraryFolderId: libraryFolder.id
|
||||
})
|
||||
allowedItemId = allowedItem.id
|
||||
|
||||
const explicitBook = await Database.bookModel.create({
|
||||
title: 'Explicit Book',
|
||||
explicit: true,
|
||||
audioFiles: [],
|
||||
tags: [],
|
||||
narrators: [],
|
||||
genres: [],
|
||||
chapters: []
|
||||
})
|
||||
const explicitItem = await Database.libraryItemModel.create({
|
||||
path: '/test-lib/explicit',
|
||||
isFile: false,
|
||||
libraryFiles: [],
|
||||
mediaId: explicitBook.id,
|
||||
mediaType: 'book',
|
||||
libraryId: library.id,
|
||||
libraryFolderId: libraryFolder.id
|
||||
})
|
||||
explicitItemId = explicitItem.id
|
||||
|
||||
const taggedBook = await Database.bookModel.create({
|
||||
title: 'Tagged Book',
|
||||
explicit: false,
|
||||
audioFiles: [],
|
||||
tags: ['restricted-tag'],
|
||||
narrators: [],
|
||||
genres: [],
|
||||
chapters: []
|
||||
})
|
||||
const taggedItem = await Database.libraryItemModel.create({
|
||||
path: '/test-lib/tagged',
|
||||
isFile: false,
|
||||
libraryFiles: [],
|
||||
mediaId: taggedBook.id,
|
||||
mediaType: 'book',
|
||||
libraryId: library.id,
|
||||
libraryFolderId: libraryFolder.id
|
||||
})
|
||||
taggedItemId = taggedItem.id
|
||||
|
||||
const permissions = Database.userModel.getDefaultPermissionsForUserType('user')
|
||||
permissions.download = true
|
||||
permissions.accessExplicitContent = false
|
||||
permissions.accessAllLibraries = false
|
||||
permissions.accessAllTags = false
|
||||
permissions.librariesAccessible = [library.id]
|
||||
permissions.itemTagsSelected = ['allowed-tag']
|
||||
permissions.selectedTagsNotAccessible = false
|
||||
|
||||
restrictedUser = await Database.userModel.create({
|
||||
username: 'restricted',
|
||||
pash: 'hash',
|
||||
token: 'token',
|
||||
type: 'user',
|
||||
isActive: true,
|
||||
permissions,
|
||||
bookmarks: [],
|
||||
extraData: {}
|
||||
})
|
||||
|
||||
sinon.stub(Logger, 'info')
|
||||
sinon.stub(Logger, 'warn')
|
||||
sinon.stub(Logger, 'error')
|
||||
sinon.stub(zipHelpers, 'zipDirectoriesPipe').resolves()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
sinon.restore()
|
||||
await Database.sequelize.sync({ force: true })
|
||||
})
|
||||
|
||||
function makeReq(ids) {
|
||||
return {
|
||||
query: { ids: ids.join(',') },
|
||||
user: restrictedUser,
|
||||
library: libraryRecord
|
||||
}
|
||||
}
|
||||
|
||||
function makeRes() {
|
||||
return {
|
||||
sendStatus: sinon.spy(),
|
||||
status: sinon.stub().returnsThis(),
|
||||
send: sinon.spy()
|
||||
}
|
||||
}
|
||||
|
||||
it('returns 403 for bulk download of an explicit item', async () => {
|
||||
const req = makeReq([explicitItemId])
|
||||
const res = makeRes()
|
||||
|
||||
await LibraryController.downloadMultiple(req, res)
|
||||
|
||||
expect(res.sendStatus.calledWith(403)).to.be.true
|
||||
expect(zipHelpers.zipDirectoriesPipe.called).to.be.false
|
||||
})
|
||||
|
||||
it('returns 403 for bulk download of a tag-restricted item', async () => {
|
||||
const req = makeReq([taggedItemId])
|
||||
const res = makeRes()
|
||||
|
||||
await LibraryController.downloadMultiple(req, res)
|
||||
|
||||
expect(res.sendStatus.calledWith(403)).to.be.true
|
||||
expect(zipHelpers.zipDirectoriesPipe.called).to.be.false
|
||||
})
|
||||
|
||||
it('returns 403 when bulk download includes both allowed and forbidden items', async () => {
|
||||
const req = makeReq([allowedItemId, explicitItemId])
|
||||
const res = makeRes()
|
||||
|
||||
await LibraryController.downloadMultiple(req, res)
|
||||
|
||||
expect(res.sendStatus.calledWith(403)).to.be.true
|
||||
expect(zipHelpers.zipDirectoriesPipe.called).to.be.false
|
||||
})
|
||||
|
||||
it('starts zip download for allowed items only', async () => {
|
||||
const req = makeReq([allowedItemId])
|
||||
const res = makeRes()
|
||||
|
||||
await LibraryController.downloadMultiple(req, res)
|
||||
|
||||
expect(res.sendStatus.called).to.be.false
|
||||
expect(zipHelpers.zipDirectoriesPipe.calledOnce).to.be.true
|
||||
const pathObjects = zipHelpers.zipDirectoriesPipe.firstCall.args[0]
|
||||
expect(pathObjects).to.have.length(1)
|
||||
expect(pathObjects[0].path).to.equal('/test-lib/allowed')
|
||||
})
|
||||
})
|
||||
@@ -123,7 +123,9 @@ describe('LibraryItemController', () => {
|
||||
const fakeReq = {
|
||||
query: {},
|
||||
user: {
|
||||
canDelete: true
|
||||
username: 'test',
|
||||
canDelete: true,
|
||||
checkCanAccessLibraryItem: () => true
|
||||
},
|
||||
body: {
|
||||
libraryItemIds: [libraryItem1Id]
|
||||
@@ -199,4 +201,102 @@ describe('LibraryItemController', () => {
|
||||
expect(series2Exists).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe('batch item access control', () => {
|
||||
let lib1Id
|
||||
let itemLib1Id
|
||||
let itemLib2Id
|
||||
|
||||
beforeEach(async () => {
|
||||
const lib1 = await Database.libraryModel.create({ name: 'Lib 1', mediaType: 'book' })
|
||||
const folder1 = await Database.libraryFolderModel.create({ path: '/l1', libraryId: lib1.id })
|
||||
const book1 = await Database.bookModel.create({ title: 'B1', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
||||
const li1 = await Database.libraryItemModel.create({
|
||||
libraryFiles: [],
|
||||
mediaId: book1.id,
|
||||
mediaType: 'book',
|
||||
libraryId: lib1.id,
|
||||
libraryFolderId: folder1.id
|
||||
})
|
||||
lib1Id = lib1.id
|
||||
itemLib1Id = li1.id
|
||||
|
||||
const lib2 = await Database.libraryModel.create({ name: 'Lib 2', mediaType: 'book' })
|
||||
const folder2 = await Database.libraryFolderModel.create({ path: '/l2', libraryId: lib2.id })
|
||||
const book2 = await Database.bookModel.create({ title: 'B2', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
||||
const li2 = await Database.libraryItemModel.create({
|
||||
libraryFiles: [],
|
||||
mediaId: book2.id,
|
||||
mediaType: 'book',
|
||||
libraryId: lib2.id,
|
||||
libraryFolderId: folder2.id
|
||||
})
|
||||
itemLib2Id = li2.id
|
||||
})
|
||||
|
||||
const userLimitedToLib1 = () => ({
|
||||
username: 'limited',
|
||||
canDelete: true,
|
||||
canUpdate: true,
|
||||
checkCanAccessLibraryItem(li) {
|
||||
return li.libraryId === lib1Id
|
||||
}
|
||||
})
|
||||
|
||||
it('batchGet returns 403 for a library item the user cannot access', async () => {
|
||||
const fakeRes = { sendStatus: sinon.spy(), json: sinon.spy() }
|
||||
const fakeReq = {
|
||||
body: { libraryItemIds: [itemLib2Id] },
|
||||
user: userLimitedToLib1()
|
||||
}
|
||||
await LibraryItemController.batchGet.bind(apiRouter)(fakeReq, fakeRes)
|
||||
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
|
||||
})
|
||||
|
||||
it('batchGet returns items when the user can access them', async () => {
|
||||
const fakeRes = { sendStatus: sinon.spy(), json: sinon.spy() }
|
||||
const fakeReq = {
|
||||
body: { libraryItemIds: [itemLib1Id] },
|
||||
user: userLimitedToLib1()
|
||||
}
|
||||
await LibraryItemController.batchGet.bind(apiRouter)(fakeReq, fakeRes)
|
||||
expect(fakeRes.json.calledOnce).to.be.true
|
||||
const payload = fakeRes.json.firstCall.args[0]
|
||||
expect(payload.libraryItems).to.have.length(1)
|
||||
expect(payload.libraryItems[0].id).to.equal(itemLib1Id)
|
||||
})
|
||||
|
||||
it('batchUpdate returns 403 for a library item the user cannot access', async () => {
|
||||
const fakeRes = { sendStatus: sinon.spy(), json: sinon.spy() }
|
||||
const fakeReq = {
|
||||
user: userLimitedToLib1(),
|
||||
body: [{ id: itemLib2Id, mediaPayload: {} }]
|
||||
}
|
||||
await LibraryItemController.batchUpdate.bind(apiRouter)(fakeReq, fakeRes)
|
||||
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
|
||||
})
|
||||
|
||||
it('batchUpdate returns 403 when the user lacks canUpdate', async () => {
|
||||
const u = userLimitedToLib1()
|
||||
u.canUpdate = false
|
||||
const fakeRes = { sendStatus: sinon.spy(), json: sinon.spy() }
|
||||
const fakeReq = {
|
||||
user: u,
|
||||
body: [{ id: itemLib1Id, mediaPayload: {} }]
|
||||
}
|
||||
await LibraryItemController.batchUpdate.bind(apiRouter)(fakeReq, fakeRes)
|
||||
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
|
||||
})
|
||||
|
||||
it('batchDelete returns 403 for a library item the user cannot access', async () => {
|
||||
const fakeRes = { sendStatus: sinon.spy() }
|
||||
const fakeReq = {
|
||||
query: {},
|
||||
user: userLimitedToLib1(),
|
||||
body: { libraryItemIds: [itemLib2Id] }
|
||||
}
|
||||
await LibraryItemController.batchDelete.bind(apiRouter)(fakeReq, fakeRes)
|
||||
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,636 +0,0 @@
|
||||
const { expect } = require('chai')
|
||||
const { Sequelize } = require('sequelize')
|
||||
const sinon = require('sinon')
|
||||
|
||||
const Database = require('../../../server/Database')
|
||||
const ApiRouter = require('../../../server/routers/ApiRouter')
|
||||
const MeController = require('../../../server/controllers/MeController')
|
||||
const Auth = require('../../../server/Auth')
|
||||
const Logger = require('../../../server/Logger')
|
||||
const SocketAuthority = require('../../../server/SocketAuthority')
|
||||
|
||||
describe('MeController - IDOR Security Tests', () => {
|
||||
/** @type {ApiRouter} */
|
||||
let apiRouter
|
||||
|
||||
beforeEach(async () => {
|
||||
global.ServerSettings = {}
|
||||
Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
||||
Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')
|
||||
await Database.buildModels()
|
||||
|
||||
// Create mock server object with required dependencies
|
||||
const mockServer = {
|
||||
auth: new Auth(),
|
||||
playbackSessionManager: { sessions: [] },
|
||||
abMergeManager: {},
|
||||
backupManager: {},
|
||||
podcastManager: {},
|
||||
audioMetadataManager: {},
|
||||
cronManager: {},
|
||||
emailManager: {},
|
||||
apiCacheManager: { middleware: (req, res, next) => next() }
|
||||
}
|
||||
|
||||
apiRouter = new ApiRouter(mockServer)
|
||||
|
||||
sinon.stub(Logger, 'info')
|
||||
sinon.stub(Logger, 'error')
|
||||
sinon.stub(SocketAuthority, 'clientEmitter')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
sinon.restore()
|
||||
|
||||
// Clear all tables
|
||||
await Database.sequelize.sync({ force: true })
|
||||
})
|
||||
|
||||
describe('removeMediaProgress - IDOR Protection', () => {
|
||||
let user1, user2
|
||||
let mediaProgress1, mediaProgress2
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create two users
|
||||
user1 = await Database.userModel.create({
|
||||
username: 'user1',
|
||||
pash: 'hashed_password_1',
|
||||
type: 'user',
|
||||
isActive: true
|
||||
})
|
||||
|
||||
user2 = await Database.userModel.create({
|
||||
username: 'user2',
|
||||
pash: 'hashed_password_2',
|
||||
type: 'user',
|
||||
isActive: true
|
||||
})
|
||||
|
||||
// Create library and book
|
||||
const library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
|
||||
const libraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: library.id })
|
||||
const book = await Database.bookModel.create({ title: 'Test Book', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
||||
const libraryItem = await Database.libraryItemModel.create({
|
||||
libraryFiles: [],
|
||||
mediaId: book.id,
|
||||
mediaType: 'book',
|
||||
libraryId: library.id,
|
||||
libraryFolderId: libraryFolder.id
|
||||
})
|
||||
|
||||
// Create media progress for each user
|
||||
mediaProgress1 = await Database.mediaProgressModel.create({
|
||||
userId: user1.id,
|
||||
mediaItemId: book.id,
|
||||
mediaItemType: 'book',
|
||||
duration: 1000,
|
||||
currentTime: 500,
|
||||
isFinished: false
|
||||
})
|
||||
|
||||
mediaProgress2 = await Database.mediaProgressModel.create({
|
||||
userId: user2.id,
|
||||
mediaItemId: book.id,
|
||||
mediaItemType: 'book',
|
||||
duration: 1000,
|
||||
currentTime: 300,
|
||||
isFinished: false
|
||||
})
|
||||
|
||||
// Load media progresses into users
|
||||
user1.mediaProgresses = await user1.getMediaProgresses()
|
||||
user2.mediaProgresses = await user2.getMediaProgresses()
|
||||
})
|
||||
|
||||
it('should allow user to delete their own media progress', async () => {
|
||||
const fakeReq = {
|
||||
user: user1,
|
||||
params: { id: mediaProgress1.id }
|
||||
}
|
||||
const fakeRes = {
|
||||
sendStatus: sinon.spy(),
|
||||
status: sinon.stub().returnsThis(),
|
||||
send: sinon.spy()
|
||||
}
|
||||
|
||||
await MeController.removeMediaProgress(fakeReq, fakeRes)
|
||||
|
||||
expect(fakeRes.sendStatus.calledWith(200)).to.be.true
|
||||
|
||||
// Verify media progress was deleted
|
||||
const deletedProgress = await Database.mediaProgressModel.findByPk(mediaProgress1.id)
|
||||
expect(deletedProgress).to.be.null
|
||||
})
|
||||
|
||||
it('should prevent user from deleting another users media progress (IDOR)', async () => {
|
||||
const fakeReq = {
|
||||
user: user1,
|
||||
params: { id: mediaProgress2.id } // Trying to delete user2's progress
|
||||
}
|
||||
const fakeRes = {
|
||||
sendStatus: sinon.spy(),
|
||||
status: sinon.stub().returnsThis(),
|
||||
send: sinon.spy()
|
||||
}
|
||||
|
||||
await MeController.removeMediaProgress(fakeReq, fakeRes)
|
||||
|
||||
expect(fakeRes.sendStatus.calledWith(404)).to.be.true
|
||||
|
||||
// Verify media progress was NOT deleted
|
||||
const existingProgress = await Database.mediaProgressModel.findByPk(mediaProgress2.id)
|
||||
expect(existingProgress).to.not.be.null
|
||||
expect(existingProgress.userId).to.equal(user2.id)
|
||||
})
|
||||
|
||||
it('should return 404 for non-existent media progress', async () => {
|
||||
const fakeReq = {
|
||||
user: user1,
|
||||
params: { id: 'non-existent-id' }
|
||||
}
|
||||
const fakeRes = {
|
||||
sendStatus: sinon.spy(),
|
||||
status: sinon.stub().returnsThis(),
|
||||
send: sinon.spy()
|
||||
}
|
||||
|
||||
await MeController.removeMediaProgress(fakeReq, fakeRes)
|
||||
|
||||
expect(fakeRes.sendStatus.calledWith(404)).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe('Bookmark Operations - Authorization Checks', () => {
|
||||
let user1, user2
|
||||
let library1, library2
|
||||
let libraryItem1, libraryItem2
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create two users with different library access
|
||||
user1 = await Database.userModel.create({
|
||||
username: 'user1',
|
||||
pash: 'hashed_password_1',
|
||||
type: 'user',
|
||||
isActive: true,
|
||||
librariesAccessible: null // Access to all libraries
|
||||
})
|
||||
|
||||
user2 = await Database.userModel.create({
|
||||
username: 'user2',
|
||||
pash: 'hashed_password_2',
|
||||
type: 'user',
|
||||
isActive: true,
|
||||
librariesAccessible: [] // Will be set to specific library
|
||||
})
|
||||
|
||||
// Create two libraries
|
||||
library1 = await Database.libraryModel.create({ name: 'Library 1', mediaType: 'book' })
|
||||
library2 = await Database.libraryModel.create({ name: 'Library 2', mediaType: 'book' })
|
||||
|
||||
// User2 only has access to library1
|
||||
user2.librariesAccessible = [library1.id]
|
||||
await user2.save()
|
||||
|
||||
const libraryFolder1 = await Database.libraryFolderModel.create({ path: '/test1', libraryId: library1.id })
|
||||
const libraryFolder2 = await Database.libraryFolderModel.create({ path: '/test2', libraryId: library2.id })
|
||||
|
||||
const book1 = await Database.bookModel.create({ title: 'Book 1', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
||||
const book2 = await Database.bookModel.create({ title: 'Book 2', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
||||
|
||||
libraryItem1 = await Database.libraryItemModel.create({
|
||||
libraryFiles: [],
|
||||
mediaId: book1.id,
|
||||
mediaType: 'book',
|
||||
libraryId: library1.id,
|
||||
libraryFolderId: libraryFolder1.id
|
||||
})
|
||||
|
||||
libraryItem2 = await Database.libraryItemModel.create({
|
||||
libraryFiles: [],
|
||||
mediaId: book2.id,
|
||||
mediaType: 'book',
|
||||
libraryId: library2.id,
|
||||
libraryFolderId: libraryFolder2.id
|
||||
})
|
||||
|
||||
// Initialize bookmarks
|
||||
user1.bookmarks = []
|
||||
user2.bookmarks = []
|
||||
})
|
||||
|
||||
describe('createBookmark', () => {
|
||||
it('should allow user to create bookmark for accessible library item', async () => {
|
||||
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id)
|
||||
|
||||
const bookmark = { libraryItemId: libraryItem1.id, time: 100, title: 'Test Bookmark', createdAt: Date.now() }
|
||||
|
||||
const fakeReq = {
|
||||
user: {
|
||||
...user2.toJSON(),
|
||||
id: user2.id,
|
||||
username: user2.username,
|
||||
checkCanAccessLibraryItem: () => true,
|
||||
createBookmark: sinon.stub().resolves(bookmark),
|
||||
toOldJSONForBrowser: () => ({ id: user2.id, username: user2.username })
|
||||
},
|
||||
params: { id: libraryItem1.id },
|
||||
body: { time: 100, title: 'Test Bookmark' }
|
||||
}
|
||||
const fakeRes = {
|
||||
sendStatus: sinon.spy(),
|
||||
status: sinon.stub().returnsThis(),
|
||||
send: sinon.spy(),
|
||||
json: sinon.spy()
|
||||
}
|
||||
|
||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
|
||||
|
||||
await MeController.createBookmark(fakeReq, fakeRes)
|
||||
|
||||
expect(fakeRes.json.calledOnce).to.be.true
|
||||
expect(fakeRes.json.calledWith(bookmark)).to.be.true
|
||||
|
||||
Database.libraryItemModel.getExpandedById.restore()
|
||||
})
|
||||
|
||||
it('should prevent user from creating bookmark for inaccessible library item (IDOR)', async () => {
|
||||
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem2.id)
|
||||
|
||||
const fakeReq = {
|
||||
user: user2, // user2 doesn't have access to library2
|
||||
params: { id: libraryItem2.id },
|
||||
body: { time: 100, title: 'Test Bookmark' }
|
||||
}
|
||||
const fakeRes = {
|
||||
sendStatus: sinon.spy(),
|
||||
status: sinon.stub().returnsThis(),
|
||||
send: sinon.spy(),
|
||||
json: sinon.spy()
|
||||
}
|
||||
|
||||
// Mock getExpandedById
|
||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
|
||||
|
||||
await MeController.createBookmark(fakeReq, fakeRes)
|
||||
|
||||
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
|
||||
expect(fakeRes.json.called).to.be.false
|
||||
|
||||
Database.libraryItemModel.getExpandedById.restore()
|
||||
})
|
||||
|
||||
it('should return 404 for non-existent library item', async () => {
|
||||
const fakeReq = {
|
||||
user: user1,
|
||||
params: { id: 'non-existent-id' },
|
||||
body: { time: 100, title: 'Test Bookmark' }
|
||||
}
|
||||
const fakeRes = {
|
||||
sendStatus: sinon.spy(),
|
||||
status: sinon.stub().returnsThis(),
|
||||
send: sinon.spy(),
|
||||
json: sinon.spy()
|
||||
}
|
||||
|
||||
// Mock getExpandedById to return null
|
||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(null)
|
||||
|
||||
await MeController.createBookmark(fakeReq, fakeRes)
|
||||
|
||||
expect(fakeRes.sendStatus.calledWith(404)).to.be.true
|
||||
|
||||
Database.libraryItemModel.getExpandedById.restore()
|
||||
})
|
||||
|
||||
it('should validate bookmark time parameter', async () => {
|
||||
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id)
|
||||
|
||||
const fakeReq = {
|
||||
user: {
|
||||
...user1.toJSON(),
|
||||
id: user1.id,
|
||||
username: user1.username,
|
||||
checkCanAccessLibraryItem: () => true
|
||||
},
|
||||
params: { id: libraryItem1.id },
|
||||
body: { time: null, title: 'Test Bookmark' } // null time is invalid
|
||||
}
|
||||
const fakeRes = {
|
||||
sendStatus: sinon.spy(),
|
||||
status: sinon.stub().returnsThis(),
|
||||
send: sinon.spy(),
|
||||
json: sinon.spy()
|
||||
}
|
||||
|
||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
|
||||
|
||||
await MeController.createBookmark(fakeReq, fakeRes)
|
||||
|
||||
expect(fakeRes.status.calledWith(400)).to.be.true
|
||||
expect(fakeRes.send.calledWith('Invalid time')).to.be.true
|
||||
|
||||
Database.libraryItemModel.getExpandedById.restore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateBookmark', () => {
|
||||
beforeEach(async () => {
|
||||
// Add existing bookmark to user1
|
||||
user1.bookmarks = [{ libraryItemId: libraryItem1.id, time: 100, title: 'Original Title' }]
|
||||
await user1.save()
|
||||
})
|
||||
|
||||
it('should allow user to update bookmark for accessible library item', async () => {
|
||||
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id)
|
||||
|
||||
const bookmark = { libraryItemId: libraryItem1.id, time: 100, title: 'Updated Title' }
|
||||
|
||||
const fakeReq = {
|
||||
user: {
|
||||
...user1.toJSON(),
|
||||
id: user1.id,
|
||||
username: user1.username,
|
||||
checkCanAccessLibraryItem: () => true,
|
||||
updateBookmark: sinon.stub().resolves(bookmark),
|
||||
toOldJSONForBrowser: () => ({ id: user1.id, username: user1.username })
|
||||
},
|
||||
params: { id: libraryItem1.id },
|
||||
body: { time: 100, title: 'Updated Title' }
|
||||
}
|
||||
const fakeRes = {
|
||||
sendStatus: sinon.spy(),
|
||||
status: sinon.stub().returnsThis(),
|
||||
send: sinon.spy(),
|
||||
json: sinon.spy()
|
||||
}
|
||||
|
||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
|
||||
|
||||
await MeController.updateBookmark(fakeReq, fakeRes)
|
||||
|
||||
expect(fakeRes.json.calledOnce).to.be.true
|
||||
expect(fakeRes.json.calledWith(bookmark)).to.be.true
|
||||
|
||||
Database.libraryItemModel.getExpandedById.restore()
|
||||
})
|
||||
|
||||
it('should prevent user from updating bookmark for inaccessible library item (IDOR)', async () => {
|
||||
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem2.id)
|
||||
|
||||
const fakeReq = {
|
||||
user: user2, // user2 doesn't have access to library2
|
||||
params: { id: libraryItem2.id },
|
||||
body: { time: 100, title: 'Updated Title' }
|
||||
}
|
||||
const fakeRes = {
|
||||
sendStatus: sinon.spy(),
|
||||
status: sinon.stub().returnsThis(),
|
||||
send: sinon.spy(),
|
||||
json: sinon.spy()
|
||||
}
|
||||
|
||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
|
||||
|
||||
await MeController.updateBookmark(fakeReq, fakeRes)
|
||||
|
||||
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
|
||||
|
||||
Database.libraryItemModel.getExpandedById.restore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeBookmark', () => {
|
||||
beforeEach(async () => {
|
||||
// Add existing bookmark to user1
|
||||
user1.bookmarks = [{ libraryItemId: libraryItem1.id, time: 100, title: 'Test Bookmark' }]
|
||||
await user1.save()
|
||||
})
|
||||
|
||||
it('should allow user to remove bookmark for accessible library item', async () => {
|
||||
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id)
|
||||
|
||||
const fakeReq = {
|
||||
user: {
|
||||
...user1.toJSON(),
|
||||
id: user1.id,
|
||||
username: user1.username,
|
||||
checkCanAccessLibraryItem: () => true,
|
||||
findBookmark: sinon.stub().returns({ libraryItemId: libraryItem1.id, time: 100, title: 'Test Bookmark' }),
|
||||
removeBookmark: sinon.stub().resolves(true),
|
||||
toOldJSONForBrowser: () => ({ id: user1.id, username: user1.username })
|
||||
},
|
||||
params: { id: libraryItem1.id, time: '100' }
|
||||
}
|
||||
const fakeRes = {
|
||||
sendStatus: sinon.spy(),
|
||||
status: sinon.stub().returnsThis(),
|
||||
send: sinon.spy()
|
||||
}
|
||||
|
||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
|
||||
|
||||
await MeController.removeBookmark(fakeReq, fakeRes)
|
||||
|
||||
expect(fakeRes.sendStatus.calledWith(200)).to.be.true
|
||||
|
||||
Database.libraryItemModel.getExpandedById.restore()
|
||||
})
|
||||
|
||||
it('should prevent user from removing bookmark for inaccessible library item (IDOR)', async () => {
|
||||
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem2.id)
|
||||
|
||||
const fakeReq = {
|
||||
user: user2, // user2 doesn't have access to library2
|
||||
params: { id: libraryItem2.id, time: '100' }
|
||||
}
|
||||
const fakeRes = {
|
||||
sendStatus: sinon.spy(),
|
||||
status: sinon.stub().returnsThis(),
|
||||
send: sinon.spy()
|
||||
}
|
||||
|
||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
|
||||
|
||||
await MeController.removeBookmark(fakeReq, fakeRes)
|
||||
|
||||
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
|
||||
|
||||
Database.libraryItemModel.getExpandedById.restore()
|
||||
})
|
||||
|
||||
it('should validate time parameter is a number', async () => {
|
||||
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id)
|
||||
|
||||
const fakeReq = {
|
||||
user: {
|
||||
...user1.toJSON(),
|
||||
id: user1.id,
|
||||
username: user1.username,
|
||||
checkCanAccessLibraryItem: () => true
|
||||
},
|
||||
params: { id: libraryItem1.id, time: 'not-a-number' }
|
||||
}
|
||||
const fakeRes = {
|
||||
sendStatus: sinon.spy(),
|
||||
status: sinon.stub().returnsThis(),
|
||||
send: sinon.spy()
|
||||
}
|
||||
|
||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
|
||||
|
||||
await MeController.removeBookmark(fakeReq, fakeRes)
|
||||
|
||||
expect(fakeRes.status.calledWith(400)).to.be.true
|
||||
expect(fakeRes.send.calledWith('Invalid time')).to.be.true
|
||||
|
||||
Database.libraryItemModel.getExpandedById.restore()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getItemListeningSessions - Authorization Check', () => {
|
||||
let user1, user2
|
||||
let library1, library2
|
||||
let libraryItem1, libraryItem2
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create two users with different library access
|
||||
user1 = await Database.userModel.create({
|
||||
username: 'user1',
|
||||
pash: 'hashed_password_1',
|
||||
type: 'user',
|
||||
isActive: true,
|
||||
librariesAccessible: null // Access to all libraries
|
||||
})
|
||||
|
||||
user2 = await Database.userModel.create({
|
||||
username: 'user2',
|
||||
pash: 'hashed_password_2',
|
||||
type: 'user',
|
||||
isActive: true,
|
||||
librariesAccessible: [] // Will be set to specific library
|
||||
})
|
||||
|
||||
// Create two libraries
|
||||
library1 = await Database.libraryModel.create({ name: 'Library 1', mediaType: 'book' })
|
||||
library2 = await Database.libraryModel.create({ name: 'Library 2', mediaType: 'book' })
|
||||
|
||||
// User2 only has access to library1
|
||||
user2.librariesAccessible = [library1.id]
|
||||
await user2.save()
|
||||
|
||||
const libraryFolder1 = await Database.libraryFolderModel.create({ path: '/test1', libraryId: library1.id })
|
||||
const libraryFolder2 = await Database.libraryFolderModel.create({ path: '/test2', libraryId: library2.id })
|
||||
|
||||
const book1 = await Database.bookModel.create({ title: 'Book 1', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
||||
const book2 = await Database.bookModel.create({ title: 'Book 2', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
||||
|
||||
libraryItem1 = await Database.libraryItemModel.create({
|
||||
libraryFiles: [],
|
||||
mediaId: book1.id,
|
||||
mediaType: 'book',
|
||||
libraryId: library1.id,
|
||||
libraryFolderId: libraryFolder1.id
|
||||
})
|
||||
|
||||
libraryItem2 = await Database.libraryItemModel.create({
|
||||
libraryFiles: [],
|
||||
mediaId: book2.id,
|
||||
mediaType: 'book',
|
||||
libraryId: library2.id,
|
||||
libraryFolderId: libraryFolder2.id
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow user to view listening sessions for accessible library item', async () => {
|
||||
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id)
|
||||
|
||||
// Create mock context with getUserItemListeningSessionsHelper
|
||||
const mockContext = {
|
||||
getUserItemListeningSessionsHelper: sinon.stub().resolves([{ id: 'session1', timeListening: 300, startedAt: Date.now() }])
|
||||
}
|
||||
|
||||
const fakeReq = {
|
||||
user: {
|
||||
...user1.toJSON(),
|
||||
id: user1.id,
|
||||
username: user1.username,
|
||||
checkCanAccessLibraryItem: () => true
|
||||
},
|
||||
params: { libraryItemId: libraryItem1.id },
|
||||
query: {}
|
||||
}
|
||||
const fakeRes = {
|
||||
sendStatus: sinon.spy(),
|
||||
status: sinon.stub().returnsThis(),
|
||||
send: sinon.spy(),
|
||||
json: sinon.spy()
|
||||
}
|
||||
|
||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
|
||||
sinon.stub(Database.podcastEpisodeModel, 'findByPk').resolves(null)
|
||||
|
||||
await MeController.getItemListeningSessions.bind(mockContext)(fakeReq, fakeRes)
|
||||
|
||||
expect(fakeRes.json.calledOnce).to.be.true
|
||||
expect(fakeRes.sendStatus.called).to.be.false
|
||||
|
||||
// Verify the payload structure
|
||||
const payload = fakeRes.json.firstCall.args[0]
|
||||
expect(payload).to.have.property('total')
|
||||
expect(payload).to.have.property('sessions')
|
||||
|
||||
Database.libraryItemModel.getExpandedById.restore()
|
||||
Database.podcastEpisodeModel.findByPk.restore()
|
||||
})
|
||||
|
||||
it('should prevent user from viewing listening sessions for inaccessible library item (IDOR)', async () => {
|
||||
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem2.id)
|
||||
|
||||
const fakeReq = {
|
||||
user: user2, // user2 doesn't have access to library2
|
||||
params: { libraryItemId: libraryItem2.id },
|
||||
query: {}
|
||||
}
|
||||
const fakeRes = {
|
||||
sendStatus: sinon.spy(),
|
||||
status: sinon.stub().returnsThis(),
|
||||
send: sinon.spy(),
|
||||
json: sinon.spy()
|
||||
}
|
||||
|
||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
|
||||
sinon.stub(Database.podcastEpisodeModel, 'findByPk').resolves(null)
|
||||
|
||||
await MeController.getItemListeningSessions.bind(apiRouter)(fakeReq, fakeRes)
|
||||
|
||||
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
|
||||
expect(fakeRes.json.called).to.be.false
|
||||
|
||||
Database.libraryItemModel.getExpandedById.restore()
|
||||
Database.podcastEpisodeModel.findByPk.restore()
|
||||
})
|
||||
|
||||
it('should return 404 for non-existent library item', async () => {
|
||||
const fakeReq = {
|
||||
user: user1,
|
||||
params: { libraryItemId: 'non-existent-id' },
|
||||
query: {}
|
||||
}
|
||||
const fakeRes = {
|
||||
sendStatus: sinon.spy(),
|
||||
status: sinon.stub().returnsThis(),
|
||||
send: sinon.spy(),
|
||||
json: sinon.spy()
|
||||
}
|
||||
|
||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(null)
|
||||
|
||||
await MeController.getItemListeningSessions.bind(apiRouter)(fakeReq, fakeRes)
|
||||
|
||||
expect(fakeRes.sendStatus.calledWith(404)).to.be.true
|
||||
|
||||
Database.libraryItemModel.getExpandedById.restore()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
const { expect } = require('chai')
|
||||
const sinon = require('sinon')
|
||||
|
||||
const UserController = require('../../../server/controllers/UserController')
|
||||
const Logger = require('../../../server/Logger')
|
||||
|
||||
describe('UserController - delete', () => {
|
||||
beforeEach(() => {
|
||||
sinon.stub(Logger, 'error')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
it('rejects deleting the root user by UUID', async () => {
|
||||
const rootUser = { id: 'root-uuid', isRoot: true, username: 'root' }
|
||||
const adminUser = { id: 'admin-uuid', isRoot: false, username: 'admin' }
|
||||
const fakeRes = { sendStatus: sinon.spy(), json: sinon.spy() }
|
||||
|
||||
await UserController.delete({ user: adminUser, reqUser: rootUser, params: { id: rootUser.id } }, fakeRes)
|
||||
|
||||
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
|
||||
expect(fakeRes.json.called).to.be.false
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
// Import dependencies and modules for testing
|
||||
const { expect } = require('chai')
|
||||
const sinon = require('sinon')
|
||||
const { LRUCache } = require('lru-cache')
|
||||
const ApiCacheManager = require('../../../server/managers/ApiCacheManager')
|
||||
|
||||
describe('ApiCacheManager', () => {
|
||||
@@ -94,4 +95,17 @@ describe('ApiCacheManager', () => {
|
||||
expect(res.originalSend.calledWith(body)).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe('clear on mediaProgress', () => {
|
||||
it('should remove recent-episodes cache entries', () => {
|
||||
const key = JSON.stringify({ user: 'u', url: '/libraries/abc-123/recent-episodes?limit=50&page=0' })
|
||||
const cache = new LRUCache({ max: 10 })
|
||||
cache.set(key, { body: '[]', headers: {}, statusCode: 200 })
|
||||
const manager = new ApiCacheManager(cache)
|
||||
|
||||
manager.clear({ name: 'mediaProgress' }, 'afterUpdate')
|
||||
|
||||
expect(cache.get(key)).to.be.undefined
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user