mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-16 14:21:41 +00:00
Added functionality to rename book folders.
This commit is contained in:
parent
f9e6655359
commit
8ccaec3d9f
12 changed files with 652 additions and 148 deletions
|
|
@ -7,6 +7,7 @@
|
|||
"fish": "latest"
|
||||
},
|
||||
"extensions": [
|
||||
"eamodio.gitlens"
|
||||
"eamodio.gitlens",
|
||||
"octref.vetur"
|
||||
]
|
||||
}
|
||||
|
|
@ -50,6 +50,24 @@
|
|||
</div>
|
||||
</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>
|
||||
|
|
@ -70,6 +88,9 @@ export default {
|
|||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem ? this.libraryItem.id : null
|
||||
},
|
||||
|
|
|
|||
|
|
@ -11,159 +11,210 @@
|
|||
</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 :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">
|
||||
<div v-if="isEmbedTool" class="w-full flex justify-end items-center mb-4">
|
||||
<ui-checkbox v-if="!isFinished" v-model="shouldBackupAudioFiles" 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="!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>
|
||||
</div>
|
||||
<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 class="w-full max-w-2xl"></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 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>
|
||||
</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 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>
|
||||
|
||||
<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 class="w-full h-px bg-white bg-opacity-10 my-8" />
|
||||
|
||||
<div class="w-full max-w-4xl mx-auto">
|
||||
<div v-if="isEmbedTool" class="w-full flex justify-end items-center mb-4">
|
||||
<ui-checkbox v-if="!isFinished" v-model="shouldBackupAudioFiles" 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="!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>
|
||||
</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 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>
|
||||
|
||||
<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>
|
||||
<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 />
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-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.replace('lbr','[').replace('rbr',']') }}</div>
|
||||
<div class="w-2/3">
|
||||
{{ value }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
|
@ -210,7 +261,10 @@ export default {
|
|||
bitrate: '64k',
|
||||
channels: '2',
|
||||
codec: 'aac'
|
||||
}
|
||||
},
|
||||
renameFormat: '',
|
||||
sampleFormatObject: null,
|
||||
directorySeperatorChar: '/'
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
|
@ -229,6 +283,12 @@ export default {
|
|||
isM4BTool() {
|
||||
return this.selectedTool === 'm4b'
|
||||
},
|
||||
isM4BorEmbedTool() {
|
||||
return this.isM4BTool || this.isEmbedTool
|
||||
},
|
||||
isRenameTool() {
|
||||
return this.selectedTool === 'renamer'
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem.id
|
||||
},
|
||||
|
|
@ -250,18 +310,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
|
||||
|
|
@ -337,6 +401,41 @@ 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
|
||||
|
|
@ -379,6 +478,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()
|
||||
|
|
@ -389,11 +491,22 @@ export default {
|
|||
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'
|
||||
debugger
|
||||
},
|
||||
fetchToneObject() {
|
||||
this.$axios
|
||||
|
|
|
|||
|
|
@ -182,6 +182,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">
|
||||
|
|
@ -291,6 +301,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
|
||||
}
|
||||
|
|
@ -324,6 +342,10 @@ export default {
|
|||
updateServerLanguage(val) {
|
||||
this.updateSettingsKey('language', val)
|
||||
},
|
||||
updateDefaultRenameString(){
|
||||
this.updateSettingsKey('defaultRenameString', this.newServerSettings.defaultRenameString)
|
||||
this.$store.commit('setDefaultRenameString', this.newServerSettings.defaultRenameString)
|
||||
},
|
||||
updateSettingsKey(key, val) {
|
||||
this.updateServerSettings({
|
||||
[key]: val
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export const state = () => ({
|
|||
processingBatch: false,
|
||||
previousPath: '/',
|
||||
showExperimentalFeatures: false,
|
||||
defaultRenameString: '',
|
||||
bookshelfBookIds: [],
|
||||
openModal: null,
|
||||
innerModalOpen: false,
|
||||
|
|
@ -215,6 +216,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)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
"ButtonEdit": "Edit",
|
||||
"ButtonEditChapters": "Edit Chapters",
|
||||
"ButtonEditPodcast": "Edit Podcast",
|
||||
"ButtonExecuteRename": "Execute Rename",
|
||||
"ButtonForceReScan": "Force Re-Scan",
|
||||
"ButtonFullPath": "Full Path",
|
||||
"ButtonHide": "Hide",
|
||||
|
|
@ -43,6 +44,7 @@
|
|||
"ButtonPlay": "Play",
|
||||
"ButtonPlaying": "Playing",
|
||||
"ButtonPlaylists": "Playlists",
|
||||
"ButtonPreviewRename": "Preview Rename",
|
||||
"ButtonPurgeAllCache": "Purge All Cache",
|
||||
"ButtonPurgeItemsCache": "Purge Items Cache",
|
||||
"ButtonPurgeMediaProgress": "Purge Media Progress",
|
||||
|
|
@ -126,6 +128,7 @@
|
|||
"HeaderPreviewCover": "Preview Cover",
|
||||
"HeaderRemoveEpisode": "Remove Episode",
|
||||
"HeaderRemoveEpisodes": "Remove {0} Episodes",
|
||||
"HeaderRenameTool": "Folder Renaming Tool",
|
||||
"HeaderRSSFeedIsOpen": "RSS Feed is Open",
|
||||
"HeaderSavedMediaProgress": "Saved Media Progress",
|
||||
"HeaderSchedule": "Schedule",
|
||||
|
|
@ -198,6 +201,7 @@
|
|||
"LabelDevice": "Device",
|
||||
"LabelDeviceInfo": "Device Info",
|
||||
"LabelDirectory": "Directory",
|
||||
"LabelDirectorySeperator": "Directory Seperator",
|
||||
"LabelDiscFromFilename": "Disc from Filename",
|
||||
"LabelDiscFromMetadata": "Disc from Metadata",
|
||||
"LabelDownload": "Download",
|
||||
|
|
@ -239,6 +243,7 @@
|
|||
"LabelIntervalEveryHour": "Every hour",
|
||||
"LabelInvalidParts": "Invalid Parts",
|
||||
"LabelItem": "Item",
|
||||
"LabelKey": "Key",
|
||||
"LabelLanguage": "Language",
|
||||
"LabelLanguageDefaultServer": "Default Server Language",
|
||||
"LabelLastSeen": "Last Seen",
|
||||
|
|
@ -347,6 +352,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",
|
||||
|
|
@ -393,6 +400,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",
|
||||
|
|
|
|||
|
|
@ -32,6 +32,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')
|
||||
|
|
@ -74,6 +75,7 @@ class Server {
|
|||
this.coverManager = new CoverManager(this.db, this.cacheManager)
|
||||
this.podcastManager = new PodcastManager(this.db, this.watcher, this.notificationManager)
|
||||
this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.taskManager)
|
||||
this.folderRenameManager = new LibraryItemFolderManager(this.db, this.watcher)
|
||||
this.rssFeedManager = new RssFeedManager(this.db)
|
||||
this.eBookManager = new EBookManager(this.db)
|
||||
|
||||
|
|
|
|||
|
|
@ -79,5 +79,41 @@ class ToolsController {
|
|||
req.libraryItem = item
|
||||
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()
|
||||
266
server/managers/LibraryItemFolderManager.js
Normal file
266
server/managers/LibraryItemFolderManager.js
Normal 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
|
||||
|
|
@ -158,7 +158,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)
|
||||
}
|
||||
|
|
@ -184,7 +190,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) {
|
||||
var json = this.toJSON()
|
||||
var hasUpdates = false
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ class ServerSettings {
|
|||
// Metadata - choose to store inside users library item folder
|
||||
this.storeCoverWithItem = false
|
||||
this.storeMetadataWithItem = false
|
||||
this.defaultRenameString = "$bookAuthor/$bookTitle"
|
||||
|
||||
// Security/Rate limits
|
||||
this.rateLimitLoginRequests = 10
|
||||
|
|
@ -76,6 +77,7 @@ class ServerSettings {
|
|||
|
||||
this.storeCoverWithItem = !!settings.storeCoverWithItem
|
||||
this.storeMetadataWithItem = !!settings.storeMetadataWithItem
|
||||
this.defaultRenameString = settings.defaultRenameString || "$bookAuthor/$bookTitle"
|
||||
|
||||
this.rateLimitLoginRequests = !isNaN(settings.rateLimitLoginRequests) ? Number(settings.rateLimitLoginRequests) : 10
|
||||
this.rateLimitLoginWindow = !isNaN(settings.rateLimitLoginWindow) ? Number(settings.rateLimitLoginWindow) : 10 * 60 * 1000 // 10 Minutes
|
||||
|
|
@ -131,6 +133,7 @@ class ServerSettings {
|
|||
scannerUseTone: this.scannerUseTone,
|
||||
storeCoverWithItem: this.storeCoverWithItem,
|
||||
storeMetadataWithItem: this.storeMetadataWithItem,
|
||||
defaultRenameString: this.defaultRenameString,
|
||||
rateLimitLoginRequests: this.rateLimitLoginRequests,
|
||||
rateLimitLoginWindow: this.rateLimitLoginWindow,
|
||||
backupSchedule: this.backupSchedule,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -272,6 +273,8 @@ class ApiRouter {
|
|||
this.router.post('/tools/item/:id/encode-m4b', ToolsController.itemMiddleware.bind(this), ToolsController.encodeM4b.bind(this))
|
||||
this.router.delete('/tools/item/:id/encode-m4b', ToolsController.itemMiddleware.bind(this), ToolsController.cancelM4bEncode.bind(this))
|
||||
this.router.post('/tools/item/:id/embed-metadata', ToolsController.itemMiddleware.bind(this), ToolsController.embedAudioFileMetadata.bind(this))
|
||||
this.router.post('/tools/item/:id/renameFolder', ToolsController.itemMiddleware.bind(this), ToolsController.renameBookFolder.bind(this))
|
||||
this.router.get('/tools/item/:id/renameFolder', ToolsController.itemMiddleware.bind(this), ToolsController.testRenameBookFolder.bind(this))
|
||||
|
||||
//
|
||||
// RSS Feed Routes (Admin and up)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue