mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Merge pull request #4384 from josh-vin/feat/ChaptersEnhancments
Enhancement: Improves chapter editing and adds bulk import
This commit is contained in:
		
						commit
						dcaca43817
					
				| @ -53,51 +53,101 @@ | |||||||
| 
 | 
 | ||||||
|         <div class="flex text-xs uppercase text-gray-300 font-semibold mb-2"> |         <div class="flex text-xs uppercase text-gray-300 font-semibold mb-2"> | ||||||
|           <div class="w-8 min-w-8 md:w-12 md:min-w-12"></div> |           <div class="w-8 min-w-8 md:w-12 md:min-w-12"></div> | ||||||
|           <div class="w-24 min-w-24 md:w-32 md:min-w-32 px-2">{{ $strings.LabelStart }}</div> |           <div class="w-38 min-w-38 md:w-40 md:min-w-40 px-1 pl-8">{{ $strings.LabelStart }}</div> | ||||||
|           <div class="grow px-2">{{ $strings.LabelTitle }}</div> |           <div class="grow px-1 min-w-54">{{ $strings.LabelTitle }}</div> | ||||||
|  |           <div class="w-7 min-w-7 px-1 flex items-center justify-center"> | ||||||
|  |             <ui-tooltip :text="allChaptersLocked ? $strings.TooltipUnlockAllChapters : $strings.TooltipLockAllChapters" direction="bottom"> | ||||||
|  |               <button class="w-7 h-7 rounded-full flex items-center justify-center cursor-pointer transition-colors duration-150" :class="allChaptersLocked ? 'text-orange-400 hover:text-orange-300' : 'text-gray-300 hover:text-white'" @click="toggleAllChaptersLock"> | ||||||
|  |                 <span class="material-symbols text-xl">{{ allChaptersLocked ? 'lock' : 'lock_open' }}</span> | ||||||
|  |               </button> | ||||||
|  |             </ui-tooltip> | ||||||
|  |           </div> | ||||||
|           <div class="w-32"></div> |           <div class="w-32"></div> | ||||||
|         </div> |         </div> | ||||||
|         <template v-for="chapter in newChapters"> |         <div v-for="chapter in newChapters" :key="chapter.id" class="flex py-1"> | ||||||
|           <div :key="chapter.id" class="flex py-1"> |           <div class="w-8 min-w-8 md:w-12 md:min-w-12">#{{ chapter.id + 1 }}</div> | ||||||
|             <div class="w-8 min-w-8 md:w-12 md:min-w-12">#{{ chapter.id + 1 }}</div> |           <div class="w-38 min-w-38 md:w-40 md:min-w-40 px-1"> | ||||||
|             <div class="w-24 min-w-24 md:w-32 md:min-w-32 px-1"> |             <div class="flex items-center gap-1"> | ||||||
|               <ui-text-input v-if="showSecondInputs" v-model="chapter.start" type="number" class="text-xs" @change="checkChapters" /> |               <ui-tooltip :text="$strings.TooltipSubtractOneSecond" direction="bottom"> | ||||||
|               <ui-time-picker v-else class="text-xs" v-model="chapter.start" :show-three-digit-hour="mediaDuration >= 360000" @change="checkChapters" /> |                 <button | ||||||
|             </div> |                   class="w-6 h-6 rounded-full flex items-center justify-center text-gray-300 hover:text-white transform hover:scale-110 duration-150 flex-shrink-0" | ||||||
|             <div class="grow px-1"> |                   :class="{ 'opacity-50 cursor-not-allowed': chapter.id === 0 && chapter.start - timeIncrementAmount < 0 }" | ||||||
|               <ui-text-input v-model="chapter.title" @change="checkChapters" class="text-xs min-w-52" /> |                   @click="incrementChapterTime(chapter, -timeIncrementAmount)" | ||||||
|             </div> |                   :disabled="chapter.id === 0 && chapter.start - timeIncrementAmount < 0" | ||||||
|             <div class="w-32 min-w-32 px-2 py-1"> |                 > | ||||||
|               <div class="flex items-center"> |                   <span class="material-symbols text-sm">remove</span> | ||||||
|                 <ui-tooltip :text="$strings.MessageRemoveChapter" direction="bottom"> |                 </button> | ||||||
|                   <button v-if="newChapters.length > 1" class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-error transform hover:scale-110 duration-150" @click="removeChapter(chapter)"> |               </ui-tooltip> | ||||||
|                     <span class="material-symbols text-base">remove</span> |  | ||||||
|                   </button> |  | ||||||
|                 </ui-tooltip> |  | ||||||
| 
 | 
 | ||||||
|                 <ui-tooltip :text="$strings.MessageInsertChapterBelow" direction="bottom"> |               <div class="flex-1 min-w-0"> | ||||||
|                   <button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-success transform hover:scale-110 duration-150" @click="addChapter(chapter)"> |                 <ui-text-input v-if="showSecondInputs" v-model="chapter.start" type="number" class="text-xs" @change="checkChapters" /> | ||||||
|                     <span class="material-symbols text-lg">add</span> |                 <ui-time-picker v-else class="text-xs" v-model="chapter.start" :show-three-digit-hour="mediaDuration >= 360000" @change="checkChapters" /> | ||||||
|                   </button> |  | ||||||
|                 </ui-tooltip> |  | ||||||
| 
 |  | ||||||
|                 <ui-tooltip :text="selectedChapterId === chapter.id && isPlayingChapter ? $strings.MessagePauseChapter : $strings.MessagePlayChapter" direction="bottom"> |  | ||||||
|                   <button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-white transform hover:scale-110 duration-150" @click="playChapter(chapter)"> |  | ||||||
|                     <widgets-loading-spinner v-if="selectedChapterId === chapter.id && isLoadingChapter" /> |  | ||||||
|                     <span v-else-if="selectedChapterId === chapter.id && isPlayingChapter" class="material-symbols text-base">pause</span> |  | ||||||
|                     <span v-else class="material-symbols text-base">play_arrow</span> |  | ||||||
|                   </button> |  | ||||||
|                 </ui-tooltip> |  | ||||||
| 
 |  | ||||||
