From 09397cf3de9700d39fdbdc026016f8a65994b9d2 Mon Sep 17 00:00:00 2001 From: Josh Vincent Date: Fri, 6 Jun 2025 09:29:14 -0600 Subject: [PATCH 1/5] Improves chapter editing and adds bulk import Adds chapter locking functionality, allowing users to lock individual chapters or all chapters at once to prevent accidental edits. Implements time increment buttons to precisely adjust chapter start times. Introduces bulk chapter import functionality, allowing users to quickly add multiple chapters using a detected numbering pattern. Adds elapsed time display during chapter playback for better user feedback. Updates UI tooltips and icons for improved clarity and user experience. --- client/pages/audiobook/_id/chapters.vue | 385 ++++++++++++++++++++---- client/strings/en-us.json | 18 +- client/strings/es.json | 18 +- client/strings/fr.json | 20 +- 4 files changed, 378 insertions(+), 63 deletions(-) diff --git a/client/pages/audiobook/_id/chapters.vue b/client/pages/audiobook/_id/chapters.vue index a8840744..10840f25 100644 --- a/client/pages/audiobook/_id/chapters.vue +++ b/client/pages/audiobook/_id/chapters.vue @@ -53,51 +53,102 @@
-
{{ $strings.LabelStart }}
-
{{ $strings.LabelTitle }}
+
+ + + +
+
{{ $strings.LabelStart }}
+
{{ $strings.LabelTitle }}
- +
+
+ + + + +
+ + +
+ + + + +
+
+
+ +
+
+
+ + + + + + + + + + + + +
{{ elapsedTime }}s
+ + + +
+
+ +
+
+
+
+
+ +
+
+ + + +
+
@@ -114,19 +165,15 @@
{{ $strings.LabelDuration }}
- +
+

{{ $secondsToTimestamp(Math.round(track.duration), false, true) }}

+
+ + @@ -209,6 +256,33 @@ + + +
+
+

{{ $strings.HeaderBulkChapterModal }}

+

{{ $strings.MessageBulkChapterPattern }}

