Merge branch 'subdirectory-fixes' of https://github.com/mikiher/audiobookshelf into subdirectory-fixes

This commit is contained in:
mikiher 2024-10-14 13:19:30 +03:00
commit bf16681bea
31 changed files with 799 additions and 111 deletions

View File

@ -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 ''

View File

@ -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
}
]
},

View File

@ -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

View File

@ -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)

View File

@ -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",

View File

@ -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",

View File

@ -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()

View File

@ -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">

View File

@ -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

View File

@ -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

View File

@ -550,7 +550,7 @@
"LabelSleepTimer": "স্লিপ টাইমার",
"LabelSlug": "স্লাগ",
"LabelStart": "শুরু",
"LabelStartTime": "শুরু করার সময়",
"LabelStartTime": "শুরুর সময়",
"LabelStarted": "শুরু হয়েছে",
"LabelStartedAt": "এতে শুরু হয়েছে",
"LabelStatsAudioTracks": "অডিও ট্র্যাক",
@ -901,6 +901,7 @@
"ToastErrorCannotShare": "এই ডিভাইসে স্থানীয়ভাবে শেয়ার করা যাবে না",
"ToastFailedToLoadData": "ডেটা লোড করা যায়নি",
"ToastFailedToShare": "শেয়ার করতে ব্যর্থ",
"ToastFailedToUpdate": "আপডেট করতে ব্যর্থ হয়েছে",
"ToastInvalidImageUrl": "অকার্যকর ছবির ইউআরএল",
"ToastInvalidUrl": "অকার্যকর ইউআরএল",
"ToastItemCoverUpdateSuccess": "আইটেম কভার আপডেট করা হয়েছে",

View File

@ -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}\"",

View File

@ -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",

View File

@ -0,0 +1 @@
{}

View File

@ -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",

View File

@ -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 lanalyse",
"ToastLibraryScanStarted": "Analyse de la bibliothèque démarrée",
"ToastLibraryUpdateSuccess": "Bibliothèque « {0} » mise à jour",
"ToastMatchAllAuthorsFailed": "Tous les auteurs et autrices nont 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} »",

View File

@ -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",

View File

@ -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}\"",

View File

@ -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",

View File

@ -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",

View File

@ -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
View File

@ -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",

View File

@ -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",

View File

@ -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)

View File

@ -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 |

View 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 }

View File

@ -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']
}

View File

@ -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}}',

View File

@ -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)]
}

View File

@ -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

View 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' })
})
})
})