|                 <ui-tooltip v-if="chapter.error" :text="chapter.error" direction="left"> |  | ||||||
|                   <button class="w-7 h-7 rounded-full flex items-center justify-center text-error"> |  | ||||||
|                     <span class="material-symbols text-lg">error_outline</span> |  | ||||||
|                   </button> |  | ||||||
|                 </ui-tooltip> |  | ||||||
|               </div> |               </div> | ||||||
|  | 
 | ||||||
|  |               <ui-tooltip :text="$strings.TooltipAddOneSecond" direction="bottom"> | ||||||
|  |                 <button class="w-6 h-6 rounded-full flex items-center justify-center text-gray-300 hover:text-white transform hover:scale-110 duration-150 flex-shrink-0" :class="{ 'opacity-50 cursor-not-allowed': chapter.start + timeIncrementAmount >= mediaDuration }" @click="incrementChapterTime(chapter, timeIncrementAmount)" :disabled="chapter.start + timeIncrementAmount >= mediaDuration"> | ||||||
|  |                   <span class="material-symbols text-sm">add</span> | ||||||
|  |                 </button> | ||||||
|  |               </ui-tooltip> | ||||||
|             </div> |             </div> | ||||||
|           </div> |           </div> | ||||||
|         </template> |           <div class="grow px-1"> | ||||||
|  |             <ui-text-input v-model="chapter.title" @change="checkChapters" class="text-xs min-w-52" /> | ||||||
|  |           </div> | ||||||
|  |           <div class="w-7 min-w-7 px-1 py-1"> | ||||||
|  |             <div class="flex items-center justify-center"> | ||||||
|  |               <ui-tooltip :text="lockedChapters.has(chapter.id) ? $strings.TooltipUnlockChapter : $strings.TooltipLockChapter" direction="bottom"> | ||||||
|  |                 <button class="w-7 h-7 rounded-full flex items-center justify-center transform hover:scale-110 duration-150 flex-shrink-0" :class="lockedChapters.has(chapter.id) ? 'text-orange-400 hover:text-orange-300' : 'text-gray-300 hover:text-white'" @click="toggleChapterLock(chapter, $event)"> | ||||||
|  |                   <span class="material-symbols text-base">{{ lockedChapters.has(chapter.id) ? 'lock' : 'lock_open' }}</span> | ||||||
|  |                 </button> | ||||||
|  |               </ui-tooltip> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |           <div class="w-32 min-w-32 px-2 py-1"> | ||||||
|  |             <div class="flex items-center"> | ||||||
|  |               <ui-tooltip :text="$strings.MessageRemoveChapter" direction="bottom"> | ||||||
|  |                 <button v-if="newChapters.length > 1" class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-error transform hover:scale-110 duration-150" @click="removeChapter(chapter)"> | ||||||
|  |                   <span class="material-symbols text-base">delete</span> | ||||||
|  |                 </button> | ||||||
|  |               </ui-tooltip> | ||||||
|  | 
 | ||||||
|  |               <ui-tooltip :text="$strings.MessageInsertChapterBelow" direction="bottom"> | ||||||
|  |                 <button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-success transform hover:scale-110 duration-150" @click="addChapter(chapter)"> | ||||||
|  |                   <span class="material-symbols text-lg">add_row_below</span> | ||||||
|  |                 </button> | ||||||
|  |               </ui-tooltip> | ||||||
|  |               <ui-tooltip :text="selectedChapterId === chapter.id && isPlayingChapter ? $strings.MessagePauseChapter : $strings.MessagePlayChapter" direction="bottom"> | ||||||
|  |                 <button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-white transform hover:scale-110 duration-150" @click="playChapter(chapter)"> | ||||||
|  |                   <widgets-loading-spinner v-if="selectedChapterId === chapter.id && isLoadingChapter" /> | ||||||
|  |                   <span v-else-if="selectedChapterId === chapter.id && isPlayingChapter" class="material-symbols text-base">pause</span> | ||||||
|  |                   <span v-else class="material-symbols text-base">play_arrow</span> | ||||||
|  |                 </button> | ||||||
|  |               </ui-tooltip> | ||||||
|  |               <ui-tooltip v-if="selectedChapterId === chapter.id && (isPlayingChapter || isLoadingChapter)" :text="$strings.TooltipAdjustChapterStart" direction="bottom"> | ||||||
|  |                 <div class="ml-2 text-xs text-gray-300 font-mono min-w-10 cursor-pointer hover:text-white transition-colors duration-150" @click="adjustChapterStartTime(chapter)">{{ elapsedTime }}s</div> | ||||||
|  |               </ui-tooltip> | ||||||
|  |               <ui-tooltip v-if="chapter.error" :text="chapter.error" direction="left"> | ||||||
|  |                 <button class="w-7 h-7 rounded-full flex items-center justify-center text-error"> | ||||||
|  |                   <span class="material-symbols text-lg">error_outline</span> | ||||||
|  |                 </button> | ||||||
|  |               </ui-tooltip> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |         <div class="flex items-center mt-4 mb-2"> | ||||||
|  |           <div class="w-8 min-w-8 md:w-12 md:min-w-12"></div> | ||||||
|  |           <div class="w-38 min-w-38 md:w-40 md:min-w-40 px-1"></div> | ||||||
|  |           <div class="flex items-center gap-2 grow px-1"> | ||||||
|  |             <ui-text-input v-model="bulkChapterInput" :placeholder="$strings.PlaceholderBulkChapterInput" class="text-xs grow min-w-52" @keyup.enter="handleBulkChapterAdd" /> | ||||||
|  |           </div> | ||||||
|  |           <div class="w-39 min-w-39 px-1 py-1"> | ||||||
|  |             <ui-tooltip :text="$strings.TooltipAddChapters" direction="bottom" class="inline-block align-middle"> | ||||||
|  |               <button class="w-5 h-5 rounded-full flex items-center justify-center text-gray-300 hover:text-success transform hover:scale-110 duration-150 flex-shrink-0" :aria-label="$strings.TooltipAddChapters" :class="{ 'opacity-50 cursor-not-allowed': !bulkChapterInput.trim() }" :disabled="!bulkChapterInput.trim()" @click="handleBulkChapterAdd"> | ||||||
|  |                 <span class="material-symbols text-lg">add</span> | ||||||
|  |               </button> | ||||||
|  |             </ui-tooltip> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|       </div> |       </div> | ||||||
| 
 | 
 | ||||||
