@@ -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 @@
@@ -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' })
+ })
+ })
+})