This commit is contained in:
yuuzhan 2023-06-09 22:15:39 +00:00 committed by GitHub
commit 6e592d5933
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 661 additions and 167 deletions

View file

@ -62,6 +62,24 @@
</widgets-alert>
</div>
<!-- Rename Folders -->
<div v-if="userIsAdminOrUp" class="w-full border border-black-200 p-4 my-8">
<div class="flex items-center">
<div>
<p class="text-lg">{{ $strings.LabelToolsRenamer }}</p>
<p class="max-w-sm text-sm pt-2 text-gray-300">{{ $strings.LabelToolsRenamerDescription }}</p>
</div>
<div class="flex-grow" />
<div>
<ui-btn :to="`/audiobook/${libraryItemId}/manage?tool=renamer`" class="flex items-center"
>{{ $strings.ButtonOpenManager }}
<span class="material-icons text-lg ml-2">launch</span>
</ui-btn>
</div>
</div>
</div>
<p v-if="!mediaTracks.length" class="text-lg text-center my-8">{{ $strings.MessageNoAudioTracks }}</p>
</div>
</template>
@ -82,6 +100,9 @@ export default {
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
libraryItemId() {
return this.libraryItem?.id || null
},

View file

@ -10,25 +10,207 @@
</div>
</div>
</div>
<div class="flex justify-center">
<div class="w-full max-w-2xl">
<p class="text-xl mb-1">{{ $strings.HeaderMetadataToEmbed }}</p>
<p class="mb-2 text-base text-gray-300">audiobookshelf uses <a href="https://github.com/sandreas/tone" target="_blank" class="hover:underline text-blue-400 hover:text-blue-300">tone</a> to write metadata.</p>
</div>
<div class="w-full max-w-2xl"></div>
</div>
<div class="flex justify-center flex-wrap">
<div class="w-full max-w-2xl border border-white border-opacity-10 bg-bg mx-2">
<div class="flex py-2 px-4">
<div class="w-1/3 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelMetaTag }}</div>
<div class="w-2/3 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelValue }}</div>
<div v-if="isM4BorEmbedTool">
<div class="flex justify-center">
<div class="w-full max-w-2xl">
<p class="text-xl mb-1">{{ $strings.HeaderMetadataToEmbed }}</p>
<p class="mb-2 text-base text-gray-300">audiobookshelf uses <a href="https://github.com/sandreas/tone" target="_blank" class="hover:underline text-blue-400 hover:text-blue-300">tone</a> to write metadata.</p>
</div>
<div class="w-full max-h-72 overflow-auto">
<template v-for="(value, key, index) in toneObject">
<div class="w-full max-w-2xl"></div>
</div>
<div class="flex justify-center flex-wrap">
<div class="w-full max-w-2xl border border-white border-opacity-10 bg-bg mx-2">
<div class="flex py-2 px-4">
<div class="w-1/3 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelMetaTag }}</div>
<div class="w-2/3 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelValue }}</div>
</div>
<div class="w-full max-h-72 overflow-auto">
<template v-for="(value, key, index) in toneObject">
<div :key="key" class="flex py-1 px-4 text-sm" :class="index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''">
<div class="w-1/3 font-semibold">{{ key }}</div>
<div class="w-2/3">
{{ value }}
</div>
</div>
</template>
</div>
</div>
<div class="w-full max-w-2xl border border-white border-opacity-10 bg-bg mx-2">
<div class="flex py-2 px-4 bg-primary bg-opacity-25">
<div class="flex-grow text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelChapterTitle }}</div>
<div class="w-24 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelStart }}</div>
<div class="w-24 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelEnd }}</div>
</div>
<div class="w-full max-h-72 overflow-auto">
<p v-if="!metadataChapters.length" class="py-5 text-center text-gray-200">{{ $strings.MessageNoChapters }}</p>
<template v-for="(chapter, index) in metadataChapters">
<div :key="index" class="flex py-1 px-4 text-sm" :class="index % 2 === 1 ? 'bg-primary bg-opacity-25' : ''">
<div class="flex-grow font-semibold">{{ chapter.title }}</div>
<div class="w-24">
{{ $secondsToTimestamp(chapter.start) }}
</div>
<div class="w-24">
{{ $secondsToTimestamp(chapter.end) }}
</div>
</div>
</template>
</div>
</div>
</div>
<div class="w-full h-px bg-white bg-opacity-10 my-8" />
<div class="w-full max-w-4xl mx-auto">
<!-- queued alert -->
<widgets-alert v-if="isMetadataEmbedQueued" type="warning" class="mb-4">
<p class="text-lg">Audiobook is queued for metadata embed ({{ queuedEmbedLIds.length }} in queue)</p>
</widgets-alert>
<!-- metadata embed action buttons -->
<div v-else-if="isEmbedTool" class="w-full flex justify-end items-center mb-4">
<ui-checkbox v-if="!isTaskFinished" v-model="shouldBackupAudioFiles" :disabled="processing" label="Backup audio files" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" @input="toggleBackupAudioFiles" />
<div class="flex-grow" />
<ui-btn v-if="!isTaskFinished" color="primary" :loading="processing" @click.stop="embedClick">{{ $strings.ButtonStartMetadataEmbed }}</ui-btn>
<p v-else class="text-success text-lg font-semibold">{{ $strings.MessageEmbedFinished }}</p>
</div>
<!-- m4b embed action buttons -->
<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" 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 class="text-success text-lg font-semibold">{{ $strings.MessageM4BFinished }}</p>
</div>
<!-- advanced encoding options -->
<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 v-if="isEmbedTool" class="flex items-start mb-2">
<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>
</div>
<div v-else class="flex items-start mb-2">
<span class="material-icons text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">
Finished M4B will be put into your audiobook folder at <span class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono">.../{{ libraryItemRelPath }}/</span>.
</p>
</div>
<div v-if="shouldBackupAudioFiles || isM4BTool" class="flex items-start mb-2">
<span class="material-icons text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">
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>
</div>
<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>
<p class="text-gray-200 ml-2">Chapters are not embedded in multi-track audiobooks.</p>
</div>
<div v-if="isM4BTool" class="flex items-start mb-2">
<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>
</div>
<div v-if="isM4BTool" class="flex items-start mb-2">
<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>
</div>
<div class="flex items-start mb-2">
<span class="material-icons text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">Once the task is started you can navigate away from this page.</p>
</div>
</div>
</div>
<div class="w-full max-w-4xl mx-auto">
<p class="mb-2 font-semibold">{{ $strings.HeaderAudioTracks }}</p>
<div class="w-full mx-auto border border-white border-opacity-10 bg-bg">
<div class="flex py-2 px-4 bg-primary bg-opacity-25">
<div class="w-10 text-xs font-semibold text-gray-200">#</div>
<div class="flex-grow text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelFilename }}</div>
<div class="w-16 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelSize }}</div>
<div class="w-24"></div>
</div>
<template v-for="file in audioFiles">
<div :key="file.index" class="flex py-2 px-4 text-sm" :class="file.index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''">
<div class="w-10">{{ file.index }}</div>
<div class="flex-grow">
{{ file.metadata.filename }}
</div>
<div class="w-16 font-mono text-gray-200">
{{ $bytesPretty(file.metadata.size) }}
</div>
<div class="w-24">
<div class="flex justify-center">
<span v-if="audiofilesFinished[file.ino]" class="material-icons text-xl text-success leading-none">check_circle</span>
<div v-else-if="audiofilesEncoding[file.ino]">
<widgets-loading-spinner />
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
<div v-else-if="isRenameTool">
<div class="flex justify-left">
<div class="w-full max-w-2xl">
<p class="text-xl mb-1">{{ $strings.HeaderRenameTool }}</p>
<div class="flex items-start mb-2">
<ui-text-input-with-label ref="userRenameFormat" v-model="renameFormat" :label="'Format for Rename'" class="m-2 max-w-100" />
</div>
<div class="flex items-start mb-2">
<ui-btn color="primary" :loading="processing" @click.stop="previewRenameClick">{{ $strings.ButtonPreviewRename }}</ui-btn>
</div>
<div class="flex items-start mb-2">
<span class="material-icons text-base text-warning pt-1">star</span>
{{ $strings.LabelCurrent }} {{ $strings.LabelPath }}: <span ref="currentRelPath" class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono">...{{ this.directorySeperatorChar }}{{ libraryItemRelPath }}{{ this.directorySeperatorChar }}</span>
</div>
<div class="flex items-start mb-2">
<span class="material-icons text-base text-warning pt-1">star</span>
{{ $strings.LabelNew }} {{ $strings.LabelPath }}: <span ref="newFolderRelPath" class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono">...{{ this.directorySeperatorChar }}{{ libraryItemRelPath }}{{ this.directorySeperatorChar }}</span>
</div>
<div class="flex items-start mb-2">
<ui-btn ref="executeRenameButton" :loading="processing" color="primary" @click.stop="renameClick">{{ $strings.ButtonExecuteRename }}</ui-btn>
</div>
<div class="flex items-start mb-2">
<span ref="renameStatus" class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono"></span>
</div>
</div>
<div class="w-full max-w-2xl">
<div class="flex py-2 px-4 bg-primary bg-opacity-25">
<div class="w-1/3 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelKey }}</div>
<div class="w-2/3 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelValue }}</div>
</div>
<div class="flex py-1 px-4 text-sm">
<div class="w-1/3 font-semibold">/</div>
<div class="w-2/3">
{{ $strings.LabelDirectorySeperator }}
</div>
</div>
<template v-for="(value, key, index) in sampleFormatObject">
<div :key="key" class="flex py-1 px-4 text-sm" :class="index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''">
<div class="w-1/3 font-semibold">{{ key }}</div>
<div class="w-1/3 font-semibold">${{ key.replace('lbr', '[').replace('rbr', ']') }}</div>
<div class="w-2/3">
{{ value }}
</div>
@ -36,139 +218,6 @@
</template>
</div>
</div>
<div class="w-full max-w-2xl border border-white border-opacity-10 bg-bg mx-2">
<div class="flex py-2 px-4 bg-primary bg-opacity-25">
<div class="flex-grow text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelChapterTitle }}</div>
<div class="w-24 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelStart }}</div>
<div class="w-24 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelEnd }}</div>
</div>
<div class="w-full max-h-72 overflow-auto">
<p v-if="!metadataChapters.length" class="py-5 text-center text-gray-200">{{ $strings.MessageNoChapters }}</p>
<template v-for="(chapter, index) in metadataChapters">
<div :key="index" class="flex py-1 px-4 text-sm" :class="index % 2 === 1 ? 'bg-primary bg-opacity-25' : ''">
<div class="flex-grow font-semibold">{{ chapter.title }}</div>
<div class="w-24">
{{ $secondsToTimestamp(chapter.start) }}
</div>
<div class="w-24">
{{ $secondsToTimestamp(chapter.end) }}
</div>
</div>
</template>
</div>
</div>
</div>
<div class="w-full h-px bg-white bg-opacity-10 my-8" />
<div class="w-full max-w-4xl mx-auto">
<!-- queued alert -->
<widgets-alert v-if="isMetadataEmbedQueued" type="warning" class="mb-4">
<p class="text-lg">Audiobook is queued for metadata embed ({{ queuedEmbedLIds.length }} in queue)</p>
</widgets-alert>
<!-- metadata embed action buttons -->
<div v-else-if="isEmbedTool" class="w-full flex justify-end items-center mb-4">
<ui-checkbox v-if="!isTaskFinished" v-model="shouldBackupAudioFiles" :disabled="processing" label="Backup audio files" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" @input="toggleBackupAudioFiles" />
<div class="flex-grow" />
<ui-btn v-if="!isTaskFinished" color="primary" :loading="processing" @click.stop="embedClick">{{ $strings.ButtonStartMetadataEmbed }}</ui-btn>
<p v-else class="text-success text-lg font-semibold">{{ $strings.MessageEmbedFinished }}</p>
</div>
<!-- m4b embed action buttons -->
<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" 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 class="text-success text-lg font-semibold">{{ $strings.MessageM4BFinished }}</p>
</div>
<!-- advanced encoding options -->
<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 v-if="isEmbedTool" class="flex items-start mb-2">
<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>
</div>
<div v-else class="flex items-start mb-2">
<span class="material-icons text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">
Finished M4B will be put into your audiobook folder at <span class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono">.../{{ libraryItemRelPath }}/</span>.
</p>
</div>
<div v-if="shouldBackupAudioFiles || isM4BTool" class="flex items-start mb-2">
<span class="material-icons text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">
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>
</div>
<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>
<p class="text-gray-200 ml-2">Chapters are not embedded in multi-track audiobooks.</p>
</div>
<div v-if="isM4BTool" class="flex items-start mb-2">
<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>
</div>
<div v-if="isM4BTool" class="flex items-start mb-2">
<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>
</div>
<div class="flex items-start mb-2">
<span class="material-icons text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">Once the task is started you can navigate away from this page.</p>
</div>
</div>
</div>
<div class="w-full max-w-4xl mx-auto">
<p class="mb-2 font-semibold">{{ $strings.HeaderAudioTracks }}</p>
<div class="w-full mx-auto border border-white border-opacity-10 bg-bg">
<div class="flex py-2 px-4 bg-primary bg-opacity-25">
<div class="w-10 text-xs font-semibold text-gray-200">#</div>
<div class="flex-grow text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelFilename }}</div>
<div class="w-16 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelSize }}</div>
<div class="w-24"></div>
</div>
<template v-for="file in audioFiles">
<div :key="file.index" class="flex py-2 px-4 text-sm" :class="file.index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''">
<div class="w-10">{{ file.index }}</div>
<div class="flex-grow">
{{ file.metadata.filename }}
</div>
<div class="w-16 font-mono text-gray-200">
{{ $bytesPretty(file.metadata.size) }}
</div>
<div class="w-24">
<div class="flex justify-center">
<span v-if="audiofilesFinished[file.ino]" class="material-icons text-xl text-success leading-none">check_circle</span>
<div v-else-if="audiofilesEncoding[file.ino]">
<widgets-loading-spinner />
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
@ -217,7 +266,10 @@ export default {
bitrate: '64k',
channels: '2',
codec: 'aac'
}
},
renameFormat: '',
sampleFormatObject: null,
directorySeperatorChar: '/'
}
},
watch: {
@ -236,6 +288,12 @@ export default {
isM4BTool() {
return this.selectedTool === 'm4b'
},
isM4BorEmbedTool() {
return this.isM4BTool || this.isEmbedTool
},
isRenameTool() {
return this.selectedTool === 'renamer'
},
libraryItemId() {
return this.libraryItem.id
},
@ -257,18 +315,22 @@ export default {
streamLibraryItem() {
return this.$store.state.streamLibraryItem
},
defaultRenameString() {
return this.$store.state.defaultRenameString
},
metadataChapters() {
return this.media.chapters || []
},
availableTools() {
var tools = [
{ value: 'embed', text: 'Embed Metadata' },
{ value: 'renamer', text: 'Rename Folder' },
{ value: 'm4b', text: 'M4B Encoder' }
]
if (this.isSingleM4b) {
return [{ value: 'embed', text: 'Embed Metadata' }]
} else {
return [
{ value: 'embed', text: 'Embed Metadata' },
{ value: 'm4b', text: 'M4B Encoder' }
]
tools.pop()
}
return tools
},
taskFailed() {
return this.isTaskFinished && this.task.isFailed
@ -361,6 +423,40 @@ export default {
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
async previewRenameClick() {
this.processing = true
const renameObject = await this.$axios
.$get(`/api/tools/item/${this.libraryItemId}/renameFolder?strFormat=${this.renameFormat}${this.sampleFormatObject == null ? '&includeSampleTemplate=1' : ''}`)
.then((renameObject) => {
return renameObject
})
.catch((error) => {
console.error('Failed to Rename', error)
})
this.directorySeperatorChar = renameObject.directorySeperatorChar
this.$refs.newFolderRelPath.setHTML('...' + this.directorySeperatorChar + renameObject.newRelativeDirectory + this.directorySeperatorChar)
if (renameObject.templateSample) {
this.sampleFormatObject = renameObject.templateSample
}
this.processing = false
},
async renameClick() {
const renameObject = await this.$axios
.$post(`/api/tools/item/${this.libraryItemId}/renameFolder?strFormat=${this.renameFormat}`)
.then((renameObject) => {
return renameObject
})
.catch((error) => {
console.error('Failed to Rename', error)
})
if (renameObject.success) {
this.$refs.renameStatus.setHTML('Folder for Book updated to ...' + this.directorySeperatorChar + renameObject.details.newRelativeDirectory + this.directorySeperatorChar + ' from ' + renameObject.details.oldRelativeDirectory)
this.$refs.currentRelPath.setHTML('...' + this.directorySeperatorChar + renameObject.details.newRelativeDirectory + this.directorySeperatorChar)
} else {
this.$refs.renameStatus.setHTML('Failed to Rename: ' + JSON.stringify(renameObject.details))
}
this.processing = false
},
updateAudioFileMetadata() {
this.processing = true
this.$axios
@ -385,6 +481,9 @@ export default {
selectedToolUpdated() {
let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + `?tool=${this.selectedTool}`
window.history.replaceState({ path: newurl }, '', newurl)
if (this.selectedTool == 'renamer') {
this.previewRenameClick()
}
},
init() {
this.fetchToneObject()
@ -394,12 +493,20 @@ export default {
} else {
this.selectedToolUpdated()
}
} else if (this.$route.query.tool === 'renamer') {
if (this.availableTools.some((t) => t.value === 'renamer')) {
this.previewRenameClick()
this.selectedTool = 'renamer'
} else {
this.selectedToolUpdated()
}
}
if (this.task) this.taskUpdated(this.task)
const shouldBackupAudioFiles = localStorage.getItem('embedMetadataShouldBackup')
this.shouldBackupAudioFiles = shouldBackupAudioFiles != 0
const defaultRenameString = localStorage.getItem('defaultRenameString')
this.renameFormat = defaultRenameString ? defaultRenameString : '$authorName/$bookName'
},
fetchToneObject() {
this.$axios

View file

@ -192,6 +192,16 @@
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-text-input-with-label ref="defaultRenameString" v-model="newServerSettings.defaultRenameString" :disabled="updatingServerSettings" :label="$strings.LabelSettingsRenameString" @blur="() => updateDefaultRenameString()" class="m-2 max-w-100" />
<ui-tooltip :text="$strings.LabelSettingsRenameStringHelp">
<p class="pl-4">
<span id="settings-sorting-ignore-prefixes"></span>
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<!-- <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerUseTone" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerUseTone', val)" />
<ui-tooltip text="Tone library for metadata">
@ -311,6 +321,14 @@ export default {
this.$store.commit('setExperimentalFeatures', val)
}
},
defaultRenameString: {
get() {
return this.$store.state.defaultRenameString
},
set(val) {
this.$store.commit('defaultRenameString', val)
}
},
dateFormats() {
return this.$store.state.globals.dateFormats
},
@ -355,6 +373,10 @@ export default {
updateServerLanguage(val) {
this.updateSettingsKey('language', val)
},
updateDefaultRenameString(){
this.updateSettingsKey('defaultRenameString', this.newServerSettings.defaultRenameString)
this.$store.commit('setDefaultRenameString', this.newServerSettings.defaultRenameString)
},
updateMetadataFileFormat(val) {
if (this.serverSettings.metadataFileFormat === val) return
this.updateSettingsKey('metadataFileFormat', val)

View file

@ -22,6 +22,7 @@ export const state = () => ({
processingBatch: false,
previousPath: '/',
showExperimentalFeatures: false,
defaultRenameString: '',
bookshelfBookIds: [],
episodeTableEpisodeIds: [],
openModal: null,
@ -227,6 +228,10 @@ export const mutations = {
setProcessingBatch(state, val) {
state.processingBatch = val
},
setDefaultRenameString(state, val) {
state.defaultRenameString = val
localStorage.setItem('defaultRenameString', val)
},
setExperimentalFeatures(state, val) {
state.showExperimentalFeatures = val
localStorage.setItem('experimental', val ? 1 : 0)

View file

@ -24,6 +24,7 @@
"ButtonEdit": "Edit",
"ButtonEditChapters": "Edit Chapters",
"ButtonEditPodcast": "Edit Podcast",
"ButtonExecuteRename": "Execute Rename",
"ButtonForceReScan": "Force Re-Scan",
"ButtonFullPath": "Full Path",
"ButtonHide": "Hide",
@ -44,6 +45,7 @@
"ButtonPlay": "Play",
"ButtonPlaying": "Playing",
"ButtonPlaylists": "Playlists",
"ButtonPreviewRename": "Preview Rename",
"ButtonPurgeAllCache": "Purge All Cache",
"ButtonPurgeItemsCache": "Purge Items Cache",
"ButtonPurgeMediaProgress": "Purge Media Progress",
@ -134,6 +136,7 @@
"HeaderPreviewCover": "Preview Cover",
"HeaderRemoveEpisode": "Remove Episode",
"HeaderRemoveEpisodes": "Remove {0} Episodes",
"HeaderRenameTool": "Folder Renaming Tool",
"HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "RSS Feed is Open",
"HeaderSavedMediaProgress": "Saved Media Progress",
@ -217,6 +220,7 @@
"LabelDevice": "Device",
"LabelDeviceInfo": "Device Info",
"LabelDirectory": "Directory",
"LabelDirectorySeperator": "Directory Seperator",
"LabelDiscFromFilename": "Disc from Filename",
"LabelDiscFromMetadata": "Disc from Metadata",
"LabelDownload": "Download",
@ -268,6 +272,7 @@
"LabelInvalidParts": "Invalid Parts",
"LabelInvert": "Invert",
"LabelItem": "Item",
"LabelKey": "Key",
"LabelLanguage": "Language",
"LabelLanguageDefaultServer": "Default Server Language",
"LabelLastBookAdded": "Last Book Added",
@ -390,6 +395,8 @@
"LabelSettingsPreferMatchedMetadataHelp": "Matched data will overide item details when using Quick Match. By default Quick Match will only fill in missing details.",
"LabelSettingsPreferOPFMetadata": "Prefer OPF metadata",
"LabelSettingsPreferOPFMetadataHelp": "OPF file metadata will be used for book details over folder names",
"LabelSettingsRenameString": "Default Rename Format",
"LabelSettingsRenameStringHelp": "Default format applied when manually renaming book folders",
"LabelSettingsSkipMatchingBooksWithASIN": "Skip matching books that already have an ASIN",
"LabelSettingsSkipMatchingBooksWithISBN": "Skip matching books that already have an ISBN",
"LabelSettingsSortingIgnorePrefixes": "Ignore prefixes when sorting",
@ -440,6 +447,8 @@
"LabelToolsEmbedMetadataDescription": "Embed metadata into audio files including cover image and chapters.",
"LabelToolsMakeM4b": "Make M4B Audiobook File",
"LabelToolsMakeM4bDescription": "Generate a .M4B audiobook file with embedded metadata, cover image, and chapters.",
"LabelToolsRenamer": "Rename Item Folder",
"LabelToolsRenamerDescription": "Rename / move item folder based on a customizable pattern and the item's metadata",
"LabelToolsSplitM4b": "Split M4B to MP3's",
"LabelToolsSplitM4bDescription": "Create MP3's from an M4B split by chapters with embedded metadata, cover image, and chapters.",
"LabelTotalDuration": "Total Duration",

View file

@ -34,6 +34,7 @@ const BackupManager = require('./managers/BackupManager')
const PlaybackSessionManager = require('./managers/PlaybackSessionManager')
const PodcastManager = require('./managers/PodcastManager')
const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
const LibraryItemFolderManager = require('./managers/LibraryItemFolderManager')
const RssFeedManager = require('./managers/RssFeedManager')
const CronManager = require('./managers/CronManager')
const TaskManager = require('./managers/TaskManager')
@ -76,6 +77,7 @@ class Server {
this.coverManager = new CoverManager(this.db, this.cacheManager)
this.podcastManager = new PodcastManager(this.db, this.watcher, this.notificationManager, this.taskManager)
this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.taskManager)
this.folderRenameManager = new LibraryItemFolderManager(this.db, this.watcher)
this.rssFeedManager = new RssFeedManager(this.db)
this.scanner = new Scanner(this.db, this.coverManager, this.taskManager)

View file

@ -118,5 +118,41 @@ class ToolsController {
next()
}
// POST: api/tools/item/:id/renameFolder
async renameBookFolder(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error('[MiscController] renameBookFolder: Non-admin user attempting to make m4b', req.user)
return res.sendStatus(403)
}
if (req.libraryItem.isMissing || req.libraryItem.isInvalid) {
Logger.error(`[MiscController] renameBookFolder: library item not found or invalid ${req.params.id}`)
return res.status(404).send('Audiobook not found')
}
if (req.libraryItem.mediaType !== 'book') {
Logger.error(`[MiscController] renameBookFolder: Invalid library item ${req.params.id}: not a book`)
return res.status(500).send('Invalid library item: not a book')
}
const options = req.query || {}
var renameStatus = await this.folderRenameManager.startRenameBookFolder(req.libraryItem, req.query.strFormat || "")
//res.sendStatus(200)
res.json(renameStatus)
}
testRenameBookFolder(req, res) {
if (req.libraryItem.isMissing || req.libraryItem.isInvalid) {
Logger.error(`[MiscController] renameBookFolder: library item not found or invalid ${req.params.id}`)
return res.status(404).send('Audiobook not found')
}
if (req.libraryItem.mediaType !== 'book') {
Logger.error(`[MiscController] renameBookFolder: Invalid library item ${req.params.id}: not a book`)
return res.status(500).send('Invalid library item: not a book')
}
var result = this.folderRenameManager.parseRenameFormat(req.libraryItem, req.query.strFormat || "", req.query.includeSampleTemplate || "")
res.json(result)
}
}
module.exports = new ToolsController()

View file

@ -0,0 +1,266 @@
const fs = require('../libs/fsExtra')
const Logger = require('../Logger')
const LibrarySettings = require('../objects/settings/LibrarySettings')
const fileUtils = require('../utils/fileUtils')
const { getLibraryItemFileData } = require('../utils/scandir')
const LoggerTag = `[LibraryItemFolderManager]`
class LibraryItemFolderManager {
constructor(db, watcher) {
this.db = db
this.watcher = watcher
this.directorySeperatorChar = '/'
}
parseRenameFormat(libraryItem, format, includeTemplateSample) {
const library = this.db.libraries.find(lib => lib.id === libraryItem.libraryId)
const folder = library.folders.find(f => f.id === libraryItem.folderId)
const basePath = fileUtils.filePathToPOSIX(folder.fullPath + this.directorySeperatorChar)
if (format == "") {
format = global.ServerSettings.defaultRenameString
}
const newRelativeDirectoryRaw = fileUtils.filePathToPOSIX(this.parseTemplateString(format, libraryItem))
const directorySplit = newRelativeDirectoryRaw.split(this.directorySeperatorChar)
var newRelativeDirectory = ""
for (const index in directorySplit) {
if (directorySplit[index]) { //make sure they dont put a bunch of //// in a row
const newProposedName = fileUtils.sanitizeFilename(directorySplit[index])
if (newProposedName) { //make sure there are at least some valid characters
if (newRelativeDirectory) newRelativeDirectory += this.directorySeperatorChar //add a slash if it is not the first element
newRelativeDirectory += newProposedName
}
}
}
var santizedLogString = ""
if (newRelativeDirectory != newRelativeDirectoryRaw) {
santizedLogString = ` and then sanitized to ${newRelativeDirectory}`
}
Logger.info(`${LoggerTag} ${format} processed to ${newRelativeDirectoryRaw}${santizedLogString}`)
const newDirectory = basePath + newRelativeDirectory
const oldRelativeDirectory = libraryItem.relPath
const oldDirectory = libraryItem.path
var templateSample = null
if (includeTemplateSample) {
templateSample = {
bookAuthor: fileUtils.sanitizeFilename(this.parseTemplateString("\$bookAuthor", libraryItem)),
bookTitle: fileUtils.sanitizeFilename(this.parseTemplateString("\$bookTitle", libraryItem)),
explicit: fileUtils.sanitizeFilename(this.parseTemplateString("\$explicit", libraryItem)),
subtitle: fileUtils.sanitizeFilename(this.parseTemplateString("\$subtitle", libraryItem)),
narrator: fileUtils.sanitizeFilename(this.parseTemplateString("\$narrator", libraryItem)),
genres: fileUtils.sanitizeFilename(this.parseTemplateString("\$genres", libraryItem)),
publishedYear: fileUtils.sanitizeFilename(this.parseTemplateString("\$publishedYear", libraryItem)),
publishedDate: fileUtils.sanitizeFilename(this.parseTemplateString("\$publishedDate", libraryItem)),
publisher: fileUtils.sanitizeFilename(this.parseTemplateString("\$publisher", libraryItem)),
isbn: fileUtils.sanitizeFilename(this.parseTemplateString("\$isbn", libraryItem)),
asin: fileUtils.sanitizeFilename(this.parseTemplateString("\$asin", libraryItem)),
language: fileUtils.sanitizeFilename(this.parseTemplateString("\$language", libraryItem)),
seriesSummary: fileUtils.sanitizeFilename(this.parseTemplateString("\$seriesSummary", libraryItem)),
seriesName: fileUtils.sanitizeFilename(this.parseTemplateString("\$seriesName", libraryItem)),
seriesNamelbr0rbr: fileUtils.sanitizeFilename(this.parseTemplateString("\$seriesName[0]", libraryItem)),
seriesSequence: fileUtils.sanitizeFilename(this.parseTemplateString("\$seriesSequence", libraryItem)),
seriesSequencelbr0rbr: fileUtils.sanitizeFilename(this.parseTemplateString("\$seriesSequence[0]", libraryItem))
}
}
return {
basePath: basePath,
newRelativeDirectory: newRelativeDirectory,
newDirectory: newDirectory,
oldRelativeDirectory: oldRelativeDirectory,
oldDirectory: oldDirectory,
formatUsed: format,
directorySeperatorChar: this.directorySeperatorChar,
templateSample: templateSample
}
}
async startRenameBookFolder(libraryItem, format) {
Logger.info(`${LoggerTag} Start rename for ${libraryItem.id}`)
const newPaths = this.parseRenameFormat(libraryItem, format)
const basePath = newPaths.basePath
const newRelativeDirectory = newPaths.newRelativeDirectory
const newDirectory = newPaths.newDirectory
const oldRelativeDirectory = newPaths.oldRelativeDirectory
const oldDirectory = newPaths.oldDirectory
if (newDirectory === oldDirectory) {
Logger.info(`${LoggerTag} New folder (${newDirectory}) equal to current folder(${oldDirectory}), aborting rename`)
return {
msg: "Folder not Renamed",
success: false,
details: `New folder (${newDirectory}) equal to current folder(${oldDirectory}), aborting rename`
}
}
//TODO - Check if target directory is a sub-directory of another book, fail if so.
Logger.info(`${LoggerTag} Renaming ${oldDirectory} to ${newDirectory}`)
this.watcher.addIgnoreDir(oldDirectory)
this.watcher.addIgnoreDir(newDirectory)
if (!await fs.pathExists(newDirectory)) {
const li = this.db.libraryItems.find(li => newDirectory.startsWith(li.path))
if (li) {
return {
msg: "Folder is sub path of another book",
success: false,
details: `The path you are trying to move to ${newDirectory} is a sub-directory of another book - ${li.media.metadata.title}(${li.id}) stored in ${li.path}`,
}
}
var moveStatus = await fs.move(oldDirectory, newDirectory, { overwrite: false }).catch((err) => {
return err
})
if (moveStatus != null) {
Logger.error(`${LoggerTag} Failed folder rename ${moveStatus}`)
return {
msg: "Failed to move folder",
success: false,
details: `${moveStatus}`
}
} else {
//move Success, check subfolders if empty and then remove.
var currentCheckingRelDirectory = oldRelativeDirectory
if (currentCheckingRelDirectory.endsWith(this.directorySeperatorChar)) currentCheckingRelDirectory = currentCheckingRelDirectory.substring(0, currentCheckingRelDirectory.length - 1)
const dirParts = currentCheckingRelDirectory.split(this.directorySeperatorChar)
for (var i = dirParts.length - 1; i >= 0; i--) {
currentCheckingRelDirectory = currentCheckingRelDirectory.substring(0, currentCheckingRelDirectory.length - dirParts[i].length - 1)
var currentCheckingDirectory = basePath + currentCheckingRelDirectory
if (currentCheckingRelDirectory) {
var fileItems = await fileUtils.recurseFiles(currentCheckingDirectory) //this will find files nested in subdirectories as well.
if (fileItems && fileItems.length == 0) {
Logger.debug(`${LoggerTag} no files in sub folders, safe to delete '${currentCheckingRelDirectory}'`)
fileUtils.removeFile(currentCheckingDirectory)
} else {
break; //if we do find a file, then no need to check parent dirs they will all have files in subfolders.
}
}
}
}
}
else {
Logger.error(`${LoggerTag} target folder ${newDirectory} already exists. Cancelling move`)
return { msg: "failed to move folder", success: false, details: `target folder ${newDirectory} already exists. Cancelling move` }
}
Logger.debug(`${LoggerTag} dynamically updating all path variables in Library Item from ${oldRelativeDirectory} to ${newRelativeDirectory}`)
this.recursiveUpdatePath(libraryItem, libraryItem.toJSON(), oldRelativeDirectory, newRelativeDirectory, basePath)
await this.db.updateLibraryItem(libraryItem)
const libItem = this.db.getLibraryItem(libraryItem.id)
//you have to put the removal of the ignore on a delay for long enough that the Watcher process
//will have checkd on the directory, recognized it should be ignored, before removing the ignore process
setTimeout(() => {
this.watcher.removeIgnoreDir(oldDirectory)
this.watcher.removeIgnoreDir(newDirectory)
}, this.watcher.pendingDelay)
Logger.info(`${LoggerTag} rename successful.`)
return {
msg: "Rename Success",
success: true,
details: newPaths
//, testObj: testObj
}
}
recursiveUpdatePath(item, json, oldRelPath, newRelPath, basePath) {
for (const key in json) {
if (key.toLowerCase().includes("path")) {
if (item[key] == basePath + oldRelPath) {
item[key] = basePath + newRelPath
}
else if (item[key] == oldRelPath) {
item[key] = newRelPath
}
else if (item[key].startsWith(oldRelPath + this.directorySeperatorChar)) {
item[key] = item[key].replace(oldRelPath + this.directorySeperatorChar, newRelPath + this.directorySeperatorChar)
}
else if (item[key].startsWith(basePath + oldRelPath + this.directorySeperatorChar)) {
item[key] = item[key].replace(basePath + oldRelPath + this.directorySeperatorChar, basePath + newRelPath + this.directorySeperatorChar)
}
}
if (typeof json[key] === "object") {
this.recursiveUpdatePath(item[key], json[key], oldRelPath, newRelPath, basePath)
}
}
}
sanitizeText(str) {
if (str) {
return fileUtils.sanitizeFilename(str)
}
else {
return ""
}
}
parseTemplateString(userTemplate, libraryItem) {
var template = userTemplate ? userTemplate : global.ServerSettings.defaultRenameString
var bookTitle = this.sanitizeText(libraryItem.media.metadata.title)
var bookAuthor = this.sanitizeText(libraryItem.media.metadata.authorName)
var subtitle = this.sanitizeText(libraryItem.media.metadata.subtitle)
//series
var narrator = this.sanitizeText(libraryItem.media.metadata.narratorName)
var genres = this.sanitizeText(libraryItem.media.metadata.genresDisplay)
var publishedYear = this.sanitizeText(libraryItem.media.metadata.publishedYear)
var publishedDate = this.sanitizeText(libraryItem.media.metadata.publishedDate)
var publisher = this.sanitizeText(libraryItem.media.metadata.publisher)
var isbn = this.sanitizeText(libraryItem.media.metadata.isbn)
var asin = this.sanitizeText(libraryItem.media.metadata.asin)
var language = this.sanitizeText(libraryItem.media.metadata.language)
var explicit = this.sanitizeText(libraryItem.media.metadata.explicitDisplay)
var seriesSummary = this.sanitizeText(libraryItem.media.metadata.seriesName)
var output = template
while (output.includes("\$bookTitle")) output = output.replace("\$bookTitle", bookTitle)
while (output.includes("\$bookAuthor")) output = output.replace("\$bookAuthor", bookAuthor)
while (output.includes("\$subtitle")) output = output.replace("\$subtitle", subtitle)
while (output.includes("\$narrator")) output = output.replace("\$narrator", narrator)
while (output.includes("\$genres")) output = output.replace("\$genres", genres)
while (output.includes("\$publishedYear")) output = output.replace("\$publishedYear", publishedYear)
while (output.includes("\$publishedDate")) output = output.replace("\$publishedDate", publishedDate)
while (output.includes("\$publisher")) output = output.replace("\$publisher", publisher)
while (output.includes("\$isbn")) output = output.replace("\$isbn", isbn)
while (output.includes("\$asin")) output = output.replace("\$asin", asin)
while (output.includes("\$language")) output = output.replace("\$language", language)
while (output.includes("\$explicit")) output = output.replace("\$explicit", explicit)
while (output.includes("\$seriesSummary")) output = output.replace("\$seriesSummary", seriesSummary)
var seriesNameRegex = /\$seriesName\[\d+\]/
while (output.match(seriesNameRegex) != null) {
var subString = output.match(seriesNameRegex)[0]
if (subString != null) {
var digitStr = subString.replace("\$seriesName[", "").replace("]", "")
var digit = parseInt(digitStr)
var replacementText = this.sanitizeText(libraryItem.media.metadata.getSeriesNameDisplay(digit))
output = output.replace(subString, replacementText)
}
}
while (output.includes("\$seriesName")) output = output.replace("\$seriesName", this.sanitizeText(libraryItem.media.metadata.getSeriesNameDisplay(0)))
var seriesSeqRegex = /\$seriesSequence\[\d+\]/
while (output.match(seriesSeqRegex) != null) {
var subString = output.match(seriesSeqRegex)[0]
if (subString != null) {
var digitStr = subString.replace("\$seriesSequence[", "").replace("]", "")
var digit = parseInt(digitStr)
var replacementText = this.sanitizeText(libraryItem.media.metadata.getSeriesSequenceDisplay(digit))
output = output.replace(subString, replacementText)
}
}
while (output.includes("\$seriesSequence")) output = output.replace("\$seriesSequence", this.sanitizeText(libraryItem.media.metadata.getSeriesSequenceDisplay(0)))
return output
}
}
module.exports = LibraryItemFolderManager

View file

@ -410,13 +410,10 @@ class LibraryItem {
this.isInvalid = !this.media.hasMediaEntities
// If cover path is in item folder, make sure libraryFile exists for it
if (this.media.coverPath && this.media.coverPath.startsWith(this.path)) {
const lf = this.libraryFiles.find(lf => lf.metadata.path === this.media.coverPath)
if (!lf) {
Logger.warn(`[LibraryItem] Invalid cover path - library file dne "${this.media.coverPath}"`)
this.media.updateCover('')
hasUpdated = true
}
if (this.media.coverPath && fs.pathExistsSync(this.media.coverPath) == false) {
Logger.warn(`[LibraryItem] Invalid cover path - library file dne "${this.media.coverPath}"`)
this.media.updateCover('')
hasUpdated = true
}
if (hasUpdated) {

View file

@ -173,7 +173,13 @@ class BookMetadata {
if (!this.authorName) return this.title
return this.title + '&' + this.authorName
}
get genresDisplay() {
if (!this.genres.length) return ''
return this.genres.join(', ')
}
get explicitDisplay() {
return this.explicit ? 'Explicit' : 'Not Explicit'
}
hasAuthor(id) {
return !!this.authors.find(au => au.id == id)
}
@ -199,7 +205,24 @@ class BookMetadata {
if (!series.sequence) return series.name
return `${series.name} #${series.sequence}`
}
getSeriesNameDisplay(index) {
if (this.validateSeries(index)) {
return this.series[index].name
} else { return null }
}
getSeriesSequenceDisplay(index) {
if (this.validateSeries(index)) {
return this.series[index].sequence
} else { return null }
}
validateSeries(index) {
if (this.series.length && index < this.series.length && index >= 0) {
return true
}
else {
return false
}
}
update(payload) {
const json = this.toJSON()
let hasUpdates = false

View file

@ -20,6 +20,7 @@ class ServerSettings {
// Metadata - choose to store inside users library item folder
this.storeCoverWithItem = false
this.storeMetadataWithItem = false
this.defaultRenameString = "$bookAuthor/$bookTitle"
this.metadataFileFormat = 'json'
// Security/Rate limits
@ -77,6 +78,7 @@ class ServerSettings {
this.storeCoverWithItem = !!settings.storeCoverWithItem
this.storeMetadataWithItem = !!settings.storeMetadataWithItem
this.defaultRenameString = settings.defaultRenameString || "$bookAuthor/$bookTitle"
this.metadataFileFormat = settings.metadataFileFormat || 'json'
this.rateLimitLoginRequests = !isNaN(settings.rateLimitLoginRequests) ? Number(settings.rateLimitLoginRequests) : 10
@ -144,6 +146,7 @@ class ServerSettings {
scannerUseTone: this.scannerUseTone,
storeCoverWithItem: this.storeCoverWithItem,
storeMetadataWithItem: this.storeMetadataWithItem,
defaultRenameString: this.defaultRenameString,
metadataFileFormat: this.metadataFileFormat,
rateLimitLoginRequests: this.rateLimitLoginRequests,
rateLimitLoginWindow: this.rateLimitLoginWindow,

View file

@ -48,6 +48,7 @@ class ApiRouter {
this.cacheManager = Server.cacheManager
this.podcastManager = Server.podcastManager
this.audioMetadataManager = Server.audioMetadataManager
this.folderRenameManager = Server.folderRenameManager
this.rssFeedManager = Server.rssFeedManager
this.cronManager = Server.cronManager
this.notificationManager = Server.notificationManager
@ -293,6 +294,8 @@ class ApiRouter {
this.router.delete('/tools/item/:id/encode-m4b', ToolsController.middleware.bind(this), ToolsController.cancelM4bEncode.bind(this))
this.router.post('/tools/item/:id/embed-metadata', ToolsController.middleware.bind(this), ToolsController.embedAudioFileMetadata.bind(this))
this.router.post('/tools/batch/embed-metadata', ToolsController.middleware.bind(this), ToolsController.batchEmbedMetadata.bind(this))
this.router.post('/tools/item/:id/renameFolder', ToolsController.middleware.bind(this), ToolsController.renameBookFolder.bind(this))
this.router.get('/tools/item/:id/renameFolder', ToolsController.middleware.bind(this), ToolsController.testRenameBookFolder.bind(this))
//
// RSS Feed Routes (Admin and up)