|       <div class="w-full max-w-xl py-4 px-2"> |       <div class="w-full max-w-xl py-4 px-2"> | ||||||
| @ -114,19 +164,15 @@ | |||||||
|           <div class="w-20">{{ $strings.LabelDuration }}</div> |           <div class="w-20">{{ $strings.LabelDuration }}</div> | ||||||
|           <div class="w-20 hidden md:block text-center">{{ $strings.HeaderChapters }}</div> |           <div class="w-20 hidden md:block text-center">{{ $strings.HeaderChapters }}</div> | ||||||
|         </div> |         </div> | ||||||
|         <template v-for="track in audioTracks"> |         <div v-for="track in audioTracks" :key="track.ino" class="flex items-center py-2" :class="currentTrackIndex === track.index && isPlayingChapter ? 'bg-success/10' : ''"> | ||||||
|           <div :key="track.ino" class="flex items-center py-2" :class="currentTrackIndex === track.index && isPlayingChapter ? 'bg-success/10' : ''"> |           <div class="grow max-w-[calc(100%-80px)] pr-2"> | ||||||
|             <div class="grow max-w-[calc(100%-80px)] pr-2"> |             <p class="text-xs truncate max-w-sm">{{ track.metadata.filename }}</p> | ||||||
|               <p class="text-xs truncate max-w-sm">{{ track.metadata.filename }}</p> |  | ||||||
|             </div> |  | ||||||
|             <div class="w-20" style="min-width: 80px"> |  | ||||||
|               <p class="text-xs font-mono text-gray-200">{{ $secondsToTimestamp(Math.round(track.duration), false, true) }}</p> |  | ||||||
|             </div> |  | ||||||
|             <div class="w-20 hidden md:flex justify-center" style="min-width: 80px"> |  | ||||||
|               <span v-if="(track.chapters || []).length" class="material-symbols text-success text-sm">check</span> |  | ||||||
|             </div> |  | ||||||
|           </div> |           </div> | ||||||
|         </template> |           <div class="w-20" style="min-width: 80px"> | ||||||
|  |             <p class="text-xs font-mono text-gray-200">{{ $secondsToTimestamp(Math.round(track.duration), false, true) }}</p> | ||||||
|  |           </div> | ||||||
|  |           <div class="w-20 hidden md:flex justify-center" style="min-width: 80px"><span v-if="(track.chapters || []).length" class="material-symbols text-success text-sm">check</span></div> | ||||||
|  |         </div> | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
| 
 | 
 | ||||||
| @ -134,6 +180,7 @@ | |||||||
|       <ui-loading-indicator /> |       <ui-loading-indicator /> | ||||||
|     </div> |     </div> | ||||||
| 
 | 
 | ||||||
|  |     <!-- audible chapter lookup modal --> | ||||||
|     <modals-modal v-model="showFindChaptersModal" name="edit-book" :width="500" :processing="findingChapters"> |     <modals-modal v-model="showFindChaptersModal" name="edit-book" :width="500" :processing="findingChapters"> | ||||||
|       <template #outer> |       <template #outer> | ||||||
|         <div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none"> |         <div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none"> | ||||||
| @ -159,12 +206,16 @@ | |||||||
|           </div> |           </div> | ||||||
|         </div> |         </div> | ||||||
|         <div v-else class="w-full p-4"> |         <div v-else class="w-full p-4"> | ||||||
|           <div class="flex justify-between mb-4"> |           <div class="flex mb-4"> | ||||||
|  |             <button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-white flex-shrink-0" :aria-label="$strings.ButtonBack" @click="resetChapterLookupData"> | ||||||
|  |               <span class="material-symbols text-lg">arrow_back</span> | ||||||
|  |             </button> | ||||||
|             <p> |             <p> | ||||||
|               {{ $strings.LabelDurationFound }} <span class="font-semibold">{{ $secondsToTimestamp(chapterData.runtimeLengthSec) }}</span |               {{ $strings.LabelDurationFound }} <span class="font-semibold">{{ $secondsToTimestamp(chapterData.runtimeLengthSec) }}</span> | ||||||
|               ><br /> |               <br /> | ||||||
|               <span class="font-semibold" :class="{ 'text-warning': chapters.length !== chapterData.chapters.length }">{{ chapterData.chapters.length }}</span> {{ $strings.LabelChaptersFound }} |               <span class="font-semibold" :class="{ 'text-warning': chapters.length !== chapterData.chapters.length }">{{ chapterData.chapters.length }}</span> {{ $strings.LabelChaptersFound }} | ||||||
|             </p> |             </p> | ||||||
|  |             <div class="grow" /> | ||||||
|             <p> |             <p> | ||||||
|               {{ $strings.LabelYourAudiobookDuration }}: <span class="font-semibold">{{ $secondsToTimestamp(mediaDurationRounded) }}</span |               {{ $strings.LabelYourAudiobookDuration }}: <span class="font-semibold">{{ $secondsToTimestamp(mediaDurationRounded) }}</span | ||||||
|               ><br /> |               ><br /> | ||||||
| @ -198,17 +249,49 @@ | |||||||
|               <p class="pl-2">{{ $strings.MessageChapterStartIsAfter }}</p> |               <p class="pl-2">{{ $strings.MessageChapterStartIsAfter }}</p> | ||||||
|             </div> |             </div> | ||||||
|           </div> |           </div> | ||||||
|           <div class="flex items-center pt-2"> |           <div class="flex items-center pt-2 justify-between"> | ||||||
|             <ui-btn small color="bg-primary" class="mr-1" @click="applyChapterNamesOnly">{{ $strings.ButtonMapChapterTitles }}</ui-btn> |             <div class="flex items-center gap-2"> | ||||||
|             <ui-tooltip :text="$strings.MessageMapChapterTitles" direction="top" class="flex items-center"> |               <ui-btn small color="bg-primary" @click="applyChapterNamesOnly">{{ $strings.ButtonMapChapterTitles }}</ui-btn> | ||||||
|               <span class="material-symbols text-xl text-gray-200">info</span> |               <ui-tooltip :text="$strings.MessageMapChapterTitles" direction="top" class="flex items-center"> | ||||||
|             </ui-tooltip> |                 <span class="material-symbols text-xl text-gray-200">info</span> | ||||||
|             <div class="grow" /> |               </ui-tooltip> | ||||||
|  |             </div> | ||||||
|             <ui-btn small color="bg-success" @click="applyChapterData">{{ $strings.ButtonApplyChapters }}</ui-btn> |             <ui-btn small color="bg-success" @click="applyChapterData">{{ $strings.ButtonApplyChapters }}</ui-btn> | ||||||
|           </div> |           </div> | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
|     </modals-modal> |     </modals-modal> | ||||||
|  | 
 | ||||||