+
+ {{ $strings.LabelDetectedPattern }} "{{ detectedPattern.before }}{{ detectedPattern.startingNumber }}{{ detectedPattern.after }}" +
+ {{ $strings.LabelNextChapters }} + "{{ detectedPattern.before }}{{ detectedPattern.startingNumber + 1 }}{{ detectedPattern.after }}", "{{ detectedPattern.before }}{{ detectedPattern.startingNumber + 2 }}{{ detectedPattern.after }}", etc. +
+
+ + +
+
+ {{ $strings.ButtonAddChapters }} + {{ $strings.ButtonCancel }} +
+
+
+
@@ -265,7 +339,17 @@ export default { removeBranding: false, showSecondInputs: false, audibleRegions: ['US', 'CA', 'UK', 'AU', 'FR', 'DE', 'JP', 'IT', 'IN', 'ES'], - hasChanges: false + hasChanges: false, + timeIncrementAmount: 1, + elapsedTime: 0, + playStartTime: null, + elapsedTimeInterval: null, + lockedChapters: new Set(), + lastSelectedLockIndex: null, + bulkChapterInput: '', + showBulkChapterModal: false, + bulkChapterCount: 1, + detectedPattern: null } }, computed: { @@ -304,6 +388,9 @@ export default { }, selectedChapterId() { return this.selectedChapter ? this.selectedChapter.id : null + }, + allChaptersLocked() { + return this.newChapters.length > 0 && this.newChapters.every((chapter) => this.lockedChapters.has(chapter.id)) } }, methods: { @@ -334,19 +421,27 @@ export default { const amount = Number(this.shiftAmount) - const lastChapter = this.newChapters[this.newChapters.length - 1] - if (lastChapter.start + amount > this.mediaDurationRounded) { - this.$toast.error(this.$strings.ToastChaptersInvalidShiftAmountLast) + // Check if any unlocked chapters would be affected negatively + const unlockedChapters = this.newChapters.filter((chap) => !this.lockedChapters.has(chap.id)) + + if (unlockedChapters.length === 0) { + this.$toast.warning(this.$strings.ToastChaptersAllLocked) return } - if (this.newChapters[1].start + amount <= 0) { - this.$toast.error(this.$strings.ToastChaptersInvalidShiftAmountStart) + if (unlockedChapters[0].id === 0 && unlockedChapters[0].end + amount <= 0) { + this.$toast.error(this.$strings.ToastChapterInvalidShiftAmount) return } for (let i = 0; i < this.newChapters.length; i++) { const chap = this.newChapters[i] + + // Skip locked chapters + if (this.lockedChapters.has(chap.id)) { + continue + } + chap.end = Math.min(chap.end + amount, this.mediaDuration) if (i > 0) { chap.start = Math.max(0, chap.start + amount) @@ -354,6 +449,96 @@ export default { } this.checkChapters() }, + incrementChapterTime(chapter, amount) { + // Don't allow incrementing first chapter below 0 + if (chapter.id === 0 && chapter.start + amount < 0) { + return + } + + // Don't allow incrementing beyond media duration + if (chapter.start + amount >= this.mediaDuration) { + return + } + + // Find the previous chapter to ensure we don't go below it + const previousChapter = this.newChapters[chapter.id - 1] + if (previousChapter && chapter.start + amount <= previousChapter.start) { + return + } + + // Find the next chapter to ensure we don't go above it + const nextChapter = this.newChapters[chapter.id + 1] + if (nextChapter && chapter.start + amount >= nextChapter.start) { + return + } + + chapter.start = Math.max(0, chapter.start + amount) + this.checkChapters() + }, + startElapsedTimeTracking() { + this.elapsedTime = 0 + this.playStartTime = Date.now() + this.elapsedTimeInterval = setInterval(() => { + this.elapsedTime = Math.floor((Date.now() - this.playStartTime) / 1000) + }, 100) // Update every 100ms for smooth display + }, + stopElapsedTimeTracking() { + if (this.elapsedTimeInterval) { + clearInterval(this.elapsedTimeInterval) + this.elapsedTimeInterval = null + } + this.elapsedTime = 0 + this.playStartTime = null + }, + toggleChapterLock(chapter, event) { + const chapterId = chapter.id + + // Handle shift-click for range selection + if (event.shiftKey && this.lastSelectedLockIndex !== null) { + const startIndex = Math.min(this.lastSelectedLockIndex, chapterId) + const endIndex = Math.max(this.lastSelectedLockIndex, chapterId) + + // Determine if we should lock or unlock based on the target chapter's current state + const shouldLock = !this.lockedChapters.has(chapterId) + + for (let i = startIndex; i <= endIndex; i++) { + if (shouldLock) { + this.lockedChapters.add(i) + } else { + this.lockedChapters.delete(i) + } + } + } else { + // Single chapter toggle + if (this.lockedChapters.has(chapterId)) { + this.lockedChapters.delete(chapterId) + } else { + this.lockedChapters.add(chapterId) + } + } + + this.lastSelectedLockIndex = chapterId + + // Force reactivity update + this.lockedChapters = new Set(this.lockedChapters) + }, + lockAllChapters() { + this.newChapters.forEach((chapter) => { + this.lockedChapters.add(chapter.id) + }) + this.lockedChapters = new Set(this.lockedChapters) + }, + unlockAllChapters() { + this.lockedChapters.clear() + this.lockedChapters = new Set(this.lockedChapters) + }, + toggleAllChaptersLock() { + if (this.allChaptersLocked) { + this.unlockAllChapters() + } else { + this.lockAllChapters() + } + }, editItem() { this.$store.commit('showEditModal', this.libraryItem) }, @@ -451,6 +636,7 @@ export default { console.log('Audio playing') this.isLoadingChapter = false this.isPlayingChapter = true + this.startElapsedTimeTracking() }) audioEl.addEventListener('ended', () => { console.log('Audio ended') @@ -473,6 +659,7 @@ export default { this.selectedChapter = null this.isPlayingChapter = false this.isLoadingChapter = false + this.stopElapsedTimeTracking() }, saveChapters() { this.checkChapters() @@ -679,6 +866,86 @@ export default { this.saving = false }) }, + handleBulkChapterAdd() { + const input = this.bulkChapterInput.trim() + if (!input) return + + // Check if input contains any numbers and extract pattern info + const numberMatch = input.match(/(\d+)/) + + if (numberMatch) { + // Extract the base pattern and number + const foundNumber = parseInt(numberMatch[1]) + const numberIndex = numberMatch.index + const beforeNumber = input.substring(0, numberIndex) + const afterNumber = input.substring(numberIndex + numberMatch[1].length) + + // Store pattern info for bulk creation + this.detectedPattern = { + before: beforeNumber, + after: afterNumber, + startingNumber: foundNumber + } + + // Show modal to ask for number of chapters + this.bulkChapterCount = 1 + this.showBulkChapterModal = true + } else { + // Add single chapter with the entered title + this.addSingleChapterFromInput(input) + } + }, + addSingleChapterFromInput(title) { + // Find the last chapter to determine where to add the new one + const lastChapter = this.newChapters[this.newChapters.length - 1] + const newStart = lastChapter ? lastChapter.end : 0 + const newEnd = Math.min(newStart + 300, this.mediaDuration) // Default 5 minutes or media duration + + const newChapter = { + id: this.newChapters.length, + start: newStart, + end: newEnd, + title: title + } + + this.newChapters.push(newChapter) + this.bulkChapterInput = '' + this.checkChapters() + }, + + addBulkChapters() { + const count = parseInt(this.bulkChapterCount) + if (!count || count < 1 || count > 150) { + this.$toast.error(this.$strings.ToastBulkChapterInvalidCount) + return + } + + const { before, after, startingNumber } = this.detectedPattern + const lastChapter = this.newChapters[this.newChapters.length - 1] + const baseStart = lastChapter ? lastChapter.end : 0 + const defaultDuration = 300 // 5 minutes per chapter + + // Add multiple chapters with the detected pattern + for (let i = 0; i < count; i++) { + const chapterNumber = startingNumber + i + const newStart = baseStart + i * defaultDuration + const newEnd = Math.min(newStart + defaultDuration, this.mediaDuration) + + const newChapter = { + id: this.newChapters.length, + start: newStart, + end: newEnd, + title: `${before}${chapterNumber}${after}` + } + + this.newChapters.push(newChapter) + } + + this.bulkChapterInput = '' + this.showBulkChapterModal = false + this.detectedPattern = null + this.checkChapters() + }, libraryItemUpdated(libraryItem) { if (libraryItem.id === this.libraryItem.id) { if (!!libraryItem.media.metadata.asin && this.mediaMetadata.asin !== libraryItem.media.metadata.asin) { diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 939eb9f4..39726ff0 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -1103,5 +1103,21 @@ "ToastUserPasswordChangeSuccess": "Password changed successfully", "ToastUserPasswordMismatch": "Passwords do not match", "ToastUserPasswordMustChange": "New password cannot match old password", - "ToastUserRootRequireName": "Must enter a root username" + "ToastUserRootRequireName": "Must enter a root username", + "ToastChaptersAllLocked": "All chapters are locked. Unlock some chapters to shift their times.", + "ToastChapterInvalidShiftAmount": "Invalid shift amount. First chapter would have zero or negative length.", + "ToastBulkChapterInvalidCount": "Please enter a valid number between 1 and 150", + "PlaceholderBulkChapterInput": "Enter chapter title or use numbering (e.g., 'Episode 1', 'Chapter 10', '1.')", + "HeaderBulkChapterModal": "Add Multiple Chapters", + "MessageBulkChapterPattern": "How many chapters would you like to add with this numbering pattern?", + "LabelDetectedPattern": "Detected pattern:", + "LabelNextChapters": "Next chapters will be:", + "LabelNumberOfChapters": "Number of chapters:", + "TooltipAddChapters": "Add chapter(s)", + "TooltipAddOneSecond": "Add 1 second", + "TooltipLockAllChapters": "Lock all chapters", + "TooltipLockChapter": "Lock chapter (Shift+click for range)", + "TooltipSubtractOneSecond": "Subtract 1 second", + "TooltipUnlockAllChapters": "Unlock all chapters", + "TooltipUnlockChapter": "Unlock chapter (Shift+click for range)" } diff --git a/client/strings/es.json b/client/strings/es.json index 4dac8272..d81236f0 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -1103,5 +1103,21 @@ "ToastUserPasswordChangeSuccess": "Contraseña modificada correctamente", "ToastUserPasswordMismatch": "No coinciden las contraseñas", "ToastUserPasswordMustChange": "La nueva contraseña no puede ser igual que la anterior", - "ToastUserRootRequireName": "Debe introducir un nombre de usuario administrativo" + "ToastUserRootRequireName": "Debe introducir un nombre de usuario administrativo", + "ToastChaptersAllLocked": "Todos los capítulos están bloqueados. Desbloquee algunos capítulos para cambiar sus tiempos.", + "ToastChapterInvalidShiftAmount": "Cantidad de desplazamiento inválida. El primer capítulo tendría duración cero o negativa.", + "ToastBulkChapterInvalidCount": "Por favor ingrese un número válido entre 1 y 150", + "PlaceholderBulkChapterInput": "Ingrese título de capítulo o use numeración (ej. 'Episodio 1', 'Capítulo 10', '1.')", + "HeaderBulkChapterModal": "Añadir Múltiples Capítulos", + "MessageBulkChapterPattern": "¿Cuántos capítulos desea añadir con este patrón de numeración?", + "LabelDetectedPattern": "Patrón detectado:", + "LabelNextChapters": "Los próximos capítulos serán:", + "LabelNumberOfChapters": "Número de capítulos:", + "TooltipAddChapters": "Añadir capítulo(s)", + "TooltipAddOneSecond": "Añadir 1 segundo", + "TooltipLockAllChapters": "Bloquear todos los capítulos", + "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)" } diff --git a/client/strings/fr.json b/client/strings/fr.json index 03a0cdee..88666f58 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -1102,5 +1102,21 @@ "ToastUserPasswordChangeSuccess": "Mot de passe modifié avec succès", "ToastUserPasswordMismatch": "Les mots de passe ne correspondent pas", "ToastUserPasswordMustChange": "Le nouveau mot de passe ne peut pas être identique à l’ancien", - "ToastUserRootRequireName": "Vous devez entrer un nom d’utilisateur root" -} + "ToastUserRootRequireName": "Vous devez entrer un nom d’utilisateur root", + "ToastChaptersAllLocked": "Tous les chapitres sont verrouillés. Déverrouillez certains chapitres pour décaler leurs temps.", + "ToastChapterInvalidShiftAmount": "Montant de décalage invalide. Le premier chapitre aurait une durée nulle ou négative.", + "ToastBulkChapterInvalidCount": "Veuillez entrer un nombre valide entre 1 et 150", + "PlaceholderBulkChapterInput": "Entrez le titre du chapitre ou utilisez la numérotation (ex. 'Épisode 1', 'Chapitre 10', '1.')", + "HeaderBulkChapterModal": "Ajouter Plusieurs Chapitres", + "MessageBulkChapterPattern": "Combien de chapitres souhaitez-vous ajouter avec ce motif de numérotation ?", + "LabelDetectedPattern": "Motif détecté :", + "LabelNextChapters": "Les prochains chapitres seront :", + "LabelNumberOfChapters": "Nombre de chapitres :", + "TooltipAddChapters": "Ajouter chapitre(s)", + "TooltipAddOneSecond": "Ajouter 1 seconde", + "TooltipLockAllChapters": "Verrouiller tous les chapitres", + "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)" +} \ No newline at end of file From 679ffed0eaf11b03c151f7b02badc6cb308f0e3c Mon Sep 17 00:00:00 2001 From: Josh Vincent Date: Fri, 6 Jun 2025 11:49:13 -0600 Subject: [PATCH 2/5] Alphabetizes strings --- client/strings/en-us.json | 18 +++++++++--------- client/strings/es.json | 18 +++++++++--------- client/strings/fr.json | 20 ++++++++++---------- 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 39726ff0..64c7d99c 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -124,6 +124,7 @@ "HeaderAudiobookTools": "Audiobook File Management Tools", "HeaderAuthentication": "Authentication", "HeaderBackups": "Backups", + "HeaderBulkChapterModal": "Add Multiple Chapters", "HeaderChangePassword": "Change Password", "HeaderChapters": "Chapters", "HeaderChooseAFolder": "Choose a Folder", @@ -297,6 +298,7 @@ "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDescription": "Description", "LabelDeselectAll": "Deselect All", + "LabelDetectedPattern": "Detected pattern:", "LabelDevice": "Device", "LabelDeviceInfo": "Device Info", "LabelDeviceIsAvailableTo": "Device is available to...", @@ -454,6 +456,7 @@ "LabelNewestAuthors": "Newest Authors", "LabelNewestEpisodes": "Newest Episodes", "LabelNextBackupDate": "Next backup date", + "LabelNextChapters": "Next chapters will be:", "LabelNextScheduledRun": "Next scheduled run", "LabelNoCustomMetadataProviders": "No custom metadata providers", "LabelNoEpisodesSelected": "No episodes selected", @@ -470,6 +473,7 @@ "LabelNotificationsMaxQueueSize": "Max queue size for notification events", "LabelNotificationsMaxQueueSizeHelp": "Events are limited to firing 1 per second. Events will be ignored if the queue is at max size. This prevents notification spamming.", "LabelNumberOfBooks": "Number of Books", + "LabelNumberOfChapters": "Number of chapters:", "LabelNumberOfEpisodes": "# of Episodes", "LabelOpenIDAdvancedPermsClaimDescription": "Name of the OpenID claim that contains advanced permissions for user actions within the application which will apply to non-admin roles (if configured). If the claim is missing from the response, access to ABS will be denied. If a single option is missing, it will be treated as false. Ensure the identity provider's claim matches the expected structure:", "LabelOpenIDClaims": "Leave the following options empty to disable advanced group and permissions assignment, automatically assigning 'User' group then.", @@ -722,6 +726,7 @@ "MessageBookshelfNoResultsForFilter": "No results for filter \"{0}: {1}\"", "MessageBookshelfNoResultsForQuery": "No results for query", "MessageBookshelfNoSeries": "You have no series", + "MessageBulkChapterPattern": "How many chapters would you like to add with this numbering pattern?", "MessageChapterEndIsAfter": "Chapter end is after the end of your audiobook", "MessageChapterErrorFirstNotZero": "First chapter must start at 0", "MessageChapterErrorStartGteDuration": "Invalid start time must be less than audiobook duration", @@ -919,6 +924,7 @@ "NotificationOnBackupFailedDescription": "Triggered when a backup fails", "NotificationOnEpisodeDownloadedDescription": "Triggered when a podcast episode is auto-downloaded", "NotificationOnTestDescription": "Event for testing the notification system", + "PlaceholderBulkChapterInput": "Enter chapter title or use numbering (e.g., 'Episode 1', 'Chapter 10', '1.')", "PlaceholderNewCollection": "New collection name", "PlaceholderNewFolderPath": "New folder path", "PlaceholderNewPlaylist": "New playlist name", @@ -972,8 +978,11 @@ "ToastBookmarkCreateFailed": "Failed to create bookmark", "ToastBookmarkCreateSuccess": "Bookmark added", "ToastBookmarkRemoveSuccess": "Bookmark removed", + "ToastBulkChapterInvalidCount": "Please enter a valid number between 1 and 150", "ToastCachePurgeFailed": "Failed to purge cache", "ToastCachePurgeSuccess": "Cache purged successfully", + "ToastChapterInvalidShiftAmount": "Invalid shift amount. First chapter would have zero or negative length.", + "ToastChaptersAllLocked": "All chapters are locked. Unlock some chapters to shift their times.", "ToastChaptersHaveErrors": "Chapters have errors", "ToastChaptersInvalidShiftAmountLast": "Invalid shift amount. The last chapter start time would extend beyond the duration of this audiobook.", "ToastChaptersInvalidShiftAmountStart": "Invalid shift amount. The first chapter would have zero or negative length and would be overwritten by the second chapter. Increase the start duration of second chapter.", @@ -1104,15 +1113,6 @@ "ToastUserPasswordMismatch": "Passwords do not match", "ToastUserPasswordMustChange": "New password cannot match old password", "ToastUserRootRequireName": "Must enter a root username", - "ToastChaptersAllLocked": "All chapters are locked. Unlock some chapters to shift their times.", - "ToastChapterInvalidShiftAmount": "Invalid shift amount. First chapter would have zero or negative length.", - "ToastBulkChapterInvalidCount": "Please enter a valid number between 1 and 150", - "PlaceholderBulkChapterInput": "Enter chapter title or use numbering (e.g., 'Episode 1', 'Chapter 10', '1.')", - "HeaderBulkChapterModal": "Add Multiple Chapters", - "MessageBulkChapterPattern": "How many chapters would you like to add with this numbering pattern?", - "LabelDetectedPattern": "Detected pattern:", - "LabelNextChapters": "Next chapters will be:", - "LabelNumberOfChapters": "Number of chapters:", "TooltipAddChapters": "Add chapter(s)", "TooltipAddOneSecond": "Add 1 second", "TooltipLockAllChapters": "Lock all chapters", diff --git a/client/strings/es.json b/client/strings/es.json index d81236f0..ca0fc91c 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -124,6 +124,7 @@ "HeaderAudiobookTools": "Herramientas de Gestión de Archivos de Audiolibro", "HeaderAuthentication": "Autenticación", "HeaderBackups": "Respaldos", + "HeaderBulkChapterModal": "Añadir Múltiples Capítulos", "HeaderChangePassword": "Cambiar contraseña", "HeaderChapters": "Capítulos", "HeaderChooseAFolder": "Escoger una Carpeta", @@ -297,6 +298,7 @@ "LabelDeleteFromFileSystemCheckbox": "Eliminar del sistema de archivos (desmarque para quitar de la base de datos solamente)", "LabelDescription": "Descripción", "LabelDeselectAll": "Deseleccionar Todos", + "LabelDetectedPattern": "Patrón detectado:", "LabelDevice": "Dispositivo", "LabelDeviceInfo": "Información del dispositivo", "LabelDeviceIsAvailableTo": "El dispositivo está disponible para...", @@ -454,6 +456,7 @@ "LabelNewestAuthors": "Autores más nuevos", "LabelNewestEpisodes": "Episodios más nuevos", "LabelNextBackupDate": "Fecha del siguiente respaldo", + "LabelNextChapters": "Los próximos capítulos serán:", "LabelNextScheduledRun": "Próxima ejecución programada", "LabelNoCustomMetadataProviders": "Sin proveedores de metadatos personalizados", "LabelNoEpisodesSelected": "Ningún Episodio Seleccionado", @@ -470,6 +473,7 @@ "LabelNotificationsMaxQueueSize": "Tamaño máximo de la cola de notificaciones", "LabelNotificationsMaxQueueSizeHelp": "Las notificaciones están limitadas a 1 por segundo. Las notificaciones serán ignoradas si llegan al numero máximo de cola para prevenir spam de eventos.", "LabelNumberOfBooks": "Número de libros", + "LabelNumberOfChapters": "Número de capítulos:", "LabelNumberOfEpisodes": "N.º de episodios", "LabelOpenIDAdvancedPermsClaimDescription": "Nombre de la notificación de OpenID que contiene permisos avanzados para acciones de usuario dentro de la aplicación que se aplicarán a roles que no sean de administrador (si están configurados). Si el reclamo no aparece en la respuesta, se denegará el acceso a ABS. Si falta una sola opción, se tratará como falsa. Asegúrese de que la notificación del proveedor de identidades coincida con la estructura esperada:", "LabelOpenIDClaims": "Deje las siguientes opciones vacías para desactivar la asignación avanzada de grupos y permisos, lo que asignaría de manera automática al grupo «Usuario».", @@ -722,6 +726,7 @@ "MessageBookshelfNoResultsForFilter": "El filtro «{0}: {1}» no produjo ningún resultado", "MessageBookshelfNoResultsForQuery": "No hay resultados para la consulta", "MessageBookshelfNoSeries": "No tiene ninguna serie", + "MessageBulkChapterPattern": "¿Cuántos capítulos desea añadir con este patrón de numeración?", "MessageChapterEndIsAfter": "El final del capítulo es después del final de tu audiolibro", "MessageChapterErrorFirstNotZero": "El primer capítulo debe iniciar en 0", "MessageChapterErrorStartGteDuration": "El tiempo de inicio no es válido: debe ser inferior a la duración del audiolibro", @@ -919,6 +924,7 @@ "NotificationOnBackupFailedDescription": "Se activa cuando falla una copia de seguridad", "NotificationOnEpisodeDownloadedDescription": "Se activa cuando se descarga automáticamente un episodio de un podcast", "NotificationOnTestDescription": "Evento para probar el sistema de notificaciones", + "PlaceholderBulkChapterInput": "Ingrese título de capítulo o use numeración (ej. 'Episodio 1', 'Capítulo 10', '1.')", "PlaceholderNewCollection": "Nuevo nombre de la colección", "PlaceholderNewFolderPath": "Nueva ruta de carpeta", "PlaceholderNewPlaylist": "Nuevo nombre de la lista de reproducción", @@ -972,8 +978,11 @@ "ToastBookmarkCreateFailed": "No se pudo crear el marcador", "ToastBookmarkCreateSuccess": "Marcador añadido", "ToastBookmarkRemoveSuccess": "Marcador eliminado", + "ToastBulkChapterInvalidCount": "Por favor ingrese un número válido entre 1 y 150", "ToastCachePurgeFailed": "No se pudo purgar la antememoria", "ToastCachePurgeSuccess": "Se purgó la antememoria correctamente", + "ToastChapterInvalidShiftAmount": "Cantidad de desplazamiento inválida. El primer capítulo tendría duración cero o negativa.", + "ToastChaptersAllLocked": "Todos los capítulos están bloqueados. Desbloquee algunos capítulos para cambiar sus tiempos.", "ToastChaptersHaveErrors": "Los capítulos tienen errores", "ToastChaptersInvalidShiftAmountLast": "Cantidad de desplazamiento no válida. La hora de inicio del último capítulo se extendería más allá de la duración de este audiolibro.", "ToastChaptersInvalidShiftAmountStart": "Cantidad de desplazamiento no válida. El primer capítulo tendría una duración cero o negativa y lo sobrescribiría el segundo capítulo. Aumente la duración inicial del segundo capítulo.", @@ -1104,15 +1113,6 @@ "ToastUserPasswordMismatch": "No coinciden las contraseñas", "ToastUserPasswordMustChange": "La nueva contraseña no puede ser igual que la anterior", "ToastUserRootRequireName": "Debe introducir un nombre de usuario administrativo", - "ToastChaptersAllLocked": "Todos los capítulos están bloqueados. Desbloquee algunos capítulos para cambiar sus tiempos.", - "ToastChapterInvalidShiftAmount": "Cantidad de desplazamiento inválida. El primer capítulo tendría duración cero o negativa.", - "ToastBulkChapterInvalidCount": "Por favor ingrese un número válido entre 1 y 150", - "PlaceholderBulkChapterInput": "Ingrese título de capítulo o use numeración (ej. 'Episodio 1', 'Capítulo 10', '1.')", - "HeaderBulkChapterModal": "Añadir Múltiples Capítulos", - "MessageBulkChapterPattern": "¿Cuántos capítulos desea añadir con este patrón de numeración?", - "LabelDetectedPattern": "Patrón detectado:", - "LabelNextChapters": "Los próximos capítulos serán:", - "LabelNumberOfChapters": "Número de capítulos:", "TooltipAddChapters": "Añadir capítulo(s)", "TooltipAddOneSecond": "Añadir 1 segundo", "TooltipLockAllChapters": "Bloquear todos los capítulos", diff --git a/client/strings/fr.json b/client/strings/fr.json index 88666f58..1d5f8859 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -124,6 +124,7 @@ "HeaderAudiobookTools": "Outils de gestion de fichiers de livres audio", "HeaderAuthentication": "Authentification", "HeaderBackups": "Sauvegardes", + "HeaderBulkChapterModal": "Ajouter Plusieurs Chapitres", "HeaderChangePassword": "Modifier le mot de passe", "HeaderChapters": "Chapitres", "HeaderChooseAFolder": "Sélectionner un dossier", @@ -296,6 +297,7 @@ "LabelDeleteFromFileSystemCheckbox": "Supprimer du système de fichiers (décocher pour ne supprimer que de la base de données)", "LabelDescription": "Description", "LabelDeselectAll": "Tout déselectionner", + "LabelDetectedPattern": "Motif détecté :", "LabelDevice": "Appareil", "LabelDeviceInfo": "Détail de l’appareil", "LabelDeviceIsAvailableTo": "L’appareil est disponible pour…", @@ -453,6 +455,7 @@ "LabelNewestAuthors": "Auteurs récents", "LabelNewestEpisodes": "Épisodes récents", "LabelNextBackupDate": "Date de la prochaine sauvegarde", + "LabelNextChapters": "Les prochains chapitres seront :", "LabelNextScheduledRun": "Prochain lancement prévu", "LabelNoCustomMetadataProviders": "Aucun fournisseurs de métadonnées personnalisés", "LabelNoEpisodesSelected": "Aucun épisode sélectionné", @@ -469,6 +472,7 @@ "LabelNotificationsMaxQueueSize": "Nombres de notifications maximum à mettre en attente", "LabelNotificationsMaxQueueSizeHelp": "La limite de notification est de un évènement par seconde. Les notifications seront ignorées si la file d’attente est à son maximum. Cela empêche un flot trop important.", "LabelNumberOfBooks": "Nombre de livres", + "LabelNumberOfChapters": "Nombre de chapitres :", "LabelNumberOfEpisodes": "Nombre d'épisodes", "LabelOpenIDAdvancedPermsClaimDescription": "Nom de la demande OpenID qui contient des autorisations avancées pour les actions de l’utilisateur dans l’application, qui s’appliqueront à des rôles autres que celui d’administrateur (s’il est configuré). Si la demande est absente de la réponse, l’accès à ABS sera refusé. Si une seule option est manquante, elle sera considérée comme false. Assurez-vous que la demande du fournisseur d’identité correspond à la structure attendue :", "LabelOpenIDClaims": "Laissez les options suivantes vides pour désactiver l’attribution avancée de groupes et d’autorisations, en attribuant alors automatiquement le groupe « Utilisateur ».", @@ -721,6 +725,7 @@ "MessageBookshelfNoResultsForFilter": "Aucun résultat pour le filtre « {0} : {1} »", "MessageBookshelfNoResultsForQuery": "Aucun résultat pour la requête", "MessageBookshelfNoSeries": "Vous n’avez aucune série", + "MessageBulkChapterPattern": "Combien de chapitres souhaitez-vous ajouter avec ce motif de numérotation ?", "MessageChapterEndIsAfter": "La fin du chapitre se situe après la fin de votre livre audio", "MessageChapterErrorFirstNotZero": "Le premier capitre doit débuter à 0", "MessageChapterErrorStartGteDuration": "Horodatage invalide car il doit débuter avant la fin du livre", @@ -918,6 +923,7 @@ "NotificationOnBackupFailedDescription": "Déclenché lorsqu'une sauvegarde échoue", "NotificationOnEpisodeDownloadedDescription": "Déclenché lorsqu’un épisode de podcast est téléchargé automatiquement", "NotificationOnTestDescription": "Événement pour tester le système de notification", + "PlaceholderBulkChapterInput": "Entrez le titre du chapitre ou utilisez la numérotation (ex. 'Épisode 1', 'Chapitre 10', '1.')", "PlaceholderNewCollection": "Nom de la nouvelle collection", "PlaceholderNewFolderPath": "Nouveau chemin de dossier", "PlaceholderNewPlaylist": "Nouveau nom de liste de lecture", @@ -971,8 +977,11 @@ "ToastBookmarkCreateFailed": "Échec de la création de signet", "ToastBookmarkCreateSuccess": "Signet ajouté", "ToastBookmarkRemoveSuccess": "Signet supprimé", + "ToastBulkChapterInvalidCount": "Veuillez entrer un nombre valide entre 1 et 150", "ToastCachePurgeFailed": "Échec de la purge du cache", "ToastCachePurgeSuccess": "Cache purgé avec succès", + "ToastChapterInvalidShiftAmount": "Montant de décalage invalide. Le premier chapitre aurait une durée nulle ou négative.", + "ToastChaptersAllLocked": "Tous les chapitres sont verrouillés. Déverrouillez certains chapitres pour décaler leurs temps.", "ToastChaptersHaveErrors": "Les chapitres contiennent des erreurs", "ToastChaptersInvalidShiftAmountLast": "Durée de décalage non valide. L’heure de début du dernier chapitre pourrait dépasser la durée de ce livre audio.", "ToastChaptersInvalidShiftAmountStart": "Durée de décalage non valide. Le premier chapitre aurait une longueur nulle ou négative et serait écrasé par le second. Augmentez la durée de début du second chapitre.", @@ -1103,15 +1112,6 @@ "ToastUserPasswordMismatch": "Les mots de passe ne correspondent pas", "ToastUserPasswordMustChange": "Le nouveau mot de passe ne peut pas être identique à l’ancien", "ToastUserRootRequireName": "Vous devez entrer un nom d’utilisateur root", - "ToastChaptersAllLocked": "Tous les chapitres sont verrouillés. Déverrouillez certains chapitres pour décaler leurs temps.", - "ToastChapterInvalidShiftAmount": "Montant de décalage invalide. Le premier chapitre aurait une durée nulle ou négative.", - "ToastBulkChapterInvalidCount": "Veuillez entrer un nombre valide entre 1 et 150", - "PlaceholderBulkChapterInput": "Entrez le titre du chapitre ou utilisez la numérotation (ex. 'Épisode 1', 'Chapitre 10', '1.')", - "HeaderBulkChapterModal": "Ajouter Plusieurs Chapitres", - "MessageBulkChapterPattern": "Combien de chapitres souhaitez-vous ajouter avec ce motif de numérotation ?", - "LabelDetectedPattern": "Motif détecté :", - "LabelNextChapters": "Les prochains chapitres seront :", - "LabelNumberOfChapters": "Nombre de chapitres :", "TooltipAddChapters": "Ajouter chapitre(s)", "TooltipAddOneSecond": "Ajouter 1 seconde", "TooltipLockAllChapters": "Verrouiller tous les chapitres", @@ -1119,4 +1119,4 @@ "TooltipSubtractOneSecond": "Soustraire 1 seconde", "TooltipUnlockAllChapters": "Déverrouiller tous les chapitres", "TooltipUnlockChapter": "Déverrouiller le chapitre (Maj+clic pour plage)" -} \ No newline at end of file +} From 54815ea9c7625ef1cae022a0bceabd6ce06100f4 Mon Sep 17 00:00:00 2001 From: Josh Vincent Date: Fri, 6 Jun 2025 13:25:20 -0600 Subject: [PATCH 3/5] Add a second to bulk chapters so its valid This will enable users to go in and fix the chapter timing later but still save easily with the bulk import. --- client/pages/audiobook/_id/chapters.vue | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/client/pages/audiobook/_id/chapters.vue b/client/pages/audiobook/_id/chapters.vue index 10840f25..26902240 100644 --- a/client/pages/audiobook/_id/chapters.vue +++ b/client/pages/audiobook/_id/chapters.vue @@ -922,14 +922,13 @@ export default { const { before, after, startingNumber } = this.detectedPattern const lastChapter = this.newChapters[this.newChapters.length - 1] - const baseStart = lastChapter ? lastChapter.end : 0 - const defaultDuration = 300 // 5 minutes per chapter + const baseStart = lastChapter ? lastChapter.start + 1 : 0 // Add multiple chapters with the detected pattern for (let i = 0; i < count; i++) { const chapterNumber = startingNumber + i - const newStart = baseStart + i * defaultDuration - const newEnd = Math.min(newStart + defaultDuration, this.mediaDuration) + const newStart = baseStart + i + const newEnd = Math.min(newStart + i + i, this.mediaDuration) const newChapter = { id: this.newChapters.length, From c41bdb951cd882e80c58109b958d9e37473cb44e Mon Sep 17 00:00:00 2001 From: Josh Vincent Date: Sat, 7 Jun 2025 14:48:05 -0600 Subject: [PATCH 4/5] Moves the lock button and fixes padding on bulk add feature. Moves the lock button the right of the Title text box. Enhances the bulk chapter add feature by preserving zero-padding in chapter titles and prevents editing of locked chapters. Also allows Enter to be pressed in the Add Multiple Chapters modal. Adds a warning toast when attempting to modify locked chapters. Fixes sizing of boxes on smaller windows --- client/pages/audiobook/_id/chapters.vue | 91 ++++++++++++++----------- client/strings/en-us.json | 1 + 2 files changed, 53 insertions(+), 39 deletions(-) diff --git a/client/pages/audiobook/_id/chapters.vue b/client/pages/audiobook/_id/chapters.vue index 26902240..4271b501 100644 --- a/client/pages/audiobook/_id/chapters.vue +++ b/client/pages/audiobook/_id/chapters.vue @@ -53,29 +53,20 @@
-
+
{{ $strings.LabelStart }}
+
{{ $strings.LabelTitle }}
+
-
-
{{ $strings.LabelStart }}
-
{{ $strings.LabelTitle }}
#{{ chapter.id + 1 }}
-
-
- - - -
-
-
+
+
+
+ + + +
+
@@ -136,14 +136,13 @@
-
-
+
- +
-
+
- @@ -266,15 +265,16 @@

{{ $strings.HeaderBulkChapterModal }}

{{ $strings.MessageBulkChapterPattern }}

+
- {{ $strings.LabelDetectedPattern }} "{{ detectedPattern.before }}{{ detectedPattern.startingNumber }}{{ detectedPattern.after }}" + {{ $strings.LabelDetectedPattern }} "{{ detectedPattern.before }}{{ formatNumberWithPadding(detectedPattern.startingNumber, detectedPattern) }}{{ detectedPattern.after }}"
{{ $strings.LabelNextChapters }} - "{{ detectedPattern.before }}{{ detectedPattern.startingNumber + 1 }}{{ detectedPattern.after }}", "{{ detectedPattern.before }}{{ detectedPattern.startingNumber + 2 }}{{ detectedPattern.after }}", etc. + "{{ detectedPattern.before }}{{ formatNumberWithPadding(detectedPattern.startingNumber + 1, detectedPattern) }}{{ detectedPattern.after }}", "{{ detectedPattern.before }}{{ formatNumberWithPadding(detectedPattern.startingNumber + 2, detectedPattern) }}{{ detectedPattern.after }}", etc.
- +
{{ $strings.ButtonAddChapters }} @@ -394,6 +394,12 @@ export default { } }, methods: { + formatNumberWithPadding(number, pattern) { + if (!pattern || !pattern.hasLeadingZeros || !pattern.originalPadding) { + return number.toString() + } + return number.toString().padStart(pattern.originalPadding, '0') + }, setChaptersFromTracks() { let currentStartTime = 0 let index = 0 @@ -460,15 +466,8 @@ export default { return } - // Find the previous chapter to ensure we don't go below it - const previousChapter = this.newChapters[chapter.id - 1] - if (previousChapter && chapter.start + amount <= previousChapter.start) { - return - } - - // Find the next chapter to ensure we don't go above it - const nextChapter = this.newChapters[chapter.id + 1] - if (nextChapter && chapter.start + amount >= nextChapter.start) { + if (this.lockedChapters.has(chapter.id)) { + this.$toast.warning(this.$strings.ToastChapterLocked) return } @@ -553,6 +552,10 @@ export default { this.checkChapters() }, removeChapter(chapter) { + if (this.lockedChapters.has(chapter.id)) { + this.$toast.warning(this.$strings.ToastChapterLocked) + return + } this.newChapters = this.newChapters.filter((ch) => ch.id !== chapter.id) this.checkChapters() }, @@ -874,17 +877,20 @@ export default { const numberMatch = input.match(/(\d+)/) if (numberMatch) { - // Extract the base pattern and number - const foundNumber = parseInt(numberMatch[1]) + // Extract the base pattern and number, preserving zero-padding + const originalNumberString = numberMatch[1] + const foundNumber = parseInt(originalNumberString) const numberIndex = numberMatch.index const beforeNumber = input.substring(0, numberIndex) - const afterNumber = input.substring(numberIndex + numberMatch[1].length) + const afterNumber = input.substring(numberIndex + originalNumberString.length) - // Store pattern info for bulk creation + // Store pattern info for bulk creation, preserving padding this.detectedPattern = { before: beforeNumber, after: afterNumber, - startingNumber: foundNumber + startingNumber: foundNumber, + originalPadding: originalNumberString.length, + hasLeadingZeros: originalNumberString.length > 1 && originalNumberString.startsWith('0') } // Show modal to ask for number of chapters @@ -920,13 +926,20 @@ export default { return } - const { before, after, startingNumber } = this.detectedPattern + const { before, after, startingNumber, originalPadding, hasLeadingZeros } = this.detectedPattern const lastChapter = this.newChapters[this.newChapters.length - 1] const baseStart = lastChapter ? lastChapter.start + 1 : 0 // Add multiple chapters with the detected pattern for (let i = 0; i < count; i++) { const chapterNumber = startingNumber + i + let formattedNumber = chapterNumber.toString() + + // Apply zero-padding if the original had leading zeros + if (hasLeadingZeros && originalPadding > 1) { + formattedNumber = chapterNumber.toString().padStart(originalPadding, '0') + } + const newStart = baseStart + i const newEnd = Math.min(newStart + i + i, this.mediaDuration) @@ -934,7 +947,7 @@ export default { id: this.newChapters.length, start: newStart, end: newEnd, - title: `${before}${chapterNumber}${after}` + title: `${before}${formattedNumber}${after}` } this.newChapters.push(newChapter) diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 64c7d99c..a3923699 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -982,6 +982,7 @@ "ToastCachePurgeFailed": "Failed to purge cache", "ToastCachePurgeSuccess": "Cache purged successfully", "ToastChapterInvalidShiftAmount": "Invalid shift amount. First chapter would have zero or negative length.", + "ToastChapterLocked": "Chapter is locked.", "ToastChaptersAllLocked": "All chapters are locked. Unlock some chapters to shift their times.", "ToastChaptersHaveErrors": "Chapters have errors", "ToastChaptersInvalidShiftAmountLast": "Invalid shift amount. The last chapter start time would extend beyond the duration of this audiobook.", From 9da0be6d36da4ea9be4dab70041ee371fc538854 Mon Sep 17 00:00:00 2001 From: Josh Vincent Date: Sat, 7 Jun 2025 19:56:36 -0600 Subject: [PATCH 5/5] Allow clicking on elapsedTime to adjust chapter start --- client/pages/audiobook/_id/chapters.vue | 36 +++++++++---------------- client/strings/en-us.json | 2 ++ 2 files changed, 15 insertions(+), 23 deletions(-) diff --git a/client/pages/audiobook/_id/chapters.vue b/client/pages/audiobook/_id/chapters.vue index 4271b501..c723baf1 100644 --- a/client/pages/audiobook/_id/chapters.vue +++ b/client/pages/audiobook/_id/chapters.vue @@ -123,9 +123,9 @@ play_arrow - - -
{{ elapsedTime }}s
+ +
{{ elapsedTime }}s
+