Compare commits

..

18 Commits

Author SHA1 Message Date
advplyr 3e15e09c07 Fix:Get libraries endpoint #1296 2022-12-19 17:46:32 -06:00
advplyr 0592a41d4f Version bump 2.2.11 2022-12-19 17:16:58 -06:00
advplyr c32e33f804 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-12-19 17:16:48 -06:00
advplyr 616ffb8f79 Add:M4b tool configurable options bitrate/channels/codec #1029 #1257 2022-12-19 17:13:04 -06:00
advplyr bc771a3a44 Delete DownloadManager.js 2022-12-19 16:20:18 -06:00
advplyr 539d1a2d4f Merge pull request #1294 from tomazed/translation-fr
Update fr.json regarding new Metadata strings
2022-12-19 16:16:56 -06:00
advplyr 4d8cea0bb4 Merge pull request #1293 from springsunx/patch-1
Update zh-cn.json
2022-12-19 16:16:37 -06:00
advplyr 8b46262e93 Merge pull request #1292 from Hallo951/master
Update de.json
2022-12-19 16:16:17 -06:00
advplyr eb9a077520 Fix scroll listener for multi select inputs 2022-12-19 16:10:45 -06:00
advplyr 3d3a224402 Fix:Edit modal dropdown menus hidden #1295 2022-12-19 15:32:17 -06:00
advplyr e1397a6dda Update:Author cover image API endpoint to get raw cover image #1291 2022-12-19 15:06:43 -06:00
advplyr 8f49aae979 Fix:Adding podcast and filename sanitize func #1290 2022-12-19 15:02:31 -06:00
Tomazed c0a13f01d4 Update fr.json regarding new Metadata strings 2022-12-19 16:39:46 +01:00
SunX efcebc616c Update zh-cn.json 2022-12-19 22:08:54 +08:00
Hallo951 902867c3bc Update de.json 2022-12-19 09:02:17 +01:00
advplyr b7abd372e4 Version bump 2.2.10 2022-12-18 18:38:00 -06:00
advplyr 147ffc0210 Fix:Cover size widget behind home page arrow #1288 2022-12-18 18:37:03 -06:00
advplyr 1b2ccb6cee Fix:Series inner input behind details modal #1289 2022-12-18 18:35:05 -06:00
22 changed files with 184 additions and 480 deletions
@@ -1,7 +1,7 @@
<template> <template>
<div id="bookshelf" ref="wrapper" class="w-full max-w-full h-full overflow-y-scroll relative"> <div id="bookshelf" ref="wrapper" class="w-full max-w-full h-full overflow-y-scroll relative">
<!-- Cover size widget --> <!-- Cover size widget -->
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" /> <widgets-cover-size-widget class="fixed bottom-4 right-4 z-50" />
<div v-if="loaded && !shelves.length && !search" class="w-full flex flex-col items-center justify-center py-12"> <div v-if="loaded && !shelves.length && !search" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p> <p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
+1 -1
View File
@@ -21,7 +21,7 @@
</div> </div>
</div> </div>
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" /> <widgets-cover-size-widget class="fixed bottom-4 right-4 z-50" />
</div> </div>
</template> </template>
@@ -1,5 +1,5 @@
<template> <template>
<div ref="wrapper" class="hidden absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 rounded-lg items-center justify-center" style="z-index: 51" @click="clickClose"> <div ref="wrapper" class="hidden absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 rounded-lg items-center justify-center" style="z-index: 61" @click="clickClose">
<div class="absolute top-3 right-3 md:top-5 md:right-5 h-8 w-8 md:h-12 md:w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300"> <div class="absolute top-3 right-3 md:top-5 md:right-5 h-8 w-8 md:h-12 md:w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300">
<span class="material-icons text-2xl md:text-4xl">close</span> <span class="material-icons text-2xl md:text-4xl">close</span>
</div> </div>
+3 -3
View File
@@ -15,7 +15,7 @@
</div> </div>
</form> </form>
<ul ref="menu" v-show="showMenu" class="absolute z-50 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label"> <ul ref="menu" v-show="showMenu" class="absolute z-60 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in itemsToShow"> <template v-for="item in itemsToShow">
<li :key="item" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent> <li :key="item" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center"> <div class="flex items-center">
@@ -117,7 +117,7 @@ export default {
}, 50) }, 50)
}, },
recalcMenuPos() { recalcMenuPos() {
if (!this.menu) return if (!this.menu || !this.$refs.inputWrapper) return
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect() var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
if (boundingBox.y > window.innerHeight - 8) { if (boundingBox.y > window.innerHeight - 8) {
// Input is off the page // Input is off the page
@@ -135,7 +135,7 @@ export default {
this.menu.style.width = boundingBox.width + 'px' this.menu.style.width = boundingBox.width + 'px'
}, },
unmountMountMenu() { unmountMountMenu() {
if (!this.$refs.menu) return if (!this.$refs.menu || !this.$refs.inputWrapper) return
this.menu = this.$refs.menu this.menu = this.$refs.menu
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect() var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
+3 -3
View File
@@ -11,7 +11,7 @@
</div> </div>
</div> </div>
<ul ref="menu" v-show="showMenu" class="absolute z-50 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label"> <ul ref="menu" v-show="showMenu" class="absolute z-60 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items"> <template v-for="item in items">
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent> <li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center"> <div class="flex items-center">
@@ -68,14 +68,14 @@ export default {
}, },
methods: { methods: {
recalcMenuPos() { recalcMenuPos() {
if (!this.menu) return if (!this.menu || !this.$refs.inputWrapper) return
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect() var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
this.menu.style.top = boundingBox.y + boundingBox.height - 4 + 'px' this.menu.style.top = boundingBox.y + boundingBox.height - 4 + 'px'
this.menu.style.left = boundingBox.x + 'px' this.menu.style.left = boundingBox.x + 'px'
this.menu.style.width = boundingBox.width + 'px' this.menu.style.width = boundingBox.width + 'px'
}, },
unmountMountMenu() { unmountMountMenu() {
if (!this.$refs.menu) return if (!this.$refs.menu || !this.$refs.inputWrapper) return
this.menu = this.$refs.menu this.menu = this.$refs.menu
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect() var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
@@ -18,7 +18,7 @@
</div> </div>
</form> </form>
<ul ref="menu" v-show="showMenu" class="absolute z-50 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label"> <ul ref="menu" v-show="showMenu" class="absolute z-60 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in itemsToShow"> <template v-for="item in itemsToShow">
<li :key="item.id" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent> <li :key="item.id" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center"> <div class="flex items-center">
@@ -120,6 +120,7 @@ export default {
console.error('Failed to get search results', error) console.error('Failed to get search results', error)
return [] return []
}) })
this.items = results || [] this.items = results || []
this.searching = false this.searching = false
}, },
@@ -139,7 +140,7 @@ export default {
}, 50) }, 50)
}, },
recalcMenuPos() { recalcMenuPos() {
if (!this.menu) return if (!this.menu || !this.$refs.inputWrapper) return
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect() var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
if (boundingBox.y > window.innerHeight - 8) { if (boundingBox.y > window.innerHeight - 8) {
// Input is off the page // Input is off the page
@@ -157,7 +158,7 @@ export default {
this.menu.style.width = boundingBox.width + 'px' this.menu.style.width = boundingBox.width + 'px'
}, },
unmountMountMenu() { unmountMountMenu() {
if (!this.$refs.menu) return if (!this.$refs.menu || !this.$refs.inputWrapper) return
this.menu = this.$refs.menu this.menu = this.$refs.menu
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect() var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
+1 -1
View File
@@ -8,7 +8,7 @@
</div> </div>
</form> </form>
<ul ref="menu" v-show="isFocused && currentSearch" class="absolute z-50 mt-0 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label"> <ul ref="menu" v-show="isFocused && currentSearch" class="absolute z-60 mt-0 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items"> <template v-for="item in items">
<li :key="item.id" class="text-gray-50 select-none relative py-2 pr-3 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent> <li :key="item.id" class="text-gray-50 select-none relative py-2 pr-3 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center"> <div class="flex items-center">
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.2.9", "version": "2.2.11",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.2.9", "version": "2.2.11",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@nuxtjs/axios": "^5.13.6", "@nuxtjs/axios": "^5.13.6",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.2.9", "version": "2.2.11",
"description": "Self-hosted audiobook and podcast client", "description": "Self-hosted audiobook and podcast client",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
+53 -8
View File
@@ -62,19 +62,38 @@
<div class="w-full h-px bg-white bg-opacity-10 my-8" /> <div class="w-full h-px bg-white bg-opacity-10 my-8" />
<div class="w-full max-w-4xl mx-auto"> <div class="w-full max-w-4xl mx-auto">
<div v-if="selectedTool === 'embed'" class="w-full flex justify-end items-center mb-4"> <div v-if="isEmbedTool" class="w-full flex justify-end items-center mb-4">
<ui-btn v-if="!isFinished" color="primary" :loading="processing" @click.stop="embedClick">{{ $strings.ButtonStartMetadataEmbed }}</ui-btn> <ui-btn v-if="!isFinished" color="primary" :loading="processing" @click.stop="embedClick">{{ $strings.ButtonStartMetadataEmbed }}</ui-btn>
<p v-else class="text-success text-lg font-semibold">{{ $strings.MessageEmbedFinished }}</p> <p v-else class="text-success text-lg font-semibold">{{ $strings.MessageEmbedFinished }}</p>
</div> </div>
<div v-else class="w-full flex justify-end items-center mb-4"> <div v-else class="w-full flex items-center mb-4">
<button :disabled="processing" class="text-sm uppercase text-gray-200 flex items-center pt-px pl-1 pr-2 hover:bg-white/5 rounded-md" @click="showEncodeOptions = !showEncodeOptions">
<span class="material-icons text-xl">{{ showEncodeOptions ? 'check_box' : 'check_box_outline_blank' }}</span> <span class="pl-1">Use Advanced Options</span>
</button>
<div class="flex-grow" />
<ui-btn v-if="!isTaskFinished && processing" color="error" :loading="isCancelingEncode" class="mr-2" @click.stop="cancelEncodeClick">{{ $strings.ButtonCancelEncode }}</ui-btn> <ui-btn v-if="!isTaskFinished && processing" color="error" :loading="isCancelingEncode" class="mr-2" @click.stop="cancelEncodeClick">{{ $strings.ButtonCancelEncode }}</ui-btn>
<ui-btn v-if="!isTaskFinished" color="primary" :loading="processing" @click.stop="encodeM4bClick">{{ $strings.ButtonStartM4BEncode }}</ui-btn> <ui-btn v-if="!isTaskFinished" color="primary" :loading="processing" @click.stop="encodeM4bClick">{{ $strings.ButtonStartM4BEncode }}</ui-btn>
<p v-else-if="taskFailed" class="text-error text-lg font-semibold">{{ $strings.MessageM4BFailed }} {{ taskError }}</p> <p v-else-if="taskFailed" class="text-error text-lg font-semibold">{{ $strings.MessageM4BFailed }} {{ taskError }}</p>
<p v-else class="text-success text-lg font-semibold">{{ $strings.MessageM4BFinished }}</p> <p v-else class="text-success text-lg font-semibold">{{ $strings.MessageM4BFinished }}</p>
</div> </div>
<div v-if="isM4BTool" class="overflow-hidden">
<transition name="slide">
<div v-if="showEncodeOptions" class="mb-4 pb-4 border-b border-white/10">
<div class="flex flex-wrap -mx-2">
<ui-text-input-with-label ref="bitrateInput" v-model="encodingOptions.bitrate" :disabled="processing || isTaskFinished" :label="'Audio Bitrate (e.g. 64k)'" class="m-2 max-w-40" />
<ui-text-input-with-label ref="channelsInput" v-model="encodingOptions.channels" :disabled="processing || isTaskFinished" :label="'Audio Channels (1 or 2)'" class="m-2 max-w-40" />
<ui-text-input-with-label ref="codecInput" v-model="encodingOptions.codec" :disabled="processing || isTaskFinished" :label="'Audio Codec'" class="m-2 max-w-40" />
</div>
<p class="text-sm text-warning">Warning: Do not update these settings unless you are familiar with ffmpeg encoding options.</p>
</div>
</transition>
</div>
<div class="mb-4"> <div class="mb-4">
<div v-if="selectedTool === 'embed'" class="flex items-start mb-2"> <div v-if="isEmbedTool" class="flex items-start mb-2">
<span class="material-icons text-base text-warning pt-1">star</span> <span class="material-icons text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">Metadata will be embedded in the audio tracks inside your audiobook folder.</p> <p class="text-gray-200 ml-2">Metadata will be embedded in the audio tracks inside your audiobook folder.</p>
</div> </div>
@@ -91,15 +110,15 @@
A backup of your original audio files will be stored in <span class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono">/metadata/cache/items/{{ libraryItemId }}/</span>. Make sure to periodically purge items cache. A backup of your original audio files will be stored in <span class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono">/metadata/cache/items/{{ libraryItemId }}/</span>. Make sure to periodically purge items cache.
</p> </p>
</div> </div>
<div v-if="selectedTool === 'embed' && audioFiles.length > 1" class="flex items-start mb-2"> <div v-if="isEmbedTool && audioFiles.length > 1" class="flex items-start mb-2">
<span class="material-icons text-base text-warning pt-1">star</span> <span class="material-icons text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">Chapters are not embedded in multi-track audiobooks.</p> <p class="text-gray-200 ml-2">Chapters are not embedded in multi-track audiobooks.</p>
</div> </div>
<div v-if="selectedTool === 'm4b'" class="flex items-start mb-2"> <div v-if="isM4BTool" class="flex items-start mb-2">
<span class="material-icons text-base text-warning pt-1">star</span> <span class="material-icons text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">Encoding can take up to 30 minutes.</p> <p class="text-gray-200 ml-2">Encoding can take up to 30 minutes.</p>
</div> </div>
<div v-if="selectedTool === 'm4b'" class="flex items-start mb-2"> <div v-if="isM4BTool" class="flex items-start mb-2">
<span class="material-icons text-base text-warning pt-1">star</span> <span class="material-icons text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">If you have the watcher disabled you will need to re-scan this audiobook afterwards.</p> <p class="text-gray-200 ml-2">If you have the watcher disabled you will need to re-scan this audiobook afterwards.</p>
</div> </div>
@@ -180,7 +199,13 @@ export default {
isFinished: false, isFinished: false,
toneObject: null, toneObject: null,
selectedTool: 'embed', selectedTool: 'embed',
isCancelingEncode: false isCancelingEncode: false,
showEncodeOptions: false,
encodingOptions: {
bitrate: '64k',
channels: '2',
codec: 'aac'
}
} }
}, },
watch: { watch: {
@@ -193,6 +218,12 @@ export default {
} }
}, },
computed: { computed: {
isEmbedTool() {
return this.selectedTool === 'embed'
},
isM4BTool() {
return this.selectedTool === 'm4b'
},
libraryItemId() { libraryItemId() {
return this.libraryItem.id return this.libraryItem.id
}, },
@@ -260,9 +291,23 @@ export default {
}) })
}, },
encodeM4bClick() { encodeM4bClick() {
if (this.$refs.bitrateInput) this.$refs.bitrateInput.blur()
if (this.$refs.channelsInput) this.$refs.channelsInput.blur()
if (this.$refs.codecInput) this.$refs.codecInput.blur()
let queryStr = ''
if (this.showEncodeOptions) {
const options = []
if (this.encodingOptions.bitrate) options.push(`bitrate=${this.encodingOptions.bitrate}`)
if (this.encodingOptions.channels) options.push(`channels=${this.encodingOptions.channels}`)
if (this.encodingOptions.codec) options.push(`codec=${this.encodingOptions.codec}`)
if (options.length) {
queryStr = `?${options.join('&')}`
}
}
this.processing = true this.processing = true
this.$axios this.$axios
.$post(`/api/tools/item/${this.libraryItemId}/encode-m4b`) .$post(`/api/tools/item/${this.libraryItemId}/encode-m4b${queryStr}`)
.then(() => { .then(() => {
console.log('Ab m4b merge started') console.log('Ab m4b merge started')
}) })
+1 -1
View File
@@ -47,7 +47,7 @@ Vue.prototype.$sanitizeFilename = (filename, colonReplacement = ' - ') => {
const windowsTrailingRe = /[\. ]+$/ const windowsTrailingRe = /[\. ]+$/
const lineBreaks = /[\n\r]/g const lineBreaks = /[\n\r]/g
sanitized = filename let sanitized = filename
.replace(':', colonReplacement) // Replace first occurrence of a colon .replace(':', colonReplacement) // Replace first occurrence of a colon
.replace(illegalRe, replacement) .replace(illegalRe, replacement)
.replace(controlRe, replacement) .replace(controlRe, replacement)
+31 -31
View File
@@ -41,7 +41,7 @@
"ButtonOpenManager": "Manager öffnen", "ButtonOpenManager": "Manager öffnen",
"ButtonPlay": "Abspielen", "ButtonPlay": "Abspielen",
"ButtonPlaying": "Spielt", "ButtonPlaying": "Spielt",
"ButtonPlaylists": "Playlists", "ButtonPlaylists": "Wiedergabelisten",
"ButtonPurgeAllCache": "Bereinige alle Zwischenspeicher", "ButtonPurgeAllCache": "Bereinige alle Zwischenspeicher",
"ButtonPurgeItemsCache": "Bereinige den Hörbuch/Podcast-Zwischenspeicher", "ButtonPurgeItemsCache": "Bereinige den Hörbuch/Podcast-Zwischenspeicher",
"ButtonPurgeMediaProgress": "Bereinige die Hörfortschritte", "ButtonPurgeMediaProgress": "Bereinige die Hörfortschritte",
@@ -95,7 +95,7 @@
"HeaderFindChapters": "Kapitel suchen", "HeaderFindChapters": "Kapitel suchen",
"HeaderIgnoredFiles": "Ignorierte Dateien", "HeaderIgnoredFiles": "Ignorierte Dateien",
"HeaderItemFiles": "Objekt-Dateien", "HeaderItemFiles": "Objekt-Dateien",
"HeaderItemMetadataUtils": "Item Metadata Utils", "HeaderItemMetadataUtils": "Hörbuch/Podcast Metadaten-Werkzeuge",
"HeaderLastListeningSession": "Letzte Hörsitzung", "HeaderLastListeningSession": "Letzte Hörsitzung",
"HeaderLatestEpisodes": "Letzte Episoden", "HeaderLatestEpisodes": "Letzte Episoden",
"HeaderLibraries": "Bibliotheken", "HeaderLibraries": "Bibliotheken",
@@ -105,8 +105,8 @@
"HeaderListeningStats": "Hörstatistiken", "HeaderListeningStats": "Hörstatistiken",
"HeaderLogin": "Anmeldung", "HeaderLogin": "Anmeldung",
"HeaderLogs": "Protokolle", "HeaderLogs": "Protokolle",
"HeaderManageGenres": "Manage Genres", "HeaderManageGenres": "Kategorien verwalten",
"HeaderManageTags": "Manage Tags", "HeaderManageTags": "Tags verwalten",
"HeaderMapDetails": "Stapelverarbeitung", "HeaderMapDetails": "Stapelverarbeitung",
"HeaderMatch": "Online-Suche", "HeaderMatch": "Online-Suche",
"HeaderMetadataToEmbed": "Einzubettende Metadaten", "HeaderMetadataToEmbed": "Einzubettende Metadaten",
@@ -117,8 +117,8 @@
"HeaderOtherFiles": "Sonstige Dateien", "HeaderOtherFiles": "Sonstige Dateien",
"HeaderPermissions": "Berechtigungen", "HeaderPermissions": "Berechtigungen",
"HeaderPlayerQueue": "Spieler Warteschlange", "HeaderPlayerQueue": "Spieler Warteschlange",
"HeaderPlaylist": "Playlist", "HeaderPlaylist": "Wiedergabeliste",
"HeaderPlaylistItems": "Playlist Items", "HeaderPlaylistItems": "Hörbücher/Podcasts der Wiedergabeliste",
"HeaderPodcastsToAdd": "Podcasts zum Hinzufügen", "HeaderPodcastsToAdd": "Podcasts zum Hinzufügen",
"HeaderPreviewCover": "Vorschau Titelbild", "HeaderPreviewCover": "Vorschau Titelbild",
"HeaderRemoveEpisode": "Episode löschen", "HeaderRemoveEpisode": "Episode löschen",
@@ -154,9 +154,9 @@
"LabelActivity": "Aktivitäten", "LabelActivity": "Aktivitäten",
"LabelAddedAt": "Hinzugefügt am", "LabelAddedAt": "Hinzugefügt am",
"LabelAddToCollection": "Zur Sammlung hinzufügen", "LabelAddToCollection": "Zur Sammlung hinzufügen",
"LabelAddToCollectionBatch": "Füge {0} Bücher der Sammlung hinzu", "LabelAddToCollectionBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Sammlung hinzu",
"LabelAddToPlaylist": "Add to Playlist", "LabelAddToPlaylist": "Zur Wiedergabeliste hinzufügen",
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist", "LabelAddToPlaylistBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Wiedergabeliste hinzu",
"LabelAll": "Alle", "LabelAll": "Alle",
"LabelAllUsers": "Alle Benutzer", "LabelAllUsers": "Alle Benutzer",
"LabelAppend": "Anhängen", "LabelAppend": "Anhängen",
@@ -294,7 +294,7 @@
"LabelPermissionsUpdate": "Aktualisieren", "LabelPermissionsUpdate": "Aktualisieren",
"LabelPermissionsUpload": "Hochladen", "LabelPermissionsUpload": "Hochladen",
"LabelPhotoPathURL": "Foto Pfad/URL", "LabelPhotoPathURL": "Foto Pfad/URL",
"LabelPlaylists": "Playlists", "LabelPlaylists": "Wiedergabelisten",
"LabelPlayMethod": "Abspielmethode", "LabelPlayMethod": "Abspielmethode",
"LabelPodcast": "Podcast", "LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts", "LabelPodcasts": "Podcasts",
@@ -421,7 +421,7 @@
"LabelWeekdaysToRun": "Wochentage für die Ausführung", "LabelWeekdaysToRun": "Wochentage für die Ausführung",
"LabelYourAudiobookDuration": "Laufzeit Ihres Hörbuchs", "LabelYourAudiobookDuration": "Laufzeit Ihres Hörbuchs",
"LabelYourBookmarks": "Lesezeichen", "LabelYourBookmarks": "Lesezeichen",
"LabelYourPlaylists": "Eigene Playlists", "LabelYourPlaylists": "Eigene Wiedergabelisten",
"LabelYourProgress": "Fortschritt", "LabelYourProgress": "Fortschritt",
"MessageAddToPlayerQueue": "Zur Abspielwarteliste hinzufügen", "MessageAddToPlayerQueue": "Zur Abspielwarteliste hinzufügen",
"MessageAppriseDescription": "Um diese Funktion nutzen zu können, müssen Sie eine Instanz von <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> laufen haben oder eine API verwenden welche dieselbe Anfragen bearbeiten kann. <br />Die Apprise API Url muss der vollständige URL-Pfad sein, an den die Benachrichtigung gesendet werden soll, z.B. wenn Ihre API-Instanz unter <code>http://192.168.1.1:8337</code> läuft, würden Sie <code>http://192.168.1.1:8337/notify</code> eingeben.", "MessageAppriseDescription": "Um diese Funktion nutzen zu können, müssen Sie eine Instanz von <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> laufen haben oder eine API verwenden welche dieselbe Anfragen bearbeiten kann. <br />Die Apprise API Url muss der vollständige URL-Pfad sein, an den die Benachrichtigung gesendet werden soll, z.B. wenn Ihre API-Instanz unter <code>http://192.168.1.1:8337</code> läuft, würden Sie <code>http://192.168.1.1:8337/notify</code> eingeben.",
@@ -444,13 +444,13 @@
"MessageConfirmRemoveCollection": "Sind Sie sicher, dass Sie die Sammlung \"{0}\" löschen wollen?", "MessageConfirmRemoveCollection": "Sind Sie sicher, dass Sie die Sammlung \"{0}\" löschen wollen?",
"MessageConfirmRemoveEpisode": "Sind Sie sicher, dass Sie die Episode \"{0}\" löschen möchten?", "MessageConfirmRemoveEpisode": "Sind Sie sicher, dass Sie die Episode \"{0}\" löschen möchten?",
"MessageConfirmRemoveEpisodes": "Sind Sie sicher, dass Sie {0} Episoden löschen wollen?", "MessageConfirmRemoveEpisodes": "Sind Sie sicher, dass Sie {0} Episoden löschen wollen?",
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?", "MessageConfirmRemovePlaylist": "Sind Sie sicher, dass Sie die Wiedergabeliste \"{0}\" entfernen möchten?",
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?", "MessageConfirmRenameGenre": "Sind Sie sicher, dass Sie die Kategorie \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts umbenennen wollen?",
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.", "MessageConfirmRenameGenreMergeNote": "Hinweis: Kategorie existiert bereits -> Kategorien werden zusammengelegt.",
"MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".", "MessageConfirmRenameGenreWarning": "Warnung! Ein ähnliche Kategorie mit einem anderen Wortlaut existiert bereits: \"{0}\".",
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?", "MessageConfirmRenameTag": "Sind Sie sicher, dass Sie den Tag \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts umbenennen wollen?",
"MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.", "MessageConfirmRenameTagMergeNote": "Hinweis: Tag existiert bereits -> Tags werden zusammengelegt.",
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".", "MessageConfirmRenameTagWarning": "Warnung! Ein ähnlicher Tag mit einem anderen Wortlaut existiert bereits: \"{0}\".",
"MessageDownloadingEpisode": "Episode herunterladen", "MessageDownloadingEpisode": "Episode herunterladen",
"MessageDragFilesIntoTrackOrder": "Verschieben Sie die Dateien in die richtige Reihenfolge", "MessageDragFilesIntoTrackOrder": "Verschieben Sie die Dateien in die richtige Reihenfolge",
"MessageEmbedFinished": "Einbettung abgeschlossen!", "MessageEmbedFinished": "Einbettung abgeschlossen!",
@@ -461,7 +461,7 @@
"MessageImportantNotice": "Wichtiger Hinweis!", "MessageImportantNotice": "Wichtiger Hinweis!",
"MessageInsertChapterBelow": "Kapitel unten einfügen", "MessageInsertChapterBelow": "Kapitel unten einfügen",
"MessageItemsSelected": "{0} ausgewählte Elemente", "MessageItemsSelected": "{0} ausgewählte Elemente",
"MessageItemsUpdated": "{0} Items Updated", "MessageItemsUpdated": "{0} Hörbüch(er)/Podcast(s) aktualisiert",
"MessageJoinUsOn": "Besuchen Sie uns auf", "MessageJoinUsOn": "Besuchen Sie uns auf",
"MessageListeningSessionsInTheLastYear": "{0} Ereignisse im letzten Jahr", "MessageListeningSessionsInTheLastYear": "{0} Ereignisse im letzten Jahr",
"MessageLoading": "Laden...", "MessageLoading": "Laden...",
@@ -495,7 +495,7 @@
"MessageNoResults": "Keine Ergebnisse", "MessageNoResults": "Keine Ergebnisse",
"MessageNoSearchResultsFor": "Keine Suchergebnisse für \"{0}\"", "MessageNoSearchResultsFor": "Keine Suchergebnisse für \"{0}\"",
"MessageNoSeries": "Keine Serien", "MessageNoSeries": "Keine Serien",
"MessageNoTags": "No Tags", "MessageNoTags": "Keine Tags",
"MessageNotYetImplemented": "Noch nicht implementiert", "MessageNotYetImplemented": "Noch nicht implementiert",
"MessageNoUpdateNecessary": "Keine Aktualisierung erforderlich", "MessageNoUpdateNecessary": "Keine Aktualisierung erforderlich",
"MessageNoUpdatesWereNecessary": "Keine Aktualisierungen waren notwendig", "MessageNoUpdatesWereNecessary": "Keine Aktualisierungen waren notwendig",
@@ -503,7 +503,7 @@
"MessageOr": "oder", "MessageOr": "oder",
"MessagePauseChapter": "Kapitelwiedergabe pausieren", "MessagePauseChapter": "Kapitelwiedergabe pausieren",
"MessagePlayChapter": "Kapitelanfang anhören", "MessagePlayChapter": "Kapitelanfang anhören",
"MessagePlaylistCreateFromCollection": "Create playlist from collection", "MessagePlaylistCreateFromCollection": "Erstelle eine Wiedergabeliste aus der Sammlung",
"MessagePodcastHasNoRSSFeedForMatching": "Podcast hat keine RSS-Feed-Url welche für den Online-Abgleich verwendet werden kann", "MessagePodcastHasNoRSSFeedForMatching": "Podcast hat keine RSS-Feed-Url welche für den Online-Abgleich verwendet werden kann",
"MessageQuickMatchDescription": "Füllt leere Details und Titelbilder mit dem ersten Treffer aus '{0}'. Überschreibt keine Details, es sei denn, die Server-Einstellung \"Passende Metadaten bevorzugen\" ist aktiviert.", "MessageQuickMatchDescription": "Füllt leere Details und Titelbilder mit dem ersten Treffer aus '{0}'. Überschreibt keine Details, es sei denn, die Server-Einstellung \"Passende Metadaten bevorzugen\" ist aktiviert.",
"MessageRemoveAllItemsWarning": "WARNUNG! Bei dieser Aktion werden alle Bibliotheksobjekte aus der Datenbank entfernt, einschließlich aller Aktualisierungen oder Online-Abgleichs, die Sie vorgenommen haben. Ihre eigentlichen Dateien bleiben davon unberührt. Sind Sie sicher?", "MessageRemoveAllItemsWarning": "WARNUNG! Bei dieser Aktion werden alle Bibliotheksobjekte aus der Datenbank entfernt, einschließlich aller Aktualisierungen oder Online-Abgleichs, die Sie vorgenommen haben. Ihre eigentlichen Dateien bleiben davon unberührt. Sind Sie sicher?",
@@ -539,7 +539,7 @@
"NoteUploaderUnsupportedFiles": "Nicht unterstützte Dateien werden ignoriert. Bei der Auswahl oder dem Löschen eines Ordners werden andere Dateien, die sich nicht in einem Elementordner befinden, ignoriert.", "NoteUploaderUnsupportedFiles": "Nicht unterstützte Dateien werden ignoriert. Bei der Auswahl oder dem Löschen eines Ordners werden andere Dateien, die sich nicht in einem Elementordner befinden, ignoriert.",
"PlaceholderNewCollection": "Neuer Sammlungsname", "PlaceholderNewCollection": "Neuer Sammlungsname",
"PlaceholderNewFolderPath": "Neuer Ordnerpfad", "PlaceholderNewFolderPath": "Neuer Ordnerpfad",
"PlaceholderNewPlaylist": "New playlist name", "PlaceholderNewPlaylist": "Neuer Wiedergabelistenname",
"PlaceholderSearch": "Suche...", "PlaceholderSearch": "Suche...",
"ToastAccountUpdateFailed": "Aktualisierung des Kontos fehlgeschlagen", "ToastAccountUpdateFailed": "Aktualisierung des Kontos fehlgeschlagen",
"ToastAccountUpdateSuccess": "Konto aktualisiert", "ToastAccountUpdateSuccess": "Konto aktualisiert",
@@ -589,16 +589,16 @@
"ToastLibraryScanStarted": "Bibliotheksscan gestartet", "ToastLibraryScanStarted": "Bibliotheksscan gestartet",
"ToastLibraryUpdateFailed": "Aktualisierung der Bibliothek fehlgeschlagen", "ToastLibraryUpdateFailed": "Aktualisierung der Bibliothek fehlgeschlagen",
"ToastLibraryUpdateSuccess": "Bibliothek \"{0}\" aktualisiert", "ToastLibraryUpdateSuccess": "Bibliothek \"{0}\" aktualisiert",
"ToastPlaylistCreateFailed": "Failed to create playlist", "ToastPlaylistCreateFailed": "Erstellen der Wiedergabeliste fehlgeschlagen",
"ToastPlaylistCreateSuccess": "Playlist created", "ToastPlaylistCreateSuccess": "Wiedergabeliste erstellt",
"ToastPlaylistRemoveFailed": "Failed to remove playlist", "ToastPlaylistRemoveFailed": "Löschen der Wiedergabeliste fehlgeschlagen",
"ToastPlaylistRemoveSuccess": "Playlist removed", "ToastPlaylistRemoveSuccess": "Wiedergabeliste gelöscht",
"ToastPlaylistUpdateFailed": "Failed to update playlist", "ToastPlaylistUpdateFailed": "Aktualisieren der Wiedergabeliste fehlgeschlagen",
"ToastPlaylistUpdateSuccess": "Playlist aktualisieren", "ToastPlaylistUpdateSuccess": "Wiedergabeliste aktualisiert",
"ToastPodcastCreateFailed": "Podcast konnte nicht erstellt werden", "ToastPodcastCreateFailed": "Podcast konnte nicht erstellt werden",
"ToastPodcastCreateSuccess": "Podcast erfolgreich erstellt", "ToastPodcastCreateSuccess": "Podcast erstellt",
"ToastRemoveItemFromCollectionFailed": "Element/Eintrag konnte nicht aus der Sammlung entfernt werden", "ToastRemoveItemFromCollectionFailed": "Löschen des Hörbuchs/Podcasts aus der Sammlung fehlgeschlagen",
"ToastRemoveItemFromCollectionSuccess": "Element/Eintrag aus der Sammlung entfernt", "ToastRemoveItemFromCollectionSuccess": "Hörbuch/Podcast aus der Sammlung gelöscht",
"ToastRSSFeedCloseFailed": "RSS-Feed konnte nicht geschlossen werden", "ToastRSSFeedCloseFailed": "RSS-Feed konnte nicht geschlossen werden",
"ToastRSSFeedCloseSuccess": "RSS-Feed geschlossen", "ToastRSSFeedCloseSuccess": "RSS-Feed geschlossen",
"ToastSessionDeleteFailed": "Sitzung konnte nicht gelöscht werden", "ToastSessionDeleteFailed": "Sitzung konnte nicht gelöscht werden",
+11 -11
View File
@@ -95,7 +95,7 @@
"HeaderFindChapters": "Trouver les Chapitres", "HeaderFindChapters": "Trouver les Chapitres",
"HeaderIgnoredFiles": "Fichiers Ignorés", "HeaderIgnoredFiles": "Fichiers Ignorés",
"HeaderItemFiles": "Fichiers des Articles", "HeaderItemFiles": "Fichiers des Articles",
"HeaderItemMetadataUtils": "Item Metadata Utils", "HeaderItemMetadataUtils": "Outils de Gestion des Métadonnées",
"HeaderLastListeningSession": "Dernière Session d'Ecoute", "HeaderLastListeningSession": "Dernière Session d'Ecoute",
"HeaderLatestEpisodes": "Dernier Episodes", "HeaderLatestEpisodes": "Dernier Episodes",
"HeaderLibraries": "Bibliothèque", "HeaderLibraries": "Bibliothèque",
@@ -105,8 +105,8 @@
"HeaderListeningStats": "Statistiques d'Ecoute", "HeaderListeningStats": "Statistiques d'Ecoute",
"HeaderLogin": "Connexion", "HeaderLogin": "Connexion",
"HeaderLogs": "Fichiers Journaux", "HeaderLogs": "Fichiers Journaux",
"HeaderManageGenres": "Manage Genres", "HeaderManageGenres": "Gérer les Genres",
"HeaderManageTags": "Manage Tags", "HeaderManageTags": "Gérer les Etiquettes",
"HeaderMapDetails": "Edition en Masse", "HeaderMapDetails": "Edition en Masse",
"HeaderMatch": "Rechercher", "HeaderMatch": "Rechercher",
"HeaderMetadataToEmbed": "Métadonnée à Intégrer", "HeaderMetadataToEmbed": "Métadonnée à Intégrer",
@@ -445,12 +445,12 @@
"MessageConfirmRemoveEpisode": "Etes vous certain de vouloir supprimer l'épisode \"{0}\"?", "MessageConfirmRemoveEpisode": "Etes vous certain de vouloir supprimer l'épisode \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Etes vous certain de vouloir supprimer {0} épisodes?", "MessageConfirmRemoveEpisodes": "Etes vous certain de vouloir supprimer {0} épisodes?",
"MessageConfirmRemovePlaylist": "Etes vous certain de vouloir supprimer la liste de lecture \"{0}\"?", "MessageConfirmRemovePlaylist": "Etes vous certain de vouloir supprimer la liste de lecture \"{0}\"?",
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?", "MessageConfirmRenameGenre": "Etes vous certain de vouloir renommer le genre \"{0}\" vers \"{1}\" pour tous les articles?",
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.", "MessageConfirmRenameGenreMergeNote": "Information: Ce genre existe déjà et sera fusionné.",
"MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".", "MessageConfirmRenameGenreWarning": "Attention! Un genre similaire avec une casse différente existe déjà \"{0}\".",
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?", "MessageConfirmRenameTag": "Etes vous certain de vouloir renommer l'étiquette \"{0}\" vers \"{1}\" pour tous les articles?",
"MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.", "MessageConfirmRenameTagMergeNote": "Information: Cette étiquette existe déjà et sera fusionnée.",
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".", "MessageConfirmRenameTagWarning": "Attention! Une étiquette similaire avec une casse différente existe déjà \"{0}\".",
"MessageDownloadingEpisode": "Téléchargement de l'épisode", "MessageDownloadingEpisode": "Téléchargement de l'épisode",
"MessageDragFilesIntoTrackOrder": "Faire glisser les fichiers dans l'ordre correct", "MessageDragFilesIntoTrackOrder": "Faire glisser les fichiers dans l'ordre correct",
"MessageEmbedFinished": "Intégration Terminée!", "MessageEmbedFinished": "Intégration Terminée!",
@@ -461,7 +461,7 @@
"MessageImportantNotice": "Information Importante!", "MessageImportantNotice": "Information Importante!",
"MessageInsertChapterBelow": "Insérer le chapitre ci-dessous", "MessageInsertChapterBelow": "Insérer le chapitre ci-dessous",
"MessageItemsSelected": "{0} Articles Sélectionnés", "MessageItemsSelected": "{0} Articles Sélectionnés",
"MessageItemsUpdated": "{0} Items Updated", "MessageItemsUpdated": "{0} Articles Mis à Jour",
"MessageJoinUsOn": "Rejoignez-nous sur", "MessageJoinUsOn": "Rejoignez-nous sur",
"MessageListeningSessionsInTheLastYear": "{0} sessions d'écoute l'an dernier", "MessageListeningSessionsInTheLastYear": "{0} sessions d'écoute l'an dernier",
"MessageLoading": "Chargement...", "MessageLoading": "Chargement...",
@@ -495,7 +495,7 @@
"MessageNoResults": "Pas de Résultats", "MessageNoResults": "Pas de Résultats",
"MessageNoSearchResultsFor": "Pas de résultats de recherche pour \"{0}\"", "MessageNoSearchResultsFor": "Pas de résultats de recherche pour \"{0}\"",
"MessageNoSeries": "Pas de Séries", "MessageNoSeries": "Pas de Séries",
"MessageNoTags": "No Tags", "MessageNoTags": "Pas d'Etiquettes",
"MessageNotYetImplemented": "Non implémenté", "MessageNotYetImplemented": "Non implémenté",
"MessageNoUpdateNecessary": "Pas de mise à jour nécessaire", "MessageNoUpdateNecessary": "Pas de mise à jour nécessaire",
"MessageNoUpdatesWereNecessary": "Aucune mise à jour n'était nécessaire", "MessageNoUpdatesWereNecessary": "Aucune mise à jour n'était nécessaire",
+21 -21
View File
@@ -1,5 +1,5 @@
{ {
"ButtonAdd": "加", "ButtonAdd": "加",
"ButtonAddChapters": "添加章节", "ButtonAddChapters": "添加章节",
"ButtonAddPodcasts": "添加播客", "ButtonAddPodcasts": "添加播客",
"ButtonAddYourFirstLibrary": "添加第一个媒体库", "ButtonAddYourFirstLibrary": "添加第一个媒体库",
@@ -66,7 +66,7 @@
"ButtonSelectFolderPath": "选择文件夹路径", "ButtonSelectFolderPath": "选择文件夹路径",
"ButtonSeries": "系列", "ButtonSeries": "系列",
"ButtonSetChaptersFromTracks": "将音轨设置为章节", "ButtonSetChaptersFromTracks": "将音轨设置为章节",
"ButtonShiftTimes": "快速移动时间", "ButtonShiftTimes": "快速调整时间",
"ButtonShow": "显示", "ButtonShow": "显示",
"ButtonStartM4BEncode": "开始 M4B 编码", "ButtonStartM4BEncode": "开始 M4B 编码",
"ButtonStartMetadataEmbed": "开始嵌入元数据", "ButtonStartMetadataEmbed": "开始嵌入元数据",
@@ -95,7 +95,7 @@
"HeaderFindChapters": "查找章节", "HeaderFindChapters": "查找章节",
"HeaderIgnoredFiles": "忽略的文件", "HeaderIgnoredFiles": "忽略的文件",
"HeaderItemFiles": "项目文件", "HeaderItemFiles": "项目文件",
"HeaderItemMetadataUtils": "Item Metadata Utils", "HeaderItemMetadataUtils": "项目元数据管理程序",
"HeaderLastListeningSession": "最后一次收听会话", "HeaderLastListeningSession": "最后一次收听会话",
"HeaderLatestEpisodes": "最新剧集", "HeaderLatestEpisodes": "最新剧集",
"HeaderLibraries": "媒体库", "HeaderLibraries": "媒体库",
@@ -105,9 +105,9 @@
"HeaderListeningStats": "收听统计数据", "HeaderListeningStats": "收听统计数据",
"HeaderLogin": "登录", "HeaderLogin": "登录",
"HeaderLogs": "日志", "HeaderLogs": "日志",
"HeaderManageGenres": "Manage Genres", "HeaderManageGenres": "管理流派",
"HeaderManageTags": "Manage Tags", "HeaderManageTags": "管理标签",
"HeaderMapDetails": "Map details", "HeaderMapDetails": "编辑详情",
"HeaderMatch": "匹配", "HeaderMatch": "匹配",
"HeaderMetadataToEmbed": "嵌入元数据", "HeaderMetadataToEmbed": "嵌入元数据",
"HeaderNewAccount": "新建帐户", "HeaderNewAccount": "新建帐户",
@@ -159,7 +159,7 @@
"LabelAddToPlaylistBatch": "添加 {0} 个项目到播放列表", "LabelAddToPlaylistBatch": "添加 {0} 个项目到播放列表",
"LabelAll": "全部", "LabelAll": "全部",
"LabelAllUsers": "所有用户", "LabelAllUsers": "所有用户",
"LabelAppend": "Append", "LabelAppend": "附加",
"LabelAuthor": "作者", "LabelAuthor": "作者",
"LabelAuthorFirstLast": "作者 (姓 名)", "LabelAuthorFirstLast": "作者 (姓 名)",
"LabelAuthorLastFirst": "作者 (名, 姓)", "LabelAuthorLastFirst": "作者 (名, 姓)",
@@ -206,7 +206,7 @@
"LabelEpisode": "剧集", "LabelEpisode": "剧集",
"LabelEpisodeTitle": "剧集标题", "LabelEpisodeTitle": "剧集标题",
"LabelEpisodeType": "剧集类型", "LabelEpisodeType": "剧集类型",
"LabelExplicit": "信息确", "LabelExplicit": "信息确",
"LabelFeedURL": "源 URL", "LabelFeedURL": "源 URL",
"LabelFile": "文件", "LabelFile": "文件",
"LabelFileBirthtime": "文件创建时间", "LabelFileBirthtime": "文件创建时间",
@@ -283,7 +283,7 @@
"LabelNumberOfBooks": "图书数量", "LabelNumberOfBooks": "图书数量",
"LabelNumberOfEpisodes": "# 集", "LabelNumberOfEpisodes": "# 集",
"LabelOpenRSSFeed": "打开 RSS 源", "LabelOpenRSSFeed": "打开 RSS 源",
"LabelOverwrite": "Overwrite", "LabelOverwrite": "覆盖",
"LabelPassword": "密码", "LabelPassword": "密码",
"LabelPath": "路径", "LabelPath": "路径",
"LabelPermissionsAccessAllLibraries": "可以访问所有媒体库", "LabelPermissionsAccessAllLibraries": "可以访问所有媒体库",
@@ -384,7 +384,7 @@
"LabelTimeListened": "收听时间", "LabelTimeListened": "收听时间",
"LabelTimeListenedToday": "今日收听的时间", "LabelTimeListenedToday": "今日收听的时间",
"LabelTimeRemaining": "剩余 {0}", "LabelTimeRemaining": "剩余 {0}",
"LabelTimeToShift": "快速移动时间以秒为单位", "LabelTimeToShift": "快速调整时间以秒为单位",
"LabelTitle": "标题", "LabelTitle": "标题",
"LabelToolsEmbedMetadata": "嵌入元数据", "LabelToolsEmbedMetadata": "嵌入元数据",
"LabelToolsEmbedMetadataDescription": "将元数据嵌入音频文件, 包括封面图像和章节.", "LabelToolsEmbedMetadataDescription": "将元数据嵌入音频文件, 包括封面图像和章节.",
@@ -445,12 +445,12 @@
"MessageConfirmRemoveEpisode": "您确定要移除剧集 \"{0}\"?", "MessageConfirmRemoveEpisode": "您确定要移除剧集 \"{0}\"?",
"MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?", "MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?",
"MessageConfirmRemovePlaylist": "你确定要移除播放列表 \"{0}\"?", "MessageConfirmRemovePlaylist": "你确定要移除播放列表 \"{0}\"?",
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?", "MessageConfirmRenameGenre": "你确定要将所有项目流派 \"{0}\" 重命名到 \"{1}\"?",
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.", "MessageConfirmRenameGenreMergeNote": "注意: 该流派已经存在, 因此它们将被合并.",
"MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".", "MessageConfirmRenameGenreWarning": "警告! 已经存在有大小写不同的类似流派 \"{0}\".",
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?", "MessageConfirmRenameTag": "你确定要将所有项目标签 \"{0}\" 重命名到 \"{1}\"?",
"MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.", "MessageConfirmRenameTagMergeNote": "注意: 该标签已经存在, 因此它们将被合并.",
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".", "MessageConfirmRenameTagWarning": "警告! 已经存在有大小写不同的类似标签 \"{0}\".",
"MessageDownloadingEpisode": "正在下载剧集", "MessageDownloadingEpisode": "正在下载剧集",
"MessageDragFilesIntoTrackOrder": "将文件拖动到正确的音轨顺序", "MessageDragFilesIntoTrackOrder": "将文件拖动到正确的音轨顺序",
"MessageEmbedFinished": "嵌入完成!", "MessageEmbedFinished": "嵌入完成!",
@@ -461,7 +461,7 @@
"MessageImportantNotice": "重要通知!", "MessageImportantNotice": "重要通知!",
"MessageInsertChapterBelow": "在下面插入章节", "MessageInsertChapterBelow": "在下面插入章节",
"MessageItemsSelected": "已选定 {0} 个项目", "MessageItemsSelected": "已选定 {0} 个项目",
"MessageItemsUpdated": "{0} Items Updated", "MessageItemsUpdated": "已更新 {0} 个项目",
"MessageJoinUsOn": "加入我们", "MessageJoinUsOn": "加入我们",
"MessageListeningSessionsInTheLastYear": "去年收听 {0} 个会话", "MessageListeningSessionsInTheLastYear": "去年收听 {0} 个会话",
"MessageLoading": "加载...", "MessageLoading": "加载...",
@@ -495,7 +495,7 @@
"MessageNoResults": "无结果", "MessageNoResults": "无结果",
"MessageNoSearchResultsFor": "没有搜索到结果 \"{0}\"", "MessageNoSearchResultsFor": "没有搜索到结果 \"{0}\"",
"MessageNoSeries": "无系列", "MessageNoSeries": "无系列",
"MessageNoTags": "No Tags", "MessageNoTags": "无标签",
"MessageNotYetImplemented": "尚未实施", "MessageNotYetImplemented": "尚未实施",
"MessageNoUpdateNecessary": "无需更新", "MessageNoUpdateNecessary": "无需更新",
"MessageNoUpdatesWereNecessary": "无需更新", "MessageNoUpdatesWereNecessary": "无需更新",
@@ -503,7 +503,7 @@
"MessageOr": "或", "MessageOr": "或",
"MessagePauseChapter": "暂停章节播放", "MessagePauseChapter": "暂停章节播放",
"MessagePlayChapter": "开始章节播放", "MessagePlayChapter": "开始章节播放",
"MessagePlaylistCreateFromCollection": "Create playlist from collection", "MessagePlaylistCreateFromCollection": "从收藏中创建播放列表",
"MessagePodcastHasNoRSSFeedForMatching": "播客没有可用于匹配 RSS 源的 url", "MessagePodcastHasNoRSSFeedForMatching": "播客没有可用于匹配 RSS 源的 url",
"MessageQuickMatchDescription": "使用来自 '{0}' 的第一个匹配结果填充空白详细信息和封面. 除非启用 '首选匹配元数据' 服务器设置, 否则不会覆盖详细信息.", "MessageQuickMatchDescription": "使用来自 '{0}' 的第一个匹配结果填充空白详细信息和封面. 除非启用 '首选匹配元数据' 服务器设置, 否则不会覆盖详细信息.",
"MessageRemoveAllItemsWarning": "警告! 此操作将从数据库中删除所有的媒体库项, 包括您所做的任何更新或匹配. 这不会对实际文件产生任何影响. 你确定吗?", "MessageRemoveAllItemsWarning": "警告! 此操作将从数据库中删除所有的媒体库项, 包括您所做的任何更新或匹配. 这不会对实际文件产生任何影响. 你确定吗?",
@@ -589,8 +589,8 @@
"ToastLibraryScanStarted": "媒体库扫描已启动", "ToastLibraryScanStarted": "媒体库扫描已启动",
"ToastLibraryUpdateFailed": "更新图书库失败", "ToastLibraryUpdateFailed": "更新图书库失败",
"ToastLibraryUpdateSuccess": "媒体库 \"{0}\" 已更新", "ToastLibraryUpdateSuccess": "媒体库 \"{0}\" 已更新",
"ToastPlaylistCreateFailed": "Failed to create playlist", "ToastPlaylistCreateFailed": "创建播放列表失败",
"ToastPlaylistCreateSuccess": "Playlist created", "ToastPlaylistCreateSuccess": "已成功创建播放列表",
"ToastPlaylistRemoveFailed": "删除播放列表失败", "ToastPlaylistRemoveFailed": "删除播放列表失败",
"ToastPlaylistRemoveSuccess": "播放列表已删除", "ToastPlaylistRemoveSuccess": "播放列表已删除",
"ToastPlaylistUpdateFailed": "更新播放列表失败", "ToastPlaylistUpdateFailed": "更新播放列表失败",
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.2.9", "version": "2.2.11",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.2.9", "version": "2.2.11",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"axios": "^0.26.1", "axios": "^0.26.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.2.9", "version": "2.2.11",
"description": "Self-hosted audiobook and podcast server", "description": "Self-hosted audiobook and podcast server",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
+13 -2
View File
@@ -1,8 +1,11 @@
const fs = require('../libs/fsExtra')
const { createNewSortInstance } = require('../libs/fastSort')
const Logger = require('../Logger') const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
const { reqSupportsWebp } = require('../utils/index') const { reqSupportsWebp } = require('../utils/index')
const { createNewSortInstance } = require('../libs/fastSort')
const naturalSort = createNewSortInstance({ const naturalSort = createNewSortInstance({
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
@@ -206,7 +209,15 @@ class AuthorController {
// GET api/authors/:id/image // GET api/authors/:id/image
async getImage(req, res) { async getImage(req, res) {
let { query: { width, height, format }, author } = req const { query: { width, height, format, raw }, author } = req
if (raw) { // any value
if (!author.imagePath || !await fs.pathExists(author.imagePath)) {
return res.sendStatus(404)
}
return res.sendFile(author.imagePath)
}
const options = { const options = {
format: format || (reqSupportsWebp(req) ? 'webp' : 'jpeg'), format: format || (reqSupportsWebp(req) ? 'webp' : 'jpeg'),
+3 -1
View File
@@ -59,7 +59,9 @@ class LibraryController {
findAll(req, res) { findAll(req, res) {
const librariesAccessible = req.user.librariesAccessible || [] const librariesAccessible = req.user.librariesAccessible || []
if (librariesAccessible && librariesAccessible.length) { if (librariesAccessible && librariesAccessible.length) {
return res.json(this.db.libraries.filter(lib => librariesAccessible.includes(lib.id)).map(lib => lib.toJSON())) return res.json({
libraries: this.db.libraries.filter(lib => librariesAccessible.includes(lib.id)).map(lib => lib.toJSON())
})
} }
res.json({ res.json({
+2 -1
View File
@@ -26,7 +26,8 @@ class ToolsController {
return res.status(500).send('Invalid audiobook: no audio tracks') return res.status(500).send('Invalid audiobook: no audio tracks')
} }
this.abMergeManager.startAudiobookMerge(req.user, req.libraryItem) const options = req.query || {}
this.abMergeManager.startAudiobookMerge(req.user, req.libraryItem, options)
res.sendStatus(200) res.sendStatus(200)
} }
+25 -17
View File
@@ -45,7 +45,7 @@ class AbMergeManager {
this.downloadDirPathExist = true this.downloadDirPathExist = true
} }
async startAudiobookMerge(user, libraryItem) { async startAudiobookMerge(user, libraryItem, options = {}) {
const task = new Task() const task = new Task()
const audiobookDirname = Path.basename(libraryItem.path) const audiobookDirname = Path.basename(libraryItem.path)
@@ -72,20 +72,28 @@ class AbMergeManager {
await fs.mkdir(taskData.itemCachePath) await fs.mkdir(taskData.itemCachePath)
} }
this.runAudiobookMerge(libraryItem, task) this.runAudiobookMerge(libraryItem, task, options || {})
} }
async runAudiobookMerge(libraryItem, task) { async runAudiobookMerge(libraryItem, task, encodingOptions) {
const audioBitrate = encodingOptions.bitrate || '64k'
const audioCodec = encodingOptions.codec || 'aac'
const audioChannels = encodingOptions.channels || 2
// If changing audio file type then encoding is needed // If changing audio file type then encoding is needed
var audioTracks = libraryItem.media.tracks const audioTracks = libraryItem.media.tracks
var audioRequiresEncode = audioTracks[0].metadata.ext !== '.m4b'
var firstTrackIsM4b = audioTracks[0].metadata.ext.toLowerCase() === '.m4b' // TODO: Updated in 2.2.11 to always encode even if merging multiple m4b. This is because just using the file extension as was being done before is not enough. This can be an option or do more to check if a concat is possible.
var isOneTrack = audioTracks.length === 1 // const audioRequiresEncode = audioTracks[0].metadata.ext !== '.m4b'
const audioRequiresEncode = true
const firstTrackIsM4b = audioTracks[0].metadata.ext.toLowerCase() === '.m4b'
const isOneTrack = audioTracks.length === 1
const ffmpegInputs = [] const ffmpegInputs = []
if (!isOneTrack) { if (!isOneTrack) {
var concatFilePath = Path.join(task.data.itemCachePath, 'files.txt') const concatFilePath = Path.join(task.data.itemCachePath, 'files.txt')
await writeConcatFile(audioTracks, concatFilePath) await writeConcatFile(audioTracks, concatFilePath)
ffmpegInputs.push({ ffmpegInputs.push({
input: concatFilePath, input: concatFilePath,
@@ -99,15 +107,15 @@ class AbMergeManager {
} }
const logLevel = process.env.NODE_ENV === 'production' ? 'error' : 'warning' const logLevel = process.env.NODE_ENV === 'production' ? 'error' : 'warning'
var ffmpegOptions = [`-loglevel ${logLevel}`] let ffmpegOptions = [`-loglevel ${logLevel}`]
var ffmpegOutputOptions = ['-f mp4'] const ffmpegOutputOptions = ['-f mp4']
if (audioRequiresEncode) { if (audioRequiresEncode) {
ffmpegOptions = ffmpegOptions.concat([ ffmpegOptions = ffmpegOptions.concat([
'-map 0:a', '-map 0:a',
'-acodec aac', `-acodec ${audioCodec}`,
'-ac 2', `-ac ${audioChannels}`,
'-b:a 64k' `-b:a ${audioBitrate}`
]) ])
} else { } else {
ffmpegOptions.push('-max_muxing_queue_size 1000') ffmpegOptions.push('-max_muxing_queue_size 1000')
@@ -119,7 +127,7 @@ class AbMergeManager {
} }
} }
var toneJsonPath = null let toneJsonPath = null
try { try {
toneJsonPath = Path.join(task.data.itemCachePath, 'metadata.json') toneJsonPath = Path.join(task.data.itemCachePath, 'metadata.json')
await toneHelpers.writeToneMetadataJsonFile(libraryItem, libraryItem.media.chapters, toneJsonPath, 1) await toneHelpers.writeToneMetadataJsonFile(libraryItem, libraryItem.media.chapters, toneJsonPath, 1)
@@ -133,16 +141,16 @@ class AbMergeManager {
'TrackNumber': 1, 'TrackNumber': 1,
} }
var workerData = { const workerData = {
inputs: ffmpegInputs, inputs: ffmpegInputs,
options: ffmpegOptions, options: ffmpegOptions,
outputOptions: ffmpegOutputOptions, outputOptions: ffmpegOutputOptions,
output: task.data.tempFilepath output: task.data.tempFilepath
} }
var worker = null let worker = null
try { try {
var workerPath = Path.join(global.appRoot, 'server/utils/downloadWorker.js') const workerPath = Path.join(global.appRoot, 'server/utils/downloadWorker.js')
worker = new workerThreads.Worker(workerPath, { workerData }) worker = new workerThreads.Worker(workerPath, { workerData })
} catch (error) { } catch (error) {
Logger.error(`[AbMergeManager] Start worker thread failed`, error) Logger.error(`[AbMergeManager] Start worker thread failed`, error)
-364
View File
@@ -1,364 +0,0 @@
const Path = require('path')
const fs = require('../libs/fsExtra')
const archiver = require('../libs/archiver')
const workerThreads = require('worker_threads')
const Logger = require('../Logger')
const Download = require('../objects/Download')
const filePerms = require('../utils/filePerms')
const { getId } = require('../utils/index')
const { writeConcatFile, writeMetadataFile } = require('../utils/ffmpegHelpers')
const { getFileSize } = require('../utils/fileUtils')
const TAG = 'DownloadManager'
class DownloadManager {
constructor(db) {
this.db = db
this.downloadDirPath = Path.join(global.MetadataPath, 'downloads')
this.pendingDownloads = []
this.downloads = []
}
getDownload(downloadId) {
return this.downloads.find(d => d.id === downloadId)
}
downloadSocketRequest(socket, payload) {
var client = socket.sheepClient
var audiobook = this.db.audiobooks.find(a => a.id === payload.audiobookId)
var options = {
...payload
}
delete options.audiobookId
this.prepareDownload(client, audiobook, options)
}
removeSocketRequest(socket, downloadId) {
var download = this.downloads.find(d => d.id === downloadId)
if (!download) {
Logger.error('Remove download request download not found ' + downloadId)
return
}
this.removeDownload(download)
}
async prepareDownload(client, audiobook, options = {}) {
var downloadId = getId('dl')
var dlpath = Path.join(this.downloadDirPath, downloadId)
Logger.info(`Start Download for ${audiobook.id} - DownloadId: ${downloadId} - ${dlpath}`)
await fs.ensureDir(dlpath)
var downloadType = options.type || 'singleAudio'
delete options.type
var fileext = null
var audiobookDirname = Path.basename(audiobook.path)
if (downloadType === 'singleAudio') {
var audioFileType = options.audioFileType || '.m4b'
delete options.audioFileType
if (audioFileType === 'same') {
var firstTrack = audiobook.tracks[0]
audioFileType = firstTrack.ext
}
fileext = audioFileType
} else if (downloadType === 'zip') {
fileext = '.zip'
}
var filename = audiobookDirname + fileext
var downloadData = {
id: downloadId,
audiobookId: audiobook.id,
type: downloadType,
options: options,
dirpath: dlpath,
fullPath: Path.join(dlpath, filename),
filename,
ext: fileext,
userId: (client && client.user) ? client.user.id : null,
socket: (client && client.socket) ? client.socket : null
}
var download = new Download()
download.setData(downloadData)
download.setTimeoutTimer(this.downloadTimedOut.bind(this))
if (downloadData.socket) {
downloadData.socket.emit('download_started', download.toJSON())
}
if (download.type === 'singleAudio') {
this.processSingleAudioDownload(audiobook, download)
} else if (download.type === 'zip') {
this.processZipDownload(audiobook, download)
}
}
async processZipDownload(audiobook, download) {
this.pendingDownloads.push({
id: download.id,
download
})
Logger.info(`[DownloadManager] Processing Zip download ${download.fullPath}`)
var success = await this.zipAudiobookDir(audiobook.fullPath, download.fullPath).then(() => {
return true
}).catch((error) => {
Logger.error('[DownloadManager] Process Zip Failed', error)
return false
})
this.sendResult(download, { success })
}
zipAudiobookDir(audiobookPath, downloadPath) {
return new Promise((resolve, reject) => {
// create a file to stream archive data to
const output = fs.createWriteStream(downloadPath)
const archive = archiver('zip', {
zlib: { level: 9 } // Sets the compression level.
})
// listen for all archive data to be written
// 'close' event is fired only when a file descriptor is involved
output.on('close', () => {
Logger.info(archive.pointer() + ' total bytes')
Logger.debug('archiver has been finalized and the output file descriptor has closed.')
resolve()
})
// This event is fired when the data source is drained no matter what was the data source.
// It is not part of this library but rather from the NodeJS Stream API.
// @see: https://nodejs.org/api/stream.html#stream_event_end
output.on('end', () => {
Logger.debug('Data has been drained')
})
// good practice to catch warnings (ie stat failures and other non-blocking errors)
archive.on('warning', function (err) {
if (err.code === 'ENOENT') {
// log warning
Logger.warn(`[DownloadManager] Archiver warning: ${err.message}`)
} else {
// throw error
Logger.error(`[DownloadManager] Archiver error: ${err.message}`)
// throw err
reject(err)
}
})
archive.on('error', function (err) {
Logger.error(`[DownloadManager] Archiver error: ${err.message}`)
reject(err)
})
// pipe archive data to the file
archive.pipe(output)
archive.directory(audiobookPath, false)
archive.finalize()
})
}
async processSingleAudioDownload(audiobook, download) {
// If changing audio file type then encoding is needed
var audioRequiresEncode = audiobook.tracks[0].ext !== download.ext
var shouldIncludeCover = download.includeCover && audiobook.book.cover
var firstTrackIsM4b = audiobook.tracks[0].ext.toLowerCase() === '.m4b'
var isOneTrack = audiobook.tracks.length === 1
const ffmpegInputs = []
if (!isOneTrack) {
var concatFilePath = Path.join(download.dirpath, 'files.txt')
await writeConcatFile(audiobook.tracks, concatFilePath)
ffmpegInputs.push({
input: concatFilePath,
options: ['-safe 0', '-f concat']
})
} else {
ffmpegInputs.push({
input: audiobook.tracks[0].fullPath,
options: firstTrackIsM4b ? ['-f mp4'] : []
})
}
const logLevel = process.env.NODE_ENV === 'production' ? 'error' : 'warning'
var ffmpegOptions = [`-loglevel ${logLevel}`]
var ffmpegOutputOptions = []
if (audioRequiresEncode) {
ffmpegOptions = ffmpegOptions.concat([
'-map 0:a',
'-acodec aac',
'-ac 2',
'-b:a 64k',
'-id3v2_version 3'
])
} else {
ffmpegOptions.push('-max_muxing_queue_size 1000')
if (isOneTrack && firstTrackIsM4b && !shouldIncludeCover) {
ffmpegOptions.push('-c copy')
} else {
ffmpegOptions.push('-c:a copy')
}
}
if (download.ext === '.m4b') {
Logger.info('Concat m4b\'s use -f mp4')
ffmpegOutputOptions.push('-f mp4')
}
if (download.includeMetadata) {
var metadataFilePath = Path.join(download.dirpath, 'metadata.txt')
await writeMetadataFile(audiobook, metadataFilePath)
ffmpegInputs.push({
input: metadataFilePath
})
ffmpegOptions.push('-map_metadata 1')
}
if (shouldIncludeCover) {
var _cover = audiobook.book.coverFullPath.replace(/\\/g, '/')
ffmpegInputs.push({
input: _cover,
options: ['-f image2pipe']
})
ffmpegOptions.push('-vf [2:v]crop=trunc(iw/2)*2:trunc(ih/2)*2')
ffmpegOptions.push('-map 2:v')
}
var workerData = {
inputs: ffmpegInputs,
options: ffmpegOptions,
outputOptions: ffmpegOutputOptions,
output: download.fullPath,
}
var worker = null
try {
var workerPath = Path.join(global.appRoot, 'server/utils/downloadWorker.js')
worker = new workerThreads.Worker(workerPath, { workerData })
} catch (error) {
Logger.error(`[${TAG}] Start worker thread failed`, error)
if (download.socket) {
var downloadJson = download.toJSON()
download.socket.emit('download_failed', downloadJson)
}
this.removeDownload(download)
return
}
worker.on('message', (message) => {
if (message != null && typeof message === 'object') {
if (message.type === 'RESULT') {
if (!download.isTimedOut) {
this.sendResult(download, message)
}
} else if (message.type === 'FFMPEG') {
if (Logger[message.level]) {
Logger[message.level](message.log)
}
}
} else {
Logger.error('Invalid worker message', message)
}
})
this.pendingDownloads.push({
id: download.id,
download,
worker
})
}
async downloadTimedOut(download) {
Logger.info(`[DownloadManager] Download ${download.id} timed out (${download.timeoutTimeMs}ms)`)
if (download.socket) {
var downloadJson = download.toJSON()
downloadJson.isTimedOut = true
download.socket.emit('download_failed', downloadJson)
}
this.removeDownload(download)
}
async downloadExpired(download) {
Logger.info(`[DownloadManager] Download ${download.id} expired`)
if (download.socket) {
download.socket.emit('download_expired', download.toJSON())
}
this.removeDownload(download)
}
async sendResult(download, result) {
download.clearTimeoutTimer()
// Remove pending download
this.pendingDownloads = this.pendingDownloads.filter(d => d.id !== download.id)
if (result.isKilled) {
if (download.socket) {
download.socket.emit('download_killed', download.toJSON())
}
return
}
if (!result.success) {
if (download.socket) {
download.socket.emit('download_failed', download.toJSON())
}
this.removeDownload(download)
return
}
// Set file permissions and ownership
await filePerms.setDefault(download.fullPath)
var filesize = await getFileSize(download.fullPath)
download.setComplete(filesize)
if (download.socket) {
download.socket.emit('download_ready', download.toJSON())
}
download.setExpirationTimer(this.downloadExpired.bind(this))
this.downloads.push(download)
Logger.info(`[DownloadManager] Download Ready ${download.id}`)
}
async removeDownload(download) {
Logger.info('[DownloadManager] Removing download ' + download.id)
download.clearTimeoutTimer()
download.clearExpirationTimer()
var pendingDl = this.pendingDownloads.find(d => d.id === download.id)
if (pendingDl) {
this.pendingDownloads = this.pendingDownloads.filter(d => d.id !== download.id)
Logger.warn(`[DownloadManager] Removing download in progress - stopping worker`)
if (pendingDl.worker) {
try {
pendingDl.worker.postMessage('STOP')
} catch (error) {
Logger.error('[DownloadManager] Error posting stop message to worker', error)
}
}
}
await fs.remove(download.dirpath).then(() => {
Logger.info('[DownloadManager] Deleted download', download.dirpath)
}).catch((err) => {
Logger.error('[DownloadManager] Failed to delete download', err)
})
this.downloads = this.downloads.filter(d => d.id !== download.id)
}
}
module.exports = DownloadManager
+1 -1
View File
@@ -197,7 +197,7 @@ module.exports.sanitizeFilename = (filename, colonReplacement = ' - ') => {
const windowsTrailingRe = /[\. ]+$/ const windowsTrailingRe = /[\. ]+$/
const lineBreaks = /[\n\r]/g const lineBreaks = /[\n\r]/g
sanitized = filename let sanitized = filename
.replace(':', colonReplacement) // Replace first occurrence of a colon .replace(':', colonReplacement) // Replace first occurrence of a colon
.replace(illegalRe, replacement) .replace(illegalRe, replacement)
.replace(controlRe, replacement) .replace(controlRe, replacement)