|  |     <!-- create bulk chapters modal --> | ||||||
|  |     <modals-modal v-model="showBulkChapterModal" name="bulk-chapters" :width="400"> | ||||||
|  |       <template #outer> | ||||||
|  |         <div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none"> | ||||||
|  |           <p class="text-3xl text-white truncate pointer-events-none">{{ $strings.HeaderBulkChapterModal }}</p> | ||||||
|  |         </div> | ||||||
|  |       </template> | ||||||
|  |       <div class="w-full h-full max-h-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative p-6"> | ||||||
|  |         <div class="flex flex-col space-y-8"> | ||||||
|  |           <p class="text-base">{{ $strings.MessageBulkChapterPattern }}</p> | ||||||
|  | 
 | ||||||
|  |           <div v-if="detectedPattern" class="text-sm text-gray-400 bg-gray-800 p-2 rounded"> | ||||||
|  |             <strong>{{ $strings.LabelDetectedPattern }}</strong> "{{ detectedPattern.before }}{{ formatNumberWithPadding(detectedPattern.startingNumber, detectedPattern) }}{{ detectedPattern.after }}" | ||||||
|  |             <br /> | ||||||
|  |             <strong>{{ $strings.LabelNextChapters }}</strong> | ||||||
|  |             "{{ detectedPattern.before }}{{ formatNumberWithPadding(detectedPattern.startingNumber + 1, detectedPattern) }}{{ detectedPattern.after }}", "{{ detectedPattern.before }}{{ formatNumberWithPadding(detectedPattern.startingNumber + 2, detectedPattern) }}{{ detectedPattern.after }}", etc. | ||||||
|  |           </div> | ||||||
|  |           <div class="flex px-1 items-center"> | ||||||
|  |             <label class="text-base font-medium">{{ $strings.LabelNumberOfChapters }}</label> | ||||||
|  |             <div class="grow" /> | ||||||
|  |             <ui-text-input v-model="bulkChapterCount" type="number" min="1" max="50" class="w-14" :style="{ height: `2em` }" @keyup.enter="addBulkChapters" /> | ||||||
|  |           </div> | ||||||
|  |           <div class="flex px-1 items-center"> | ||||||
|  |             <ui-btn small @click="showBulkChapterModal = false">{{ $strings.ButtonCancel }}</ui-btn> | ||||||
|  |             <div class="grow" /> | ||||||
|  |             <ui-btn small color="bg-success" @click="addBulkChapters">{{ $strings.ButtonAddChapters }}</ui-btn> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </modals-modal> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| @ -265,7 +348,17 @@ export default { | |||||||
|       removeBranding: false, |       removeBranding: false, | ||||||
|       showSecondInputs: false, |       showSecondInputs: false, | ||||||
|       audibleRegions: ['US', 'CA', 'UK', 'AU', 'FR', 'DE', 'JP', 'IT', 'IN', 'ES'], |       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: { |   computed: { | ||||||
| @ -304,9 +397,18 @@ export default { | |||||||
|     }, |     }, | ||||||
|     selectedChapterId() { |     selectedChapterId() { | ||||||
|       return this.selectedChapter ? this.selectedChapter.id : null |       return this.selectedChapter ? this.selectedChapter.id : null | ||||||
|  |     }, | ||||||
|  |     allChaptersLocked() { | ||||||
|  |       return this.newChapters.length > 0 && this.newChapters.every((chapter) => this.lockedChapters.has(chapter.id)) | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   methods: { |   methods: { | ||||||
|  |     formatNumberWithPadding(number, pattern) { | ||||||
|  |       if (!pattern || !pattern.hasLeadingZeros || !pattern.originalPadding) { | ||||||
|  |         return number.toString() | ||||||
|  |       } | ||||||
|  |       return number.toString().padStart(pattern.originalPadding, '0') | ||||||
|  |     }, | ||||||
|     setChaptersFromTracks() { |     setChaptersFromTracks() { | ||||||
|       let currentStartTime = 0 |       let currentStartTime = 0 | ||||||
|       let index = 0 |       let index = 0 | ||||||
| @ -321,7 +423,7 @@ export default { | |||||||
|         currentStartTime += track.duration |         currentStartTime += track.duration | ||||||
|       } |       } | ||||||
|       this.newChapters = chapters |       this.newChapters = chapters | ||||||
| 
 |       this.lockedChapters = new Set() | ||||||
|       this.checkChapters() |       this.checkChapters() | ||||||
|     }, |     }, | ||||||
|     toggleRemoveBranding() { |     toggleRemoveBranding() { | ||||||
| @ -334,19 +436,22 @@ export default { | |||||||
| 
 | 
 | ||||||
|       const amount = Number(this.shiftAmount) |       const amount = Number(this.shiftAmount) | ||||||
| 
 | 
 | ||||||
|       const lastChapter = this.newChapters[this.newChapters.length - 1] |       // Check if any unlocked chapters would be affected negatively | ||||||
|       if (lastChapter.start + amount > this.mediaDurationRounded) { |       const unlockedChapters = this.newChapters.filter((chap) => !this.lockedChapters.has(chap.id)) | ||||||
|         this.$toast.error(this.$strings.ToastChaptersInvalidShiftAmountLast) |  | ||||||
|         return |  | ||||||
|       } |  | ||||||
| 
 | 
 | ||||||
|       if (this.newChapters[1].start + amount <= 0) { |       if (unlockedChapters.length === 0) { | ||||||
|         this.$toast.error(this.$strings.ToastChaptersInvalidShiftAmountStart) |         this.$toast.warning(this.$strings.ToastChaptersAllLocked) | ||||||
|         return |         return | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       for (let i = 0; i < this.newChapters.length; i++) { |       for (let i = 0; i < this.newChapters.length; i++) { | ||||||
|         const chap = this.newChapters[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) |         chap.end = Math.min(chap.end + amount, this.mediaDuration) | ||||||
|         if (i > 0) { |         if (i > 0) { | ||||||
|           chap.start = Math.max(0, chap.start + amount) |           chap.start = Math.max(0, chap.start + amount) | ||||||
| @ -354,6 +459,83 @@ export default { | |||||||
|       } |       } | ||||||
|       this.checkChapters() |       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() { |     editItem() { | ||||||
|       this.$store.commit('showEditModal', this.libraryItem) |       this.$store.commit('showEditModal', this.libraryItem) | ||||||
|     }, |     }, | ||||||
| @ -368,6 +550,10 @@ export default { | |||||||
|       this.checkChapters() |       this.checkChapters() | ||||||
|     }, |     }, | ||||||
|     removeChapter(chapter) { |     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.newChapters = this.newChapters.filter((ch) => ch.id !== chapter.id) | ||||||
|       this.checkChapters() |       this.checkChapters() | ||||||
|     }, |     }, | ||||||
| @ -451,6 +637,7 @@ export default { | |||||||
|         console.log('Audio playing') |         console.log('Audio playing') | ||||||
|         this.isLoadingChapter = false |         this.isLoadingChapter = false | ||||||
|         this.isPlayingChapter = true |         this.isPlayingChapter = true | ||||||
|  |         this.startElapsedTimeTracking() | ||||||
|       }) |       }) | ||||||
|       audioEl.addEventListener('ended', () => { |       audioEl.addEventListener('ended', () => { | ||||||
|         console.log('Audio ended') |         console.log('Audio ended') | ||||||
| @ -473,6 +660,10 @@ export default { | |||||||
|       this.selectedChapter = null |       this.selectedChapter = null | ||||||
|       this.isPlayingChapter = false |       this.isPlayingChapter = false | ||||||
|       this.isLoadingChapter = false |       this.isLoadingChapter = false | ||||||
|  |       this.stopElapsedTimeTracking() | ||||||
|  |     }, | ||||||
|  |     resetChapterLookupData() { | ||||||
|  |       this.chapterData = null | ||||||
|     }, |     }, | ||||||
|     saveChapters() { |     saveChapters() { | ||||||
|       this.checkChapters() |       this.checkChapters() | ||||||
| @ -523,7 +714,7 @@ export default { | |||||||
|     }, |     }, | ||||||
|     applyChapterNamesOnly() { |     applyChapterNamesOnly() { | ||||||
|       this.newChapters.forEach((chapter, index) => { |       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 |           chapter.title = this.chapterData.chapters[index].title | ||||||
|         } |         } | ||||||
|       }) |       }) | ||||||
| @ -535,7 +726,7 @@ export default { | |||||||
|     }, |     }, | ||||||
|     applyChapterData() { |     applyChapterData() { | ||||||
|       let index = 0 |       let index = 0 | ||||||
|       this.newChapters = this.chapterData.chapters |       const audibleChapters = this.chapterData.chapters | ||||||
|         .filter((chap) => chap.startOffsetSec < this.mediaDuration) |         .filter((chap) => chap.startOffsetSec < this.mediaDuration) | ||||||
|         .map((chap) => { |         .map((chap) => { | ||||||
|           return { |           return { | ||||||
| @ -545,6 +736,21 @@ export default { | |||||||
|             title: chap.title |             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.showFindChaptersModal = false | ||||||
|       this.chapterData = null |       this.chapterData = null | ||||||
| 
 | 
 | ||||||
| @ -643,6 +849,7 @@ export default { | |||||||
|           } |           } | ||||||
|         ] |         ] | ||||||
|       } |       } | ||||||
|  |       this.lockedChapters = new Set() | ||||||
|       this.checkChapters() |       this.checkChapters() | ||||||
|     }, |     }, | ||||||
|     removeAllChaptersClick() { |     removeAllChaptersClick() { | ||||||
| @ -684,6 +891,91 @@ export default { | |||||||
|           this.saving = false |           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) { |     libraryItemUpdated(libraryItem) { | ||||||
|       if (libraryItem.id === this.libraryItem.id) { |       if (libraryItem.id === this.libraryItem.id) { | ||||||
|         if (!!libraryItem.media.metadata.asin && this.mediaMetadata.asin !== libraryItem.media.metadata.asin) { |         if (!!libraryItem.media.metadata.asin && this.mediaMetadata.asin !== libraryItem.media.metadata.asin) { | ||||||
|  | |||||||
| @ -127,6 +127,7 @@ | |||||||
|   "HeaderAudiobookTools": "Audiobook File Management Tools", |   "HeaderAudiobookTools": "Audiobook File Management Tools", | ||||||
|   "HeaderAuthentication": "Authentication", |   "HeaderAuthentication": "Authentication", | ||||||
|   "HeaderBackups": "Backups", |   "HeaderBackups": "Backups", | ||||||
|  |   "HeaderBulkChapterModal": "Add Multiple Chapters", | ||||||
|   "HeaderChangePassword": "Change Password", |   "HeaderChangePassword": "Change Password", | ||||||
|   "HeaderChapters": "Chapters", |   "HeaderChapters": "Chapters", | ||||||
|   "HeaderChooseAFolder": "Choose a Folder", |   "HeaderChooseAFolder": "Choose a Folder", | ||||||
| @ -308,6 +309,7 @@ | |||||||
|   "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", |   "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", | ||||||
|   "LabelDescription": "Description", |   "LabelDescription": "Description", | ||||||
|   "LabelDeselectAll": "Deselect All", |   "LabelDeselectAll": "Deselect All", | ||||||
|  |   "LabelDetectedPattern": "Detected pattern:", | ||||||
|   "LabelDevice": "Device", |   "LabelDevice": "Device", | ||||||
|   "LabelDeviceInfo": "Device Info", |   "LabelDeviceInfo": "Device Info", | ||||||
|   "LabelDeviceIsAvailableTo": "Device is available to...", |   "LabelDeviceIsAvailableTo": "Device is available to...", | ||||||
| @ -472,6 +474,7 @@ | |||||||
|   "LabelNewestAuthors": "Newest Authors", |   "LabelNewestAuthors": "Newest Authors", | ||||||
|   "LabelNewestEpisodes": "Newest Episodes", |   "LabelNewestEpisodes": "Newest Episodes", | ||||||
|   "LabelNextBackupDate": "Next backup date", |   "LabelNextBackupDate": "Next backup date", | ||||||
|  |   "LabelNextChapters": "Next chapters will be:", | ||||||
|   "LabelNextScheduledRun": "Next scheduled run", |   "LabelNextScheduledRun": "Next scheduled run", | ||||||
|   "LabelNoApiKeys": "No API keys", |   "LabelNoApiKeys": "No API keys", | ||||||
|   "LabelNoCustomMetadataProviders": "No custom metadata providers", |   "LabelNoCustomMetadataProviders": "No custom metadata providers", | ||||||
| @ -489,6 +492,7 @@ | |||||||
|   "LabelNotificationsMaxQueueSize": "Max queue size for notification events", |   "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.", |   "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", |   "LabelNumberOfBooks": "Number of Books", | ||||||
|  |   "LabelNumberOfChapters": "Number of chapters:", | ||||||
|   "LabelNumberOfEpisodes": "# of Episodes", |   "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 (<b>if configured</b>). 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 <code>false</code>. Ensure the identity provider's claim matches the expected structure:", |   "LabelOpenIDAdvancedPermsClaimDescription": "Name of the OpenID claim that contains advanced permissions for user actions within the application which will apply to non-admin roles (<b>if configured</b>). 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 <code>false</code>. 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.", |   "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}\"", |   "MessageBookshelfNoResultsForFilter": "No results for filter \"{0}: {1}\"", | ||||||
|   "MessageBookshelfNoResultsForQuery": "No results for query", |   "MessageBookshelfNoResultsForQuery": "No results for query", | ||||||
|   "MessageBookshelfNoSeries": "You have no series", |   "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", |   "MessageChapterEndIsAfter": "Chapter end is after the end of your audiobook", | ||||||
|   "MessageChapterErrorFirstNotZero": "First chapter must start at 0", |   "MessageChapterErrorFirstNotZero": "First chapter must start at 0", | ||||||
|   "MessageChapterErrorStartGteDuration": "Invalid start time must be less than audiobook duration", |   "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", |   "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", |   "NotificationOnRSSFeedFailedDescription": "Triggered when the RSS feed request fails for an automatic episode download", | ||||||
|   "NotificationOnTestDescription": "Event for testing the notification system", |   "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", |   "PlaceholderNewCollection": "New collection name", | ||||||
|   "PlaceholderNewFolderPath": "New folder path", |   "PlaceholderNewFolderPath": "New folder path", | ||||||
|   "PlaceholderNewPlaylist": "New playlist name", |   "PlaceholderNewPlaylist": "New playlist name", | ||||||
| @ -1001,8 +1007,12 @@ | |||||||
|   "ToastBookmarkCreateFailed": "Failed to create bookmark", |   "ToastBookmarkCreateFailed": "Failed to create bookmark", | ||||||
|   "ToastBookmarkCreateSuccess": "Bookmark added", |   "ToastBookmarkCreateSuccess": "Bookmark added", | ||||||
|   "ToastBookmarkRemoveSuccess": "Bookmark removed", |   "ToastBookmarkRemoveSuccess": "Bookmark removed", | ||||||
|  |   "ToastBulkChapterInvalidCount": "Enter a number between 1 and 150", | ||||||
|   "ToastCachePurgeFailed": "Failed to purge cache", |   "ToastCachePurgeFailed": "Failed to purge cache", | ||||||
|   "ToastCachePurgeSuccess": "Cache purged successfully", |   "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", |   "ToastChaptersHaveErrors": "Chapters have errors", | ||||||
|   "ToastChaptersInvalidShiftAmountLast": "Invalid shift amount. The last chapter start time would extend beyond the duration of this audiobook.", |   "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.", |   "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", |   "ToastUserPasswordChangeSuccess": "Password changed successfully", | ||||||
|   "ToastUserPasswordMismatch": "Passwords do not match", |   "ToastUserPasswordMismatch": "Passwords do not match", | ||||||
|   "ToastUserPasswordMustChange": "New password cannot match old password", |   "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)" | ||||||
| } | } | ||||||
|  | |||||||
| @ -124,6 +124,7 @@ | |||||||
|   "HeaderAudiobookTools": "Herramientas de Gestión de Archivos de Audiolibro", |   "HeaderAudiobookTools": "Herramientas de Gestión de Archivos de Audiolibro", | ||||||
|   "HeaderAuthentication": "Autenticación", |   "HeaderAuthentication": "Autenticación", | ||||||
|   "HeaderBackups": "Respaldos", |   "HeaderBackups": "Respaldos", | ||||||
|  |   "HeaderBulkChapterModal": "Añadir Múltiples Capítulos", | ||||||
|   "HeaderChangePassword": "Cambiar contraseña", |   "HeaderChangePassword": "Cambiar contraseña", | ||||||
|   "HeaderChapters": "Capítulos", |   "HeaderChapters": "Capítulos", | ||||||
|   "HeaderChooseAFolder": "Escoger una Carpeta", |   "HeaderChooseAFolder": "Escoger una Carpeta", | ||||||
| @ -297,6 +298,7 @@ | |||||||
|   "LabelDeleteFromFileSystemCheckbox": "Eliminar del sistema de archivos (desmarque para quitar de la base de datos solamente)", |   "LabelDeleteFromFileSystemCheckbox": "Eliminar del sistema de archivos (desmarque para quitar de la base de datos solamente)", | ||||||
|   "LabelDescription": "Descripción", |   "LabelDescription": "Descripción", | ||||||
|   "LabelDeselectAll": "Deseleccionar Todos", |   "LabelDeselectAll": "Deseleccionar Todos", | ||||||
|  |   "LabelDetectedPattern": "Patrón detectado:", | ||||||
|   "LabelDevice": "Dispositivo", |   "LabelDevice": "Dispositivo", | ||||||
|   "LabelDeviceInfo": "Información del dispositivo", |   "LabelDeviceInfo": "Información del dispositivo", | ||||||
|   "LabelDeviceIsAvailableTo": "El dispositivo está disponible para...", |   "LabelDeviceIsAvailableTo": "El dispositivo está disponible para...", | ||||||
| @ -454,6 +456,7 @@ | |||||||
|   "LabelNewestAuthors": "Autores más nuevos", |   "LabelNewestAuthors": "Autores más nuevos", | ||||||
|   "LabelNewestEpisodes": "Episodios más nuevos", |   "LabelNewestEpisodes": "Episodios más nuevos", | ||||||
|   "LabelNextBackupDate": "Fecha del siguiente respaldo", |   "LabelNextBackupDate": "Fecha del siguiente respaldo", | ||||||
|  |   "LabelNextChapters": "Los próximos capítulos serán:", | ||||||
|   "LabelNextScheduledRun": "Próxima ejecución programada", |   "LabelNextScheduledRun": "Próxima ejecución programada", | ||||||
|   "LabelNoCustomMetadataProviders": "Sin proveedores de metadatos personalizados", |   "LabelNoCustomMetadataProviders": "Sin proveedores de metadatos personalizados", | ||||||
|   "LabelNoEpisodesSelected": "Ningún Episodio Seleccionado", |   "LabelNoEpisodesSelected": "Ningún Episodio Seleccionado", | ||||||
| @ -470,6 +473,7 @@ | |||||||
|   "LabelNotificationsMaxQueueSize": "Tamaño máximo de la cola de notificaciones", |   "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.", |   "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", |   "LabelNumberOfBooks": "Número de libros", | ||||||
|  |   "LabelNumberOfChapters": "Número de capítulos:", | ||||||
|   "LabelNumberOfEpisodes": "N.º de episodios", |   "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 (<b>si están configurados</b>). Si el reclamo no aparece en la respuesta, se denegará el acceso a ABS. Si falta una sola opción, se tratará como <code>falsa</code>. Asegúrese de que la notificación del proveedor de identidades coincida con la estructura esperada:", |   "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 (<b>si están configurados</b>). Si el reclamo no aparece en la respuesta, se denegará el acceso a ABS. Si falta una sola opción, se tratará como <code>falsa</code>. 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».", |   "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", |   "MessageBookshelfNoResultsForFilter": "El filtro «{0}: {1}» no produjo ningún resultado", | ||||||
|   "MessageBookshelfNoResultsForQuery": "No hay resultados para la consulta", |   "MessageBookshelfNoResultsForQuery": "No hay resultados para la consulta", | ||||||
|   "MessageBookshelfNoSeries": "No tiene ninguna serie", |   "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", |   "MessageChapterEndIsAfter": "El final del capítulo es después del final de tu audiolibro", | ||||||
|   "MessageChapterErrorFirstNotZero": "El primer capítulo debe iniciar en 0", |   "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", |   "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", |   "NotificationOnBackupFailedDescription": "Se activa cuando falla una copia de seguridad", | ||||||
|   "NotificationOnEpisodeDownloadedDescription": "Se activa cuando se descarga automáticamente un episodio de un podcast", |   "NotificationOnEpisodeDownloadedDescription": "Se activa cuando se descarga automáticamente un episodio de un podcast", | ||||||
|   "NotificationOnTestDescription": "Evento para probar el sistema de notificaciones", |   "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", |   "PlaceholderNewCollection": "Nuevo nombre de la colección", | ||||||
|   "PlaceholderNewFolderPath": "Nueva ruta de carpeta", |   "PlaceholderNewFolderPath": "Nueva ruta de carpeta", | ||||||
|   "PlaceholderNewPlaylist": "Nuevo nombre de la lista de reproducción", |   "PlaceholderNewPlaylist": "Nuevo nombre de la lista de reproducción", | ||||||
| @ -972,8 +978,10 @@ | |||||||
|   "ToastBookmarkCreateFailed": "No se pudo crear el marcador", |   "ToastBookmarkCreateFailed": "No se pudo crear el marcador", | ||||||
|   "ToastBookmarkCreateSuccess": "Marcador añadido", |   "ToastBookmarkCreateSuccess": "Marcador añadido", | ||||||
|   "ToastBookmarkRemoveSuccess": "Marcador eliminado", |   "ToastBookmarkRemoveSuccess": "Marcador eliminado", | ||||||
|  |   "ToastBulkChapterInvalidCount": "Por favor ingrese un número válido entre 1 y 150", | ||||||
|   "ToastCachePurgeFailed": "No se pudo purgar la antememoria", |   "ToastCachePurgeFailed": "No se pudo purgar la antememoria", | ||||||
|   "ToastCachePurgeSuccess": "Se purgó la antememoria correctamente", |   "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", |   "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.", |   "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.", |   "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", |   "ToastUserPasswordChangeSuccess": "Contraseña modificada correctamente", | ||||||
|   "ToastUserPasswordMismatch": "No coinciden las contraseñas", |   "ToastUserPasswordMismatch": "No coinciden las contraseñas", | ||||||
|   "ToastUserPasswordMustChange": "La nueva contraseña no puede ser igual que la anterior", |   "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)" | ||||||
| } | } | ||||||
|  | |||||||
| @ -127,6 +127,7 @@ | |||||||
|   "HeaderAudiobookTools": "Outils de gestion de fichiers de livres audio", |   "HeaderAudiobookTools": "Outils de gestion de fichiers de livres audio", | ||||||
|   "HeaderAuthentication": "Authentification", |   "HeaderAuthentication": "Authentification", | ||||||
|   "HeaderBackups": "Sauvegardes", |   "HeaderBackups": "Sauvegardes", | ||||||
|  |   "HeaderBulkChapterModal": "Ajouter Plusieurs Chapitres", | ||||||
|   "HeaderChangePassword": "Modifier le mot de passe", |   "HeaderChangePassword": "Modifier le mot de passe", | ||||||
|   "HeaderChapters": "Chapitres", |   "HeaderChapters": "Chapitres", | ||||||
|   "HeaderChooseAFolder": "Sélectionner un dossier", |   "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)", |   "LabelDeleteFromFileSystemCheckbox": "Supprimer du système de fichiers (décocher pour ne supprimer que de la base de données)", | ||||||
|   "LabelDescription": "Description", |   "LabelDescription": "Description", | ||||||
|   "LabelDeselectAll": "Tout déselectionner", |   "LabelDeselectAll": "Tout déselectionner", | ||||||
|  |   "LabelDetectedPattern": "Motif détecté :", | ||||||
|   "LabelDevice": "Appareil", |   "LabelDevice": "Appareil", | ||||||
|   "LabelDeviceInfo": "Détail de l’appareil", |   "LabelDeviceInfo": "Détail de l’appareil", | ||||||
|   "LabelDeviceIsAvailableTo": "L’appareil est disponible pour…", |   "LabelDeviceIsAvailableTo": "L’appareil est disponible pour…", | ||||||
| @ -472,6 +474,7 @@ | |||||||
|   "LabelNewestAuthors": "Auteurs récents", |   "LabelNewestAuthors": "Auteurs récents", | ||||||
|   "LabelNewestEpisodes": "Épisodes récents", |   "LabelNewestEpisodes": "Épisodes récents", | ||||||
|   "LabelNextBackupDate": "Date de la prochaine sauvegarde", |   "LabelNextBackupDate": "Date de la prochaine sauvegarde", | ||||||
|  |   "LabelNextChapters": "Les prochains chapitres seront :", | ||||||
|   "LabelNextScheduledRun": "Prochain lancement prévu", |   "LabelNextScheduledRun": "Prochain lancement prévu", | ||||||
|   "LabelNoApiKeys": "Aucune clé API", |   "LabelNoApiKeys": "Aucune clé API", | ||||||
|   "LabelNoCustomMetadataProviders": "Aucun fournisseurs de métadonnées personnalisés", |   "LabelNoCustomMetadataProviders": "Aucun fournisseurs de métadonnées personnalisés", | ||||||
| @ -489,6 +492,7 @@ | |||||||
|   "LabelNotificationsMaxQueueSize": "Nombres de notifications maximum à mettre en attente", |   "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.", |   "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", |   "LabelNumberOfBooks": "Nombre de livres", | ||||||
|  |   "LabelNumberOfChapters": "Nombre de chapitres :", | ||||||
|   "LabelNumberOfEpisodes": "Nombre d'épisodes", |   "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 (<b>s’il est configuré</b>). 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 <code>false</code>. Assurez-vous que la demande du fournisseur d’identité correspond à la structure attendue :", |   "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 (<b>s’il est configuré</b>). 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 <code>false</code>. 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 ».", |   "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} »", |   "MessageBookshelfNoResultsForFilter": "Aucun résultat pour le filtre « {0} : {1} »", | ||||||
|   "MessageBookshelfNoResultsForQuery": "Aucun résultat pour la requête", |   "MessageBookshelfNoResultsForQuery": "Aucun résultat pour la requête", | ||||||
|   "MessageBookshelfNoSeries": "Vous n’avez aucune série", |   "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", |   "MessageChapterEndIsAfter": "La fin du chapitre se situe après la fin de votre livre audio", | ||||||
|   "MessageChapterErrorFirstNotZero": "Le premier capitre doit débuter à 0", |   "MessageChapterErrorFirstNotZero": "Le premier capitre doit débuter à 0", | ||||||
|   "MessageChapterErrorStartGteDuration": "Horodatage invalide car il doit débuter avant la fin du livre", |   "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", |   "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", |   "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", |   "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", |   "PlaceholderNewCollection": "Nom de la nouvelle collection", | ||||||
|   "PlaceholderNewFolderPath": "Nouveau chemin de dossier", |   "PlaceholderNewFolderPath": "Nouveau chemin de dossier", | ||||||
|   "PlaceholderNewPlaylist": "Nouveau nom de liste de lecture", |   "PlaceholderNewPlaylist": "Nouveau nom de liste de lecture", | ||||||
| @ -1001,8 +1007,10 @@ | |||||||
|   "ToastBookmarkCreateFailed": "Échec de la création de signet", |   "ToastBookmarkCreateFailed": "Échec de la création de signet", | ||||||
|   "ToastBookmarkCreateSuccess": "Signet ajouté", |   "ToastBookmarkCreateSuccess": "Signet ajouté", | ||||||
|   "ToastBookmarkRemoveSuccess": "Signet supprimé", |   "ToastBookmarkRemoveSuccess": "Signet supprimé", | ||||||
|  |   "ToastBulkChapterInvalidCount": "Veuillez entrer un nombre valide entre 1 et 150", | ||||||
|   "ToastCachePurgeFailed": "Échec de la purge du cache", |   "ToastCachePurgeFailed": "Échec de la purge du cache", | ||||||
|   "ToastCachePurgeSuccess": "Cache purgé avec succès", |   "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", |   "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.", |   "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.", |   "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", |   "ToastUserPasswordChangeSuccess": "Mot de passe modifié avec succès", | ||||||
|   "ToastUserPasswordMismatch": "Les mots de passe ne correspondent pas", |   "ToastUserPasswordMismatch": "Les mots de passe ne correspondent pas", | ||||||
|   "ToastUserPasswordMustChange": "Le nouveau mot de passe ne peut pas être identique à l’ancien", |   "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)" | ||||||
| } | } | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user