mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-02-15 01:40:15 +01:00
Merge branch 'subdirectory-fixes' of https://github.com/mikiher/audiobookshelf into subdirectory-fixes
This commit is contained in:
commit
bf16681bea
@ -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