mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Merge branch 'advplyr:master' into subdirectory-fixes
This commit is contained in:
		
						commit
						027190b5a4
					
				| @ -325,7 +325,7 @@ export default { | ||||
|     }, | ||||
|     displaySubtitle() { | ||||
|       if (!this.libraryItem) return '\u00A0' | ||||
|       if (this.collapsedSeries) return this.collapsedSeries.numBooks === 1 ? '1 book' : `${this.collapsedSeries.numBooks} books` | ||||
|       if (this.collapsedSeries) return `${this.collapsedSeries.numBooks} ${this.$strings.LabelBooks}` | ||||
|       if (this.mediaMetadata.subtitle) return this.mediaMetadata.subtitle | ||||
|       if (this.mediaMetadata.seriesName) return this.mediaMetadata.seriesName | ||||
|       return '' | ||||
|  | ||||
| @ -413,21 +413,17 @@ export default { | ||||
|           id: 'isbn', | ||||
|           name: 'ISBN' | ||||
|         }, | ||||
|         { | ||||
|           id: 'subtitle', | ||||
|           name: this.$strings.LabelSubtitle | ||||
|         }, | ||||
|         { | ||||
|           id: 'authors', | ||||
|           name: this.$strings.LabelAuthor | ||||
|         }, | ||||
|         { | ||||
|           id: 'publishedYear', | ||||
|           name: this.$strings.LabelPublishYear | ||||
|           id: 'chapters', | ||||
|           name: this.$strings.LabelChapters | ||||
|         }, | ||||
|         { | ||||
|           id: 'series', | ||||
|           name: this.$strings.LabelSeries | ||||
|           id: 'cover', | ||||
|           name: this.$strings.LabelCover | ||||
|         }, | ||||
|         { | ||||
|           id: 'description', | ||||
| @ -438,24 +434,32 @@ export default { | ||||
|           name: this.$strings.LabelGenres | ||||
|         }, | ||||
|         { | ||||
|           id: 'tags', | ||||
|           name: this.$strings.LabelTags | ||||
|           id: 'language', | ||||
|           name: this.$strings.LabelLanguage | ||||
|         }, | ||||
|         { | ||||
|           id: 'narrators', | ||||
|           name: this.$strings.LabelNarrator | ||||
|         }, | ||||
|         { | ||||
|           id: 'publishedYear', | ||||
|           name: this.$strings.LabelPublishYear | ||||
|         }, | ||||
|         { | ||||
|           id: 'publisher', | ||||
|           name: this.$strings.LabelPublisher | ||||
|         }, | ||||
|         { | ||||
|           id: 'language', | ||||
|           name: this.$strings.LabelLanguage | ||||
|           id: 'series', | ||||
|           name: this.$strings.LabelSeries | ||||
|         }, | ||||
|         { | ||||
|           id: 'cover', | ||||
|           name: this.$strings.LabelCover | ||||
|           id: 'subtitle', | ||||
|           name: this.$strings.LabelSubtitle | ||||
|         }, | ||||
|         { | ||||
|           id: 'tags', | ||||
|           name: this.$strings.LabelTags | ||||
|         } | ||||
|       ] | ||||
|     }, | ||||
|  | ||||
| @ -33,18 +33,18 @@ | ||||
|             <span class="material-symbols text-lg ml-2">launch</span> | ||||
|           </ui-btn> | ||||
| 
 | ||||
|           <ui-btn v-if="!isMetadataEmbedQueued && !isEmbedTaskRunning" class="w-full mt-4" small @click.stop="quickEmbed">Quick Embed</ui-btn> | ||||
|           <ui-btn v-if="!isMetadataEmbedQueued && !isEmbedTaskRunning" class="w-full mt-4" small @click.stop="quickEmbed">{{ $strings.ButtonQuickEmbed }}</ui-btn> | ||||
|         </div> | ||||
|       </div> | ||||
| 
 | ||||
|       <!-- queued alert --> | ||||
|       <widgets-alert v-if="isMetadataEmbedQueued" type="warning" class="mt-4"> | ||||
|         <p class="text-lg">Queued for metadata embed ({{ queuedEmbedLIds.length }} in queue)</p> | ||||
|         <p class="text-lg">{{ $getString('MessageQuickEmbedQueue', [queuedEmbedLIds.length]) }}</p> | ||||
|       </widgets-alert> | ||||
| 
 | ||||
|       <!-- processing alert --> | ||||
|       <widgets-alert v-if="isEmbedTaskRunning" type="warning" class="mt-4"> | ||||
|         <p class="text-lg">Currently embedding metadata</p> | ||||
|         <p class="text-lg">{{ $strings.MessageQuickEmbedInProgress }}</p> | ||||
|       </widgets-alert> | ||||
|     </div> | ||||
| 
 | ||||
