diff --git a/client/pages/audiobook/_id/chapters.vue b/client/pages/audiobook/_id/chapters.vue index 708a7e0a1..a2fe42ffc 100644 --- a/client/pages/audiobook/_id/chapters.vue +++ b/client/pages/audiobook/_id/chapters.vue @@ -53,51 +53,101 @@
-
{{ $strings.LabelStart }}
-
{{ $strings.LabelTitle }}
+
{{ $strings.LabelStart }}
+
{{ $strings.LabelTitle }}
+
+ + + +
- +
+ +
+
+
+ + + +
+
+
+
+ + + + + + + + + + + +
{{ elapsedTime }}s
+
+ + + +
+
+ +
+
+
+
+ +
+
+ + + +
+
@@ -114,19 +164,15 @@
{{ $strings.LabelDuration }}
- +
+

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

+
+ + @@ -134,6 +180,7 @@ + @@ -265,7 +348,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,9 +397,18 @@ 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: { + 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 @@ -321,7 +423,7 @@ export default { currentStartTime += track.duration } this.newChapters = chapters - + this.lockedChapters = new Set() this.checkChapters() }, toggleRemoveBranding() { @@ -334,19 +436,22 @@ 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) - return - } + // Check if any unlocked chapters would be affected negatively + const unlockedChapters = this.newChapters.filter((chap) => !this.lockedChapters.has(chap.id)) - if (this.newChapters[1].start + amount <= 0) { - this.$toast.error(this.$strings.ToastChaptersInvalidShiftAmountStart) + if (unlockedChapters.length === 0) { + this.$toast.warning(this.$strings.ToastChaptersAllLocked) 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 +459,83 @@ export default { } this.checkChapters() }, + incrementChapterTime(chapter, amount) { + if (chapter.id === 0 && chapter.start + amount < 0) { + return + } + if (chapter.start + amount >= this.mediaDuration) { + return + } + + chapter.start = Math.max(0, chapter.start + amount) + this.checkChapters() + }, + adjustChapterStartTime(chapter) { + const newStartTime = chapter.start + this.elapsedTime + chapter.start = newStartTime + this.checkChapters() + this.$toast.success(this.$strings.ToastChapterStartTimeAdjusted.replace('{0}', this.elapsedTime)) + + this.destroyAudioEl() + }, + startElapsedTimeTracking() { + this.elapsedTime = 0 + this.playStartTime = Date.now() + this.elapsedTimeInterval = setInterval(() => { + this.elapsedTime = Math.floor((Date.now() - this.playStartTime) / 1000) + }, 100) + }, + stopElapsedTimeTracking() { + if (this.elapsedTimeInterval) { + clearInterval(this.elapsedTimeInterval) + this.elapsedTimeInterval = null + } + this.elapsedTime = 0 + this.playStartTime = null + }, + toggleChapterLock(chapter, event) { + const chapterId = chapter.id + + if (event.shiftKey && this.lastSelectedLockIndex !== null) { + const startIndex = Math.min(this.lastSelectedLockIndex, chapterId) + const endIndex = Math.max(this.lastSelectedLockIndex, chapterId) + 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 { + if (this.lockedChapters.has(chapterId)) { + this.lockedChapters.delete(chapterId) + } else { + this.lockedChapters.add(chapterId) + } + } + + this.lastSelectedLockIndex = chapterId + 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) }, @@ -368,6 +550,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() }, @@ -451,6 +637,7 @@ export default { console.log('Audio playing') this.isLoadingChapter = false this.isPlayingChapter = true + this.startElapsedTimeTracking() }) audioEl.addEventListener('ended', () => { console.log('Audio ended') @@ -473,6 +660,10 @@ export default { this.selectedChapter = null this.isPlayingChapter = false this.isLoadingChapter = false + this.stopElapsedTimeTracking() + }, + resetChapterLookupData() { + this.chapterData = null }, saveChapters() { this.checkChapters() @@ -523,7 +714,7 @@ export default { }, applyChapterNamesOnly() { this.newChapters.forEach((chapter, index) => { - if (this.chapterData.chapters[index]) { + if (this.chapterData.chapters[index] && !this.lockedChapters.has(chapter.id)) { chapter.title = this.chapterData.chapters[index].title } }) @@ -535,7 +726,7 @@ export default { }, applyChapterData() { let index = 0 - this.newChapters = this.chapterData.chapters + const audibleChapters = this.chapterData.chapters .filter((chap) => chap.startOffsetSec < this.mediaDuration) .map((chap) => { return { @@ -545,6 +736,21 @@ export default { title: chap.title } }) + + const merged = [] + let audibleIdx = 0 + for (let i = 0; i < Math.max(this.newChapters.length, audibleChapters.length); i++) { + const isLocked = this.lockedChapters.has(i) + if (isLocked && this.newChapters[i]) { + merged.push({ ...this.newChapters[i], id: i }) + } else if (audibleChapters[audibleIdx]) { + merged.push({ ...audibleChapters[audibleIdx], id: i }) + audibleIdx++ + } else if (this.newChapters[i]) { + merged.push({ ...this.newChapters[i], id: i }) + } + } + this.newChapters = merged this.showFindChaptersModal = false this.chapterData = null @@ -643,6 +849,7 @@ export default { } ] } + this.lockedChapters = new Set() this.checkChapters() }, removeAllChaptersClick() { @@ -684,6 +891,91 @@ export default { this.saving = false }) }, + handleBulkChapterAdd() { + const input = this.bulkChapterInput.trim() + if (!input) return + + const numberMatch = input.match(/(\d+)/) + + if (numberMatch) { + // 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 + originalNumberString.length) + + this.detectedPattern = { + before: beforeNumber, + after: afterNumber, + startingNumber: foundNumber, + originalPadding: originalNumberString.length, + hasLeadingZeros: originalNumberString.length > 1 && originalNumberString.startsWith('0') + } + + this.bulkChapterCount = 1 + this.showBulkChapterModal = true + } else { + 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) + + 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, 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) + + const newChapter = { + id: this.newChapters.length, + start: newStart, + end: newEnd, + title: `${before}${formattedNumber}${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 70ac874d2..5885445b8 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -127,6 +127,7 @@ "HeaderAudiobookTools": "Audiobook File Management Tools", "HeaderAuthentication": "Authentication", "HeaderBackups": "Backups", + "HeaderBulkChapterModal": "Add Multiple Chapters", "HeaderChangePassword": "Change Password", "HeaderChapters": "Chapters", "HeaderChooseAFolder": "Choose a Folder", @@ -308,6 +309,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...", @@ -472,6 +474,7 @@ "LabelNewestAuthors": "Newest Authors", "LabelNewestEpisodes": "Newest Episodes", "LabelNextBackupDate": "Next backup date", + "LabelNextChapters": "Next chapters will be:", "LabelNextScheduledRun": "Next scheduled run", "LabelNoApiKeys": "No API keys", "LabelNoCustomMetadataProviders": "No custom metadata providers", @@ -489,6 +492,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.", @@ -745,6 +749,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", @@ -948,6 +953,7 @@ "NotificationOnRSSFeedDisabledDescription": "Triggered when automatic episode downloads are disabled due to too many failed attempts", "NotificationOnRSSFeedFailedDescription": "Triggered when the RSS feed request fails for an automatic episode download", "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", @@ -1001,8 +1007,12 @@ "ToastBookmarkCreateFailed": "Failed to create bookmark", "ToastBookmarkCreateSuccess": "Bookmark added", "ToastBookmarkRemoveSuccess": "Bookmark removed", + "ToastBulkChapterInvalidCount": "Enter a number between 1 and 150", "ToastCachePurgeFailed": "Failed to purge cache", "ToastCachePurgeSuccess": "Cache purged successfully", + "ToastChapterLocked": "Chapter is locked.", + "ToastChapterStartTimeAdjusted": "Chapter start time adjusted by {0} seconds", + "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.", @@ -1136,5 +1146,13 @@ "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", + "TooltipAddChapters": "Add chapter(s)", + "TooltipAddOneSecond": "Add 1 second", + "TooltipAdjustChapterStart": "Click to adjust start time", + "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 4dac82720..a70006405 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,10 @@ "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", + "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.", @@ -1103,5 +1111,12 @@ "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", + "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 8193c41c6..796b549c5 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -127,6 +127,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", @@ -308,6 +309,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…", @@ -472,6 +474,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", "LabelNoApiKeys": "Aucune clé API", "LabelNoCustomMetadataProviders": "Aucun fournisseurs de métadonnées personnalisés", @@ -489,6 +492,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 ».", @@ -745,6 +749,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", @@ -948,6 +953,7 @@ "NotificationOnRSSFeedDisabledDescription": "Déclenché lorsque les téléchargements automatiques d’épisodes sont désactivés en raison d’un trop grand nombre de tentatives infructueuses", "NotificationOnRSSFeedFailedDescription": "Déclenché lorsque la demande de flux RSS échoue pour un téléchargement automatique d’épisode", "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", @@ -1001,8 +1007,10 @@ "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", + "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.", @@ -1136,5 +1144,12 @@ "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", + "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)" }