diff --git a/client/components/cards/LazyBookCard.vue b/client/components/cards/LazyBookCard.vue index ae2cdd5b..09424b3c 100644 --- a/client/components/cards/LazyBookCard.vue +++ b/client/components/cards/LazyBookCard.vue @@ -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 '' diff --git a/client/components/controls/LibraryFilterSelect.vue b/client/components/controls/LibraryFilterSelect.vue index ba96f103..2d9ced5a 100644 --- a/client/components/controls/LibraryFilterSelect.vue +++ b/client/components/controls/LibraryFilterSelect.vue @@ -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 } ] }, diff --git a/client/components/modals/item/tabs/Tools.vue b/client/components/modals/item/tabs/Tools.vue index ad9435c4..5f148c3d 100644 --- a/client/components/modals/item/tabs/Tools.vue +++ b/client/components/modals/item/tabs/Tools.vue @@ -33,18 +33,18 @@ launch - Quick Embed + {{ $strings.ButtonQuickEmbed }} -

Queued for metadata embed ({{ queuedEmbedLIds.length }} in queue)

+

{{ $getString('MessageQuickEmbedQueue', [queuedEmbedLIds.length]) }}

-

Currently embedding metadata

+

{{ $strings.MessageQuickEmbedInProgress }}

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

Would you like to continue?', + message: this.$strings.MessageConfirmQuickEmbed, callback: (confirmed) => { if (confirmed) { this.$axios diff --git a/client/components/modals/notification/NotificationEditModal.vue b/client/components/modals/notification/NotificationEditModal.vue index 32fa20ea..83b5a9d0 100644 --- a/client/components/modals/notification/NotificationEditModal.vue +++ b/client/components/modals/notification/NotificationEditModal.vue @@ -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) diff --git a/client/package-lock.json b/client/package-lock.json index 4c6df795..073ca445 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -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", diff --git a/client/package.json b/client/package.json index ab70a30b..626895e0 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/pages/audiobook/_id/manage.vue b/client/pages/audiobook/_id/manage.vue index 7de82b51..56d78d1c 100644 --- a/client/pages/audiobook/_id/manage.vue +++ b/client/pages/audiobook/_id/manage.vue @@ -63,11 +63,11 @@
-

Audiobook is queued for metadata embed ({{ queuedEmbedLIds.length }} in queue)

+

{{ $getString('MessageEmbedQueue', [queuedEmbedLIds.length]) }}

- +
@@ -78,7 +78,7 @@
@@ -94,11 +94,11 @@
- - - + + +
-

Warning: Do not update these settings unless you are familiar with ffmpeg encoding options.

+

{{ $strings.LabelEncodingWarningAdvancedSettings }}

@@ -106,36 +106,36 @@
star -

Metadata will be embedded in the audio tracks inside your audiobook folder.

+

{{ $strings.LabelEncodingInfoEmbedded }}

star

- Finished M4B will be put into your audiobook folder at .../{{ libraryItemRelPath }}/. + {{ $strings.LabelEncodingFinishedM4B }} .../{{ libraryItemRelPath }}/.

star

- A backup of your original audio files will be stored in /metadata/cache/items/{{ libraryItemId }}/. Make sure to periodically purge items cache. + {{ $strings.LabelEncodingBackupLocation }} /metadata/cache/items/{{ libraryItemId }}/. {{ $strings.LabelEncodingClearItemCache }}

star -

Chapters are not embedded in multi-track audiobooks.

+

{{ $strings.LabelEncodingChaptersNotEmbedded }}

star -

Encoding can take up to 30 minutes.

+

{{ $strings.LabelEncodingTimeWarning }}

star -

If you have the watcher disabled you will need to re-scan this audiobook afterwards.

+

{{ $strings.LabelEncodingWatcherDisabled }}

star -

Once the task is started you can navigate away from this page.

+

{{ $strings.LabelEncodingStartedNavigation }}

