\ No newline at end of file
diff --git a/client/components/tables/TracksTable.vue b/client/components/tables/TracksTable.vue
index 05f7ea17..22944453 100644
--- a/client/components/tables/TracksTable.vue
+++ b/client/components/tables/TracksTable.vue
@@ -5,9 +5,8 @@
@@ -284,6 +268,12 @@ export default {
}
},
computed: {
+ userToken() {
+ return this.$store.getters['user/getToken']
+ },
+ downloadUrl() {
+ return `${process.env.serverUrl}/api/items/${this.libraryItemId}/download?token=${this.userToken}`
+ },
dateFormat() {
return this.$store.state.serverSettings.dateFormat
},
@@ -296,9 +286,6 @@ export default {
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
- isFile() {
- return this.libraryItem.isFile
- },
bookCoverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
@@ -308,6 +295,9 @@ export default {
isDeveloperMode() {
return this.$store.state.developerMode
},
+ isFile() {
+ return this.libraryItem.isFile
+ },
isBook() {
return this.libraryItem.mediaType === 'book'
},
@@ -526,12 +516,56 @@ export default {
},
showCollectionsButton() {
return this.isBook && this.userCanUpdate
+ },
+ contextMenuItems() {
+ const items = []
+
+ if (this.showCollectionsButton) {
+ items.push({
+ text: this.$strings.LabelCollections,
+ action: 'collections'
+ })
+ }
+
+ if (!this.isPodcast && this.tracks.length) {
+ items.push({
+ text: this.$strings.LabelYourPlaylists,
+ action: 'playlists'
+ })
+ }
+
+ if (this.bookmarks.length) {
+ items.push({
+ text: this.$strings.LabelYourBookmarks,
+ action: 'bookmarks'
+ })
+ }
+
+ if (this.showRssFeedBtn) {
+ items.push({
+ text: this.$strings.LabelOpenRSSFeed,
+ action: 'rss-feeds'
+ })
+ }
+
+ if (this.userCanDownload) {
+ items.push({
+ text: this.$strings.LabelDownload,
+ action: 'download'
+ })
+ }
+
+ if (this.userCanDelete) {
+ items.push({
+ text: this.$strings.ButtonDelete,
+ action: 'delete'
+ })
+ }
+
+ return items
}
},
methods: {
- clickBookmarksBtn() {
- this.showBookmarksModal = true
- },
selectBookmark(bookmark) {
if (!bookmark) return
if (this.isStreaming) {
@@ -707,14 +741,6 @@ export default {
})
}
},
- collectionsClick() {
- this.$store.commit('setSelectedLibraryItem', this.libraryItem)
- this.$store.commit('globals/setShowCollectionsModal', true)
- },
- playlistsClick() {
- this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem }])
- this.$store.commit('globals/setShowPlaylistsModal', true)
- },
clickRSSFeed() {
this.$store.commit('globals/setRSSFeedOpenCloseModal', {
id: this.libraryItemId,
@@ -772,6 +798,58 @@ export default {
}
this.$store.commit('addItemToQueue', queueItem)
}
+ },
+ downloadLibraryItem() {
+ const a = document.createElement('a')
+ a.style.display = 'none'
+ a.href = this.downloadUrl
+ document.body.appendChild(a)
+ a.click()
+ setTimeout(() => {
+ a.remove()
+ })
+ },
+ deleteLibraryItem() {
+ const payload = {
+ message: 'This will delete the library item from the database and your file system. Are you sure?',
+ checkboxLabel: 'Delete from file system. Uncheck to only remove from database.',
+ yesButtonText: this.$strings.ButtonDelete,
+ yesButtonColor: 'error',
+ checkboxDefaultValue: true,
+ callback: (confirmed, hardDelete) => {
+ if (confirmed) {
+ this.$axios
+ .$delete(`/api/items/${this.libraryItemId}?hard=${hardDelete ? 1 : 0}`)
+ .then(() => {
+ this.$toast.success('Item deleted')
+ this.$router.replace(`/library/${this.libraryId}`)
+ })
+ .catch((error) => {
+ console.error('Failed to delete item', error)
+ this.$toast.error('Failed to delete item')
+ })
+ }
+ },
+ type: 'yesNo'
+ }
+ this.$store.commit('globals/setConfirmPrompt', payload)
+ },
+ contextMenuAction(action) {
+ if (action === 'collections') {
+ this.$store.commit('setSelectedLibraryItem', this.libraryItem)
+ this.$store.commit('globals/setShowCollectionsModal', true)
+ } else if (action === 'playlists') {
+ this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem }])
+ this.$store.commit('globals/setShowPlaylistsModal', true)
+ } else if (action === 'bookmarks') {
+ this.showBookmarksModal = true
+ } else if (action === 'rss-feeds') {
+ this.clickRSSFeed()
+ } else if (action === 'download') {
+ this.downloadLibraryItem()
+ } else if (action === 'delete') {
+ this.deleteLibraryItem()
+ }
}
},
mounted() {
diff --git a/client/players/PlayerHandler.js b/client/players/PlayerHandler.js
index 93d884f6..2fb188e8 100644
--- a/client/players/PlayerHandler.js
+++ b/client/players/PlayerHandler.js
@@ -123,7 +123,7 @@ export default class PlayerHandler {
playerError() {
// Switch to HLS stream on error
- if (!this.isCasting && !this.currentStreamId && (this.player instanceof LocalAudioPlayer)) {
+ if (!this.isCasting && (this.player instanceof LocalAudioPlayer)) {
console.log(`[PlayerHandler] Audio player error switching to HLS stream`)
this.prepare(true)
}
@@ -173,16 +173,30 @@ export default class PlayerHandler {
this.ctx.setBufferTime(buffertime)
}
+ getDeviceId() {
+ let deviceId = localStorage.getItem('absDeviceId')
+ if (!deviceId) {
+ deviceId = this.ctx.$randomId()
+ localStorage.setItem('absDeviceId', deviceId)
+ }
+ return deviceId
+ }
+
async prepare(forceTranscode = false) {
- var payload = {
+ this.currentSessionId = null // Reset session
+
+ const payload = {
+ deviceInfo: {
+ deviceId: this.getDeviceId()
+ },
supportedMimeTypes: this.player.playableMimeTypes,
mediaPlayer: this.isCasting ? 'chromecast' : 'html5',
forceTranscode,
forceDirectPlay: this.isCasting || this.isVideo // TODO: add transcode support for chromecast
}
- var path = this.episodeId ? `/api/items/${this.libraryItem.id}/play/${this.episodeId}` : `/api/items/${this.libraryItem.id}/play`
- var session = await this.ctx.$axios.$post(path, payload).catch((error) => {
+ const path = this.episodeId ? `/api/items/${this.libraryItem.id}/play/${this.episodeId}` : `/api/items/${this.libraryItem.id}/play`
+ const session = await this.ctx.$axios.$post(path, payload).catch((error) => {
console.error('Failed to start stream', error)
})
this.prepareSession(session)
@@ -238,12 +252,17 @@ export default class PlayerHandler {
closePlayer() {
console.log('[PlayerHandler] Close Player')
this.sendCloseSession()
+ this.resetPlayer()
+ }
+
+ resetPlayer() {
if (this.player) {
this.player.destroy()
}
this.player = null
this.playerState = 'IDLE'
this.libraryItem = null
+ this.currentSessionId = null
this.startTime = 0
this.stopPlayInterval()
}
diff --git a/client/plugins/utils.js b/client/plugins/utils.js
index c9ece333..c4162de5 100644
--- a/client/plugins/utils.js
+++ b/client/plugins/utils.js
@@ -1,5 +1,8 @@
import Vue from 'vue'
import cronParser from 'cron-parser'
+import { nanoid } from 'nanoid'
+
+Vue.prototype.$randomId = () => nanoid()
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
if (isNaN(bytes) || bytes == 0) {
diff --git a/client/strings/de.json b/client/strings/de.json
index 28b859d6..4b9188e5 100644
--- a/client/strings/de.json
+++ b/client/strings/de.json
@@ -20,7 +20,7 @@
"ButtonCreate": "Erstellen",
"ButtonCreateBackup": "Sicherung erstellen",
"ButtonDelete": "Löschen",
- "ButtonDownloadQueue": "Queue",
+ "ButtonDownloadQueue": "Warteschlange",
"ButtonEdit": "Bearbeiten",
"ButtonEditChapters": "Kapitel bearbeiten",
"ButtonEditPodcast": "Podcast bearbeiten",
@@ -93,9 +93,9 @@
"HeaderCollection": "Sammlungen",
"HeaderCollectionItems": "Sammlungseinträge",
"HeaderCover": "Titelbild",
- "HeaderCurrentDownloads": "Current Downloads",
+ "HeaderCurrentDownloads": "Aktuelle Downloads",
"HeaderDetails": "Details",
- "HeaderDownloadQueue": "Download Queue",
+ "HeaderDownloadQueue": "Download Warteschlange",
"HeaderEpisodes": "Episoden",
"HeaderFiles": "Dateien",
"HeaderFindChapters": "Kapitel suchen",
@@ -161,6 +161,7 @@
"LabelAccountTypeGuest": "Gast",
"LabelAccountTypeUser": "Benutzer",
"LabelActivity": "Aktivitäten",
+ "LabelAdded": "Added",
"LabelAddedAt": "Hinzugefügt am",
"LabelAddToCollection": "Zur Sammlung hinzufügen",
"LabelAddToCollectionBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Sammlung hinzu",
@@ -182,11 +183,15 @@
"LabelBackupsMaxBackupSizeHelp": "Zum Schutz vor Fehlkonfigurationen schlagen Sicherungen fehl, wenn sie die konfigurierte Größe überschreiten.",
"LabelBackupsNumberToKeep": "Anzahl der aufzubewahrenden Sicherungen",
"LabelBackupsNumberToKeepHelp": "Es wird immer nur 1 Sicherung auf einmal entfernt. Wenn Sie bereits mehrere Sicherungen als die definierte max. Anzahl haben, sollten Sie diese manuell entfernen.",
+ "LabelBitrate": "Bitrate",
"LabelBooks": "Bücher",
"LabelChangePassword": "Passwort ändern",
+ "LabelChannels": "Kanäle",
+ "LabelChapters": "Chapters",
"LabelChaptersFound": "gefundene Kapitel",
"LabelChapterTitle": "Kapitelüberschrift",
"LabelClosePlayer": "Player schließen",
+ "LabelCodec": "Codec",
"LabelCollapseSeries": "Serien zusammenfassen",
"LabelCollections": "Sammlungen",
"LabelComplete": "Vollständig",
@@ -199,7 +204,7 @@
"LabelCronExpression": "Cron Ausdruck",
"LabelCurrent": "Aktuell",
"LabelCurrently": "Aktuell:",
- "LabelCustomCronExpression": "Custom Cron Expression:",
+ "LabelCustomCronExpression": "Benutzerdefinierter Cron-Ausdruck",
"LabelDatetime": "Datum & Uhrzeit",
"LabelDescription": "Beschreibung",
"LabelDeselectAll": "Alles abwählen",
@@ -212,6 +217,7 @@
"LabelDuration": "Laufzeit",
"LabelDurationFound": "Gefundene Laufzeit:",
"LabelEdit": "Bearbeiten",
+ "LabelEmbeddedCover": "Eingebettetes Cover",
"LabelEnable": "Aktivieren",
"LabelEnd": "Ende",
"LabelEpisode": "Episode",
@@ -229,6 +235,7 @@
"LabelFinished": "beendet",
"LabelFolder": "Ordner",
"LabelFolders": "Verzeichnisse",
+ "LabelFormat": "Format",
"LabelGenre": "Kategorie",
"LabelGenres": "Kategorien",
"LabelHardDeleteFile": "Datei dauerhaft löschen",
@@ -250,6 +257,8 @@
"LabelItem": "Medium",
"LabelLanguage": "Sprache",
"LabelLanguageDefaultServer": "Standard-Server-Sprache",
+ "LabelLastBookAdded": "Last Book Added",
+ "LabelLastBookUpdated": "Last Book Updated",
"LabelLastSeen": "Zuletzt angesehen",
"LabelLastTime": "Letztes Mal",
"LabelLastUpdate": "Letzte Aktualisierung",
@@ -268,10 +277,12 @@
"LabelMediaType": "Medientyp",
"LabelMetadataProvider": "Metadatenanbieter",
"LabelMetaTag": "Meta Schlagwort",
+ "LabelMetaTags": "Meta Tags",
"LabelMinute": "Minute",
"LabelMissing": "Fehlend",
"LabelMissingParts": "Fehlende Teile",
"LabelMore": "Mehr",
+ "LabelMoreInfo": "More Info",
"LabelName": "Name",
"LabelNarrator": "Erzähler",
"LabelNarrators": "Erzähler",
@@ -279,8 +290,8 @@
"LabelNewestAuthors": "Neuste Autoren",
"LabelNewestEpisodes": "Neueste Episoden",
"LabelNewPassword": "Neues Passwort",
- "LabelNextBackupDate": "Next backup date",
- "LabelNextScheduledRun": "Next scheduled run",
+ "LabelNextBackupDate": "Nächstes Sicherungsdatum",
+ "LabelNextScheduledRun": "Nächster planmäßiger Durchlauf",
"LabelNotes": "Hinweise",
"LabelNotFinished": "nicht beendet",
"LabelNotificationAppriseURL": "Apprise URL(s)",
@@ -313,7 +324,7 @@
"LabelPodcasts": "Podcasts",
"LabelPodcastType": "Podcast Type",
"LabelPrefixesToIgnore": "Zu ignorierende(s) Vorwort(e) (Groß- und Kleinschreibung wird nicht berücksichtigt)",
- "LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
+ "LabelPreventIndexing": "Verhindere, dass dein Feed von iTunes- und Google-Podcast-Verzeichnissen indiziert wird",
"LabelProgress": "Fortschritt",
"LabelProvider": "Anbieter",
"LabelPubDate": "Veröffentlichungsdatum",
@@ -321,14 +332,14 @@
"LabelPublishYear": "Jahr",
"LabelRecentlyAdded": "Kürzlich hinzugefügt",
"LabelRecentSeries": "Aktuelle Serien",
- "LabelRecommended": "Recommended",
+ "LabelRecommended": "Empfohlen",
"LabelRegion": "Region",
"LabelReleaseDate": "Veröffentlichungsdatum",
"LabelRemoveCover": "Lösche Titelbild",
- "LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
- "LabelRSSFeedCustomOwnerName": "Custom owner Name",
+ "LabelRSSFeedCustomOwnerEmail": "Benutzerdefinierte Eigentümer-E-Mail",
+ "LabelRSSFeedCustomOwnerName": "Benutzerdefinierter Name des Eigentümers",
"LabelRSSFeedOpen": "RSS Feed Offen",
- "LabelRSSFeedPreventIndexing": "Prevent Indexing",
+ "LabelRSSFeedPreventIndexing": "Indizierung verhindern",
"LabelRSSFeedSlug": "RSS Feed Schlagwort",
"LabelRSSFeedURL": "RSS Feed URL",
"LabelSearchTerm": "Begriff suchen",
@@ -402,6 +413,7 @@
"LabelTags": "Schlagwörter",
"LabelTagsAccessibleToUser": "Für Benutzer zugängliche Schlagwörter",
"LabelTasks": "Tasks Running",
+ "LabelTimeBase": "Time Base",
"LabelTimeListened": "Gehörte Zeit",
"LabelTimeListenedToday": "Heute gehörte Zeit",
"LabelTimeRemaining": "{0} verbleibend",
@@ -456,7 +468,7 @@
"MessageChapterEndIsAfter": "Ungültige Kapitelendzeit: Kapitelende > Mediumende (Kapitelende liegt nach dem Ende des Mediums)",
"MessageChapterErrorFirstNotZero": "Ungültige Kapitelstartzeit: Das erste Kapitel muss bei 0 beginnen",
"MessageChapterErrorStartGteDuration": "Ungültige Kapitelstartzeit: Kapitelanfang > Mediumlänge (Kapitelanfang liegt zeitlich nach dem Ende des Mediums -> Lösung: Kapitelanfang < Mediumlänge)",
- "MessageChapterErrorStartLtPrev": "Ungültige Kapitelstartzeit: Kapitelanfang < Kapitelanfang vorheriges Kapitel (Kapitelanfang liegt zeitlich vor dem Beginn des vorherigen Kapitels -> Lösung: Kapitelanfang >= Startzeit des vorherigen Kapitels)",
+ "MessageChapterErrorStartLtPrev": "Ungültige Kapitelstartzeit: Kapitelanfang < Kapitelanfang vorheriges Kapitel (Kapitelanfang liegt zeitlich vor dem Beginn des vorherigen Kapitels -> Lösung: Kapitelanfang >= Startzeit des vorherigen Kapitels)",
"MessageChapterStartIsAfter": "Ungültige Kapitelstartzeit: Kapitelanfang > Mediumende (Kapitelanfang liegt nach dem Ende des Mediums)",
"MessageCheckingCron": "Überprüfe cron...",
"MessageConfirmDeleteBackup": "Sind Sie sicher, dass Sie die Sicherung für {0} löschen wollen?",
@@ -465,6 +477,7 @@
"MessageConfirmForceReScan": "Sind Sie sicher, dass Sie einen erneuten Scanvorgang erzwingen wollen?",
"MessageConfirmMarkSeriesFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als abgeschlossen markieren wollen?",
"MessageConfirmMarkSeriesNotFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als nicht abgeschlossen markieren wollen?",
+ "MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
"MessageConfirmRemoveCollection": "Sind Sie sicher, dass Sie die Sammlung \"{0}\" löschen wollen?",
"MessageConfirmRemoveEpisode": "Sind Sie sicher, dass Sie die Episode \"{0}\" löschen möchten?",
"MessageConfirmRemoveEpisodes": "Sind Sie sicher, dass Sie {0} Episoden löschen wollen?",
@@ -504,8 +517,8 @@
"MessageNoCollections": "Keine Sammlungen",
"MessageNoCoversFound": "Keine Titelbilder gefunden",
"MessageNoDescription": "Keine Beschreibung",
- "MessageNoDownloadsInProgress": "No downloads currently in progress",
- "MessageNoDownloadsQueued": "No downloads queued",
+ "MessageNoDownloadsInProgress": "Derzeit keine Downloads in Arbeit",
+ "MessageNoDownloadsQueued": "Keine Downloads in der Warteschlange",
"MessageNoEpisodeMatchesFound": "Keine Episodenübereinstimmungen gefunden",
"MessageNoEpisodes": "Keine Episoden",
"MessageNoFoldersAvailable": "Keine Ordner verfügbar",
@@ -522,7 +535,7 @@
"MessageNoSearchResultsFor": "Keine Suchergebnisse für \"{0}\"",
"MessageNoSeries": "Keine Serien",
"MessageNoTags": "Keine Tags",
- "MessageNoTasksRunning": "No Tasks Running",
+ "MessageNoTasksRunning": "Keine laufenden Aufgaben",
"MessageNotYetImplemented": "Noch nicht implementiert",
"MessageNoUpdateNecessary": "Keine Aktualisierung erforderlich",
"MessageNoUpdatesWereNecessary": "Keine Aktualisierungen waren notwendig",
@@ -536,7 +549,7 @@
"MessageRemoveAllItemsWarning": "WARNUNG! Bei dieser Aktion werden alle Bibliotheksobjekte aus der Datenbank entfernt, einschließlich aller Aktualisierungen oder Online-Abgleichs, die Sie vorgenommen haben. Ihre eigentlichen Dateien bleiben davon unberührt. Sind Sie sicher?",
"MessageRemoveChapter": "Kapitel löschen",
"MessageRemoveEpisodes": "Entferne {0} Episode(n)",
- "MessageRemoveFromPlayerQueue": "Aus der Abspielwarteliste löschen Remove from player queue",
+ "MessageRemoveFromPlayerQueue": "Aus der Abspielwarteliste löschen",
"MessageRemoveUserWarning": "Sind Sie sicher, dass Sie den Benutzer \"{0}\" dauerhaft löschen wollen?",
"MessageReportBugsAndContribute": "Fehler melden, Funktionen anfordern und Beiträge leisten auf",
"MessageResetChaptersConfirm": "Sind Sie sicher, dass Sie die Kapitel zurücksetzen und die vorgenommenen Änderungen rückgängig machen wollen?",
@@ -568,7 +581,7 @@
"PlaceholderNewFolderPath": "Neuer Ordnerpfad",
"PlaceholderNewPlaylist": "Neuer Wiedergabelistenname",
"PlaceholderSearch": "Suche...",
- "PlaceholderSearchEpisode": "Search episode...",
+ "PlaceholderSearchEpisode": "Suche Episode...",
"ToastAccountUpdateFailed": "Aktualisierung des Kontos fehlgeschlagen",
"ToastAccountUpdateSuccess": "Konto aktualisiert",
"ToastAuthorImageRemoveFailed": "Bild konnte nicht entfernt werden",
diff --git a/client/strings/en-us.json b/client/strings/en-us.json
index 6550a503..07d8bbe9 100644
--- a/client/strings/en-us.json
+++ b/client/strings/en-us.json
@@ -161,6 +161,7 @@
"LabelAccountTypeGuest": "Guest",
"LabelAccountTypeUser": "User",
"LabelActivity": "Activity",
+ "LabelAdded": "Added",
"LabelAddedAt": "Added At",
"LabelAddToCollection": "Add to Collection",
"LabelAddToCollectionBatch": "Add {0} Books to Collection",
@@ -182,11 +183,15 @@
"LabelBackupsMaxBackupSizeHelp": "As a safeguard against misconfiguration, backups will fail if they exceed the configured size.",
"LabelBackupsNumberToKeep": "Number of backups to keep",
"LabelBackupsNumberToKeepHelp": "Only 1 backup will be removed at a time so if you already have more backups than this you should manually remove them.",
+ "LabelBitrate": "Bitrate",
"LabelBooks": "Books",
"LabelChangePassword": "Change Password",
+ "LabelChannels": "Channels",
+ "LabelChapters": "Chapters",
"LabelChaptersFound": "chapters found",
"LabelChapterTitle": "Chapter Title",
"LabelClosePlayer": "Close player",
+ "LabelCodec": "Codec",
"LabelCollapseSeries": "Collapse Series",
"LabelCollections": "Collections",
"LabelComplete": "Complete",
@@ -212,6 +217,7 @@
"LabelDuration": "Duration",
"LabelDurationFound": "Duration found:",
"LabelEdit": "Edit",
+ "LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Enable",
"LabelEnd": "End",
"LabelEpisode": "Episode",
@@ -229,6 +235,7 @@
"LabelFinished": "Finished",
"LabelFolder": "Folder",
"LabelFolders": "Folders",
+ "LabelFormat": "Format",
"LabelGenre": "Genre",
"LabelGenres": "Genres",
"LabelHardDeleteFile": "Hard delete file",
@@ -250,6 +257,8 @@
"LabelItem": "Item",
"LabelLanguage": "Language",
"LabelLanguageDefaultServer": "Default Server Language",
+ "LabelLastBookAdded": "Last Book Added",
+ "LabelLastBookUpdated": "Last Book Updated",
"LabelLastSeen": "Last Seen",
"LabelLastTime": "Last Time",
"LabelLastUpdate": "Last Update",
@@ -268,10 +277,12 @@
"LabelMediaType": "Media Type",
"LabelMetadataProvider": "Metadata Provider",
"LabelMetaTag": "Meta Tag",
+ "LabelMetaTags": "Meta Tags",
"LabelMinute": "Minute",
"LabelMissing": "Missing",
"LabelMissingParts": "Missing Parts",
"LabelMore": "More",
+ "LabelMoreInfo": "More Info",
"LabelName": "Name",
"LabelNarrator": "Narrator",
"LabelNarrators": "Narrators",
@@ -402,6 +413,7 @@
"LabelTags": "Tags",
"LabelTagsAccessibleToUser": "Tags Accessible to User",
"LabelTasks": "Tasks Running",
+ "LabelTimeBase": "Time Base",
"LabelTimeListened": "Time Listened",
"LabelTimeListenedToday": "Time Listened Today",
"LabelTimeRemaining": "{0} remaining",
@@ -465,6 +477,7 @@
"MessageConfirmForceReScan": "Are you sure you want to force re-scan?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
+ "MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
"MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?",
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
diff --git a/client/strings/es.json b/client/strings/es.json
index bb2a224d..99d25e6f 100644
--- a/client/strings/es.json
+++ b/client/strings/es.json
@@ -161,6 +161,7 @@
"LabelAccountTypeGuest": "Invitado",
"LabelAccountTypeUser": "Usuario",
"LabelActivity": "Actividad",
+ "LabelAdded": "Added",
"LabelAddedAt": "Añadido",
"LabelAddToCollection": "Añadido a la Colección",
"LabelAddToCollectionBatch": "Se Añadieron {0} Libros a la Colección",
@@ -182,11 +183,15 @@
"LabelBackupsMaxBackupSizeHelp": "Como protección contra una configuración errónea, los respaldos fallaran si se excede el tamaño configurado.",
"LabelBackupsNumberToKeep": "Numero de respaldos para conservar",
"LabelBackupsNumberToKeepHelp": "Solamente 1 respaldo se removerá a la vez. Si tiene mas respaldos guardados, necesita removerlos manualmente.",
+ "LabelBitrate": "Bitrate",
"LabelBooks": "Libros",
"LabelChangePassword": "Cambiar Contraseña",
+ "LabelChannels": "Channels",
+ "LabelChapters": "Chapters",
"LabelChaptersFound": "Capitulo Encontrado",
"LabelChapterTitle": "Titulo del Capitulo",
"LabelClosePlayer": "Close player",
+ "LabelCodec": "Codec",
"LabelCollapseSeries": "Colapsar Series",
"LabelCollections": "Colecciones",
"LabelComplete": "Completo",
@@ -212,6 +217,7 @@
"LabelDuration": "Duración",
"LabelDurationFound": "Duración Comprobada:",
"LabelEdit": "Editar",
+ "LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Habilitar",
"LabelEnd": "Fin",
"LabelEpisode": "Episodio",
@@ -229,6 +235,7 @@
"LabelFinished": "Terminado",
"LabelFolder": "Carpeta",
"LabelFolders": "Carpetas",
+ "LabelFormat": "Format",
"LabelGenre": "Genero",
"LabelGenres": "Géneros",
"LabelHardDeleteFile": "Eliminar Definitivamente",
@@ -250,6 +257,8 @@
"LabelItem": "Elemento",
"LabelLanguage": "Lenguaje",
"LabelLanguageDefaultServer": "Lenguaje Predeterminado del Servidor",
+ "LabelLastBookAdded": "Last Book Added",
+ "LabelLastBookUpdated": "Last Book Updated",
"LabelLastSeen": "Ultima Vez Visto",
"LabelLastTime": "Ultima Vez",
"LabelLastUpdate": "Ultima Actualización",
@@ -268,10 +277,12 @@
"LabelMediaType": "Tipo de Multimedia",
"LabelMetadataProvider": "Proveedor de Metadata",
"LabelMetaTag": "Meta Tag",
+ "LabelMetaTags": "Meta Tags",
"LabelMinute": "Minuto",
"LabelMissing": "Ausente",
"LabelMissingParts": "Partes Ausentes",
"LabelMore": "Mas",
+ "LabelMoreInfo": "More Info",
"LabelName": "Nombre",
"LabelNarrator": "Narrador",
"LabelNarrators": "Narradores",
@@ -402,6 +413,7 @@
"LabelTags": "Etiquetas",
"LabelTagsAccessibleToUser": "Etiquetas Accessible para el Usuario",
"LabelTasks": "Tareas Corriendo",
+ "LabelTimeBase": "Time Base",
"LabelTimeListened": "Tiempo Escuchando",
"LabelTimeListenedToday": "Tiempo Escuchando Hoy",
"LabelTimeRemaining": "{0} restante",
@@ -465,6 +477,7 @@
"MessageConfirmForceReScan": "Esta seguro que desea forzar re-escanear?",
"MessageConfirmMarkSeriesFinished": "Esta seguro que desea marcar todos los libros en esta serie como terminados?",
"MessageConfirmMarkSeriesNotFinished": "Esta seguro que desea marcar todos los libros en esta serie como no terminados?",
+ "MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
"MessageConfirmRemoveCollection": "Esta seguro que desea remover la colección \"{0}\"?",
"MessageConfirmRemoveEpisode": "Esta seguro que desea remover el episodio \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Esta seguro que desea remover {0} episodios?",
diff --git a/client/strings/fr.json b/client/strings/fr.json
index 04b6b075..61649d15 100644
--- a/client/strings/fr.json
+++ b/client/strings/fr.json
@@ -20,7 +20,7 @@
"ButtonCreate": "Créer",
"ButtonCreateBackup": "Créer une sauvegarde",
"ButtonDelete": "Effacer",
- "ButtonDownloadQueue": "Queue de téléchargement",
+ "ButtonDownloadQueue": "File d’attente de téléchargement",
"ButtonEdit": "Modifier",
"ButtonEditChapters": "Modifier les chapitres",
"ButtonEditPodcast": "Modifier les podcasts",
@@ -161,6 +161,7 @@
"LabelAccountTypeGuest": "Invité",
"LabelAccountTypeUser": "Utilisateur",
"LabelActivity": "Activité",
+ "LabelAdded": "Added",
"LabelAddedAt": "Date d’ajout",
"LabelAddToCollection": "Ajouter à la collection",
"LabelAddToCollectionBatch": "Ajout de {0} livres à la lollection",
@@ -182,11 +183,15 @@
"LabelBackupsMaxBackupSizeHelp": "Afin de prévenir les mauvaises configuration, la sauvegarde échouera si elle excède la taille limite.",
"LabelBackupsNumberToKeep": "Nombre de sauvegardes à maintenir",
"LabelBackupsNumberToKeepHelp": "Une seule sauvegarde sera effacée à la fois. Si vous avez plus de sauvegardes à effacer, vous devrez le faire manuellement.",
+ "LabelBitrate": "Bitrate",
"LabelBooks": "Livres",
"LabelChangePassword": "Modifier le mot de passe",
+ "LabelChannels": "Channels",
+ "LabelChapters": "Chapters",
"LabelChaptersFound": "Chapitres trouvés",
"LabelChapterTitle": "Titres du chapitre",
"LabelClosePlayer": "Fermer le lecteur",
+ "LabelCodec": "Codec",
"LabelCollapseSeries": "Réduire les séries",
"LabelCollections": "Collections",
"LabelComplete": "Complet",
@@ -212,6 +217,7 @@
"LabelDuration": "Durée",
"LabelDurationFound": "Durée trouvée :",
"LabelEdit": "Modifier",
+ "LabelEmbeddedCover": "Couverture du livre intégrée",
"LabelEnable": "Activer",
"LabelEnd": "Fin",
"LabelEpisode": "Épisode",
@@ -229,6 +235,7 @@
"LabelFinished": "Fini(e)",
"LabelFolder": "Dossier",
"LabelFolders": "Dossiers",
+ "LabelFormat": "Format",
"LabelGenre": "Genre",
"LabelGenres": "Genres",
"LabelHardDeleteFile": "Suppression du fichier",
@@ -250,6 +257,8 @@
"LabelItem": "Article",
"LabelLanguage": "Langue",
"LabelLanguageDefaultServer": "Langue par défaut",
+ "LabelLastBookAdded": "Last Book Added",
+ "LabelLastBookUpdated": "Last Book Updated",
"LabelLastSeen": "Vu dernièrement",
"LabelLastTime": "Progression",
"LabelLastUpdate": "Dernière mise à jour",
@@ -268,10 +277,12 @@
"LabelMediaType": "Type de média",
"LabelMetadataProvider": "Fournisseur de métadonnées",
"LabelMetaTag": "Etiquette de métadonnée",
+ "LabelMetaTags": "Meta Tags",
"LabelMinute": "Minute",
"LabelMissing": "Manquant",
"LabelMissingParts": "Parties manquantes",
"LabelMore": "Plus",
+ "LabelMoreInfo": "More Info",
"LabelName": "Nom",
"LabelNarrator": "Narrateur",
"LabelNarrators": "Narrateurs",
@@ -279,7 +290,7 @@
"LabelNewestAuthors": "Nouveaux auteurs",
"LabelNewestEpisodes": "Derniers épisodes",
"LabelNewPassword": "Nouveau mot de passe",
- "LabelNextBackupDate": "Prochaine date de sauvegarde",
+ "LabelNextBackupDate": "Date de la prochaine sauvegarde",
"LabelNextScheduledRun": "Prochain lancement prévu",
"LabelNotes": "Notes",
"LabelNotFinished": "Non terminé(e)",
@@ -328,7 +339,7 @@
"LabelRSSFeedCustomOwnerEmail": "Email propriétaire personnalisé",
"LabelRSSFeedCustomOwnerName": "Nom propriétaire personnalisé",
"LabelRSSFeedOpen": "Flux RSS ouvert",
- "LabelRSSFeedPreventIndexing": "Empêcher l'indexation",
+ "LabelRSSFeedPreventIndexing": "Empêcher l’indexation",
"LabelRSSFeedSlug": "Identificateur d’adresse du Flux RSS ",
"LabelRSSFeedURL": "Adresse du flux RSS",
"LabelSearchTerm": "Terme de recherche",
@@ -402,6 +413,7 @@
"LabelTags": "Étiquettes",
"LabelTagsAccessibleToUser": "Étiquettes accessibles à l’utilisateur",
"LabelTasks": "Tasks Running",
+ "LabelTimeBase": "Time Base",
"LabelTimeListened": "Temps d’écoute",
"LabelTimeListenedToday": "Nombres d’écoutes Aujourd’hui",
"LabelTimeRemaining": "{0} restantes",
@@ -465,6 +477,7 @@
"MessageConfirmForceReScan": "Êtes-vous sûr de vouloir lancer une Analyse Forcée ?",
"MessageConfirmMarkSeriesFinished": "Êtes-vous sûr de vouloir marquer comme terminé tous les livres de cette série ?",
"MessageConfirmMarkSeriesNotFinished": "Êtes-vous sûr de vouloir marquer comme non terminé tous les livres de cette série ?",
+ "MessageConfirmRemoveAllChapters": "Êtes-vous sûr de vouloir supprimer tous les chapitres ?",
"MessageConfirmRemoveCollection": "Êtes-vous sûr de vouloir supprimer la collection « {0} » ?",
"MessageConfirmRemoveEpisode": "Êtes-vous sûr de vouloir supprimer l’épisode « {0} » ?",
"MessageConfirmRemoveEpisodes": "Êtes-vous sûr de vouloir supprimer {0} épisodes ?",
@@ -638,4 +651,4 @@
"ToastSocketFailedToConnect": "Échec de la connexion WebSocket",
"ToastUserDeleteFailed": "Échec de la suppression de l’utilisateur",
"ToastUserDeleteSuccess": "Utilisateur supprimé"
-}
\ No newline at end of file
+}
diff --git a/client/strings/gu.json b/client/strings/gu.json
new file mode 100644
index 00000000..c1d80158
--- /dev/null
+++ b/client/strings/gu.json
@@ -0,0 +1,654 @@
+{
+ "ButtonAdd": "ઉમેરો",
+ "ButtonAddChapters": "પ્રકરણો ઉમેરો",
+ "ButtonAddPodcasts": "પોડકાસ્ટ ઉમેરો",
+ "ButtonAddYourFirstLibrary": "તમારી પ્રથમ પુસ્તકાલય ઉમેરો",
+ "ButtonApply": "લાગુ કરો",
+ "ButtonApplyChapters": "પ્રકરણો લાગુ કરો",
+ "ButtonAuthors": "લેખકો",
+ "ButtonBrowseForFolder": "ફોલ્ડર માટે જુઓ",
+ "ButtonCancel": "રદ કરો",
+ "ButtonCancelEncode": "એન્કોડ રદ કરો",
+ "ButtonChangeRootPassword": "રૂટ પાસવર્ડ બદલો",
+ "ButtonCheckAndDownloadNewEpisodes": "નવા એપિસોડ્સ ચેક કરો અને ડાઉનલોડ કરો",
+ "ButtonChooseAFolder": "ફોલ્ડર પસંદ કરો",
+ "ButtonChooseFiles": "ફાઇલો પસંદ કરો",
+ "ButtonClearFilter": "ફિલ્ટર જતુ કરો ",
+ "ButtonCloseFeed": "ફીડ બંધ કરો",
+ "ButtonCollections": "સંગ્રહ",
+ "ButtonConfigureScanner": "સ્કેનર સેટિંગ બદલો",
+ "ButtonCreate": "બનાવો",
+ "ButtonCreateBackup": "બેકઅપ બનાવો",
+ "ButtonDelete": "કાઢી નાખો",
+ "ButtonDownloadQueue": "કતાર ડાઉનલોડ કરો",
+ "ButtonEdit": "સંપાદિત કરો",
+ "ButtonEditChapters": "પ્રકરણો સંપાદિત કરો",
+ "ButtonEditPodcast": "પોડકાસ્ટ સંપાદિત કરો",
+ "ButtonForceReScan": "બળપૂર્વક ફરીથી સ્કેન કરો",
+ "ButtonFullPath": "સંપૂર્ણ પથ",
+ "ButtonHide": "છુપાવો",
+ "ButtonHome": "ઘર",
+ "ButtonIssues": "સમસ્યાઓ",
+ "ButtonLatest": "નવીનતમ",
+ "ButtonLibrary": "પુસ્તકાલય",
+ "ButtonLogout": "લૉગ આઉટ",
+ "ButtonLookup": "શોધો",
+ "ButtonManageTracks": "ટ્રેક્સ મેનેજ કરો",
+ "ButtonMapChapterTitles": "પ્રકરણ શીર્ષકો મેપ કરો",
+ "ButtonMatchAllAuthors": "બધા મેળ ખાતા લેખકો શોધો",
+ "ButtonMatchBooks": "મેળ ખાતી પુસ્તકો શોધો",
+ "ButtonNevermind": "કંઈ વાંધો નહીં",
+ "ButtonOk": "ઓકે",
+ "ButtonOpenFeed": "ફીડ ખોલો",
+ "ButtonOpenManager": "મેનેજર ખોલો",
+ "ButtonPlay": "ચલાવો",
+ "ButtonPlaying": "ચલાવી રહ્યું છે",
+ "ButtonPlaylists": "પ્લેલિસ્ટ",
+ "ButtonPurgeAllCache": "બધો Cache કાઢી નાખો",
+ "ButtonPurgeItemsCache": "વસ્તુઓનો Cache કાઢી નાખો",
+ "ButtonPurgeMediaProgress": "બધું સાંભળ્યું કાઢી નાખો",
+ "ButtonQueueAddItem": "કતારમાં ઉમેરો",
+ "ButtonQueueRemoveItem": "કતારથી કાઢી નાખો",
+ "ButtonQuickMatch": "ઝડપી મેળ ખવડાવો",
+ "ButtonRead": "વાંચો",
+ "ButtonRemove": "કાઢી નાખો",
+ "ButtonRemoveAll": "બધું કાઢી નાખો",
+ "ButtonRemoveAllLibraryItems": "બધું પુસ્તકાલય વસ્તુઓ કાઢી નાખો",
+ "ButtonRemoveFromContinueListening": "સાંભળતી પુસ્તકો માંથી કાઢી નાખો",
+ "ButtonRemoveSeriesFromContinueSeries": "સાંભળતી સિરીઝ માંથી કાઢી નાખો",
+ "ButtonReScan": "ફરીથી સ્કેન કરો",
+ "ButtonReset": "રીસેટ કરો",
+ "ButtonRestore": "પુનઃસ્થાપિત કરો",
+ "ButtonSave": "સાચવો",
+ "ButtonSaveAndClose": "સાચવો અને બંધ કરો",
+ "ButtonSaveTracklist": "ટ્રેક યાદી સાચવો",
+ "ButtonScan": "સ્કેન કરો",
+ "ButtonScanLibrary": "પુસ્તકાલય સ્કેન કરો",
+ "ButtonSearch": "શોધો",
+ "ButtonSelectFolderPath": "ફોલ્ડર પથ પસંદ કરો",
+ "ButtonSeries": "સિરીઝ",
+ "ButtonSetChaptersFromTracks": "ટ્રેક્સથી પ્રકરણો સેટ કરો",
+ "ButtonShiftTimes": "સમય શિફ્ટ કરો",
+ "ButtonShow": "બતાવો",
+ "ButtonStartM4BEncode": "M4B એન્કોડ શરૂ કરો",
+ "ButtonStartMetadataEmbed": "મેટાડેટા એમ્બેડ શરૂ કરો",
+ "ButtonSubmit": "સબમિટ કરો",
+ "ButtonUpload": "અપલોડ કરો",
+ "ButtonUploadBackup": "બેકઅપ અપલોડ કરો",
+ "ButtonUploadCover": "કવર અપલોડ કરો",
+ "ButtonUploadOPMLFile": "OPML ફાઇલ અપલોડ કરો",
+ "ButtonUserDelete": "વપરાશકર્તા {0} કાઢી નાખો",
+ "ButtonUserEdit": "વપરાશકર્તા {0} સંપાદિત કરો",
+ "ButtonViewAll": "બધું જુઓ",
+ "ButtonYes": "હા",
+ "HeaderAccount": "એકાઉન્ટ",
+ "HeaderAdvanced": "અડ્વાન્સડ",
+ "HeaderAppriseNotificationSettings": "Apprise સૂચના સેટિંગ્સ",
+ "HeaderAudiobookTools": "Audiobook File Management Tools",
+ "HeaderAudioTracks": "Audio Tracks",
+ "HeaderBackups": "Backups",
+ "HeaderChangePassword": "Change Password",
+ "HeaderChapters": "Chapters",
+ "HeaderChooseAFolder": "Choose a Folder",
+ "HeaderCollection": "Collection",
+ "HeaderCollectionItems": "Collection Items",
+ "HeaderCover": "Cover",
+ "HeaderCurrentDownloads": "Current Downloads",
+ "HeaderDetails": "Details",
+ "HeaderDownloadQueue": "Download Queue",
+ "HeaderEpisodes": "Episodes",
+ "HeaderFiles": "Files",
+ "HeaderFindChapters": "Find Chapters",
+ "HeaderIgnoredFiles": "Ignored Files",
+ "HeaderItemFiles": "Item Files",
+ "HeaderItemMetadataUtils": "Item Metadata Utils",
+ "HeaderLastListeningSession": "Last Listening Session",
+ "HeaderLatestEpisodes": "Latest episodes",
+ "HeaderLibraries": "Libraries",
+ "HeaderLibraryFiles": "Library Files",
+ "HeaderLibraryStats": "Library Stats",
+ "HeaderListeningSessions": "Listening Sessions",
+ "HeaderListeningStats": "Listening Stats",
+ "HeaderLogin": "Login",
+ "HeaderLogs": "Logs",
+ "HeaderManageGenres": "Manage Genres",
+ "HeaderManageTags": "Manage Tags",
+ "HeaderMapDetails": "Map details",
+ "HeaderMatch": "Match",
+ "HeaderMetadataToEmbed": "Metadata to embed",
+ "HeaderNewAccount": "New Account",
+ "HeaderNewLibrary": "New Library",
+ "HeaderNotifications": "Notifications",
+ "HeaderOpenRSSFeed": "Open RSS Feed",
+ "HeaderOtherFiles": "Other Files",
+ "HeaderPermissions": "Permissions",
+ "HeaderPlayerQueue": "Player Queue",
+ "HeaderPlaylist": "Playlist",
+ "HeaderPlaylistItems": "Playlist Items",
+ "HeaderPodcastsToAdd": "Podcasts to Add",
+ "HeaderPreviewCover": "Preview Cover",
+ "HeaderRemoveEpisode": "Remove Episode",
+ "HeaderRemoveEpisodes": "Remove {0} Episodes",
+ "HeaderRSSFeedGeneral": "RSS Details",
+ "HeaderRSSFeedIsOpen": "RSS Feed is Open",
+ "HeaderSavedMediaProgress": "Saved Media Progress",
+ "HeaderSchedule": "Schedule",
+ "HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
+ "HeaderSession": "Session",
+ "HeaderSetBackupSchedule": "Set Backup Schedule",
+ "HeaderSettings": "Settings",
+ "HeaderSettingsDisplay": "Display",
+ "HeaderSettingsExperimental": "Experimental Features",
+ "HeaderSettingsGeneral": "General",
+ "HeaderSettingsScanner": "Scanner",
+ "HeaderSleepTimer": "Sleep Timer",
+ "HeaderStatsLargestItems": "Largest Items",
+ "HeaderStatsLongestItems": "Longest Items (hrs)",
+ "HeaderStatsMinutesListeningChart": "Minutes Listening (last 7 days)",
+ "HeaderStatsRecentSessions": "Recent Sessions",
+ "HeaderStatsTop10Authors": "Top 10 Authors",
+ "HeaderStatsTop5Genres": "Top 5 Genres",
+ "HeaderTools": "Tools",
+ "HeaderUpdateAccount": "Update Account",
+ "HeaderUpdateAuthor": "Update Author",
+ "HeaderUpdateDetails": "Update Details",
+ "HeaderUpdateLibrary": "Update Library",
+ "HeaderUsers": "Users",
+ "HeaderYourStats": "Your Stats",
+ "LabelAbridged": "Abridged",
+ "LabelAccountType": "Account Type",
+ "LabelAccountTypeAdmin": "Admin",
+ "LabelAccountTypeGuest": "Guest",
+ "LabelAccountTypeUser": "User",
+ "LabelActivity": "Activity",
+ "LabelAdded": "Added",
+ "LabelAddedAt": "Added At",
+ "LabelAddToCollection": "Add to Collection",
+ "LabelAddToCollectionBatch": "Add {0} Books to Collection",
+ "LabelAddToPlaylist": "Add to Playlist",
+ "LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
+ "LabelAll": "All",
+ "LabelAllUsers": "All Users",
+ "LabelAlreadyInYourLibrary": "Already in your library",
+ "LabelAppend": "Append",
+ "LabelAuthor": "Author",
+ "LabelAuthorFirstLast": "Author (First Last)",
+ "LabelAuthorLastFirst": "Author (Last, First)",
+ "LabelAuthors": "Authors",
+ "LabelAutoDownloadEpisodes": "Auto Download Episodes",
+ "LabelBackToUser": "Back to User",
+ "LabelBackupsEnableAutomaticBackups": "Enable automatic backups",
+ "LabelBackupsEnableAutomaticBackupsHelp": "Backups saved in /metadata/backups",
+ "LabelBackupsMaxBackupSize": "Maximum backup size (in GB)",
+ "LabelBackupsMaxBackupSizeHelp": "As a safeguard against misconfiguration, backups will fail if they exceed the configured size.",
+ "LabelBackupsNumberToKeep": "Number of backups to keep",
+ "LabelBackupsNumberToKeepHelp": "Only 1 backup will be removed at a time so if you already have more backups than this you should manually remove them.",
+ "LabelBitrate": "Bitrate",
+ "LabelBooks": "Books",
+ "LabelChangePassword": "Change Password",
+ "LabelChannels": "Channels",
+ "LabelChapters": "Chapters",
+ "LabelChaptersFound": "chapters found",
+ "LabelChapterTitle": "Chapter Title",
+ "LabelClosePlayer": "Close player",
+ "LabelCodec": "Codec",
+ "LabelCollapseSeries": "Collapse Series",
+ "LabelCollections": "Collections",
+ "LabelComplete": "Complete",
+ "LabelConfirmPassword": "Confirm Password",
+ "LabelContinueListening": "Continue Listening",
+ "LabelContinueSeries": "Continue Series",
+ "LabelCover": "Cover",
+ "LabelCoverImageURL": "Cover Image URL",
+ "LabelCreatedAt": "Created At",
+ "LabelCronExpression": "Cron Expression",
+ "LabelCurrent": "Current",
+ "LabelCurrently": "Currently:",
+ "LabelCustomCronExpression": "Custom Cron Expression:",
+ "LabelDatetime": "Datetime",
+ "LabelDescription": "Description",
+ "LabelDeselectAll": "Deselect All",
+ "LabelDevice": "Device",
+ "LabelDeviceInfo": "Device Info",
+ "LabelDirectory": "Directory",
+ "LabelDiscFromFilename": "Disc from Filename",
+ "LabelDiscFromMetadata": "Disc from Metadata",
+ "LabelDownload": "Download",
+ "LabelDuration": "Duration",
+ "LabelDurationFound": "Duration found:",
+ "LabelEdit": "Edit",
+ "LabelEmbeddedCover": "Embedded Cover",
+ "LabelEnable": "Enable",
+ "LabelEnd": "End",
+ "LabelEpisode": "Episode",
+ "LabelEpisodeTitle": "Episode Title",
+ "LabelEpisodeType": "Episode Type",
+ "LabelExample": "Example",
+ "LabelExplicit": "Explicit",
+ "LabelFeedURL": "Feed URL",
+ "LabelFile": "File",
+ "LabelFileBirthtime": "File Birthtime",
+ "LabelFileModified": "File Modified",
+ "LabelFilename": "Filename",
+ "LabelFilterByUser": "Filter by User",
+ "LabelFindEpisodes": "Find Episodes",
+ "LabelFinished": "Finished",
+ "LabelFolder": "Folder",
+ "LabelFolders": "Folders",
+ "LabelFormat": "Format",
+ "LabelGenre": "Genre",
+ "LabelGenres": "Genres",
+ "LabelHardDeleteFile": "Hard delete file",
+ "LabelHour": "Hour",
+ "LabelIcon": "Icon",
+ "LabelIncludeInTracklist": "Include in Tracklist",
+ "LabelIncomplete": "Incomplete",
+ "LabelInProgress": "In Progress",
+ "LabelInterval": "Interval",
+ "LabelIntervalCustomDailyWeekly": "Custom daily/weekly",
+ "LabelIntervalEvery12Hours": "Every 12 hours",
+ "LabelIntervalEvery15Minutes": "Every 15 minutes",
+ "LabelIntervalEvery2Hours": "Every 2 hours",
+ "LabelIntervalEvery30Minutes": "Every 30 minutes",
+ "LabelIntervalEvery6Hours": "Every 6 hours",
+ "LabelIntervalEveryDay": "Every day",
+ "LabelIntervalEveryHour": "Every hour",
+ "LabelInvalidParts": "Invalid Parts",
+ "LabelItem": "Item",
+ "LabelLanguage": "Language",
+ "LabelLanguageDefaultServer": "Default Server Language",
+ "LabelLastBookAdded": "Last Book Added",
+ "LabelLastBookUpdated": "Last Book Updated",
+ "LabelLastSeen": "Last Seen",
+ "LabelLastTime": "Last Time",
+ "LabelLastUpdate": "Last Update",
+ "LabelLess": "Less",
+ "LabelLibrariesAccessibleToUser": "Libraries Accessible to User",
+ "LabelLibrary": "Library",
+ "LabelLibraryItem": "Library Item",
+ "LabelLibraryName": "Library Name",
+ "LabelLimit": "Limit",
+ "LabelListenAgain": "Listen Again",
+ "LabelLogLevelDebug": "Debug",
+ "LabelLogLevelInfo": "Info",
+ "LabelLogLevelWarn": "Warn",
+ "LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date",
+ "LabelMediaPlayer": "Media Player",
+ "LabelMediaType": "Media Type",
+ "LabelMetadataProvider": "Metadata Provider",
+ "LabelMetaTag": "Meta Tag",
+ "LabelMetaTags": "Meta Tags",
+ "LabelMinute": "Minute",
+ "LabelMissing": "Missing",
+ "LabelMissingParts": "Missing Parts",
+ "LabelMore": "More",
+ "LabelMoreInfo": "More Info",
+ "LabelName": "Name",
+ "LabelNarrator": "Narrator",
+ "LabelNarrators": "Narrators",
+ "LabelNew": "New",
+ "LabelNewestAuthors": "Newest Authors",
+ "LabelNewestEpisodes": "Newest Episodes",
+ "LabelNewPassword": "New Password",
+ "LabelNextBackupDate": "Next backup date",
+ "LabelNextScheduledRun": "Next scheduled run",
+ "LabelNotes": "Notes",
+ "LabelNotFinished": "Not Finished",
+ "LabelNotificationAppriseURL": "Apprise URL(s)",
+ "LabelNotificationAvailableVariables": "Available variables",
+ "LabelNotificationBodyTemplate": "Body Template",
+ "LabelNotificationEvent": "Notification Event",
+ "LabelNotificationsMaxFailedAttempts": "Max failed attempts",
+ "LabelNotificationsMaxFailedAttemptsHelp": "Notifications are disabled once they fail to send this many times",
+ "LabelNotificationsMaxQueueSize": "Max queue size for notification events",
+ "LabelNotificationsMaxQueueSizeHelp": "Events are limited to firing 1 per second. Events will be ignored if the queue is at max size. This prevents notification spamming.",
+ "LabelNotificationTitleTemplate": "Title Template",
+ "LabelNotStarted": "Not Started",
+ "LabelNumberOfBooks": "Number of Books",
+ "LabelNumberOfEpisodes": "# of Episodes",
+ "LabelOpenRSSFeed": "Open RSS Feed",
+ "LabelOverwrite": "Overwrite",
+ "LabelPassword": "Password",
+ "LabelPath": "Path",
+ "LabelPermissionsAccessAllLibraries": "Can Access All Libraries",
+ "LabelPermissionsAccessAllTags": "Can Access All Tags",
+ "LabelPermissionsAccessExplicitContent": "Can Access Explicit Content",
+ "LabelPermissionsDelete": "Can Delete",
+ "LabelPermissionsDownload": "Can Download",
+ "LabelPermissionsUpdate": "Can Update",
+ "LabelPermissionsUpload": "Can Upload",
+ "LabelPhotoPathURL": "Photo Path/URL",
+ "LabelPlaylists": "Playlists",
+ "LabelPlayMethod": "Play Method",
+ "LabelPodcast": "Podcast",
+ "LabelPodcasts": "Podcasts",
+ "LabelPodcastType": "Podcast Type",
+ "LabelPrefixesToIgnore": "Prefixes to Ignore (case insensitive)",
+ "LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
+ "LabelProgress": "Progress",
+ "LabelProvider": "Provider",
+ "LabelPubDate": "Pub Date",
+ "LabelPublisher": "Publisher",
+ "LabelPublishYear": "Publish Year",
+ "LabelRecentlyAdded": "Recently Added",
+ "LabelRecentSeries": "Recent Series",
+ "LabelRecommended": "Recommended",
+ "LabelRegion": "Region",
+ "LabelReleaseDate": "Release Date",
+ "LabelRemoveCover": "Remove cover",
+ "LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
+ "LabelRSSFeedCustomOwnerName": "Custom owner Name",
+ "LabelRSSFeedOpen": "RSS Feed Open",
+ "LabelRSSFeedPreventIndexing": "Prevent Indexing",
+ "LabelRSSFeedSlug": "RSS Feed Slug",
+ "LabelRSSFeedURL": "RSS Feed URL",
+ "LabelSearchTerm": "Search Term",
+ "LabelSearchTitle": "Search Title",
+ "LabelSearchTitleOrASIN": "Search Title or ASIN",
+ "LabelSeason": "Season",
+ "LabelSequence": "Sequence",
+ "LabelSeries": "Series",
+ "LabelSeriesName": "Series Name",
+ "LabelSeriesProgress": "Series Progress",
+ "LabelSettingsBookshelfViewHelp": "Skeumorphic design with wooden shelves",
+ "LabelSettingsChromecastSupport": "Chromecast support",
+ "LabelSettingsDateFormat": "Date Format",
+ "LabelSettingsDisableWatcher": "Disable Watcher",
+ "LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library",
+ "LabelSettingsDisableWatcherHelp": "Disables the automatic adding/updating of items when file changes are detected. *Requires server restart",
+ "LabelSettingsEnableEReader": "Enable e-reader for all users",
+ "LabelSettingsEnableEReaderHelp": "E-reader is still a work in progress, but use this setting to open it up to all your users (or use the \"Experimental Features\" toggle just for use by you)",
+ "LabelSettingsExperimentalFeatures": "Experimental features",
+ "LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
+ "LabelSettingsFindCovers": "Find covers",
+ "LabelSettingsFindCoversHelp": "If your audiobook does not have an embedded cover or a cover image inside the folder, the scanner will attempt to find a cover. Note: This will extend scan time",
+ "LabelSettingsHomePageBookshelfView": "Home page use bookshelf view",
+ "LabelSettingsLibraryBookshelfView": "Library use bookshelf view",
+ "LabelSettingsOverdriveMediaMarkers": "Use Overdrive Media Markers for chapters",
+ "LabelSettingsOverdriveMediaMarkersHelp": "MP3 files from Overdrive come with chapter timings embedded as custom metadata. Enabling this will use these tags for chapter timings automatically",
+ "LabelSettingsParseSubtitles": "Parse subtitles",
+ "LabelSettingsParseSubtitlesHelp": "Extract subtitles from audiobook folder names. Subtitle must be seperated by \" - \" i.e. \"Book Title - A Subtitle Here\" has the subtitle \"A Subtitle Here\"",
+ "LabelSettingsPreferAudioMetadata": "Prefer audio metadata",
+ "LabelSettingsPreferAudioMetadataHelp": "Audio file ID3 meta tags will be used for book details over folder names",
+ "LabelSettingsPreferMatchedMetadata": "Prefer matched metadata",
+ "LabelSettingsPreferMatchedMetadataHelp": "Matched data will overide item details when using Quick Match. By default Quick Match will only fill in missing details.",
+ "LabelSettingsPreferOPFMetadata": "Prefer OPF metadata",
+ "LabelSettingsPreferOPFMetadataHelp": "OPF file metadata will be used for book details over folder names",
+ "LabelSettingsSkipMatchingBooksWithASIN": "Skip matching books that already have an ASIN",
+ "LabelSettingsSkipMatchingBooksWithISBN": "Skip matching books that already have an ISBN",
+ "LabelSettingsSortingIgnorePrefixes": "Ignore prefixes when sorting",
+ "LabelSettingsSortingIgnorePrefixesHelp": "i.e. for prefix \"the\" book title \"The Book Title\" would sort as \"Book Title, The\"",
+ "LabelSettingsSquareBookCovers": "Use square book covers",
+ "LabelSettingsSquareBookCoversHelp": "Prefer to use square covers over standard 1.6:1 book covers",
+ "LabelSettingsStoreCoversWithItem": "Store covers with item",
+ "LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept",
+ "LabelSettingsStoreMetadataWithItem": "Store metadata with item",
+ "LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension",
+ "LabelSettingsTimeFormat": "Time Format",
+ "LabelShowAll": "Show All",
+ "LabelSize": "Size",
+ "LabelSleepTimer": "Sleep timer",
+ "LabelStart": "Start",
+ "LabelStarted": "Started",
+ "LabelStartedAt": "Started At",
+ "LabelStartTime": "Start Time",
+ "LabelStatsAudioTracks": "Audio Tracks",
+ "LabelStatsAuthors": "Authors",
+ "LabelStatsBestDay": "Best Day",
+ "LabelStatsDailyAverage": "Daily Average",
+ "LabelStatsDays": "Days",
+ "LabelStatsDaysListened": "Days Listened",
+ "LabelStatsHours": "Hours",
+ "LabelStatsInARow": "in a row",
+ "LabelStatsItemsFinished": "Items Finished",
+ "LabelStatsItemsInLibrary": "Items in Library",
+ "LabelStatsMinutes": "minutes",
+ "LabelStatsMinutesListening": "Minutes Listening",
+ "LabelStatsOverallDays": "Overall Days",
+ "LabelStatsOverallHours": "Overall Hours",
+ "LabelStatsWeekListening": "Week Listening",
+ "LabelSubtitle": "Subtitle",
+ "LabelSupportedFileTypes": "Supported File Types",
+ "LabelTag": "Tag",
+ "LabelTags": "Tags",
+ "LabelTagsAccessibleToUser": "Tags Accessible to User",
+ "LabelTasks": "Tasks Running",
+ "LabelTimeBase": "Time Base",
+ "LabelTimeListened": "Time Listened",
+ "LabelTimeListenedToday": "Time Listened Today",
+ "LabelTimeRemaining": "{0} remaining",
+ "LabelTimeToShift": "Time to shift in seconds",
+ "LabelTitle": "Title",
+ "LabelToolsEmbedMetadata": "Embed Metadata",
+ "LabelToolsEmbedMetadataDescription": "Embed metadata into audio files including cover image and chapters.",
+ "LabelToolsMakeM4b": "Make M4B Audiobook File",
+ "LabelToolsMakeM4bDescription": "Generate a .M4B audiobook file with embedded metadata, cover image, and chapters.",
+ "LabelToolsSplitM4b": "Split M4B to MP3's",
+ "LabelToolsSplitM4bDescription": "Create MP3's from an M4B split by chapters with embedded metadata, cover image, and chapters.",
+ "LabelTotalDuration": "Total Duration",
+ "LabelTotalTimeListened": "Total Time Listened",
+ "LabelTrackFromFilename": "Track from Filename",
+ "LabelTrackFromMetadata": "Track from Metadata",
+ "LabelTracks": "Tracks",
+ "LabelTracksMultiTrack": "Multi-track",
+ "LabelTracksSingleTrack": "Single-track",
+ "LabelType": "Type",
+ "LabelUnabridged": "Unabridged",
+ "LabelUnknown": "Unknown",
+ "LabelUpdateCover": "Update Cover",
+ "LabelUpdateCoverHelp": "Allow overwriting of existing covers for the selected books when a match is located",
+ "LabelUpdatedAt": "Updated At",
+ "LabelUpdateDetails": "Update Details",
+ "LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located",
+ "LabelUploaderDragAndDrop": "Drag & drop files or folders",
+ "LabelUploaderDropFiles": "Drop files",
+ "LabelUseChapterTrack": "Use chapter track",
+ "LabelUseFullTrack": "Use full track",
+ "LabelUser": "User",
+ "LabelUsername": "Username",
+ "LabelValue": "Value",
+ "LabelVersion": "Version",
+ "LabelViewBookmarks": "View bookmarks",
+ "LabelViewChapters": "View chapters",
+ "LabelViewQueue": "View player queue",
+ "LabelVolume": "Volume",
+ "LabelWeekdaysToRun": "Weekdays to run",
+ "LabelYourAudiobookDuration": "Your audiobook duration",
+ "LabelYourBookmarks": "Your Bookmarks",
+ "LabelYourPlaylists": "Your Playlists",
+ "LabelYourProgress": "Your Progress",
+ "MessageAddToPlayerQueue": "Add to player queue",
+ "MessageAppriseDescription": "To use this feature you will need to have an instance of Apprise API running or an api that will handle those same requests. The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at http://192.168.1.1:8337 then you would put http://192.168.1.1:8337/notify.",
+ "MessageBackupsDescription": "Backups include users, user progress, library item details, server settings, and images stored in /metadata/items & /metadata/authors. Backups do not include any files stored in your library folders.",
+ "MessageBatchQuickMatchDescription": "Quick Match will attempt to add missing covers and metadata for the selected items. Enable the options below to allow Quick Match to overwrite existing covers and/or metadata.",
+ "MessageBookshelfNoCollections": "You haven't made any collections yet",
+ "MessageBookshelfNoResultsForFilter": "No Results for filter \"{0}: {1}\"",
+ "MessageBookshelfNoRSSFeeds": "No RSS feeds are open",
+ "MessageBookshelfNoSeries": "You have no series",
+ "MessageChapterEndIsAfter": "Chapter end is after the end of your audiobook",
+ "MessageChapterErrorFirstNotZero": "First chapter must start at 0",
+ "MessageChapterErrorStartGteDuration": "Invalid start time must be less than audiobook duration",
+ "MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
+ "MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook",
+ "MessageCheckingCron": "Checking cron...",
+ "MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
+ "MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",
+ "MessageConfirmDeleteSession": "Are you sure you want to delete this session?",
+ "MessageConfirmForceReScan": "Are you sure you want to force re-scan?",
+ "MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
+ "MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
+ "MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
+ "MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?",
+ "MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
+ "MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
+ "MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
+ "MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
+ "MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",
+ "MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".",
+ "MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?",
+ "MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
+ "MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".",
+ "MessageDownloadingEpisode": "Downloading episode",
+ "MessageDragFilesIntoTrackOrder": "Drag files into correct track order",
+ "MessageEmbedFinished": "Embed Finished!",
+ "MessageEpisodesQueuedForDownload": "{0} Episode(s) queued for download",
+ "MessageFeedURLWillBe": "Feed URL will be {0}",
+ "MessageFetching": "Fetching...",
+ "MessageForceReScanDescription": "will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be scanned as new.",
+ "MessageImportantNotice": "Important Notice!",
+ "MessageInsertChapterBelow": "Insert chapter below",
+ "MessageItemsSelected": "{0} Items Selected",
+ "MessageItemsUpdated": "{0} Items Updated",
+ "MessageJoinUsOn": "Join us on",
+ "MessageListeningSessionsInTheLastYear": "{0} listening sessions in the last year",
+ "MessageLoading": "Loading...",
+ "MessageLoadingFolders": "Loading folders...",
+ "MessageM4BFailed": "M4B Failed!",
+ "MessageM4BFinished": "M4B Finished!",
+ "MessageMapChapterTitles": "Map chapter titles to your existing audiobook chapters without adjusting timestamps",
+ "MessageMarkAsFinished": "Mark as Finished",
+ "MessageMarkAsNotFinished": "Mark as Not Finished",
+ "MessageMatchBooksDescription": "will attempt to match books in the library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.",
+ "MessageNoAudioTracks": "No audio tracks",
+ "MessageNoAuthors": "No Authors",
+ "MessageNoBackups": "No Backups",
+ "MessageNoBookmarks": "No Bookmarks",
+ "MessageNoChapters": "No Chapters",
+ "MessageNoCollections": "No Collections",
+ "MessageNoCoversFound": "No Covers Found",
+ "MessageNoDescription": "No description",
+ "MessageNoDownloadsInProgress": "No downloads currently in progress",
+ "MessageNoDownloadsQueued": "No downloads queued",
+ "MessageNoEpisodeMatchesFound": "No episode matches found",
+ "MessageNoEpisodes": "No Episodes",
+ "MessageNoFoldersAvailable": "No Folders Available",
+ "MessageNoGenres": "No Genres",
+ "MessageNoIssues": "No Issues",
+ "MessageNoItems": "No Items",
+ "MessageNoItemsFound": "No items found",
+ "MessageNoListeningSessions": "No Listening Sessions",
+ "MessageNoLogs": "No Logs",
+ "MessageNoMediaProgress": "No Media Progress",
+ "MessageNoNotifications": "No Notifications",
+ "MessageNoPodcastsFound": "No podcasts found",
+ "MessageNoResults": "No Results",
+ "MessageNoSearchResultsFor": "No search results for \"{0}\"",
+ "MessageNoSeries": "No Series",
+ "MessageNoTags": "No Tags",
+ "MessageNoTasksRunning": "No Tasks Running",
+ "MessageNotYetImplemented": "Not yet implemented",
+ "MessageNoUpdateNecessary": "No update necessary",
+ "MessageNoUpdatesWereNecessary": "No updates were necessary",
+ "MessageNoUserPlaylists": "You have no playlists",
+ "MessageOr": "or",
+ "MessagePauseChapter": "Pause chapter playback",
+ "MessagePlayChapter": "Listen to beginning of chapter",
+ "MessagePlaylistCreateFromCollection": "Create playlist from collection",
+ "MessagePodcastHasNoRSSFeedForMatching": "Podcast has no RSS feed url to use for matching",
+ "MessageQuickMatchDescription": "Populate empty item details & cover with first match result from '{0}'. Does not overwrite details unless 'Prefer matched metadata' server setting is enabled.",
+ "MessageRemoveAllItemsWarning": "WARNING! This action will remove all library items from the database including any updates or matches you have made. This does not do anything to your actual files. Are you sure?",
+ "MessageRemoveChapter": "Remove chapter",
+ "MessageRemoveEpisodes": "Remove {0} episode(s)",
+ "MessageRemoveFromPlayerQueue": "Remove from player queue",
+ "MessageRemoveUserWarning": "Are you sure you want to permanently delete user \"{0}\"?",
+ "MessageReportBugsAndContribute": "Report bugs, request features, and contribute on",
+ "MessageResetChaptersConfirm": "Are you sure you want to reset chapters and undo the changes you made?",
+ "MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on",
+ "MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.
Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.
All clients using your server will be automatically refreshed.",
+ "MessageSearchResultsFor": "Search results for",
+ "MessageServerCouldNotBeReached": "Server could not be reached",
+ "MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
+ "MessageStartPlaybackAtTime": "Start playback for \"{0}\" at {1}?",
+ "MessageThinking": "Thinking...",
+ "MessageUploaderItemFailed": "Failed to upload",
+ "MessageUploaderItemSuccess": "Successfully Uploaded!",
+ "MessageUploading": "Uploading...",
+ "MessageValidCronExpression": "Valid cron expression",
+ "MessageWatcherIsDisabledGlobally": "Watcher is disabled globally in server settings",
+ "MessageXLibraryIsEmpty": "{0} Library is empty!",
+ "MessageYourAudiobookDurationIsLonger": "Your audiobook duration is longer than the duration found",
+ "MessageYourAudiobookDurationIsShorter": "Your audiobook duration is shorter than duration found",
+ "NoteChangeRootPassword": "Root user is the only user that can have an empty password",
+ "NoteChapterEditorTimes": "Note: First chapter start time must remain at 0:00 and the last chapter start time cannot exceed this audiobooks duration.",
+ "NoteFolderPicker": "Note: folders already mapped will not be shown",
+ "NoteFolderPickerDebian": "Note: Folder picker for the debian install is not fully implemented. You should enter the path to your library directly.",
+ "NoteRSSFeedPodcastAppsHttps": "Warning: Most podcast apps will require the RSS feed URL is using HTTPS",
+ "NoteRSSFeedPodcastAppsPubDate": "Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.",
+ "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.",
+ "PlaceholderNewCollection": "New collection name",
+ "PlaceholderNewFolderPath": "New folder path",
+ "PlaceholderNewPlaylist": "New playlist name",
+ "PlaceholderSearch": "Search..",
+ "PlaceholderSearchEpisode": "Search episode..",
+ "ToastAccountUpdateFailed": "Failed to update account",
+ "ToastAccountUpdateSuccess": "Account updated",
+ "ToastAuthorImageRemoveFailed": "Failed to remove image",
+ "ToastAuthorImageRemoveSuccess": "Author image removed",
+ "ToastAuthorUpdateFailed": "Failed to update author",
+ "ToastAuthorUpdateMerged": "Author merged",
+ "ToastAuthorUpdateSuccess": "Author updated",
+ "ToastAuthorUpdateSuccessNoImageFound": "Author updated (no image found)",
+ "ToastBackupCreateFailed": "Failed to create backup",
+ "ToastBackupCreateSuccess": "Backup created",
+ "ToastBackupDeleteFailed": "Failed to delete backup",
+ "ToastBackupDeleteSuccess": "Backup deleted",
+ "ToastBackupRestoreFailed": "Failed to restore backup",
+ "ToastBackupUploadFailed": "Failed to upload backup",
+ "ToastBackupUploadSuccess": "Backup uploaded",
+ "ToastBatchUpdateFailed": "Batch update failed",
+ "ToastBatchUpdateSuccess": "Batch update success",
+ "ToastBookmarkCreateFailed": "Failed to create bookmark",
+ "ToastBookmarkCreateSuccess": "Bookmark added",
+ "ToastBookmarkRemoveFailed": "Failed to remove bookmark",
+ "ToastBookmarkRemoveSuccess": "Bookmark removed",
+ "ToastBookmarkUpdateFailed": "Failed to update bookmark",
+ "ToastBookmarkUpdateSuccess": "Bookmark updated",
+ "ToastChaptersHaveErrors": "Chapters have errors",
+ "ToastChaptersMustHaveTitles": "Chapters must have titles",
+ "ToastCollectionItemsRemoveFailed": "Failed to remove item(s) from collection",
+ "ToastCollectionItemsRemoveSuccess": "Item(s) removed from collection",
+ "ToastCollectionRemoveFailed": "Failed to remove collection",
+ "ToastCollectionRemoveSuccess": "Collection removed",
+ "ToastCollectionUpdateFailed": "Failed to update collection",
+ "ToastCollectionUpdateSuccess": "Collection updated",
+ "ToastItemCoverUpdateFailed": "Failed to update item cover",
+ "ToastItemCoverUpdateSuccess": "Item cover updated",
+ "ToastItemDetailsUpdateFailed": "Failed to update item details",
+ "ToastItemDetailsUpdateSuccess": "Item details updated",
+ "ToastItemDetailsUpdateUnneeded": "No updates needed for item details",
+ "ToastItemMarkedAsFinishedFailed": "Failed to mark as Finished",
+ "ToastItemMarkedAsFinishedSuccess": "Item marked as Finished",
+ "ToastItemMarkedAsNotFinishedFailed": "Failed to mark as Not Finished",
+ "ToastItemMarkedAsNotFinishedSuccess": "Item marked as Not Finished",
+ "ToastLibraryCreateFailed": "Failed to create library",
+ "ToastLibraryCreateSuccess": "Library \"{0}\" created",
+ "ToastLibraryDeleteFailed": "Failed to delete library",
+ "ToastLibraryDeleteSuccess": "Library deleted",
+ "ToastLibraryScanFailedToStart": "Failed to start scan",
+ "ToastLibraryScanStarted": "Library scan started",
+ "ToastLibraryUpdateFailed": "Failed to update library",
+ "ToastLibraryUpdateSuccess": "Library \"{0}\" updated",
+ "ToastPlaylistCreateFailed": "Failed to create playlist",
+ "ToastPlaylistCreateSuccess": "Playlist created",
+ "ToastPlaylistRemoveFailed": "Failed to remove playlist",
+ "ToastPlaylistRemoveSuccess": "Playlist removed",
+ "ToastPlaylistUpdateFailed": "Failed to update playlist",
+ "ToastPlaylistUpdateSuccess": "Playlist updated",
+ "ToastPodcastCreateFailed": "Failed to create podcast",
+ "ToastPodcastCreateSuccess": "Podcast created successfully",
+ "ToastRemoveItemFromCollectionFailed": "Failed to remove item from collection",
+ "ToastRemoveItemFromCollectionSuccess": "Item removed from collection",
+ "ToastRSSFeedCloseFailed": "Failed to close RSS feed",
+ "ToastRSSFeedCloseSuccess": "RSS feed closed",
+ "ToastSeriesUpdateFailed": "Series update failed",
+ "ToastSeriesUpdateSuccess": "Series update success",
+ "ToastSessionDeleteFailed": "Failed to delete session",
+ "ToastSessionDeleteSuccess": "Session deleted",
+ "ToastSocketConnected": "Socket connected",
+ "ToastSocketDisconnected": "Socket disconnected",
+ "ToastSocketFailedToConnect": "Socket failed to connect",
+ "ToastUserDeleteFailed": "Failed to delete user",
+ "ToastUserDeleteSuccess": "User deleted"
+}
\ No newline at end of file
diff --git a/client/strings/hi.json b/client/strings/hi.json
new file mode 100644
index 00000000..928d9cf2
--- /dev/null
+++ b/client/strings/hi.json
@@ -0,0 +1,654 @@
+{
+ "ButtonAdd": "जोड़ें",
+ "ButtonAddChapters": "अध्याय जोड़ें",
+ "ButtonAddPodcasts": "पॉडकास्ट जोड़ें",
+ "ButtonAddYourFirstLibrary": "अपनी पहली पुस्तकालय जोड़ें",
+ "ButtonApply": "लागू करें",
+ "ButtonApplyChapters": "अध्यायों में परिवर्तन लागू करें",
+ "ButtonAuthors": "लेखक",
+ "ButtonBrowseForFolder": "फ़ोल्डर खोजें",
+ "ButtonCancel": "रद्द करें",
+ "ButtonCancelEncode": "एनकोड रद्द करें",
+ "ButtonChangeRootPassword": "रूट का पासवर्ड बदलें",
+ "ButtonCheckAndDownloadNewEpisodes": "नए एपिसोड खोजें और डाउनलोड करें",
+ "ButtonChooseAFolder": "एक फ़ोल्डर चुनें",
+ "ButtonChooseFiles": "फ़ाइलें चुनें",
+ "ButtonClearFilter": "लागू फ़िल्टर साफ़ करें",
+ "ButtonCloseFeed": "फ़ीड बंद करें",
+ "ButtonCollections": "संग्रह",
+ "ButtonConfigureScanner": "स्कैनर सेटिंग्स बदलें",
+ "ButtonCreate": "बनाएं",
+ "ButtonCreateBackup": "बैकअप लें",
+ "ButtonDelete": "हटाएं",
+ "ButtonDownloadQueue": "कतार डाउनलोड करें",
+ "ButtonEdit": "संपादित करें",
+ "ButtonEditChapters": "अध्याय संपादित करें",
+ "ButtonEditPodcast": "पॉडकास्ट संपादित करें",
+ "ButtonForceReScan": "बलपूर्वक पुन: स्कैन करें",
+ "ButtonFullPath": "पूर्ण पथ",
+ "ButtonHide": "छुपाएं",
+ "ButtonHome": "घर",
+ "ButtonIssues": "समस्याएं",
+ "ButtonLatest": "नवीनतम",
+ "ButtonLibrary": "पुस्तकालय",
+ "ButtonLogout": "लॉग आउट",
+ "ButtonLookup": "तलाश करें",
+ "ButtonManageTracks": "ट्रैक्स मैनेज करें",
+ "ButtonMapChapterTitles": "अध्यायों का मिलान करें",
+ "ButtonMatchAllAuthors": "सभी लेखकों को तलाश करें",
+ "ButtonMatchBooks": "संबंधित पुस्तकों का मिलान करें",
+ "ButtonNevermind": "कोई बात नहीं",
+ "ButtonOk": "ठीक है",
+ "ButtonOpenFeed": "फ़ीड खोलें",
+ "ButtonOpenManager": "मैनेजर खोलें",
+ "ButtonPlay": "चलाएँ",
+ "ButtonPlaying": "चल रही है",
+ "ButtonPlaylists": "प्लेलिस्ट्स",
+ "ButtonPurgeAllCache": "सभी Cache मिटाएं",
+ "ButtonPurgeItemsCache": "आइटम Cache मिटाएं",
+ "ButtonPurgeMediaProgress": "अभी तक सुना हुआ सब हटा दे",
+ "ButtonQueueAddItem": "क़तार में जोड़ें",
+ "ButtonQueueRemoveItem": "कतार से हटाएं",
+ "ButtonQuickMatch": "जल्दी से समानता की तलाश करें",
+ "ButtonRead": "पढ़ लिया",
+ "ButtonRemove": "हटाएं",
+ "ButtonRemoveAll": "सभी हटाएं",
+ "ButtonRemoveAllLibraryItems": "पुस्तकालय की सभी आइटम हटाएं",
+ "ButtonRemoveFromContinueListening": "सुनना जारी रखें से हटाएं",
+ "ButtonRemoveSeriesFromContinueSeries": "इस सीरीज को कंटिन्यू सीरीज से हटा दें",
+ "ButtonReScan": "पुन: स्कैन करें",
+ "ButtonReset": "रीसेट करें",
+ "ButtonRestore": "पुनर्स्थापित करें",
+ "ButtonSave": "सहेजें",
+ "ButtonSaveAndClose": "सहेजें और बंद करें",
+ "ButtonSaveTracklist": "ट्रैक सूची सहेजें",
+ "ButtonScan": "स्कैन करें",
+ "ButtonScanLibrary": "पुस्तकालय स्कैन करें",
+ "ButtonSearch": "खोजें",
+ "ButtonSelectFolderPath": "फ़ोल्डर का पथ चुनें",
+ "ButtonSeries": "सीरीज",
+ "ButtonSetChaptersFromTracks": "ट्रैक्स से अध्याय बनाएं",
+ "ButtonShiftTimes": "समय खिसकाए",
+ "ButtonShow": "दिखाएं",
+ "ButtonStartM4BEncode": "M4B एन्कोडिंग शुरू करें",
+ "ButtonStartMetadataEmbed": "मेटाडेटा एम्बेडिंग शुरू करें",
+ "ButtonSubmit": "जमा करें",
+ "ButtonUpload": "अपलोड करें",
+ "ButtonUploadBackup": "बैकअप अपलोड करें",
+ "ButtonUploadCover": "कवर अपलोड करें",
+ "ButtonUploadOPMLFile": "OPML फ़ाइल अपलोड करें",
+ "ButtonUserDelete": "उपयोगकर्ता {0} को हटाएं",
+ "ButtonUserEdit": "उपयोगकर्ता {0} को संपादित करें",
+ "ButtonViewAll": "सभी को देखें",
+ "ButtonYes": "हाँ",
+ "HeaderAccount": "खाता",
+ "HeaderAdvanced": "विकसित",
+ "HeaderAppriseNotificationSettings": "Apprise अधिसूचना सेटिंग्स",
+ "HeaderAudiobookTools": "Audiobook File Management Tools",
+ "HeaderAudioTracks": "Audio Tracks",
+ "HeaderBackups": "Backups",
+ "HeaderChangePassword": "Change Password",
+ "HeaderChapters": "Chapters",
+ "HeaderChooseAFolder": "Choose a Folder",
+ "HeaderCollection": "Collection",
+ "HeaderCollectionItems": "Collection Items",
+ "HeaderCover": "Cover",
+ "HeaderCurrentDownloads": "Current Downloads",
+ "HeaderDetails": "Details",
+ "HeaderDownloadQueue": "Download Queue",
+ "HeaderEpisodes": "Episodes",
+ "HeaderFiles": "Files",
+ "HeaderFindChapters": "Find Chapters",
+ "HeaderIgnoredFiles": "Ignored Files",
+ "HeaderItemFiles": "Item Files",
+ "HeaderItemMetadataUtils": "Item Metadata Utils",
+ "HeaderLastListeningSession": "Last Listening Session",
+ "HeaderLatestEpisodes": "Latest episodes",
+ "HeaderLibraries": "Libraries",
+ "HeaderLibraryFiles": "Library Files",
+ "HeaderLibraryStats": "Library Stats",
+ "HeaderListeningSessions": "Listening Sessions",
+ "HeaderListeningStats": "Listening Stats",
+ "HeaderLogin": "Login",
+ "HeaderLogs": "Logs",
+ "HeaderManageGenres": "Manage Genres",
+ "HeaderManageTags": "Manage Tags",
+ "HeaderMapDetails": "Map details",
+ "HeaderMatch": "Match",
+ "HeaderMetadataToEmbed": "Metadata to embed",
+ "HeaderNewAccount": "New Account",
+ "HeaderNewLibrary": "New Library",
+ "HeaderNotifications": "Notifications",
+ "HeaderOpenRSSFeed": "Open RSS Feed",
+ "HeaderOtherFiles": "Other Files",
+ "HeaderPermissions": "Permissions",
+ "HeaderPlayerQueue": "Player Queue",
+ "HeaderPlaylist": "Playlist",
+ "HeaderPlaylistItems": "Playlist Items",
+ "HeaderPodcastsToAdd": "Podcasts to Add",
+ "HeaderPreviewCover": "Preview Cover",
+ "HeaderRemoveEpisode": "Remove Episode",
+ "HeaderRemoveEpisodes": "Remove {0} Episodes",
+ "HeaderRSSFeedGeneral": "RSS Details",
+ "HeaderRSSFeedIsOpen": "RSS Feed is Open",
+ "HeaderSavedMediaProgress": "Saved Media Progress",
+ "HeaderSchedule": "Schedule",
+ "HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
+ "HeaderSession": "Session",
+ "HeaderSetBackupSchedule": "Set Backup Schedule",
+ "HeaderSettings": "Settings",
+ "HeaderSettingsDisplay": "Display",
+ "HeaderSettingsExperimental": "Experimental Features",
+ "HeaderSettingsGeneral": "General",
+ "HeaderSettingsScanner": "Scanner",
+ "HeaderSleepTimer": "Sleep Timer",
+ "HeaderStatsLargestItems": "Largest Items",
+ "HeaderStatsLongestItems": "Longest Items (hrs)",
+ "HeaderStatsMinutesListeningChart": "Minutes Listening (last 7 days)",
+ "HeaderStatsRecentSessions": "Recent Sessions",
+ "HeaderStatsTop10Authors": "Top 10 Authors",
+ "HeaderStatsTop5Genres": "Top 5 Genres",
+ "HeaderTools": "Tools",
+ "HeaderUpdateAccount": "Update Account",
+ "HeaderUpdateAuthor": "Update Author",
+ "HeaderUpdateDetails": "Update Details",
+ "HeaderUpdateLibrary": "Update Library",
+ "HeaderUsers": "Users",
+ "HeaderYourStats": "Your Stats",
+ "LabelAbridged": "Abridged",
+ "LabelAccountType": "Account Type",
+ "LabelAccountTypeAdmin": "Admin",
+ "LabelAccountTypeGuest": "Guest",
+ "LabelAccountTypeUser": "User",
+ "LabelActivity": "Activity",
+ "LabelAdded": "Added",
+ "LabelAddedAt": "Added At",
+ "LabelAddToCollection": "Add to Collection",
+ "LabelAddToCollectionBatch": "Add {0} Books to Collection",
+ "LabelAddToPlaylist": "Add to Playlist",
+ "LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
+ "LabelAll": "All",
+ "LabelAllUsers": "All Users",
+ "LabelAlreadyInYourLibrary": "Already in your library",
+ "LabelAppend": "Append",
+ "LabelAuthor": "Author",
+ "LabelAuthorFirstLast": "Author (First Last)",
+ "LabelAuthorLastFirst": "Author (Last, First)",
+ "LabelAuthors": "Authors",
+ "LabelAutoDownloadEpisodes": "Auto Download Episodes",
+ "LabelBackToUser": "Back to User",
+ "LabelBackupsEnableAutomaticBackups": "Enable automatic backups",
+ "LabelBackupsEnableAutomaticBackupsHelp": "Backups saved in /metadata/backups",
+ "LabelBackupsMaxBackupSize": "Maximum backup size (in GB)",
+ "LabelBackupsMaxBackupSizeHelp": "As a safeguard against misconfiguration, backups will fail if they exceed the configured size.",
+ "LabelBackupsNumberToKeep": "Number of backups to keep",
+ "LabelBackupsNumberToKeepHelp": "Only 1 backup will be removed at a time so if you already have more backups than this you should manually remove them.",
+ "LabelBitrate": "Bitrate",
+ "LabelBooks": "Books",
+ "LabelChangePassword": "Change Password",
+ "LabelChannels": "Channels",
+ "LabelChapters": "Chapters",
+ "LabelChaptersFound": "chapters found",
+ "LabelChapterTitle": "Chapter Title",
+ "LabelClosePlayer": "Close player",
+ "LabelCodec": "Codec",
+ "LabelCollapseSeries": "Collapse Series",
+ "LabelCollections": "Collections",
+ "LabelComplete": "Complete",
+ "LabelConfirmPassword": "Confirm Password",
+ "LabelContinueListening": "Continue Listening",
+ "LabelContinueSeries": "Continue Series",
+ "LabelCover": "Cover",
+ "LabelCoverImageURL": "Cover Image URL",
+ "LabelCreatedAt": "Created At",
+ "LabelCronExpression": "Cron Expression",
+ "LabelCurrent": "Current",
+ "LabelCurrently": "Currently:",
+ "LabelCustomCronExpression": "Custom Cron Expression:",
+ "LabelDatetime": "Datetime",
+ "LabelDescription": "Description",
+ "LabelDeselectAll": "Deselect All",
+ "LabelDevice": "Device",
+ "LabelDeviceInfo": "Device Info",
+ "LabelDirectory": "Directory",
+ "LabelDiscFromFilename": "Disc from Filename",
+ "LabelDiscFromMetadata": "Disc from Metadata",
+ "LabelDownload": "Download",
+ "LabelDuration": "Duration",
+ "LabelDurationFound": "Duration found:",
+ "LabelEdit": "Edit",
+ "LabelEmbeddedCover": "Embedded Cover",
+ "LabelEnable": "Enable",
+ "LabelEnd": "End",
+ "LabelEpisode": "Episode",
+ "LabelEpisodeTitle": "Episode Title",
+ "LabelEpisodeType": "Episode Type",
+ "LabelExample": "Example",
+ "LabelExplicit": "Explicit",
+ "LabelFeedURL": "Feed URL",
+ "LabelFile": "File",
+ "LabelFileBirthtime": "File Birthtime",
+ "LabelFileModified": "File Modified",
+ "LabelFilename": "Filename",
+ "LabelFilterByUser": "Filter by User",
+ "LabelFindEpisodes": "Find Episodes",
+ "LabelFinished": "Finished",
+ "LabelFolder": "Folder",
+ "LabelFolders": "Folders",
+ "LabelFormat": "Format",
+ "LabelGenre": "Genre",
+ "LabelGenres": "Genres",
+ "LabelHardDeleteFile": "Hard delete file",
+ "LabelHour": "Hour",
+ "LabelIcon": "Icon",
+ "LabelIncludeInTracklist": "Include in Tracklist",
+ "LabelIncomplete": "Incomplete",
+ "LabelInProgress": "In Progress",
+ "LabelInterval": "Interval",
+ "LabelIntervalCustomDailyWeekly": "Custom daily/weekly",
+ "LabelIntervalEvery12Hours": "Every 12 hours",
+ "LabelIntervalEvery15Minutes": "Every 15 minutes",
+ "LabelIntervalEvery2Hours": "Every 2 hours",
+ "LabelIntervalEvery30Minutes": "Every 30 minutes",
+ "LabelIntervalEvery6Hours": "Every 6 hours",
+ "LabelIntervalEveryDay": "Every day",
+ "LabelIntervalEveryHour": "Every hour",
+ "LabelInvalidParts": "Invalid Parts",
+ "LabelItem": "Item",
+ "LabelLanguage": "Language",
+ "LabelLanguageDefaultServer": "Default Server Language",
+ "LabelLastBookAdded": "Last Book Added",
+ "LabelLastBookUpdated": "Last Book Updated",
+ "LabelLastSeen": "Last Seen",
+ "LabelLastTime": "Last Time",
+ "LabelLastUpdate": "Last Update",
+ "LabelLess": "Less",
+ "LabelLibrariesAccessibleToUser": "Libraries Accessible to User",
+ "LabelLibrary": "Library",
+ "LabelLibraryItem": "Library Item",
+ "LabelLibraryName": "Library Name",
+ "LabelLimit": "Limit",
+ "LabelListenAgain": "Listen Again",
+ "LabelLogLevelDebug": "Debug",
+ "LabelLogLevelInfo": "Info",
+ "LabelLogLevelWarn": "Warn",
+ "LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date",
+ "LabelMediaPlayer": "Media Player",
+ "LabelMediaType": "Media Type",
+ "LabelMetadataProvider": "Metadata Provider",
+ "LabelMetaTag": "Meta Tag",
+ "LabelMetaTags": "Meta Tags",
+ "LabelMinute": "Minute",
+ "LabelMissing": "Missing",
+ "LabelMissingParts": "Missing Parts",
+ "LabelMore": "More",
+ "LabelMoreInfo": "More Info",
+ "LabelName": "Name",
+ "LabelNarrator": "Narrator",
+ "LabelNarrators": "Narrators",
+ "LabelNew": "New",
+ "LabelNewestAuthors": "Newest Authors",
+ "LabelNewestEpisodes": "Newest Episodes",
+ "LabelNewPassword": "New Password",
+ "LabelNextBackupDate": "Next backup date",
+ "LabelNextScheduledRun": "Next scheduled run",
+ "LabelNotes": "Notes",
+ "LabelNotFinished": "Not Finished",
+ "LabelNotificationAppriseURL": "Apprise URL(s)",
+ "LabelNotificationAvailableVariables": "Available variables",
+ "LabelNotificationBodyTemplate": "Body Template",
+ "LabelNotificationEvent": "Notification Event",
+ "LabelNotificationsMaxFailedAttempts": "Max failed attempts",
+ "LabelNotificationsMaxFailedAttemptsHelp": "Notifications are disabled once they fail to send this many times",
+ "LabelNotificationsMaxQueueSize": "Max queue size for notification events",
+ "LabelNotificationsMaxQueueSizeHelp": "Events are limited to firing 1 per second. Events will be ignored if the queue is at max size. This prevents notification spamming.",
+ "LabelNotificationTitleTemplate": "Title Template",
+ "LabelNotStarted": "Not Started",
+ "LabelNumberOfBooks": "Number of Books",
+ "LabelNumberOfEpisodes": "# of Episodes",
+ "LabelOpenRSSFeed": "Open RSS Feed",
+ "LabelOverwrite": "Overwrite",
+ "LabelPassword": "Password",
+ "LabelPath": "Path",
+ "LabelPermissionsAccessAllLibraries": "Can Access All Libraries",
+ "LabelPermissionsAccessAllTags": "Can Access All Tags",
+ "LabelPermissionsAccessExplicitContent": "Can Access Explicit Content",
+ "LabelPermissionsDelete": "Can Delete",
+ "LabelPermissionsDownload": "Can Download",
+ "LabelPermissionsUpdate": "Can Update",
+ "LabelPermissionsUpload": "Can Upload",
+ "LabelPhotoPathURL": "Photo Path/URL",
+ "LabelPlaylists": "Playlists",
+ "LabelPlayMethod": "Play Method",
+ "LabelPodcast": "Podcast",
+ "LabelPodcasts": "Podcasts",
+ "LabelPodcastType": "Podcast Type",
+ "LabelPrefixesToIgnore": "Prefixes to Ignore (case insensitive)",
+ "LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
+ "LabelProgress": "Progress",
+ "LabelProvider": "Provider",
+ "LabelPubDate": "Pub Date",
+ "LabelPublisher": "Publisher",
+ "LabelPublishYear": "Publish Year",
+ "LabelRecentlyAdded": "Recently Added",
+ "LabelRecentSeries": "Recent Series",
+ "LabelRecommended": "Recommended",
+ "LabelRegion": "Region",
+ "LabelReleaseDate": "Release Date",
+ "LabelRemoveCover": "Remove cover",
+ "LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
+ "LabelRSSFeedCustomOwnerName": "Custom owner Name",
+ "LabelRSSFeedOpen": "RSS Feed Open",
+ "LabelRSSFeedPreventIndexing": "Prevent Indexing",
+ "LabelRSSFeedSlug": "RSS Feed Slug",
+ "LabelRSSFeedURL": "RSS Feed URL",
+ "LabelSearchTerm": "Search Term",
+ "LabelSearchTitle": "Search Title",
+ "LabelSearchTitleOrASIN": "Search Title or ASIN",
+ "LabelSeason": "Season",
+ "LabelSequence": "Sequence",
+ "LabelSeries": "Series",
+ "LabelSeriesName": "Series Name",
+ "LabelSeriesProgress": "Series Progress",
+ "LabelSettingsBookshelfViewHelp": "Skeumorphic design with wooden shelves",
+ "LabelSettingsChromecastSupport": "Chromecast support",
+ "LabelSettingsDateFormat": "Date Format",
+ "LabelSettingsDisableWatcher": "Disable Watcher",
+ "LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library",
+ "LabelSettingsDisableWatcherHelp": "Disables the automatic adding/updating of items when file changes are detected. *Requires server restart",
+ "LabelSettingsEnableEReader": "Enable e-reader for all users",
+ "LabelSettingsEnableEReaderHelp": "E-reader is still a work in progress, but use this setting to open it up to all your users (or use the \"Experimental Features\" toggle just for use by you)",
+ "LabelSettingsExperimentalFeatures": "Experimental features",
+ "LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
+ "LabelSettingsFindCovers": "Find covers",
+ "LabelSettingsFindCoversHelp": "If your audiobook does not have an embedded cover or a cover image inside the folder, the scanner will attempt to find a cover. Note: This will extend scan time",
+ "LabelSettingsHomePageBookshelfView": "Home page use bookshelf view",
+ "LabelSettingsLibraryBookshelfView": "Library use bookshelf view",
+ "LabelSettingsOverdriveMediaMarkers": "Use Overdrive Media Markers for chapters",
+ "LabelSettingsOverdriveMediaMarkersHelp": "MP3 files from Overdrive come with chapter timings embedded as custom metadata. Enabling this will use these tags for chapter timings automatically",
+ "LabelSettingsParseSubtitles": "Parse subtitles",
+ "LabelSettingsParseSubtitlesHelp": "Extract subtitles from audiobook folder names. Subtitle must be seperated by \" - \" i.e. \"Book Title - A Subtitle Here\" has the subtitle \"A Subtitle Here\"",
+ "LabelSettingsPreferAudioMetadata": "Prefer audio metadata",
+ "LabelSettingsPreferAudioMetadataHelp": "Audio file ID3 meta tags will be used for book details over folder names",
+ "LabelSettingsPreferMatchedMetadata": "Prefer matched metadata",
+ "LabelSettingsPreferMatchedMetadataHelp": "Matched data will overide item details when using Quick Match. By default Quick Match will only fill in missing details.",
+ "LabelSettingsPreferOPFMetadata": "Prefer OPF metadata",
+ "LabelSettingsPreferOPFMetadataHelp": "OPF file metadata will be used for book details over folder names",
+ "LabelSettingsSkipMatchingBooksWithASIN": "Skip matching books that already have an ASIN",
+ "LabelSettingsSkipMatchingBooksWithISBN": "Skip matching books that already have an ISBN",
+ "LabelSettingsSortingIgnorePrefixes": "Ignore prefixes when sorting",
+ "LabelSettingsSortingIgnorePrefixesHelp": "i.e. for prefix \"the\" book title \"The Book Title\" would sort as \"Book Title, The\"",
+ "LabelSettingsSquareBookCovers": "Use square book covers",
+ "LabelSettingsSquareBookCoversHelp": "Prefer to use square covers over standard 1.6:1 book covers",
+ "LabelSettingsStoreCoversWithItem": "Store covers with item",
+ "LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept",
+ "LabelSettingsStoreMetadataWithItem": "Store metadata with item",
+ "LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension",
+ "LabelSettingsTimeFormat": "Time Format",
+ "LabelShowAll": "Show All",
+ "LabelSize": "Size",
+ "LabelSleepTimer": "Sleep timer",
+ "LabelStart": "Start",
+ "LabelStarted": "Started",
+ "LabelStartedAt": "Started At",
+ "LabelStartTime": "Start Time",
+ "LabelStatsAudioTracks": "Audio Tracks",
+ "LabelStatsAuthors": "Authors",
+ "LabelStatsBestDay": "Best Day",
+ "LabelStatsDailyAverage": "Daily Average",
+ "LabelStatsDays": "Days",
+ "LabelStatsDaysListened": "Days Listened",
+ "LabelStatsHours": "Hours",
+ "LabelStatsInARow": "in a row",
+ "LabelStatsItemsFinished": "Items Finished",
+ "LabelStatsItemsInLibrary": "Items in Library",
+ "LabelStatsMinutes": "minutes",
+ "LabelStatsMinutesListening": "Minutes Listening",
+ "LabelStatsOverallDays": "Overall Days",
+ "LabelStatsOverallHours": "Overall Hours",
+ "LabelStatsWeekListening": "Week Listening",
+ "LabelSubtitle": "Subtitle",
+ "LabelSupportedFileTypes": "Supported File Types",
+ "LabelTag": "Tag",
+ "LabelTags": "Tags",
+ "LabelTagsAccessibleToUser": "Tags Accessible to User",
+ "LabelTasks": "Tasks Running",
+ "LabelTimeBase": "Time Base",
+ "LabelTimeListened": "Time Listened",
+ "LabelTimeListenedToday": "Time Listened Today",
+ "LabelTimeRemaining": "{0} remaining",
+ "LabelTimeToShift": "Time to shift in seconds",
+ "LabelTitle": "Title",
+ "LabelToolsEmbedMetadata": "Embed Metadata",
+ "LabelToolsEmbedMetadataDescription": "Embed metadata into audio files including cover image and chapters.",
+ "LabelToolsMakeM4b": "Make M4B Audiobook File",
+ "LabelToolsMakeM4bDescription": "Generate a .M4B audiobook file with embedded metadata, cover image, and chapters.",
+ "LabelToolsSplitM4b": "Split M4B to MP3's",
+ "LabelToolsSplitM4bDescription": "Create MP3's from an M4B split by chapters with embedded metadata, cover image, and chapters.",
+ "LabelTotalDuration": "Total Duration",
+ "LabelTotalTimeListened": "Total Time Listened",
+ "LabelTrackFromFilename": "Track from Filename",
+ "LabelTrackFromMetadata": "Track from Metadata",
+ "LabelTracks": "Tracks",
+ "LabelTracksMultiTrack": "Multi-track",
+ "LabelTracksSingleTrack": "Single-track",
+ "LabelType": "Type",
+ "LabelUnabridged": "Unabridged",
+ "LabelUnknown": "Unknown",
+ "LabelUpdateCover": "Update Cover",
+ "LabelUpdateCoverHelp": "Allow overwriting of existing covers for the selected books when a match is located",
+ "LabelUpdatedAt": "Updated At",
+ "LabelUpdateDetails": "Update Details",
+ "LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located",
+ "LabelUploaderDragAndDrop": "Drag & drop files or folders",
+ "LabelUploaderDropFiles": "Drop files",
+ "LabelUseChapterTrack": "Use chapter track",
+ "LabelUseFullTrack": "Use full track",
+ "LabelUser": "User",
+ "LabelUsername": "Username",
+ "LabelValue": "Value",
+ "LabelVersion": "Version",
+ "LabelViewBookmarks": "View bookmarks",
+ "LabelViewChapters": "View chapters",
+ "LabelViewQueue": "View player queue",
+ "LabelVolume": "Volume",
+ "LabelWeekdaysToRun": "Weekdays to run",
+ "LabelYourAudiobookDuration": "Your audiobook duration",
+ "LabelYourBookmarks": "Your Bookmarks",
+ "LabelYourPlaylists": "Your Playlists",
+ "LabelYourProgress": "Your Progress",
+ "MessageAddToPlayerQueue": "Add to player queue",
+ "MessageAppriseDescription": "To use this feature you will need to have an instance of Apprise API running or an api that will handle those same requests. The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at http://192.168.1.1:8337 then you would put http://192.168.1.1:8337/notify.",
+ "MessageBackupsDescription": "Backups include users, user progress, library item details, server settings, and images stored in /metadata/items & /metadata/authors. Backups do not include any files stored in your library folders.",
+ "MessageBatchQuickMatchDescription": "Quick Match will attempt to add missing covers and metadata for the selected items. Enable the options below to allow Quick Match to overwrite existing covers and/or metadata.",
+ "MessageBookshelfNoCollections": "You haven't made any collections yet",
+ "MessageBookshelfNoResultsForFilter": "No Results for filter \"{0}: {1}\"",
+ "MessageBookshelfNoRSSFeeds": "No RSS feeds are open",
+ "MessageBookshelfNoSeries": "You have no series",
+ "MessageChapterEndIsAfter": "Chapter end is after the end of your audiobook",
+ "MessageChapterErrorFirstNotZero": "First chapter must start at 0",
+ "MessageChapterErrorStartGteDuration": "Invalid start time must be less than audiobook duration",
+ "MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
+ "MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook",
+ "MessageCheckingCron": "Checking cron...",
+ "MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
+ "MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",
+ "MessageConfirmDeleteSession": "Are you sure you want to delete this session?",
+ "MessageConfirmForceReScan": "Are you sure you want to force re-scan?",
+ "MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
+ "MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
+ "MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
+ "MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?",
+ "MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
+ "MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
+ "MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
+ "MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
+ "MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",
+ "MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".",
+ "MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?",
+ "MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
+ "MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".",
+ "MessageDownloadingEpisode": "Downloading episode",
+ "MessageDragFilesIntoTrackOrder": "Drag files into correct track order",
+ "MessageEmbedFinished": "Embed Finished!",
+ "MessageEpisodesQueuedForDownload": "{0} Episode(s) queued for download",
+ "MessageFeedURLWillBe": "Feed URL will be {0}",
+ "MessageFetching": "Fetching...",
+ "MessageForceReScanDescription": "will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be scanned as new.",
+ "MessageImportantNotice": "Important Notice!",
+ "MessageInsertChapterBelow": "Insert chapter below",
+ "MessageItemsSelected": "{0} Items Selected",
+ "MessageItemsUpdated": "{0} Items Updated",
+ "MessageJoinUsOn": "Join us on",
+ "MessageListeningSessionsInTheLastYear": "{0} listening sessions in the last year",
+ "MessageLoading": "Loading...",
+ "MessageLoadingFolders": "Loading folders...",
+ "MessageM4BFailed": "M4B Failed!",
+ "MessageM4BFinished": "M4B Finished!",
+ "MessageMapChapterTitles": "Map chapter titles to your existing audiobook chapters without adjusting timestamps",
+ "MessageMarkAsFinished": "Mark as Finished",
+ "MessageMarkAsNotFinished": "Mark as Not Finished",
+ "MessageMatchBooksDescription": "will attempt to match books in the library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.",
+ "MessageNoAudioTracks": "No audio tracks",
+ "MessageNoAuthors": "No Authors",
+ "MessageNoBackups": "No Backups",
+ "MessageNoBookmarks": "No Bookmarks",
+ "MessageNoChapters": "No Chapters",
+ "MessageNoCollections": "No Collections",
+ "MessageNoCoversFound": "No Covers Found",
+ "MessageNoDescription": "No description",
+ "MessageNoDownloadsInProgress": "No downloads currently in progress",
+ "MessageNoDownloadsQueued": "No downloads queued",
+ "MessageNoEpisodeMatchesFound": "No episode matches found",
+ "MessageNoEpisodes": "No Episodes",
+ "MessageNoFoldersAvailable": "No Folders Available",
+ "MessageNoGenres": "No Genres",
+ "MessageNoIssues": "No Issues",
+ "MessageNoItems": "No Items",
+ "MessageNoItemsFound": "No items found",
+ "MessageNoListeningSessions": "No Listening Sessions",
+ "MessageNoLogs": "No Logs",
+ "MessageNoMediaProgress": "No Media Progress",
+ "MessageNoNotifications": "No Notifications",
+ "MessageNoPodcastsFound": "No podcasts found",
+ "MessageNoResults": "No Results",
+ "MessageNoSearchResultsFor": "No search results for \"{0}\"",
+ "MessageNoSeries": "No Series",
+ "MessageNoTags": "No Tags",
+ "MessageNoTasksRunning": "No Tasks Running",
+ "MessageNotYetImplemented": "Not yet implemented",
+ "MessageNoUpdateNecessary": "No update necessary",
+ "MessageNoUpdatesWereNecessary": "No updates were necessary",
+ "MessageNoUserPlaylists": "You have no playlists",
+ "MessageOr": "or",
+ "MessagePauseChapter": "Pause chapter playback",
+ "MessagePlayChapter": "Listen to beginning of chapter",
+ "MessagePlaylistCreateFromCollection": "Create playlist from collection",
+ "MessagePodcastHasNoRSSFeedForMatching": "Podcast has no RSS feed url to use for matching",
+ "MessageQuickMatchDescription": "Populate empty item details & cover with first match result from '{0}'. Does not overwrite details unless 'Prefer matched metadata' server setting is enabled.",
+ "MessageRemoveAllItemsWarning": "WARNING! This action will remove all library items from the database including any updates or matches you have made. This does not do anything to your actual files. Are you sure?",
+ "MessageRemoveChapter": "Remove chapter",
+ "MessageRemoveEpisodes": "Remove {0} episode(s)",
+ "MessageRemoveFromPlayerQueue": "Remove from player queue",
+ "MessageRemoveUserWarning": "Are you sure you want to permanently delete user \"{0}\"?",
+ "MessageReportBugsAndContribute": "Report bugs, request features, and contribute on",
+ "MessageResetChaptersConfirm": "Are you sure you want to reset chapters and undo the changes you made?",
+ "MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on",
+ "MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.
Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.
All clients using your server will be automatically refreshed.",
+ "MessageSearchResultsFor": "Search results for",
+ "MessageServerCouldNotBeReached": "Server could not be reached",
+ "MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
+ "MessageStartPlaybackAtTime": "Start playback for \"{0}\" at {1}?",
+ "MessageThinking": "Thinking...",
+ "MessageUploaderItemFailed": "Failed to upload",
+ "MessageUploaderItemSuccess": "Successfully Uploaded!",
+ "MessageUploading": "Uploading...",
+ "MessageValidCronExpression": "Valid cron expression",
+ "MessageWatcherIsDisabledGlobally": "Watcher is disabled globally in server settings",
+ "MessageXLibraryIsEmpty": "{0} Library is empty!",
+ "MessageYourAudiobookDurationIsLonger": "Your audiobook duration is longer than the duration found",
+ "MessageYourAudiobookDurationIsShorter": "Your audiobook duration is shorter than duration found",
+ "NoteChangeRootPassword": "रूट user is the only user that can have an empty password",
+ "NoteChapterEditorTimes": "Note: First chapter start time must remain at 0:00 and the last chapter start time cannot exceed this audiobooks duration.",
+ "NoteFolderPicker": "Note: folders already mapped will not be shown",
+ "NoteFolderPickerDebian": "Note: Folder picker for the debian install is not fully implemented. You should enter the path to your library directly.",
+ "NoteRSSFeedPodcastAppsHttps": "Warning: Most podcast apps will require the RSS feed URL is using HTTPS",
+ "NoteRSSFeedPodcastAppsPubDate": "Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.",
+ "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.",
+ "PlaceholderNewCollection": "New collection name",
+ "PlaceholderNewFolderPath": "New folder path",
+ "PlaceholderNewPlaylist": "New playlist name",
+ "PlaceholderSearch": "Search..",
+ "PlaceholderSearchEpisode": "Search episode..",
+ "ToastAccountUpdateFailed": "Failed to update account",
+ "ToastAccountUpdateSuccess": "Account updated",
+ "ToastAuthorImageRemoveFailed": "Failed to remove image",
+ "ToastAuthorImageRemoveSuccess": "Author image removed",
+ "ToastAuthorUpdateFailed": "Failed to update author",
+ "ToastAuthorUpdateMerged": "Author merged",
+ "ToastAuthorUpdateSuccess": "Author updated",
+ "ToastAuthorUpdateSuccessNoImageFound": "Author updated (no image found)",
+ "ToastBackupCreateFailed": "Failed to create backup",
+ "ToastBackupCreateSuccess": "Backup created",
+ "ToastBackupDeleteFailed": "Failed to delete backup",
+ "ToastBackupDeleteSuccess": "Backup deleted",
+ "ToastBackupRestoreFailed": "Failed to restore backup",
+ "ToastBackupUploadFailed": "Failed to upload backup",
+ "ToastBackupUploadSuccess": "Backup uploaded",
+ "ToastBatchUpdateFailed": "Batch update failed",
+ "ToastBatchUpdateSuccess": "Batch update success",
+ "ToastBookmarkCreateFailed": "Failed to create bookmark",
+ "ToastBookmarkCreateSuccess": "Bookmark added",
+ "ToastBookmarkRemoveFailed": "Failed to remove bookmark",
+ "ToastBookmarkRemoveSuccess": "Bookmark removed",
+ "ToastBookmarkUpdateFailed": "Failed to update bookmark",
+ "ToastBookmarkUpdateSuccess": "Bookmark updated",
+ "ToastChaptersHaveErrors": "Chapters have errors",
+ "ToastChaptersMustHaveTitles": "Chapters must have titles",
+ "ToastCollectionItemsRemoveFailed": "Failed to remove item(s) from collection",
+ "ToastCollectionItemsRemoveSuccess": "Item(s) removed from collection",
+ "ToastCollectionRemoveFailed": "Failed to remove collection",
+ "ToastCollectionRemoveSuccess": "Collection removed",
+ "ToastCollectionUpdateFailed": "Failed to update collection",
+ "ToastCollectionUpdateSuccess": "Collection updated",
+ "ToastItemCoverUpdateFailed": "Failed to update item cover",
+ "ToastItemCoverUpdateSuccess": "Item cover updated",
+ "ToastItemDetailsUpdateFailed": "Failed to update item details",
+ "ToastItemDetailsUpdateSuccess": "Item details updated",
+ "ToastItemDetailsUpdateUnneeded": "No updates needed for item details",
+ "ToastItemMarkedAsFinishedFailed": "Failed to mark as Finished",
+ "ToastItemMarkedAsFinishedSuccess": "Item marked as Finished",
+ "ToastItemMarkedAsNotFinishedFailed": "Failed to mark as Not Finished",
+ "ToastItemMarkedAsNotFinishedSuccess": "Item marked as Not Finished",
+ "ToastLibraryCreateFailed": "Failed to create library",
+ "ToastLibraryCreateSuccess": "Library \"{0}\" created",
+ "ToastLibraryDeleteFailed": "Failed to delete library",
+ "ToastLibraryDeleteSuccess": "Library deleted",
+ "ToastLibraryScanFailedToStart": "Failed to start scan",
+ "ToastLibraryScanStarted": "Library scan started",
+ "ToastLibraryUpdateFailed": "Failed to update library",
+ "ToastLibraryUpdateSuccess": "Library \"{0}\" updated",
+ "ToastPlaylistCreateFailed": "Failed to create playlist",
+ "ToastPlaylistCreateSuccess": "Playlist created",
+ "ToastPlaylistRemoveFailed": "Failed to remove playlist",
+ "ToastPlaylistRemoveSuccess": "Playlist removed",
+ "ToastPlaylistUpdateFailed": "Failed to update playlist",
+ "ToastPlaylistUpdateSuccess": "Playlist updated",
+ "ToastPodcastCreateFailed": "Failed to create podcast",
+ "ToastPodcastCreateSuccess": "Podcast created successfully",
+ "ToastRemoveItemFromCollectionFailed": "Failed to remove item from collection",
+ "ToastRemoveItemFromCollectionSuccess": "Item removed from collection",
+ "ToastRSSFeedCloseFailed": "Failed to close RSS feed",
+ "ToastRSSFeedCloseSuccess": "RSS feed closed",
+ "ToastSeriesUpdateFailed": "Series update failed",
+ "ToastSeriesUpdateSuccess": "Series update success",
+ "ToastSessionDeleteFailed": "Failed to delete session",
+ "ToastSessionDeleteSuccess": "Session deleted",
+ "ToastSocketConnected": "Socket connected",
+ "ToastSocketDisconnected": "Socket disconnected",
+ "ToastSocketFailedToConnect": "Socket failed to connect",
+ "ToastUserDeleteFailed": "Failed to delete user",
+ "ToastUserDeleteSuccess": "User deleted"
+}
\ No newline at end of file
diff --git a/client/strings/hr.json b/client/strings/hr.json
index 3b9a5d75..45fffdf4 100644
--- a/client/strings/hr.json
+++ b/client/strings/hr.json
@@ -161,6 +161,7 @@
"LabelAccountTypeGuest": "Gost",
"LabelAccountTypeUser": "Korisnik",
"LabelActivity": "Aktivnost",
+ "LabelAdded": "Added",
"LabelAddedAt": "Added At",
"LabelAddToCollection": "Dodaj u kolekciju",
"LabelAddToCollectionBatch": "Add {0} Books to Collection",
@@ -182,11 +183,15 @@
"LabelBackupsMaxBackupSizeHelp": "As a safeguard against misconfiguration, backups will fail if they exceed the configured size.",
"LabelBackupsNumberToKeep": "Broj backupa zadržati",
"LabelBackupsNumberToKeepHelp": "Samo 1 backup će biti odjednom obrisan. Ako koristite više njih, morati ćete ih ručno ukloniti.",
+ "LabelBitrate": "Bitrate",
"LabelBooks": "Knjige",
"LabelChangePassword": "Promijeni lozinku",
+ "LabelChannels": "Channels",
+ "LabelChapters": "Chapters",
"LabelChaptersFound": "poglavlja pronađena",
"LabelChapterTitle": "Ime poglavlja",
"LabelClosePlayer": "Close player",
+ "LabelCodec": "Codec",
"LabelCollapseSeries": "Collapse Series",
"LabelCollections": "Kolekcije",
"LabelComplete": "Complete",
@@ -212,6 +217,7 @@
"LabelDuration": "Trajanje",
"LabelDurationFound": "Pronađeno trajanje:",
"LabelEdit": "Uredi",
+ "LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Uključi",
"LabelEnd": "Kraj",
"LabelEpisode": "Epizoda",
@@ -229,6 +235,7 @@
"LabelFinished": "Finished",
"LabelFolder": "Folder",
"LabelFolders": "Folderi",
+ "LabelFormat": "Format",
"LabelGenre": "Genre",
"LabelGenres": "Žanrovi",
"LabelHardDeleteFile": "Obriši datoteku zauvijek",
@@ -250,6 +257,8 @@
"LabelItem": "Stavka",
"LabelLanguage": "Jezik",
"LabelLanguageDefaultServer": "Default jezik servera",
+ "LabelLastBookAdded": "Last Book Added",
+ "LabelLastBookUpdated": "Last Book Updated",
"LabelLastSeen": "Zadnje pogledano",
"LabelLastTime": "Prošli put",
"LabelLastUpdate": "Zadnja aktualizacija",
@@ -268,10 +277,12 @@
"LabelMediaType": "Media Type",
"LabelMetadataProvider": "Poslužitelj metapodataka ",
"LabelMetaTag": "Meta Tag",
+ "LabelMetaTags": "Meta Tags",
"LabelMinute": "Minuta",
"LabelMissing": "Nedostaje",
"LabelMissingParts": "Nedostajali dijelovi",
"LabelMore": "Više",
+ "LabelMoreInfo": "More Info",
"LabelName": "Ime",
"LabelNarrator": "Narrator",
"LabelNarrators": "Naratori",
@@ -402,6 +413,7 @@
"LabelTags": "Tags",
"LabelTagsAccessibleToUser": "Tags dostupni korisniku",
"LabelTasks": "Tasks Running",
+ "LabelTimeBase": "Time Base",
"LabelTimeListened": "Vremena odslušano",
"LabelTimeListenedToday": "Vremena odslušano danas",
"LabelTimeRemaining": "{0} preostalo",
@@ -465,6 +477,7 @@
"MessageConfirmForceReScan": "Jeste li sigurni da želite ponovno skenirati?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
+ "MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
"MessageConfirmRemoveCollection": "AJeste li sigurni da želite obrisati kolekciju \"{0}\"?",
"MessageConfirmRemoveEpisode": "Jeste li sigurni da želite obrisati epizodu \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Jeste li sigurni da želite obrisati {0} epizoda/-u?",
diff --git a/client/strings/it.json b/client/strings/it.json
index c44dc48a..6ca8ae99 100644
--- a/client/strings/it.json
+++ b/client/strings/it.json
@@ -161,6 +161,7 @@
"LabelAccountTypeGuest": "Ospite",
"LabelAccountTypeUser": "Utente",
"LabelActivity": "Attività",
+ "LabelAdded": "Added",
"LabelAddedAt": "Aggiunto il",
"LabelAddToCollection": "Aggiungi alla Raccolta",
"LabelAddToCollectionBatch": "Aggiungi {0} Libri alla Raccolta",
@@ -182,11 +183,15 @@
"LabelBackupsMaxBackupSizeHelp": "Come protezione contro gli errori di config, i backup falliranno se superano la dimensione configurata.",
"LabelBackupsNumberToKeep": "Numero di backup da mantenere",
"LabelBackupsNumberToKeepHelp": "Verrà rimosso solo 1 backup alla volta, quindi se hai più backup, dovrai rimuoverli manualmente.",
+ "LabelBitrate": "Bitrate",
"LabelBooks": "Libri",
"LabelChangePassword": "Cambia Password",
+ "LabelChannels": "Channels",
+ "LabelChapters": "Chapters",
"LabelChaptersFound": "Capitoli Trovati",
"LabelChapterTitle": "Titoli dei Capitoli",
"LabelClosePlayer": "Chiudi player",
+ "LabelCodec": "Codec",
"LabelCollapseSeries": "Comprimi Serie",
"LabelCollections": "Raccolte",
"LabelComplete": "Completo",
@@ -212,6 +217,7 @@
"LabelDuration": "Durata",
"LabelDurationFound": "Durata Trovata:",
"LabelEdit": "Modifica",
+ "LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Abilita",
"LabelEnd": "Fine",
"LabelEpisode": "Episodio",
@@ -229,6 +235,7 @@
"LabelFinished": "Finita",
"LabelFolder": "Cartella",
"LabelFolders": "Cartelle",
+ "LabelFormat": "Format",
"LabelGenre": "Genere",
"LabelGenres": "Generi",
"LabelHardDeleteFile": "Elimina Definitivamente",
@@ -250,6 +257,8 @@
"LabelItem": "Oggetti",
"LabelLanguage": "Lingua",
"LabelLanguageDefaultServer": "Lingua di Default",
+ "LabelLastBookAdded": "Last Book Added",
+ "LabelLastBookUpdated": "Last Book Updated",
"LabelLastSeen": "Ultimi Visti",
"LabelLastTime": "Ultima Volta",
"LabelLastUpdate": "Ultimo Aggiornamento",
@@ -268,10 +277,12 @@
"LabelMediaType": "Tipo Media",
"LabelMetadataProvider": "Metadata Provider",
"LabelMetaTag": "Meta Tag",
+ "LabelMetaTags": "Meta Tags",
"LabelMinute": "Minuto",
"LabelMissing": "Altro",
"LabelMissingParts": "Parti rimantenti",
"LabelMore": "Molto",
+ "LabelMoreInfo": "More Info",
"LabelName": "Nome",
"LabelNarrator": "Narratore",
"LabelNarrators": "Narratori",
@@ -402,6 +413,7 @@
"LabelTags": "Tags",
"LabelTagsAccessibleToUser": "Tags permessi agli Utenti",
"LabelTasks": "Processi in esecuzione",
+ "LabelTimeBase": "Time Base",
"LabelTimeListened": "Tempo di Ascolto",
"LabelTimeListenedToday": "Tempo di Ascolto Oggi",
"LabelTimeRemaining": "{0} rimanente",
@@ -465,6 +477,7 @@
"MessageConfirmForceReScan": "Sei sicuro di voler forzare una nuova scansione?",
"MessageConfirmMarkSeriesFinished": "Sei sicuro di voler contrassegnare tutti i libri di questa serie come completati?",
"MessageConfirmMarkSeriesNotFinished": "Sei sicuro di voler contrassegnare tutti i libri di questa serie come non completati?",
+ "MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
"MessageConfirmRemoveCollection": "Sei sicuro di voler rimuovere la Raccolta \"{0}\"?",
"MessageConfirmRemoveEpisode": "Sei sicuro di voler rimuovere l'episodio \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Sei sicuro di voler rimuovere {0} episodi?",
diff --git a/client/strings/pl.json b/client/strings/pl.json
index 8d07bd64..5b52c210 100644
--- a/client/strings/pl.json
+++ b/client/strings/pl.json
@@ -161,6 +161,7 @@
"LabelAccountTypeGuest": "Gość",
"LabelAccountTypeUser": "Użytkownik",
"LabelActivity": "Aktywność",
+ "LabelAdded": "Added",
"LabelAddedAt": "Dodano",
"LabelAddToCollection": "Dodaj do kolekcji",
"LabelAddToCollectionBatch": "Dodaj {0} książki do kolekcji",
@@ -182,11 +183,15 @@
"LabelBackupsMaxBackupSizeHelp": "Jako zabezpieczenie przed błędną konfiguracją, kopie zapasowe nie będą wykonywane, jeśli przekroczą skonfigurowany rozmiar.",
"LabelBackupsNumberToKeep": "Liczba kopii zapasowych do przechowywania",
"LabelBackupsNumberToKeepHelp": "Tylko 1 kopia zapasowa zostanie usunięta, więc jeśli masz już więcej kopii zapasowych, powinieneś je ręcznie usunąć.",
+ "LabelBitrate": "Bitrate",
"LabelBooks": "Książki",
"LabelChangePassword": "Zmień hasło",
+ "LabelChannels": "Channels",
+ "LabelChapters": "Chapters",
"LabelChaptersFound": "Znalezione rozdziały",
"LabelChapterTitle": "Tytuł rozdziału",
"LabelClosePlayer": "Zamknij odtwarzacz",
+ "LabelCodec": "Codec",
"LabelCollapseSeries": "Podsumuj serię",
"LabelCollections": "Kolekcje",
"LabelComplete": "Ukończone",
@@ -212,6 +217,7 @@
"LabelDuration": "Czas trwania",
"LabelDurationFound": "Znaleziona długość:",
"LabelEdit": "Edytuj",
+ "LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Włącz",
"LabelEnd": "Zakończ",
"LabelEpisode": "Odcinek",
@@ -229,6 +235,7 @@
"LabelFinished": "Zakończone",
"LabelFolder": "Folder",
"LabelFolders": "Foldery",
+ "LabelFormat": "Format",
"LabelGenre": "Gatunek",
"LabelGenres": "Gatunki",
"LabelHardDeleteFile": "Usuń trwale plik",
@@ -250,6 +257,8 @@
"LabelItem": "Pozycja",
"LabelLanguage": "Język",
"LabelLanguageDefaultServer": "Domyślny język serwera",
+ "LabelLastBookAdded": "Last Book Added",
+ "LabelLastBookUpdated": "Last Book Updated",
"LabelLastSeen": "Ostatnio widziany",
"LabelLastTime": "Ostatni czas",
"LabelLastUpdate": "Ostatnia aktualizacja",
@@ -268,10 +277,12 @@
"LabelMediaType": "Typ mediów",
"LabelMetadataProvider": "Dostawca metadanych",
"LabelMetaTag": "Tag",
+ "LabelMetaTags": "Meta Tags",
"LabelMinute": "Minuta",
"LabelMissing": "Brakujący",
"LabelMissingParts": "Brakujące cześci",
"LabelMore": "Więcej",
+ "LabelMoreInfo": "More Info",
"LabelName": "Nazwa",
"LabelNarrator": "Narrator",
"LabelNarrators": "Lektorzy",
@@ -402,6 +413,7 @@
"LabelTags": "Tagi",
"LabelTagsAccessibleToUser": "Tagi dostępne dla użytkownika",
"LabelTasks": "Tasks Running",
+ "LabelTimeBase": "Time Base",
"LabelTimeListened": "Czas odtwarzania",
"LabelTimeListenedToday": "Czas odtwarzania dzisiaj",
"LabelTimeRemaining": "Pozostało {0}",
@@ -465,6 +477,7 @@
"MessageConfirmForceReScan": "Czy na pewno chcesz wymusić ponowne skanowanie?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
+ "MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
"MessageConfirmRemoveCollection": "Czy na pewno chcesz usunąć kolekcję \"{0}\"?",
"MessageConfirmRemoveEpisode": "Czy na pewno chcesz usunąć odcinek \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Czy na pewno chcesz usunąć {0} odcinki?",
diff --git a/client/strings/ru.json b/client/strings/ru.json
index c172df99..1a04e611 100644
--- a/client/strings/ru.json
+++ b/client/strings/ru.json
@@ -155,12 +155,13 @@
"HeaderUpdateLibrary": "Обновить библиотеку",
"HeaderUsers": "Пользователи",
"HeaderYourStats": "Ваша статистика",
- "LabelAbridged": "Abridged",
+ "LabelAbridged": "Сокращенное издание",
"LabelAccountType": "Тип учетной записи",
"LabelAccountTypeAdmin": "Администратор",
"LabelAccountTypeGuest": "Гость",
"LabelAccountTypeUser": "Пользователь",
"LabelActivity": "Активность",
+ "LabelAdded": "Added",
"LabelAddedAt": "Дата добавления",
"LabelAddToCollection": "Добавить в коллекцию",
"LabelAddToCollectionBatch": "Добавить {0} книг в коллекцию",
@@ -182,11 +183,15 @@
"LabelBackupsMaxBackupSizeHelp": "В качестве защиты процесс бэкапирования будет завершаться ошибкой, если будет превышен настроенный размер.",
"LabelBackupsNumberToKeep": "Сохранять бэкапов",
"LabelBackupsNumberToKeepHelp": "За один раз только 1 бэкап будет удален, так что если у вас будет больше бэкапов, то их нужно удалить вручную.",
+ "LabelBitrate": "Bitrate",
"LabelBooks": "Книги",
"LabelChangePassword": "Изменить пароль",
+ "LabelChannels": "Channels",
+ "LabelChapters": "Chapters",
"LabelChaptersFound": "глав найдено",
"LabelChapterTitle": "Название главы",
"LabelClosePlayer": "Закрыть проигрыватель",
+ "LabelCodec": "Codec",
"LabelCollapseSeries": "Свернуть серии",
"LabelCollections": "Коллекции",
"LabelComplete": "Завершить",
@@ -212,6 +217,7 @@
"LabelDuration": "Длина",
"LabelDurationFound": "Найденная длина:",
"LabelEdit": "Редактировать",
+ "LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Включить",
"LabelEnd": "Конец",
"LabelEpisode": "Эпизод",
@@ -229,6 +235,7 @@
"LabelFinished": "Закончен",
"LabelFolder": "Папка",
"LabelFolders": "Папки",
+ "LabelFormat": "Format",
"LabelGenre": "Жанр",
"LabelGenres": "Жанры",
"LabelHardDeleteFile": "Жесткое удаление файла",
@@ -250,6 +257,8 @@
"LabelItem": "Элемент",
"LabelLanguage": "Язык",
"LabelLanguageDefaultServer": "Язык сервера по умолчанию",
+ "LabelLastBookAdded": "Last Book Added",
+ "LabelLastBookUpdated": "Last Book Updated",
"LabelLastSeen": "Последнее сканирование",
"LabelLastTime": "Последний по времени",
"LabelLastUpdate": "Последний обновленный",
@@ -268,10 +277,12 @@
"LabelMediaType": "Тип медиа",
"LabelMetadataProvider": "Провайдер",
"LabelMetaTag": "Мета тег",
+ "LabelMetaTags": "Meta Tags",
"LabelMinute": "Минуты",
"LabelMissing": "Потеряно",
"LabelMissingParts": "Потерянные части",
"LabelMore": "Еще",
+ "LabelMoreInfo": "More Info",
"LabelName": "Имя",
"LabelNarrator": "Читает",
"LabelNarrators": "Чтецы",
@@ -394,7 +405,7 @@
"LabelStatsMinutes": "минут",
"LabelStatsMinutesListening": "Минут прослушано",
"LabelStatsOverallDays": "Всего дней",
- "LabelStatsOverallHours": "Всего сасов",
+ "LabelStatsOverallHours": "Всего часов",
"LabelStatsWeekListening": "Недель прослушано",
"LabelSubtitle": "Подзаголовок",
"LabelSupportedFileTypes": "Поддерживаемые типы файлов",
@@ -402,6 +413,7 @@
"LabelTags": "Теги",
"LabelTagsAccessibleToUser": "Теги доступные для пользователя",
"LabelTasks": "Запущенные задачи",
+ "LabelTimeBase": "Time Base",
"LabelTimeListened": "Время прослушивания",
"LabelTimeListenedToday": "Время прослушивания сегодня",
"LabelTimeRemaining": "{0} осталось",
@@ -421,7 +433,7 @@
"LabelTracksMultiTrack": "Мультитрек",
"LabelTracksSingleTrack": "Один трек",
"LabelType": "Тип",
- "LabelUnabridged": "Unabridged",
+ "LabelUnabridged": "Полное издание",
"LabelUnknown": "Неизвестно",
"LabelUpdateCover": "Обновить обложку",
"LabelUpdateCoverHelp": "Позволяет перезаписывать существующие обложки для выбранных книг если будут найдены",
@@ -465,6 +477,7 @@
"MessageConfirmForceReScan": "Вы уверены, что хотите принудительно выполнить повторное сканирование?",
"MessageConfirmMarkSeriesFinished": "Вы уверены, что хотите отметить все книги этой серии как законченные?",
"MessageConfirmMarkSeriesNotFinished": "Вы уверены, что хотите отметить все книги этой серии как незаконченные?",
+ "MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
"MessageConfirmRemoveCollection": "Вы уверены, что хотите удалить коллекцию \"{0}\"?",
"MessageConfirmRemoveEpisode": "Вы уверены, что хотите удалить эпизод \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Вы уверены, что хотите удалить {0} эпизодов?",
@@ -568,7 +581,7 @@
"PlaceholderNewFolderPath": "Путь к новой папке",
"PlaceholderNewPlaylist": "Новое название плейлиста",
"PlaceholderSearch": "Поиск...",
- "PlaceholderSearchEpisode": "Search episode...",
+ "PlaceholderSearchEpisode": "Поиск эпизода...",
"ToastAccountUpdateFailed": "Не удалось обновить учетную запись",
"ToastAccountUpdateSuccess": "Учетная запись обновлена",
"ToastAuthorImageRemoveFailed": "Не удалось удалить изображение",
diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json
index afc21c05..87293a35 100644
--- a/client/strings/zh-cn.json
+++ b/client/strings/zh-cn.json
@@ -161,6 +161,7 @@
"LabelAccountTypeGuest": "来宾",
"LabelAccountTypeUser": "用户",
"LabelActivity": "活动",
+ "LabelAdded": "Added",
"LabelAddedAt": "添加于",
"LabelAddToCollection": "添加到收藏",
"LabelAddToCollectionBatch": "批量添加 {0} 个媒体到收藏",
@@ -182,11 +183,15 @@
"LabelBackupsMaxBackupSizeHelp": "为了防止错误配置, 如果备份超过配置的大小, 备份将失败.",
"LabelBackupsNumberToKeep": "要保留的备份个数",
"LabelBackupsNumberToKeepHelp": "一次只能删除一个备份, 因此如果你已经有超过此数量的备份, 则应手动删除它们.",
+ "LabelBitrate": "Bitrate",
"LabelBooks": "图书",
"LabelChangePassword": "修改密码",
+ "LabelChannels": "Channels",
+ "LabelChapters": "Chapters",
"LabelChaptersFound": "找到的章节",
"LabelChapterTitle": "章节标题",
"LabelClosePlayer": "关闭播放器",
+ "LabelCodec": "Codec",
"LabelCollapseSeries": "折叠系列",
"LabelCollections": "收藏",
"LabelComplete": "已完成",
@@ -212,6 +217,7 @@
"LabelDuration": "持续时间",
"LabelDurationFound": "找到持续时间:",
"LabelEdit": "编辑",
+ "LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "启用",
"LabelEnd": "结束",
"LabelEpisode": "剧集",
@@ -229,6 +235,7 @@
"LabelFinished": "已听完",
"LabelFolder": "文件夹",
"LabelFolders": "文件夹",
+ "LabelFormat": "Format",
"LabelGenre": "流派",
"LabelGenres": "流派",
"LabelHardDeleteFile": "完全删除文件",
@@ -250,6 +257,8 @@
"LabelItem": "项目",
"LabelLanguage": "语言",
"LabelLanguageDefaultServer": "默认服务器语言",
+ "LabelLastBookAdded": "Last Book Added",
+ "LabelLastBookUpdated": "Last Book Updated",
"LabelLastSeen": "上次查看时间",
"LabelLastTime": "最近一次",
"LabelLastUpdate": "最近更新",
@@ -268,10 +277,12 @@
"LabelMediaType": "媒体类型",
"LabelMetadataProvider": "元数据提供者",
"LabelMetaTag": "元数据标签",
+ "LabelMetaTags": "Meta Tags",
"LabelMinute": "分钟",
"LabelMissing": "丢失",
"LabelMissingParts": "丢失的部分",
"LabelMore": "更多",
+ "LabelMoreInfo": "More Info",
"LabelName": "名称",
"LabelNarrator": "演播者",
"LabelNarrators": "演播者",
@@ -402,6 +413,7 @@
"LabelTags": "标签",
"LabelTagsAccessibleToUser": "用户可访问的标签",
"LabelTasks": "正在运行的任务",
+ "LabelTimeBase": "Time Base",
"LabelTimeListened": "收听时间",
"LabelTimeListenedToday": "今日收听的时间",
"LabelTimeRemaining": "剩余 {0}",
@@ -465,6 +477,7 @@
"MessageConfirmForceReScan": "你确定要强制重新扫描吗?",
"MessageConfirmMarkSeriesFinished": "你确定要将此系列中的所有书籍都标记为已听完吗?",
"MessageConfirmMarkSeriesNotFinished": "你确定要将此系列中的所有书籍都标记为未听完吗?",
+ "MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
"MessageConfirmRemoveCollection": "您确定要移除收藏 \"{0}\"?",
"MessageConfirmRemoveEpisode": "您确定要移除剧集 \"{0}\"?",
"MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?",
diff --git a/package-lock.json b/package-lock.json
index 3529c256..b7676698 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "audiobookshelf",
- "version": "2.2.18",
+ "version": "2.2.19",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf",
- "version": "2.2.18",
+ "version": "2.2.19",
"license": "GPL-3.0",
"dependencies": {
"axios": "^0.27.2",
@@ -15,7 +15,7 @@
"htmlparser2": "^8.0.1",
"node-tone": "^1.0.1",
"socket.io": "^4.5.4",
- "xml2js": "^0.4.23"
+ "xml2js": "^0.5.0"
},
"bin": {
"audiobookshelf": "prod.js"
@@ -1329,9 +1329,9 @@
}
},
"node_modules/xml2js": {
- "version": "0.4.23",
- "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
- "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
+ "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
@@ -2300,9 +2300,9 @@
"requires": {}
},
"xml2js": {
- "version": "0.4.23",
- "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
- "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
+ "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
"requires": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
diff --git a/package.json b/package.json
index 296271b3..cb86b94b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
- "version": "2.2.18",
+ "version": "2.2.19",
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",
"scripts": {
@@ -36,7 +36,7 @@
"htmlparser2": "^8.0.1",
"node-tone": "^1.0.1",
"socket.io": "^4.5.4",
- "xml2js": "^0.4.23"
+ "xml2js": "^0.5.0"
},
"devDependencies": {
"nodemon": "^2.0.20"
diff --git a/server/Db.js b/server/Db.js
index d836ef79..b6bff04f 100644
--- a/server/Db.js
+++ b/server/Db.js
@@ -27,17 +27,16 @@ class Db {
this.SeriesPath = Path.join(global.ConfigPath, 'series')
this.FeedsPath = Path.join(global.ConfigPath, 'feeds')
- const staleTime = 1000 * 60 * 2
- this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath, { lockoptions: { stale: staleTime } })
- this.usersDb = new njodb.Database(this.UsersPath, { lockoptions: { stale: staleTime } })
- this.sessionsDb = new njodb.Database(this.SessionsPath, { lockoptions: { stale: staleTime } })
- this.librariesDb = new njodb.Database(this.LibrariesPath, { datastores: 2, lockoptions: { stale: staleTime } })
- this.settingsDb = new njodb.Database(this.SettingsPath, { datastores: 2, lockoptions: { stale: staleTime } })
- this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2, lockoptions: { stale: staleTime } })
- this.playlistsDb = new njodb.Database(this.PlaylistsPath, { datastores: 2, lockoptions: { stale: staleTime } })
- this.authorsDb = new njodb.Database(this.AuthorsPath, { lockoptions: { stale: staleTime } })
- this.seriesDb = new njodb.Database(this.SeriesPath, { datastores: 2, lockoptions: { stale: staleTime } })
- this.feedsDb = new njodb.Database(this.FeedsPath, { datastores: 2, lockoptions: { stale: staleTime } })
+ this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath, this.getNjodbOptions())
+ this.usersDb = new njodb.Database(this.UsersPath, this.getNjodbOptions())
+ this.sessionsDb = new njodb.Database(this.SessionsPath, this.getNjodbOptions())
+ this.librariesDb = new njodb.Database(this.LibrariesPath, this.getNjodbOptions())
+ this.settingsDb = new njodb.Database(this.SettingsPath, this.getNjodbOptions())
+ this.collectionsDb = new njodb.Database(this.CollectionsPath, this.getNjodbOptions())
+ this.playlistsDb = new njodb.Database(this.PlaylistsPath, this.getNjodbOptions())
+ this.authorsDb = new njodb.Database(this.AuthorsPath, this.getNjodbOptions())
+ this.seriesDb = new njodb.Database(this.SeriesPath, this.getNjodbOptions())
+ this.feedsDb = new njodb.Database(this.FeedsPath, this.getNjodbOptions())
this.libraryItems = []
this.users = []
@@ -59,6 +58,21 @@ class Db {
return this.users.some(u => u.id === 'root')
}
+ getNjodbOptions() {
+ return {
+ lockoptions: {
+ stale: 1000 * 20, // 20 seconds
+ update: 2500,
+ retries: {
+ retries: 20,
+ minTimeout: 250,
+ maxTimeout: 5000,
+ factor: 1
+ }
+ }
+ }
+ }
+
getEntityDb(entityName) {
if (entityName === 'user') return this.usersDb
else if (entityName === 'session') return this.sessionsDb
@@ -88,17 +102,16 @@ class Db {
}
reinit() {
- const staleTime = 1000 * 60 * 2
- this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath, { lockoptions: { stale: staleTime } })
- this.usersDb = new njodb.Database(this.UsersPath, { lockoptions: { stale: staleTime } })
- this.sessionsDb = new njodb.Database(this.SessionsPath, { lockoptions: { stale: staleTime } })
- this.librariesDb = new njodb.Database(this.LibrariesPath, { datastores: 2, lockoptions: { stale: staleTime } })
- this.settingsDb = new njodb.Database(this.SettingsPath, { datastores: 2, lockoptions: { stale: staleTime } })
- this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2, lockoptions: { stale: staleTime } })
- this.playlistsDb = new njodb.Database(this.PlaylistsPath, { datastores: 2, lockoptions: { stale: staleTime } })
- this.authorsDb = new njodb.Database(this.AuthorsPath, { lockoptions: { stale: staleTime } })
- this.seriesDb = new njodb.Database(this.SeriesPath, { datastores: 2, lockoptions: { stale: staleTime } })
- this.feedsDb = new njodb.Database(this.FeedsPath, { datastores: 2, lockoptions: { stale: staleTime } })
+ this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath, this.getNjodbOptions())
+ this.usersDb = new njodb.Database(this.UsersPath, this.getNjodbOptions())
+ this.sessionsDb = new njodb.Database(this.SessionsPath, this.getNjodbOptions())
+ this.librariesDb = new njodb.Database(this.LibrariesPath, this.getNjodbOptions())
+ this.settingsDb = new njodb.Database(this.SettingsPath, this.getNjodbOptions())
+ this.collectionsDb = new njodb.Database(this.CollectionsPath, this.getNjodbOptions())
+ this.playlistsDb = new njodb.Database(this.PlaylistsPath, this.getNjodbOptions())
+ this.authorsDb = new njodb.Database(this.AuthorsPath, this.getNjodbOptions())
+ this.seriesDb = new njodb.Database(this.SeriesPath, this.getNjodbOptions())
+ this.feedsDb = new njodb.Database(this.FeedsPath, this.getNjodbOptions())
return this.init()
}
diff --git a/server/controllers/AuthorController.js b/server/controllers/AuthorController.js
index d3d72f7c..106734a4 100644
--- a/server/controllers/AuthorController.js
+++ b/server/controllers/AuthorController.js
@@ -167,18 +167,19 @@ class AuthorController {
}
async match(req, res) {
- var authorData = null
+ let authorData = null
+ const region = req.body.region || 'us'
if (req.body.asin) {
- authorData = await this.authorFinder.findAuthorByASIN(req.body.asin)
+ authorData = await this.authorFinder.findAuthorByASIN(req.body.asin, region)
} else {
- authorData = await this.authorFinder.findAuthorByName(req.body.q)
+ authorData = await this.authorFinder.findAuthorByName(req.body.q, region)
}
if (!authorData) {
return res.status(404).send('Author not found')
}
Logger.debug(`[AuthorController] match author with "${req.body.q || req.body.asin}"`, authorData)
- var hasUpdates = false
+ let hasUpdates = false
if (authorData.asin && req.author.asin !== authorData.asin) {
req.author.asin = authorData.asin
hasUpdates = true
@@ -188,7 +189,7 @@ class AuthorController {
if (authorData.image && (!req.author.imagePath || hasUpdates)) {
this.cacheManager.purgeImageCache(req.author.id)
- var imageData = await this.authorFinder.saveAuthorImage(req.author.id, authorData.image)
+ const imageData = await this.authorFinder.saveAuthorImage(req.author.id, authorData.image)
if (imageData) {
req.author.imagePath = imageData.path
hasUpdates = true
@@ -204,7 +205,7 @@ class AuthorController {
req.author.updatedAt = Date.now()
await this.db.updateEntity('author', req.author)
- var numBooks = this.db.libraryItems.filter(li => {
+ const numBooks = this.db.libraryItems.filter(li => {
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id)
}).length
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))
diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js
index bbc70f31..154f6f17 100644
--- a/server/controllers/LibraryController.js
+++ b/server/controllers/LibraryController.js
@@ -417,6 +417,10 @@ class LibraryController {
return se.totalDuration
} else if (payload.sortBy === 'addedAt') {
return se.addedAt
+ } else if (payload.sortBy === 'lastBookUpdated') {
+ return Math.max(...(se.books).map(x => x.updatedAt), 0)
+ } else if (payload.sortBy === 'lastBookAdded') {
+ return Math.max(...(se.books).map(x => x.addedAt), 0)
} else { // sort by name
return this.db.serverSettings.sortingIgnorePrefix ? se.nameIgnorePrefixSort : se.name
}
diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js
index 05aee92d..19f287f0 100644
--- a/server/controllers/LibraryItemController.js
+++ b/server/controllers/LibraryItemController.js
@@ -2,6 +2,7 @@ const fs = require('../libs/fsExtra')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
+const zipHelpers = require('../utils/zipHelpers')
const { reqSupportsWebp, isNullOrNaN } = require('../utils/index')
const { ScanResult } = require('../utils/constants')
@@ -65,10 +66,29 @@ class LibraryItemController {
}
async delete(req, res) {
+ const hardDelete = req.query.hard == 1 // Delete from file system
+ const libraryItemPath = req.libraryItem.path
await this.handleDeleteLibraryItem(req.libraryItem)
+ if (hardDelete) {
+ Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
+ await fs.remove(libraryItemPath).catch((error) => {
+ Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
+ })
+ }
res.sendStatus(200)
}
+ download(req, res) {
+ if (!req.user.canDownload) {
+ Logger.warn('User attempted to download without permission', req.user)
+ return res.sendStatus(403)
+ }
+
+ const libraryItemPath = req.libraryItem.path
+ const filename = `${req.libraryItem.media.metadata.title}.zip`
+ zipHelpers.zipDirectoryPipe(libraryItemPath, filename, res)
+ }
+
//
// PATCH: will create new authors & series if in payload
//
@@ -162,12 +182,12 @@ class LibraryItemController {
// PATCH: api/items/:id/cover
async updateCover(req, res) {
- var libraryItem = req.libraryItem
+ const libraryItem = req.libraryItem
if (!req.body.cover) {
- return res.status(400).error('Invalid request no cover path')
+ return res.status(400).send('Invalid request no cover path')
}
- var validationResult = await this.coverManager.validateCoverPath(req.body.cover, libraryItem)
+ const validationResult = await this.coverManager.validateCoverPath(req.body.cover, libraryItem)
if (validationResult.error) {
return res.status(500).send(validationResult.error)
}
@@ -280,19 +300,27 @@ class LibraryItemController {
Logger.warn(`[LibraryItemController] User attempted to delete without permission`, req.user)
return res.sendStatus(403)
}
+ const hardDelete = req.query.hard == 1 // Delete files from filesystem
- var { libraryItemIds } = req.body
+ const { libraryItemIds } = req.body
if (!libraryItemIds || !libraryItemIds.length) {
return res.sendStatus(500)
}
- var itemsToDelete = this.db.libraryItems.filter(li => libraryItemIds.includes(li.id))
+ const itemsToDelete = this.db.libraryItems.filter(li => libraryItemIds.includes(li.id))
if (!itemsToDelete.length) {
return res.sendStatus(404)
}
for (let i = 0; i < itemsToDelete.length; i++) {
+ const libraryItemPath = itemsToDelete[i].path
Logger.info(`[LibraryItemController] Deleting Library Item "${itemsToDelete[i].media.metadata.title}"`)
await this.handleDeleteLibraryItem(itemsToDelete[i])
+ if (hardDelete) {
+ Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
+ await fs.remove(libraryItemPath).catch((error) => {
+ Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
+ })
+ }
}
res.sendStatus(200)
}
@@ -436,12 +464,12 @@ class LibraryItemController {
return res.sendStatus(500)
}
- const chapters = req.body.chapters || []
- if (!chapters.length) {
+ if (!req.body.chapters) {
Logger.error(`[LibraryItemController] Invalid payload`)
return res.sendStatus(400)
}
+ const chapters = req.body.chapters || []
const wasUpdated = req.libraryItem.media.updateChapters(chapters)
if (wasUpdated) {
await this.db.updateLibraryItem(req.libraryItem)
@@ -470,6 +498,30 @@ class LibraryItemController {
res.json(toneData)
}
+ async deleteLibraryFile(req, res) {
+ const libraryFile = req.libraryItem.libraryFiles.find(lf => lf.ino === req.params.ino)
+ if (!libraryFile) {
+ Logger.error(`[LibraryItemController] Unable to delete library file. Not found. "${req.params.ino}"`)
+ return res.sendStatus(404)
+ }
+
+ await fs.remove(libraryFile.metadata.path).catch((error) => {
+ Logger.error(`[LibraryItemController] Failed to delete library file at "${libraryFile.metadata.path}"`, error)
+ })
+ req.libraryItem.removeLibraryFile(req.params.ino)
+
+ if (req.libraryItem.media.removeFileWithInode(req.params.ino)) {
+ // If book has no more media files then mark it as missing
+ if (req.libraryItem.mediaType === 'book' && !req.libraryItem.media.hasMediaEntities) {
+ req.libraryItem.setMissing()
+ }
+ }
+ req.libraryItem.updatedAt = Date.now()
+ await this.db.updateLibraryItem(req.libraryItem)
+ SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
+ res.sendStatus(200)
+ }
+
middleware(req, res, next) {
const item = this.db.libraryItems.find(li => li.id === req.params.id)
if (!item || !item.media) return res.sendStatus(404)
diff --git a/server/controllers/SessionController.js b/server/controllers/SessionController.js
index 721fbd27..5ca694fc 100644
--- a/server/controllers/SessionController.js
+++ b/server/controllers/SessionController.js
@@ -14,7 +14,7 @@ class SessionController {
return res.sendStatus(404)
}
- var listeningSessions = []
+ let listeningSessions = []
if (req.query.user) {
listeningSessions = await this.getUserListeningSessionsHelper(req.query.user)
} else {
@@ -42,6 +42,25 @@ class SessionController {
res.json(payload)
}
+ getOpenSessions(req, res) {
+ if (!req.user.isAdminOrUp) {
+ Logger.error(`[SessionController] getOpenSessions: Non-admin user requested open session data ${req.user.id}/"${req.user.username}"`)
+ return res.sendStatus(404)
+ }
+
+ const openSessions = this.playbackSessionManager.sessions.map(se => {
+ const user = this.db.users.find(u => u.id === se.userId) || null
+ return {
+ ...se.toJSON(),
+ user: user ? { id: user.id, username: user.username } : null
+ }
+ })
+
+ res.json({
+ sessions: openSessions
+ })
+ }
+
getOpenSession(req, res) {
var libraryItem = this.db.getLibraryItem(req.session.libraryItemId)
var sessionForClient = req.session.toJSONForClient(libraryItem)
diff --git a/server/finders/AuthorFinder.js b/server/finders/AuthorFinder.js
index 1f8b970b..18fb2223 100644
--- a/server/finders/AuthorFinder.js
+++ b/server/finders/AuthorFinder.js
@@ -20,16 +20,16 @@ class AuthorFinder {
})
}
- findAuthorByASIN(asin) {
+ findAuthorByASIN(asin, region) {
if (!asin) return null
- return this.audnexus.findAuthorByASIN(asin)
+ return this.audnexus.findAuthorByASIN(asin, region)
}
- async findAuthorByName(name, options = {}) {
+ async findAuthorByName(name, region, options = {}) {
if (!name) return null
const maxLevenshtein = !isNaN(options.maxLevenshtein) ? Number(options.maxLevenshtein) : 3
- var author = await this.audnexus.findAuthorByName(name, maxLevenshtein)
+ const author = await this.audnexus.findAuthorByName(name, region, maxLevenshtein)
if (!author || !author.name) {
return null
}
diff --git a/server/libs/properLockfile/lib/lockfile.js b/server/libs/properLockfile/lib/lockfile.js
index d981a00c..6e680d3a 100644
--- a/server/libs/properLockfile/lib/lockfile.js
+++ b/server/libs/properLockfile/lib/lockfile.js
@@ -118,6 +118,7 @@ function updateLock(file, options) {
// the lockfile was deleted or we are over the threshold
if (err) {
if (err.code === 'ENOENT' || isOverThreshold) {
+ console.error(`lockfile "${file}" compromised. stat code=${err.code}, isOverThreshold=${isOverThreshold}`)
return setLockAsCompromised(file, lock, Object.assign(err, { code: 'ECOMPROMISED' }));
}
@@ -129,6 +130,7 @@ function updateLock(file, options) {
const isMtimeOurs = lock.mtime.getTime() === stat.mtime.getTime();
if (!isMtimeOurs) {
+ console.error(`lockfile "${file}" compromised. mtime is not ours`)
return setLockAsCompromised(
file,
lock,
@@ -152,6 +154,7 @@ function updateLock(file, options) {
// the lockfile was deleted or we are over the threshold
if (err) {
if (err.code === 'ENOENT' || isOverThreshold) {
+ console.error(`lockfile "${file}" compromised. utimes code=${err.code}, isOverThreshold=${isOverThreshold}`)
return setLockAsCompromised(file, lock, Object.assign(err, { code: 'ECOMPROMISED' }));
}
diff --git a/server/managers/PlaybackSessionManager.js b/server/managers/PlaybackSessionManager.js
index 30b4ad24..d5d204cb 100644
--- a/server/managers/PlaybackSessionManager.js
+++ b/server/managers/PlaybackSessionManager.js
@@ -14,7 +14,6 @@ const PlaybackSession = require('../objects/PlaybackSession')
const DeviceInfo = require('../objects/DeviceInfo')
const Stream = require('../objects/Stream')
-
class PlaybackSessionManager {
constructor(db) {
this.db = db
@@ -31,13 +30,14 @@ class PlaybackSessionManager {
}
getStream(sessionId) {
const session = this.getSession(sessionId)
- return session ? session.stream : null
+ return session?.stream || null
}
getDeviceInfo(req) {
const ua = uaParserJs(req.headers['user-agent'])
const ip = requestIp.getClientIp(req)
- const clientDeviceInfo = req.body ? req.body.deviceInfo || null : null // From mobile client
+
+ const clientDeviceInfo = req.body?.deviceInfo || null
const deviceInfo = new DeviceInfo()
deviceInfo.setData(ip, ua, clientDeviceInfo, serverVersion)
@@ -138,18 +138,6 @@ class PlaybackSessionManager {
}
async syncLocalSessionRequest(user, sessionJson, res) {
- // If server session is open for this same media item then close it
- const userSessionForThisItem = this.sessions.find(playbackSession => {
- if (playbackSession.userId !== user.id) return false
- if (sessionJson.episodeId) return playbackSession.episodeId !== sessionJson.episodeId
- return playbackSession.libraryItemId === sessionJson.libraryItemId
- })
- if (userSessionForThisItem) {
- Logger.info(`[PlaybackSessionManager] syncLocalSessionRequest: Closing open session "${userSessionForThisItem.displayTitle}" for user "${user.username}"`)
- await this.closeSession(user, userSessionForThisItem, null)
- }
-
- // Sync
const result = await this.syncLocalSession(user, sessionJson)
if (result.error) {
res.status(500).send(result.error)
@@ -164,8 +152,8 @@ class PlaybackSessionManager {
}
async startSession(user, deviceInfo, libraryItem, episodeId, options) {
- // Close any sessions already open for user
- const userSessions = this.sessions.filter(playbackSession => playbackSession.userId === user.id)
+ // Close any sessions already open for user and device
+ const userSessions = this.sessions.filter(playbackSession => playbackSession.userId === user.id && playbackSession.deviceId === deviceInfo.deviceId)
for (const session of userSessions) {
Logger.info(`[PlaybackSessionManager] startSession: Closing open session "${session.displayTitle}" for user "${user.username}" (Device: ${session.deviceDescription})`)
await this.closeSession(user, session, null)
@@ -268,6 +256,7 @@ class PlaybackSessionManager {
}
Logger.debug(`[PlaybackSessionManager] closeSession "${session.id}"`)
SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions, this.db.libraryItems))
+ SocketAuthority.clientEmitter(session.userId, 'user_session_closed', session.id)
return this.removeSession(session.id)
}
diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js
index e60238cc..d50bc7bb 100644
--- a/server/managers/PodcastManager.js
+++ b/server/managers/PodcastManager.js
@@ -53,15 +53,15 @@ class PodcastManager {
}
async downloadPodcastEpisodes(libraryItem, episodesToDownload, isAutoDownload) {
- var index = libraryItem.media.episodes.length + 1
- episodesToDownload.forEach((ep) => {
- var newPe = new PodcastEpisode()
+ let index = libraryItem.media.episodes.length + 1
+ for (const ep of episodesToDownload) {
+ const newPe = new PodcastEpisode()
newPe.setData(ep, index++)
newPe.libraryItemId = libraryItem.id
- var newPeDl = new PodcastEpisodeDownload()
+ const newPeDl = new PodcastEpisodeDownload()
newPeDl.setData(newPe, libraryItem, isAutoDownload, libraryItem.libraryId)
this.startPodcastEpisodeDownload(newPeDl)
- })
+ }
}
async startPodcastEpisodeDownload(podcastEpisodeDownload) {
@@ -94,7 +94,6 @@ class PodcastManager {
await filePerms.setDefault(this.currentDownload.libraryItem.path)
}
-
let success = false
if (this.currentDownload.urlFileExtension === 'mp3') {
// Download episode and tag it
@@ -156,6 +155,11 @@ class PodcastManager {
const podcastEpisode = this.currentDownload.podcastEpisode
podcastEpisode.audioFile = audioFile
+
+ if (audioFile.chapters?.length) {
+ podcastEpisode.chapters = audioFile.chapters.map(ch => ({ ...ch }))
+ }
+
libraryItem.media.addPodcastEpisode(podcastEpisode)
if (libraryItem.isInvalid) {
// First episode added to an empty podcast
@@ -214,13 +218,13 @@ class PodcastManager {
}
async probeAudioFile(libraryFile) {
- var path = libraryFile.metadata.path
- var mediaProbeData = await prober.probe(path)
+ const path = libraryFile.metadata.path
+ const mediaProbeData = await prober.probe(path)
if (mediaProbeData.error) {
Logger.error(`[PodcastManager] Podcast Episode downloaded but failed to probe "${path}"`, mediaProbeData.error)
return false
}
- var newAudioFile = new AudioFile()
+ const newAudioFile = new AudioFile()
newAudioFile.setDataFromProbe(libraryFile, mediaProbeData)
return newAudioFile
}
diff --git a/server/objects/DeviceInfo.js b/server/objects/DeviceInfo.js
index 4015be70..4d7cf0d6 100644
--- a/server/objects/DeviceInfo.js
+++ b/server/objects/DeviceInfo.js
@@ -1,5 +1,6 @@
class DeviceInfo {
constructor(deviceInfo = null) {
+ this.deviceId = null
this.ipAddress = null
// From User Agent (see: https://www.npmjs.com/package/ua-parser-js)
@@ -32,6 +33,7 @@ class DeviceInfo {
toJSON() {
const obj = {
+ deviceId: this.deviceId,
ipAddress: this.ipAddress,
browserName: this.browserName,
browserVersion: this.browserVersion,
@@ -60,23 +62,42 @@ class DeviceInfo {
return `${this.osName} ${this.osVersion} / ${this.browserName}`
}
+ // When client doesn't send a device id
+ getTempDeviceId() {
+ const keys = [
+ this.browserName,
+ this.browserVersion,
+ this.osName,
+ this.osVersion,
+ this.clientVersion,
+ this.manufacturer,
+ this.model,
+ this.sdkVersion,
+ this.ipAddress
+ ].map(k => k || '')
+ return 'temp-' + Buffer.from(keys.join('-'), 'utf-8').toString('base64')
+ }
+
setData(ip, ua, clientDeviceInfo, serverVersion) {
+ this.deviceId = clientDeviceInfo?.deviceId || null
this.ipAddress = ip || null
- const uaObj = ua || {}
- this.browserName = uaObj.browser.name || null
- this.browserVersion = uaObj.browser.version || null
- this.osName = uaObj.os.name || null
- this.osVersion = uaObj.os.version || null
- this.deviceType = uaObj.device.type || null
+ this.browserName = ua?.browser.name || null
+ this.browserVersion = ua?.browser.version || null
+ this.osName = ua?.os.name || null
+ this.osVersion = ua?.os.version || null
+ this.deviceType = ua?.device.type || null
- const cdi = clientDeviceInfo || {}
- this.clientVersion = cdi.clientVersion || null
- this.manufacturer = cdi.manufacturer || null
- this.model = cdi.model || null
- this.sdkVersion = cdi.sdkVersion || null
+ this.clientVersion = clientDeviceInfo?.clientVersion || null
+ this.manufacturer = clientDeviceInfo?.manufacturer || null
+ this.model = clientDeviceInfo?.model || null
+ this.sdkVersion = clientDeviceInfo?.sdkVersion || null
this.serverVersion = serverVersion || null
+
+ if (!this.deviceId) {
+ this.deviceId = this.getTempDeviceId()
+ }
}
}
module.exports = DeviceInfo
\ No newline at end of file
diff --git a/server/objects/PlaybackSession.js b/server/objects/PlaybackSession.js
index db19a8d2..b4daa026 100644
--- a/server/objects/PlaybackSession.js
+++ b/server/objects/PlaybackSession.js
@@ -55,7 +55,7 @@ class PlaybackSession {
libraryItemId: this.libraryItemId,
episodeId: this.episodeId,
mediaType: this.mediaType,
- mediaMetadata: this.mediaMetadata ? this.mediaMetadata.toJSON() : null,
+ mediaMetadata: this.mediaMetadata?.toJSON() || null,
chapters: (this.chapters || []).map(c => ({ ...c })),
displayTitle: this.displayTitle,
displayAuthor: this.displayAuthor,
@@ -63,7 +63,7 @@ class PlaybackSession {
duration: this.duration,
playMethod: this.playMethod,
mediaPlayer: this.mediaPlayer,
- deviceInfo: this.deviceInfo ? this.deviceInfo.toJSON() : null,
+ deviceInfo: this.deviceInfo?.toJSON() || null,
date: this.date,
dayOfWeek: this.dayOfWeek,
timeListening: this.timeListening,
@@ -82,7 +82,7 @@ class PlaybackSession {
libraryItemId: this.libraryItemId,
episodeId: this.episodeId,
mediaType: this.mediaType,
- mediaMetadata: this.mediaMetadata ? this.mediaMetadata.toJSON() : null,
+ mediaMetadata: this.mediaMetadata?.toJSON() || null,
chapters: (this.chapters || []).map(c => ({ ...c })),
displayTitle: this.displayTitle,
displayAuthor: this.displayAuthor,
@@ -90,7 +90,7 @@ class PlaybackSession {
duration: this.duration,
playMethod: this.playMethod,
mediaPlayer: this.mediaPlayer,
- deviceInfo: this.deviceInfo ? this.deviceInfo.toJSON() : null,
+ deviceInfo: this.deviceInfo?.toJSON() || null,
date: this.date,
dayOfWeek: this.dayOfWeek,
timeListening: this.timeListening,
@@ -151,6 +151,10 @@ class PlaybackSession {
return Math.max(0, Math.min(this.currentTime / this.duration, 1))
}
+ get deviceId() {
+ return this.deviceInfo?.deviceId
+ }
+
get deviceDescription() {
if (!this.deviceInfo) return 'No Device Info'
return this.deviceInfo.deviceDescription
diff --git a/server/objects/entities/PodcastEpisode.js b/server/objects/entities/PodcastEpisode.js
index 80e66611..394a0bea 100644
--- a/server/objects/entities/PodcastEpisode.js
+++ b/server/objects/entities/PodcastEpisode.js
@@ -1,6 +1,6 @@
const Path = require('path')
const Logger = require('../../Logger')
-const { getId, cleanStringForSearch } = require('../../utils/index')
+const { getId, cleanStringForSearch, areEquivalent, copyValue } = require('../../utils/index')
const AudioFile = require('../files/AudioFile')
const AudioTrack = require('../files/AudioTrack')
@@ -18,6 +18,7 @@ class PodcastEpisode {
this.description = null
this.enclosure = null
this.pubDate = null
+ this.chapters = []
this.audioFile = null
this.publishedAt = null
@@ -41,6 +42,7 @@ class PodcastEpisode {
this.description = episode.description
this.enclosure = episode.enclosure ? { ...episode.enclosure } : null
this.pubDate = episode.pubDate
+ this.chapters = episode.chapters?.map(ch => ({ ...ch })) || []
this.audioFile = new AudioFile(episode.audioFile)
this.publishedAt = episode.publishedAt
this.addedAt = episode.addedAt
@@ -62,6 +64,7 @@ class PodcastEpisode {
description: this.description,
enclosure: this.enclosure ? { ...this.enclosure } : null,
pubDate: this.pubDate,
+ chapters: this.chapters.map(ch => ({ ...ch })),
audioFile: this.audioFile.toJSON(),
publishedAt: this.publishedAt,
addedAt: this.addedAt,
@@ -82,6 +85,7 @@ class PodcastEpisode {
description: this.description,
enclosure: this.enclosure ? { ...this.enclosure } : null,
pubDate: this.pubDate,
+ chapters: this.chapters.map(ch => ({ ...ch })),
audioFile: this.audioFile.toJSON(),
audioTrack: this.audioTrack.toJSON(),
publishedAt: this.publishedAt,
@@ -136,6 +140,7 @@ class PodcastEpisode {
this.setDataFromAudioMetaTags(audioFile.metaTags, true)
+ this.chapters = audioFile.chapters?.map((c) => ({ ...c }))
this.addedAt = Date.now()
this.updatedAt = Date.now()
}
@@ -143,8 +148,8 @@ class PodcastEpisode {
update(payload) {
let hasUpdates = false
for (const key in this.toJSON()) {
- if (payload[key] != undefined && payload[key] != this[key]) {
- this[key] = payload[key]
+ if (payload[key] != undefined && !areEquivalent(payload[key], this[key])) {
+ this[key] = copyValue(payload[key])
hasUpdates = true
}
}
diff --git a/server/objects/mediaTypes/Podcast.js b/server/objects/mediaTypes/Podcast.js
index 743512e8..46c34a39 100644
--- a/server/objects/mediaTypes/Podcast.js
+++ b/server/objects/mediaTypes/Podcast.js
@@ -166,7 +166,11 @@ class Podcast {
}
removeFileWithInode(inode) {
- this.episodes = this.episodes.filter(ep => ep.ino !== inode)
+ const hasEpisode = this.episodes.some(ep => ep.audioFile.ino === inode)
+ if (hasEpisode) {
+ this.episodes = this.episodes.filter(ep => ep.audioFile.ino !== inode)
+ }
+ return hasEpisode
}
findFileWithInode(inode) {
diff --git a/server/providers/Audnexus.js b/server/providers/Audnexus.js
index 43dfb665..b74d1d13 100644
--- a/server/providers/Audnexus.js
+++ b/server/providers/Audnexus.js
@@ -7,9 +7,12 @@ class Audnexus {
this.baseUrl = 'https://api.audnex.us'
}
- authorASINsRequest(name) {
- name = encodeURIComponent(name);
- return axios.get(`${this.baseUrl}/authors?name=${name}`).then((res) => {
+ authorASINsRequest(name, region) {
+ name = encodeURIComponent(name)
+ const regionQuery = region ? `®ion=${region}` : ''
+ const authorRequestUrl = `${this.baseUrl}/authors?name=${name}${regionQuery}`
+ Logger.info(`[Audnexus] Searching for author "${authorRequestUrl}"`)
+ return axios.get(authorRequestUrl).then((res) => {
return res.data || []
}).catch((error) => {
Logger.error(`[Audnexus] Author ASIN request failed for ${name}`, error)
@@ -17,9 +20,12 @@ class Audnexus {
})
}
- authorRequest(asin) {
- asin = encodeURIComponent(asin);
- return axios.get(`${this.baseUrl}/authors/${asin}`).then((res) => {
+ authorRequest(asin, region) {
+ asin = encodeURIComponent(asin)
+ const regionQuery = region ? `?region=${region}` : ''
+ const authorRequestUrl = `${this.baseUrl}/authors/${asin}${regionQuery}`
+ Logger.info(`[Audnexus] Searching for author "${authorRequestUrl}"`)
+ return axios.get(authorRequestUrl).then((res) => {
return res.data
}).catch((error) => {
Logger.error(`[Audnexus] Author request failed for ${asin}`, error)
@@ -27,8 +33,8 @@ class Audnexus {
})
}
- async findAuthorByASIN(asin) {
- var author = await this.authorRequest(asin)
+ async findAuthorByASIN(asin, region) {
+ const author = await this.authorRequest(asin, region)
if (!author) {
return null
}
@@ -40,14 +46,14 @@ class Audnexus {
}
}
- async findAuthorByName(name, maxLevenshtein = 3) {
+ async findAuthorByName(name, region, maxLevenshtein = 3) {
Logger.debug(`[Audnexus] Looking up author by name ${name}`)
- var asins = await this.authorASINsRequest(name)
- var matchingAsin = asins.find(obj => levenshteinDistance(obj.name, name) <= maxLevenshtein)
+ const asins = await this.authorASINsRequest(name, region)
+ const matchingAsin = asins.find(obj => levenshteinDistance(obj.name, name) <= maxLevenshtein)
if (!matchingAsin) {
return null
}
- var author = await this.authorRequest(matchingAsin.asin)
+ const author = await this.authorRequest(matchingAsin.asin)
if (!author) {
return null
}
diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js
index 5b37fd5b..1b2df155 100644
--- a/server/routers/ApiRouter.js
+++ b/server/routers/ApiRouter.js
@@ -98,6 +98,7 @@ class ApiRouter {
this.router.get('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.findOne.bind(this))
this.router.patch('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.update.bind(this))
this.router.delete('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.delete.bind(this))
+ this.router.get('/items/:id/download', LibraryItemController.middleware.bind(this), LibraryItemController.download.bind(this))
this.router.patch('/items/:id/media', LibraryItemController.middleware.bind(this), LibraryItemController.updateMedia.bind(this))
this.router.get('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.getCover.bind(this))
this.router.post('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.uploadCover.bind(this))
@@ -111,6 +112,7 @@ class ApiRouter {
this.router.get('/items/:id/tone-object', LibraryItemController.middleware.bind(this), LibraryItemController.getToneMetadataObject.bind(this))
this.router.post('/items/:id/chapters', LibraryItemController.middleware.bind(this), LibraryItemController.updateMediaChapters.bind(this))
this.router.post('/items/:id/tone-scan/:index?', LibraryItemController.middleware.bind(this), LibraryItemController.toneScan.bind(this))
+ this.router.delete('/items/:id/file/:ino', LibraryItemController.middleware.bind(this), LibraryItemController.deleteLibraryFile.bind(this))
this.router.post('/items/batch/delete', LibraryItemController.batchDelete.bind(this))
this.router.post('/items/batch/update', LibraryItemController.batchUpdate.bind(this))
@@ -214,6 +216,7 @@ class ApiRouter {
//
this.router.get('/sessions', SessionController.getAllWithUserData.bind(this))
this.router.delete('/sessions/:id', SessionController.middleware.bind(this), SessionController.delete.bind(this))
+ this.router.get('/sessions/open', SessionController.getOpenSessions.bind(this))
this.router.post('/session/local', SessionController.syncLocal.bind(this))
this.router.post('/session/local-all', SessionController.syncLocalSessions.bind(this))
// TODO: Update these endpoints because they are only for open playback sessions
@@ -407,6 +410,12 @@ class ApiRouter {
await this.cacheManager.purgeCoverCache(libraryItem.id)
}
+ const itemMetadataPath = Path.join(global.MetadataPath, 'items', libraryItem.id)
+ if (await fs.pathExists(itemMetadataPath)) {
+ Logger.debug(`[ApiRouter] Removing item metadata path "${itemMetadataPath}"`)
+ await fs.remove(itemMetadataPath)
+ }
+
await this.db.removeLibraryItem(libraryItem.id)
SocketAuthority.emitter('item_removed', libraryItem.toJSONExpanded())
}
diff --git a/server/scanner/MediaFileScanner.js b/server/scanner/MediaFileScanner.js
index adf2098b..b0c00b20 100644
--- a/server/scanner/MediaFileScanner.js
+++ b/server/scanner/MediaFileScanner.js
@@ -221,7 +221,6 @@ class MediaFileScanner {
*/
async scanMediaFiles(mediaLibraryFiles, libraryItem, libraryScan = null) {
const preferAudioMetadata = libraryScan ? !!libraryScan.preferAudioMetadata : !!global.ServerSettings.scannerPreferAudioMetadata
- const preferOverdriveMediaMarker = !!global.ServerSettings.scannerPreferOverdriveMediaMarker
let hasUpdated = false
diff --git a/server/scanner/Scanner.js b/server/scanner/Scanner.js
index 682700b5..d6f20930 100644
--- a/server/scanner/Scanner.js
+++ b/server/scanner/Scanner.js
@@ -68,7 +68,7 @@ class Scanner {
async scanLibraryItem(libraryMediaType, folder, libraryItem) {
// TODO: Support for single media item
- const libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, libraryItem.path, false, this.db.serverSettings)
+ const libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, libraryItem.path, false)
if (!libraryItemData) {
return ScanResult.NOTHING
}
@@ -173,7 +173,7 @@ class Scanner {
// Scan each library
for (let i = 0; i < libraryScan.folders.length; i++) {
const folder = libraryScan.folders[i]
- const itemDataFoundInFolder = await scanFolder(libraryScan.libraryMediaType, folder, this.db.serverSettings)
+ const itemDataFoundInFolder = await scanFolder(libraryScan.libraryMediaType, folder)
libraryScan.addLog(LogLevel.INFO, `${itemDataFoundInFolder.length} item data found in folder "${folder.fullPath}"`)
libraryItemDataFound = libraryItemDataFound.concat(itemDataFoundInFolder)
}
@@ -200,11 +200,22 @@ class Scanner {
// Find library item folder with matching inode or matching path
const dataFound = libraryItemDataFound.find(lid => lid.ino === libraryItem.ino || comparePaths(lid.relPath, libraryItem.relPath))
if (!dataFound) {
- libraryScan.addLog(LogLevel.WARN, `Library Item "${libraryItem.media.metadata.title}" is missing`)
- Logger.warn(`[Scanner] Library item "${libraryItem.media.metadata.title}" is missing (inode "${libraryItem.ino}")`)
- libraryScan.resultsMissing++
- libraryItem.setMissing()
- itemsToUpdate.push(libraryItem)
+ // Podcast folder can have no episodes and still be valid
+ if (libraryScan.libraryMediaType === 'podcast' && await fs.pathExists(libraryItem.path)) {
+ Logger.info(`[Scanner] Library item "${libraryItem.media.metadata.title}" folder exists but has no episodes`)
+ if (libraryItem.isMissing) {
+ libraryScan.resultsUpdated++
+ libraryItem.isMissing = false
+ libraryItem.setLastScan()
+ itemsToUpdate.push(libraryItem)
+ }
+ } else {
+ libraryScan.addLog(LogLevel.WARN, `Library Item "${libraryItem.media.metadata.title}" is missing`)
+ Logger.warn(`[Scanner] Library item "${libraryItem.media.metadata.title}" is missing (inode "${libraryItem.ino}")`)
+ libraryScan.resultsMissing++
+ libraryItem.setMissing()
+ itemsToUpdate.push(libraryItem)
+ }
} else {
const checkRes = libraryItem.checkScanData(dataFound)
if (checkRes.newLibraryFiles.length || libraryScan.scanOptions.forceRescan) { // Item has new files
@@ -632,7 +643,7 @@ class Scanner {
}
async scanPotentialNewLibraryItem(libraryMediaType, folder, fullPath, isSingleMediaItem = false) {
- const libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, fullPath, isSingleMediaItem, this.db.serverSettings)
+ const libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, fullPath, isSingleMediaItem)
if (!libraryItemData) return null
return this.scanNewLibraryItem(libraryItemData, libraryMediaType)
}
diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js
index d59a3ec4..58200349 100644
--- a/server/utils/podcastUtils.js
+++ b/server/utils/podcastUtils.js
@@ -74,23 +74,31 @@ function extractPodcastMetadata(channel) {
function extractEpisodeData(item) {
// Episode must have url
- if (!item.enclosure || !item.enclosure.length || !item.enclosure[0]['$'] || !item.enclosure[0]['$'].url) {
+ if (!item.enclosure?.[0]?.['$']?.url) {
Logger.error(`[podcastUtils] Invalid podcast episode data`)
return null
}
- var episode = {
+ const episode = {
enclosure: {
...item.enclosure[0]['$']
}
}
+ episode.enclosure.url = episode.enclosure.url.trim()
+
// Full description with html
if (item['content:encoded']) {
const rawDescription = (extractFirstArrayItem(item, 'content:encoded') || '').trim()
episode.description = htmlSanitizer.sanitize(rawDescription)
}
+ // Extract chapters
+ if (item['podcast:chapters']?.[0]?.['$']?.url) {
+ episode.chaptersUrl = item['podcast:chapters'][0]['$'].url
+ episode.chaptersType = item['podcast:chapters'][0]['$'].type || 'application/json'
+ }
+
// Supposed to be the plaintext description but not always followed
if (item['description']) {
const rawDescription = extractFirstArrayItem(item, 'description') || ''
@@ -133,14 +141,16 @@ function cleanEpisodeData(data) {
duration: data.duration || '',
explicit: data.explicit || '',
publishedAt,
- enclosure: data.enclosure
+ enclosure: data.enclosure,
+ chaptersUrl: data.chaptersUrl || null,
+ chaptersType: data.chaptersType || null
}
}
function extractPodcastEpisodes(items) {
- var episodes = []
+ const episodes = []
items.forEach((item) => {
- var extracted = extractEpisodeData(item)
+ const extracted = extractEpisodeData(item)
if (extracted) {
episodes.push(cleanEpisodeData(extracted))
}
diff --git a/server/utils/prober.js b/server/utils/prober.js
index bc0f55cc..37f90bd8 100644
--- a/server/utils/prober.js
+++ b/server/utils/prober.js
@@ -186,7 +186,7 @@ function parseTags(format, verbose) {
file_tag_seriespart: tryGrabTags(format, 'series-part', 'episode_id', 'mvin'),
file_tag_isbn: tryGrabTags(format, 'isbn'), // custom
file_tag_language: tryGrabTags(format, 'language', 'lang'),
- file_tag_asin: tryGrabTags(format, 'asin'), // custom
+ file_tag_asin: tryGrabTags(format, 'asin', 'audible_asin'), // custom
file_tag_itunesid: tryGrabTags(format, 'itunes-id'), // custom
file_tag_podcasttype: tryGrabTags(format, 'podcast-type'), // custom
file_tag_episodetype: tryGrabTags(format, 'episode-type'), // custom
diff --git a/server/utils/scandir.js b/server/utils/scandir.js
index 9df5254b..7f5b9855 100644
--- a/server/utils/scandir.js
+++ b/server/utils/scandir.js
@@ -175,7 +175,7 @@ function cleanFileObjects(libraryItemPath, files) {
}
// Scan folder
-async function scanFolder(libraryMediaType, folder, serverSettings = {}) {
+async function scanFolder(libraryMediaType, folder) {
const folderPath = filePathToPOSIX(folder.fullPath)
const pathExists = await fs.pathExists(folderPath)
@@ -216,7 +216,7 @@ async function scanFolder(libraryMediaType, folder, serverSettings = {}) {
fileObjs = await cleanFileObjects(folderPath, [libraryItemPath])
isFile = true
} else {
- libraryItemData = getDataFromMediaDir(libraryMediaType, folderPath, libraryItemPath, serverSettings, libraryItemGrouping[libraryItemPath])
+ libraryItemData = getDataFromMediaDir(libraryMediaType, folderPath, libraryItemPath)
fileObjs = await cleanFileObjects(libraryItemData.path, libraryItemGrouping[libraryItemPath])
}
@@ -347,19 +347,18 @@ function getPodcastDataFromDir(folderPath, relPath) {
}
}
-function getDataFromMediaDir(libraryMediaType, folderPath, relPath, serverSettings, fileNames) {
+function getDataFromMediaDir(libraryMediaType, folderPath, relPath) {
if (libraryMediaType === 'podcast') {
return getPodcastDataFromDir(folderPath, relPath)
} else if (libraryMediaType === 'book') {
- var parseSubtitle = !!serverSettings.scannerParseSubtitle
- return getBookDataFromDir(folderPath, relPath, parseSubtitle)
+ return getBookDataFromDir(folderPath, relPath, !!global.ServerSettings.scannerParseSubtitle)
} else {
return getPodcastDataFromDir(folderPath, relPath)
}
}
// Called from Scanner.js
-async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath, isSingleMediaItem, serverSettings = {}) {
+async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath, isSingleMediaItem) {
libraryItemPath = filePathToPOSIX(libraryItemPath)
const folderFullPath = filePathToPOSIX(folder.fullPath)
@@ -384,8 +383,7 @@ async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath,
}
} else {
fileItems = await recurseFiles(libraryItemPath)
- const fileNames = fileItems.map(i => i.name)
- libraryItemData = getDataFromMediaDir(libraryMediaType, folderFullPath, libraryItemDir, serverSettings, fileNames)
+ libraryItemData = getDataFromMediaDir(libraryMediaType, folderFullPath, libraryItemDir)
}
const libraryItemDirStats = await getFileTimestampsWithIno(libraryItemData.path)
diff --git a/server/utils/toneHelpers.js b/server/utils/toneHelpers.js
index d1a2b166..384b4c5d 100644
--- a/server/utils/toneHelpers.js
+++ b/server/utils/toneHelpers.js
@@ -73,6 +73,10 @@ module.exports.writeToneMetadataJsonFile = (libraryItem, chapters, filePath, tra
}
module.exports.tagAudioFile = (filePath, payload) => {
+ if (process.env.TONE_PATH) {
+ tone.TONE_PATH = process.env.TONE_PATH
+ }
+
return tone.tag(filePath, payload).then((data) => {
return true
}).catch((error) => {
diff --git a/server/utils/zipHelpers.js b/server/utils/zipHelpers.js
new file mode 100644
index 00000000..c1617272
--- /dev/null
+++ b/server/utils/zipHelpers.js
@@ -0,0 +1,52 @@
+const Logger = require('../Logger')
+const archiver = require('../libs/archiver')
+
+module.exports.zipDirectoryPipe = (path, filename, res) => {
+ return new Promise((resolve, reject) => {
+ // create a file to stream archive data to
+ res.attachment(filename)
+
+ const archive = archiver('zip', {
+ zlib: { level: 9 } // Sets the compression level.
+ })
+
+ // listen for all archive data to be written
+ // 'close' event is fired only when a file descriptor is involved
+ res.on('close', () => {
+ Logger.info(archive.pointer() + ' total bytes')
+ Logger.debug('archiver has been finalized and the output file descriptor has closed.')
+ resolve()
+ })
+
+ // This event is fired when the data source is drained no matter what was the data source.
+ // It is not part of this library but rather from the NodeJS Stream API.
+ // @see: https://nodejs.org/api/stream.html#stream_event_end
+ res.on('end', () => {
+ Logger.debug('Data has been drained')
+ })
+
+ // good practice to catch warnings (ie stat failures and other non-blocking errors)
+ archive.on('warning', function (err) {
+ if (err.code === 'ENOENT') {
+ // log warning
+ Logger.warn(`[DownloadManager] Archiver warning: ${err.message}`)
+ } else {
+ // throw error
+ Logger.error(`[DownloadManager] Archiver error: ${err.message}`)
+ // throw err
+ reject(err)
+ }
+ })
+ archive.on('error', function (err) {
+ Logger.error(`[DownloadManager] Archiver error: ${err.message}`)
+ reject(err)
+ })
+
+ // pipe archive data to the file
+ archive.pipe(res)
+
+ archive.directory(path, false)
+
+ archive.finalize()
+ })
+}
\ No newline at end of file