+
+
+
+
@@ -63,6 +67,9 @@ export default {
if (!this.playbackSession.coverPath) return `${this.$config.routerBasePath}/book_placeholder.jpg`
return `${this.$config.routerBasePath}/public/share/${this.mediaItemShare.slug}/cover`
},
+ downloadUrl() {
+ return `${process.env.serverUrl}/public/share/${this.mediaItemShare.slug}/download`
+ },
audioTracks() {
return (this.playbackSession.audioTracks || []).map((track) => {
track.relativeContentUrl = track.contentUrl
@@ -103,6 +110,84 @@ export default {
}
},
methods: {
+ mediaSessionPlay() {
+ console.log('Media session play')
+ this.play()
+ },
+ mediaSessionPause() {
+ console.log('Media session pause')
+ this.pause()
+ },
+ mediaSessionStop() {
+ console.log('Media session stop')
+ this.pause()
+ },
+ mediaSessionSeekBackward() {
+ console.log('Media session seek backward')
+ this.jumpBackward()
+ },
+ mediaSessionSeekForward() {
+ console.log('Media session seek forward')
+ this.jumpForward()
+ },
+ mediaSessionSeekTo(e) {
+ console.log('Media session seek to', e)
+ if (e.seekTime !== null && !isNaN(e.seekTime)) {
+ this.seek(e.seekTime)
+ }
+ },
+ mediaSessionPreviousTrack() {
+ if (this.$refs.audioPlayer) {
+ this.$refs.audioPlayer.prevChapter()
+ }
+ },
+ mediaSessionNextTrack() {
+ if (this.$refs.audioPlayer) {
+ this.$refs.audioPlayer.nextChapter()
+ }
+ },
+ updateMediaSessionPlaybackState() {
+ if ('mediaSession' in navigator) {
+ navigator.mediaSession.playbackState = this.isPlaying ? 'playing' : 'paused'
+ }
+ },
+ setMediaSession() {
+ // https://developer.mozilla.org/en-US/docs/Web/API/Media_Session_API
+ if ('mediaSession' in navigator) {
+ const chapterInfo = []
+ if (this.chapters.length > 0) {
+ this.chapters.forEach((chapter) => {
+ chapterInfo.push({
+ title: chapter.title,
+ startTime: chapter.start
+ })
+ })
+ }
+
+ navigator.mediaSession.metadata = new MediaMetadata({
+ title: this.mediaItemShare.playbackSession.displayTitle || 'No title',
+ artist: this.mediaItemShare.playbackSession.displayAuthor || 'Unknown',
+ artwork: [
+ {
+ src: this.coverUrl
+ }
+ ],
+ chapterInfo
+ })
+ console.log('Set media session metadata', navigator.mediaSession.metadata)
+
+ navigator.mediaSession.setActionHandler('play', this.mediaSessionPlay)
+ navigator.mediaSession.setActionHandler('pause', this.mediaSessionPause)
+ navigator.mediaSession.setActionHandler('stop', this.mediaSessionStop)
+ navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward)
+ navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward)
+ navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo)
+ navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionSeekBackward)
+ navigator.mediaSession.setActionHandler('nexttrack', this.mediaSessionSeekForward)
+ } else {
+ console.warn('Media session not available')
+ }
+ },
async coverImageLoaded(e) {
if (!this.playbackSession.coverPath) return
const fac = new FastAverageColor()
@@ -119,8 +204,19 @@ export default {
})
},
playPause() {
+ if (this.isPlaying) {
+ this.pause()
+ } else {
+ this.play()
+ }
+ },
+ play() {
if (!this.localAudioPlayer || !this.hasLoaded) return
- this.localAudioPlayer.playPause()
+ this.localAudioPlayer.play()
+ },
+ pause() {
+ if (!this.localAudioPlayer || !this.hasLoaded) return
+ this.localAudioPlayer.pause()
},
jumpForward() {
if (!this.localAudioPlayer || !this.hasLoaded) return
@@ -206,6 +302,7 @@ export default {
} else {
this.stopPlayInterval()
}
+ this.updateMediaSessionPlaybackState()
},
playerTimeUpdate(time) {
this.setCurrentTime(time)
@@ -247,6 +344,9 @@ export default {
},
playerFinished() {
console.log('Player finished')
+ },
+ downloadShareItem() {
+ this.$downloadFile(this.downloadUrl)
}
},
mounted() {
@@ -266,6 +366,8 @@ export default {
this.localAudioPlayer.on('timeupdate', this.playerTimeUpdate.bind(this))
this.localAudioPlayer.on('error', this.playerError.bind(this))
this.localAudioPlayer.on('finished', this.playerFinished.bind(this))
+
+ this.setMediaSession()
},
beforeDestroy() {
window.removeEventListener('resize', this.resize)
diff --git a/client/strings/bg.json b/client/strings/bg.json
index 8e124d063..bc4db4f6a 100644
--- a/client/strings/bg.json
+++ b/client/strings/bg.json
@@ -729,7 +729,6 @@
"ToastBookmarkUpdateSuccess": "Отметката е обновена",
"ToastChaptersHaveErrors": "Главите имат грешки",
"ToastChaptersMustHaveTitles": "Главите трябва да имат заглавия",
- "ToastCollectionItemsRemoveSuccess": "Елемент(и) премахнати от колекция",
"ToastCollectionRemoveSuccess": "Колекцията е премахната",
"ToastCollectionUpdateSuccess": "Колекцията е обновена",
"ToastItemCoverUpdateSuccess": "Корицата на елемента е обновена",
diff --git a/client/strings/bn.json b/client/strings/bn.json
index 16f8a4478..e88db2e51 100644
--- a/client/strings/bn.json
+++ b/client/strings/bn.json
@@ -951,8 +951,6 @@
"ToastChaptersRemoved": "অধ্যায়গুলো মুছে ফেলা হয়েছে",
"ToastChaptersUpdated": "অধ্যায় আপডেট করা হয়েছে",
"ToastCollectionItemsAddFailed": "আইটেম(গুলি) সংগ্রহে যোগ করা ব্যর্থ হয়েছে",
- "ToastCollectionItemsAddSuccess": "আইটেম(গুলি) সংগ্রহে যোগ করা সফল হয়েছে",
- "ToastCollectionItemsRemoveSuccess": "আইটেম(গুলি) সংগ্রহ থেকে সরানো হয়েছে",
"ToastCollectionRemoveSuccess": "সংগ্রহ সরানো হয়েছে",
"ToastCollectionUpdateSuccess": "সংগ্রহ আপডেট করা হয়েছে",
"ToastCoverUpdateFailed": "কভার আপডেট ব্যর্থ হয়েছে",
diff --git a/client/strings/ca.json b/client/strings/ca.json
index f7e85ae25..bebb17e92 100644
--- a/client/strings/ca.json
+++ b/client/strings/ca.json
@@ -904,8 +904,6 @@
"ToastChaptersRemoved": "Capítols eliminats",
"ToastChaptersUpdated": "Capítols actualitzats",
"ToastCollectionItemsAddFailed": "Error en afegir elements a la col·lecció",
- "ToastCollectionItemsAddSuccess": "Elements afegits a la col·lecció",
- "ToastCollectionItemsRemoveSuccess": "Elements eliminats de la col·lecció",
"ToastCollectionRemoveSuccess": "Col·lecció eliminada",
"ToastCollectionUpdateSuccess": "Col·lecció actualitzada",
"ToastCoverUpdateFailed": "Error en actualitzar la portada",
diff --git a/client/strings/cs.json b/client/strings/cs.json
index d079d7a55..3db3ddaaf 100644
--- a/client/strings/cs.json
+++ b/client/strings/cs.json
@@ -234,7 +234,7 @@
"LabelAppend": "Připojit",
"LabelAudioBitrate": "Bitový tok zvuku (např. 128k)",
"LabelAudioChannels": "Zvukové kanály (1 nebo 2)",
- "LabelAudioCodec": "Kodek audia",
+ "LabelAudioCodec": "Audio Kodek",
"LabelAuthor": "Autor",
"LabelAuthorFirstLast": "Autor (jméno a příjmení)",
"LabelAuthorLastFirst": "Autor (příjmení a jméno)",
@@ -420,6 +420,7 @@
"LabelMatchExistingUsersBy": "Přiřadit stávající uživatele podle",
"LabelMatchExistingUsersByDescription": "Slouží k propojení stávajících uživatelů. Po propojení budou uživatelé přiřazeni k jedinečnému ID od poskytovatele SSO.",
"LabelMaxEpisodesToDownload": "Maximální # epizod pro stažení. Použijte 0 pro bez omezení.",
+ "LabelMaxEpisodesToDownloadPerCheck": "Maximální počet nových epizod ke stažení při jedné kontrole",
"LabelMaxEpisodesToKeep": "Maximální počet epizod k zachování",
"LabelMaxEpisodesToKeepHelp": "Hodnotou 0 není nastaven žádný maximální limit. Po automatickém stažení nové epizody se odstraní nejstarší epizoda, pokud máte více než X epizod. Při každém novém stažení se odstraní pouze 1 epizoda.",
"LabelMediaPlayer": "Přehrávač médií",
@@ -735,6 +736,7 @@
"MessageConfirmPurgeCache": "Vyčistit mezipaměť odstraní celý adresář na adrese /metadata/cache.
Určitě chcete odstranit adresář mezipaměti?",
"MessageConfirmPurgeItemsCache": "Vyčištění mezipaměti položek odstraní celý adresář /metadata/cache/items. Jste si jistí?",
"MessageConfirmQuickEmbed": "Varování! Rychlé vložení nezálohuje vaše zvukové soubory. Ujistěte se, že máte zálohu zvukových souborů.
Chcete pokračovat?",
+ "MessageConfirmQuickMatchEpisodes": "Pokud je nalezena shoda při rychlém párování epizod, dojde k přepsání podrobností. Aktualizovány budou pouze nespárované epizody. Jste si jisti?",
"MessageConfirmReScanLibraryItems": "Opravdu chcete znovu prohledat {0} položky?",
"MessageConfirmRemoveAllChapters": "Opravdu chcete odstranit všechny kapitoly?",
"MessageConfirmRemoveAuthor": "Opravdu chcete odstranit autora \"{0}\"?",
@@ -742,6 +744,7 @@
"MessageConfirmRemoveEpisode": "Opravdu chcete odstranit epizodu \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Opravdu chcete odstranit {0} epizody?",
"MessageConfirmRemoveListeningSessions": "Opravdu chcete odebrat {0} poslechových relací?",
+ "MessageConfirmRemoveMetadataFiles": "Jste si jisti, že chcete odstranit všechny metadata.{0} soubory ve složkách s položkami ve vaší knihovně?",
"MessageConfirmRemoveNarrator": "Opravdu chcete odebrat předčítání \"{0}\"?",
"MessageConfirmRemovePlaylist": "Opravdu chcete odstranit svůj playlist \"{0}\"?",
"MessageConfirmRenameGenre": "Opravdu chcete přejmenovat žánr \"{0}\" na \"{1}\" pro všechny položky?",
@@ -757,6 +760,7 @@
"MessageDragFilesIntoTrackOrder": "Přetáhněte soubory do správného pořadí stop",
"MessageEmbedFailed": "Vložení selhalo!",
"MessageEmbedFinished": "Vložení dokončeno!",
+ "MessageEmbedQueue": "Zařazeno do fronty pro vložení metadat ({0} ve frontě)",
"MessageEpisodesQueuedForDownload": "{0} Epizody zařazené do fronty ke stažení",
"MessageEreaderDevices": "Aby bylo zajištěno doručení elektronických knih, může být nutné přidat výše uvedenou e-mailovou adresu jako platného odesílatele pro každé zařízení uvedené níže.",
"MessageFeedURLWillBe": "URL zdroje bude {0}",
@@ -801,6 +805,7 @@
"MessageNoLogs": "Žádné protokoly",
"MessageNoMediaProgress": "Žádný průběh médií",
"MessageNoNotifications": "Žádná oznámení",
+ "MessageNoPodcastFeed": "Neplatný podcast: Žádný kanál",
"MessageNoPodcastsFound": "Nebyly nalezeny žádné podcasty",
"MessageNoResults": "Žádné výsledky",
"MessageNoSearchResultsFor": "Nebyly nalezeny žádné výsledky hledání pro \"{0}\"",
@@ -817,7 +822,10 @@
"MessagePlaylistCreateFromCollection": "Vytvořit seznam skladeb z kolekce",
"MessagePleaseWait": "Čekejte prosím...",
"MessagePodcastHasNoRSSFeedForMatching": "Podcast nemá žádnou adresu URL kanálu RSS, kterou by mohl použít pro porovnávání",
- "MessageQuickMatchDescription": "Vyplňte prázdné detaily položky a obálku prvním výsledkem shody z '{0}'. Nepřepisuje podrobnosti, pokud není povoleno nastavení serveru \"Preferovat párování metadata\".",
+ "MessageQuickEmbedInProgress": "Probíhá rychlé vkládání",
+ "MessageQuickEmbedQueue": "Zařazeno do fronty pro rychlé vložení ({0} ve frontě)",
+ "MessageQuickMatchAllEpisodes": "Rychlá shoda všech epizod",
+ "MessageQuickMatchDescription": "Vyplnit prázdné detaily položky a obálky prvním výsledkem shody z '{0}'. Nepřepisuje detaily, pokud není povoleno nastavení serveru 'Preferovat shodná metadata'.",
"MessageRemoveChapter": "Odstranit kapitolu",
"MessageRemoveEpisodes": "Odstranit {0} epizodu",
"MessageRemoveFromPlayerQueue": "Odstranit z fronty přehrávače",
@@ -848,10 +856,13 @@
"MessageTaskFailedToMergeAudioFiles": "Spojení audio souborů selhalo",
"MessageTaskFailedToMoveM4bFile": "Přesunutí m4b souboru selhalo",
"MessageTaskFailedToWriteMetadataFile": "Zápis souboru metadat selhal",
+ "MessageTaskMatchingBooksInLibrary": "Párování knih v knihovně „{0}“",
"MessageTaskNoFilesToScan": "Žádné soubory ke skenování",
"MessageTaskOpmlImport": "Import OPML",
"MessageTaskOpmlImportDescription": "Vytváření podcastů z {0} RSS feedů",
+ "MessageTaskOpmlImportFeed": "Importní zdroj OPML",
"MessageTaskOpmlImportFeedDescription": "Importování RSS feedu \"{0}\"",
+ "MessageTaskOpmlImportFeedFailed": "Nepodařilo se získat kanál podcastu",
"MessageTaskOpmlImportFeedPodcastDescription": "Vytváření podcastu \"{0}\"",
"MessageTaskOpmlImportFeedPodcastExists": "Podcast se stejnou cestou již existuje",
"MessageTaskOpmlImportFeedPodcastFailed": "Vytváření podcastu selhalo",
@@ -932,7 +943,6 @@
"ToastChaptersHaveErrors": "Kapitoly obsahují chyby",
"ToastChaptersMustHaveTitles": "Kapitoly musí mít názvy",
"ToastChaptersRemoved": "Kapitoly odstraněny",
- "ToastCollectionItemsRemoveSuccess": "Položky odstraněny z kolekce",
"ToastCollectionRemoveSuccess": "Kolekce odstraněna",
"ToastCollectionUpdateSuccess": "Kolekce aktualizována",
"ToastCoverUpdateFailed": "Aktualizace obálky selhala",
diff --git a/client/strings/da.json b/client/strings/da.json
index b4b92bc86..0f7b1eedb 100644
--- a/client/strings/da.json
+++ b/client/strings/da.json
@@ -19,6 +19,7 @@
"ButtonChooseFiles": "Vælg filer",
"ButtonClearFilter": "Ryd filter",
"ButtonCloseFeed": "Luk feed",
+ "ButtonCloseSession": "Luk Åben Session",
"ButtonCollections": "Samlinger",
"ButtonConfigureScanner": "Konfigurer scanner",
"ButtonCreate": "Opret",
@@ -29,7 +30,9 @@
"ButtonEditChapters": "Rediger kapitler",
"ButtonEditPodcast": "Rediger podcast",
"ButtonEnable": "Aktiver",
- "ButtonForceReScan": "Tvungen genindlæsning",
+ "ButtonFireAndFail": "Affyring Og Fejl",
+ "ButtonFireOnTest": "Affyring vedTest begivenhed",
+ "ButtonForceReScan": "Tving genindlæsning",
"ButtonFullPath": "Fuld sti",
"ButtonHide": "Skjul",
"ButtonHome": "Hjem",
@@ -637,7 +640,6 @@
"ToastBookmarkUpdateSuccess": "Bogmærke opdateret",
"ToastChaptersHaveErrors": "Kapitler har fejl",
"ToastChaptersMustHaveTitles": "Kapitler skal have titler",
- "ToastCollectionItemsRemoveSuccess": "Element(er) fjernet fra samlingen",
"ToastCollectionRemoveSuccess": "Samling fjernet",
"ToastCollectionUpdateSuccess": "Samling opdateret",
"ToastItemCoverUpdateSuccess": "Varens omslag opdateret",
diff --git a/client/strings/de.json b/client/strings/de.json
index db298c75a..6ac20fabc 100644
--- a/client/strings/de.json
+++ b/client/strings/de.json
@@ -959,8 +959,6 @@
"ToastChaptersRemoved": "Kapitel entfernt",
"ToastChaptersUpdated": "Kapitel aktualisiert",
"ToastCollectionItemsAddFailed": "Das Hinzufügen von Element(en) zur Sammlung ist fehlgeschlagen",
- "ToastCollectionItemsAddSuccess": "Element(e) erfolgreich zur Sammlung hinzugefügt",
- "ToastCollectionItemsRemoveSuccess": "Medien aus der Sammlung entfernt",
"ToastCollectionRemoveSuccess": "Sammlung entfernt",
"ToastCollectionUpdateSuccess": "Sammlung aktualisiert",
"ToastCoverUpdateFailed": "Cover-Update fehlgeschlagen",
diff --git a/client/strings/en-us.json b/client/strings/en-us.json
index 442a3ddbd..65417d48a 100644
--- a/client/strings/en-us.json
+++ b/client/strings/en-us.json
@@ -301,6 +301,7 @@
"LabelDiscover": "Discover",
"LabelDownload": "Download",
"LabelDownloadNEpisodes": "Download {0} episodes",
+ "LabelDownloadable": "Downloadable",
"LabelDuration": "Duration",
"LabelDurationComparisonExactMatch": "(exact match)",
"LabelDurationComparisonLonger": "({0} longer)",
@@ -589,6 +590,7 @@
"LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders",
"LabelSettingsTimeFormat": "Time Format",
"LabelShare": "Share",
+ "LabelShareDownloadableHelp": "Allows users with the share link to download a zip file of the library item.",
"LabelShareOpen": "Share Open",
"LabelShareURL": "Share URL",
"LabelShowAll": "Show All",
@@ -960,8 +962,6 @@
"ToastChaptersRemoved": "Chapters removed",
"ToastChaptersUpdated": "Chapters updated",
"ToastCollectionItemsAddFailed": "Item(s) added to collection failed",
- "ToastCollectionItemsAddSuccess": "Item(s) added to collection success",
- "ToastCollectionItemsRemoveSuccess": "Item(s) removed from collection",
"ToastCollectionRemoveSuccess": "Collection removed",
"ToastCollectionUpdateSuccess": "Collection updated",
"ToastCoverUpdateFailed": "Cover update failed",
diff --git a/client/strings/es.json b/client/strings/es.json
index fa4f8c455..4196b6dd7 100644
--- a/client/strings/es.json
+++ b/client/strings/es.json
@@ -959,8 +959,6 @@
"ToastChaptersRemoved": "Capítulos eliminados",
"ToastChaptersUpdated": "Capítulos actualizados",
"ToastCollectionItemsAddFailed": "Artículo(s) añadido(s) a la colección fallido(s)",
- "ToastCollectionItemsAddSuccess": "Artículo(s) añadido(s) a la colección correctamente",
- "ToastCollectionItemsRemoveSuccess": "Elementos(s) removidos de la colección",
"ToastCollectionRemoveSuccess": "Colección removida",
"ToastCollectionUpdateSuccess": "Colección actualizada",
"ToastCoverUpdateFailed": "Error al actualizar la cubierta",
diff --git a/client/strings/et.json b/client/strings/et.json
index 8d256c011..835666f87 100644
--- a/client/strings/et.json
+++ b/client/strings/et.json
@@ -713,7 +713,6 @@
"ToastBookmarkUpdateSuccess": "Järjehoidja värskendatud",
"ToastChaptersHaveErrors": "Peatükkidel on vigu",
"ToastChaptersMustHaveTitles": "Peatükkidel peab olema pealkiri",
- "ToastCollectionItemsRemoveSuccess": "Üksus(ed) eemaldatud kogumist",
"ToastCollectionRemoveSuccess": "Kogum eemaldatud",
"ToastCollectionUpdateSuccess": "Kogum värskendatud",
"ToastItemCoverUpdateSuccess": "Üksuse kaas värskendatud",
diff --git a/client/strings/fr.json b/client/strings/fr.json
index 2c92bf7c2..2dfe5b0b2 100644
--- a/client/strings/fr.json
+++ b/client/strings/fr.json
@@ -953,8 +953,6 @@
"ToastChaptersRemoved": "Chapitres supprimés",
"ToastChaptersUpdated": "Chapitres mis à jour",
"ToastCollectionItemsAddFailed": "Échec de l’ajout de(s) élément(s) à la collection",
- "ToastCollectionItemsAddSuccess": "Ajout de(s) élément(s) à la collection réussi",
- "ToastCollectionItemsRemoveSuccess": "Élément(s) supprimé(s) de la collection",
"ToastCollectionRemoveSuccess": "Collection supprimée",
"ToastCollectionUpdateSuccess": "Collection mise à jour",
"ToastCoverUpdateFailed": "Échec de la mise à jour de la couverture",
diff --git a/client/strings/he.json b/client/strings/he.json
index a93d3f058..71a6df9ee 100644
--- a/client/strings/he.json
+++ b/client/strings/he.json
@@ -744,7 +744,6 @@
"ToastBookmarkUpdateSuccess": "הסימניה עודכנה בהצלחה",
"ToastChaptersHaveErrors": "פרקים מכילים שגיאות",
"ToastChaptersMustHaveTitles": "פרקים חייבים לכלול כותרות",
- "ToastCollectionItemsRemoveSuccess": "הפריט(ים) הוסרו מהאוסף בהצלחה",
"ToastCollectionRemoveSuccess": "האוסף הוסר בהצלחה",
"ToastCollectionUpdateSuccess": "האוסף עודכן בהצלחה",
"ToastItemCoverUpdateSuccess": "כריכת הפריט עודכנה בהצלחה",
diff --git a/client/strings/hr.json b/client/strings/hr.json
index bc66b8a56..8b824128c 100644
--- a/client/strings/hr.json
+++ b/client/strings/hr.json
@@ -959,8 +959,6 @@
"ToastChaptersRemoved": "Poglavlja uklonjena",
"ToastChaptersUpdated": "Poglavlja su ažurirana",
"ToastCollectionItemsAddFailed": "Neuspješno dodavanje stavki u zbirku",
- "ToastCollectionItemsAddSuccess": "Uspješno dodavanje stavki u zbirku",
- "ToastCollectionItemsRemoveSuccess": "Stavke izbrisane iz zbirke",
"ToastCollectionRemoveSuccess": "Zbirka izbrisana",
"ToastCollectionUpdateSuccess": "Zbirka ažurirana",
"ToastCoverUpdateFailed": "Ažuriranje naslovnice nije uspjelo",
diff --git a/client/strings/hu.json b/client/strings/hu.json
index 4aa240955..25f9a9be8 100644
--- a/client/strings/hu.json
+++ b/client/strings/hu.json
@@ -206,7 +206,7 @@
"HeaderUpdateDetails": "Részletek frissítése",
"HeaderUpdateLibrary": "Könyvtár frissítése",
"HeaderUsers": "Felhasználók",
- "HeaderYearReview": "{0} év áttekintése",
+ "HeaderYearReview": "{0} év visszatekintése",
"HeaderYourStats": "Saját statisztikák",
"LabelAbridged": "Tömörített",
"LabelAbridgedChecked": "Rövidített (ellenőrizve)",
@@ -478,7 +478,7 @@
"LabelPermissionsDownload": "Letölthet",
"LabelPermissionsUpdate": "Frissíthet",
"LabelPermissionsUpload": "Feltölthet",
- "LabelPersonalYearReview": "Az éved áttekintése ({0})",
+ "LabelPersonalYearReview": "Az évvisszatekintésed ({0})",
"LabelPhotoPathURL": "Fénykép útvonal/URL",
"LabelPlayMethod": "Lejátszási módszer",
"LabelPlayerChapterNumberMarker": "{0} a {1} -ből",
@@ -539,7 +539,7 @@
"LabelSeriesName": "Sorozat neve",
"LabelSeriesProgress": "Sorozat haladása",
"LabelServerLogLevel": "Kiszolgáló naplózási szint",
- "LabelServerYearReview": "Szerver évértékelő ({0})",
+ "LabelServerYearReview": "Szerver évvisszatekintés ({0})",
"LabelSetEbookAsPrimary": "Beállítás elsődlegesként",
"LabelSetEbookAsSupplementary": "Beállítás kiegészítőként",
"LabelSettingsAllowIframe": "A beágyazás engedélyezése egy iframe-be",
@@ -684,8 +684,8 @@
"LabelWeekdaysToRun": "Futás napjai",
"LabelXBooks": "{0} könyv",
"LabelXItems": "{0} elem",
- "LabelYearReviewHide": "Évértékelő elrejtése",
- "LabelYearReviewShow": "Évértékelés megtekintése",
+ "LabelYearReviewHide": "Az évvisszatekintés elrejtése",
+ "LabelYearReviewShow": "Évvisszatekintés megtekintése",
"LabelYourAudiobookDuration": "Hangoskönyv időtartama",
"LabelYourBookmarks": "Könyvjelzőid",
"LabelYourPlaylists": "Lejátszási listáid",
@@ -910,7 +910,7 @@
"StatsTopNarrator": "TOP ELŐADÓ",
"StatsTopNarrators": "TOP ELŐADÓ",
"StatsTotalDuration": "A teljes időtartam…",
- "StatsYearInReview": "ÉVÉRTÉKELÉS",
+ "StatsYearInReview": "ÉVVISSZATEKINTÉS",
"ToastAccountUpdateSuccess": "Fiók frissítve",
"ToastAppriseUrlRequired": "Meg kell adnia egy Apprise URL-címet",
"ToastAsinRequired": "ASIN kötelező",
@@ -945,7 +945,6 @@
"ToastChaptersMustHaveTitles": "A fejezeteknek címekkel kell rendelkezniük",
"ToastChaptersRemoved": "Fejezetek eltávolítva",
"ToastChaptersUpdated": "Fejezetek frissítve",
- "ToastCollectionItemsRemoveSuccess": "Elem(ek) eltávolítva a gyűjteményből",
"ToastCollectionRemoveSuccess": "Gyűjtemény eltávolítva",
"ToastCollectionUpdateSuccess": "Gyűjtemény frissítve",
"ToastCoverUpdateFailed": "A borító frissítése nem sikerült",
diff --git a/client/strings/it.json b/client/strings/it.json
index 70490e3b0..633dea897 100644
--- a/client/strings/it.json
+++ b/client/strings/it.json
@@ -950,8 +950,6 @@
"ToastChaptersRemoved": "Capitoli rimossi",
"ToastChaptersUpdated": "Capitoli aggiornati",
"ToastCollectionItemsAddFailed": "l'aggiunta dell'elemento(i) alla raccolta non è riuscito",
- "ToastCollectionItemsAddSuccess": "L'aggiunta dell'elemento(i) alla raccolta è riuscito",
- "ToastCollectionItemsRemoveSuccess": "Oggetto(i) rimossi dalla Raccolta",
"ToastCollectionRemoveSuccess": "Collezione rimossa",
"ToastCollectionUpdateSuccess": "Raccolta aggiornata",
"ToastCoverUpdateFailed": "Aggiornamento cover fallito",
diff --git a/client/strings/lt.json b/client/strings/lt.json
index 9fe65e3af..8c902f5a8 100644
--- a/client/strings/lt.json
+++ b/client/strings/lt.json
@@ -104,7 +104,7 @@
"ButtonViewAll": "Peržiūrėti visus",
"ButtonYes": "Taip",
"ErrorUploadFetchMetadataAPI": "Klaida gaunant metaduomenis",
- "ErrorUploadFetchMetadataNoResults": "Nepavyko gauti metaduomenų - pabandykite atnaujinti pavadinimą ir/ar autorių.",
+ "ErrorUploadFetchMetadataNoResults": "Nepavyko gauti metaduomenų - pabandykite atnaujinti pavadinimą ir/ar autorių",
"ErrorUploadLacksTitle": "Pavadinimas yra privalomas",
"HeaderAccount": "Paskyra",
"HeaderAdvanced": "Papildomi",
@@ -419,7 +419,7 @@
"LabelSettingsExperimentalFeatures": "Eksperimentiniai funkcionalumai",
"LabelSettingsExperimentalFeaturesHelp": "Funkcijos, kurios yra kuriamos ir laukiami jūsų komentarai. Spustelėkite, kad atidarytumėte „GitHub“ diskusiją.",
"LabelSettingsFindCovers": "Rasti viršelius",
- "LabelSettingsFindCoversHelp": "Jei jūsų audioknyga neturi įterpto viršelio arba viršelio paveikslėlio aplanke, bandyti rasti viršelį. Pastaba: Tai padidins skenavimo trukmę.",
+ "LabelSettingsFindCoversHelp": "Jei jūsų audioknyga neturi įterpto viršelio arba viršelio paveikslėlio aplanke, bandyti rasti viršelį. Pastaba: Tai padidins skenavimo trukmę",
"LabelSettingsHideSingleBookSeries": "Slėpti serijas, turinčias tik vieną knygą",
"LabelSettingsHideSingleBookSeriesHelp": "Serijos, turinčios tik vieną knygą, bus paslėptos nuo serijų puslapio ir pagrindinio puslapio lentynų.",
"LabelSettingsHomePageBookshelfView": "Naudoti pagrindinio puslapio knygų lentynų vaizdą",
@@ -666,8 +666,6 @@
"ToastChaptersMustHaveTitles": "Skyriai turi turėti pavadinimus",
"ToastChaptersRemoved": "Skyriai pašalinti",
"ToastCollectionItemsAddFailed": "Nepavyko pridėti į kolekciją",
- "ToastCollectionItemsAddSuccess": "Pridėta į kolekciją",
- "ToastCollectionItemsRemoveSuccess": "Elementai pašalinti iš kolekcijos",
"ToastCollectionRemoveSuccess": "Kolekcija pašalinta",
"ToastCollectionUpdateSuccess": "Kolekcija atnaujinta",
"ToastCoverUpdateFailed": "Viršelio atnaujinimas nepavyko",
diff --git a/client/strings/nl.json b/client/strings/nl.json
index bc5a40ca2..97bb1d01f 100644
--- a/client/strings/nl.json
+++ b/client/strings/nl.json
@@ -946,8 +946,6 @@
"ToastChaptersRemoved": "Hoofdstukken verwijderd",
"ToastChaptersUpdated": "Hoofdstukken bijgewerkt",
"ToastCollectionItemsAddFailed": "Item(s) toegevoegd aan collectie mislukt",
- "ToastCollectionItemsAddSuccess": "Item(s) toegevoegd aan collectie gelukt",
- "ToastCollectionItemsRemoveSuccess": "Onderdeel (of onderdelen) verwijderd uit collectie",
"ToastCollectionRemoveSuccess": "Collectie verwijderd",
"ToastCollectionUpdateSuccess": "Collectie bijgewerkt",
"ToastCoverUpdateFailed": "Cover update mislukt",
diff --git a/client/strings/no.json b/client/strings/no.json
index 592991b47..553a852e9 100644
--- a/client/strings/no.json
+++ b/client/strings/no.json
@@ -29,13 +29,16 @@
"ButtonEdit": "Rediger",
"ButtonEditChapters": "Rediger kapittel",
"ButtonEditPodcast": "Rediger podcast",
+ "ButtonEnable": "Aktiver",
+ "ButtonFireAndFail": "Kjør ved feil",
+ "ButtonFireOnTest": "Kjør onTest-kommando",
"ButtonForceReScan": "Tving skann",
"ButtonFullPath": "Full sti",
"ButtonHide": "Gjøm",
"ButtonHome": "Hjem",
"ButtonIssues": "Problemer",
- "ButtonJumpBackward": "Hopp Bakover",
- "ButtonJumpForward": "Hopp Fremover",
+ "ButtonJumpBackward": "Hopp bakover",
+ "ButtonJumpForward": "Hopp frem",
"ButtonLatest": "Siste",
"ButtonLibrary": "Bibliotek",
"ButtonLogout": "Logg ut",
@@ -45,24 +48,31 @@
"ButtonMatchAllAuthors": "Søk opp alle forfattere",
"ButtonMatchBooks": "Søk opp bøker",
"ButtonNevermind": "Avbryt",
+ "ButtonNext": "Neste",
"ButtonNextChapter": "Neste Kapittel",
+ "ButtonNextItemInQueue": "Neste element i køen",
+ "ButtonOk": "Ok",
"ButtonOpenFeed": "Åpne Feed",
"ButtonOpenManager": "Åpne behandler",
+ "ButtonPause": "Pause",
"ButtonPlay": "Spill av",
+ "ButtonPlayAll": "Spill av alle",
"ButtonPlaying": "Spiller av",
"ButtonPlaylists": "Spillelister",
"ButtonPrevious": "Forrige",
"ButtonPreviousChapter": "Forrige Kapittel",
+ "ButtonProbeAudioFile": "Analyser lydfil",
"ButtonPurgeAllCache": "Tøm alle mellomlager",
"ButtonPurgeItemsCache": "Tøm mellomlager",
"ButtonQueueAddItem": "Legg til kø",
"ButtonQueueRemoveItem": "Fjern fra kø",
- "ButtonQuickEmbedMetadata": "Hurtig Innbygging Av Metadata",
+ "ButtonQuickEmbed": "Hurtiginnbygging",
+ "ButtonQuickEmbedMetadata": "Bygg inn metadata",
"ButtonQuickMatch": "Kjapt søk",
"ButtonReScan": "Skann på nytt",
"ButtonRead": "Les",
- "ButtonReadLess": "Les Mindre",
- "ButtonReadMore": "Les Mer",
+ "ButtonReadLess": "Vis mindre",
+ "ButtonReadMore": "Vis mer",
"ButtonRefresh": "Oppdater",
"ButtonRemove": "Fjern",
"ButtonRemoveAll": "Fjern alle",
@@ -71,12 +81,15 @@
"ButtonRemoveFromContinueReading": "Fjern fra Fortsett å lese",
"ButtonRemoveSeriesFromContinueSeries": "Fjern serie fra Fortsett serie",
"ButtonReset": "Nullstill",
+ "ButtonResetToDefault": "Tilbakestill til standard",
"ButtonRestore": "Gjenopprett",
"ButtonSave": "Lagre",
"ButtonSaveAndClose": "Lagre og lukk",
"ButtonSaveTracklist": "Lagre spilleliste",
"ButtonScan": "Skann",
"ButtonScanLibrary": "Skann bibliotek",
+ "ButtonScrollLeft": "Rull til venstre",
+ "ButtonScrollRight": "Rull til høyre",
"ButtonSearch": "Søk",
"ButtonSelectFolderPath": "Velg mappe",
"ButtonSeries": "Serier",
@@ -88,20 +101,26 @@
"ButtonStartMetadataEmbed": "Start Metadata innbaking",
"ButtonStats": "Statistikk",
"ButtonSubmit": "Send inn",
+ "ButtonTest": "Test",
+ "ButtonUnlinkOpenId": "Koble fra OpenID",
"ButtonUpload": "Last opp",
"ButtonUploadBackup": "Last opp sikkerhetskopi",
"ButtonUploadCover": "Last opp cover",
"ButtonUploadOPMLFile": "Last opp OPML fil",
"ButtonUserDelete": "Slett bruker {0}",
"ButtonUserEdit": "Rediger bruker {0}",
- "ButtonViewAll": "Vis alt",
+ "ButtonViewAll": "Vis alle",
"ButtonYes": "Ja",
"ErrorUploadFetchMetadataAPI": "Feil ved innhenting av metadata",
+ "ErrorUploadFetchMetadataNoResults": "Kunne ikke hente metadata - forsøk å oppdatere tittel og/eller forfatter",
+ "ErrorUploadLacksTitle": "Tittel kreves",
"HeaderAccount": "Konto",
+ "HeaderAddCustomMetadataProvider": "Legg til egendefinert metadata tilbyder",
"HeaderAdvanced": "Avansert",
- "HeaderAppriseNotificationSettings": "Apprise notifikasjonsinstillinger",
+ "HeaderAppriseNotificationSettings": "Apprise varslingsinstillinger",
"HeaderAudioTracks": "Lydspor",
"HeaderAudiobookTools": "Lydbok Filbehandlingsverktøy",
+ "HeaderAuthentication": "Autentisering",
"HeaderBackups": "Sikkerhetskopier",
"HeaderChangePassword": "Bytt passord",
"HeaderChapters": "Kapittel",
@@ -110,6 +129,8 @@
"HeaderCollectionItems": "Samlingsgjenstander",
"HeaderCover": "Omslag",
"HeaderCurrentDownloads": "Aktive nedlastinger",
+ "HeaderCustomMessageOnLogin": "Egendefinert melding ved pålogging",
+ "HeaderCustomMetadataProviders": "Egendefinerte metadata tilbydere",
"HeaderDetails": "Detaljer",
"HeaderDownloadQueue": "Last ned kø",
"HeaderEbookFiles": "Ebook filer",
@@ -140,12 +161,17 @@
"HeaderMetadataToEmbed": "Metadata å bake inn",
"HeaderNewAccount": "Ny konto",
"HeaderNewLibrary": "Ny bibliotek",
- "HeaderNotifications": "Notifikasjoner",
+ "HeaderNotificationCreate": "Opprett varsling",
+ "HeaderNotificationUpdate": "Oppdater varsling",
+ "HeaderNotifications": "Varslinger",
"HeaderOpenIDConnectAuthentication": "Autentisering med OpenID Connect",
+ "HeaderOpenListeningSessions": "Åpne lyttesesjoner",
"HeaderOpenRSSFeed": "Åpne RSS Feed",
"HeaderOtherFiles": "Andre filer",
+ "HeaderPasswordAuthentication": "Logg inn med brukernavn og passord",
"HeaderPermissions": "Rettigheter",
"HeaderPlayerQueue": "Spiller kø",
+ "HeaderPlayerSettings": "Avspillingsinnstillinger",
"HeaderPlaylist": "Spilleliste",
"HeaderPlaylistItems": "Spillelisteelement",
"HeaderPodcastsToAdd": "Podcaster å legge til",
@@ -157,6 +183,7 @@
"HeaderRemoveEpisodes": "Fjern {0} episoder",
"HeaderSavedMediaProgress": "Lagret mediefremgang",
"HeaderSchedule": "Timeplan",
+ "HeaderScheduleEpisodeDownloads": "Planlegg automatisk nedlasting av episoder",
"HeaderScheduleLibraryScans": "Planlegg automatisk bibliotek skann",
"HeaderSession": "Sesjon",
"HeaderSetBackupSchedule": "Sett timeplan for sikkerhetskopi",
@@ -165,6 +192,7 @@
"HeaderSettingsExperimental": "Eksperimentelle funksjoner",
"HeaderSettingsGeneral": "Generell",
"HeaderSettingsScanner": "Skanner",
+ "HeaderSettingsWebClient": "Webklient",
"HeaderSleepTimer": "Sove timer",
"HeaderStatsLargestItems": "Største enheter",
"HeaderStatsLongestItems": "Lengste enheter (timer)",
@@ -179,9 +207,14 @@
"HeaderUpdateDetails": "Oppdater detaljer",
"HeaderUpdateLibrary": "Oppdater bibliotek",
"HeaderUsers": "Brukere",
+ "HeaderYearReview": "{0} oppsummert",
"HeaderYourStats": "Din statistikk",
"LabelAbridged": "Forkortet",
+ "LabelAbridgedChecked": "Forkortet (valgt)",
+ "LabelAbridgedUnchecked": "Forkortet (ikke valgt)",
+ "LabelAccessibleBy": "Tilgjengelig via",
"LabelAccountType": "Kontotype",
+ "LabelAccountTypeAdmin": "Administrator",
"LabelAccountTypeGuest": "Gjest",
"LabelAccountTypeUser": "Bruker",
"LabelActivity": "Aktivitet",
@@ -190,32 +223,55 @@
"LabelAddToPlaylist": "Legg til i spilleliste",
"LabelAddToPlaylistBatch": "Legg {0} enheter til i spilleliste",
"LabelAddedAt": "Lagt Til",
+ "LabelAddedDate": "La til {0}",
+ "LabelAdminUsersOnly": "Kun administratorer",
"LabelAll": "Alle",
"LabelAllUsers": "Alle brukere",
+ "LabelAllUsersExcludingGuests": "Alle brukere bortsett fra gjester",
+ "LabelAllUsersIncludingGuests": "Alle brukere inkludert gjester",
"LabelAlreadyInYourLibrary": "Allerede i biblioteket",
+ "LabelApiToken": "API token",
"LabelAppend": "Legge til",
+ "LabelAudioBitrate": "Bitrate for lyd (f.eks. 128k)",
+ "LabelAudioChannels": "Lydkanaler (1 eller 2)",
+ "LabelAudioCodec": "Audio Codec",
"LabelAuthor": "Forfatter",
"LabelAuthorFirstLast": "Forfatter (Fornavn Etternavn)",
"LabelAuthorLastFirst": "Forfatter (Etternavn Fornavn)",
"LabelAuthors": "Forfattere",
"LabelAutoDownloadEpisodes": "Last ned episoder automatisk",
+ "LabelAutoFetchMetadata": "Automatisk henting av metadata",
+ "LabelAutoFetchMetadataHelp": "Henter metadata for tittel, forfatter og serie for å optimalisere opplasting. Ekstra metadata må kanskje bekreftes etter opplasting.",
+ "LabelAutoLaunch": "Autostart",
+ "LabelAutoLaunchDescription": "Omdiriger til leverandør for innlogging automatisk når innloggingssiden åpnes. (Kan overstyres med /login?autoLaunch=0)",
+ "LabelAutoRegister": "Automatisk registrering",
+ "LabelAutoRegisterDescription": "Lag bruker automatisk ved første innlogging",
"LabelBackToUser": "Tilbake til bruker",
+ "LabelBackupAudioFiles": "Sikkerhetskopier lydfiler",
+ "LabelBackupLocation": "Mappe for sikkerhetskopiering",
"LabelBackupsEnableAutomaticBackups": "Aktiver automatisk sikkerhetskopi",
"LabelBackupsEnableAutomaticBackupsHelp": "Sikkerhetskopier lagret under /metadata/backups",
- "LabelBackupsMaxBackupSize": "Maks sikkerhetskopi størrelse (i GB)",
+ "LabelBackupsMaxBackupSize": "Maksimal størrelse for sikkerhetskopi (i GB) (0 for ubegrenset)",
"LabelBackupsMaxBackupSizeHelp": "For å forhindre feilkonfigurasjon, vil sikkerhetskopier mislykkes hvis de oveskride konfigurert størrelse.",
"LabelBackupsNumberToKeep": "Antall sikkerhetskopier som skal beholdes",
"LabelBackupsNumberToKeepHelp": "Kun 1 sikkerhetskopi vil bli fjernet om gangen, hvis du allerede har flere sikkerhetskopier enn dette bør du fjerne de manuelt.",
"LabelBitrate": "Bithastighet",
+ "LabelBonus": "Bonus",
"LabelBooks": "Bøker",
+ "LabelButtonText": "Tekst på knappen",
+ "LabelByAuthor": "av {0}",
"LabelChangePassword": "Endre passord",
"LabelChannels": "Kanaler",
+ "LabelChapterCount": "{0} kapitler",
"LabelChapterTitle": "Kapittel tittel",
"LabelChapters": "Kapitler",
"LabelChaptersFound": "kapitler funnet",
+ "LabelClickForMoreInfo": "Klikk for mer informasjon",
+ "LabelClickToUseCurrentValue": "Klikk for å bruke valgt verdi",
"LabelClosePlayer": "Lukk spiller",
"LabelCodec": "Kodek",
"LabelCollapseSeries": "Minimer serier",
+ "LabelCollapseSubSeries": "Skjul underserier",
"LabelCollection": "Samling",
"LabelCollections": "Samlings",
"LabelComplete": "Fullfør",
@@ -232,58 +288,94 @@
"LabelCustomCronExpression": "Tilpasset Cron utrykk:",
"LabelDatetime": "Dato tid",
"LabelDays": "Dager",
+ "LabelDeleteFromFileSystemCheckbox": "Slett fra filsystemet (fjern haken for kun å ta bort fra databasen)",
"LabelDescription": "Beskrivelse",
"LabelDeselectAll": "Fjern valg",
"LabelDevice": "Enhet",
"LabelDeviceInfo": "Enhetsinformasjon",
+ "LabelDeviceIsAvailableTo": "Enheten er tilgjengelig for...",
"LabelDirectory": "Mappe",
"LabelDiscFromFilename": "Disk fra filnavn",
"LabelDiscFromMetadata": "Disk fra metadata",
- "LabelDiscover": "Oppdagelse",
+ "LabelDiscover": "Oppdag",
"LabelDownload": "Last ned",
"LabelDownloadNEpisodes": "Last ned {0} episoder",
"LabelDuration": "Varighet",
+ "LabelDurationComparisonExactMatch": "(nøyaktig treff)",
+ "LabelDurationComparisonLonger": "({0} lenger)",
+ "LabelDurationComparisonShorter": "({0} kortere)",
"LabelDurationFound": "Varighet funnet:",
"LabelEbook": "Ebok",
"LabelEbooks": "E-bøker",
"LabelEdit": "Rediger",
"LabelEmail": "Epost",
"LabelEmailSettingsFromAddress": "Fra Adresse",
+ "LabelEmailSettingsRejectUnauthorized": "Avvis uautoriserte sertifikat",
+ "LabelEmailSettingsRejectUnauthorizedHelp": "Ved å deaktivere sjekk av SSL sertifikat eksponerer man tilkoblingen for sikkerhetsrisiko, som for eksempel mann-i-midten-angrep. Slå av kun om du forstår implikasjonene og stoler på e-post-serveren du kobler til!",
"LabelEmailSettingsSecure": "Sikker",
"LabelEmailSettingsSecureHelp": "Hvis aktivert, vil tilkoblingen bruke TLS under tilkobling til tjeneren. Ellers vil TLS bli brukt hvis tjeneren støtter STARTTLS utvidelsen. I de fleste tilfeller aktiver valget hvis du kobler til med port 465. Med port 587 eller 25 deaktiver valget. (fra nodemailer.com/smtp/#authentication)",
"LabelEmailSettingsTestAddress": "Test Adresse",
"LabelEmbeddedCover": "Bak inn omslag",
"LabelEnable": "Aktiver",
+ "LabelEncodingBackupLocation": "En sikkerhetskopi av de originale lyd-filene lagres i mappen:",
+ "LabelEncodingChaptersNotEmbedded": "Kapitler er ikke bygget inn i flersporede lydbøker.",
+ "LabelEncodingClearItemCache": "Husk å tømme mellomlageret med jevne mellomrom.",
+ "LabelEncodingFinishedM4B": "Ferdig konvertert M4B-lydbøker legges i lydbok-mappen:",
+ "LabelEncodingInfoEmbedded": "Metadata bygges inn i lydsporene i lydbokmappen.",
+ "LabelEncodingStartedNavigation": "Så snart oppgaven er startet kan du navigere bort fra denne siden.",
+ "LabelEncodingTimeWarning": "Konvertering kan ta opptil 30 minutter.",
+ "LabelEncodingWarningAdvancedSettings": "Advarsel: Ikke oppdater disse innstillingene med mindre du er godt kjent med hvordan ffmpeg og konverteringsvalgene fungerer.",
+ "LabelEncodingWatcherDisabled": "Hvis du har slått av overvåking så må du skanne dette biblioteket på nytt etterpå.",
"LabelEnd": "Slutt",
"LabelEndOfChapter": "Slutt på kapittel",
+ "LabelEpisode": "Episode",
+ "LabelEpisodeNotLinkedToRssFeed": "Episode er ikke koblet til en RSS feed",
+ "LabelEpisodeNumber": "Episode #{0}",
"LabelEpisodeTitle": "Episode tittel",
"LabelEpisodeType": "Episode type",
+ "LabelEpisodeUrlFromRssFeed": "Episode URL fra RSS feed",
+ "LabelEpisodes": "Episoder",
+ "LabelEpisodic": "Episodisk",
"LabelExample": "Eksempel",
+ "LabelExpandSeries": "Vis serie",
+ "LabelExpandSubSeries": "Vis underserie",
"LabelExplicit": "Eksplisitt",
+ "LabelExplicitChecked": "Eksplisitt (avhuket)",
+ "LabelExplicitUnchecked": "Ikke eksplisitt (ikke avhuket)",
"LabelExportOPML": "Eksporter OPML",
"LabelFeedURL": "Feed Adresse",
+ "LabelFetchingMetadata": "Henter metadata",
"LabelFile": "Fil",
"LabelFileBirthtime": "Fil Opprettelsesdato",
+ "LabelFileBornDate": "Født {0}",
"LabelFileModified": "Fil Endret",
+ "LabelFileModifiedDate": "Redigert {0}",
"LabelFilename": "Filnavn",
"LabelFilterByUser": "Filtrer etter bruker",
"LabelFindEpisodes": "Finn episoder",
"LabelFinished": "Fullført",
"LabelFolder": "Mappe",
"LabelFolders": "Mapper",
+ "LabelFontBold": "Fet",
"LabelFontBoldness": "Skrifttykkelse",
"LabelFontFamily": "Fontfamilie",
+ "LabelFontItalic": "Kursiv",
"LabelFontScale": "Font størrelse",
+ "LabelFontStrikethrough": "Gjennomstreking",
+ "LabelFormat": "Format",
+ "LabelFull": "Full",
"LabelGenre": "Sjanger",
"LabelGenres": "Sjangers",
"LabelHardDeleteFile": "Tving sletting av fil",
"LabelHasEbook": "Har e-bok",
"LabelHasSupplementaryEbook": "Har komplimentær e-bok",
"LabelHideSubtitles": "Skjul undertekster",
+ "LabelHighestPriority": "Høyeste prioritet",
"LabelHost": "Tjener",
"LabelHour": "Time",
"LabelHours": "Timer",
"LabelIcon": "Ikon",
+ "LabelImageURLFromTheWeb": "Bilde-URL fra nett",
"LabelInProgress": "I gang",
"LabelIncludeInTracklist": "Inkluder i sporliste",
"LabelIncomplete": "Ufullstendig",
@@ -298,8 +390,11 @@
"LabelIntervalEveryHour": "Hver time",
"LabelInvert": "Inverter",
"LabelItem": "Enhet",
+ "LabelJumpBackwardAmount": "Hopp bakover med",
+ "LabelJumpForwardAmount": "Hopp forover med",
"LabelLanguage": "Språk",
"LabelLanguageDefaultServer": "Standard tjener språk",
+ "LabelLanguages": "Språk",
"LabelLastBookAdded": "Siste bok lagt til",
"LabelLastBookUpdated": "Siste bok oppdatert",
"LabelLastSeen": "Sist sett",
@@ -311,17 +406,36 @@
"LabelLess": "Mindre",
"LabelLibrariesAccessibleToUser": "Biblioteker tilgjengelig for bruker",
"LabelLibrary": "Bibliotek",
+ "LabelLibraryFilterSublistEmpty": "",
"LabelLibraryItem": "Bibliotek enhet",
"LabelLibraryName": "Bibliotek navn",
"LabelLimit": "Begrensning",
"LabelLineSpacing": "Linjemellomrom",
"LabelListenAgain": "Lytt igjen",
+ "LabelLogLevelDebug": "Debug",
+ "LabelLogLevelInfo": "Info",
+ "LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Se etter nye episoder etter denne datoen",
+ "LabelLowestPriority": "Laveste prioritet",
+ "LabelMatchExistingUsersBy": "Knytt sammen eksisterende brukere basert på",
+ "LabelMatchExistingUsersByDescription": "Brukes for å koble til eksisterende brukere. Når koblingen er i orden vil brukerne bli identifisert med en unik id fra SSO-tilbyderen.",
+ "LabelMaxEpisodesToDownload": "Maksimalt antall episoder som skal lastes ned. Bruk 0 for ubegrenset.",
+ "LabelMaxEpisodesToDownloadPerCheck": "Maksimalt antall nye episoder som skal lastes ned per sjekk",
+ "LabelMaxEpisodesToKeep": "Maksimalt antall episoder som skal beholdes",
+ "LabelMaxEpisodesToKeepHelp": "Sett verdien til null (0) for ubegrenset. Etter at en episode lastes ned automatisk, så slettes den eldste episoden, om du har mer enn X episoder. Det slettes kun én episode per nye nedlasting.",
"LabelMediaPlayer": "Mediespiller",
"LabelMediaType": "Medie type",
+ "LabelMetaTag": "Meta tag",
+ "LabelMetaTags": "Meta tags",
+ "LabelMetadataOrderOfPrecedenceDescription": "Høyere prioritert kilder for metadata overstyrer laverer prioriterte kilder for metadata.",
"LabelMetadataProvider": "Metadata Leverandør",
"LabelMinute": "Minutt",
+ "LabelMinutes": "Minutter",
"LabelMissing": "Mangler",
+ "LabelMissingEbook": "Har ingen e-bok",
+ "LabelMissingSupplementaryEbook": "Har ingen komplementær e-bok",
+ "LabelMobileRedirectURIs": "Tillatte URL-er for vidersending",
+ "LabelMobileRedirectURIsDescription": "Dette er en liste over godkjente videresendings-URL-er for mobil-apper. Standarden er audiobookshelf://oauth, som du kan fjerne eller supplere med ekstra URL-er for tredjeparts app-integrasjoner. For å tillate alle URL-er kan du bruke kun en (*) .",
"LabelMore": "Mer",
"LabelMoreInfo": "Mer info",
"LabelName": "Navn",
@@ -333,6 +447,7 @@
"LabelNewestEpisodes": "Nyeste episoder",
"LabelNextBackupDate": "Neste sikkerhetskopi dato",
"LabelNextScheduledRun": "Neste planlagte kjøring",
+ "LabelNoCustomMetadataProviders": "Ingen egendefinerte tilbydere for metadata",
"LabelNoEpisodesSelected": "Ingen episoder valgt",
"LabelNotFinished": "Ikke fullført",
"LabelNotStarted": "Ikke startet",
@@ -340,66 +455,95 @@
"LabelNotificationAppriseURL": "Apprise URL(er)",
"LabelNotificationAvailableVariables": "Tilgjengelige variabler",
"LabelNotificationBodyTemplate": "Kroppsmal",
- "LabelNotificationEvent": "Notifikasjons hendelse",
+ "LabelNotificationEvent": "Varsling",
"LabelNotificationTitleTemplate": "Tittel mal",
"LabelNotificationsMaxFailedAttempts": "Maks mislykkede forsøk",
- "LabelNotificationsMaxFailedAttemptsHelp": "Notifikasjoner er deaktivert når de mislykkes på sende dette flere ganger",
- "LabelNotificationsMaxQueueSize": "Maks kø lengde for Notifikasjonshendelser",
- "LabelNotificationsMaxQueueSizeHelp": "Hendelser er begrenset til avfyre 1 gang per sekund. Hendelser vil bli ignorert om køen er full. Dette forhindrer Notifikasjon spam.",
+ "LabelNotificationsMaxFailedAttemptsHelp": "Varslinger deaktiveres når sending feiles dette antallet ganger",
+ "LabelNotificationsMaxQueueSize": "Maksimalt antall varslinger i kø",
+ "LabelNotificationsMaxQueueSizeHelp": "Hendelser er begrenset til avfyre én gang per sekund. Hendelser blir ignorert om køen er full. Dette forhindrer overflod av varslinger.",
"LabelNumberOfBooks": "Antall bøker",
"LabelNumberOfEpisodes": "Antall episoder",
+ "LabelOpenIDClaims": "La følge valg være tomme for å slå av avanserte gruppe og tillatelser. Gruppen \"Bruker\" vil da også automatisk legges til.",
"LabelOpenRSSFeed": "Åpne RSS Feed",
"LabelOverwrite": "Overskriv",
+ "LabelPaginationPageXOfY": "Side {0} av {1}",
"LabelPassword": "Passord",
"LabelPath": "Sti",
"LabelPermanent": "Fast",
"LabelPermissionsAccessAllLibraries": "Har tilgang til alle bibliotek",
"LabelPermissionsAccessAllTags": "Har til gang til alle tags",
"LabelPermissionsAccessExplicitContent": "Har tilgang til eksplisitt material",
+ "LabelPermissionsCreateEreader": "Kan opprette e-leser",
"LabelPermissionsDelete": "Kan slette",
"LabelPermissionsDownload": "Kan laste ned",
"LabelPermissionsUpdate": "Kan oppdatere",
"LabelPermissionsUpload": "Kan laste opp",
+ "LabelPersonalYearReview": "Oppsummering av året ditt ({0})",
"LabelPhotoPathURL": "Bilde sti/URL",
"LabelPlayMethod": "Avspillingsmetode",
+ "LabelPlayerChapterNumberMarker": "{0} av {1}",
"LabelPlaylists": "Spilleliste",
+ "LabelPodcast": "Podcast",
"LabelPodcastSearchRegion": "Podcast-søkeområde",
"LabelPodcastType": "Podcast type",
"LabelPodcasts": "Podcaster",
+ "LabelPort": "Port",
"LabelPrefixesToIgnore": "Prefiks som skal ignoreres (skiller ikke mellom store og små bokstaver)",
"LabelPreventIndexing": "Forhindre at din feed fra å bli indeksert av iTunes og Google podcast kataloger",
"LabelPrimaryEbook": "Primær ebok",
"LabelProgress": "Framgang",
"LabelProvider": "Tilbyder",
+ "LabelProviderAuthorizationValue": "Autorisasjons header-verdi",
"LabelPubDate": "Publiseringsdato",
"LabelPublishYear": "Publikasjonsår",
+ "LabelPublishedDate": "Publisert {0}",
+ "LabelPublishedDecade": "Tiår for utgivelse",
+ "LabelPublishedDecades": "Tiår for utgivelse",
"LabelPublisher": "Forlegger",
+ "LabelPublishers": "Utgivere",
"LabelRSSFeedCustomOwnerEmail": "Tilpasset eier e-post",
"LabelRSSFeedCustomOwnerName": "Tilpasset eier Navn",
"LabelRSSFeedOpen": "RSS Feed åpne",
"LabelRSSFeedPreventIndexing": "Forhindre indeksering",
- "LabelRSSFeedSlug": "RSS-informasjonskanalunderadresse",
+ "LabelRSSFeedSlug": "RSS-feed ID",
+ "LabelRSSFeedURL": "RSS-feed URL",
+ "LabelRandomly": "Tilfeldig",
+ "LabelReAddSeriesToContinueListening": "Legg til igjen til \"Fortsett å lytte\"",
"LabelRead": "Les",
"LabelReadAgain": "Les igjen",
"LabelReadEbookWithoutProgress": "Les ebok uten å beholde fremgang",
"LabelRecentSeries": "Nylige serier",
"LabelRecentlyAdded": "Nylig tillagt",
"LabelRecommended": "Anbefalte",
+ "LabelRedo": "Gjenta",
+ "LabelRegion": "Region",
"LabelReleaseDate": "Utgivelsesdato",
+ "LabelRemoveAllMetadataAbs": "Fjern alle metadata.abs filer",
+ "LabelRemoveAllMetadataJson": "Fjern alle metadata.json filer",
"LabelRemoveCover": "Fjern omslag",
+ "LabelRemoveMetadataFile": "Fjern metadata-filer fra biblioteks-mapper",
+ "LabelRemoveMetadataFileHelp": "Fjern alle metadata.json og metadata.abs i alle {0} mappene.",
+ "LabelRowsPerPage": "Rader per side",
"LabelSearchTerm": "Søkeord",
"LabelSearchTitle": "Søk tittel",
"LabelSearchTitleOrASIN": "Søk tittel eller ASIN",
"LabelSeason": "Sesong",
+ "LabelSeasonNumber": "Sesong #{0}",
+ "LabelSelectAll": "Velg alt",
"LabelSelectAllEpisodes": "Velg alle episoder",
"LabelSelectEpisodesShowing": "Velg {0} episoder vist",
+ "LabelSelectUsers": "Velg brukere",
"LabelSendEbookToDevice": "Send Ebok til...",
"LabelSequence": "Sekvens",
+ "LabelSerial": "Serienr.",
"LabelSeries": "Serier",
"LabelSeriesName": "Serier Navn",
"LabelSeriesProgress": "Serier fremgang",
+ "LabelServerLogLevel": "Server logg-nivå",
+ "LabelServerYearReview": "Server - Oppsummering av året ({0})",
"LabelSetEbookAsPrimary": "Sett som primær",
"LabelSetEbookAsSupplementary": "Sett som supplerende",
+ "LabelSettingsAllowIframe": "Tillat å bygge inn i en iframe",
"LabelSettingsAudiobooksOnly": "Kun lydbøker",
"LabelSettingsAudiobooksOnlyHelp": "Aktivering av dette valget til ignorere ebok filer utenom de er i en lydbok mappe hvor de vil bli satt som supplerende ebøker",
"LabelSettingsBookshelfViewHelp": "Skeuomorf design med hyller av ved",
@@ -411,6 +555,8 @@
"LabelSettingsEnableWatcher": "Aktiver overvåker",
"LabelSettingsEnableWatcherForLibrary": "Aktiver mappe overvåker for bibliotek",
"LabelSettingsEnableWatcherHelp": "Aktiverer automatisk opprettelse/oppdatering av enheter når filendringer er oppdaget. *Krever restart av server*",
+ "LabelSettingsEpubsAllowScriptedContent": "Tillat scripting i innholdet i ebub-bøker",
+ "LabelSettingsEpubsAllowScriptedContentHelp": "Tillat epub-filer å kjøre script. Det er anbefalt å slå av denne innstillingen med mindre du stoler på kilden til epub-filene.",
"LabelSettingsExperimentalFeatures": "Eksperimentelle funksjoner",
"LabelSettingsExperimentalFeaturesHelp": "Funksjoner under utvikling som kan trenge din tilbakemelding og hjelp med testing. Klikk for å åpne GitHub diskusjon.",
"LabelSettingsFindCovers": "Finn omslag",
@@ -419,8 +565,13 @@
"LabelSettingsHideSingleBookSeriesHelp": "Serier som har kun en bok vil bli gjemt på serie- og hjemmeside hyllen.",
"LabelSettingsHomePageBookshelfView": "Hjemmeside bruk bokhyllevisning",
"LabelSettingsLibraryBookshelfView": "Bibliotek bruk bokhyllevisning",
+ "LabelSettingsLibraryMarkAsFinishedPercentComplete": "Prosent ferdig er større enn",
+ "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Gjenværende tid er mindre enn (sekunder)",
+ "LabelSettingsLibraryMarkAsFinishedWhen": "Marker som ferdig når",
+ "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Hopp over tidligere bøker i \"Fortsett serien\"",
+ "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "\"Fortsett serie\"-siden viser første bok som ikke er påbegynt i serier der en bok er lest og ingen bøker leses nå. Ved å slå på denne innstillingen så vil man fortsette på serien etter siste leste bok, fremfor første bok som ikke er startet på i en serie.",
"LabelSettingsParseSubtitles": "Analyser undertekster",
- "LabelSettingsParseSubtitlesHelp": "Trekk ut undertekster fra lydbok mappenavn. undertekster må være separert med \" - \" f.eks. \"Boktittel - Undertekst her\" har Undertekst \"Undertekst her\"",
+ "LabelSettingsParseSubtitlesHelp": "Hent undertittel fra lydbokens mappenavn. Undertittel må være separert med \" - \" f.eks. \"Boktittel - En lengre tittel\" har undertittel \"En lengre tittel\".",
"LabelSettingsPreferMatchedMetadata": "Foretrekk funnet metadata",
"LabelSettingsPreferMatchedMetadataHelp": "Funnet data vil overskrive enhetens detaljene når man bruker Kjapt søk. Som standard vil Kjapt søk kun fylle inn manglende detaljer.",
"LabelSettingsSkipMatchingBooksWithASIN": "Hopp over bøker som allerede har ASIN",
@@ -435,10 +586,17 @@
"LabelSettingsStoreMetadataWithItemHelp": "Som standard vil metadata bli lagret under /metadata/items, aktiveres dette valget vil metadata bli lagret i samme mappe som gjenstanden",
"LabelSettingsTimeFormat": "Tid format",
"LabelShare": "Dele",
+ "LabelShareOpen": "Åpne deling",
"LabelShareURL": "Dele URL",
- "LabelShowAll": "Vis alt",
+ "LabelShowAll": "Vis alle",
+ "LabelShowSeconds": "Vis sekunder",
+ "LabelShowSubtitles": "Vis undertitler",
"LabelSize": "Størrelse",
"LabelSleepTimer": "Sove-timer",
+ "LabelSlug": "Slug",
+ "LabelSortAscending": "Stigende",
+ "LabelSortDescending": "Synkende",
+ "LabelStart": "Start",
"LabelStartTime": "Start Tid",
"LabelStarted": "Startet",
"LabelStartedAt": "Startet",
@@ -459,15 +617,24 @@
"LabelStatsWeekListening": "Uker lyttet",
"LabelSubtitle": "undertekster",
"LabelSupportedFileTypes": "Støttede filtyper",
+ "LabelTag": "Tag",
"LabelTags": "Tagger",
"LabelTagsAccessibleToUser": "Tagger tilgjengelig for bruker",
"LabelTagsNotAccessibleToUser": "Tagger ikke tilgjengelig for bruker",
"LabelTasks": "Oppgaver som kjører",
+ "LabelTextEditorBulletedList": "Punkt-liste",
+ "LabelTextEditorLink": "Link",
+ "LabelTextEditorNumberedList": "Nummerert liste",
+ "LabelTextEditorUnlink": "Fjern link",
"LabelTheme": "Tema",
"LabelThemeDark": "Mørk",
"LabelThemeLight": "Lys",
"LabelTimeBase": "Tidsbase",
+ "LabelTimeDurationXHours": "{0} timer",
+ "LabelTimeDurationXMinutes": "{0} minutter",
+ "LabelTimeDurationXSeconds": "{0} sekunder",
"LabelTimeInMinutes": "Timer i minutter",
+ "LabelTimeLeft": "{0} gjenstår",
"LabelTimeListened": "Tid lyttet",
"LabelTimeListenedToday": "Tid lyttet idag",
"LabelTimeRemaining": "{0} gjennstående",
@@ -475,6 +642,7 @@
"LabelTitle": "Tittel",
"LabelToolsEmbedMetadata": "Bak inn metadata",
"LabelToolsEmbedMetadataDescription": "Bak inn metadata i lydfilen, inkludert omslagsbilde og kapitler.",
+ "LabelToolsM4bEncoder": "M4B enkoder",
"LabelToolsMakeM4b": "Lag M4B Lydbokfil",
"LabelToolsMakeM4bDescription": "Lager en.M4B lydbokfil med innbakte omslagsbilde og kapitler.",
"LabelToolsSplitM4b": "Del M4B inn i MP3er",
@@ -487,39 +655,56 @@
"LabelTracksMultiTrack": "Flerspor",
"LabelTracksNone": "Ingen spor",
"LabelTracksSingleTrack": "Enkelspor",
+ "LabelTrailer": "Trailer",
+ "LabelType": "Type",
"LabelUnabridged": "Uavkortet",
+ "LabelUndo": "Angre",
"LabelUnknown": "Ukjent",
+ "LabelUnknownPublishDate": "Ukjent publiseringsdato",
"LabelUpdateCover": "Oppdater omslag",
"LabelUpdateCoverHelp": "Tillat overskriving av eksisterende omslag for de valgte bøkene når en lik bok er funnet",
"LabelUpdateDetails": "Oppdater detaljer",
"LabelUpdateDetailsHelp": "Tillat overskriving av eksisterende detaljer for de valgte bøkene når en lik bok er funnet",
"LabelUpdatedAt": "Oppdatert",
"LabelUploaderDragAndDrop": "Dra og slipp filer eller mapper",
+ "LabelUploaderDragAndDropFilesOnly": "Dra & slipp filer",
"LabelUploaderDropFiles": "Slipp filer",
+ "LabelUploaderItemFetchMetadataHelp": "Hent tittel, forfatter og serie automatisk",
+ "LabelUseAdvancedOptions": "Bruk avanserte valg",
"LabelUseChapterTrack": "Bruk kapittelspor",
"LabelUseFullTrack": "Bruke hele sporet",
+ "LabelUseZeroForUnlimited": "Bruk 0 for ubegrenset",
"LabelUser": "Bruker",
"LabelUsername": "Brukernavn",
"LabelValue": "Verdi",
"LabelVersion": "Versjon",
"LabelViewBookmarks": "Vis bokmerker",
"LabelViewChapters": "Vis kapitler",
+ "LabelViewPlayerSettings": "Vis innstillinger for avspiller",
"LabelViewQueue": "Vis spillerkø",
"LabelVolume": "Volum",
+ "LabelWebRedirectURLsDescription": "Godkjenn disse URL-ene hos OAuth-tilbyder for å tillate videresending til web-appen etter innlogging:",
+ "LabelWebRedirectURLsSubfolder": "Undermapper for videresendings-URL-er",
"LabelWeekdaysToRun": "Ukedager å kjøre",
+ "LabelXBooks": "{0} bøker",
+ "LabelXItems": "{0} elementer",
+ "LabelYearReviewHide": "Skjul oppsummering av året",
+ "LabelYearReviewShow": "Vis oppsummering av året",
"LabelYourAudiobookDuration": "Din lydbok lengde",
"LabelYourBookmarks": "Dine bokmerker",
"LabelYourPlaylists": "Dine spillelister",
"LabelYourProgress": "Din fremgang",
"MessageAddToPlayerQueue": "Legg til i kø",
- "MessageAppriseDescription": "For å bruke denne funksjonen trenger du en instans av Apprise API kjørende eller ett api som vil håndere disse forespørslene. Apprise API Url skal være den fulle URL stien for å sende Notifikasjonen, f.eks., hvis din API instans er hos http://192.168.1.1:8337 vil du bruke http://192.168.1.1:8337/notify.",
+ "MessageAppriseDescription": "For å bruke denne funksjonen trenger du en instans av Apprise API kjørende eller et API som håndterer disse forespørslene. Apprise API URL skal være hele URL-en til varslingen, f.eks., hvis din API-instans er på http://192.168.1.1:8337 så skal du bruke http://192.168.1.1:8337/notify.",
"MessageBackupsDescription": "Sikkerhetskopier inkluderer, brukerfremgang, detaljer om bibliotekgjenstander, tjener instillinger og bilder lagret under /metadata/items og /metadata/authors. Sikkerhetskopier vil ikke inkludere filer som er lagret i bibliotek mappene.",
- "MessageBackupsLocationEditNote": "Merk: Endring av sikkerhetskopieringssted hverken endrer eller flytter eksisterende sikkerhetskopier",
- "MessageBackupsLocationPathEmpty": "Sti til sikkerhetskopieringssted må angis",
+ "MessageBackupsLocationEditNote": "Viktig: Endring av mappen for sikkerhetskopi hverken endrer eller flytter eksisterende sikkerhetskopier!",
+ "MessageBackupsLocationNoEditNote": "NB: Mappen for sikkerhetskopi settes i en miljøvariabel og kan ikke endres her.",
+ "MessageBackupsLocationPathEmpty": "Mappen for sikkerhetskopiering må angis",
"MessageBatchQuickMatchDescription": "Kjapt søk vil forsøke å legge til manglende omslag og metadata for de valgte gjenstandene. Aktiver dette valget for å tillate Kjapt søk til å overskrive eksisterende omslag og/eller metadata.",
"MessageBookshelfNoCollections": "Du har ikke laget noen samlinger ennå",
"MessageBookshelfNoRSSFeeds": "Ingen RSS feed er åpen",
"MessageBookshelfNoResultsForFilter": "Ingen resultat for filter \"{0}: {1}\"",
+ "MessageBookshelfNoResultsForQuery": "Ingen resultater for søket",
"MessageBookshelfNoSeries": "Du har ingen serier",
"MessageChapterEndIsAfter": "Kapittel slutt er etter slutt av lydboken",
"MessageChapterErrorFirstNotZero": "Første kapittel starter på 0",
@@ -529,18 +714,35 @@
"MessageCheckingCron": "Sjekker cron...",
"MessageConfirmCloseFeed": "Er du sikker på at du vil lukke denne feeden?",
"MessageConfirmDeleteBackup": "Er du sikker på at du vil slette sikkerhetskopi for {0}?",
+ "MessageConfirmDeleteDevice": "Er du sikker på at du vil slette e-leser enheten \"{0}\"?",
"MessageConfirmDeleteFile": "Dette vil slette filen fra filsystemet. Er du sikker?",
"MessageConfirmDeleteLibrary": "Er du sikker på at du vil slette biblioteket \"{0}\" for godt?",
+ "MessageConfirmDeleteLibraryItem": "Nå slettes elementet fra databasen og fil-systemet. Er du sikker?",
+ "MessageConfirmDeleteLibraryItems": "Nå slettes {0} elementer fra databasen og fil-systemet. Er du sikker?",
+ "MessageConfirmDeleteMetadataProvider": "Er du sikker på at du vil slette den egendefinerte leverandøren av metadata: \"{0}\"?",
+ "MessageConfirmDeleteNotification": "Er du sikker på at du vil slette dette varselet?",
"MessageConfirmDeleteSession": "Er du sikker på at du vil slette denne sesjonen?",
+ "MessageConfirmEmbedMetadataInAudioFiles": "Er du sikker på at du vil legge til metadata i {0} lyd-filer?",
"MessageConfirmForceReScan": "Er du sikker på at du vil tvinge en ny skann?",
"MessageConfirmMarkAllEpisodesFinished": "Er du sikker på at du vil markere alle episodene som fullført?",
"MessageConfirmMarkAllEpisodesNotFinished": "Er du sikker på at du vil markere alle episodene som ikke fullført?",
+ "MessageConfirmMarkItemFinished": "Er du sikker på at du vil markere {0} som ferdig?",
+ "MessageConfirmMarkItemNotFinished": "Er du sikker på at du vil markere {0} som ikke ferdig?",
"MessageConfirmMarkSeriesFinished": "Er du sikker på at du vil markere alle bøkene i serien som fullført?",
"MessageConfirmMarkSeriesNotFinished": "Er du sikker på at du vil markere alle bøkene i serien som ikke fullført?",
+ "MessageConfirmNotificationTestTrigger": "Utløs dette varselet med test-data?",
+ "MessageConfirmPurgeCache": "(Purge cache) Dette vil sletter hele mappen /metadata/cache.
Er du sikker på at du du vil slette cache-mappen?",
+ "MessageConfirmPurgeItemsCache": "(Purge items cache) Dette vil sletter hele mappen /metadata/cache/items. Er du sikker?",
+ "MessageConfirmQuickEmbed": "Advarsel! Rask innbygging av metadata tar ikke backup av lyd-filene først. Forsikre deg om at du har sikkerhetskopi av filene.
Fortsett?",
+ "MessageConfirmQuickMatchEpisodes": "Hurtig gjenkjenning av episoder overskriver detaljene hvis en match blir funnet. Kun episoder som ikke allerede er matchet blir oppdatert. Er du sikker?",
+ "MessageConfirmReScanLibraryItems": "Er du sikker på at du ønsker å skanne {0} elementer på nytt?",
"MessageConfirmRemoveAllChapters": "Er du sikker på at du vil fjerne alle kapitler?",
+ "MessageConfirmRemoveAuthor": "Er du sikker på at du vil fjerne forfatteren \"{0}\"?",
"MessageConfirmRemoveCollection": "Er du sikker på at du vil fjerne samling\"{0}\"?",
"MessageConfirmRemoveEpisode": "Er du sikker på at du vil fjerne episode \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Er du sikker på at du vil fjerne {0} episoder?",
+ "MessageConfirmRemoveListeningSessions": "Er du sikker på at du vil fjerne {0} lytte-sesjoner?",
+ "MessageConfirmRemoveMetadataFiles": "Er du sikker på at du vil fjerne alle metadata.{0}-filer i mappene for biblioteks-elementer?",
"MessageConfirmRemoveNarrator": "Er du sikker på at du vil fjerne forteller \"{0}\"?",
"MessageConfirmRemovePlaylist": "Er du sikker på at du vil fjerne spillelisten \"{0}\"?",
"MessageConfirmRenameGenre": "Er du sikker på at du vil endre sjanger \"{0}\" til \"{1}\" for alle gjenstandene?",
@@ -549,11 +751,16 @@
"MessageConfirmRenameTag": "Er du sikker på at du vil endre tag \"{0}\" til \"{1}\" for alle gjenstandene?",
"MessageConfirmRenameTagMergeNote": "Notis: Denne taggen finnes allerede så de vil bli slått sammen.",
"MessageConfirmRenameTagWarning": "Advarsel! En lignende tag eksisterer allerede (med forsjellige store / små bokstaver) \"{0}\".",
+ "MessageConfirmResetProgress": "Er du sikkert på at du vil tilbakestille fremgangen?",
"MessageConfirmSendEbookToDevice": "Er du sikker på at du vil sende {0} ebok \"{1}\" til enhet \"{2}\"?",
+ "MessageConfirmUnlinkOpenId": "Er du sikker på at du vil koble denne brukeren fra OpenID?",
"MessageDownloadingEpisode": "Laster ned episode",
"MessageDragFilesIntoTrackOrder": "Dra filene i rett spor rekkefølge",
+ "MessageEmbedFailed": "Innbygging feilet!",
"MessageEmbedFinished": "Bak inn Fullført!",
+ "MessageEmbedQueue": "Lagt i køen for innbygging av metadata ({0} i kø)",
"MessageEpisodesQueuedForDownload": "{0} Episode(r) lagt til i kø for nedlasting",
+ "MessageEreaderDevices": "For å sikre sendingen av e-bøker, så må du kanskje legge til e-postadressen over som en gyldig avsender for hver enhet i listen over.",
"MessageFeedURLWillBe": "Feed URL vil bli {0}",
"MessageFetching": "Henter...",
"MessageForceReScanDescription": "vil skanne alle filene igjen som en ny skann. Lyd fil ID3 tagger, OPF filer og tekstfiler vil bli skannet som nye.",
@@ -593,7 +800,7 @@
"MessageNoListeningSessions": "Ingen Lyttesesjoner",
"MessageNoLogs": "Ingen logger",
"MessageNoMediaProgress": "Ingen mediefremgang",
- "MessageNoNotifications": "Ingen notifikasjoner",
+ "MessageNoNotifications": "Ingen varslinger",
"MessageNoPodcastsFound": "Ingen podcaster funnet",
"MessageNoResults": "Ingen resultat",
"MessageNoSearchResultsFor": "Ingen søkeresultat for \"{0}\"",
@@ -648,30 +855,64 @@
"ToastAuthorUpdateMerged": "Forfatter slått sammen",
"ToastAuthorUpdateSuccess": "Forfatter oppdatert",
"ToastAuthorUpdateSuccessNoImageFound": "Forfatter oppdater (ingen bilde funnet)",
+ "ToastBackupAppliedSuccess": "Sikkerhetskopi slått på",
"ToastBackupCreateFailed": "Mislykkes å lage sikkerhetskopi",
"ToastBackupCreateSuccess": "Sikkerhetskopi opprettet",
"ToastBackupDeleteFailed": "Mislykkes å slette sikkerhetskopi",
"ToastBackupDeleteSuccess": "Sikkerhetskopi slettet",
+ "ToastBackupInvalidMaxKeep": "Ugyldig antall sikkerhetskopier ønskes beholdt",
+ "ToastBackupInvalidMaxSize": "Ugyldig maksimal størrelse for sikkerhetskopi",
"ToastBackupRestoreFailed": "Misslykkes å gjenopprette sikkerhetskopi",
"ToastBackupUploadFailed": "Misslykkes å laste opp sikkerhetskopi",
"ToastBackupUploadSuccess": "Sikkerhetskopi lastet opp",
+ "ToastBatchDeleteFailed": "Sletting feilet på utvalget",
+ "ToastBatchDeleteSuccess": "Sletting av samling utført",
+ "ToastBatchQuickMatchFailed": "Feil ved rask integrering av metadata!",
+ "ToastBatchQuickMatchStarted": "Rask integrering av metadata for {0} bøker startet!",
"ToastBatchUpdateFailed": "Bulk oppdatering mislykket",
"ToastBatchUpdateSuccess": "Bulk oppdatering fullført",
"ToastBookmarkCreateFailed": "Misslykkes å opprette bokmerke",
"ToastBookmarkCreateSuccess": "Bokmerke lagt til",
"ToastBookmarkRemoveSuccess": "Bokmerke fjernet",
"ToastBookmarkUpdateSuccess": "Bokmerke oppdatert",
+ "ToastCachePurgeFailed": "Kunne ikke å slette mellomlager",
+ "ToastCachePurgeSuccess": "Mellomlager slettet",
"ToastChaptersHaveErrors": "Kapittel har feil",
"ToastChaptersMustHaveTitles": "Kapittel må ha titler",
- "ToastCollectionItemsRemoveSuccess": "Gjenstand(er) fjernet fra samling",
+ "ToastChaptersRemoved": "Kapitler fjernet",
+ "ToastChaptersUpdated": "Kapitler oppdatert",
+ "ToastCollectionItemsAddFailed": "Feil med å legge til element(er)",
"ToastCollectionRemoveSuccess": "Samling fjernet",
"ToastCollectionUpdateSuccess": "samlingupdated",
+ "ToastCoverUpdateFailed": "Oppdatering av bilde feilet",
+ "ToastDeleteFileFailed": "Kunne ikke slette fil",
+ "ToastDeleteFileSuccess": "Fil slettet",
+ "ToastDeviceAddFailed": "Kunne ikke legge til enhet",
+ "ToastDeviceNameAlreadyExists": "E-leser med dette navnet eksisterer allerede",
+ "ToastDeviceTestEmailFailed": "Kunne ikke sende test e-post",
+ "ToastDeviceTestEmailSuccess": "E-post for testing er sendt",
+ "ToastEmailSettingsUpdateSuccess": "Innstillinger for e-post oppdatert",
+ "ToastEncodeCancelFailed": "Kunne ikke stoppe konverteringen",
+ "ToastEncodeCancelSucces": "Konvertering kansellert",
+ "ToastEpisodeDownloadQueueClearFailed": "Kunne ikke tømme køen",
+ "ToastEpisodeDownloadQueueClearSuccess": "Nedlastingskø for eposider tømt",
+ "ToastEpisodeUpdateSuccess": "{0} episoder oppdatert",
+ "ToastFailedToLoadData": "Kunne ikke laste inn data",
+ "ToastFailedToMatch": "Kunne ikke matche",
+ "ToastFailedToShare": "Deling feilet",
+ "ToastFailedToUpdate": "Oppdatering feilet",
+ "ToastInvalidImageUrl": "Ugyldig URL for bilde",
+ "ToastInvalidMaxEpisodesToDownload": "Ugyldig maksimalt antall for nedlasting av episoder",
+ "ToastInvalidUrl": "Ugyldig URL",
"ToastItemCoverUpdateSuccess": "Omslag oppdatert",
+ "ToastItemDeletedFailed": "Kunne ikke slette element",
+ "ToastItemDeletedSuccess": "Element slettet",
"ToastItemDetailsUpdateSuccess": "Detaljer oppdatert",
"ToastItemMarkedAsFinishedFailed": "Misslykkes å markere som Fullført",
"ToastItemMarkedAsFinishedSuccess": "Gjenstand marker som Fullført",
"ToastItemMarkedAsNotFinishedFailed": "Misslykkes å markere som Ikke Fullført",
"ToastItemMarkedAsNotFinishedSuccess": "Markert som Ikke Fullført",
+ "ToastItemUpdateSuccess": "Element oppdatert",
"ToastLibraryCreateFailed": "Misslykkes å opprette bibliotek",
"ToastLibraryCreateSuccess": "Bibliotek \"{0}\" opprettet",
"ToastLibraryDeleteFailed": "Misslykkes å slette bibliotek",
@@ -679,25 +920,83 @@
"ToastLibraryScanFailedToStart": "Misslykkes å starte skann",
"ToastLibraryScanStarted": "Bibliotek skann startet",
"ToastLibraryUpdateSuccess": "Bibliotek \"{0}\" oppdatert",
+ "ToastMatchAllAuthorsFailed": "Kunne ikke finne match for alle forfattere",
+ "ToastMetadataFilesRemovedError": "Feil ved fjerning av metadata.{0}-filer",
+ "ToastMetadataFilesRemovedNoneFound": "Ingen metata.{0}-filer funnet i biblioteket",
+ "ToastMetadataFilesRemovedNoneRemoved": "Ingen metata.{0}-filer fjernet",
+ "ToastMetadataFilesRemovedSuccess": "{0} metata.{1}-filer fjernet",
+ "ToastMustHaveAtLeastOnePath": "Påkrevd med minst én mappe",
+ "ToastNameEmailRequired": "Navn og e-post påkrevd",
+ "ToastNameRequired": "Navn er påkrevd",
+ "ToastNewEpisodesFound": "{0} nye episoder funnet",
+ "ToastNewUserCreatedFailed": "Kunne ikke opprette konto: \"{0}\"",
+ "ToastNewUserCreatedSuccess": "Ny konto opprettet",
+ "ToastNewUserLibraryError": "Velg minst ett bibliotek",
+ "ToastNewUserPasswordError": "Passord kreves. Kun root-bruker kan ha blankt passord",
+ "ToastNewUserTagError": "Velg minst en tag",
+ "ToastNewUserUsernameError": "Skriv inn brukernavn",
+ "ToastNoNewEpisodesFound": "Ingen nye episoder funnet",
+ "ToastNoUpdatesNecessary": "Ingen oppdateringer nødvendig",
+ "ToastNotificationCreateFailed": "Kunne ikke opprette varsling",
+ "ToastNotificationDeleteFailed": "Kunne ikke slette varsling",
+ "ToastNotificationFailedMaximum": "Maksimalt antall forsøk som feiler må være større eller lik null (0)",
+ "ToastNotificationQueueMaximum": "Maksimal størrelse på varsel-kø må være større eller lik null (0)",
+ "ToastNotificationSettingsUpdateSuccess": "Innstillinger for varsling oppdatert",
+ "ToastNotificationTestTriggerFailed": "Kunne ikke utløse test-varsel",
+ "ToastNotificationTestTriggerSuccess": "Test-varsel utløst",
+ "ToastNotificationUpdateSuccess": "Varsel oppdatert",
"ToastPlaylistCreateFailed": "Misslykkes å opprette spilleliste",
"ToastPlaylistCreateSuccess": "Spilleliste opprettet",
"ToastPlaylistRemoveSuccess": "Spilleliste fjernet",
"ToastPlaylistUpdateSuccess": "Spilleliste oppdatert",
"ToastPodcastCreateFailed": "Misslykkes å opprette podcast",
"ToastPodcastCreateSuccess": "Podcast opprettet",
+ "ToastPodcastGetFeedFailed": "Kunne ikke hente podcast-feed",
+ "ToastPodcastNoEpisodesInFeed": "Ingen episoder funnet i RSS-feed",
+ "ToastPodcastNoRssFeed": "Podcast har ingen RSS-feed",
+ "ToastProgressIsNotBeingSynced": "Progresjon synkroniserer ikke, start avspilling på nytt",
+ "ToastProviderCreatedFailed": "Kunne ikke legge til tilbyder",
+ "ToastProviderCreatedSuccess": "Ny tilbyder lagt til",
+ "ToastProviderNameAndUrlRequired": "Navn og URL er påkrevd",
+ "ToastProviderRemoveSuccess": "Tilbyder fjernet",
"ToastRSSFeedCloseFailed": "Misslykkes å lukke RSS feed",
"ToastRSSFeedCloseSuccess": "RSS feed lukket",
+ "ToastRemoveFailed": "Kunne ikke fjerne",
"ToastRemoveItemFromCollectionFailed": "Misslykkes å fjerne gjenstsand fra samling",
"ToastRemoveItemFromCollectionSuccess": "Gjenstand fjernet fra samling",
+ "ToastRemoveItemsWithIssuesFailed": "Kunne ikke fjerne bibliotek-elementer med feil",
+ "ToastRemoveItemsWithIssuesSuccess": "Fjernet bibliotek-elementer med feil",
+ "ToastRenameFailed": "Kunne ikke endre navn",
+ "ToastRescanFailed": "Ny skanning feilet for {0}",
+ "ToastRescanRemoved": "Ny skanning utført og element fjernet",
+ "ToastRescanUpToDate": "Ny skanning utført og element var oppdatert",
+ "ToastRescanUpdated": "Ny skanning utført og element oppdatert",
+ "ToastScanFailed": "Kunne ikke skanne bibliotek-element",
+ "ToastSelectAtLeastOneUser": "Velg minst én bruker",
"ToastSendEbookToDeviceFailed": "Misslykkes å sende ebok",
"ToastSendEbookToDeviceSuccess": "Ebok sendt til \"{0}\"",
"ToastSeriesUpdateFailed": "Misslykkes å oppdatere serie",
"ToastSeriesUpdateSuccess": "Serie oppdatert",
+ "ToastServerSettingsUpdateSuccess": "Server-innstillinger oppdatert",
+ "ToastSessionCloseFailed": "Kunne ikke avslutte sesjon",
"ToastSessionDeleteFailed": "Misslykkes å slette sesjon",
"ToastSessionDeleteSuccess": "Sesjon slettet",
+ "ToastSleepTimerDone": "Søvn-timer ferdig... zZzzZz",
+ "ToastSlugMustChange": "Slug inneholder ugyldige tegn",
+ "ToastSlugRequired": "Slug påkrevd",
"ToastSocketConnected": "Socket koblet til",
"ToastSocketDisconnected": "Socket koblet fra",
"ToastSocketFailedToConnect": "Misslykkes å koble til Socket",
+ "ToastSortingPrefixesEmptyError": "Må ha minst én sorteringsprefiks",
+ "ToastSortingPrefixesUpdateSuccess": "Sorteringsprefiks oppdatert ({0} element)",
+ "ToastTitleRequired": "Tittel påkrevd",
+ "ToastUnknownError": "Ukjent feil",
+ "ToastUnlinkOpenIdFailed": "Kunne ikke koble bruker fra OpenID",
+ "ToastUnlinkOpenIdSuccess": "Bruker koblet fra OpenID",
"ToastUserDeleteFailed": "Misslykkes å slette bruker",
- "ToastUserDeleteSuccess": "Bruker slettet"
+ "ToastUserDeleteSuccess": "Bruker slettet",
+ "ToastUserPasswordChangeSuccess": "Passord ble endret",
+ "ToastUserPasswordMismatch": "Passord må stemme overens",
+ "ToastUserPasswordMustChange": "Nytt passord kan ikke være identisk med gammelt passord",
+ "ToastUserRootRequireName": "Root-brukernavn er påkrevd"
}
diff --git a/client/strings/pl.json b/client/strings/pl.json
index 85c7b769b..d8cf9848f 100644
--- a/client/strings/pl.json
+++ b/client/strings/pl.json
@@ -772,7 +772,6 @@
"ToastBookmarkCreateSuccess": "Dodano zakładkę",
"ToastBookmarkRemoveSuccess": "Zakładka została usunięta",
"ToastBookmarkUpdateSuccess": "Zaktualizowano zakładkę",
- "ToastCollectionItemsRemoveSuccess": "Przedmiot(y) zostały usunięte z kolekcji",
"ToastCollectionRemoveSuccess": "Kolekcja usunięta",
"ToastCollectionUpdateSuccess": "Zaktualizowano kolekcję",
"ToastItemCoverUpdateSuccess": "Zaktualizowano okładkę",
diff --git a/client/strings/pt-br.json b/client/strings/pt-br.json
index 7df7c47de..b6d7e7e36 100644
--- a/client/strings/pt-br.json
+++ b/client/strings/pt-br.json
@@ -735,7 +735,6 @@
"ToastCachePurgeSuccess": "Cache apagado com sucesso",
"ToastChaptersHaveErrors": "Capítulos com erro",
"ToastChaptersMustHaveTitles": "Capítulos precisam ter títulos",
- "ToastCollectionItemsRemoveSuccess": "Item(ns) removidos da coleção",
"ToastCollectionRemoveSuccess": "Coleção removida",
"ToastCollectionUpdateSuccess": "Coleção atualizada",
"ToastDeleteFileFailed": "Falha ao apagar arquivo",
diff --git a/client/strings/ru.json b/client/strings/ru.json
index 3e07ea022..41129bde5 100644
--- a/client/strings/ru.json
+++ b/client/strings/ru.json
@@ -959,8 +959,6 @@
"ToastChaptersRemoved": "Удалены главы",
"ToastChaptersUpdated": "Обновленные главы",
"ToastCollectionItemsAddFailed": "Не удалось добавить элемент(ы) в коллекцию",
- "ToastCollectionItemsAddSuccess": "Элемент(ы) добавлены в коллекцию",
- "ToastCollectionItemsRemoveSuccess": "Элемент(ы), удалены из коллекции",
"ToastCollectionRemoveSuccess": "Коллекция удалена",
"ToastCollectionUpdateSuccess": "Коллекция обновлена",
"ToastCoverUpdateFailed": "Не удалось обновить обложку",
diff --git a/client/strings/sl.json b/client/strings/sl.json
index a12643f44..3c304eaa8 100644
--- a/client/strings/sl.json
+++ b/client/strings/sl.json
@@ -959,8 +959,6 @@
"ToastChaptersRemoved": "Poglavja so odstranjena",
"ToastChaptersUpdated": "Poglavja so posodobljena",
"ToastCollectionItemsAddFailed": "Dodajanje elementov v zbirko ni uspelo",
- "ToastCollectionItemsAddSuccess": "Dodajanje elementov v zbirko je bilo uspešno",
- "ToastCollectionItemsRemoveSuccess": "Elementi so bili odstranjeni iz zbirke",
"ToastCollectionRemoveSuccess": "Zbirka je bila odstranjena",
"ToastCollectionUpdateSuccess": "Zbirka je bila posodobljena",
"ToastCoverUpdateFailed": "Posodobitev naslovnice ni uspela",
diff --git a/client/strings/sv.json b/client/strings/sv.json
index 0d156efd7..8e60e2cd4 100644
--- a/client/strings/sv.json
+++ b/client/strings/sv.json
@@ -13,7 +13,7 @@
"ButtonBrowseForFolder": "Bläddra efter mapp",
"ButtonCancel": "Avbryt",
"ButtonCancelEncode": "Avbryt kodning",
- "ButtonChangeRootPassword": "Ändra rootlösenord",
+ "ButtonChangeRootPassword": "Ändra lösenordet för root",
"ButtonCheckAndDownloadNewEpisodes": "Kontrollera och ladda ner nya avsnitt",
"ButtonChooseAFolder": "Välj en mapp",
"ButtonChooseFiles": "Välj filer",
@@ -29,7 +29,7 @@
"ButtonEditChapters": "Redigera kapitel",
"ButtonEditPodcast": "Redigera podcast",
"ButtonForceReScan": "Tvinga omstart",
- "ButtonFullPath": "Full sökväg",
+ "ButtonFullPath": "Fullständig sökväg",
"ButtonHide": "Dölj",
"ButtonHome": "Hem",
"ButtonIssues": "Problem",
@@ -42,13 +42,18 @@
"ButtonMatchAllAuthors": "Matcha alla författare",
"ButtonMatchBooks": "Matcha böcker",
"ButtonNevermind": "Glöm det",
- "ButtonOk": "Okej",
+ "ButtonNext": "Nästa",
+ "ButtonNextChapter": "Nästa kapitel",
+ "ButtonOk": "Ok",
"ButtonOpenFeed": "Öppna flöde",
"ButtonOpenManager": "Öppna Manager",
"ButtonPause": "Pausa",
"ButtonPlay": "Spela",
+ "ButtonPlayAll": "Spela alla",
"ButtonPlaying": "Spelar",
"ButtonPlaylists": "Spellistor",
+ "ButtonPrevious": "Föregående",
+ "ButtonPreviousChapter": "Föregående kapitel",
"ButtonPurgeAllCache": "Rensa all cache",
"ButtonPurgeItemsCache": "Rensa föremåls-cache",
"ButtonQueueAddItem": "Lägg till i kön",
@@ -56,6 +61,9 @@
"ButtonQuickMatch": "Snabb matchning",
"ButtonReScan": "Omstart",
"ButtonRead": "Läs",
+ "ButtonReadLess": "Visa mindre",
+ "ButtonReadMore": "Visa mer",
+ "ButtonRefresh": "Uppdatera",
"ButtonRemove": "Ta bort",
"ButtonRemoveAll": "Ta bort alla",
"ButtonRemoveAllLibraryItems": "Ta bort alla biblioteksobjekt",
@@ -72,12 +80,13 @@
"ButtonScanLibrary": "Skanna bibliotek",
"ButtonSearch": "Sök",
"ButtonSelectFolderPath": "Välj mappens sökväg",
- "ButtonSeries": "Serie",
+ "ButtonSeries": "Serier",
"ButtonSetChaptersFromTracks": "Ställ in kapitel från spår",
"ButtonShiftTimes": "Förskjut tider",
"ButtonShow": "Visa",
"ButtonStartM4BEncode": "Starta M4B-kodning",
"ButtonStartMetadataEmbed": "Starta inbäddning av metadata",
+ "ButtonStats": "Statistik",
"ButtonSubmit": "Skicka",
"ButtonTest": "Testa",
"ButtonUpload": "Ladda upp",
@@ -123,7 +132,7 @@
"HeaderListeningStats": "Lyssningsstatistik",
"HeaderLogin": "Logga in",
"HeaderLogs": "Loggar",
- "HeaderManageGenres": "Hantera genrer",
+ "HeaderManageGenres": "Hantera kategorier",
"HeaderManageTags": "Hantera taggar",
"HeaderMapDetails": "Karta detaljer",
"HeaderMatch": "Matcha",
@@ -154,13 +163,14 @@
"HeaderSettingsExperimental": "Experimentella funktioner",
"HeaderSettingsGeneral": "Allmänt",
"HeaderSettingsScanner": "Skanner",
+ "HeaderSettingsWebClient": "Webklient",
"HeaderSleepTimer": "Sovtidtagare",
- "HeaderStatsLargestItems": "Största föremål",
- "HeaderStatsLongestItems": "Längsta föremål (tim)",
+ "HeaderStatsLargestItems": "Största objekt",
+ "HeaderStatsLongestItems": "Längsta objekt (tim)",
"HeaderStatsMinutesListeningChart": "Minuters lyssning (senaste 7 dagar)",
"HeaderStatsRecentSessions": "Senaste sessioner",
- "HeaderStatsTop10Authors": "Topp 10 författare",
- "HeaderStatsTop5Genres": "Topp 5 genrer",
+ "HeaderStatsTop10Authors": "10 populäraste författarna",
+ "HeaderStatsTop5Genres": "5 populäraste kategorierna",
"HeaderTableOfContents": "Innehållsförteckning",
"HeaderTools": "Verktyg",
"HeaderUpdateAccount": "Uppdatera konto",
@@ -168,7 +178,8 @@
"HeaderUpdateDetails": "Uppdatera detaljer",
"HeaderUpdateLibrary": "Uppdatera bibliotek",
"HeaderUsers": "Användare",
- "HeaderYourStats": "Dina statistik",
+ "HeaderYearReview": "Sammanställning för {0}",
+ "HeaderYourStats": "Din statistik",
"LabelAbridged": "Förkortad",
"LabelAccountType": "Kontotyp",
"LabelAccountTypeGuest": "Gäst",
@@ -191,18 +202,23 @@
"LabelAuthorLastFirst": "Författare (Efternamn, Förnamn)",
"LabelAuthors": "Författare",
"LabelAutoDownloadEpisodes": "Automatisk nedladdning av avsnitt",
+ "LabelAutoFetchMetadata": "Automatisk nedladdning av metadata",
+ "LabelAutoFetchMetadataHelp": "Hämtar metadata för titel, författare och serier. Kompletterande metadata får adderas efter uppladdningen.",
"LabelBackToUser": "Tillbaka till användaren",
- "LabelBackupLocation": "Säkerhetskopia Plats",
+ "LabelBackupLocation": "Plats för säkerhetskopia",
"LabelBackupsEnableAutomaticBackups": "Aktivera automatiska säkerhetskopior",
- "LabelBackupsEnableAutomaticBackupsHelp": "Säkerhetskopior sparas i /metadata/säkerhetskopior",
- "LabelBackupsMaxBackupSize": "Maximal säkerhetskopiostorlek (i GB)",
+ "LabelBackupsEnableAutomaticBackupsHelp": "Säkerhetskopior sparas i \"/metadata/backups\"",
+ "LabelBackupsMaxBackupSize": "Maximal storlek på säkerhetskopia (i GB) (0 = obegränsad)",
"LabelBackupsMaxBackupSizeHelp": "Som ett skydd mot felkonfiguration kommer säkerhetskopior att misslyckas om de överskrider den konfigurerade storleken.",
"LabelBackupsNumberToKeep": "Antal säkerhetskopior att behålla",
"LabelBackupsNumberToKeepHelp": "Endast en säkerhetskopia tas bort åt gången, så om du redan har fler säkerhetskopior än detta bör du ta bort dem manuellt.",
"LabelBitrate": "Bitfrekvens",
"LabelBooks": "Böcker",
+ "LabelButtonText": "Knapptext",
+ "LabelByAuthor": "av {0}",
"LabelChangePassword": "Ändra lösenord",
"LabelChannels": "Kanaler",
+ "LabelChapterCount": "{0} kapitel",
"LabelChapterTitle": "Kapitelrubrik",
"LabelChapters": "Kapitel",
"LabelChaptersFound": "hittade kapitel",
@@ -215,7 +231,7 @@
"LabelConfirmPassword": "Bekräfta lösenord",
"LabelContinueListening": "Fortsätt Lyssna",
"LabelContinueReading": "Fortsätt Läsa",
- "LabelContinueSeries": "Forsätt Serie",
+ "LabelContinueSeries": "Fortsätt Serie",
"LabelCover": "Omslag",
"LabelCoverImageURL": "URL till omslagsbild",
"LabelCreatedAt": "Skapad vid",
@@ -267,8 +283,8 @@
"LabelFontBoldness": "Fetstil",
"LabelFontFamily": "Teckensnittsfamilj",
"LabelFontScale": "Teckensnittsskala",
- "LabelGenre": "Genre",
- "LabelGenres": "Genrer",
+ "LabelGenre": "Kategori",
+ "LabelGenres": "Kategorier",
"LabelHardDeleteFile": "Hård radering av fil",
"LabelHasEbook": "Har E-bok",
"LabelHasSupplementaryEbook": "Har komplimenterande E-bok",
@@ -316,19 +332,19 @@
"LabelMediaType": "Mediatyp",
"LabelMetaTag": "Metamärke",
"LabelMetaTags": "Metamärken",
- "LabelMetadataProvider": "Metadataleverantör",
+ "LabelMetadataProvider": "Källa för metadata",
"LabelMinute": "Minut",
"LabelMissing": "Saknad",
"LabelMore": "Mer",
"LabelMoreInfo": "Mer information",
"LabelName": "Namn",
- "LabelNarrator": "Berättare",
- "LabelNarrators": "Berättare",
+ "LabelNarrator": "Uppläsare",
+ "LabelNarrators": "Uppläsare",
"LabelNew": "Ny",
"LabelNewPassword": "Nytt lösenord",
"LabelNewestAuthors": "Senast tillagda författare",
"LabelNewestEpisodes": "Senast tillagda avsnitt",
- "LabelNextBackupDate": "Nästa säkerhetskopia datum",
+ "LabelNextBackupDate": "Nästa datum för säkerhetskopia",
"LabelNextScheduledRun": "Nästa schemalagda körning",
"LabelNoEpisodesSelected": "Inga avsnitt valda",
"LabelNotFinished": "Ej avslutad",
@@ -367,7 +383,7 @@
"LabelPreventIndexing": "Förhindra att ditt flöde indexeras av iTunes och Google-podcastsökmotorer",
"LabelPrimaryEbook": "Primär e-bok",
"LabelProgress": "Framsteg",
- "LabelProvider": "Leverantör",
+ "LabelProvider": "Källa",
"LabelPubDate": "Publiceringsdatum",
"LabelPublishYear": "Publiceringsår",
"LabelPublisher": "Utgivare",
@@ -388,14 +404,14 @@
"LabelRemoveCover": "Ta bort omslag",
"LabelSearchTerm": "Sökterm",
"LabelSearchTitle": "Sök titel",
- "LabelSearchTitleOrASIN": "Sök titel eller ASIN",
+ "LabelSearchTitleOrASIN": "Sök titel eller ASIN-kod",
"LabelSeason": "Säsong",
"LabelSelectAllEpisodes": "Välj alla avsnitt",
"LabelSelectEpisodesShowing": "Välj {0} avsnitt som visas",
"LabelSelectUsers": "Välj användare",
"LabelSendEbookToDevice": "Skicka e-bok till...",
"LabelSequence": "Sekvens",
- "LabelSeries": "Serie",
+ "LabelSeries": "Serier",
"LabelSeriesName": "Serienamn",
"LabelSeriesProgress": "Serieframsteg",
"LabelSetEbookAsPrimary": "Ange som primär",
@@ -403,7 +419,7 @@
"LabelSettingsAudiobooksOnly": "Endast ljudböcker",
"LabelSettingsAudiobooksOnlyHelp": "Aktivera detta alternativ kommer att ignorera e-boksfiler om de inte finns inom en ljudboksmapp, i vilket fall de kommer att anges som kompletterande e-böcker",
"LabelSettingsBookshelfViewHelp": "Skeumorfisk design med trähyllor",
- "LabelSettingsChromecastSupport": "Chromecast-stöd",
+ "LabelSettingsChromecastSupport": "Stöd för Chromecast",
"LabelSettingsDateFormat": "Datumformat",
"LabelSettingsDisableWatcher": "Inaktivera Watcher",
"LabelSettingsDisableWatcherForLibrary": "Inaktivera mappbevakning för bibliotek",
@@ -415,24 +431,24 @@
"LabelSettingsExperimentalFeaturesHelp": "Funktioner under utveckling som behöver din feedback och hjälp med testning. Klicka för att öppna diskussionen på GitHub.",
"LabelSettingsFindCovers": "Hitta omslag",
"LabelSettingsFindCoversHelp": "Om din ljudbok inte har ett inbäddat omslag eller en omslagsbild i mappen kommer skannern att försöka hitta ett omslag. Observera: Detta kommer att förlänga skannningstiden",
- "LabelSettingsHideSingleBookSeries": "Dölj enboksserier",
+ "LabelSettingsHideSingleBookSeries": "Dölj serier med en bok",
"LabelSettingsHideSingleBookSeriesHelp": "Serier som har en enda bok kommer att döljas från seriesidan och hyllsidan på startsidan.",
"LabelSettingsHomePageBookshelfView": "Startsida använd bokhyllvy",
"LabelSettingsLibraryBookshelfView": "Bibliotek använd bokhyllvy",
"LabelSettingsParseSubtitles": "Analysera undertexter",
- "LabelSettingsParseSubtitlesHelp": "Extrahera undertexter från mappnamn för ljudböcker. Undertext måste vara åtskilda av \" - \" t.ex. \"Boktitel - En undertitel här\" har undertiteln \"En undertitel här\"",
+ "LabelSettingsParseSubtitlesHelp": "Extrahera undertitlar från namnet på mappar för ljudböcker. Undertiteln måste vara åtskilda med ett bindestreck \" - \". Mappen \"Boktitel - En undertitel här\" har undertiteln \"En undertitel här\"",
"LabelSettingsPreferMatchedMetadata": "Föredra matchad metadata",
"LabelSettingsPreferMatchedMetadataHelp": "Matchad data kommer att åsidosätta objektdetaljer vid snabbmatchning. Som standard kommer snabbmatchning endast att fylla i saknade detaljer.",
- "LabelSettingsSkipMatchingBooksWithASIN": "Hoppa över matchande böcker med ASIN",
+ "LabelSettingsSkipMatchingBooksWithASIN": "Hoppa över matchande böcker med ASIN-kod",
"LabelSettingsSkipMatchingBooksWithISBN": "Hoppa över matchande böcker med ISBN",
"LabelSettingsSortingIgnorePrefixes": "Ignorera prefix vid sortering",
- "LabelSettingsSortingIgnorePrefixesHelp": "t.ex. för prefixet \"the\" kommer boktiteln \"The Book Title\" att sorteras som \"Book Title, The\"",
+ "LabelSettingsSortingIgnorePrefixesHelp": "För prefix som t.ex. \"the\" kommer boktiteln \"The Book Title\" att sorteras som \"Book Title, The\"",
"LabelSettingsSquareBookCovers": "Använd fyrkantiga bokomslag",
"LabelSettingsSquareBookCoversHelp": "Föredrar att använda fyrkantiga omslag över standard 1.6:1 bokomslag",
"LabelSettingsStoreCoversWithItem": "Lagra omslag med objekt",
- "LabelSettingsStoreCoversWithItemHelp": "Som standard lagras omslag i /metadata/items, att aktivera detta alternativ kommer att lagra omslag i din biblioteksmapp. Endast en fil med namnet \"cover\" kommer att behållas",
+ "LabelSettingsStoreCoversWithItemHelp": "Som standard lagras bokomslag i mappen /metadata/items. Genom att aktivera detta alternativ kommer omslagen att lagra i din biblioteksmapp. Endast en fil med namnet \"cover\" kommer att behållas",
"LabelSettingsStoreMetadataWithItem": "Lagra metadata med objekt",
- "LabelSettingsStoreMetadataWithItemHelp": "Som standard lagras metadatafiler i /metadata/items, att aktivera detta alternativ kommer att lagra metadatafiler i dina biblioteksmappar",
+ "LabelSettingsStoreMetadataWithItemHelp": "Som standard lagras metadatafiler i mappen /metadata/items. Genom att aktivera detta alternativ kommer metadatafilerna att lagras i dina biblioteksmappar",
"LabelSettingsTimeFormat": "Tidsformat",
"LabelShowAll": "Visa alla",
"LabelSize": "Storlek",
@@ -457,7 +473,7 @@
"LabelStatsOverallHours": "Totalt antal timmar",
"LabelStatsWeekListening": "Veckans lyssnande",
"LabelSubtitle": "Underrubrik",
- "LabelSupportedFileTypes": "Stödda filtyper",
+ "LabelSupportedFileTypes": "Filtyper som accepteras",
"LabelTag": "Tagg",
"LabelTags": "Taggar",
"LabelTagsAccessibleToUser": "Taggar tillgängliga för användaren",
@@ -467,17 +483,22 @@
"LabelThemeDark": "Mörkt",
"LabelThemeLight": "Ljust",
"LabelTimeBase": "Tidsbas",
+ "LabelTimeDurationXHours": "{0} timmar",
+ "LabelTimeDurationXMinutes": "{0} minuter",
+ "LabelTimeDurationXSeconds": "{0} sekunder",
+ "LabelTimeInMinutes": "Tid i minuter",
+ "LabelTimeLeft": "{0} återstår",
"LabelTimeListened": "Tid lyssnad",
"LabelTimeListenedToday": "Tid lyssnad idag",
- "LabelTimeRemaining": "{0} kvar",
+ "LabelTimeRemaining": "{0} återstår",
"LabelTimeToShift": "Tid att skifta i sekunder",
"LabelTitle": "Titel",
"LabelToolsEmbedMetadata": "Bädda in metadata",
"LabelToolsEmbedMetadataDescription": "Bädda in metadata i ljudfiler, inklusive omslagsbild och kapitel.",
"LabelToolsMakeM4b": "Skapa M4B ljudbok",
"LabelToolsMakeM4bDescription": "Skapa en .M4B ljudboksfil med inbäddad metadata, omslagsbild och kapitel.",
- "LabelToolsSplitM4b": "Dela M4B till MP3-filer",
- "LabelToolsSplitM4bDescription": "Skapa MP3-filer från en M4B fil uppdelad i kapitel med inbäddad metadata, omslagsbild och kapitel.",
+ "LabelToolsSplitM4b": "Dela upp M4B-fil i MP3-filer",
+ "LabelToolsSplitM4bDescription": "Skapa MP3-filer från en M4B-fil uppdelad i kapitel med inbäddad metadata, omslagsbild och kapitel.",
"LabelTotalDuration": "Total varaktighet",
"LabelTotalTimeListened": "Total tid lyssnad",
"LabelTrackFromFilename": "Spår från filnamn",
@@ -486,6 +507,7 @@
"LabelTracksMultiTrack": "Flerspårigt",
"LabelTracksNone": "Inga spår",
"LabelTracksSingleTrack": "Enspårigt",
+ "LabelTrailer": "Trailer",
"LabelType": "Typ",
"LabelUnabridged": "Oavkortad",
"LabelUnknown": "Okänd",
@@ -496,16 +518,20 @@
"LabelUpdatedAt": "Uppdaterad vid",
"LabelUploaderDragAndDrop": "Dra och släpp filer eller mappar",
"LabelUploaderDropFiles": "Släpp filer",
+ "LabelUploaderItemFetchMetadataHelp": "Hämtar automatiskt titel, författare och serier.",
"LabelUseChapterTrack": "Använd kapitelspår",
"LabelUseFullTrack": "Använd hela spåret",
"LabelUser": "Användare",
"LabelUsername": "Användarnamn",
"LabelValue": "Värde",
+ "LabelVersion": "Version",
"LabelViewBookmarks": "Visa bokmärken",
"LabelViewChapters": "Visa kapitel",
"LabelViewQueue": "Visa spellista",
"LabelVolume": "Volym",
"LabelWeekdaysToRun": "Vardagar att köra",
+ "LabelYearReviewHide": "Dölj sammanställning för året",
+ "LabelYearReviewShow": "Visa sammanställning för året",
"LabelYourAudiobookDuration": "Din ljudboks varaktighet",
"LabelYourBookmarks": "Dina bokmärken",
"LabelYourPlaylists": "Dina spellistor",
@@ -535,22 +561,22 @@
"MessageConfirmMarkAllEpisodesFinished": "Är du säker på att du vill markera alla avsnitt som avslutade?",
"MessageConfirmMarkAllEpisodesNotFinished": "Är du säker på att du vill markera alla avsnitt som inte avslutade?",
"MessageConfirmMarkSeriesFinished": "Är du säker på att du vill markera alla böcker i denna serie som avslutade?",
- "MessageConfirmMarkSeriesNotFinished": "Är du säker på att du vill markera alla böcker i denna serie som inte avslutade?",
- "MessageConfirmQuickEmbed": "Varning! Quick embed kommer inte att säkerhetskopiera dina ljudfiler. Se till att du har en säkerhetskopia av dina ljudfiler.
Vill du fortsätta?",
+ "MessageConfirmMarkSeriesNotFinished": "Är du säker på att du vill markera alla böcker i denna serie som ej avslutade?",
+ "MessageConfirmQuickEmbed": "VARNING! Quick embed kommer inte att säkerhetskopiera dina ljudfiler. Se till att du har en säkerhetskopia av dina ljudfiler.
Vill du fortsätta?",
"MessageConfirmReScanLibraryItems": "Är du säker på att du vill göra omgenomsökning för {0} objekt?",
"MessageConfirmRemoveAllChapters": "Är du säker på att du vill ta bort alla kapitel?",
"MessageConfirmRemoveAuthor": "Är du säker på att du vill ta bort författaren \"{0}\"?",
"MessageConfirmRemoveCollection": "Är du säker på att du vill ta bort samlingen \"{0}\"?",
"MessageConfirmRemoveEpisode": "Är du säker på att du vill ta bort avsnittet \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Är du säker på att du vill ta bort {0} avsnitt?",
- "MessageConfirmRemoveNarrator": "Är du säker på att du vill ta bort berättaren \"{0}\"?",
+ "MessageConfirmRemoveNarrator": "Är du säker på att du vill ta bort uppläsaren \"{0}\"?",
"MessageConfirmRemovePlaylist": "Är du säker på att du vill ta bort din spellista \"{0}\"?",
- "MessageConfirmRenameGenre": "Är du säker på att du vill byta namn på genren \"{0}\" till \"{1}\" för alla objekt?",
- "MessageConfirmRenameGenreMergeNote": "Observera: Den här genren finns redan, så de kommer att slås samman.",
- "MessageConfirmRenameGenreWarning": "Varning! En liknande genre med annat skrivsätt finns redan \"{0}\".",
+ "MessageConfirmRenameGenre": "Är du säker på att du vill byta namn på kategori \"{0}\" till \"{1}\" för alla objekt?",
+ "MessageConfirmRenameGenreMergeNote": "OBS: Den här kategorin finns redan, så de kommer att slås samman.",
+ "MessageConfirmRenameGenreWarning": "Varning! En liknande kategori med annat skrivsätt finns redan \"{0}\".",
"MessageConfirmRenameTag": "Är du säker på att du vill byta namn på taggen \"{0}\" till \"{1}\" för alla objekt?",
- "MessageConfirmRenameTagMergeNote": "Observera: Den här taggen finns redan, så de kommer att slås samman.",
- "MessageConfirmRenameTagWarning": "Varning! En liknande tagg med annat skrivsätt finns redan \"{0}\".",
+ "MessageConfirmRenameTagMergeNote": "OBS: Den här taggen finns redan, så de kommer att slås samman.",
+ "MessageConfirmRenameTagWarning": "VARNING! En liknande tagg med annat skrivsätt finns redan \"{0}\".",
"MessageConfirmSendEbookToDevice": "Är du säker på att du vill skicka {0} e-bok \"{1}\" till enheten \"{2}\"?",
"MessageDownloadingEpisode": "Laddar ner avsnitt",
"MessageDragFilesIntoTrackOrder": "Dra filer till rätt spårordning",
@@ -574,7 +600,7 @@
"MessageMarkAllEpisodesNotFinished": "Markera alla avsnitt som inte avslutade",
"MessageMarkAsFinished": "Markera som avslutad",
"MessageMarkAsNotFinished": "Markera som inte avslutad",
- "MessageMatchBooksDescription": "kommer att försöka matcha böcker i biblioteket med en bok från den valda sökleverantören och fylla i tomma detaljer och omslagskonst. Överskriver inte detaljer.",
+ "MessageMatchBooksDescription": "kommer att försöka matcha böcker i biblioteket med en bok från den valda källan och fylla i uppgifter som saknas och bokomslag. Inga befintliga uppgifter kommer att ersättas.",
"MessageNoAudioTracks": "Inga ljudspår",
"MessageNoAuthors": "Inga författare",
"MessageNoBackups": "Inga säkerhetskopior",
@@ -588,7 +614,7 @@
"MessageNoEpisodeMatchesFound": "Inga matchande avsnitt hittades",
"MessageNoEpisodes": "Inga avsnitt",
"MessageNoFoldersAvailable": "Inga mappar tillgängliga",
- "MessageNoGenres": "Inga genrer",
+ "MessageNoGenres": "Inga kategorier",
"MessageNoIssues": "Inga problem",
"MessageNoItems": "Inga objekt",
"MessageNoItemsFound": "Inga objekt hittades",
@@ -637,7 +663,7 @@
"NoteFolderPicker": "Obs: Mappar som redan är kartlagda kommer inte att visas",
"NoteRSSFeedPodcastAppsHttps": "Varning: De flesta podcastappar kräver att RSS-flödets URL används med HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Varning: 1 eller flera av dina avsnitt har inte ett publiceringsdatum. Vissa podcastappar kräver detta.",
- "NoteUploaderFoldersWithMediaFiles": "Mappar med mediefiler hanteras som separata biblioteksobjekt.",
+ "NoteUploaderFoldersWithMediaFiles": "Mappar med flera mediefiler hanteras som separata objekt i biblioteket.",
"NoteUploaderOnlyAudioFiles": "Om du bara laddar upp ljudfiler kommer varje ljudfil att hanteras som en separat ljudbok.",
"NoteUploaderUnsupportedFiles": "Oaccepterade filer ignoreras. När du väljer eller släpper en mapp ignoreras andra filer som inte finns i ett objektmapp.",
"PlaceholderNewCollection": "Nytt samlingsnamn",
@@ -645,29 +671,42 @@
"PlaceholderNewPlaylist": "Nytt spellistanamn",
"PlaceholderSearch": "Sök...",
"PlaceholderSearchEpisode": "Sök avsnitt...",
+ "StatsTopAuthor": "POPULÄRAST FÖRFATTAREN",
+ "StatsTopAuthors": "POPULÄRASTE FÖRFATTARNA",
+ "StatsTopGenre": "Populäraste kategorin",
+ "StatsTopGenres": "Populäraste kategorierna",
+ "StatsTopMonth": "Bästa månaden",
+ "StatsTopNarrator": "Populärast uppläsarna",
+ "StatsTopNarrators": "Populäraste uppläsaren",
+ "StatsYearInReview": "SAMMANSTÄLLNING AV ÅRET",
"ToastAccountUpdateSuccess": "Kontot uppdaterat",
+ "ToastAsinRequired": "En ASIN-kod krävs",
"ToastAuthorImageRemoveSuccess": "Författarens bild borttagen",
+ "ToastAuthorNotFound": "Författaren \"{0}\" kunde inte identifieras",
+ "ToastAuthorRemoveSuccess": "Författaren har raderats",
+ "ToastAuthorSearchNotFound": "Författaren kunde inte identifieras",
"ToastAuthorUpdateMerged": "Författaren sammanslagen",
"ToastAuthorUpdateSuccess": "Författaren uppdaterad",
"ToastAuthorUpdateSuccessNoImageFound": "Författaren uppdaterad (ingen bild hittad)",
"ToastBackupCreateFailed": "Det gick inte att skapa en säkerhetskopia",
- "ToastBackupCreateSuccess": "Säkerhetskopia skapad",
- "ToastBackupDeleteFailed": "Det gick inte att ta bort säkerhetskopian",
- "ToastBackupDeleteSuccess": "Säkerhetskopan borttagen",
- "ToastBackupRestoreFailed": "Det gick inte att återställa säkerhetskopan",
- "ToastBackupUploadFailed": "Det gick inte att ladda upp säkerhetskopan",
- "ToastBackupUploadSuccess": "Säkerhetskopan uppladdad",
+ "ToastBackupCreateSuccess": "Säkerhetskopian har skapats",
+ "ToastBackupDeleteFailed": "Det gick inte att radera säkerhetskopian",
+ "ToastBackupDeleteSuccess": "Säkerhetskopian har raderats",
+ "ToastBackupInvalidMaxKeep": "Felaktigt antal kopior av backup har angivits",
+ "ToastBackupInvalidMaxSize": "Felaktig storlek på backup har angivits",
+ "ToastBackupRestoreFailed": "Det gick inte att återställa säkerhetskopian",
+ "ToastBackupUploadFailed": "Det gick inte att ladda upp säkerhetskopian",
+ "ToastBackupUploadSuccess": "Säkerhetskopian uppladdad",
"ToastBatchUpdateFailed": "Batchuppdateringen misslyckades",
"ToastBatchUpdateSuccess": "Batchuppdateringen lyckades",
"ToastBookmarkCreateFailed": "Det gick inte att skapa bokmärket",
- "ToastBookmarkCreateSuccess": "Bokmärket tillagt",
- "ToastBookmarkRemoveSuccess": "Bokmärket borttaget",
- "ToastBookmarkUpdateSuccess": "Bokmärket uppdaterat",
+ "ToastBookmarkCreateSuccess": "Bokmärket har adderats",
+ "ToastBookmarkRemoveSuccess": "Bokmärket har raderats",
+ "ToastBookmarkUpdateSuccess": "Bokmärket har uppdaterats",
"ToastChaptersHaveErrors": "Kapitlen har fel",
"ToastChaptersMustHaveTitles": "Kapitel måste ha titlar",
- "ToastCollectionItemsRemoveSuccess": "Objekt borttagna från samlingen",
- "ToastCollectionRemoveSuccess": "Samlingen borttagen",
- "ToastCollectionUpdateSuccess": "Samlingen uppdaterad",
+ "ToastCollectionRemoveSuccess": "Samlingen har raderats",
+ "ToastCollectionUpdateSuccess": "Samlingen har uppdaterats",
"ToastItemCoverUpdateSuccess": "Objektets omslag uppdaterat",
"ToastItemDetailsUpdateSuccess": "Objektdetaljer uppdaterade",
"ToastItemMarkedAsFinishedFailed": "Misslyckades med att markera som färdig",
@@ -693,8 +732,8 @@
"ToastRemoveItemFromCollectionSuccess": "Objektet borttaget från samlingen",
"ToastSendEbookToDeviceFailed": "Misslyckades med att skicka e-boken till enheten",
"ToastSendEbookToDeviceSuccess": "E-boken skickad till enheten \"{0}\"",
- "ToastSeriesUpdateFailed": "Serieuppdateringen misslyckades",
- "ToastSeriesUpdateSuccess": "Serieuppdateringen lyckades",
+ "ToastSeriesUpdateFailed": "Uppdateringen av serier misslyckades",
+ "ToastSeriesUpdateSuccess": "Uppdateringen av serierna lyckades",
"ToastSessionDeleteFailed": "Misslyckades med att ta bort sessionen",
"ToastSessionDeleteSuccess": "Sessionen borttagen",
"ToastSocketConnected": "Socket ansluten",
diff --git a/client/strings/uk.json b/client/strings/uk.json
index 3d0150f52..4b7452642 100644
--- a/client/strings/uk.json
+++ b/client/strings/uk.json
@@ -959,8 +959,6 @@
"ToastChaptersRemoved": "Розділи видалені",
"ToastChaptersUpdated": "Розділи оновлені",
"ToastCollectionItemsAddFailed": "Не вдалося додати елемент(и) до колекції",
- "ToastCollectionItemsAddSuccess": "Елемент(и) успішно додано до колекції",
- "ToastCollectionItemsRemoveSuccess": "Елемент(и) видалено з добірки",
"ToastCollectionRemoveSuccess": "Добірку видалено",
"ToastCollectionUpdateSuccess": "Добірку оновлено",
"ToastCoverUpdateFailed": "Не вдалося оновити обкладинку",
diff --git a/client/strings/vi-vn.json b/client/strings/vi-vn.json
index 8b0ca165c..a176dba32 100644
--- a/client/strings/vi-vn.json
+++ b/client/strings/vi-vn.json
@@ -683,7 +683,6 @@
"ToastBookmarkUpdateSuccess": "Đánh dấu đã được cập nhật",
"ToastChaptersHaveErrors": "Các chương có lỗi",
"ToastChaptersMustHaveTitles": "Các chương phải có tiêu đề",
- "ToastCollectionItemsRemoveSuccess": "Mục đã được xóa khỏi bộ sưu tập",
"ToastCollectionRemoveSuccess": "Bộ sưu tập đã được xóa",
"ToastCollectionUpdateSuccess": "Bộ sưu tập đã được cập nhật",
"ToastItemCoverUpdateSuccess": "Ảnh bìa mục đã được cập nhật",
diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json
index e4791aff5..472ef84e0 100644
--- a/client/strings/zh-cn.json
+++ b/client/strings/zh-cn.json
@@ -88,6 +88,8 @@
"ButtonSaveTracklist": "保存音轨列表",
"ButtonScan": "扫描",
"ButtonScanLibrary": "扫描库",
+ "ButtonScrollLeft": "向左滚动",
+ "ButtonScrollRight": "向右滚动",
"ButtonSearch": "查找",
"ButtonSelectFolderPath": "选择文件夹路径",
"ButtonSeries": "系列",
@@ -190,6 +192,7 @@
"HeaderSettingsExperimental": "实验功能",
"HeaderSettingsGeneral": "通用",
"HeaderSettingsScanner": "扫描",
+ "HeaderSettingsWebClient": "网页客户端",
"HeaderSleepTimer": "睡眠计时",
"HeaderStatsLargestItems": "最大的项目",
"HeaderStatsLongestItems": "项目时长(小时)",
@@ -542,6 +545,7 @@
"LabelServerYearReview": "服务器年度回顾 ({0})",
"LabelSetEbookAsPrimary": "设置为主",
"LabelSetEbookAsSupplementary": "设置为补充",
+ "LabelSettingsAllowIframe": "允许嵌入到 iframe 中",
"LabelSettingsAudiobooksOnly": "只有有声读物",
"LabelSettingsAudiobooksOnlyHelp": "启用此设置将忽略电子书文件, 除非它们位于有声读物文件夹中, 在这种情况下, 它们将被设置为补充电子书",
"LabelSettingsBookshelfViewHelp": "带有木架子的拟物化设计",
@@ -592,6 +596,8 @@
"LabelSize": "文件大小",
"LabelSleepTimer": "睡眠定时",
"LabelSlug": "Slug",
+ "LabelSortAscending": "升序",
+ "LabelSortDescending": "降序",
"LabelStart": "开始",
"LabelStartTime": "开始时间",
"LabelStarted": "开始于",
@@ -953,8 +959,6 @@
"ToastChaptersRemoved": "已删除章节",
"ToastChaptersUpdated": "章节已更新",
"ToastCollectionItemsAddFailed": "项目添加到收藏夹失败",
- "ToastCollectionItemsAddSuccess": "项目添加到收藏夹成功",
- "ToastCollectionItemsRemoveSuccess": "项目从收藏夹移除",
"ToastCollectionRemoveSuccess": "收藏夹已删除",
"ToastCollectionUpdateSuccess": "收藏夹已更新",
"ToastCoverUpdateFailed": "封面更新失败",
diff --git a/client/strings/zh-tw.json b/client/strings/zh-tw.json
index 080d1bac0..96f878c2e 100644
--- a/client/strings/zh-tw.json
+++ b/client/strings/zh-tw.json
@@ -727,7 +727,6 @@
"ToastBookmarkUpdateSuccess": "書籤已更新",
"ToastChaptersHaveErrors": "章節有錯誤",
"ToastChaptersMustHaveTitles": "章節必須有標題",
- "ToastCollectionItemsRemoveSuccess": "項目從收藏夾移除",
"ToastCollectionRemoveSuccess": "收藏夾已刪除",
"ToastCollectionUpdateSuccess": "收藏夾已更新",
"ToastItemCoverUpdateSuccess": "項目封面已更新",
diff --git a/package-lock.json b/package-lock.json
index efa917dcd..c34525379 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "audiobookshelf",
- "version": "2.17.5",
+ "version": "2.17.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf",
- "version": "2.17.5",
+ "version": "2.17.6",
"license": "GPL-3.0",
"dependencies": {
"axios": "^0.27.2",
diff --git a/package.json b/package.json
index 2e9c97090..d7b190373 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
- "version": "2.17.5",
+ "version": "2.17.6",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",
diff --git a/server/Database.js b/server/Database.js
index afb09dae9..bd14fbd5a 100644
--- a/server/Database.js
+++ b/server/Database.js
@@ -406,21 +406,6 @@ class Database {
return Promise.all(oldBooks.map((oldBook) => this.models.book.saveFromOld(oldBook)))
}
- createBulkCollectionBooks(collectionBooks) {
- if (!this.sequelize) return false
- return this.models.collectionBook.bulkCreate(collectionBooks)
- }
-
- createPlaylistMediaItem(playlistMediaItem) {
- if (!this.sequelize) return false
- return this.models.playlistMediaItem.create(playlistMediaItem)
- }
-
- createBulkPlaylistMediaItems(playlistMediaItems) {
- if (!this.sequelize) return false
- return this.models.playlistMediaItem.bulkCreate(playlistMediaItems)
- }
-
async createLibraryItem(oldLibraryItem) {
if (!this.sequelize) return false
await oldLibraryItem.saveMetadata()
diff --git a/server/Server.js b/server/Server.js
index 46850cbb7..e9e77f00f 100644
--- a/server/Server.js
+++ b/server/Server.js
@@ -6,6 +6,7 @@ const util = require('util')
const fs = require('./libs/fsExtra')
const fileUpload = require('./libs/expressFileupload')
const cookieParser = require('cookie-parser')
+const axios = require('axios')
const { version } = require('../package.json')
@@ -54,7 +55,26 @@ class Server {
global.XAccel = process.env.USE_X_ACCEL
global.AllowCors = process.env.ALLOW_CORS === '1'
- if (process.env.DISABLE_SSRF_REQUEST_FILTER === '1') {
+ if (process.env.EXP_PROXY_SUPPORT === '1') {
+ // https://github.com/advplyr/audiobookshelf/pull/3754
+ Logger.info(`[Server] Experimental Proxy Support Enabled, SSRF Request Filter was Disabled`)
+ global.DisableSsrfRequestFilter = () => true
+
+ axios.defaults.maxRedirects = 0
+ axios.interceptors.response.use(
+ (response) => response,
+ (error) => {
+ if ([301, 302].includes(error.response?.status)) {
+ return axios({
+ ...error.config,
+ url: error.response.headers.location
+ })
+ }
+
+ return Promise.reject(error)
+ }
+ )
+ } else if (process.env.DISABLE_SSRF_REQUEST_FILTER === '1') {
Logger.info(`[Server] SSRF Request Filter Disabled`)
global.DisableSsrfRequestFilter = () => true
} else if (process.env.SSRF_REQUEST_FILTER_WHITELIST?.length) {
diff --git a/server/controllers/CollectionController.js b/server/controllers/CollectionController.js
index 3e35c08b5..6986f2b79 100644
--- a/server/controllers/CollectionController.js
+++ b/server/controllers/CollectionController.js
@@ -5,13 +5,17 @@ const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const RssFeedManager = require('../managers/RssFeedManager')
-const Collection = require('../objects/Collection')
/**
* @typedef RequestUserObject
* @property {import('../models/User')} user
*
* @typedef {Request & RequestUserObject} RequestWithUser
+ *
+ * @typedef RequestEntityObject
+ * @property {import('../models/Collection')} collection
+ *
+ * @typedef {RequestWithUser & RequestEntityObject} CollectionControllerRequest
*/
class CollectionController {
@@ -25,36 +29,71 @@ class CollectionController {
* @param {Response} res
*/
async create(req, res) {
- const newCollection = new Collection()
- req.body.userId = req.user.id
- if (!newCollection.setData(req.body)) {
+ const reqBody = req.body || {}
+
+ // Validation
+ if (!reqBody.name || !reqBody.libraryId) {
return res.status(400).send('Invalid collection data')
}
+ if (reqBody.description && typeof reqBody.description !== 'string') {
+ return res.status(400).send('Invalid collection description')
+ }
+ const libraryItemIds = (reqBody.books || []).filter((b) => !!b && typeof b == 'string')
+ if (!libraryItemIds.length) {
+ return res.status(400).send('Invalid collection data. No books')
+ }
- // Create collection record
- await Database.collectionModel.createFromOld(newCollection)
-
- // Get library items in collection
- const libraryItemsInCollection = await Database.libraryItemModel.getForCollection(newCollection)
-
- // Create collectionBook records
- let order = 1
- const collectionBooksToAdd = []
- for (const libraryItemId of newCollection.books) {
- const libraryItem = libraryItemsInCollection.find((li) => li.id === libraryItemId)
- if (libraryItem) {
- collectionBooksToAdd.push({
- collectionId: newCollection.id,
- bookId: libraryItem.media.id,
- order: order++
- })
+ // Load library items
+ const libraryItems = await Database.libraryItemModel.findAll({
+ attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
+ where: {
+ id: libraryItemIds,
+ libraryId: reqBody.libraryId,
+ mediaType: 'book'
}
- }
- if (collectionBooksToAdd.length) {
- await Database.createBulkCollectionBooks(collectionBooksToAdd)
+ })
+ if (libraryItems.length !== libraryItemIds.length) {
+ return res.status(400).send('Invalid collection data. Invalid books')
}
- const jsonExpanded = newCollection.toJSONExpanded(libraryItemsInCollection)
+ /** @type {import('../models/Collection')} */
+ let newCollection = null
+
+ const transaction = await Database.sequelize.transaction()
+ try {
+ // Create collection
+ newCollection = await Database.collectionModel.create(
+ {
+ libraryId: reqBody.libraryId,
+ name: reqBody.name,
+ description: reqBody.description || null
+ },
+ { transaction }
+ )
+
+ // Create collectionBooks
+ const collectionBookPayloads = libraryItemIds.map((llid, index) => {
+ const libraryItem = libraryItems.find((li) => li.id === llid)
+ return {
+ collectionId: newCollection.id,
+ bookId: libraryItem.mediaId,
+ order: index + 1
+ }
+ })
+ await Database.collectionBookModel.bulkCreate(collectionBookPayloads, { transaction })
+
+ await transaction.commit()
+ } catch (error) {
+ await transaction.rollback()
+ Logger.error('[CollectionController] create:', error)
+ return res.status(500).send('Failed to create collection')
+ }
+
+ // Load books expanded
+ newCollection.books = await newCollection.getBooksExpandedWithLibraryItem()
+
+ // Note: The old collection model stores expanded libraryItems in the books property
+ const jsonExpanded = newCollection.toOldJSONExpanded()
SocketAuthority.emitter('collection_added', jsonExpanded)
res.json(jsonExpanded)
}
@@ -75,7 +114,7 @@ class CollectionController {
/**
* GET: /api/collections/:id
*
- * @param {RequestWithUser} req
+ * @param {CollectionControllerRequest} req
* @param {Response} res
*/
async findOne(req, res) {
@@ -94,7 +133,7 @@ class CollectionController {
* PATCH: /api/collections/:id
* Update collection
*
- * @param {RequestWithUser} req
+ * @param {CollectionControllerRequest} req
* @param {Response} res
*/
async update(req, res) {
@@ -158,7 +197,7 @@ class CollectionController {
*
* @this {import('../routers/ApiRouter')}
*
- * @param {RequestWithUser} req
+ * @param {CollectionControllerRequest} req
* @param {Response} res
*/
async delete(req, res) {
@@ -178,7 +217,7 @@ class CollectionController {
* Add a single book to a collection
* Req.body { id: }
*
- * @param {RequestWithUser} req
+ * @param {CollectionControllerRequest} req
* @param {Response} res
*/
async addBook(req, res) {
@@ -212,7 +251,7 @@ class CollectionController {
* Remove a single book from a collection. Re-order books
* TODO: bookId is actually libraryItemId. Clients need updating to use bookId
*
- * @param {RequestWithUser} req
+ * @param {CollectionControllerRequest} req
* @param {Response} res
*/
async removeBook(req, res) {
@@ -257,29 +296,31 @@ class CollectionController {
* Add multiple books to collection
* Req.body { books: }
*
- * @param {RequestWithUser} req
+ * @param {CollectionControllerRequest} req
* @param {Response} res
*/
async addBatch(req, res) {
// filter out invalid libraryItemIds
const bookIdsToAdd = (req.body.books || []).filter((b) => !!b && typeof b == 'string')
if (!bookIdsToAdd.length) {
- return res.status(500).send('Invalid request body')
+ return res.status(400).send('Invalid request body')
}
// Get library items associated with ids
const libraryItems = await Database.libraryItemModel.findAll({
+ attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
where: {
- id: {
- [Sequelize.Op.in]: bookIdsToAdd
- }
- },
- include: {
- model: Database.bookModel
+ id: bookIdsToAdd,
+ libraryId: req.collection.libraryId,
+ mediaType: 'book'
}
})
+ if (!libraryItems.length) {
+ return res.status(400).send('Invalid request body. No valid books')
+ }
// Get collection books already in collection
+ /** @type {import('../models/CollectionBook')[]} */
const collectionBooks = await req.collection.getCollectionBooks()
let order = collectionBooks.length + 1
@@ -288,10 +329,10 @@ class CollectionController {
// Check and set new collection books to add
for (const libraryItem of libraryItems) {
- if (!collectionBooks.some((cb) => cb.bookId === libraryItem.media.id)) {
+ if (!collectionBooks.some((cb) => cb.bookId === libraryItem.mediaId)) {
collectionBooksToAdd.push({
collectionId: req.collection.id,
- bookId: libraryItem.media.id,
+ bookId: libraryItem.mediaId,
order: order++
})
hasUpdated = true
@@ -302,7 +343,8 @@ class CollectionController {
let jsonExpanded = null
if (hasUpdated) {
- await Database.createBulkCollectionBooks(collectionBooksToAdd)
+ await Database.collectionBookModel.bulkCreate(collectionBooksToAdd)
+
jsonExpanded = await req.collection.getOldJsonExpanded()
SocketAuthority.emitter('collection_updated', jsonExpanded)
} else {
@@ -316,7 +358,7 @@ class CollectionController {
* Remove multiple books from collection
* Req.body { books: }
*
- * @param {RequestWithUser} req
+ * @param {CollectionControllerRequest} req
* @param {Response} res
*/
async removeBatch(req, res) {
@@ -329,9 +371,7 @@ class CollectionController {
// Get library items associated with ids
const libraryItems = await Database.libraryItemModel.findAll({
where: {
- id: {
- [Sequelize.Op.in]: bookIdsToRemove
- }
+ id: bookIdsToRemove
},
include: {
model: Database.bookModel
@@ -339,6 +379,7 @@ class CollectionController {
})
// Get collection books already in collection
+ /** @type {import('../models/CollectionBook')[]} */
const collectionBooks = await req.collection.getCollectionBooks({
order: [['order', 'ASC']]
})
diff --git a/server/controllers/PlaylistController.js b/server/controllers/PlaylistController.js
index 5b84fe16f..8c13ecb2f 100644
--- a/server/controllers/PlaylistController.js
+++ b/server/controllers/PlaylistController.js
@@ -3,13 +3,16 @@ const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
-const Playlist = require('../objects/Playlist')
-
/**
* @typedef RequestUserObject
* @property {import('../models/User')} user
*
* @typedef {Request & RequestUserObject} RequestWithUser
+ *
+ * @typedef RequestEntityObject
+ * @property {import('../models/Playlist')} playlist
+ *
+ * @typedef {RequestWithUser & RequestEntityObject} PlaylistControllerRequest
*/
class PlaylistController {
@@ -23,48 +26,103 @@ class PlaylistController {
* @param {Response} res
*/
async create(req, res) {
- const oldPlaylist = new Playlist()
- req.body.userId = req.user.id
- const success = oldPlaylist.setData(req.body)
- if (!success) {
- return res.status(400).send('Invalid playlist request data')
+ const reqBody = req.body || {}
+
+ // Validation
+ if (!reqBody.name || !reqBody.libraryId) {
+ return res.status(400).send('Invalid playlist data')
+ }
+ if (reqBody.description && typeof reqBody.description !== 'string') {
+ return res.status(400).send('Invalid playlist description')
+ }
+ const items = reqBody.items || []
+ const isPodcast = items.some((i) => i.episodeId)
+ const libraryItemIds = new Set()
+ for (const item of items) {
+ if (!item.libraryItemId || typeof item.libraryItemId !== 'string') {
+ return res.status(400).send('Invalid playlist item')
+ }
+ if (isPodcast && (!item.episodeId || typeof item.episodeId !== 'string')) {
+ return res.status(400).send('Invalid playlist item episodeId')
+ } else if (!isPodcast && item.episodeId) {
+ return res.status(400).send('Invalid playlist item episodeId')
+ }
+ libraryItemIds.add(item.libraryItemId)
}
- // Create Playlist record
- const newPlaylist = await Database.playlistModel.createFromOld(oldPlaylist)
-
- // Lookup all library items in playlist
- const libraryItemIds = oldPlaylist.items.map((i) => i.libraryItemId).filter((i) => i)
- const libraryItemsInPlaylist = await Database.libraryItemModel.findAll({
+ // Load library items
+ const libraryItems = await Database.libraryItemModel.findAll({
+ attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
where: {
- id: libraryItemIds
+ id: Array.from(libraryItemIds),
+ libraryId: reqBody.libraryId,
+ mediaType: isPodcast ? 'podcast' : 'book'
}
})
+ if (libraryItems.length !== libraryItemIds.size) {
+ return res.status(400).send('Invalid playlist data. Invalid items')
+ }
- // Create playlistMediaItem records
- const mediaItemsToAdd = []
- let order = 1
- for (const mediaItemObj of oldPlaylist.items) {
- const libraryItem = libraryItemsInPlaylist.find((li) => li.id === mediaItemObj.libraryItemId)
- if (!libraryItem) continue
-
- mediaItemsToAdd.push({
- mediaItemId: mediaItemObj.episodeId || libraryItem.mediaId,
- mediaItemType: mediaItemObj.episodeId ? 'podcastEpisode' : 'book',
- playlistId: oldPlaylist.id,
- order: order++
+ // Validate podcast episodes
+ if (isPodcast) {
+ const podcastEpisodeIds = items.map((i) => i.episodeId)
+ const podcastEpisodes = await Database.podcastEpisodeModel.findAll({
+ attributes: ['id'],
+ where: {
+ id: podcastEpisodeIds
+ }
})
- }
- if (mediaItemsToAdd.length) {
- await Database.createBulkPlaylistMediaItems(mediaItemsToAdd)
+ if (podcastEpisodes.length !== podcastEpisodeIds.length) {
+ return res.status(400).send('Invalid playlist data. Invalid podcast episodes')
+ }
}
- const jsonExpanded = await newPlaylist.getOldJsonExpanded()
- SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
- res.json(jsonExpanded)
+ const transaction = await Database.sequelize.transaction()
+ try {
+ // Create playlist
+ const newPlaylist = await Database.playlistModel.create(
+ {
+ libraryId: reqBody.libraryId,
+ userId: req.user.id,
+ name: reqBody.name,
+ description: reqBody.description || null
+ },
+ { transaction }
+ )
+
+ // Create playlistMediaItems
+ const playlistItemPayloads = []
+ for (const [index, item] of items.entries()) {
+ const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
+ playlistItemPayloads.push({
+ playlistId: newPlaylist.id,
+ mediaItemId: item.episodeId || libraryItem.mediaId,
+ mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
+ order: index + 1
+ })
+ }
+
+ await Database.playlistMediaItemModel.bulkCreate(playlistItemPayloads, { transaction })
+
+ await transaction.commit()
+
+ newPlaylist.playlistMediaItems = await newPlaylist.getMediaItemsExpandedWithLibraryItem()
+
+ const jsonExpanded = newPlaylist.toOldJSONExpanded()
+ SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
+ res.json(jsonExpanded)
+ } catch (error) {
+ await transaction.rollback()
+ Logger.error('[PlaylistController] create:', error)
+ res.status(500).send('Failed to create playlist')
+ }
}
/**
+ * @deprecated - Use /api/libraries/:libraryId/playlists
+ * This is not used by Abs web client or mobile apps
+ * TODO: Remove this endpoint or make it the primary
+ *
* GET: /api/playlists
* Get all playlists for user
*
@@ -72,68 +130,89 @@ class PlaylistController {
* @param {Response} res
*/
async findAllForUser(req, res) {
- const playlistsForUser = await Database.playlistModel.findAll({
- where: {
- userId: req.user.id
- }
- })
- const playlists = []
- for (const playlist of playlistsForUser) {
- const jsonExpanded = await playlist.getOldJsonExpanded()
- playlists.push(jsonExpanded)
- }
+ const playlistsForUser = await Database.playlistModel.getOldPlaylistsForUserAndLibrary(req.user.id)
res.json({
- playlists
+ playlists: playlistsForUser
})
}
/**
* GET: /api/playlists/:id
*
- * @param {RequestWithUser} req
+ * @param {PlaylistControllerRequest} req
* @param {Response} res
*/
async findOne(req, res) {
- const jsonExpanded = await req.playlist.getOldJsonExpanded()
- res.json(jsonExpanded)
+ req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
+ res.json(req.playlist.toOldJSONExpanded())
}
/**
* PATCH: /api/playlists/:id
* Update playlist
*
- * @param {RequestWithUser} req
+ * Used for updating name and description or reordering items
+ *
+ * @param {PlaylistControllerRequest} req
* @param {Response} res
*/
async update(req, res) {
- const updatedPlaylist = req.playlist.set(req.body)
- let wasUpdated = false
- const changed = updatedPlaylist.changed()
- if (changed?.length) {
- await req.playlist.save()
- Logger.debug(`[PlaylistController] Updated playlist ${req.playlist.id} keys [${changed.join(',')}]`)
- wasUpdated = true
+ // Validation
+ const reqBody = req.body || {}
+ if (reqBody.libraryId || reqBody.userId) {
+ // Could allow support for this if needed with additional validation
+ return res.status(400).send('Invalid playlist data. Cannot update libraryId or userId')
+ }
+ if (reqBody.name && typeof reqBody.name !== 'string') {
+ return res.status(400).send('Invalid playlist name')
+ }
+ if (reqBody.description && typeof reqBody.description !== 'string') {
+ return res.status(400).send('Invalid playlist description')
+ }
+ if (reqBody.items && (!Array.isArray(reqBody.items) || reqBody.items.some((i) => !i.libraryItemId || typeof i.libraryItemId !== 'string' || (i.episodeId && typeof i.episodeId !== 'string')))) {
+ return res.status(400).send('Invalid playlist items')
}
- // If array of items is passed in then update order of playlist media items
- const libraryItemIds = req.body.items?.map((i) => i.libraryItemId).filter((i) => i) || []
- if (libraryItemIds.length) {
+ const playlistUpdatePayload = {}
+ if (reqBody.name) playlistUpdatePayload.name = reqBody.name
+ if (reqBody.description) playlistUpdatePayload.description = reqBody.description
+
+ // Update name and description
+ let wasUpdated = false
+ if (Object.keys(playlistUpdatePayload).length) {
+ req.playlist.set(playlistUpdatePayload)
+ const changed = req.playlist.changed()
+ if (changed?.length) {
+ await req.playlist.save()
+ Logger.debug(`[PlaylistController] Updated playlist ${req.playlist.id} keys [${changed.join(',')}]`)
+ wasUpdated = true
+ }
+ }
+
+ // If array of items is set then update order of playlist media items
+ if (reqBody.items?.length) {
+ const libraryItemIds = Array.from(new Set(reqBody.items.map((i) => i.libraryItemId)))
const libraryItems = await Database.libraryItemModel.findAll({
+ attributes: ['id', 'mediaId', 'mediaType'],
where: {
id: libraryItemIds
}
})
- const existingPlaylistMediaItems = await updatedPlaylist.getPlaylistMediaItems({
+ if (libraryItems.length !== libraryItemIds.length) {
+ return res.status(400).send('Invalid playlist items. Items not found')
+ }
+ /** @type {import('../models/PlaylistMediaItem')[]} */
+ const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({
order: [['order', 'ASC']]
})
+ if (existingPlaylistMediaItems.length !== reqBody.items.length) {
+ return res.status(400).send('Invalid playlist items. Length mismatch')
+ }
// Set an array of mediaItemId
const newMediaItemIdOrder = []
- for (const item of req.body.items) {
+ for (const item of reqBody.items) {
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
- if (!libraryItem) {
- continue
- }
const mediaItemId = item.episodeId || libraryItem.mediaId
newMediaItemIdOrder.push(mediaItemId)
}
@@ -146,21 +225,21 @@ class PlaylistController {
})
// Update order on playlistMediaItem records
- let order = 1
- for (const playlistMediaItem of existingPlaylistMediaItems) {
- if (playlistMediaItem.order !== order) {
+ for (const [index, playlistMediaItem] of existingPlaylistMediaItems.entries()) {
+ if (playlistMediaItem.order !== index + 1) {
await playlistMediaItem.update({
- order
+ order: index + 1
})
wasUpdated = true
}
- order++
}
}
- const jsonExpanded = await updatedPlaylist.getOldJsonExpanded()
+ req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
+
+ const jsonExpanded = req.playlist.toOldJSONExpanded()
if (wasUpdated) {
- SocketAuthority.clientEmitter(updatedPlaylist.userId, 'playlist_updated', jsonExpanded)
+ SocketAuthority.clientEmitter(req.playlist.userId, 'playlist_updated', jsonExpanded)
}
res.json(jsonExpanded)
}
@@ -169,11 +248,13 @@ class PlaylistController {
* DELETE: /api/playlists/:id
* Remove playlist
*
- * @param {RequestWithUser} req
+ * @param {PlaylistControllerRequest} req
* @param {Response} res
*/
async delete(req, res) {
- const jsonExpanded = await req.playlist.getOldJsonExpanded()
+ req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
+ const jsonExpanded = req.playlist.toOldJSONExpanded()
+
await req.playlist.destroy()
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
res.sendStatus(200)
@@ -183,12 +264,13 @@ class PlaylistController {
* POST: /api/playlists/:id/item
* Add item to playlist
*
- * @param {RequestWithUser} req
+ * This is not used by Abs web client or mobile apps. Only the batch endpoints are used.
+ *
+ * @param {PlaylistControllerRequest} req
* @param {Response} res
*/
async addItem(req, res) {
- const oldPlaylist = await Database.playlistModel.getById(req.playlist.id)
- const itemToAdd = req.body
+ const itemToAdd = req.body || {}
if (!itemToAdd.libraryItemId) {
return res.status(400).send('Request body has no libraryItemId')
@@ -198,12 +280,9 @@ class PlaylistController {
if (!libraryItem) {
return res.status(400).send('Library item not found')
}
- if (libraryItem.libraryId !== oldPlaylist.libraryId) {
+ if (libraryItem.libraryId !== req.playlist.libraryId) {
return res.status(400).send('Library item in different library')
}
- if (oldPlaylist.containsItem(itemToAdd)) {
- return res.status(400).send('Item already in playlist')
- }
if ((itemToAdd.episodeId && !libraryItem.isPodcast) || (libraryItem.isPodcast && !itemToAdd.episodeId)) {
return res.status(400).send('Invalid item to add for this library type')
}
@@ -211,15 +290,38 @@ class PlaylistController {
return res.status(400).send('Episode not found in library item')
}
- const playlistMediaItem = {
- playlistId: oldPlaylist.id,
- mediaItemId: itemToAdd.episodeId || libraryItem.media.id,
- mediaItemType: itemToAdd.episodeId ? 'podcastEpisode' : 'book',
- order: oldPlaylist.items.length + 1
+ req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
+
+ if (req.playlist.checkHasMediaItem(itemToAdd.libraryItemId, itemToAdd.episodeId)) {
+ return res.status(400).send('Item already in playlist')
+ }
+
+ const jsonExpanded = req.playlist.toOldJSONExpanded()
+
+ const playlistMediaItem = {
+ playlistId: req.playlist.id,
+ mediaItemId: itemToAdd.episodeId || libraryItem.media.id,
+ mediaItemType: itemToAdd.episodeId ? 'podcastEpisode' : 'book',
+ order: req.playlist.playlistMediaItems.length + 1
+ }
+ await Database.playlistMediaItemModel.create(playlistMediaItem)
+
+ // Add the new item to to the old json expanded to prevent having to fully reload the playlist media items
+ if (itemToAdd.episodeId) {
+ const episode = libraryItem.media.episodes.find((ep) => ep.id === itemToAdd.episodeId)
+ jsonExpanded.items.push({
+ episodeId: itemToAdd.episodeId,
+ episode: episode.toJSONExpanded(),
+ libraryItemId: libraryItem.id,
+ libraryItem: libraryItem.toJSONMinified()
+ })
+ } else {
+ jsonExpanded.items.push({
+ libraryItemId: libraryItem.id,
+ libraryItem: libraryItem.toJSONExpanded()
+ })
}
- await Database.createPlaylistMediaItem(playlistMediaItem)
- const jsonExpanded = await req.playlist.getOldJsonExpanded()
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_updated', jsonExpanded)
res.json(jsonExpanded)
}
@@ -228,43 +330,36 @@ class PlaylistController {
* DELETE: /api/playlists/:id/item/:libraryItemId/:episodeId?
* Remove item from playlist
*
- * @param {RequestWithUser} req
+ * @param {PlaylistControllerRequest} req
* @param {Response} res
*/
async removeItem(req, res) {
- const oldLibraryItem = await Database.libraryItemModel.getOldById(req.params.libraryItemId)
- if (!oldLibraryItem) {
- return res.status(404).send('Library item not found')
+ req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
+
+ let playlistMediaItem = null
+ if (req.params.episodeId) {
+ playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItemId === req.params.episodeId)
+ } else {
+ playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItem.libraryItem?.id === req.params.libraryItemId)
}
-
- // Get playlist media items
- const mediaItemId = req.params.episodeId || oldLibraryItem.media.id
- const playlistMediaItems = await req.playlist.getPlaylistMediaItems({
- order: [['order', 'ASC']]
- })
-
- // Check if media item to delete is in playlist
- const mediaItemToRemove = playlistMediaItems.find((pmi) => pmi.mediaItemId === mediaItemId)
- if (!mediaItemToRemove) {
+ if (!playlistMediaItem) {
return res.status(404).send('Media item not found in playlist')
}
// Remove record
- await mediaItemToRemove.destroy()
+ await playlistMediaItem.destroy()
+ req.playlist.playlistMediaItems = req.playlist.playlistMediaItems.filter((pmi) => pmi.id !== playlistMediaItem.id)
// Update playlist media items order
- let order = 1
- for (const mediaItem of playlistMediaItems) {
- if (mediaItem.mediaItemId === mediaItemId) continue
- if (mediaItem.order !== order) {
+ for (const [index, mediaItem] of req.playlist.playlistMediaItems.entries()) {
+ if (mediaItem.order !== index + 1) {
await mediaItem.update({
- order
+ order: index + 1
})
}
- order++
}
- const jsonExpanded = await req.playlist.getOldJsonExpanded()
+ const jsonExpanded = req.playlist.toOldJSONExpanded()
// Playlist is removed when there are no items
if (!jsonExpanded.items.length) {
@@ -282,64 +377,68 @@ class PlaylistController {
* POST: /api/playlists/:id/batch/add
* Batch add playlist items
*
- * @param {RequestWithUser} req
+ * @param {PlaylistControllerRequest} req
* @param {Response} res
*/
async addBatch(req, res) {
- if (!req.body.items?.length) {
- return res.status(400).send('Invalid request body')
- }
- const itemsToAdd = req.body.items
-
- const libraryItemIds = itemsToAdd.map((i) => i.libraryItemId).filter((i) => i)
- if (!libraryItemIds.length) {
- return res.status(400).send('Invalid request body')
+ if (!req.body.items?.length || !Array.isArray(req.body.items) || req.body.items.some((i) => !i?.libraryItemId || typeof i.libraryItemId !== 'string' || (i.episodeId && typeof i.episodeId !== 'string'))) {
+ return res.status(400).send('Invalid request body items')
}
// Find all library items
- const libraryItems = await Database.libraryItemModel.findAll({
- where: {
- id: libraryItemIds
- }
- })
+ const libraryItemIds = new Set(req.body.items.map((i) => i.libraryItemId).filter((i) => i))
- // Get all existing playlist media items
- const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({
- order: [['order', 'ASC']]
- })
+ const oldLibraryItems = await Database.libraryItemModel.getAllOldLibraryItems({ id: Array.from(libraryItemIds) })
+ if (oldLibraryItems.length !== libraryItemIds.size) {
+ return res.status(400).send('Invalid request body items')
+ }
+
+ req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
const mediaItemsToAdd = []
+ const jsonExpanded = req.playlist.toOldJSONExpanded()
// Setup array of playlistMediaItem records to add
- let order = existingPlaylistMediaItems.length + 1
- for (const item of itemsToAdd) {
- const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
- if (!libraryItem) {
- return res.status(404).send('Item not found with id ' + item.libraryItemId)
+ let order = req.playlist.playlistMediaItems.length + 1
+ for (const item of req.body.items) {
+ const libraryItem = oldLibraryItems.find((li) => li.id === item.libraryItemId)
+
+ const mediaItemId = item.episodeId || libraryItem.media.id
+ if (req.playlist.playlistMediaItems.some((pmi) => pmi.mediaItemId === mediaItemId)) {
+ // Already exists in playlist
+ continue
} else {
- const mediaItemId = item.episodeId || libraryItem.mediaId
- if (existingPlaylistMediaItems.some((pmi) => pmi.mediaItemId === mediaItemId)) {
- // Already exists in playlist
- continue
+ mediaItemsToAdd.push({
+ playlistId: req.playlist.id,
+ mediaItemId,
+ mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
+ order: order++
+ })
+
+ // Add the new item to to the old json expanded to prevent having to fully reload the playlist media items
+ if (item.episodeId) {
+ const episode = libraryItem.media.episodes.find((ep) => ep.id === item.episodeId)
+ jsonExpanded.items.push({
+ episodeId: item.episodeId,
+ episode: episode.toJSONExpanded(),
+ libraryItemId: libraryItem.id,
+ libraryItem: libraryItem.toJSONMinified()
+ })
} else {
- mediaItemsToAdd.push({
- playlistId: req.playlist.id,
- mediaItemId,
- mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
- order: order++
+ jsonExpanded.items.push({
+ libraryItemId: libraryItem.id,
+ libraryItem: libraryItem.toJSONExpanded()
})
}
}
}
- let jsonExpanded = null
if (mediaItemsToAdd.length) {
- await Database.createBulkPlaylistMediaItems(mediaItemsToAdd)
- jsonExpanded = await req.playlist.getOldJsonExpanded()
+ await Database.playlistMediaItemModel.bulkCreate(mediaItemsToAdd)
+
SocketAuthority.clientEmitter(req.playlist.userId, 'playlist_updated', jsonExpanded)
- } else {
- jsonExpanded = await req.playlist.getOldJsonExpanded()
}
+
res.json(jsonExpanded)
}
@@ -347,50 +446,40 @@ class PlaylistController {
* POST: /api/playlists/:id/batch/remove
* Batch remove playlist items
*
- * @param {RequestWithUser} req
+ * @param {PlaylistControllerRequest} req
* @param {Response} res
*/
async removeBatch(req, res) {
- if (!req.body.items?.length) {
- return res.status(400).send('Invalid request body')
+ if (!req.body.items?.length || !Array.isArray(req.body.items) || req.body.items.some((i) => !i?.libraryItemId || typeof i.libraryItemId !== 'string' || (i.episodeId && typeof i.episodeId !== 'string'))) {
+ return res.status(400).send('Invalid request body items')
}
- const itemsToRemove = req.body.items
- const libraryItemIds = itemsToRemove.map((i) => i.libraryItemId).filter((i) => i)
- if (!libraryItemIds.length) {
- return res.status(400).send('Invalid request body')
- }
-
- // Find all library items
- const libraryItems = await Database.libraryItemModel.findAll({
- where: {
- id: libraryItemIds
- }
- })
-
- // Get all existing playlist media items for playlist
- const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({
- order: [['order', 'ASC']]
- })
- let numMediaItems = existingPlaylistMediaItems.length
+ req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
// Remove playlist media items
let hasUpdated = false
- for (const item of itemsToRemove) {
- const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
- if (!libraryItem) continue
- const mediaItemId = item.episodeId || libraryItem.mediaId
- const existingMediaItem = existingPlaylistMediaItems.find((pmi) => pmi.mediaItemId === mediaItemId)
- if (!existingMediaItem) continue
- await existingMediaItem.destroy()
+ for (const item of req.body.items) {
+ let playlistMediaItem = null
+ if (item.episodeId) {
+ playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItemId === item.episodeId)
+ } else {
+ playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItem.libraryItem?.id === item.libraryItemId)
+ }
+ if (!playlistMediaItem) {
+ Logger.warn(`[PlaylistController] Playlist item not found in playlist ${req.playlist.id}`, item)
+ continue
+ }
+
+ await playlistMediaItem.destroy()
+ req.playlist.playlistMediaItems = req.playlist.playlistMediaItems.filter((pmi) => pmi.id !== playlistMediaItem.id)
+
hasUpdated = true
- numMediaItems--
}
- const jsonExpanded = await req.playlist.getOldJsonExpanded()
+ const jsonExpanded = req.playlist.toOldJSONExpanded()
if (hasUpdated) {
// Playlist is removed when there are no items
- if (!numMediaItems) {
+ if (!req.playlist.playlistMediaItems.length) {
Logger.info(`[PlaylistController] Playlist "${req.playlist.name}" has no more items - removing it`)
await req.playlist.destroy()
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
@@ -425,33 +514,41 @@ class PlaylistController {
return res.status(400).send('Collection has no books')
}
- const oldPlaylist = new Playlist()
- oldPlaylist.setData({
- userId: req.user.id,
- libraryId: collection.libraryId,
- name: collection.name,
- description: collection.description || null
- })
+ const transaction = await Database.sequelize.transaction()
+ try {
+ const playlist = await Database.playlistModel.create(
+ {
+ userId: req.user.id,
+ libraryId: collection.libraryId,
+ name: collection.name,
+ description: collection.description || null
+ },
+ { transaction }
+ )
- // Create Playlist record
- const newPlaylist = await Database.playlistModel.createFromOld(oldPlaylist)
+ const mediaItemsToAdd = []
+ for (const [index, libraryItem] of collectionExpanded.books.entries()) {
+ mediaItemsToAdd.push({
+ playlistId: playlist.id,
+ mediaItemId: libraryItem.media.id,
+ mediaItemType: 'book',
+ order: index + 1
+ })
+ }
+ await Database.playlistMediaItemModel.bulkCreate(mediaItemsToAdd, { transaction })
- // Create PlaylistMediaItem records
- const mediaItemsToAdd = []
- let order = 1
- for (const libraryItem of collectionExpanded.books) {
- mediaItemsToAdd.push({
- playlistId: newPlaylist.id,
- mediaItemId: libraryItem.media.id,
- mediaItemType: 'book',
- order: order++
- })
+ await transaction.commit()
+
+ playlist.playlistMediaItems = await playlist.getMediaItemsExpandedWithLibraryItem()
+
+ const jsonExpanded = playlist.toOldJSONExpanded()
+ SocketAuthority.clientEmitter(playlist.userId, 'playlist_added', jsonExpanded)
+ res.json(jsonExpanded)
+ } catch (error) {
+ await transaction.rollback()
+ Logger.error('[PlaylistController] createFromCollection:', error)
+ res.status(500).send('Failed to create playlist')
}
- await Database.createBulkPlaylistMediaItems(mediaItemsToAdd)
-
- const jsonExpanded = await newPlaylist.getOldJsonExpanded()
- SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
- res.json(jsonExpanded)
}
/**
diff --git a/server/controllers/ShareController.js b/server/controllers/ShareController.js
index e1568c0db..93c6e9fbc 100644
--- a/server/controllers/ShareController.js
+++ b/server/controllers/ShareController.js
@@ -7,6 +7,7 @@ const Database = require('../Database')
const { PlayMethod } = require('../utils/constants')
const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUtils')
+const zipHelpers = require('../utils/zipHelpers')
const PlaybackSession = require('../objects/PlaybackSession')
const ShareManager = require('../managers/ShareManager')
@@ -210,6 +211,65 @@ class ShareController {
res.sendFile(audioTrackPath)
}
+ /**
+ * Public route - requires share_session_id cookie
+ *
+ * GET: /api/share/:slug/download
+ * Downloads media item share
+ *
+ * @param {Request} req
+ * @param {Response} res
+ */
+ async downloadMediaItemShare(req, res) {
+ if (!req.cookies.share_session_id) {
+ return res.status(404).send('Share session not set')
+ }
+
+ const { slug } = req.params
+ const mediaItemShare = ShareManager.findBySlug(slug)
+ if (!mediaItemShare) {
+ return res.status(404)
+ }
+ if (!mediaItemShare.isDownloadable) {
+ return res.status(403).send('Download is not allowed for this item')
+ }
+
+ const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id)
+ if (!playbackSession || playbackSession.mediaItemShareId !== mediaItemShare.id) {
+ return res.status(404).send('Share session not found')
+ }
+
+ const libraryItem = await Database.libraryItemModel.findByPk(playbackSession.libraryItemId, {
+ attributes: ['id', 'path', 'relPath', 'isFile']
+ })
+ if (!libraryItem) {
+ return res.status(404).send('Library item not found')
+ }
+
+ const itemPath = libraryItem.path
+ const itemTitle = playbackSession.displayTitle
+
+ Logger.info(`[ShareController] Requested download for book "${itemTitle}" at "${itemPath}"`)
+
+ try {
+ if (libraryItem.isFile) {
+ const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(itemPath))
+ if (audioMimeType) {
+ res.setHeader('Content-Type', audioMimeType)
+ }
+ await new Promise((resolve, reject) => res.download(itemPath, libraryItem.relPath, (error) => (error ? reject(error) : resolve())))
+ } else {
+ const filename = `${itemTitle}.zip`
+ await zipHelpers.zipDirectoryPipe(itemPath, filename, res)
+ }
+
+ Logger.info(`[ShareController] Downloaded item "${itemTitle}" at "${itemPath}"`)
+ } catch (error) {
+ Logger.error(`[ShareController] Download failed for item "${itemTitle}" at "${itemPath}"`, error)
+ res.status(500).send('Failed to download the item')
+ }
+ }
+
/**
* Public route - requires share_session_id cookie
*
@@ -259,7 +319,7 @@ class ShareController {
return res.sendStatus(403)
}
- const { slug, expiresAt, mediaItemType, mediaItemId } = req.body
+ const { slug, expiresAt, mediaItemType, mediaItemId, isDownloadable } = req.body
if (!slug?.trim?.() || typeof mediaItemType !== 'string' || typeof mediaItemId !== 'string') {
return res.status(400).send('Missing or invalid required fields')
@@ -298,7 +358,8 @@ class ShareController {
expiresAt: expiresAt || null,
mediaItemId,
mediaItemType,
- userId: req.user.id
+ userId: req.user.id,
+ isDownloadable
})
ShareManager.openMediaItemShare(mediaItemShare)
diff --git a/server/managers/RssFeedManager.js b/server/managers/RssFeedManager.js
index cedf0dfb8..de009c3d9 100644
--- a/server/managers/RssFeedManager.js
+++ b/server/managers/RssFeedManager.js
@@ -98,11 +98,22 @@ class RssFeedManager {
podcastId: feed.entity.mediaId
},
attributes: ['id', 'updatedAt'],
- order: [['createdAt', 'DESC']]
+ order: [['updatedAt', 'DESC']]
})
+
if (mostRecentPodcastEpisode && mostRecentPodcastEpisode.updatedAt > newEntityUpdatedAt) {
newEntityUpdatedAt = mostRecentPodcastEpisode.updatedAt
}
+ } else {
+ const book = await Database.bookModel.findOne({
+ where: {
+ id: feed.entity.mediaId
+ },
+ attributes: ['id', 'updatedAt']
+ })
+ if (book && book.updatedAt > newEntityUpdatedAt) {
+ newEntityUpdatedAt = book.updatedAt
+ }
}
return newEntityUpdatedAt > feed.entityUpdatedAt
@@ -111,7 +122,7 @@ class RssFeedManager {
attributes: ['id', 'updatedAt'],
include: {
model: Database.bookModel,
- attributes: ['id'],
+ attributes: ['id', 'audioFiles', 'updatedAt'],
through: {
attributes: []
},
@@ -122,13 +133,16 @@ class RssFeedManager {
}
})
+ const totalBookTracks = feed.entity.books.reduce((total, book) => total + book.includedAudioFiles.length, 0)
+ if (feed.feedEpisodes.length !== totalBookTracks) {
+ return true
+ }
+
let newEntityUpdatedAt = feed.entity.updatedAt
const mostRecentItemUpdatedAt = feed.entity.books.reduce((mostRecent, book) => {
- if (book.libraryItem.updatedAt > mostRecent) {
- return book.libraryItem.updatedAt
- }
- return mostRecent
+ let updatedAt = book.libraryItem.updatedAt > book.updatedAt ? book.libraryItem.updatedAt : book.updatedAt
+ return updatedAt > mostRecent ? updatedAt : mostRecent
}, 0)
if (mostRecentItemUpdatedAt > newEntityUpdatedAt) {
@@ -151,6 +165,9 @@ class RssFeedManager {
let feed = await Database.feedModel.findOne({
where: {
slug: req.params.slug
+ },
+ include: {
+ model: Database.feedEpisodeModel
}
})
if (!feed) {
@@ -163,8 +180,6 @@ class RssFeedManager {
if (feedRequiresUpdate) {
Logger.info(`[RssFeedManager] Feed "${feed.title}" requires update - updating feed`)
feed = await feed.updateFeedForEntity()
- } else {
- feed.feedEpisodes = await feed.getFeedEpisodes()
}
const xml = feed.buildXml(req.originalHostPrefix)
diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md
index f49924327..3b5a5626b 100644
--- a/server/migrations/changelog.md
+++ b/server/migrations/changelog.md
@@ -11,3 +11,5 @@ Please add a record of every database migration that you create to this file. Th
| v2.17.3 | v2.17.3-fk-constraints | Changes the foreign key constraints for tables due to sequelize bug dropping constraints in v2.17.0 migration |
| v2.17.4 | v2.17.4-use-subfolder-for-oidc-redirect-uris | Save subfolder to OIDC redirect URIs to support existing installations |
| v2.17.5 | v2.17.5-remove-host-from-feed-urls | removes the host (serverAddress) from URL columns in the feeds and feedEpisodes tables |
+| v2.17.6 | v2.17.6-share-add-isdownloadable | Adds the isDownloadable column to the mediaItemShares table |
+| v2.17.7 | v2.17.7-add-indices | Adds indices to the libraryItems and books tables to reduce query times |
diff --git a/server/migrations/v2.17.6-share-add-isdownloadable.js b/server/migrations/v2.17.6-share-add-isdownloadable.js
new file mode 100644
index 000000000..9434d2844
--- /dev/null
+++ b/server/migrations/v2.17.6-share-add-isdownloadable.js
@@ -0,0 +1,68 @@
+/**
+ * @typedef MigrationContext
+ * @property {import('sequelize').QueryInterface} queryInterface - a Sequelize QueryInterface object.
+ * @property {import('../Logger')} logger - a Logger object.
+ *
+ * @typedef MigrationOptions
+ * @property {MigrationContext} context - an object containing the migration context.
+ */
+
+const migrationVersion = '2.17.6'
+const migrationName = `${migrationVersion}-share-add-isdownloadable`
+const loggerPrefix = `[${migrationVersion} migration]`
+
+/**
+ * This migration script adds the isDownloadable column to the mediaItemShares table.
+ *
+ * @param {MigrationOptions} options - an object containing the migration context.
+ * @returns {Promise} - A promise that resolves when the migration is complete.
+ */
+async function up({ context: { queryInterface, logger } }) {
+ logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
+
+ if (await queryInterface.tableExists('mediaItemShares')) {
+ const tableDescription = await queryInterface.describeTable('mediaItemShares')
+ if (!tableDescription.isDownloadable) {
+ logger.info(`${loggerPrefix} Adding isDownloadable column to mediaItemShares table`)
+ await queryInterface.addColumn('mediaItemShares', 'isDownloadable', {
+ type: queryInterface.sequelize.Sequelize.DataTypes.BOOLEAN,
+ defaultValue: false,
+ allowNull: false
+ })
+ logger.info(`${loggerPrefix} Added isDownloadable column to mediaItemShares table`)
+ } else {
+ logger.info(`${loggerPrefix} isDownloadable column already exists in mediaItemShares table`)
+ }
+ } else {
+ logger.info(`${loggerPrefix} mediaItemShares table does not exist`)
+ }
+
+ logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
+}
+
+/**
+ * This migration script removes the isDownloadable column from the mediaItemShares table.
+ *
+ * @param {MigrationOptions} options - an object containing the migration context.
+ * @returns {Promise} - A promise that resolves when the migration is complete.
+ */
+async function down({ context: { queryInterface, logger } }) {
+ logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
+
+ if (await queryInterface.tableExists('mediaItemShares')) {
+ const tableDescription = await queryInterface.describeTable('mediaItemShares')
+ if (tableDescription.isDownloadable) {
+ logger.info(`${loggerPrefix} Removing isDownloadable column from mediaItemShares table`)
+ await queryInterface.removeColumn('mediaItemShares', 'isDownloadable')
+ logger.info(`${loggerPrefix} Removed isDownloadable column from mediaItemShares table`)
+ } else {
+ logger.info(`${loggerPrefix} isDownloadable column does not exist in mediaItemShares table`)
+ }
+ } else {
+ logger.info(`${loggerPrefix} mediaItemShares table does not exist`)
+ }
+
+ logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
+}
+
+module.exports = { up, down }
diff --git a/server/migrations/v2.17.7-add-indices.js b/server/migrations/v2.17.7-add-indices.js
new file mode 100644
index 000000000..b3821de85
--- /dev/null
+++ b/server/migrations/v2.17.7-add-indices.js
@@ -0,0 +1,83 @@
+/**
+ * @typedef MigrationContext
+ * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
+ * @property {import('../Logger')} logger - a Logger object.
+ *
+ * @typedef MigrationOptions
+ * @property {MigrationContext} context - an object containing the migration context.
+ */
+
+const migrationVersion = '2.17.7'
+const migrationName = `${migrationVersion}-add-indices`
+const loggerPrefix = `[${migrationVersion} migration]`
+
+/**
+ * This upward migration adds some indices to the libraryItems and books tables to improve query performance
+ *
+ * @param {MigrationOptions} options - an object containing the migration context.
+ * @returns {Promise} - A promise that resolves when the migration is complete.
+ */
+async function up({ context: { queryInterface, logger } }) {
+ // Upwards migration script
+ logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
+
+ await addIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', 'size'])
+ await addIndex(queryInterface, logger, 'books', ['duration'])
+
+ logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
+}
+
+/**
+ * This downward migration script removes the indices added in the upward migration script
+ *
+ * @param {MigrationOptions} options - an object containing the migration context.
+ * @returns {Promise} - A promise that resolves when the migration is complete.
+ */
+async function down({ context: { queryInterface, logger } }) {
+ // Downward migration script
+ logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
+
+ await removeIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', 'size'])
+ await removeIndex(queryInterface, logger, 'books', ['duration'])
+
+ logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
+}
+
+/**
+ * Utility function to add an index to a table. If the index already exists, it logs a message and continues.
+ *
+ * @param {import('sequelize').QueryInterface} queryInterface
+ * @param {import ('../Logger')} logger
+ * @param {string} tableName
+ * @param {string[]} columns
+ */
+async function addIndex(queryInterface, logger, tableName, columns) {
+ try {
+ logger.info(`${loggerPrefix} adding index [${columns.join(', ')}] to table "${tableName}"`)
+ await queryInterface.addIndex(tableName, columns)
+ logger.info(`${loggerPrefix} added index [${columns.join(', ')}] to table "${tableName}"`)
+ } catch (error) {
+ if (error.name === 'SequelizeDatabaseError' && error.message.includes('already exists')) {
+ logger.info(`${loggerPrefix} index [${columns.join(', ')}] for table "${tableName}" already exists`)
+ } else {
+ throw error
+ }
+ }
+}
+
+/**
+ * Utility function to remove an index from a table.
+ * Sequelize implemets it using DROP INDEX IF EXISTS, so it won't throw an error if the index doesn't exist.
+ *
+ * @param {import('sequelize').QueryInterface} queryInterface
+ * @param {import ('../Logger')} logger
+ * @param {string} tableName
+ * @param {string[]} columns
+ */
+async function removeIndex(queryInterface, logger, tableName, columns) {
+ logger.info(`${loggerPrefix} removing index [${columns.join(', ')}] from table "${tableName}"`)
+ await queryInterface.removeIndex(tableName, columns)
+ logger.info(`${loggerPrefix} removed index [${columns.join(', ')}] from table "${tableName}"`)
+}
+
+module.exports = { up, down }
diff --git a/server/models/Book.js b/server/models/Book.js
index f7341db90..a904f5369 100644
--- a/server/models/Book.js
+++ b/server/models/Book.js
@@ -321,10 +321,10 @@ class Book extends Model {
// },
{
fields: ['publishedYear']
+ },
+ {
+ fields: ['duration']
}
- // {
- // fields: ['duration']
- // }
]
}
)
diff --git a/server/models/Collection.js b/server/models/Collection.js
index e01ad90a6..c8f62e699 100644
--- a/server/models/Collection.js
+++ b/server/models/Collection.js
@@ -1,7 +1,5 @@
const { DataTypes, Model, Sequelize } = require('sequelize')
-const oldCollection = require('../objects/Collection')
-
class Collection extends Model {
constructor(values, options) {
super(values, options)
@@ -26,12 +24,12 @@ class Collection extends Model {
}
/**
- * Get all old collections toJSONExpanded, items filtered for user permissions
+ * Get all toOldJSONExpanded, items filtered for user permissions
*
* @param {import('./User')} user
* @param {string} [libraryId]
* @param {string[]} [include]
- * @returns {Promise} oldCollection.toJSONExpanded
+ * @async
*/
static async getOldCollectionsJsonExpanded(user, libraryId, include) {
let collectionWhere = null
@@ -79,8 +77,6 @@ class Collection extends Model {
// TODO: Handle user permission restrictions on initial query
return collections
.map((c) => {
- const oldCollection = this.getOldCollection(c)
-
// Filter books using user permissions
const books =
c.books?.filter((b) => {
@@ -95,20 +91,14 @@ class Collection extends Model {
return true
}) || []
- // Map to library items
- const libraryItems = books.map((b) => {
- const libraryItem = b.libraryItem
- delete b.libraryItem
- libraryItem.media = b
- return this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem)
- })
-
// Users with restricted permissions will not see this collection
- if (!books.length && oldCollection.books.length) {
+ if (!books.length && c.books.length) {
return null
}
- const collectionExpanded = oldCollection.toJSONExpanded(libraryItems)
+ this.books = books
+
+ const collectionExpanded = c.toOldJSONExpanded()
// Map feed if found
if (c.feeds?.length) {
@@ -153,69 +143,6 @@ class Collection extends Model {
})
}
- /**
- * Get old collection from Collection
- * @param {Collection} collectionExpanded
- * @returns {oldCollection}
- */
- static getOldCollection(collectionExpanded) {
- const libraryItemIds = collectionExpanded.books?.map((b) => b.libraryItem?.id || null).filter((lid) => lid) || []
- return new oldCollection({
- id: collectionExpanded.id,
- libraryId: collectionExpanded.libraryId,
- name: collectionExpanded.name,
- description: collectionExpanded.description,
- books: libraryItemIds,
- lastUpdate: collectionExpanded.updatedAt.valueOf(),
- createdAt: collectionExpanded.createdAt.valueOf()
- })
- }
-
- /**
- *
- * @param {oldCollection} oldCollection
- * @returns {Promise}
- */
- static createFromOld(oldCollection) {
- const collection = this.getFromOld(oldCollection)
- return this.create(collection)
- }
-
- static getFromOld(oldCollection) {
- return {
- id: oldCollection.id,
- name: oldCollection.name,
- description: oldCollection.description,
- libraryId: oldCollection.libraryId
- }
- }
-
- static removeById(collectionId) {
- return this.destroy({
- where: {
- id: collectionId
- }
- })
- }
-
- /**
- * Get old collection by id
- * @param {string} collectionId
- * @returns {Promise} returns null if not found
- */
- static async getOldById(collectionId) {
- if (!collectionId) return null
- const collection = await this.findByPk(collectionId, {
- include: {
- model: this.sequelize.models.book,
- include: this.sequelize.models.libraryItem
- },
- order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']]
- })
- if (!collection) return null
- return this.getOldCollection(collection)
- }
-
/**
* Remove all collections belonging to library
* @param {string} libraryId
@@ -286,64 +213,37 @@ class Collection extends Model {
}
/**
- * Get old collection toJSONExpanded, items filtered for user permissions
+ * Get toOldJSONExpanded, items filtered for user permissions
*
* @param {import('./User')|null} user
* @param {string[]} [include]
- * @returns {Promise} oldCollection.toJSONExpanded
+ * @async
*/
async getOldJsonExpanded(user, include) {
- this.books =
- (await this.getBooks({
- include: [
- {
- model: this.sequelize.models.libraryItem
- },
- {
- model: this.sequelize.models.author,
- through: {
- attributes: []
- }
- },
- {
- model: this.sequelize.models.series,
- through: {
- attributes: ['sequence']
- }
- }
- ],
- order: [Sequelize.literal('`collectionBook.order` ASC')]
- })) || []
+ this.books = await this.getBooksExpandedWithLibraryItem()
// Filter books using user permissions
// TODO: Handle user permission restrictions on initial query
- const books =
- this.books?.filter((b) => {
- if (user) {
- if (b.tags?.length && !user.checkCanAccessLibraryItemWithTags(b.tags)) {
- return false
- }
- if (b.explicit === true && !user.canAccessExplicitContent) {
- return false
- }
+ if (user) {
+ const books = this.books.filter((b) => {
+ if (b.tags?.length && !user.checkCanAccessLibraryItemWithTags(b.tags)) {
+ return false
+ }
+ if (b.explicit === true && !user.canAccessExplicitContent) {
+ return false
}
return true
- }) || []
+ })
- // Map to library items
- const libraryItems = books.map((b) => {
- const libraryItem = b.libraryItem
- delete b.libraryItem
- libraryItem.media = b
- return this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem)
- })
+ // Users with restricted permissions will not see this collection
+ if (!books.length && this.books.length) {
+ return null
+ }
- // Users with restricted permissions will not see this collection
- if (!books.length && this.books.length) {
- return null
+ this.books = books
}
- const collectionExpanded = this.toOldJSONExpanded(libraryItems)
+ const collectionExpanded = this.toOldJSONExpanded()
if (include?.includes('rssfeed')) {
const feeds = await this.getFeeds()
@@ -357,10 +257,10 @@ class Collection extends Model {
/**
*
- * @param {string[]} libraryItemIds
+ * @param {string[]} [libraryItemIds=[]]
* @returns
*/
- toOldJSON(libraryItemIds) {
+ toOldJSON(libraryItemIds = []) {
return {
id: this.id,
libraryId: this.libraryId,
@@ -372,19 +272,19 @@ class Collection extends Model {
}
}
- /**
- *
- * @param {import('../objects/LibraryItem')} oldLibraryItems
- * @returns
- */
- toOldJSONExpanded(oldLibraryItems) {
- const json = this.toOldJSON(oldLibraryItems.map((li) => li.id))
- json.books = json.books
- .map((libraryItemId) => {
- const book = oldLibraryItems.find((li) => li.id === libraryItemId)
- return book ? book.toJSONExpanded() : null
- })
- .filter((b) => !!b)
+ toOldJSONExpanded() {
+ if (!this.books) {
+ throw new Error('Books are required to expand Collection')
+ }
+
+ const json = this.toOldJSON()
+ json.books = this.books.map((book) => {
+ const libraryItem = book.libraryItem
+ delete book.libraryItem
+ libraryItem.media = book
+ return this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem).toJSONExpanded()
+ })
+
return json
}
}
diff --git a/server/models/CollectionBook.js b/server/models/CollectionBook.js
index e04da3b24..e706d68c4 100644
--- a/server/models/CollectionBook.js
+++ b/server/models/CollectionBook.js
@@ -16,15 +16,6 @@ class CollectionBook extends Model {
this.createdAt
}
- static removeByIds(collectionId, bookId) {
- return this.destroy({
- where: {
- bookId,
- collectionId
- }
- })
- }
-
static init(sequelize) {
super.init(
{
diff --git a/server/models/Feed.js b/server/models/Feed.js
index d8f8553ca..41bca449c 100644
--- a/server/models/Feed.js
+++ b/server/models/Feed.js
@@ -107,6 +107,9 @@ class Feed extends Model {
entityUpdatedAt = libraryItem.media.podcastEpisodes.reduce((mostRecent, episode) => {
return episode.updatedAt > mostRecent ? episode.updatedAt : mostRecent
}, entityUpdatedAt)
+ } else if (libraryItem.media.updatedAt > entityUpdatedAt) {
+ // Book feeds will use Book.updatedAt if more recent
+ entityUpdatedAt = libraryItem.media.updatedAt
}
const feedObj = {
@@ -187,7 +190,8 @@ class Feed extends Model {
const booksWithTracks = collectionExpanded.books.filter((book) => book.includedAudioFiles.length)
const entityUpdatedAt = booksWithTracks.reduce((mostRecent, book) => {
- return book.libraryItem.updatedAt > mostRecent ? book.libraryItem.updatedAt : mostRecent
+ const updatedAt = book.libraryItem.updatedAt > book.updatedAt ? book.libraryItem.updatedAt : book.updatedAt
+ return updatedAt > mostRecent ? updatedAt : mostRecent
}, collectionExpanded.updatedAt)
const firstBookWithCover = booksWithTracks.find((book) => book.coverPath)
@@ -275,7 +279,8 @@ class Feed extends Model {
static getFeedObjForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions = null) {
const booksWithTracks = seriesExpanded.books.filter((book) => book.includedAudioFiles.length)
const entityUpdatedAt = booksWithTracks.reduce((mostRecent, book) => {
- return book.libraryItem.updatedAt > mostRecent ? book.libraryItem.updatedAt : mostRecent
+ const updatedAt = book.libraryItem.updatedAt > book.updatedAt ? book.libraryItem.updatedAt : book.updatedAt
+ return updatedAt > mostRecent ? updatedAt : mostRecent
}, seriesExpanded.updatedAt)
const firstBookWithCover = booksWithTracks.find((book) => book.coverPath)
@@ -516,17 +521,24 @@ class Feed extends Model {
try {
const updatedFeed = await this.update(feedObj, { transaction })
- // Remove existing feed episodes
- await feedEpisodeModel.destroy({
- where: {
- feedId: this.id
- },
- transaction
- })
+ const existingFeedEpisodeIds = this.feedEpisodes.map((ep) => ep.id)
// Create new feed episodes
updatedFeed.feedEpisodes = await feedEpisodeCreateFunc(feedEpisodeCreateFuncEntity, updatedFeed, this.slug, transaction)
+ const newFeedEpisodeIds = updatedFeed.feedEpisodes.map((ep) => ep.id)
+ const feedEpisodeIdsToRemove = existingFeedEpisodeIds.filter((epid) => !newFeedEpisodeIds.includes(epid))
+
+ if (feedEpisodeIdsToRemove.length) {
+ Logger.info(`[Feed] Removing ${feedEpisodeIdsToRemove.length} episodes from feed ${this.id}`)
+ await feedEpisodeModel.destroy({
+ where: {
+ id: feedEpisodeIdsToRemove
+ },
+ transaction
+ })
+ }
+
await transaction.commit()
return updatedFeed
diff --git a/server/models/FeedEpisode.js b/server/models/FeedEpisode.js
index 0d1a3a485..5825dd4e7 100644
--- a/server/models/FeedEpisode.js
+++ b/server/models/FeedEpisode.js
@@ -53,9 +53,10 @@ class FeedEpisode extends Model {
* @param {import('./Feed')} feed
* @param {string} slug
* @param {import('./PodcastEpisode')} episode
+ * @param {string} [existingEpisodeId]
*/
- static getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode) {
- const episodeId = uuidv4()
+ static getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode, existingEpisodeId = null) {
+ const episodeId = existingEpisodeId || uuidv4()
return {
id: episodeId,
title: episode.title,
@@ -94,11 +95,18 @@ class FeedEpisode extends Model {
libraryItemExpanded.media.podcastEpisodes.sort((a, b) => new Date(a.pubDate) - new Date(b.pubDate))
}
+ let numExisting = 0
for (const episode of libraryItemExpanded.media.podcastEpisodes) {
- feedEpisodeObjs.push(this.getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode))
+ // Check for existing episode by filepath
+ const existingEpisode = feed.feedEpisodes?.find((feedEpisode) => {
+ return feedEpisode.filePath === episode.audioFile.metadata.path
+ })
+ numExisting = existingEpisode ? numExisting + 1 : numExisting
+
+ feedEpisodeObjs.push(this.getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode, existingEpisode?.id))
}
- Logger.info(`[FeedEpisode] Creating ${feedEpisodeObjs.length} episodes for feed ${feed.id}`)
- return this.bulkCreate(feedEpisodeObjs, { transaction })
+ Logger.info(`[FeedEpisode] Upserting ${feedEpisodeObjs.length} episodes for feed ${feed.id} (${numExisting} existing)`)
+ return this.bulkCreate(feedEpisodeObjs, { transaction, updateOnDuplicate: ['title', 'author', 'description', 'siteURL', 'enclosureURL', 'enclosureType', 'enclosureSize', 'pubDate', 'season', 'episode', 'episodeType', 'duration', 'filePath', 'explicit'] })
}
/**
@@ -127,11 +135,12 @@ class FeedEpisode extends Model {
* @param {string} slug
* @param {import('./Book').AudioFileObject} audioTrack
* @param {boolean} useChapterTitles
+ * @param {string} [existingEpisodeId]
*/
- static getFeedEpisodeObjFromAudiobookTrack(book, pubDateStart, feed, slug, audioTrack, useChapterTitles) {
+ static getFeedEpisodeObjFromAudiobookTrack(book, pubDateStart, feed, slug, audioTrack, useChapterTitles, existingEpisodeId = null) {
// Example: Fri, 04 Feb 2015 00:00:00 GMT
let timeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset pubdate to ensure correct order
- let episodeId = uuidv4()
+ let episodeId = existingEpisodeId || uuidv4()
// e.g. Track 1 will have a pub date before Track 2
const audiobookPubDate = date.format(new Date(pubDateStart.valueOf() + timeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
@@ -179,11 +188,18 @@ class FeedEpisode extends Model {
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(libraryItemExpanded.media)
const feedEpisodeObjs = []
+ let numExisting = 0
for (const track of libraryItemExpanded.media.trackList) {
- feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(libraryItemExpanded.media, libraryItemExpanded.createdAt, feed, slug, track, useChapterTitles))
+ // Check for existing episode by filepath
+ const existingEpisode = feed.feedEpisodes?.find((episode) => {
+ return episode.filePath === track.metadata.path
+ })
+ numExisting = existingEpisode ? numExisting + 1 : numExisting
+
+ feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(libraryItemExpanded.media, libraryItemExpanded.createdAt, feed, slug, track, useChapterTitles, existingEpisode?.id))
}
- Logger.info(`[FeedEpisode] Creating ${feedEpisodeObjs.length} episodes for feed ${feed.id}`)
- return this.bulkCreate(feedEpisodeObjs, { transaction })
+ Logger.info(`[FeedEpisode] Upserting ${feedEpisodeObjs.length} episodes for feed ${feed.id} (${numExisting} existing)`)
+ return this.bulkCreate(feedEpisodeObjs, { transaction, updateOnDuplicate: ['title', 'author', 'description', 'siteURL', 'enclosureURL', 'enclosureType', 'enclosureSize', 'pubDate', 'season', 'episode', 'episodeType', 'duration', 'filePath', 'explicit'] })
}
/**
@@ -200,14 +216,21 @@ class FeedEpisode extends Model {
}).libraryItem.createdAt
const feedEpisodeObjs = []
+ let numExisting = 0
for (const book of books) {
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(book)
for (const track of book.trackList) {
- feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(book, earliestLibraryItemCreatedAt, feed, slug, track, useChapterTitles))
+ // Check for existing episode by filepath
+ const existingEpisode = feed.feedEpisodes?.find((episode) => {
+ return episode.filePath === track.metadata.path
+ })
+ numExisting = existingEpisode ? numExisting + 1 : numExisting
+
+ feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(book, earliestLibraryItemCreatedAt, feed, slug, track, useChapterTitles, existingEpisode?.id))
}
}
- Logger.info(`[FeedEpisode] Creating ${feedEpisodeObjs.length} episodes for feed ${feed.id}`)
- return this.bulkCreate(feedEpisodeObjs, { transaction })
+ Logger.info(`[FeedEpisode] Upserting ${feedEpisodeObjs.length} episodes for feed ${feed.id} (${numExisting} existing)`)
+ return this.bulkCreate(feedEpisodeObjs, { transaction, updateOnDuplicate: ['title', 'author', 'description', 'siteURL', 'enclosureURL', 'enclosureType', 'enclosureSize', 'pubDate', 'season', 'episode', 'episodeType', 'duration', 'filePath', 'explicit'] })
}
/**
diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js
index 8ebed1d50..2aa41b703 100644
--- a/server/models/LibraryItem.js
+++ b/server/models/LibraryItem.js
@@ -123,7 +123,7 @@ class LibraryItem extends Model {
}
/**
- * Currently unused because this is too slow and uses too much mem
+ *
* @param {import('sequelize').WhereOptions} [where]
* @returns {Array} old library items
*/
@@ -1061,6 +1061,9 @@ class LibraryItem extends Model {
{
fields: ['libraryId', 'mediaType']
},
+ {
+ fields: ['libraryId', 'mediaType', 'size']
+ },
{
fields: ['libraryId', 'mediaId', 'mediaType']
},
diff --git a/server/models/MediaItemShare.js b/server/models/MediaItemShare.js
index 38b8dbbf4..2d7b3896a 100644
--- a/server/models/MediaItemShare.js
+++ b/server/models/MediaItemShare.js
@@ -12,6 +12,7 @@ const { DataTypes, Model } = require('sequelize')
* @property {Object} extraData
* @property {Date} createdAt
* @property {Date} updatedAt
+ * @property {boolean} isDownloadable
*
* @typedef {MediaItemShareObject & MediaItemShare} MediaItemShareModel
*/
@@ -25,11 +26,40 @@ const { DataTypes, Model } = require('sequelize')
* @property {Date} expiresAt
* @property {Date} createdAt
* @property {Date} updatedAt
+ * @property {boolean} isDownloadable
*/
class MediaItemShare extends Model {
constructor(values, options) {
super(values, options)
+
+ /** @type {UUIDV4} */
+ this.id
+ /** @type {UUIDV4} */
+ this.mediaItemId
+ /** @type {string} */
+ this.mediaItemType
+ /** @type {string} */
+ this.slug
+ /** @type {string} */
+ this.pash
+ /** @type {UUIDV4} */
+ this.userId
+ /** @type {Date} */
+ this.expiresAt
+ /** @type {Object} */
+ this.extraData
+ /** @type {Date} */
+ this.createdAt
+ /** @type {Date} */
+ this.updatedAt
+ /** @type {boolean} */
+ this.isDownloadable
+
+ // Expanded properties
+
+ /** @type {import('./Book')|import('./PodcastEpisode')} */
+ this.mediaItem
}
toJSONForClient() {
@@ -40,7 +70,8 @@ class MediaItemShare extends Model {
slug: this.slug,
expiresAt: this.expiresAt,
createdAt: this.createdAt,
- updatedAt: this.updatedAt
+ updatedAt: this.updatedAt,
+ isDownloadable: this.isDownloadable
}
}
@@ -114,7 +145,8 @@ class MediaItemShare extends Model {
slug: DataTypes.STRING,
pash: DataTypes.STRING,
expiresAt: DataTypes.DATE,
- extraData: DataTypes.JSON
+ extraData: DataTypes.JSON,
+ isDownloadable: DataTypes.BOOLEAN
},
{
sequelize,
diff --git a/server/models/Playlist.js b/server/models/Playlist.js
index 490e80876..7817211f3 100644
--- a/server/models/Playlist.js
+++ b/server/models/Playlist.js
@@ -1,8 +1,6 @@
-const { DataTypes, Model, Op, literal } = require('sequelize')
+const { DataTypes, Model, Op } = require('sequelize')
const Logger = require('../Logger')
-const oldPlaylist = require('../objects/Playlist')
-
class Playlist extends Model {
constructor(values, options) {
super(values, options)
@@ -21,134 +19,23 @@ class Playlist extends Model {
this.createdAt
/** @type {Date} */
this.updatedAt
- }
- static getOldPlaylist(playlistExpanded) {
- const items = playlistExpanded.playlistMediaItems
- .map((pmi) => {
- const mediaItem = pmi.mediaItem || pmi.dataValues?.mediaItem
- const libraryItemId = mediaItem?.podcast?.libraryItem?.id || mediaItem?.libraryItem?.id || null
- if (!libraryItemId) {
- Logger.error(`[Playlist] Invalid playlist media item - No library item id found`, JSON.stringify(pmi, null, 2))
- return null
- }
- return {
- episodeId: pmi.mediaItemType === 'podcastEpisode' ? pmi.mediaItemId : '',
- libraryItemId
- }
- })
- .filter((pmi) => pmi)
+ // Expanded properties
- return new oldPlaylist({
- id: playlistExpanded.id,
- libraryId: playlistExpanded.libraryId,
- userId: playlistExpanded.userId,
- name: playlistExpanded.name,
- description: playlistExpanded.description,
- items,
- lastUpdate: playlistExpanded.updatedAt.valueOf(),
- createdAt: playlistExpanded.createdAt.valueOf()
- })
+ /** @type {import('./PlaylistMediaItem')[]} - only set when expanded */
+ this.playlistMediaItems
}
/**
- * Get old playlist toJSONExpanded
- * @param {string[]} [include]
- * @returns {Promise} oldPlaylist.toJSONExpanded
- */
- async getOldJsonExpanded(include) {
- this.playlistMediaItems =
- (await this.getPlaylistMediaItems({
- include: [
- {
- model: this.sequelize.models.book,
- include: this.sequelize.models.libraryItem
- },
- {
- model: this.sequelize.models.podcastEpisode,
- include: {
- model: this.sequelize.models.podcast,
- include: this.sequelize.models.libraryItem
- }
- }
- ],
- order: [['order', 'ASC']]
- })) || []
-
- const oldPlaylist = this.sequelize.models.playlist.getOldPlaylist(this)
- const libraryItemIds = oldPlaylist.items.map((i) => i.libraryItemId)
-
- let libraryItems = await this.sequelize.models.libraryItem.getAllOldLibraryItems({
- id: libraryItemIds
- })
-
- const playlistExpanded = oldPlaylist.toJSONExpanded(libraryItems)
-
- return playlistExpanded
- }
-
- static createFromOld(oldPlaylist) {
- const playlist = this.getFromOld(oldPlaylist)
- return this.create(playlist)
- }
-
- static getFromOld(oldPlaylist) {
- return {
- id: oldPlaylist.id,
- name: oldPlaylist.name,
- description: oldPlaylist.description,
- userId: oldPlaylist.userId,
- libraryId: oldPlaylist.libraryId
- }
- }
-
- static removeById(playlistId) {
- return this.destroy({
- where: {
- id: playlistId
- }
- })
- }
-
- /**
- * Get playlist by id
- * @param {string} playlistId
- * @returns {Promise} returns null if not found
- */
- static async getById(playlistId) {
- if (!playlistId) return null
- const playlist = await this.findByPk(playlistId, {
- include: {
- model: this.sequelize.models.playlistMediaItem,
- include: [
- {
- model: this.sequelize.models.book,
- include: this.sequelize.models.libraryItem
- },
- {
- model: this.sequelize.models.podcastEpisode,
- include: {
- model: this.sequelize.models.podcast,
- include: this.sequelize.models.libraryItem
- }
- }
- ]
- },
- order: [['playlistMediaItems', 'order', 'ASC']]
- })
- if (!playlist) return null
- return this.getOldPlaylist(playlist)
- }
-
- /**
- * Get old playlists for user and optionally for library
+ * Get old playlists for user and library
*
* @param {string} userId
- * @param {string} [libraryId]
- * @returns {Promise}
+ * @param {string} libraryId
+ * @async
*/
- static async getOldPlaylistsForUserAndLibrary(userId, libraryId = null) {
+ static async getOldPlaylistsForUserAndLibrary(userId, libraryId) {
if (!userId && !libraryId) return []
+
const whereQuery = {}
if (userId) {
whereQuery.userId = userId
@@ -163,7 +50,23 @@ class Playlist extends Model {
include: [
{
model: this.sequelize.models.book,
- include: this.sequelize.models.libraryItem
+ include: [
+ {
+ model: this.sequelize.models.libraryItem
+ },
+ {
+ model: this.sequelize.models.author,
+ through: {
+ attributes: []
+ }
+ },
+ {
+ model: this.sequelize.models.series,
+ through: {
+ attributes: ['sequence']
+ }
+ }
+ ]
},
{
model: this.sequelize.models.podcastEpisode,
@@ -174,42 +77,13 @@ class Playlist extends Model {
}
]
},
- order: [
- [literal('name COLLATE NOCASE'), 'ASC'],
- ['playlistMediaItems', 'order', 'ASC']
- ]
+ order: [['playlistMediaItems', 'order', 'ASC']]
})
- const oldPlaylists = []
- for (const playlistExpanded of playlistsExpanded) {
- const oldPlaylist = this.getOldPlaylist(playlistExpanded)
- const libraryItems = []
- for (const pmi of playlistExpanded.playlistMediaItems) {
- let mediaItem = pmi.mediaItem || pmi.dataValues.mediaItem
+ // Sort by name asc
+ playlistsExpanded.sort((a, b) => a.name.localeCompare(b.name))
- if (!mediaItem) {
- Logger.error(`[Playlist] Invalid playlist media item - No media item found`, JSON.stringify(mediaItem, null, 2))
- continue
- }
- let libraryItem = mediaItem.libraryItem || mediaItem.podcast?.libraryItem
-
- if (mediaItem.podcast) {
- libraryItem.media = mediaItem.podcast
- libraryItem.media.podcastEpisodes = [mediaItem]
- delete mediaItem.podcast.libraryItem
- } else {
- libraryItem.media = mediaItem
- delete mediaItem.libraryItem
- }
-
- const oldLibraryItem = this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem)
- libraryItems.push(oldLibraryItem)
- }
- const oldPlaylistJson = oldPlaylist.toJSONExpanded(libraryItems)
- oldPlaylists.push(oldPlaylistJson)
- }
-
- return oldPlaylists
+ return playlistsExpanded.map((playlist) => playlist.toOldJSONExpanded())
}
/**
@@ -345,6 +219,117 @@ class Playlist extends Model {
}
})
}
+
+ /**
+ * Get all media items in playlist expanded with library item
+ *
+ * @returns {Promise}
+ */
+ getMediaItemsExpandedWithLibraryItem() {
+ return this.getPlaylistMediaItems({
+ include: [
+ {
+ model: this.sequelize.models.book,
+ include: [
+ {
+ model: this.sequelize.models.libraryItem
+ },
+ {
+ model: this.sequelize.models.author,
+ through: {
+ attributes: []
+ }
+ },
+ {
+ model: this.sequelize.models.series,
+ through: {
+ attributes: ['sequence']
+ }
+ }
+ ]
+ },
+ {
+ model: this.sequelize.models.podcastEpisode,
+ include: [
+ {
+ model: this.sequelize.models.podcast,
+ include: this.sequelize.models.libraryItem
+ }
+ ]
+ }
+ ],
+ order: [['order', 'ASC']]
+ })
+ }
+
+ /**
+ * Get playlists toOldJSONExpanded
+ *
+ * @async
+ */
+ async getOldJsonExpanded() {
+ this.playlistMediaItems = await this.getMediaItemsExpandedWithLibraryItem()
+ return this.toOldJSONExpanded()
+ }
+
+ /**
+ * Old model used libraryItemId instead of bookId
+ *
+ * @param {string} libraryItemId
+ * @param {string} [episodeId]
+ */
+ checkHasMediaItem(libraryItemId, episodeId) {
+ if (!this.playlistMediaItems) {
+ throw new Error('playlistMediaItems are required to check Playlist')
+ }
+ if (episodeId) {
+ return this.playlistMediaItems.some((pmi) => pmi.mediaItemId === episodeId)
+ }
+ return this.playlistMediaItems.some((pmi) => pmi.mediaItem.libraryItem.id === libraryItemId)
+ }
+
+ toOldJSON() {
+ return {
+ id: this.id,
+ name: this.name,
+ libraryId: this.libraryId,
+ userId: this.userId,
+ description: this.description,
+ lastUpdate: this.updatedAt.valueOf(),
+ createdAt: this.createdAt.valueOf()
+ }
+ }
+
+ toOldJSONExpanded() {
+ if (!this.playlistMediaItems) {
+ throw new Error('playlistMediaItems are required to expand Playlist')
+ }
+
+ const json = this.toOldJSON()
+ json.items = this.playlistMediaItems.map((pmi) => {
+ if (pmi.mediaItemType === 'book') {
+ const libraryItem = pmi.mediaItem.libraryItem
+ delete pmi.mediaItem.libraryItem
+ libraryItem.media = pmi.mediaItem
+ return {
+ libraryItemId: libraryItem.id,
+ libraryItem: this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem).toJSONExpanded()
+ }
+ }
+
+ const libraryItem = pmi.mediaItem.podcast.libraryItem
+ delete pmi.mediaItem.podcast.libraryItem
+ libraryItem.media = pmi.mediaItem.podcast
+ return {
+ episodeId: pmi.mediaItemId,
+ episode: pmi.mediaItem.toOldJSONExpanded(libraryItem.id),
+ libraryItemId: libraryItem.id,
+ libraryItem: this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem).toJSONMinified()
+ }
+ })
+
+ return json
+ }
}
module.exports = Playlist
diff --git a/server/models/PlaylistMediaItem.js b/server/models/PlaylistMediaItem.js
index 1c53bea11..2eac036b7 100644
--- a/server/models/PlaylistMediaItem.js
+++ b/server/models/PlaylistMediaItem.js
@@ -16,15 +16,11 @@ class PlaylistMediaItem extends Model {
this.playlistId
/** @type {Date} */
this.createdAt
- }
- static removeByIds(playlistId, mediaItemId) {
- return this.destroy({
- where: {
- playlistId,
- mediaItemId
- }
- })
+ // Expanded properties
+
+ /** @type {import('./Book')|import('./PodcastEpisode')} - only set when expanded */
+ this.mediaItem
}
getMediaItem(options) {
diff --git a/server/models/PodcastEpisode.js b/server/models/PodcastEpisode.js
index 1f99361ab..1fa32da7a 100644
--- a/server/models/PodcastEpisode.js
+++ b/server/models/PodcastEpisode.js
@@ -170,6 +170,62 @@ class PodcastEpisode extends Model {
})
PodcastEpisode.belongsTo(podcast)
}
+
+ /**
+ * AudioTrack object used in old model
+ *
+ * @returns {import('./Book').AudioFileObject|null}
+ */
+ get track() {
+ if (!this.audioFile) return null
+ const track = structuredClone(this.audioFile)
+ track.startOffset = 0
+ track.title = this.audioFile.metadata.title
+ return track
+ }
+
+ toOldJSON(libraryItemId) {
+ let enclosure = null
+ if (this.enclosureURL) {
+ enclosure = {
+ url: this.enclosureURL,
+ type: this.enclosureType,
+ length: this.enclosureSize !== null ? String(this.enclosureSize) : null
+ }
+ }
+
+ return {
+ libraryItemId: libraryItemId,
+ podcastId: this.podcastId,
+ id: this.id,
+ oldEpisodeId: this.extraData?.oldEpisodeId || null,
+ index: this.index,
+ season: this.season,
+ episode: this.episode,
+ episodeType: this.episodeType,
+ title: this.title,
+ subtitle: this.subtitle,
+ description: this.description,
+ enclosure,
+ guid: this.extraData?.guid || null,
+ pubDate: this.pubDate,
+ chapters: this.chapters?.map((ch) => ({ ...ch })) || [],
+ audioFile: this.audioFile || null,
+ publishedAt: this.publishedAt?.valueOf() || null,
+ addedAt: this.createdAt.valueOf(),
+ updatedAt: this.updatedAt.valueOf()
+ }
+ }
+
+ toOldJSONExpanded(libraryItemId) {
+ const json = this.toOldJSON(libraryItemId)
+
+ json.audioTrack = this.track
+ json.size = this.audioFile?.metadata.size || 0
+ json.duration = this.audioFile?.duration || 0
+
+ return json
+ }
}
module.exports = PodcastEpisode
diff --git a/server/objects/Collection.js b/server/objects/Collection.js
deleted file mode 100644
index 970d714b8..000000000
--- a/server/objects/Collection.js
+++ /dev/null
@@ -1,115 +0,0 @@
-const uuidv4 = require("uuid").v4
-
-class Collection {
- constructor(collection) {
- this.id = null
- this.libraryId = null
-
- this.name = null
- this.description = null
-
- this.cover = null
- this.coverFullPath = null
- this.books = []
-
- this.lastUpdate = null
- this.createdAt = null
-
- if (collection) {
- this.construct(collection)
- }
- }
-
- toJSON() {
- return {
- id: this.id,
- libraryId: this.libraryId,
- name: this.name,
- description: this.description,
- cover: this.cover,
- coverFullPath: this.coverFullPath,
- books: [...this.books],
- lastUpdate: this.lastUpdate,
- createdAt: this.createdAt
- }
- }
-
- toJSONExpanded(libraryItems, minifiedBooks = false) {
- const json = this.toJSON()
- json.books = json.books.map(bookId => {
- const book = libraryItems.find(li => li.id === bookId)
- return book ? minifiedBooks ? book.toJSONMinified() : book.toJSONExpanded() : null
- }).filter(b => !!b)
- return json
- }
-
- // Expanded and filtered out items not accessible to user
- toJSONExpandedForUser(user, libraryItems) {
- const json = this.toJSON()
- json.books = json.books.map(libraryItemId => {
- const libraryItem = libraryItems.find(li => li.id === libraryItemId)
- return libraryItem ? libraryItem.toJSONExpanded() : null
- }).filter(li => {
- return li && user.checkCanAccessLibraryItem(li)
- })
- return json
- }
-
- construct(collection) {
- this.id = collection.id
- this.libraryId = collection.libraryId
- this.name = collection.name
- this.description = collection.description || null
- this.cover = collection.cover || null
- this.coverFullPath = collection.coverFullPath || null
- this.books = collection.books ? [...collection.books] : []
- this.lastUpdate = collection.lastUpdate || null
- this.createdAt = collection.createdAt || null
- }
-
- setData(data) {
- if (!data.libraryId || !data.name) {
- return false
- }
- this.id = uuidv4()
- this.libraryId = data.libraryId
- this.name = data.name
- this.description = data.description || null
- this.cover = data.cover || null
- this.coverFullPath = data.coverFullPath || null
- this.books = data.books ? [...data.books] : []
- this.lastUpdate = Date.now()
- this.createdAt = Date.now()
- return true
- }
-
- addBook(bookId) {
- this.books.push(bookId)
- this.lastUpdate = Date.now()
- }
-
- removeBook(bookId) {
- this.books = this.books.filter(bid => bid !== bookId)
- this.lastUpdate = Date.now()
- }
-
- update(payload) {
- let hasUpdates = false
- for (const key in payload) {
- if (key === 'books') {
- if (payload.books && this.books.join(',') !== payload.books.join(',')) {
- this.books = [...payload.books]
- hasUpdates = true
- }
- } else if (this[key] !== undefined && this[key] !== payload[key]) {
- hasUpdates = true
- this[key] = payload[key]
- }
- }
- if (hasUpdates) {
- this.lastUpdate = Date.now()
- }
- return hasUpdates
- }
-}
-module.exports = Collection
\ No newline at end of file
diff --git a/server/objects/Playlist.js b/server/objects/Playlist.js
deleted file mode 100644
index c4b3357b9..000000000
--- a/server/objects/Playlist.js
+++ /dev/null
@@ -1,148 +0,0 @@
-const uuidv4 = require("uuid").v4
-
-class Playlist {
- constructor(playlist) {
- this.id = null
- this.libraryId = null
- this.userId = null
-
- this.name = null
- this.description = null
-
- this.coverPath = null
-
- // Array of objects like { libraryItemId: "", episodeId: "" } (episodeId optional)
- this.items = []
-
- this.lastUpdate = null
- this.createdAt = null
-
- if (playlist) {
- this.construct(playlist)
- }
- }
-
- toJSON() {
- return {
- id: this.id,
- libraryId: this.libraryId,
- userId: this.userId,
- name: this.name,
- description: this.description,
- coverPath: this.coverPath,
- items: [...this.items],
- lastUpdate: this.lastUpdate,
- createdAt: this.createdAt
- }
- }
-
- // Expands the items array
- toJSONExpanded(libraryItems) {
- var json = this.toJSON()
- json.items = json.items.map(item => {
- const libraryItem = libraryItems.find(li => li.id === item.libraryItemId)
- if (!libraryItem) {
- // Not found
- return null
- }
- if (item.episodeId) {
- if (!libraryItem.isPodcast) {
- // Invalid
- return null
- }
- const episode = libraryItem.media.episodes.find(ep => ep.id === item.episodeId)
- if (!episode) {
- // Not found
- return null
- }
-
- return {
- ...item,
- episode: episode.toJSONExpanded(),
- libraryItem: libraryItem.toJSONMinified()
- }
- } else {
- return {
- ...item,
- libraryItem: libraryItem.toJSONExpanded()
- }
- }
- }).filter(i => i)
- return json
- }
-
- construct(playlist) {
- this.id = playlist.id
- this.libraryId = playlist.libraryId
- this.userId = playlist.userId
- this.name = playlist.name
- this.description = playlist.description || null
- this.coverPath = playlist.coverPath || null
- this.items = playlist.items ? playlist.items.map(i => ({ ...i })) : []
- this.lastUpdate = playlist.lastUpdate || null
- this.createdAt = playlist.createdAt || null
- }
-
- setData(data) {
- if (!data.userId || !data.libraryId || !data.name) {
- return false
- }
- this.id = uuidv4()
- this.userId = data.userId
- this.libraryId = data.libraryId
- this.name = data.name
- this.description = data.description || null
- this.coverPath = data.coverPath || null
- this.items = data.items ? data.items.map(i => ({ ...i })) : []
- this.lastUpdate = Date.now()
- this.createdAt = Date.now()
- return true
- }
-
- addItem(libraryItemId, episodeId = null) {
- this.items.push({
- libraryItemId,
- episodeId: episodeId || null
- })
- this.lastUpdate = Date.now()
- }
-
- removeItem(libraryItemId, episodeId = null) {
- if (episodeId) this.items = this.items.filter(i => i.libraryItemId !== libraryItemId || i.episodeId !== episodeId)
- else this.items = this.items.filter(i => i.libraryItemId !== libraryItemId)
- this.lastUpdate = Date.now()
- }
-
- update(payload) {
- let hasUpdates = false
- for (const key in payload) {
- if (key === 'items') {
- if (payload.items && JSON.stringify(payload.items) !== JSON.stringify(this.items)) {
- this.items = payload.items.map(i => ({ ...i }))
- hasUpdates = true
- }
- } else if (this[key] !== undefined && this[key] !== payload[key]) {
- hasUpdates = true
- this[key] = payload[key]
- }
- }
- if (hasUpdates) {
- this.lastUpdate = Date.now()
- }
- return hasUpdates
- }
-
- containsItem(item) {
- if (item.episodeId) return this.items.some(i => i.libraryItemId === item.libraryItemId && i.episodeId === item.episodeId)
- return this.items.some(i => i.libraryItemId === item.libraryItemId)
- }
-
- hasItemsForLibraryItem(libraryItemId) {
- return this.items.some(i => i.libraryItemId === libraryItemId)
- }
-
- removeItemsForLibraryItem(libraryItemId) {
- this.items = this.items.filter(i => i.libraryItemId !== libraryItemId)
- }
-}
-module.exports = Playlist
\ No newline at end of file
diff --git a/server/routers/PublicRouter.js b/server/routers/PublicRouter.js
index 98ac4955a..107edf99c 100644
--- a/server/routers/PublicRouter.js
+++ b/server/routers/PublicRouter.js
@@ -15,6 +15,7 @@ class PublicRouter {
this.router.get('/share/:slug', ShareController.getMediaItemShareBySlug.bind(this))
this.router.get('/share/:slug/track/:index', ShareController.getMediaItemShareAudioTrack.bind(this))
this.router.get('/share/:slug/cover', ShareController.getMediaItemShareCoverImage.bind(this))
+ this.router.get('/share/:slug/download', ShareController.downloadMediaItemShare.bind(this))
this.router.patch('/share/:slug/progress', ShareController.updateMediaItemShareProgress.bind(this))
}
}
diff --git a/test/server/migrations/v2.17.6-share-add-isdownloadable.test.js b/test/server/migrations/v2.17.6-share-add-isdownloadable.test.js
new file mode 100644
index 000000000..6c778b148
--- /dev/null
+++ b/test/server/migrations/v2.17.6-share-add-isdownloadable.test.js
@@ -0,0 +1,68 @@
+const chai = require('chai')
+const sinon = require('sinon')
+const { expect } = chai
+
+const { DataTypes } = require('sequelize')
+
+const { up, down } = require('../../../server/migrations/v2.17.6-share-add-isdownloadable')
+
+describe('Migration v2.17.6-share-add-isDownloadable', () => {
+ let queryInterface, logger
+
+ beforeEach(() => {
+ queryInterface = {
+ addColumn: sinon.stub().resolves(),
+ removeColumn: sinon.stub().resolves(),
+ tableExists: sinon.stub().resolves(true),
+ describeTable: sinon.stub().resolves({ isDownloadable: undefined }),
+ sequelize: {
+ Sequelize: {
+ DataTypes: {
+ BOOLEAN: DataTypes.BOOLEAN
+ }
+ }
+ }
+ }
+
+ logger = {
+ info: sinon.stub(),
+ error: sinon.stub()
+ }
+ })
+
+ describe('up', () => {
+ it('should add the isDownloadable column to mediaItemShares table', async () => {
+ await up({ context: { queryInterface, logger } })
+
+ expect(queryInterface.addColumn.calledOnce).to.be.true
+ expect(
+ queryInterface.addColumn.calledWith('mediaItemShares', 'isDownloadable', {
+ type: DataTypes.BOOLEAN,
+ defaultValue: false,
+ allowNull: false
+ })
+ ).to.be.true
+
+ expect(logger.info.calledWith('[2.17.6 migration] UPGRADE BEGIN: 2.17.6-share-add-isdownloadable')).to.be.true
+ expect(logger.info.calledWith('[2.17.6 migration] Adding isDownloadable column to mediaItemShares table')).to.be.true
+ expect(logger.info.calledWith('[2.17.6 migration] Added isDownloadable column to mediaItemShares table')).to.be.true
+ expect(logger.info.calledWith('[2.17.6 migration] UPGRADE END: 2.17.6-share-add-isdownloadable')).to.be.true
+ })
+ })
+
+ describe('down', () => {
+ it('should remove the isDownloadable column from mediaItemShares table', async () => {
+ queryInterface.describeTable.resolves({ isDownloadable: true })
+
+ await down({ context: { queryInterface, logger } })
+
+ expect(queryInterface.removeColumn.calledOnce).to.be.true
+ expect(queryInterface.removeColumn.calledWith('mediaItemShares', 'isDownloadable')).to.be.true
+
+ expect(logger.info.calledWith('[2.17.6 migration] DOWNGRADE BEGIN: 2.17.6-share-add-isdownloadable')).to.be.true
+ expect(logger.info.calledWith('[2.17.6 migration] Removing isDownloadable column from mediaItemShares table')).to.be.true
+ expect(logger.info.calledWith('[2.17.6 migration] Removed isDownloadable column from mediaItemShares table')).to.be.true
+ expect(logger.info.calledWith('[2.17.6 migration] DOWNGRADE END: 2.17.6-share-add-isdownloadable')).to.be.true
+ })
+ })
+})