| @ -113,7 +113,7 @@ export default { | ||||
|   methods: { | ||||
|     quickEmbed() { | ||||
|       const payload = { | ||||
|         message: 'Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?', | ||||
|         message: this.$strings.MessageConfirmQuickEmbed, | ||||
|         callback: (confirmed) => { | ||||
|           if (confirmed) { | ||||
|             this.$axios | ||||
|  | ||||
| @ -77,7 +77,13 @@ export default { | ||||
|       return this.notificationData.events || [] | ||||
|     }, | ||||
|     eventOptions() { | ||||
|       return this.notificationEvents.map((e) => ({ value: e.name, text: e.name, subtext: e.description })) | ||||
|       return this.notificationEvents.map((e) => { | ||||
|         return { | ||||
|           value: e.name, | ||||
|           text: e.name, | ||||
|           subtext: this.$strings[e.descriptionKey] || e.description | ||||
|         } | ||||
|       }) | ||||
|     }, | ||||
|     selectedEventData() { | ||||
|       return this.notificationEvents.find((e) => e.name === this.newNotification.eventName) | ||||
|  | ||||
							
								
								
									
										4
									
								
								client/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										4
									
								
								client/package-lock.json
									
									
									
										generated
									
									
									
								
							| @ -1,12 +1,12 @@ | ||||
| { | ||||
|   "name": "audiobookshelf-client", | ||||
|   "version": "2.14.0", | ||||
|   "version": "2.15.0", | ||||
|   "lockfileVersion": 3, | ||||
|   "requires": true, | ||||
|   "packages": { | ||||
|     "": { | ||||
|       "name": "audiobookshelf-client", | ||||
|       "version": "2.14.0", | ||||
|       "version": "2.15.0", | ||||
|       "license": "ISC", | ||||
|       "dependencies": { | ||||
|         "@nuxtjs/axios": "^5.13.6", | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| { | ||||
|   "name": "audiobookshelf-client", | ||||
|   "version": "2.14.0", | ||||
|   "version": "2.15.0", | ||||
|   "buildNumber": 1, | ||||
|   "description": "Self-hosted audiobook and podcast client", | ||||
|   "main": "index.js", | ||||
|  | ||||
| @ -63,11 +63,11 @@ | ||||
|     <div class="w-full max-w-4xl mx-auto"> | ||||
|       <!-- queued alert --> | ||||
|       <widgets-alert v-if="isMetadataEmbedQueued" type="warning" class="mb-4"> | ||||
|         <p class="text-lg">Audiobook is queued for metadata embed ({{ queuedEmbedLIds.length }} in queue)</p> | ||||
|         <p class="text-lg">{{ $getString('MessageEmbedQueue', [queuedEmbedLIds.length]) }}</p> | ||||
|       </widgets-alert> | ||||
|       <!-- metadata embed action buttons --> | ||||
|       <div v-else-if="isEmbedTool" class="w-full flex justify-end items-center mb-4"> | ||||
|         <ui-checkbox v-if="!isTaskFinished" v-model="shouldBackupAudioFiles" :disabled="processing" label="Backup audio files" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" @input="toggleBackupAudioFiles" /> | ||||
|         <ui-checkbox v-if="!isTaskFinished" v-model="shouldBackupAudioFiles" :disabled="processing" :label="$strings.LabelBackupAudioFiles" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" @input="toggleBackupAudioFiles" /> | ||||
| 
 | ||||
|         <div class="flex-grow" /> | ||||
| 
 | ||||
| @ -78,7 +78,7 @@ | ||||
|       <!-- m4b embed action buttons --> | ||||
|       <div v-else class="w-full flex items-center mb-4"> | ||||
|         <button :disabled="processing" class="text-sm uppercase text-gray-200 flex items-center pt-px pl-1 pr-2 hover:bg-white/5 rounded-md" @click="showEncodeOptions = !showEncodeOptions"> | ||||
|           <span class="material-symbols text-xl">{{ showEncodeOptions || usingCustomEncodeOptions ? 'check_box' : 'check_box_outline_blank' }}</span> <span class="pl-1">Use Advanced Options</span> | ||||
|           <span class="material-symbols text-xl">{{ showEncodeOptions || usingCustomEncodeOptions ? 'check_box' : 'check_box_outline_blank' }}</span> <span class="pl-1">{{ $strings.LabelUseAdvancedOptions }}</span> | ||||
|         </button> | ||||
| 
 | ||||
|         <div class="flex-grow" /> | ||||
| @ -94,11 +94,11 @@ | ||||
|         <transition name="slide"> | ||||
|           <div v-if="showEncodeOptions || usingCustomEncodeOptions" class="mb-4 pb-4 border-b border-white/10"> | ||||
|             <div class="flex flex-wrap -mx-2"> | ||||
|               <ui-text-input-with-label ref="bitrateInput" v-model="encodingOptions.bitrate" :disabled="processing || isTaskFinished" :label="'Audio Bitrate (e.g. 128k)'" class="m-2 max-w-40" @input="bitrateChanged" /> | ||||
|               <ui-text-input-with-label ref="channelsInput" v-model="encodingOptions.channels" :disabled="processing || isTaskFinished" :label="'Audio Channels (1 or 2)'" class="m-2 max-w-40" @input="channelsChanged" /> | ||||
|               <ui-text-input-with-label ref="codecInput" v-model="encodingOptions.codec" :disabled="processing || isTaskFinished" :label="'Audio Codec'" class="m-2 max-w-40" @input="codecChanged" /> | ||||
|               <ui-text-input-with-label ref="bitrateInput" v-model="encodingOptions.bitrate" :disabled="processing || isTaskFinished" :label="$strings.LabelAudioBitrate" class="m-2 max-w-40" @input="bitrateChanged" /> | ||||
|               <ui-text-input-with-label ref="channelsInput" v-model="encodingOptions.channels" :disabled="processing || isTaskFinished" :label="$strings.LabelAudioChannels" class="m-2 max-w-40" @input="channelsChanged" /> | ||||
|               <ui-text-input-with-label ref="codecInput" v-model="encodingOptions.codec" :disabled="processing || isTaskFinished" :label="$strings.LabelAudioCodec" class="m-2 max-w-40" @input="codecChanged" /> | ||||
|             </div> | ||||
|             <p class="text-sm text-warning">Warning: Do not update these settings unless you are familiar with ffmpeg encoding options.</p> | ||||
|             <p class="text-sm text-warning">{{ $strings.LabelEncodingWarningAdvancedSettings }}</p> | ||||
|           </div> | ||||
|         </transition> | ||||
|       </div> | ||||
| @ -106,36 +106,36 @@ | ||||
|       <div class="mb-4"> | ||||
|         <div v-if="isEmbedTool" class="flex items-start mb-2"> | ||||
|           <span class="material-symbols text-base text-warning pt-1">star</span> | ||||
|           <p class="text-gray-200 ml-2">Metadata will be embedded in the audio tracks inside your audiobook folder.</p> | ||||
|           <p class="text-gray-200 ml-2">{{ $strings.LabelEncodingInfoEmbedded }}</p> | ||||
|         </div> | ||||
|         <div v-else class="flex items-start mb-2"> | ||||
|           <span class="material-symbols text-base text-warning pt-1">star</span> | ||||
|           <p class="text-gray-200 ml-2"> | ||||
|             Finished M4B will be put into your audiobook folder at <span class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono">.../{{ libraryItemRelPath }}/</span>. | ||||
|             {{ $strings.LabelEncodingFinishedM4B }} <span class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono">.../{{ libraryItemRelPath }}/</span>. | ||||
|           </p> | ||||
|         </div> | ||||
| 
 | ||||
|         <div v-if="shouldBackupAudioFiles || isM4BTool" class="flex items-start mb-2"> | ||||
|           <span class="material-symbols text-base text-warning pt-1">star</span> | ||||
|           <p class="text-gray-200 ml-2"> | ||||
|             A backup of your original audio files will be stored in <span class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono">/metadata/cache/items/{{ libraryItemId }}/</span>. Make sure to periodically purge items cache. | ||||
|             {{ $strings.LabelEncodingBackupLocation }} <span class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono">/metadata/cache/items/{{ libraryItemId }}/</span>. {{ $strings.LabelEncodingClearItemCache }} | ||||
|           </p> | ||||
|         </div> | ||||
|         <div v-if="isEmbedTool && audioFiles.length > 1" class="flex items-start mb-2"> | ||||
|           <span class="material-symbols text-base text-warning pt-1">star</span> | ||||
|           <p class="text-gray-200 ml-2">Chapters are not embedded in multi-track audiobooks.</p> | ||||
|           <p class="text-gray-200 ml-2">{{ $strings.LabelEncodingChaptersNotEmbedded }}</p> | ||||
|         </div> | ||||
|         <div v-if="isM4BTool" class="flex items-start mb-2"> | ||||
|           <span class="material-symbols text-base text-warning pt-1">star</span> | ||||
|           <p class="text-gray-200 ml-2">Encoding can take up to 30 minutes.</p> | ||||
|           <p class="text-gray-200 ml-2">{{ $strings.LabelEncodingTimeWarning }}</p> | ||||
|         </div> | ||||
|         <div v-if="isM4BTool" class="flex items-start mb-2"> | ||||
|           <span class="material-symbols text-base text-warning pt-1">star</span> | ||||
|           <p class="text-gray-200 ml-2">If you have the watcher disabled you will need to re-scan this audiobook afterwards.</p> | ||||
|           <p class="text-gray-200 ml-2">{{ $strings.LabelEncodingWatcherDisabled }}</p> | ||||
|         </div> | ||||
|         <div class="flex items-start mb-2"> | ||||
|           <span class="material-symbols text-base text-warning pt-1">star</span> | ||||
|           <p class="text-gray-200 ml-2">Once the task is started you can navigate away from this page.</p> | ||||
|           <p class="text-gray-200 ml-2">{{ $strings.LabelEncodingStartedNavigation }}</p> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
| @ -269,11 +269,11 @@ export default { | ||||
|     }, | ||||
|     availableTools() { | ||||
|       if (this.isSingleM4b) { | ||||
|         return [{ value: 'embed', text: 'Embed Metadata' }] | ||||
|         return [{ value: 'embed', text: this.$strings.LabelToolsEmbedMetadata }] | ||||
|       } else { | ||||
|         return [ | ||||
|           { value: 'embed', text: 'Embed Metadata' }, | ||||
|           { value: 'm4b', text: 'M4B Encoder' } | ||||
|           { value: 'embed', text: this.$strings.LabelToolsEmbedMetadata }, | ||||
|           { value: 'm4b', text: this.$strings.LabelToolsM4bEncoder } | ||||
|         ] | ||||
|       } | ||||
|     }, | ||||
| @ -370,7 +370,7 @@ export default { | ||||
|     }, | ||||
|     embedClick() { | ||||
|       const payload = { | ||||
|         message: `Are you sure you want to embed metadata in ${this.audioFiles.length} audio files?`, | ||||
|         message: this.$getString('MessageConfirmEmbedMetadataInAudioFiles', [this.audioFiles.length]), | ||||
|         callback: (confirmed) => { | ||||
|           if (confirmed) { | ||||
|             this.updateAudioFileMetadata() | ||||
|  | ||||
| @ -10,9 +10,9 @@ | ||||
|       </template> | ||||
| 
 | ||||
|       <div class="flex justify-between mb-2 place-items-end"> | ||||
|         <ui-text-input ref="input" v-model="search" placeholder="Search filter.." @input="inputUpdate" clearable class="w-full sm:w-40 h-8 text-sm sm:mb-0" /> | ||||
|         <ui-text-input ref="input" v-model="search" :placeholder="$strings.PlaceholderSearch" @input="inputUpdate" clearable class="w-full sm:w-40 h-8 text-sm sm:mb-0" /> | ||||
| 
 | ||||
|         <ui-dropdown v-model="newServerSettings.logLevel" label="Server Log Level" :items="logLevelItems" @input="logLevelUpdated" class="w-full sm:w-44" /> | ||||
|         <ui-dropdown v-model="newServerSettings.logLevel" :label="$strings.LabelServerLogLevel" :items="logLevelItems" @input="logLevelUpdated" class="w-full sm:w-44" /> | ||||
|       </div> | ||||
| 
 | ||||
|       <div class="relative"> | ||||
|  | ||||
| @ -5,7 +5,7 @@ | ||||
|     <div id="bookshelf" class="w-full overflow-y-auto px-2 py-6 sm:px-4 md:p-12 relative"> | ||||
|       <div class="w-full max-w-4xl mx-auto flex"> | ||||
|         <form @submit.prevent="submit" class="flex flex-grow"> | ||||
|           <ui-text-input v-model="searchInput" type="search" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2 text-sm md:text-base" /> | ||||
|           <ui-text-input v-model="searchInput" type="search" :disabled="processing" :placeholder="$strings.MessagePodcastSearchField" class="flex-grow mr-2 text-sm md:text-base" /> | ||||
|           <ui-btn type="submit" :disabled="processing" class="hidden md:block">{{ $strings.ButtonSubmit }}</ui-btn> | ||||
|           <ui-btn type="submit" :disabled="processing" class="block md:hidden" small>{{ $strings.ButtonSubmit }}</ui-btn> | ||||
|         </form> | ||||
| @ -108,7 +108,7 @@ export default { | ||||
| 
 | ||||
|       if (!txt || !txt.includes('<opml') || !txt.includes('<outline ')) { | ||||
|         // Quick lazy check for valid OPML | ||||
|         this.$toast.error('Invalid OPML file <opml> tag not found OR an <outline> tag was not found') | ||||
|         this.$toast.error(this.$strings.MessageTaskOpmlParseFastFail) | ||||
|         this.processing = false | ||||
|         return | ||||
|       } | ||||
| @ -117,7 +117,7 @@ export default { | ||||
|         .$post(`/api/podcasts/opml/parse`, { opmlText: txt }) | ||||
|         .then((data) => { | ||||
|           if (!data.feeds?.length) { | ||||
|             this.$toast.error('No feeds found in OPML file') | ||||
|             this.$toast.error(this.$strings.MessageTaskOpmlParseNoneFound) | ||||
|           } else { | ||||
|             this.opmlFeeds = data.feeds || [] | ||||
|             this.showOPMLFeedsModal = true | ||||
| @ -125,7 +125,7 @@ export default { | ||||
|         }) | ||||
|         .catch((error) => { | ||||
|           console.error('Failed', error) | ||||
|           this.$toast.error('Failed to parse OPML file') | ||||
|           this.$toast.error(this.$strings.MessageTaskOpmlParseFailed) | ||||
|         }) | ||||
|         .finally(() => { | ||||
|           this.processing = false | ||||
| @ -191,7 +191,7 @@ export default { | ||||
|         return | ||||
|       } | ||||
|       if (!podcast.feedUrl) { | ||||
|         this.$toast.error('Invalid podcast - no feed') | ||||
|         this.$toast.error(this.$strings.MessageNoPodcastFeed) | ||||
|         return | ||||
|       } | ||||
|       this.processing = true | ||||
|  | ||||
| @ -10,7 +10,7 @@ | ||||
|         <p v-if="mediaItemShare.playbackSession.displayAuthor" class="text-lg lg:text-xl text-slate-400 font-semibold text-center mb-1 truncate">{{ mediaItemShare.playbackSession.displayAuthor }}</p> | ||||
| 
 | ||||
|         <div class="w-full pt-16"> | ||||
|           <player-ui ref="audioPlayer" :chapters="chapters" :paused="isPaused" :loading="!hasLoaded" :is-podcast="false" hide-bookmarks hide-sleep-timer @playPause="playPause" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setVolume="setVolume" @setPlaybackRate="setPlaybackRate" @seek="seek" /> | ||||
|           <player-ui ref="audioPlayer" :chapters="chapters" :current-chapter="currentChapter" :paused="isPaused" :loading="!hasLoaded" :is-podcast="false" hide-bookmarks hide-sleep-timer @playPause="playPause" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setVolume="setVolume" @setPlaybackRate="setPlaybackRate" @seek="seek" /> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
| @ -51,7 +51,8 @@ export default { | ||||
|       windowHeight: 0, | ||||
|       listeningTimeSinceSync: 0, | ||||
|       coverRgb: null, | ||||
|       coverBgIsLight: false | ||||
|       coverBgIsLight: false, | ||||
|       currentTime: 0 | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
| @ -83,6 +84,9 @@ export default { | ||||
|     chapters() { | ||||
|       return this.playbackSession.chapters || [] | ||||
|     }, | ||||
|     currentChapter() { | ||||
|       return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end) | ||||
|     }, | ||||
|     coverAspectRatio() { | ||||
|       const coverAspectRatio = this.playbackSession.coverAspectRatio | ||||
|       return coverAspectRatio === this.$constants.BookCoverAspectRatio.STANDARD ? 1.6 : 1 | ||||
| @ -154,6 +158,7 @@ export default { | ||||
| 
 | ||||
|       // Update UI | ||||
|       this.$refs.audioPlayer.setCurrentTime(time) | ||||
|       this.currentTime = time | ||||
|     }, | ||||
|     setDuration() { | ||||
|       if (!this.localAudioPlayer) return | ||||
|  | ||||
| @ -550,7 +550,7 @@ | ||||
|   "LabelSleepTimer": "স্লিপ টাইমার", | ||||
|   "LabelSlug": "স্লাগ", | ||||
|   "LabelStart": "শুরু", | ||||
|   "LabelStartTime": "শুরু করার সময়", | ||||
|   "LabelStartTime": "শুরুর সময়", | ||||
|   "LabelStarted": "শুরু হয়েছে", | ||||
|   "LabelStartedAt": "এতে শুরু হয়েছে", | ||||
|   "LabelStatsAudioTracks": "অডিও ট্র্যাক", | ||||
| @ -901,6 +901,7 @@ | ||||
|   "ToastErrorCannotShare": "এই ডিভাইসে স্থানীয়ভাবে শেয়ার করা যাবে না", | ||||
|   "ToastFailedToLoadData": "ডেটা লোড করা যায়নি", | ||||
|   "ToastFailedToShare": "শেয়ার করতে ব্যর্থ", | ||||
|   "ToastFailedToUpdate": "আপডেট করতে ব্যর্থ হয়েছে", | ||||
|   "ToastInvalidImageUrl": "অকার্যকর ছবির ইউআরএল", | ||||
|   "ToastInvalidUrl": "অকার্যকর ইউআরএল", | ||||
|   "ToastItemCoverUpdateSuccess": "আইটেম কভার আপডেট করা হয়েছে", | ||||
|  | ||||
| @ -465,6 +465,8 @@ | ||||
|   "LabelPubDate": "Veröffentlichungsdatum", | ||||
|   "LabelPublishYear": "Jahr", | ||||
|   "LabelPublishedDate": "Veröffentlicht {0}", | ||||
|   "LabelPublishedDecade": "Jahrzehnt", | ||||
|   "LabelPublishedDecades": "Jahrzehnte", | ||||
|   "LabelPublisher": "Herausgeber", | ||||
|   "LabelPublishers": "Herausgeber", | ||||
|   "LabelRSSFeedCustomOwnerEmail": "Benutzerdefinierte Eigentümer-E-Mail", | ||||
| @ -567,7 +569,7 @@ | ||||
|   "LabelStatsMinutesListening": "Gehörte Minuten", | ||||
|   "LabelStatsOverallDays": "Gesamte Tage", | ||||
|   "LabelStatsOverallHours": "Gesamte Stunden", | ||||
|   "LabelStatsWeekListening": "Wochenhördauer", | ||||
|   "LabelStatsWeekListening": "7-Tage-Durchschnitt", | ||||
|   "LabelSubtitle": "Untertitel", | ||||
|   "LabelSupportedFileTypes": "Unterstützte Dateitypen", | ||||
|   "LabelTag": "Schlagwort", | ||||
| @ -791,17 +793,24 @@ | ||||
|   "MessageTaskFailedToMergeAudioFiles": "Fehler beim zusammenführen der Audiodateien", | ||||
|   "MessageTaskFailedToMoveM4bFile": "Fehler beim verschieben der m4b Datei", | ||||
|   "MessageTaskFailedToWriteMetadataFile": "Fehler beim schreiben der Metadaten-Datei", | ||||
|   "MessageTaskMatchingBooksInLibrary": "Vergleiche Bücher in Bibliothek \"{0}\"", | ||||
|   "MessageTaskNoFilesToScan": "Keine Dateien zum scannen", | ||||
|   "MessageTaskOpmlImport": "OPML-Import", | ||||
|   "MessageTaskOpmlImportDescription": "Podcasts von {0} RSS-Feeds werden ersrtellt", | ||||
|   "MessageTaskOpmlImportFeed": "OPML-Feed importieren", | ||||
|   "MessageTaskOpmlImportFeedDescription": "RSS-Feed \"{0}\" wird importiert", | ||||
|   "MessageTaskOpmlImportFeedFailed": "Podcast Feed konnte nicht geladen werden", | ||||
|   "MessageTaskOpmlImportFeedPodcastDescription": "Podcast \"{0}\" wird erstellt", | ||||
|   "MessageTaskOpmlImportFeedPodcastExists": "Der Podcast ist bereits im Pfad vorhanden", | ||||
|   "MessageTaskOpmlImportFeedPodcastFailed": "Erstellen des Podcasts fehlgeschlagen", | ||||
|   "MessageTaskOpmlImportFinished": "{0} Podcasts hinzugefügt", | ||||
|   "MessageTaskScanItemsAdded": "{0} hinzugefügt", | ||||
|   "MessageTaskScanItemsMissing": "{0} fehlend", | ||||
|   "MessageTaskScanItemsUpdated": "{0} aktualisiert", | ||||
|   "MessageTaskScanNoChangesNeeded": "Keine Änderungen nötig", | ||||
|   "MessageTaskScanningFileChanges": "Überprüfe \"{0}\" nach geänderten Dateien", | ||||
|   "MessageTaskScanningLibrary": "Bibliothek \"{0}\" wird durchsucht", | ||||
|   "MessageTaskTargetDirectoryNotWritable": "Das Zielverzeichnis ist schreibgeschützt", | ||||
|   "MessageThinking": "Nachdenken...", | ||||
|   "MessageUploaderItemFailed": "Hochladen fehlgeschlagen", | ||||
|   "MessageUploaderItemSuccess": "Erfolgreich hochgeladen!", | ||||
| @ -894,6 +903,7 @@ | ||||
|   "ToastErrorCannotShare": "Das kann nicht nativ auf diesem Gerät freigegeben werden", | ||||
|   "ToastFailedToLoadData": "Daten laden fehlgeschlagen", | ||||
|   "ToastFailedToShare": "Fehler beim Teilen", | ||||
|   "ToastFailedToUpdate": "Aktualisierung ist fehlgeschlagen", | ||||
|   "ToastInvalidImageUrl": "Ungültiger Bild URL", | ||||
|   "ToastInvalidUrl": "Ungültiger URL", | ||||
|   "ToastItemCoverUpdateSuccess": "Titelbild aktualisiert", | ||||
| @ -912,6 +922,7 @@ | ||||
|   "ToastLibraryScanFailedToStart": "Scan konnte nicht gestartet werden", | ||||
|   "ToastLibraryScanStarted": "Bibliotheksscan gestartet", | ||||
|   "ToastLibraryUpdateSuccess": "Bibliothek \"{0}\" aktualisiert", | ||||
|   "ToastMatchAllAuthorsFailed": "Nicht alle Autoren konnten zugeordnet werden", | ||||
|   "ToastNameEmailRequired": "Name und E-Mail sind erforderlich", | ||||
|   "ToastNameRequired": "Name ist erforderlich", | ||||
|   "ToastNewUserCreatedFailed": "Fehler beim erstellen des Accounts: \"{ 0}\"", | ||||
|  | ||||
| @ -66,6 +66,7 @@ | ||||
|   "ButtonPurgeItemsCache": "Purge Items Cache", | ||||
|   "ButtonQueueAddItem": "Add to queue", | ||||
|   "ButtonQueueRemoveItem": "Remove from queue", | ||||
|   "ButtonQuickEmbed": "Quick Embed", | ||||
|   "ButtonQuickEmbedMetadata": "Quick Embed Metadata", | ||||
|   "ButtonQuickMatch": "Quick Match", | ||||
|   "ButtonReScan": "Re-Scan", | ||||
| @ -225,6 +226,9 @@ | ||||
|   "LabelAllUsersIncludingGuests": "All users including guests", | ||||
|   "LabelAlreadyInYourLibrary": "Already in your library", | ||||
|   "LabelAppend": "Append", | ||||
|   "LabelAudioBitrate": "Audio Bitrate (e.g. 128k)", | ||||
|   "LabelAudioChannels": "Audio Channels (1 or 2)", | ||||
|   "LabelAudioCodec": "Audio Codec", | ||||
|   "LabelAuthor": "Author", | ||||
|   "LabelAuthorFirstLast": "Author (First Last)", | ||||
|   "LabelAuthorLastFirst": "Author (Last, First)", | ||||
| @ -237,6 +241,7 @@ | ||||
|   "LabelAutoRegister": "Auto Register", | ||||
|   "LabelAutoRegisterDescription": "Automatically create new users after logging in", | ||||
|   "LabelBackToUser": "Back to User", | ||||
|   "LabelBackupAudioFiles": "Backup Audio Files", | ||||
|   "LabelBackupLocation": "Backup Location", | ||||
|   "LabelBackupsEnableAutomaticBackups": "Enable automatic backups", | ||||
|   "LabelBackupsEnableAutomaticBackupsHelp": "Backups saved in /metadata/backups", | ||||
| @ -303,6 +308,15 @@ | ||||
|   "LabelEmailSettingsTestAddress": "Test Address", | ||||
|   "LabelEmbeddedCover": "Embedded Cover", | ||||
|   "LabelEnable": "Enable", | ||||
|   "LabelEncodingBackupLocation": "A backup of your original audio files will be stored in:", | ||||
|   "LabelEncodingChaptersNotEmbedded": "Chapters are not embedded in multi-track audiobooks.", | ||||
|   "LabelEncodingClearItemCache": "Make sure to periodically purge items cache.", | ||||
|   "LabelEncodingFinishedM4B": "Finished M4B will be put into your audiobook folder at:", | ||||
|   "LabelEncodingInfoEmbedded": "Metadata will be embedded in the audio tracks inside your audiobook folder.", | ||||
|   "LabelEncodingStartedNavigation": "Once the task is started you can navigate away from this page.", | ||||
|   "LabelEncodingTimeWarning": "Encoding can take up to 30 minutes.", | ||||
|   "LabelEncodingWarningAdvancedSettings": "Warning: Do not update these settings unless you are familiar with ffmpeg encoding options.", | ||||
|   "LabelEncodingWatcherDisabled": "If you have the watcher disabled you will need to re-scan this audiobook afterwards.", | ||||
|   "LabelEnd": "End", | ||||
|   "LabelEndOfChapter": "End of Chapter", | ||||
|   "LabelEpisode": "Episode", | ||||
| @ -501,6 +515,7 @@ | ||||
|   "LabelSeries": "Series", | ||||
|   "LabelSeriesName": "Series Name", | ||||
|   "LabelSeriesProgress": "Series Progress", | ||||
|   "LabelServerLogLevel": "Server Log Level", | ||||
|   "LabelServerYearReview": "Server Year in Review ({0})", | ||||
|   "LabelSetEbookAsPrimary": "Set as primary", | ||||
|   "LabelSetEbookAsSupplementary": "Set as supplementary", | ||||
| @ -596,6 +611,7 @@ | ||||
|   "LabelTitle": "Title", | ||||
|   "LabelToolsEmbedMetadata": "Embed Metadata", | ||||
|   "LabelToolsEmbedMetadataDescription": "Embed metadata into audio files including cover image and chapters.", | ||||
|   "LabelToolsM4bEncoder": "M4B Encoder", | ||||
|   "LabelToolsMakeM4b": "Make M4B Audiobook File", | ||||
|   "LabelToolsMakeM4bDescription": "Generate a .M4B audiobook file with embedded metadata, cover image, and chapters.", | ||||
|   "LabelToolsSplitM4b": "Split M4B to MP3's", | ||||
| @ -621,6 +637,7 @@ | ||||
|   "LabelUploaderDragAndDrop": "Drag & drop files or folders", | ||||
|   "LabelUploaderDropFiles": "Drop files", | ||||
|   "LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series", | ||||
|   "LabelUseAdvancedOptions": "Use Advanced Options", | ||||
|   "LabelUseChapterTrack": "Use chapter track", | ||||
|   "LabelUseFullTrack": "Use full track", | ||||
|   "LabelUser": "User", | ||||
| @ -669,6 +686,7 @@ | ||||
|   "MessageConfirmDeleteMetadataProvider": "Are you sure you want to delete custom metadata provider \"{0}\"?", | ||||
|   "MessageConfirmDeleteNotification": "Are you sure you want to delete this notification?", | ||||
|   "MessageConfirmDeleteSession": "Are you sure you want to delete this session?", | ||||
|   "MessageConfirmEmbedMetadataInAudioFiles": "Are you sure you want to embed metadata in {0} audio files?", | ||||
|   "MessageConfirmForceReScan": "Are you sure you want to force re-scan?", | ||||
|   "MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?", | ||||
|   "MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?", | ||||
| @ -702,6 +720,7 @@ | ||||
|   "MessageDragFilesIntoTrackOrder": "Drag files into correct track order", | ||||
|   "MessageEmbedFailed": "Embed Failed!", | ||||
|   "MessageEmbedFinished": "Embed Finished!", | ||||
|   "MessageEmbedQueue": "Queued for metadata embed ({0} in queue)", | ||||
|   "MessageEpisodesQueuedForDownload": "{0} Episode(s) queued for download", | ||||
|   "MessageEreaderDevices": "To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below.", | ||||
|   "MessageFeedURLWillBe": "Feed URL will be {0}", | ||||
| @ -746,6 +765,7 @@ | ||||
|   "MessageNoLogs": "No Logs", | ||||
|   "MessageNoMediaProgress": "No Media Progress", | ||||
|   "MessageNoNotifications": "No Notifications", | ||||
|   "MessageNoPodcastFeed": "Invalid podcast: No Feed", | ||||
|   "MessageNoPodcastsFound": "No podcasts found", | ||||
|   "MessageNoResults": "No Results", | ||||
|   "MessageNoSearchResultsFor": "No search results for \"{0}\"", | ||||
| @ -762,6 +782,9 @@ | ||||
|   "MessagePlaylistCreateFromCollection": "Create playlist from collection", | ||||
|   "MessagePleaseWait": "Please wait...", | ||||
|   "MessagePodcastHasNoRSSFeedForMatching": "Podcast has no RSS feed url to use for matching", | ||||
|   "MessagePodcastSearchField": "Enter search term or RSS feed URL", | ||||
|   "MessageQuickEmbedInProgress": "Quick embed in progress", | ||||
|   "MessageQuickEmbedQueue": "Queued for quick embed ({0} in queue)", | ||||
|   "MessageQuickMatchDescription": "Populate empty item details & cover with first match result from '{0}'. Does not overwrite details unless 'Prefer matched metadata' server setting is enabled.", | ||||
|   "MessageRemoveChapter": "Remove chapter", | ||||
|   "MessageRemoveEpisodes": "Remove {0} episode(s)", | ||||
| @ -804,6 +827,9 @@ | ||||
|   "MessageTaskOpmlImportFeedPodcastExists": "Podcast already exists at path", | ||||
|   "MessageTaskOpmlImportFeedPodcastFailed": "Failed to create podcast", | ||||
|   "MessageTaskOpmlImportFinished": "Added {0} podcasts", | ||||
|   "MessageTaskOpmlParseFailed": "Failed to parse OPML file", | ||||
|   "MessageTaskOpmlParseFastFail": "Invalid OPML file <opml> tag not found OR an <outline> tag was not found", | ||||
|   "MessageTaskOpmlParseNoneFound": "No feeds found in OPML file", | ||||
|   "MessageTaskScanItemsAdded": "{0} added", | ||||
|   "MessageTaskScanItemsMissing": "{0} missing", | ||||
|   "MessageTaskScanItemsUpdated": "{0} updated", | ||||
| @ -828,6 +854,10 @@ | ||||
|   "NoteUploaderFoldersWithMediaFiles": "Folders with media files will be handled as separate library items.", | ||||
|   "NoteUploaderOnlyAudioFiles": "If uploading only audio files then each audio file will be handled as a separate audiobook.", | ||||
|   "NoteUploaderUnsupportedFiles": "Unsupported files are ignored. When choosing or dropping a folder, other files that are not in an item folder are ignored.", | ||||
|   "NotificationOnBackupCompletedDescription": "Triggered when a backup is completed", | ||||
|   "NotificationOnBackupFailedDescription": "Triggered when a backup fails", | ||||
|   "NotificationOnEpisodeDownloadedDescription": "Triggered when a podcast episode is auto-downloaded", | ||||
|   "NotificationOnTestDescription": "Event for testing the notification system", | ||||
|   "PlaceholderNewCollection": "New collection name", | ||||
|   "PlaceholderNewFolderPath": "New folder path", | ||||
|   "PlaceholderNewPlaylist": "New playlist name", | ||||
|  | ||||
							
								
								
									
										1
									
								
								client/strings/en_US.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								client/strings/en_US.json
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | ||||
| {} | ||||
| @ -465,6 +465,8 @@ | ||||
|   "LabelPubDate": "Fecha de publicación", | ||||
|   "LabelPublishYear": "Año de publicación", | ||||
|   "LabelPublishedDate": "Publicado {0}", | ||||
|   "LabelPublishedDecade": "Una década de publicaciones", | ||||
|   "LabelPublishedDecades": "Décadas publicadas", | ||||
|   "LabelPublisher": "Editor", | ||||
|   "LabelPublishers": "Editores", | ||||
|   "LabelRSSFeedCustomOwnerEmail": "Correo electrónico de dueño personalizado", | ||||
| @ -920,7 +922,8 @@ | ||||
|   "ToastLibraryScanFailedToStart": "Error al iniciar el escaneo", | ||||
|   "ToastLibraryScanStarted": "Se inició el escaneo de la biblioteca", | ||||
|   "ToastLibraryUpdateSuccess": "Biblioteca \"{0}\" actualizada", | ||||
|   "ToastNameEmailRequired": "Nombre y correo electrónico obligatorios", | ||||
|   "ToastMatchAllAuthorsFailed": "No coincide con todos los autores", | ||||
|   "ToastNameEmailRequired": "Son obligatorios el nombre y el correo electrónico", | ||||
|   "ToastNameRequired": "Nombre obligatorio", | ||||
|   "ToastNewUserCreatedFailed": "Error al crear la cuenta: \"{0}\"", | ||||
|   "ToastNewUserCreatedSuccess": "Nueva cuenta creada", | ||||
|  | ||||
| @ -465,6 +465,8 @@ | ||||
|   "LabelPubDate": "Date de publication", | ||||
|   "LabelPublishYear": "Année de publication", | ||||
|   "LabelPublishedDate": "Publié en {0}", | ||||
|   "LabelPublishedDecade": "Décennie de publication", | ||||
|   "LabelPublishedDecades": "Décennies de publication", | ||||
|   "LabelPublisher": "Éditeur", | ||||
|   "LabelPublishers": "Éditeurs", | ||||
|   "LabelRSSFeedCustomOwnerEmail": "Courriel personnalisée du propriétaire", | ||||
| @ -901,6 +903,7 @@ | ||||
|   "ToastErrorCannotShare": "Impossible de partager nativement sur cet appareil", | ||||
|   "ToastFailedToLoadData": "Échec du chargement des données", | ||||
|   "ToastFailedToShare": "Échec du partage", | ||||
|   "ToastFailedToUpdate": "Échec de la mise à jour", | ||||
|   "ToastInvalidImageUrl": "URL de l'image invalide", | ||||
|   "ToastInvalidUrl": "URL invalide", | ||||
|   "ToastItemCoverUpdateSuccess": "Couverture mise à jour", | ||||
| @ -919,6 +922,7 @@ | ||||
|   "ToastLibraryScanFailedToStart": "Échec du démarrage de l’analyse", | ||||
|   "ToastLibraryScanStarted": "Analyse de la bibliothèque démarrée", | ||||
|   "ToastLibraryUpdateSuccess": "Bibliothèque « {0} » mise à jour", | ||||
|   "ToastMatchAllAuthorsFailed": "Tous les auteurs et autrices n’ont pas pu être classés", | ||||
|   "ToastNameEmailRequired": "Le nom et le courriel sont requis", | ||||
|   "ToastNameRequired": "Le nom est requis", | ||||
|   "ToastNewUserCreatedFailed": "La création du compte à échouée : « {0} »", | ||||
|  | ||||
| @ -463,8 +463,10 @@ | ||||
|   "LabelProvider": "Dobavljač", | ||||
|   "LabelProviderAuthorizationValue": "Vrijednost autorizacijskog zaglavlja", | ||||
|   "LabelPubDate": "Datum izdavanja", | ||||
|   "LabelPublishYear": "Godina izdavanja", | ||||
|   "LabelPublishYear": "Godina objavljivanja", | ||||
|   "LabelPublishedDate": "Objavljeno {0}", | ||||
|   "LabelPublishedDecade": "Desetljeće objavljivanja", | ||||
|   "LabelPublishedDecades": "Desetljeća objavljivanja", | ||||
|   "LabelPublisher": "Izdavač", | ||||
|   "LabelPublishers": "Izdavači", | ||||
|   "LabelRSSFeedCustomOwnerEmail": "Prilagođena adresa e-pošte vlasnika", | ||||
| @ -920,6 +922,7 @@ | ||||
|   "ToastLibraryScanFailedToStart": "Skeniranje nije uspjelo", | ||||
|   "ToastLibraryScanStarted": "Skeniranje knjižnice započelo", | ||||
|   "ToastLibraryUpdateSuccess": "Knjižnica \"{0}\" ažurirana", | ||||
|   "ToastMatchAllAuthorsFailed": "Nisu prepoznati svi autori", | ||||
|   "ToastNameEmailRequired": "Ime i adresa e-pošte su obavezni", | ||||
|   "ToastNameRequired": "Ime je obavezno", | ||||
|   "ToastNewUserCreatedFailed": "Račun \"{0}\" nije uspješno izrađen", | ||||
|  | ||||
| @ -465,6 +465,8 @@ | ||||
|   "LabelPubDate": "Data di pubblicazione", | ||||
|   "LabelPublishYear": "Anno di pubblicazione", | ||||
|   "LabelPublishedDate": "{0} pubblicati", | ||||
|   "LabelPublishedDecade": "Decennio di pubblicazione", | ||||
|   "LabelPublishedDecades": "Decenni di pubblicazione", | ||||
|   "LabelPublisher": "Editore", | ||||
|   "LabelPublishers": "Editori", | ||||
|   "LabelRSSFeedCustomOwnerEmail": "E-mail del proprietario personalizzato", | ||||
| @ -777,6 +779,38 @@ | ||||
|   "MessageShareExpiresIn": "Scade in {0}", | ||||
|   "MessageShareURLWillBe": "L'indirizzo sarà: <strong>{0}</strong>", | ||||
|   "MessageStartPlaybackAtTime": "Avvia la riproduzione per \"{0}\" a {1}?", | ||||
|   "MessageTaskAudioFileNotWritable": "Il file audio «{0}» non è scrivibile", | ||||
|   "MessageTaskCanceledByUser": "Attività annullata dall'utente", | ||||
|   "MessageTaskDownloadingEpisodeDescription": "Scaricamento dell'episodio «{0}»", | ||||
|   "MessageTaskEmbeddingMetadata": "Metadati integrati", | ||||
|   "MessageTaskEmbeddingMetadataDescription": "Integrazione dei metadati nell'audiolibro «{0}»", | ||||
|   "MessageTaskEncodingM4b": "Codifica M4B", | ||||
|   "MessageTaskEncodingM4bDescription": "Codifica dell'audiolibro «{0}» in un singolo file m4b", | ||||
|   "MessageTaskFailed": "Fallimento", | ||||
|   "MessageTaskFailedToBackupAudioFile": "Non riuscita a eseguire il backup del file audio «{0}»", | ||||
|   "MessageTaskFailedToCreateCacheDirectory": "Non riuscita a creare la cartella della cache", | ||||
|   "MessageTaskFailedToEmbedMetadataInFile": "Non ha inserito i metadati nel file «{0}»", | ||||
|   "MessageTaskFailedToMergeAudioFiles": "Non è riuscito a fondere i file audio", | ||||
|   "MessageTaskFailedToMoveM4bFile": "Non è riuscito a spostare il file m4b", | ||||
|   "MessageTaskFailedToWriteMetadataFile": "Non è riuscito a scrivere file di metadati", | ||||
|   "MessageTaskMatchingBooksInLibrary": "Libri di corrispondenza in biblioteca «{0}»", | ||||
|   "MessageTaskNoFilesToScan": "Nessun file per la scansione", | ||||
|   "MessageTaskOpmlImport": "Importazione OPML", | ||||
|   "MessageTaskOpmlImportDescription": "Creazione di podcast da {0} flusso RSS", | ||||
|   "MessageTaskOpmlImportFeed": "Flusso di importazione OPML", | ||||
|   "MessageTaskOpmlImportFeedDescription": "Importazione del flusso RSS «{0}»", | ||||
|   "MessageTaskOpmlImportFeedFailed": "Impossibile ottenere il flusso del podcast", | ||||
|   "MessageTaskOpmlImportFeedPodcastDescription": "Creazione di podcast «{0}»", | ||||
|   "MessageTaskOpmlImportFeedPodcastExists": "Il podcast esiste già nel percorso", | ||||
|   "MessageTaskOpmlImportFeedPodcastFailed": "Errore durante la creazione del podcast", | ||||
|   "MessageTaskOpmlImportFinished": "{0} podcast aggiunti", | ||||
|   "MessageTaskScanItemsAdded": "{0} aggiunti", | ||||
|   "MessageTaskScanItemsMissing": "{0} mancanti", | ||||
|   "MessageTaskScanItemsUpdated": "{0} aggiornati", | ||||
|   "MessageTaskScanNoChangesNeeded": "Nessuna modifica necessaria", | ||||
|   "MessageTaskScanningFileChanges": "Cambiamenti di file di scansione in «{0}»", | ||||
|   "MessageTaskScanningLibrary": "Scansione della biblioteca «{0}»", | ||||
|   "MessageTaskTargetDirectoryNotWritable": "La cartella di destinazione non è scrivibile", | ||||
|   "MessageThinking": "Elaborazione...", | ||||
|   "MessageUploaderItemFailed": "Caricamento Fallito", | ||||
|   "MessageUploaderItemSuccess": "Caricato con successo!", | ||||
| @ -869,6 +903,7 @@ | ||||
|   "ToastErrorCannotShare": "Impossibile condividere in modo nativo su questo dispositivo", | ||||
|   "ToastFailedToLoadData": "Impossibile caricare i dati", | ||||
|   "ToastFailedToShare": "Impossibile condividere", | ||||
|   "ToastFailedToUpdate": "Non aggiornato", | ||||
|   "ToastInvalidImageUrl": "URL dell'immagine non valido", | ||||
|   "ToastInvalidUrl": "URL non valido", | ||||
|   "ToastItemCoverUpdateSuccess": "Cover aggiornata", | ||||
| @ -887,6 +922,7 @@ | ||||
|   "ToastLibraryScanFailedToStart": "Errore inizio scansione", | ||||
|   "ToastLibraryScanStarted": "Scansione Libreria iniziata", | ||||
|   "ToastLibraryUpdateSuccess": "Libreria \"{0}\" aggiornata", | ||||
|   "ToastMatchAllAuthorsFailed": "Tutti gli autori non hanno potuto essere classificati", | ||||
|   "ToastNameEmailRequired": "Nome ed email sono obbligatori", | ||||
|   "ToastNameRequired": "Il nome è obbligatorio", | ||||
|   "ToastNewUserCreatedFailed": "Impossibile creare l'account: \"{0}\"", | ||||
|  | ||||
| @ -19,6 +19,7 @@ | ||||
|   "ButtonChooseFiles": "Pasirinkite failus", | ||||
|   "ButtonClearFilter": "Valyti filtrą", | ||||
|   "ButtonCloseFeed": "Uždaryti srautą", | ||||
|   "ButtonCloseSession": "Uždaryti Atidarytą sesiją", | ||||
|   "ButtonCollections": "Kolekcijos", | ||||
|   "ButtonConfigureScanner": "Konfigūruoti skenerį", | ||||
|   "ButtonCreate": "Kurti", | ||||
| @ -28,11 +29,14 @@ | ||||
|   "ButtonEdit": "Redaguoti", | ||||
|   "ButtonEditChapters": "Redaguoti skyrius", | ||||
|   "ButtonEditPodcast": "Redaguoti tinklalaidę", | ||||
|   "ButtonEnable": "Įjungti", | ||||
|   "ButtonForceReScan": "Priverstinai nuskaityti iš naujo", | ||||
|   "ButtonFullPath": "Visas kelias", | ||||
|   "ButtonHide": "Slėpti", | ||||
|   "ButtonHome": "Pradžia", | ||||
|   "ButtonIssues": "Problemos", | ||||
|   "ButtonJumpBackward": "Peršokti atgal", | ||||
|   "ButtonJumpForward": "Peršokti į priekį", | ||||
|   "ButtonLatest": "Naujausias", | ||||
|   "ButtonLibrary": "Biblioteka", | ||||
|   "ButtonLogout": "Atsijungti", | ||||
| @ -42,12 +46,19 @@ | ||||
|   "ButtonMatchAllAuthors": "Pritaikyti visus autorius", | ||||
|   "ButtonMatchBooks": "Pritaikyti knygas", | ||||
|   "ButtonNevermind": "Nesvarbu", | ||||
|   "ButtonNext": "Kitas", | ||||
|   "ButtonNextChapter": "Kitas Skyrius", | ||||
|   "ButtonNextItemInQueue": "Kitas eilėje", | ||||
|   "ButtonOk": "Ok", | ||||
|   "ButtonOpenFeed": "Atidaryti srautą", | ||||
|   "ButtonOpenManager": "Atidaryti tvarkyklę", | ||||
|   "ButtonPause": "Pauzė", | ||||
|   "ButtonPlay": "Groti", | ||||
|   "ButtonPlayAll": "Groti Visus", | ||||
|   "ButtonPlaying": "Grojama", | ||||
|   "ButtonPlaylists": "Grojaraščiai", | ||||
|   "ButtonPrevious": "Praeitas", | ||||
|   "ButtonPreviousChapter": "Praeitas Skyrius", | ||||
|   "ButtonPurgeAllCache": "Valyti visą saugyklą", | ||||
|   "ButtonPurgeItemsCache": "Valyti elementų saugyklą", | ||||
|   "ButtonQueueAddItem": "Pridėti į eilę", | ||||
| @ -55,6 +66,9 @@ | ||||
|   "ButtonQuickMatch": "Greitas pritaikymas", | ||||
|   "ButtonReScan": "Iš naujo nuskaityti", | ||||
|   "ButtonRead": "Skaityti", | ||||
|   "ButtonReadLess": "Mažiau", | ||||
|   "ButtonReadMore": "Daugiau", | ||||
|   "ButtonRefresh": "Atnaujinti", | ||||
|   "ButtonRemove": "Pašalinti", | ||||
|   "ButtonRemoveAll": "Pašalinti viską", | ||||
|   "ButtonRemoveAllLibraryItems": "Pašalinti visus bibliotekos elementus", | ||||
| @ -72,12 +86,15 @@ | ||||
|   "ButtonSelectFolderPath": "Pasirinkti aplanko kelią", | ||||
|   "ButtonSeries": "Serijos", | ||||
|   "ButtonSetChaptersFromTracks": "Nustatyti skyrius iš takelių", | ||||
|   "ButtonShare": "Dalintis", | ||||
|   "ButtonShiftTimes": "Perstumti laikus", | ||||
|   "ButtonShow": "Rodyti", | ||||
|   "ButtonStartM4BEncode": "Pradėti M4B kodavimą", | ||||
|   "ButtonStartMetadataEmbed": "Pradėti metaduomenų įterpimą", | ||||
|   "ButtonStats": "Statistika", | ||||
|   "ButtonSubmit": "Pateikti", | ||||
|   "ButtonTest": "Testuoti", | ||||
|   "ButtonUnlinkOpenId": "Atsieti OpenID", | ||||
|   "ButtonUpload": "Įkelti", | ||||
|   "ButtonUploadBackup": "Įkelti atsarginę kopiją", | ||||
|   "ButtonUploadCover": "Įkelti viršelį", | ||||
| @ -86,11 +103,15 @@ | ||||
|   "ButtonUserEdit": "Redaguoti naudotoją {0}", | ||||
|   "ButtonViewAll": "Peržiūrėti visus", | ||||
|   "ButtonYes": "Taip", | ||||
|   "ErrorUploadFetchMetadataAPI": "Klaida gaunant metaduomenis", | ||||
|   "ErrorUploadFetchMetadataNoResults": "Nepavyko gauti metaduomenų - pabandykite atnaujinti pavadinimą ir/ar autorių.", | ||||
|   "ErrorUploadLacksTitle": "Pavadinimas yra privalomas", | ||||
|   "HeaderAccount": "Paskyra", | ||||
|   "HeaderAdvanced": "Papildomi", | ||||
|   "HeaderAppriseNotificationSettings": "Apprise pranešimo nustatymai", | ||||
|   "HeaderAudioTracks": "Garso takeliai", | ||||
|   "HeaderAudiobookTools": "Audioknygų failų valdymo įrankiai", | ||||
|   "HeaderAuthentication": "Autentifikacija", | ||||
|   "HeaderBackups": "Atsarginės kopijos", | ||||
|   "HeaderChangePassword": "Pakeisti slaptažodį", | ||||
|   "HeaderChapters": "Skyriai", | ||||
| @ -99,6 +120,7 @@ | ||||
|   "HeaderCollectionItems": "Kolekcijos elementai", | ||||
|   "HeaderCover": "Viršelis", | ||||
|   "HeaderCurrentDownloads": "Dabartiniai parsisiuntimai", | ||||
|   "HeaderCustomMessageOnLogin": "Pritaikyta prisijungimo žinutė", | ||||
|   "HeaderDetails": "Detalės", | ||||
|   "HeaderDownloadQueue": "Parsisiuntimo eilė", | ||||
|   "HeaderEbookFiles": "Eknygos failai", | ||||
| @ -189,7 +211,7 @@ | ||||
|   "LabelBackToUser": "Grįžti į naudotoją", | ||||
|   "LabelBackupsEnableAutomaticBackups": "Įjungti automatinį atsarginių kopijų kūrimą", | ||||
|   "LabelBackupsEnableAutomaticBackupsHelp": "Atsarginės kopijos bus išsaugotos /metadata/backups aplanke", | ||||
|   "LabelBackupsMaxBackupSize": "Maksimalus atsarginių kopijų dydis (GB)", | ||||
|   "LabelBackupsMaxBackupSize": "Maksimalus atsarginių kopijų dydis (GB) (0 - neribotai)", | ||||
|   "LabelBackupsMaxBackupSizeHelp": "Jei konfigūruotas dydis viršijamas, atsarginės kopijos nebus sukurtos, kad būtų išvengta klaidingų konfigūracijų.", | ||||
|   "LabelBackupsNumberToKeep": "Laikytinų atsarginių kopijų skaičius", | ||||
|   "LabelBackupsNumberToKeepHelp": "Tik viena atsarginė kopija bus pašalinta vienu metu, todėl jei jau turite daugiau atsarginių kopijų nei nurodyta, turite jas pašalinti rankiniu būdu.", | ||||
| @ -397,7 +419,7 @@ | ||||
|   "LabelSettingsExperimentalFeatures": "Eksperimentiniai funkcionalumai", | ||||
|   "LabelSettingsExperimentalFeaturesHelp": "Funkcijos, kurios yra kuriamos ir laukiami jūsų komentarai. Spustelėkite, kad atidarytumėte „GitHub“ diskusiją.", | ||||
|   "LabelSettingsFindCovers": "Rasti viršelius", | ||||
|   "LabelSettingsFindCoversHelp": "Jei jūsų audioknyga neturi įterpto viršelio arba viršelio paveikslėlio aplanko, skeneris bandys rasti viršelį.<br>Pastaba: Tai padidins skenavimo trukmę.", | ||||
|   "LabelSettingsFindCoversHelp": "Jei jūsų audioknyga neturi įterpto viršelio arba viršelio paveikslėlio aplanke, bandyti rasti viršelį.<br>Pastaba: Tai padidins skenavimo trukmę.", | ||||
|   "LabelSettingsHideSingleBookSeries": "Slėpti serijas, turinčias tik vieną knygą", | ||||
|   "LabelSettingsHideSingleBookSeriesHelp": "Serijos, turinčios tik vieną knygą, bus paslėptos nuo serijų puslapio ir pagrindinio puslapio lentynų.", | ||||
|   "LabelSettingsHomePageBookshelfView": "Naudoti pagrindinio puslapio knygų lentynų vaizdą", | ||||
| @ -413,7 +435,7 @@ | ||||
|   "LabelSettingsSquareBookCovers": "Naudoti kvadratinius knygos viršelius", | ||||
|   "LabelSettingsSquareBookCoversHelp": "Naudoti kvadratinius viršelius vietoj standartinių 1.6:1 knygų viršelių", | ||||
|   "LabelSettingsStoreCoversWithItem": "Saugoti viršelius su elementu", | ||||
|   "LabelSettingsStoreCoversWithItemHelp": "Pagal nutylėjimą viršeliai saugomi /metadata/items aplanke, įjungus šią parinktį viršeliai bus saugomi jūsų bibliotekos elemento aplanke. Bus išsaugotas tik vienas „cover“ pavadinimo failas.", | ||||
|   "LabelSettingsStoreCoversWithItemHelp": "Pagal nutylėjimą viršeliai saugomi /metadata/items aplanke, įjungus šią parinktį viršeliai bus saugomi jūsų bibliotekos elemento aplanke. Bus išsaugotas tik vienas failas su \"cover\" pavadinimu.", | ||||
|   "LabelSettingsStoreMetadataWithItem": "Saugoti metaduomenis su elementu", | ||||
|   "LabelSettingsStoreMetadataWithItemHelp": "Pagal nutylėjimą metaduomenų failai saugomi /metadata/items aplanke, įjungus šią parinktį metaduomenų failai bus saugomi jūsų bibliotekos elemento aplanke", | ||||
|   "LabelSettingsTimeFormat": "Laiko formatas", | ||||
| @ -642,10 +664,17 @@ | ||||
|   "ToastBookmarkUpdateSuccess": "Žyma atnaujinta", | ||||
|   "ToastChaptersHaveErrors": "Skyriai turi klaidų", | ||||
|   "ToastChaptersMustHaveTitles": "Skyriai turi turėti pavadinimus", | ||||
|   "ToastChaptersRemoved": "Skyriai pašalinti", | ||||
|   "ToastCollectionItemsAddFailed": "Nepavyko pridėti į kolekciją", | ||||
|   "ToastCollectionItemsAddSuccess": "Pridėta į kolekciją", | ||||
|   "ToastCollectionItemsRemoveSuccess": "Elementai pašalinti iš kolekcijos", | ||||
|   "ToastCollectionRemoveSuccess": "Kolekcija pašalinta", | ||||
|   "ToastCollectionUpdateSuccess": "Kolekcija atnaujinta", | ||||
|   "ToastCoverUpdateFailed": "Viršelio atnaujinimas nepavyko", | ||||
|   "ToastDeviceTestEmailSuccess": "Bandomasis el. laiškas išsiųstas", | ||||
|   "ToastItemCoverUpdateSuccess": "Elemento viršelis atnaujintas", | ||||
|   "ToastItemDeletedFailed": "Nepavyko ištrinti", | ||||
|   "ToastItemDeletedSuccess": "Ištrinta", | ||||
|   "ToastItemDetailsUpdateSuccess": "Elemento detalės atnaujintos", | ||||
|   "ToastItemMarkedAsFinishedFailed": "Pažymėti kaip Baigta nepavyko", | ||||
|   "ToastItemMarkedAsFinishedSuccess": "Elementas pažymėtas kaip Baigta", | ||||
|  | ||||
| @ -31,6 +31,7 @@ | ||||
|   "ButtonForceReScan": "Forceer nieuwe scan", | ||||
|   "ButtonFullPath": "Volledig pad", | ||||
|   "ButtonHide": "Verberg", | ||||
|   "ButtonHome": "Thuis", | ||||
|   "ButtonIssues": "Problemen", | ||||
|   "ButtonJumpBackward": "Spring achteruit", | ||||
|   "ButtonJumpForward": "Spring vooruit", | ||||
| @ -76,6 +77,7 @@ | ||||
|   "ButtonScanLibrary": "Scan bibliotheek", | ||||
|   "ButtonSearch": "Zoeken", | ||||
|   "ButtonSelectFolderPath": "Maplocatie selecteren", | ||||
|   "ButtonSeries": "Series", | ||||
|   "ButtonSetChaptersFromTracks": "Maak hoofdstukken op basis van tracks", | ||||
|   "ButtonShare": "Deel", | ||||
|   "ButtonShiftTimes": "Tijden verschuiven", | ||||
| @ -93,6 +95,7 @@ | ||||
|   "ErrorUploadFetchMetadataAPI": "Error metadata ophalen", | ||||
|   "ErrorUploadFetchMetadataNoResults": "Kan metadata niet ophalen - probeer de titel en/of auteur te updaten", | ||||
|   "ErrorUploadLacksTitle": "Moet een titel hebben", | ||||
|   "HeaderAccount": "Account", | ||||
|   "HeaderAdvanced": "Geavanceerd", | ||||
|   "HeaderAppriseNotificationSettings": "Apprise-notificatie instellingen", | ||||
|   "HeaderAudioTracks": "Audiotracks", | ||||
| @ -105,6 +108,7 @@ | ||||
|   "HeaderCollectionItems": "Collectie-objecten", | ||||
|   "HeaderCover": "Omslag", | ||||
|   "HeaderCurrentDownloads": "Huidige downloads", | ||||
|   "HeaderDetails": "Details", | ||||
|   "HeaderDownloadQueue": "Download-wachtrij", | ||||
|   "HeaderEbookFiles": "Ebook bestanden", | ||||
|   "HeaderEmail": "E-mail", | ||||
| @ -207,8 +211,8 @@ | ||||
|   "LabelCollections": "Collecties", | ||||
|   "LabelComplete": "Compleet", | ||||
|   "LabelConfirmPassword": "Bevestig wachtwoord", | ||||
|   "LabelContinueListening": "Verder luisteren", | ||||
|   "LabelContinueReading": "Verder luisteren", | ||||
|   "LabelContinueListening": "Verder Luisteren", | ||||
|   "LabelContinueReading": "Verder lezen", | ||||
|   "LabelContinueSeries": "Ga verder met serie", | ||||
|   "LabelCoverImageURL": "Coverafbeelding URL", | ||||
|   "LabelCreatedAt": "Gecreëerd op", | ||||
|  | ||||
| @ -134,7 +134,7 @@ | ||||
|   "HeaderEmail": "E-pošta", | ||||
|   "HeaderEmailSettings": "Nastavitve e-pošte", | ||||
|   "HeaderEpisodes": "Epizode", | ||||
|   "HeaderEreaderDevices": "Ebralne naprave", | ||||
|   "HeaderEreaderDevices": "E-bralniki", | ||||
|   "HeaderEreaderSettings": "Nastavitve ebralnika", | ||||
|   "HeaderFiles": "Datoteke", | ||||
|   "HeaderFindChapters": "Najdi poglavja", | ||||
| @ -146,7 +146,7 @@ | ||||
|   "HeaderLibraries": "Knjižnice", | ||||
|   "HeaderLibraryFiles": "Datoteke knjižnice", | ||||
|   "HeaderLibraryStats": "Statistika knjižnice", | ||||
|   "HeaderListeningSessions": "Seje poslušanja", | ||||
|   "HeaderListeningSessions": "Sej poslušanja", | ||||
|   "HeaderListeningStats": "Statistika poslušanja", | ||||
|   "HeaderLogin": "Prijava", | ||||
|   "HeaderLogs": "Dnevniki", | ||||
| @ -161,10 +161,10 @@ | ||||
|   "HeaderNotificationCreate": "Ustvari obvestilo", | ||||
|   "HeaderNotificationUpdate": "Posodobi obvestilo", | ||||
|   "HeaderNotifications": "Obvestila", | ||||
|   "HeaderOpenIDConnectAuthentication": "Preverjanje pristnosti OpenID Connect", | ||||
|   "HeaderOpenIDConnectAuthentication": "Prijava z OpenID Connect", | ||||
|   "HeaderOpenRSSFeed": "Odpri vir RSS", | ||||
|   "HeaderOtherFiles": "Ostale datoteke", | ||||
|   "HeaderPasswordAuthentication": "Preverjanje pristnosti gesla", | ||||
|   "HeaderPasswordAuthentication": "Preverjanje pristnosti z geslom", | ||||
|   "HeaderPermissions": "Dovoljenja", | ||||
|   "HeaderPlayerQueue": "Čakalna vrsta predvajalnika", | ||||
|   "HeaderPlayerSettings": "Nastavitve predvajalnika", | ||||
| @ -186,7 +186,7 @@ | ||||
|   "HeaderSettingsDisplay": "Zaslon", | ||||
|   "HeaderSettingsExperimental": "Eksperimentalne funkcije", | ||||
|   "HeaderSettingsGeneral": "Splošno", | ||||
|   "HeaderSettingsScanner": "Skener", | ||||
|   "HeaderSettingsScanner": "Pregledovalnik", | ||||
|   "HeaderSleepTimer": "Časovnik za izklop", | ||||
|   "HeaderStatsLargestItems": "Največji elementi", | ||||
|   "HeaderStatsLongestItems": "Najdaljši elementi (ure)", | ||||
| @ -219,7 +219,7 @@ | ||||
|   "LabelAddedAt": "Dodano ob", | ||||
|   "LabelAddedDate": "Dodano {0}", | ||||
|   "LabelAdminUsersOnly": "Samo administratorji", | ||||
|   "LabelAll": "Vsi", | ||||
|   "LabelAll": "Vse", | ||||
|   "LabelAllUsers": "Vsi uporabniki", | ||||
|   "LabelAllUsersExcludingGuests": "Vsi uporabniki razen gosti", | ||||
|   "LabelAllUsersIncludingGuests": "Vsi uporabniki vključno z gosti", | ||||
| @ -245,7 +245,7 @@ | ||||
|   "LabelBackupsNumberToKeep": "Število varnostnih kopij, ki jih je treba hraniti", | ||||
|   "LabelBackupsNumberToKeepHelp": "Naenkrat bo odstranjena samo ena varnostna kopija, če že imate več varnostnih kopij, jih odstranite ročno.", | ||||
|   "LabelBitrate": "Bitna hitrost", | ||||
|   "LabelBooks": "Knjige", | ||||
|   "LabelBooks": "knjig", | ||||
|   "LabelButtonText": "Besedilo gumba", | ||||
|   "LabelByAuthor": "od {0}", | ||||
|   "LabelChangePassword": "Spremeni geslo", | ||||
| @ -400,8 +400,8 @@ | ||||
|   "LabelMinute": "Minuta", | ||||
|   "LabelMinutes": "Minute", | ||||
|   "LabelMissing": "Manjkajoče", | ||||
|   "LabelMissingEbook": "Nima nobene eknjige", | ||||
|   "LabelMissingSupplementaryEbook": "Nima nobene dodatne eknjige", | ||||
|   "LabelMissingEbook": "Nima nobene e-knjige", | ||||
|   "LabelMissingSupplementaryEbook": "Nima nobene dodatne e-knjige", | ||||
|   "LabelMobileRedirectURIs": "Dovoljeni mobilni preusmeritveni URI-ji", | ||||
|   "LabelMobileRedirectURIsDescription": "To je seznam dovoljenih veljavnih preusmeritvenih URI-jev za mobilne aplikacije. Privzeti je <code>audiobookshelf://oauth</code>, ki ga lahko odstranite ali dopolnite z dodatnimi URI-ji za integracijo aplikacij tretjih oseb. Uporaba zvezdice (<code>*</code>) kot edinega vnosa dovoljuje kateri koli URI.", | ||||
|   "LabelMore": "Več", | ||||
| @ -463,10 +463,12 @@ | ||||
|   "LabelProvider": "Ponudnik", | ||||
|   "LabelProviderAuthorizationValue": "Vrednost glave avtorizacije", | ||||
|   "LabelPubDate": "Datum objave", | ||||
|   "LabelPublishYear": "Leto objave", | ||||
|   "LabelPublishedDate": "Objavljeno {0}", | ||||
|   "LabelPublisher": "Založnik", | ||||
|   "LabelPublishers": "Založniki", | ||||
|   "LabelPublishYear": "Leto izdaje", | ||||
|   "LabelPublishedDate": "Izdano {0}", | ||||
|   "LabelPublishedDecade": "Desetletje izdaje", | ||||
|   "LabelPublishedDecades": "Desetletja izdaje", | ||||
|   "LabelPublisher": "Izdajatelj", | ||||
|   "LabelPublishers": "Izdajatelji", | ||||
|   "LabelRSSFeedCustomOwnerEmail": "E-pošta lastnika po meri", | ||||
|   "LabelRSSFeedCustomOwnerName": "Ime lastnika po meri", | ||||
|   "LabelRSSFeedOpen": "Odprt vir RSS", | ||||
| @ -507,11 +509,11 @@ | ||||
|   "LabelSettingsBookshelfViewHelp": "Skeuomorfna oblika z lesenimi policami", | ||||
|   "LabelSettingsChromecastSupport": "Podpora za Chromecast", | ||||
|   "LabelSettingsDateFormat": "Oblika datuma", | ||||
|   "LabelSettingsDisableWatcher": "Onemogoči pregledovalca", | ||||
|   "LabelSettingsDisableWatcherForLibrary": "Onemogoči pregledovalca map za knjižnico", | ||||
|   "LabelSettingsDisableWatcher": "Onemogoči spremljanje datotečnega sistema", | ||||
|   "LabelSettingsDisableWatcherForLibrary": "Onemogoči spremljanje map za knjižnico", | ||||
|   "LabelSettingsDisableWatcherHelp": "Onemogoči samodejno dodajanje/posodabljanje elementov, ko so zaznane spremembe datoteke. *Potreben je ponovni zagon strežnika", | ||||
|   "LabelSettingsEnableWatcher": "Omogoči pregledovalca", | ||||
|   "LabelSettingsEnableWatcherForLibrary": "Omogoči pregledovalca map za knjižnico", | ||||
|   "LabelSettingsEnableWatcher": "Omogoči spremljanje sprememb", | ||||
|   "LabelSettingsEnableWatcherForLibrary": "Omogoči spremljanje sprememb v mapi knjižnice", | ||||
|   "LabelSettingsEnableWatcherHelp": "Omogoča samodejno dodajanje/posodabljanje elementov, ko so zaznane spremembe datoteke. *Potreben je ponovni zagon strežnika", | ||||
|   "LabelSettingsEpubsAllowScriptedContent": "Dovoli skriptirano vsebino v epubih", | ||||
|   "LabelSettingsEpubsAllowScriptedContentHelp": "Dovoli datotekam epub izvajanje skript. Priporočljivo je, da to nastavitev pustite onemogočeno, razen če zaupate viru datotek epub.", | ||||
| @ -526,12 +528,12 @@ | ||||
|   "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Preskoči prejšnje knjige v nadaljevanju serije", | ||||
|   "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Polica z domačo stranjo Nadaljuj serijo prikazuje prvo nezačeto knjigo v seriji, ki ima vsaj eno dokončano knjigo in ni nobene knjige v teku. Če omogočite to nastavitev, se bo serija nadaljevala od najbolj dokončane knjige namesto od prve nezačete knjige.", | ||||
|   "LabelSettingsParseSubtitles": "Uporabi podnapise", | ||||
|   "LabelSettingsParseSubtitlesHelp": "Izvleci podnapise iz imen map zvočnih knjig.<br>Podnaslov mora biti ločen z \" - \"<br>npr. »Naslov knjige – Tu podnapis« ima podnaslov »Tu podnapis«", | ||||
|   "LabelSettingsParseSubtitlesHelp": "Izvleci podnapise iz imen map zvočnih knjig.<br>Podnapis mora biti ločen z \" - \"<br>npr. \"Naslov knjige – tu podnapis\" ima podnapis \"tu podnapis\"", | ||||
|   "LabelSettingsPreferMatchedMetadata": "Prednost imajo ujemajoči se metapodatki", | ||||
|   "LabelSettingsPreferMatchedMetadataHelp": "Pri uporabi hitrega ujemanja bodo ujemajoči se podatki preglasili podrobnosti artikla. Hitro ujemanje bo privzeto izpolnil samo manjkajoče podrobnosti.", | ||||
|   "LabelSettingsSkipMatchingBooksWithASIN": "Preskoči ujemajoče se knjige, ki že imajo ASIN", | ||||
|   "LabelSettingsSkipMatchingBooksWithISBN": "Preskoči ujemajoče se knjige, ki že imajo oznako ISBN", | ||||
|   "LabelSettingsSortingIgnorePrefixes": "Pri razvrščanju ne upoštevajte predpon", | ||||
|   "LabelSettingsSortingIgnorePrefixes": "Pri razvrščanju ne upoštevaj predpon", | ||||
|   "LabelSettingsSortingIgnorePrefixesHelp": "npr. za naslov knjige s predpono \"the\" bi se \"The Book Title\" razvrstil kot \"Book Title, The\"", | ||||
|   "LabelSettingsSquareBookCovers": "Uporabi kvadratne platnice knjig", | ||||
|   "LabelSettingsSquareBookCoversHelp": "Raje uporabi kvadratne platnice kot standardne knjižne platnice 1.6:1", | ||||
| @ -558,15 +560,15 @@ | ||||
|   "LabelStatsBestDay": "Najboljši dan", | ||||
|   "LabelStatsDailyAverage": "Dnevno povprečje", | ||||
|   "LabelStatsDays": "Dnevi", | ||||
|   "LabelStatsDaysListened": "Poslušani dnevi", | ||||
|   "LabelStatsDaysListened": "Dnevi poslušanja", | ||||
|   "LabelStatsHours": "Ure", | ||||
|   "LabelStatsInARow": "v vrsti", | ||||
|   "LabelStatsItemsFinished": "Končani elementi", | ||||
|   "LabelStatsItemsInLibrary": "Elementi v knjižnici", | ||||
|   "LabelStatsMinutes": "minute", | ||||
|   "LabelStatsMinutesListening": "Poslušane minute", | ||||
|   "LabelStatsMinutesListening": "Minut poslušanja", | ||||
|   "LabelStatsOverallDays": "Skupaj dnevi", | ||||
|   "LabelStatsOverallHours": "Skupaj ure", | ||||
|   "LabelStatsOverallHours": "Skupaj ur", | ||||
|   "LabelStatsWeekListening": "Tednov poslušanja", | ||||
|   "LabelSubtitle": "Podnapis", | ||||
|   "LabelSupportedFileTypes": "Podprte vrste datotek", | ||||
| @ -594,8 +596,8 @@ | ||||
|   "LabelTitle": "Naslov", | ||||
|   "LabelToolsEmbedMetadata": "Vdelaj metapodatke", | ||||
|   "LabelToolsEmbedMetadataDescription": "Vdelajte metapodatke v zvočne datoteke, vključno s sliko naslovnice in poglavji.", | ||||
|   "LabelToolsMakeM4b": "Ustvari datoteko zvočne knjige M4B", | ||||
|   "LabelToolsMakeM4bDescription": "Ustvarite datoteko zvočne knjige .M4B z vdelanimi metapodatki, sliko naslovnice in poglavji.", | ||||
|   "LabelToolsMakeM4b": "Ustvari M4B datoteko zvočne knjige", | ||||
|   "LabelToolsMakeM4bDescription": "Ustvari zvočno knjigo v .M4B obliki z vdelanimi metapodatki, sliko naslovnice in poglavji.", | ||||
|   "LabelToolsSplitM4b": "Razdeli M4B v MP3 datoteke", | ||||
|   "LabelToolsSplitM4bDescription": "Ustvarite MP3 datoteke iz datoteke M4B, razdeljene po poglavjih z vdelanimi metapodatki, naslovno sliko in poglavji.", | ||||
|   "LabelTotalDuration": "Skupno trajanje", | ||||
| @ -610,7 +612,7 @@ | ||||
|   "LabelUnabridged": "Neskrajšano", | ||||
|   "LabelUndo": "Razveljavi", | ||||
|   "LabelUnknown": "Neznano", | ||||
|   "LabelUnknownPublishDate": "Neznan datum objave", | ||||
|   "LabelUnknownPublishDate": "Neznan datum izdaje", | ||||
|   "LabelUpdateCover": "Posodobi naslovnico", | ||||
|   "LabelUpdateCoverHelp": "Dovoli prepisovanje obstoječih naslovnic za izbrane knjige, ko se najde ujemanje", | ||||
|   "LabelUpdateDetails": "Posodobi podrobnosti", | ||||
| @ -640,7 +642,7 @@ | ||||
|   "LabelYourPlaylists": "Tvoje seznami predvajanj", | ||||
|   "LabelYourProgress": "Tvoj napredek", | ||||
|   "MessageAddToPlayerQueue": "Dodaj v čakalno vrsto predvajalnika", | ||||
|   "MessageAppriseDescription": "Če želite uporabljati to funkcijo, morate imeti zagnan primerek <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">API Apprise</a> ali API, ki bo obravnaval te iste zahteve. <br />Url API-ja Apprise mora biti celotna pot URL-ja za pošiljanje obvestila, npr. če je vaš primerek API-ja postrežen na <code>http://192.168.1.1:8337</code>, bi morali vnesti <code >http://192.168.1.1:8337/notify</code>.", | ||||
|   "MessageAppriseDescription": "Če želite uporabljati to funkcijo, morate imeti zagnano namestitev <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">API Apprise</a> ali API, ki bo obravnavala te iste zahteve. <br />Url API-ja Apprise mora biti celotna pot URL-ja za pošiljanje obvestila, npr. če je vaša namestitev API-ja postrežena na <code>http://192.168.1.1:8337</code>, bi morali vnesti <code >http://192.168.1.1:8337/notify</code>.", | ||||
|   "MessageBackupsDescription": "Varnostne kopije vključujejo uporabnike, napredek uporabnikov, podrobnosti elementov knjižnice, nastavitve strežnika in slike, shranjene v <code>/metadata/items</code> & <code>/metadata/authors</code>. Varnostne kopije <strong>ne</strong> vključujejo datotek, shranjenih v mapah vaše knjižnice.", | ||||
|   "MessageBackupsLocationEditNote": "Opomba: Posodabljanje lokacije varnostne kopije ne bo premaknilo ali spremenilo obstoječih varnostnih kopij", | ||||
|   "MessageBackupsLocationNoEditNote": "Opomba: Lokacija varnostne kopije je nastavljena s spremenljivko okolja in je tu ni mogoče spremeniti.", | ||||
| @ -651,9 +653,9 @@ | ||||
|   "MessageBookshelfNoResultsForFilter": "Ni rezultatov za filter \"{0}: {1}\"", | ||||
|   "MessageBookshelfNoResultsForQuery": "Ni rezultatov za poizvedbo", | ||||
|   "MessageBookshelfNoSeries": "Nimate serij", | ||||
|   "MessageChapterEndIsAfter": "Konec poglavja je za koncem vaše zvočne knjige", | ||||
|   "MessageChapterEndIsAfter": "Konec poglavja je po koncu zvočne knjige", | ||||
|   "MessageChapterErrorFirstNotZero": "Prvo poglavje se mora začeti pri 0", | ||||
|   "MessageChapterErrorStartGteDuration": "Neveljaven začetni čas mora biti krajši od trajanja zvočne knjige", | ||||
|   "MessageChapterErrorStartGteDuration": "Neveljaven začetni čas, mora biti krajši od trajanja zvočne knjige", | ||||
|   "MessageChapterErrorStartLtPrev": "Neveljaven začetni čas mora biti večji od ali enak začetnemu času prejšnjega poglavja", | ||||
|   "MessageChapterStartIsAfter": "Začetek poglavja je po koncu vaše zvočne knjige", | ||||
|   "MessageCheckingCron": "Preverjam cron...", | ||||
| @ -667,7 +669,7 @@ | ||||
|   "MessageConfirmDeleteMetadataProvider": "Ali ste prepričani, da želite izbrisati ponudnika metapodatkov po meri \"{0}\"?", | ||||
|   "MessageConfirmDeleteNotification": "Ali ste prepričani, da želite izbrisati to obvestilo?", | ||||
|   "MessageConfirmDeleteSession": "Ali ste prepričani, da želite izbrisati to sejo?", | ||||
|   "MessageConfirmForceReScan": "Ali ste prepričani, da želite vsiliti ponovno iskanje?", | ||||
|   "MessageConfirmForceReScan": "Ali ste prepričani, da želite vsiliti ponovno pregledovanje?", | ||||
|   "MessageConfirmMarkAllEpisodesFinished": "Ali ste prepričani, da želite označiti vse epizode kot dokončane?", | ||||
|   "MessageConfirmMarkAllEpisodesNotFinished": "Ali ste prepričani, da želite vse epizode označiti kot nedokončane?", | ||||
|   "MessageConfirmMarkItemFinished": "Ali ste prepričani, da želite \"{0}\" označiti kot dokončanega?", | ||||
| @ -678,7 +680,7 @@ | ||||
|   "MessageConfirmPurgeCache": "Čiščenje predpomnilnika bo izbrisalo celoten imenik v <code>/metadata/cache</code>. <br /><br />Ali ste prepričani, da želite odstraniti imenik predpomnilnika?", | ||||
|   "MessageConfirmPurgeItemsCache": "Čiščenje predpomnilnika elementov bo izbrisalo celoten imenik na <code>/metadata/cache/items</code>.<br />Ste prepričani?", | ||||
|   "MessageConfirmQuickEmbed": "Opozorilo! Hitra vdelava ne bo varnostno kopirala vaših zvočnih datotek. Prepričajte se, da imate varnostno kopijo zvočnih datotek. <br><br>Ali želite nadaljevati?", | ||||
|   "MessageConfirmReScanLibraryItems": "Ali ste prepričani, da želite ponovno poiskati {0} elementov?", | ||||
|   "MessageConfirmReScanLibraryItems": "Ali ste prepričani, da želite ponovno pregledati {0} elementov?", | ||||
|   "MessageConfirmRemoveAllChapters": "Ali ste prepričani, da želite odstraniti vsa poglavja?", | ||||
|   "MessageConfirmRemoveAuthor": "Ali ste prepričani, da želite odstraniti avtorja \"{0}\"?", | ||||
|   "MessageConfirmRemoveCollection": "Ali ste prepričani, da želite odstraniti zbirko \"{0}\"?", | ||||
| @ -704,7 +706,7 @@ | ||||
|   "MessageEreaderDevices": "Da zagotovite dostavo e-knjig, boste morda morali dodati zgornji e-poštni naslov kot veljavnega pošiljatelja za vsako spodaj navedeno napravo.", | ||||
|   "MessageFeedURLWillBe": "URL vira bo {0}", | ||||
|   "MessageFetching": "Pridobivam...", | ||||
|   "MessageForceReScanDescription": "bo znova pregledal vse datoteke kot nov pregled. Oznake ID3 zvočnih datotek, datoteke OPF in besedilne datoteke bodo pregledane kot nove.", | ||||
|   "MessageForceReScanDescription": "bo znova pregledal vse datoteke kot pregled od začetka. Oznake ID3 zvočnih datotek, datoteke OPF in besedilne datoteke bodo pregledane kot nove.", | ||||
|   "MessageImportantNotice": "Pomembno obvestilo!", | ||||
|   "MessageInsertChapterBelow": "Spodaj vstavite poglavje", | ||||
|   "MessageItemsSelected": "{0} izbranih elementov", | ||||
| @ -716,12 +718,12 @@ | ||||
|   "MessageLogsDescription": "Dnevniki so shranjeni v <code>/metadata/logs</code> kot datoteke JSON. Dnevniki zrušitev so shranjeni v <code>/metadata/logs/crash_logs.txt</code>.", | ||||
|   "MessageM4BFailed": "M4B ni uspel!", | ||||
|   "MessageM4BFinished": "M4B končan!", | ||||
|   "MessageMapChapterTitles": "Preslikajte naslove poglavij v obstoječa poglavja zvočne knjige brez prilagajanja časovnih žigov", | ||||
|   "MessageMapChapterTitles": "Preslikaj naslove poglavij v obstoječa poglavja zvočne knjige brez prilagajanja časovnih indentifikatorjev", | ||||
|   "MessageMarkAllEpisodesFinished": "Označi vse epizode kot končane", | ||||
|   "MessageMarkAllEpisodesNotFinished": "Označi vse epizode kot nedokončane", | ||||
|   "MessageMarkAsFinished": "Označi kot dokončano", | ||||
|   "MessageMarkAsNotFinished": "Označi kot nedokončano", | ||||
|   "MessageMatchBooksDescription": "bo poskušal povezati knjige v knjižnici s knjigo izbranega ponudnika iskanja in izpolniti prazne podatke in naslovnico. Ne prepisujejo se pa podrobnosti.", | ||||
|   "MessageMatchBooksDescription": "bo poskušal povezati knjige v knjižnici s knjigo izbranega ponudnika iskanja in izpolniti prazne podatke in naslovnico. Ne prepisuje čez obstoječe podatke.", | ||||
|   "MessageNoAudioTracks": "Ni zvočnih posnetkov", | ||||
|   "MessageNoAuthors": "Brez avtorjev", | ||||
|   "MessageNoBackups": "Brez varnostnih kopij", | ||||
| @ -791,7 +793,7 @@ | ||||
|   "MessageTaskFailedToMergeAudioFiles": "Zvočnih datotek ni bilo mogoče združiti", | ||||
|   "MessageTaskFailedToMoveM4bFile": "Datoteke m4b ni bilo mogoče premakniti", | ||||
|   "MessageTaskFailedToWriteMetadataFile": "Metapodatke ni bilo mogoče zapisati v datoteke", | ||||
|   "MessageTaskMatchingBooksInLibrary": "Ujemam knjige v knjižnici \"{0}\"", | ||||
|   "MessageTaskMatchingBooksInLibrary": "Prepoznavam knjige v knjižnici \"{0}\"", | ||||
|   "MessageTaskNoFilesToScan": "Ni datotek za pregledovanje", | ||||
|   "MessageTaskOpmlImport": "Uvoz OPML", | ||||
|   "MessageTaskOpmlImportDescription": "Ustvarjanje podcastov iz {0} virov RSS", | ||||
| @ -807,14 +809,14 @@ | ||||
|   "MessageTaskScanItemsUpdated": "{0} posodobljeno", | ||||
|   "MessageTaskScanNoChangesNeeded": "Spremembe niso potrebne", | ||||
|   "MessageTaskScanningFileChanges": "Pregledovanje sprememb v datoteki \"{0}\"", | ||||
|   "MessageTaskScanningLibrary": "Pregled knjižnice \"{0}\"", | ||||
|   "MessageTaskScanningLibrary": "Pregledujem knjižnico \"{0}\"", | ||||
|   "MessageTaskTargetDirectoryNotWritable": "Ciljni imenik ni zapisljiv", | ||||
|   "MessageThinking": "Razmišljam...", | ||||
|   "MessageUploaderItemFailed": "Nalaganje ni uspelo", | ||||
|   "MessageUploaderItemSuccess": "Uspešno naloženo!", | ||||
|   "MessageUploading": "Nalaganje...", | ||||
|   "MessageValidCronExpression": "Veljaven cron izraz", | ||||
|   "MessageWatcherIsDisabledGlobally": "Pregledovalec je globalno onemogočen v nastavitvah strežnika", | ||||
|   "MessageWatcherIsDisabledGlobally": "Spremljanje sprememb datotek je globalno onemogočeno v nastavitvah strežnika", | ||||
|   "MessageXLibraryIsEmpty": "{0} Knjižnica je prazna!", | ||||
|   "MessageYourAudiobookDurationIsLonger": "Trajanje vaše zvočne knjige je daljše od ugotovljenega trajanja", | ||||
|   "MessageYourAudiobookDurationIsShorter": "Trajanje vaše zvočne knjige je krajše od ugotovljenega trajanja", | ||||
| @ -834,11 +836,11 @@ | ||||
|   "StatsAuthorsAdded": "dodanih avtorjev", | ||||
|   "StatsBooksAdded": "dodanih knjig", | ||||
|   "StatsBooksAdditional": "Nekateri dodatki vključujejo…", | ||||
|   "StatsBooksFinished": "končane knjige", | ||||
|   "StatsBooksFinished": "končanih knjig", | ||||
|   "StatsBooksFinishedThisYear": "Nekaj knjig, ki so bile dokončane letos…", | ||||
|   "StatsBooksListenedTo": "poslušane knjige", | ||||
|   "StatsBooksListenedTo": "poslušanih knjig", | ||||
|   "StatsCollectionGrewTo": "Vaša zbirka knjig se je povečala na …", | ||||
|   "StatsSessions": "seje", | ||||
|   "StatsSessions": "sej", | ||||
|   "StatsSpentListening": "porabil za poslušanje", | ||||
|   "StatsTopAuthor": "TOP AVTOR", | ||||
|   "StatsTopAuthors": "TOP AVTORJI", | ||||
| @ -920,6 +922,7 @@ | ||||
|   "ToastLibraryScanFailedToStart": "Pregleda ni bilo mogoče začeti", | ||||
|   "ToastLibraryScanStarted": "Pregled knjižnice se je začel", | ||||
|   "ToastLibraryUpdateSuccess": "Knjižnica \"{0}\" je bila posodobljena", | ||||
|   "ToastMatchAllAuthorsFailed": "Ujemanje vseh avtorjev ni bilo uspešno", | ||||
|   "ToastNameEmailRequired": "Ime in e-pošta sta obvezna", | ||||
|   "ToastNameRequired": "Ime je obvezno", | ||||
|   "ToastNewUserCreatedFailed": "Računa ni bilo mogoče ustvariti: \"{0}\"", | ||||
|  | ||||
							
								
								
									
										4
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										4
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @ -1,12 +1,12 @@ | ||||
| { | ||||
|   "name": "audiobookshelf", | ||||
|   "version": "2.14.0", | ||||
|   "version": "2.15.0", | ||||
|   "lockfileVersion": 3, | ||||
|   "requires": true, | ||||
|   "packages": { | ||||
|     "": { | ||||
|       "name": "audiobookshelf", | ||||
|       "version": "2.14.0", | ||||
|       "version": "2.15.0", | ||||
|       "license": "GPL-3.0", | ||||
|       "dependencies": { | ||||
|         "axios": "^0.27.2", | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| { | ||||
|   "name": "audiobookshelf", | ||||
|   "version": "2.14.0", | ||||
|   "version": "2.15.0", | ||||
|   "buildNumber": 1, | ||||
|   "description": "Self-hosted audiobook and podcast server", | ||||
|   "main": "index.js", | ||||
|  | ||||
| @ -38,6 +38,7 @@ class MigrationManager { | ||||
|     if (!(await fs.pathExists(this.configPath))) throw new Error(`Config path does not exist: ${this.configPath}`) | ||||
| 
 | ||||
|     this.migrationsDir = path.join(this.configPath, 'migrations') | ||||
|     await fs.ensureDir(this.migrationsDir) | ||||
| 
 | ||||
|     this.serverVersion = this.extractVersionFromTag(serverVersion) | ||||
|     if (!this.serverVersion) throw new Error(`Invalid server version: ${serverVersion}. Expected a version tag like v1.2.3.`) | ||||
| @ -222,8 +223,6 @@ class MigrationManager { | ||||
|   } | ||||
| 
 | ||||
|   async copyMigrationsToConfigDir() { | ||||
|     await fs.ensureDir(this.migrationsDir) // Ensure the target directory exists
 | ||||
| 
 | ||||
|     if (!(await fs.pathExists(this.migrationsSourceDir))) return | ||||
| 
 | ||||
|     const files = await fs.readdir(this.migrationsSourceDir) | ||||
|  | ||||
| @ -2,6 +2,6 @@ | ||||
| 
 | ||||
| Please add a record of every database migration that you create to this file. This will help us keep track of changes to the database schema over time. | ||||
| 
 | ||||
| | Server Version | Migration Script Name | Description | | ||||
| | -------------- | --------------------- | ----------- | | ||||
| |                |                       |             | | ||||
| | Server Version | Migration Script Name        | Description                                       | | ||||
| | -------------- | ---------------------------- | ------------------------------------------------- | | ||||
| | v2.15.0        | v2.15.0-series-column-unique | Series must have unique names in the same library | | ||||
|  | ||||
							
								
								
									
										206
									
								
								server/migrations/v2.15.0-series-column-unique.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										206
									
								
								server/migrations/v2.15.0-series-column-unique.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,206 @@ | ||||
| /** | ||||
|  * @typedef MigrationContext | ||||
|  * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. | ||||
|  * @property {import('../Logger')} logger - a Logger object. | ||||
|  * | ||||
|  * @typedef MigrationOptions | ||||
|  * @property {MigrationContext} context - an object containing the migration context. | ||||
|  */ | ||||
| 
 | ||||
| /** | ||||
|  * This upward migration script cleans any duplicate series in the `Series` table and | ||||
|  * adds a unique index on the `name` and `libraryId` columns. | ||||
|  * | ||||
|  * @param {MigrationOptions} options - an object containing the migration context. | ||||
|  * @returns {Promise<void>} - A promise that resolves when the migration is complete. | ||||
|  */ | ||||
| async function up({ context: { queryInterface, logger } }) { | ||||
|   // Upwards migration script
 | ||||
|   logger.info('[2.15.0 migration] UPGRADE BEGIN: 2.15.0-series-column-unique ') | ||||
| 
 | ||||
|   // Check if the unique index already exists
 | ||||
|   const seriesIndexes = await queryInterface.showIndex('Series') | ||||
|   if (seriesIndexes.some((index) => index.name === 'unique_series_name_per_library')) { | ||||
|     logger.info('[2.15.0 migration] Unique index on Series.name and Series.libraryId already exists') | ||||
|     logger.info('[2.15.0 migration] UPGRADE END: 2.15.0-series-column-unique ') | ||||
|     return | ||||
|   } | ||||
| 
 | ||||
|   // The steps taken to deduplicate the series are as follows:
 | ||||
|   // 1. Find all duplicate series in the `Series` table.
 | ||||
|   // 2. Iterate over the duplicate series and find all book IDs that are associated with the duplicate series in `bookSeries` table.
 | ||||
|   //    2.a For each book ID, check if the ID occurs multiple times for the duplicate series.
 | ||||
|   //    2.b If so, keep only one of the rows that has this bookId and seriesId.
 | ||||
|   // 3. Update `bookSeries` table to point to the most recent series.
 | ||||
|   // 4. Delete the older series.
 | ||||
| 
 | ||||
|   // Use the queryInterface to get the series table and find duplicates in the `name` and `libraryId` column
 | ||||
|   const [duplicates] = await queryInterface.sequelize.query(` | ||||
|     SELECT name, libraryId | ||||
|     FROM Series | ||||
|     GROUP BY name, libraryId | ||||
|     HAVING COUNT(name) > 1 | ||||
|   `)
 | ||||
| 
 | ||||
|   // Print out how many duplicates were found
 | ||||
|   logger.info(`[2.15.0 migration] Found ${duplicates.length} duplicate series`) | ||||
| 
 | ||||
|   // Iterate over each duplicate series
 | ||||
|   for (const duplicate of duplicates) { | ||||
|     // Report the series name that is being deleted
 | ||||
|     logger.info(`[2.15.0 migration] Deduplicating series "${duplicate.name}" in library ${duplicate.libraryId}`) | ||||
| 
 | ||||
|     // Determine any duplicate book IDs in the `bookSeries` table for the same series
 | ||||
|     const [duplicateBookIds] = await queryInterface.sequelize.query( | ||||
|       ` | ||||
|         SELECT bookId | ||||
|         FROM BookSeries | ||||
|         WHERE seriesId IN ( | ||||
|           SELECT id | ||||
|           FROM Series | ||||
|           WHERE name = :name AND libraryId = :libraryId | ||||
|         ) | ||||
|         GROUP BY bookId | ||||
|         HAVING COUNT(bookId) > 1 | ||||
|         `,
 | ||||
|       { | ||||
|         replacements: { | ||||
|           name: duplicate.name, | ||||
|           libraryId: duplicate.libraryId | ||||
|         } | ||||
|       } | ||||
|     ) | ||||
| 
 | ||||
|     // Iterate over the duplicate book IDs if there is at least one and only keep the first row that has this bookId and seriesId
 | ||||
|     for (const { bookId } of duplicateBookIds) { | ||||
|       logger.info(`[2.15.0 migration] Deduplicating bookId ${bookId} in series "${duplicate.name}" of library ${duplicate.libraryId}`) | ||||
|       // Get all rows of `BookSeries` table that have the same `bookId` and `seriesId`. Sort by `sequence` with nulls sorted last
 | ||||
|       const [duplicateBookSeries] = await queryInterface.sequelize.query( | ||||
|         ` | ||||
|             SELECT id | ||||
|             FROM BookSeries | ||||
|             WHERE bookId = :bookId | ||||
|             AND seriesId IN ( | ||||
|               SELECT id | ||||
|               FROM Series | ||||
|               WHERE name = :name AND libraryId = :libraryId | ||||
|             ) | ||||
|             ORDER BY sequence NULLS LAST | ||||
|             `,
 | ||||
|         { | ||||
|           replacements: { | ||||
|             bookId, | ||||
|             name: duplicate.name, | ||||
|             libraryId: duplicate.libraryId | ||||
|           } | ||||
|         } | ||||
|       ) | ||||
| 
 | ||||
|       // remove the first element from the array
 | ||||
|       duplicateBookSeries.shift() | ||||
| 
 | ||||
|       // Delete the remaining duplicate rows
 | ||||
|       if (duplicateBookSeries.length > 0) { | ||||
|         const [deletedBookSeries] = await queryInterface.sequelize.query( | ||||
|           ` | ||||
|               DELETE FROM BookSeries | ||||
|               WHERE id IN (:ids) | ||||
|               `,
 | ||||
|           { | ||||
|             replacements: { | ||||
|               ids: duplicateBookSeries.map((row) => row.id) | ||||
|             } | ||||
|           } | ||||
|         ) | ||||
|       } | ||||
|       logger.info(`[2.15.0 migration] Finished cleanup of bookId ${bookId} in series "${duplicate.name}" of library ${duplicate.libraryId}`) | ||||
|     } | ||||
| 
 | ||||
|     // Get all the most recent series which matches the `name` and `libraryId`
 | ||||
|     const [mostRecentSeries] = await queryInterface.sequelize.query( | ||||
|       ` | ||||
|         SELECT id | ||||
|         FROM Series | ||||
|         WHERE name = :name AND libraryId = :libraryId | ||||
|         ORDER BY updatedAt DESC | ||||
|         LIMIT 1 | ||||
|         `,
 | ||||
|       { | ||||
|         replacements: { | ||||
|           name: duplicate.name, | ||||
|           libraryId: duplicate.libraryId | ||||
|         }, | ||||
|         type: queryInterface.sequelize.QueryTypes.SELECT | ||||
|       } | ||||
|     ) | ||||
| 
 | ||||
|     if (mostRecentSeries) { | ||||
|       // Update all BookSeries records for this series to point to the most recent series
 | ||||
|       const [seriesUpdated] = await queryInterface.sequelize.query( | ||||
|         ` | ||||
|           UPDATE BookSeries | ||||
|           SET seriesId = :mostRecentSeriesId | ||||
|           WHERE seriesId IN ( | ||||
|             SELECT id | ||||
|             FROM Series | ||||
|             WHERE name = :name AND libraryId = :libraryId | ||||
|             AND id != :mostRecentSeriesId | ||||
|           ) | ||||
|           `,
 | ||||
|         { | ||||
|           replacements: { | ||||
|             name: duplicate.name, | ||||
|             libraryId: duplicate.libraryId, | ||||
|             mostRecentSeriesId: mostRecentSeries.id | ||||
|           } | ||||
|         } | ||||
|       ) | ||||
| 
 | ||||
|       // Delete the older series
 | ||||
|       const seriesDeleted = await queryInterface.sequelize.query( | ||||
|         ` | ||||
|           DELETE FROM Series | ||||
|           WHERE name = :name AND libraryId = :libraryId | ||||
|           AND id != :mostRecentSeriesId | ||||
|           `,
 | ||||
|         { | ||||
|           replacements: { | ||||
|             name: duplicate.name, | ||||
|             libraryId: duplicate.libraryId, | ||||
|             mostRecentSeriesId: mostRecentSeries.id | ||||
|           } | ||||
|         } | ||||
|       ) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   logger.info(`[2.15.0 migration] Deduplication complete`) | ||||
| 
 | ||||
|   // Create a unique index based on the name and library ID for the `Series` table
 | ||||
|   await queryInterface.addIndex('Series', ['name', 'libraryId'], { | ||||
|     unique: true, | ||||
|     name: 'unique_series_name_per_library' | ||||
|   }) | ||||
|   logger.info('[2.15.0 migration] Added unique index on Series.name and Series.libraryId') | ||||
| 
 | ||||
|   logger.info('[2.15.0 migration] UPGRADE END: 2.15.0-series-column-unique ') | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * This removes the unique index on the `Series` table. | ||||
|  * | ||||
|  * @param {MigrationOptions} options - an object containing the migration context. | ||||
|  * @returns {Promise<void>} - A promise that resolves when the migration is complete. | ||||
|  */ | ||||
| async function down({ context: { queryInterface, logger } }) { | ||||
|   // Downward migration script
 | ||||
|   logger.info('[2.15.0 migration] DOWNGRADE BEGIN: 2.15.0-series-column-unique ') | ||||
| 
 | ||||
|   // Remove the unique index
 | ||||
|   await queryInterface.removeIndex('Series', 'unique_series_name_per_library') | ||||
|   logger.info('[2.15.0 migration] Removed unique index on Series.name and Series.libraryId') | ||||
| 
 | ||||
|   logger.info('[2.15.0 migration] DOWNGRADE END: 2.15.0-series-column-unique ') | ||||
| } | ||||
| 
 | ||||
| module.exports = { up, down } | ||||
| @ -83,6 +83,12 @@ class Series extends Model { | ||||
|           //     collate: 'NOCASE'
 | ||||
|           //   }]
 | ||||
|           // },
 | ||||
|           { | ||||
|             // unique constraint on name and libraryId
 | ||||
|             fields: ['name', 'libraryId'], | ||||
|             unique: true, | ||||
|             name: 'unique_series_name_per_library' | ||||
|           }, | ||||
|           { | ||||
|             fields: ['libraryId'] | ||||
|           } | ||||
|  | ||||
| @ -7,6 +7,7 @@ module.exports.notificationData = { | ||||
|       requiresLibrary: true, | ||||
|       libraryMediaType: 'podcast', | ||||
|       description: 'Triggered when a podcast episode is auto-downloaded', | ||||
|       descriptionKey: 'NotificationOnEpisodeDownloadedDescription', | ||||
|       variables: ['libraryItemId', 'libraryId', 'podcastTitle', 'podcastAuthor', 'podcastDescription', 'podcastGenres', 'episodeTitle', 'episodeSubtitle', 'episodeDescription', 'libraryName', 'episodeId', 'mediaTags'], | ||||
|       defaults: { | ||||
|         title: 'New {{podcastTitle}} Episode!', | ||||
| @ -31,6 +32,7 @@ module.exports.notificationData = { | ||||
|       name: 'onBackupCompleted', | ||||
|       requiresLibrary: false, | ||||
|       description: 'Triggered when a backup is completed', | ||||
|       descriptionKey: 'NotificationOnBackupCompletedDescription', | ||||
|       variables: ['completionTime', 'backupPath', 'backupSize', 'backupCount', 'removedOldest'], | ||||
|       defaults: { | ||||
|         title: 'Backup Completed', | ||||
| @ -48,6 +50,7 @@ module.exports.notificationData = { | ||||
|       name: 'onBackupFailed', | ||||
|       requiresLibrary: false, | ||||
|       description: 'Triggered when a backup fails', | ||||
|       descriptionKey: 'NotificationOnBackupFailedDescription', | ||||
|       variables: ['errorMsg'], | ||||
|       defaults: { | ||||
|         title: 'Backup Failed', | ||||
| @ -61,6 +64,7 @@ module.exports.notificationData = { | ||||
|       name: 'onTest', | ||||
|       requiresLibrary: false, | ||||
|       description: 'Event for testing the notification system', | ||||
|       descriptionKey: 'NotificationOnTestDescription', | ||||
|       variables: ['version'], | ||||
|       defaults: { | ||||
|         title: 'Test Notification on Abs {{version}}', | ||||
|  | ||||
| @ -219,7 +219,7 @@ module.exports = { | ||||
|         mediaWhere[key] = { | ||||
|           [Sequelize.Op.or]: [null, ''] | ||||
|         } | ||||
|       } else if (['genres', 'tags', 'narrators'].includes(value)) { | ||||
|       } else if (['genres', 'tags', 'narrators', 'chapters'].includes(value)) { | ||||
|         mediaWhere[value] = { | ||||
|           [Sequelize.Op.or]: [null, Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col(value)), 0)] | ||||
|         } | ||||
|  | ||||
| @ -63,6 +63,8 @@ describe('MigrationManager', () => { | ||||
|       await migrationManager.init(serverVersion) | ||||
| 
 | ||||
|       // Assert
 | ||||
|       expect(fsEnsureDirStub.calledOnce).to.be.true | ||||
|       expect(fsEnsureDirStub.calledWith(migrationManager.migrationsDir)).to.be.true | ||||
|       expect(migrationManager.serverVersion).to.equal(serverVersion) | ||||
|       expect(migrationManager.sequelize).to.equal(sequelizeStub) | ||||
|       expect(migrationManager.migrationsDir).to.equal(path.join(__dirname, 'migrations')) | ||||
| @ -353,8 +355,6 @@ describe('MigrationManager', () => { | ||||
|       await migrationManager.copyMigrationsToConfigDir() | ||||
| 
 | ||||
|       // Assert
 | ||||
|       expect(fsEnsureDirStub.calledOnce).to.be.true | ||||
|       expect(fsEnsureDirStub.calledWith(targetDir)).to.be.true | ||||
|       expect(readdirStub.calledOnce).to.be.true | ||||
|       expect(readdirStub.calledWith(migrationsSourceDir)).to.be.true | ||||
|       expect(fsCopyStub.calledTwice).to.be.true | ||||
| @ -382,8 +382,6 @@ describe('MigrationManager', () => { | ||||
|       } catch (error) {} | ||||
| 
 | ||||
|       // Assert
 | ||||
|       expect(fsEnsureDirStub.calledOnce).to.be.true | ||||
|       expect(fsEnsureDirStub.calledWith(targetDir)).to.be.true | ||||
|       expect(readdirStub.calledOnce).to.be.true | ||||
|       expect(readdirStub.calledWith(migrationsSourceDir)).to.be.true | ||||
|       expect(fsCopyStub.calledTwice).to.be.true | ||||
|  | ||||
							
								
								
									
										335
									
								
								test/server/migrations/v2.15.0-series-column-unique.test.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										335
									
								
								test/server/migrations/v2.15.0-series-column-unique.test.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,335 @@ | ||||
| const { expect } = require('chai') | ||||
| const sinon = require('sinon') | ||||
| const { up, down } = require('../../../server/migrations/v2.15.0-series-column-unique') | ||||
| const { Sequelize } = require('sequelize') | ||||
| const Logger = require('../../../server/Logger') | ||||
| const { query } = require('express') | ||||
| const { logger } = require('sequelize/lib/utils/logger') | ||||
| const e = require('express') | ||||
| 
 | ||||
| describe('migration-v2.15.0-series-column-unique', () => { | ||||
|   let sequelize | ||||
|   let queryInterface | ||||
|   let loggerInfoStub | ||||
|   let series1Id | ||||
|   let series2Id | ||||
|   let series3Id | ||||
|   let series1Id_dup | ||||
|   let series3Id_dup | ||||
|   let series1Id_dup2 | ||||
|   let book1Id | ||||
|   let book2Id | ||||
|   let book3Id | ||||
|   let book4Id | ||||
|   let book5Id | ||||
|   let book6Id | ||||
|   let library1Id | ||||
|   let library2Id | ||||
|   let bookSeries1Id | ||||
|   let bookSeries2Id | ||||
|   let bookSeries3Id | ||||
|   let bookSeries1Id_dup | ||||
|   let bookSeries3Id_dup | ||||
|   let bookSeries1Id_dup2 | ||||
| 
 | ||||
|   beforeEach(() => { | ||||
|     sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) | ||||
|     queryInterface = sequelize.getQueryInterface() | ||||
|     loggerInfoStub = sinon.stub(Logger, 'info') | ||||
|   }) | ||||
| 
 | ||||
|   afterEach(() => { | ||||
|     sinon.restore() | ||||
|   }) | ||||
| 
 | ||||
|   describe('up', () => { | ||||
|     beforeEach(async () => { | ||||
|       await queryInterface.createTable('Series', { | ||||
|         id: { type: Sequelize.UUID, primaryKey: true }, | ||||
|         name: { type: Sequelize.STRING, allowNull: false }, | ||||
|         libraryId: { type: Sequelize.UUID, allowNull: false }, | ||||
|         createdAt: { type: Sequelize.DATE, allowNull: false }, | ||||
|         updatedAt: { type: Sequelize.DATE, allowNull: false } | ||||
|       }) | ||||
|       // Create a table for BookSeries, with a unique constraint of bookId and seriesId
 | ||||
|       await queryInterface.createTable( | ||||
|         'BookSeries', | ||||
|         { | ||||
|           id: { type: Sequelize.UUID, primaryKey: true }, | ||||
|           sequence: { type: Sequelize.STRING, allowNull: true }, | ||||
|           bookId: { type: Sequelize.UUID, allowNull: false }, | ||||
|           seriesId: { type: Sequelize.UUID, allowNull: false } | ||||
|         }, | ||||
|         { uniqueKeys: { book_series_unique: { fields: ['bookId', 'seriesId'] } } } | ||||
|       ) | ||||
|       // Set UUIDs for the tests
 | ||||
|       series1Id = 'fc086255-3fd2-4a95-8a28-840d9206501b' | ||||
|       series2Id = '70f46ac2-ee48-4b3c-9822-933cc15c29bd' | ||||
|       series3Id = '01cac008-142b-4e15-b0ff-cf7cc2c5b64e' | ||||
|       series1Id_dup = 'ad0b3b3b-4b3b-4b3b-4b3b-4b3b4b3b4b3b' | ||||
|       series3Id_dup = '4b3b4b3b-4b3b-4b3b-4b3b-4b3b4b3b4b3b' | ||||
|       series1Id_dup2 = '0123456a-4b3b-4b3b-4b3b-4b3b4b3b4b3b' | ||||
|       book1Id = '4a38b6e5-0ae4-4de4-b119-4e33891bd63f' | ||||
|       book2Id = '8bc2e61d-47f6-42ef-a3f4-93cf2f1de82f' | ||||
|       book3Id = 'ec9bbaaf-1e55-457f-b59c-bd2bd955a404' | ||||
|       book4Id = '876f3b3b-4b3b-4b3b-4b3b-4b3b4b3b4b3b' | ||||
|       book5Id = '4e5b4b3b-4b3b-4b3b-4b3b-4b3b4b3b4b3b' | ||||
|       book6Id = 'abcda123-4b3b-4b3b-4b3b-4b3b4b3b4b3b' | ||||
|       library1Id = '3a5a1c7c-a914-472e-88b0-b871ceae63e7' | ||||
|       library2Id = 'fd6c324a-4f3a-4bb0-99d6-7a330e765e7e' | ||||
|       bookSeries1Id = 'eca24687-2241-4ffa-a9b3-02a0ba03c763' | ||||
|       bookSeries2Id = '56f56105-813b-4395-9689-fd04198e7d5d' | ||||
|       bookSeries3Id = '404a1761-c710-4d86-9d78-68d9a9c0fb6b' | ||||
|       bookSeries1Id_dup = '8bea3b3b-4b3b-4b3b-4b3b-4b3b4b3b4b3b' | ||||
|       bookSeries3Id_dup = '89656a3b-4b3b-4b3b-4b3b-4b3b4b3b4b3b' | ||||
|       bookSeries1Id_dup2 = '9bea3b3b-4b3b-4b3b-4b3b-4b3b4b3b4b3b' | ||||
|     }) | ||||
|     afterEach(async () => { | ||||
|       await queryInterface.dropTable('Series') | ||||
|       await queryInterface.dropTable('BookSeries') | ||||
|     }) | ||||
|     it('upgrade with no duplicate series', async () => { | ||||
|       // Add some entries to the Series table using the UUID for the ids
 | ||||
|       await queryInterface.bulkInsert('Series', [ | ||||
|         { id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, | ||||
|         { id: series2Id, name: 'Series 2', libraryId: library2Id, createdAt: new Date(), updatedAt: new Date() }, | ||||
|         { id: series3Id, name: 'Series 3', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() } | ||||
|       ]) | ||||
|       // Add some entries to the BookSeries table
 | ||||
|       await queryInterface.bulkInsert('BookSeries', [ | ||||
|         { id: bookSeries1Id, sequence: '1', bookId: book1Id, seriesId: series1Id }, | ||||
|         { id: bookSeries2Id, bookId: book2Id, seriesId: series2Id }, | ||||
|         { id: bookSeries3Id, sequence: '1', bookId: book3Id, seriesId: series3Id } | ||||
|       ]) | ||||
| 
 | ||||
|       await up({ context: { queryInterface, logger: Logger } }) | ||||
| 
 | ||||
|       expect(loggerInfoStub.callCount).to.equal(5) | ||||
|       expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.15.0 migration] UPGRADE BEGIN: 2.15.0-series-column-unique '))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.15.0 migration] Found 0 duplicate series'))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.15.0 migration] Deduplication complete'))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.15.0 migration] Added unique index on Series.name and Series.libraryId'))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.15.0 migration] UPGRADE END: 2.15.0-series-column-unique '))).to.be.true | ||||
|       // Validate rows in tables
 | ||||
|       const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) | ||||
|       expect(series).to.have.length(3) | ||||
|       expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id }) | ||||
|       expect(series).to.deep.include({ id: series2Id, name: 'Series 2', libraryId: library2Id }) | ||||
|       expect(series).to.deep.include({ id: series3Id, name: 'Series 3', libraryId: library1Id }) | ||||
|       const bookSeries = await queryInterface.sequelize.query('SELECT "id", "sequence", "bookId", "seriesId" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT }) | ||||
|       expect(bookSeries).to.have.length(3) | ||||
|       expect(bookSeries).to.deep.include({ id: bookSeries1Id, sequence: '1', bookId: book1Id, seriesId: series1Id }) | ||||
|       expect(bookSeries).to.deep.include({ id: bookSeries2Id, sequence: null, bookId: book2Id, seriesId: series2Id }) | ||||
|       expect(bookSeries).to.deep.include({ id: bookSeries3Id, sequence: '1', bookId: book3Id, seriesId: series3Id }) | ||||
|     }) | ||||
|     it('upgrade with duplicate series and no sequence', async () => { | ||||
|       // Add some entries to the Series table using the UUID for the ids
 | ||||
|       await queryInterface.bulkInsert('Series', [ | ||||
|         { id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, | ||||
|         { id: series2Id, name: 'Series 2', libraryId: library2Id, createdAt: new Date(), updatedAt: new Date() }, | ||||
|         { id: series3Id, name: 'Series 3', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, | ||||
|         { id: series1Id_dup, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, | ||||
|         { id: series3Id_dup, name: 'Series 3', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, | ||||
|         { id: series1Id_dup2, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() } | ||||
|       ]) | ||||
|       // Add some entries to the BookSeries table
 | ||||
|       await queryInterface.bulkInsert('BookSeries', [ | ||||
|         { id: bookSeries1Id, bookId: book1Id, seriesId: series1Id }, | ||||
|         { id: bookSeries2Id, bookId: book2Id, seriesId: series2Id }, | ||||
|         { id: bookSeries3Id, bookId: book3Id, seriesId: series3Id }, | ||||
|         { id: bookSeries1Id_dup, bookId: book4Id, seriesId: series1Id_dup }, | ||||
|         { id: bookSeries3Id_dup, bookId: book5Id, seriesId: series3Id_dup }, | ||||
|         { id: bookSeries1Id_dup2, bookId: book6Id, seriesId: series1Id_dup2 } | ||||
|       ]) | ||||
| 
 | ||||
|       await up({ context: { queryInterface, logger: Logger } }) | ||||
| 
 | ||||
|       expect(loggerInfoStub.callCount).to.equal(7) | ||||
|       expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.15.0 migration] UPGRADE BEGIN: 2.15.0-series-column-unique '))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.15.0 migration] Found 2 duplicate series'))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.15.0 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.15.0 migration] Deduplicating series "Series 3" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.15.0 migration] Deduplication complete'))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.15.0 migration] Added unique index on Series.name and Series.libraryId'))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(6).calledWith(sinon.match('[2.15.0 migration] UPGRADE END: 2.15.0-series-column-unique '))).to.be.true | ||||
|       // Validate rows
 | ||||
|       const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) | ||||
|       expect(series).to.have.length(3) | ||||
|       expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id }) | ||||
|       expect(series).to.deep.include({ id: series2Id, name: 'Series 2', libraryId: library2Id }) | ||||
|       expect(series).to.deep.include({ id: series3Id, name: 'Series 3', libraryId: library1Id }) | ||||
|       const bookSeries = await queryInterface.sequelize.query('SELECT "id", "bookId", "seriesId" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT }) | ||||
|       expect(bookSeries).to.have.length(6) | ||||
|       expect(bookSeries).to.deep.include({ id: bookSeries1Id, bookId: book1Id, seriesId: series1Id }) | ||||
|       expect(bookSeries).to.deep.include({ id: bookSeries2Id, bookId: book2Id, seriesId: series2Id }) | ||||
|       expect(bookSeries).to.deep.include({ id: bookSeries3Id, bookId: book3Id, seriesId: series3Id }) | ||||
|       expect(bookSeries).to.deep.include({ id: bookSeries1Id_dup, bookId: book4Id, seriesId: series1Id }) | ||||
|       expect(bookSeries).to.deep.include({ id: bookSeries3Id_dup, bookId: book5Id, seriesId: series3Id }) | ||||
|       expect(bookSeries).to.deep.include({ id: bookSeries1Id_dup2, bookId: book6Id, seriesId: series1Id }) | ||||
|     }) | ||||
|     it('upgrade with same series name in different libraries', async () => { | ||||
|       // Add some entries to the Series table using the UUID for the ids
 | ||||
|       await queryInterface.bulkInsert('Series', [ | ||||
|         { id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, | ||||
|         { id: series2Id, name: 'Series 1', libraryId: library2Id, createdAt: new Date(), updatedAt: new Date() } | ||||
|       ]) | ||||
|       // Add some entries to the BookSeries table
 | ||||
|       await queryInterface.bulkInsert('BookSeries', [ | ||||
|         { id: bookSeries1Id, bookId: book1Id, seriesId: series1Id }, | ||||
|         { id: bookSeries2Id, bookId: book2Id, seriesId: series2Id } | ||||
|       ]) | ||||
| 
 | ||||
|       await up({ context: { queryInterface, logger: Logger } }) | ||||
| 
 | ||||
|       expect(loggerInfoStub.callCount).to.equal(5) | ||||
|       expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.15.0 migration] UPGRADE BEGIN: 2.15.0-series-column-unique '))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.15.0 migration] Found 0 duplicate series'))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.15.0 migration] Deduplication complete'))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.15.0 migration] Added unique index on Series.name and Series.libraryId'))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.15.0 migration] UPGRADE END: 2.15.0-series-column-unique '))).to.be.true | ||||
|       // Validate rows
 | ||||
|       const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) | ||||
|       expect(series).to.have.length(2) | ||||
|       expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id }) | ||||
|       expect(series).to.deep.include({ id: series2Id, name: 'Series 1', libraryId: library2Id }) | ||||
|       const bookSeries = await queryInterface.sequelize.query('SELECT "id", "bookId", "seriesId" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT }) | ||||
|       expect(bookSeries).to.have.length(2) | ||||
|       expect(bookSeries).to.deep.include({ id: bookSeries1Id, bookId: book1Id, seriesId: series1Id }) | ||||
|       expect(bookSeries).to.deep.include({ id: bookSeries2Id, bookId: book2Id, seriesId: series2Id }) | ||||
|     }) | ||||
|     it('upgrade with one book in two of the same series, both sequence are null', async () => { | ||||
|       // Create two different series with the same name in the same library
 | ||||
|       await queryInterface.bulkInsert('Series', [ | ||||
|         { id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, | ||||
|         { id: series2Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() } | ||||
|       ]) | ||||
|       // Create a book that is in both series
 | ||||
|       await queryInterface.bulkInsert('BookSeries', [ | ||||
|         { id: bookSeries1Id, bookId: book1Id, seriesId: series1Id }, | ||||
|         { id: bookSeries2Id, bookId: book1Id, seriesId: series2Id } | ||||
|       ]) | ||||
| 
 | ||||
|       await up({ context: { queryInterface, logger: Logger } }) | ||||
| 
 | ||||
|       expect(loggerInfoStub.callCount).to.equal(8) | ||||
|       expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.15.0 migration] UPGRADE BEGIN: 2.15.0-series-column-unique '))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.15.0 migration] Found 1 duplicate series'))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.15.0 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.15.0 migration] Deduplicating bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.15.0 migration] Finished cleanup of bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.15.0 migration] Deduplication complete'))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(6).calledWith(sinon.match('[2.15.0 migration] Added unique index on Series.name and Series.libraryId'))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(7).calledWith(sinon.match('[2.15.0 migration] UPGRADE END: 2.15.0-series-column-unique '))).to.be.true | ||||
|       // validate rows
 | ||||
|       const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) | ||||
|       expect(series).to.have.length(1) | ||||
|       expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id }) | ||||
|       const bookSeries = await queryInterface.sequelize.query('SELECT "id", "sequence", "bookId", "seriesId" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT }) | ||||
|       expect(bookSeries).to.have.length(1) | ||||
|       // Keep BookSeries 2 because it was edited last from cleaning up duplicate books
 | ||||
|       expect(bookSeries).to.deep.include({ id: bookSeries2Id, sequence: null, bookId: book1Id, seriesId: series1Id }) | ||||
|     }) | ||||
|     it('upgrade with one book in two of the same series, one sequence is null', async () => { | ||||
|       // Create two different series with the same name in the same library
 | ||||
|       await queryInterface.bulkInsert('Series', [ | ||||
|         { id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, | ||||
|         { id: series2Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() } | ||||
|       ]) | ||||
|       // Create a book that is in both series
 | ||||
|       await queryInterface.bulkInsert('BookSeries', [ | ||||
|         { id: bookSeries1Id, sequence: '1', bookId: book1Id, seriesId: series1Id }, | ||||
|         { id: bookSeries2Id, bookId: book1Id, seriesId: series2Id } | ||||
|       ]) | ||||
| 
 | ||||
|       await up({ context: { queryInterface, logger: Logger } }) | ||||
| 
 | ||||
|       expect(loggerInfoStub.callCount).to.equal(8) | ||||
|       expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.15.0 migration] UPGRADE BEGIN: 2.15.0-series-column-unique '))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.15.0 migration] Found 1 duplicate series'))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.15.0 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.15.0 migration] Deduplicating bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.15.0 migration] Finished cleanup of bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.15.0 migration] Deduplication complete'))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(6).calledWith(sinon.match('[2.15.0 migration] Added unique index on Series.name and Series.libraryId'))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(7).calledWith(sinon.match('[2.15.0 migration] UPGRADE END: 2.15.0-series-column-unique '))).to.be.true | ||||
|       // validate rows
 | ||||
|       const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) | ||||
|       expect(series).to.have.length(1) | ||||
|       expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id }) | ||||
|       const bookSeries = await queryInterface.sequelize.query('SELECT "id", "sequence", "bookId", "seriesId" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT }) | ||||
|       expect(bookSeries).to.have.length(1) | ||||
|       expect(bookSeries).to.deep.include({ id: bookSeries1Id, sequence: '1', bookId: book1Id, seriesId: series1Id }) | ||||
|     }) | ||||
|     it('upgrade with one book in two of the same series, both sequence are not null', async () => { | ||||
|       // Create two different series with the same name in the same library
 | ||||
|       await queryInterface.bulkInsert('Series', [ | ||||
|         { id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, | ||||
|         { id: series2Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() } | ||||
|       ]) | ||||
|       // Create a book that is in both series
 | ||||
|       await queryInterface.bulkInsert('BookSeries', [ | ||||
|         { id: bookSeries1Id, sequence: '3', bookId: book1Id, seriesId: series1Id }, | ||||
|         { id: bookSeries2Id, sequence: '2', bookId: book1Id, seriesId: series2Id } | ||||
|       ]) | ||||
| 
 | ||||
|       await up({ context: { queryInterface, logger: Logger } }) | ||||
| 
 | ||||
|       expect(loggerInfoStub.callCount).to.equal(8) | ||||
|       expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.15.0 migration] UPGRADE BEGIN: 2.15.0-series-column-unique '))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.15.0 migration] Found 1 duplicate series'))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.15.0 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.15.0 migration] Deduplicating bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.15.0 migration] Finished cleanup of bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.15.0 migration] Deduplication complete'))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(6).calledWith(sinon.match('[2.15.0 migration] Added unique index on Series.name and Series.libraryId'))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(7).calledWith(sinon.match('[2.15.0 migration] UPGRADE END: 2.15.0-series-column-unique '))).to.be.true | ||||
|       // validate rows
 | ||||
|       const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) | ||||
|       expect(series).to.have.length(1) | ||||
|       expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id }) | ||||
|       const bookSeries = await queryInterface.sequelize.query('SELECT "id", "sequence", "bookId", "seriesId" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT }) | ||||
|       expect(bookSeries).to.have.length(1) | ||||
|       // Keep BookSeries 2 because it is the lower sequence number
 | ||||
|       expect(bookSeries).to.deep.include({ id: bookSeries2Id, sequence: '2', bookId: book1Id, seriesId: series1Id }) | ||||
|     }) | ||||
|   }) | ||||
| 
 | ||||
|   describe('down', () => { | ||||
|     beforeEach(async () => { | ||||
|       await queryInterface.createTable('Series', { | ||||
|         id: { type: Sequelize.UUID, primaryKey: true }, | ||||
|         name: { type: Sequelize.STRING, allowNull: false }, | ||||
|         libraryId: { type: Sequelize.UUID, allowNull: false }, | ||||
|         createdAt: { type: Sequelize.DATE, allowNull: false }, | ||||
|         updatedAt: { type: Sequelize.DATE, allowNull: false } | ||||
|       }) | ||||
|       // Create a table for BookSeries, with a unique constraint of bookId and seriesId
 | ||||
|       await queryInterface.createTable( | ||||
|         'BookSeries', | ||||
|         { | ||||
|           id: { type: Sequelize.UUID, primaryKey: true }, | ||||
|           bookId: { type: Sequelize.UUID, allowNull: false }, | ||||
|           seriesId: { type: Sequelize.UUID, allowNull: false } | ||||
|         }, | ||||
|         { uniqueKeys: { book_series_unique: { fields: ['bookId', 'seriesId'] } } } | ||||
|       ) | ||||
|     }) | ||||
|     it('should not have unique constraint on series name and libraryId', async () => { | ||||
|       await up({ context: { queryInterface, logger: Logger } }) | ||||
|       await down({ context: { queryInterface, logger: Logger } }) | ||||
| 
 | ||||
|       expect(loggerInfoStub.callCount).to.equal(8) | ||||
|       expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.15.0 migration] UPGRADE BEGIN: 2.15.0-series-column-unique '))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.15.0 migration] Found 0 duplicate series'))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.15.0 migration] Deduplication complete'))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.15.0 migration] Added unique index on Series.name and Series.libraryId'))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.15.0 migration] UPGRADE END: 2.15.0-series-column-unique '))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.15.0 migration] DOWNGRADE BEGIN: 2.15.0-series-column-unique '))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(6).calledWith(sinon.match('[2.15.0 migration] Removed unique index on Series.name and Series.libraryId'))).to.be.true | ||||
|       expect(loggerInfoStub.getCall(7).calledWith(sinon.match('[2.15.0 migration] DOWNGRADE END: 2.15.0-series-column-unique '))).to.be.true | ||||
|       // Ensure index does not exist
 | ||||
|       const indexes = await queryInterface.showIndex('Series') | ||||
|       expect(indexes).to.not.deep.include({ tableName: 'Series', unique: true, fields: ['name', 'libraryId'], name: 'unique_series_name_per_library' }) | ||||
|     }) | ||||
|   }) | ||||
| }) | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user