@@ -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() diff --git a/client/pages/config/log.vue b/client/pages/config/log.vue index 41df00e2..4015a9f6 100644 --- a/client/pages/config/log.vue +++ b/client/pages/config/log.vue @@ -10,9 +10,9 @@
- + - +
diff --git a/client/pages/library/_library/podcast/search.vue b/client/pages/library/_library/podcast/search.vue index fd44df97..37ceeee0 100644 --- a/client/pages/library/_library/podcast/search.vue +++ b/client/pages/library/_library/podcast/search.vue @@ -5,7 +5,7 @@
- + {{ $strings.ButtonSubmit }} @@ -108,7 +108,7 @@ export default { if (!txt || !txt.includes(' tag not found OR an 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 diff --git a/client/pages/share/_slug.vue b/client/pages/share/_slug.vue index 384a9513..f4f93b1d 100644 --- a/client/pages/share/_slug.vue +++ b/client/pages/share/_slug.vue @@ -10,7 +10,7 @@

{{ mediaItemShare.playbackSession.displayAuthor }}

- +
@@ -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 diff --git a/client/strings/bn.json b/client/strings/bn.json index a76b4046..b705a802 100644 --- a/client/strings/bn.json +++ b/client/strings/bn.json @@ -550,7 +550,7 @@ "LabelSleepTimer": "স্লিপ টাইমার", "LabelSlug": "স্লাগ", "LabelStart": "শুরু", - "LabelStartTime": "শুরু করার সময়", + "LabelStartTime": "শুরুর সময়", "LabelStarted": "শুরু হয়েছে", "LabelStartedAt": "এতে শুরু হয়েছে", "LabelStatsAudioTracks": "অডিও ট্র্যাক", @@ -901,6 +901,7 @@ "ToastErrorCannotShare": "এই ডিভাইসে স্থানীয়ভাবে শেয়ার করা যাবে না", "ToastFailedToLoadData": "ডেটা লোড করা যায়নি", "ToastFailedToShare": "শেয়ার করতে ব্যর্থ", + "ToastFailedToUpdate": "আপডেট করতে ব্যর্থ হয়েছে", "ToastInvalidImageUrl": "অকার্যকর ছবির ইউআরএল", "ToastInvalidUrl": "অকার্যকর ইউআরএল", "ToastItemCoverUpdateSuccess": "আইটেম কভার আপডেট করা হয়েছে", diff --git a/client/strings/de.json b/client/strings/de.json index 25c34c7e..e8ca3f59 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -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}\"", diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 34b014dc..adfe1001 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -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 tag not found OR an 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", diff --git a/client/strings/en_US.json b/client/strings/en_US.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/client/strings/en_US.json @@ -0,0 +1 @@ +{} diff --git a/client/strings/es.json b/client/strings/es.json index 2510d809..cd9621bf 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -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", diff --git a/client/strings/fr.json b/client/strings/fr.json index 3c5e4002..064597b3 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -465,6 +465,8 @@ "LabelPubDate": "Date de publication", "LabelPublishYear": "Année de publication", "LabelPublishedDate": "Publié en {0}", + "LabelPublishedDecade": "Décennie de publication", + "LabelPublishedDecades": "Décennies de publication", "LabelPublisher": "Éditeur", "LabelPublishers": "Éditeurs", "LabelRSSFeedCustomOwnerEmail": "Courriel personnalisée du propriétaire", @@ -901,6 +903,7 @@ "ToastErrorCannotShare": "Impossible de partager nativement sur cet appareil", "ToastFailedToLoadData": "Échec du chargement des données", "ToastFailedToShare": "Échec du partage", + "ToastFailedToUpdate": "Échec de la mise à jour", "ToastInvalidImageUrl": "URL de l'image invalide", "ToastInvalidUrl": "URL invalide", "ToastItemCoverUpdateSuccess": "Couverture mise à jour", @@ -919,6 +922,7 @@ "ToastLibraryScanFailedToStart": "Échec du démarrage de l’analyse", "ToastLibraryScanStarted": "Analyse de la bibliothèque démarrée", "ToastLibraryUpdateSuccess": "Bibliothèque « {0} » mise à jour", + "ToastMatchAllAuthorsFailed": "Tous les auteurs et autrices n’ont pas pu être classés", "ToastNameEmailRequired": "Le nom et le courriel sont requis", "ToastNameRequired": "Le nom est requis", "ToastNewUserCreatedFailed": "La création du compte à échouée : « {0} »", diff --git a/client/strings/hr.json b/client/strings/hr.json index 9d0ed0d5..1ae07981 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -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", diff --git a/client/strings/it.json b/client/strings/it.json index 3cffc1eb..3078706a 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -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à: {0}", "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}\"", diff --git a/client/strings/lt.json b/client/strings/lt.json index 6cc7966c..9fe65e3a 100644 --- a/client/strings/lt.json +++ b/client/strings/lt.json @@ -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į.
Pastaba: Tai padidins skenavimo trukmę.", + "LabelSettingsFindCoversHelp": "Jei jūsų audioknyga neturi įterpto viršelio arba viršelio paveikslėlio aplanke, bandyti rasti viršelį.
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", diff --git a/client/strings/nl.json b/client/strings/nl.json index cd2c872c..06a1ffa8 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -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", diff --git a/client/strings/sl.json b/client/strings/sl.json index 28655c52..68676313 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -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 audiobookshelf://oauth, ki ga lahko odstranite ali dopolnite z dodatnimi URI-ji za integracijo aplikacij tretjih oseb. Uporaba zvezdice (*) 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.
Podnaslov mora biti ločen z \" - \"
npr. »Naslov knjige – Tu podnapis« ima podnaslov »Tu podnapis«", + "LabelSettingsParseSubtitlesHelp": "Izvleci podnapise iz imen map zvočnih knjig.
Podnapis mora biti ločen z \" - \"
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 API Apprise ali API, ki bo obravnaval te iste zahteve.
Url API-ja Apprise mora biti celotna pot URL-ja za pošiljanje obvestila, npr. če je vaš primerek API-ja postrežen na http://192.168.1.1:8337, bi morali vnesti http://192.168.1.1:8337/notify.", + "MessageAppriseDescription": "Če želite uporabljati to funkcijo, morate imeti zagnano namestitev API Apprise ali API, ki bo obravnavala te iste zahteve.
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 http://192.168.1.1:8337, bi morali vnesti http://192.168.1.1:8337/notify.", "MessageBackupsDescription": "Varnostne kopije vključujejo uporabnike, napredek uporabnikov, podrobnosti elementov knjižnice, nastavitve strežnika in slike, shranjene v /metadata/items & /metadata/authors. Varnostne kopije ne 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 /metadata/cache.

Ali ste prepričani, da želite odstraniti imenik predpomnilnika?", "MessageConfirmPurgeItemsCache": "Čiščenje predpomnilnika elementov bo izbrisalo celoten imenik na /metadata/cache/items.
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.

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 /metadata/logs kot datoteke JSON. Dnevniki zrušitev so shranjeni v /metadata/logs/crash_logs.txt.", "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}\"", diff --git a/package-lock.json b/package-lock.json index 7c1798ed..a428e6f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 69bc41dd..7e8a4a9a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/managers/MigrationManager.js b/server/managers/MigrationManager.js index 706e359c..beaf8a4d 100644 --- a/server/managers/MigrationManager.js +++ b/server/managers/MigrationManager.js @@ -38,6 +38,7 @@ class MigrationManager { if (!(await fs.pathExists(this.configPath))) throw new Error(`Config path does not exist: ${this.configPath}`) this.migrationsDir = path.join(this.configPath, 'migrations') + await fs.ensureDir(this.migrationsDir) this.serverVersion = this.extractVersionFromTag(serverVersion) if (!this.serverVersion) throw new Error(`Invalid server version: ${serverVersion}. Expected a version tag like v1.2.3.`) @@ -222,8 +223,6 @@ class MigrationManager { } async copyMigrationsToConfigDir() { - await fs.ensureDir(this.migrationsDir) // Ensure the target directory exists - if (!(await fs.pathExists(this.migrationsSourceDir))) return const files = await fs.readdir(this.migrationsSourceDir) diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md index 2e3c295a..b5dde749 100644 --- a/server/migrations/changelog.md +++ b/server/migrations/changelog.md @@ -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 | diff --git a/server/migrations/v2.15.0-series-column-unique.js b/server/migrations/v2.15.0-series-column-unique.js new file mode 100644 index 00000000..96b0ea60 --- /dev/null +++ b/server/migrations/v2.15.0-series-column-unique.js @@ -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} - 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} - 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 } diff --git a/server/models/Series.js b/server/models/Series.js index c57a1a11..731908e9 100644 --- a/server/models/Series.js +++ b/server/models/Series.js @@ -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'] } diff --git a/server/utils/notifications.js b/server/utils/notifications.js index 96e8ddf8..7a3e1198 100644 --- a/server/utils/notifications.js +++ b/server/utils/notifications.js @@ -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}}', diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index 43ffafef..e64e7b78 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -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)] } diff --git a/test/server/managers/MigrationManager.test.js b/test/server/managers/MigrationManager.test.js index ae94cd75..af2e9da8 100644 --- a/test/server/managers/MigrationManager.test.js +++ b/test/server/managers/MigrationManager.test.js @@ -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 diff --git a/test/server/migrations/v2.15.0-series-column-unique.test.js b/test/server/migrations/v2.15.0-series-column-unique.test.js new file mode 100644 index 00000000..4ae07e63 --- /dev/null +++ b/test/server/migrations/v2.15.0-series-column-unique.test.js @@ -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' }) + }) + }) +})