diff --git a/client/components/modals/rssfeed/OpenCloseModal.vue b/client/components/modals/rssfeed/OpenCloseModal.vue
index 53542cf55..4eff94013 100644
--- a/client/components/modals/rssfeed/OpenCloseModal.vue
+++ b/client/components/modals/rssfeed/OpenCloseModal.vue
@@ -10,9 +10,9 @@
@@ -324,21 +333,21 @@ export default {
},
updateServerSettings(payload) {
this.updatingServerSettings = true
- this.$store
- .dispatch('updateServerSettings', payload)
- .then(() => {
- this.updatingServerSettings = false
+ this.$store.dispatch('updateServerSettings', payload).then((response) => {
+ this.updatingServerSettings = false
- if (payload.language) {
- // Updating language after save allows for re-rendering
- this.$setLanguageCode(payload.language)
- }
- })
- .catch((error) => {
- console.error('Failed to update server settings', error)
- this.updatingServerSettings = false
- this.$toast.error(this.$strings.ToastFailedToUpdate)
- })
+ if (response.error) {
+ console.error('Failed to update server settins', response.error)
+ this.$toast.error(response.error)
+ this.initServerSettings()
+ return
+ }
+
+ if (payload.language) {
+ // Updating language after save allows for re-rendering
+ this.$setLanguageCode(payload.language)
+ }
+ })
},
initServerSettings() {
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
diff --git a/client/pages/config/rss-feeds.vue b/client/pages/config/rss-feeds.vue
index 68117a859..039e9a0df 100644
--- a/client/pages/config/rss-feeds.vue
+++ b/client/pages/config/rss-feeds.vue
@@ -126,7 +126,7 @@ export default {
},
coverUrl(feed) {
if (!feed.coverPath) return `${this.$config.routerBasePath}/Logo.png`
- return `${feed.feedUrl}/cover`
+ return `${this.$config.routerBasePath}${feed.feedUrl}/cover`
},
async loadFeeds() {
const data = await this.$axios.$get(`/api/feeds`).catch((err) => {
diff --git a/client/pages/config/users/index.vue b/client/pages/config/users/index.vue
index 4dd825910..184529cbe 100644
--- a/client/pages/config/users/index.vue
+++ b/client/pages/config/users/index.vue
@@ -2,6 +2,10 @@
@@ -29,7 +33,8 @@ export default {
data() {
return {
selectedAccount: null,
- showAccountModal: false
+ showAccountModal: false,
+ numUsers: 0
}
},
computed: {},
diff --git a/client/plugins/i18n.js b/client/plugins/i18n.js
index 0ec5cccee..12d2b44bc 100644
--- a/client/plugins/i18n.js
+++ b/client/plugins/i18n.js
@@ -7,6 +7,7 @@ const defaultCode = 'en-us'
const languageCodeMap = {
bg: { label: 'Български', dateFnsLocale: 'bg' },
bn: { label: 'বাংলা', dateFnsLocale: 'bn' },
+ ca: { label: 'Català', dateFnsLocale: 'ca' },
cs: { label: 'Čeština', dateFnsLocale: 'cs' },
da: { label: 'Dansk', dateFnsLocale: 'da' },
de: { label: 'Deutsch', dateFnsLocale: 'de' },
diff --git a/client/store/index.js b/client/store/index.js
index acd03eb46..2f2201b66 100644
--- a/client/store/index.js
+++ b/client/store/index.js
@@ -72,16 +72,17 @@ export const actions = {
return this.$axios
.$patch('/api/settings', updatePayload)
.then((result) => {
- if (result.success) {
+ if (result.serverSettings) {
commit('setServerSettings', result.serverSettings)
- return true
- } else {
- return false
}
+ return result
})
.catch((error) => {
console.error('Failed to update server settings', error)
- return false
+ const errorMsg = error.response?.data || 'Unknown error'
+ return {
+ error: errorMsg
+ }
})
},
checkForUpdate({ commit }) {
diff --git a/client/strings/ca.json b/client/strings/ca.json
new file mode 100644
index 000000000..f7e85ae25
--- /dev/null
+++ b/client/strings/ca.json
@@ -0,0 +1,1027 @@
+{
+ "ButtonAdd": "Afegeix",
+ "ButtonAddChapters": "Afegeix",
+ "ButtonAddDevice": "Afegeix Dispositiu",
+ "ButtonAddLibrary": "Crea Biblioteca",
+ "ButtonAddPodcasts": "Afegeix Podcasts",
+ "ButtonAddUser": "Crea Usuari",
+ "ButtonAddYourFirstLibrary": "Crea la teva Primera Biblioteca",
+ "ButtonApply": "Aplica",
+ "ButtonApplyChapters": "Aplica Capítols",
+ "ButtonAuthors": "Autors",
+ "ButtonBack": "Enrere",
+ "ButtonBrowseForFolder": "Cerca Carpeta",
+ "ButtonCancel": "Cancel·la",
+ "ButtonCancelEncode": "Cancel·la Codificador",
+ "ButtonChangeRootPassword": "Canvia Contrasenya Root",
+ "ButtonCheckAndDownloadNewEpisodes": "Verifica i Descarrega Nous Episodis",
+ "ButtonChooseAFolder": "Tria una Carpeta",
+ "ButtonChooseFiles": "Tria un Fitxer",
+ "ButtonClearFilter": "Elimina Filtres",
+ "ButtonCloseFeed": "Tanca Font",
+ "ButtonCloseSession": "Tanca la sessió oberta",
+ "ButtonCollections": "Col·leccions",
+ "ButtonConfigureScanner": "Configura Escàner",
+ "ButtonCreate": "Crea",
+ "ButtonCreateBackup": "Crea Còpia de Seguretat",
+ "ButtonDelete": "Elimina",
+ "ButtonDownloadQueue": "Cua",
+ "ButtonEdit": "Edita",
+ "ButtonEditChapters": "Edita Capítol",
+ "ButtonEditPodcast": "Edita Podcast",
+ "ButtonEnable": "Habilita",
+ "ButtonFireAndFail": "Executat i fallat",
+ "ButtonFireOnTest": "Activa esdeveniment de prova",
+ "ButtonForceReScan": "Força Re-escaneig",
+ "ButtonFullPath": "Ruta Completa",
+ "ButtonHide": "Amaga",
+ "ButtonHome": "Inici",
+ "ButtonIssues": "Problemes",
+ "ButtonJumpBackward": "Retrocedeix",
+ "ButtonJumpForward": "Avança",
+ "ButtonLatest": "Últims",
+ "ButtonLibrary": "Biblioteca",
+ "ButtonLogout": "Tanca Sessió",
+ "ButtonLookup": "Cerca",
+ "ButtonManageTracks": "Gestiona Pistes d'Àudio",
+ "ButtonMapChapterTitles": "Assigna Títols als Capítols",
+ "ButtonMatchAllAuthors": "Troba Tots els Autors",
+ "ButtonMatchBooks": "Troba Llibres",
+ "ButtonNevermind": "Oblida-ho",
+ "ButtonNext": "Següent",
+ "ButtonNextChapter": "Següent Capítol",
+ "ButtonNextItemInQueue": "Següent element a la cua",
+ "ButtonOk": "D'acord",
+ "ButtonOpenFeed": "Obre Font",
+ "ButtonOpenManager": "Obre Editor",
+ "ButtonPause": "Pausa",
+ "ButtonPlay": "Reprodueix",
+ "ButtonPlayAll": "Reprodueix tot",
+ "ButtonPlaying": "Reproduint",
+ "ButtonPlaylists": "Llistes de reproducció",
+ "ButtonPrevious": "Anterior",
+ "ButtonPreviousChapter": "Capítol Anterior",
+ "ButtonProbeAudioFile": "Examina fitxer d'àudio",
+ "ButtonPurgeAllCache": "Esborra Tot el Cache",
+ "ButtonPurgeItemsCache": "Esborra Cache d'Elements",
+ "ButtonQueueAddItem": "Afegeix a la Cua",
+ "ButtonQueueRemoveItem": "Elimina de la Cua",
+ "ButtonQuickEmbed": "Inserció Ràpida",
+ "ButtonQuickEmbedMetadata": "Afegeix Metadades Ràpidament",
+ "ButtonQuickMatch": "Troba Ràpidament",
+ "ButtonReScan": "Re-escaneja",
+ "ButtonRead": "Llegeix",
+ "ButtonReadLess": "Llegeix menys",
+ "ButtonReadMore": "Llegeix més",
+ "ButtonRefresh": "Refresca",
+ "ButtonRemove": "Elimina",
+ "ButtonRemoveAll": "Elimina Tot",
+ "ButtonRemoveAllLibraryItems": "Elimina Tots els Elements de la Biblioteca",
+ "ButtonRemoveFromContinueListening": "Elimina de Continuar Escoltant",
+ "ButtonRemoveFromContinueReading": "Elimina de Continuar Llegint",
+ "ButtonRemoveSeriesFromContinueSeries": "Elimina Sèrie de Continuar Sèries",
+ "ButtonReset": "Restableix",
+ "ButtonResetToDefault": "Restaura Valors per Defecte",
+ "ButtonRestore": "Restaura",
+ "ButtonSave": "Desa",
+ "ButtonSaveAndClose": "Desa i Tanca",
+ "ButtonSaveTracklist": "Desa Pistes",
+ "ButtonScan": "Escaneja",
+ "ButtonScanLibrary": "Escaneja Biblioteca",
+ "ButtonSearch": "Cerca",
+ "ButtonSelectFolderPath": "Selecciona Ruta de Carpeta",
+ "ButtonSeries": "Sèries",
+ "ButtonSetChaptersFromTracks": "Selecciona Capítols Segons les Pistes",
+ "ButtonShare": "Comparteix",
+ "ButtonShiftTimes": "Desplaça Temps",
+ "ButtonShow": "Mostra",
+ "ButtonStartM4BEncode": "Inicia Codificació M4B",
+ "ButtonStartMetadataEmbed": "Inicia Inserció de Metadades",
+ "ButtonStats": "Estadístiques",
+ "ButtonSubmit": "Envia",
+ "ButtonTest": "Prova",
+ "ButtonUnlinkOpenId": "Desvincula OpenID",
+ "ButtonUpload": "Carrega",
+ "ButtonUploadBackup": "Carrega Còpia de Seguretat",
+ "ButtonUploadCover": "Carrega Portada",
+ "ButtonUploadOPMLFile": "Carrega Fitxer OPML",
+ "ButtonUserDelete": "Elimina Usuari {0}",
+ "ButtonUserEdit": "Edita Usuari {0}",
+ "ButtonViewAll": "Mostra-ho Tot",
+ "ButtonYes": "Sí",
+ "ErrorUploadFetchMetadataAPI": "Error obtenint metadades",
+ "ErrorUploadFetchMetadataNoResults": "No s'han pogut obtenir metadades - Intenta actualitzar el títol i/o autor",
+ "ErrorUploadLacksTitle": "S'ha de tenir un títol",
+ "HeaderAccount": "Compte",
+ "HeaderAddCustomMetadataProvider": "Afegeix un proveïdor de metadades personalitzat",
+ "HeaderAdvanced": "Avançat",
+ "HeaderAppriseNotificationSettings": "Configuració de Notificacions Apprise",
+ "HeaderAudioTracks": "Pistes d'àudio",
+ "HeaderAudiobookTools": "Eines de Gestió d'Arxius d'Audiollibre",
+ "HeaderAuthentication": "Autenticació",
+ "HeaderBackups": "Còpies de Seguretat",
+ "HeaderChangePassword": "Canvia Contrasenya",
+ "HeaderChapters": "Capítols",
+ "HeaderChooseAFolder": "Tria una Carpeta",
+ "HeaderCollection": "Col·lecció",
+ "HeaderCollectionItems": "Elements a la Col·lecció",
+ "HeaderCover": "Portada",
+ "HeaderCurrentDownloads": "Descàrregues Actuals",
+ "HeaderCustomMessageOnLogin": "Missatge Personalitzat a l'Iniciar Sessió",
+ "HeaderCustomMetadataProviders": "Proveïdors de Metadades Personalitzats",
+ "HeaderDetails": "Detalls",
+ "HeaderDownloadQueue": "Cua de Descàrregues",
+ "HeaderEbookFiles": "Fitxers de Llibres Digitals",
+ "HeaderEmail": "Correu electrònic",
+ "HeaderEmailSettings": "Configuració de Correu Electrònic",
+ "HeaderEpisodes": "Episodis",
+ "HeaderEreaderDevices": "Dispositius Ereader",
+ "HeaderEreaderSettings": "Configuració del Lector",
+ "HeaderFiles": "Element",
+ "HeaderFindChapters": "Cerca Capítol",
+ "HeaderIgnoredFiles": "Ignora Element",
+ "HeaderItemFiles": "Carpetes d'Elements",
+ "HeaderItemMetadataUtils": "Utilitats de Metadades d'Elements",
+ "HeaderLastListeningSession": "Últimes Sessions",
+ "HeaderLatestEpisodes": "Últims Episodis",
+ "HeaderLibraries": "Biblioteques",
+ "HeaderLibraryFiles": "Fitxers de Biblioteca",
+ "HeaderLibraryStats": "Estadístiques de Biblioteca",
+ "HeaderListeningSessions": "Sessió",
+ "HeaderListeningStats": "Estadístiques de Temps Escoltat",
+ "HeaderLogin": "Inicia Sessió",
+ "HeaderLogs": "Registres",
+ "HeaderManageGenres": "Gestiona Gèneres",
+ "HeaderManageTags": "Gestiona Etiquetes",
+ "HeaderMapDetails": "Assigna Detalls",
+ "HeaderMatch": "Troba",
+ "HeaderMetadataOrderOfPrecedence": "Ordre de Precedència de Metadades",
+ "HeaderMetadataToEmbed": "Metadades a Inserir",
+ "HeaderNewAccount": "Nou Compte",
+ "HeaderNewLibrary": "Nova Biblioteca",
+ "HeaderNotificationCreate": "Crea Notificació",
+ "HeaderNotificationUpdate": "Actualització de Notificació",
+ "HeaderNotifications": "Notificacions",
+ "HeaderOpenIDConnectAuthentication": "Autenticació OpenID Connect",
+ "HeaderOpenListeningSessions": "Sessions públiques d'escolta",
+ "HeaderOpenRSSFeed": "Obre Font RSS",
+ "HeaderOtherFiles": "Altres Fitxers",
+ "HeaderPasswordAuthentication": "Autenticació per Contrasenya",
+ "HeaderPermissions": "Permisos",
+ "HeaderPlayerQueue": "Cua del Reproductor",
+ "HeaderPlayerSettings": "Configuració del Reproductor",
+ "HeaderPlaylist": "Llista de Reproducció",
+ "HeaderPlaylistItems": "Elements de la Llista de Reproducció",
+ "HeaderPodcastsToAdd": "Podcasts a afegir",
+ "HeaderPreviewCover": "Previsualització de la Portada",
+ "HeaderRSSFeedGeneral": "Detalls RSS",
+ "HeaderRSSFeedIsOpen": "La Font RSS està oberta",
+ "HeaderRSSFeeds": "Fonts RSS",
+ "HeaderRemoveEpisode": "Elimina Episodi",
+ "HeaderRemoveEpisodes": "Elimina {0} Episodis",
+ "HeaderSavedMediaProgress": "Desa el Progrés del Multimèdia",
+ "HeaderSchedule": "Horari",
+ "HeaderScheduleEpisodeDownloads": "Programa Descàrregues Automàtiques d'Episodis",
+ "HeaderScheduleLibraryScans": "Programa Escaneig Automàtic de Biblioteca",
+ "HeaderSession": "Sessió",
+ "HeaderSetBackupSchedule": "Programa Còpies de Seguretat",
+ "HeaderSettings": "Configuració",
+ "HeaderSettingsDisplay": "Interfície",
+ "HeaderSettingsExperimental": "Funcions Experimentals",
+ "HeaderSettingsGeneral": "General",
+ "HeaderSettingsScanner": "Escàner",
+ "HeaderSleepTimer": "Temporitzador de son",
+ "HeaderStatsLargestItems": "Elements més Grans",
+ "HeaderStatsLongestItems": "Elements més Llargs (h)",
+ "HeaderStatsMinutesListeningChart": "Minuts Escoltant (Últims 7 dies)",
+ "HeaderStatsRecentSessions": "Sessions Recents",
+ "HeaderStatsTop10Authors": "Top 10 Autors",
+ "HeaderStatsTop5Genres": "Top 5 Gèneres",
+ "HeaderTableOfContents": "Taula de Continguts",
+ "HeaderTools": "Eines",
+ "HeaderUpdateAccount": "Actualitza Compte",
+ "HeaderUpdateAuthor": "Actualitza Autor",
+ "HeaderUpdateDetails": "Actualitza Detalls",
+ "HeaderUpdateLibrary": "Actualitza Biblioteca",
+ "HeaderUsers": "Usuaris",
+ "HeaderYearReview": "Revisió de l'Any {0}",
+ "HeaderYourStats": "Les teves Estadístiques",
+ "LabelAbridged": "Resumit",
+ "LabelAbridgedChecked": "Resumit (comprovat)",
+ "LabelAbridgedUnchecked": "Sense resumir (no comprovat)",
+ "LabelAccessibleBy": "Accessible per",
+ "LabelAccountType": "Tipus de Compte",
+ "LabelAccountTypeAdmin": "Administrador",
+ "LabelAccountTypeGuest": "Convidat",
+ "LabelAccountTypeUser": "Usuari",
+ "LabelActivity": "Activitat",
+ "LabelAddToCollection": "Afegit a la Col·lecció",
+ "LabelAddToCollectionBatch": "S'han Afegit {0} Llibres a la Col·lecció",
+ "LabelAddToPlaylist": "Afegit a la llista de reproducció",
+ "LabelAddToPlaylistBatch": "S'han Afegit {0} Elements a la Llista de Reproducció",
+ "LabelAddedAt": "Afegit",
+ "LabelAddedDate": "{0} Afegit",
+ "LabelAdminUsersOnly": "Només usuaris administradors",
+ "LabelAll": "Tots",
+ "LabelAllUsers": "Tots els Usuaris",
+ "LabelAllUsersExcludingGuests": "Tots els usuaris excepte convidats",
+ "LabelAllUsersIncludingGuests": "Tots els usuaris i convidats",
+ "LabelAlreadyInYourLibrary": "Ja existeix a la Biblioteca",
+ "LabelApiToken": "Token de l'API",
+ "LabelAppend": "Adjuntar",
+ "LabelAudioBitrate": "Taxa de bits d'àudio (per exemple, 128k)",
+ "LabelAudioChannels": "Canals d'àudio (1 o 2)",
+ "LabelAudioCodec": "Còdec d'àudio",
+ "LabelAuthor": "Autor",
+ "LabelAuthorFirstLast": "Autor (Nom Cognom)",
+ "LabelAuthorLastFirst": "Autor (Cognom, Nom)",
+ "LabelAuthors": "Autors",
+ "LabelAutoDownloadEpisodes": "Descarregar episodis automàticament",
+ "LabelAutoFetchMetadata": "Actualitzar Metadades Automàticament",
+ "LabelAutoFetchMetadataHelp": "Obtén metadades de títol, autor i sèrie per agilitzar la càrrega. És possible que calgui revisar metadades addicionals després de la càrrega.",
+ "LabelAutoLaunch": "Inici automàtic",
+ "LabelAutoLaunchDescription": "Redirigir automàticament al proveïdor d'autenticació quan s'accedeix a la pàgina d'inici de sessió (ruta d'excepció manual
/login?autoLaunch=0)",
+ "LabelAutoRegister": "Registre automàtic",
+ "LabelAutoRegisterDescription": "Crear usuaris automàticament en iniciar sessió",
+ "LabelBackToUser": "Torna a Usuari",
+ "LabelBackupAudioFiles": "Còpia de seguretat d'arxius d'àudio",
+ "LabelBackupLocation": "Ubicació de la Còpia de Seguretat",
+ "LabelBackupsEnableAutomaticBackups": "Habilitar Còpies de Seguretat Automàtiques",
+ "LabelBackupsEnableAutomaticBackupsHelp": "Còpies de seguretat desades a /metadata/backups",
+ "LabelBackupsMaxBackupSize": "Mida màxima de la còpia de seguretat (en GB) (0 per il·limitat)",
+ "LabelBackupsMaxBackupSizeHelp": "Com a protecció contra una configuració incorrecta, les còpies de seguretat fallaran si superen la mida configurada.",
+ "LabelBackupsNumberToKeep": "Nombre de còpies de seguretat a conservar",
+ "LabelBackupsNumberToKeepHelp": "Només s'eliminarà una còpia de seguretat alhora. Si té més còpies desades, haurà d'eliminar-les manualment.",
+ "LabelBitrate": "Taxa de bits",
+ "LabelBonus": "Bonus",
+ "LabelBooks": "Llibres",
+ "LabelButtonText": "Text del botó",
+ "LabelByAuthor": "per {0}",
+ "LabelChangePassword": "Canviar Contrasenya",
+ "LabelChannels": "Canals",
+ "LabelChapterCount": "{0} capítols",
+ "LabelChapterTitle": "Títol del Capítol",
+ "LabelChapters": "Capítols",
+ "LabelChaptersFound": "Capítol Trobat",
+ "LabelClickForMoreInfo": "Fes clic per a més informació",
+ "LabelClickToUseCurrentValue": "Fes clic per utilitzar el valor actual",
+ "LabelClosePlayer": "Tancar reproductor",
+ "LabelCodec": "Còdec",
+ "LabelCollapseSeries": "Contraure sèrie",
+ "LabelCollapseSubSeries": "Contraure la subsèrie",
+ "LabelCollection": "Col·lecció",
+ "LabelCollections": "Col·leccions",
+ "LabelComplete": "Complet",
+ "LabelConfirmPassword": "Confirmar Contrasenya",
+ "LabelContinueListening": "Continuar escoltant",
+ "LabelContinueReading": "Continuar llegint",
+ "LabelContinueSeries": "Continuar sèries",
+ "LabelCover": "Portada",
+ "LabelCoverImageURL": "URL de la Imatge de Portada",
+ "LabelCreatedAt": "Creat",
+ "LabelCronExpression": "Expressió de Cron",
+ "LabelCurrent": "Actual",
+ "LabelCurrently": "En aquest moment:",
+ "LabelCustomCronExpression": "Expressió de Cron Personalitzada:",
+ "LabelDatetime": "Hora i Data",
+ "LabelDays": "Dies",
+ "LabelDeleteFromFileSystemCheckbox": "Eliminar arxius del sistema (desmarcar per eliminar només de la base de dades)",
+ "LabelDescription": "Descripció",
+ "LabelDeselectAll": "Desseleccionar Tots",
+ "LabelDevice": "Dispositiu",
+ "LabelDeviceInfo": "Informació del Dispositiu",
+ "LabelDeviceIsAvailableTo": "El dispositiu està disponible per a...",
+ "LabelDirectory": "Directori",
+ "LabelDiscFromFilename": "Disc a partir del Nom de l'Arxiu",
+ "LabelDiscFromMetadata": "Disc a partir de Metadades",
+ "LabelDiscover": "Descobrir",
+ "LabelDownload": "Descarregar",
+ "LabelDownloadNEpisodes": "Descarregar {0} episodis",
+ "LabelDuration": "Duració",
+ "LabelDurationComparisonExactMatch": "(coincidència exacta)",
+ "LabelDurationComparisonLonger": "({0} més llarg)",
+ "LabelDurationComparisonShorter": "({0} més curt)",
+ "LabelDurationFound": "Duració Trobada:",
+ "LabelEbook": "Llibre electrònic",
+ "LabelEbooks": "Llibres electrònics",
+ "LabelEdit": "Editar",
+ "LabelEmail": "Correu electrònic",
+ "LabelEmailSettingsFromAddress": "Remitent",
+ "LabelEmailSettingsRejectUnauthorized": "Rebutja certificats no autoritzats",
+ "LabelEmailSettingsRejectUnauthorizedHelp": "Desactivar la validació de certificats SSL pot exposar la teva connexió a riscos de seguretat, com atacs de tipus man-in-the-middle. Desactiva aquesta opció només si coneixes les implicacions i confies en el servidor de correu al qual et connectes.",
+ "LabelEmailSettingsSecure": "Seguretat",
+ "LabelEmailSettingsSecureHelp": "Si està activat, es farà servir TLS per connectar-se al servidor. Si està desactivat, es farà servir TLS si el servidor admet l'extensió STARTTLS. En la majoria dels casos, pots deixar aquesta opció activada si et connectes al port 465. Desactiva-la en el cas d'usar els ports 587 o 25. (de nodemailer.com/smtp/#authentication)",
+ "LabelEmailSettingsTestAddress": "Provar Adreça",
+ "LabelEmbeddedCover": "Portada Integrada",
+ "LabelEnable": "Habilitar",
+ "LabelEncodingBackupLocation": "Es guardarà una còpia de seguretat dels teus arxius d'àudio originals a:",
+ "LabelEncodingChaptersNotEmbedded": "Els capítols no s'incrusten en els audiollibres multipista.",
+ "LabelEncodingClearItemCache": "Assegura't de purgar periòdicament la memòria cau.",
+ "LabelEncodingFinishedM4B": "El M4B acabat es col·locarà a la teva carpeta d'audiollibres a:",
+ "LabelEncodingInfoEmbedded": "Les metadades s'integraran a les pistes d'àudio dins de la carpeta d'audiollibres.",
+ "LabelEncodingStartedNavigation": "Un cop iniciada la tasca, pots sortir d'aquesta pàgina.",
+ "LabelEncodingTimeWarning": "La codificació pot trigar fins a 30 minuts.",
+ "LabelEncodingWarningAdvancedSettings": "Advertència: No actualitzis aquesta configuració tret que estiguis familiaritzat amb les opcions de codificació d'ffmpeg.",
+ "LabelEncodingWatcherDisabled": "Si has desactivat la supervisió dels arxius, hauràs de tornar a escanejar aquest audiollibre més endavant.",
+ "LabelEnd": "Fi",
+ "LabelEndOfChapter": "Fi del capítol",
+ "LabelEpisode": "Episodi",
+ "LabelEpisodeNotLinkedToRssFeed": "Episodi no enllaçat al feed RSS",
+ "LabelEpisodeNumber": "Episodi #{0}",
+ "LabelEpisodeTitle": "Títol de l'Episodi",
+ "LabelEpisodeType": "Tipus d'Episodi",
+ "LabelEpisodeUrlFromRssFeed": "URL de l'episodi del feed RSS",
+ "LabelEpisodes": "Episodis",
+ "LabelEpisodic": "Episodis",
+ "LabelExample": "Exemple",
+ "LabelExpandSeries": "Ampliar sèrie",
+ "LabelExpandSubSeries": "Expandir la subsèrie",
+ "LabelExplicit": "Explícit",
+ "LabelExplicitChecked": "Explícit (marcat)",
+ "LabelExplicitUnchecked": "No Explícit (sense marcar)",
+ "LabelExportOPML": "Exportar OPML",
+ "LabelFeedURL": "Font de URL",
+ "LabelFetchingMetadata": "Obtenció de metadades",
+ "LabelFile": "Arxiu",
+ "LabelFileBirthtime": "Arxiu creat a",
+ "LabelFileBornDate": "Creat {0}",
+ "LabelFileModified": "Arxiu modificat",
+ "LabelFileModifiedDate": "Modificat {0}",
+ "LabelFilename": "Nom de l'arxiu",
+ "LabelFilterByUser": "Filtrar per Usuari",
+ "LabelFindEpisodes": "Cercar Episodi",
+ "LabelFinished": "Acabat",
+ "LabelFolder": "Carpeta",
+ "LabelFolders": "Carpetes",
+ "LabelFontBold": "Negreta",
+ "LabelFontBoldness": "Nivell de negreta en font",
+ "LabelFontFamily": "Família tipogràfica",
+ "LabelFontItalic": "Cursiva",
+ "LabelFontScale": "Mida de la font",
+ "LabelFontStrikethrough": "Ratllat",
+ "LabelFormat": "Format",
+ "LabelFull": "Complet",
+ "LabelGenre": "Gènere",
+ "LabelGenres": "Gèneres",
+ "LabelHardDeleteFile": "Eliminar Definitivament",
+ "LabelHasEbook": "Té un llibre electrònic",
+ "LabelHasSupplementaryEbook": "Té un llibre electrònic complementari",
+ "LabelHideSubtitles": "Amagar subtítols",
+ "LabelHighestPriority": "Prioritat més alta",
+ "LabelHost": "Amfitrió",
+ "LabelHour": "Hora",
+ "LabelHours": "Hores",
+ "LabelIcon": "Icona",
+ "LabelImageURLFromTheWeb": "URL de la imatge",
+ "LabelInProgress": "En procés",
+ "LabelIncludeInTracklist": "Incloure a la Llista de Pistes",
+ "LabelIncomplete": "Incomplet",
+ "LabelInterval": "Interval",
+ "LabelIntervalCustomDailyWeekly": "Personalitzar diari/setmanal",
+ "LabelIntervalEvery12Hours": "Cada 12 Hores",
+ "LabelIntervalEvery15Minutes": "Cada 15 minuts",
+ "LabelIntervalEvery2Hours": "Cada 2 Hores",
+ "LabelIntervalEvery30Minutes": "Cada 30 minuts",
+ "LabelIntervalEvery6Hours": "Cada 6 Hores",
+ "LabelIntervalEveryDay": "Cada Dia",
+ "LabelIntervalEveryHour": "Cada Hora",
+ "LabelInvert": "Invertir",
+ "LabelItem": "Element",
+ "LabelJumpBackwardAmount": "Quantitat de salts cap enrere",
+ "LabelJumpForwardAmount": "Quantitat de salts cap endavant",
+ "LabelLanguage": "Idioma",
+ "LabelLanguageDefaultServer": "Idioma Predeterminat del Servidor",
+ "LabelLanguages": "Idiomes",
+ "LabelLastBookAdded": "Últim Llibre Afegit",
+ "LabelLastBookUpdated": "Últim Llibre Actualitzat",
+ "LabelLastSeen": "Última Vegada Vist",
+ "LabelLastTime": "Última Vegada",
+ "LabelLastUpdate": "Última Actualització",
+ "LabelLayout": "Distribució",
+ "LabelLayoutSinglePage": "Pàgina única",
+ "LabelLayoutSplitPage": "Dues Pàgines",
+ "LabelLess": "Menys",
+ "LabelLibrariesAccessibleToUser": "Biblioteques Disponibles per a l'Usuari",
+ "LabelLibrary": "Biblioteca",
+ "LabelLibraryFilterSublistEmpty": "Sense {0}",
+ "LabelLibraryItem": "Element de Biblioteca",
+ "LabelLibraryName": "Nom de Biblioteca",
+ "LabelLimit": "Límits",
+ "LabelLineSpacing": "Interlineat",
+ "LabelListenAgain": "Escoltar de nou",
+ "LabelLogLevelDebug": "Depurar",
+ "LabelLogLevelInfo": "Informació",
+ "LabelLogLevelWarn": "Advertència",
+ "LabelLookForNewEpisodesAfterDate": "Cercar nous episodis a partir d'aquesta data",
+ "LabelLowestPriority": "Menor prioritat",
+ "LabelMatchExistingUsersBy": "Emparellar els usuaris existents per",
+ "LabelMatchExistingUsersByDescription": "S'utilitza per connectar usuaris existents. Un cop connectats, els usuaris seran emparellats per un identificador únic del seu proveïdor de SSO",
+ "LabelMaxEpisodesToDownload": "Nombre màxim d'episodis per descarregar. Usa 0 per descarregar una quantitat il·limitada.",
+ "LabelMaxEpisodesToDownloadPerCheck": "Nombre màxim de nous episodis que es descarregaran per comprovació",
+ "LabelMaxEpisodesToKeep": "Nombre màxim d'episodis que es mantindran",
+ "LabelMaxEpisodesToKeepHelp": "El valor 0 no estableix un límit màxim. Després de descarregar automàticament un nou episodi, això eliminarà l'episodi més antic si té més de X episodis. Això només eliminarà 1 episodi per nova descàrrega.",
+ "LabelMediaPlayer": "Reproductor de Mitjans",
+ "LabelMediaType": "Tipus de multimèdia",
+ "LabelMetaTag": "Metaetiqueta",
+ "LabelMetaTags": "Metaetiquetes",
+ "LabelMetadataOrderOfPrecedenceDescription": "Les fonts de metadades de major prioritat prevaldran sobre les de menor prioritat",
+ "LabelMetadataProvider": "Proveïdor de Metadades",
+ "LabelMinute": "Minut",
+ "LabelMinutes": "Minuts",
+ "LabelMissing": "Absent",
+ "LabelMissingEbook": "No té ebook",
+ "LabelMissingSupplementaryEbook": "No té ebook complementari",
+ "LabelMobileRedirectURIs": "URI de redirecció mòbil permeses",
+ "LabelMobileRedirectURIsDescription": "Aquesta és una llista blanca d'URI de redirecció vàlides per a aplicacions mòbils. El predeterminat és
audiobookshelf, que pots eliminar o complementar amb URI addicionals per a la integració d'aplicacions de tercers. Usant un asterisc (
*) com a única entrada que permet qualsevol URI.",
+ "LabelMore": "Més",
+ "LabelMoreInfo": "Més informació",
+ "LabelName": "Nom",
+ "LabelNarrator": "Narrador",
+ "LabelNarrators": "Narradors",
+ "LabelNew": "Nou",
+ "LabelNewPassword": "Nova Contrasenya",
+ "LabelNewestAuthors": "Autors més recents",
+ "LabelNewestEpisodes": "Episodis més recents",
+ "LabelNextBackupDate": "Data del Següent Respatller",
+ "LabelNextScheduledRun": "Proper Execució Programada",
+ "LabelNoCustomMetadataProviders": "Sense proveïdors de metadades personalitzats",
+ "LabelNoEpisodesSelected": "Cap Episodi Seleccionat",
+ "LabelNotFinished": "No acabat",
+ "LabelNotStarted": "Sense iniciar",
+ "LabelNotes": "Notes",
+ "LabelNotificationAppriseURL": "URL(s) d'Apprise",
+ "LabelNotificationAvailableVariables": "Variables Disponibles",
+ "LabelNotificationBodyTemplate": "Plantilla de Cos",
+ "LabelNotificationEvent": "Esdeveniment de Notificació",
+ "LabelNotificationTitleTemplate": "Plantilla de Títol",
+ "LabelNotificationsMaxFailedAttempts": "Màxim d'Intents Fallits",
+ "LabelNotificationsMaxFailedAttemptsHelp": "Les notificacions es desactivaran després de fallar aquest nombre de vegades",
+ "LabelNotificationsMaxQueueSize": "Mida màxima de la cua de notificacions",
+ "LabelNotificationsMaxQueueSizeHelp": "Les notificacions estan limitades a 1 per segon. Les notificacions seran ignorades si arriben al número màxim de cua per prevenir spam d'esdeveniments.",
+ "LabelNumberOfBooks": "Nombre de Llibres",
+ "LabelNumberOfEpisodes": "Nombre d'Episodis",
+ "LabelOpenIDAdvancedPermsClaimDescription": "Nom de la notificació de OpenID que conté permisos avançats per accions d'usuari dins l'aplicació que s'aplicaran a rols que no siguin d'administrador (
si estan configurats). Si el reclam no apareix en la resposta, es denegarà l'accés a ABS. Si manca una sola opció, es tractarà com a
falsa. Assegura't que la notificació del proveïdor d'identitats coincideixi amb l'estructura esperada:",
+ "LabelOpenIDClaims": "Deixa les següents opcions buides per desactivar l'assignació avançada de grups i permisos, el que assignaria automàticament al grup 'Usuari'.",
+ "LabelOpenIDGroupClaimDescription": "Nom de la declaració OpenID que conté una llista de grups de l'usuari. Comunament coneguts com
grups.
Si es configura, l'aplicació assignarà automàticament rols basats en la pertinença a grups de l'usuari, sempre que aquests grups es denominen 'admin', 'user' o 'guest' en la notificació. La sol·licitud ha de contenir una llista, i si un usuari pertany a diversos grups, l'aplicació assignarà el rol corresponent al major nivell d'accés. Si cap grup coincideix, es denegarà l'accés.",
+ "LabelOverwrite": "Sobreescriure",
+ "LabelPaginationPageXOfY": "Pàgina {0} de {1}",
+ "LabelPassword": "Contrasenya",
+ "LabelPath": "Ruta de carpeta",
+ "LabelPermanent": "Permanent",
+ "LabelPermissionsAccessAllLibraries": "Pot Accedir a Totes les Biblioteques",
+ "LabelPermissionsAccessAllTags": "Pot Accedir a Totes les Etiquetes",
+ "LabelPermissionsAccessExplicitContent": "Pot Accedir a Contingut Explícit",
+ "LabelPermissionsCreateEreader": "Pot Crear un Gestor de Projectes",
+ "LabelPermissionsDelete": "Pot Eliminar",
+ "LabelPermissionsDownload": "Pot Descarregar",
+ "LabelPermissionsUpdate": "Pot Actualitzar",
+ "LabelPermissionsUpload": "Pot Pujar",
+ "LabelPersonalYearReview": "Revisió del teu any ({0})",
+ "LabelPhotoPathURL": "Ruta/URL de la Foto",
+ "LabelPlayMethod": "Mètode de Reproducció",
+ "LabelPlayerChapterNumberMarker": "{0} de {1}",
+ "LabelPlaylists": "Llistes de Reproducció",
+ "LabelPodcast": "Podcast",
+ "LabelPodcastSearchRegion": "Regió de Cerca de Podcasts",
+ "LabelPodcastType": "Tipus de Podcast",
+ "LabelPodcasts": "Podcasts",
+ "LabelPort": "Port",
+ "LabelPrefixesToIgnore": "Prefixos per Ignorar (no distingeix entre majúscules i minúscules.)",
+ "LabelPreventIndexing": "Evita que la teva font sigui indexada pels directoris de podcasts d'iTunes i Google",
+ "LabelPrimaryEbook": "Ebook Principal",
+ "LabelProgress": "Progrés",
+ "LabelProvider": "Proveïdor",
+ "LabelProviderAuthorizationValue": "Valor de l'encapçalament d'autorització",
+ "LabelPubDate": "Data de Publicació",
+ "LabelPublishYear": "Any de Publicació",
+ "LabelPublishedDate": "Publicat {0}",
+ "LabelPublishedDecade": "Dècada de Publicació",
+ "LabelPublishedDecades": "Dècades Publicades",
+ "LabelPublisher": "Editor",
+ "LabelPublishers": "Editors",
+ "LabelRSSFeedCustomOwnerEmail": "Correu Electrònic Personalitzat del Propietari",
+ "LabelRSSFeedCustomOwnerName": "Nom Personalitzat del Propietari",
+ "LabelRSSFeedOpen": "Font RSS Oberta",
+ "LabelRSSFeedPreventIndexing": "Evitar l'indexació",
+ "LabelRSSFeedSlug": "Font RSS Slug",
+ "LabelRSSFeedURL": "URL de la Font RSS",
+ "LabelRandomly": "Aleatòriament",
+ "LabelReAddSeriesToContinueListening": "Reafegir la sèrie per continuar escoltant-la",
+ "LabelRead": "Llegit",
+ "LabelReadAgain": "Tornar a llegir",
+ "LabelReadEbookWithoutProgress": "Llegir Ebook sense guardar progrés",
+ "LabelRecentSeries": "Sèries Recents",
+ "LabelRecentlyAdded": "Afegit Recentment",
+ "LabelRecommended": "Recomanats",
+ "LabelRedo": "Refer",
+ "LabelRegion": "Regió",
+ "LabelReleaseDate": "Data d'Estrena",
+ "LabelRemoveAllMetadataAbs": "Eliminar tots els fitxers metadata.abs",
+ "LabelRemoveAllMetadataJson": "Eliminar tots els fitxers metadata.json",
+ "LabelRemoveCover": "Eliminar Coberta",
+ "LabelRemoveMetadataFile": "Eliminar fitxers de metadades en carpetes d'elements de biblioteca",
+ "LabelRemoveMetadataFileHelp": "Elimina tots els fitxers metadata.json i metadata.abs de les teves carpetes {0}.",
+ "LabelRowsPerPage": "Files per Pàgina",
+ "LabelSearchTerm": "Cercar Terme",
+ "LabelSearchTitle": "Cercar Títol",
+ "LabelSearchTitleOrASIN": "Cercar Títol o ASIN",
+ "LabelSeason": "Temporada",
+ "LabelSeasonNumber": "Temporada #{0}",
+ "LabelSelectAll": "Seleccionar tot",
+ "LabelSelectAllEpisodes": "Seleccionar tots els episodis",
+ "LabelSelectEpisodesShowing": "Seleccionar els {0} episodis visibles",
+ "LabelSelectUsers": "Seleccionar usuaris",
+ "LabelSendEbookToDevice": "Enviar Ebook a...",
+ "LabelSequence": "Seqüència",
+ "LabelSerial": "Serial",
+ "LabelSeries": "Sèries",
+ "LabelSeriesName": "Nom de la Sèrie",
+ "LabelSeriesProgress": "Progrés de la Sèrie",
+ "LabelServerLogLevel": "Nivell de registre del servidor",
+ "LabelServerYearReview": "Resum de l'any del servidor ({0})",
+ "LabelSetEbookAsPrimary": "Establir com a principal",
+ "LabelSetEbookAsSupplementary": "Establir com a suplementari",
+ "LabelSettingsAudiobooksOnly": "Només Audiollibres",
+ "LabelSettingsAudiobooksOnlyHelp": "Activant aquesta opció s'ignoraran els fitxers d'ebook, excepte si estan dins d'una carpeta d'audiollibre, en aquest cas es marcaran com ebooks suplementaris",
+ "LabelSettingsBookshelfViewHelp": "Disseny esqueomorf amb prestatgeries de fusta",
+ "LabelSettingsChromecastSupport": "Compatibilitat amb Chromecast",
+ "LabelSettingsDateFormat": "Format de Data",
+ "LabelSettingsDisableWatcher": "Desactivar Watcher",
+ "LabelSettingsDisableWatcherForLibrary": "Desactivar Watcher de Carpetes per a aquesta biblioteca",
+ "LabelSettingsDisableWatcherHelp": "Desactiva la funció d'afegir/actualitzar elements automàticament quan es detectin canvis en els fitxers. *Requereix reiniciar el servidor",
+ "LabelSettingsEnableWatcher": "Habilitar Watcher",
+ "LabelSettingsEnableWatcherForLibrary": "Habilitar Watcher per a la carpeta d'aquesta biblioteca",
+ "LabelSettingsEnableWatcherHelp": "Permet afegir/actualitzar elements automàticament quan es detectin canvis en els fitxers. *Requereix reiniciar el servidor",
+ "LabelSettingsEpubsAllowScriptedContent": "Permetre scripts en epubs",
+ "LabelSettingsEpubsAllowScriptedContentHelp": "Permetre que els fitxers epub executin scripts. Es recomana mantenir aquesta opció desactivada tret que confiïs en l'origen dels fitxers epub.",
+ "LabelSettingsExperimentalFeatures": "Funcions Experimentals",
+ "LabelSettingsExperimentalFeaturesHelp": "Funcions en desenvolupament que es beneficiarien dels teus comentaris i experiències de prova. Feu clic aquí per obrir una conversa a Github.",
+ "LabelShowAll": "Mostra-ho tot",
+ "LabelShowSeconds": "Mostra segons",
+ "LabelShowSubtitles": "Mostra subtítols",
+ "LabelSize": "Mida",
+ "LabelSleepTimer": "Temporitzador de repòs",
+ "LabelSlug": "Slug",
+ "LabelStart": "Inicia",
+ "LabelStartTime": "Hora d'inici",
+ "LabelStarted": "Iniciat",
+ "LabelStartedAt": "Iniciat a",
+ "LabelStatsAudioTracks": "Pistes d'àudio",
+ "LabelStatsAuthors": "Autors",
+ "LabelStatsBestDay": "Millor dia",
+ "LabelStatsDailyAverage": "Mitjana diària",
+ "LabelStatsDays": "Dies",
+ "LabelStatsDaysListened": "Dies escoltats",
+ "LabelStatsHours": "Hores",
+ "LabelStatsInARow": "seguits",
+ "LabelStatsItemsFinished": "Elements acabats",
+ "LabelStatsItemsInLibrary": "Elements a la biblioteca",
+ "LabelStatsMinutes": "minuts",
+ "LabelStatsMinutesListening": "Minuts escoltant",
+ "LabelStatsOverallDays": "Total de dies",
+ "LabelStatsOverallHours": "Total d'hores",
+ "LabelStatsWeekListening": "Temps escoltat aquesta setmana",
+ "LabelSubtitle": "Subtítol",
+ "LabelSupportedFileTypes": "Tipus de fitxers compatibles",
+ "LabelTag": "Etiqueta",
+ "LabelTags": "Etiquetes",
+ "LabelTagsAccessibleToUser": "Etiquetes accessibles per a l'usuari",
+ "LabelTagsNotAccessibleToUser": "Etiquetes no accessibles per a l'usuari",
+ "LabelTasks": "Tasques en execució",
+ "LabelTextEditorBulletedList": "Llista amb punts",
+ "LabelTextEditorLink": "Enllaça",
+ "LabelTextEditorNumberedList": "Llista numerada",
+ "LabelTextEditorUnlink": "Desenllaça",
+ "LabelTheme": "Tema",
+ "LabelThemeDark": "Fosc",
+ "LabelThemeLight": "Clar",
+ "LabelTimeBase": "Temps base",
+ "LabelTimeDurationXHours": "{0} hores",
+ "LabelTimeDurationXMinutes": "{0} minuts",
+ "LabelTimeDurationXSeconds": "{0} segons",
+ "LabelTimeInMinutes": "Temps en minuts",
+ "LabelTimeLeft": "Queden {0}",
+ "LabelTimeListened": "Temps escoltat",
+ "LabelTimeListenedToday": "Temps escoltat avui",
+ "LabelTimeRemaining": "{0} restant",
+ "LabelTimeToShift": "Temps per canviar en segons",
+ "LabelTitle": "Títol",
+ "LabelToolsEmbedMetadata": "Incrusta metadades",
+ "LabelToolsEmbedMetadataDescription": "Incrusta metadades en els fitxers d'àudio, incloent la portada i capítols.",
+ "LabelToolsM4bEncoder": "Codificador M4B",
+ "LabelToolsMakeM4b": "Crea fitxer d'audiollibre M4B",
+ "LabelToolsMakeM4bDescription": "Genera un fitxer d'audiollibre .M4B amb metadades, imatges de portada i capítols incrustats.",
+ "LabelToolsSplitM4b": "Divideix M4B en fitxers MP3",
+ "LabelToolsSplitM4bDescription": "Divideix un M4B en fitxers MP3 i incrusta metadades, imatges de portada i capítols.",
+ "LabelTotalDuration": "Duració total",
+ "LabelTotalTimeListened": "Temps total escoltat",
+ "LabelTrackFromFilename": "Pista des del nom del fitxer",
+ "LabelTrackFromMetadata": "Pista des de metadades",
+ "LabelTracks": "Pistes",
+ "LabelTracksMultiTrack": "Diverses pistes",
+ "LabelTracksNone": "Cap pista",
+ "LabelTracksSingleTrack": "Una pista",
+ "LabelTrailer": "Tràiler",
+ "LabelType": "Tipus",
+ "LabelUnabridged": "No abreujat",
+ "LabelUndo": "Desfés",
+ "LabelUnknown": "Desconegut",
+ "LabelUnknownPublishDate": "Data de publicació desconeguda",
+ "LabelUpdateCover": "Actualitza portada",
+ "LabelUpdateCoverHelp": "Permet sobreescriure les portades existents dels llibres seleccionats quan es trobi una coincidència.",
+ "LabelUpdateDetails": "Actualitza detalls",
+ "LabelUpdateDetailsHelp": "Permet sobreescriure els detalls existents dels llibres seleccionats quan es trobin coincidències.",
+ "LabelUpdatedAt": "Actualitzat a",
+ "LabelUploaderDragAndDrop": "Arrossega i deixa anar fitxers o carpetes",
+ "LabelUploaderDragAndDropFilesOnly": "Arrossega i deixa anar fitxers",
+ "LabelUploaderDropFiles": "Deixa anar els fitxers",
+ "LabelUploaderItemFetchMetadataHelp": "Cerca títol, autor i sèries automàticament",
+ "LabelUseAdvancedOptions": "Utilitza opcions avançades",
+ "LabelUseChapterTrack": "Utilitza pista per capítol",
+ "LabelUseFullTrack": "Utilitza pista completa",
+ "LabelUseZeroForUnlimited": "Utilitza 0 per il·limitat",
+ "LabelUser": "Usuari",
+ "LabelUsername": "Nom d'usuari",
+ "LabelValue": "Valor",
+ "LabelVersion": "Versió",
+ "LabelViewBookmarks": "Mostra marcadors",
+ "LabelViewChapters": "Mostra capítols",
+ "LabelViewPlayerSettings": "Mostra els ajustaments del reproductor",
+ "LabelViewQueue": "Mostra cua del reproductor",
+ "LabelVolume": "Volum",
+ "LabelWebRedirectURLsDescription": "Autoritza aquestes URL al teu proveïdor OAuth per permetre redirecció a l'aplicació web després d'iniciar sessió:",
+ "LabelWebRedirectURLsSubfolder": "Subcarpeta per a URL de redirecció",
+ "LabelWeekdaysToRun": "Executar en dies de la setmana",
+ "LabelXBooks": "{0} llibres",
+ "LabelXItems": "{0} elements",
+ "LabelYearReviewHide": "Oculta resum de l'any",
+ "LabelYearReviewShow": "Mostra resum de l'any",
+ "LabelYourAudiobookDuration": "Duració del teu audiollibre",
+ "LabelYourBookmarks": "Els teus marcadors",
+ "LabelYourPlaylists": "Les teves llistes",
+ "LabelYourProgress": "El teu progrés",
+ "MessageAddToPlayerQueue": "Afegeix a la cua del reproductor",
+ "MessageAppriseDescription": "Per utilitzar aquesta funció, hauràs de tenir l'
API d'Apprise en funcionament o una API que gestioni resultats similars.
La URL de l'API d'Apprise ha de tenir la mateixa ruta d'arxius que on s'envien les notificacions. Per exemple: si la teva API és a
http://192.168.1.1:8337, llavors posaries
http://192.168.1.1:8337/notify.",
+ "MessageBackupsDescription": "Les còpies de seguretat inclouen: usuaris, progrés dels usuaris, detalls dels elements de la biblioteca, configuració del servidor i imatges a
/metadata/items i
/metadata/authors. Les còpies de seguretat
NO inclouen cap fitxer guardat a la carpeta de la teva biblioteca.",
+ "MessageBackupsLocationEditNote": "Nota: Actualitzar la ubicació de la còpia de seguretat no mourà ni modificarà les còpies existents",
+ "MessageBackupsLocationNoEditNote": "Nota: La ubicació de la còpia de seguretat es defineix mitjançant una variable d'entorn i no es pot modificar aquí.",
+ "MessageBackupsLocationPathEmpty": "La ruta de la còpia de seguretat no pot estar buida",
+ "MessageBatchQuickMatchDescription": "La funció \"Troba Ràpid\" intentarà afegir portades i metadades que falten als elements seleccionats. Activa l'opció següent perquè \"Troba Ràpid\" pugui sobreescriure portades i/o metadades existents.",
+ "MessageBookshelfNoCollections": "No tens cap col·lecció",
+ "MessageBookshelfNoRSSFeeds": "Cap font RSS està oberta",
+ "MessageBookshelfNoResultsForFilter": "Cap resultat per al filtre \"{0}: {1}\"",
+ "MessageBookshelfNoResultsForQuery": "Cap resultat per a la consulta",
+ "MessageBookshelfNoSeries": "No tens cap sèrie",
+ "MessageChapterEndIsAfter": "El final del capítol és després del final del teu audiollibre",
+ "MessageChapterErrorFirstNotZero": "El primer capítol ha de començar a 0",
+ "MessageChapterErrorStartGteDuration": "El temps d'inici no és vàlid: ha de ser inferior a la durada de l'audiollibre",
+ "MessageChapterErrorStartLtPrev": "El temps d'inici no és vàlid: ha de ser igual o més gran que el temps d'inici del capítol anterior",
+ "MessageChapterStartIsAfter": "L'inici del capítol és després del final del teu audiollibre",
+ "MessageCheckingCron": "Comprovant cron...",
+ "MessageConfirmCloseFeed": "Estàs segur que vols tancar aquesta font?",
+ "MessageConfirmDeleteBackup": "Estàs segur que vols eliminar la còpia de seguretat {0}?",
+ "MessageConfirmDeleteDevice": "Estàs segur que vols eliminar el lector electrònic \"{0}\"?",
+ "MessageConfirmDeleteFile": "Això eliminarà el fitxer del teu sistema. Estàs segur?",
+ "MessageConfirmDeleteLibrary": "Estàs segur que vols eliminar permanentment la biblioteca \"{0}\"?",
+ "MessageConfirmDeleteLibraryItem": "Això eliminarà l'element de la base de dades i del sistema. Estàs segur?",
+ "MessageConfirmDeleteLibraryItems": "Això eliminarà {0} element(s) de la base de dades i del sistema. Estàs segur?",
+ "MessageConfirmDeleteMetadataProvider": "Estàs segur que vols eliminar el proveïdor de metadades personalitzat \"{0}\"?",
+ "MessageConfirmDeleteNotification": "Estàs segur que vols eliminar aquesta notificació?",
+ "MessageConfirmDeleteSession": "Estàs segur que vols eliminar aquesta sessió?",
+ "MessageConfirmEmbedMetadataInAudioFiles": "Estàs segur que vols incrustar metadades a {0} fitxer(s) d'àudio?",
+ "MessageConfirmForceReScan": "Estàs segur que vols forçar un reescaneig?",
+ "MessageConfirmMarkAllEpisodesFinished": "Estàs segur que vols marcar tots els episodis com a acabats?",
+ "MessageConfirmMarkAllEpisodesNotFinished": "Estàs segur que vols marcar tots els episodis com a no acabats?",
+ "MessageConfirmMarkItemFinished": "Estàs segur que vols marcar \"{0}\" com a acabat?",
+ "MessageConfirmMarkItemNotFinished": "Estàs segur que vols marcar \"{0}\" com a no acabat?",
+ "MessageConfirmMarkSeriesFinished": "Estàs segur que vols marcar tots els llibres d'aquesta sèrie com a acabats?",
+ "MessageConfirmMarkSeriesNotFinished": "Estàs segur que vols marcar tots els llibres d'aquesta sèrie com a no acabats?",
+ "MessageConfirmNotificationTestTrigger": "Vols activar aquesta notificació amb dades de prova?",
+ "MessageConfirmPurgeCache": "Esborrar la memòria cau eliminarà tot el directori localitzat a
/metadata/cache.
Estàs segur que vols eliminar-lo?",
+ "MessageConfirmPurgeItemsCache": "Esborrar la memòria cau dels elements eliminarà el directori
/metadata/cache/items.
Estàs segur?",
+ "MessageConfirmQuickEmbed": "Advertència! La integració ràpida no fa còpies de seguretat dels teus fitxers d'àudio. Assegura't d'haver-ne fet una còpia abans.
Vols continuar?",
+ "MessageConfirmQuickMatchEpisodes": "El reconeixement ràpid sobreescriurà els detalls si es troba una coincidència. Estàs segur?",
+ "MessageConfirmReScanLibraryItems": "Estàs segur que vols reescanejar {0} element(s)?",
+ "MessageConfirmRemoveAllChapters": "Estàs segur que vols eliminar tots els capítols?",
+ "MessageConfirmRemoveAuthor": "Estàs segur que vols eliminar l'autor \"{0}\"?",
+ "MessageConfirmRemoveCollection": "Estàs segur que vols eliminar la col·lecció \"{0}\"?",
+ "MessageConfirmRemoveEpisode": "Estàs segur que vols eliminar l'episodi \"{0}\"?",
+ "MessageConfirmRemoveEpisodes": "Estàs segur que vols eliminar {0} episodis?",
+ "MessageConfirmRemoveListeningSessions": "Estàs segur que vols eliminar {0} sessions d'escolta?",
+ "MessageConfirmRemoveMetadataFiles": "Estàs segur que vols eliminar tots els fitxers de metadades.{0} de les carpetes dels elements de la teva biblioteca?",
+ "MessageConfirmRemoveNarrator": "Estàs segur que vols eliminar el narrador \"{0}\"?",
+ "MessageConfirmRemovePlaylist": "Estàs segur que vols eliminar la llista de reproducció \"{0}\"?",
+ "MessageConfirmRenameGenre": "Estàs segur que vols canviar el gènere \"{0}\" a \"{1}\" per a tots els elements?",
+ "MessageConfirmRenameGenreMergeNote": "Nota: Aquest gènere ja existeix, i es fusionarà.",
+ "MessageConfirmRenameGenreWarning": "Advertència! Ja existeix un gènere similar \"{0}\".",
+ "MessageConfirmRenameTag": "Estàs segur que vols canviar l'etiqueta \"{0}\" a \"{1}\" per a tots els elements?",
+ "MessageConfirmRenameTagMergeNote": "Nota: Aquesta etiqueta ja existeix, i es fusionarà.",
+ "MessageConfirmRenameTagWarning": "Advertència! Ja existeix una etiqueta similar \"{0}\".",
+ "MessageConfirmResetProgress": "Estàs segur que vols reiniciar el teu progrés?",
+ "MessageConfirmSendEbookToDevice": "Estàs segur que vols enviar {0} ebook(s) \"{1}\" al dispositiu \"{2}\"?",
+ "MessageConfirmUnlinkOpenId": "Estàs segur que vols desvincular aquest usuari d'OpenID?",
+ "MessageDownloadingEpisode": "Descarregant capítol",
+ "MessageDragFilesIntoTrackOrder": "Arrossega els fitxers en l'ordre correcte de les pistes",
+ "MessageEmbedFailed": "Error en incrustar!",
+ "MessageEmbedFinished": "Incrustació acabada!",
+ "MessageEmbedQueue": "En cua per incrustar metadades ({0} en cua)",
+ "MessageMarkAsFinished": "Marcar com acabat",
+ "MessageMarkAsNotFinished": "Marcar com no acabat",
+ "MessageMatchBooksDescription": "S'intentarà fer coincidir els llibres de la biblioteca amb un llibre del proveïdor de cerca seleccionat, i s'ompliran els detalls buits i la portada. No sobreescriu els detalls.",
+ "MessageNoAudioTracks": "Sense pistes d'àudio",
+ "MessageNoAuthors": "Sense autors",
+ "MessageNoBackups": "Sense còpies de seguretat",
+ "MessageNoBookmarks": "Sense marcadors",
+ "MessageNoChapters": "Sense capítols",
+ "MessageNoCollections": "Sense col·leccions",
+ "MessageNoCoversFound": "Cap portada trobada",
+ "MessageNoDescription": "Sense descripció",
+ "MessageNoDevices": "Sense dispositius",
+ "MessageNoDownloadsInProgress": "No hi ha descàrregues en curs",
+ "MessageNoDownloadsQueued": "Sense cua de descàrrega",
+ "MessageNoEpisodeMatchesFound": "No s'han trobat episodis que coincideixin",
+ "MessageNoEpisodes": "Sense episodis",
+ "MessageNoFoldersAvailable": "No hi ha carpetes disponibles",
+ "MessageNoGenres": "Sense gèneres",
+ "MessageNoIssues": "Sense problemes",
+ "MessageNoItems": "Sense elements",
+ "MessageNoItemsFound": "Cap element trobat",
+ "MessageNoListeningSessions": "Sense sessions escoltades",
+ "MessageNoLogs": "Sense registres",
+ "MessageNoMediaProgress": "Sense progrés multimèdia",
+ "MessageNoNotifications": "Sense notificacions",
+ "MessageNoPodcastFeed": "Podcast no vàlid: sense font",
+ "MessageNoPodcastsFound": "Cap podcast trobat",
+ "MessageNoResults": "Sense resultats",
+ "MessageNoSearchResultsFor": "No hi ha resultats per a la cerca \"{0}\"",
+ "MessageNoSeries": "Sense sèries",
+ "MessageNoTags": "Sense etiquetes",
+ "MessageNoTasksRunning": "Sense tasques en execució",
+ "MessageNoUpdatesWereNecessary": "No calien actualitzacions",
+ "MessageNoUserPlaylists": "No tens cap llista de reproducció",
+ "MessageNotYetImplemented": "Encara no implementat",
+ "MessageOpmlPreviewNote": "Nota: Aquesta és una vista prèvia de l'arxiu OPML analitzat. El títol real del podcast s'obtindrà del canal RSS.",
+ "MessageOr": "o",
+ "MessagePauseChapter": "Pausar la reproducció del capítol",
+ "MessagePlayChapter": "Escoltar l'inici del capítol",
+ "MessagePlaylistCreateFromCollection": "Crear una llista de reproducció a partir d'una col·lecció",
+ "MessagePleaseWait": "Espera si us plau...",
+ "MessagePodcastHasNoRSSFeedForMatching": "El podcast no té una URL de font RSS que es pugui utilitzar",
+ "MessagePodcastSearchField": "Introdueix el terme de cerca o la URL de la font RSS",
+ "MessageQuickEmbedInProgress": "Integració ràpida en procés",
+ "MessageQuickEmbedQueue": "En cua per a inserció ràpida ({0} en cua)",
+ "MessageQuickMatchAllEpisodes": "Combina ràpidament tots els episodis",
+ "MessageQuickMatchDescription": "Omple detalls d'elements buits i portades amb els primers resultats de '{0}'. No sobreescriu els detalls tret que l'opció \"Preferir metadades trobades\" del servidor estigui habilitada.",
+ "MessageRemoveChapter": "Eliminar capítols",
+ "MessageRemoveEpisodes": "Eliminar {0} episodi(s)",
+ "MessageRemoveFromPlayerQueue": "Eliminar de la cua del reproductor",
+ "MessageRemoveUserWarning": "Estàs segur que vols eliminar l'usuari \"{0}\"?",
+ "MessageReportBugsAndContribute": "Informa d'errors, sol·licita funcions i contribueix a",
+ "MessageResetChaptersConfirm": "Estàs segur que vols desfer els canvis i revertir els capítols al seu estat original?",
+ "MessageRestoreBackupConfirm": "Estàs segur que vols restaurar la còpia de seguretat creada a",
+ "MessageRestoreBackupWarning": "Restaurar sobreescriurà tota la base de dades situada a /config i les imatges de portades a /metadata/items i /metadata/authors.
La còpia de seguretat no modifica cap fitxer a les carpetes de la teva biblioteca. Si has activat l'opció del servidor per guardar portades i metadades a les carpetes de la biblioteca, aquests fitxers no es guarden ni sobreescriuen.
Tots els clients que utilitzin el teu servidor s'actualitzaran automàticament.",
+ "MessageSearchResultsFor": "Resultats de la cerca de",
+ "MessageSelected": "{0} seleccionat(s)",
+ "MessageServerCouldNotBeReached": "No es va poder establir la connexió amb el servidor",
+ "MessageSetChaptersFromTracksDescription": "Establir capítols utilitzant cada fitxer d'àudio com un capítol i el títol del capítol com el nom del fitxer d'àudio",
+ "MessageShareExpirationWillBe": "La caducitat serà
{0}",
+ "MessageShareExpiresIn": "Caduca en {0}",
+ "MessageShareURLWillBe": "La URL per compartir serà
{0}",
+ "MessageStartPlaybackAtTime": "Començar la reproducció per a \"{0}\" a {1}?",
+ "MessageTaskAudioFileNotWritable": "El fitxer d'àudio \"{0}\" no es pot escriure",
+ "MessageTaskCanceledByUser": "Tasca cancel·lada per l'usuari",
+ "MessageTaskDownloadingEpisodeDescription": "Descarregant l'episodi \"{0}\"",
+ "MessageTaskEmbeddingMetadata": "Inserint metadades",
+ "MessageTaskEmbeddingMetadataDescription": "Inserint metadades en l'audiollibre \"{0}\"",
+ "MessageTaskEncodingM4b": "Codificant M4B",
+ "MessageTaskEncodingM4bDescription": "Codificant l'audiollibre \"{0}\" en un únic fitxer M4B",
+ "MessageTaskFailed": "Fallada",
+ "MessageTaskFailedToBackupAudioFile": "Error en fer una còpia de seguretat del fitxer d'àudio \"{0}\"",
+ "MessageTaskFailedToCreateCacheDirectory": "Error en crear el directori de la memòria cau",
+ "MessageTaskFailedToEmbedMetadataInFile": "Error en incrustar metadades en el fitxer \"{0}\"",
+ "MessageTaskFailedToMergeAudioFiles": "Error en fusionar fitxers d'àudio",
+ "MessageTaskFailedToMoveM4bFile": "Error en moure el fitxer M4B",
+ "MessageTaskFailedToWriteMetadataFile": "Error en escriure el fitxer de metadades",
+ "MessageTaskMatchingBooksInLibrary": "Coincidint llibres a la biblioteca \"{0}\"",
+ "MessageTaskNoFilesToScan": "Sense fitxers per escanejar",
+ "MessageTaskOpmlImport": "Importar OPML",
+ "MessageTaskOpmlImportDescription": "Creant podcasts a partir de {0} fonts RSS",
+ "MessageTaskOpmlImportFeed": "Importació de feed OPML",
+ "MessageTaskOpmlImportFeedDescription": "Importació del feed RSS \"{0}\"",
+ "MessageTaskOpmlImportFeedFailed": "No es pot obtenir el podcast",
+ "MessageTaskOpmlImportFeedPodcastDescription": "Creant el podcast \"{0}\"",
+ "MessageTaskOpmlImportFeedPodcastExists": "El podcast ja existeix a la ruta",
+ "MessageTaskOpmlImportFeedPodcastFailed": "Error en crear el podcast",
+ "MessageTaskOpmlImportFinished": "Afegit {0} podcasts",
+ "MessageTaskOpmlParseFailed": "No s'ha pogut analitzar el fitxer OPML",
+ "MessageTaskOpmlParseFastFail": "No s'ha trobat l'etiqueta
o al fitxer OPML",
+ "MessageTaskOpmlParseNoneFound": "No s'han trobat fonts al fitxer OPML",
+ "MessageTaskScanItemsAdded": "{0} afegit",
+ "MessageTaskScanItemsMissing": "{0} faltant",
+ "MessageTaskScanItemsUpdated": "{0} actualitzat",
+ "MessageTaskScanNoChangesNeeded": "No calen canvis",
+ "MessageTaskScanningFileChanges": "Escanejant canvis al fitxer en \"{0}\"",
+ "MessageTaskScanningLibrary": "Escanejant la biblioteca \"{0}\"",
+ "MessageTaskTargetDirectoryNotWritable": "El directori de destinació no es pot escriure",
+ "MessageThinking": "Pensant...",
+ "MessageUploaderItemFailed": "Error en pujar",
+ "MessageUploaderItemSuccess": "Pujada amb èxit!",
+ "MessageUploading": "Pujant...",
+ "MessageValidCronExpression": "Expressió de cron vàlida",
+ "MessageWatcherIsDisabledGlobally": "El watcher està desactivat globalment a la configuració del servidor",
+ "MessageXLibraryIsEmpty": "La biblioteca {0} està buida!",
+ "MessageYourAudiobookDurationIsLonger": "La durada del teu audiollibre és més llarga que la durada trobada",
+ "MessageYourAudiobookDurationIsShorter": "La durada del teu audiollibre és més curta que la durada trobada",
+ "NoteChangeRootPassword": "L'usuari Root és l'únic usuari que pot no tenir una contrasenya",
+ "NoteChapterEditorTimes": "Nota: El temps d'inici del primer capítol ha de romandre a 0:00, i el temps d'inici de l'últim capítol no pot superar la durada de l'audiollibre.",
+ "NoteFolderPicker": "Nota: Les carpetes ja assignades no es mostraran",
+ "NoteRSSFeedPodcastAppsHttps": "Advertència: La majoria d'aplicacions de podcast requereixen que la URL de la font RSS utilitzi HTTPS",
+ "NoteRSSFeedPodcastAppsPubDate": "Advertència: Un o més dels teus episodis no tenen data de publicació. Algunes aplicacions de podcast ho requereixen.",
+ "NoteUploaderFoldersWithMediaFiles": "Les carpetes amb fitxers multimèdia es gestionaran com a elements separats a la biblioteca.",
+ "NoteUploaderOnlyAudioFiles": "Si només puges fitxers d'àudio, cada fitxer es gestionarà com un audiollibre separat.",
+ "NoteUploaderUnsupportedFiles": "Els fitxers no compatibles seran ignorats. Si selecciona o arrossega una carpeta, els fitxers que no estiguin dins d'una subcarpeta seran ignorats.",
+ "NotificationOnBackupCompletedDescription": "S'activa quan es completa una còpia de seguretat",
+ "NotificationOnBackupFailedDescription": "S'activa quan falla una còpia de seguretat",
+ "NotificationOnEpisodeDownloadedDescription": "S'activa quan es descarrega automàticament un episodi d'un podcast",
+ "NotificationOnTestDescription": "Esdeveniment per provar el sistema de notificacions",
+ "PlaceholderNewCollection": "Nou nom de la col·lecció",
+ "PlaceholderNewFolderPath": "Nova ruta de carpeta",
+ "PlaceholderNewPlaylist": "Nou nom de la llista de reproducció",
+ "PlaceholderSearch": "Cerca...",
+ "PlaceholderSearchEpisode": "Cerca d'episodis...",
+ "StatsAuthorsAdded": "autors afegits",
+ "StatsBooksAdded": "llibres afegits",
+ "StatsBooksAdditional": "Algunes addicions inclouen…",
+ "StatsBooksFinished": "llibres acabats",
+ "StatsBooksFinishedThisYear": "Alguns llibres acabats aquest any…",
+ "StatsBooksListenedTo": "llibres escoltats",
+ "StatsCollectionGrewTo": "La teva col·lecció de llibres ha crescut fins a…",
+ "StatsSessions": "sessions",
+ "StatsSpentListening": "dedicat a escoltar",
+ "StatsTopAuthor": "AUTOR DESTACAT",
+ "StatsTopAuthors": "AUTORS DESTACATS",
+ "StatsTopGenre": "GÈNERE PRINCIPAL",
+ "StatsTopGenres": "GÈNERES PRINCIPALS",
+ "StatsTopMonth": "DESTACAT DEL MES",
+ "StatsTopNarrator": "NARRADOR DESTACAT",
+ "StatsTopNarrators": "NARRADORS DESTACATS",
+ "StatsTotalDuration": "Amb una durada total de…",
+ "StatsYearInReview": "RESUM DE L'ANY",
+ "ToastAccountUpdateSuccess": "Compte actualitzat",
+ "ToastAppriseUrlRequired": "Cal introduir una URL de Apprise",
+ "ToastAsinRequired": "ASIN requerit",
+ "ToastAuthorImageRemoveSuccess": "S'ha eliminat la imatge de l'autor",
+ "ToastAuthorNotFound": "No s'ha trobat l'autor \"{0}\"",
+ "ToastAuthorRemoveSuccess": "Autor eliminat",
+ "ToastAuthorSearchNotFound": "No s'ha trobat l'autor",
+ "ToastAuthorUpdateMerged": "Autor combinat",
+ "ToastAuthorUpdateSuccess": "Autor actualitzat",
+ "ToastAuthorUpdateSuccessNoImageFound": "Autor actualitzat (Imatge no trobada)",
+ "ToastBackupAppliedSuccess": "Còpia de seguretat aplicada",
+ "ToastBackupCreateFailed": "Error en crear la còpia de seguretat",
+ "ToastBackupCreateSuccess": "Còpia de seguretat creada",
+ "ToastBackupDeleteFailed": "Error en eliminar la còpia de seguretat",
+ "ToastBackupDeleteSuccess": "Còpia de seguretat eliminada",
+ "ToastBackupInvalidMaxKeep": "Nombre no vàlid de còpies de seguretat a conservar",
+ "ToastBackupInvalidMaxSize": "Mida màxima de còpia de seguretat no vàlida",
+ "ToastBackupRestoreFailed": "Error en restaurar la còpia de seguretat",
+ "ToastBackupUploadFailed": "Error en carregar la còpia de seguretat",
+ "ToastBackupUploadSuccess": "Còpia de seguretat carregada",
+ "ToastBatchDeleteFailed": "Error en l'eliminació per lots",
+ "ToastBatchDeleteSuccess": "Eliminació per lots correcte",
+ "ToastBatchQuickMatchFailed": "Error en la sincronització ràpida per lots!",
+ "ToastBatchQuickMatchStarted": "S'ha iniciat la sincronització ràpida per lots de {0} llibres!",
+ "ToastBatchUpdateFailed": "Error en l'actualització massiva",
+ "ToastBatchUpdateSuccess": "Actualització massiva completada",
+ "ToastBookmarkCreateFailed": "Error en crear marcador",
+ "ToastBookmarkCreateSuccess": "Marcador afegit",
+ "ToastBookmarkRemoveSuccess": "Marcador eliminat",
+ "ToastBookmarkUpdateSuccess": "Marcador actualitzat",
+ "ToastCachePurgeFailed": "Error en purgar la memòria cau",
+ "ToastCachePurgeSuccess": "Memòria cau purgada amb èxit",
+ "ToastChaptersHaveErrors": "Els capítols tenen errors",
+ "ToastChaptersMustHaveTitles": "Els capítols han de tenir un títol",
+ "ToastChaptersRemoved": "Capítols eliminats",
+ "ToastChaptersUpdated": "Capítols actualitzats",
+ "ToastCollectionItemsAddFailed": "Error en afegir elements a la col·lecció",
+ "ToastCollectionItemsAddSuccess": "Elements afegits a la col·lecció",
+ "ToastCollectionItemsRemoveSuccess": "Elements eliminats de la col·lecció",
+ "ToastCollectionRemoveSuccess": "Col·lecció eliminada",
+ "ToastCollectionUpdateSuccess": "Col·lecció actualitzada",
+ "ToastCoverUpdateFailed": "Error en actualitzar la portada",
+ "ToastDeleteFileFailed": "Error en eliminar l'arxiu",
+ "ToastDeleteFileSuccess": "Arxiu eliminat",
+ "ToastDeviceAddFailed": "Error en afegir el dispositiu",
+ "ToastDeviceNameAlreadyExists": "Ja existeix un dispositiu amb aquest nom",
+ "ToastDeviceTestEmailFailed": "Error en enviar el correu de prova",
+ "ToastDeviceTestEmailSuccess": "Correu de prova enviat",
+ "ToastEmailSettingsUpdateSuccess": "Configuració de correu electrònic actualitzada",
+ "ToastEncodeCancelFailed": "No s'ha pogut cancel·lar la codificació",
+ "ToastEncodeCancelSucces": "Codificació cancel·lada",
+ "ToastEpisodeDownloadQueueClearFailed": "No s'ha pogut buidar la cua de descàrregues",
+ "ToastEpisodeDownloadQueueClearSuccess": "Cua de descàrregues buidada",
+ "ToastEpisodeUpdateSuccess": "{0} episodi(s) actualitzat(s)",
+ "ToastErrorCannotShare": "No es pot compartir de manera nativa en aquest dispositiu",
+ "ToastFailedToLoadData": "Error en carregar les dades",
+ "ToastFailedToMatch": "Error en emparellar",
+ "ToastFailedToShare": "Error en compartir",
+ "ToastFailedToUpdate": "Error en actualitzar",
+ "ToastInvalidImageUrl": "URL de la imatge no vàlida",
+ "ToastInvalidMaxEpisodesToDownload": "Nombre màxim d'episodis per descarregar no vàlid",
+ "ToastInvalidUrl": "URL no vàlida",
+ "ToastItemCoverUpdateSuccess": "Portada de l'element actualitzada",
+ "ToastItemDeletedFailed": "Error en eliminar l'element",
+ "ToastItemDeletedSuccess": "Element eliminat",
+ "ToastItemDetailsUpdateSuccess": "Detalls de l'element actualitzats",
+ "ToastItemMarkedAsFinishedFailed": "Error en marcar com a acabat",
+ "ToastItemMarkedAsFinishedSuccess": "Element marcat com a acabat",
+ "ToastItemMarkedAsNotFinishedFailed": "Error en marcar com a no acabat",
+ "ToastItemMarkedAsNotFinishedSuccess": "Element marcat com a no acabat",
+ "ToastItemUpdateSuccess": "Element actualitzat",
+ "ToastLibraryCreateFailed": "Error en crear la biblioteca",
+ "ToastLibraryCreateSuccess": "Biblioteca \"{0}\" creada",
+ "ToastLibraryDeleteFailed": "Error en eliminar la biblioteca",
+ "ToastLibraryDeleteSuccess": "Biblioteca eliminada",
+ "ToastLibraryScanFailedToStart": "Error en iniciar l'escaneig",
+ "ToastLibraryScanStarted": "S'ha iniciat l'escaneig de la biblioteca",
+ "ToastLibraryUpdateSuccess": "Biblioteca \"{0}\" actualitzada",
+ "ToastMatchAllAuthorsFailed": "No coincideix amb tots els autors",
+ "ToastMetadataFilesRemovedError": "Error en eliminar metadades de {0} arxius",
+ "ToastMetadataFilesRemovedNoneFound": "No s'han trobat metadades en {0} arxius",
+ "ToastMetadataFilesRemovedNoneRemoved": "Cap metadada eliminada en {0} arxius",
+ "ToastMetadataFilesRemovedSuccess": "{0} metadades eliminades en {1} arxius",
+ "ToastMustHaveAtLeastOnePath": "Ha de tenir almenys una ruta",
+ "ToastNameEmailRequired": "El nom i el correu electrònic són obligatoris",
+ "ToastNameRequired": "Nom obligatori",
+ "ToastNewEpisodesFound": "{0} episodi(s) nou(s) trobat(s)",
+ "ToastNewUserCreatedFailed": "Error en crear el compte: \"{0}\"",
+ "ToastNewUserCreatedSuccess": "Nou compte creat",
+ "ToastNewUserLibraryError": "Ha de seleccionar almenys una biblioteca",
+ "ToastNewUserPasswordError": "Necessites una contrasenya, només el root pot estar sense contrasenya",
+ "ToastNewUserTagError": "Selecciona almenys una etiqueta",
+ "ToastNewUserUsernameError": "Introdueix un nom d'usuari",
+ "ToastNoNewEpisodesFound": "No s'han trobat nous episodis",
+ "ToastNoUpdatesNecessary": "No cal actualitzar",
+ "ToastNotificationCreateFailed": "Error en crear la notificació",
+ "ToastNotificationDeleteFailed": "Error en eliminar la notificació",
+ "ToastNotificationFailedMaximum": "El nombre màxim d'intents fallits ha de ser ≥ 0",
+ "ToastNotificationQueueMaximum": "La cua de notificació màxima ha de ser ≥ 0",
+ "ToastNotificationSettingsUpdateSuccess": "Configuració de notificació actualitzada",
+ "ToastNotificationTestTriggerFailed": "No s'ha pogut activar la notificació de prova",
+ "ToastNotificationTestTriggerSuccess": "Notificació de prova activada",
+ "ToastNotificationUpdateSuccess": "Notificació actualitzada",
+ "ToastPlaylistCreateFailed": "Error en crear la llista de reproducció",
+ "ToastPlaylistCreateSuccess": "Llista de reproducció creada",
+ "ToastPlaylistRemoveSuccess": "Llista de reproducció eliminada",
+ "ToastPlaylistUpdateSuccess": "Llista de reproducció actualitzada",
+ "ToastPodcastCreateFailed": "Error en crear el podcast",
+ "ToastPodcastCreateSuccess": "Podcast creat",
+ "ToastPodcastGetFeedFailed": "No s'ha pogut obtenir el podcast",
+ "ToastPodcastNoEpisodesInFeed": "No s'han trobat episodis en el feed RSS",
+ "ToastPodcastNoRssFeed": "El podcast no té un feed RSS",
+ "ToastProgressIsNotBeingSynced": "El progrés no s'està sincronitzant, reinicia la reproducció",
+ "ToastProviderCreatedFailed": "Error en afegir el proveïdor",
+ "ToastProviderCreatedSuccess": "Nou proveïdor afegit",
+ "ToastProviderNameAndUrlRequired": "Nom i URL obligatoris",
+ "ToastProviderRemoveSuccess": "Proveïdor eliminat",
+ "ToastRSSFeedCloseFailed": "Error en tancar el feed RSS",
+ "ToastRSSFeedCloseSuccess": "Feed RSS tancat",
+ "ToastRemoveFailed": "Error en eliminar",
+ "ToastRemoveItemFromCollectionFailed": "Error en eliminar l'element de la col·lecció",
+ "ToastRemoveItemFromCollectionSuccess": "Element eliminat de la col·lecció",
+ "ToastRemoveItemsWithIssuesFailed": "Error en eliminar elements incorrectes de la biblioteca",
+ "ToastRemoveItemsWithIssuesSuccess": "S'han eliminat els elements incorrectes de la biblioteca",
+ "ToastRenameFailed": "Error en canviar el nom",
+ "ToastRescanFailed": "Error en reescanejar per a {0}",
+ "ToastRescanRemoved": "Element reescanejat eliminat",
+ "ToastRescanUpToDate": "Reescaneig completat, l'element ja estava actualitzat",
+ "ToastRescanUpdated": "Reescaneig completat, l'element ha estat actualitzat",
+ "ToastScanFailed": "No s'ha pogut escanejar l'element de la biblioteca",
+ "ToastSelectAtLeastOneUser": "Selecciona almenys un usuari",
+ "ToastSendEbookToDeviceFailed": "Error en enviar l'ebook al dispositiu",
+ "ToastSendEbookToDeviceSuccess": "Ebook enviat al dispositiu \"{0}\"",
+ "ToastSeriesUpdateFailed": "Error en actualitzar la sèrie",
+ "ToastSeriesUpdateSuccess": "Sèrie actualitzada",
+ "ToastServerSettingsUpdateSuccess": "Configuració del servidor actualitzada",
+ "ToastSessionCloseFailed": "Error en tancar la sessió",
+ "ToastSessionDeleteFailed": "Error en eliminar la sessió",
+ "ToastSessionDeleteSuccess": "Sessió eliminada",
+ "ToastSleepTimerDone": "Temporitzador d'apagada activat... zZzzZz",
+ "ToastSlugMustChange": "L'slug conté caràcters no vàlids",
+ "ToastSlugRequired": "Slug obligatori",
+ "ToastSocketConnected": "Socket connectat",
+ "ToastSocketDisconnected": "Socket desconnectat",
+ "ToastSocketFailedToConnect": "Error en connectar al Socket",
+ "ToastSortingPrefixesEmptyError": "Cal tenir almenys 1 prefix per ordenar",
+ "ToastSortingPrefixesUpdateSuccess": "Prefixos d'ordenació actualitzats ({0} elements)",
+ "ToastTitleRequired": "Títol obligatori",
+ "ToastUnknownError": "Error desconegut",
+ "ToastUnlinkOpenIdFailed": "Error en desvincular l'usuari d'OpenID",
+ "ToastUnlinkOpenIdSuccess": "Usuari desvinculat d'OpenID",
+ "ToastUserDeleteFailed": "Error en eliminar l'usuari",
+ "ToastUserDeleteSuccess": "Usuari eliminat",
+ "ToastUserPasswordChangeSuccess": "Contrasenya canviada correctament",
+ "ToastUserPasswordMismatch": "Les contrasenyes no coincideixen",
+ "ToastUserPasswordMustChange": "La nova contrasenya no pot ser igual a l'anterior",
+ "ToastUserRootRequireName": "Cal introduir un nom d'usuari root"
+}
diff --git a/client/strings/de.json b/client/strings/de.json
index 030f8f1b3..d3a10eadc 100644
--- a/client/strings/de.json
+++ b/client/strings/de.json
@@ -584,7 +584,7 @@
"LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet",
"LabelSettingsTimeFormat": "Zeitformat",
"LabelShare": "Freigeben",
- "LabelShareOpen": "Freigabe",
+ "LabelShareOpen": "Freigeben",
"LabelShareURL": "Freigabe URL",
"LabelShowAll": "Alles anzeigen",
"LabelShowSeconds": "Zeige Sekunden",
@@ -679,6 +679,8 @@
"LabelViewPlayerSettings": "Zeige player Einstellungen",
"LabelViewQueue": "Player-Warteschlange anzeigen",
"LabelVolume": "Lautstärke",
+ "LabelWebRedirectURLsDescription": "Autorisieren Sie diese URLs bei ihrem OAuth-Anbieter, um die Weiterleitung zurück zur Webanwendung nach dem Login zu ermöglichen:",
+ "LabelWebRedirectURLsSubfolder": "Unterordner für Weiterleitung-URLs",
"LabelWeekdaysToRun": "Wochentage für die Ausführung",
"LabelXBooks": "{0} Bücher",
"LabelXItems": "{0} Medien",
@@ -728,7 +730,7 @@
"MessageConfirmPurgeCache": "Cache leeren wird das ganze Verzeichnis /metadata/cache löschen.
Bist du dir sicher, dass das Cache Verzeichnis gelöscht werden soll?",
"MessageConfirmPurgeItemsCache": "Durch Elementcache leeren wird das gesamte Verzeichnis unter /metadata/cache/items gelöscht.
Bist du dir sicher?",
"MessageConfirmQuickEmbed": "Warnung! Audiodateien werden bei der Schnelleinbettung nicht gesichert! Achte darauf, dass du eine Sicherungskopie der Audiodateien besitzt.
Möchtest du fortfahren?",
- "MessageConfirmQuickMatchEpisodes": "Schnelles Zuordnen von Episoden überschreibt die Details, wenn eine Übereinstimmung gefunden wird. Nur nicht zugeordnete Episoden werden aktualisiert. Bist du sicher?",
+ "MessageConfirmQuickMatchEpisodes": "Schnellabgleich von Episoden überschreibt deren Details, wenn ein passender Eintrag gefunden wurde, wird aber nur auf bisher unbearbeitete Episoden angewendet. Wirklich fortfahren?",
"MessageConfirmReScanLibraryItems": "{0} Elemente werden erneut gescannt! Bist du dir sicher?",
"MessageConfirmRemoveAllChapters": "Alle Kapitel werden entfernt! Bist du dir sicher?",
"MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Bist du dir sicher?",
@@ -833,7 +835,7 @@
"MessageSetChaptersFromTracksDescription": "Kaitelerstellung basiert auf den existierenden einzelnen Audiodateien. Pro existierende Audiodatei wird 1 Kapitel erstellt, wobei deren Kapitelname aus dem Audiodateinamen extrahiert wird",
"MessageShareExpirationWillBe": "Läuft am {0} ab",
"MessageShareExpiresIn": "Läuft in {0} ab",
- "MessageShareURLWillBe": "Der Freigabe Link wird {0} sein.",
+ "MessageShareURLWillBe": "Der Freigabe Link wird {0} sein",
"MessageStartPlaybackAtTime": "Start der Wiedergabe für \"{0}\" bei {1}?",
"MessageTaskAudioFileNotWritable": "Die Audiodatei \"{0}\" ist schreibgeschützt",
"MessageTaskCanceledByUser": "Aufgabe vom Benutzer abgebrochen",
@@ -1041,7 +1043,7 @@
"ToastRenameFailed": "Umbenennen fehlgeschlagen",
"ToastRescanFailed": "Erneut scannen fehlgeschlagen für {0}",
"ToastRescanRemoved": "Erneut scannen erledigt, Artikel wurde entfernt",
- "ToastRescanUpToDate": "Erneut scannen erledigt, Artikel wahr auf dem neusten Stand",
+ "ToastRescanUpToDate": "Erneut scannen erledigt, Artikel war auf dem neusten Stand",
"ToastRescanUpdated": "Erneut scannen erledigt, Artikel wurde verändert",
"ToastScanFailed": "Fehler beim scannen des Artikels der Bibliothek",
"ToastSelectAtLeastOneUser": "Wähle mindestens einen Benutzer aus",
diff --git a/client/strings/en-us.json b/client/strings/en-us.json
index 0c077ed67..805e8f48b 100644
--- a/client/strings/en-us.json
+++ b/client/strings/en-us.json
@@ -190,6 +190,7 @@
"HeaderSettingsExperimental": "Experimental Features",
"HeaderSettingsGeneral": "General",
"HeaderSettingsScanner": "Scanner",
+ "HeaderSettingsWebClient": "Web Client",
"HeaderSleepTimer": "Sleep Timer",
"HeaderStatsLargestItems": "Largest Items",
"HeaderStatsLongestItems": "Longest Items (hrs)",
@@ -542,6 +543,7 @@
"LabelServerYearReview": "Server Year in Review ({0})",
"LabelSetEbookAsPrimary": "Set as primary",
"LabelSetEbookAsSupplementary": "Set as supplementary",
+ "LabelSettingsAllowIframe": "Allow embedding in an iframe",
"LabelSettingsAudiobooksOnly": "Audiobooks only",
"LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks",
"LabelSettingsBookshelfViewHelp": "Skeumorphic design with wooden shelves",
@@ -679,6 +681,8 @@
"LabelViewPlayerSettings": "View player settings",
"LabelViewQueue": "View player queue",
"LabelVolume": "Volume",
+ "LabelWebRedirectURLsDescription": "Authorize these URLs in your OAuth provider to allow redirection back to the web app after login:",
+ "LabelWebRedirectURLsSubfolder": "Subfolder for Redirect URLs",
"LabelWeekdaysToRun": "Weekdays to run",
"LabelXBooks": "{0} books",
"LabelXItems": "{0} items",
diff --git a/client/strings/es.json b/client/strings/es.json
index 76a62c161..87956e54b 100644
--- a/client/strings/es.json
+++ b/client/strings/es.json
@@ -679,6 +679,8 @@
"LabelViewPlayerSettings": "Ver los ajustes del reproductor",
"LabelViewQueue": "Ver Fila del Reproductor",
"LabelVolume": "Volumen",
+ "LabelWebRedirectURLsDescription": "Autorice estas URL en su proveedor OAuth para permitir la redirección a la aplicación web después de iniciar sesión:",
+ "LabelWebRedirectURLsSubfolder": "Subcarpeta para URL de redireccionamiento",
"LabelWeekdaysToRun": "Correr en Días de la Semana",
"LabelXBooks": "{0} libros",
"LabelXItems": "{0} elementos",
diff --git a/client/strings/hr.json b/client/strings/hr.json
index a7f2562b7..48d9b5a0f 100644
--- a/client/strings/hr.json
+++ b/client/strings/hr.json
@@ -271,7 +271,7 @@
"LabelCollapseSubSeries": "Podserijale prikaži sažeto",
"LabelCollection": "Zbirka",
"LabelCollections": "Zbirke",
- "LabelComplete": "Dovršeno",
+ "LabelComplete": "Potpuno",
"LabelConfirmPassword": "Potvrda zaporke",
"LabelContinueListening": "Nastavi slušati",
"LabelContinueReading": "Nastavi čitati",
@@ -532,7 +532,7 @@
"LabelSelectAllEpisodes": "Označi sve nastavke",
"LabelSelectEpisodesShowing": "Prikazujem {0} odabranih nastavaka",
"LabelSelectUsers": "Označi korisnike",
- "LabelSendEbookToDevice": "Pošalji e-knjigu",
+ "LabelSendEbookToDevice": "Pošalji e-knjigu …",
"LabelSequence": "Slijed",
"LabelSerial": "Serijal",
"LabelSeries": "Serijal",
@@ -567,7 +567,7 @@
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Preostalo vrijeme je manje od (sekundi)",
"LabelSettingsLibraryMarkAsFinishedWhen": "Označi medij dovršenim kada",
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Preskoči ranije knjige u funkciji Nastavi serijal",
- "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Na polici početne stranice Nastavi serijal prikazuje se prva nezapočeta knjiga serijala koji imaju barem jednu dovršenu knjigu i nijednu započetu knjigu. Ako uključite ovu opciju, serijal će vam se nastaviti od zadnje dovršene knjige umjesto od prve nezapočete knjige.",
+ "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Na polici početne stranice Nastavi serijal prikazuje se prva nezapočeta knjiga serijala koji imaju barem jednu dovršenu knjigu i nijednu započetu knjigu. Ako se ova opcija uključi serijal će nastaviti od zadnje dovršene knjige umjesto od prve nezapočete knjige.",
"LabelSettingsParseSubtitles": "Raščlani podnaslove",
"LabelSettingsParseSubtitlesHelp": "Iz naziva mape zvučne knjige raščlanjuje podnaslov.
Podnaslov mora biti odvojen s \" - \"
npr. \"Naslov knjige - Ovo je podnaslov\" imat će podnaslov \"Ovo je podnaslov\"",
"LabelSettingsPreferMatchedMetadata": "Daj prednost meta-podatcima prepoznatih stavki",
@@ -679,6 +679,8 @@
"LabelViewPlayerSettings": "Pogledaj postavke reproduktora",
"LabelViewQueue": "Pogledaj redoslijed izvođenja reproduktora",
"LabelVolume": "Glasnoća",
+ "LabelWebRedirectURLsDescription": "Autoriziraj ove URL-ove u svom pružatelju OAuth ovjere kako bi omogućio preusmjeravanje natrag na web-aplikaciju nakon prijave:",
+ "LabelWebRedirectURLsSubfolder": "Podmapa za URL-ove preusmjeravanja",
"LabelWeekdaysToRun": "Dani u tjednu za pokretanje",
"LabelXBooks": "{0} knjiga",
"LabelXItems": "{0} stavki",
diff --git a/client/strings/sl.json b/client/strings/sl.json
index 02c1fb132..58500f9fb 100644
--- a/client/strings/sl.json
+++ b/client/strings/sl.json
@@ -184,7 +184,7 @@
"HeaderScheduleEpisodeDownloads": "Načrtovanje samodejnega prenosa epizod",
"HeaderScheduleLibraryScans": "Načrtuj samodejno pregledovanje knjižnice",
"HeaderSession": "Seja",
- "HeaderSetBackupSchedule": "Nastavite urnik varnostnega kopiranja",
+ "HeaderSetBackupSchedule": "Nastavi urnik varnostnega kopiranja",
"HeaderSettings": "Nastavitve",
"HeaderSettingsDisplay": "Zaslon",
"HeaderSettingsExperimental": "Eksperimentalne funkcije",
@@ -679,6 +679,8 @@
"LabelViewPlayerSettings": "Ogled nastavitev predvajalnika",
"LabelViewQueue": "Ogled čakalno vrsto predvajalnika",
"LabelVolume": "Glasnost",
+ "LabelWebRedirectURLsDescription": "Avtorizirajte URL-je pri svojem ponudniku OAuth ter s tem omogočite preusmeritev nazaj v spletno aplikacijo po prijavi:",
+ "LabelWebRedirectURLsSubfolder": "Podmapa za URL-je preusmeritve",
"LabelWeekdaysToRun": "Delovni dnevi predvajanja",
"LabelXBooks": "{0} knjig",
"LabelXItems": "{0} elementov",
@@ -830,7 +832,7 @@
"MessageSearchResultsFor": "Rezultati iskanja za",
"MessageSelected": "{0} izbrano",
"MessageServerCouldNotBeReached": "Strežnika ni bilo mogoče doseči",
- "MessageSetChaptersFromTracksDescription": "Nastavite poglavja z uporabo vsake zvočne datoteke kot poglavja in naslova poglavja kot imena zvočne datoteke",
+ "MessageSetChaptersFromTracksDescription": "Nastavi poglavja z uporabo vsake zvočne datoteke kot poglavja in naslova poglavja kot imena zvočne datoteke",
"MessageShareExpirationWillBe": "Potečeno bo {0}",
"MessageShareExpiresIn": "Poteče čez {0}",
"MessageShareURLWillBe": "URL za skupno rabo bo {0}",
diff --git a/client/strings/uk.json b/client/strings/uk.json
index 448bbf4c8..f2342636b 100644
--- a/client/strings/uk.json
+++ b/client/strings/uk.json
@@ -679,6 +679,8 @@
"LabelViewPlayerSettings": "Переглянути налаштування програвача",
"LabelViewQueue": "Переглянути чергу відтворення",
"LabelVolume": "Гучність",
+ "LabelWebRedirectURLsDescription": "Авторизуйте ці URL у вашому OAuth постачальнику, щоб дозволити редирекцію назад до веб-додатку після входу:",
+ "LabelWebRedirectURLsSubfolder": "Підпапка для Redirect URL",
"LabelWeekdaysToRun": "Виконувати у дні",
"LabelXBooks": "{0} книг",
"LabelXItems": "{0} елементів",
diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json
index 072cbd39e..e4791aff5 100644
--- a/client/strings/zh-cn.json
+++ b/client/strings/zh-cn.json
@@ -663,6 +663,7 @@
"LabelUpdateDetailsHelp": "找到匹配项时允许覆盖所选书籍存在的详细信息",
"LabelUpdatedAt": "更新时间",
"LabelUploaderDragAndDrop": "拖放文件或文件夹",
+ "LabelUploaderDragAndDropFilesOnly": "拖放文件",
"LabelUploaderDropFiles": "删除文件",
"LabelUploaderItemFetchMetadataHelp": "自动获取标题, 作者和系列",
"LabelUseAdvancedOptions": "使用高级选项",
@@ -678,6 +679,8 @@
"LabelViewPlayerSettings": "查看播放器设置",
"LabelViewQueue": "查看播放列表",
"LabelVolume": "音量",
+ "LabelWebRedirectURLsDescription": "在你的 OAuth 提供商中授权这些链接,以允许在登录后重定向回 Web 应用程序:",
+ "LabelWebRedirectURLsSubfolder": "重定向 URL 的子文件夹",
"LabelWeekdaysToRun": "工作日运行",
"LabelXBooks": "{0} 本书",
"LabelXItems": "{0} 项目",
diff --git a/index.js b/index.js
index de1ed5c30..9a0be347c 100644
--- a/index.js
+++ b/index.js
@@ -11,6 +11,7 @@ if (isDev) {
if (devEnv.FFProbePath) process.env.FFPROBE_PATH = devEnv.FFProbePath
if (devEnv.NunicodePath) process.env.NUSQLITE3_PATH = devEnv.NunicodePath
if (devEnv.SkipBinariesCheck) process.env.SKIP_BINARIES_CHECK = '1'
+ if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1'
if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath
process.env.SOURCE = 'local'
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || ''
diff --git a/package-lock.json b/package-lock.json
index 062fb0322..efa917dcd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "audiobookshelf",
- "version": "2.17.3",
+ "version": "2.17.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf",
- "version": "2.17.3",
+ "version": "2.17.5",
"license": "GPL-3.0",
"dependencies": {
"axios": "^0.27.2",
diff --git a/package.json b/package.json
index db63261b1..2e9c97090 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
- "version": "2.17.3",
+ "version": "2.17.5",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",
diff --git a/server/Auth.js b/server/Auth.js
index b0046799b..74b767f5b 100644
--- a/server/Auth.js
+++ b/server/Auth.js
@@ -131,7 +131,7 @@ class Auth {
{
client: openIdClient,
params: {
- redirect_uri: '/auth/openid/callback',
+ redirect_uri: `${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`,
scope: 'openid profile email'
}
},
@@ -480,9 +480,9 @@ class Auth {
// for the request to mobile-redirect and as such the session is not shared
this.openIdAuthSession.set(state, { mobile_redirect_uri: req.query.redirect_uri })
- redirectUri = new URL('/auth/openid/mobile-redirect', hostUrl).toString()
+ redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString()
} else {
- redirectUri = new URL('/auth/openid/callback', hostUrl).toString()
+ redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, hostUrl).toString()
if (req.query.state) {
Logger.debug(`[Auth] Invalid state - not allowed on web openid flow`)
@@ -733,7 +733,7 @@ class Auth {
const host = req.get('host')
// TODO: ABS does currently not support subfolders for installation
// If we want to support it we need to include a config for the serverurl
- postLogoutRedirectUri = `${protocol}://${host}/login`
+ postLogoutRedirectUri = `${protocol}://${host}${global.RouterBasePath}/login`
}
// else for openid-mobile we keep postLogoutRedirectUri on null
// nice would be to redirect to the app here, but for example Authentik does not implement
diff --git a/server/Server.js b/server/Server.js
index ae9746d8d..2f1220d87 100644
--- a/server/Server.js
+++ b/server/Server.js
@@ -84,7 +84,6 @@ class Server {
Logger.logManager = new LogManager()
this.server = null
- this.io = null
}
/**
@@ -195,8 +194,10 @@ class Server {
const app = express()
app.use((req, res, next) => {
- // Prevent clickjacking by disallowing iframes
- res.setHeader('Content-Security-Policy', "frame-ancestors 'self'")
+ if (!global.ServerSettings.allowIframe) {
+ // Prevent clickjacking by disallowing iframes
+ res.setHeader('Content-Security-Policy', "frame-ancestors 'self'")
+ }
/**
* @temporary
@@ -249,14 +250,17 @@ class Server {
const router = express.Router()
// if RouterBasePath is set, modify all requests to include the base path
- if (global.RouterBasePath) {
- app.use((req, res, next) => {
- if (!req.url.startsWith(global.RouterBasePath)) {
- req.url = `${global.RouterBasePath}${req.url}`
- }
- next()
- })
- }
+ app.use((req, res, next) => {
+ const urlStartsWithRouterBasePath = req.url.startsWith(global.RouterBasePath)
+ const host = req.get('host')
+ const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
+ const prefix = urlStartsWithRouterBasePath ? global.RouterBasePath : ''
+ req.originalHostPrefix = `${protocol}://${host}${prefix}`
+ if (!urlStartsWithRouterBasePath) {
+ req.url = `${global.RouterBasePath}${req.url}`
+ }
+ next()
+ })
app.use(global.RouterBasePath, router)
app.disable('x-powered-by')
@@ -441,18 +445,11 @@ class Server {
async stop() {
Logger.info('=== Stopping Server ===')
Watcher.close()
- Logger.info('Watcher Closed')
-
- return new Promise((resolve) => {
- SocketAuthority.close((err) => {
- if (err) {
- Logger.error('Failed to close server', err)
- } else {
- Logger.info('Server successfully closed')
- }
- resolve()
- })
- })
+ Logger.info('[Server] Watcher Closed')
+ await SocketAuthority.close()
+ Logger.info('[Server] Closing HTTP Server')
+ await new Promise((resolve) => this.server.close(resolve))
+ Logger.info('[Server] HTTP Server Closed')
}
}
module.exports = Server
diff --git a/server/SocketAuthority.js b/server/SocketAuthority.js
index a71829361..19c686d97 100644
--- a/server/SocketAuthority.js
+++ b/server/SocketAuthority.js
@@ -14,7 +14,7 @@ const Auth = require('./Auth')
class SocketAuthority {
constructor() {
this.Server = null
- this.io = null
+ this.socketIoServers = []
/** @type {Object.} */
this.clients = {}
@@ -89,82 +89,104 @@ class SocketAuthority {
*
* @param {Function} callback
*/
- close(callback) {
- Logger.info('[SocketAuthority] Shutting down')
- // This will close all open socket connections, and also close the underlying http server
- if (this.io) this.io.close(callback)
- else callback()
+ async close() {
+ Logger.info('[SocketAuthority] closing...')
+ const closePromises = this.socketIoServers.map((io) => {
+ return new Promise((resolve) => {
+ Logger.info(`[SocketAuthority] Closing Socket.IO server: ${io.path}`)
+ io.close(() => {
+ Logger.info(`[SocketAuthority] Socket.IO server closed: ${io.path}`)
+ resolve()
+ })
+ })
+ })
+ await Promise.all(closePromises)
+ Logger.info('[SocketAuthority] closed')
+ this.socketIoServers = []
}
initialize(Server) {
this.Server = Server
- this.io = new SocketIO.Server(this.Server.server, {
+ const socketIoOptions = {
cors: {
origin: '*',
methods: ['GET', 'POST']
- },
- path: `${global.RouterBasePath}/socket.io`
- })
-
- this.io.on('connection', (socket) => {
- this.clients[socket.id] = {
- id: socket.id,
- socket,
- connected_at: Date.now()
}
- socket.sheepClient = this.clients[socket.id]
+ }
- Logger.info('[SocketAuthority] Socket Connected', socket.id)
+ const ioServer = new SocketIO.Server(Server.server, socketIoOptions)
+ ioServer.path = '/socket.io'
+ this.socketIoServers.push(ioServer)
- // Required for associating a User with a socket
- socket.on('auth', (token) => this.authenticateSocket(socket, token))
+ if (global.RouterBasePath) {
+ // open a separate socket.io server for the router base path, keeping the original server open for legacy clients
+ const ioBasePath = `${global.RouterBasePath}/socket.io`
+ const ioBasePathServer = new SocketIO.Server(Server.server, { ...socketIoOptions, path: ioBasePath })
+ ioBasePathServer.path = ioBasePath
+ this.socketIoServers.push(ioBasePathServer)
+ }
- // Scanning
- socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId))
-
- // Logs
- socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
- socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))
-
- // Sent automatically from socket.io clients
- socket.on('disconnect', (reason) => {
- Logger.removeSocketListener(socket.id)
-
- const _client = this.clients[socket.id]
- if (!_client) {
- Logger.warn(`[SocketAuthority] Socket ${socket.id} disconnect, no client (Reason: ${reason})`)
- } else if (!_client.user) {
- Logger.info(`[SocketAuthority] Unauth socket ${socket.id} disconnected (Reason: ${reason})`)
- delete this.clients[socket.id]
- } else {
- Logger.debug('[SocketAuthority] User Offline ' + _client.user.username)
- this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions))
-
- const disconnectTime = Date.now() - _client.connected_at
- Logger.info(`[SocketAuthority] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`)
- delete this.clients[socket.id]
+ this.socketIoServers.forEach((io) => {
+ io.on('connection', (socket) => {
+ this.clients[socket.id] = {
+ id: socket.id,
+ socket,
+ connected_at: Date.now()
}
- })
+ socket.sheepClient = this.clients[socket.id]
- //
- // Events for testing
- //
- socket.on('message_all_users', (payload) => {
- // admin user can send a message to all authenticated users
- // displays on the web app as a toast
- const client = this.clients[socket.id] || {}
- if (client.user?.isAdminOrUp) {
- this.emitter('admin_message', payload.message || '')
- } else {
- Logger.error(`[SocketAuthority] Non-admin user sent the message_all_users event`)
- }
- })
- socket.on('ping', () => {
- const client = this.clients[socket.id] || {}
- const user = client.user || {}
- Logger.debug(`[SocketAuthority] Received ping from socket ${user.username || 'No User'}`)
- socket.emit('pong')
+ Logger.info(`[SocketAuthority] Socket Connected to ${io.path}`, socket.id)
+
+ // Required for associating a User with a socket
+ socket.on('auth', (token) => this.authenticateSocket(socket, token))
+
+ // Scanning
+ socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId))
+
+ // Logs
+ socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
+ socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))
+
+ // Sent automatically from socket.io clients
+ socket.on('disconnect', (reason) => {
+ Logger.removeSocketListener(socket.id)
+
+ const _client = this.clients[socket.id]
+ if (!_client) {
+ Logger.warn(`[SocketAuthority] Socket ${socket.id} disconnect, no client (Reason: ${reason})`)
+ } else if (!_client.user) {
+ Logger.info(`[SocketAuthority] Unauth socket ${socket.id} disconnected (Reason: ${reason})`)
+ delete this.clients[socket.id]
+ } else {
+ Logger.debug('[SocketAuthority] User Offline ' + _client.user.username)
+ this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions))
+
+ const disconnectTime = Date.now() - _client.connected_at
+ Logger.info(`[SocketAuthority] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`)
+ delete this.clients[socket.id]
+ }
+ })
+
+ //
+ // Events for testing
+ //
+ socket.on('message_all_users', (payload) => {
+ // admin user can send a message to all authenticated users
+ // displays on the web app as a toast
+ const client = this.clients[socket.id] || {}
+ if (client.user?.isAdminOrUp) {
+ this.emitter('admin_message', payload.message || '')
+ } else {
+ Logger.error(`[SocketAuthority] Non-admin user sent the message_all_users event`)
+ }
+ })
+ socket.on('ping', () => {
+ const client = this.clients[socket.id] || {}
+ const user = client.user || {}
+ Logger.debug(`[SocketAuthority] Received ping from socket ${user.username || 'No User'}`)
+ socket.emit('pong')
+ })
})
})
}
diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js
index cf901bea0..b35619b70 100644
--- a/server/controllers/MiscController.js
+++ b/server/controllers/MiscController.js
@@ -126,6 +126,10 @@ class MiscController {
if (!isObject(settingsUpdate)) {
return res.status(400).send('Invalid settings update object')
}
+ if (settingsUpdate.allowIframe == false && process.env.ALLOW_IFRAME === '1') {
+ Logger.warn('Cannot disable iframe when ALLOW_IFRAME is enabled in environment')
+ return res.status(400).send('Cannot disable iframe when ALLOW_IFRAME is enabled in environment')
+ }
const madeUpdates = Database.serverSettings.update(settingsUpdate)
if (madeUpdates) {
@@ -137,7 +141,6 @@ class MiscController {
}
}
return res.json({
- success: true,
serverSettings: Database.serverSettings.toJSONForBrowser()
})
}
@@ -679,9 +682,9 @@ class MiscController {
continue
}
let updatedValue = settingsUpdate[key]
- if (updatedValue === '') updatedValue = null
+ if (updatedValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') updatedValue = null
let currentValue = currentAuthenticationSettings[key]
- if (currentValue === '') currentValue = null
+ if (currentValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') currentValue = null
if (updatedValue !== currentValue) {
Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${currentValue}" to "${updatedValue}"`)
diff --git a/server/managers/CoverManager.js b/server/managers/CoverManager.js
index 9b4aa32d0..2b3a697d7 100644
--- a/server/managers/CoverManager.js
+++ b/server/managers/CoverManager.js
@@ -12,7 +12,7 @@ const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata')
const CacheManager = require('../managers/CacheManager')
class CoverManager {
- constructor() { }
+ constructor() {}
getCoverDirectory(libraryItem) {
if (global.ServerSettings.storeCoverWithItem && !libraryItem.isFile) {
@@ -93,10 +93,13 @@ class CoverManager {
const coverFullPath = Path.posix.join(coverDirPath, `cover${extname}`)
// Move cover from temp upload dir to destination
- const success = await coverFile.mv(coverFullPath).then(() => true).catch((error) => {
- Logger.error('[CoverManager] Failed to move cover file', path, error)
- return false
- })
+ const success = await coverFile
+ .mv(coverFullPath)
+ .then(() => true)
+ .catch((error) => {
+ Logger.error('[CoverManager] Failed to move cover file', coverFullPath, error)
+ return false
+ })
if (!success) {
return {
@@ -124,11 +127,13 @@ class CoverManager {
var temppath = Path.posix.join(coverDirPath, 'cover')
let errorMsg = ''
- let success = await downloadImageFile(url, temppath).then(() => true).catch((err) => {
- errorMsg = err.message || 'Unknown error'
- Logger.error(`[CoverManager] Download image file failed for "${url}"`, errorMsg)
- return false
- })
+ let success = await downloadImageFile(url, temppath)
+ .then(() => true)
+ .catch((err) => {
+ errorMsg = err.message || 'Unknown error'
+ Logger.error(`[CoverManager] Download image file failed for "${url}"`, errorMsg)
+ return false
+ })
if (!success) {
return {
error: 'Failed to download image from url: ' + errorMsg
@@ -180,7 +185,7 @@ class CoverManager {
}
// Cover path does not exist
- if (!await fs.pathExists(coverPath)) {
+ if (!(await fs.pathExists(coverPath))) {
Logger.error(`[CoverManager] validate cover path does not exist "${coverPath}"`)
return {
error: 'Cover path does not exist'
@@ -188,7 +193,7 @@ class CoverManager {
}
// Cover path is not a file
- if (!await checkPathIsFile(coverPath)) {
+ if (!(await checkPathIsFile(coverPath))) {
Logger.error(`[CoverManager] validate cover path is not a file "${coverPath}"`)
return {
error: 'Cover path is not a file'
@@ -211,10 +216,13 @@ class CoverManager {
var newCoverPath = Path.posix.join(coverDirPath, coverFilename)
Logger.debug(`[CoverManager] validate cover path copy cover from "${coverPath}" to "${newCoverPath}"`)
- var copySuccess = await fs.copy(coverPath, newCoverPath, { overwrite: true }).then(() => true).catch((error) => {
- Logger.error(`[CoverManager] validate cover path failed to copy cover`, error)
- return false
- })
+ var copySuccess = await fs
+ .copy(coverPath, newCoverPath, { overwrite: true })
+ .then(() => true)
+ .catch((error) => {
+ Logger.error(`[CoverManager] validate cover path failed to copy cover`, error)
+ return false
+ })
if (!copySuccess) {
return {
error: 'Failed to copy cover to dir'
@@ -236,14 +244,14 @@ class CoverManager {
/**
* Extract cover art from audio file and save for library item
- *
- * @param {import('../models/Book').AudioFileObject[]} audioFiles
- * @param {string} libraryItemId
- * @param {string} [libraryItemPath] null for isFile library items
+ *
+ * @param {import('../models/Book').AudioFileObject[]} audioFiles
+ * @param {string} libraryItemId
+ * @param {string} [libraryItemPath] null for isFile library items
* @returns {Promise} returns cover path
*/
async saveEmbeddedCoverArt(audioFiles, libraryItemId, libraryItemPath) {
- let audioFileWithCover = audioFiles.find(af => af.embeddedCoverArt)
+ let audioFileWithCover = audioFiles.find((af) => af.embeddedCoverArt)
if (!audioFileWithCover) return null
let coverDirPath = null
@@ -273,10 +281,10 @@ class CoverManager {
/**
* Extract cover art from ebook and save for library item
- *
- * @param {import('../utils/parsers/parseEbookMetadata').EBookFileScanData} ebookFileScanData
- * @param {string} libraryItemId
- * @param {string} [libraryItemPath] null for isFile library items
+ *
+ * @param {import('../utils/parsers/parseEbookMetadata').EBookFileScanData} ebookFileScanData
+ * @param {string} libraryItemId
+ * @param {string} [libraryItemPath] null for isFile library items
* @returns {Promise} returns cover path
*/
async saveEbookCoverArt(ebookFileScanData, libraryItemId, libraryItemPath) {
@@ -310,9 +318,9 @@ class CoverManager {
}
/**
- *
- * @param {string} url
- * @param {string} libraryItemId
+ *
+ * @param {string} url
+ * @param {string} libraryItemId
* @param {string} [libraryItemPath] null if library item isFile or is from adding new podcast
* @returns {Promise<{error:string}|{cover:string}>}
*/
@@ -328,10 +336,12 @@ class CoverManager {
await fs.ensureDir(coverDirPath)
const temppath = Path.posix.join(coverDirPath, 'cover')
- const success = await downloadImageFile(url, temppath).then(() => true).catch((err) => {
- Logger.error(`[CoverManager] Download image file failed for "${url}"`, err)
- return false
- })
+ const success = await downloadImageFile(url, temppath)
+ .then(() => true)
+ .catch((err) => {
+ Logger.error(`[CoverManager] Download image file failed for "${url}"`, err)
+ return false
+ })
if (!success) {
return {
error: 'Failed to download image from url'
@@ -361,4 +371,4 @@ class CoverManager {
}
}
}
-module.exports = new CoverManager()
\ No newline at end of file
+module.exports = new CoverManager()
diff --git a/server/managers/RssFeedManager.js b/server/managers/RssFeedManager.js
index 7716440df..583f0bb67 100644
--- a/server/managers/RssFeedManager.js
+++ b/server/managers/RssFeedManager.js
@@ -1,3 +1,4 @@
+const { Request, Response } = require('express')
const Path = require('path')
const Logger = require('../Logger')
@@ -77,6 +78,12 @@ class RssFeedManager {
return Database.feedModel.findByPkOld(id)
}
+ /**
+ * GET: /feed/:slug
+ *
+ * @param {Request} req
+ * @param {Response} res
+ */
async getFeed(req, res) {
const feed = await this.findFeedBySlug(req.params.slug)
if (!feed) {
@@ -162,11 +169,17 @@ class RssFeedManager {
}
}
- const xml = feed.buildXml()
+ const xml = feed.buildXml(req.originalHostPrefix)
res.set('Content-Type', 'text/xml')
res.send(xml)
}
+ /**
+ * GET: /feed/:slug/item/:episodeId/*
+ *
+ * @param {Request} req
+ * @param {Response} res
+ */
async getFeedItem(req, res) {
const feed = await this.findFeedBySlug(req.params.slug)
if (!feed) {
@@ -183,6 +196,12 @@ class RssFeedManager {
res.sendFile(episodePath)
}
+ /**
+ * GET: /feed/:slug/cover*
+ *
+ * @param {Request} req
+ * @param {Response} res
+ */
async getFeedCover(req, res) {
const feed = await this.findFeedBySlug(req.params.slug)
if (!feed) {
diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md
index 51e826000..f49924327 100644
--- a/server/migrations/changelog.md
+++ b/server/migrations/changelog.md
@@ -2,10 +2,12 @@
Please add a record of every database migration that you create to this file. This will help us keep track of changes to the database schema over time.
-| Server Version | Migration Script Name | Description |
-| -------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------- |
-| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library |
-| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 |
-| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes |
-| v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model |
-| v2.17.3 | v2.17.3-fk-constraints | Changes the foreign key constraints for tables due to sequelize bug dropping constraints in v2.17.0 migration |
+| Server Version | Migration Script Name | Description |
+| -------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
+| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library |
+| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 |
+| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes |
+| v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model |
+| v2.17.3 | v2.17.3-fk-constraints | Changes the foreign key constraints for tables due to sequelize bug dropping constraints in v2.17.0 migration |
+| v2.17.4 | v2.17.4-use-subfolder-for-oidc-redirect-uris | Save subfolder to OIDC redirect URIs to support existing installations |
+| v2.17.5 | v2.17.5-remove-host-from-feed-urls | removes the host (serverAddress) from URL columns in the feeds and feedEpisodes tables |
diff --git a/server/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris.js b/server/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris.js
new file mode 100644
index 000000000..03797e35e
--- /dev/null
+++ b/server/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris.js
@@ -0,0 +1,84 @@
+/**
+ * @typedef MigrationContext
+ * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
+ * @property {import('../Logger')} logger - a Logger object.
+ *
+ * @typedef MigrationOptions
+ * @property {MigrationContext} context - an object containing the migration context.
+ */
+
+/**
+ * This upward migration adds an subfolder setting for OIDC redirect URIs.
+ * It updates existing OIDC setups to set this option to None (empty subfolder), so they continue to work as before.
+ * IF OIDC is not enabled, no action is taken (i.e. the subfolder is left undefined),
+ * so that future OIDC setups will use the default subfolder.
+ *
+ * @param {MigrationOptions} options - an object containing the migration context.
+ * @returns {Promise} - A promise that resolves when the migration is complete.
+ */
+async function up({ context: { queryInterface, logger } }) {
+ // Upwards migration script
+ logger.info('[2.17.4 migration] UPGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris')
+
+ const serverSettings = await getServerSettings(queryInterface, logger)
+ if (serverSettings.authActiveAuthMethods?.includes('openid')) {
+ logger.info('[2.17.4 migration] OIDC is enabled, adding authOpenIDSubfolderForRedirectURLs to server settings')
+ serverSettings.authOpenIDSubfolderForRedirectURLs = ''
+ await updateServerSettings(queryInterface, logger, serverSettings)
+ } else {
+ logger.info('[2.17.4 migration] OIDC is not enabled, no action required')
+ }
+
+ logger.info('[2.17.4 migration] UPGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris')
+}
+
+/**
+ * This downward migration script removes the subfolder setting for OIDC redirect URIs.
+ *
+ * @param {MigrationOptions} options - an object containing the migration context.
+ * @returns {Promise} - A promise that resolves when the migration is complete.
+ */
+async function down({ context: { queryInterface, logger } }) {
+ // Downward migration script
+ logger.info('[2.17.4 migration] DOWNGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris ')
+
+ // Remove the OIDC subfolder option from the server settings
+ const serverSettings = await getServerSettings(queryInterface, logger)
+ if (serverSettings.authOpenIDSubfolderForRedirectURLs !== undefined) {
+ logger.info('[2.17.4 migration] Removing authOpenIDSubfolderForRedirectURLs from server settings')
+ delete serverSettings.authOpenIDSubfolderForRedirectURLs
+ await updateServerSettings(queryInterface, logger, serverSettings)
+ } else {
+ logger.info('[2.17.4 migration] authOpenIDSubfolderForRedirectURLs not found in server settings, no action required')
+ }
+
+ logger.info('[2.17.4 migration] DOWNGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris ')
+}
+
+async function getServerSettings(queryInterface, logger) {
+ const result = await queryInterface.sequelize.query('SELECT value FROM settings WHERE key = "server-settings";')
+ if (!result[0].length) {
+ logger.error('[2.17.4 migration] Server settings not found')
+ throw new Error('Server settings not found')
+ }
+
+ let serverSettings = null
+ try {
+ serverSettings = JSON.parse(result[0][0].value)
+ } catch (error) {
+ logger.error('[2.17.4 migration] Error parsing server settings:', error)
+ throw error
+ }
+
+ return serverSettings
+}
+
+async function updateServerSettings(queryInterface, logger, serverSettings) {
+ await queryInterface.sequelize.query('UPDATE settings SET value = :value WHERE key = "server-settings";', {
+ replacements: {
+ value: JSON.stringify(serverSettings)
+ }
+ })
+}
+
+module.exports = { up, down }
diff --git a/server/migrations/v2.17.5-remove-host-from-feed-urls.js b/server/migrations/v2.17.5-remove-host-from-feed-urls.js
new file mode 100644
index 000000000..e08877f23
--- /dev/null
+++ b/server/migrations/v2.17.5-remove-host-from-feed-urls.js
@@ -0,0 +1,74 @@
+/**
+ * @typedef MigrationContext
+ * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
+ * @property {import('../Logger')} logger - a Logger object.
+ *
+ * @typedef MigrationOptions
+ * @property {MigrationContext} context - an object containing the migration context.
+ */
+
+const migrationVersion = '2.17.5'
+const migrationName = `${migrationVersion}-remove-host-from-feed-urls`
+const loggerPrefix = `[${migrationVersion} migration]`
+
+/**
+ * This upward migration removes the host (serverAddress) from URL columns in the feeds and feedEpisodes tables.
+ *
+ * @param {MigrationOptions} options - an object containing the migration context.
+ * @returns {Promise} - A promise that resolves when the migration is complete.
+ */
+async function up({ context: { queryInterface, logger } }) {
+ // Upwards migration script
+ logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
+
+ logger.info(`${loggerPrefix} Removing serverAddress from Feeds table URLs`)
+ await queryInterface.sequelize.query(`
+ UPDATE Feeds
+ SET feedUrl = REPLACE(feedUrl, COALESCE(serverAddress, ''), ''),
+ imageUrl = REPLACE(imageUrl, COALESCE(serverAddress, ''), ''),
+ siteUrl = REPLACE(siteUrl, COALESCE(serverAddress, ''), '');
+ `)
+ logger.info(`${loggerPrefix} Removed serverAddress from Feeds table URLs`)
+
+ logger.info(`${loggerPrefix} Removing serverAddress from FeedEpisodes table URLs`)
+ await queryInterface.sequelize.query(`
+ UPDATE FeedEpisodes
+ SET siteUrl = REPLACE(siteUrl, (SELECT COALESCE(serverAddress, '') FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId), ''),
+ enclosureUrl = REPLACE(enclosureUrl, (SELECT COALESCE(serverAddress, '') FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId), '');
+ `)
+ logger.info(`${loggerPrefix} Removed serverAddress from FeedEpisodes table URLs`)
+
+ logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
+}
+
+/**
+ * This downward migration script adds the host (serverAddress) back to URL columns in the feeds and feedEpisodes tables.
+ *
+ * @param {MigrationOptions} options - an object containing the migration context.
+ * @returns {Promise} - A promise that resolves when the migration is complete.
+ */
+async function down({ context: { queryInterface, logger } }) {
+ // Downward migration script
+ logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
+
+ logger.info(`${loggerPrefix} Adding serverAddress back to Feeds table URLs`)
+ await queryInterface.sequelize.query(`
+ UPDATE Feeds
+ SET feedUrl = COALESCE(serverAddress, '') || feedUrl,
+ imageUrl = COALESCE(serverAddress, '') || imageUrl,
+ siteUrl = COALESCE(serverAddress, '') || siteUrl;
+ `)
+ logger.info(`${loggerPrefix} Added serverAddress back to Feeds table URLs`)
+
+ logger.info(`${loggerPrefix} Adding serverAddress back to FeedEpisodes table URLs`)
+ await queryInterface.sequelize.query(`
+ UPDATE FeedEpisodes
+ SET siteUrl = (SELECT COALESCE(serverAddress, '') || FeedEpisodes.siteUrl FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId),
+ enclosureUrl = (SELECT COALESCE(serverAddress, '') || FeedEpisodes.enclosureUrl FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId);
+ `)
+ logger.info(`${loggerPrefix} Added serverAddress back to FeedEpisodes table URLs`)
+
+ logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
+}
+
+module.exports = { up, down }
diff --git a/server/objects/Feed.js b/server/objects/Feed.js
index 74a220e35..ac50b899f 100644
--- a/server/objects/Feed.js
+++ b/server/objects/Feed.js
@@ -29,9 +29,6 @@ class Feed {
this.createdAt = null
this.updatedAt = null
- // Cached xml
- this.xml = null
-
if (feed) {
this.construct(feed)
}
@@ -109,7 +106,7 @@ class Feed {
const mediaMetadata = media.metadata
const isPodcast = libraryItem.mediaType === 'podcast'
- const feedUrl = `${serverAddress}/feed/${slug}`
+ const feedUrl = `/feed/${slug}`
const author = isPodcast ? mediaMetadata.author : mediaMetadata.authorName
this.id = uuidv4()
@@ -128,9 +125,9 @@ class Feed {
this.meta.title = mediaMetadata.title
this.meta.description = mediaMetadata.description
this.meta.author = author
- this.meta.imageUrl = media.coverPath ? `${serverAddress}/feed/${slug}/cover${coverFileExtension}` : `${serverAddress}/Logo.png`
+ this.meta.imageUrl = media.coverPath ? `/feed/${slug}/cover${coverFileExtension}` : `/Logo.png`
this.meta.feedUrl = feedUrl
- this.meta.link = `${serverAddress}/item/${libraryItem.id}`
+ this.meta.link = `/item/${libraryItem.id}`
this.meta.explicit = !!mediaMetadata.explicit
this.meta.type = mediaMetadata.type
this.meta.language = mediaMetadata.language
@@ -176,7 +173,7 @@ class Feed {
this.meta.title = mediaMetadata.title
this.meta.description = mediaMetadata.description
this.meta.author = author
- this.meta.imageUrl = media.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover${coverFileExtension}` : `${this.serverAddress}/Logo.png`
+ this.meta.imageUrl = media.coverPath ? `/feed/${this.slug}/cover${coverFileExtension}` : `/Logo.png`
this.meta.explicit = !!mediaMetadata.explicit
this.meta.type = mediaMetadata.type
this.meta.language = mediaMetadata.language
@@ -202,11 +199,10 @@ class Feed {
}
this.updatedAt = Date.now()
- this.xml = null
}
setFromCollection(userId, slug, collectionExpanded, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) {
- const feedUrl = `${serverAddress}/feed/${slug}`
+ const feedUrl = `/feed/${slug}`
const itemsWithTracks = collectionExpanded.books.filter((libraryItem) => libraryItem.media.tracks.length)
const firstItemWithCover = itemsWithTracks.find((item) => item.media.coverPath)
@@ -227,9 +223,9 @@ class Feed {
this.meta.title = collectionExpanded.name
this.meta.description = collectionExpanded.description || ''
this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks)
- this.meta.imageUrl = this.coverPath ? `${serverAddress}/feed/${slug}/cover${coverFileExtension}` : `${serverAddress}/Logo.png`
+ this.meta.imageUrl = this.coverPath ? `/feed/${slug}/cover${coverFileExtension}` : `/Logo.png`
this.meta.feedUrl = feedUrl
- this.meta.link = `${serverAddress}/collection/${collectionExpanded.id}`
+ this.meta.link = `/collection/${collectionExpanded.id}`
this.meta.explicit = !!itemsWithTracks.some((li) => li.media.metadata.explicit) // explicit if any item is explicit
this.meta.preventIndexing = preventIndexing
this.meta.ownerName = ownerName
@@ -272,7 +268,7 @@ class Feed {
this.meta.title = collectionExpanded.name
this.meta.description = collectionExpanded.description || ''
this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks)
- this.meta.imageUrl = this.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover${coverFileExtension}` : `${this.serverAddress}/Logo.png`
+ this.meta.imageUrl = this.coverPath ? `/feed/${this.slug}/cover${coverFileExtension}` : `/Logo.png`
this.meta.explicit = !!itemsWithTracks.some((li) => li.media.metadata.explicit) // explicit if any item is explicit
this.episodes = []
@@ -297,11 +293,10 @@ class Feed {
})
this.updatedAt = Date.now()
- this.xml = null
}
setFromSeries(userId, slug, seriesExpanded, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) {
- const feedUrl = `${serverAddress}/feed/${slug}`
+ const feedUrl = `/feed/${slug}`
let itemsWithTracks = seriesExpanded.books.filter((libraryItem) => libraryItem.media.tracks.length)
// Sort series items by series sequence
@@ -326,9 +321,9 @@ class Feed {
this.meta.title = seriesExpanded.name
this.meta.description = seriesExpanded.description || ''
this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks)
- this.meta.imageUrl = this.coverPath ? `${serverAddress}/feed/${slug}/cover${coverFileExtension}` : `${serverAddress}/Logo.png`
+ this.meta.imageUrl = this.coverPath ? `/feed/${slug}/cover${coverFileExtension}` : `/Logo.png`
this.meta.feedUrl = feedUrl
- this.meta.link = `${serverAddress}/library/${libraryId}/series/${seriesExpanded.id}`
+ this.meta.link = `/library/${libraryId}/series/${seriesExpanded.id}`
this.meta.explicit = !!itemsWithTracks.some((li) => li.media.metadata.explicit) // explicit if any item is explicit
this.meta.preventIndexing = preventIndexing
this.meta.ownerName = ownerName
@@ -374,7 +369,7 @@ class Feed {
this.meta.title = seriesExpanded.name
this.meta.description = seriesExpanded.description || ''
this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks)
- this.meta.imageUrl = this.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover${coverFileExtension}` : `${this.serverAddress}/Logo.png`
+ this.meta.imageUrl = this.coverPath ? `/feed/${this.slug}/cover${coverFileExtension}` : `/Logo.png`
this.meta.explicit = !!itemsWithTracks.some((li) => li.media.metadata.explicit) // explicit if any item is explicit
this.episodes = []
@@ -399,18 +394,14 @@ class Feed {
})
this.updatedAt = Date.now()
- this.xml = null
}
- buildXml() {
- if (this.xml) return this.xml
-
- var rssfeed = new RSS(this.meta.getRSSData())
+ buildXml(originalHostPrefix) {
+ var rssfeed = new RSS(this.meta.getRSSData(originalHostPrefix))
this.episodes.forEach((ep) => {
- rssfeed.item(ep.getRSSData())
+ rssfeed.item(ep.getRSSData(originalHostPrefix))
})
- this.xml = rssfeed.xml()
- return this.xml
+ return rssfeed.xml()
}
getAuthorsStringFromLibraryItems(libraryItems) {
diff --git a/server/objects/FeedEpisode.js b/server/objects/FeedEpisode.js
index 6d9f36a08..13d590ff7 100644
--- a/server/objects/FeedEpisode.js
+++ b/server/objects/FeedEpisode.js
@@ -79,7 +79,7 @@ class FeedEpisode {
this.title = episode.title
this.description = episode.description || ''
this.enclosure = {
- url: `${serverAddress}${contentUrl}`,
+ url: `${contentUrl}`,
type: episode.audioTrack.mimeType,
size: episode.size
}
@@ -136,7 +136,7 @@ class FeedEpisode {
this.title = title
this.description = mediaMetadata.description || ''
this.enclosure = {
- url: `${serverAddress}${contentUrl}`,
+ url: `${contentUrl}`,
type: audioTrack.mimeType,
size: audioTrack.metadata.size
}
@@ -151,15 +151,19 @@ class FeedEpisode {
this.fullPath = audioTrack.metadata.path
}
- getRSSData() {
+ getRSSData(hostPrefix) {
return {
title: this.title,
description: this.description || '',
- url: this.link,
- guid: this.enclosure.url,
+ url: `${hostPrefix}${this.link}`,
+ guid: `${hostPrefix}${this.enclosure.url}`,
author: this.author,
date: this.pubDate,
- enclosure: this.enclosure,
+ enclosure: {
+ url: `${hostPrefix}${this.enclosure.url}`,
+ type: this.enclosure.type,
+ size: this.enclosure.size
+ },
custom_elements: [
{ 'itunes:author': this.author },
{ 'itunes:duration': secondsToTimestamp(this.duration) },
diff --git a/server/objects/FeedMeta.js b/server/objects/FeedMeta.js
index 307e12bc1..e439fe8f7 100644
--- a/server/objects/FeedMeta.js
+++ b/server/objects/FeedMeta.js
@@ -60,42 +60,36 @@ class FeedMeta {
}
}
- getRSSData() {
- const blockTags = [
- { 'itunes:block': 'yes' },
- { 'googleplay:block': 'yes' }
- ]
+ getRSSData(hostPrefix) {
+ const blockTags = [{ 'itunes:block': 'yes' }, { 'googleplay:block': 'yes' }]
return {
title: this.title,
description: this.description || '',
generator: 'Audiobookshelf',
- feed_url: this.feedUrl,
- site_url: this.link,
- image_url: this.imageUrl,
+ feed_url: `${hostPrefix}${this.feedUrl}`,
+ site_url: `${hostPrefix}${this.link}`,
+ image_url: `${hostPrefix}${this.imageUrl}`,
custom_namespaces: {
- 'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd',
- 'psc': 'http://podlove.org/simple-chapters',
- 'podcast': 'https://podcastindex.org/namespace/1.0',
- 'googleplay': 'http://www.google.com/schemas/play-podcasts/1.0'
+ itunes: 'http://www.itunes.com/dtds/podcast-1.0.dtd',
+ psc: 'http://podlove.org/simple-chapters',
+ podcast: 'https://podcastindex.org/namespace/1.0',
+ googleplay: 'http://www.google.com/schemas/play-podcasts/1.0'
},
custom_elements: [
- { 'language': this.language || 'en' },
- { 'author': this.author || 'advplyr' },
+ { language: this.language || 'en' },
+ { author: this.author || 'advplyr' },
{ 'itunes:author': this.author || 'advplyr' },
{ 'itunes:summary': this.description || '' },
{ 'itunes:type': this.type },
{
'itunes:image': {
_attr: {
- href: this.imageUrl
+ href: `${hostPrefix}${this.imageUrl}`
}
}
},
{
- 'itunes:owner': [
- { 'itunes:name': this.ownerName || this.author || '' },
- { 'itunes:email': this.ownerEmail || '' }
- ]
+ 'itunes:owner': [{ 'itunes:name': this.ownerName || this.author || '' }, { 'itunes:email': this.ownerEmail || '' }]
},
{ 'itunes:explicit': !!this.explicit },
...(this.preventIndexing ? blockTags : [])
diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js
index 8ecb8ff05..29913e449 100644
--- a/server/objects/settings/ServerSettings.js
+++ b/server/objects/settings/ServerSettings.js
@@ -24,6 +24,7 @@ class ServerSettings {
// Security/Rate limits
this.rateLimitLoginRequests = 10
this.rateLimitLoginWindow = 10 * 60 * 1000 // 10 Minutes
+ this.allowIframe = false
// Backups
this.backupPath = Path.join(global.MetadataPath, 'backups')
@@ -78,6 +79,7 @@ class ServerSettings {
this.authOpenIDMobileRedirectURIs = ['audiobookshelf://oauth']
this.authOpenIDGroupClaim = ''
this.authOpenIDAdvancedPermsClaim = ''
+ this.authOpenIDSubfolderForRedirectURLs = undefined
if (settings) {
this.construct(settings)
@@ -98,6 +100,7 @@ class ServerSettings {
this.rateLimitLoginRequests = !isNaN(settings.rateLimitLoginRequests) ? Number(settings.rateLimitLoginRequests) : 10
this.rateLimitLoginWindow = !isNaN(settings.rateLimitLoginWindow) ? Number(settings.rateLimitLoginWindow) : 10 * 60 * 1000 // 10 Minutes
+ this.allowIframe = !!settings.allowIframe
this.backupPath = settings.backupPath || Path.join(global.MetadataPath, 'backups')
this.backupSchedule = settings.backupSchedule || false
@@ -139,6 +142,7 @@ class ServerSettings {
this.authOpenIDMobileRedirectURIs = settings.authOpenIDMobileRedirectURIs || ['audiobookshelf://oauth']
this.authOpenIDGroupClaim = settings.authOpenIDGroupClaim || ''
this.authOpenIDAdvancedPermsClaim = settings.authOpenIDAdvancedPermsClaim || ''
+ this.authOpenIDSubfolderForRedirectURLs = settings.authOpenIDSubfolderForRedirectURLs
if (!Array.isArray(this.authActiveAuthMethods)) {
this.authActiveAuthMethods = ['local']
@@ -188,6 +192,11 @@ class ServerSettings {
Logger.info(`[ServerSettings] Using backup path from environment variable ${process.env.BACKUP_PATH}`)
this.backupPath = process.env.BACKUP_PATH
}
+
+ if (process.env.ALLOW_IFRAME === '1' && !this.allowIframe) {
+ Logger.info(`[ServerSettings] Using allowIframe from environment variable`)
+ this.allowIframe = true
+ }
}
toJSON() {
@@ -205,6 +214,7 @@ class ServerSettings {
metadataFileFormat: this.metadataFileFormat,
rateLimitLoginRequests: this.rateLimitLoginRequests,
rateLimitLoginWindow: this.rateLimitLoginWindow,
+ allowIframe: this.allowIframe,
backupPath: this.backupPath,
backupSchedule: this.backupSchedule,
backupsToKeep: this.backupsToKeep,
@@ -240,7 +250,8 @@ class ServerSettings {
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy,
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client
authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client
- authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim // Do not return to client
+ authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client
+ authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs
}
}
@@ -286,6 +297,7 @@ class ServerSettings {
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client
authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client
authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client
+ authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs,
authOpenIDSamplePermissions: User.getSampleAbsPermissions()
}
diff --git a/server/scanner/AudioFileScanner.js b/server/scanner/AudioFileScanner.js
index 3c364c106..6c808aaa1 100644
--- a/server/scanner/AudioFileScanner.js
+++ b/server/scanner/AudioFileScanner.js
@@ -133,8 +133,8 @@ class AudioFileScanner {
// Look for disc number in folder path e.g. /Book Title/CD01/audiofile.mp3
const pathdir = Path.dirname(path).split('/').pop()
- if (pathdir && /^cd\d{1,3}$/i.test(pathdir)) {
- const discFromFolder = Number(pathdir.replace(/cd/i, ''))
+ if (pathdir && /^(cd|dis[ck])\s*\d{1,3}$/i.test(pathdir)) {
+ const discFromFolder = Number(pathdir.replace(/^(cd|dis[ck])\s*/i, ''))
if (!isNaN(discFromFolder) && discFromFolder !== null) discNumber = discFromFolder
}
diff --git a/server/scanner/LibraryScanner.js b/server/scanner/LibraryScanner.js
index bd0bb310f..a52350f65 100644
--- a/server/scanner/LibraryScanner.js
+++ b/server/scanner/LibraryScanner.js
@@ -424,8 +424,8 @@ class LibraryScanner {
}
const folder = library.libraryFolders[0]
- const relFilePaths = folderGroups[folderId].fileUpdates.map((fileUpdate) => fileUpdate.relPath)
- const fileUpdateGroup = scanUtils.groupFilesIntoLibraryItemPaths(library.mediaType, relFilePaths)
+ const filePathItems = folderGroups[folderId].fileUpdates.map((fileUpdate) => fileUtils.getFilePathItemFromFileUpdate(fileUpdate))
+ const fileUpdateGroup = scanUtils.groupFileItemsIntoLibraryItemDirs(library.mediaType, filePathItems, !!library.settings?.audiobooksOnly)
if (!Object.keys(fileUpdateGroup).length) {
Logger.info(`[LibraryScanner] No important changes to scan for in folder "${folderId}"`)
diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js
index b0c73d6c6..8b87d3a09 100644
--- a/server/utils/fileUtils.js
+++ b/server/utils/fileUtils.js
@@ -131,11 +131,21 @@ async function readTextFile(path) {
}
module.exports.readTextFile = readTextFile
+/**
+ * @typedef FilePathItem
+ * @property {string} name - file name e.g. "audiofile.m4b"
+ * @property {string} path - fullpath excluding folder e.g. "Author/Book/audiofile.m4b"
+ * @property {string} reldirpath - path excluding file name e.g. "Author/Book"
+ * @property {string} fullpath - full path e.g. "/audiobooks/Author/Book/audiofile.m4b"
+ * @property {string} extension - file extension e.g. ".m4b"
+ * @property {number} deep - depth of file in directory (0 is file in folder root)
+ */
+
/**
* Get array of files inside dir
* @param {string} path
* @param {string} [relPathToReplace]
- * @returns {{name:string, path:string, dirpath:string, reldirpath:string, fullpath:string, extension:string, deep:number}[]}
+ * @returns {FilePathItem[]}
*/
async function recurseFiles(path, relPathToReplace = null) {
path = filePathToPOSIX(path)
@@ -213,7 +223,6 @@ async function recurseFiles(path, relPathToReplace = null) {
return {
name: item.name,
path: item.fullname.replace(relPathToReplace, ''),
- dirpath: item.path,
reldirpath: isInRoot ? '' : item.path.replace(relPathToReplace, ''),
fullpath: item.fullname,
extension: item.extension,
@@ -228,6 +237,26 @@ async function recurseFiles(path, relPathToReplace = null) {
}
module.exports.recurseFiles = recurseFiles
+/**
+ *
+ * @param {import('../Watcher').PendingFileUpdate} fileUpdate
+ * @returns {FilePathItem}
+ */
+module.exports.getFilePathItemFromFileUpdate = (fileUpdate) => {
+ let relPath = fileUpdate.relPath
+ if (relPath.startsWith('/')) relPath = relPath.slice(1)
+
+ const dirname = Path.dirname(relPath)
+ return {
+ name: Path.basename(relPath),
+ path: relPath,
+ reldirpath: dirname === '.' ? '' : dirname,
+ fullpath: fileUpdate.path,
+ extension: Path.extname(relPath),
+ deep: relPath.split('/').length - 1
+ }
+}
+
/**
* Download file from web to local file system
* Uses SSRF filter to prevent internal URLs
diff --git a/server/utils/prober.js b/server/utils/prober.js
index b54b981d2..838899bdc 100644
--- a/server/utils/prober.js
+++ b/server/utils/prober.js
@@ -189,7 +189,7 @@ function parseTags(format, verbose) {
file_tag_genre: tryGrabTags(format, 'genre', 'tcon', 'tco'),
file_tag_series: tryGrabTags(format, 'series', 'show', 'mvnm'),
file_tag_seriespart: tryGrabTags(format, 'series-part', 'episode_id', 'mvin', 'part'),
- file_tag_grouping: tryGrabTags(format, 'grouping'),
+ file_tag_grouping: tryGrabTags(format, 'grouping', 'grp1'),
file_tag_isbn: tryGrabTags(format, 'isbn'), // custom
file_tag_language: tryGrabTags(format, 'language', 'lang'),
file_tag_asin: tryGrabTags(format, 'asin', 'audible_asin'), // custom
diff --git a/server/utils/queries/adminStats.js b/server/utils/queries/adminStats.js
index 0c490de42..9d7f572a3 100644
--- a/server/utils/queries/adminStats.js
+++ b/server/utils/queries/adminStats.js
@@ -5,7 +5,7 @@ const fsExtra = require('../../libs/fsExtra')
module.exports = {
/**
- *
+ *
* @param {number} year YYYY
* @returns {Promise}
*/
@@ -22,7 +22,7 @@ module.exports = {
},
/**
- *
+ *
* @param {number} year YYYY
* @returns {Promise}
*/
@@ -39,7 +39,7 @@ module.exports = {
},
/**
- *
+ *
* @param {number} year YYYY
* @returns {Promise}
*/
@@ -63,7 +63,7 @@ module.exports = {
},
/**
- *
+ *
* @param {number} year YYYY
*/
async getStatsForYear(year) {
@@ -75,7 +75,7 @@ module.exports = {
for (const book of booksAdded) {
// Grab first 25 that have a cover
- if (book.coverPath && !booksWithCovers.includes(book.libraryItem.id) && booksWithCovers.length < 25 && await fsExtra.pathExists(book.coverPath)) {
+ if (book.coverPath && !booksWithCovers.includes(book.libraryItem.id) && booksWithCovers.length < 25 && (await fsExtra.pathExists(book.coverPath))) {
booksWithCovers.push(book.libraryItem.id)
}
if (book.duration && !isNaN(book.duration)) {
@@ -95,45 +95,54 @@ module.exports = {
const listeningSessions = await this.getListeningSessionsForYear(year)
let totalListeningTime = 0
for (const ls of listeningSessions) {
- totalListeningTime += (ls.timeListening || 0)
+ totalListeningTime += ls.timeListening || 0
- const authors = ls.mediaMetadata.authors || []
+ const authors = ls.mediaMetadata?.authors || []
authors.forEach((au) => {
if (!authorListeningMap[au.name]) authorListeningMap[au.name] = 0
- authorListeningMap[au.name] += (ls.timeListening || 0)
+ authorListeningMap[au.name] += ls.timeListening || 0
})
- const narrators = ls.mediaMetadata.narrators || []
+ const narrators = ls.mediaMetadata?.narrators || []
narrators.forEach((narrator) => {
if (!narratorListeningMap[narrator]) narratorListeningMap[narrator] = 0
- narratorListeningMap[narrator] += (ls.timeListening || 0)
+ narratorListeningMap[narrator] += ls.timeListening || 0
})
// Filter out bad genres like "audiobook" and "audio book"
- const genres = (ls.mediaMetadata.genres || []).filter(g => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book'))
+ const genres = (ls.mediaMetadata?.genres || []).filter((g) => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book'))
genres.forEach((genre) => {
if (!genreListeningMap[genre]) genreListeningMap[genre] = 0
- genreListeningMap[genre] += (ls.timeListening || 0)
+ genreListeningMap[genre] += ls.timeListening || 0
})
}
let topAuthors = null
- topAuthors = Object.keys(authorListeningMap).map(authorName => ({
- name: authorName,
- time: Math.round(authorListeningMap[authorName])
- })).sort((a, b) => b.time - a.time).slice(0, 3)
+ topAuthors = Object.keys(authorListeningMap)
+ .map((authorName) => ({
+ name: authorName,
+ time: Math.round(authorListeningMap[authorName])
+ }))
+ .sort((a, b) => b.time - a.time)
+ .slice(0, 3)
let topNarrators = null
- topNarrators = Object.keys(narratorListeningMap).map(narratorName => ({
- name: narratorName,
- time: Math.round(narratorListeningMap[narratorName])
- })).sort((a, b) => b.time - a.time).slice(0, 3)
+ topNarrators = Object.keys(narratorListeningMap)
+ .map((narratorName) => ({
+ name: narratorName,
+ time: Math.round(narratorListeningMap[narratorName])
+ }))
+ .sort((a, b) => b.time - a.time)
+ .slice(0, 3)
let topGenres = null
- topGenres = Object.keys(genreListeningMap).map(genre => ({
- genre,
- time: Math.round(genreListeningMap[genre])
- })).sort((a, b) => b.time - a.time).slice(0, 3)
+ topGenres = Object.keys(genreListeningMap)
+ .map((genre) => ({
+ genre,
+ time: Math.round(genreListeningMap[genre])
+ }))
+ .sort((a, b) => b.time - a.time)
+ .slice(0, 3)
// Stats for total books, size and duration for everything added this year or earlier
const [totalStatResultsRow] = await Database.sequelize.query(`SELECT SUM(li.size) AS totalSize, SUM(b.duration) AS totalDuration, COUNT(*) AS totalItems FROM libraryItems li, books b WHERE b.id = li.mediaId AND li.mediaType = 'book' AND li.createdAt < ":nextYear-01-01";`, {
diff --git a/server/utils/queries/userStats.js b/server/utils/queries/userStats.js
index 76b69ed78..fbba7129a 100644
--- a/server/utils/queries/userStats.js
+++ b/server/utils/queries/userStats.js
@@ -127,20 +127,20 @@ module.exports = {
bookListeningMap[ls.displayTitle] += listeningSessionListeningTime
}
- const authors = ls.mediaMetadata.authors || []
+ const authors = ls.mediaMetadata?.authors || []
authors.forEach((au) => {
if (!authorListeningMap[au.name]) authorListeningMap[au.name] = 0
authorListeningMap[au.name] += listeningSessionListeningTime
})
- const narrators = ls.mediaMetadata.narrators || []
+ const narrators = ls.mediaMetadata?.narrators || []
narrators.forEach((narrator) => {
if (!narratorListeningMap[narrator]) narratorListeningMap[narrator] = 0
narratorListeningMap[narrator] += listeningSessionListeningTime
})
// Filter out bad genres like "audiobook" and "audio book"
- const genres = (ls.mediaMetadata.genres || []).filter((g) => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book'))
+ const genres = (ls.mediaMetadata?.genres || []).filter((g) => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book'))
genres.forEach((genre) => {
if (!genreListeningMap[genre]) genreListeningMap[genre] = 0
genreListeningMap[genre] += listeningSessionListeningTime
diff --git a/server/utils/scandir.js b/server/utils/scandir.js
index ff21e814f..f59d0a5bc 100644
--- a/server/utils/scandir.js
+++ b/server/utils/scandir.js
@@ -33,109 +33,8 @@ function checkFilepathIsAudioFile(filepath) {
module.exports.checkFilepathIsAudioFile = checkFilepathIsAudioFile
/**
- * TODO: Function needs to be re-done
* @param {string} mediaType
- * @param {string[]} paths array of relative file paths
- * @returns {Record} map of files grouped into potential libarary item dirs
- */
-function groupFilesIntoLibraryItemPaths(mediaType, paths) {
- // Step 1: Clean path, Remove leading "/", Filter out non-media files in root dir
- var nonMediaFilePaths = []
- var pathsFiltered = paths
- .map((path) => {
- return path.startsWith('/') ? path.slice(1) : path
- })
- .filter((path) => {
- let parsedPath = Path.parse(path)
- // Is not in root dir OR is a book media file
- if (parsedPath.dir) {
- if (!isMediaFile(mediaType, parsedPath.ext, false)) {
- // Seperate out non-media files
- nonMediaFilePaths.push(path)
- return false
- }
- return true
- } else if (mediaType === 'book' && isMediaFile(mediaType, parsedPath.ext, false)) {
- // (book media type supports single file audiobooks/ebooks in root dir)
- return true
- }
- return false
- })
-
- // Step 2: Sort by least number of directories
- pathsFiltered.sort((a, b) => {
- var pathsA = Path.dirname(a).split('/').length
- var pathsB = Path.dirname(b).split('/').length
- return pathsA - pathsB
- })
-
- // Step 3: Group files in dirs
- var itemGroup = {}
- pathsFiltered.forEach((path) => {
- var dirparts = Path.dirname(path)
- .split('/')
- .filter((p) => !!p && p !== '.') // dirname returns . if no directory
- var numparts = dirparts.length
- var _path = ''
-
- if (!numparts) {
- // Media file in root
- itemGroup[path] = path
- } else {
- // Iterate over directories in path
- for (let i = 0; i < numparts; i++) {
- var dirpart = dirparts.shift()
- _path = Path.posix.join(_path, dirpart)
-
- if (itemGroup[_path]) {
- // Directory already has files, add file
- var relpath = Path.posix.join(dirparts.join('/'), Path.basename(path))
- itemGroup[_path].push(relpath)
- return
- } else if (!dirparts.length) {
- // This is the last directory, create group
- itemGroup[_path] = [Path.basename(path)]
- return
- } else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) {
- // Next directory is the last and is a CD dir, create group
- itemGroup[_path] = [Path.posix.join(dirparts[0], Path.basename(path))]
- return
- }
- }
- }
- })
-
- // Step 4: Add in non-media files if they fit into item group
- if (nonMediaFilePaths.length) {
- for (const nonMediaFilePath of nonMediaFilePaths) {
- const pathDir = Path.dirname(nonMediaFilePath)
- const filename = Path.basename(nonMediaFilePath)
- const dirparts = pathDir.split('/')
- const numparts = dirparts.length
- let _path = ''
-
- // Iterate over directories in path
- for (let i = 0; i < numparts; i++) {
- const dirpart = dirparts.shift()
- _path = Path.posix.join(_path, dirpart)
- if (itemGroup[_path]) {
- // Directory is a group
- const relpath = Path.posix.join(dirparts.join('/'), filename)
- itemGroup[_path].push(relpath)
- } else if (!dirparts.length) {
- itemGroup[_path] = [filename]
- }
- }
- }
- }
-
- return itemGroup
-}
-module.exports.groupFilesIntoLibraryItemPaths = groupFilesIntoLibraryItemPaths
-
-/**
- * @param {string} mediaType
- * @param {{name:string, path:string, dirpath:string, reldirpath:string, fullpath:string, extension:string, deep:number}[]} fileItems (see recurseFiles)
+ * @param {import('./fileUtils').FilePathItem[]} fileItems
* @param {boolean} [audiobooksOnly=false]
* @returns {Record} map of files grouped into potential libarary item dirs
*/
@@ -147,7 +46,9 @@ function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems, audiobooksOnly
// Step 2: Seperate media files and other files
// - Directories without a media file will not be included
+ /** @type {import('./fileUtils').FilePathItem[]} */
const mediaFileItems = []
+ /** @type {import('./fileUtils').FilePathItem[]} */
const otherFileItems = []
itemsFiltered.forEach((item) => {
if (isMediaFile(mediaType, item.extension, audiobooksOnly)) mediaFileItems.push(item)
@@ -179,7 +80,7 @@ function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems, audiobooksOnly
// This is the last directory, create group
libraryItemGroup[_path] = [item.name]
return
- } else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) {
+ } else if (dirparts.length === 1 && /^(cd|dis[ck])\s*\d{1,3}$/i.test(dirparts[0])) {
// Next directory is the last and is a CD dir, create group
libraryItemGroup[_path] = [Path.posix.join(dirparts[0], item.name)]
return
diff --git a/test/server/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris.test.js b/test/server/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris.test.js
new file mode 100644
index 000000000..1662d5f98
--- /dev/null
+++ b/test/server/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris.test.js
@@ -0,0 +1,116 @@
+const { expect } = require('chai')
+const sinon = require('sinon')
+const { up, down } = require('../../../server/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris')
+const { Sequelize } = require('sequelize')
+const Logger = require('../../../server/Logger')
+
+describe('Migration v2.17.4-use-subfolder-for-oidc-redirect-uris', () => {
+ let queryInterface, logger, context
+
+ beforeEach(() => {
+ queryInterface = {
+ sequelize: {
+ query: sinon.stub()
+ }
+ }
+ logger = {
+ info: sinon.stub(),
+ error: sinon.stub()
+ }
+ context = { queryInterface, logger }
+ })
+
+ describe('up', () => {
+ it('should add authOpenIDSubfolderForRedirectURLs if OIDC is enabled', async () => {
+ queryInterface.sequelize.query.onFirstCall().resolves([[{ value: JSON.stringify({ authActiveAuthMethods: ['openid'] }) }]])
+ queryInterface.sequelize.query.onSecondCall().resolves()
+
+ await up({ context })
+
+ expect(logger.info.calledWith('[2.17.4 migration] UPGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris')).to.be.true
+ expect(logger.info.calledWith('[2.17.4 migration] OIDC is enabled, adding authOpenIDSubfolderForRedirectURLs to server settings')).to.be.true
+ expect(queryInterface.sequelize.query.calledTwice).to.be.true
+ expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true
+ expect(
+ queryInterface.sequelize.query.calledWith('UPDATE settings SET value = :value WHERE key = "server-settings";', {
+ replacements: {
+ value: JSON.stringify({ authActiveAuthMethods: ['openid'], authOpenIDSubfolderForRedirectURLs: '' })
+ }
+ })
+ ).to.be.true
+ expect(logger.info.calledWith('[2.17.4 migration] UPGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris')).to.be.true
+ })
+
+ it('should not add authOpenIDSubfolderForRedirectURLs if OIDC is not enabled', async () => {
+ queryInterface.sequelize.query.onFirstCall().resolves([[{ value: JSON.stringify({ authActiveAuthMethods: [] }) }]])
+
+ await up({ context })
+
+ expect(logger.info.calledWith('[2.17.4 migration] UPGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris')).to.be.true
+ expect(logger.info.calledWith('[2.17.4 migration] OIDC is not enabled, no action required')).to.be.true
+ expect(queryInterface.sequelize.query.calledOnce).to.be.true
+ expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true
+ expect(logger.info.calledWith('[2.17.4 migration] UPGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris')).to.be.true
+ })
+
+ it('should throw an error if server settings cannot be parsed', async () => {
+ queryInterface.sequelize.query.onFirstCall().resolves([[{ value: 'invalid json' }]])
+
+ try {
+ await up({ context })
+ } catch (error) {
+ expect(queryInterface.sequelize.query.calledOnce).to.be.true
+ expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true
+ expect(logger.error.calledWith('[2.17.4 migration] Error parsing server settings:')).to.be.true
+ expect(error).to.be.instanceOf(Error)
+ }
+ })
+
+ it('should throw an error if server settings are not found', async () => {
+ queryInterface.sequelize.query.onFirstCall().resolves([[]])
+
+ try {
+ await up({ context })
+ } catch (error) {
+ expect(queryInterface.sequelize.query.calledOnce).to.be.true
+ expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true
+ expect(logger.error.calledWith('[2.17.4 migration] Server settings not found')).to.be.true
+ expect(error).to.be.instanceOf(Error)
+ }
+ })
+ })
+
+ describe('down', () => {
+ it('should remove authOpenIDSubfolderForRedirectURLs if it exists', async () => {
+ queryInterface.sequelize.query.onFirstCall().resolves([[{ value: JSON.stringify({ authOpenIDSubfolderForRedirectURLs: '' }) }]])
+ queryInterface.sequelize.query.onSecondCall().resolves()
+
+ await down({ context })
+
+ expect(logger.info.calledWith('[2.17.4 migration] DOWNGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris ')).to.be.true
+ expect(logger.info.calledWith('[2.17.4 migration] Removing authOpenIDSubfolderForRedirectURLs from server settings')).to.be.true
+ expect(queryInterface.sequelize.query.calledTwice).to.be.true
+ expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true
+ expect(
+ queryInterface.sequelize.query.calledWith('UPDATE settings SET value = :value WHERE key = "server-settings";', {
+ replacements: {
+ value: JSON.stringify({})
+ }
+ })
+ ).to.be.true
+ expect(logger.info.calledWith('[2.17.4 migration] DOWNGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris ')).to.be.true
+ })
+
+ it('should not remove authOpenIDSubfolderForRedirectURLs if it does not exist', async () => {
+ queryInterface.sequelize.query.onFirstCall().resolves([[{ value: JSON.stringify({}) }]])
+
+ await down({ context })
+
+ expect(logger.info.calledWith('[2.17.4 migration] DOWNGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris ')).to.be.true
+ expect(logger.info.calledWith('[2.17.4 migration] authOpenIDSubfolderForRedirectURLs not found in server settings, no action required')).to.be.true
+ expect(queryInterface.sequelize.query.calledOnce).to.be.true
+ expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true
+ expect(logger.info.calledWith('[2.17.4 migration] DOWNGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris ')).to.be.true
+ })
+ })
+})
diff --git a/test/server/migrations/v2.17.5-remove-host-from-feed-urls.test.js b/test/server/migrations/v2.17.5-remove-host-from-feed-urls.test.js
new file mode 100644
index 000000000..786ed6ae6
--- /dev/null
+++ b/test/server/migrations/v2.17.5-remove-host-from-feed-urls.test.js
@@ -0,0 +1,202 @@
+const { expect } = require('chai')
+const sinon = require('sinon')
+const { up, down } = require('../../../server/migrations/v2.17.5-remove-host-from-feed-urls')
+const { Sequelize, DataTypes } = require('sequelize')
+const Logger = require('../../../server/Logger')
+
+const defineModels = (sequelize) => {
+ const Feeds = sequelize.define('Feeds', {
+ id: { type: DataTypes.UUID, primaryKey: true, defaultValue: DataTypes.UUIDV4 },
+ feedUrl: { type: DataTypes.STRING },
+ imageUrl: { type: DataTypes.STRING },
+ siteUrl: { type: DataTypes.STRING },
+ serverAddress: { type: DataTypes.STRING }
+ })
+
+ const FeedEpisodes = sequelize.define('FeedEpisodes', {
+ id: { type: DataTypes.UUID, primaryKey: true, defaultValue: DataTypes.UUIDV4 },
+ feedId: { type: DataTypes.UUID },
+ siteUrl: { type: DataTypes.STRING },
+ enclosureUrl: { type: DataTypes.STRING }
+ })
+
+ return { Feeds, FeedEpisodes }
+}
+
+describe('Migration v2.17.4-use-subfolder-for-oidc-redirect-uris', () => {
+ let queryInterface, logger, context
+ let sequelize
+ let Feeds, FeedEpisodes
+ const feed1Id = '00000000-0000-4000-a000-000000000001'
+ const feed2Id = '00000000-0000-4000-a000-000000000002'
+ const feedEpisode1Id = '00000000-4000-a000-0000-000000000011'
+ const feedEpisode2Id = '00000000-4000-a000-0000-000000000012'
+ const feedEpisode3Id = '00000000-4000-a000-0000-000000000021'
+
+ before(async () => {
+ sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
+ queryInterface = sequelize.getQueryInterface()
+ ;({ Feeds, FeedEpisodes } = defineModels(sequelize))
+ await sequelize.sync()
+ })
+
+ after(async () => {
+ await sequelize.close()
+ })
+
+ beforeEach(async () => {
+ // Reset tables before each test
+ await Feeds.destroy({ where: {}, truncate: true })
+ await FeedEpisodes.destroy({ where: {}, truncate: true })
+
+ logger = {
+ info: sinon.stub(),
+ error: sinon.stub()
+ }
+ context = { queryInterface, logger }
+ })
+
+ describe('up', () => {
+ it('should remove serverAddress from URLs in Feeds and FeedEpisodes tables', async () => {
+ await Feeds.bulkCreate([
+ { id: feed1Id, feedUrl: 'http://server1.com/feed1', imageUrl: 'http://server1.com/img1', siteUrl: 'http://server1.com/site1', serverAddress: 'http://server1.com' },
+ { id: feed2Id, feedUrl: 'http://server2.com/feed2', imageUrl: 'http://server2.com/img2', siteUrl: 'http://server2.com/site2', serverAddress: 'http://server2.com' }
+ ])
+
+ await FeedEpisodes.bulkCreate([
+ { id: feedEpisode1Id, feedId: feed1Id, siteUrl: 'http://server1.com/episode11', enclosureUrl: 'http://server1.com/enclosure11' },
+ { id: feedEpisode2Id, feedId: feed1Id, siteUrl: 'http://server1.com/episode12', enclosureUrl: 'http://server1.com/enclosure12' },
+ { id: feedEpisode3Id, feedId: feed2Id, siteUrl: 'http://server2.com/episode21', enclosureUrl: 'http://server2.com/enclosure21' }
+ ])
+
+ await up({ context })
+ const feeds = await Feeds.findAll({ raw: true })
+ const feedEpisodes = await FeedEpisodes.findAll({ raw: true })
+
+ expect(logger.info.calledWith('[2.17.5 migration] UPGRADE BEGIN: 2.17.5-remove-host-from-feed-urls')).to.be.true
+ expect(logger.info.calledWith('[2.17.5 migration] Removing serverAddress from Feeds table URLs')).to.be.true
+
+ expect(feeds[0].feedUrl).to.equal('/feed1')
+ expect(feeds[0].imageUrl).to.equal('/img1')
+ expect(feeds[0].siteUrl).to.equal('/site1')
+ expect(feeds[1].feedUrl).to.equal('/feed2')
+ expect(feeds[1].imageUrl).to.equal('/img2')
+ expect(feeds[1].siteUrl).to.equal('/site2')
+
+ expect(logger.info.calledWith('[2.17.5 migration] Removed serverAddress from Feeds table URLs')).to.be.true
+ expect(logger.info.calledWith('[2.17.5 migration] Removing serverAddress from FeedEpisodes table URLs')).to.be.true
+
+ expect(feedEpisodes[0].siteUrl).to.equal('/episode11')
+ expect(feedEpisodes[0].enclosureUrl).to.equal('/enclosure11')
+ expect(feedEpisodes[1].siteUrl).to.equal('/episode12')
+ expect(feedEpisodes[1].enclosureUrl).to.equal('/enclosure12')
+ expect(feedEpisodes[2].siteUrl).to.equal('/episode21')
+ expect(feedEpisodes[2].enclosureUrl).to.equal('/enclosure21')
+
+ expect(logger.info.calledWith('[2.17.5 migration] Removed serverAddress from FeedEpisodes table URLs')).to.be.true
+ expect(logger.info.calledWith('[2.17.5 migration] UPGRADE END: 2.17.5-remove-host-from-feed-urls')).to.be.true
+ })
+
+ it('should handle null URLs in Feeds and FeedEpisodes tables', async () => {
+ await Feeds.bulkCreate([{ id: feed1Id, feedUrl: 'http://server1.com/feed1', imageUrl: null, siteUrl: 'http://server1.com/site1', serverAddress: 'http://server1.com' }])
+
+ await FeedEpisodes.bulkCreate([{ id: feedEpisode1Id, feedId: feed1Id, siteUrl: null, enclosureUrl: 'http://server1.com/enclosure11' }])
+
+ await up({ context })
+ const feeds = await Feeds.findAll({ raw: true })
+ const feedEpisodes = await FeedEpisodes.findAll({ raw: true })
+
+ expect(feeds[0].feedUrl).to.equal('/feed1')
+ expect(feeds[0].imageUrl).to.be.null
+ expect(feeds[0].siteUrl).to.equal('/site1')
+ expect(feedEpisodes[0].siteUrl).to.be.null
+ expect(feedEpisodes[0].enclosureUrl).to.equal('/enclosure11')
+ })
+
+ it('should handle null serverAddress in Feeds table', async () => {
+ await Feeds.bulkCreate([{ id: feed1Id, feedUrl: 'http://server1.com/feed1', imageUrl: 'http://server1.com/img1', siteUrl: 'http://server1.com/site1', serverAddress: null }])
+ await FeedEpisodes.bulkCreate([{ id: feedEpisode1Id, feedId: feed1Id, siteUrl: 'http://server1.com/episode11', enclosureUrl: 'http://server1.com/enclosure11' }])
+
+ await up({ context })
+ const feeds = await Feeds.findAll({ raw: true })
+ const feedEpisodes = await FeedEpisodes.findAll({ raw: true })
+
+ expect(feeds[0].feedUrl).to.equal('http://server1.com/feed1')
+ expect(feeds[0].imageUrl).to.equal('http://server1.com/img1')
+ expect(feeds[0].siteUrl).to.equal('http://server1.com/site1')
+ expect(feedEpisodes[0].siteUrl).to.equal('http://server1.com/episode11')
+ expect(feedEpisodes[0].enclosureUrl).to.equal('http://server1.com/enclosure11')
+ })
+ })
+
+ describe('down', () => {
+ it('should add serverAddress back to URLs in Feeds and FeedEpisodes tables', async () => {
+ await Feeds.bulkCreate([
+ { id: feed1Id, feedUrl: '/feed1', imageUrl: '/img1', siteUrl: '/site1', serverAddress: 'http://server1.com' },
+ { id: feed2Id, feedUrl: '/feed2', imageUrl: '/img2', siteUrl: '/site2', serverAddress: 'http://server2.com' }
+ ])
+
+ await FeedEpisodes.bulkCreate([
+ { id: feedEpisode1Id, feedId: feed1Id, siteUrl: '/episode11', enclosureUrl: '/enclosure11' },
+ { id: feedEpisode2Id, feedId: feed1Id, siteUrl: '/episode12', enclosureUrl: '/enclosure12' },
+ { id: feedEpisode3Id, feedId: feed2Id, siteUrl: '/episode21', enclosureUrl: '/enclosure21' }
+ ])
+
+ await down({ context })
+ const feeds = await Feeds.findAll({ raw: true })
+ const feedEpisodes = await FeedEpisodes.findAll({ raw: true })
+
+ expect(logger.info.calledWith('[2.17.5 migration] DOWNGRADE BEGIN: 2.17.5-remove-host-from-feed-urls')).to.be.true
+ expect(logger.info.calledWith('[2.17.5 migration] Adding serverAddress back to Feeds table URLs')).to.be.true
+
+ expect(feeds[0].feedUrl).to.equal('http://server1.com/feed1')
+ expect(feeds[0].imageUrl).to.equal('http://server1.com/img1')
+ expect(feeds[0].siteUrl).to.equal('http://server1.com/site1')
+ expect(feeds[1].feedUrl).to.equal('http://server2.com/feed2')
+ expect(feeds[1].imageUrl).to.equal('http://server2.com/img2')
+ expect(feeds[1].siteUrl).to.equal('http://server2.com/site2')
+
+ expect(logger.info.calledWith('[2.17.5 migration] Added serverAddress back to Feeds table URLs')).to.be.true
+ expect(logger.info.calledWith('[2.17.5 migration] Adding serverAddress back to FeedEpisodes table URLs')).to.be.true
+
+ expect(feedEpisodes[0].siteUrl).to.equal('http://server1.com/episode11')
+ expect(feedEpisodes[0].enclosureUrl).to.equal('http://server1.com/enclosure11')
+ expect(feedEpisodes[1].siteUrl).to.equal('http://server1.com/episode12')
+ expect(feedEpisodes[1].enclosureUrl).to.equal('http://server1.com/enclosure12')
+ expect(feedEpisodes[2].siteUrl).to.equal('http://server2.com/episode21')
+ expect(feedEpisodes[2].enclosureUrl).to.equal('http://server2.com/enclosure21')
+
+ expect(logger.info.calledWith('[2.17.5 migration] DOWNGRADE END: 2.17.5-remove-host-from-feed-urls')).to.be.true
+ })
+
+ it('should handle null URLs in Feeds and FeedEpisodes tables', async () => {
+ await Feeds.bulkCreate([{ id: feed1Id, feedUrl: '/feed1', imageUrl: null, siteUrl: '/site1', serverAddress: 'http://server1.com' }])
+ await FeedEpisodes.bulkCreate([{ id: feedEpisode1Id, feedId: feed1Id, siteUrl: null, enclosureUrl: '/enclosure11' }])
+
+ await down({ context })
+ const feeds = await Feeds.findAll({ raw: true })
+ const feedEpisodes = await FeedEpisodes.findAll({ raw: true })
+
+ expect(feeds[0].feedUrl).to.equal('http://server1.com/feed1')
+ expect(feeds[0].imageUrl).to.be.null
+ expect(feeds[0].siteUrl).to.equal('http://server1.com/site1')
+ expect(feedEpisodes[0].siteUrl).to.be.null
+ expect(feedEpisodes[0].enclosureUrl).to.equal('http://server1.com/enclosure11')
+ })
+
+ it('should handle null serverAddress in Feeds table', async () => {
+ await Feeds.bulkCreate([{ id: feed1Id, feedUrl: '/feed1', imageUrl: '/img1', siteUrl: '/site1', serverAddress: null }])
+ await FeedEpisodes.bulkCreate([{ id: feedEpisode1Id, feedId: feed1Id, siteUrl: '/episode11', enclosureUrl: '/enclosure11' }])
+
+ await down({ context })
+ const feeds = await Feeds.findAll({ raw: true })
+ const feedEpisodes = await FeedEpisodes.findAll({ raw: true })
+
+ expect(feeds[0].feedUrl).to.equal('/feed1')
+ expect(feeds[0].imageUrl).to.equal('/img1')
+ expect(feeds[0].siteUrl).to.equal('/site1')
+ expect(feedEpisodes[0].siteUrl).to.equal('/episode11')
+ expect(feedEpisodes[0].enclosureUrl).to.equal('/enclosure11')
+ })
+ })
+})
diff --git a/test/server/utils/scandir.test.js b/test/server/utils/scandir.test.js
new file mode 100644
index 000000000..a5ff6ae0e
--- /dev/null
+++ b/test/server/utils/scandir.test.js
@@ -0,0 +1,52 @@
+const Path = require('path')
+const chai = require('chai')
+const expect = chai.expect
+const scanUtils = require('../../../server/utils/scandir')
+
+describe('scanUtils', async () => {
+ it('should properly group files into potential book library items', async () => {
+ global.isWin = process.platform === 'win32'
+ global.ServerSettings = {
+ scannerParseSubtitle: true
+ }
+
+ const filePaths = [
+ 'randomfile.txt', // Should be ignored because it's not a book media file
+ 'Book1.m4b', // Root single file audiobook
+ 'Book2/audiofile.m4b',
+ 'Book2/disk 001/audiofile.m4b',
+ 'Book2/disk 002/audiofile.m4b',
+ 'Author/Book3/audiofile.mp3',
+ 'Author/Book3/Disc 1/audiofile.mp3',
+ 'Author/Book3/Disc 2/audiofile.mp3',
+ 'Author/Series/Book4/cover.jpg',
+ 'Author/Series/Book4/CD1/audiofile.mp3',
+ 'Author/Series/Book4/CD2/audiofile.mp3',
+ 'Author/Series2/Book5/deeply/nested/cd 01/audiofile.mp3',
+ 'Author/Series2/Book5/deeply/nested/cd 02/audiofile.mp3',
+ 'Author/Series2/Book5/randomfile.js' // Should be ignored because it's not a book media file
+ ]
+
+ // Create fileItems to match the format of fileUtils.recurseFiles
+ const fileItems = []
+ for (const filePath of filePaths) {
+ const dirname = Path.dirname(filePath)
+ fileItems.push({
+ name: Path.basename(filePath),
+ reldirpath: dirname === '.' ? '' : dirname,
+ extension: Path.extname(filePath),
+ deep: filePath.split('/').length - 1
+ })
+ }
+
+ const libraryItemGrouping = scanUtils.groupFileItemsIntoLibraryItemDirs('book', fileItems, false)
+
+ expect(libraryItemGrouping).to.deep.equal({
+ 'Book1.m4b': 'Book1.m4b',
+ Book2: ['audiofile.m4b', 'disk 001/audiofile.m4b', 'disk 002/audiofile.m4b'],
+ 'Author/Book3': ['audiofile.mp3', 'Disc 1/audiofile.mp3', 'Disc 2/audiofile.mp3'],
+ 'Author/Series/Book4': ['CD1/audiofile.mp3', 'CD2/audiofile.mp3', 'cover.jpg'],
+ 'Author/Series2/Book5/deeply/nested': ['cd 01/audiofile.mp3', 'cd 02/audiofile.mp3']
+ })
+ })
+})