Merge branch 'master' into feat/book-series-info

This commit is contained in:
Greg Lorenzen 2025-02-12 18:32:34 -08:00
commit 06633f261a
30 changed files with 732 additions and 103 deletions

View File

@ -99,6 +99,7 @@ export default {
this.$store.commit('showEditModal', libraryItem) this.$store.commit('showEditModal', libraryItem)
}, },
editEpisode({ libraryItem, episode }) { editEpisode({ libraryItem, episode }) {
this.$store.commit('setEpisodeTableEpisodeIds', [episode.id])
this.$store.commit('setSelectedLibraryItem', libraryItem) this.$store.commit('setSelectedLibraryItem', libraryItem)
this.$store.commit('globals/setSelectedEpisode', episode) this.$store.commit('globals/setSelectedEpisode', episode)
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true) this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)

View File

@ -196,6 +196,9 @@ export default {
methods: { methods: {
async goPrevBook() { async goPrevBook() {
if (this.currentBookshelfIndex - 1 < 0) return if (this.currentBookshelfIndex - 1 < 0) return
// Remove focus from active input
document.activeElement?.blur?.()
var prevBookId = this.bookshelfBookIds[this.currentBookshelfIndex - 1] var prevBookId = this.bookshelfBookIds[this.currentBookshelfIndex - 1]
this.processing = true this.processing = true
var prevBook = await this.$axios.$get(`/api/items/${prevBookId}?expanded=1`).catch((error) => { var prevBook = await this.$axios.$get(`/api/items/${prevBookId}?expanded=1`).catch((error) => {
@ -215,6 +218,9 @@ export default {
}, },
async goNextBook() { async goNextBook() {
if (this.currentBookshelfIndex >= this.bookshelfBookIds.length - 1) return if (this.currentBookshelfIndex >= this.bookshelfBookIds.length - 1) return
// Remove focus from active input
document.activeElement?.blur?.()
this.processing = true this.processing = true
var nextBookId = this.bookshelfBookIds[this.currentBookshelfIndex + 1] var nextBookId = this.bookshelfBookIds[this.currentBookshelfIndex + 1]
var nextBook = await this.$axios.$get(`/api/items/${nextBookId}?expanded=1`).catch((error) => { var nextBook = await this.$axios.$get(`/api/items/${nextBookId}?expanded=1`).catch((error) => {
@ -300,4 +306,4 @@ export default {
.tab.tab-selected { .tab.tab-selected {
height: 41px; height: 41px;
} }
</style> </style>

View File

@ -117,8 +117,12 @@ export default {
methods: { methods: {
async goPrevEpisode() { async goPrevEpisode() {
if (this.currentEpisodeIndex - 1 < 0) return if (this.currentEpisodeIndex - 1 < 0) return
// Remove focus from active input
document.activeElement?.blur?.()
const prevEpisodeId = this.episodeTableEpisodeIds[this.currentEpisodeIndex - 1] const prevEpisodeId = this.episodeTableEpisodeIds[this.currentEpisodeIndex - 1]
this.processing = true this.processing = true
const prevEpisode = await this.$axios.$get(`/api/podcasts/${this.libraryItem.id}/episode/${prevEpisodeId}`).catch((error) => { const prevEpisode = await this.$axios.$get(`/api/podcasts/${this.libraryItem.id}/episode/${prevEpisodeId}`).catch((error) => {
const errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch episode' const errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch episode'
this.$toast.error(errorMsg) this.$toast.error(errorMsg)
@ -134,8 +138,12 @@ export default {
}, },
async goNextEpisode() { async goNextEpisode() {
if (this.currentEpisodeIndex >= this.episodeTableEpisodeIds.length - 1) return if (this.currentEpisodeIndex >= this.episodeTableEpisodeIds.length - 1) return
// Remove focus from active input
document.activeElement?.blur?.()
this.processing = true this.processing = true
const nextEpisodeId = this.episodeTableEpisodeIds[this.currentEpisodeIndex + 1] const nextEpisodeId = this.episodeTableEpisodeIds[this.currentEpisodeIndex + 1]
const nextEpisode = await this.$axios.$get(`/api/podcasts/${this.libraryItem.id}/episode/${nextEpisodeId}`).catch((error) => { const nextEpisode = await this.$axios.$get(`/api/podcasts/${this.libraryItem.id}/episode/${nextEpisodeId}`).catch((error) => {
const errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch book' const errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch book'
this.$toast.error(errorMsg) this.$toast.error(errorMsg)

View File

@ -124,6 +124,7 @@ export default {
this.updateSelectionMode(false) this.updateSelectionMode(false)
}, },
editEpisode({ libraryItem, episode }) { editEpisode({ libraryItem, episode }) {
this.$store.commit('setEpisodeTableEpisodeIds', [episode.id])
this.$store.commit('setSelectedLibraryItem', libraryItem) this.$store.commit('setSelectedLibraryItem', libraryItem)
this.$store.commit('globals/setSelectedEpisode', episode) this.$store.commit('globals/setSelectedEpisode', episode)
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true) this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)

View File

@ -1,6 +1,6 @@
const pkg = require('./package.json') const pkg = require('./package.json')
const routerBasePath = process.env.ROUTER_BASE_PATH || '/audiobookshelf' const routerBasePath = process.env.ROUTER_BASE_PATH ?? '/audiobookshelf'
const serverHostUrl = process.env.NODE_ENV === 'production' ? '' : 'http://localhost:3333' const serverHostUrl = process.env.NODE_ENV === 'production' ? '' : 'http://localhost:3333'
const serverPaths = ['api/', 'public/', 'hls/', 'auth/', 'feed/', 'status', 'login', 'logout', 'init'] const serverPaths = ['api/', 'public/', 'hls/', 'auth/', 'feed/', 'status', 'login', 'logout', 'init']
const proxy = Object.fromEntries(serverPaths.map((path) => [`${routerBasePath}/${path}`, { target: process.env.NODE_ENV !== 'production' ? serverHostUrl : '/' }])) const proxy = Object.fromEntries(serverPaths.map((path) => [`${routerBasePath}/${path}`, { target: process.env.NODE_ENV !== 'production' ? serverHostUrl : '/' }]))

View File

@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.19.0", "version": "2.19.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.19.0", "version": "2.19.2",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@nuxtjs/axios": "^5.13.6", "@nuxtjs/axios": "^5.13.6",

View File

@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.19.0", "version": "2.19.2",
"buildNumber": 1, "buildNumber": 1,
"description": "Self-hosted audiobook and podcast client", "description": "Self-hosted audiobook and podcast client",
"main": "index.js", "main": "index.js",

View File

@ -137,7 +137,16 @@ export default {
this.$toast.error(this.$strings.ToastFailedToLoadData) this.$toast.error(this.$strings.ToastFailedToLoadData)
return return
} }
this.feeds = data.feeds this.feeds = data.feeds.map((feed) => ({
...feed,
episodes: [...feed.episodes].sort((a, b) => {
if (!a.pubDate) return 1 // null dates sort to end
if (!b.pubDate) return -1
const dateA = new Date(a.pubDate)
const dateB = new Date(b.pubDate)
return dateA - dateB
})
}))
}, },
init() { init() {
this.loadFeeds() this.loadFeeds()

View File

@ -10,6 +10,7 @@
"ButtonApplyChapters": "Ужыць раздзелы", "ButtonApplyChapters": "Ужыць раздзелы",
"ButtonAuthors": "Аўтары", "ButtonAuthors": "Аўтары",
"ButtonBack": "Назад", "ButtonBack": "Назад",
"ButtonBatchEditPopulateFromExisting": "Запоўніць з існуючага",
"ButtonBrowseForFolder": "Знайсці тэчку", "ButtonBrowseForFolder": "Знайсці тэчку",
"ButtonCancel": "Адмяніць", "ButtonCancel": "Адмяніць",
"ButtonCancelEncode": "Адмяніць кадзіраванне", "ButtonCancelEncode": "Адмяніць кадзіраванне",
@ -35,14 +36,18 @@
"ButtonForceReScan": "Прымусовае паўторнае сканаванне", "ButtonForceReScan": "Прымусовае паўторнае сканаванне",
"ButtonFullPath": "Поўны шлях", "ButtonFullPath": "Поўны шлях",
"ButtonHide": "Схаваць", "ButtonHide": "Схаваць",
"ButtonHome": "Галоўная",
"ButtonIssues": "Праблемы", "ButtonIssues": "Праблемы",
"ButtonJumpBackward": "Перайсці назад", "ButtonJumpBackward": "Перайсці назад",
"ButtonJumpForward": "Перайсці наперад", "ButtonJumpForward": "Перайсці наперад",
"ButtonLatest": "Апошняе",
"ButtonLibrary": "Бібліятэка", "ButtonLibrary": "Бібліятэка",
"ButtonLogout": "Выйсці", "ButtonLogout": "Выйсці",
"ButtonLookup": "", "ButtonLookup": "",
"ButtonManageTracks": "Кіраванне дарожкамі",
"ButtonMapChapterTitles": "Супаставіць назвы раздзелаў", "ButtonMapChapterTitles": "Супаставіць назвы раздзелаў",
"ButtonMatchAllAuthors": "Супадзенне ўсіх аўтараў", "ButtonMatchAllAuthors": "Супадзенне ўсіх аўтараў",
"ButtonMatchBooks": "Падбор кніг",
"ButtonNevermind": "Няважна", "ButtonNevermind": "Няважна",
"ButtonNext": "Далей", "ButtonNext": "Далей",
"ButtonNextChapter": "Наступны раздзел", "ButtonNextChapter": "Наступны раздзел",
@ -71,6 +76,9 @@
"ButtonRemove": "Выдаліць", "ButtonRemove": "Выдаліць",
"ButtonRemoveAll": "Выдаліць усе", "ButtonRemoveAll": "Выдаліць усе",
"ButtonRemoveAllLibraryItems": "Выдаліць усе элементы бібліятэкі", "ButtonRemoveAllLibraryItems": "Выдаліць усе элементы бібліятэкі",
"ButtonRemoveFromContinueListening": "Выдаліць з Працягваць слухаць",
"ButtonRemoveFromContinueReading": "Выдаліць з Працягваць чытанне",
"ButtonRemoveSeriesFromContinueSeries": "Выдаліць серыю з Працягваць серыю",
"ButtonReset": "Скінуць", "ButtonReset": "Скінуць",
"ButtonResetToDefault": "Скінуць па змаўчанні", "ButtonResetToDefault": "Скінуць па змаўчанні",
"ButtonRestore": "Аднавіць", "ButtonRestore": "Аднавіць",
@ -100,9 +108,14 @@
"ButtonUserEdit": "Рэдагаваць карыстальніка {0}", "ButtonUserEdit": "Рэдагаваць карыстальніка {0}",
"ButtonViewAll": "Прагледзець усе", "ButtonViewAll": "Прагледзець усе",
"ButtonYes": "Так", "ButtonYes": "Так",
"ErrorUploadFetchMetadataAPI": "Памылка пры атрыманні метададзеных",
"ErrorUploadFetchMetadataNoResults": "Не ўдалося атрымаць метададзеныя паспрабуйце абнавіць назву і/або аўтара",
"ErrorUploadLacksTitle": "Павінна быць назва",
"HeaderAccount": "Уліковы запіс", "HeaderAccount": "Уліковы запіс",
"HeaderAddCustomMetadataProvider": "Дадаць карыстальніцкага пастаўшчыка метаданных", "HeaderAddCustomMetadataProvider": "Дадаць карыстальніцкага пастаўшчыка метаданных",
"HeaderAdvanced": "Дадаткова",
"HeaderAppriseNotificationSettings": "Налады апавяшчэнняў Apprise", "HeaderAppriseNotificationSettings": "Налады апавяшчэнняў Apprise",
"HeaderAudioTracks": "Аўдыядарожкі",
"HeaderAudiobookTools": "Сродкі кіравання файламі аўдыякніг", "HeaderAudiobookTools": "Сродкі кіравання файламі аўдыякніг",
"HeaderAuthentication": "Аўтэнтыфікацыя", "HeaderAuthentication": "Аўтэнтыфікацыя",
"HeaderBackups": "Рэзервовыя копіі", "HeaderBackups": "Рэзервовыя копіі",
@ -112,6 +125,91 @@
"HeaderCollection": "Калекцыя", "HeaderCollection": "Калекцыя",
"HeaderCollectionItems": "Элементы калекцыі", "HeaderCollectionItems": "Элементы калекцыі",
"HeaderCover": "Вокладка", "HeaderCover": "Вокладка",
"HeaderCurrentDownloads": "Бягучыя загрузкі", "HeaderCurrentDownloads": "Бягучыя спампоўкі",
"HeaderCustomMessageOnLogin": "Карыстальніцкае паведамленне пры ўваходзе" "HeaderCustomMessageOnLogin": "Карыстальніцкае паведамленне пры ўваходзе",
"HeaderCustomMetadataProviders": "Карыстальніцкія крыніцы метададзеных",
"HeaderDetails": "Падрабязнасці",
"HeaderDownloadQueue": "Чарга спамповак",
"HeaderEbookFiles": "Файлы электронных кніг",
"HeaderEmail": "Электронная пошта",
"HeaderEmailSettings": "Налады электроннай пошты",
"HeaderEpisodes": "Эпізоды",
"HeaderEreaderDevices": "Прылады для чытання",
"HeaderEreaderSettings": "Налады прылады для чытання",
"HeaderFiles": "Файлы",
"HeaderFindChapters": "Знайсці раздзелы",
"HeaderIgnoredFiles": "Ігнараваныя файлы",
"HeaderItemFiles": "Файлы элементаў",
"HeaderItemMetadataUtils": "Утыліты для метададзеных элементаў",
"HeaderLastListeningSession": "Апошні сеанс праслухоўвання",
"HeaderLatestEpisodes": "Апошнія эпізоды",
"HeaderLibraries": "Бібліятэкі",
"HeaderLibraryFiles": "Файлы бібліятэкі",
"HeaderLibraryStats": "Статыстыка бібліятэкі",
"HeaderListeningSessions": "Сеансы праслухоўвання",
"HeaderListeningStats": "Статыстыка праслухоўвання",
"HeaderLogin": "Уваход",
"HeaderLogs": "Журналы",
"HeaderManageGenres": "Кіраванне жанрамі",
"HeaderManageTags": "Кіраванне тэгамі",
"HeaderMapDetails": "Падрабязнасці адлюстравання",
"HeaderNewAccount": "Новы ўліковы запіс",
"HeaderNewLibrary": "Новая бібліятэка",
"HeaderNotificationCreate": "Стварыць апавяшчэнне",
"HeaderNotificationUpdate": "Абнавіць апавяшчэнне",
"HeaderNotifications": "Апавяшчэнні",
"HeaderOpenListeningSessions": "Адкрыць сеансы праслухоўвання",
"HeaderScheduleEpisodeDownloads": "Расклад аўтаматычных спамповак эпізодаў",
"HeaderSettings": "Налады",
"HeaderSettingsDisplay": "Дысплей",
"HeaderSettingsExperimental": "Эксперыментальныя функцыі",
"HeaderSettingsGeneral": "Агульныя",
"HeaderSettingsScanner": "Сканер",
"HeaderSettingsWebClient": "Вэб-кліент",
"HeaderStatsTop10Authors": "10 лепшых аўтараў",
"HeaderStatsTop5Genres": "5 лепшых жанраў",
"HeaderTableOfContents": "Змест",
"HeaderTools": "Інструменты",
"HeaderUpdateAccount": "Абнавіць уліковы запіс",
"LabelAccountType": "Тып уліковага запіса",
"LabelAccountTypeAdmin": "Адміністратар",
"LabelAccountTypeGuest": "Госць",
"LabelAccountTypeUser": "Карыстальнік",
"LabelAudioBitrate": "Бітрэйт аўдыё (напрыклад, 128к)",
"LabelAudioChannels": "Аўдыёканалы (1 або 2)",
"LabelAudioCodec": "Аўдыёкодэк",
"LabelAutoDownloadEpisodes": "Аўтаматычнае спампаванне эпізодаў",
"LabelBackupAudioFiles": "Рэзервовае капіраванне аўдыёфайлаў",
"LabelContinueListening": "Працягваць слухаць",
"LabelDownload": "Спампаваць",
"LabelDownloadNEpisodes": "Спампована {0} эпізодаў",
"LabelDownloadable": "Спампоўваецца",
"LabelEncodingBackupLocation": "Рэзервовая копія вашых арыгінальных аўдыёфайлаў будзе захавана ў:",
"LabelEncodingChaptersNotEmbedded": "Раздзелы не ўбудаваны ў шматдарожкавыя аўдыякнігі.",
"LabelEncodingFinishedM4B": "Гатовы файл M4B будзе змешчаны ў вашу тэчку з аўдыякнігамі па адрасе:",
"LabelEncodingInfoEmbedded": "Метаданыя будуць убудаваны ў аўдыядарожкі ўнутры вашай тэчкі з аўдыякнігамі.",
"LabelMaxEpisodesToDownload": "Максімальная колькасць эпізодаў для спампоўкі. Выкарыстоўвайце 0 для неабмежаванай колькасці.",
"LabelMaxEpisodesToDownloadPerCheck": "Максімальная колькасць новых эпізодаў для спампоўкі за праверку",
"LabelMaxEpisodesToKeepHelp": "Значэнне 0 не ўстанаўлівае максімальнага абмежавання. Пасля аўтаматычнай спампоўкі новага эпізоду будзе выдалены самы стары эпізод, калі ў вас больш за X эпізодаў. Пры кожнай новай спампоўцы будзе выдаляцца толькі 1 эпізод.",
"LabelPermissionsDownload": "Можна спампаваць",
"LabelReAddSeriesToContinueListening": "Дадаць серыю зноў у Працягваць слухаць",
"LabelShareDownloadableHelp": "Дазваляе карыстальнікам, якія маюць спасылку на доступ, спампаваць ZIP-файл элемента бібліятэкі.",
"LabelStatsAudioTracks": "Аўдыядарожкі",
"LabelTracks": "Дарожкі",
"MessageConfirmRemoveListeningSessions": "Вы ўпэўнены, што жадаеце выдаліць {0} сеансаў праслухоўвання?",
"MessageDownloadingEpisode": "Спампоўка эпізоду",
"MessageEpisodesQueuedForDownload": "{0} эпізод(аў) у чарзе для спампоўкі",
"MessageNoDownloadsInProgress": "Зараз няма актыўных спамповак",
"MessageNoDownloadsQueued": "Няма спамповак у чарзе",
"MessageNoListeningSessions": "Няма сеансаў праслухоўвання",
"MessageTaskDownloadingEpisodeDescription": "Спампоўка эпізоду \"{0}\"",
"NotificationOnEpisodeDownloadedDescription": "Выклікаецца, калі эпізод падкаста аўтаматычна спампоўваецца",
"ToastAccountUpdateSuccess": "Уліковы запіс абноўлены",
"ToastEpisodeDownloadQueueClearFailed": "Не ўдалося ачысціць чаргу",
"ToastEpisodeDownloadQueueClearSuccess": "Чарга спампоўкі эпізодаў ачышчана",
"ToastInvalidMaxEpisodesToDownload": "Няправільная максімальная колькасць эпізодаў для спампоўкі",
"ToastNewUserCreatedFailed": "Не ўдалося стварыць уліковы запіс: \"{0}\"",
"ToastNewUserCreatedSuccess": "Новы ўліковы запіс створаны",
"ToastUserPasswordMustChange": "Новы пароль не можа супадаць са старым",
"ToastUserRootRequireName": "Неабходна ўвесці імя карыстальніка адміністратара"
} }

View File

@ -10,6 +10,8 @@
"ButtonApplyChapters": "Kapitel anwenden", "ButtonApplyChapters": "Kapitel anwenden",
"ButtonAuthors": "Autoren", "ButtonAuthors": "Autoren",
"ButtonBack": "Zurück", "ButtonBack": "Zurück",
"ButtonBatchEditPopulateFromExisting": "Auffüllen aus vorhandenem",
"ButtonBatchEditPopulateMapDetails": "Kartendetails auffüllen",
"ButtonBrowseForFolder": "Ordnersuche", "ButtonBrowseForFolder": "Ordnersuche",
"ButtonCancel": "Abbrechen", "ButtonCancel": "Abbrechen",
"ButtonCancelEncode": "Codierung abbrechen", "ButtonCancelEncode": "Codierung abbrechen",
@ -484,6 +486,7 @@
"LabelPersonalYearReview": "Dein Jahr in Übersicht ({0})", "LabelPersonalYearReview": "Dein Jahr in Übersicht ({0})",
"LabelPhotoPathURL": "Foto Pfad/URL", "LabelPhotoPathURL": "Foto Pfad/URL",
"LabelPlayMethod": "Abspielmethode", "LabelPlayMethod": "Abspielmethode",
"LabelPlaybackRateIncrementDecrement": "Wiedergaberate der Erhöhung/Verminderung",
"LabelPlayerChapterNumberMarker": "{0} von {1}", "LabelPlayerChapterNumberMarker": "{0} von {1}",
"LabelPlaylists": "Wiedergabelisten", "LabelPlaylists": "Wiedergabelisten",
"LabelPodcast": "Podcast", "LabelPodcast": "Podcast",
@ -704,8 +707,10 @@
"MessageBackupsLocationEditNote": "Hinweis: Durch das Aktualisieren des Backup-Speicherorts werden vorhandene Sicherungen nicht verschoben oder geändert", "MessageBackupsLocationEditNote": "Hinweis: Durch das Aktualisieren des Backup-Speicherorts werden vorhandene Sicherungen nicht verschoben oder geändert",
"MessageBackupsLocationNoEditNote": "Hinweis: Der Sicherungsspeicherort wird über eine Umgebungsvariable festgelegt und kann hier nicht geändert werden.", "MessageBackupsLocationNoEditNote": "Hinweis: Der Sicherungsspeicherort wird über eine Umgebungsvariable festgelegt und kann hier nicht geändert werden.",
"MessageBackupsLocationPathEmpty": "Der Backup-Pfad darf nicht leer sein", "MessageBackupsLocationPathEmpty": "Der Backup-Pfad darf nicht leer sein",
"MessageBatchEditPopulateMapDetailsAllHelp": "Fülle die aktivierten Felder mit Daten aus allen Elementen. Felder mit mehreren Werten werden zusammengeführt",
"MessageBatchQuickMatchDescription": "Der Schnellabgleich versucht, fehlende Titelbilder und Metadaten für die ausgewählten Artikel hinzuzufügen. Aktiviere die nachstehenden Optionen, damit der Schnellabgleich vorhandene Titelbilder und/oder Metadaten überschreiben kann.", "MessageBatchQuickMatchDescription": "Der Schnellabgleich versucht, fehlende Titelbilder und Metadaten für die ausgewählten Artikel hinzuzufügen. Aktiviere die nachstehenden Optionen, damit der Schnellabgleich vorhandene Titelbilder und/oder Metadaten überschreiben kann.",
"MessageBookshelfNoCollections": "Es wurden noch keine Sammlungen erstellt", "MessageBookshelfNoCollections": "Es wurden noch keine Sammlungen erstellt",
"MessageBookshelfNoCollectionsHelp": "Sammlungen sind öffentlich. Alle Benutzer mit Zugriff auf die Bibliothek können sie sehen.",
"MessageBookshelfNoRSSFeeds": "Keine RSS-Feeds geöffnet", "MessageBookshelfNoRSSFeeds": "Keine RSS-Feeds geöffnet",
"MessageBookshelfNoResultsForFilter": "Keine Ergebnisse für Filter \"{0}: {1}\"", "MessageBookshelfNoResultsForFilter": "Keine Ergebnisse für Filter \"{0}: {1}\"",
"MessageBookshelfNoResultsForQuery": "Keine Ergebnisse für die Abfrage", "MessageBookshelfNoResultsForQuery": "Keine Ergebnisse für die Abfrage",
@ -816,6 +821,7 @@
"MessageNoTasksRunning": "Keine laufenden Aufgaben", "MessageNoTasksRunning": "Keine laufenden Aufgaben",
"MessageNoUpdatesWereNecessary": "Keine Aktualisierungen waren notwendig", "MessageNoUpdatesWereNecessary": "Keine Aktualisierungen waren notwendig",
"MessageNoUserPlaylists": "Keine Wiedergabelisten vorhanden", "MessageNoUserPlaylists": "Keine Wiedergabelisten vorhanden",
"MessageNoUserPlaylistsHelp": "Wiedergabelisten sind privat. Nur der Benutzer, der sie erstellt hat, kann sie sehen.",
"MessageNotYetImplemented": "Noch nicht implementiert", "MessageNotYetImplemented": "Noch nicht implementiert",
"MessageOpmlPreviewNote": "Hinweis: Dies ist nur eine Vorschau der geparsten OPML Datei. Der eigentliche Podcast-Titel wird aus dem RSS-Feed übernommen.", "MessageOpmlPreviewNote": "Hinweis: Dies ist nur eine Vorschau der geparsten OPML Datei. Der eigentliche Podcast-Titel wird aus dem RSS-Feed übernommen.",
"MessageOr": "Oder", "MessageOr": "Oder",

View File

@ -641,10 +641,10 @@
"LabelTimeDurationXMinutes": "{0} minuta", "LabelTimeDurationXMinutes": "{0} minuta",
"LabelTimeDurationXSeconds": "{0} sekundi", "LabelTimeDurationXSeconds": "{0} sekundi",
"LabelTimeInMinutes": "Vrijeme u minutama", "LabelTimeInMinutes": "Vrijeme u minutama",
"LabelTimeLeft": "{0} preostalo", "LabelTimeLeft": "preostalo {0}",
"LabelTimeListened": "Vremena odslušano", "LabelTimeListened": "Vremena odslušano",
"LabelTimeListenedToday": "Vremena odslušano danas", "LabelTimeListenedToday": "Vremena odslušano danas",
"LabelTimeRemaining": "{0} preostalo", "LabelTimeRemaining": "preostalo {0}",
"LabelTimeToShift": "Vrijeme za pomjeriti u sekundama", "LabelTimeToShift": "Vrijeme za pomjeriti u sekundama",
"LabelTitle": "Naslov", "LabelTitle": "Naslov",
"LabelToolsEmbedMetadata": "Ugradi meta-podatke", "LabelToolsEmbedMetadata": "Ugradi meta-podatke",

View File

@ -10,6 +10,8 @@
"ButtonApplyChapters": "Applica", "ButtonApplyChapters": "Applica",
"ButtonAuthors": "Autori", "ButtonAuthors": "Autori",
"ButtonBack": "Indietro", "ButtonBack": "Indietro",
"ButtonBatchEditPopulateFromExisting": "Popola da esistente",
"ButtonBatchEditPopulateMapDetails": "Inserisci i dettagli della mappa",
"ButtonBrowseForFolder": "Per Cartella", "ButtonBrowseForFolder": "Per Cartella",
"ButtonCancel": "Cancella", "ButtonCancel": "Cancella",
"ButtonCancelEncode": "Ferma la codifica", "ButtonCancelEncode": "Ferma la codifica",
@ -88,6 +90,8 @@
"ButtonSaveTracklist": "Salva Tracklist", "ButtonSaveTracklist": "Salva Tracklist",
"ButtonScan": "Scansiona", "ButtonScan": "Scansiona",
"ButtonScanLibrary": "Scansiona Libreria", "ButtonScanLibrary": "Scansiona Libreria",
"ButtonScrollLeft": "Scorri verso sinistra",
"ButtonScrollRight": "Scorri verso destra",
"ButtonSearch": "Cerca", "ButtonSearch": "Cerca",
"ButtonSelectFolderPath": "Seleziona percorso cartella", "ButtonSelectFolderPath": "Seleziona percorso cartella",
"ButtonSeries": "Serie", "ButtonSeries": "Serie",
@ -190,6 +194,7 @@
"HeaderSettingsExperimental": "Opzioni Sperimentali", "HeaderSettingsExperimental": "Opzioni Sperimentali",
"HeaderSettingsGeneral": "Generale", "HeaderSettingsGeneral": "Generale",
"HeaderSettingsScanner": "Scanner", "HeaderSettingsScanner": "Scanner",
"HeaderSettingsWebClient": "Web Client",
"HeaderSleepTimer": "Sveglia", "HeaderSleepTimer": "Sveglia",
"HeaderStatsLargestItems": "File pesanti", "HeaderStatsLargestItems": "File pesanti",
"HeaderStatsLongestItems": "libri più lunghi (ore)", "HeaderStatsLongestItems": "libri più lunghi (ore)",
@ -429,7 +434,7 @@
"LabelMetadataProvider": "Metadata Provider", "LabelMetadataProvider": "Metadata Provider",
"LabelMinute": "Minuto", "LabelMinute": "Minuto",
"LabelMinutes": "Minuti", "LabelMinutes": "Minuti",
"LabelMissing": "Altro", "LabelMissing": "Mancante",
"LabelMissingEbook": "Non ha libri digitali", "LabelMissingEbook": "Non ha libri digitali",
"LabelMissingSupplementaryEbook": "Non ha un libro digitale supplementare", "LabelMissingSupplementaryEbook": "Non ha un libro digitale supplementare",
"LabelMobileRedirectURIs": "URI di reindirizzamento mobile consentiti", "LabelMobileRedirectURIs": "URI di reindirizzamento mobile consentiti",
@ -481,6 +486,7 @@
"LabelPersonalYearReview": "Il tuo anno in rassegna ({0})", "LabelPersonalYearReview": "Il tuo anno in rassegna ({0})",
"LabelPhotoPathURL": "foto Path/URL", "LabelPhotoPathURL": "foto Path/URL",
"LabelPlayMethod": "Metodo di riproduzione", "LabelPlayMethod": "Metodo di riproduzione",
"LabelPlaybackRateIncrementDecrement": "Valore incremento/decremento velocità di riproduzione",
"LabelPlayerChapterNumberMarker": "{0} di {1}", "LabelPlayerChapterNumberMarker": "{0} di {1}",
"LabelPlaylists": "Playlist", "LabelPlaylists": "Playlist",
"LabelPodcast": "Podcast", "LabelPodcast": "Podcast",
@ -543,6 +549,7 @@
"LabelServerYearReview": "Anno del server in sintesi({0})", "LabelServerYearReview": "Anno del server in sintesi({0})",
"LabelSetEbookAsPrimary": "Imposta come primario", "LabelSetEbookAsPrimary": "Imposta come primario",
"LabelSetEbookAsSupplementary": "Imposta come suplementare", "LabelSetEbookAsSupplementary": "Imposta come suplementare",
"LabelSettingsAllowIframe": "Consenti l'incorporamento in un iframe",
"LabelSettingsAudiobooksOnly": "Solo Audiolibri", "LabelSettingsAudiobooksOnly": "Solo Audiolibri",
"LabelSettingsAudiobooksOnlyHelp": "L'abilitazione di questa impostazione ignorerà i file di libro digitale a meno che non si trovino all'interno di una cartella di audiolibri, nel qual caso verranno impostati come libri digitali supplementari", "LabelSettingsAudiobooksOnlyHelp": "L'abilitazione di questa impostazione ignorerà i file di libro digitale a meno che non si trovino all'interno di una cartella di audiolibri, nel qual caso verranno impostati come libri digitali supplementari",
"LabelSettingsBookshelfViewHelp": "Design con scaffali in legno", "LabelSettingsBookshelfViewHelp": "Design con scaffali in legno",
@ -585,6 +592,7 @@
"LabelSettingsStoreMetadataWithItemHelp": "Di default, i metadati sono salvati dentro /metadata/items, abilitando questa opzione si memorizzeranno i metadata nella cartella della libreria", "LabelSettingsStoreMetadataWithItemHelp": "Di default, i metadati sono salvati dentro /metadata/items, abilitando questa opzione si memorizzeranno i metadata nella cartella della libreria",
"LabelSettingsTimeFormat": "Formato Ora", "LabelSettingsTimeFormat": "Formato Ora",
"LabelShare": "Condividi", "LabelShare": "Condividi",
"LabelShareDownloadableHelp": "Consente agli utenti dotati del link di condivisione di scaricare un file zip dell'elemento della libreria.",
"LabelShareOpen": "Apri Condivisioni", "LabelShareOpen": "Apri Condivisioni",
"LabelShareURL": "Condividi URL", "LabelShareURL": "Condividi URL",
"LabelShowAll": "Mostra tutto", "LabelShowAll": "Mostra tutto",
@ -593,6 +601,8 @@
"LabelSize": "Dimensione", "LabelSize": "Dimensione",
"LabelSleepTimer": "Temporizzatore", "LabelSleepTimer": "Temporizzatore",
"LabelSlug": "Lento", "LabelSlug": "Lento",
"LabelSortAscending": "Crescente",
"LabelSortDescending": "Discendente",
"LabelStart": "Inizo", "LabelStart": "Inizo",
"LabelStartTime": "Tempo di inizio", "LabelStartTime": "Tempo di inizio",
"LabelStarted": "Iniziato", "LabelStarted": "Iniziato",
@ -664,6 +674,7 @@
"LabelUpdateDetailsHelp": "Consenti la sovrascrittura dei dettagli esistenti per i libri selezionati quando viene individuata una corrispondenza", "LabelUpdateDetailsHelp": "Consenti la sovrascrittura dei dettagli esistenti per i libri selezionati quando viene individuata una corrispondenza",
"LabelUpdatedAt": "Aggiornato alle", "LabelUpdatedAt": "Aggiornato alle",
"LabelUploaderDragAndDrop": "Drag & drop file o Cartelle", "LabelUploaderDragAndDrop": "Drag & drop file o Cartelle",
"LabelUploaderDragAndDropFilesOnly": "Drag & drop files",
"LabelUploaderDropFiles": "Elimina file", "LabelUploaderDropFiles": "Elimina file",
"LabelUploaderItemFetchMetadataHelp": "Recupera automaticamente titolo, autore e serie", "LabelUploaderItemFetchMetadataHelp": "Recupera automaticamente titolo, autore e serie",
"LabelUseAdvancedOptions": "Usa le opzioni avanzate", "LabelUseAdvancedOptions": "Usa le opzioni avanzate",
@ -679,6 +690,8 @@
"LabelViewPlayerSettings": "Mostra Impostazioni player", "LabelViewPlayerSettings": "Mostra Impostazioni player",
"LabelViewQueue": "Visualizza coda", "LabelViewQueue": "Visualizza coda",
"LabelVolume": "Volume", "LabelVolume": "Volume",
"LabelWebRedirectURLsDescription": "Autorizza questi URL nel tuo provider OAuth per consentire il reindirizzamento all'app Web dopo l'accesso:",
"LabelWebRedirectURLsSubfolder": "Sottocartella per URL di reindirizzamento",
"LabelWeekdaysToRun": "Giorni feriali da eseguire", "LabelWeekdaysToRun": "Giorni feriali da eseguire",
"LabelXBooks": "{0} libri", "LabelXBooks": "{0} libri",
"LabelXItems": "{0} oggetti", "LabelXItems": "{0} oggetti",
@ -694,8 +707,11 @@
"MessageBackupsLocationEditNote": "Nota: l'aggiornamento della posizione di backup non sposterà o modificherà i backup esistenti", "MessageBackupsLocationEditNote": "Nota: l'aggiornamento della posizione di backup non sposterà o modificherà i backup esistenti",
"MessageBackupsLocationNoEditNote": "Nota: la posizione del backup viene impostata tramite una variabile di ambiente e non può essere modificata qui.", "MessageBackupsLocationNoEditNote": "Nota: la posizione del backup viene impostata tramite una variabile di ambiente e non può essere modificata qui.",
"MessageBackupsLocationPathEmpty": "Il percorso del backup non può essere vuoto", "MessageBackupsLocationPathEmpty": "Il percorso del backup non può essere vuoto",
"MessageBatchEditPopulateMapDetailsAllHelp": "Popola i campi abilitati con i dati di tutti gli elementi. I campi con più valori verranno uniti",
"MessageBatchEditPopulateMapDetailsItemHelp": "Compila i campi dei dettagli della mappa abilitati con i dati di questo elemento",
"MessageBatchQuickMatchDescription": "Quick Match tenterà di aggiungere copertine e metadati mancanti per gli elementi selezionati. Attiva l'opzione per consentire a Quick Match di sovrascrivere copertine e/o metadati esistenti.", "MessageBatchQuickMatchDescription": "Quick Match tenterà di aggiungere copertine e metadati mancanti per gli elementi selezionati. Attiva l'opzione per consentire a Quick Match di sovrascrivere copertine e/o metadati esistenti.",
"MessageBookshelfNoCollections": "Non hai ancora creato nessuna raccolta", "MessageBookshelfNoCollections": "Non hai ancora creato nessuna raccolta",
"MessageBookshelfNoCollectionsHelp": "le collezioni sono pubbliche. Tutti gli utenti con accesso alla biblioteca possono vederle.",
"MessageBookshelfNoRSSFeeds": "Nessun RSS feeds aperto", "MessageBookshelfNoRSSFeeds": "Nessun RSS feeds aperto",
"MessageBookshelfNoResultsForFilter": "Nessun risultato per il filtro \"{0}: {1}\"", "MessageBookshelfNoResultsForFilter": "Nessun risultato per il filtro \"{0}: {1}\"",
"MessageBookshelfNoResultsForQuery": "Nessun risultato per la query", "MessageBookshelfNoResultsForQuery": "Nessun risultato per la query",
@ -748,6 +764,7 @@
"MessageConfirmResetProgress": "Vuoi davvero azzerare i tuoi progressi?", "MessageConfirmResetProgress": "Vuoi davvero azzerare i tuoi progressi?",
"MessageConfirmSendEbookToDevice": "Sei sicuro/sicura di voler inviare {0} libro «{1}» al dispositivo «{2}»?", "MessageConfirmSendEbookToDevice": "Sei sicuro/sicura di voler inviare {0} libro «{1}» al dispositivo «{2}»?",
"MessageConfirmUnlinkOpenId": "Vuoi davvero scollegare questo utente da OpenID?", "MessageConfirmUnlinkOpenId": "Vuoi davvero scollegare questo utente da OpenID?",
"MessageDaysListenedInTheLastYear": "{0} giorni ascoltati nell'ultimo anno",
"MessageDownloadingEpisode": "Scaricamento dellepisodio in corso", "MessageDownloadingEpisode": "Scaricamento dellepisodio in corso",
"MessageDragFilesIntoTrackOrder": "Trascina i file nell'ordine di traccia corretto", "MessageDragFilesIntoTrackOrder": "Trascina i file nell'ordine di traccia corretto",
"MessageEmbedFailed": "Incorporamento non riuscito!", "MessageEmbedFailed": "Incorporamento non riuscito!",
@ -805,6 +822,7 @@
"MessageNoTasksRunning": "Nessun processo in esecuzione", "MessageNoTasksRunning": "Nessun processo in esecuzione",
"MessageNoUpdatesWereNecessary": "Nessun aggiornamento necessario", "MessageNoUpdatesWereNecessary": "Nessun aggiornamento necessario",
"MessageNoUserPlaylists": "non hai nessuna Playlist", "MessageNoUserPlaylists": "non hai nessuna Playlist",
"MessageNoUserPlaylistsHelp": "Le playlist sono private. Solo l'utente che le crea può vederle.",
"MessageNotYetImplemented": "Non Ancora Implementato", "MessageNotYetImplemented": "Non Ancora Implementato",
"MessageOpmlPreviewNote": "Nota: questa è un'anteprima del file OPML analizzato. Il titolo effettivo del podcast verrà preso dal feed RSS.", "MessageOpmlPreviewNote": "Nota: questa è un'anteprima del file OPML analizzato. Il titolo effettivo del podcast verrà preso dal feed RSS.",
"MessageOr": "o", "MessageOr": "o",
@ -826,6 +844,7 @@
"MessageResetChaptersConfirm": "Sei sicuro di voler reimpostare i capitoli e annullare le modifiche ?", "MessageResetChaptersConfirm": "Sei sicuro di voler reimpostare i capitoli e annullare le modifiche ?",
"MessageRestoreBackupConfirm": "Sei sicuro di voler ripristinare il backup creato su", "MessageRestoreBackupConfirm": "Sei sicuro di voler ripristinare il backup creato su",
"MessageRestoreBackupWarning": "Il ripristino di un backup sovrascriverà l'intero database situato in /config e sovrascrive le immagini in /metadata/items & /metadata/authors.<br /><br />I backup non modificano alcun file nelle cartelle della libreria. Se hai abilitato le impostazioni del server per archiviare copertine e metadati nelle cartelle della libreria, questi non vengono sottoposti a backup o sovrascritti.<br /><br />Tutti i client che utilizzano il tuo server verranno aggiornati automaticamente.", "MessageRestoreBackupWarning": "Il ripristino di un backup sovrascriverà l'intero database situato in /config e sovrascrive le immagini in /metadata/items & /metadata/authors.<br /><br />I backup non modificano alcun file nelle cartelle della libreria. Se hai abilitato le impostazioni del server per archiviare copertine e metadati nelle cartelle della libreria, questi non vengono sottoposti a backup o sovrascritti.<br /><br />Tutti i client che utilizzano il tuo server verranno aggiornati automaticamente.",
"MessageScheduleLibraryScanNote": "Per la maggior parte degli utenti, si consiglia di lasciare questa funzionalità disabilitata e di mantenere abilitata l'impostazione di folder watcher. Il folder watcher rileverà automaticamente le modifiche nelle cartelle della libreria. Il folder watcher non funziona per ogni file system (come NFS), quindi è possibile utilizzare le scansioni pianificate della libreria.",
"MessageSearchResultsFor": "cerca risultati per", "MessageSearchResultsFor": "cerca risultati per",
"MessageSelected": "{0} selezionati", "MessageSelected": "{0} selezionati",
"MessageServerCouldNotBeReached": "Impossibile raggiungere il server", "MessageServerCouldNotBeReached": "Impossibile raggiungere il server",
@ -952,6 +971,7 @@
"ToastCollectionRemoveSuccess": "Collezione rimossa", "ToastCollectionRemoveSuccess": "Collezione rimossa",
"ToastCollectionUpdateSuccess": "Raccolta aggiornata", "ToastCollectionUpdateSuccess": "Raccolta aggiornata",
"ToastCoverUpdateFailed": "Aggiornamento cover fallito", "ToastCoverUpdateFailed": "Aggiornamento cover fallito",
"ToastDateTimeInvalidOrIncomplete": "Data e ora non sono valide o incomplete",
"ToastDeleteFileFailed": "Impossibile eliminare il file", "ToastDeleteFileFailed": "Impossibile eliminare il file",
"ToastDeleteFileSuccess": "File eliminato", "ToastDeleteFileSuccess": "File eliminato",
"ToastDeviceAddFailed": "Aggiunta dispositivo fallita", "ToastDeviceAddFailed": "Aggiunta dispositivo fallita",
@ -1004,6 +1024,7 @@
"ToastNewUserTagError": "Devi selezionare almeno un tag", "ToastNewUserTagError": "Devi selezionare almeno un tag",
"ToastNewUserUsernameError": "Inserisci un nome utente", "ToastNewUserUsernameError": "Inserisci un nome utente",
"ToastNoNewEpisodesFound": "Nessun nuovo episodio trovato", "ToastNoNewEpisodesFound": "Nessun nuovo episodio trovato",
"ToastNoRSSFeed": "Il podcast non ha un feed RSS",
"ToastNoUpdatesNecessary": "Nessun aggiornamento necessario", "ToastNoUpdatesNecessary": "Nessun aggiornamento necessario",
"ToastNotificationCreateFailed": "Impossibile creare la notifica", "ToastNotificationCreateFailed": "Impossibile creare la notifica",
"ToastNotificationDeleteFailed": "Impossibile eliminare la notifica", "ToastNotificationDeleteFailed": "Impossibile eliminare la notifica",

View File

@ -484,6 +484,7 @@
"LabelPersonalYearReview": "Jouw jaar in review ({0})", "LabelPersonalYearReview": "Jouw jaar in review ({0})",
"LabelPhotoPathURL": "Foto pad/URL", "LabelPhotoPathURL": "Foto pad/URL",
"LabelPlayMethod": "Afspeelwijze", "LabelPlayMethod": "Afspeelwijze",
"LabelPlaybackRateIncrementDecrement": "Afspeel Snelheid Vermeerderen/Verminderen",
"LabelPlayerChapterNumberMarker": "{0} van {1}", "LabelPlayerChapterNumberMarker": "{0} van {1}",
"LabelPlaylists": "Afspeellijsten", "LabelPlaylists": "Afspeellijsten",
"LabelPodcast": "Podcast", "LabelPodcast": "Podcast",
@ -704,8 +705,11 @@
"MessageBackupsLocationEditNote": "Let op: het bijwerken van de back-uplocatie zal bestaande back-ups niet verplaatsen of wijzigen", "MessageBackupsLocationEditNote": "Let op: het bijwerken van de back-uplocatie zal bestaande back-ups niet verplaatsen of wijzigen",
"MessageBackupsLocationNoEditNote": "Let op: De back-uplocatie wordt ingesteld via een omgevingsvariabele en kan hier niet worden gewijzigd.", "MessageBackupsLocationNoEditNote": "Let op: De back-uplocatie wordt ingesteld via een omgevingsvariabele en kan hier niet worden gewijzigd.",
"MessageBackupsLocationPathEmpty": "Backup locatie pad kan niet leeg zijn", "MessageBackupsLocationPathEmpty": "Backup locatie pad kan niet leeg zijn",
"MessageBatchEditPopulateMapDetailsAllHelp": "Vul actieve velden in met data van alle items. Velden met meerdere waarden zullen worden samengevoegd",
"MessageBatchEditPopulateMapDetailsItemHelp": "Vul actieve folder detail velden met de data van dit item",
"MessageBatchQuickMatchDescription": "Quick Match zal proberen ontbrekende covers en metadata voor de geselecteerde onderdelen te matchten. Schakel de opties hieronder in om Quick Match toe te staan bestaande covers en/of metadata te overschrijven.", "MessageBatchQuickMatchDescription": "Quick Match zal proberen ontbrekende covers en metadata voor de geselecteerde onderdelen te matchten. Schakel de opties hieronder in om Quick Match toe te staan bestaande covers en/of metadata te overschrijven.",
"MessageBookshelfNoCollections": "Je hebt nog geen collecties gemaakt", "MessageBookshelfNoCollections": "Je hebt nog geen collecties gemaakt",
"MessageBookshelfNoCollectionsHelp": "Collecties zijn publiekelijk. Alle gebruikers met toegang tot de bibliotheek kunnen ze zien.",
"MessageBookshelfNoRSSFeeds": "Geen RSS-feeds geopend", "MessageBookshelfNoRSSFeeds": "Geen RSS-feeds geopend",
"MessageBookshelfNoResultsForFilter": "Geen resultaten voor filter \"{0}: {1}\"", "MessageBookshelfNoResultsForFilter": "Geen resultaten voor filter \"{0}: {1}\"",
"MessageBookshelfNoResultsForQuery": "Geen resultaten voor query", "MessageBookshelfNoResultsForQuery": "Geen resultaten voor query",
@ -816,6 +820,7 @@
"MessageNoTasksRunning": "Geen lopende taken", "MessageNoTasksRunning": "Geen lopende taken",
"MessageNoUpdatesWereNecessary": "Geen bijwerkingen waren noodzakelijk", "MessageNoUpdatesWereNecessary": "Geen bijwerkingen waren noodzakelijk",
"MessageNoUserPlaylists": "Je hebt geen afspeellijsten", "MessageNoUserPlaylists": "Je hebt geen afspeellijsten",
"MessageNoUserPlaylistsHelp": "Afspeellijsten zijn privaat. Alleen de gebruikers die ze hebben gemaakt kunnen ze zien.",
"MessageNotYetImplemented": "Nog niet geimplementeerd", "MessageNotYetImplemented": "Nog niet geimplementeerd",
"MessageOpmlPreviewNote": "Let op: Dit is een preview van het geparseerde OPML-bestand. De werkelijke podcasttitel wordt overgenomen uit de RSS-feed.", "MessageOpmlPreviewNote": "Let op: Dit is een preview van het geparseerde OPML-bestand. De werkelijke podcasttitel wordt overgenomen uit de RSS-feed.",
"MessageOr": "of", "MessageOr": "of",

View File

@ -10,11 +10,13 @@
"ButtonApplyChapters": "Tillämpa kapitel", "ButtonApplyChapters": "Tillämpa kapitel",
"ButtonAuthors": "Författare", "ButtonAuthors": "Författare",
"ButtonBack": "Tillbaka", "ButtonBack": "Tillbaka",
"ButtonBatchEditPopulateFromExisting": "Hämta befintlig information",
"ButtonBatchEditPopulateMapDetails": "Addera befintliga information",
"ButtonBrowseForFolder": "Bläddra efter mapp", "ButtonBrowseForFolder": "Bläddra efter mapp",
"ButtonCancel": "Avbryt", "ButtonCancel": "Avbryt",
"ButtonCancelEncode": "Avbryt omkodning", "ButtonCancelEncode": "Avbryt omkodning",
"ButtonChangeRootPassword": "Ändra lösenordet för root", "ButtonChangeRootPassword": "Ändra lösenordet för root",
"ButtonCheckAndDownloadNewEpisodes": "Kontrollera och ladda ner nya avsnitt", "ButtonCheckAndDownloadNewEpisodes": "Sök & Ladda ner nya avsnitt",
"ButtonChooseAFolder": "Välj en mapp", "ButtonChooseAFolder": "Välj en mapp",
"ButtonChooseFiles": "Välj filer", "ButtonChooseFiles": "Välj filer",
"ButtonClearFilter": "Rensa filter", "ButtonClearFilter": "Rensa filter",
@ -30,7 +32,7 @@
"ButtonEditChapters": "Redigera kapitel", "ButtonEditChapters": "Redigera kapitel",
"ButtonEditPodcast": "Redigera podcast", "ButtonEditPodcast": "Redigera podcast",
"ButtonEnable": "Aktivera", "ButtonEnable": "Aktivera",
"ButtonForceReScan": "Tvinga omstart", "ButtonForceReScan": "Starta ny skanning",
"ButtonFullPath": "Fullständig sökväg", "ButtonFullPath": "Fullständig sökväg",
"ButtonHide": "Dölj", "ButtonHide": "Dölj",
"ButtonHome": "Hem", "ButtonHome": "Hem",
@ -64,8 +66,8 @@
"ButtonPurgeItemsCache": "Rensa cache för föremål", "ButtonPurgeItemsCache": "Rensa cache för föremål",
"ButtonQueueAddItem": "Lägg till i kön", "ButtonQueueAddItem": "Lägg till i kön",
"ButtonQueueRemoveItem": "Ta bort från kön", "ButtonQueueRemoveItem": "Ta bort från kön",
"ButtonQuickMatch": "Snabb matchning", "ButtonQuickMatch": "Snabbmatchning",
"ButtonReScan": "Omstart", "ButtonReScan": "Ny skanning",
"ButtonRead": "Läs", "ButtonRead": "Läs",
"ButtonReadLess": "Visa mindre", "ButtonReadLess": "Visa mindre",
"ButtonReadMore": "Visa mer", "ButtonReadMore": "Visa mer",
@ -73,8 +75,8 @@
"ButtonRemove": "Ta bort", "ButtonRemove": "Ta bort",
"ButtonRemoveAll": "Ta bort alla", "ButtonRemoveAll": "Ta bort alla",
"ButtonRemoveAllLibraryItems": "Ta bort alla objekt i biblioteket", "ButtonRemoveAllLibraryItems": "Ta bort alla objekt i biblioteket",
"ButtonRemoveFromContinueListening": "Radera från 'Fortsätt läsa/lyssna'", "ButtonRemoveFromContinueListening": "Radera från 'Fortsätt lyssna'",
"ButtonRemoveFromContinueReading": "Ta bort från Fortsätt läsa", "ButtonRemoveFromContinueReading": "Radera från 'Fortsätt läsa'",
"ButtonRemoveSeriesFromContinueSeries": "Radera från 'Fortsätt med serien'", "ButtonRemoveSeriesFromContinueSeries": "Radera från 'Fortsätt med serien'",
"ButtonReset": "Tillbaka", "ButtonReset": "Tillbaka",
"ButtonResetToDefault": "Återställ till standard", "ButtonResetToDefault": "Återställ till standard",
@ -97,8 +99,8 @@
"ButtonSubmit": "Spara", "ButtonSubmit": "Spara",
"ButtonTest": "Testa", "ButtonTest": "Testa",
"ButtonUpload": "Ladda upp", "ButtonUpload": "Ladda upp",
"ButtonUploadBackup": "Ladda upp säkerhetskopia", "ButtonUploadBackup": "Läs in säkerhetskopia",
"ButtonUploadCover": "Ladda upp bokomslag", "ButtonUploadCover": "Ladda upp omslag",
"ButtonUploadOPMLFile": "Ladda upp OPML-fil", "ButtonUploadOPMLFile": "Ladda upp OPML-fil",
"ButtonUserDelete": "Radera användare {0}", "ButtonUserDelete": "Radera användare {0}",
"ButtonUserEdit": "Redigera användare {0}", "ButtonUserEdit": "Redigera användare {0}",
@ -120,7 +122,7 @@
"HeaderChooseAFolder": "Välj en mapp", "HeaderChooseAFolder": "Välj en mapp",
"HeaderCollection": "Samling", "HeaderCollection": "Samling",
"HeaderCollectionItems": "Böcker i samlingen", "HeaderCollectionItems": "Böcker i samlingen",
"HeaderCover": "Bokomslag", "HeaderCover": "Omslag",
"HeaderCurrentDownloads": "Aktuella nedladdningar", "HeaderCurrentDownloads": "Aktuella nedladdningar",
"HeaderCustomMetadataProviders": "Egen källa för metadata", "HeaderCustomMetadataProviders": "Egen källa för metadata",
"HeaderDetails": "Detaljer", "HeaderDetails": "Detaljer",
@ -134,8 +136,8 @@
"HeaderFiles": "Filer", "HeaderFiles": "Filer",
"HeaderFindChapters": "Hitta kapitel", "HeaderFindChapters": "Hitta kapitel",
"HeaderIgnoredFiles": "Ignorerade filer", "HeaderIgnoredFiles": "Ignorerade filer",
"HeaderItemFiles": "Föremålsfiler", "HeaderItemFiles": "Filer",
"HeaderItemMetadataUtils": "Metadataverktyg för föremål", "HeaderItemMetadataUtils": "Metadataverktyg",
"HeaderLastListeningSession": "Senaste lyssningstillfället", "HeaderLastListeningSession": "Senaste lyssningstillfället",
"HeaderLatestEpisodes": "Senaste avsnitten", "HeaderLatestEpisodes": "Senaste avsnitten",
"HeaderLibraries": "Bibliotek", "HeaderLibraries": "Bibliotek",
@ -147,7 +149,7 @@
"HeaderLogs": "Loggar", "HeaderLogs": "Loggar",
"HeaderManageGenres": "Hantera kategorier", "HeaderManageGenres": "Hantera kategorier",
"HeaderManageTags": "Hantera taggar", "HeaderManageTags": "Hantera taggar",
"HeaderMapDetails": "Karta detaljer", "HeaderMapDetails": "Gemensam information för samtliga objekt",
"HeaderMatch": "Matcha", "HeaderMatch": "Matcha",
"HeaderMetadataOrderOfPrecedence": "Prioriteringsordning vid inläsning av metadata", "HeaderMetadataOrderOfPrecedence": "Prioriteringsordning vid inläsning av metadata",
"HeaderMetadataToEmbed": "Metadata som kommer att adderas", "HeaderMetadataToEmbed": "Metadata som kommer att adderas",
@ -164,15 +166,15 @@
"HeaderPlaylist": "Spellista", "HeaderPlaylist": "Spellista",
"HeaderPlaylistItems": "Böcker i spellistan", "HeaderPlaylistItems": "Böcker i spellistan",
"HeaderPodcastsToAdd": "Podcaster att lägga till", "HeaderPodcastsToAdd": "Podcaster att lägga till",
"HeaderPreviewCover": "Förhandsgranska bokomslag", "HeaderPreviewCover": "Förhandsgranska omslag",
"HeaderRSSFeedGeneral": "RSS-information", "HeaderRSSFeedGeneral": "RSS-information",
"HeaderRSSFeedIsOpen": "RSS-flödet är öppet", "HeaderRSSFeedIsOpen": "RSS-flödet är öppet",
"HeaderRSSFeeds": "RSS-flöden", "HeaderRSSFeeds": "RSS-flöden",
"HeaderRemoveEpisode": "Ta bort avsnitt", "HeaderRemoveEpisode": "Radera avsnitt",
"HeaderRemoveEpisodes": "Ta bort {0} avsnitt", "HeaderRemoveEpisodes": "Radera {0} avsnitt",
"HeaderSavedMediaProgress": "Sparad historik", "HeaderSavedMediaProgress": "Sparad historik",
"HeaderSchedule": "Schema", "HeaderSchedule": "Schema",
"HeaderScheduleEpisodeDownloads": "Schemalägg automatiska avsnittsnedladdningar", "HeaderScheduleEpisodeDownloads": "Schemalägg automatiska nedladdning av avsnitt",
"HeaderScheduleLibraryScans": "Schema för skanning av biblioteket", "HeaderScheduleLibraryScans": "Schema för skanning av biblioteket",
"HeaderSession": "Tillfälle", "HeaderSession": "Tillfälle",
"HeaderSetBackupSchedule": "Ange schemaläggning för säkerhetskopia", "HeaderSetBackupSchedule": "Ange schemaläggning för säkerhetskopia",
@ -198,7 +200,7 @@
"HeaderUsers": "Användare", "HeaderUsers": "Användare",
"HeaderYearReview": "Sammanställning av {0}", "HeaderYearReview": "Sammanställning av {0}",
"HeaderYourStats": "Din statistik", "HeaderYourStats": "Din statistik",
"LabelAbridged": "Förkortad", "LabelAbridged": "Förkortad version",
"LabelAccessibleBy": "Tillgänglig för", "LabelAccessibleBy": "Tillgänglig för",
"LabelAccountType": "Kontotyp", "LabelAccountType": "Kontotyp",
"LabelAccountTypeAdmin": "Administratör", "LabelAccountTypeAdmin": "Administratör",
@ -235,10 +237,10 @@
"LabelBackupLocation": "Plats för säkerhetskopia", "LabelBackupLocation": "Plats för säkerhetskopia",
"LabelBackupsEnableAutomaticBackups": "Aktivera automatisk säkerhetskopiering", "LabelBackupsEnableAutomaticBackups": "Aktivera automatisk säkerhetskopiering",
"LabelBackupsEnableAutomaticBackupsHelp": "Säkerhetskopior sparas i \"/metadata/backups\"", "LabelBackupsEnableAutomaticBackupsHelp": "Säkerhetskopior sparas i \"/metadata/backups\"",
"LabelBackupsMaxBackupSize": "Maximal storlek på säkerhetskopia (i GB) (0 = obegränsad)", "LabelBackupsMaxBackupSize": "Maximal storlek på säkerhetskopia i GigaByte (0 = obegränsad)",
"LabelBackupsMaxBackupSizeHelp": "Som ett skydd mot en felaktig konfiguration kommer säkerhetskopior inte att genomföras om de överskrider den konfigurerade storleken.", "LabelBackupsMaxBackupSizeHelp": "Som ett skydd mot en felaktig konfiguration kommer säkerhetskopior inte att genomföras om de överskrider den konfigurerade storleken.",
"LabelBackupsNumberToKeep": "Antal säkerhetskopior att behålla", "LabelBackupsNumberToKeep": "Antal säkerhetskopior att behålla",
"LabelBackupsNumberToKeepHelp": "Endast en gammal säkerhetskopia tas bort åt gången, så om du redan har fler säkerhetskopior än det angivna beloppet bör du ta bort dem manuellt.", "LabelBackupsNumberToKeepHelp": "Endast en gammal säkerhetskopia tas bort åt gången, så om du redan har fler säkerhetskopior än det angivna värdet bör du ta bort dem manuellt.",
"LabelBitrate": "Bitfrekvens", "LabelBitrate": "Bitfrekvens",
"LabelBonus": "Bonus", "LabelBonus": "Bonus",
"LabelBooks": "Böcker", "LabelBooks": "Böcker",
@ -262,7 +264,7 @@
"LabelContinueListening": "Fortsätt att lyssna", "LabelContinueListening": "Fortsätt att lyssna",
"LabelContinueReading": "Fortsätt att läsa", "LabelContinueReading": "Fortsätt att läsa",
"LabelContinueSeries": "Fortsätt med serien", "LabelContinueSeries": "Fortsätt med serien",
"LabelCover": "Bokomslag", "LabelCover": "Omslag",
"LabelCoverImageURL": "URL till omslagsbild", "LabelCoverImageURL": "URL till omslagsbild",
"LabelCreatedAt": "Skapad", "LabelCreatedAt": "Skapad",
"LabelCronExpression": "Schemaläggning med hjälp av Cron (Cron Expression)", "LabelCronExpression": "Schemaläggning med hjälp av Cron (Cron Expression)",
@ -297,7 +299,7 @@
"LabelEmailSettingsSecure": "Säker", "LabelEmailSettingsSecure": "Säker",
"LabelEmailSettingsSecureHelp": "Om sant kommer anslutningen att använda TLS vid anslutning till servern. Om falskt används TLS om servern stöder STARTTLS-tillägget. I de flesta fall, om du ansluter till port 465, bör du ställa in detta värde till sant. För port 587 eller 25, låt det vara falskt. (från nodemailer.com/smtp/#authentication)", "LabelEmailSettingsSecureHelp": "Om sant kommer anslutningen att använda TLS vid anslutning till servern. Om falskt används TLS om servern stöder STARTTLS-tillägget. I de flesta fall, om du ansluter till port 465, bör du ställa in detta värde till sant. För port 587 eller 25, låt det vara falskt. (från nodemailer.com/smtp/#authentication)",
"LabelEmailSettingsTestAddress": "E-postadress för test", "LabelEmailSettingsTestAddress": "E-postadress för test",
"LabelEmbeddedCover": "Inbäddat bokomslag", "LabelEmbeddedCover": "Infogat omslag",
"LabelEnable": "Aktivera", "LabelEnable": "Aktivera",
"LabelEncodingBackupLocation": "En säkerhetskopia av dina orginalljudfiler kommer att placeras i katalogen:", "LabelEncodingBackupLocation": "En säkerhetskopia av dina orginalljudfiler kommer att placeras i katalogen:",
"LabelEncodingClearItemCache": "Kom ihåg att regelbundet radera cachen för föremål. Du hittar funktionen längst ner på sidan 'Inställningar'.", "LabelEncodingClearItemCache": "Kom ihåg att regelbundet radera cachen för föremål. Du hittar funktionen längst ner på sidan 'Inställningar'.",
@ -310,26 +312,33 @@
"LabelEnd": "Slut", "LabelEnd": "Slut",
"LabelEndOfChapter": "Slut av kapitel", "LabelEndOfChapter": "Slut av kapitel",
"LabelEpisode": "Avsnitt", "LabelEpisode": "Avsnitt",
"LabelEpisodeTitle": "Avsnittsrubrik", "LabelEpisodeNumber": "Avsnitt #{0}",
"LabelEpisodeType": "Avsnittstyp", "LabelEpisodeTitle": "Titel på avsnittet",
"LabelEpisodeType": "Typ av avsnitt",
"LabelEpisodes": "Avsnitt",
"LabelEpisodic": "Uppdelad i avsnitt",
"LabelExample": "Exempel", "LabelExample": "Exempel",
"LabelExpandSeries": "Expandera serier", "LabelExpandSeries": "Expandera serier",
"LabelFeedURL": "Flödes-URL", "LabelExplicit": "Explicit version",
"LabelExplicitChecked": "Explicit version (markerad)",
"LabelExplicitUnchecked": "Ej Explicit version (ej markerad)",
"LabelExportOPML": "Exportera OPML-information",
"LabelFeedURL": "URL-adress för flödet",
"LabelFetchingMetadata": "Hämtar metadata", "LabelFetchingMetadata": "Hämtar metadata",
"LabelFile": "Fil", "LabelFile": "Fil",
"LabelFileBirthtime": "Tidpunkt, filen skapades", "LabelFileBirthtime": "Tidpunkt, fil skapad",
"LabelFileModified": "Tidpunkt, filen ändrades", "LabelFileModified": "Tidpunkt, fil ändrad",
"LabelFileModifiedDate": "Ändrad {0}", "LabelFileModifiedDate": "Ändrad {0}",
"LabelFilename": "Filnamn", "LabelFilename": "Filnamn",
"LabelFilterByUser": "Välj användare", "LabelFilterByUser": "Välj användare",
"LabelFindEpisodes": "Hitta avsnitt", "LabelFindEpisodes": "Sök avsnitt",
"LabelFinished": "Avslutad", "LabelFinished": "Avslutad",
"LabelFolder": "Mapp", "LabelFolder": "Mapp",
"LabelFolders": "Mappar", "LabelFolders": "Mappar",
"LabelFontBold": "Fetstil", "LabelFontBold": "Fetstil",
"LabelFontBoldness": "Fetstil", "LabelFontBoldness": "Fetstil",
"LabelFontFamily": "Typsnittsfamilj", "LabelFontFamily": "Typsnittsfamilj",
"LabelFontItalic": "Kursiverad", "LabelFontItalic": "Kursiv",
"LabelFontScale": "Skala på typsnitt", "LabelFontScale": "Skala på typsnitt",
"LabelFontStrikethrough": "Genomstruken", "LabelFontStrikethrough": "Genomstruken",
"LabelGenre": "Kategori", "LabelGenre": "Kategori",
@ -363,7 +372,7 @@
"LabelLanguage": "Språk", "LabelLanguage": "Språk",
"LabelLanguageDefaultServer": "Standardspråk för server", "LabelLanguageDefaultServer": "Standardspråk för server",
"LabelLanguages": "Språk", "LabelLanguages": "Språk",
"LabelLastBookAdded": "Bok senast tillagd", "LabelLastBookAdded": "Bok senast adderad",
"LabelLastBookUpdated": "Bok senast uppdaterad", "LabelLastBookUpdated": "Bok senast uppdaterad",
"LabelLastSeen": "Senast inloggad", "LabelLastSeen": "Senast inloggad",
"LabelLastTime": "Senaste tillfället", "LabelLastTime": "Senaste tillfället",
@ -378,12 +387,16 @@
"LabelLibraryName": "Biblioteksnamn", "LabelLibraryName": "Biblioteksnamn",
"LabelLimit": "Begränsning", "LabelLimit": "Begränsning",
"LabelLineSpacing": "Radavstånd", "LabelLineSpacing": "Radavstånd",
"LabelListenAgain": "Läs/Lyssna igen", "LabelListenAgain": "Lyssna igen",
"LabelLogLevelDebug": "Felsökning", "LabelLogLevelDebug": "Felsökning",
"LabelLogLevelInfo": "Information", "LabelLogLevelInfo": "Information",
"LabelLogLevelWarn": "Varningar", "LabelLogLevelWarn": "Varningar",
"LabelLookForNewEpisodesAfterDate": "Sök efter nya avsnitt efter detta datum", "LabelLookForNewEpisodesAfterDate": "Sök efter nya avsnitt efter detta datum",
"LabelLowestPriority": "Lägst prioritet", "LabelLowestPriority": "Lägst prioritet",
"LabelMaxEpisodesToDownload": "Maximalt antal avsnitt att ladda ner (0 = obegränsat).",
"LabelMaxEpisodesToDownloadPerCheck": "Maximalt antal nya avsnitt att ladda ner per tillfälle",
"LabelMaxEpisodesToKeep": "Maximalt antal avsnitt att behålla",
"LabelMaxEpisodesToKeepHelp": "'0' innebär obegränsat antal avsnitt. Efter att nya avsnitt laddats ner raderas det äldsta avsnittet om du har mer än maximalt antal avsnitt. Endast ett avsnitt kommer att raderas per tillfälle.",
"LabelMediaPlayer": "Mediaspelare", "LabelMediaPlayer": "Mediaspelare",
"LabelMediaType": "Mediatyp", "LabelMediaType": "Mediatyp",
"LabelMetaTag": "Metadata", "LabelMetaTag": "Metadata",
@ -403,11 +416,11 @@
"LabelNew": "Nytt", "LabelNew": "Nytt",
"LabelNewPassword": "Nytt lösenord", "LabelNewPassword": "Nytt lösenord",
"LabelNewestAuthors": "Senaste författarna", "LabelNewestAuthors": "Senaste författarna",
"LabelNewestEpisodes": "Senast tillagda avsnitt", "LabelNewestEpisodes": "Senast adderade avsnitt",
"LabelNextBackupDate": "Nästa datum för säkerhetskopiering", "LabelNextBackupDate": "Nästa tillfälle för säkerhetskopiering",
"LabelNextScheduledRun": "Nästa schemalagda körning", "LabelNextScheduledRun": "Nästa schemalagda körning",
"LabelNoCustomMetadataProviders": "Ingen egen källa för metadata", "LabelNoCustomMetadataProviders": "Ingen egen källa för metadata",
"LabelNoEpisodesSelected": "Inga avsnitt valda", "LabelNoEpisodesSelected": "Inga avsnitt har valts",
"LabelNotFinished": "Ej avslutad", "LabelNotFinished": "Ej avslutad",
"LabelNotStarted": "Ej påbörjad", "LabelNotStarted": "Ej påbörjad",
"LabelNotes": "Anteckningar", "LabelNotes": "Anteckningar",
@ -429,7 +442,7 @@
"LabelPath": "Sökväg", "LabelPath": "Sökväg",
"LabelPermissionsAccessAllLibraries": "Kan komma åt alla bibliotek", "LabelPermissionsAccessAllLibraries": "Kan komma åt alla bibliotek",
"LabelPermissionsAccessAllTags": "Kan komma åt alla taggar", "LabelPermissionsAccessAllTags": "Kan komma åt alla taggar",
"LabelPermissionsAccessExplicitContent": "Kan komma åt explicit innehåll", "LabelPermissionsAccessExplicitContent": "Kan komma åt explicit version",
"LabelPermissionsCreateEreader": "Kan addera e-läsarenhet", "LabelPermissionsCreateEreader": "Kan addera e-läsarenhet",
"LabelPermissionsDelete": "Kan radera", "LabelPermissionsDelete": "Kan radera",
"LabelPermissionsDownload": "Kan ladda ner", "LabelPermissionsDownload": "Kan ladda ner",
@ -442,7 +455,7 @@
"LabelPlaylists": "Spellistor", "LabelPlaylists": "Spellistor",
"LabelPodcast": "Podcast", "LabelPodcast": "Podcast",
"LabelPodcastSearchRegion": "Podcast-sökområde", "LabelPodcastSearchRegion": "Podcast-sökområde",
"LabelPodcastType": "Podcasttyp", "LabelPodcastType": "Typ av postcast",
"LabelPodcasts": "Podcasts", "LabelPodcasts": "Podcasts",
"LabelPort": "Port", "LabelPort": "Port",
"LabelPrefixesToIgnore": "Prefix att ignorera (skiftlägesokänsligt)", "LabelPrefixesToIgnore": "Prefix att ignorera (skiftlägesokänsligt)",
@ -464,14 +477,15 @@
"LabelRead": "Läst", "LabelRead": "Läst",
"LabelReadAgain": "Läs igen", "LabelReadAgain": "Läs igen",
"LabelReadEbookWithoutProgress": "Läs e-bok utan att behålla framsteg", "LabelReadEbookWithoutProgress": "Läs e-bok utan att behålla framsteg",
"LabelRecentSeries": "Nyaste serierna", "LabelRecentSeries": "Senaste serierna",
"LabelRecentlyAdded": "Nyligen tillagda", "LabelRecentlyAdded": "Nyligen adderade",
"LabelRecommended": "Rekommenderad", "LabelRecommended": "Rekommenderad",
"LabelRedo": "Gör om",
"LabelRegion": "Region", "LabelRegion": "Region",
"LabelReleaseDate": "Utgivningsdatum", "LabelReleaseDate": "Utgivningsdatum",
"LabelRemoveAllMetadataAbs": "Radera alla 'metadata.abs' filer", "LabelRemoveAllMetadataAbs": "Radera alla 'metadata.abs' filer",
"LabelRemoveAllMetadataJson": "Radera alla 'metadata.json' filer", "LabelRemoveAllMetadataJson": "Radera alla 'metadata.json' filer",
"LabelRemoveCover": "Ta bort bokomslag", "LabelRemoveCover": "Ta bort omslag",
"LabelRemoveMetadataFile": "Radera metadata-filer i alla mappar i biblioteket", "LabelRemoveMetadataFile": "Radera metadata-filer i alla mappar i biblioteket",
"LabelRemoveMetadataFileHelp": "Radera alla 'metadata.json' och 'metadata.abs' filer i dina {0} mappar.", "LabelRemoveMetadataFileHelp": "Radera alla 'metadata.json' och 'metadata.abs' filer i dina {0} mappar.",
"LabelRowsPerPage": "Antal rader per sida", "LabelRowsPerPage": "Antal rader per sida",
@ -479,6 +493,7 @@
"LabelSearchTitle": "Titel", "LabelSearchTitle": "Titel",
"LabelSearchTitleOrASIN": "Sök titel eller ASIN-kod", "LabelSearchTitleOrASIN": "Sök titel eller ASIN-kod",
"LabelSeason": "Säsong", "LabelSeason": "Säsong",
"LabelSeasonNumber": "Säsong #{0}",
"LabelSelectAll": "Välj alla", "LabelSelectAll": "Välj alla",
"LabelSelectAllEpisodes": "Välj alla avsnitt", "LabelSelectAllEpisodes": "Välj alla avsnitt",
"LabelSelectEpisodesShowing": "Välj {0} avsnitt som visas", "LabelSelectEpisodesShowing": "Välj {0} avsnitt som visas",
@ -500,16 +515,16 @@
"LabelSettingsDateFormat": "Datumformat", "LabelSettingsDateFormat": "Datumformat",
"LabelSettingsDisableWatcher": "Inaktivera Watcher", "LabelSettingsDisableWatcher": "Inaktivera Watcher",
"LabelSettingsDisableWatcherForLibrary": "Inaktivera bevakning med Watcher för biblioteket", "LabelSettingsDisableWatcherForLibrary": "Inaktivera bevakning med Watcher för biblioteket",
"LabelSettingsDisableWatcherHelp": "Inaktiverar automatik att addera/uppdatera objekt<br>när ändringar av filer genomförs.<br>OBS: Kräver en omstart av servern", "LabelSettingsDisableWatcherHelp": "Inaktiverar automatik att addera/uppdatera<br> objekt när ändringar av filer genomförs.<br>OBS: Kräver en omstart av servern",
"LabelSettingsEnableWatcher": "Aktivera Watcher", "LabelSettingsEnableWatcher": "Aktivera Watcher",
"LabelSettingsEnableWatcherForLibrary": "Aktivera bevakning med Watcher för biblioteket", "LabelSettingsEnableWatcherForLibrary": "Aktivera bevakning med Watcher för biblioteket",
"LabelSettingsEnableWatcherHelp": "Aktiverar automatik att addera/uppdatera objekt<br>när ändringar av filer genomförs.<br>OBS: Kräver en omstart av servern", "LabelSettingsEnableWatcherHelp": "Aktiverar automatik att addera/uppdatera<br> objekt när ändringar av filer genomförs.<br>OBS: Kräver en omstart av servern",
"LabelSettingsEpubsAllowScriptedContent": "Tillåt e-böcker i epubs-format som innehåller script", "LabelSettingsEpubsAllowScriptedContent": "Tillåt e-böcker i epubs-format som innehåller script",
"LabelSettingsEpubsAllowScriptedContentHelp": "Tillåt att epub-filer får använda script.<br>Det rekommenderas att denna inställning är<br>avstängd när du inte litar på källan för epub-filerna.", "LabelSettingsEpubsAllowScriptedContentHelp": "Tillåt att epub-filer får använda script.<br>Det rekommenderas att denna inställning är<br>avstängd när du inte litar på källan för epub-filerna.",
"LabelSettingsExperimentalFeatures": "Experimentella funktioner", "LabelSettingsExperimentalFeatures": "Experimentella funktioner",
"LabelSettingsExperimentalFeaturesHelp": "Funktioner under utveckling som behöver din feedback och hjälp med testning. Klicka för att öppna diskussionen på GitHub.", "LabelSettingsExperimentalFeaturesHelp": "Funktioner under utveckling som behöver din feedback och hjälp med testning. Klicka för att öppna diskussionen på GitHub.",
"LabelSettingsFindCovers": "Hitta ett bokomslag", "LabelSettingsFindCovers": "Hitta ett omslag",
"LabelSettingsFindCoversHelp": "Om din bok inte har ett bokomslag inbäddat i filen eller en fil med bokomslaget i mappen kommer skannern att försöka hitta ett omslag. OBS: Detta kommer att förlänga inläsningstiden", "LabelSettingsFindCoversHelp": "Om din bok INTE har ett omslag inbäddat i filen eller en fil med omslaget i mappen kommer skannern att försöka hitta ett omslag.<br>OBS: Detta kommer att förlänga inläsningstiden",
"LabelSettingsHideSingleBookSeries": "Dölj serier som endast innehåller en bok", "LabelSettingsHideSingleBookSeries": "Dölj serier som endast innehåller en bok",
"LabelSettingsHideSingleBookSeriesHelp": "Serier som endast har en bok kommer att<br>döljas från sidan 'Serier' och hyllorna på startsidan.", "LabelSettingsHideSingleBookSeriesHelp": "Serier som endast har en bok kommer att<br>döljas från sidan 'Serier' och hyllorna på startsidan.",
"LabelSettingsHomePageBookshelfView": "Använd vy liknande en bokhylla på startsidan", "LabelSettingsHomePageBookshelfView": "Använd vy liknande en bokhylla på startsidan",
@ -520,17 +535,17 @@
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Hoppa över tidigare böcker i en serie", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Hoppa över tidigare böcker i en serie",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Sektionen 'Fortsätt med serien' på startsidan visar \"nästa bok\" i serien,<br>där åtminstone en bok avslutats, och ingen bok i serien har påbörjats.<br>Om detta alternativ aktiveras kommer efterföljande bok till den<br>avslutade att föreslås - istället för den första ej avslutade boken i serien.", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Sektionen 'Fortsätt med serien' på startsidan visar \"nästa bok\" i serien,<br>där åtminstone en bok avslutats, och ingen bok i serien har påbörjats.<br>Om detta alternativ aktiveras kommer efterföljande bok till den<br>avslutade att föreslås - istället för den första ej avslutade boken i serien.",
"LabelSettingsParseSubtitles": "Hämta undertitel från bokens mapp", "LabelSettingsParseSubtitles": "Hämta undertitel från bokens mapp",
"LabelSettingsParseSubtitlesHelp": "Hämtar undertiteln från namnet på mappen där boken lagras.<br>Undertiteln måste vara åtskilda med ett bindestreck ' - '.<br>En mapp med namnet 'Boktitel - Bokens undertitel'<br> får undertiteln \"Bokens undertitel\"", "LabelSettingsParseSubtitlesHelp": "Hämtar undertiteln från namnet<br> på mappen där boken lagras.<br>Undertiteln måste vara åtskilda med ett bindestreck ' - '.<br>En mapp med namnet 'Boktitel - Bokens undertitel'<br> får undertiteln \"Bokens undertitel\"",
"LabelSettingsPreferMatchedMetadata": "Prioritera matchad metadata", "LabelSettingsPreferMatchedMetadata": "Prioritera matchad metadata",
"LabelSettingsPreferMatchedMetadataHelp": "Matchad data kommer att åsidosätta objektdetaljer vid snabbmatchning. Som standard kommer snabbmatchning endast att fylla i saknade detaljer.", "LabelSettingsPreferMatchedMetadataHelp": "Matchad data kommer att ersätta befintliga uppgifter vid en snabbmatchning. Som standard kommer en snabbmatchning endast att fylla i saknade detaljer.",
"LabelSettingsSkipMatchingBooksWithASIN": "Hoppa över matchande böcker som har en ASIN-kod", "LabelSettingsSkipMatchingBooksWithASIN": "Hoppa över matchande böcker som har en ASIN-kod",
"LabelSettingsSkipMatchingBooksWithISBN": "Hoppa över matchande böcker som har en ISBN-kod", "LabelSettingsSkipMatchingBooksWithISBN": "Hoppa över matchande böcker som har en ISBN-kod",
"LabelSettingsSortingIgnorePrefixes": "Ignorera prefix vid sortering", "LabelSettingsSortingIgnorePrefixes": "Ignorera prefix vid sortering",
"LabelSettingsSortingIgnorePrefixesHelp": "För prefix som t.ex. \"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 kvadratiska bokomslag", "LabelSettingsSquareBookCovers": "Använd kvadratiska omslag",
"LabelSettingsSquareBookCoversHelp": "Föredrar att använda kvadratiska bokomslag<br>före standardformatet 1.6:1", "LabelSettingsSquareBookCoversHelp": "Föredrar att använda kvadratiska omslag<br>före standardformatet 1.6:1",
"LabelSettingsStoreCoversWithItem": "Lagra bokomslag med objektet", "LabelSettingsStoreCoversWithItem": "Lagra omslag med objektet",
"LabelSettingsStoreCoversWithItemHelp": "Som standard lagras bokomslag i mappen '/metadata/items'.<br>Genom att aktivera detta alternativ kommer<br>omslagen att lagra i din biblioteksmapp.<br>Endast en fil med namnet 'cover' kommer att behållas", "LabelSettingsStoreCoversWithItemHelp": "Som standard lagras omslag i mappen '/metadata/items'.<br>Genom att aktivera detta alternativ kommer<br>omslagen att lagra i din biblioteksmapp.<br>Endast en fil med namnet 'cover' kommer att behållas",
"LabelSettingsStoreMetadataWithItem": "Lagra metadata med objektet", "LabelSettingsStoreMetadataWithItem": "Lagra metadata med objektet",
"LabelSettingsStoreMetadataWithItemHelp": "Som standard lagras metadatafiler i mappen '/metadata/items'. Genom att aktivera detta alternativ kommer metadatafilerna att lagra i din biblioteksmapp", "LabelSettingsStoreMetadataWithItemHelp": "Som standard lagras metadatafiler i mappen '/metadata/items'. Genom att aktivera detta alternativ kommer metadatafilerna att lagra i din biblioteksmapp",
"LabelSettingsTimeFormat": "Tidsformat", "LabelSettingsTimeFormat": "Tidsformat",
@ -569,6 +584,7 @@
"LabelTagsNotAccessibleToUser": "Taggar inte tillgängliga för användaren", "LabelTagsNotAccessibleToUser": "Taggar inte tillgängliga för användaren",
"LabelTasks": "Pågående aktivitet", "LabelTasks": "Pågående aktivitet",
"LabelTextEditorBulletedList": "Punktlista", "LabelTextEditorBulletedList": "Punktlista",
"LabelTextEditorLink": "Länk",
"LabelTextEditorNumberedList": "Numrerad lista", "LabelTextEditorNumberedList": "Numrerad lista",
"LabelTheme": "Utseende", "LabelTheme": "Utseende",
"LabelThemeDark": "Mörkt", "LabelThemeDark": "Mörkt",
@ -604,8 +620,8 @@
"LabelUndo": "Ångra", "LabelUndo": "Ångra",
"LabelUnknown": "Okänd", "LabelUnknown": "Okänd",
"LabelUnknownPublishDate": "Okänt publiceringsdatum", "LabelUnknownPublishDate": "Okänt publiceringsdatum",
"LabelUpdateCover": "Uppdatera bokomslag", "LabelUpdateCover": "Uppdatera omslag",
"LabelUpdateCoverHelp": "Tillåt att befintliga bokomslag för de valda böckerna ersätts när en matchning hittas", "LabelUpdateCoverHelp": "Tillåt att befintliga omslag för de valda böckerna ersätts när en matchning hittas",
"LabelUpdateDetails": "Uppdatera detaljer", "LabelUpdateDetails": "Uppdatera detaljer",
"LabelUpdateDetailsHelp": "Tillåt att befintliga detaljer för de valda böckerna ersätts när en matchning hittas", "LabelUpdateDetailsHelp": "Tillåt att befintliga detaljer för de valda böckerna ersätts när en matchning hittas",
"LabelUpdatedAt": "Uppdaterades", "LabelUpdatedAt": "Uppdaterades",
@ -637,12 +653,15 @@
"LabelYourProgress": "Framsteg", "LabelYourProgress": "Framsteg",
"MessageAddToPlayerQueue": "Lägg till i spellistan", "MessageAddToPlayerQueue": "Lägg till i spellistan",
"MessageAppriseDescription": "För att använda den här funktionen behöver du ha en instans av <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> igång eller en API som hanterar dessa begäranden. <br />Apprise API-urlen bör vara hela URL-sökvägen för att skicka meddelandet, t.ex., om din API-instans är tillgänglig på <code>http://192.168.1.1:8337</code>, bör du ange <code>http://192.168.1.1:8337/notify</code>.", "MessageAppriseDescription": "För att använda den här funktionen behöver du ha en instans av <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> igång eller en API som hanterar dessa begäranden. <br />Apprise API-urlen bör vara hela URL-sökvägen för att skicka meddelandet, t.ex., om din API-instans är tillgänglig på <code>http://192.168.1.1:8337</code>, bör du ange <code>http://192.168.1.1:8337/notify</code>.",
"MessageBackupsDescription": "Säkerhetskopior inkluderar användare, användarnas framsteg, biblioteksobjekt, serverinställningar<br>och bilder lagrade i <code>/metadata/items</code> & <code>/metadata/authors</code>.<br>De inkluderar <strong>INTE</strong> några filer lagrade i dina biblioteksmappar.", "MessageBackupsDescription": "Säkerhetskopior inkluderar användare, användarnas framsteg, biblioteksobjekt,<br>serverinställningar och bilder lagrade i <code>/metadata/items</code> & <code>/metadata/authors</code>.<br>De inkluderar <strong>INTE</strong> några filer lagrade i dina biblioteksmappar.",
"MessageBackupsLocationEditNote": "OBS: När du ändrar plats för säkerhetskopiorna så flyttas INTE gamla säkerhetskopior dit.", "MessageBackupsLocationEditNote": "OBS: När du ändrar plats för säkerhetskopiorna så flyttas INTE gamla säkerhetskopior dit.",
"MessageBackupsLocationNoEditNote": "OBS: Platsen där säkerhetskopiorna lagras bestäms av en central inställning och kan inte ändras här.", "MessageBackupsLocationNoEditNote": "OBS: Platsen där säkerhetskopiorna lagras bestäms av en central inställning och kan inte ändras här.",
"MessageBackupsLocationPathEmpty": "Uppgiften om platsen för lagring av säkerhetskopior kan inte lämnas tom", "MessageBackupsLocationPathEmpty": "Uppgiften om platsen för lagring av säkerhetskopior kan inte lämnas tom",
"MessageBatchEditPopulateMapDetailsAllHelp": "Adderar information från alla objekt nedan i de fält som aktiverats. Om fälten innehåller olika uppgifter kommer informationen att slås samman.",
"MessageBatchEditPopulateMapDetailsItemHelp": "Addera information från detta objekt i aktiva fält ovan",
"MessageBatchQuickMatchDescription": "Quick Match kommer försöka lägga till saknade omslag och metadata för de valda föremålen. Aktivera alternativen nedan för att tillåta Quick Match att överskriva befintliga omslag och/eller metadata.", "MessageBatchQuickMatchDescription": "Quick Match kommer försöka lägga till saknade omslag och metadata för de valda föremålen. Aktivera alternativen nedan för att tillåta Quick Match att överskriva befintliga omslag och/eller metadata.",
"MessageBookshelfNoCollections": "Du har ännu inte skapat några samlingar", "MessageBookshelfNoCollections": "Du har ännu inte skapat några samlingar",
"MessageBookshelfNoCollectionsHelp": "Samlingar är privata. Endast den användare som skapat en samling kan se den.",
"MessageBookshelfNoRSSFeeds": "Inga RSS-flöden är öppna", "MessageBookshelfNoRSSFeeds": "Inga RSS-flöden är öppna",
"MessageBookshelfNoResultsForFilter": "Inga resultat för filter \"{0}: {1}\"", "MessageBookshelfNoResultsForFilter": "Inga resultat för filter \"{0}: {1}\"",
"MessageBookshelfNoResultsForQuery": "Sökningen gav inget resultat", "MessageBookshelfNoResultsForQuery": "Sökningen gav inget resultat",
@ -674,12 +693,12 @@
"MessageConfirmPurgeCache": "När du rensar cashen kommer katalogen <code>/metadata/cache</code> att raderas. <br /><br />Är du säker på att du vill radera katalogen?", "MessageConfirmPurgeCache": "När du rensar cashen kommer katalogen <code>/metadata/cache</code> att raderas. <br /><br />Är du säker på att du vill radera katalogen?",
"MessageConfirmPurgeItemsCache": "När du rensar cashen för föremål kommer katalogen <code>/metadata/cache/items</code> att raderas. <br /><br />Är du säker på att du vill radera katalogen?", "MessageConfirmPurgeItemsCache": "När du rensar cashen för föremål kommer katalogen <code>/metadata/cache/items</code> att raderas. <br /><br />Är du säker på att du vill radera katalogen?",
"MessageConfirmQuickEmbed": "VARNING! Quick embed kommer inte att säkerhetskopiera dina ljudfiler. Se till att du har en säkerhetskopia av dina ljudfiler. <br><br>Vill du fortsätta?", "MessageConfirmQuickEmbed": "VARNING! Quick embed kommer inte att säkerhetskopiera dina ljudfiler. Se till att du har en säkerhetskopia av dina ljudfiler. <br><br>Vill du fortsätta?",
"MessageConfirmReScanLibraryItems": "Är du säker på att du vill göra en ny genomsökning för {0} objekt?", "MessageConfirmReScanLibraryItems": "Är du säker på att du vill göra en ny skanning för {0} objekt?",
"MessageConfirmRemoveAllChapters": "Är du säker på att du vill ta bort alla kapitel?", "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}\"?", "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}\"?", "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}\"?", "MessageConfirmRemoveEpisode": "Är du säker på att du vill radera avsnittet \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Är du säker på att du vill ta bort {0} avsnitt?", "MessageConfirmRemoveEpisodes": "Är du säker på att du vill radera {0} avsnitt?",
"MessageConfirmRemoveListeningSessions": "Är du säker på att du vill radera {0} lyssningstillfällen?", "MessageConfirmRemoveListeningSessions": "Är du säker på att du vill radera {0} lyssningstillfällen?",
"MessageConfirmRemoveMetadataFiles": "Är du säker på att du vill radera filerna 'metadata.{0}' i alla mappar i ditt bibliotek?", "MessageConfirmRemoveMetadataFiles": "Är du säker på att du vill radera filerna 'metadata.{0}' i alla mappar i ditt bibliotek?",
"MessageConfirmRemoveNarrator": "Är du säker på att du vill ta bort uppläsaren \"{0}\"?", "MessageConfirmRemoveNarrator": "Är du säker på att du vill ta bort uppläsaren \"{0}\"?",
@ -697,7 +716,7 @@
"MessageDragFilesIntoTrackOrder": "Dra filer till rätt spårordning", "MessageDragFilesIntoTrackOrder": "Dra filer till rätt spårordning",
"MessageEmbedFinished": "Inbäddning genomförd!", "MessageEmbedFinished": "Inbäddning genomförd!",
"MessageEpisodesQueuedForDownload": "{0} avsnitt i kö för nedladdning", "MessageEpisodesQueuedForDownload": "{0} avsnitt i kö för nedladdning",
"MessageEreaderDevices": "För att säkerställa överföring av e-böcker kan du bli tvungen<br>att addera ovanstående e-postadress som godkänd<br>avsändare för varje enhet angiven nedan.", "MessageEreaderDevices": "För att säkerställa överföring av e-böcker kan du bli tvungen<br>att addera ovanstående e-postadress som godkänd avsändare<br>för varje enhet angiven nedan.",
"MessageFeedURLWillBe": "Flödes-URL kommer att vara {0}", "MessageFeedURLWillBe": "Flödes-URL kommer att vara {0}",
"MessageFetching": "Hämtar...", "MessageFetching": "Hämtar...",
"MessageForceReScanDescription": "kommer att göra en omgångssökning av alla filer som en färsk sökning. ID3-taggar för ljudfiler, OPF-filer och textfiler kommer att sökas som nya.", "MessageForceReScanDescription": "kommer att göra en omgångssökning av alla filer som en färsk sökning. ID3-taggar för ljudfiler, OPF-filer och textfiler kommer att sökas som nya.",
@ -716,19 +735,19 @@
"MessageMarkAllEpisodesNotFinished": "Markera alla avsnitt som ej avslutade", "MessageMarkAllEpisodesNotFinished": "Markera alla avsnitt som ej avslutade",
"MessageMarkAsFinished": "Markera som avslutad", "MessageMarkAsFinished": "Markera som avslutad",
"MessageMarkAsNotFinished": "Markera som ej avslutad", "MessageMarkAsNotFinished": "Markera som ej avslutad",
"MessageMatchBooksDescription": "kommer att försöka matcha böcker i biblioteket med en bok från<br>den valda källan och fylla i uppgifter som saknas och bokomslag.<br>Inga befintliga uppgifter kommer att ersättas.", "MessageMatchBooksDescription": "kommer att försöka matcha böcker i biblioteket med en bok från<br/>den valda källan och fylla i uppgifter som saknas och omslag.<br/>Inga befintliga uppgifter kommer att ersättas.",
"MessageNoAudioTracks": "Inga ljudspår har hittats", "MessageNoAudioTracks": "Inga ljudspår har hittats",
"MessageNoAuthors": "Inga författare", "MessageNoAuthors": "Inga författare",
"MessageNoBackups": "Inga säkerhetskopior", "MessageNoBackups": "Inga säkerhetskopior",
"MessageNoBookmarks": "Inga bokmärken", "MessageNoBookmarks": "Inga bokmärken",
"MessageNoChapters": "Inga kapitel", "MessageNoChapters": "Inga kapitel",
"MessageNoCollections": "Inga samlingar", "MessageNoCollections": "Inga samlingar",
"MessageNoCoversFound": "Inga bokomslag hittades", "MessageNoCoversFound": "Inga omslag hittades",
"MessageNoDescription": "Ingen beskrivning", "MessageNoDescription": "Ingen beskrivning",
"MessageNoDevices": "Inga enheter angivna", "MessageNoDevices": "Inga enheter angivna",
"MessageNoDownloadsInProgress": "Inga nedladdningar pågår för närvarande", "MessageNoDownloadsInProgress": "Inga nedladdningar pågår för närvarande",
"MessageNoDownloadsQueued": "Inga nedladdningar i kö", "MessageNoDownloadsQueued": "Inga nedladdningar i kö",
"MessageNoEpisodeMatchesFound": "Inga matchande avsnitt hittades", "MessageNoEpisodeMatchesFound": "Inga matchande avsnitt kunde hittas",
"MessageNoEpisodes": "Inga avsnitt", "MessageNoEpisodes": "Inga avsnitt",
"MessageNoFoldersAvailable": "Inga mappar tillgängliga", "MessageNoFoldersAvailable": "Inga mappar tillgängliga",
"MessageNoGenres": "Inga kategorier", "MessageNoGenres": "Inga kategorier",
@ -747,6 +766,7 @@
"MessageNoTasksRunning": "Inga pågående uppgifter", "MessageNoTasksRunning": "Inga pågående uppgifter",
"MessageNoUpdatesWereNecessary": "Inga uppdateringar var nödvändiga", "MessageNoUpdatesWereNecessary": "Inga uppdateringar var nödvändiga",
"MessageNoUserPlaylists": "Du har inga spellistor", "MessageNoUserPlaylists": "Du har inga spellistor",
"MessageNoUserPlaylistsHelp": "Spellistor är privata. Endast den användare som skapat listan kan se den.",
"MessageNotYetImplemented": "Ännu inte implementerad", "MessageNotYetImplemented": "Ännu inte implementerad",
"MessageOr": "eller", "MessageOr": "eller",
"MessagePauseChapter": "Pausa kapiteluppspelning", "MessagePauseChapter": "Pausa kapiteluppspelning",
@ -754,9 +774,11 @@
"MessagePlaylistCreateFromCollection": "Skapa en spellista från samlingen", "MessagePlaylistCreateFromCollection": "Skapa en spellista från samlingen",
"MessagePleaseWait": "Vänta ett ögonblick...", "MessagePleaseWait": "Vänta ett ögonblick...",
"MessagePodcastHasNoRSSFeedForMatching": "Podcasten har ingen RSS-flödes-URL att använda för matchning", "MessagePodcastHasNoRSSFeedForMatching": "Podcasten har ingen RSS-flödes-URL att använda för matchning",
"MessagePodcastSearchField": "Skriv sökfrågan eller URL-adressen för RSS-flödet",
"MessageQuickMatchAllEpisodes": "Snabbmatchning av alla avsnitt",
"MessageQuickMatchDescription": "Adderar uppgifter som saknas samt en omslagsbild från<br>första träffen i resultatet vid sökningen från '{0}'.<br>Skriver inte över befintliga uppgifter om inte<br>inställningen 'Prioritera matchad metadata' är aktiverad.", "MessageQuickMatchDescription": "Adderar uppgifter som saknas samt en omslagsbild från<br>första träffen i resultatet vid sökningen från '{0}'.<br>Skriver inte över befintliga uppgifter om inte<br>inställningen 'Prioritera matchad metadata' är aktiverad.",
"MessageRemoveChapter": "Ta bort kapitel", "MessageRemoveChapter": "Ta bort kapitel",
"MessageRemoveEpisodes": "Ta bort {0} avsnitt", "MessageRemoveEpisodes": "Radera {0} avsnitt",
"MessageRemoveFromPlayerQueue": "Ta bort från spellistan", "MessageRemoveFromPlayerQueue": "Ta bort från spellistan",
"MessageRemoveUserWarning": "Är du säker på att du vill radera användaren \"{0}\"?", "MessageRemoveUserWarning": "Är du säker på att du vill radera användaren \"{0}\"?",
"MessageReportBugsAndContribute": "Rapportera buggar, begär funktioner och bidra på", "MessageReportBugsAndContribute": "Rapportera buggar, begär funktioner och bidra på",
@ -770,11 +792,23 @@
"MessageSetChaptersFromTracksDescription": "Ställ in kapitel med varje ljudfil som ett kapitel och kapitelrubrik som ljudfilens namn", "MessageSetChaptersFromTracksDescription": "Ställ in kapitel med varje ljudfil som ett kapitel och kapitelrubrik som ljudfilens namn",
"MessageStartPlaybackAtTime": "Starta uppspelning av \"{0}\" vid tidpunkt {1}?", "MessageStartPlaybackAtTime": "Starta uppspelning av \"{0}\" vid tidpunkt {1}?",
"MessageTaskCanceledByUser": "Uppgiften avslutades av användaren", "MessageTaskCanceledByUser": "Uppgiften avslutades av användaren",
"MessageTaskDownloadingEpisodeDescription": "Laddar ner avsnitt \"{0}\"",
"MessageTaskEmbeddingMetadata": "Infogar metadata",
"MessageTaskEmbeddingMetadataDescription": "Infogar metadata i ljudboken \"{0}\"",
"MessageTaskEncodingM4bDescription": "Omkodning av ljudbok \"{0}\" till en M4B-fil", "MessageTaskEncodingM4bDescription": "Omkodning av ljudbok \"{0}\" till en M4B-fil",
"MessageTaskFailed": "Misslyckades", "MessageTaskFailed": "Misslyckades",
"MessageTaskFailedToCreateCacheDirectory": "Misslyckades med att skapa bibliotek för cachen", "MessageTaskFailedToCreateCacheDirectory": "Misslyckades med att skapa bibliotek för cachen",
"MessageTaskFailedToEmbedMetadataInFile": "Misslyckades med att infoga metadata i \"{0}\"",
"MessageTaskFailedToMergeAudioFiles": "Misslyckades med att sammanfoga ljudfilerna",
"MessageTaskFailedToMoveM4bFile": "Misslyckades med att flytta M4B-filen",
"MessageTaskFailedToWriteMetadataFile": "Misslyckades med att skapa filen med metadata",
"MessageTaskMatchingBooksInLibrary": "Matchar böcker i biblioteket \"{0}\"", "MessageTaskMatchingBooksInLibrary": "Matchar böcker i biblioteket \"{0}\"",
"MessageTaskOpmlImportFinished": "Adderade {0} podcasts",
"MessageTaskOpmlParseFailed": "Misslyckades att tolka OPML-filen",
"MessageTaskOpmlParseFastFail": "Felaktig OPML-fil. Ingen <opml> tag eller <outline> tag finns i filen",
"MessageTaskOpmlParseNoneFound": "Inget flöde finns angivet i OPML-filen",
"MessageTaskScanItemsAdded": "{0} adderades", "MessageTaskScanItemsAdded": "{0} adderades",
"MessageTaskScanItemsMissing": "{0} saknades",
"MessageTaskScanItemsUpdated": "{0} uppdaterades", "MessageTaskScanItemsUpdated": "{0} uppdaterades",
"MessageTaskScanNoChangesNeeded": "Inget adderades eller uppdaterades", "MessageTaskScanNoChangesNeeded": "Inget adderades eller uppdaterades",
"MessageTaskScanningLibrary": "Biblioteket \"{0}\" har skannats", "MessageTaskScanningLibrary": "Biblioteket \"{0}\" har skannats",
@ -791,7 +825,7 @@
"NoteChapterEditorTimes": "OBS: Starttiden för första kapitlet måste vara 0:00 och starttiden för det sista kapitlet får inte överstiga ljudbokens totala varaktighet.", "NoteChapterEditorTimes": "OBS: Starttiden för första kapitlet måste vara 0:00 och starttiden för det sista kapitlet får inte överstiga ljudbokens totala varaktighet.",
"NoteFolderPicker": "OBS: Mappar som redan är kopplade kommer inte att visas", "NoteFolderPicker": "OBS: Mappar som redan är kopplade kommer inte att visas",
"NoteRSSFeedPodcastAppsHttps": "VARNING: De flesta applikationer för podcasts kräver att URL:en för RSS-flödet använder HTTPS", "NoteRSSFeedPodcastAppsHttps": "VARNING: De flesta applikationer för podcasts kräver att URL:en för RSS-flödet använder HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "VARNING: Ett eller flera av dina avsnitt har inte ett publiceringsdatum. Vissa applikationer för podcasts kräver detta.", "NoteRSSFeedPodcastAppsPubDate": "VARNING: Ett eller flera av dina avsnitt saknar publiceringsdatum. Vissa applikationer för podcasts kräver detta.",
"NoteUploaderFoldersWithMediaFiles": "Mappar som innehåller mediefiler hanteras som separata objekt i biblioteket.", "NoteUploaderFoldersWithMediaFiles": "Mappar som innehåller mediefiler hanteras som separata objekt i biblioteket.",
"NoteUploaderOnlyAudioFiles": "Om du bara laddar upp ljudfiler kommer varje ljudfil att hanteras som en separat ljudbok.", "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.", "NoteUploaderUnsupportedFiles": "Oaccepterade filer ignoreras. När du väljer eller släpper en mapp ignoreras andra filer som inte finns i ett objektmapp.",
@ -845,10 +879,12 @@
"ToastCachePurgeSuccess": "Rensning av cachen har genomförts", "ToastCachePurgeSuccess": "Rensning av cachen har genomförts",
"ToastChaptersHaveErrors": "Kapitlen har fel", "ToastChaptersHaveErrors": "Kapitlen har fel",
"ToastChaptersMustHaveTitles": "Kapitel måste ha titlar", "ToastChaptersMustHaveTitles": "Kapitel måste ha titlar",
"ToastChaptersRemoved": "Kapitlen har raderats",
"ToastChaptersUpdated": "Kapitlen har uppdaterats",
"ToastCollectionItemsAddFailed": "Misslyckades med att addera böcker till samlingen", "ToastCollectionItemsAddFailed": "Misslyckades med att addera böcker till samlingen",
"ToastCollectionRemoveSuccess": "Samlingen har raderats", "ToastCollectionRemoveSuccess": "Samlingen har raderats",
"ToastCollectionUpdateSuccess": "Samlingen har uppdaterats", "ToastCollectionUpdateSuccess": "Samlingen har uppdaterats",
"ToastCoverUpdateFailed": "Uppdatering av bokomslag misslyckades", "ToastCoverUpdateFailed": "Uppdatering av omslag misslyckades",
"ToastDateTimeInvalidOrIncomplete": "Datum och klockslag är felaktigt eller ej komplett", "ToastDateTimeInvalidOrIncomplete": "Datum och klockslag är felaktigt eller ej komplett",
"ToastDeleteFileFailed": "Misslyckades att radera filen", "ToastDeleteFileFailed": "Misslyckades att radera filen",
"ToastDeleteFileSuccess": "Filen har raderats", "ToastDeleteFileSuccess": "Filen har raderats",
@ -856,19 +892,23 @@
"ToastDeviceTestEmailSuccess": "Ett testmail har skickats", "ToastDeviceTestEmailSuccess": "Ett testmail har skickats",
"ToastEmailSettingsUpdateSuccess": "Inställningarna av e-post har uppdaterats", "ToastEmailSettingsUpdateSuccess": "Inställningarna av e-post har uppdaterats",
"ToastEncodeCancelSucces": "Omkodningen avbruten", "ToastEncodeCancelSucces": "Omkodningen avbruten",
"ToastEpisodeDownloadQueueClearFailed": "Misslyckades med att tömma kön",
"ToastEpisodeDownloadQueueClearSuccess": "Kö för nedladdning av avsnitt har tömts",
"ToastEpisodeUpdateSuccess": "{0} avsnitt uppdaterades",
"ToastFailedToLoadData": "Misslyckades med att ladda data", "ToastFailedToLoadData": "Misslyckades med att ladda data",
"ToastFailedToUpdate": "Misslyckades med att uppdatera", "ToastFailedToUpdate": "Misslyckades med att uppdatera",
"ToastInvalidImageUrl": "Felaktig URL-adress till omslagsbilden", "ToastInvalidImageUrl": "Felaktig URL-adress till omslagsbilden",
"ToastInvalidMaxEpisodesToDownload": "Ogiltigt maximalt antal avsnitt att ladda ner", "ToastInvalidMaxEpisodesToDownload": "Ogiltigt maximalt antal avsnitt att ladda ner",
"ToastInvalidUrl": "Felaktig URL-adress", "ToastInvalidUrl": "Felaktig URL-adress",
"ToastItemCoverUpdateSuccess": "Objektets bokomslag har uppdaterats", "ToastItemCoverUpdateSuccess": "Objektets omslag har uppdaterats",
"ToastItemDeletedFailed": "Misslyckades med att radera objektet", "ToastItemDeletedFailed": "Misslyckades med att radera objektet",
"ToastItemDeletedSuccess": "Objektet har raderats", "ToastItemDeletedSuccess": "Objektet har raderats",
"ToastItemDetailsUpdateSuccess": "Detaljerna om boken har uppdaterats", "ToastItemDetailsUpdateSuccess": "Informationen om objektet har uppdaterats",
"ToastItemMarkedAsFinishedFailed": "Misslyckades med att markera den som avslutad", "ToastItemMarkedAsFinishedFailed": "Misslyckades med att markera den som avslutad",
"ToastItemMarkedAsFinishedSuccess": "Den har markerat som avslutad", "ToastItemMarkedAsFinishedSuccess": "Den har markerat som avslutad",
"ToastItemMarkedAsNotFinishedFailed": "Misslyckades med att markera den som ej avslutad", "ToastItemMarkedAsNotFinishedFailed": "Misslyckades med att markera den som ej avslutad",
"ToastItemMarkedAsNotFinishedSuccess": "Den har markerats som ej avslutad", "ToastItemMarkedAsNotFinishedSuccess": "Den har markerats som ej avslutad",
"ToastItemUpdateSuccess": "Objektet har uppdaterats",
"ToastLibraryCreateFailed": "Det gick inte att skapa biblioteket", "ToastLibraryCreateFailed": "Det gick inte att skapa biblioteket",
"ToastLibraryCreateSuccess": "Biblioteket \"{0}\" har skapats", "ToastLibraryCreateSuccess": "Biblioteket \"{0}\" har skapats",
"ToastLibraryDeleteFailed": "Det gick inte att ta bort biblioteket", "ToastLibraryDeleteFailed": "Det gick inte att ta bort biblioteket",
@ -876,26 +916,32 @@
"ToastLibraryScanFailedToStart": "Misslyckades med att starta skanningen", "ToastLibraryScanFailedToStart": "Misslyckades med att starta skanningen",
"ToastLibraryScanStarted": "Skanning av biblioteket påbörjad", "ToastLibraryScanStarted": "Skanning av biblioteket påbörjad",
"ToastLibraryUpdateSuccess": "Biblioteket \"{0}\" har uppdaterats", "ToastLibraryUpdateSuccess": "Biblioteket \"{0}\" har uppdaterats",
"ToastMatchAllAuthorsFailed": "Misslyckades med att matcha alla författare",
"ToastMetadataFilesRemovedError": "Misslyckades med att radera 'metadata.{0}' filerna", "ToastMetadataFilesRemovedError": "Misslyckades med att radera 'metadata.{0}' filerna",
"ToastMetadataFilesRemovedNoneFound": "Inga 'metadata.{0}' filer hittades i biblioteket", "ToastMetadataFilesRemovedNoneFound": "Inga 'metadata.{0}' filer hittades i biblioteket",
"ToastMetadataFilesRemovedNoneRemoved": "Inga 'metadata.{0}' filer raderades", "ToastMetadataFilesRemovedNoneRemoved": "Inga 'metadata.{0}' filer raderades",
"ToastMetadataFilesRemovedSuccess": "{0} 'metadata.{1}' raderades", "ToastMetadataFilesRemovedSuccess": "{0} 'metadata.{1}' raderades",
"ToastNameEmailRequired": "Ett namn och en e-postadress måste anges", "ToastNameEmailRequired": "Ett namn och en e-postadress måste anges",
"ToastNameRequired": "Ett namn måste anges", "ToastNameRequired": "Ett namn måste anges",
"ToastNewEpisodesFound": "Hittade {0} nya avsnitt",
"ToastNewUserCreatedFailed": "Misslyckades med att skapa kontot \"{0}\"", "ToastNewUserCreatedFailed": "Misslyckades med att skapa kontot \"{0}\"",
"ToastNewUserCreatedSuccess": "Ett nytt konto har skapats", "ToastNewUserCreatedSuccess": "Ett nytt konto har skapats",
"ToastNewUserLibraryError": "Minst ett bibliotek måste anges", "ToastNewUserLibraryError": "Minst ett bibliotek måste anges",
"ToastNewUserPasswordError": "Ett lösenord måste anges. Endast användaren 'root' kan vara utan lösenord.", "ToastNewUserPasswordError": "Ett lösenord måste anges. Endast användaren 'root' kan vara utan lösenord.",
"ToastNewUserTagError": "Minst en tagg måste läggas till",
"ToastNewUserUsernameError": "Ange ett användarnamn", "ToastNewUserUsernameError": "Ange ett användarnamn",
"ToastNoNewEpisodesFound": "Inga nya avsnitt kunde hittas",
"ToastNoUpdatesNecessary": "Inga uppdateringar var nödvändiga", "ToastNoUpdatesNecessary": "Inga uppdateringar var nödvändiga",
"ToastNotificationCreateFailed": "Misslyckades med att skapa meddelandet", "ToastNotificationCreateFailed": "Misslyckades med att skapa meddelandet",
"ToastNotificationDeleteFailed": "Misslyckades med att radera meddelandet", "ToastNotificationDeleteFailed": "Misslyckades med att radera meddelandet",
"ToastNotificationUpdateSuccess": "Meddelandet har uppdaterats",
"ToastPlaylistCreateFailed": "Det gick inte att skapa spellistan", "ToastPlaylistCreateFailed": "Det gick inte att skapa spellistan",
"ToastPlaylistCreateSuccess": "Spellistan skapad", "ToastPlaylistCreateSuccess": "Spellistan skapad",
"ToastPlaylistRemoveSuccess": "Spellistan har tagits bort", "ToastPlaylistRemoveSuccess": "Spellistan har tagits bort",
"ToastPlaylistUpdateSuccess": "Spellistan uppdaterad", "ToastPlaylistUpdateSuccess": "Spellistan har uppdaterats",
"ToastPodcastCreateFailed": "Misslyckades med att skapa podcasten", "ToastPodcastCreateFailed": "Misslyckades med att skapa podcasten",
"ToastPodcastCreateSuccess": "Podcasten skapad framgångsrikt", "ToastPodcastCreateSuccess": "Podcasten skapades framgångsrikt",
"ToastPodcastNoEpisodesInFeed": "Inga avsnitt finns i RSS-flödet",
"ToastProviderCreatedFailed": "Misslyckades med att addera en källa", "ToastProviderCreatedFailed": "Misslyckades med att addera en källa",
"ToastProviderCreatedSuccess": "En ny källa har adderats", "ToastProviderCreatedSuccess": "En ny källa har adderats",
"ToastProviderNameAndUrlRequired": "Ett namn och en URL-adress krävs", "ToastProviderNameAndUrlRequired": "Ett namn och en URL-adress krävs",
@ -905,7 +951,14 @@
"ToastRemoveFailed": "Misslyckades med att radera", "ToastRemoveFailed": "Misslyckades med att radera",
"ToastRemoveItemFromCollectionFailed": "Misslyckades med att ta bort objektet från samlingen", "ToastRemoveItemFromCollectionFailed": "Misslyckades med att ta bort objektet från samlingen",
"ToastRemoveItemFromCollectionSuccess": "Objektet borttaget från samlingen", "ToastRemoveItemFromCollectionSuccess": "Objektet borttaget från samlingen",
"ToastRemoveItemsWithIssuesFailed": "Misslyckades med att radera objekt med problem",
"ToastRemoveItemsWithIssuesSuccess": "Raderade objekt med problem",
"ToastRenameFailed": "Misslyckades med att ändra namn", "ToastRenameFailed": "Misslyckades med att ändra namn",
"ToastRescanFailed": "Skanningen misslyckades för {0}",
"ToastRescanRemoved": "Skanningen har genomförts - objektet har raderats",
"ToastRescanUpToDate": "Skanningen har genomförts - objektet behövde inte uppdateras",
"ToastRescanUpdated": "Skanningen har genomförts - objektet har uppdaterats",
"ToastScanFailed": "Misslyckades med att skanna biblioteket",
"ToastSelectAtLeastOneUser": "Åtminstone en användare måste väljas", "ToastSelectAtLeastOneUser": "Åtminstone en användare måste väljas",
"ToastSendEbookToDeviceFailed": "Misslyckades med att skicka e-boken till enheten", "ToastSendEbookToDeviceFailed": "Misslyckades med att skicka e-boken till enheten",
"ToastSendEbookToDeviceSuccess": "E-boken skickad till enheten \"{0}\"", "ToastSendEbookToDeviceSuccess": "E-boken skickad till enheten \"{0}\"",

View File

@ -29,7 +29,7 @@ if (isDev) {
if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1' if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1'
if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath
process.env.SOURCE = 'local' process.env.SOURCE = 'local'
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || '' process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath ?? '/audiobookshelf'
} }
const inputConfig = options.config ? Path.resolve(options.config) : null const inputConfig = options.config ? Path.resolve(options.config) : null
@ -41,7 +41,7 @@ const CONFIG_PATH = inputConfig || process.env.CONFIG_PATH || Path.resolve('conf
const METADATA_PATH = inputMetadata || process.env.METADATA_PATH || Path.resolve('metadata') const METADATA_PATH = inputMetadata || process.env.METADATA_PATH || Path.resolve('metadata')
const SOURCE = options.source || process.env.SOURCE || 'debian' const SOURCE = options.source || process.env.SOURCE || 'debian'
const ROUTER_BASE_PATH = process.env.ROUTER_BASE_PATH || '/audiobookshelf' const ROUTER_BASE_PATH = process.env.ROUTER_BASE_PATH ?? '/audiobookshelf'
console.log(`Running in ${process.env.NODE_ENV} mode.`) console.log(`Running in ${process.env.NODE_ENV} mode.`)
console.log(`Options: CONFIG_PATH=${CONFIG_PATH}, METADATA_PATH=${METADATA_PATH}, PORT=${PORT}, HOST=${HOST}, SOURCE=${SOURCE}, ROUTER_BASE_PATH=${ROUTER_BASE_PATH}`) console.log(`Options: CONFIG_PATH=${CONFIG_PATH}, METADATA_PATH=${METADATA_PATH}, PORT=${PORT}, HOST=${HOST}, SOURCE=${SOURCE}, ROUTER_BASE_PATH=${ROUTER_BASE_PATH}`)

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.19.0", "version": "2.19.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.19.0", "version": "2.19.2",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"axios": "^0.27.2", "axios": "^0.27.2",

View File

@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.19.0", "version": "2.19.2",
"buildNumber": 1, "buildNumber": 1,
"description": "Self-hosted audiobook and podcast server", "description": "Self-hosted audiobook and podcast server",
"main": "index.js", "main": "index.js",

View File

@ -25,7 +25,7 @@ const CONFIG_PATH = inputConfig || process.env.CONFIG_PATH || Path.resolve('conf
const METADATA_PATH = inputMetadata || process.env.METADATA_PATH || Path.resolve('metadata') const METADATA_PATH = inputMetadata || process.env.METADATA_PATH || Path.resolve('metadata')
const SOURCE = options.source || process.env.SOURCE || 'debian' const SOURCE = options.source || process.env.SOURCE || 'debian'
const ROUTER_BASE_PATH = process.env.ROUTER_BASE_PATH || '/audiobookshelf' const ROUTER_BASE_PATH = process.env.ROUTER_BASE_PATH ?? '/audiobookshelf'
console.log(process.env.NODE_ENV, 'Config', CONFIG_PATH, METADATA_PATH) console.log(process.env.NODE_ENV, 'Config', CONFIG_PATH, METADATA_PATH)

View File

@ -10,6 +10,7 @@ const ExtractJwt = require('passport-jwt').ExtractJwt
const OpenIDClient = require('openid-client') const OpenIDClient = require('openid-client')
const Database = require('./Database') const Database = require('./Database')
const Logger = require('./Logger') const Logger = require('./Logger')
const { escapeRegExp } = require('./utils')
/** /**
* @class Class for handling all the authentication related functionality. * @class Class for handling all the authentication related functionality.
@ -18,7 +19,11 @@ class Auth {
constructor() { constructor() {
// Map of openId sessions indexed by oauth2 state-variable // Map of openId sessions indexed by oauth2 state-variable
this.openIdAuthSession = new Map() this.openIdAuthSession = new Map()
this.ignorePatterns = [/\/api\/items\/[^/]+\/cover/, /\/api\/authors\/[^/]+\/image/] const escapedRouterBasePath = escapeRegExp(global.RouterBasePath)
this.ignorePatterns = [
new RegExp(`^(${escapedRouterBasePath}/api)?/items/[^/]+/cover$`),
new RegExp(`^(${escapedRouterBasePath}/api)?/authors/[^/]+/image$`)
]
} }
/** /**
@ -28,7 +33,7 @@ class Auth {
* @private * @private
*/ */
authNotNeeded(req) { authNotNeeded(req) {
return req.method === 'GET' && this.ignorePatterns.some((pattern) => pattern.test(req.originalUrl)) return req.method === 'GET' && this.ignorePatterns.some((pattern) => pattern.test(req.path))
} }
ifAuthNeeded(middleware) { ifAuthNeeded(middleware) {

View File

@ -246,6 +246,15 @@ class RssFeedManager {
const extname = Path.extname(feed.coverPath).toLowerCase().slice(1) const extname = Path.extname(feed.coverPath).toLowerCase().slice(1)
res.type(`image/${extname}`) res.type(`image/${extname}`)
const readStream = fs.createReadStream(feed.coverPath) const readStream = fs.createReadStream(feed.coverPath)
readStream.on('error', (error) => {
Logger.error(`[RssFeedManager] Error streaming cover image: ${error.message}`)
// Only send error if headers haven't been sent yet
if (!res.headersSent) {
res.sendStatus(404)
}
})
readStream.pipe(res) readStream.pipe(res)
} }

View File

@ -13,3 +13,4 @@ Please add a record of every database migration that you create to this file. Th
| 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.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.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 | | v2.17.7 | v2.17.7-add-indices | Adds indices to the libraryItems and books tables to reduce query times |
| v2.19.1 | v2.19.1-copy-title-to-library-items | Copies title and titleIgnorePrefix to the libraryItems table, creates update triggers and indices |

View File

@ -0,0 +1,164 @@
const util = require('util')
/**
* @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.19.1'
const migrationName = `${migrationVersion}-copy-title-to-library-items`
const loggerPrefix = `[${migrationVersion} migration]`
/**
* This upward migration adds a title column to the libraryItems table, copies the title from the book to the libraryItem,
* and creates a new index on the title column. In addition it sets a trigger on the books table to update the title column
* in the libraryItems table when a book is updated.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function up({ context: { queryInterface, logger } }) {
// Upwards migration script
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
await addColumn(queryInterface, logger, 'libraryItems', 'title', { type: queryInterface.sequelize.Sequelize.STRING, allowNull: true })
await copyColumn(queryInterface, logger, 'books', 'title', 'id', 'libraryItems', 'title', 'mediaId')
await addTrigger(queryInterface, logger, 'books', 'title', 'id', 'libraryItems', 'title', 'mediaId')
await addIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', { name: 'title', collate: 'NOCASE' }])
await addColumn(queryInterface, logger, 'libraryItems', 'titleIgnorePrefix', { type: queryInterface.sequelize.Sequelize.STRING, allowNull: true })
await copyColumn(queryInterface, logger, 'books', 'titleIgnorePrefix', 'id', 'libraryItems', 'titleIgnorePrefix', 'mediaId')
await addTrigger(queryInterface, logger, 'books', 'titleIgnorePrefix', 'id', 'libraryItems', 'titleIgnorePrefix', 'mediaId')
await addIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', { name: 'titleIgnorePrefix', collate: 'NOCASE' }])
await addIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', 'createdAt'])
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
}
/**
* This downward migration script removes the title column from the libraryItems table, removes the trigger on the books table,
* and removes the index on the title column.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function down({ context: { queryInterface, logger } }) {
// Downward migration script
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
await removeIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', 'title'])
await removeTrigger(queryInterface, logger, 'libraryItems', 'title')
await removeColumn(queryInterface, logger, 'libraryItems', 'title')
await removeIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', 'titleIgnorePrefix'])
await removeTrigger(queryInterface, logger, 'libraryItems', 'titleIgnorePrefix')
await removeColumn(queryInterface, logger, 'libraryItems', 'titleIgnorePrefix')
await removeIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', 'createdAt'])
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
}
/**
* Utility function to add an index to a table. If the index already z`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) {
const columnString = columns.map((column) => util.inspect(column)).join(', ')
const indexName = convertToSnakeCase(`${tableName}_${columns.map((column) => (typeof column === 'string' ? column : column.name)).join('_')}`)
try {
logger.info(`${loggerPrefix} adding index on [${columnString}] to table ${tableName}. index name: ${indexName}"`)
await queryInterface.addIndex(tableName, columns)
logger.info(`${loggerPrefix} added index on [${columnString}] to table ${tableName}. index name: ${indexName}"`)
} catch (error) {
if (error.name === 'SequelizeDatabaseError' && error.message.includes('already exists')) {
logger.info(`${loggerPrefix} index [${columnString}] 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}"`)
}
async function addColumn(queryInterface, logger, table, column, options) {
logger.info(`${loggerPrefix} adding column "${column}" to table "${table}"`)
const tableDescription = await queryInterface.describeTable(table)
if (!tableDescription[column]) {
await queryInterface.addColumn(table, column, options)
logger.info(`${loggerPrefix} added column "${column}" to table "${table}"`)
} else {
logger.info(`${loggerPrefix} column "${column}" already exists in table "${table}"`)
}
}
async function removeColumn(queryInterface, logger, table, column) {
logger.info(`${loggerPrefix} removing column "${column}" from table "${table}"`)
await queryInterface.removeColumn(table, column)
logger.info(`${loggerPrefix} removed column "${column}" from table "${table}"`)
}
async function copyColumn(queryInterface, logger, sourceTable, sourceColumn, sourceIdColumn, targetTable, targetColumn, targetIdColumn) {
logger.info(`${loggerPrefix} copying column "${sourceColumn}" from table "${sourceTable}" to table "${targetTable}"`)
await queryInterface.sequelize.query(`
UPDATE ${targetTable}
SET ${targetColumn} = ${sourceTable}.${sourceColumn}
FROM ${sourceTable}
WHERE ${targetTable}.${targetIdColumn} = ${sourceTable}.${sourceIdColumn}
`)
logger.info(`${loggerPrefix} copied column "${sourceColumn}" from table "${sourceTable}" to table "${targetTable}"`)
}
async function addTrigger(queryInterface, logger, sourceTable, sourceColumn, sourceIdColumn, targetTable, targetColumn, targetIdColumn) {
logger.info(`${loggerPrefix} adding trigger to update ${targetTable}.${targetColumn} when ${sourceTable}.${sourceColumn} is updated`)
const triggerName = convertToSnakeCase(`update_${targetTable}_${targetColumn}`)
await queryInterface.sequelize.query(`DROP TRIGGER IF EXISTS ${triggerName}`)
await queryInterface.sequelize.query(`
CREATE TRIGGER ${triggerName}
AFTER UPDATE OF ${sourceColumn} ON ${sourceTable}
FOR EACH ROW
BEGIN
UPDATE ${targetTable}
SET ${targetColumn} = NEW.${sourceColumn}
WHERE ${targetTable}.${targetIdColumn} = NEW.${sourceIdColumn};
END;
`)
logger.info(`${loggerPrefix} added trigger to update ${targetTable}.${targetColumn} when ${sourceTable}.${sourceColumn} is updated`)
}
async function removeTrigger(queryInterface, logger, targetTable, targetColumn) {
logger.info(`${loggerPrefix} removing trigger to update ${targetTable}.${targetColumn}`)
const triggerName = convertToSnakeCase(`update_${targetTable}_${targetColumn}`)
await queryInterface.sequelize.query(`DROP TRIGGER IF EXISTS ${triggerName}`)
logger.info(`${loggerPrefix} removed trigger to update ${targetTable}.${targetColumn}`)
}
function convertToSnakeCase(str) {
return str.replace(/([A-Z])/g, '_$1').toLowerCase()
}
module.exports = { up, down }

View File

@ -3,6 +3,7 @@ const Logger = require('../Logger')
const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils') const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils')
const parseNameString = require('../utils/parsers/parseNameString') const parseNameString = require('../utils/parsers/parseNameString')
const htmlSanitizer = require('../utils/htmlSanitizer') const htmlSanitizer = require('../utils/htmlSanitizer')
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
/** /**
* @typedef EBookFileObject * @typedef EBookFileObject
@ -192,6 +193,14 @@ class Book extends Model {
] ]
} }
) )
Book.addHook('afterDestroy', async (instance) => {
libraryItemsBookFilters.clearCountCache('afterDestroy')
})
Book.addHook('afterCreate', async (instance) => {
libraryItemsBookFilters.clearCountCache('afterCreate')
})
} }
/** /**

View File

@ -73,6 +73,10 @@ class LibraryItem extends Model {
/** @type {Book.BookExpanded|Podcast.PodcastExpanded} - only set when expanded */ /** @type {Book.BookExpanded|Podcast.PodcastExpanded} - only set when expanded */
this.media this.media
/** @type {string} */
this.title // Only used for sorting
/** @type {string} */
this.titleIgnorePrefix // Only used for sorting
} }
/** /**
@ -677,7 +681,9 @@ class LibraryItem extends Model {
lastScan: DataTypes.DATE, lastScan: DataTypes.DATE,
lastScanVersion: DataTypes.STRING, lastScanVersion: DataTypes.STRING,
libraryFiles: DataTypes.JSON, libraryFiles: DataTypes.JSON,
extraData: DataTypes.JSON extraData: DataTypes.JSON,
title: DataTypes.STRING,
titleIgnorePrefix: DataTypes.STRING
}, },
{ {
sequelize, sequelize,
@ -695,6 +701,15 @@ class LibraryItem extends Model {
{ {
fields: ['libraryId', 'mediaType', 'size'] fields: ['libraryId', 'mediaType', 'size']
}, },
{
fields: ['libraryId', 'mediaType', 'createdAt']
},
{
fields: ['libraryId', 'mediaType', { name: 'title', collate: 'NOCASE' }]
},
{
fields: ['libraryId', 'mediaType', { name: 'titleIgnorePrefix', collate: 'NOCASE' }]
},
{ {
fields: ['libraryId', 'mediaId', 'mediaType'] fields: ['libraryId', 'mediaId', 'mediaType']
}, },

View File

@ -521,6 +521,8 @@ class BookScanner {
libraryItemObj.isMissing = false libraryItemObj.isMissing = false
libraryItemObj.isInvalid = false libraryItemObj.isInvalid = false
libraryItemObj.extraData = {} libraryItemObj.extraData = {}
libraryItemObj.title = bookMetadata.title
libraryItemObj.titleIgnorePrefix = getTitleIgnorePrefix(bookMetadata.title)
// Set isSupplementary flag on ebook library files // Set isSupplementary flag on ebook library files
for (const libraryFile of libraryItemObj.libraryFiles) { for (const libraryFile of libraryItemObj.libraryFiles) {

41
server/utils/profiler.js Normal file
View File

@ -0,0 +1,41 @@
const { performance, createHistogram } = require('perf_hooks')
const util = require('util')
const Logger = require('../Logger')
const histograms = new Map()
function profile(asyncFunc, isFindQuery = true, funcName = asyncFunc.name) {
if (!histograms.has(funcName)) {
const histogram = createHistogram()
histogram.values = []
histograms.set(funcName, histogram)
}
const histogram = histograms.get(funcName)
return async (...args) => {
if (isFindQuery) {
const findOptions = args[0]
Logger.info(`[${funcName}] findOptions:`, util.inspect(findOptions, { depth: null }))
findOptions.logging = (query, time) => Logger.info(`[${funcName}] ${query} Elapsed time: ${time}ms`)
findOptions.benchmark = true
}
const start = performance.now()
try {
const result = await asyncFunc(...args)
return result
} catch (error) {
Logger.error(`[${funcName}] failed`)
throw error
} finally {
const end = performance.now()
const duration = Math.round(end - start)
histogram.record(duration)
histogram.values.push(duration)
Logger.info(`[${funcName}] duration: ${duration}ms`)
Logger.info(`[${funcName}] histogram values:`, histogram.values)
Logger.info(`[${funcName}] histogram:`, histogram)
}
}
}
module.exports = { profile }

View File

@ -4,6 +4,9 @@ const Logger = require('../../Logger')
const authorFilters = require('./authorFilters') const authorFilters = require('./authorFilters')
const ShareManager = require('../../managers/ShareManager') const ShareManager = require('../../managers/ShareManager')
const { profile } = require('../profiler')
const countCache = new Map()
module.exports = { module.exports = {
/** /**
@ -270,9 +273,9 @@ module.exports = {
} }
if (global.ServerSettings.sortingIgnorePrefix) { if (global.ServerSettings.sortingIgnorePrefix) {
return [[Sequelize.literal('titleIgnorePrefix COLLATE NOCASE'), dir]] return [[Sequelize.literal('`libraryItem`.`titleIgnorePrefix` COLLATE NOCASE'), dir]]
} else { } else {
return [[Sequelize.literal('`book`.`title` COLLATE NOCASE'), dir]] return [[Sequelize.literal('`libraryItem`.`title` COLLATE NOCASE'), dir]]
} }
} else if (sortBy === 'sequence') { } else if (sortBy === 'sequence') {
const nullDir = sortDesc ? 'DESC NULLS FIRST' : 'ASC NULLS LAST' const nullDir = sortDesc ? 'DESC NULLS FIRST' : 'ASC NULLS LAST'
@ -336,6 +339,28 @@ module.exports = {
return { booksToExclude, bookSeriesToInclude } return { booksToExclude, bookSeriesToInclude }
}, },
clearCountCache(hook) {
Logger.debug(`[LibraryItemsBookFilters] book.${hook}: Clearing count cache`)
countCache.clear()
},
async findAndCountAll(findOptions, limit, offset) {
const findOptionsKey = JSON.stringify(findOptions)
Logger.debug(`[LibraryItemsBookFilters] findOptionsKey: ${findOptionsKey}`)
findOptions.limit = limit || null
findOptions.offset = offset
if (countCache.has(findOptionsKey)) {
const rows = await Database.bookModel.findAll(findOptions)
return { rows, count: countCache.get(findOptionsKey) }
} else {
const result = await Database.bookModel.findAndCountAll(findOptions)
countCache.set(findOptionsKey, result.count)
return result
}
},
/** /**
* Get library items for book media type using filter and sort * Get library items for book media type using filter and sort
* @param {string} libraryId * @param {string} libraryId
@ -411,7 +436,8 @@ module.exports = {
if (includeRSSFeed) { if (includeRSSFeed) {
libraryItemIncludes.push({ libraryItemIncludes.push({
model: Database.feedModel, model: Database.feedModel,
required: filterGroup === 'feed-open' required: filterGroup === 'feed-open',
separate: true
}) })
} }
if (filterGroup === 'feed-open' && !includeRSSFeed) { if (filterGroup === 'feed-open' && !includeRSSFeed) {
@ -554,13 +580,13 @@ module.exports = {
// When collapsing series and sorting by title then use the series name instead of the book title // When collapsing series and sorting by title then use the series name instead of the book title
// for this set an attribute "display_title" to use in sorting // for this set an attribute "display_title" to use in sorting
if (global.ServerSettings.sortingIgnorePrefix) { if (global.ServerSettings.sortingIgnorePrefix) {
bookAttributes.include.push([Sequelize.literal(`IFNULL((SELECT s.nameIgnorePrefix FROM bookSeries AS bs, series AS s WHERE bs.seriesId = s.id AND bs.bookId = book.id AND bs.id IN (${bookSeriesToInclude.map((v) => `"${v.id}"`).join(', ')})), titleIgnorePrefix)`), 'display_title']) bookAttributes.include.push([Sequelize.literal(`IFNULL((SELECT s.nameIgnorePrefix FROM bookSeries AS bs, series AS s WHERE bs.seriesId = s.id AND bs.bookId = book.id AND bs.id IN (${bookSeriesToInclude.map((v) => `"${v.id}"`).join(', ')})), \`libraryItem\`.\`titleIgnorePrefix\`)`), 'display_title'])
} else { } else {
bookAttributes.include.push([Sequelize.literal(`IFNULL((SELECT s.name FROM bookSeries AS bs, series AS s WHERE bs.seriesId = s.id AND bs.bookId = book.id AND bs.id IN (${bookSeriesToInclude.map((v) => `"${v.id}"`).join(', ')})), \`book\`.\`title\`)`), 'display_title']) bookAttributes.include.push([Sequelize.literal(`IFNULL((SELECT s.name FROM bookSeries AS bs, series AS s WHERE bs.seriesId = s.id AND bs.bookId = book.id AND bs.id IN (${bookSeriesToInclude.map((v) => `"${v.id}"`).join(', ')})), \`libraryItem\`.\`title\`)`), 'display_title'])
} }
} }
const { rows: books, count } = await Database.bookModel.findAndCountAll({ const findOptions = {
where: bookWhere, where: bookWhere,
distinct: true, distinct: true,
attributes: bookAttributes, attributes: bookAttributes,
@ -577,10 +603,11 @@ module.exports = {
...bookIncludes ...bookIncludes
], ],
order: sortOrder, order: sortOrder,
subQuery: false, subQuery: false
limit: limit || null, }
offset
}) const findAndCountAll = process.env.QUERY_PROFILING ? profile(this.findAndCountAll) : this.findAndCountAll
const { rows: books, count } = await findAndCountAll(findOptions, limit, offset)
const libraryItems = books.map((bookExpanded) => { const libraryItems = books.map((bookExpanded) => {
const libraryItem = bookExpanded.libraryItem const libraryItem = bookExpanded.libraryItem
@ -1008,8 +1035,8 @@ module.exports = {
const textSearchQuery = await Database.createTextSearchQuery(query) const textSearchQuery = await Database.createTextSearchQuery(query)
const matchTitle = textSearchQuery.matchExpression('title') const matchTitle = textSearchQuery.matchExpression('book.title')
const matchSubtitle = textSearchQuery.matchExpression('subtitle') const matchSubtitle = textSearchQuery.matchExpression('book.subtitle')
// Search title, subtitle, asin, isbn // Search title, subtitle, asin, isbn
const books = await Database.bookModel.findAll({ const books = await Database.bookModel.findAll({

View File

@ -84,7 +84,7 @@ module.exports = {
return [[Sequelize.literal(`\`podcast\`.\`author\` COLLATE NOCASE ${nullDir}`)]] return [[Sequelize.literal(`\`podcast\`.\`author\` COLLATE NOCASE ${nullDir}`)]]
} else if (sortBy === 'media.metadata.title') { } else if (sortBy === 'media.metadata.title') {
if (global.ServerSettings.sortingIgnorePrefix) { if (global.ServerSettings.sortingIgnorePrefix) {
return [[Sequelize.literal('titleIgnorePrefix COLLATE NOCASE'), dir]] return [[Sequelize.literal('`podcast`.`titleIgnorePrefix` COLLATE NOCASE'), dir]]
} else { } else {
return [[Sequelize.literal('`podcast`.`title` COLLATE NOCASE'), dir]] return [[Sequelize.literal('`podcast`.`title` COLLATE NOCASE'), dir]]
} }
@ -321,8 +321,8 @@ module.exports = {
const textSearchQuery = await Database.createTextSearchQuery(query) const textSearchQuery = await Database.createTextSearchQuery(query)
const matchTitle = textSearchQuery.matchExpression('title') const matchTitle = textSearchQuery.matchExpression('podcast.title')
const matchAuthor = textSearchQuery.matchExpression('author') const matchAuthor = textSearchQuery.matchExpression('podcast.author')
// Search title, author, itunesId, itunesArtistId // Search title, author, itunesId, itunesArtistId
const podcasts = await Database.podcastModel.findAll({ const podcasts = await Database.podcastModel.findAll({

View File

@ -129,9 +129,9 @@ describe('migration-v2.15.0-series-column-unique', () => {
{ id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, { id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() },
{ id: series2Id, name: 'Series 2', libraryId: library2Id, createdAt: new Date(), updatedAt: new Date() }, { id: series2Id, name: 'Series 2', libraryId: library2Id, createdAt: new Date(), updatedAt: new Date() },
{ id: series3Id, name: 'Series 3', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, { id: series3Id, name: 'Series 3', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() },
{ id: series1Id_dup, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, { id: series1Id_dup, name: 'Series 1', libraryId: library1Id, createdAt: new Date(0), updatedAt: new Date(0) },
{ id: series3Id_dup, name: 'Series 3', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, { id: series3Id_dup, name: 'Series 3', libraryId: library1Id, createdAt: new Date(0), updatedAt: new Date(0) },
{ id: series1Id_dup2, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() } { id: series1Id_dup2, name: 'Series 1', libraryId: library1Id, createdAt: new Date(0), updatedAt: new Date(0) }
]) ])
// Add some entries to the BookSeries table // Add some entries to the BookSeries table
await queryInterface.bulkInsert('BookSeries', [ await queryInterface.bulkInsert('BookSeries', [

View File

@ -0,0 +1,148 @@
const chai = require('chai')
const sinon = require('sinon')
const { expect } = chai
const { DataTypes, Sequelize } = require('sequelize')
const Logger = require('../../../server/Logger')
const { up, down } = require('../../../server/migrations/v2.19.1-copy-title-to-library-items')
describe('Migration v2.19.1-copy-title-to-library-items', () => {
let sequelize
let queryInterface
let loggerInfoStub
beforeEach(async () => {
sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
queryInterface = sequelize.getQueryInterface()
loggerInfoStub = sinon.stub(Logger, 'info')
await queryInterface.createTable('books', {
id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true },
title: { type: DataTypes.STRING, allowNull: true },
titleIgnorePrefix: { type: DataTypes.STRING, allowNull: true }
})
await queryInterface.createTable('libraryItems', {
id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true },
libraryId: { type: DataTypes.INTEGER, allowNull: false },
mediaType: { type: DataTypes.STRING, allowNull: false },
mediaId: { type: DataTypes.INTEGER, allowNull: false },
createdAt: { type: DataTypes.DATE, allowNull: false }
})
await queryInterface.bulkInsert('books', [
{ id: 1, title: 'The Book 1', titleIgnorePrefix: 'Book 1, The' },
{ id: 2, title: 'Book 2', titleIgnorePrefix: 'Book 2' }
])
await queryInterface.bulkInsert('libraryItems', [
{ id: 1, libraryId: 1, mediaType: 'book', mediaId: 1, createdAt: '2025-01-01 00:00:00.000 +00:00' },
{ id: 2, libraryId: 2, mediaType: 'book', mediaId: 2, createdAt: '2025-01-02 00:00:00.000 +00:00' }
])
})
afterEach(() => {
sinon.restore()
})
describe('up', () => {
it('should copy title and titleIgnorePrefix to libraryItems', async () => {
await up({ context: { queryInterface, logger: Logger } })
const [libraryItems] = await queryInterface.sequelize.query('SELECT * FROM libraryItems')
expect(libraryItems).to.deep.equal([
{ id: 1, libraryId: 1, mediaType: 'book', mediaId: 1, title: 'The Book 1', titleIgnorePrefix: 'Book 1, The', createdAt: '2025-01-01 00:00:00.000 +00:00' },
{ id: 2, libraryId: 2, mediaType: 'book', mediaId: 2, title: 'Book 2', titleIgnorePrefix: 'Book 2', createdAt: '2025-01-02 00:00:00.000 +00:00' }
])
})
it('should add index on title to libraryItems', async () => {
await up({ context: { queryInterface, logger: Logger } })
const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_title_ignore_prefix'`)
expect(count).to.equal(1)
})
it('should add trigger to books.title to update libraryItems.title', async () => {
await up({ context: { queryInterface, logger: Logger } })
const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title'`)
expect(count).to.equal(1)
})
it('should add index on titleIgnorePrefix to libraryItems', async () => {
await up({ context: { queryInterface, logger: Logger } })
const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_title_ignore_prefix'`)
expect(count).to.equal(1)
})
it('should add trigger to books.titleIgnorePrefix to update libraryItems.titleIgnorePrefix', async () => {
await up({ context: { queryInterface, logger: Logger } })
const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_ignore_prefix'`)
expect(count).to.equal(1)
})
it('should add index on createdAt to libraryItems', async () => {
await up({ context: { queryInterface, logger: Logger } })
const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_created_at'`)
expect(count).to.equal(1)
})
})
describe('down', () => {
it('should remove title and titleIgnorePrefix from libraryItems', async () => {
await up({ context: { queryInterface, logger: Logger } })
await down({ context: { queryInterface, logger: Logger } })
const [libraryItems] = await queryInterface.sequelize.query('SELECT * FROM libraryItems')
expect(libraryItems).to.deep.equal([
{ id: 1, libraryId: 1, mediaType: 'book', mediaId: 1, createdAt: '2025-01-01 00:00:00.000 +00:00' },
{ id: 2, libraryId: 2, mediaType: 'book', mediaId: 2, createdAt: '2025-01-02 00:00:00.000 +00:00' }
])
})
it('should remove title trigger from books', async () => {
await up({ context: { queryInterface, logger: Logger } })
await down({ context: { queryInterface, logger: Logger } })
const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title'`)
expect(count).to.equal(0)
})
it('should remove titleIgnorePrefix trigger from books', async () => {
await up({ context: { queryInterface, logger: Logger } })
await down({ context: { queryInterface, logger: Logger } })
const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_ignore_prefix'`)
expect(count).to.equal(0)
})
it('should remove index on titleIgnorePrefix from libraryItems', async () => {
await up({ context: { queryInterface, logger: Logger } })
await down({ context: { queryInterface, logger: Logger } })
const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_title_ignore_prefix'`)
expect(count).to.equal(0)
})
it('should remove index on title from libraryItems', async () => {
await up({ context: { queryInterface, logger: Logger } })
await down({ context: { queryInterface, logger: Logger } })
const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_title'`)
expect(count).to.equal(0)
})
it('should remove index on createdAt from libraryItems', async () => {
await up({ context: { queryInterface, logger: Logger } })
await down({ context: { queryInterface, logger: Logger } })
const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_created_at'`)
expect(count).to.equal(0)
})
})
})