mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-03-02 05:59:42 +00:00
Merge remote-tracking branch 'remotes/upstream/master'
This commit is contained in:
commit
956f475a6e
45 changed files with 1995 additions and 370 deletions
|
|
@ -10,9 +10,9 @@
|
|||
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedIsOpen }}</p>
|
||||
|
||||
<div class="w-full relative">
|
||||
<ui-text-input v-model="currentFeed.feedUrl" readonly />
|
||||
<ui-text-input :value="feedUrl" readonly />
|
||||
|
||||
<span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(currentFeed.feedUrl)">content_copy</span>
|
||||
<span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(feedUrl)">content_copy</span>
|
||||
</div>
|
||||
|
||||
<div v-if="currentFeed.meta" class="mt-5">
|
||||
|
|
@ -111,8 +111,11 @@ export default {
|
|||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
feedUrl() {
|
||||
return this.currentFeed ? `${window.origin}${this.$config.routerBasePath}${this.currentFeed.feedUrl}` : ''
|
||||
},
|
||||
demoFeedUrl() {
|
||||
return `${window.origin}/feed/${this.newFeedSlug}`
|
||||
return `${window.origin}${this.$config.routerBasePath}/feed/${this.newFeedSlug}`
|
||||
},
|
||||
isHttp() {
|
||||
return window.origin.startsWith('http://')
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedGeneral }}</p>
|
||||
|
||||
<div class="w-full relative">
|
||||
<ui-text-input v-model="feed.feedUrl" readonly />
|
||||
<span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(feed.feedUrl)">content_copy</span>
|
||||
<ui-text-input :value="feedUrl" readonly />
|
||||
<span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(feedUrl)">content_copy</span>
|
||||
</div>
|
||||
|
||||
<div v-if="feed.meta" class="mt-5">
|
||||
|
|
@ -70,6 +70,9 @@ export default {
|
|||
},
|
||||
_feed() {
|
||||
return this.feed || {}
|
||||
},
|
||||
feedUrl() {
|
||||
return this.feed ? `${window.origin}${this.$config.routerBasePath}${this.feed.feedUrl}` : ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@ export default {
|
|||
this.users = res.users.sort((a, b) => {
|
||||
return a.createdAt - b.createdAt
|
||||
})
|
||||
this.$emit('numUsers', this.users.length)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
|
|
|
|||
4
client/package-lock.json
generated
4
client/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.17.3",
|
||||
"version": "2.17.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.17.3",
|
||||
"version": "2.17.5",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.17.3",
|
||||
"version": "2.17.5",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast client",
|
||||
"main": "index.js",
|
||||
|
|
|
|||
|
|
@ -64,6 +64,20 @@
|
|||
<ui-multi-select ref="redirectUris" v-model="newAuthSettings.authOpenIDMobileRedirectURIs" :items="newAuthSettings.authOpenIDMobileRedirectURIs" :label="$strings.LabelMobileRedirectURIs" class="mb-2" :menuDisabled="true" :disabled="savingSettings" />
|
||||
<p class="sm:pl-4 text-sm text-gray-300 mb-2" v-html="$strings.LabelMobileRedirectURIsDescription" />
|
||||
|
||||
<div class="flex sm:items-center flex-col sm:flex-row pt-1 mb-2">
|
||||
<div class="w-44">
|
||||
<ui-dropdown v-model="newAuthSettings.authOpenIDSubfolderForRedirectURLs" small :items="subfolderOptions" :label="$strings.LabelWebRedirectURLsSubfolder" :disabled="savingSettings" />
|
||||
</div>
|
||||
<div class="mt-2 sm:mt-5">
|
||||
<p class="sm:pl-4 text-sm text-gray-300">{{ $strings.LabelWebRedirectURLsDescription }}</p>
|
||||
<p class="sm:pl-4 text-sm text-gray-300 mb-2">
|
||||
<code>{{ webCallbackURL }}</code>
|
||||
<br />
|
||||
<code>{{ mobileAppCallbackURL }}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ui-text-input-with-label ref="buttonTextInput" v-model="newAuthSettings.authOpenIDButtonText" :disabled="savingSettings" :label="$strings.LabelButtonText" class="mb-2" />
|
||||
|
||||
<div class="flex sm:items-center flex-col sm:flex-row pt-1 mb-2">
|
||||
|
|
@ -164,6 +178,27 @@ export default {
|
|||
value: 'username'
|
||||
}
|
||||
]
|
||||
},
|
||||
subfolderOptions() {
|
||||
const options = [
|
||||
{
|
||||
text: 'None',
|
||||
value: ''
|
||||
}
|
||||
]
|
||||
if (this.$config.routerBasePath) {
|
||||
options.push({
|
||||
text: this.$config.routerBasePath,
|
||||
value: this.$config.routerBasePath
|
||||
})
|
||||
}
|
||||
return options
|
||||
},
|
||||
webCallbackURL() {
|
||||
return `https://<your.server.com>${this.newAuthSettings.authOpenIDSubfolderForRedirectURLs ? this.newAuthSettings.authOpenIDSubfolderForRedirectURLs : ''}/auth/openid/callback`
|
||||
},
|
||||
mobileAppCallbackURL() {
|
||||
return `https://<your.server.com>${this.newAuthSettings.authOpenIDSubfolderForRedirectURLs ? this.newAuthSettings.authOpenIDSubfolderForRedirectURLs : ''}/auth/openid/mobile-redirect`
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
@ -325,7 +360,8 @@ export default {
|
|||
},
|
||||
init() {
|
||||
this.newAuthSettings = {
|
||||
...this.authSettings
|
||||
...this.authSettings,
|
||||
authOpenIDSubfolderForRedirectURLs: this.authSettings.authOpenIDSubfolderForRedirectURLs === undefined ? this.$config.routerBasePath : this.authSettings.authOpenIDSubfolderForRedirectURLs
|
||||
}
|
||||
this.enableLocalAuth = this.authMethods.includes('local')
|
||||
this.enableOpenIDAuth = this.authMethods.includes('openid')
|
||||
|
|
|
|||
|
|
@ -42,11 +42,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2 mb-2">
|
||||
<ui-toggle-switch labeledBy="settings-chromecast-support" v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
|
||||
<p class="pl-4" id="settings-chromecast-support">{{ $strings.LabelSettingsChromecastSupport }}</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-4">
|
||||
<h2 class="font-semibold">{{ $strings.HeaderSettingsScanner }}</h2>
|
||||
</div>
|
||||
|
|
@ -94,6 +89,20 @@
|
|||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="pt-4">
|
||||
<h2 class="font-semibold">{{ $strings.HeaderSettingsWebClient }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch labeledBy="settings-chromecast-support" v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
|
||||
<p class="pl-4" id="settings-chromecast-support">{{ $strings.LabelSettingsChromecastSupport }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2 mb-2">
|
||||
<ui-toggle-switch labeledBy="settings-allow-iframe" v-model="newServerSettings.allowIframe" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('allowIframe', val)" />
|
||||
<p class="pl-4" id="settings-allow-iframe">{{ $strings.LabelSettingsAllowIframe }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
|
|
@ -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 } : {}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@
|
|||
<div>
|
||||
<app-settings-content :header-text="$strings.HeaderUsers">
|
||||
<template #header-items>
|
||||
<div v-if="numUsers" class="mx-2 px-1.5 rounded-lg bg-primary/50 text-gray-300/90 text-sm inline-flex items-center justify-center">
|
||||
<span>{{ numUsers }}</span>
|
||||
</div>
|
||||
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/users" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
|
|
@ -13,7 +17,7 @@
|
|||
<ui-btn color="primary" small @click="setShowUserModal()">{{ $strings.ButtonAddUser }}</ui-btn>
|
||||
</template>
|
||||
|
||||
<tables-users-table class="pt-2" @edit="setShowUserModal" />
|
||||
<tables-users-table class="pt-2" @edit="setShowUserModal" @numUsers="(count) => (numUsers = count)" />
|
||||
</app-settings-content>
|
||||
<modals-account-modal ref="accountModal" v-model="showAccountModal" :account="selectedAccount" />
|
||||
</div>
|
||||
|
|
@ -29,7 +33,8 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
selectedAccount: null,
|
||||
showAccountModal: false
|
||||
showAccountModal: false,
|
||||
numUsers: 0
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
1027
client/strings/ca.json
Normal file
1027
client/strings/ca.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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 <code>/metadata/cache</code> löschen. <br /><br />Bist du dir sicher, dass das Cache Verzeichnis gelöscht werden soll?",
|
||||
"MessageConfirmPurgeItemsCache": "Durch Elementcache leeren wird das gesamte Verzeichnis unter <code>/metadata/cache/items</code> gelöscht.<br />Bist du dir sicher?",
|
||||
"MessageConfirmQuickEmbed": "Warnung! Audiodateien werden bei der Schnelleinbettung nicht gesichert! Achte darauf, dass du eine Sicherungskopie der Audiodateien besitzt. <br><br>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 <strong>{0}</strong> ab",
|
||||
"MessageShareExpiresIn": "Läuft in {0} ab",
|
||||
"MessageShareURLWillBe": "Der Freigabe Link wird <strong>{0}</strong> sein.",
|
||||
"MessageShareURLWillBe": "Der Freigabe Link wird <strong>{0}</strong> 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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.<br>Podnaslov mora biti odvojen s \" - \"<br>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",
|
||||
|
|
|
|||
|
|
@ -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 <strong>{0}</strong>",
|
||||
"MessageShareExpiresIn": "Poteče čez {0}",
|
||||
"MessageShareURLWillBe": "URL za skupno rabo bo <strong>{0}</strong>",
|
||||
|
|
|
|||
|
|
@ -679,6 +679,8 @@
|
|||
"LabelViewPlayerSettings": "Переглянути налаштування програвача",
|
||||
"LabelViewQueue": "Переглянути чергу відтворення",
|
||||
"LabelVolume": "Гучність",
|
||||
"LabelWebRedirectURLsDescription": "Авторизуйте ці URL у вашому OAuth постачальнику, щоб дозволити редирекцію назад до веб-додатку після входу:",
|
||||
"LabelWebRedirectURLsSubfolder": "Підпапка для Redirect URL",
|
||||
"LabelWeekdaysToRun": "Виконувати у дні",
|
||||
"LabelXBooks": "{0} книг",
|
||||
"LabelXItems": "{0} елементів",
|
||||
|
|
|
|||
|
|
@ -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} 项目",
|
||||
|
|
|
|||
1
index.js
1
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 || ''
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ const Auth = require('./Auth')
|
|||
class SocketAuthority {
|
||||
constructor() {
|
||||
this.Server = null
|
||||
this.io = null
|
||||
this.socketIoServers = []
|
||||
|
||||
/** @type {Object.<string, SocketClient>} */
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}"`)
|
||||
|
|
|
|||
|
|
@ -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<string>} 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<string>} 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()
|
||||
module.exports = new CoverManager()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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<void>} - 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<void>} - 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 }
|
||||
74
server/migrations/v2.17.5-remove-host-from-feed-urls.js
Normal file
74
server/migrations/v2.17.5-remove-host-from-feed-urls.js
Normal file
|
|
@ -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<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function up({ context: { queryInterface, logger } }) {
|
||||
// Upwards migration script
|
||||
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
|
||||
|
||||
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<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function down({ context: { queryInterface, logger } }) {
|
||||
// Downward migration script
|
||||
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
|
||||
|
||||
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 }
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
|
|
|
|||
|
|
@ -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 : [])
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}"`)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ const fsExtra = require('../../libs/fsExtra')
|
|||
|
||||
module.exports = {
|
||||
/**
|
||||
*
|
||||
*
|
||||
* @param {number} year YYYY
|
||||
* @returns {Promise<PlaybackSession[]>}
|
||||
*/
|
||||
|
|
@ -22,7 +22,7 @@ module.exports = {
|
|||
},
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* @param {number} year YYYY
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
|
|
@ -39,7 +39,7 @@ module.exports = {
|
|||
},
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* @param {number} year YYYY
|
||||
* @returns {Promise<import('../../models/Book')[]>}
|
||||
*/
|
||||
|
|
@ -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";`, {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string,string[]>} 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<string,string[]>} 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
52
test/server/utils/scandir.test.js
Normal file
52
test/server/utils/scandir.test.js
Normal file
|
|
@ -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']
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue