created a directory reorganizer

This commit is contained in:
Thomas 2026-03-20 11:21:56 -06:00
parent 8b89b27654
commit b4fded0d40
47 changed files with 809 additions and 80 deletions

View file

@ -180,6 +180,13 @@ export default {
action: 'rescan'
})
if (this.userCanUpdate) {
options.push({
text: this.$strings.ButtonReorganizeFiles,
action: 'reorganize'
})
}
// The limit of 50 is introduced because of the URL length. Each id has 36 chars, so 36 * 40 = 1440
// + 40 , separators = 1480 chars + base path 280 chars = 1760 chars. This keeps the URL under 2000 chars even with longer domains
if (this.selectedMediaItems.length <= 40) {
@ -225,6 +232,8 @@ export default {
this.batchAutoMatchClick()
} else if (action === 'rescan') {
this.batchRescan()
} else if (action === 'reorganize') {
this.batchReorganizeClick()
} else if (action === 'download') {
this.batchDownload()
}
@ -368,6 +377,9 @@ export default {
batchEditClick() {
this.$router.push('/batch')
},
batchReorganizeClick() {
this.$store.commit('globals/setShowBatchReorganizeModal', true)
},
batchAddToCollectionClick() {
this.$store.commit('globals/setShowBatchCollectionsModal', true)
},

View file

@ -0,0 +1,156 @@
<template>
<modals-modal v-model="show" name="batchReorganize" :processing="processing" :width="500" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<div v-if="show" class="w-full h-full py-4">
<div class="w-full overflow-y-auto overflow-x-hidden max-h-96">
<p class="text-base px-8 py-4">{{ $strings.LabelToolsReorganizeFilesDescription }}</p>
<div v-if="organizationPreview.length" class="px-8 py-2">
<p class="text-sm font-semibold mb-2">{{ $strings.LabelPreview }}</p>
<div class="bg-bg-secondary rounded p-3 max-h-48 overflow-y-auto">
<div v-for="(preview, index) in organizationPreview" :key="index" class="text-xs mb-2">
<p class="text-gray-400">{{ preview.title }}</p>
<p class="text-green-400">{{ preview.newPath }}</p>
</div>
</div>
</div>
<div class="mt-4 pt-4 text-white/80 border-t border-white/5">
<div class="flex items-center px-4">
<ui-btn type="button" @click="show = false">{{ $strings.ButtonCancel }}</ui-btn>
<div class="grow" />
<ui-btn color="bg-success" :disabled="!selectedItemIds.length" @click="doBatchReorganize">
{{ $strings.ButtonReorganizeFiles }}
</ui-btn>
</div>
</div>
</div>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
data() {
return {
processing: false,
loadedItems: []
}
},
watch: {
show: {
handler(newVal) {
if (newVal) {
this.init()
}
}
}
},
computed: {
show: {
get() {
return this.$store.state.globals.showBatchReorganizeModal
},
set(val) {
this.$store.commit('globals/setShowBatchReorganizeModal', val)
}
},
title() {
return this.$getString('MessageItemsSelected', [this.selectedItemIds.length])
},
selectedItemIds() {
return (this.$store.state.globals.selectedMediaItems || []).map((i) => i.id)
},
selectedItems() {
return this.loadedItems.length > 0 ? this.loadedItems : (this.$store.state.globals.selectedMediaItems || [])
},
organizationPreview() {
// Show a preview of where files will be moved
return this.selectedItems.slice(0, 5).map((item) => {
let newPath = ''
if (item.mediaType === 'podcast') {
newPath = item.media?.metadata?.title || 'Unknown Podcast'
} else {
const parts = []
if (item.media?.metadata?.authorName) parts.push(item.media.metadata.authorName)
if (item.media?.metadata?.seriesName) parts.push(item.media.metadata.seriesName)
if (item.media?.metadata?.title) parts.push(item.media.metadata.title)
newPath = parts.join(' / ') || 'Unknown Item'
}
return {
title: item.media?.metadata?.title || item.title,
newPath
}
})
}
},
mounted() {
// Listen for batch reorganize completion
this.$socket.on('batch_reorganize_complete', this.handleBatchComplete)
},
beforeDestroy() {
this.$socket.off('batch_reorganize_complete', this.handleBatchComplete)
},
methods: {
async init() {
// Load full item data for preview
this.loadedItems = []
try {
const libraryItems = await this.$axios.$post(`/api/items/batch/get`, {
libraryItemIds: this.selectedItemIds
})
if (libraryItems && libraryItems.libraryItems) {
this.loadedItems = libraryItems.libraryItems
}
} catch (error) {
console.error('Failed to load items for preview', error)
}
},
doBatchReorganize() {
if (!this.selectedItemIds.length) return
if (this.processing) return
this.processing = true
this.$store.commit('setProcessingBatch', true)
this.$axios
.$post(`/api/items/batch/reorganize-files`, {
libraryItemIds: this.selectedItemIds
})
.then(() => {
this.$toast.info(this.$getString('MessageBatchReorganizeStarted', [this.selectedItemIds.length]))
})
.catch((error) => {
this.$toast.error('Failed to start batch reorganize')
console.error('Failed to batch reorganize', error)
this.processing = false
this.$store.commit('setProcessingBatch', false)
})
.finally(() => {
this.show = false
})
},
handleBatchComplete(result) {
this.processing = false
this.$store.commit('setProcessingBatch', false)
if (result.successCount > 0 && result.errorCount === 0) {
this.$toast.success(this.$getString('MessageBatchReorganizeSuccess', [result.successCount]))
} else if (result.successCount > 0 && result.errorCount > 0) {
this.$toast.warning(this.$getString('MessageBatchReorganizePartial', [result.successCount, result.errorCount]))
} else {
this.$toast.error('Failed to reorganize files')
}
// Clear selection
this.$store.commit('globals/resetSelectedMediaItems')
}
}
}
</script>

View file

@ -49,6 +49,20 @@
</div>
<p v-if="!mediaTracks.length" class="text-lg text-center my-8">{{ $strings.MessageNoAudioTracks }}</p>
<!-- Reorganize Files -->
<div class="w-full border border-black-200 p-4 my-8">
<div class="flex items-center">
<div>
<p class="text-lg">{{ $strings.LabelToolsReorganizeFiles }}</p>
<p class="max-w-sm text-sm pt-2 text-gray-300">{{ $strings.LabelToolsReorganizeFilesDescription }}</p>
</div>
<div class="grow" />
<div>
<ui-btn :loading="isReorganizing" color="bg-primary" @click.stop="reorganizeFiles">{{ $strings.ButtonReorganizeFiles }}</ui-btn>
</div>
</div>
</div>
</div>
</template>
@ -62,7 +76,9 @@ export default {
}
},
data() {
return {}
return {
isReorganizing: false
}
},
computed: {
libraryItemId() {
@ -123,6 +139,38 @@ export default {
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
async reorganizeFiles() {
const title = this.libraryItem?.media?.metadata?.title || this.libraryItem?.media?.title || this.libraryItem?.title || 'this item'
const payload = {
message: `Are you sure you want to reorganize the files for "${title}"? Files will be moved to a directory structure based on metadata.`,
callback: (confirmed) => {
if (confirmed) {
this.performReorganize()
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
async performReorganize() {
this.isReorganizing = true
try {
const response = await this.$axios.$post(`/api/items/${this.libraryItemId}/reorganize-files`)
console.log('Reorganize response:', response)
this.$toast.success('Files reorganized successfully')
} catch (error) {
console.error('Reorganize error:', error)
let errorMsg = 'Failed to reorganize files'
if (error.response?.data) {
errorMsg = error.response.data
} else if (error.message) {
errorMsg = error.message
}
this.$toast.error(errorMsg)
} finally {
this.isReorganizing = false
}
}
}
}

View file

@ -18,6 +18,7 @@
<modals-podcast-view-episode />
<modals-authors-edit-modal />
<modals-batch-quick-match-model />
<modals-batch-reorganize-modal />
<modals-rssfeed-open-close-modal />
<modals-raw-cover-preview-modal />
<modals-share-modal />

View file

@ -27,6 +27,7 @@ export const state = () => ({
isCasting: false, // Actively casting
isChromecastInitialized: false, // Script loadeds
showBatchQuickMatchModal: false,
showBatchReorganizeModal: false,
dateFormats: [
{
text: 'MM/DD/YYYY',
@ -204,6 +205,9 @@ export const mutations = {
setShowBatchQuickMatchModal(state, val) {
state.showBatchQuickMatchModal = val
},
setShowBatchReorganizeModal(state, val) {
state.showBatchReorganizeModal = val
},
resetSelectedMediaItems(state) {
state.selectedMediaItems = []
},

View file

@ -1114,5 +1114,12 @@
"ToastUserPasswordChangeSuccess": "تم تغيير كلمة المرور بنجاح",
"ToastUserPasswordMismatch": "كلمات المرور غير متطابقة",
"ToastUserPasswordMustChange": "يجب ألا تطابق كلمة المرور الجديدة كلمة المرور القديمة",
"ToastUserRootRequireName": "يجب إدخال اسم مستخدم الجذر"
"ToastUserRootRequireName": "يجب إدخال اسم مستخدم الجذر",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -1161,5 +1161,12 @@
"TooltipLockChapter": "Заблакіраваць раздзел (Shift+націсканне для дыяпазону)",
"TooltipSubtractOneSecond": "Адняць 1 секунду",
"TooltipUnlockAllChapters": "Разблакіраваць усе раздзелы",
"TooltipUnlockChapter": "Разблакіраваць раздзел (Shift+націсканне для выбару дыяпазону)"
"TooltipUnlockChapter": "Разблакіраваць раздзел (Shift+націсканне для выбару дыяпазону)",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -1026,5 +1026,12 @@
"ToastSortingPrefixesEmptyError": "Трябва да има поне 1 префикс за сортиране",
"ToastSortingPrefixesUpdateSuccess": "Префиксите за сортиране са актуализирани ({0} елемента)",
"ToastUserDeleteFailed": "Неуспешно изтриване на потребител",
"ToastUserDeleteSuccess": "Потребителят е изтрит"
"ToastUserDeleteSuccess": "Потребителят е изтрит",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -1080,5 +1080,12 @@
"ToastUserPasswordChangeSuccess": "পাসওয়ার্ড সফলভাবে পরিবর্তন করা হয়েছে",
"ToastUserPasswordMismatch": "পাসওয়ার্ড মিলছে না",
"ToastUserPasswordMustChange": "নতুন পাসওয়ার্ড পুরানো পাসওয়ার্ডের সাথে মিলতে পারবে না",
"ToastUserRootRequireName": "একটি রুট ব্যবহারকারীর নাম লিখতে হবে"
"ToastUserRootRequireName": "একটি রুট ব্যবহারকারীর নাম লিখতে হবে",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -1073,5 +1073,12 @@
"ToastUserPasswordChangeSuccess": "Contrasenya canviada correctament",
"ToastUserPasswordMismatch": "Les contrasenyes no coincideixen",
"ToastUserPasswordMustChange": "La nova contrasenya no pot ser igual a l'anterior",
"ToastUserRootRequireName": "Cal introduir un nom d'usuari root"
"ToastUserRootRequireName": "Cal introduir un nom d'usuari root",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -1161,5 +1161,12 @@
"TooltipLockChapter": "Uzamknout kapitolu (Shift+klik pro rozsah)",
"TooltipSubtractOneSecond": "Odečíst 1 sekundu",
"TooltipUnlockAllChapters": "Odemknout všechny kapitoly",
"TooltipUnlockChapter": "Odemknout kapitolu (Shift+klik pro rozsah)"
"TooltipUnlockChapter": "Odemknout kapitolu (Shift+klik pro rozsah)",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -1161,5 +1161,12 @@
"TooltipLockChapter": "Lås kapitel (Shift+click for at markere flere)",
"TooltipSubtractOneSecond": "Fratag 1 sekund",
"TooltipUnlockAllChapters": "Lås alle kapitaler op",
"TooltipUnlockChapter": "Lås kapitel op (Shift+click for at markere flere)"
"TooltipUnlockChapter": "Lås kapitel op (Shift+click for at markere flere)",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -1161,5 +1161,12 @@
"TooltipLockChapter": "Kapitel sperren (Shift+Klick für mehrere)",
"TooltipSubtractOneSecond": "1 Sekunde abziehen",
"TooltipUnlockAllChapters": "Alle Kapitel freigeben",
"TooltipUnlockChapter": "Kapitel freigeben (Shift+Klick für mehrere)"
"TooltipUnlockChapter": "Kapitel freigeben (Shift+Klick für mehrere)",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -320,5 +320,12 @@
"MessageLoading": "Φόρτωση...",
"MessageMarkAsFinished": "Σήμανση ως Ολοκληρωμένο",
"MessageNoItemsFound": "Δεν βρέθηκαν αντικείμενα",
"MessageNoUserPlaylists": "Δεν έχετε λίστες αναπαραγωγής"
"MessageNoUserPlaylists": "Δεν έχετε λίστες αναπαραγωγής",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -1161,5 +1161,12 @@
"TooltipLockChapter": "Lock chapter (Shift+click for range)",
"TooltipSubtractOneSecond": "Subtract 1 second",
"TooltipUnlockAllChapters": "Unlock all chapters",
"TooltipUnlockChapter": "Unlock chapter (Shift+click for range)"
"TooltipUnlockChapter": "Unlock chapter (Shift+click for range)",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -1161,5 +1161,12 @@
"TooltipLockChapter": "Bloquear capítulo (Mayús+clic para rango)",
"TooltipSubtractOneSecond": "Restar 1 segundo",
"TooltipUnlockAllChapters": "Desbloquear todos los capítulos",
"TooltipUnlockChapter": "Desbloquear capítulo (Mayús+clic para rango)"
"TooltipUnlockChapter": "Desbloquear capítulo (Mayús+clic para rango)",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -749,5 +749,12 @@
"ToastSocketDisconnected": "Pesa ühendus katkenud",
"ToastSocketFailedToConnect": "Pesa ühendamine ebaõnnestus",
"ToastUserDeleteFailed": "Kasutaja kustutamine ebaõnnestus",
"ToastUserDeleteSuccess": "Kasutaja kustutatud"
"ToastUserDeleteSuccess": "Kasutaja kustutatud",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -1 +1,9 @@
{}
{
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -24,5 +24,12 @@
"ButtonSave": "ذخیره",
"ButtonSearch": "جستجو",
"ButtonSeries": "مجموعه",
"ButtonSubmit": "ثبت"
"ButtonSubmit": "ثبت",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -1161,5 +1161,12 @@
"TooltipLockChapter": "Lukitse luku (Shift+napauta valitaksesi alueen)",
"TooltipSubtractOneSecond": "Vähennä 1 sekunti",
"TooltipUnlockAllChapters": "Avaa kaikki luvut",
"TooltipUnlockChapter": "Avaa luku (Shift+napauta valitaksesi alueen)"
"TooltipUnlockChapter": "Avaa luku (Shift+napauta valitaksesi alueen)",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -1161,5 +1161,12 @@
"TooltipLockChapter": "Verrouiller le chapitre (Maj+clic pour plage)",
"TooltipSubtractOneSecond": "Soustraire 1 seconde",
"TooltipUnlockAllChapters": "Déverrouiller tous les chapitres",
"TooltipUnlockChapter": "Déverrouiller le chapitre (Maj+clic pour plage)"
"TooltipUnlockChapter": "Déverrouiller le chapitre (Maj+clic pour plage)",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -194,5 +194,12 @@
"LabelPodcastSearchRegion": "પોડકાસ્ટ શોધ પ્રદેશ",
"LabelSettingsPreferMatchedMetadataHelp": "Matched data will overide item details when using Quick Match. By default Quick Match will only fill in missing details.",
"MessageBookshelfNoResultsForFilter": "No Results for filter \"{0}: {1}\"",
"ToastSendEbookToDeviceFailed": "Failed to Send Ebook to device"
"ToastSendEbookToDeviceFailed": "Failed to Send Ebook to device",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -946,5 +946,12 @@
"ToastSocketDisconnected": "קצה תקשורת נותק",
"ToastSocketFailedToConnect": "התחברות קצה התקשורת נכשלה",
"ToastUserDeleteFailed": "מחיקת המשתמש נכשלה",
"ToastUserDeleteSuccess": "המשתמש נמחק בהצלחה"
"ToastUserDeleteSuccess": "המשתמש נמחק בהצלחה",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -99,5 +99,12 @@
"LabelSettingsPreferMatchedMetadataHelp": "Matched data will overide item details when using Quick Match. By default Quick Match will only fill in missing details.",
"MessageBookshelfNoResultsForFilter": "No Results for filter \"{0}: {1}\"",
"NoteChangeRootPassword": "रूट user is the only user that can have an empty password",
"ToastSendEbookToDeviceFailed": "Failed to Send Ebook to device"
"ToastSendEbookToDeviceFailed": "Failed to Send Ebook to device",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -1161,5 +1161,12 @@
"TooltipLockChapter": "Zaključaj poglavlje (Shift + klik za raspon)",
"TooltipSubtractOneSecond": "Oduzmi 1 sekundu",
"TooltipUnlockAllChapters": "Otključaj sva poglavlja",
"TooltipUnlockChapter": "Otključaj poglavlje (Shift+klik za raspon)"
"TooltipUnlockChapter": "Otključaj poglavlje (Shift+klik za raspon)",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -1161,5 +1161,12 @@
"TooltipLockChapter": "Fejezet zárolása (Shift+kattintás a tartományhoz)",
"TooltipSubtractOneSecond": "1 másodperc levonása",
"TooltipUnlockAllChapters": "Az összes fejezet feloldása",
"TooltipUnlockChapter": "Fejezet feloldása (Shift+kattintás a tartományhoz)"
"TooltipUnlockChapter": "Fejezet feloldása (Shift+kattintás a tartományhoz)",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -1 +1,9 @@
{}
{
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -1161,5 +1161,12 @@
"TooltipLockChapter": "Bloccare capitolo (Shift+click per intervallo)",
"TooltipSubtractOneSecond": "Sottrarre 1 secondo",
"TooltipUnlockAllChapters": "Sbloccare tutti i capitoli",
"TooltipUnlockChapter": "Sbloccare capitolo (Shift+click per intervallo)"
"TooltipUnlockChapter": "Sbloccare capitolo (Shift+click per intervallo)",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -267,5 +267,12 @@
"LabelSettingsStoreCoversWithItem": "表紙を項目と一緒に保存する",
"LabelSettingsStoreCoversWithItemHelp": "デフォルトでは表紙は /metadata/items に保存されますが、この設定をオンにするとライブラリーの項目のフォルダーに保存されます。\"cover\" という名前のファイル一つのみが保持されます",
"LabelSettingsStoreMetadataWithItem": "メタデータを項目と一緒に保存する",
"LabelSettingsStoreMetadataWithItemHelp": "デフォルトではメタデータは/metadata/itemsに保存されますが、この設定をオンにするとライブラリーの項目のフォルダーに保存されます"
"LabelSettingsStoreMetadataWithItemHelp": "デフォルトではメタデータは/metadata/itemsに保存されますが、この設定をオンにするとライブラリーの項目のフォルダーに保存されます",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -1161,5 +1161,12 @@
"TooltipLockChapter": "장 잠금(범위를 위해 Shift+클릭)",
"TooltipSubtractOneSecond": "1초 빼기",
"TooltipUnlockAllChapters": "모든 챕터 잠금 해제",
"TooltipUnlockChapter": "챕터 잠금 해제(범위를 보려면 Shift+클릭)"
"TooltipUnlockChapter": "챕터 잠금 해제(범위를 보려면 Shift+클릭)",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -703,5 +703,12 @@
"ToastSocketDisconnected": "Severis atjungtas",
"ToastSocketFailedToConnect": "Nepavyko prisijungti prie serverio",
"ToastUserDeleteFailed": "Nepavyko ištrinti naudotojo",
"ToastUserDeleteSuccess": "Naudotojas ištrintas"
"ToastUserDeleteSuccess": "Naudotojas ištrintas",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -1159,5 +1159,12 @@
"TooltipLockChapter": "Hoofdstuk vergrendelen (Shift+klikken voor bereik)",
"TooltipSubtractOneSecond": "Trek 1 seconde af",
"TooltipUnlockAllChapters": "Alle hoofdstukken ontgrendelen",
"TooltipUnlockChapter": "Hoofdstuk ontgrendelen (Shift+klikken voor bereik)"
"TooltipUnlockChapter": "Hoofdstuk ontgrendelen (Shift+klikken voor bereik)",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -1161,5 +1161,12 @@
"TooltipLockChapter": "Lås kapittel (Shift+klikk for område)",
"TooltipSubtractOneSecond": "Trekk fra 1 sekund",
"TooltipUnlockAllChapters": "Lås opp alle kapitler",
"TooltipUnlockChapter": "Lås opp kapittel (Shift+klikk for område)"
"TooltipUnlockChapter": "Lås opp kapittel (Shift+klikk for område)",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -1113,5 +1113,12 @@
"TooltipLockChapter": "Zablokuj rozdział (przytrzymaj Shift i kliknij, aby zaznaczyć zakres)",
"TooltipSubtractOneSecond": "Odejmij sekundę",
"TooltipUnlockAllChapters": "Odblokuj wszystkie rozdziały",
"TooltipUnlockChapter": "Odblokuj rozdział (przytrzymaj Shift i kliknij, aby zaznaczyć zakres)"
"TooltipUnlockChapter": "Odblokuj rozdział (przytrzymaj Shift i kliknij, aby zaznaczyć zakres)",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -1161,5 +1161,12 @@
"TooltipLockChapter": "Bloquear capítulo (Shift+clique para selecionar o intervalo)",
"TooltipSubtractOneSecond": "Subtrair 1 segundo",
"TooltipUnlockAllChapters": "Desbloqueie todos os capítulos",
"TooltipUnlockChapter": "Desbloquear capítulo (Shift + clique para selecionar o intervalo)"
"TooltipUnlockChapter": "Desbloquear capítulo (Shift + clique para selecionar o intervalo)",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -641,5 +641,12 @@
"ToastPodcastCreateFailed": "Nu s-a putut crea podcastul",
"ToastPodcastCreateSuccess": "Podcast creat cu succes",
"ToastRSSFeedCloseFailed": "Nu s-a putut închide fluxul RSS",
"ToastRSSFeedCloseSuccess": "Flux RSS închis"
"ToastRSSFeedCloseSuccess": "Flux RSS închis",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -1161,5 +1161,12 @@
"TooltipLockChapter": "Заблокировать главу (Shift+клик для диапазона)",
"TooltipSubtractOneSecond": "Вычтите 1 секунду",
"TooltipUnlockAllChapters": "Разблокируйте все главы",
"TooltipUnlockChapter": "Разблокируйте главу (Shift+клик для диапазона)"
"TooltipUnlockChapter": "Разблокируйте главу (Shift+клик для диапазона)",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -1161,5 +1161,12 @@
"TooltipLockChapter": "Zamknúť kapitolu (Shift+klik pre skupinu)",
"TooltipSubtractOneSecond": "Odobrať 1 sekundu",
"TooltipUnlockAllChapters": "Odomknúť všetky kapitoly",
"TooltipUnlockChapter": "Odomknúť kapitolu (Shift+klik pre skupinu)"
"TooltipUnlockChapter": "Odomknúť kapitolu (Shift+klik pre skupinu)",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -1161,5 +1161,12 @@
"TooltipLockChapter": "Zakleni poglavje (Shift+klik za obseg)",
"TooltipSubtractOneSecond": "Odštej 1 sekundo",
"TooltipUnlockAllChapters": "Odkleni vsa poglavja",
"TooltipUnlockChapter": "Odkleni poglavje (Shift+klik za obseg)"
"TooltipUnlockChapter": "Odkleni poglavje (Shift+klik za obseg)",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -1161,5 +1161,12 @@
"TooltipLockChapter": "Lås kapitel (Tryck på Shift + Klick för att markera flera)",
"TooltipSubtractOneSecond": "Minska med 1 sekund",
"TooltipUnlockAllChapters": "Lås upp alla kapitel",
"TooltipUnlockChapter": "Lås upp kapitel (Tryck på Shift + Klick för att markera flera)"
"TooltipUnlockChapter": "Lås upp kapitel (Tryck på Shift + Klick för att markera flera)",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -1161,5 +1161,12 @@
"TooltipLockChapter": "Bölümü kilitle (aralık için Shift+tıkla)",
"TooltipSubtractOneSecond": "1 saniye çıkar",
"TooltipUnlockAllChapters": "Tüm bölümlerin kilidini aç",
"TooltipUnlockChapter": "Bölüm kilidini aç (aralık için Shift+tıkla)"
"TooltipUnlockChapter": "Bölüm kilidini aç (aralık için Shift+tıkla)",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -1161,5 +1161,12 @@
"TooltipLockChapter": "Заблокувати розділ (Shift+клацання для діапазону)",
"TooltipSubtractOneSecond": "Відніміть 1 секунду",
"TooltipUnlockAllChapters": "Розблокувати всі розділи",
"TooltipUnlockChapter": "Розблокувати розділ (Shift+клацання для діапазону)"
"TooltipUnlockChapter": "Розблокувати розділ (Shift+клацання для діапазону)",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -742,5 +742,12 @@
"ToastSocketDisconnected": "Ngắt kết nối socket",
"ToastSocketFailedToConnect": "Không thể kết nối socket",
"ToastUserDeleteFailed": "Xóa người dùng thất bại",
"ToastUserDeleteSuccess": "Người dùng đã được xóa"
"ToastUserDeleteSuccess": "Người dùng đã được xóa",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -1161,5 +1161,12 @@
"TooltipLockChapter": "锁定章节 (按住 Shift再点击, 可进行范围选择)",
"TooltipSubtractOneSecond": "减 1 秒",
"TooltipUnlockAllChapters": "解锁所有章节",
"TooltipUnlockChapter": "解锁章节 (按住 Shift再点击, 可进行范围选择)"
"TooltipUnlockChapter": "解锁章节 (按住 Shift再点击, 可进行范围选择)",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -755,5 +755,12 @@
"ToastSocketDisconnected": "網路已斷開",
"ToastSocketFailedToConnect": "網路連接失敗",
"ToastUserDeleteFailed": "刪除使用者失敗",
"ToastUserDeleteSuccess": "使用者已刪除"
"ToastUserDeleteSuccess": "使用者已刪除",
"ButtonReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFiles": "Reorganize Files",
"LabelToolsReorganizeFilesDescription": "Move all files to a directory structure based on item metadata (author/series/title for books, title for podcasts)",
"MessageBatchReorganizeStarted": "Reorganizing files for {0} items in background...",
"MessageBatchReorganizeSuccess": "{0} items reorganized successfully",
"MessageBatchReorganizePartial": "{0} items reorganized, {1} failed",
"LabelPreview": "Preview"
}

View file

@ -9,7 +9,7 @@ const Database = require('../Database')
const zipHelpers = require('../utils/zipHelpers')
const { reqSupportsWebp } = require('../utils/index')
const { ScanResult, AudioMimeType } = require('../utils/constants')
const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUtils')
const { getAudioMimeTypeFromExtname, encodeUriPath, sanitizeFilename } = require('../utils/fileUtils')
const LibraryItemScanner = require('../scanner/LibraryItemScanner')
const AudioFileScanner = require('../scanner/AudioFileScanner')
const Scanner = require('../scanner/Scanner')
@ -489,6 +489,34 @@ class LibraryItemController {
res.json(req.libraryItem.toOldJSON())
}
/**
* POST /api/items/:id/reorganize-files
* Reorganize library item files into metadata-based directory structure
*
* @param {LibraryItemControllerRequest} req
* @param {Response} res
*/
async reorganizeFiles(req, res) {
if (!req.user.canUpdate) {
Logger.warn(`[LibraryItemController] User "${req.user.username}" attempted to reorganize files without permission`)
return res.sendStatus(403)
}
try {
const result = await this.reorganizeFilesForItem(req.libraryItem)
if (!result.success) {
return res.status(400).send(`Error: ${result.error}`)
}
Logger.info(`[LibraryItemController] reorganizeFiles completed successfully`)
res.json({ success: true })
} catch (error) {
Logger.error(`[LibraryItemController] reorganizeFiles error: ${error.message}`)
res.status(400).send(`Error: ${error.message}`)
}
}
/**
* POST /api/items/:id/match
*
@ -1163,6 +1191,202 @@ class LibraryItemController {
* @param {Response} res
* @param {NextFunction} next
*/
/**
* Helper method to reorganize a single item's files
* @param {import('../models/LibraryItem')} libraryItem
* @returns {Promise<{success: boolean, error?: string}>}
*/
async reorganizeFilesForItem(libraryItem) {
try {
const itemId = libraryItem.id
const currentPath = libraryItem.path
const libraryId = libraryItem.libraryId
const isBook = libraryItem.isBook
const isPodcast = libraryItem.isPodcast
Logger.info(`[LibraryItemController] Reorganizing item ${itemId}`)
// Reload media with authors and series for books
let media = libraryItem.media
if (isBook) {
media = await libraryItem.getMedia({
include: [
{
model: Database.authorModel,
through: {
attributes: ['id', 'createdAt']
}
},
{
model: Database.seriesModel,
through: {
attributes: ['id', 'sequence', 'createdAt']
}
}
],
order: [
[Database.authorModel, Database.bookAuthorModel, 'createdAt', 'ASC'],
[Database.seriesModel, 'bookSeries', 'createdAt', 'ASC']
]
})
}
if (!media) {
return { success: false, error: 'Media not found' }
}
// Get library folder
const library = await Database.libraryModel.findByIdWithFolders(libraryId)
if (!library || !library.libraryFolders || !library.libraryFolders.length) {
return { success: false, error: 'Library not found' }
}
const libraryFolder = library.libraryFolders.find((f) => currentPath.startsWith(f.path))
if (!libraryFolder) {
return { success: false, error: 'Library folder not found' }
}
// Build new path
const parts = []
if (isPodcast) {
parts.push(media.title)
} else if (isBook) {
if (media.authorName) {
parts.push(media.authorName)
}
if (media.seriesName) {
parts.push(media.seriesName)
}
parts.push(media.title)
}
const sanitizedParts = parts.filter(Boolean).map((p) => sanitizeFilename(p))
const newPath = Path.join(libraryFolder.path, ...sanitizedParts)
Logger.info(`[LibraryItemController] Item path: ${currentPath} -> ${newPath}`)
// Move files if path is different
if (newPath !== currentPath) {
// Check current directory exists
const currentExists = await fs.pathExists(currentPath)
if (!currentExists) {
return { success: false, error: 'Current directory does not exist' }
}
// Check if target already exists with different item
const targetExists = await fs.pathExists(newPath)
if (targetExists) {
const existingItem = await Database.libraryItemModel.findOne({ where: { path: newPath } })
if (existingItem && existingItem.id !== itemId) {
return { success: false, error: 'Target directory already contains a different item' }
}
}
// Create target directory
await fs.ensureDir(newPath)
// Read and move files
const files = await fs.readdir(currentPath)
Logger.info(`[LibraryItemController] Moving ${files.length} files for item ${itemId}`)
for (const file of files) {
const src = Path.join(currentPath, file)
const dst = Path.join(newPath, file)
await fs.move(src, dst, { overwrite: true })
}
// Update database
await Database.libraryItemModel.update({ path: newPath }, { where: { id: itemId } })
// Clean up old directory
try {
const remaining = await fs.readdir(currentPath)
if (remaining.length === 0) {
await fs.remove(currentPath)
Logger.info(`[LibraryItemController] Removed empty old directory`)
}
} catch (err) {
Logger.warn(`[LibraryItemController] Could not remove old directory: ${err.message}`)
}
} else {
Logger.info(`[LibraryItemController] Paths are the same - no move needed for item ${itemId}`)
}
return { success: true }
} catch (error) {
Logger.error(`[LibraryItemController] Error reorganizing item: ${error.message}`)
return { success: false, error: error.message }
}
}
/**
* POST: /api/items/batch/reorganize-files
* Reorganize multiple library items' files into metadata-based directory structure
* Responds immediately, processes in background
*
* @this {import('../routers/ApiRouter')}
* @param {RequestWithUser} req - Request with libraryItemIds in body
* @param {Response} res
*/
async batchReorganizeFiles(req, res) {
if (!req.user.canUpdate) {
Logger.warn(`[LibraryItemController] User "${req.user.username}" attempted to batch reorganize without permission`)
return res.sendStatus(403)
}
const { libraryItemIds } = req.body
if (!libraryItemIds?.length || !Array.isArray(libraryItemIds)) {
return res.status(400).send('Invalid request body')
}
// Fetch items
const itemsToReorganize = await Database.libraryItemModel.findAllExpandedWhere({
id: libraryItemIds
})
if (!itemsToReorganize.length) {
return res.sendStatus(404)
}
// Respond immediately - this is a long-running operation
res.sendStatus(200)
// Get the controller instance for calling helper methods
const controller = require('./LibraryItemController')
// Process in background
Logger.info(`[LibraryItemController] Starting batch reorganize for ${itemsToReorganize.length} items`)
let successCount = 0
let errorCount = 0
const errors = []
for (const libraryItem of itemsToReorganize) {
// Get the controller instance to call helper methods
const result = await controller.reorganizeFilesForItem(libraryItem)
if (result.success) {
successCount++
Logger.info(`[LibraryItemController] Successfully reorganized: ${libraryItem.media.title}`)
} else {
errorCount++
errors.push({ id: libraryItem.id, title: libraryItem.media.title, error: result.error })
Logger.error(`[LibraryItemController] Failed to reorganize ${libraryItem.media.title}: ${result.error}`)
}
}
Logger.info(`[LibraryItemController] Batch reorganize complete: ${successCount} succeeded, ${errorCount} failed`)
// Notify user of completion
SocketAuthority.clientEmitter(req.user.id, 'batch_reorganize_complete', {
successCount,
errorCount,
errors,
total: itemsToReorganize.length
})
}
async middleware(req, res, next) {
req.libraryItem = await Database.libraryItemModel.getExpandedById(req.params.id)
if (!req.libraryItem?.media) return res.sendStatus(404)

View file

@ -104,6 +104,7 @@ class ApiRouter {
this.router.post('/items/batch/get', LibraryItemController.batchGet.bind(this))
this.router.post('/items/batch/quickmatch', LibraryItemController.batchQuickMatch.bind(this))
this.router.post('/items/batch/scan', LibraryItemController.batchScan.bind(this))
this.router.post('/items/batch/reorganize-files', LibraryItemController.batchReorganizeFiles.bind(this))
this.router.get('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.findOne.bind(this))
this.router.delete('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.delete.bind(this))
@ -117,6 +118,7 @@ class ApiRouter {
this.router.post('/items/:id/play', LibraryItemController.middleware.bind(this), LibraryItemController.startPlaybackSession.bind(this))
this.router.post('/items/:id/play/:episodeId', LibraryItemController.middleware.bind(this), LibraryItemController.startEpisodePlaybackSession.bind(this))
this.router.patch('/items/:id/tracks', LibraryItemController.middleware.bind(this), LibraryItemController.updateTracks.bind(this))
this.router.post('/items/:id/reorganize-files', LibraryItemController.middleware.bind(this), LibraryItemController.reorganizeFiles.bind(this))
this.router.post('/items/:id/scan', LibraryItemController.middleware.bind(this), LibraryItemController.scan.bind(this))
this.router.get('/items/:id/metadata-object', LibraryItemController.middleware.bind(this), LibraryItemController.getMetadataObject.bind(this))
this.router.post('/items/:id/chapters', LibraryItemController.middleware.bind(this), LibraryItemController.updateMediaChapters.bind(this))