Merge remote-tracking branch 'origin/master' into auth_passportjs

This commit is contained in:
lukeIam 2023-09-10 13:11:35 +00:00
commit f0f03efe17
138 changed files with 11777 additions and 7343 deletions

View File

@ -60,13 +60,13 @@ install_ffmpeg() {
fi fi
$WGET $WGET
tar xvf ffmpeg-git-amd64-static.tar.xz --strip-components=1 tar xvf ffmpeg-git-amd64-static.tar.xz --strip-components=1 --no-same-owner
rm ffmpeg-git-amd64-static.tar.xz rm ffmpeg-git-amd64-static.tar.xz
# Temp downloading tone library to the ffmpeg dir # Temp downloading tone library to the ffmpeg dir
echo "Getting tone.." echo "Getting tone.."
$WGET_TONE $WGET_TONE
tar xvf tone-0.1.5-linux-x64.tar.gz --strip-components=1 tar xvf tone-0.1.5-linux-x64.tar.gz --strip-components=1 --no-same-owner
rm tone-0.1.5-linux-x64.tar.gz rm tone-0.1.5-linux-x64.tar.gz
echo "Good to go on Ffmpeg (& tone)... hopefully" echo "Good to go on Ffmpeg (& tone)... hopefully"

View File

@ -171,7 +171,7 @@ export default {
}, },
async fetchCategories() { async fetchCategories() {
const categories = await this.$axios const categories = await this.$axios
.$get(`/api/libraries/${this.currentLibraryId}/personalized2?include=rssfeed,numEpisodesIncomplete`) .$get(`/api/libraries/${this.currentLibraryId}/personalized?include=rssfeed,numEpisodesIncomplete`)
.then((data) => { .then((data) => {
return data return data
}) })

View File

@ -99,6 +99,11 @@ export default {
id: 'config-item-metadata-utils', id: 'config-item-metadata-utils',
title: this.$strings.HeaderItemMetadataUtils, title: this.$strings.HeaderItemMetadataUtils,
path: '/config/item-metadata-utils' path: '/config/item-metadata-utils'
},
{
id: 'config-rss-feeds',
title: this.$strings.HeaderRSSFeeds,
path: '/config/rss-feeds'
} }
] ]

View File

@ -314,11 +314,6 @@ export default {
} }
let entityPath = this.entityName === 'series-books' ? 'items' : this.entityName let entityPath = this.entityName === 'series-books' ? 'items' : this.entityName
// TODO: Temp use new library items API for everything except collapse sub-series
if (entityPath === 'items' && !this.collapseBookSeries && !(this.filterName === 'Series' && this.collapseSeries)) {
entityPath += '2'
}
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : '' const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed,numEpisodesIncomplete` const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed,numEpisodesIncomplete`
@ -628,6 +623,11 @@ export default {
return entitiesPerShelfBefore < this.entitiesPerShelf // Books per shelf has changed return entitiesPerShelfBefore < this.entitiesPerShelf // Books per shelf has changed
}, },
async init(bookshelf) { async init(bookshelf) {
if (this.entityName === 'series') {
this.booksPerFetch = 50
} else {
this.booksPerFetch = 100
}
this.checkUpdateSearchParams() this.checkUpdateSearchParams()
this.initSizeData(bookshelf) this.initSizeData(bookshelf)

View File

@ -219,7 +219,7 @@ export default {
return this.mediaMetadata.series return this.mediaMetadata.series
}, },
seriesSequence() { seriesSequence() {
return this.series ? this.series.sequence : null return this.series?.sequence || null
}, },
libraryId() { libraryId() {
return this._libraryItem.libraryId return this._libraryItem.libraryId
@ -318,6 +318,7 @@ export default {
if (this.orderBy === 'media.duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false) if (this.orderBy === 'media.duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size) if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} Episodes` if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} Episodes`
if (this.orderBy === 'media.metadata.publishedYear' && this.mediaMetadata.publishedYear) return 'Published ' + this.mediaMetadata.publishedYear
return null return null
}, },
episodeProgress() { episodeProgress() {

View File

@ -36,7 +36,7 @@ export default {
return this.narrator?.name || '' return this.narrator?.name || ''
}, },
numBooks() { numBooks() {
return this.narrator?.books?.length || 0 return this.narrator?.numBooks || this.narrator?.books?.length || 0
}, },
userCanUpdate() { userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate'] return this.$store.getters['user/getUserCanUpdate']

View File

@ -103,7 +103,7 @@ export default {
return this.$store.state.libraries.currentLibraryId return this.$store.state.libraries.currentLibraryId
}, },
totalResults() { totalResults() {
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.podcastResults.length return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.podcastResults.length + this.narratorResults.length
} }
}, },
methods: { methods: {

View File

@ -13,8 +13,8 @@
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }"> <div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center"> <div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
<img src="/Logo.png" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" /> <img v-if="width > 100" src="/Logo.png" class="mb-2" :style="{ height: 40 * sizeMultiplier + 'px' }" />
<p class="text-center text-error" :style="{ fontSize: sizeMultiplier + 'rem' }">Invalid Cover</p> <p class="text-center text-error" :style="{ fontSize: invalidCoverFontSize + 'rem' }">Invalid Cover</p>
</div> </div>
</div> </div>
@ -58,6 +58,9 @@ export default {
sizeMultiplier() { sizeMultiplier() {
return this.width / 120 return this.width / 120
}, },
invalidCoverFontSize() {
return Math.max(this.sizeMultiplier * 0.8, 0.5)
},
placeholderCoverPadding() { placeholderCoverPadding() {
return 0.8 * this.sizeMultiplier return 0.8 * this.sizeMultiplier
}, },

View File

@ -283,9 +283,8 @@ export default {
} }
if (success) { if (success) {
this.$toast.success('Update Successful') this.$toast.success('Update Successful')
// this.$emit('close') } else if (this.media.coverPath) {
} else { this.imageUrl = this.media.coverPath
this.imageUrl = this.media.coverPath || ''
} }
this.isProcessing = false this.isProcessing = false
}, },

View File

@ -11,9 +11,9 @@
</div> </div>
<div class="py-3"> <div class="py-3">
<div class="flex items-center"> <div class="flex items-center">
<ui-toggle-switch v-if="!globalWatcherDisabled" v-model="disableWatcher" @input="formUpdated" /> <ui-toggle-switch v-if="!globalWatcherDisabled" v-model="enableWatcher" @input="formUpdated" />
<ui-toggle-switch v-else disabled :value="false" /> <ui-toggle-switch v-else disabled :value="false" />
<p class="pl-4 text-base">{{ $strings.LabelSettingsDisableWatcherForLibrary }}</p> <p class="pl-4 text-base">{{ $strings.LabelSettingsEnableWatcherForLibrary }}</p>
</div> </div>
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*{{ $strings.MessageWatcherIsDisabledGlobally }}</p> <p v-if="globalWatcherDisabled" class="text-xs text-warning">*{{ $strings.MessageWatcherIsDisabledGlobally }}</p>
</div> </div>
@ -65,7 +65,7 @@ export default {
return { return {
provider: null, provider: null,
useSquareBookCovers: false, useSquareBookCovers: false,
disableWatcher: false, enableWatcher: false,
skipMatchingMediaWithAsin: false, skipMatchingMediaWithAsin: false,
skipMatchingMediaWithIsbn: false, skipMatchingMediaWithIsbn: false,
audiobooksOnly: false, audiobooksOnly: false,
@ -95,7 +95,7 @@ export default {
return { return {
settings: { settings: {
coverAspectRatio: this.useSquareBookCovers ? this.$constants.BookCoverAspectRatio.SQUARE : this.$constants.BookCoverAspectRatio.STANDARD, coverAspectRatio: this.useSquareBookCovers ? this.$constants.BookCoverAspectRatio.SQUARE : this.$constants.BookCoverAspectRatio.STANDARD,
disableWatcher: !!this.disableWatcher, disableWatcher: !this.enableWatcher,
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin, skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn, skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn,
audiobooksOnly: !!this.audiobooksOnly, audiobooksOnly: !!this.audiobooksOnly,
@ -108,7 +108,7 @@ export default {
}, },
init() { init() {
this.useSquareBookCovers = this.librarySettings.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE this.useSquareBookCovers = this.librarySettings.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
this.disableWatcher = !!this.librarySettings.disableWatcher this.enableWatcher = !this.librarySettings.disableWatcher
this.skipMatchingMediaWithAsin = !!this.librarySettings.skipMatchingMediaWithAsin this.skipMatchingMediaWithAsin = !!this.librarySettings.skipMatchingMediaWithAsin
this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn
this.audiobooksOnly = !!this.librarySettings.audiobooksOnly this.audiobooksOnly = !!this.librarySettings.audiobooksOnly

View File

@ -132,6 +132,8 @@ export default {
return return
} }
this.processing = true
const payload = { const payload = {
serverAddress: window.origin, serverAddress: window.origin,
slug: this.newFeedSlug, slug: this.newFeedSlug,
@ -151,6 +153,9 @@ export default {
const errorMsg = error.response ? error.response.data : null const errorMsg = error.response ? error.response.data : null
this.$toast.error(errorMsg || 'Failed to open RSS Feed') this.$toast.error(errorMsg || 'Failed to open RSS Feed')
}) })
.finally(() => {
this.processing = false
})
}, },
copyToClipboard(str) { copyToClipboard(str) {
this.$copyToClipboard(str, this) this.$copyToClipboard(str, this)

View File

@ -0,0 +1,124 @@
<template>
<modals-modal v-model="show" name="rss-feed-view-modal" :processing="processing" :width="700" :height="'unset'">
<div ref="wrapper" class="px-8 py-6 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
<div v-if="feed" class="w-full">
<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-icons 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>
</div>
<div v-if="feed.meta" class="mt-5">
<div class="flex py-0.5">
<div class="w-48">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedPreventIndexing }}</span>
</div>
<div>{{ feed.meta.preventIndexing ? 'Yes' : 'No' }}</div>
</div>
<div v-if="feed.meta.ownerName" class="flex py-0.5">
<div class="w-48">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedCustomOwnerName }}</span>
</div>
<div>{{ feed.meta.ownerName }}</div>
</div>
<div v-if="feed.meta.ownerEmail" class="flex py-0.5">
<div class="w-48">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedCustomOwnerEmail }}</span>
</div>
<div>{{ feed.meta.ownerEmail }}</div>
</div>
</div>
<!-- -->
<div class="episodesTable mt-2">
<div class="bg-primary bg-opacity-40 h-12 header">
{{ $strings.LabelEpisodeTitle }}
</div>
<div class="scroller">
<div v-for="episode in feed.episodes" :key="episode.id" class="h-8 text-xs truncate">
{{ episode.title }}
</div>
</div>
</div>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
feed: {
type: Object,
default: () => {}
}
},
data() {
return {
processing: false
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
_feed() {
return this.feed || {}
}
},
methods: {
copyToClipboard(str) {
this.$copyToClipboard(str, this)
}
},
mounted() {}
}
</script>
<style scoped>
.episodesTable {
width: 100%;
max-width: 100%;
border: 1px solid #474747;
display: flex;
flex-direction: column;
}
.episodesTable div.header {
background-color: #272727;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
padding: 4px 8px;
}
.episodesTable .scroller {
display: flex;
flex-direction: column;
max-height: 250px;
overflow-x: hidden;
overflow-y: scroll;
}
.episodesTable .scroller div {
background-color: #373838;
padding: 4px 8px;
display: flex;
align-items: center;
justify-content: flex-start;
height: 32px;
flex: 0 0 32px;
}
.episodesTable .scroller div:nth-child(even) {
background-color: #2f2f2f;
}
</style>

View File

@ -303,8 +303,8 @@ export default {
}, },
parseImageFilename(filename) { parseImageFilename(filename) {
var basename = Path.basename(filename, Path.extname(filename)) var basename = Path.basename(filename, Path.extname(filename))
var numbersinpath = basename.match(/\d{1,5}/g) var numbersinpath = basename.match(/\d+/g)
if (!numbersinpath || !numbersinpath.length) { if (!numbersinpath?.length) {
return { return {
index: -1, index: -1,
filename filename

View File

@ -18,7 +18,7 @@
</div> </div>
</div> </div>
<div class="flex px-4"> <div v-if="isBookLibrary" class="flex px-4">
<svg class="h-14 w-14 md:h-18 md:w-18" viewBox="0 0 24 24"> <svg class="h-14 w-14 md:h-18 md:w-18" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,6A2,2 0 0,0 10,8A2,2 0 0,0 12,10A2,2 0 0,0 14,8A2,2 0 0,0 12,6M12,13C14.67,13 20,14.33 20,17V20H4V17C4,14.33 9.33,13 12,13M12,14.9C9.03,14.9 5.9,16.36 5.9,17V18.1H18.1V17C18.1,16.36 14.97,14.9 12,14.9Z" /> <path fill="currentColor" d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,6A2,2 0 0,0 10,8A2,2 0 0,0 12,10A2,2 0 0,0 14,8A2,2 0 0,0 12,6M12,13C14.67,13 20,14.33 20,17V20H4V17C4,14.33 9.33,13 12,13M12,14.9C9.03,14.9 5.9,16.36 5.9,17V18.1H18.1V17C18.1,16.36 14.97,14.9 12,14.9Z" />
</svg> </svg>
@ -58,26 +58,32 @@ export default {
return {} return {}
}, },
computed: { computed: {
currentLibraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
isBookLibrary() {
return this.currentLibraryMediaType === 'book'
},
user() { user() {
return this.$store.state.user.user return this.$store.state.user.user
}, },
totalItems() { totalItems() {
return this.libraryStats ? this.libraryStats.totalItems : 0 return this.libraryStats?.totalItems || 0
}, },
totalAuthors() { totalAuthors() {
return this.libraryStats ? this.libraryStats.totalAuthors : 0 return this.libraryStats?.totalAuthors || 0
}, },
numAudioTracks() { numAudioTracks() {
return this.libraryStats ? this.libraryStats.numAudioTracks : 0 return this.libraryStats?.numAudioTracks || 0
}, },
totalDuration() { totalDuration() {
return this.libraryStats ? this.libraryStats.totalDuration : 0 return this.libraryStats?.totalDuration || 0
}, },
totalHours() { totalHours() {
return Math.round(this.totalDuration / (60 * 60)) return Math.round(this.totalDuration / (60 * 60))
}, },
totalSizePretty() { totalSizePretty() {
var totalSize = this.libraryStats ? this.libraryStats.totalSize : 0 var totalSize = this.libraryStats?.totalSize || 0
return this.$bytesPretty(totalSize, 1) return this.$bytesPretty(totalSize, 1)
}, },
totalSizeNum() { totalSizeNum() {

View File

@ -11,10 +11,6 @@
<ui-btn @click="clickAddLibrary">{{ $strings.ButtonAddYourFirstLibrary }}</ui-btn> <ui-btn @click="clickAddLibrary">{{ $strings.ButtonAddYourFirstLibrary }}</ui-btn>
</div> </div>
<p v-if="libraries.length" class="text-xs mt-4 text-gray-200">
*<strong>{{ $strings.ButtonForceReScan }}</strong> {{ $strings.MessageForceReScanDescription }}
</p>
<p v-if="libraries.length && libraries.some((li) => li.mediaType === 'book')" class="text-xs mt-4 text-gray-200"> <p v-if="libraries.length && libraries.some((li) => li.mediaType === 'book')" class="text-xs mt-4 text-gray-200">
**<strong>{{ $strings.ButtonMatchBooks }}</strong> {{ $strings.MessageMatchBooksDescription }} **<strong>{{ $strings.ButtonMatchBooks }}</strong> {{ $strings.MessageMatchBooksDescription }}
</p> </p>

View File

@ -71,11 +71,6 @@ export default {
text: this.$strings.ButtonScan, text: this.$strings.ButtonScan,
action: 'scan', action: 'scan',
value: 'scan' value: 'scan'
},
{
text: this.$strings.ButtonForceReScan,
action: 'force-scan',
value: 'force-scan'
} }
] ]
if (this.isBookLibrary) { if (this.isBookLibrary) {
@ -137,26 +132,6 @@ export default {
this.$toast.error(this.$strings.ToastLibraryScanFailedToStart) this.$toast.error(this.$strings.ToastLibraryScanFailedToStart)
}) })
}, },
forceScan() {
const payload = {
message: this.$strings.MessageConfirmForceReScan,
callback: (confirmed) => {
if (confirmed) {
this.$store
.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id, force: 1 })
.then(() => {
this.$toast.success(this.$strings.ToastLibraryScanStarted)
})
.catch((error) => {
console.error('Failed to start scan', error)
this.$toast.error(this.$strings.ToastLibraryScanFailedToStart)
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
deleteClick() { deleteClick() {
const payload = { const payload = {
message: this.$getString('MessageConfirmDeleteLibrary', [this.library.name]), message: this.$getString('MessageConfirmDeleteLibrary', [this.library.name]),

View File

@ -343,6 +343,10 @@ export default {
} }
this.$store.commit('libraries/removeCollection', collection) this.$store.commit('libraries/removeCollection', collection)
}, },
seriesRemoved({ id, libraryId }) {
if (this.currentLibraryId !== libraryId) return
this.$store.commit('libraries/removeSeriesFromFilterData', id)
},
playlistAdded(playlist) { playlistAdded(playlist) {
if (playlist.userId !== this.user.id || this.currentLibraryId !== playlist.libraryId) return if (playlist.userId !== this.user.id || this.currentLibraryId !== playlist.libraryId) return
this.$store.commit('libraries/addUpdateUserPlaylist', playlist) this.$store.commit('libraries/addUpdateUserPlaylist', playlist)
@ -442,6 +446,9 @@ export default {
this.socket.on('collection_updated', this.collectionUpdated) this.socket.on('collection_updated', this.collectionUpdated)
this.socket.on('collection_removed', this.collectionRemoved) this.socket.on('collection_removed', this.collectionRemoved)
// Series Listeners
this.socket.on('series_removed', this.seriesRemoved)
// User Playlist Listeners // User Playlist Listeners
this.socket.on('playlist_added', this.playlistAdded) this.socket.on('playlist_added', this.playlistAdded)
this.socket.on('playlist_updated', this.playlistUpdated) this.socket.on('playlist_updated', this.playlistUpdated)

View File

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

View File

@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.3.3", "version": "2.4.1",
"description": "Self-hosted audiobook and podcast client", "description": "Self-hosted audiobook and podcast client",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@ -55,6 +55,7 @@ export default {
else if (pageName === 'library-stats') return this.$strings.HeaderLibraryStats else if (pageName === 'library-stats') return this.$strings.HeaderLibraryStats
else if (pageName === 'users') return this.$strings.HeaderUsers else if (pageName === 'users') return this.$strings.HeaderUsers
else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils
else if (pageName === 'rss-feeds') return this.$strings.HeaderRSSFeeds
else if (pageName === 'email') return this.$strings.HeaderEmail else if (pageName === 'email') return this.$strings.HeaderEmail
} }
return this.$strings.HeaderSettings return this.$strings.HeaderSettings

View File

@ -36,7 +36,10 @@
</ui-tooltip> </ui-tooltip>
</div> </div>
<div v-if="newServerSettings.sortingIgnorePrefix" class="w-72 ml-14 mb-2"> <div v-if="newServerSettings.sortingIgnorePrefix" class="w-72 ml-14 mb-2">
<ui-multi-select v-model="newServerSettings.sortingPrefixes" small :items="newServerSettings.sortingPrefixes" :label="$strings.LabelPrefixesToIgnore" @input="updateSortingPrefixes" :disabled="updatingServerSettings" /> <ui-multi-select v-model="newServerSettings.sortingPrefixes" small :items="newServerSettings.sortingPrefixes" :label="$strings.LabelPrefixesToIgnore" @input="sortingPrefixesUpdated" :disabled="savingPrefixes" />
<div class="flex justify-end py-1">
<ui-btn v-if="hasPrefixesChanged" color="success" :loading="savingPrefixes" small @click="updateSortingPrefixes">Save</ui-btn>
</div>
</div> </div>
<div class="flex items-center py-2 mb-2"> <div class="flex items-center py-2 mb-2">
@ -157,10 +160,10 @@
</div> </div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch labeledBy="settings-disable-watcher" v-model="newServerSettings.scannerDisableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', val)" /> <ui-toggle-switch labeledBy="settings-disable-watcher" v-model="scannerEnableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', !val)" />
<ui-tooltip :text="$strings.LabelSettingsDisableWatcherHelp"> <ui-tooltip :text="$strings.LabelSettingsEnableWatcherHelp">
<p class="pl-4"> <p class="pl-4">
<span id="settings-disable-watcher">{{ $strings.LabelSettingsDisableWatcher }}</span> <span id="settings-disable-watcher">{{ $strings.LabelSettingsEnableWatcher }}</span>
<span class="material-icons icon-text">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
@ -259,9 +262,12 @@ export default {
updatingServerSettings: false, updatingServerSettings: false,
homepageUseBookshelfView: false, homepageUseBookshelfView: false,
useBookshelfView: false, useBookshelfView: false,
scannerEnableWatcher: false,
isPurgingCache: false, isPurgingCache: false,
hasPrefixesChanged: false,
newServerSettings: {}, newServerSettings: {},
showConfirmPurgeCache: false, showConfirmPurgeCache: false,
savingPrefixes: false,
metadataFileFormats: [ metadataFileFormats: [
{ {
text: '.json', text: '.json',
@ -304,15 +310,36 @@ export default {
} }
}, },
methods: { methods: {
updateSortingPrefixes(val) { sortingPrefixesUpdated(val) {
if (!val || !val.length) { const prefixes = [...new Set(val?.map((prefix) => prefix.trim().toLowerCase()) || [])]
this.newServerSettings.sortingPrefixes = prefixes
const serverPrefixes = this.serverSettings.sortingPrefixes || []
this.hasPrefixesChanged = prefixes.some((p) => !serverPrefixes.includes(p)) || serverPrefixes.some((p) => !prefixes.includes(p))
},
updateSortingPrefixes() {
const prefixes = [...new Set(this.newServerSettings.sortingPrefixes.map((prefix) => prefix.trim().toLowerCase()) || [])]
if (!prefixes.length) {
this.$toast.error('Must have at least 1 prefix') this.$toast.error('Must have at least 1 prefix')
return return
} }
var prefixes = val.map((prefix) => prefix.trim().toLowerCase())
this.updateServerSettings({ this.savingPrefixes = true
sortingPrefixes: prefixes this.$axios
}) .$patch(`/api/sorting-prefixes`, { sortingPrefixes: prefixes })
.then((data) => {
this.$toast.success(`Sorting prefixes updated. ${data.rowsUpdated} rows`)
if (data.serverSettings) {
this.$store.commit('setServerSettings', data.serverSettings)
}
this.hasPrefixesChanged = false
})
.catch((error) => {
console.error('Failed to update prefixes', error)
this.$toast.error('Failed to update sorting prefixes')
})
.finally(() => {
this.savingPrefixes = false
})
}, },
updateScannerCoverProvider(val) { updateScannerCoverProvider(val) {
this.updateServerSettings({ this.updateServerSettings({
@ -337,6 +364,9 @@ export default {
this.updateSettingsKey('metadataFileFormat', val) this.updateSettingsKey('metadataFileFormat', val)
}, },
updateSettingsKey(key, val) { updateSettingsKey(key, val) {
if (key === 'scannerDisableWatcher') {
this.newServerSettings.scannerDisableWatcher = val
}
this.updateServerSettings({ this.updateServerSettings({
[key]: val [key]: val
}) })
@ -363,6 +393,7 @@ export default {
initServerSettings() { initServerSettings() {
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {} this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
this.newServerSettings.sortingPrefixes = [...(this.newServerSettings.sortingPrefixes || [])] this.newServerSettings.sortingPrefixes = [...(this.newServerSettings.sortingPrefixes || [])]
this.scannerEnableWatcher = !this.newServerSettings.scannerDisableWatcher
this.homepageUseBookshelfView = this.newServerSettings.homeBookshelfView != this.$constants.BookshelfView.DETAIL this.homepageUseBookshelfView = this.newServerSettings.homeBookshelfView != this.$constants.BookshelfView.DETAIL
this.useBookshelfView = this.newServerSettings.bookshelfView != this.$constants.BookshelfView.DETAIL this.useBookshelfView = this.newServerSettings.bookshelfView != this.$constants.BookshelfView.DETAIL

View File

@ -22,7 +22,7 @@
</div> </div>
</template> </template>
</div> </div>
<div class="w-80 my-6 mx-auto"> <div v-if="isBookLibrary" class="w-80 my-6 mx-auto">
<h1 class="text-2xl mb-4">{{ $strings.HeaderStatsTop10Authors }}</h1> <h1 class="text-2xl mb-4">{{ $strings.HeaderStatsTop10Authors }}</h1>
<p v-if="!top10Authors.length">{{ $strings.MessageNoAuthors }}</p> <p v-if="!top10Authors.length">{{ $strings.MessageNoAuthors }}</p>
<template v-for="(author, index) in top10Authors"> <template v-for="(author, index) in top10Authors">
@ -114,43 +114,49 @@ export default {
return this.$store.state.user.user return this.$store.state.user.user
}, },
totalItems() { totalItems() {
return this.libraryStats ? this.libraryStats.totalItems : 0 return this.libraryStats?.totalItems || 0
}, },
genresWithCount() { genresWithCount() {
return this.libraryStats ? this.libraryStats.genresWithCount : [] return this.libraryStats?.genresWithCount || []
}, },
top5Genres() { top5Genres() {
return this.genresWithCount.slice(0, 5) return this.genresWithCount?.slice(0, 5) || []
}, },
top10LongestItems() { top10LongestItems() {
return this.libraryStats ? this.libraryStats.longestItems || [] : [] return this.libraryStats?.longestItems || []
}, },
longestItemDuration() { longestItemDuration() {
if (!this.top10LongestItems.length) return 0 if (!this.top10LongestItems.length) return 0
return this.top10LongestItems[0].duration return this.top10LongestItems[0].duration
}, },
top10LargestItems() { top10LargestItems() {
return this.libraryStats ? this.libraryStats.largestItems || [] : [] return this.libraryStats?.largestItems || []
}, },
largestItemSize() { largestItemSize() {
if (!this.top10LargestItems.length) return 0 if (!this.top10LargestItems.length) return 0
return this.top10LargestItems[0].size return this.top10LargestItems[0].size
}, },
authorsWithCount() { authorsWithCount() {
return this.libraryStats ? this.libraryStats.authorsWithCount : [] return this.libraryStats?.authorsWithCount || []
}, },
mostUsedAuthorCount() { mostUsedAuthorCount() {
if (!this.authorsWithCount.length) return 0 if (!this.authorsWithCount.length) return 0
return this.authorsWithCount[0].count return this.authorsWithCount[0].count
}, },
top10Authors() { top10Authors() {
return this.authorsWithCount.slice(0, 10) return this.authorsWithCount?.slice(0, 10) || []
}, },
currentLibraryId() { currentLibraryId() {
return this.$store.state.libraries.currentLibraryId return this.$store.state.libraries.currentLibraryId
}, },
currentLibraryName() { currentLibraryName() {
return this.$store.getters['libraries/getCurrentLibraryName'] return this.$store.getters['libraries/getCurrentLibraryName']
},
currentLibraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
isBookLibrary() {
return this.currentLibraryMediaType === 'book'
} }
}, },
methods: { methods: {

View File

@ -0,0 +1,176 @@
<template>
<div>
<app-settings-content :header-text="$strings.HeaderRSSFeeds">
<div v-if="feeds.length" class="block max-w-full">
<table class="rssFeedsTable text-xs">
<tr class="bg-primary bg-opacity-40 h-12">
<th class="w-16 min-w-16"></th>
<th class="w-48 max-w-64 min-w-24 text-left truncate">{{ $strings.LabelTitle }}</th>
<th class="w-48 min-w-24 text-left hidden xl:table-cell">{{ $strings.LabelSlug }}</th>
<th class="w-24 min-w-16 text-left hidden md:table-cell">{{ $strings.LabelType }}</th>
<th class="w-16 min-w-16 text-center">{{ $strings.HeaderEpisodes }}</th>
<th class="w-16 min-w-16 text-center hidden lg:table-cell">{{ $strings.LabelRSSFeedPreventIndexing }}</th>
<th class="w-48 min-w-24 flex-grow hidden md:table-cell">{{ $strings.LabelLastUpdate }}</th>
<th class="w-16 text-left"></th>
</tr>
<tr v-for="feed in feeds" :key="feed.id" class="cursor-pointer h-12" @click="showFeed(feed)">
<!-- -->
<td>
<img :src="coverUrl(feed)" class="h-full w-full" />
</td>
<!-- -->
<td class="w-48 max-w-64 min-w-24 text-left truncate">
<p class="truncate">{{ feed.meta.title }}</p>
</td>
<!-- -->
<td class="hidden xl:table-cell">
<p class="truncate">{{ feed.slug }}</p>
</td>
<!-- -->
<td class="hidden md:table-cell">
<p class="">{{ getEntityType(feed.entityType) }}</p>
</td>
<!-- -->
<td class="text-center">
<p class="">{{ feed.episodes.length }}</p>
</td>
<!-- -->
<td class="text-center leading-none hidden lg:table-cell">
<p v-if="feed.meta.preventIndexing" class="">
<span class="material-icons text-2xl">check</span>
</p>
</td>
<!-- -->
<td class="text-center hidden md:table-cell">
<ui-tooltip v-if="feed.updatedAt" direction="top" :text="$formatDatetime(feed.updatedAt, dateFormat, timeFormat)">
<p class="text-gray-200">{{ $dateDistanceFromNow(feed.updatedAt) }}</p>
</ui-tooltip>
</td>
<!-- -->
<td class="text-center">
<ui-icon-btn icon="delete" class="mx-0.5" :size="7" bg-color="error" outlined @click.stop="deleteFeedClick(feed)" />
</td>
</tr>
</table>
</div>
</app-settings-content>
<modals-rssfeed-view-feed-modal v-model="showFeedModal" :feed="selectedFeed" />
</div>
</template>
<script>
export default {
data() {
return {
showFeedModal: false,
selectedFeed: null,
feeds: []
}
},
computed: {
dateFormat() {
return this.$store.state.serverSettings.dateFormat
},
timeFormat() {
return this.$store.state.serverSettings.timeFormat
}
},
methods: {
showFeed(feed) {
this.selectedFeed = feed
this.showFeedModal = true
},
deleteFeedClick(feed) {
const payload = {
message: this.$strings.MessageConfirmCloseFeed,
callback: (confirmed) => {
if (confirmed) {
this.deleteFeed(feed)
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
deleteFeed(feed) {
this.processing = true
this.$axios
.$post(`/api/feeds/${feed.id}/close`)
.then(() => {
this.$toast.success(this.$strings.ToastRSSFeedCloseSuccess)
this.show = false
this.loadFeeds()
})
.catch((error) => {
console.error('Failed to close RSS feed', error)
this.$toast.error(this.$strings.ToastRSSFeedCloseFailed)
})
.finally(() => {
this.processing = false
})
},
getEntityType(entityType) {
if (entityType === 'libraryItem') return this.$strings.LabelItem
else if (entityType === 'series') return this.$strings.LabelSeries
else if (entityType === 'collection') return this.$strings.LabelCollection
return this.$strings.LabelUnknown
},
coverUrl(feed) {
if (!feed.coverPath) return `${this.$config.routerBasePath}/Logo.png`
return `${feed.feedUrl}/cover`
},
async loadFeeds() {
const data = await this.$axios.$get(`/api/feeds`).catch((err) => {
console.error('Failed to load RSS feeds', err)
return null
})
if (!data) {
this.$toast.error('Failed to load RSS feeds')
return
}
this.feeds = data.feeds
},
init() {
this.loadFeeds()
}
},
mounted() {
this.init()
}
}
</script>
<style scoped>
.rssFeedsTable {
border-collapse: collapse;
width: 100%;
max-width: 100%;
border: 1px solid #474747;
}
.rssFeedsTable tr:first-child {
background-color: #272727;
}
.rssFeedsTable tr:not(:first-child) {
background-color: #373838;
}
.rssFeedsTable tr:not(:first-child):nth-child(odd) {
background-color: #2f2f2f;
}
.rssFeedsTable tr:hover:not(:first-child) {
background-color: #474747;
}
.rssFeedsTable td {
padding: 4px 8px;
}
.rssFeedsTable th {
padding: 4px 8px;
font-size: 0.75rem;
}
</style>

View File

@ -47,7 +47,7 @@
<div class="py-2"> <div class="py-2">
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">{{ $strings.HeaderSavedMediaProgress }}</h1> <h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">{{ $strings.HeaderSavedMediaProgress }}</h1>
<table v-if="mediaProgressWithMedia.length" class="userAudiobooksTable"> <table v-if="mediaProgress.length" class="userAudiobooksTable">
<tr class="bg-primary bg-opacity-40"> <tr class="bg-primary bg-opacity-40">
<th class="w-16 text-left">{{ $strings.LabelItem }}</th> <th class="w-16 text-left">{{ $strings.LabelItem }}</th>
<th class="text-left"></th> <th class="text-left"></th>
@ -55,19 +55,14 @@
<th class="w-40 hidden sm:table-cell">{{ $strings.LabelStartedAt }}</th> <th class="w-40 hidden sm:table-cell">{{ $strings.LabelStartedAt }}</th>
<th class="w-40 hidden sm:table-cell">{{ $strings.LabelLastUpdate }}</th> <th class="w-40 hidden sm:table-cell">{{ $strings.LabelLastUpdate }}</th>
</tr> </tr>
<tr v-for="item in mediaProgressWithMedia" :key="item.id" :class="!item.isFinished ? '' : 'isFinished'"> <tr v-for="item in mediaProgress" :key="item.id" :class="!item.isFinished ? '' : 'isFinished'">
<td> <td>
<covers-book-cover :width="50" :library-item="item" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-preview-cover v-if="item.coverPath" :width="50" :src="$store.getters['globals/getLibraryItemCoverSrcById'](item.libraryItemId, item.mediaUpdatedAt)" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" />
<div v-else class="bg-primary flex items-center justify-center text-center text-xs text-gray-400 p-1" :style="{ width: '50px', height: 50 * bookCoverAspectRatio + 'px' }">No Cover</div>
</td> </td>
<td> <td>
<template v-if="item.media && item.media.metadata && item.episode"> <p>{{ item.displayTitle || 'Unknown' }}</p>
<p>{{ item.episode.title || 'Unknown' }}</p> <p v-if="item.displaySubtitle" class="text-white text-opacity-50 text-sm font-sans">{{ item.displaySubtitle }}</p>
<p class="text-white text-opacity-50 text-sm font-sans">{{ item.media.metadata.title }}</p>
</template>
<template v-else-if="item.media && item.media.metadata">
<p>{{ item.media.metadata.title || 'Unknown' }}</p>
<p v-if="item.media.metadata.authorName" class="text-white text-opacity-50 text-sm font-sans">by {{ item.media.metadata.authorName }}</p>
</template>
</td> </td>
<td class="text-center"> <td class="text-center">
<p class="text-sm">{{ Math.floor(item.progress * 100) }}%</p> <p class="text-sm">{{ Math.floor(item.progress * 100) }}%</p>
@ -124,9 +119,6 @@ export default {
mediaProgress() { mediaProgress() {
return this.user.mediaProgress.sort((a, b) => b.lastUpdate - a.lastUpdate) return this.user.mediaProgress.sort((a, b) => b.lastUpdate - a.lastUpdate)
}, },
mediaProgressWithMedia() {
return this.mediaProgress.filter((mp) => mp.media)
},
totalListeningTime() { totalListeningTime() {
return this.listeningStats.totalTime || 0 return this.listeningStats.totalTime || 0
}, },

View File

@ -160,7 +160,7 @@ export default {
} }
// Include episode downloads for podcasts // Include episode downloads for podcasts
var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1&include=authors,downloads,rssfeed`).catch((error) => { var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1&include=downloads,rssfeed`).catch((error) => {
console.error('Failed', error) console.error('Failed', error)
return false return false
}) })
@ -761,6 +761,7 @@ export default {
if (this.libraryId) { if (this.libraryId) {
this.$store.commit('libraries/setCurrentLibrary', this.libraryId) this.$store.commit('libraries/setCurrentLibrary', this.libraryId)
} }
this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
this.$root.socket.on('item_updated', this.libraryItemUpdated) this.$root.socket.on('item_updated', this.libraryItemUpdated)
this.$root.socket.on('rss_feed_open', this.rssFeedOpen) this.$root.socket.on('rss_feed_open', this.rssFeedOpen)
this.$root.socket.on('rss_feed_closed', this.rssFeedClosed) this.$root.socket.on('rss_feed_closed', this.rssFeedClosed)
@ -769,6 +770,7 @@ export default {
this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished) this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished)
}, },
beforeDestroy() { beforeDestroy() {
this.$eventBus.$off(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
this.$root.socket.off('item_updated', this.libraryItemUpdated) this.$root.socket.off('item_updated', this.libraryItemUpdated)
this.$root.socket.off('rss_feed_open', this.rssFeedOpen) this.$root.socket.off('rss_feed_open', this.rssFeedOpen)
this.$root.socket.off('rss_feed_closed', this.rssFeedClosed) this.$root.socket.off('rss_feed_closed', this.rssFeedClosed)

View File

@ -234,6 +234,10 @@ export const mutations = {
setNumUserPlaylists(state, numUserPlaylists) { setNumUserPlaylists(state, numUserPlaylists) {
state.numUserPlaylists = numUserPlaylists state.numUserPlaylists = numUserPlaylists
}, },
removeSeriesFromFilterData(state, seriesId) {
if (!seriesId || !state.filterData) return
state.filterData.series = state.filterData.series.filter(se => se.id !== seriesId)
},
updateFilterDataWithItem(state, libraryItem) { updateFilterDataWithItem(state, libraryItem) {
if (!libraryItem || !state.filterData) return if (!libraryItem || !state.filterData) return
if (state.currentLibraryId !== libraryItem.libraryId) return if (state.currentLibraryId !== libraryItem.libraryId) return

View File

@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Lösche {0} Episoden", "HeaderRemoveEpisodes": "Lösche {0} Episoden",
"HeaderRSSFeedGeneral": "RSS Details", "HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "RSS-Feed ist geöffnet", "HeaderRSSFeedIsOpen": "RSS-Feed ist geöffnet",
"HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Gespeicherte Hörfortschritte", "HeaderSavedMediaProgress": "Gespeicherte Hörfortschritte",
"HeaderSchedule": "Zeitplan", "HeaderSchedule": "Zeitplan",
"HeaderScheduleLibraryScans": "Automatische Bibliotheksscans", "HeaderScheduleLibraryScans": "Automatische Bibliotheksscans",
@ -201,6 +202,7 @@
"LabelClosePlayer": "Player schließen", "LabelClosePlayer": "Player schließen",
"LabelCodec": "Codec", "LabelCodec": "Codec",
"LabelCollapseSeries": "Serien zusammenfassen", "LabelCollapseSeries": "Serien zusammenfassen",
"LabelCollection": "Sammlung",
"LabelCollections": "Sammlungen", "LabelCollections": "Sammlungen",
"LabelComplete": "Vollständig", "LabelComplete": "Vollständig",
"LabelConfirmPassword": "Passwort bestätigen", "LabelConfirmPassword": "Passwort bestätigen",
@ -222,7 +224,7 @@
"LabelDirectory": "Verzeichnis", "LabelDirectory": "Verzeichnis",
"LabelDiscFromFilename": "CD aus dem Dateinamen", "LabelDiscFromFilename": "CD aus dem Dateinamen",
"LabelDiscFromMetadata": "CD aus den Metadaten", "LabelDiscFromMetadata": "CD aus den Metadaten",
"LabelDiscover": "Discover", "LabelDiscover": "Finden",
"LabelDownload": "Herunterladen", "LabelDownload": "Herunterladen",
"LabelDownloadNEpisodes": "Download {0} episodes", "LabelDownloadNEpisodes": "Download {0} episodes",
"LabelDuration": "Laufzeit", "LabelDuration": "Laufzeit",
@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Überwachung deaktivieren", "LabelSettingsDisableWatcher": "Überwachung deaktivieren",
"LabelSettingsDisableWatcherForLibrary": "Ordnerüberwachung für die Bibliothek deaktivieren", "LabelSettingsDisableWatcherForLibrary": "Ordnerüberwachung für die Bibliothek deaktivieren",
"LabelSettingsDisableWatcherHelp": "Deaktiviert das automatische Hinzufügen/Aktualisieren von Elementen, wenn Dateiänderungen erkannt werden. *Erfordert einen Server-Neustart", "LabelSettingsDisableWatcherHelp": "Deaktiviert das automatische Hinzufügen/Aktualisieren von Elementen, wenn Dateiänderungen erkannt werden. *Erfordert einen Server-Neustart",
"LabelSettingsEnableWatcher": "Enable Watcher",
"LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library",
"LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsExperimentalFeatures": "Experimentelle Funktionen", "LabelSettingsExperimentalFeatures": "Experimentelle Funktionen",
"LabelSettingsExperimentalFeaturesHelp": "Funktionen welche sich in der Entwicklung befinden, benötigen Ihr Feedback und Ihre Hilfe beim Testen. Klicken Sie hier, um die Github-Diskussion zu öffnen.", "LabelSettingsExperimentalFeaturesHelp": "Funktionen welche sich in der Entwicklung befinden, benötigen Ihr Feedback und Ihre Hilfe beim Testen. Klicken Sie hier, um die Github-Diskussion zu öffnen.",
"LabelSettingsFindCovers": "Suche Titelbilder", "LabelSettingsFindCovers": "Suche Titelbilder",
@ -428,6 +433,7 @@
"LabelShowAll": "Alles anzeigen", "LabelShowAll": "Alles anzeigen",
"LabelSize": "Größe", "LabelSize": "Größe",
"LabelSleepTimer": "Einschlaf-Timer", "LabelSleepTimer": "Einschlaf-Timer",
"LabelSlug": "URL Teil",
"LabelStart": "Start", "LabelStart": "Start",
"LabelStarted": "Gestartet", "LabelStarted": "Gestartet",
"LabelStartedAt": "Gestartet am", "LabelStartedAt": "Gestartet am",
@ -475,7 +481,7 @@
"LabelTrackFromMetadata": "Titel aus Metadaten", "LabelTrackFromMetadata": "Titel aus Metadaten",
"LabelTracks": "Dateien", "LabelTracks": "Dateien",
"LabelTracksMultiTrack": "Mehrfachdatei", "LabelTracksMultiTrack": "Mehrfachdatei",
"LabelTracksNone": "No tracks", "LabelTracksNone": "Keine Dateien",
"LabelTracksSingleTrack": "Einzeldatei", "LabelTracksSingleTrack": "Einzeldatei",
"LabelType": "Typ", "LabelType": "Typ",
"LabelUnabridged": "Ungekürzt", "LabelUnabridged": "Ungekürzt",
@ -496,7 +502,7 @@
"LabelViewBookmarks": "Lesezeichen anzeigen", "LabelViewBookmarks": "Lesezeichen anzeigen",
"LabelViewChapters": "Kapitel anzeigen", "LabelViewChapters": "Kapitel anzeigen",
"LabelViewQueue": "Spieler-Warteschlange anzeigen", "LabelViewQueue": "Spieler-Warteschlange anzeigen",
"LabelVolume": "Volume", "LabelVolume": "Volumen",
"LabelWeekdaysToRun": "Wochentage für die Ausführung", "LabelWeekdaysToRun": "Wochentage für die Ausführung",
"LabelYourAudiobookDuration": "Laufzeit Ihres Mediums", "LabelYourAudiobookDuration": "Laufzeit Ihres Mediums",
"LabelYourBookmarks": "Lesezeichen", "LabelYourBookmarks": "Lesezeichen",
@ -516,8 +522,9 @@
"MessageChapterErrorStartLtPrev": "Ungültige Kapitelstartzeit: Kapitelanfang < Kapitelanfang vorheriges Kapitel (Kapitelanfang liegt zeitlich vor dem Beginn des vorherigen Kapitels -> Lösung: Kapitelanfang >= Startzeit des vorherigen Kapitels)", "MessageChapterErrorStartLtPrev": "Ungültige Kapitelstartzeit: Kapitelanfang < Kapitelanfang vorheriges Kapitel (Kapitelanfang liegt zeitlich vor dem Beginn des vorherigen Kapitels -> Lösung: Kapitelanfang >= Startzeit des vorherigen Kapitels)",
"MessageChapterStartIsAfter": "Ungültige Kapitelstartzeit: Kapitelanfang > Mediumende (Kapitelanfang liegt nach dem Ende des Mediums)", "MessageChapterStartIsAfter": "Ungültige Kapitelstartzeit: Kapitelanfang > Mediumende (Kapitelanfang liegt nach dem Ende des Mediums)",
"MessageCheckingCron": "Überprüfe Cron...", "MessageCheckingCron": "Überprüfe Cron...",
"MessageConfirmCloseFeed": "Sind Sie sicher, dass Sie diesen Feed schließen wollen?",
"MessageConfirmDeleteBackup": "Sind Sie sicher, dass Sie die Sicherung für {0} löschen wollen?", "MessageConfirmDeleteBackup": "Sind Sie sicher, dass Sie die Sicherung für {0} löschen wollen?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?", "MessageConfirmDeleteFile": "Es wird die Datei vom System löschen. Sind Sie sicher?",
"MessageConfirmDeleteLibrary": "Sind Sie sicher, dass Sie die Bibliothek \"{0}\" dauerhaft löschen wollen?", "MessageConfirmDeleteLibrary": "Sind Sie sicher, dass Sie die Bibliothek \"{0}\" dauerhaft löschen wollen?",
"MessageConfirmDeleteSession": "Sind Sie sicher, dass Sie diese Sitzung löschen möchten?", "MessageConfirmDeleteSession": "Sind Sie sicher, dass Sie diese Sitzung löschen möchten?",
"MessageConfirmForceReScan": "Sind Sie sicher, dass Sie einen erneuten Scanvorgang erzwingen wollen?", "MessageConfirmForceReScan": "Sind Sie sicher, dass Sie einen erneuten Scanvorgang erzwingen wollen?",
@ -560,7 +567,7 @@
"MessageMarkAllEpisodesNotFinished": "Alle Episoden als ungehört markieren", "MessageMarkAllEpisodesNotFinished": "Alle Episoden als ungehört markieren",
"MessageMarkAsFinished": "Als beendet markieren", "MessageMarkAsFinished": "Als beendet markieren",
"MessageMarkAsNotFinished": "Als nicht abgeschlossen markieren", "MessageMarkAsNotFinished": "Als nicht abgeschlossen markieren",
"MessageMatchBooksDescription": "versucht, Bücher in der Bibliothek mit einem Buch des ausgewählten Suchanbieters abzugleichen und leere Details und das Titelbild auszufüllen. Details werden nicht überschrieben.", "MessageMatchBooksDescription": "Es wird versucht die Bücher in der Bibliothek mit einem Buch des ausgewählten Suchanbieters abzugleichen um leere Details und das Titelbild auszufüllen. Vorhandene Details werden nicht überschrieben.",
"MessageNoAudioTracks": "Keine Audiodateien", "MessageNoAudioTracks": "Keine Audiodateien",
"MessageNoAuthors": "Keine Autoren", "MessageNoAuthors": "Keine Autoren",
"MessageNoBackups": "Keine Sicherungen", "MessageNoBackups": "Keine Sicherungen",
@ -596,7 +603,7 @@
"MessagePauseChapter": "Kapitelwiedergabe pausieren", "MessagePauseChapter": "Kapitelwiedergabe pausieren",
"MessagePlayChapter": "Kapitelanfang anhören", "MessagePlayChapter": "Kapitelanfang anhören",
"MessagePlaylistCreateFromCollection": "Erstelle eine Wiedergabeliste aus der Sammlung", "MessagePlaylistCreateFromCollection": "Erstelle eine Wiedergabeliste aus der Sammlung",
"MessagePodcastHasNoRSSFeedForMatching": "Podcast hat keine RSS-Feed-Url welche für den Online-Abgleich verwendet werden kann", "MessagePodcastHasNoRSSFeedForMatching": "Der Podcast hat keine RSS-Feed-Url welche für den Online-Abgleich verwendet werden kann",
"MessageQuickMatchDescription": "Füllt leere Details und Titelbilder mit dem ersten Treffer aus '{0}'. Überschreibt keine Details, es sei denn, die Server-Einstellung \"Passende Metadaten bevorzugen\" ist aktiviert.", "MessageQuickMatchDescription": "Füllt leere Details und Titelbilder mit dem ersten Treffer aus '{0}'. Überschreibt keine Details, es sei denn, die Server-Einstellung \"Passende Metadaten bevorzugen\" ist aktiviert.",
"MessageRemoveChapter": "Kapitel löschen", "MessageRemoveChapter": "Kapitel löschen",
"MessageRemoveEpisodes": "Entferne {0} Episode(n)", "MessageRemoveEpisodes": "Entferne {0} Episode(n)",

View File

@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Remove {0} Episodes", "HeaderRemoveEpisodes": "Remove {0} Episodes",
"HeaderRSSFeedGeneral": "RSS Details", "HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "RSS Feed is Open", "HeaderRSSFeedIsOpen": "RSS Feed is Open",
"HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Saved Media Progress", "HeaderSavedMediaProgress": "Saved Media Progress",
"HeaderSchedule": "Schedule", "HeaderSchedule": "Schedule",
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans", "HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
@ -201,6 +202,7 @@
"LabelClosePlayer": "Close player", "LabelClosePlayer": "Close player",
"LabelCodec": "Codec", "LabelCodec": "Codec",
"LabelCollapseSeries": "Collapse Series", "LabelCollapseSeries": "Collapse Series",
"LabelCollection": "Collection",
"LabelCollections": "Collections", "LabelCollections": "Collections",
"LabelComplete": "Complete", "LabelComplete": "Complete",
"LabelConfirmPassword": "Confirm Password", "LabelConfirmPassword": "Confirm Password",
@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Disable Watcher", "LabelSettingsDisableWatcher": "Disable Watcher",
"LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library", "LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library",
"LabelSettingsDisableWatcherHelp": "Disables the automatic adding/updating of items when file changes are detected. *Requires server restart", "LabelSettingsDisableWatcherHelp": "Disables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsEnableWatcher": "Enable Watcher",
"LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library",
"LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsExperimentalFeatures": "Experimental features", "LabelSettingsExperimentalFeatures": "Experimental features",
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.", "LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
"LabelSettingsFindCovers": "Find covers", "LabelSettingsFindCovers": "Find covers",
@ -428,6 +433,7 @@
"LabelShowAll": "Show All", "LabelShowAll": "Show All",
"LabelSize": "Size", "LabelSize": "Size",
"LabelSleepTimer": "Sleep timer", "LabelSleepTimer": "Sleep timer",
"LabelSlug": "Slug",
"LabelStart": "Start", "LabelStart": "Start",
"LabelStarted": "Started", "LabelStarted": "Started",
"LabelStartedAt": "Started At", "LabelStartedAt": "Started At",
@ -516,6 +522,7 @@
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time", "MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
"MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook", "MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook",
"MessageCheckingCron": "Checking cron...", "MessageCheckingCron": "Checking cron...",
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?", "MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?", "MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?", "MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",

View File

@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Remover {0} Episodios", "HeaderRemoveEpisodes": "Remover {0} Episodios",
"HeaderRSSFeedGeneral": "Detalles RSS", "HeaderRSSFeedGeneral": "Detalles RSS",
"HeaderRSSFeedIsOpen": "Fuente RSS esta abierta", "HeaderRSSFeedIsOpen": "Fuente RSS esta abierta",
"HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Guardar Progreso de multimedia", "HeaderSavedMediaProgress": "Guardar Progreso de multimedia",
"HeaderSchedule": "Horario", "HeaderSchedule": "Horario",
"HeaderScheduleLibraryScans": "Programar Escaneo Automático de Biblioteca", "HeaderScheduleLibraryScans": "Programar Escaneo Automático de Biblioteca",
@ -201,6 +202,7 @@
"LabelClosePlayer": "Close player", "LabelClosePlayer": "Close player",
"LabelCodec": "Codec", "LabelCodec": "Codec",
"LabelCollapseSeries": "Colapsar Series", "LabelCollapseSeries": "Colapsar Series",
"LabelCollection": "Collection",
"LabelCollections": "Colecciones", "LabelCollections": "Colecciones",
"LabelComplete": "Completo", "LabelComplete": "Completo",
"LabelConfirmPassword": "Confirmar Contraseña", "LabelConfirmPassword": "Confirmar Contraseña",
@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Deshabilitar Watcher", "LabelSettingsDisableWatcher": "Deshabilitar Watcher",
"LabelSettingsDisableWatcherForLibrary": "Deshabilitar Watcher de Carpetas para esta biblioteca", "LabelSettingsDisableWatcherForLibrary": "Deshabilitar Watcher de Carpetas para esta biblioteca",
"LabelSettingsDisableWatcherHelp": "Deshabilitar la función automática de agregar/actualizar los elementos, cuando se detecta cambio en los archivos. *Require Reiniciar el Servidor", "LabelSettingsDisableWatcherHelp": "Deshabilitar la función automática de agregar/actualizar los elementos, cuando se detecta cambio en los archivos. *Require Reiniciar el Servidor",
"LabelSettingsEnableWatcher": "Enable Watcher",
"LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library",
"LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsExperimentalFeatures": "Funciones Experimentales", "LabelSettingsExperimentalFeatures": "Funciones Experimentales",
"LabelSettingsExperimentalFeaturesHelp": "Funciones en desarrollo sobre las que esperamos sus comentarios y experiencia. Haga click aquí para abrir una conversación en Github.", "LabelSettingsExperimentalFeaturesHelp": "Funciones en desarrollo sobre las que esperamos sus comentarios y experiencia. Haga click aquí para abrir una conversación en Github.",
"LabelSettingsFindCovers": "Buscar Portadas", "LabelSettingsFindCovers": "Buscar Portadas",
@ -428,6 +433,7 @@
"LabelShowAll": "Mostrar Todos", "LabelShowAll": "Mostrar Todos",
"LabelSize": "Tamaño", "LabelSize": "Tamaño",
"LabelSleepTimer": "Temporizador para Dormir", "LabelSleepTimer": "Temporizador para Dormir",
"LabelSlug": "Slug",
"LabelStart": "Iniciar", "LabelStart": "Iniciar",
"LabelStarted": "Indiciado", "LabelStarted": "Indiciado",
"LabelStartedAt": "Iniciado En", "LabelStartedAt": "Iniciado En",
@ -516,6 +522,7 @@
"MessageChapterErrorStartLtPrev": "El tiempo de inicio no es válida debe ser mayor o igual que la hora de inicio del capítulo anterior", "MessageChapterErrorStartLtPrev": "El tiempo de inicio no es válida debe ser mayor o igual que la hora de inicio del capítulo anterior",
"MessageChapterStartIsAfter": "El comienzo del capítulo es después del final de su audiolibro", "MessageChapterStartIsAfter": "El comienzo del capítulo es después del final de su audiolibro",
"MessageCheckingCron": "Checking cron...", "MessageCheckingCron": "Checking cron...",
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
"MessageConfirmDeleteBackup": "Esta seguro que desea eliminar el respaldo {0}?", "MessageConfirmDeleteBackup": "Esta seguro que desea eliminar el respaldo {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?", "MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Esta seguro que desea eliminar permanentemente la biblioteca \"{0}\"?", "MessageConfirmDeleteLibrary": "Esta seguro que desea eliminar permanentemente la biblioteca \"{0}\"?",

View File

@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Suppression de {0} épisodes", "HeaderRemoveEpisodes": "Suppression de {0} épisodes",
"HeaderRSSFeedGeneral": "Détails de flux RSS", "HeaderRSSFeedGeneral": "Détails de flux RSS",
"HeaderRSSFeedIsOpen": "Le Flux RSS est actif", "HeaderRSSFeedIsOpen": "Le Flux RSS est actif",
"HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Progression de la sauvegarde des médias", "HeaderSavedMediaProgress": "Progression de la sauvegarde des médias",
"HeaderSchedule": "Programmation", "HeaderSchedule": "Programmation",
"HeaderScheduleLibraryScans": "Analyse automatique de la bibliothèque", "HeaderScheduleLibraryScans": "Analyse automatique de la bibliothèque",
@ -201,6 +202,7 @@
"LabelClosePlayer": "Fermer le lecteur", "LabelClosePlayer": "Fermer le lecteur",
"LabelCodec": "Codec", "LabelCodec": "Codec",
"LabelCollapseSeries": "Réduire les séries", "LabelCollapseSeries": "Réduire les séries",
"LabelCollection": "Collection",
"LabelCollections": "Collections", "LabelCollections": "Collections",
"LabelComplete": "Complet", "LabelComplete": "Complet",
"LabelConfirmPassword": "Confirmer le mot de passe", "LabelConfirmPassword": "Confirmer le mot de passe",
@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Désactiver la surveillance", "LabelSettingsDisableWatcher": "Désactiver la surveillance",
"LabelSettingsDisableWatcherForLibrary": "Désactiver la surveillance des dossiers pour la bibliothèque", "LabelSettingsDisableWatcherForLibrary": "Désactiver la surveillance des dossiers pour la bibliothèque",
"LabelSettingsDisableWatcherHelp": "Désactive la mise à jour automatique lorsque les fichiers changent. *Nécessite un redémarrage*", "LabelSettingsDisableWatcherHelp": "Désactive la mise à jour automatique lorsque les fichiers changent. *Nécessite un redémarrage*",
"LabelSettingsEnableWatcher": "Enable Watcher",
"LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library",
"LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsExperimentalFeatures": "Fonctionnalités expérimentales", "LabelSettingsExperimentalFeatures": "Fonctionnalités expérimentales",
"LabelSettingsExperimentalFeaturesHelp": "Fonctionnalités en cours de développement sur lesquelles nous attendons votre retour et expérience. Cliquez pour ouvrir la discussion Github.", "LabelSettingsExperimentalFeaturesHelp": "Fonctionnalités en cours de développement sur lesquelles nous attendons votre retour et expérience. Cliquez pour ouvrir la discussion Github.",
"LabelSettingsFindCovers": "Chercher des couvertures de livre", "LabelSettingsFindCovers": "Chercher des couvertures de livre",
@ -428,6 +433,7 @@
"LabelShowAll": "Afficher Tout", "LabelShowAll": "Afficher Tout",
"LabelSize": "Taille", "LabelSize": "Taille",
"LabelSleepTimer": "Minuterie", "LabelSleepTimer": "Minuterie",
"LabelSlug": "Slug",
"LabelStart": "Démarrer", "LabelStart": "Démarrer",
"LabelStarted": "Démarré", "LabelStarted": "Démarré",
"LabelStartedAt": "Démarré à", "LabelStartedAt": "Démarré à",
@ -516,6 +522,7 @@
"MessageChapterErrorStartLtPrev": "Horodatage invalide car il doit débuter au moins après le précédent chapitre", "MessageChapterErrorStartLtPrev": "Horodatage invalide car il doit débuter au moins après le précédent chapitre",
"MessageChapterStartIsAfter": "Le premier chapitre est situé au début de votre livre audio", "MessageChapterStartIsAfter": "Le premier chapitre est situé au début de votre livre audio",
"MessageCheckingCron": "Vérification du cron…", "MessageCheckingCron": "Vérification du cron…",
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
"MessageConfirmDeleteBackup": "Êtes-vous sûr de vouloir supprimer la Sauvegarde de {0} ?", "MessageConfirmDeleteBackup": "Êtes-vous sûr de vouloir supprimer la Sauvegarde de {0} ?",
"MessageConfirmDeleteFile": "Cela Le fichier sera supprimer de votre système. Êtes-vous sûr ?", "MessageConfirmDeleteFile": "Cela Le fichier sera supprimer de votre système. Êtes-vous sûr ?",
"MessageConfirmDeleteLibrary": "Êtes-vous sûr de vouloir supprimer définitivement la bibliothèque « {0} » ?", "MessageConfirmDeleteLibrary": "Êtes-vous sûr de vouloir supprimer définitivement la bibliothèque « {0} » ?",

View File

@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Remove {0} Episodes", "HeaderRemoveEpisodes": "Remove {0} Episodes",
"HeaderRSSFeedGeneral": "RSS Details", "HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "RSS Feed is Open", "HeaderRSSFeedIsOpen": "RSS Feed is Open",
"HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Saved Media Progress", "HeaderSavedMediaProgress": "Saved Media Progress",
"HeaderSchedule": "Schedule", "HeaderSchedule": "Schedule",
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans", "HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
@ -201,6 +202,7 @@
"LabelClosePlayer": "Close player", "LabelClosePlayer": "Close player",
"LabelCodec": "Codec", "LabelCodec": "Codec",
"LabelCollapseSeries": "Collapse Series", "LabelCollapseSeries": "Collapse Series",
"LabelCollection": "Collection",
"LabelCollections": "Collections", "LabelCollections": "Collections",
"LabelComplete": "Complete", "LabelComplete": "Complete",
"LabelConfirmPassword": "Confirm Password", "LabelConfirmPassword": "Confirm Password",
@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Disable Watcher", "LabelSettingsDisableWatcher": "Disable Watcher",
"LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library", "LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library",
"LabelSettingsDisableWatcherHelp": "Disables the automatic adding/updating of items when file changes are detected. *Requires server restart", "LabelSettingsDisableWatcherHelp": "Disables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsEnableWatcher": "Enable Watcher",
"LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library",
"LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsExperimentalFeatures": "Experimental features", "LabelSettingsExperimentalFeatures": "Experimental features",
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.", "LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
"LabelSettingsFindCovers": "Find covers", "LabelSettingsFindCovers": "Find covers",
@ -428,6 +433,7 @@
"LabelShowAll": "Show All", "LabelShowAll": "Show All",
"LabelSize": "Size", "LabelSize": "Size",
"LabelSleepTimer": "Sleep timer", "LabelSleepTimer": "Sleep timer",
"LabelSlug": "Slug",
"LabelStart": "Start", "LabelStart": "Start",
"LabelStarted": "Started", "LabelStarted": "Started",
"LabelStartedAt": "Started At", "LabelStartedAt": "Started At",
@ -516,6 +522,7 @@
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time", "MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
"MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook", "MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook",
"MessageCheckingCron": "Checking cron...", "MessageCheckingCron": "Checking cron...",
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?", "MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?", "MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?", "MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",

View File

@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Remove {0} Episodes", "HeaderRemoveEpisodes": "Remove {0} Episodes",
"HeaderRSSFeedGeneral": "RSS Details", "HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "RSS Feed is Open", "HeaderRSSFeedIsOpen": "RSS Feed is Open",
"HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Saved Media Progress", "HeaderSavedMediaProgress": "Saved Media Progress",
"HeaderSchedule": "Schedule", "HeaderSchedule": "Schedule",
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans", "HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
@ -201,6 +202,7 @@
"LabelClosePlayer": "Close player", "LabelClosePlayer": "Close player",
"LabelCodec": "Codec", "LabelCodec": "Codec",
"LabelCollapseSeries": "Collapse Series", "LabelCollapseSeries": "Collapse Series",
"LabelCollection": "Collection",
"LabelCollections": "Collections", "LabelCollections": "Collections",
"LabelComplete": "Complete", "LabelComplete": "Complete",
"LabelConfirmPassword": "Confirm Password", "LabelConfirmPassword": "Confirm Password",
@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Disable Watcher", "LabelSettingsDisableWatcher": "Disable Watcher",
"LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library", "LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library",
"LabelSettingsDisableWatcherHelp": "Disables the automatic adding/updating of items when file changes are detected. *Requires server restart", "LabelSettingsDisableWatcherHelp": "Disables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsEnableWatcher": "Enable Watcher",
"LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library",
"LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsExperimentalFeatures": "Experimental features", "LabelSettingsExperimentalFeatures": "Experimental features",
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.", "LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
"LabelSettingsFindCovers": "Find covers", "LabelSettingsFindCovers": "Find covers",
@ -428,6 +433,7 @@
"LabelShowAll": "Show All", "LabelShowAll": "Show All",
"LabelSize": "Size", "LabelSize": "Size",
"LabelSleepTimer": "Sleep timer", "LabelSleepTimer": "Sleep timer",
"LabelSlug": "Slug",
"LabelStart": "Start", "LabelStart": "Start",
"LabelStarted": "Started", "LabelStarted": "Started",
"LabelStartedAt": "Started At", "LabelStartedAt": "Started At",
@ -516,6 +522,7 @@
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time", "MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
"MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook", "MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook",
"MessageCheckingCron": "Checking cron...", "MessageCheckingCron": "Checking cron...",
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?", "MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?", "MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?", "MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",

View File

@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Ukloni {0} epizoda/-e", "HeaderRemoveEpisodes": "Ukloni {0} epizoda/-e",
"HeaderRSSFeedGeneral": "RSS Details", "HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "RSS Feed je otvoren", "HeaderRSSFeedIsOpen": "RSS Feed je otvoren",
"HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Spremljen Media Progress", "HeaderSavedMediaProgress": "Spremljen Media Progress",
"HeaderSchedule": "Schedule", "HeaderSchedule": "Schedule",
"HeaderScheduleLibraryScans": "Zakaži automatsko skeniranje biblioteke", "HeaderScheduleLibraryScans": "Zakaži automatsko skeniranje biblioteke",
@ -201,6 +202,7 @@
"LabelClosePlayer": "Close player", "LabelClosePlayer": "Close player",
"LabelCodec": "Codec", "LabelCodec": "Codec",
"LabelCollapseSeries": "Collapse Series", "LabelCollapseSeries": "Collapse Series",
"LabelCollection": "Collection",
"LabelCollections": "Kolekcije", "LabelCollections": "Kolekcije",
"LabelComplete": "Complete", "LabelComplete": "Complete",
"LabelConfirmPassword": "Potvrdi lozinku", "LabelConfirmPassword": "Potvrdi lozinku",
@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Isključi Watchera", "LabelSettingsDisableWatcher": "Isključi Watchera",
"LabelSettingsDisableWatcherForLibrary": "Isključi folder watchera za biblioteku", "LabelSettingsDisableWatcherForLibrary": "Isključi folder watchera za biblioteku",
"LabelSettingsDisableWatcherHelp": "Isključi automatsko dodavanje/aktualiziranje stavci ako su promjene prepoznate. *Potreban restart servera", "LabelSettingsDisableWatcherHelp": "Isključi automatsko dodavanje/aktualiziranje stavci ako su promjene prepoznate. *Potreban restart servera",
"LabelSettingsEnableWatcher": "Enable Watcher",
"LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library",
"LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsExperimentalFeatures": "Eksperimentalni features", "LabelSettingsExperimentalFeatures": "Eksperimentalni features",
"LabelSettingsExperimentalFeaturesHelp": "Features u razvoju trebaju vaš feedback i pomoć pri testiranju. Klikni da odeš to Github discussionsa.", "LabelSettingsExperimentalFeaturesHelp": "Features u razvoju trebaju vaš feedback i pomoć pri testiranju. Klikni da odeš to Github discussionsa.",
"LabelSettingsFindCovers": "Pronađi covers", "LabelSettingsFindCovers": "Pronađi covers",
@ -428,6 +433,7 @@
"LabelShowAll": "Prikaži sve", "LabelShowAll": "Prikaži sve",
"LabelSize": "Veličina", "LabelSize": "Veličina",
"LabelSleepTimer": "Sleep timer", "LabelSleepTimer": "Sleep timer",
"LabelSlug": "Slug",
"LabelStart": "Pokreni", "LabelStart": "Pokreni",
"LabelStarted": "Pokrenuto", "LabelStarted": "Pokrenuto",
"LabelStartedAt": "Pokrenuto", "LabelStartedAt": "Pokrenuto",
@ -516,6 +522,7 @@
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time", "MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
"MessageChapterStartIsAfter": "Početak poglavlja je nakon kraja audioknjige.", "MessageChapterStartIsAfter": "Početak poglavlja je nakon kraja audioknjige.",
"MessageCheckingCron": "Provjeravam cron...", "MessageCheckingCron": "Provjeravam cron...",
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
"MessageConfirmDeleteBackup": "Jeste li sigurni da želite obrisati backup za {0}?", "MessageConfirmDeleteBackup": "Jeste li sigurni da želite obrisati backup za {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?", "MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Jeste li sigurni da želite trajno obrisati biblioteku \"{0}\"?", "MessageConfirmDeleteLibrary": "Jeste li sigurni da želite trajno obrisati biblioteku \"{0}\"?",

View File

@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Rimuovi {0} Episodi", "HeaderRemoveEpisodes": "Rimuovi {0} Episodi",
"HeaderRSSFeedGeneral": "RSS Details", "HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "RSS Feed è aperto", "HeaderRSSFeedIsOpen": "RSS Feed è aperto",
"HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Progressi salvati", "HeaderSavedMediaProgress": "Progressi salvati",
"HeaderSchedule": "Schedula", "HeaderSchedule": "Schedula",
"HeaderScheduleLibraryScans": "Schedula la scansione della libreria", "HeaderScheduleLibraryScans": "Schedula la scansione della libreria",
@ -201,6 +202,7 @@
"LabelClosePlayer": "Chiudi player", "LabelClosePlayer": "Chiudi player",
"LabelCodec": "Codec", "LabelCodec": "Codec",
"LabelCollapseSeries": "Comprimi Serie", "LabelCollapseSeries": "Comprimi Serie",
"LabelCollection": "Collection",
"LabelCollections": "Raccolte", "LabelCollections": "Raccolte",
"LabelComplete": "Completo", "LabelComplete": "Completo",
"LabelConfirmPassword": "Conferma Password", "LabelConfirmPassword": "Conferma Password",
@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Disattiva Watcher", "LabelSettingsDisableWatcher": "Disattiva Watcher",
"LabelSettingsDisableWatcherForLibrary": "Disattiva Watcher per le librerie", "LabelSettingsDisableWatcherForLibrary": "Disattiva Watcher per le librerie",
"LabelSettingsDisableWatcherHelp": "Disattiva il controllo automatico libri nelle cartelle delle librerie. *Richiede il Riavvio del Server", "LabelSettingsDisableWatcherHelp": "Disattiva il controllo automatico libri nelle cartelle delle librerie. *Richiede il Riavvio del Server",
"LabelSettingsEnableWatcher": "Enable Watcher",
"LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library",
"LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsExperimentalFeatures": "Opzioni Sperimentali", "LabelSettingsExperimentalFeatures": "Opzioni Sperimentali",
"LabelSettingsExperimentalFeaturesHelp": "Funzionalità in fase di sviluppo che potrebbero utilizzare i tuoi feedback e aiutare i test. Fare clic per aprire la discussione github.", "LabelSettingsExperimentalFeaturesHelp": "Funzionalità in fase di sviluppo che potrebbero utilizzare i tuoi feedback e aiutare i test. Fare clic per aprire la discussione github.",
"LabelSettingsFindCovers": "Trova covers", "LabelSettingsFindCovers": "Trova covers",
@ -428,6 +433,7 @@
"LabelShowAll": "Mostra Tutto", "LabelShowAll": "Mostra Tutto",
"LabelSize": "Dimensione", "LabelSize": "Dimensione",
"LabelSleepTimer": "Sleep timer", "LabelSleepTimer": "Sleep timer",
"LabelSlug": "Slug",
"LabelStart": "Inizo", "LabelStart": "Inizo",
"LabelStarted": "Iniziato", "LabelStarted": "Iniziato",
"LabelStartedAt": "Iniziato al", "LabelStartedAt": "Iniziato al",
@ -516,6 +522,7 @@
"MessageChapterErrorStartLtPrev": "L'ora di inizio non valida deve essere maggiore o uguale all'ora di inizio del capitolo precedente", "MessageChapterErrorStartLtPrev": "L'ora di inizio non valida deve essere maggiore o uguale all'ora di inizio del capitolo precedente",
"MessageChapterStartIsAfter": "L'inizio del capitolo è dopo la fine del tuo audiolibro", "MessageChapterStartIsAfter": "L'inizio del capitolo è dopo la fine del tuo audiolibro",
"MessageCheckingCron": "Controllo cron...", "MessageCheckingCron": "Controllo cron...",
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
"MessageConfirmDeleteBackup": "Sei sicuro di voler eliminare il backup {0}?", "MessageConfirmDeleteBackup": "Sei sicuro di voler eliminare il backup {0}?",
"MessageConfirmDeleteFile": "Questo eliminerà il file dal tuo file system. Sei sicuro?", "MessageConfirmDeleteFile": "Questo eliminerà il file dal tuo file system. Sei sicuro?",
"MessageConfirmDeleteLibrary": "Sei sicuro di voler eliminare definitivamente la libreria \"{0}\"?", "MessageConfirmDeleteLibrary": "Sei sicuro di voler eliminare definitivamente la libreria \"{0}\"?",

View File

@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Pašalinti {0} epizodus", "HeaderRemoveEpisodes": "Pašalinti {0} epizodus",
"HeaderRSSFeedGeneral": "RSS informacija", "HeaderRSSFeedGeneral": "RSS informacija",
"HeaderRSSFeedIsOpen": "RSS srautas yra atidarytas", "HeaderRSSFeedIsOpen": "RSS srautas yra atidarytas",
"HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Išsaugota medijos pažanga", "HeaderSavedMediaProgress": "Išsaugota medijos pažanga",
"HeaderSchedule": "Tvarkaraštis", "HeaderSchedule": "Tvarkaraštis",
"HeaderScheduleLibraryScans": "Nustatyti bibliotekų nuskaitymo tvarkaraštį", "HeaderScheduleLibraryScans": "Nustatyti bibliotekų nuskaitymo tvarkaraštį",
@ -201,6 +202,7 @@
"LabelClosePlayer": "Uždaryti grotuvą", "LabelClosePlayer": "Uždaryti grotuvą",
"LabelCodec": "Kodekas", "LabelCodec": "Kodekas",
"LabelCollapseSeries": "Suskleisti seriją", "LabelCollapseSeries": "Suskleisti seriją",
"LabelCollection": "Collection",
"LabelCollections": "Kolekcijos", "LabelCollections": "Kolekcijos",
"LabelComplete": "Baigta", "LabelComplete": "Baigta",
"LabelConfirmPassword": "Patvirtinkite slaptažodį", "LabelConfirmPassword": "Patvirtinkite slaptažodį",
@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Išjungti stebėtoją", "LabelSettingsDisableWatcher": "Išjungti stebėtoją",
"LabelSettingsDisableWatcherForLibrary": "Išjungti aplankų stebėtoją bibliotekai", "LabelSettingsDisableWatcherForLibrary": "Išjungti aplankų stebėtoją bibliotekai",
"LabelSettingsDisableWatcherHelp": "Išjungia automatinį elementų pridėjimą/atnaujinimą, jei pastebėti failų pokyčiai. *Reikalingas serverio paleidimas iš naujo", "LabelSettingsDisableWatcherHelp": "Išjungia automatinį elementų pridėjimą/atnaujinimą, jei pastebėti failų pokyčiai. *Reikalingas serverio paleidimas iš naujo",
"LabelSettingsEnableWatcher": "Enable Watcher",
"LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library",
"LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsExperimentalFeatures": "Eksperimentiniai funkcionalumai", "LabelSettingsExperimentalFeatures": "Eksperimentiniai funkcionalumai",
"LabelSettingsExperimentalFeaturesHelp": "Funkcijos, kurios yra kuriamos ir laukiami jūsų komentarai. Spustelėkite, kad atidarytumėte „GitHub“ diskusiją.", "LabelSettingsExperimentalFeaturesHelp": "Funkcijos, kurios yra kuriamos ir laukiami jūsų komentarai. Spustelėkite, kad atidarytumėte „GitHub“ diskusiją.",
"LabelSettingsFindCovers": "Rasti viršelius", "LabelSettingsFindCovers": "Rasti viršelius",
@ -428,6 +433,7 @@
"LabelShowAll": "Rodyti viską", "LabelShowAll": "Rodyti viską",
"LabelSize": "Dydis", "LabelSize": "Dydis",
"LabelSleepTimer": "Miego laikmatis", "LabelSleepTimer": "Miego laikmatis",
"LabelSlug": "Slug",
"LabelStart": "Pradėti", "LabelStart": "Pradėti",
"LabelStarted": "Pradėta", "LabelStarted": "Pradėta",
"LabelStartedAt": "Pradėta", "LabelStartedAt": "Pradėta",
@ -516,6 +522,7 @@
"MessageChapterErrorStartLtPrev": "Netinkamas pradžios laikas. Turi būti didesnis arba lygus ankstesnio skyriaus pradžios laikui", "MessageChapterErrorStartLtPrev": "Netinkamas pradžios laikas. Turi būti didesnis arba lygus ankstesnio skyriaus pradžios laikui",
"MessageChapterStartIsAfter": "Skyriaus pradžia yra po jūsų garso knygos pabaigos", "MessageChapterStartIsAfter": "Skyriaus pradžia yra po jūsų garso knygos pabaigos",
"MessageCheckingCron": "Tikrinamas cron...", "MessageCheckingCron": "Tikrinamas cron...",
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
"MessageConfirmDeleteBackup": "Ar tikrai norite ištrinti atsarginę kopiją, skirtą {0}?", "MessageConfirmDeleteBackup": "Ar tikrai norite ištrinti atsarginę kopiją, skirtą {0}?",
"MessageConfirmDeleteFile": "Tai ištrins failą iš jūsų failų sistemos. Ar tikrai?", "MessageConfirmDeleteFile": "Tai ištrins failą iš jūsų failų sistemos. Ar tikrai?",
"MessageConfirmDeleteLibrary": "Ar tikrai norite visam laikui ištrinti biblioteką \"{0}\"?", "MessageConfirmDeleteLibrary": "Ar tikrai norite visam laikui ištrinti biblioteką \"{0}\"?",

View File

@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Verwijder {0} afleveringen", "HeaderRemoveEpisodes": "Verwijder {0} afleveringen",
"HeaderRSSFeedGeneral": "RSS-details", "HeaderRSSFeedGeneral": "RSS-details",
"HeaderRSSFeedIsOpen": "RSS-feed is open", "HeaderRSSFeedIsOpen": "RSS-feed is open",
"HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Opgeslagen mediavoortgang", "HeaderSavedMediaProgress": "Opgeslagen mediavoortgang",
"HeaderSchedule": "Schema", "HeaderSchedule": "Schema",
"HeaderScheduleLibraryScans": "Schema automatische bibliotheekscans", "HeaderScheduleLibraryScans": "Schema automatische bibliotheekscans",
@ -201,6 +202,7 @@
"LabelClosePlayer": "Sluit speler", "LabelClosePlayer": "Sluit speler",
"LabelCodec": "Codec", "LabelCodec": "Codec",
"LabelCollapseSeries": "Series inklappen", "LabelCollapseSeries": "Series inklappen",
"LabelCollection": "Collection",
"LabelCollections": "Collecties", "LabelCollections": "Collecties",
"LabelComplete": "Compleet", "LabelComplete": "Compleet",
"LabelConfirmPassword": "Bevestig wachtwoord", "LabelConfirmPassword": "Bevestig wachtwoord",
@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Watcher uitschakelen", "LabelSettingsDisableWatcher": "Watcher uitschakelen",
"LabelSettingsDisableWatcherForLibrary": "Map-watcher voor bibliotheek uitschakelen", "LabelSettingsDisableWatcherForLibrary": "Map-watcher voor bibliotheek uitschakelen",
"LabelSettingsDisableWatcherHelp": "Schakelt het automatisch toevoegen/bijwerken van onderdelen wanneer bestandswijzigingen gedetecteerd zijn uit. *Vereist herstart server", "LabelSettingsDisableWatcherHelp": "Schakelt het automatisch toevoegen/bijwerken van onderdelen wanneer bestandswijzigingen gedetecteerd zijn uit. *Vereist herstart server",
"LabelSettingsEnableWatcher": "Enable Watcher",
"LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library",
"LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsExperimentalFeatures": "Experimentele functies", "LabelSettingsExperimentalFeatures": "Experimentele functies",
"LabelSettingsExperimentalFeaturesHelp": "Functies in ontwikkeling die je feedback en testing kunnen gebruiken. Klik om de Github-discussie te openen.", "LabelSettingsExperimentalFeaturesHelp": "Functies in ontwikkeling die je feedback en testing kunnen gebruiken. Klik om de Github-discussie te openen.",
"LabelSettingsFindCovers": "Zoek covers", "LabelSettingsFindCovers": "Zoek covers",
@ -428,6 +433,7 @@
"LabelShowAll": "Toon alle", "LabelShowAll": "Toon alle",
"LabelSize": "Grootte", "LabelSize": "Grootte",
"LabelSleepTimer": "Slaaptimer", "LabelSleepTimer": "Slaaptimer",
"LabelSlug": "Slug",
"LabelStart": "Start", "LabelStart": "Start",
"LabelStarted": "Gestart", "LabelStarted": "Gestart",
"LabelStartedAt": "Gestart op", "LabelStartedAt": "Gestart op",
@ -516,6 +522,7 @@
"MessageChapterErrorStartLtPrev": "Ongeldig: starttijd moet be groter zijn dan of equal aan starttijd van vorig hoofdstuk", "MessageChapterErrorStartLtPrev": "Ongeldig: starttijd moet be groter zijn dan of equal aan starttijd van vorig hoofdstuk",
"MessageChapterStartIsAfter": "Start van hoofdstuk is na het einde van je audioboek", "MessageChapterStartIsAfter": "Start van hoofdstuk is na het einde van je audioboek",
"MessageCheckingCron": "Cron aan het checken...", "MessageCheckingCron": "Cron aan het checken...",
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
"MessageConfirmDeleteBackup": "Weet je zeker dat je de backup voor {0} wil verwijderen?", "MessageConfirmDeleteBackup": "Weet je zeker dat je de backup voor {0} wil verwijderen?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?", "MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Weet je zeker dat je de bibliotheek \"{0}\" permanent wil verwijderen?", "MessageConfirmDeleteLibrary": "Weet je zeker dat je de bibliotheek \"{0}\" permanent wil verwijderen?",

View File

@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Usuń {0} odcinków", "HeaderRemoveEpisodes": "Usuń {0} odcinków",
"HeaderRSSFeedGeneral": "RSS Details", "HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "Kanał RSS jest otwarty", "HeaderRSSFeedIsOpen": "Kanał RSS jest otwarty",
"HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Zapisany postęp", "HeaderSavedMediaProgress": "Zapisany postęp",
"HeaderSchedule": "Harmonogram", "HeaderSchedule": "Harmonogram",
"HeaderScheduleLibraryScans": "Zaplanuj automatyczne skanowanie biblioteki", "HeaderScheduleLibraryScans": "Zaplanuj automatyczne skanowanie biblioteki",
@ -201,6 +202,7 @@
"LabelClosePlayer": "Zamknij odtwarzacz", "LabelClosePlayer": "Zamknij odtwarzacz",
"LabelCodec": "Codec", "LabelCodec": "Codec",
"LabelCollapseSeries": "Podsumuj serię", "LabelCollapseSeries": "Podsumuj serię",
"LabelCollection": "Collection",
"LabelCollections": "Kolekcje", "LabelCollections": "Kolekcje",
"LabelComplete": "Ukończone", "LabelComplete": "Ukończone",
"LabelConfirmPassword": "Potwierdź hasło", "LabelConfirmPassword": "Potwierdź hasło",
@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Wyłącz monitorowanie", "LabelSettingsDisableWatcher": "Wyłącz monitorowanie",
"LabelSettingsDisableWatcherForLibrary": "Wyłącz monitorowanie folderów dla biblioteki", "LabelSettingsDisableWatcherForLibrary": "Wyłącz monitorowanie folderów dla biblioteki",
"LabelSettingsDisableWatcherHelp": "Wyłącz automatyczne dodawanie/aktualizowanie elementów po wykryciu zmian w plikach. *Wymaga restartu serwera", "LabelSettingsDisableWatcherHelp": "Wyłącz automatyczne dodawanie/aktualizowanie elementów po wykryciu zmian w plikach. *Wymaga restartu serwera",
"LabelSettingsEnableWatcher": "Enable Watcher",
"LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library",
"LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsExperimentalFeatures": "Funkcje eksperymentalne", "LabelSettingsExperimentalFeatures": "Funkcje eksperymentalne",
"LabelSettingsExperimentalFeaturesHelp": "Funkcje w trakcie rozwoju, które mogą zyskanć na Twojej opinii i pomocy w testowaniu. Kliknij, aby otworzyć dyskusję na githubie.", "LabelSettingsExperimentalFeaturesHelp": "Funkcje w trakcie rozwoju, które mogą zyskanć na Twojej opinii i pomocy w testowaniu. Kliknij, aby otworzyć dyskusję na githubie.",
"LabelSettingsFindCovers": "Szukanie okładek", "LabelSettingsFindCovers": "Szukanie okładek",
@ -428,6 +433,7 @@
"LabelShowAll": "Pokaż wszystko", "LabelShowAll": "Pokaż wszystko",
"LabelSize": "Rozmiar", "LabelSize": "Rozmiar",
"LabelSleepTimer": "Wyłącznik czasowy", "LabelSleepTimer": "Wyłącznik czasowy",
"LabelSlug": "Slug",
"LabelStart": "Rozpocznij", "LabelStart": "Rozpocznij",
"LabelStarted": "Rozpoczęty", "LabelStarted": "Rozpoczęty",
"LabelStartedAt": "Rozpoczęto", "LabelStartedAt": "Rozpoczęto",
@ -516,6 +522,7 @@
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time", "MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
"MessageChapterStartIsAfter": "Początek rozdziału następuje po zakończeniu audiobooka", "MessageChapterStartIsAfter": "Początek rozdziału następuje po zakończeniu audiobooka",
"MessageCheckingCron": "Sprawdzanie cron...", "MessageCheckingCron": "Sprawdzanie cron...",
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
"MessageConfirmDeleteBackup": "Czy na pewno chcesz usunąć kopię zapasową dla {0}?", "MessageConfirmDeleteBackup": "Czy na pewno chcesz usunąć kopię zapasową dla {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?", "MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Czy na pewno chcesz trwale usunąć bibliotekę \"{0}\"?", "MessageConfirmDeleteLibrary": "Czy na pewno chcesz trwale usunąć bibliotekę \"{0}\"?",

View File

@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Удалить {0} эпизодов", "HeaderRemoveEpisodes": "Удалить {0} эпизодов",
"HeaderRSSFeedGeneral": "Сведения о RSS", "HeaderRSSFeedGeneral": "Сведения о RSS",
"HeaderRSSFeedIsOpen": "RSS-канал открыт", "HeaderRSSFeedIsOpen": "RSS-канал открыт",
"HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Прогресс медиа сохранен", "HeaderSavedMediaProgress": "Прогресс медиа сохранен",
"HeaderSchedule": "Планировщик", "HeaderSchedule": "Планировщик",
"HeaderScheduleLibraryScans": "Планировщик автоматического сканирования библиотеки", "HeaderScheduleLibraryScans": "Планировщик автоматического сканирования библиотеки",
@ -201,6 +202,7 @@
"LabelClosePlayer": "Закрыть проигрыватель", "LabelClosePlayer": "Закрыть проигрыватель",
"LabelCodec": "Кодек", "LabelCodec": "Кодек",
"LabelCollapseSeries": "Свернуть серии", "LabelCollapseSeries": "Свернуть серии",
"LabelCollection": "Collection",
"LabelCollections": "Коллекции", "LabelCollections": "Коллекции",
"LabelComplete": "Завершить", "LabelComplete": "Завершить",
"LabelConfirmPassword": "Подтвердить пароль", "LabelConfirmPassword": "Подтвердить пароль",
@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Отключить отслеживание", "LabelSettingsDisableWatcher": "Отключить отслеживание",
"LabelSettingsDisableWatcherForLibrary": "Отключить отслеживание для библиотеки", "LabelSettingsDisableWatcherForLibrary": "Отключить отслеживание для библиотеки",
"LabelSettingsDisableWatcherHelp": "Отключает автоматическое добавление/обновление элементов, когда обнаружено изменение файлов. *Требуется перезапуск сервера", "LabelSettingsDisableWatcherHelp": "Отключает автоматическое добавление/обновление элементов, когда обнаружено изменение файлов. *Требуется перезапуск сервера",
"LabelSettingsEnableWatcher": "Enable Watcher",
"LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library",
"LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsExperimentalFeatures": "Экспериментальные функции", "LabelSettingsExperimentalFeatures": "Экспериментальные функции",
"LabelSettingsExperimentalFeaturesHelp": "Функционал в разработке на который Вы могли бы дать отзыв или помочь в тестировании. Нажмите для открытия обсуждения на github.", "LabelSettingsExperimentalFeaturesHelp": "Функционал в разработке на который Вы могли бы дать отзыв или помочь в тестировании. Нажмите для открытия обсуждения на github.",
"LabelSettingsFindCovers": "Найти обложки", "LabelSettingsFindCovers": "Найти обложки",
@ -428,6 +433,7 @@
"LabelShowAll": "Показать все", "LabelShowAll": "Показать все",
"LabelSize": "Размер", "LabelSize": "Размер",
"LabelSleepTimer": "Таймер сна", "LabelSleepTimer": "Таймер сна",
"LabelSlug": "Slug",
"LabelStart": "Начало", "LabelStart": "Начало",
"LabelStarted": "Начат", "LabelStarted": "Начат",
"LabelStartedAt": "Начато В", "LabelStartedAt": "Начато В",
@ -516,6 +522,7 @@
"MessageChapterErrorStartLtPrev": "Неверное время начала, должно быть больше или равно времени начала предыдущей главы", "MessageChapterErrorStartLtPrev": "Неверное время начала, должно быть больше или равно времени начала предыдущей главы",
"MessageChapterStartIsAfter": "Глава начинается после окончания аудиокниги", "MessageChapterStartIsAfter": "Глава начинается после окончания аудиокниги",
"MessageCheckingCron": "Проверка cron...", "MessageCheckingCron": "Проверка cron...",
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
"MessageConfirmDeleteBackup": "Вы уверены, что хотите удалить бэкап для {0}?", "MessageConfirmDeleteBackup": "Вы уверены, что хотите удалить бэкап для {0}?",
"MessageConfirmDeleteFile": "Это удалит файл из Вашей файловой системы. Вы уверены?", "MessageConfirmDeleteFile": "Это удалит файл из Вашей файловой системы. Вы уверены?",
"MessageConfirmDeleteLibrary": "Вы уверены, что хотите навсегда удалить библиотеку \"{0}\"?", "MessageConfirmDeleteLibrary": "Вы уверены, что хотите навсегда удалить библиотеку \"{0}\"?",

View File

@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "移除 {0} 剧集", "HeaderRemoveEpisodes": "移除 {0} 剧集",
"HeaderRSSFeedGeneral": "RSS 详细信息", "HeaderRSSFeedGeneral": "RSS 详细信息",
"HeaderRSSFeedIsOpen": "RSS 源已打开", "HeaderRSSFeedIsOpen": "RSS 源已打开",
"HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "保存媒体进度", "HeaderSavedMediaProgress": "保存媒体进度",
"HeaderSchedule": "计划任务", "HeaderSchedule": "计划任务",
"HeaderScheduleLibraryScans": "自动扫描媒体库", "HeaderScheduleLibraryScans": "自动扫描媒体库",
@ -201,6 +202,7 @@
"LabelClosePlayer": "关闭播放器", "LabelClosePlayer": "关闭播放器",
"LabelCodec": "编解码", "LabelCodec": "编解码",
"LabelCollapseSeries": "折叠系列", "LabelCollapseSeries": "折叠系列",
"LabelCollection": "Collection",
"LabelCollections": "收藏", "LabelCollections": "收藏",
"LabelComplete": "已完成", "LabelComplete": "已完成",
"LabelConfirmPassword": "确认密码", "LabelConfirmPassword": "确认密码",
@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "禁用监视程序", "LabelSettingsDisableWatcher": "禁用监视程序",
"LabelSettingsDisableWatcherForLibrary": "禁用媒体库的文件夹监视程序", "LabelSettingsDisableWatcherForLibrary": "禁用媒体库的文件夹监视程序",
"LabelSettingsDisableWatcherHelp": "检测到文件更改时禁用自动添加和更新项目. *需要重启服务器", "LabelSettingsDisableWatcherHelp": "检测到文件更改时禁用自动添加和更新项目. *需要重启服务器",
"LabelSettingsEnableWatcher": "Enable Watcher",
"LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library",
"LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsExperimentalFeatures": "实验功能", "LabelSettingsExperimentalFeatures": "实验功能",
"LabelSettingsExperimentalFeaturesHelp": "开发中的功能需要你的反馈并帮助测试. 点击打开 github 讨论.", "LabelSettingsExperimentalFeaturesHelp": "开发中的功能需要你的反馈并帮助测试. 点击打开 github 讨论.",
"LabelSettingsFindCovers": "查找封面", "LabelSettingsFindCovers": "查找封面",
@ -428,6 +433,7 @@
"LabelShowAll": "全部显示", "LabelShowAll": "全部显示",
"LabelSize": "文件大小", "LabelSize": "文件大小",
"LabelSleepTimer": "睡眠定时", "LabelSleepTimer": "睡眠定时",
"LabelSlug": "Slug",
"LabelStart": "开始", "LabelStart": "开始",
"LabelStarted": "开始于", "LabelStarted": "开始于",
"LabelStartedAt": "从这开始", "LabelStartedAt": "从这开始",
@ -516,6 +522,7 @@
"MessageChapterErrorStartLtPrev": "无效的开始时间, 必须大于或等于上一章节的开始时间", "MessageChapterErrorStartLtPrev": "无效的开始时间, 必须大于或等于上一章节的开始时间",
"MessageChapterStartIsAfter": "章节开始是在有声读物结束之后", "MessageChapterStartIsAfter": "章节开始是在有声读物结束之后",
"MessageCheckingCron": "检查计划任务...", "MessageCheckingCron": "检查计划任务...",
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
"MessageConfirmDeleteBackup": "你确定要删除备份 {0}?", "MessageConfirmDeleteBackup": "你确定要删除备份 {0}?",
"MessageConfirmDeleteFile": "这将从文件系统中删除该文件. 你确定吗?", "MessageConfirmDeleteFile": "这将从文件系统中删除该文件. 你确定吗?",
"MessageConfirmDeleteLibrary": "你确定要永久删除媒体库 \"{0}\"?", "MessageConfirmDeleteLibrary": "你确定要永久删除媒体库 \"{0}\"?",

View File

@ -8,6 +8,7 @@ services:
- 13378:80 - 13378:80
volumes: volumes:
- ./audiobooks:/audiobooks - ./audiobooks:/audiobooks
- ./podcasts:/podcasts
- ./metadata:/metadata - ./metadata:/metadata
- ./config:/config - ./config:/config
restart: unless-stopped restart: unless-stopped

8
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.3.3", "version": "2.4.1",
"lockfileVersion": 3, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.3.3", "version": "2.4.1",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"axios": "^0.27.2", "axios": "^0.27.2",
@ -2882,4 +2882,4 @@
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
} }
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.3.3", "version": "2.4.1",
"description": "Self-hosted audiobook and podcast server", "description": "Self-hosted audiobook and podcast server",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@ -188,7 +188,7 @@ class Auth {
await Database.updateServerSettings() await Database.updateServerSettings()
// New token secret creation added in v2.1.0 so generate new API tokens for each user // New token secret creation added in v2.1.0 so generate new API tokens for each user
const users = await Database.models.user.getOldUsers() const users = await Database.userModel.getOldUsers()
if (users.length) { if (users.length) {
for (const user of users) { for (const user of users) {
user.token = await this.generateAccessToken({ userId: user.id, username: user.username }) user.token = await this.generateAccessToken({ userId: user.id, username: user.username })

View File

@ -15,15 +15,16 @@ class Database {
this.isNew = false // New absdatabase.sqlite created this.isNew = false // New absdatabase.sqlite created
this.hasRootUser = false // Used to show initialization page in web ui this.hasRootUser = false // Used to show initialization page in web ui
// Temporarily using format of old DB
// TODO: below data should be loaded from the DB as needed
this.libraryItems = []
this.settings = [] this.settings = []
this.authors = []
this.series = []
// Cached library filter data
this.libraryFilterData = {}
/** @type {import('./objects/settings/ServerSettings')} */
this.serverSettings = null this.serverSettings = null
/** @type {import('./objects/settings/NotificationSettings')} */
this.notificationSettings = null this.notificationSettings = null
/** @type {import('./objects/settings/EmailSettings')} */
this.emailSettings = null this.emailSettings = null
} }
@ -31,6 +32,105 @@ class Database {
return this.sequelize?.models || {} return this.sequelize?.models || {}
} }
/** @type {typeof import('./models/User')} */
get userModel() {
return this.models.user
}
/** @type {typeof import('./models/Library')} */
get libraryModel() {
return this.models.library
}
/** @type {typeof import('./models/LibraryFolder')} */
get libraryFolderModel() {
return this.models.libraryFolder
}
/** @type {typeof import('./models/Author')} */
get authorModel() {
return this.models.author
}
/** @type {typeof import('./models/Series')} */
get seriesModel() {
return this.models.series
}
/** @type {typeof import('./models/Book')} */
get bookModel() {
return this.models.book
}
/** @type {typeof import('./models/BookSeries')} */
get bookSeriesModel() {
return this.models.bookSeries
}
/** @type {typeof import('./models/BookAuthor')} */
get bookAuthorModel() {
return this.models.bookAuthor
}
/** @type {typeof import('./models/Podcast')} */
get podcastModel() {
return this.models.podcast
}
/** @type {typeof import('./models/PodcastEpisode')} */
get podcastEpisodeModel() {
return this.models.podcastEpisode
}
/** @type {typeof import('./models/LibraryItem')} */
get libraryItemModel() {
return this.models.libraryItem
}
/** @type {typeof import('./models/PodcastEpisode')} */
get podcastEpisodeModel() {
return this.models.podcastEpisode
}
/** @type {typeof import('./models/MediaProgress')} */
get mediaProgressModel() {
return this.models.mediaProgress
}
/** @type {typeof import('./models/Collection')} */
get collectionModel() {
return this.models.collection
}
/** @type {typeof import('./models/CollectionBook')} */
get collectionBookModel() {
return this.models.collectionBook
}
/** @type {typeof import('./models/Playlist')} */
get playlistModel() {
return this.models.playlist
}
/** @type {typeof import('./models/PlaylistMediaItem')} */
get playlistMediaItemModel() {
return this.models.playlistMediaItem
}
/** @type {typeof import('./models/Feed')} */
get feedModel() {
return this.models.feed
}
/** @type {typeof import('./models/Feed')} */
get feedEpisodeModel() {
return this.models.feedEpisode
}
/**
* Check if db file exists
* @returns {boolean}
*/
async checkHasDb() { async checkHasDb() {
if (!await fs.pathExists(this.dbPath)) { if (!await fs.pathExists(this.dbPath)) {
Logger.info(`[Database] absdatabase.sqlite not found at ${this.dbPath}`) Logger.info(`[Database] absdatabase.sqlite not found at ${this.dbPath}`)
@ -39,6 +139,10 @@ class Database {
return true return true
} }
/**
* Connect to db, build models and run migrations
* @param {boolean} [force=false] Used for testing, drops & re-creates all tables
*/
async init(force = false) { async init(force = false) {
this.dbPath = Path.join(global.ConfigPath, 'absdatabase.sqlite') this.dbPath = Path.join(global.ConfigPath, 'absdatabase.sqlite')
@ -52,9 +156,14 @@ class Database {
await this.buildModels(force) await this.buildModels(force)
Logger.info(`[Database] Db initialized with models:`, Object.keys(this.sequelize.models).join(', ')) Logger.info(`[Database] Db initialized with models:`, Object.keys(this.sequelize.models).join(', '))
await this.loadData() await this.loadData()
} }
/**
* Connect to db
* @returns {boolean}
*/
async connect() { async connect() {
Logger.info(`[Database] Initializing db at "${this.dbPath}"`) Logger.info(`[Database] Initializing db at "${this.dbPath}"`)
this.sequelize = new Sequelize({ this.sequelize = new Sequelize({
@ -77,39 +186,45 @@ class Database {
} }
} }
/**
* Disconnect from db
*/
async disconnect() { async disconnect() {
Logger.info(`[Database] Disconnecting sqlite db`) Logger.info(`[Database] Disconnecting sqlite db`)
await this.sequelize.close() await this.sequelize.close()
this.sequelize = null this.sequelize = null
} }
/**
* Reconnect to db and init
*/
async reconnect() { async reconnect() {
Logger.info(`[Database] Reconnecting sqlite db`) Logger.info(`[Database] Reconnecting sqlite db`)
await this.init() await this.init()
} }
buildModels(force = false) { buildModels(force = false) {
require('./models/User')(this.sequelize) require('./models/User').init(this.sequelize)
require('./models/Library')(this.sequelize) require('./models/Library').init(this.sequelize)
require('./models/LibraryFolder')(this.sequelize) require('./models/LibraryFolder').init(this.sequelize)
require('./models/Book')(this.sequelize) require('./models/Book').init(this.sequelize)
require('./models/Podcast')(this.sequelize) require('./models/Podcast').init(this.sequelize)
require('./models/PodcastEpisode')(this.sequelize) require('./models/PodcastEpisode').init(this.sequelize)
require('./models/LibraryItem')(this.sequelize) require('./models/LibraryItem').init(this.sequelize)
require('./models/MediaProgress')(this.sequelize) require('./models/MediaProgress').init(this.sequelize)
require('./models/Series')(this.sequelize) require('./models/Series').init(this.sequelize)
require('./models/BookSeries')(this.sequelize) require('./models/BookSeries').init(this.sequelize)
require('./models/Author')(this.sequelize) require('./models/Author').init(this.sequelize)
require('./models/BookAuthor')(this.sequelize) require('./models/BookAuthor').init(this.sequelize)
require('./models/Collection')(this.sequelize) require('./models/Collection').init(this.sequelize)
require('./models/CollectionBook')(this.sequelize) require('./models/CollectionBook').init(this.sequelize)
require('./models/Playlist')(this.sequelize) require('./models/Playlist').init(this.sequelize)
require('./models/PlaylistMediaItem')(this.sequelize) require('./models/PlaylistMediaItem').init(this.sequelize)
require('./models/Device')(this.sequelize) require('./models/Device').init(this.sequelize)
require('./models/PlaybackSession')(this.sequelize) require('./models/PlaybackSession').init(this.sequelize)
require('./models/Feed')(this.sequelize) require('./models/Feed').init(this.sequelize)
require('./models/FeedEpisode')(this.sequelize) require('./models/FeedEpisode').init(this.sequelize)
require('./models/Setting')(this.sequelize) require('./models/Setting').init(this.sequelize)
return this.sequelize.sync({ force, alter: false }) return this.sequelize.sync({ force, alter: false })
} }
@ -138,8 +253,6 @@ class Database {
await dbMigration.migrate(this.models) await dbMigration.migrate(this.models)
} }
const startTime = Date.now()
const settingsData = await this.models.setting.getOldSettings() const settingsData = await this.models.setting.getOldSettings()
this.settings = settingsData.settings this.settings = settingsData.settings
this.emailSettings = settingsData.emailSettings this.emailSettings = settingsData.emailSettings
@ -155,22 +268,11 @@ class Database {
await dbMigration.migrationPatch2(this) await dbMigration.migrationPatch2(this)
} }
Logger.info(`[Database] Loading db data...`) await this.cleanDatabase()
this.libraryItems = await this.models.libraryItem.loadAllLibraryItems()
Logger.info(`[Database] Loaded ${this.libraryItems.length} library items`)
this.authors = await this.models.author.getOldAuthors()
Logger.info(`[Database] Loaded ${this.authors.length} authors`)
this.series = await this.models.series.getAllOldSeries()
Logger.info(`[Database] Loaded ${this.series.length} series`)
// Set if root user has been created // Set if root user has been created
this.hasRootUser = await this.models.user.getHasRootUser() this.hasRootUser = await this.models.user.getHasRootUser()
Logger.info(`[Database] Db data loaded in ${((Date.now() - startTime) / 1000).toFixed(2)}s`)
if (packageJson.version !== this.serverSettings.version) { if (packageJson.version !== this.serverSettings.version) {
Logger.info(`[Database] Server upgrade detected from ${this.serverSettings.version} to ${packageJson.version}`) Logger.info(`[Database] Server upgrade detected from ${this.serverSettings.version} to ${packageJson.version}`)
this.serverSettings.version = packageJson.version this.serverSettings.version = packageJson.version
@ -219,9 +321,9 @@ class Database {
return Promise.all(oldUsers.map(u => this.updateUser(u))) return Promise.all(oldUsers.map(u => this.updateUser(u)))
} }
async removeUser(userId) { removeUser(userId) {
if (!this.sequelize) return false if (!this.sequelize) return false
await this.models.user.removeById(userId) return this.models.user.removeById(userId)
} }
upsertMediaProgress(oldMediaProgress) { upsertMediaProgress(oldMediaProgress) {
@ -239,9 +341,9 @@ class Database {
return Promise.all(oldBooks.map(oldBook => this.models.book.saveFromOld(oldBook))) return Promise.all(oldBooks.map(oldBook => this.models.book.saveFromOld(oldBook)))
} }
async createLibrary(oldLibrary) { createLibrary(oldLibrary) {
if (!this.sequelize) return false if (!this.sequelize) return false
await this.models.library.createFromOld(oldLibrary) return this.models.library.createFromOld(oldLibrary)
} }
updateLibrary(oldLibrary) { updateLibrary(oldLibrary) {
@ -249,56 +351,9 @@ class Database {
return this.models.library.updateFromOld(oldLibrary) return this.models.library.updateFromOld(oldLibrary)
} }
async removeLibrary(libraryId) { removeLibrary(libraryId) {
if (!this.sequelize) return false if (!this.sequelize) return false
await this.models.library.removeById(libraryId) return this.models.library.removeById(libraryId)
}
async createCollection(oldCollection) {
if (!this.sequelize) return false
const newCollection = await this.models.collection.createFromOld(oldCollection)
// Create CollectionBooks
if (newCollection) {
const collectionBooks = []
oldCollection.books.forEach((libraryItemId) => {
const libraryItem = this.libraryItems.find(li => li.id === libraryItemId)
if (libraryItem) {
collectionBooks.push({
collectionId: newCollection.id,
bookId: libraryItem.media.id
})
}
})
if (collectionBooks.length) {
await this.createBulkCollectionBooks(collectionBooks)
}
}
}
updateCollection(oldCollection) {
if (!this.sequelize) return false
const collectionBooks = []
let order = 1
oldCollection.books.forEach((libraryItemId) => {
const libraryItem = this.getLibraryItem(libraryItemId)
if (!libraryItem) return
collectionBooks.push({
collectionId: oldCollection.id,
bookId: libraryItem.media.id,
order: order++
})
})
return this.models.collection.fullUpdateFromOld(oldCollection, collectionBooks)
}
async removeCollection(collectionId) {
if (!this.sequelize) return false
await this.models.collection.removeById(collectionId)
}
createCollectionBook(collectionBook) {
if (!this.sequelize) return false
return this.models.collectionBook.create(collectionBook)
} }
createBulkCollectionBooks(collectionBooks) { createBulkCollectionBooks(collectionBooks) {
@ -306,62 +361,6 @@ class Database {
return this.models.collectionBook.bulkCreate(collectionBooks) return this.models.collectionBook.bulkCreate(collectionBooks)
} }
removeCollectionBook(collectionId, bookId) {
if (!this.sequelize) return false
return this.models.collectionBook.removeByIds(collectionId, bookId)
}
async createPlaylist(oldPlaylist) {
if (!this.sequelize) return false
const newPlaylist = await this.models.playlist.createFromOld(oldPlaylist)
if (newPlaylist) {
const playlistMediaItems = []
let order = 1
for (const mediaItemObj of oldPlaylist.items) {
const libraryItem = this.libraryItems.find(li => li.id === mediaItemObj.libraryItemId)
if (!libraryItem) continue
let mediaItemId = libraryItem.media.id // bookId
let mediaItemType = 'book'
if (mediaItemObj.episodeId) {
mediaItemType = 'podcastEpisode'
mediaItemId = mediaItemObj.episodeId
}
playlistMediaItems.push({
playlistId: newPlaylist.id,
mediaItemId,
mediaItemType,
order: order++
})
}
if (playlistMediaItems.length) {
await this.createBulkPlaylistMediaItems(playlistMediaItems)
}
}
}
updatePlaylist(oldPlaylist) {
if (!this.sequelize) return false
const playlistMediaItems = []
let order = 1
oldPlaylist.items.forEach((item) => {
const libraryItem = this.getLibraryItem(item.libraryItemId)
if (!libraryItem) return
playlistMediaItems.push({
playlistId: oldPlaylist.id,
mediaItemId: item.episodeId || libraryItem.media.id,
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
order: order++
})
})
return this.models.playlist.fullUpdateFromOld(oldPlaylist, playlistMediaItems)
}
async removePlaylist(playlistId) {
if (!this.sequelize) return false
await this.models.playlist.removeById(playlistId)
}
createPlaylistMediaItem(playlistMediaItem) { createPlaylistMediaItem(playlistMediaItem) {
if (!this.sequelize) return false if (!this.sequelize) return false
return this.models.playlistMediaItem.create(playlistMediaItem) return this.models.playlistMediaItem.create(playlistMediaItem)
@ -372,25 +371,10 @@ class Database {
return this.models.playlistMediaItem.bulkCreate(playlistMediaItems) return this.models.playlistMediaItem.bulkCreate(playlistMediaItems)
} }
removePlaylistMediaItem(playlistId, mediaItemId) {
if (!this.sequelize) return false
return this.models.playlistMediaItem.removeByIds(playlistId, mediaItemId)
}
getLibraryItem(libraryItemId) {
if (!this.sequelize || !libraryItemId) return false
// Temp support for old library item ids from mobile
if (libraryItemId.startsWith('li_')) return this.libraryItems.find(li => li.oldLibraryItemId === libraryItemId)
return this.libraryItems.find(li => li.id === libraryItemId)
}
async createLibraryItem(oldLibraryItem) { async createLibraryItem(oldLibraryItem) {
if (!this.sequelize) return false if (!this.sequelize) return false
await oldLibraryItem.saveMetadata() await oldLibraryItem.saveMetadata()
await this.models.libraryItem.fullCreateFromOld(oldLibraryItem) await this.models.libraryItem.fullCreateFromOld(oldLibraryItem)
this.libraryItems.push(oldLibraryItem)
} }
async updateLibraryItem(oldLibraryItem) { async updateLibraryItem(oldLibraryItem) {
@ -399,32 +383,9 @@ class Database {
return this.models.libraryItem.fullUpdateFromOld(oldLibraryItem) return this.models.libraryItem.fullUpdateFromOld(oldLibraryItem)
} }
async updateBulkLibraryItems(oldLibraryItems) {
if (!this.sequelize) return false
let updatesMade = 0
for (const oldLibraryItem of oldLibraryItems) {
await oldLibraryItem.saveMetadata()
const hasUpdates = await this.models.libraryItem.fullUpdateFromOld(oldLibraryItem)
if (hasUpdates) {
updatesMade++
}
}
return updatesMade
}
async createBulkLibraryItems(oldLibraryItems) {
if (!this.sequelize) return false
for (const oldLibraryItem of oldLibraryItems) {
await oldLibraryItem.saveMetadata()
await this.models.libraryItem.fullCreateFromOld(oldLibraryItem)
this.libraryItems.push(oldLibraryItem)
}
}
async removeLibraryItem(libraryItemId) { async removeLibraryItem(libraryItemId) {
if (!this.sequelize) return false if (!this.sequelize) return false
await this.models.libraryItem.removeById(libraryItemId) await this.models.libraryItem.removeById(libraryItemId)
this.libraryItems = this.libraryItems.filter(li => li.id !== libraryItemId)
} }
async createFeed(oldFeed) { async createFeed(oldFeed) {
@ -450,31 +411,26 @@ class Database {
async createSeries(oldSeries) { async createSeries(oldSeries) {
if (!this.sequelize) return false if (!this.sequelize) return false
await this.models.series.createFromOld(oldSeries) await this.models.series.createFromOld(oldSeries)
this.series.push(oldSeries)
} }
async createBulkSeries(oldSeriesObjs) { async createBulkSeries(oldSeriesObjs) {
if (!this.sequelize) return false if (!this.sequelize) return false
await this.models.series.createBulkFromOld(oldSeriesObjs) await this.models.series.createBulkFromOld(oldSeriesObjs)
this.series.push(...oldSeriesObjs)
} }
async removeSeries(seriesId) { async removeSeries(seriesId) {
if (!this.sequelize) return false if (!this.sequelize) return false
await this.models.series.removeById(seriesId) await this.models.series.removeById(seriesId)
this.series = this.series.filter(se => se.id !== seriesId)
} }
async createAuthor(oldAuthor) { async createAuthor(oldAuthor) {
if (!this.sequelize) return false if (!this.sequelize) return false
await this.models.author.createFromOld(oldAuthor) await this.models.author.createFromOld(oldAuthor)
this.authors.push(oldAuthor)
} }
async createBulkAuthors(oldAuthors) { async createBulkAuthors(oldAuthors) {
if (!this.sequelize) return false if (!this.sequelize) return false
await this.models.author.createBulkFromOld(oldAuthors) await this.models.author.createBulkFromOld(oldAuthors)
this.authors.push(...oldAuthors)
} }
updateAuthor(oldAuthor) { updateAuthor(oldAuthor) {
@ -485,24 +441,17 @@ class Database {
async removeAuthor(authorId) { async removeAuthor(authorId) {
if (!this.sequelize) return false if (!this.sequelize) return false
await this.models.author.removeById(authorId) await this.models.author.removeById(authorId)
this.authors = this.authors.filter(au => au.id !== authorId)
} }
async createBulkBookAuthors(bookAuthors) { async createBulkBookAuthors(bookAuthors) {
if (!this.sequelize) return false if (!this.sequelize) return false
await this.models.bookAuthor.bulkCreate(bookAuthors) await this.models.bookAuthor.bulkCreate(bookAuthors)
this.authors.push(...bookAuthors)
} }
async removeBulkBookAuthors(authorId = null, bookId = null) { async removeBulkBookAuthors(authorId = null, bookId = null) {
if (!this.sequelize) return false if (!this.sequelize) return false
if (!authorId && !bookId) return if (!authorId && !bookId) return
await this.models.bookAuthor.removeByIds(authorId, bookId) await this.models.bookAuthor.removeByIds(authorId, bookId)
this.authors = this.authors.filter(au => {
if (authorId && au.authorId !== authorId) return true
if (bookId && au.bookId !== bookId) return true
return false
})
} }
getPlaybackSessions(where = null) { getPlaybackSessions(where = null) {
@ -544,6 +493,204 @@ class Database {
if (!this.sequelize) return false if (!this.sequelize) return false
return this.models.device.createFromOld(oldDevice) return this.models.device.createFromOld(oldDevice)
} }
replaceTagInFilterData(oldTag, newTag) {
for (const libraryId in this.libraryFilterData) {
const indexOf = this.libraryFilterData[libraryId].tags.findIndex(n => n === oldTag)
if (indexOf >= 0) {
this.libraryFilterData[libraryId].tags.splice(indexOf, 1, newTag)
}
}
}
removeTagFromFilterData(tag) {
for (const libraryId in this.libraryFilterData) {
this.libraryFilterData[libraryId].tags = this.libraryFilterData[libraryId].tags.filter(t => t !== tag)
}
}
addTagsToFilterData(libraryId, tags) {
if (!this.libraryFilterData[libraryId] || !tags?.length) return
tags.forEach((t) => {
if (!this.libraryFilterData[libraryId].tags.includes(t)) {
this.libraryFilterData[libraryId].tags.push(t)
}
})
}
replaceGenreInFilterData(oldGenre, newGenre) {
for (const libraryId in this.libraryFilterData) {
const indexOf = this.libraryFilterData[libraryId].genres.findIndex(n => n === oldGenre)
if (indexOf >= 0) {
this.libraryFilterData[libraryId].genres.splice(indexOf, 1, newGenre)
}
}
}
removeGenreFromFilterData(genre) {
for (const libraryId in this.libraryFilterData) {
this.libraryFilterData[libraryId].genres = this.libraryFilterData[libraryId].genres.filter(g => g !== genre)
}
}
addGenresToFilterData(libraryId, genres) {
if (!this.libraryFilterData[libraryId] || !genres?.length) return
genres.forEach((g) => {
if (!this.libraryFilterData[libraryId].genres.includes(g)) {
this.libraryFilterData[libraryId].genres.push(g)
}
})
}
replaceNarratorInFilterData(oldNarrator, newNarrator) {
for (const libraryId in this.libraryFilterData) {
const indexOf = this.libraryFilterData[libraryId].narrators.findIndex(n => n === oldNarrator)
if (indexOf >= 0) {
this.libraryFilterData[libraryId].narrators.splice(indexOf, 1, newNarrator)
}
}
}
removeNarratorFromFilterData(narrator) {
for (const libraryId in this.libraryFilterData) {
this.libraryFilterData[libraryId].narrators = this.libraryFilterData[libraryId].narrators.filter(n => n !== narrator)
}
}
addNarratorsToFilterData(libraryId, narrators) {
if (!this.libraryFilterData[libraryId] || !narrators?.length) return
narrators.forEach((n) => {
if (!this.libraryFilterData[libraryId].narrators.includes(n)) {
this.libraryFilterData[libraryId].narrators.push(n)
}
})
}
removeSeriesFromFilterData(libraryId, seriesId) {
if (!this.libraryFilterData[libraryId]) return
this.libraryFilterData[libraryId].series = this.libraryFilterData[libraryId].series.filter(se => se.id !== seriesId)
}
addSeriesToFilterData(libraryId, seriesName, seriesId) {
if (!this.libraryFilterData[libraryId]) return
// Check if series is already added
if (this.libraryFilterData[libraryId].series.some(se => se.id === seriesId)) return
this.libraryFilterData[libraryId].series.push({
id: seriesId,
name: seriesName
})
}
removeAuthorFromFilterData(libraryId, authorId) {
if (!this.libraryFilterData[libraryId]) return
this.libraryFilterData[libraryId].authors = this.libraryFilterData[libraryId].authors.filter(au => au.id !== authorId)
}
addAuthorToFilterData(libraryId, authorName, authorId) {
if (!this.libraryFilterData[libraryId]) return
// Check if author is already added
if (this.libraryFilterData[libraryId].authors.some(au => au.id === authorId)) return
this.libraryFilterData[libraryId].authors.push({
id: authorId,
name: authorName
})
}
addPublisherToFilterData(libraryId, publisher) {
if (!this.libraryFilterData[libraryId] || !publisher || this.libraryFilterData[libraryId].publishers.includes(publisher)) return
this.libraryFilterData[libraryId].publishers.push(publisher)
}
addLanguageToFilterData(libraryId, language) {
if (!this.libraryFilterData[libraryId] || !language || this.libraryFilterData[libraryId].languages.includes(language)) return
this.libraryFilterData[libraryId].languages.push(language)
}
/**
* Used when updating items to make sure author id exists
* If library filter data is set then use that for check
* otherwise lookup in db
* @param {string} libraryId
* @param {string} authorId
* @returns {Promise<boolean>}
*/
async checkAuthorExists(libraryId, authorId) {
if (!this.libraryFilterData[libraryId]) {
return this.authorModel.checkExistsById(authorId)
}
return this.libraryFilterData[libraryId].authors.some(au => au.id === authorId)
}
/**
* Used when updating items to make sure series id exists
* If library filter data is set then use that for check
* otherwise lookup in db
* @param {string} libraryId
* @param {string} seriesId
* @returns {Promise<boolean>}
*/
async checkSeriesExists(libraryId, seriesId) {
if (!this.libraryFilterData[libraryId]) {
return this.seriesModel.checkExistsById(seriesId)
}
return this.libraryFilterData[libraryId].series.some(se => se.id === seriesId)
}
/**
* Reset numIssues for library
* @param {string} libraryId
*/
async resetLibraryIssuesFilterData(libraryId) {
if (!this.libraryFilterData[libraryId]) return // Do nothing if filter data is not set
this.libraryFilterData[libraryId].numIssues = await this.libraryItemModel.count({
where: {
libraryId,
[Sequelize.Op.or]: [
{
isMissing: true
},
{
isInvalid: true
}
]
}
})
}
/**
* Clean invalid records in database
* Series should have atleast one Book
* Book and Podcast must have an associated LibraryItem
*/
async cleanDatabase() {
// Remove invalid Podcast records
const podcastsWithNoLibraryItem = await this.podcastModel.findAll({
where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM libraryItems li WHERE li.mediaId = podcast.id)`), 0)
})
for (const podcast of podcastsWithNoLibraryItem) {
Logger.warn(`Found podcast "${podcast.title}" with no libraryItem - removing it`)
await podcast.destroy()
}
// Remove invalid Book records
const booksWithNoLibraryItem = await this.bookModel.findAll({
where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM libraryItems li WHERE li.mediaId = book.id)`), 0)
})
for (const book of booksWithNoLibraryItem) {
Logger.warn(`Found book "${book.title}" with no libraryItem - removing it`)
await book.destroy()
}
// Remove empty series
const emptySeries = await this.seriesModel.findAll({
where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM bookSeries bs WHERE bs.seriesId = series.id)`), 0)
})
for (const series of emptySeries) {
Logger.warn(`Found series "${series.name}" with no books - removing it`)
await series.destroy()
}
}
} }
module.exports = new Database() module.exports = new Database()

View File

@ -9,24 +9,19 @@ const rateLimit = require('./libs/expressRateLimit')
const { version } = require('../package.json') const { version } = require('../package.json')
// Utils // Utils
const filePerms = require('./utils/filePerms')
const fileUtils = require('./utils/fileUtils') const fileUtils = require('./utils/fileUtils')
const Logger = require('./Logger') const Logger = require('./Logger')
const Auth = require('./Auth') const Auth = require('./Auth')
const Watcher = require('./Watcher') const Watcher = require('./Watcher')
const Scanner = require('./scanner/Scanner')
const Database = require('./Database') const Database = require('./Database')
const SocketAuthority = require('./SocketAuthority') const SocketAuthority = require('./SocketAuthority')
const routes = require('./routes/index')
const ApiRouter = require('./routers/ApiRouter') const ApiRouter = require('./routers/ApiRouter')
const HlsRouter = require('./routers/HlsRouter') const HlsRouter = require('./routers/HlsRouter')
const NotificationManager = require('./managers/NotificationManager') const NotificationManager = require('./managers/NotificationManager')
const EmailManager = require('./managers/EmailManager') const EmailManager = require('./managers/EmailManager')
const CoverManager = require('./managers/CoverManager')
const AbMergeManager = require('./managers/AbMergeManager') const AbMergeManager = require('./managers/AbMergeManager')
const CacheManager = require('./managers/CacheManager') const CacheManager = require('./managers/CacheManager')
const LogManager = require('./managers/LogManager') const LogManager = require('./managers/LogManager')
@ -37,6 +32,7 @@ const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
const RssFeedManager = require('./managers/RssFeedManager') const RssFeedManager = require('./managers/RssFeedManager')
const CronManager = require('./managers/CronManager') const CronManager = require('./managers/CronManager')
const TaskManager = require('./managers/TaskManager') const TaskManager = require('./managers/TaskManager')
const LibraryScanner = require('./scanner/LibraryScanner')
//Import the main Passport and Express-Session library //Import the main Passport and Express-Session library
const passport = require('passport') const passport = require('passport')
@ -58,11 +54,9 @@ class Server {
if (!fs.pathExistsSync(global.ConfigPath)) { if (!fs.pathExistsSync(global.ConfigPath)) {
fs.mkdirSync(global.ConfigPath) fs.mkdirSync(global.ConfigPath)
filePerms.setDefaultDirSync(global.ConfigPath, false)
} }
if (!fs.pathExistsSync(global.MetadataPath)) { if (!fs.pathExistsSync(global.MetadataPath)) {
fs.mkdirSync(global.MetadataPath) fs.mkdirSync(global.MetadataPath)
filePerms.setDefaultDirSync(global.MetadataPath, false)
} }
this.watcher = new Watcher() this.watcher = new Watcher()
@ -74,16 +68,12 @@ class Server {
this.emailManager = new EmailManager() this.emailManager = new EmailManager()
this.backupManager = new BackupManager() this.backupManager = new BackupManager()
this.logManager = new LogManager() this.logManager = new LogManager()
this.cacheManager = new CacheManager()
this.abMergeManager = new AbMergeManager(this.taskManager) this.abMergeManager = new AbMergeManager(this.taskManager)
this.playbackSessionManager = new PlaybackSessionManager() this.playbackSessionManager = new PlaybackSessionManager()
this.coverManager = new CoverManager(this.cacheManager)
this.podcastManager = new PodcastManager(this.watcher, this.notificationManager, this.taskManager) this.podcastManager = new PodcastManager(this.watcher, this.notificationManager, this.taskManager)
this.audioMetadataManager = new AudioMetadataMangaer(this.taskManager) this.audioMetadataManager = new AudioMetadataMangaer(this.taskManager)
this.rssFeedManager = new RssFeedManager() this.rssFeedManager = new RssFeedManager()
this.cronManager = new CronManager(this.podcastManager)
this.scanner = new Scanner(this.coverManager, this.taskManager)
this.cronManager = new CronManager(this.scanner, this.podcastManager)
// Routers // Routers
this.apiRouter = new ApiRouter(this) this.apiRouter = new ApiRouter(this)
@ -99,6 +89,14 @@ class Server {
this.auth.isAuthenticated(req, res, next) this.auth.isAuthenticated(req, res, next)
} }
cancelLibraryScan(libraryId) {
LibraryScanner.setCancelLibraryScan(libraryId)
}
getLibrariesScanning() {
return LibraryScanner.librariesScanning
}
/** /**
* Initialize database, backups, logs, rss feeds, cron jobs & watcher * Initialize database, backups, logs, rss feeds, cron jobs & watcher
* Cleanup stale/invalid data * Cleanup stale/invalid data
@ -115,22 +113,20 @@ class Server {
} }
await this.cleanUserData() // Remove invalid user item progress await this.cleanUserData() // Remove invalid user item progress
await this.cacheManager.ensureCachePaths() await CacheManager.ensureCachePaths()
await this.backupManager.init() await this.backupManager.init()
await this.logManager.init() await this.logManager.init()
await this.apiRouter.checkRemoveEmptySeries(Database.series) // Remove empty series
await this.rssFeedManager.init() await this.rssFeedManager.init()
const libraries = await Database.models.library.getAllOldLibraries() const libraries = await Database.libraryModel.getAllOldLibraries()
this.cronManager.init(libraries) await this.cronManager.init(libraries)
if (Database.serverSettings.scannerDisableWatcher) { if (Database.serverSettings.scannerDisableWatcher) {
Logger.info(`[Server] Watcher is disabled`) Logger.info(`[Server] Watcher is disabled`)
this.watcher.disabled = true this.watcher.disabled = true
} else { } else {
this.watcher.initWatcher(libraries) this.watcher.initWatcher(libraries)
this.watcher.on('files', this.filesChanged.bind(this))
} }
} }
@ -269,17 +265,12 @@ class Server {
res.sendStatus(200) res.sendStatus(200)
} }
async filesChanged(fileUpdates) {
Logger.info('[Server]', fileUpdates.length, 'Files Changed')
await this.scanner.scanFilesChanged(fileUpdates)
}
/** /**
* Remove user media progress for items that no longer exist & remove seriesHideFrom that no longer exist * Remove user media progress for items that no longer exist & remove seriesHideFrom that no longer exist
*/ */
async cleanUserData() { async cleanUserData() {
// Get all media progress without an associated media item // Get all media progress without an associated media item
const mediaProgressToRemove = await Database.models.mediaProgress.findAll({ const mediaProgressToRemove = await Database.mediaProgressModel.findAll({
where: { where: {
'$podcastEpisode.id$': null, '$podcastEpisode.id$': null,
'$book.id$': null '$book.id$': null
@ -287,18 +278,18 @@ class Server {
attributes: ['id'], attributes: ['id'],
include: [ include: [
{ {
model: Database.models.book, model: Database.bookModel,
attributes: ['id'] attributes: ['id']
}, },
{ {
model: Database.models.podcastEpisode, model: Database.podcastEpisodeModel,
attributes: ['id'] attributes: ['id']
} }
] ]
}) })
if (mediaProgressToRemove.length) { if (mediaProgressToRemove.length) {
// Remove media progress // Remove media progress
const mediaProgressRemoved = await Database.models.mediaProgress.destroy({ const mediaProgressRemoved = await Database.mediaProgressModel.destroy({
where: { where: {
id: { id: {
[Sequelize.Op.in]: mediaProgressToRemove.map(mp => mp.id) [Sequelize.Op.in]: mediaProgressToRemove.map(mp => mp.id)
@ -311,12 +302,19 @@ class Server {
} }
// Remove series from hide from continue listening that no longer exist // Remove series from hide from continue listening that no longer exist
const users = await Database.models.user.getOldUsers() const users = await Database.userModel.getOldUsers()
for (const _user of users) { for (const _user of users) {
let hasUpdated = false let hasUpdated = false
if (_user.seriesHideFromContinueListening.length) { if (_user.seriesHideFromContinueListening.length) {
const seriesHiding = (await Database.seriesModel.findAll({
where: {
id: _user.seriesHideFromContinueListening
},
attributes: ['id'],
raw: true
})).map(se => se.id)
_user.seriesHideFromContinueListening = _user.seriesHideFromContinueListening.filter(seriesId => { _user.seriesHideFromContinueListening = _user.seriesHideFromContinueListening.filter(seriesId => {
if (!Database.series.some(se => se.id === seriesId)) { // Series removed if (!seriesHiding.includes(seriesId)) { // Series removed
hasUpdated = true hasUpdated = true
return false return false
} }

View File

@ -10,8 +10,11 @@ class SocketAuthority {
this.clients = {} this.clients = {}
} }
// returns an array of User.toJSONForPublic with `connections` for the # of socket connections /**
// a user can have many socket connections * returns an array of User.toJSONForPublic with `connections` for the # of socket connections
* a user can have many socket connections
* @returns {object[]}
*/
getUsersOnline() { getUsersOnline() {
const onlineUsersMap = {} const onlineUsersMap = {}
Object.values(this.clients).filter(c => c.user).forEach(client => { Object.values(this.clients).filter(c => c.user).forEach(client => {
@ -19,7 +22,7 @@ class SocketAuthority {
onlineUsersMap[client.user.id].connections++ onlineUsersMap[client.user.id].connections++
} else { } else {
onlineUsersMap[client.user.id] = { onlineUsersMap[client.user.id] = {
...client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, Database.libraryItems), ...client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions),
connections: 1 connections: 1
} }
} }
@ -31,9 +34,12 @@ class SocketAuthority {
return Object.values(this.clients).filter(c => c.user && c.user.id === userId) return Object.values(this.clients).filter(c => c.user && c.user.id === userId)
} }
// Emits event to all authorized clients /**
// optional filter function to only send event to specific users * Emits event to all authorized clients
// TODO: validate that filter is actually a function * @param {string} evt
* @param {any} data
* @param {Function} [filter] optional filter function to only send event to specific users
*/
emitter(evt, data, filter = null) { emitter(evt, data, filter = null) {
for (const socketId in this.clients) { for (const socketId in this.clients) {
if (this.clients[socketId].user) { if (this.clients[socketId].user) {
@ -89,7 +95,7 @@ class SocketAuthority {
socket.on('auth', (token) => this.authenticateSocket(socket, token)) socket.on('auth', (token) => this.authenticateSocket(socket, token))
// Scanning // Scanning
socket.on('cancel_scan', this.cancelScan.bind(this)) socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId))
// Logs // Logs
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level)) socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
@ -108,7 +114,7 @@ class SocketAuthority {
delete this.clients[socket.id] delete this.clients[socket.id]
} else { } else {
Logger.debug('[SocketAuthority] User Offline ' + _client.user.username) Logger.debug('[SocketAuthority] User Offline ' + _client.user.username)
this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, Database.libraryItems)) this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions))
const disconnectTime = Date.now() - _client.connected_at const disconnectTime = Date.now() - _client.connected_at
Logger.info(`[SocketAuthority] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`) Logger.info(`[SocketAuthority] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`)
@ -165,7 +171,7 @@ class SocketAuthority {
Logger.debug(`[SocketAuthority] User Online ${client.user.username}`) Logger.debug(`[SocketAuthority] User Online ${client.user.username}`)
this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, Database.libraryItems)) this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions))
// Update user lastSeen // Update user lastSeen
user.lastSeen = Date.now() user.lastSeen = Date.now()
@ -174,7 +180,7 @@ class SocketAuthority {
const initialPayload = { const initialPayload = {
userId: client.user.id, userId: client.user.id,
username: client.user.username, username: client.user.username,
librariesScanning: this.Server.scanner.librariesScanning librariesScanning: this.Server.getLibrariesScanning()
} }
if (user.isAdminOrUp) { if (user.isAdminOrUp) {
initialPayload.usersOnline = this.getUsersOnline() initialPayload.usersOnline = this.getUsersOnline()
@ -191,7 +197,7 @@ class SocketAuthority {
if (client.user) { if (client.user) {
Logger.debug('[SocketAuthority] User Offline ' + client.user.username) Logger.debug('[SocketAuthority] User Offline ' + client.user.username)
this.adminEmitter('user_offline', client.user.toJSONForPublic(null, Database.libraryItems)) this.adminEmitter('user_offline', client.user.toJSONForPublic())
} }
delete this.clients[socketId].user delete this.clients[socketId].user
@ -203,7 +209,7 @@ class SocketAuthority {
cancelScan(id) { cancelScan(id) {
Logger.debug('[SocketAuthority] Cancel scan', id) Logger.debug('[SocketAuthority] Cancel scan', id)
this.Server.scanner.setCancelLibraryScan(id) this.Server.cancelLibraryScan(id)
} }
} }
module.exports = new SocketAuthority() module.exports = new SocketAuthority()

View File

@ -1,21 +1,34 @@
const Path = require('path')
const EventEmitter = require('events') const EventEmitter = require('events')
const Watcher = require('./libs/watcher/watcher') const Watcher = require('./libs/watcher/watcher')
const Logger = require('./Logger') const Logger = require('./Logger')
const LibraryScanner = require('./scanner/LibraryScanner')
const { filePathToPOSIX } = require('./utils/fileUtils') const { filePathToPOSIX } = require('./utils/fileUtils')
/**
* @typedef PendingFileUpdate
* @property {string} path
* @property {string} relPath
* @property {string} folderId
* @property {string} type
*/
class FolderWatcher extends EventEmitter { class FolderWatcher extends EventEmitter {
constructor() { constructor() {
super() super()
this.paths = [] // Not used
this.pendingFiles = [] // Not used
/** @type {{id:string, name:string, folders:import('./objects/Folder')[], paths:string[], watcher:Watcher[]}[]} */
this.libraryWatchers = [] this.libraryWatchers = []
/** @type {PendingFileUpdate[]} */
this.pendingFileUpdates = [] this.pendingFileUpdates = []
this.pendingDelay = 4000 this.pendingDelay = 4000
this.pendingTimeout = null this.pendingTimeout = null
/** @type {string[]} */
this.ignoreDirs = [] this.ignoreDirs = []
/** @type {string[]} */
this.pendingDirsToRemoveFromIgnore = []
this.disabled = false this.disabled = false
} }
@ -29,11 +42,12 @@ class FolderWatcher extends EventEmitter {
return return
} }
Logger.info(`[Watcher] Initializing watcher for "${library.name}".`) Logger.info(`[Watcher] Initializing watcher for "${library.name}".`)
var folderPaths = library.folderPaths
const folderPaths = library.folderPaths
folderPaths.forEach((fp) => { folderPaths.forEach((fp) => {
Logger.debug(`[Watcher] Init watcher for library folder path "${fp}"`) Logger.debug(`[Watcher] Init watcher for library folder path "${fp}"`)
}) })
var watcher = new Watcher(folderPaths, { const watcher = new Watcher(folderPaths, {
ignored: /(^|[\/\\])\../, // ignore dotfiles ignored: /(^|[\/\\])\../, // ignore dotfiles
renameDetection: true, renameDetection: true,
renameTimeout: 2000, renameTimeout: 2000,
@ -144,6 +158,12 @@ class FolderWatcher extends EventEmitter {
this.addFileUpdate(libraryId, pathTo, 'renamed') this.addFileUpdate(libraryId, pathTo, 'renamed')
} }
/**
* File update detected from watcher
* @param {string} libraryId
* @param {string} path
* @param {string} type
*/
addFileUpdate(libraryId, path, type) { addFileUpdate(libraryId, path, type) {
path = filePathToPOSIX(path) path = filePathToPOSIX(path)
if (this.pendingFilePaths.includes(path)) return if (this.pendingFilePaths.includes(path)) return
@ -161,11 +181,18 @@ class FolderWatcher extends EventEmitter {
Logger.error(`[Watcher] New file folder not found in library "${libwatcher.name}" with path "${path}"`) Logger.error(`[Watcher] New file folder not found in library "${libwatcher.name}" with path "${path}"`)
return return
} }
const folderFullPath = filePathToPOSIX(folder.fullPath) const folderFullPath = filePathToPOSIX(folder.fullPath)
var relPath = path.replace(folderFullPath, '') const relPath = path.replace(folderFullPath, '')
var hasDotPath = relPath.split('/').find(p => p.startsWith('.')) if (Path.extname(relPath).toLowerCase() === '.part') {
Logger.debug(`[Watcher] Ignoring .part file "${relPath}"`)
return
}
// Ignore files/folders starting with "."
const hasDotPath = relPath.split('/').find(p => p.startsWith('.'))
if (hasDotPath) { if (hasDotPath) {
Logger.debug(`[Watcher] Ignoring dot path "${relPath}" | Piece "${hasDotPath}"`) Logger.debug(`[Watcher] Ignoring dot path "${relPath}" | Piece "${hasDotPath}"`)
return return
@ -184,7 +211,8 @@ class FolderWatcher extends EventEmitter {
// Notify server of update after "pendingDelay" // Notify server of update after "pendingDelay"
clearTimeout(this.pendingTimeout) clearTimeout(this.pendingTimeout)
this.pendingTimeout = setTimeout(() => { this.pendingTimeout = setTimeout(() => {
this.emit('files', this.pendingFileUpdates) // this.emit('files', this.pendingFileUpdates)
LibraryScanner.scanFilesChanged(this.pendingFileUpdates)
this.pendingFileUpdates = [] this.pendingFileUpdates = []
}, this.pendingDelay) }, this.pendingDelay)
} }
@ -195,24 +223,50 @@ class FolderWatcher extends EventEmitter {
}) })
} }
/**
* Convert to POSIX and remove trailing slash
* @param {string} path
* @returns {string}
*/
cleanDirPath(path) { cleanDirPath(path) {
path = filePathToPOSIX(path) path = filePathToPOSIX(path)
if (path.endsWith('/')) path = path.slice(0, -1) if (path.endsWith('/')) path = path.slice(0, -1)
return path return path
} }
/**
* Ignore this directory if files are picked up by watcher
* @param {string} path
*/
addIgnoreDir(path) { addIgnoreDir(path) {
path = this.cleanDirPath(path) path = this.cleanDirPath(path)
if (this.ignoreDirs.includes(path)) return if (this.ignoreDirs.includes(path)) return
this.pendingDirsToRemoveFromIgnore = this.pendingDirsToRemoveFromIgnore.filter(p => p !== path)
Logger.debug(`[Watcher] Ignoring directory "${path}"`) Logger.debug(`[Watcher] Ignoring directory "${path}"`)
this.ignoreDirs.push(path) this.ignoreDirs.push(path)
} }
/**
* When downloading a podcast episode we dont want the scanner triggering for that podcast
* when the episode finishes the watcher may have a delayed response so a timeout is added
* to prevent the watcher from picking up the episode
*
* @param {string} path
*/
removeIgnoreDir(path) { removeIgnoreDir(path) {
path = this.cleanDirPath(path) path = this.cleanDirPath(path)
if (!this.ignoreDirs.includes(path)) return if (!this.ignoreDirs.includes(path) || this.pendingDirsToRemoveFromIgnore.includes(path)) return
Logger.debug(`[Watcher] No longer ignoring directory "${path}"`)
this.ignoreDirs = this.ignoreDirs.filter(p => p !== path) // Add a 5 second delay before removing the ignore from this dir
this.pendingDirsToRemoveFromIgnore.push(path)
setTimeout(() => {
if (this.pendingDirsToRemoveFromIgnore.includes(path)) {
this.pendingDirsToRemoveFromIgnore = this.pendingDirsToRemoveFromIgnore.filter(p => p !== path)
Logger.debug(`[Watcher] No longer ignoring directory "${path}"`)
this.ignoreDirs = this.ignoreDirs.filter(p => p !== path)
}
}, 5000)
} }
} }
module.exports = FolderWatcher module.exports = FolderWatcher

View File

@ -1,10 +1,13 @@
const sequelize = require('sequelize')
const fs = require('../libs/fsExtra') const fs = require('../libs/fsExtra')
const { createNewSortInstance } = require('../libs/fastSort') const { createNewSortInstance } = require('../libs/fastSort')
const Logger = require('../Logger') const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database') const Database = require('../Database')
const CacheManager = require('../managers/CacheManager')
const CoverManager = require('../managers/CoverManager')
const AuthorFinder = require('../finders/AuthorFinder')
const { reqSupportsWebp } = require('../utils/index') const { reqSupportsWebp } = require('../utils/index')
@ -21,7 +24,7 @@ class AuthorController {
// Used on author landing page to include library items and items grouped in series // Used on author landing page to include library items and items grouped in series
if (include.includes('items')) { if (include.includes('items')) {
authorJson.libraryItems = await Database.models.libraryItem.getForAuthor(req.author, req.user) authorJson.libraryItems = await Database.libraryItemModel.getForAuthor(req.author, req.user)
if (include.includes('series')) { if (include.includes('series')) {
const seriesMap = {} const seriesMap = {}
@ -67,13 +70,13 @@ class AuthorController {
// Updating/removing cover image // Updating/removing cover image
if (payload.imagePath !== undefined && payload.imagePath !== req.author.imagePath) { if (payload.imagePath !== undefined && payload.imagePath !== req.author.imagePath) {
if (!payload.imagePath && req.author.imagePath) { // If removing image then remove file if (!payload.imagePath && req.author.imagePath) { // If removing image then remove file
await this.cacheManager.purgeImageCache(req.author.id) // Purge cache await CacheManager.purgeImageCache(req.author.id) // Purge cache
await this.coverManager.removeFile(req.author.imagePath) await CoverManager.removeFile(req.author.imagePath)
} else if (payload.imagePath.startsWith('http')) { // Check if image path is a url } else if (payload.imagePath.startsWith('http')) { // Check if image path is a url
const imageData = await this.authorFinder.saveAuthorImage(req.author.id, payload.imagePath) const imageData = await AuthorFinder.saveAuthorImage(req.author.id, payload.imagePath)
if (imageData) { if (imageData) {
if (req.author.imagePath) { if (req.author.imagePath) {
await this.cacheManager.purgeImageCache(req.author.id) // Purge cache await CacheManager.purgeImageCache(req.author.id) // Purge cache
} }
payload.imagePath = imageData.path payload.imagePath = imageData.path
hasUpdated = true hasUpdated = true
@ -85,7 +88,7 @@ class AuthorController {
} }
if (req.author.imagePath) { if (req.author.imagePath) {
await this.cacheManager.purgeImageCache(req.author.id) // Purge cache await CacheManager.purgeImageCache(req.author.id) // Purge cache
} }
} }
} }
@ -93,10 +96,21 @@ class AuthorController {
const authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name const authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name
// Check if author name matches another author and merge the authors // Check if author name matches another author and merge the authors
const existingAuthor = authorNameUpdate ? Database.authors.find(au => au.id !== req.author.id && payload.name === au.name) : false let existingAuthor = null
if (authorNameUpdate) {
const author = await Database.authorModel.findOne({
where: {
id: {
[sequelize.Op.not]: req.author.id
},
name: payload.name
}
})
existingAuthor = author?.getOldAuthor()
}
if (existingAuthor) { if (existingAuthor) {
const bookAuthorsToCreate = [] const bookAuthorsToCreate = []
const itemsWithAuthor = await Database.models.libraryItem.getForAuthor(req.author) const itemsWithAuthor = await Database.libraryItemModel.getForAuthor(req.author)
itemsWithAuthor.forEach(libraryItem => { // Replace old author with merging author for each book itemsWithAuthor.forEach(libraryItem => { // Replace old author with merging author for each book
libraryItem.media.metadata.replaceAuthor(req.author, existingAuthor) libraryItem.media.metadata.replaceAuthor(req.author, existingAuthor)
bookAuthorsToCreate.push({ bookAuthorsToCreate.push({
@ -113,9 +127,11 @@ class AuthorController {
// Remove old author // Remove old author
await Database.removeAuthor(req.author.id) await Database.removeAuthor(req.author.id)
SocketAuthority.emitter('author_removed', req.author.toJSON()) SocketAuthority.emitter('author_removed', req.author.toJSON())
// Update filter data
Database.removeAuthorFromFilterData(req.author.libraryId, req.author.id)
// Send updated num books for merged author // Send updated num books for merged author
const numBooks = await Database.models.libraryItem.getForAuthor(existingAuthor).length const numBooks = await Database.libraryItemModel.getForAuthor(existingAuthor).length
SocketAuthority.emitter('author_updated', existingAuthor.toJSONExpanded(numBooks)) SocketAuthority.emitter('author_updated', existingAuthor.toJSONExpanded(numBooks))
res.json({ res.json({
@ -130,7 +146,7 @@ class AuthorController {
if (hasUpdated) { if (hasUpdated) {
req.author.updatedAt = Date.now() req.author.updatedAt = Date.now()
const itemsWithAuthor = await Database.models.libraryItem.getForAuthor(req.author) const itemsWithAuthor = await Database.libraryItemModel.getForAuthor(req.author)
if (authorNameUpdate) { // Update author name on all books if (authorNameUpdate) { // Update author name on all books
itemsWithAuthor.forEach(libraryItem => { itemsWithAuthor.forEach(libraryItem => {
libraryItem.media.metadata.updateAuthor(req.author) libraryItem.media.metadata.updateAuthor(req.author)
@ -151,24 +167,13 @@ class AuthorController {
} }
} }
async search(req, res) {
var q = (req.query.q || '').toLowerCase()
if (!q) return res.json([])
var limit = (req.query.limit && !isNaN(req.query.limit)) ? Number(req.query.limit) : 25
var authors = Database.authors.filter(au => au.name?.toLowerCase().includes(q))
authors = authors.slice(0, limit)
res.json({
results: authors
})
}
async match(req, res) { async match(req, res) {
let authorData = null let authorData = null
const region = req.body.region || 'us' const region = req.body.region || 'us'
if (req.body.asin) { if (req.body.asin) {
authorData = await this.authorFinder.findAuthorByASIN(req.body.asin, region) authorData = await AuthorFinder.findAuthorByASIN(req.body.asin, region)
} else { } else {
authorData = await this.authorFinder.findAuthorByName(req.body.q, region) authorData = await AuthorFinder.findAuthorByName(req.body.q, region)
} }
if (!authorData) { if (!authorData) {
return res.status(404).send('Author not found') return res.status(404).send('Author not found')
@ -183,9 +188,9 @@ class AuthorController {
// Only updates image if there was no image before or the author ASIN was updated // Only updates image if there was no image before or the author ASIN was updated
if (authorData.image && (!req.author.imagePath || hasUpdates)) { if (authorData.image && (!req.author.imagePath || hasUpdates)) {
this.cacheManager.purgeImageCache(req.author.id) await CacheManager.purgeImageCache(req.author.id)
const imageData = await this.authorFinder.saveAuthorImage(req.author.id, authorData.image) const imageData = await AuthorFinder.saveAuthorImage(req.author.id, authorData.image)
if (imageData) { if (imageData) {
req.author.imagePath = imageData.path req.author.imagePath = imageData.path
hasUpdates = true hasUpdates = true
@ -202,7 +207,7 @@ class AuthorController {
await Database.updateAuthor(req.author) await Database.updateAuthor(req.author)
const numBooks = await Database.models.libraryItem.getForAuthor(req.author).length const numBooks = await Database.libraryItemModel.getForAuthor(req.author).length
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks)) SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))
} }
@ -229,11 +234,11 @@ class AuthorController {
height: height ? parseInt(height) : null, height: height ? parseInt(height) : null,
width: width ? parseInt(width) : null width: width ? parseInt(width) : null
} }
return this.cacheManager.handleAuthorCache(res, author, options) return CacheManager.handleAuthorCache(res, author, options)
} }
middleware(req, res, next) { async middleware(req, res, next) {
const author = Database.authors.find(au => au.id === req.params.id) const author = await Database.authorModel.getOldById(req.params.id)
if (!author) return res.sendStatus(404) if (!author) return res.sendStatus(404)
if (req.method == 'DELETE' && !req.user.canDelete) { if (req.method == 'DELETE' && !req.user.canDelete) {

View File

@ -1,4 +1,4 @@
const Logger = require('../Logger') const CacheManager = require('../managers/CacheManager')
class CacheController { class CacheController {
constructor() { } constructor() { }
@ -8,7 +8,7 @@ class CacheController {
if (!req.user.isAdminOrUp) { if (!req.user.isAdminOrUp) {
return res.sendStatus(403) return res.sendStatus(403)
} }
await this.cacheManager.purgeAll() await CacheManager.purgeAll()
res.sendStatus(200) res.sendStatus(200)
} }
@ -17,7 +17,7 @@ class CacheController {
if (!req.user.isAdminOrUp) { if (!req.user.isAdminOrUp) {
return res.sendStatus(403) return res.sendStatus(403)
} }
await this.cacheManager.purgeItems() await CacheManager.purgeItems()
res.sendStatus(200) res.sendStatus(200)
} }
} }

View File

@ -1,3 +1,4 @@
const Sequelize = require('sequelize')
const Logger = require('../Logger') const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database') const Database = require('../Database')
@ -7,22 +8,49 @@ const Collection = require('../objects/Collection')
class CollectionController { class CollectionController {
constructor() { } constructor() { }
/**
* POST: /api/collections
* Create new collection
* @param {*} req
* @param {*} res
*/
async create(req, res) { async create(req, res) {
const newCollection = new Collection() const newCollection = new Collection()
req.body.userId = req.user.id req.body.userId = req.user.id
if (!newCollection.setData(req.body)) { if (!newCollection.setData(req.body)) {
return res.status(500).send('Invalid collection data') return res.status(400).send('Invalid collection data')
}
// Create collection record
await Database.collectionModel.createFromOld(newCollection)
// Get library items in collection
const libraryItemsInCollection = await Database.libraryItemModel.getForCollection(newCollection)
// Create collectionBook records
let order = 1
const collectionBooksToAdd = []
for (const libraryItemId of newCollection.books) {
const libraryItem = libraryItemsInCollection.find(li => li.id === libraryItemId)
if (libraryItem) {
collectionBooksToAdd.push({
collectionId: newCollection.id,
bookId: libraryItem.media.id,
order: order++
})
}
}
if (collectionBooksToAdd.length) {
await Database.createBulkCollectionBooks(collectionBooksToAdd)
} }
const libraryItemsInCollection = await Database.models.libraryItem.getForCollection(newCollection)
const jsonExpanded = newCollection.toJSONExpanded(libraryItemsInCollection) const jsonExpanded = newCollection.toJSONExpanded(libraryItemsInCollection)
await Database.createCollection(newCollection)
SocketAuthority.emitter('collection_added', jsonExpanded) SocketAuthority.emitter('collection_added', jsonExpanded)
res.json(jsonExpanded) res.json(jsonExpanded)
} }
async findAll(req, res) { async findAll(req, res) {
const collectionsExpanded = await Database.models.collection.getOldCollectionsJsonExpanded(req.user) const collectionsExpanded = await Database.collectionModel.getOldCollectionsJsonExpanded(req.user)
res.json({ res.json({
collections: collectionsExpanded collections: collectionsExpanded
}) })
@ -31,140 +59,275 @@ class CollectionController {
async findOne(req, res) { async findOne(req, res) {
const includeEntities = (req.query.include || '').split(',') const includeEntities = (req.query.include || '').split(',')
const collectionExpanded = req.collection.toJSONExpanded(Database.libraryItems) const collectionExpanded = await req.collection.getOldJsonExpanded(req.user, includeEntities)
if (!collectionExpanded) {
if (includeEntities.includes('rssfeed')) { // This may happen if the user is restricted from all books
const feedData = await this.rssFeedManager.findFeedForEntityId(collectionExpanded.id) return res.sendStatus(404)
collectionExpanded.rssFeed = feedData?.toJSONMinified() || null
} }
res.json(collectionExpanded) res.json(collectionExpanded)
} }
/**
* PATCH: /api/collections/:id
* Update collection
* @param {*} req
* @param {*} res
*/
async update(req, res) { async update(req, res) {
const collection = req.collection let wasUpdated = false
const wasUpdated = collection.update(req.body)
const jsonExpanded = collection.toJSONExpanded(Database.libraryItems) // Update description and name if defined
const collectionUpdatePayload = {}
if (req.body.description !== undefined && req.body.description !== req.collection.description) {
collectionUpdatePayload.description = req.body.description
wasUpdated = true
}
if (req.body.name !== undefined && req.body.name !== req.collection.name) {
collectionUpdatePayload.name = req.body.name
wasUpdated = true
}
if (wasUpdated) {
await req.collection.update(collectionUpdatePayload)
}
// If books array is passed in then update order in collection
if (req.body.books?.length) {
const collectionBooks = await req.collection.getCollectionBooks({
include: {
model: Database.bookModel,
include: Database.libraryItemModel
},
order: [['order', 'ASC']]
})
collectionBooks.sort((a, b) => {
const aIndex = req.body.books.findIndex(lid => lid === a.book.libraryItem.id)
const bIndex = req.body.books.findIndex(lid => lid === b.book.libraryItem.id)
return aIndex - bIndex
})
for (let i = 0; i < collectionBooks.length; i++) {
if (collectionBooks[i].order !== i + 1) {
await collectionBooks[i].update({
order: i + 1
})
wasUpdated = true
}
}
}
const jsonExpanded = await req.collection.getOldJsonExpanded()
if (wasUpdated) { if (wasUpdated) {
await Database.updateCollection(collection)
SocketAuthority.emitter('collection_updated', jsonExpanded) SocketAuthority.emitter('collection_updated', jsonExpanded)
} }
res.json(jsonExpanded) res.json(jsonExpanded)
} }
async delete(req, res) { async delete(req, res) {
const collection = req.collection const jsonExpanded = await req.collection.getOldJsonExpanded()
const jsonExpanded = collection.toJSONExpanded(Database.libraryItems)
// Close rss feed - remove from db and emit socket event // Close rss feed - remove from db and emit socket event
await this.rssFeedManager.closeFeedForEntityId(collection.id) await this.rssFeedManager.closeFeedForEntityId(req.collection.id)
await req.collection.destroy()
await Database.removeCollection(collection.id)
SocketAuthority.emitter('collection_removed', jsonExpanded) SocketAuthority.emitter('collection_removed', jsonExpanded)
res.sendStatus(200) res.sendStatus(200)
} }
/**
* POST: /api/collections/:id/book
* Add a single book to a collection
* Req.body { id: <library item id> }
* @param {*} req
* @param {*} res
*/
async addBook(req, res) { async addBook(req, res) {
const collection = req.collection const libraryItem = await Database.libraryItemModel.getOldById(req.body.id)
const libraryItem = Database.libraryItems.find(li => li.id === req.body.id)
if (!libraryItem) { if (!libraryItem) {
return res.status(500).send('Book not found') return res.status(404).send('Book not found')
} }
if (libraryItem.libraryId !== collection.libraryId) { if (libraryItem.libraryId !== req.collection.libraryId) {
return res.status(500).send('Book in different library') return res.status(400).send('Book in different library')
} }
if (collection.books.includes(req.body.id)) {
return res.status(500).send('Book already in collection')
}
collection.addBook(req.body.id)
const jsonExpanded = collection.toJSONExpanded(Database.libraryItems)
const collectionBook = { // Check if book is already in collection
collectionId: collection.id, const collectionBooks = await req.collection.getCollectionBooks()
bookId: libraryItem.media.id, if (collectionBooks.some(cb => cb.bookId === libraryItem.media.id)) {
order: collection.books.length return res.status(400).send('Book already in collection')
} }
await Database.createCollectionBook(collectionBook)
// Create collectionBook record
await Database.collectionBookModel.create({
collectionId: req.collection.id,
bookId: libraryItem.media.id,
order: collectionBooks.length + 1
})
const jsonExpanded = await req.collection.getOldJsonExpanded()
SocketAuthority.emitter('collection_updated', jsonExpanded) SocketAuthority.emitter('collection_updated', jsonExpanded)
res.json(jsonExpanded) res.json(jsonExpanded)
} }
// DELETE: api/collections/:id/book/:bookId /**
* DELETE: /api/collections/:id/book/:bookId
* Remove a single book from a collection. Re-order books
* TODO: bookId is actually libraryItemId. Clients need updating to use bookId
* @param {*} req
* @param {*} res
*/
async removeBook(req, res) { async removeBook(req, res) {
const collection = req.collection const libraryItem = await Database.libraryItemModel.getOldById(req.params.bookId)
const libraryItem = Database.libraryItems.find(li => li.id === req.params.bookId)
if (!libraryItem) { if (!libraryItem) {
return res.sendStatus(404) return res.sendStatus(404)
} }
if (collection.books.includes(req.params.bookId)) { // Get books in collection ordered
collection.removeBook(req.params.bookId) const collectionBooks = await req.collection.getCollectionBooks({
const jsonExpanded = collection.toJSONExpanded(Database.libraryItems) order: [['order', 'ASC']]
})
let jsonExpanded = null
const collectionBookToRemove = collectionBooks.find(cb => cb.bookId === libraryItem.media.id)
if (collectionBookToRemove) {
// Remove collection book record
await collectionBookToRemove.destroy()
// Update order on collection books
let order = 1
for (const collectionBook of collectionBooks) {
if (collectionBook.bookId === libraryItem.media.id) continue
if (collectionBook.order !== order) {
await collectionBook.update({
order
})
}
order++
}
jsonExpanded = await req.collection.getOldJsonExpanded()
SocketAuthority.emitter('collection_updated', jsonExpanded) SocketAuthority.emitter('collection_updated', jsonExpanded)
await Database.updateCollection(collection) } else {
jsonExpanded = await req.collection.getOldJsonExpanded()
} }
res.json(collection.toJSONExpanded(Database.libraryItems)) res.json(jsonExpanded)
} }
// POST: api/collections/:id/batch/add /**
* POST: /api/collections/:id/batch/add
* Add multiple books to collection
* Req.body { books: <Array of library item ids> }
* @param {*} req
* @param {*} res
*/
async addBatch(req, res) { async addBatch(req, res) {
const collection = req.collection // filter out invalid libraryItemIds
if (!req.body.books || !req.body.books.length) { const bookIdsToAdd = (req.body.books || []).filter(b => !!b && typeof b == 'string')
if (!bookIdsToAdd.length) {
return res.status(500).send('Invalid request body') return res.status(500).send('Invalid request body')
} }
const bookIdsToAdd = req.body.books
// Get library items associated with ids
const libraryItems = await Database.libraryItemModel.findAll({
where: {
id: {
[Sequelize.Op.in]: bookIdsToAdd
}
},
include: {
model: Database.bookModel
}
})
// Get collection books already in collection
const collectionBooks = await req.collection.getCollectionBooks()
let order = collectionBooks.length + 1
const collectionBooksToAdd = [] const collectionBooksToAdd = []
let hasUpdated = false let hasUpdated = false
let order = collection.books.length // Check and set new collection books to add
for (const libraryItemId of bookIdsToAdd) { for (const libraryItem of libraryItems) {
const libraryItem = Database.libraryItems.find(li => li.id === libraryItemId) if (!collectionBooks.some(cb => cb.bookId === libraryItem.media.id)) {
if (!libraryItem) continue
if (!collection.books.includes(libraryItemId)) {
collection.addBook(libraryItemId)
collectionBooksToAdd.push({ collectionBooksToAdd.push({
collectionId: collection.id, collectionId: req.collection.id,
bookId: libraryItem.media.id, bookId: libraryItem.media.id,
order: order++ order: order++
}) })
hasUpdated = true hasUpdated = true
} else {
Logger.warn(`[CollectionController] addBatch: Library item ${libraryItem.id} already in collection`)
} }
} }
let jsonExpanded = null
if (hasUpdated) { if (hasUpdated) {
await Database.createBulkCollectionBooks(collectionBooksToAdd) await Database.createBulkCollectionBooks(collectionBooksToAdd)
SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(Database.libraryItems)) jsonExpanded = await req.collection.getOldJsonExpanded()
SocketAuthority.emitter('collection_updated', jsonExpanded)
} else {
jsonExpanded = await req.collection.getOldJsonExpanded()
} }
res.json(collection.toJSONExpanded(Database.libraryItems)) res.json(jsonExpanded)
} }
// POST: api/collections/:id/batch/remove /**
* POST: /api/collections/:id/batch/remove
* Remove multiple books from collection
* Req.body { books: <Array of library item ids> }
* @param {*} req
* @param {*} res
*/
async removeBatch(req, res) { async removeBatch(req, res) {
const collection = req.collection // filter out invalid libraryItemIds
if (!req.body.books || !req.body.books.length) { const bookIdsToRemove = (req.body.books || []).filter(b => !!b && typeof b == 'string')
if (!bookIdsToRemove.length) {
return res.status(500).send('Invalid request body') return res.status(500).send('Invalid request body')
} }
var bookIdsToRemove = req.body.books
let hasUpdated = false
for (const libraryItemId of bookIdsToRemove) {
const libraryItem = Database.libraryItems.find(li => li.id === libraryItemId)
if (!libraryItem) continue
if (collection.books.includes(libraryItemId)) { // Get library items associated with ids
collection.removeBook(libraryItemId) const libraryItems = await Database.libraryItemModel.findAll({
where: {
id: {
[Sequelize.Op.in]: bookIdsToRemove
}
},
include: {
model: Database.bookModel
}
})
// Get collection books already in collection
const collectionBooks = await req.collection.getCollectionBooks({
order: [['order', 'ASC']]
})
// Remove collection books and update order
let order = 1
let hasUpdated = false
for (const collectionBook of collectionBooks) {
if (libraryItems.some(li => li.media.id === collectionBook.bookId)) {
await collectionBook.destroy()
hasUpdated = true
continue
} else if (collectionBook.order !== order) {
await collectionBook.update({
order
})
hasUpdated = true hasUpdated = true
} }
order++
} }
let jsonExpanded = await req.collection.getOldJsonExpanded()
if (hasUpdated) { if (hasUpdated) {
await Database.updateCollection(collection) SocketAuthority.emitter('collection_updated', jsonExpanded)
SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(Database.libraryItems))
} }
res.json(collection.toJSONExpanded(Database.libraryItems)) res.json(jsonExpanded)
} }
async middleware(req, res, next) { async middleware(req, res, next) {
if (req.params.id) { if (req.params.id) {
const collection = await Database.models.collection.getById(req.params.id) const collection = await Database.collectionModel.findByPk(req.params.id)
if (!collection) { if (!collection) {
return res.status(404).send('Collection not found') return res.status(404).send('Collection not found')
} }

View File

@ -54,7 +54,7 @@ class EmailController {
async sendEBookToDevice(req, res) { async sendEBookToDevice(req, res) {
Logger.debug(`[EmailController] Send ebook to device request for libraryItemId=${req.body.libraryItemId}, deviceName=${req.body.deviceName}`) Logger.debug(`[EmailController] Send ebook to device request for libraryItemId=${req.body.libraryItemId}, deviceName=${req.body.deviceName}`)
const libraryItem = Database.getLibraryItem(req.body.libraryItemId) const libraryItem = await Database.libraryItemModel.getOldById(req.body.libraryItemId)
if (!libraryItem) { if (!libraryItem) {
return res.status(404).send('Library item not found') return res.status(404).send('Library item not found')
} }

View File

@ -17,7 +17,7 @@ class FileSystemController {
}) })
// Do not include existing mapped library paths in response // Do not include existing mapped library paths in response
const libraryFoldersPaths = await Database.models.libraryFolder.getAllLibraryFolderPaths() const libraryFoldersPaths = await Database.libraryFolderModel.getAllLibraryFolderPaths()
libraryFoldersPaths.forEach((path) => { libraryFoldersPaths.forEach((path) => {
let dir = path || '' let dir = path || ''
if (dir.includes(global.appRoot)) dir = dir.replace(global.appRoot, '') if (dir.includes(global.appRoot)) dir = dir.replace(global.appRoot, '')

File diff suppressed because it is too large Load Diff

View File

@ -8,11 +8,24 @@ const zipHelpers = require('../utils/zipHelpers')
const { reqSupportsWebp } = require('../utils/index') const { reqSupportsWebp } = require('../utils/index')
const { ScanResult } = require('../utils/constants') const { ScanResult } = require('../utils/constants')
const { getAudioMimeTypeFromExtname } = require('../utils/fileUtils') const { getAudioMimeTypeFromExtname } = require('../utils/fileUtils')
const LibraryItemScanner = require('../scanner/LibraryItemScanner')
const AudioFileScanner = require('../scanner/AudioFileScanner')
const Scanner = require('../scanner/Scanner')
const CacheManager = require('../managers/CacheManager')
const CoverManager = require('../managers/CoverManager')
class LibraryItemController { class LibraryItemController {
constructor() { } constructor() { }
// Example expand with authors: api/items/:id?expanded=1&include=authors /**
* GET: /api/items/:id
* Optional query params:
* ?include=progress,rssfeed,downloads
* ?expanded=1
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async findOne(req, res) { async findOne(req, res) {
const includeEntities = (req.query.include || '').split(',') const includeEntities = (req.query.include || '').split(',')
if (req.query.expanded == 1) { if (req.query.expanded == 1) {
@ -29,17 +42,7 @@ class LibraryItemController {
item.rssFeed = feedData?.toJSONMinified() || null item.rssFeed = feedData?.toJSONMinified() || null
} }
if (item.mediaType == 'book') { if (item.mediaType === 'podcast' && includeEntities.includes('downloads')) {
if (includeEntities.includes('authors')) {
item.media.metadata.authors = item.media.metadata.authors.map(au => {
var author = Database.authors.find(_au => _au.id === au.id)
if (!author) return null
return {
...author
}
}).filter(au => au)
}
} else if (includeEntities.includes('downloads')) {
const downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id) const downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id)
item.episodeDownloadsQueued = downloadsInQueue.map(d => d.toJSONForClient()) item.episodeDownloadsQueued = downloadsInQueue.map(d => d.toJSONForClient())
if (this.podcastManager.currentDownload?.libraryItemId === req.libraryItem.id) { if (this.podcastManager.currentDownload?.libraryItemId === req.libraryItem.id) {
@ -56,7 +59,7 @@ class LibraryItemController {
var libraryItem = req.libraryItem var libraryItem = req.libraryItem
// Item has cover and update is removing cover so purge it from cache // Item has cover and update is removing cover so purge it from cache
if (libraryItem.media.coverPath && req.body.media && (req.body.media.coverPath === '' || req.body.media.coverPath === null)) { if (libraryItem.media.coverPath && req.body.media && (req.body.media.coverPath === '' || req.body.media.coverPath === null)) {
await this.cacheManager.purgeCoverCache(libraryItem.id) await CacheManager.purgeCoverCache(libraryItem.id)
} }
const hasUpdates = libraryItem.update(req.body) const hasUpdates = libraryItem.update(req.body)
@ -71,13 +74,14 @@ class LibraryItemController {
async delete(req, res) { async delete(req, res) {
const hardDelete = req.query.hard == 1 // Delete from file system const hardDelete = req.query.hard == 1 // Delete from file system
const libraryItemPath = req.libraryItem.path const libraryItemPath = req.libraryItem.path
await this.handleDeleteLibraryItem(req.libraryItem) await this.handleDeleteLibraryItem(req.libraryItem.mediaType, req.libraryItem.id, [req.libraryItem.media.id])
if (hardDelete) { if (hardDelete) {
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`) Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
await fs.remove(libraryItemPath).catch((error) => { await fs.remove(libraryItemPath).catch((error) => {
Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error) Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
}) })
} }
await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId)
res.sendStatus(200) res.sendStatus(200)
} }
@ -103,7 +107,7 @@ class LibraryItemController {
// Item has cover and update is removing cover so purge it from cache // Item has cover and update is removing cover so purge it from cache
if (libraryItem.media.coverPath && (mediaPayload.coverPath === '' || mediaPayload.coverPath === null)) { if (libraryItem.media.coverPath && (mediaPayload.coverPath === '' || mediaPayload.coverPath === null)) {
await this.cacheManager.purgeCoverCache(libraryItem.id) await CacheManager.purgeCoverCache(libraryItem.id)
} }
// Book specific // Book specific
@ -124,7 +128,7 @@ class LibraryItemController {
// Book specific - Get all series being removed from this item // Book specific - Get all series being removed from this item
let seriesRemoved = [] let seriesRemoved = []
if (libraryItem.isBook && mediaPayload.metadata?.series) { if (libraryItem.isBook && mediaPayload.metadata?.series) {
const seriesIdsInUpdate = (mediaPayload.metadata?.series || []).map(se => se.id) const seriesIdsInUpdate = mediaPayload.metadata.series?.map(se => se.id) || []
seriesRemoved = libraryItem.media.metadata.series.filter(se => !seriesIdsInUpdate.includes(se.id)) seriesRemoved = libraryItem.media.metadata.series.filter(se => !seriesIdsInUpdate.includes(se.id))
} }
@ -135,7 +139,7 @@ class LibraryItemController {
if (seriesRemoved.length) { if (seriesRemoved.length) {
// Check remove empty series // Check remove empty series
Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`) Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`)
await this.checkRemoveEmptySeries(seriesRemoved) await this.checkRemoveEmptySeries(libraryItem.media.id, seriesRemoved.map(se => se.id))
} }
if (isPodcastAutoDownloadUpdated) { if (isPodcastAutoDownloadUpdated) {
@ -164,10 +168,10 @@ class LibraryItemController {
var result = null var result = null
if (req.body && req.body.url) { if (req.body && req.body.url) {
Logger.debug(`[LibraryItemController] Requesting download cover from url "${req.body.url}"`) Logger.debug(`[LibraryItemController] Requesting download cover from url "${req.body.url}"`)
result = await this.coverManager.downloadCoverFromUrl(libraryItem, req.body.url) result = await CoverManager.downloadCoverFromUrl(libraryItem, req.body.url)
} else if (req.files && req.files.cover) { } else if (req.files && req.files.cover) {
Logger.debug(`[LibraryItemController] Handling uploaded cover`) Logger.debug(`[LibraryItemController] Handling uploaded cover`)
result = await this.coverManager.uploadCover(libraryItem, req.files.cover) result = await CoverManager.uploadCover(libraryItem, req.files.cover)
} else { } else {
return res.status(400).send('Invalid request no file or url') return res.status(400).send('Invalid request no file or url')
} }
@ -193,7 +197,7 @@ class LibraryItemController {
return res.status(400).send('Invalid request no cover path') return res.status(400).send('Invalid request no cover path')
} }
const validationResult = await this.coverManager.validateCoverPath(req.body.cover, libraryItem) const validationResult = await CoverManager.validateCoverPath(req.body.cover, libraryItem)
if (validationResult.error) { if (validationResult.error) {
return res.status(500).send(validationResult.error) return res.status(500).send(validationResult.error)
} }
@ -213,7 +217,7 @@ class LibraryItemController {
if (libraryItem.media.coverPath) { if (libraryItem.media.coverPath) {
libraryItem.updateMediaCover('') libraryItem.updateMediaCover('')
await this.cacheManager.purgeCoverCache(libraryItem.id) await CacheManager.purgeCoverCache(libraryItem.id)
await Database.updateLibraryItem(libraryItem) await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
} }
@ -242,7 +246,7 @@ class LibraryItemController {
height: height ? parseInt(height) : null, height: height ? parseInt(height) : null,
width: width ? parseInt(width) : null width: width ? parseInt(width) : null
} }
return this.cacheManager.handleCoverCache(res, libraryItem, options) return CacheManager.handleCoverCache(res, libraryItem, options)
} }
// GET: api/items/:id/stream // GET: api/items/:id/stream
@ -296,7 +300,7 @@ class LibraryItemController {
var libraryItem = req.libraryItem var libraryItem = req.libraryItem
var options = req.body || {} var options = req.body || {}
var matchResult = await this.scanner.quickMatchLibraryItem(libraryItem, options) var matchResult = await Scanner.quickMatchLibraryItem(libraryItem, options)
res.json(matchResult) res.json(matchResult)
} }
@ -309,18 +313,23 @@ class LibraryItemController {
const hardDelete = req.query.hard == 1 // Delete files from filesystem const hardDelete = req.query.hard == 1 // Delete files from filesystem
const { libraryItemIds } = req.body const { libraryItemIds } = req.body
if (!libraryItemIds || !libraryItemIds.length) { if (!libraryItemIds?.length) {
return res.sendStatus(500) return res.status(400).send('Invalid request body')
} }
const itemsToDelete = Database.libraryItems.filter(li => libraryItemIds.includes(li.id)) const itemsToDelete = await Database.libraryItemModel.getAllOldLibraryItems({
id: libraryItemIds
})
if (!itemsToDelete.length) { if (!itemsToDelete.length) {
return res.sendStatus(404) return res.sendStatus(404)
} }
for (let i = 0; i < itemsToDelete.length; i++) {
const libraryItemPath = itemsToDelete[i].path const libraryId = itemsToDelete[0].libraryId
Logger.info(`[LibraryItemController] Deleting Library Item "${itemsToDelete[i].media.metadata.title}"`) for (const libraryItem of itemsToDelete) {
await this.handleDeleteLibraryItem(itemsToDelete[i]) const libraryItemPath = libraryItem.path
Logger.info(`[LibraryItemController] Deleting Library Item "${libraryItem.media.metadata.title}"`)
await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, [libraryItem.media.id])
if (hardDelete) { if (hardDelete) {
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`) Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
await fs.remove(libraryItemPath).catch((error) => { await fs.remove(libraryItemPath).catch((error) => {
@ -328,28 +337,42 @@ class LibraryItemController {
}) })
} }
} }
await Database.resetLibraryIssuesFilterData(libraryId)
res.sendStatus(200) res.sendStatus(200)
} }
// POST: api/items/batch/update // POST: api/items/batch/update
async batchUpdate(req, res) { async batchUpdate(req, res) {
var updatePayloads = req.body const updatePayloads = req.body
if (!updatePayloads || !updatePayloads.length) { if (!updatePayloads?.length) {
return res.sendStatus(500) return res.sendStatus(500)
} }
var itemsUpdated = 0 let itemsUpdated = 0
for (let i = 0; i < updatePayloads.length; i++) { for (const updatePayload of updatePayloads) {
var mediaPayload = updatePayloads[i].mediaPayload const mediaPayload = updatePayload.mediaPayload
var libraryItem = Database.libraryItems.find(_li => _li.id === updatePayloads[i].id) const libraryItem = await Database.libraryItemModel.getOldById(updatePayload.id)
if (!libraryItem) return null if (!libraryItem) return null
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId) await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId)
var hasUpdates = libraryItem.media.update(mediaPayload) let seriesRemoved = []
if (hasUpdates) { if (libraryItem.isBook && mediaPayload.metadata?.series) {
const seriesIdsInUpdate = (mediaPayload.metadata?.series || []).map(se => se.id)
seriesRemoved = libraryItem.media.metadata.series.filter(se => !seriesIdsInUpdate.includes(se.id))
}
if (libraryItem.media.update(mediaPayload)) {
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`) Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
if (seriesRemoved.length) {
// Check remove empty series
Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`)
await this.checkRemoveEmptySeries(libraryItem.media.id, seriesRemoved.map(se => se.id))
}
await Database.updateLibraryItem(libraryItem) await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
itemsUpdated++ itemsUpdated++
@ -368,13 +391,11 @@ class LibraryItemController {
if (!libraryItemIds.length) { if (!libraryItemIds.length) {
return res.status(403).send('Invalid payload') return res.status(403).send('Invalid payload')
} }
const libraryItems = [] const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({
libraryItemIds.forEach((lid) => { id: libraryItemIds
const li = Database.libraryItems.find(_li => _li.id === lid)
if (li) libraryItems.push(li.toJSONExpanded())
}) })
res.json({ res.json({
libraryItems libraryItems: libraryItems.map(li => li.toJSONExpanded())
}) })
} }
@ -393,7 +414,9 @@ class LibraryItemController {
return res.sendStatus(400) return res.sendStatus(400)
} }
const libraryItems = req.body.libraryItemIds.map(lid => Database.getLibraryItem(lid)).filter(li => li) const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({
id: req.body.libraryItemIds
})
if (!libraryItems?.length) { if (!libraryItems?.length) {
return res.sendStatus(400) return res.sendStatus(400)
} }
@ -401,7 +424,7 @@ class LibraryItemController {
res.sendStatus(200) res.sendStatus(200)
for (const libraryItem of libraryItems) { for (const libraryItem of libraryItems) {
const matchResult = await this.scanner.quickMatchLibraryItem(libraryItem, options) const matchResult = await Scanner.quickMatchLibraryItem(libraryItem, options)
if (matchResult.updated) { if (matchResult.updated) {
itemsUpdated++ itemsUpdated++
} else if (matchResult.warning) { } else if (matchResult.warning) {
@ -428,23 +451,31 @@ class LibraryItemController {
return res.sendStatus(400) return res.sendStatus(400)
} }
const libraryItems = req.body.libraryItemIds.map(lid => Database.getLibraryItem(lid)).filter(li => li) const libraryItems = await Database.libraryItemModel.findAll({
where: {
id: req.body.libraryItemIds
},
attributes: ['id', 'libraryId', 'isFile']
})
if (!libraryItems?.length) { if (!libraryItems?.length) {
return res.sendStatus(400) return res.sendStatus(400)
} }
res.sendStatus(200) res.sendStatus(200)
const libraryId = libraryItems[0].libraryId
for (const libraryItem of libraryItems) { for (const libraryItem of libraryItems) {
if (libraryItem.isFile) { if (libraryItem.isFile) {
Logger.warn(`[LibraryItemController] Re-scanning file library items not yet supported`) Logger.warn(`[LibraryItemController] Re-scanning file library items not yet supported`)
} else { } else {
await this.scanner.scanLibraryItemByRequest(libraryItem) await LibraryItemScanner.scanLibraryItem(libraryItem.id)
} }
} }
await Database.resetLibraryIssuesFilterData(libraryId)
} }
// POST: api/items/:id/scan (admin) // POST: api/items/:id/scan
async scan(req, res) { async scan(req, res) {
if (!req.user.isAdminOrUp) { if (!req.user.isAdminOrUp) {
Logger.error(`[LibraryItemController] Non-admin user attempted to scan library item`, req.user) Logger.error(`[LibraryItemController] Non-admin user attempted to scan library item`, req.user)
@ -456,7 +487,8 @@ class LibraryItemController {
return res.sendStatus(500) return res.sendStatus(500)
} }
const result = await this.scanner.scanLibraryItemByRequest(req.libraryItem) const result = await LibraryItemScanner.scanLibraryItem(req.libraryItem.id)
await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId)
res.json({ res.json({
result: Object.keys(ScanResult).find(key => ScanResult[key] == result) result: Object.keys(ScanResult).find(key => ScanResult[key] == result)
}) })
@ -529,7 +561,7 @@ class LibraryItemController {
return res.sendStatus(404) return res.sendStatus(404)
} }
const ffprobeData = await this.scanner.probeAudioFile(audioFile) const ffprobeData = await AudioFileScanner.probeAudioFile(audioFile)
res.json(ffprobeData) res.json(ffprobeData)
} }
@ -680,7 +712,7 @@ class LibraryItemController {
} }
async middleware(req, res, next) { async middleware(req, res, next) {
req.libraryItem = await Database.models.libraryItem.getOldById(req.params.id) req.libraryItem = await Database.libraryItemModel.getOldById(req.params.id)
if (!req.libraryItem?.media) return res.sendStatus(404) if (!req.libraryItem?.media) return res.sendStatus(404)
// Check user can access this library item // Check user can access this library item

View File

@ -59,7 +59,7 @@ class MeController {
// PATCH: api/me/progress/:id // PATCH: api/me/progress/:id
async createUpdateMediaProgress(req, res) { async createUpdateMediaProgress(req, res) {
const libraryItem = Database.libraryItems.find(ab => ab.id === req.params.id) const libraryItem = await Database.libraryItemModel.getOldById(req.params.id)
if (!libraryItem) { if (!libraryItem) {
return res.status(404).send('Item not found') return res.status(404).send('Item not found')
} }
@ -75,7 +75,7 @@ class MeController {
// PATCH: api/me/progress/:id/:episodeId // PATCH: api/me/progress/:id/:episodeId
async createUpdateEpisodeMediaProgress(req, res) { async createUpdateEpisodeMediaProgress(req, res) {
const episodeId = req.params.episodeId const episodeId = req.params.episodeId
const libraryItem = Database.libraryItems.find(ab => ab.id === req.params.id) const libraryItem = await Database.libraryItemModel.getOldById(req.params.id)
if (!libraryItem) { if (!libraryItem) {
return res.status(404).send('Item not found') return res.status(404).send('Item not found')
} }
@ -101,7 +101,7 @@ class MeController {
let shouldUpdate = false let shouldUpdate = false
for (const itemProgress of itemProgressPayloads) { for (const itemProgress of itemProgressPayloads) {
const libraryItem = Database.libraryItems.find(li => li.id === itemProgress.libraryItemId) // Make sure this library item exists const libraryItem = await Database.libraryItemModel.getOldById(itemProgress.libraryItemId)
if (libraryItem) { if (libraryItem) {
if (req.user.createUpdateMediaProgress(libraryItem, itemProgress, itemProgress.episodeId)) { if (req.user.createUpdateMediaProgress(libraryItem, itemProgress, itemProgress.episodeId)) {
const mediaProgress = req.user.getMediaProgress(libraryItem.id, itemProgress.episodeId) const mediaProgress = req.user.getMediaProgress(libraryItem.id, itemProgress.episodeId)
@ -122,10 +122,10 @@ class MeController {
// POST: api/me/item/:id/bookmark // POST: api/me/item/:id/bookmark
async createBookmark(req, res) { async createBookmark(req, res) {
var libraryItem = Database.libraryItems.find(li => li.id === req.params.id) if (!await Database.libraryItemModel.checkExistsById(req.params.id)) return res.sendStatus(404)
if (!libraryItem) return res.sendStatus(404)
const { time, title } = req.body const { time, title } = req.body
var bookmark = req.user.createBookmark(libraryItem.id, time, title) const bookmark = req.user.createBookmark(req.params.id, time, title)
await Database.updateUser(req.user) await Database.updateUser(req.user)
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser()) SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
res.json(bookmark) res.json(bookmark)
@ -133,15 +133,17 @@ class MeController {
// PATCH: api/me/item/:id/bookmark // PATCH: api/me/item/:id/bookmark
async updateBookmark(req, res) { async updateBookmark(req, res) {
var libraryItem = Database.libraryItems.find(li => li.id === req.params.id) if (!await Database.libraryItemModel.checkExistsById(req.params.id)) return res.sendStatus(404)
if (!libraryItem) return res.sendStatus(404)
const { time, title } = req.body const { time, title } = req.body
if (!req.user.findBookmark(libraryItem.id, time)) { if (!req.user.findBookmark(req.params.id, time)) {
Logger.error(`[MeController] updateBookmark not found`) Logger.error(`[MeController] updateBookmark not found`)
return res.sendStatus(404) return res.sendStatus(404)
} }
var bookmark = req.user.updateBookmark(libraryItem.id, time, title)
const bookmark = req.user.updateBookmark(req.params.id, time, title)
if (!bookmark) return res.sendStatus(500) if (!bookmark) return res.sendStatus(500)
await Database.updateUser(req.user) await Database.updateUser(req.user)
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser()) SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
res.json(bookmark) res.json(bookmark)
@ -149,16 +151,17 @@ class MeController {
// DELETE: api/me/item/:id/bookmark/:time // DELETE: api/me/item/:id/bookmark/:time
async removeBookmark(req, res) { async removeBookmark(req, res) {
var libraryItem = Database.libraryItems.find(li => li.id === req.params.id) if (!await Database.libraryItemModel.checkExistsById(req.params.id)) return res.sendStatus(404)
if (!libraryItem) return res.sendStatus(404)
var time = Number(req.params.time) const time = Number(req.params.time)
if (isNaN(time)) return res.sendStatus(500) if (isNaN(time)) return res.sendStatus(500)
if (!req.user.findBookmark(libraryItem.id, time)) { if (!req.user.findBookmark(req.params.id, time)) {
Logger.error(`[MeController] removeBookmark not found`) Logger.error(`[MeController] removeBookmark not found`)
return res.sendStatus(404) return res.sendStatus(404)
} }
req.user.removeBookmark(libraryItem.id, time)
req.user.removeBookmark(req.params.id, time)
await Database.updateUser(req.user) await Database.updateUser(req.user)
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser()) SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
res.sendStatus(200) res.sendStatus(200)
@ -190,7 +193,8 @@ class MeController {
Logger.error(`[MeController] syncLocalMediaProgress invalid local media progress object`, localProgress) Logger.error(`[MeController] syncLocalMediaProgress invalid local media progress object`, localProgress)
continue continue
} }
const libraryItem = Database.getLibraryItem(localProgress.libraryItemId)
const libraryItem = await Database.libraryItemModel.getOldById(localProgress.libraryItemId)
if (!libraryItem) { if (!libraryItem) {
Logger.error(`[MeController] syncLocalMediaProgress invalid local media progress object no library item`, localProgress) Logger.error(`[MeController] syncLocalMediaProgress invalid local media progress object no library item`, localProgress)
continue continue
@ -242,13 +246,15 @@ class MeController {
} }
// GET: api/me/items-in-progress // GET: api/me/items-in-progress
getAllLibraryItemsInProgress(req, res) { async getAllLibraryItemsInProgress(req, res) {
const limit = !isNaN(req.query.limit) ? Number(req.query.limit) || 25 : 25 const limit = !isNaN(req.query.limit) ? Number(req.query.limit) || 25 : 25
let itemsInProgress = [] let itemsInProgress = []
// TODO: More efficient to do this in a single query
for (const mediaProgress of req.user.mediaProgress) { for (const mediaProgress of req.user.mediaProgress) {
if (!mediaProgress.isFinished && (mediaProgress.progress > 0 || mediaProgress.ebookProgress > 0)) { if (!mediaProgress.isFinished && (mediaProgress.progress > 0 || mediaProgress.ebookProgress > 0)) {
const libraryItem = Database.getLibraryItem(mediaProgress.libraryItemId)
const libraryItem = await Database.libraryItemModel.getOldById(mediaProgress.libraryItemId)
if (libraryItem) { if (libraryItem) {
if (mediaProgress.episodeId && libraryItem.mediaType === 'podcast') { if (mediaProgress.episodeId && libraryItem.mediaType === 'podcast') {
const episode = libraryItem.media.episodes.find(ep => ep.id === mediaProgress.episodeId) const episode = libraryItem.media.episodes.find(ep => ep.id === mediaProgress.episodeId)
@ -278,7 +284,7 @@ class MeController {
// GET: api/me/series/:id/remove-from-continue-listening // GET: api/me/series/:id/remove-from-continue-listening
async removeSeriesFromContinueListening(req, res) { async removeSeriesFromContinueListening(req, res) {
const series = Database.series.find(se => se.id === req.params.id) const series = await Database.seriesModel.getOldById(req.params.id)
if (!series) { if (!series) {
Logger.error(`[MeController] removeSeriesFromContinueListening: Series ${req.params.id} not found`) Logger.error(`[MeController] removeSeriesFromContinueListening: Series ${req.params.id} not found`)
return res.sendStatus(404) return res.sendStatus(404)
@ -294,7 +300,7 @@ class MeController {
// GET: api/me/series/:id/readd-to-continue-listening // GET: api/me/series/:id/readd-to-continue-listening
async readdSeriesFromContinueListening(req, res) { async readdSeriesFromContinueListening(req, res) {
const series = Database.series.find(se => se.id === req.params.id) const series = await Database.seriesModel.getOldById(req.params.id)
if (!series) { if (!series) {
Logger.error(`[MeController] readdSeriesFromContinueListening: Series ${req.params.id} not found`) Logger.error(`[MeController] readdSeriesFromContinueListening: Series ${req.params.id} not found`)
return res.sendStatus(404) return res.sendStatus(404)
@ -310,9 +316,19 @@ class MeController {
// GET: api/me/progress/:id/remove-from-continue-listening // GET: api/me/progress/:id/remove-from-continue-listening
async removeItemFromContinueListening(req, res) { async removeItemFromContinueListening(req, res) {
const mediaProgress = req.user.mediaProgress.find(mp => mp.id === req.params.id)
if (!mediaProgress) {
return res.sendStatus(404)
}
const hasUpdated = req.user.removeProgressFromContinueListening(req.params.id) const hasUpdated = req.user.removeProgressFromContinueListening(req.params.id)
if (hasUpdated) { if (hasUpdated) {
await Database.updateUser(req.user) await Database.mediaProgressModel.update({
hideFromContinueListening: true
}, {
where: {
id: mediaProgress.id
}
})
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser()) SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
} }
res.json(req.user.toJSONForBrowser()) res.json(req.user.toJSONForBrowser())

View File

@ -1,12 +1,13 @@
const Sequelize = require('sequelize')
const Path = require('path') const Path = require('path')
const fs = require('../libs/fsExtra') const fs = require('../libs/fsExtra')
const Logger = require('../Logger') const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database') const Database = require('../Database')
const filePerms = require('../utils/filePerms') const libraryItemFilters = require('../utils/queries/libraryItemFilters')
const patternValidation = require('../libs/nodeCron/pattern-validation') const patternValidation = require('../libs/nodeCron/pattern-validation')
const { isObject } = require('../utils/index') const { isObject, getTitleIgnorePrefix } = require('../utils/index')
// //
// This is a controller for routes that don't have a home yet :( // This is a controller for routes that don't have a home yet :(
@ -14,7 +15,12 @@ const { isObject } = require('../utils/index')
class MiscController { class MiscController {
constructor() { } constructor() { }
// POST: api/upload /**
* POST: /api/upload
* Update library item
* @param {*} req
* @param {*} res
*/
async handleUpload(req, res) { async handleUpload(req, res) {
if (!req.user.canUpload) { if (!req.user.canUpload) {
Logger.warn('User attempted to upload without permission', req.user) Logger.warn('User attempted to upload without permission', req.user)
@ -31,7 +37,7 @@ class MiscController {
const libraryId = req.body.library const libraryId = req.body.library
const folderId = req.body.folder const folderId = req.body.folder
const library = await Database.models.library.getOldById(libraryId) const library = await Database.libraryModel.getOldById(libraryId)
if (!library) { if (!library) {
return res.status(404).send(`Library not found with id ${libraryId}`) return res.status(404).send(`Library not found with id ${libraryId}`)
} }
@ -83,12 +89,15 @@ class MiscController {
}) })
} }
await filePerms.setDefault(firstDirPath)
res.sendStatus(200) res.sendStatus(200)
} }
// GET: api/tasks /**
* GET: /api/tasks
* Get tasks for task manager
* @param {*} req
* @param {*} res
*/
getTasks(req, res) { getTasks(req, res) {
const includeArray = (req.query.include || '').split(',') const includeArray = (req.query.include || '').split(',')
@ -105,7 +114,12 @@ class MiscController {
res.json(data) res.json(data)
} }
// PATCH: api/settings (admin) /**
* PATCH: /api/settings
* Update server settings
* @param {*} req
* @param {*} res
*/
async updateServerSettings(req, res) { async updateServerSettings(req, res) {
if (!req.user.isAdminOrUp) { if (!req.user.isAdminOrUp) {
Logger.error('User other than admin attempting to update server settings', req.user) Logger.error('User other than admin attempting to update server settings', req.user)
@ -113,7 +127,7 @@ class MiscController {
} }
const settingsUpdate = req.body const settingsUpdate = req.body
if (!settingsUpdate || !isObject(settingsUpdate)) { if (!settingsUpdate || !isObject(settingsUpdate)) {
return res.status(500).send('Invalid settings update object') return res.status(400).send('Invalid settings update object')
} }
const madeUpdates = Database.serverSettings.update(settingsUpdate) const madeUpdates = Database.serverSettings.update(settingsUpdate)
@ -131,6 +145,103 @@ class MiscController {
}) })
} }
/**
* PATCH: /api/sorting-prefixes
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async updateSortingPrefixes(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error('User other than admin attempting to update server sorting prefixes', req.user)
return res.sendStatus(403)
}
let sortingPrefixes = req.body.sortingPrefixes
if (!sortingPrefixes?.length || !Array.isArray(sortingPrefixes)) {
return res.status(400).send('Invalid request body')
}
sortingPrefixes = [...new Set(sortingPrefixes.map(p => p?.trim?.().toLowerCase()).filter(p => p))]
if (!sortingPrefixes.length) {
return res.status(400).send('Invalid sortingPrefixes in request body')
}
Logger.debug(`[MiscController] Updating sorting prefixes ${sortingPrefixes.join(', ')}`)
Database.serverSettings.sortingPrefixes = sortingPrefixes
await Database.updateServerSettings()
let rowsUpdated = 0
// Update titleIgnorePrefix column on books
const books = await Database.bookModel.findAll({
attributes: ['id', 'title', 'titleIgnorePrefix']
})
const bulkUpdateBooks = []
books.forEach((book) => {
const titleIgnorePrefix = getTitleIgnorePrefix(book.title)
if (titleIgnorePrefix !== book.titleIgnorePrefix) {
bulkUpdateBooks.push({
id: book.id,
titleIgnorePrefix
})
}
})
if (bulkUpdateBooks.length) {
Logger.info(`[MiscController] Updating titleIgnorePrefix on ${bulkUpdateBooks.length} books`)
rowsUpdated += bulkUpdateBooks.length
await Database.bookModel.bulkCreate(bulkUpdateBooks, {
updateOnDuplicate: ['titleIgnorePrefix']
})
}
// Update titleIgnorePrefix column on podcasts
const podcasts = await Database.podcastModel.findAll({
attributes: ['id', 'title', 'titleIgnorePrefix']
})
const bulkUpdatePodcasts = []
podcasts.forEach((podcast) => {
const titleIgnorePrefix = getTitleIgnorePrefix(podcast.title)
if (titleIgnorePrefix !== podcast.titleIgnorePrefix) {
bulkUpdatePodcasts.push({
id: podcast.id,
titleIgnorePrefix
})
}
})
if (bulkUpdatePodcasts.length) {
Logger.info(`[MiscController] Updating titleIgnorePrefix on ${bulkUpdatePodcasts.length} podcasts`)
rowsUpdated += bulkUpdatePodcasts.length
await Database.podcastModel.bulkCreate(bulkUpdatePodcasts, {
updateOnDuplicate: ['titleIgnorePrefix']
})
}
// Update nameIgnorePrefix column on series
const allSeries = await Database.seriesModel.findAll({
attributes: ['id', 'name', 'nameIgnorePrefix']
})
const bulkUpdateSeries = []
allSeries.forEach((series) => {
const nameIgnorePrefix = getTitleIgnorePrefix(series.name)
if (nameIgnorePrefix !== series.nameIgnorePrefix) {
bulkUpdateSeries.push({
id: series.id,
nameIgnorePrefix
})
}
})
if (bulkUpdateSeries.length) {
Logger.info(`[MiscController] Updating nameIgnorePrefix on ${bulkUpdateSeries.length} series`)
rowsUpdated += bulkUpdateSeries.length
await Database.seriesModel.bulkCreate(bulkUpdateSeries, {
updateOnDuplicate: ['nameIgnorePrefix']
})
}
res.json({
rowsUpdated,
serverSettings: Database.serverSettings.toJSONForBrowser()
})
}
/** /**
* POST: /api/authorize * POST: /api/authorize
* Used to authorize an API token * Used to authorize an API token
@ -147,26 +258,55 @@ class MiscController {
res.json(userResponse) res.json(userResponse)
} }
// GET: api/tags /**
getAllTags(req, res) { * GET: /api/tags
* Get all tags
* @param {*} req
* @param {*} res
*/
async getAllTags(req, res) {
if (!req.user.isAdminOrUp) { if (!req.user.isAdminOrUp) {
Logger.error(`[MiscController] Non-admin user attempted to getAllTags`) Logger.error(`[MiscController] Non-admin user attempted to getAllTags`)
return res.sendStatus(404) return res.sendStatus(404)
} }
const tags = [] const tags = []
Database.libraryItems.forEach((li) => { const books = await Database.bookModel.findAll({
if (li.media.tags && li.media.tags.length) { attributes: ['tags'],
li.media.tags.forEach((tag) => { where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('tags')), {
if (!tags.includes(tag)) tags.push(tag) [Sequelize.Op.gt]: 0
}) })
}
}) })
for (const book of books) {
for (const tag of book.tags) {
if (!tags.includes(tag)) tags.push(tag)
}
}
const podcasts = await Database.podcastModel.findAll({
attributes: ['tags'],
where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('tags')), {
[Sequelize.Op.gt]: 0
})
})
for (const podcast of podcasts) {
for (const tag of podcast.tags) {
if (!tags.includes(tag)) tags.push(tag)
}
}
res.json({ res.json({
tags: tags tags: tags
}) })
} }
// POST: api/tags/rename /**
* POST: /api/tags/rename
* Rename tag
* Req.body { tag, newTag }
* @param {*} req
* @param {*} res
*/
async renameTag(req, res) { async renameTag(req, res) {
if (!req.user.isAdminOrUp) { if (!req.user.isAdminOrUp) {
Logger.error(`[MiscController] Non-admin user attempted to renameTag`) Logger.error(`[MiscController] Non-admin user attempted to renameTag`)
@ -183,19 +323,26 @@ class MiscController {
let tagMerged = false let tagMerged = false
let numItemsUpdated = 0 let numItemsUpdated = 0
for (const li of Database.libraryItems) { // Update filter data
if (!li.media.tags || !li.media.tags.length) continue Database.replaceTagInFilterData(tag, newTag)
if (li.media.tags.includes(newTag)) tagMerged = true // new tag is an existing tag so this is a merge const libraryItemsWithTag = await libraryItemFilters.getAllLibraryItemsWithTags([tag, newTag])
for (const libraryItem of libraryItemsWithTag) {
if (libraryItem.media.tags.includes(newTag)) {
tagMerged = true // new tag is an existing tag so this is a merge
}
if (li.media.tags.includes(tag)) { if (libraryItem.media.tags.includes(tag)) {
li.media.tags = li.media.tags.filter(t => t !== tag) // Remove old tag libraryItem.media.tags = libraryItem.media.tags.filter(t => t !== tag) // Remove old tag
if (!li.media.tags.includes(newTag)) { if (!libraryItem.media.tags.includes(newTag)) {
li.media.tags.push(newTag) // Add new tag libraryItem.media.tags.push(newTag)
} }
Logger.debug(`[MiscController] Rename tag "${tag}" to "${newTag}" for item "${li.media.metadata.title}"`) Logger.debug(`[MiscController] Rename tag "${tag}" to "${newTag}" for item "${libraryItem.media.title}"`)
await Database.updateLibraryItem(li) await libraryItem.media.update({
SocketAuthority.emitter('item_updated', li.toJSONExpanded()) tags: libraryItem.media.tags
})
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
numItemsUpdated++ numItemsUpdated++
} }
} }
@ -206,7 +353,13 @@ class MiscController {
}) })
} }
// DELETE: api/tags/:tag /**
* DELETE: /api/tags/:tag
* Remove a tag
* :tag param is base64 encoded
* @param {*} req
* @param {*} res
*/
async deleteTag(req, res) { async deleteTag(req, res) {
if (!req.user.isAdminOrUp) { if (!req.user.isAdminOrUp) {
Logger.error(`[MiscController] Non-admin user attempted to deleteTag`) Logger.error(`[MiscController] Non-admin user attempted to deleteTag`)
@ -215,17 +368,23 @@ class MiscController {
const tag = Buffer.from(decodeURIComponent(req.params.tag), 'base64').toString() const tag = Buffer.from(decodeURIComponent(req.params.tag), 'base64').toString()
let numItemsUpdated = 0 // Get all items with tag
for (const li of Database.libraryItems) { const libraryItemsWithTag = await libraryItemFilters.getAllLibraryItemsWithTags([tag])
if (!li.media.tags || !li.media.tags.length) continue
if (li.media.tags.includes(tag)) { // Update filterdata
li.media.tags = li.media.tags.filter(t => t !== tag) Database.removeTagFromFilterData(tag)
Logger.debug(`[MiscController] Remove tag "${tag}" from item "${li.media.metadata.title}"`)
await Database.updateLibraryItem(li) let numItemsUpdated = 0
SocketAuthority.emitter('item_updated', li.toJSONExpanded()) // Remove tag from items
numItemsUpdated++ for (const libraryItem of libraryItemsWithTag) {
} Logger.debug(`[MiscController] Remove tag "${tag}" from item "${libraryItem.media.title}"`)
libraryItem.media.tags = libraryItem.media.tags.filter(t => t !== tag)
await libraryItem.media.update({
tags: libraryItem.media.tags
})
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
numItemsUpdated++
} }
res.json({ res.json({
@ -233,26 +392,54 @@ class MiscController {
}) })
} }
// GET: api/genres /**
getAllGenres(req, res) { * GET: /api/genres
* Get all genres
* @param {*} req
* @param {*} res
*/
async getAllGenres(req, res) {
if (!req.user.isAdminOrUp) { if (!req.user.isAdminOrUp) {
Logger.error(`[MiscController] Non-admin user attempted to getAllGenres`) Logger.error(`[MiscController] Non-admin user attempted to getAllGenres`)
return res.sendStatus(404) return res.sendStatus(404)
} }
const genres = [] const genres = []
Database.libraryItems.forEach((li) => { const books = await Database.bookModel.findAll({
if (li.media.metadata.genres && li.media.metadata.genres.length) { attributes: ['genres'],
li.media.metadata.genres.forEach((genre) => { where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('genres')), {
if (!genres.includes(genre)) genres.push(genre) [Sequelize.Op.gt]: 0
}) })
}
}) })
for (const book of books) {
for (const tag of book.genres) {
if (!genres.includes(tag)) genres.push(tag)
}
}
const podcasts = await Database.podcastModel.findAll({
attributes: ['genres'],
where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('genres')), {
[Sequelize.Op.gt]: 0
})
})
for (const podcast of podcasts) {
for (const tag of podcast.genres) {
if (!genres.includes(tag)) genres.push(tag)
}
}
res.json({ res.json({
genres genres
}) })
} }
// POST: api/genres/rename /**
* POST: /api/genres/rename
* Rename genres
* Req.body { genre, newGenre }
* @param {*} req
* @param {*} res
*/
async renameGenre(req, res) { async renameGenre(req, res) {
if (!req.user.isAdminOrUp) { if (!req.user.isAdminOrUp) {
Logger.error(`[MiscController] Non-admin user attempted to renameGenre`) Logger.error(`[MiscController] Non-admin user attempted to renameGenre`)
@ -269,19 +456,26 @@ class MiscController {
let genreMerged = false let genreMerged = false
let numItemsUpdated = 0 let numItemsUpdated = 0
for (const li of Database.libraryItems) { // Update filter data
if (!li.media.metadata.genres || !li.media.metadata.genres.length) continue Database.replaceGenreInFilterData(genre, newGenre)
if (li.media.metadata.genres.includes(newGenre)) genreMerged = true // new genre is an existing genre so this is a merge const libraryItemsWithGenre = await libraryItemFilters.getAllLibraryItemsWithGenres([genre, newGenre])
for (const libraryItem of libraryItemsWithGenre) {
if (libraryItem.media.genres.includes(newGenre)) {
genreMerged = true // new genre is an existing genre so this is a merge
}
if (li.media.metadata.genres.includes(genre)) { if (libraryItem.media.genres.includes(genre)) {
li.media.metadata.genres = li.media.metadata.genres.filter(g => g !== genre) // Remove old genre libraryItem.media.genres = libraryItem.media.genres.filter(t => t !== genre) // Remove old genre
if (!li.media.metadata.genres.includes(newGenre)) { if (!libraryItem.media.genres.includes(newGenre)) {
li.media.metadata.genres.push(newGenre) // Add new genre libraryItem.media.genres.push(newGenre)
} }
Logger.debug(`[MiscController] Rename genre "${genre}" to "${newGenre}" for item "${li.media.metadata.title}"`) Logger.debug(`[MiscController] Rename genre "${genre}" to "${newGenre}" for item "${libraryItem.media.title}"`)
await Database.updateLibraryItem(li) await libraryItem.media.update({
SocketAuthority.emitter('item_updated', li.toJSONExpanded()) genres: libraryItem.media.genres
})
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
numItemsUpdated++ numItemsUpdated++
} }
} }
@ -292,7 +486,13 @@ class MiscController {
}) })
} }
// DELETE: api/genres/:genre /**
* DELETE: /api/genres/:genre
* Remove a genre
* :genre param is base64 encoded
* @param {*} req
* @param {*} res
*/
async deleteGenre(req, res) { async deleteGenre(req, res) {
if (!req.user.isAdminOrUp) { if (!req.user.isAdminOrUp) {
Logger.error(`[MiscController] Non-admin user attempted to deleteGenre`) Logger.error(`[MiscController] Non-admin user attempted to deleteGenre`)
@ -301,17 +501,23 @@ class MiscController {
const genre = Buffer.from(decodeURIComponent(req.params.genre), 'base64').toString() const genre = Buffer.from(decodeURIComponent(req.params.genre), 'base64').toString()
let numItemsUpdated = 0 // Update filter data
for (const li of Database.libraryItems) { Database.removeGenreFromFilterData(genre)
if (!li.media.metadata.genres || !li.media.metadata.genres.length) continue
if (li.media.metadata.genres.includes(genre)) { // Get all items with genre
li.media.metadata.genres = li.media.metadata.genres.filter(t => t !== genre) const libraryItemsWithGenre = await libraryItemFilters.getAllLibraryItemsWithGenres([genre])
Logger.debug(`[MiscController] Remove genre "${genre}" from item "${li.media.metadata.title}"`)
await Database.updateLibraryItem(li) let numItemsUpdated = 0
SocketAuthority.emitter('item_updated', li.toJSONExpanded()) // Remove genre from items
numItemsUpdated++ for (const libraryItem of libraryItemsWithGenre) {
} Logger.debug(`[MiscController] Remove genre "${genre}" from item "${libraryItem.media.title}"`)
libraryItem.media.genres = libraryItem.media.genres.filter(g => g !== genre)
await libraryItem.media.update({
genres: libraryItem.media.genres
})
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
numItemsUpdated++
} }
res.json({ res.json({

View File

@ -7,71 +7,187 @@ const Playlist = require('../objects/Playlist')
class PlaylistController { class PlaylistController {
constructor() { } constructor() { }
// POST: api/playlists /**
* POST: /api/playlists
* Create playlist
* @param {*} req
* @param {*} res
*/
async create(req, res) { async create(req, res) {
const newPlaylist = new Playlist() const oldPlaylist = new Playlist()
req.body.userId = req.user.id req.body.userId = req.user.id
const success = newPlaylist.setData(req.body) const success = oldPlaylist.setData(req.body)
if (!success) { if (!success) {
return res.status(400).send('Invalid playlist request data') return res.status(400).send('Invalid playlist request data')
} }
const jsonExpanded = newPlaylist.toJSONExpanded(Database.libraryItems)
await Database.createPlaylist(newPlaylist) // Create Playlist record
const newPlaylist = await Database.playlistModel.createFromOld(oldPlaylist)
// Lookup all library items in playlist
const libraryItemIds = oldPlaylist.items.map(i => i.libraryItemId).filter(i => i)
const libraryItemsInPlaylist = await Database.libraryItemModel.findAll({
where: {
id: libraryItemIds
}
})
// Create playlistMediaItem records
const mediaItemsToAdd = []
let order = 1
for (const mediaItemObj of oldPlaylist.items) {
const libraryItem = libraryItemsInPlaylist.find(li => li.id === mediaItemObj.libraryItemId)
if (!libraryItem) continue
mediaItemsToAdd.push({
mediaItemId: mediaItemObj.episodeId || libraryItem.mediaId,
mediaItemType: mediaItemObj.episodeId ? 'podcastEpisode' : 'book',
playlistId: oldPlaylist.id,
order: order++
})
}
if (mediaItemsToAdd.length) {
await Database.createBulkPlaylistMediaItems(mediaItemsToAdd)
}
const jsonExpanded = await newPlaylist.getOldJsonExpanded()
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded) SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
res.json(jsonExpanded) res.json(jsonExpanded)
} }
// GET: api/playlists /**
* GET: /api/playlists
* Get all playlists for user
* @param {*} req
* @param {*} res
*/
async findAllForUser(req, res) { async findAllForUser(req, res) {
const playlistsForUser = await Database.models.playlist.getPlaylistsForUserAndLibrary(req.user.id) const playlistsForUser = await Database.playlistModel.findAll({
where: {
userId: req.user.id
}
})
const playlists = []
for (const playlist of playlistsForUser) {
const jsonExpanded = await playlist.getOldJsonExpanded()
playlists.push(jsonExpanded)
}
res.json({ res.json({
playlists: playlistsForUser.map(p => p.toJSONExpanded(Database.libraryItems)) playlists
}) })
} }
// GET: api/playlists/:id /**
findOne(req, res) { * GET: /api/playlists/:id
res.json(req.playlist.toJSONExpanded(Database.libraryItems)) * @param {*} req
* @param {*} res
*/
async findOne(req, res) {
const jsonExpanded = await req.playlist.getOldJsonExpanded()
res.json(jsonExpanded)
} }
// PATCH: api/playlists/:id /**
* PATCH: /api/playlists/:id
* Update playlist
* @param {*} req
* @param {*} res
*/
async update(req, res) { async update(req, res) {
const playlist = req.playlist const updatedPlaylist = req.playlist.set(req.body)
let wasUpdated = playlist.update(req.body) let wasUpdated = false
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems) const changed = updatedPlaylist.changed()
if (changed?.length) {
await req.playlist.save()
Logger.debug(`[PlaylistController] Updated playlist ${req.playlist.id} keys [${changed.join(',')}]`)
wasUpdated = true
}
// If array of items is passed in then update order of playlist media items
const libraryItemIds = req.body.items?.map(i => i.libraryItemId).filter(i => i) || []
if (libraryItemIds.length) {
const libraryItems = await Database.libraryItemModel.findAll({
where: {
id: libraryItemIds
}
})
const existingPlaylistMediaItems = await updatedPlaylist.getPlaylistMediaItems({
order: [['order', 'ASC']]
})
// Set an array of mediaItemId
const newMediaItemIdOrder = []
for (const item of req.body.items) {
const libraryItem = libraryItems.find(li => li.id === item.libraryItemId)
if (!libraryItem) {
continue
}
const mediaItemId = item.episodeId || libraryItem.mediaId
newMediaItemIdOrder.push(mediaItemId)
}
// Sort existing playlist media items into new order
existingPlaylistMediaItems.sort((a, b) => {
const aIndex = newMediaItemIdOrder.findIndex(i => i === a.mediaItemId)
const bIndex = newMediaItemIdOrder.findIndex(i => i === b.mediaItemId)
return aIndex - bIndex
})
// Update order on playlistMediaItem records
let order = 1
for (const playlistMediaItem of existingPlaylistMediaItems) {
if (playlistMediaItem.order !== order) {
await playlistMediaItem.update({
order
})
wasUpdated = true
}
order++
}
}
const jsonExpanded = await updatedPlaylist.getOldJsonExpanded()
if (wasUpdated) { if (wasUpdated) {
await Database.updatePlaylist(playlist) SocketAuthority.clientEmitter(updatedPlaylist.userId, 'playlist_updated', jsonExpanded)
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
} }
res.json(jsonExpanded) res.json(jsonExpanded)
} }
// DELETE: api/playlists/:id /**
* DELETE: /api/playlists/:id
* Remove playlist
* @param {*} req
* @param {*} res
*/
async delete(req, res) { async delete(req, res) {
const playlist = req.playlist const jsonExpanded = await req.playlist.getOldJsonExpanded()
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems) await req.playlist.destroy()
await Database.removePlaylist(playlist.id) SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
res.sendStatus(200) res.sendStatus(200)
} }
// POST: api/playlists/:id/item /**
* POST: /api/playlists/:id/item
* Add item to playlist
* @param {*} req
* @param {*} res
*/
async addItem(req, res) { async addItem(req, res) {
const playlist = req.playlist const oldPlaylist = await Database.playlistModel.getById(req.playlist.id)
const itemToAdd = req.body const itemToAdd = req.body
if (!itemToAdd.libraryItemId) { if (!itemToAdd.libraryItemId) {
return res.status(400).send('Request body has no libraryItemId') return res.status(400).send('Request body has no libraryItemId')
} }
const libraryItem = Database.libraryItems.find(li => li.id === itemToAdd.libraryItemId) const libraryItem = await Database.libraryItemModel.getOldById(itemToAdd.libraryItemId)
if (!libraryItem) { if (!libraryItem) {
return res.status(400).send('Library item not found') return res.status(400).send('Library item not found')
} }
if (libraryItem.libraryId !== playlist.libraryId) { if (libraryItem.libraryId !== oldPlaylist.libraryId) {
return res.status(400).send('Library item in different library') return res.status(400).send('Library item in different library')
} }
if (playlist.containsItem(itemToAdd)) { if (oldPlaylist.containsItem(itemToAdd)) {
return res.status(400).send('Item already in playlist') return res.status(400).send('Item already in playlist')
} }
if ((itemToAdd.episodeId && !libraryItem.isPodcast) || (libraryItem.isPodcast && !itemToAdd.episodeId)) { if ((itemToAdd.episodeId && !libraryItem.isPodcast) || (libraryItem.isPodcast && !itemToAdd.episodeId)) {
@ -81,160 +197,248 @@ class PlaylistController {
return res.status(400).send('Episode not found in library item') return res.status(400).send('Episode not found in library item')
} }
playlist.addItem(itemToAdd.libraryItemId, itemToAdd.episodeId)
const playlistMediaItem = { const playlistMediaItem = {
playlistId: playlist.id, playlistId: oldPlaylist.id,
mediaItemId: itemToAdd.episodeId || libraryItem.media.id, mediaItemId: itemToAdd.episodeId || libraryItem.media.id,
mediaItemType: itemToAdd.episodeId ? 'podcastEpisode' : 'book', mediaItemType: itemToAdd.episodeId ? 'podcastEpisode' : 'book',
order: playlist.items.length order: oldPlaylist.items.length + 1
} }
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
await Database.createPlaylistMediaItem(playlistMediaItem) await Database.createPlaylistMediaItem(playlistMediaItem)
const jsonExpanded = await req.playlist.getOldJsonExpanded()
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded) SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
res.json(jsonExpanded) res.json(jsonExpanded)
} }
// DELETE: api/playlists/:id/item/:libraryItemId/:episodeId? /**
* DELETE: /api/playlists/:id/item/:libraryItemId/:episodeId?
* Remove item from playlist
* @param {*} req
* @param {*} res
*/
async removeItem(req, res) { async removeItem(req, res) {
const playlist = req.playlist const oldLibraryItem = await Database.libraryItemModel.getOldById(req.params.libraryItemId)
const itemToRemove = { if (!oldLibraryItem) {
libraryItemId: req.params.libraryItemId, return res.status(404).send('Library item not found')
episodeId: req.params.episodeId || null
}
if (!playlist.containsItem(itemToRemove)) {
return res.sendStatus(404)
} }
playlist.removeItem(itemToRemove.libraryItemId, itemToRemove.episodeId) // Get playlist media items
const mediaItemId = req.params.episodeId || oldLibraryItem.media.id
const playlistMediaItems = await req.playlist.getPlaylistMediaItems({
order: [['order', 'ASC']]
})
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems) // Check if media item to delete is in playlist
const mediaItemToRemove = playlistMediaItems.find(pmi => pmi.mediaItemId === mediaItemId)
if (!mediaItemToRemove) {
return res.status(404).send('Media item not found in playlist')
}
// Remove record
await mediaItemToRemove.destroy()
// Update playlist media items order
let order = 1
for (const mediaItem of playlistMediaItems) {
if (mediaItem.mediaItemId === mediaItemId) continue
if (mediaItem.order !== order) {
await mediaItem.update({
order
})
}
order++
}
const jsonExpanded = await req.playlist.getOldJsonExpanded()
// Playlist is removed when there are no items // Playlist is removed when there are no items
if (!playlist.items.length) { if (!jsonExpanded.items.length) {
Logger.info(`[PlaylistController] Playlist "${playlist.name}" has no more items - removing it`) Logger.info(`[PlaylistController] Playlist "${jsonExpanded.name}" has no more items - removing it`)
await Database.removePlaylist(playlist.id) await req.playlist.destroy()
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded) SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
} else { } else {
await Database.updatePlaylist(playlist) SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_updated', jsonExpanded)
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
} }
res.json(jsonExpanded) res.json(jsonExpanded)
} }
// POST: api/playlists/:id/batch/add /**
* POST: /api/playlists/:id/batch/add
* Batch add playlist items
* @param {*} req
* @param {*} res
*/
async addBatch(req, res) { async addBatch(req, res) {
const playlist = req.playlist if (!req.body.items?.length) {
if (!req.body.items || !req.body.items.length) { return res.status(400).send('Invalid request body')
return res.status(500).send('Invalid request body')
} }
const itemsToAdd = req.body.items const itemsToAdd = req.body.items
let hasUpdated = false
let order = playlist.items.length const libraryItemIds = itemsToAdd.map(i => i.libraryItemId).filter(i => i)
const playlistMediaItems = [] if (!libraryItemIds.length) {
return res.status(400).send('Invalid request body')
}
// Find all library items
const libraryItems = await Database.libraryItemModel.findAll({
where: {
id: libraryItemIds
}
})
// Get all existing playlist media items
const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({
order: [['order', 'ASC']]
})
const mediaItemsToAdd = []
// Setup array of playlistMediaItem records to add
let order = existingPlaylistMediaItems.length + 1
for (const item of itemsToAdd) { for (const item of itemsToAdd) {
if (!item.libraryItemId) { const libraryItem = libraryItems.find(li => li.id === item.libraryItemId)
return res.status(400).send('Item does not have libraryItemId')
}
const libraryItem = Database.getLibraryItem(item.libraryItemId)
if (!libraryItem) { if (!libraryItem) {
return res.status(400).send('Item not found with id ' + item.libraryItemId) return res.status(404).send('Item not found with id ' + item.libraryItemId)
} } else {
const mediaItemId = item.episodeId || libraryItem.mediaId
if (!playlist.containsItem(item)) { if (existingPlaylistMediaItems.some(pmi => pmi.mediaItemId === mediaItemId)) {
playlistMediaItems.push({ // Already exists in playlist
playlistId: playlist.id, continue
mediaItemId: item.episodeId || libraryItem.media.id, // podcastEpisodeId or bookId } else {
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book', mediaItemsToAdd.push({
order: order++ playlistId: req.playlist.id,
}) mediaItemId,
playlist.addItem(item.libraryItemId, item.episodeId) mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
hasUpdated = true order: order++
})
}
} }
} }
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems) let jsonExpanded = null
if (hasUpdated) { if (mediaItemsToAdd.length) {
await Database.createBulkPlaylistMediaItems(playlistMediaItems) await Database.createBulkPlaylistMediaItems(mediaItemsToAdd)
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded) jsonExpanded = await req.playlist.getOldJsonExpanded()
SocketAuthority.clientEmitter(req.playlist.userId, 'playlist_updated', jsonExpanded)
} else {
jsonExpanded = await req.playlist.getOldJsonExpanded()
} }
res.json(jsonExpanded) res.json(jsonExpanded)
} }
// POST: api/playlists/:id/batch/remove /**
* POST: /api/playlists/:id/batch/remove
* Batch remove playlist items
* @param {*} req
* @param {*} res
*/
async removeBatch(req, res) { async removeBatch(req, res) {
const playlist = req.playlist if (!req.body.items?.length) {
if (!req.body.items || !req.body.items.length) { return res.status(400).send('Invalid request body')
return res.status(500).send('Invalid request body')
} }
const itemsToRemove = req.body.items const itemsToRemove = req.body.items
const libraryItemIds = itemsToRemove.map(i => i.libraryItemId).filter(i => i)
if (!libraryItemIds.length) {
return res.status(400).send('Invalid request body')
}
// Find all library items
const libraryItems = await Database.libraryItemModel.findAll({
where: {
id: libraryItemIds
}
})
// Get all existing playlist media items for playlist
const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({
order: [['order', 'ASC']]
})
let numMediaItems = existingPlaylistMediaItems.length
// Remove playlist media items
let hasUpdated = false let hasUpdated = false
for (const item of itemsToRemove) { for (const item of itemsToRemove) {
if (!item.libraryItemId) { const libraryItem = libraryItems.find(li => li.id === item.libraryItemId)
return res.status(400).send('Item does not have libraryItemId') if (!libraryItem) continue
} const mediaItemId = item.episodeId || libraryItem.mediaId
const existingMediaItem = existingPlaylistMediaItems.find(pmi => pmi.mediaItemId === mediaItemId)
if (playlist.containsItem(item)) { if (!existingMediaItem) continue
playlist.removeItem(item.libraryItemId, item.episodeId) await existingMediaItem.destroy()
hasUpdated = true hasUpdated = true
} numMediaItems--
} }
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems) const jsonExpanded = await req.playlist.getOldJsonExpanded()
if (hasUpdated) { if (hasUpdated) {
// Playlist is removed when there are no items // Playlist is removed when there are no items
if (!playlist.items.length) { if (!numMediaItems) {
Logger.info(`[PlaylistController] Playlist "${playlist.name}" has no more items - removing it`) Logger.info(`[PlaylistController] Playlist "${req.playlist.name}" has no more items - removing it`)
await Database.removePlaylist(playlist.id) await req.playlist.destroy()
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded) SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
} else { } else {
await Database.updatePlaylist(playlist)
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded) SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
} }
} }
res.json(jsonExpanded) res.json(jsonExpanded)
} }
// POST: api/playlists/collection/:collectionId /**
* POST: /api/playlists/collection/:collectionId
* Create a playlist from a collection
* @param {*} req
* @param {*} res
*/
async createFromCollection(req, res) { async createFromCollection(req, res) {
let collection = await Database.models.collection.getById(req.params.collectionId) const collection = await Database.collectionModel.findByPk(req.params.collectionId)
if (!collection) { if (!collection) {
return res.status(404).send('Collection not found') return res.status(404).send('Collection not found')
} }
// Expand collection to get library items // Expand collection to get library items
collection = collection.toJSONExpanded(Database.libraryItems) const collectionExpanded = await collection.getOldJsonExpanded(req.user)
if (!collectionExpanded) {
// Filter out library items not accessible to user // This can happen if the user has no access to all items in collection
const libraryItems = collection.books.filter(item => req.user.checkCanAccessLibraryItem(item)) return res.status(404).send('Collection not found')
if (!libraryItems.length) {
return res.status(400).send('Collection has no books accessible to user')
} }
const newPlaylist = new Playlist() // Playlists cannot be empty
if (!collectionExpanded.books.length) {
return res.status(400).send('Collection has no books')
}
const newPlaylistData = { const oldPlaylist = new Playlist()
oldPlaylist.setData({
userId: req.user.id, userId: req.user.id,
libraryId: collection.libraryId, libraryId: collection.libraryId,
name: collection.name, name: collection.name,
description: collection.description || null, description: collection.description || null
items: libraryItems.map(li => ({ libraryItemId: li.id })) })
}
newPlaylist.setData(newPlaylistData)
const jsonExpanded = newPlaylist.toJSONExpanded(Database.libraryItems) // Create Playlist record
await Database.createPlaylist(newPlaylist) const newPlaylist = await Database.playlistModel.createFromOld(oldPlaylist)
// Create PlaylistMediaItem records
const mediaItemsToAdd = []
let order = 1
for (const libraryItem of collectionExpanded.books) {
mediaItemsToAdd.push({
playlistId: newPlaylist.id,
mediaItemId: libraryItem.media.id,
mediaItemType: 'book',
order: order++
})
}
await Database.createBulkPlaylistMediaItems(mediaItemsToAdd)
const jsonExpanded = await newPlaylist.getOldJsonExpanded()
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded) SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
res.json(jsonExpanded) res.json(jsonExpanded)
} }
async middleware(req, res, next) { async middleware(req, res, next) {
if (req.params.id) { if (req.params.id) {
const playlist = await Database.models.playlist.getById(req.params.id) const playlist = await Database.playlistModel.findByPk(req.params.id)
if (!playlist) { if (!playlist) {
return res.status(404).send('Playlist not found') return res.status(404).send('Playlist not found')
} }

View File

@ -6,7 +6,9 @@ const fs = require('../libs/fsExtra')
const { getPodcastFeed, findMatchingEpisodes } = require('../utils/podcastUtils') const { getPodcastFeed, findMatchingEpisodes } = require('../utils/podcastUtils')
const { getFileTimestampsWithIno, filePathToPOSIX } = require('../utils/fileUtils') const { getFileTimestampsWithIno, filePathToPOSIX } = require('../utils/fileUtils')
const filePerms = require('../utils/filePerms')
const Scanner = require('../scanner/Scanner')
const CoverManager = require('../managers/CoverManager')
const LibraryItem = require('../objects/LibraryItem') const LibraryItem = require('../objects/LibraryItem')
@ -19,7 +21,7 @@ class PodcastController {
} }
const payload = req.body const payload = req.body
const library = await Database.models.library.getOldById(payload.libraryId) const library = await Database.libraryModel.getOldById(payload.libraryId)
if (!library) { if (!library) {
Logger.error(`[PodcastController] Create: Library not found "${payload.libraryId}"`) Logger.error(`[PodcastController] Create: Library not found "${payload.libraryId}"`)
return res.status(404).send('Library not found') return res.status(404).send('Library not found')
@ -34,9 +36,13 @@ class PodcastController {
const podcastPath = filePathToPOSIX(payload.path) const podcastPath = filePathToPOSIX(payload.path)
// Check if a library item with this podcast folder exists already // Check if a library item with this podcast folder exists already
const existingLibraryItem = Database.libraryItems.find(li => li.path === podcastPath && li.libraryId === library.id) const existingLibraryItem = (await Database.libraryItemModel.count({
where: {
path: podcastPath
}
})) > 0
if (existingLibraryItem) { if (existingLibraryItem) {
Logger.error(`[PodcastController] Podcast already exists with name "${existingLibraryItem.media.metadata.title}" at path "${podcastPath}"`) Logger.error(`[PodcastController] Podcast already exists at path "${podcastPath}"`)
return res.status(400).send('Podcast already exists') return res.status(400).send('Podcast already exists')
} }
@ -45,7 +51,6 @@ class PodcastController {
return false return false
}) })
if (!success) return res.status(400).send('Invalid podcast path') if (!success) return res.status(400).send('Invalid podcast path')
await filePerms.setDefault(podcastPath)
const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath) const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath)
@ -71,7 +76,7 @@ class PodcastController {
if (payload.media.metadata.imageUrl) { if (payload.media.metadata.imageUrl) {
// TODO: Scan cover image to library files // TODO: Scan cover image to library files
// Podcast cover will always go into library item folder // Podcast cover will always go into library item folder
const coverResponse = await this.coverManager.downloadCoverFromUrl(libraryItem, payload.media.metadata.imageUrl, true) const coverResponse = await CoverManager.downloadCoverFromUrl(libraryItem, payload.media.metadata.imageUrl, true)
if (coverResponse) { if (coverResponse) {
if (coverResponse.error) { if (coverResponse.error) {
Logger.error(`[PodcastController] Download cover error from "${payload.media.metadata.imageUrl}": ${coverResponse.error}`) Logger.error(`[PodcastController] Download cover error from "${payload.media.metadata.imageUrl}": ${coverResponse.error}`)
@ -198,7 +203,7 @@ class PodcastController {
} }
const overrideDetails = req.query.override === '1' const overrideDetails = req.query.override === '1'
const episodesUpdated = await this.scanner.quickMatchPodcastEpisodes(req.libraryItem, { overrideDetails }) const episodesUpdated = await Scanner.quickMatchPodcastEpisodes(req.libraryItem, { overrideDetails })
if (episodesUpdated) { if (episodesUpdated) {
await Database.updateLibraryItem(req.libraryItem) await Database.updateLibraryItem(req.libraryItem)
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
@ -268,23 +273,32 @@ class PodcastController {
} }
// Update/remove playlists that had this podcast episode // Update/remove playlists that had this podcast episode
const playlistsWithEpisode = await Database.models.playlist.getPlaylistsForMediaItemIds([episodeId]) const playlistMediaItems = await Database.playlistMediaItemModel.findAll({
for (const playlist of playlistsWithEpisode) { where: {
playlist.removeItem(libraryItem.id, episodeId) mediaItemId: episodeId
},
include: {
model: Database.playlistModel,
include: Database.playlistMediaItemModel
}
})
for (const pmi of playlistMediaItems) {
const numItems = pmi.playlist.playlistMediaItems.length - 1
// If playlist is now empty then remove it if (!numItems) {
if (!playlist.items.length) {
Logger.info(`[PodcastController] Playlist "${playlist.name}" has no more items - removing it`) Logger.info(`[PodcastController] Playlist "${playlist.name}" has no more items - removing it`)
await Database.removePlaylist(playlist.id) const jsonExpanded = await pmi.playlist.getOldJsonExpanded()
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', playlist.toJSONExpanded(Database.libraryItems)) SocketAuthority.clientEmitter(pmi.playlist.userId, 'playlist_removed', jsonExpanded)
await pmi.playlist.destroy()
} else { } else {
await Database.updatePlaylist(playlist) await pmi.destroy()
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', playlist.toJSONExpanded(Database.libraryItems)) const jsonExpanded = await pmi.playlist.getOldJsonExpanded()
SocketAuthority.clientEmitter(pmi.playlist.userId, 'playlist_updated', jsonExpanded)
} }
} }
// Remove media progress for this episode // Remove media progress for this episode
const mediaProgressRemoved = await Database.models.mediaProgress.destroy({ const mediaProgressRemoved = await Database.mediaProgressModel.destroy({
where: { where: {
mediaItemId: episode.id mediaItemId: episode.id
} }
@ -298,9 +312,9 @@ class PodcastController {
res.json(libraryItem.toJSON()) res.json(libraryItem.toJSON())
} }
middleware(req, res, next) { async middleware(req, res, next) {
const item = Database.libraryItems.find(li => li.id === req.params.id) const item = await Database.libraryItemModel.getOldById(req.params.id)
if (!item || !item.media) return res.sendStatus(404) if (!item?.media) return res.sendStatus(404)
if (!item.isPodcast) { if (!item.isPodcast) {
return res.sendStatus(500) return res.sendStatus(500)

View File

@ -1,14 +1,23 @@
const Logger = require('../Logger') const Logger = require('../Logger')
const Database = require('../Database') const Database = require('../Database')
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
class RSSFeedController { class RSSFeedController {
constructor() { } constructor() { }
async getAll(req, res) {
const feeds = await this.rssFeedManager.getFeeds()
res.json({
feeds: feeds.map(f => f.toJSON()),
minified: feeds.map(f => f.toJSONMinified())
})
}
// POST: api/feeds/item/:itemId/open // POST: api/feeds/item/:itemId/open
async openRSSFeedForItem(req, res) { async openRSSFeedForItem(req, res) {
const options = req.body || {} const options = req.body || {}
const item = Database.libraryItems.find(li => li.id === req.params.itemId) const item = await Database.libraryItemModel.getOldById(req.params.itemId)
if (!item) return res.sendStatus(404) if (!item) return res.sendStatus(404)
// Check user can access this library item // Check user can access this library item
@ -45,7 +54,7 @@ class RSSFeedController {
async openRSSFeedForCollection(req, res) { async openRSSFeedForCollection(req, res) {
const options = req.body || {} const options = req.body || {}
const collection = await Database.models.collection.getById(req.params.collectionId) const collection = await Database.collectionModel.findByPk(req.params.collectionId)
if (!collection) return res.sendStatus(404) if (!collection) return res.sendStatus(404)
// Check request body options exist // Check request body options exist
@ -60,7 +69,7 @@ class RSSFeedController {
return res.status(400).send('Slug already in use') return res.status(400).send('Slug already in use')
} }
const collectionExpanded = collection.toJSONExpanded(Database.libraryItems) const collectionExpanded = await collection.getOldJsonExpanded()
const collectionItemsWithTracks = collectionExpanded.books.filter(li => li.media.tracks.length) const collectionItemsWithTracks = collectionExpanded.books.filter(li => li.media.tracks.length)
// Check collection has audio tracks // Check collection has audio tracks
@ -79,7 +88,7 @@ class RSSFeedController {
async openRSSFeedForSeries(req, res) { async openRSSFeedForSeries(req, res) {
const options = req.body || {} const options = req.body || {}
const series = Database.series.find(se => se.id === req.params.seriesId) const series = await Database.seriesModel.getOldById(req.params.seriesId)
if (!series) return res.sendStatus(404) if (!series) return res.sendStatus(404)
// Check request body options exist // Check request body options exist
@ -95,8 +104,9 @@ class RSSFeedController {
} }
const seriesJson = series.toJSON() const seriesJson = series.toJSON()
// Get books in series that have audio tracks // Get books in series that have audio tracks
seriesJson.books = Database.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(series.id) && li.media.tracks.length) seriesJson.books = (await libraryItemsBookFilters.getLibraryItemsForSeries(series)).filter(li => li.media.numTracks)
// Check series has audio tracks // Check series has audio tracks
if (!seriesJson.books.length) { if (!seriesJson.books.length) {

View File

@ -1,4 +1,8 @@
const Logger = require("../Logger") const Logger = require("../Logger")
const BookFinder = require('../finders/BookFinder')
const PodcastFinder = require('../finders/PodcastFinder')
const AuthorFinder = require('../finders/AuthorFinder')
const MusicFinder = require('../finders/MusicFinder')
class SearchController { class SearchController {
constructor() { } constructor() { }
@ -7,7 +11,7 @@ class SearchController {
const provider = req.query.provider || 'google' const provider = req.query.provider || 'google'
const title = req.query.title || '' const title = req.query.title || ''
const author = req.query.author || '' const author = req.query.author || ''
const results = await this.bookFinder.search(provider, title, author) const results = await BookFinder.search(provider, title, author)
res.json(results) res.json(results)
} }
@ -21,8 +25,8 @@ class SearchController {
} }
let results = null let results = null
if (podcast) results = await this.podcastFinder.findCovers(query.title) if (podcast) results = await PodcastFinder.findCovers(query.title)
else results = await this.bookFinder.findCovers(query.provider || 'google', query.title, query.author || null) else results = await BookFinder.findCovers(query.provider || 'google', query.title, query.author || null)
res.json({ res.json({
results results
}) })
@ -30,20 +34,20 @@ class SearchController {
async findPodcasts(req, res) { async findPodcasts(req, res) {
const term = req.query.term const term = req.query.term
const results = await this.podcastFinder.search(term) const results = await PodcastFinder.search(term)
res.json(results) res.json(results)
} }
async findAuthor(req, res) { async findAuthor(req, res) {
const query = req.query.q const query = req.query.q
const author = await this.authorFinder.findAuthorByName(query) const author = await AuthorFinder.findAuthorByName(query)
res.json(author) res.json(author)
} }
async findChapters(req, res) { async findChapters(req, res) {
const asin = req.query.asin const asin = req.query.asin
const region = (req.query.region || 'us').toLowerCase() const region = (req.query.region || 'us').toLowerCase()
const chapterData = await this.bookFinder.findChapters(asin, region) const chapterData = await BookFinder.findChapters(asin, region)
if (!chapterData) { if (!chapterData) {
return res.json({ error: 'Chapters not found' }) return res.json({ error: 'Chapters not found' })
} }
@ -51,7 +55,7 @@ class SearchController {
} }
async findMusicTrack(req, res) { async findMusicTrack(req, res) {
const tracks = await this.musicFinder.searchTrack(req.query || {}) const tracks = await MusicFinder.searchTrack(req.query || {})
res.json({ res.json({
tracks tracks
}) })

View File

@ -1,6 +1,7 @@
const Logger = require('../Logger') const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database') const Database = require('../Database')
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
class SeriesController { class SeriesController {
constructor() { } constructor() { }
@ -25,7 +26,7 @@ class SeriesController {
const libraryItemsInSeries = req.libraryItemsInSeries const libraryItemsInSeries = req.libraryItemsInSeries
const libraryItemsFinished = libraryItemsInSeries.filter(li => { const libraryItemsFinished = libraryItemsInSeries.filter(li => {
const mediaProgress = req.user.getMediaProgress(li.id) const mediaProgress = req.user.getMediaProgress(li.id)
return mediaProgress && mediaProgress.isFinished return mediaProgress?.isFinished
}) })
seriesJson.progress = { seriesJson.progress = {
libraryItemIds: libraryItemsInSeries.map(li => li.id), libraryItemIds: libraryItemsInSeries.map(li => li.id),
@ -42,17 +43,6 @@ class SeriesController {
res.json(seriesJson) res.json(seriesJson)
} }
async search(req, res) {
var q = (req.query.q || '').toLowerCase()
if (!q) return res.json([])
var limit = (req.query.limit && !isNaN(req.query.limit)) ? Number(req.query.limit) : 25
var series = Database.series.filter(se => se.name.toLowerCase().includes(q))
series = series.slice(0, limit)
res.json({
results: series
})
}
async update(req, res) { async update(req, res) {
const hasUpdated = req.series.update(req.body) const hasUpdated = req.series.update(req.body)
if (hasUpdated) { if (hasUpdated) {
@ -62,18 +52,17 @@ class SeriesController {
res.json(req.series.toJSON()) res.json(req.series.toJSON())
} }
middleware(req, res, next) { async middleware(req, res, next) {
const series = Database.series.find(se => se.id === req.params.id) const series = await Database.seriesModel.getOldById(req.params.id)
if (!series) return res.sendStatus(404) if (!series) return res.sendStatus(404)
/** /**
* Filter out any library items not accessible to user * Filter out any library items not accessible to user
*/ */
const libraryItems = Database.libraryItems.filter(li => li.media.metadata.hasSeries?.(series.id)) const libraryItems = await libraryItemsBookFilters.getLibraryItemsForSeries(series, req.user)
const libraryItemsAccessible = libraryItems.filter(li => req.user.checkCanAccessLibraryItem(li)) if (!libraryItems.length) {
if (libraryItems.length && !libraryItemsAccessible.length) { Logger.warn(`[SeriesController] User attempted to access series "${series.id}" with no accessible books`, req.user)
Logger.warn(`[SeriesController] User attempted to access series "${series.id}" without access to any of the books`, req.user) return res.sendStatus(404)
return res.sendStatus(403)
} }
if (req.method == 'DELETE' && !req.user.canDelete) { if (req.method == 'DELETE' && !req.user.canDelete) {
@ -85,7 +74,7 @@ class SeriesController {
} }
req.series = series req.series = series
req.libraryItemsInSeries = libraryItemsAccessible req.libraryItemsInSeries = libraryItems
next() next()
} }
} }

View File

@ -49,7 +49,7 @@ class SessionController {
return res.sendStatus(404) return res.sendStatus(404)
} }
const minifiedUserObjects = await Database.models.user.getMinifiedUserObjects() const minifiedUserObjects = await Database.userModel.getMinifiedUserObjects()
const openSessions = this.playbackSessionManager.sessions.map(se => { const openSessions = this.playbackSessionManager.sessions.map(se => {
return { return {
...se.toJSON(), ...se.toJSON(),
@ -62,9 +62,9 @@ class SessionController {
}) })
} }
getOpenSession(req, res) { async getOpenSession(req, res) {
var libraryItem = Database.getLibraryItem(req.session.libraryItemId) const libraryItem = await Database.libraryItemModel.getOldById(req.session.libraryItemId)
var sessionForClient = req.session.toJSONForClient(libraryItem) const sessionForClient = req.session.toJSONForClient(libraryItem)
res.json(sessionForClient) res.json(sessionForClient)
} }

View File

@ -66,7 +66,7 @@ class ToolsController {
const libraryItems = [] const libraryItems = []
for (const libraryItemId of libraryItemIds) { for (const libraryItemId of libraryItemIds) {
const libraryItem = Database.getLibraryItem(libraryItemId) const libraryItem = await Database.libraryItemModel.getOldById(libraryItemId)
if (!libraryItem) { if (!libraryItem) {
Logger.error(`[ToolsController] Batch embed metadata library item (${libraryItemId}) not found`) Logger.error(`[ToolsController] Batch embed metadata library item (${libraryItemId}) not found`)
return res.sendStatus(404) return res.sendStatus(404)
@ -99,15 +99,15 @@ class ToolsController {
res.sendStatus(200) res.sendStatus(200)
} }
middleware(req, res, next) { async middleware(req, res, next) {
if (!req.user.isAdminOrUp) { if (!req.user.isAdminOrUp) {
Logger.error(`[LibraryItemController] Non-root user attempted to access tools route`, req.user) Logger.error(`[LibraryItemController] Non-root user attempted to access tools route`, req.user)
return res.sendStatus(403) return res.sendStatus(403)
} }
if (req.params.id) { if (req.params.id) {
const item = Database.libraryItems.find(li => li.id === req.params.id) const item = await Database.libraryItemModel.getOldById(req.params.id)
if (!item || !item.media) return res.sendStatus(404) if (!item?.media) return res.sendStatus(404)
// Check user can access this library item // Check user can access this library item
if (!req.user.checkCanAccessLibraryItem(item)) { if (!req.user.checkCanAccessLibraryItem(item)) {

View File

@ -17,7 +17,7 @@ class UserController {
const includes = (req.query.include || '').split(',').map(i => i.trim()) const includes = (req.query.include || '').split(',').map(i => i.trim())
// Minimal toJSONForBrowser does not include mediaProgress and bookmarks // Minimal toJSONForBrowser does not include mediaProgress and bookmarks
const allUsers = await Database.models.user.getOldUsers() const allUsers = await Database.userModel.getOldUsers()
const users = allUsers.map(u => u.toJSONForBrowser(hideRootToken, true)) const users = allUsers.map(u => u.toJSONForBrowser(hideRootToken, true))
if (includes.includes('latestSession')) { if (includes.includes('latestSession')) {
@ -32,20 +32,67 @@ class UserController {
}) })
} }
/**
* GET: /api/users/:id
* Get a single user toJSONForBrowser
* Media progress items include: `displayTitle`, `displaySubtitle` (for podcasts), `coverPath` and `mediaUpdatedAt`
*
* @param {import("express").Request} req
* @param {import("express").Response} res
*/
async findOne(req, res) { async findOne(req, res) {
if (!req.user.isAdminOrUp) { if (!req.user.isAdminOrUp) {
Logger.error('User other than admin attempting to get user', req.user) Logger.error('User other than admin attempting to get user', req.user)
return res.sendStatus(403) return res.sendStatus(403)
} }
res.json(this.userJsonWithItemProgressDetails(req.reqUser, !req.user.isRoot)) // Get user media progress with associated mediaItem
const mediaProgresses = await Database.mediaProgressModel.findAll({
where: {
userId: req.reqUser.id
},
include: [
{
model: Database.bookModel,
attributes: ['id', 'title', 'coverPath', 'updatedAt']
},
{
model: Database.podcastEpisodeModel,
attributes: ['id', 'title'],
include: {
model: Database.podcastModel,
attributes: ['id', 'title', 'coverPath', 'updatedAt']
}
}
]
})
const oldMediaProgresses = mediaProgresses.map(mp => {
const oldMediaProgress = mp.getOldMediaProgress()
oldMediaProgress.displayTitle = mp.mediaItem?.title
if (mp.mediaItem?.podcast) {
oldMediaProgress.displaySubtitle = mp.mediaItem.podcast?.title
oldMediaProgress.coverPath = mp.mediaItem.podcast?.coverPath
oldMediaProgress.mediaUpdatedAt = mp.mediaItem.podcast?.updatedAt
} else if (mp.mediaItem) {
oldMediaProgress.coverPath = mp.mediaItem.coverPath
oldMediaProgress.mediaUpdatedAt = mp.mediaItem.updatedAt
}
return oldMediaProgress
})
const userJson = req.reqUser.toJSONForBrowser(!req.user.isRoot)
userJson.mediaProgress = oldMediaProgresses
res.json(userJson)
} }
async create(req, res) { async create(req, res) {
const account = req.body const account = req.body
const username = account.username const username = account.username
const usernameExists = await Database.models.user.getUserByUsername(username) const usernameExists = await Database.userModel.getUserByUsername(username)
if (usernameExists) { if (usernameExists) {
return res.status(500).send('Username already taken') return res.status(500).send('Username already taken')
} }
@ -80,7 +127,7 @@ class UserController {
var shouldUpdateToken = false var shouldUpdateToken = false
if (account.username !== undefined && account.username !== user.username) { if (account.username !== undefined && account.username !== user.username) {
const usernameExists = await Database.models.user.getUserByUsername(account.username) const usernameExists = await Database.userModel.getUserByUsername(account.username)
if (usernameExists) { if (usernameExists) {
return res.status(500).send('Username already taken') return res.status(500).send('Username already taken')
} }
@ -122,9 +169,13 @@ class UserController {
// Todo: check if user is logged in and cancel streams // Todo: check if user is logged in and cancel streams
// Remove user playlists // Remove user playlists
const userPlaylists = await Database.models.playlist.getPlaylistsForUserAndLibrary(user.id) const userPlaylists = await Database.playlistModel.findAll({
where: {
userId: user.id
}
})
for (const playlist of userPlaylists) { for (const playlist of userPlaylists) {
await Database.removePlaylist(playlist.id) await playlist.destroy()
} }
const userJson = user.toJSONForBrowser() const userJson = user.toJSONForBrowser()
@ -182,7 +233,7 @@ class UserController {
} }
if (req.params.id) { if (req.params.id) {
req.reqUser = await Database.models.user.getUserById(req.params.id) req.reqUser = await Database.userModel.getUserById(req.params.id)
if (!req.reqUser) { if (!req.reqUser) {
return res.sendStatus(404) return res.sendStatus(404)
} }

View File

@ -5,23 +5,23 @@ const { Sequelize } = require('sequelize')
const Database = require('../Database') const Database = require('../Database')
const getLibraryItemMinified = (libraryItemId) => { const getLibraryItemMinified = (libraryItemId) => {
return Database.models.libraryItem.findByPk(libraryItemId, { return Database.libraryItemModel.findByPk(libraryItemId, {
include: [ include: [
{ {
model: Database.models.book, model: Database.bookModel,
attributes: [ attributes: [
'id', 'title', 'subtitle', 'publishedYear', 'publishedDate', 'publisher', 'description', 'isbn', 'asin', 'language', 'explicit', 'narrators', 'coverPath', 'genres', 'tags' 'id', 'title', 'subtitle', 'publishedYear', 'publishedDate', 'publisher', 'description', 'isbn', 'asin', 'language', 'explicit', 'narrators', 'coverPath', 'genres', 'tags'
], ],
include: [ include: [
{ {
model: Database.models.author, model: Database.authorModel,
attributes: ['id', 'name'], attributes: ['id', 'name'],
through: { through: {
attributes: [] attributes: []
} }
}, },
{ {
model: Database.models.series, model: Database.seriesModel,
attributes: ['id', 'name'], attributes: ['id', 'name'],
through: { through: {
attributes: ['sequence'] attributes: ['sequence']
@ -30,7 +30,7 @@ const getLibraryItemMinified = (libraryItemId) => {
] ]
}, },
{ {
model: Database.models.podcast, model: Database.podcastModel,
attributes: [ attributes: [
'id', 'title', 'author', 'releaseDate', 'feedURL', 'imageURL', 'description', 'itunesPageURL', 'itunesId', 'itunesArtistId', 'language', 'podcastType', 'explicit', 'autoDownloadEpisodes', 'genres', 'tags', 'id', 'title', 'author', 'releaseDate', 'feedURL', 'imageURL', 'description', 'itunesPageURL', 'itunesId', 'itunesArtistId', 'language', 'podcastType', 'explicit', 'autoDownloadEpisodes', 'genres', 'tags',
[Sequelize.literal('(SELECT COUNT(*) FROM "podcastEpisodes" WHERE "podcastEpisodes"."podcastId" = podcast.id)'), 'numPodcastEpisodes'] [Sequelize.literal('(SELECT COUNT(*) FROM "podcastEpisodes" WHERE "podcastEpisodes"."podcastId" = podcast.id)'), 'numPodcastEpisodes']
@ -41,19 +41,19 @@ const getLibraryItemMinified = (libraryItemId) => {
} }
const getLibraryItemExpanded = (libraryItemId) => { const getLibraryItemExpanded = (libraryItemId) => {
return Database.models.libraryItem.findByPk(libraryItemId, { return Database.libraryItemModel.findByPk(libraryItemId, {
include: [ include: [
{ {
model: Database.models.book, model: Database.bookModel,
include: [ include: [
{ {
model: Database.models.author, model: Database.authorModel,
through: { through: {
attributes: [] attributes: []
} }
}, },
{ {
model: Database.models.series, model: Database.seriesModel,
through: { through: {
attributes: ['sequence'] attributes: ['sequence']
} }
@ -61,10 +61,10 @@ const getLibraryItemExpanded = (libraryItemId) => {
] ]
}, },
{ {
model: Database.models.podcast, model: Database.podcastModel,
include: [ include: [
{ {
model: Database.models.podcastEpisode model: Database.podcastEpisodeModel
} }
] ]
}, },

View File

@ -4,12 +4,9 @@ const Path = require('path')
const Audnexus = require('../providers/Audnexus') const Audnexus = require('../providers/Audnexus')
const { downloadFile } = require('../utils/fileUtils') const { downloadFile } = require('../utils/fileUtils')
const filePerms = require('../utils/filePerms')
class AuthorFinder { class AuthorFinder {
constructor() { constructor() {
this.AuthorPath = Path.join(global.MetadataPath, 'authors')
this.audnexus = new Audnexus() this.audnexus = new Audnexus()
} }
@ -37,12 +34,11 @@ class AuthorFinder {
} }
async saveAuthorImage(authorId, url) { async saveAuthorImage(authorId, url) {
var authorDir = this.AuthorPath var authorDir = Path.join(global.MetadataPath, 'authors')
var relAuthorDir = Path.posix.join('/metadata', 'authors') var relAuthorDir = Path.posix.join('/metadata', 'authors')
if (!await fs.pathExists(authorDir)) { if (!await fs.pathExists(authorDir)) {
await fs.ensureDir(authorDir) await fs.ensureDir(authorDir)
await filePerms.setDefault(authorDir)
} }
var imageExtension = url.toLowerCase().split('.').pop() var imageExtension = url.toLowerCase().split('.').pop()
@ -61,4 +57,4 @@ class AuthorFinder {
} }
} }
} }
module.exports = AuthorFinder module.exports = new AuthorFinder()

View File

@ -253,4 +253,4 @@ class BookFinder {
return this.audnexus.getChaptersByASIN(asin, region) return this.audnexus.getChaptersByASIN(asin, region)
} }
} }
module.exports = BookFinder module.exports = new BookFinder()

View File

@ -9,4 +9,4 @@ class MusicFinder {
return this.musicBrainz.searchTrack(options) return this.musicBrainz.searchTrack(options)
} }
} }
module.exports = MusicFinder module.exports = new MusicFinder()

View File

@ -22,4 +22,4 @@ class PodcastFinder {
return results.map(r => r.cover).filter(r => r) return results.map(r => r.cover).filter(r => r)
} }
} }
module.exports = PodcastFinder module.exports = new PodcastFinder()

View File

@ -5,7 +5,6 @@ const fs = require('../libs/fsExtra')
const workerThreads = require('worker_threads') const workerThreads = require('worker_threads')
const Logger = require('../Logger') const Logger = require('../Logger')
const Task = require('../objects/Task') const Task = require('../objects/Task')
const filePerms = require('../utils/filePerms')
const { writeConcatFile } = require('../utils/ffmpegHelpers') const { writeConcatFile } = require('../utils/ffmpegHelpers')
const toneHelpers = require('../utils/toneHelpers') const toneHelpers = require('../utils/toneHelpers')
@ -201,10 +200,6 @@ class AbMergeManager {
Logger.debug(`[AbMergeManager] Moving m4b from ${task.data.tempFilepath} to ${task.data.targetFilepath}`) Logger.debug(`[AbMergeManager] Moving m4b from ${task.data.tempFilepath} to ${task.data.targetFilepath}`)
await fs.move(task.data.tempFilepath, task.data.targetFilepath) await fs.move(task.data.tempFilepath, task.data.targetFilepath)
// Set file permissions and ownership
await filePerms.setDefault(task.data.targetFilepath)
await filePerms.setDefault(task.data.itemCachePath)
task.setFinished() task.setFinished()
await this.removeTask(task, false) await this.removeTask(task, false)
Logger.info(`[AbMergeManager] Ab task finished ${task.id}`) Logger.info(`[AbMergeManager] Ab task finished ${task.id}`)

View File

@ -1,42 +1,40 @@
const Path = require('path') const Path = require('path')
const fs = require('../libs/fsExtra') const fs = require('../libs/fsExtra')
const stream = require('stream') const stream = require('stream')
const filePerms = require('../utils/filePerms')
const Logger = require('../Logger') const Logger = require('../Logger')
const { resizeImage } = require('../utils/ffmpegHelpers') const { resizeImage } = require('../utils/ffmpegHelpers')
class CacheManager { class CacheManager {
constructor() { constructor() {
this.CachePath = null
this.CoverCachePath = null
this.ImageCachePath = null
this.ItemCachePath = null
}
/**
* Create cache directory paths if they dont exist
*/
async ensureCachePaths() { // Creates cache paths if necessary and sets owner and permissions
this.CachePath = Path.join(global.MetadataPath, 'cache') this.CachePath = Path.join(global.MetadataPath, 'cache')
this.CoverCachePath = Path.join(this.CachePath, 'covers') this.CoverCachePath = Path.join(this.CachePath, 'covers')
this.ImageCachePath = Path.join(this.CachePath, 'images') this.ImageCachePath = Path.join(this.CachePath, 'images')
this.ItemCachePath = Path.join(this.CachePath, 'items') this.ItemCachePath = Path.join(this.CachePath, 'items')
}
async ensureCachePaths() { // Creates cache paths if necessary and sets owner and permissions
var pathsCreated = false
if (!(await fs.pathExists(this.CachePath))) { if (!(await fs.pathExists(this.CachePath))) {
await fs.mkdir(this.CachePath) await fs.mkdir(this.CachePath)
pathsCreated = true
} }
if (!(await fs.pathExists(this.CoverCachePath))) { if (!(await fs.pathExists(this.CoverCachePath))) {
await fs.mkdir(this.CoverCachePath) await fs.mkdir(this.CoverCachePath)
pathsCreated = true
} }
if (!(await fs.pathExists(this.ImageCachePath))) { if (!(await fs.pathExists(this.ImageCachePath))) {
await fs.mkdir(this.ImageCachePath) await fs.mkdir(this.ImageCachePath)
pathsCreated = true
} }
if (!(await fs.pathExists(this.ItemCachePath))) { if (!(await fs.pathExists(this.ItemCachePath))) {
await fs.mkdir(this.ItemCachePath) await fs.mkdir(this.ItemCachePath)
pathsCreated = true
}
if (pathsCreated) {
await filePerms.setDefault(this.CachePath)
} }
} }
@ -74,9 +72,6 @@ class CacheManager {
const writtenFile = await resizeImage(libraryItem.media.coverPath, path, width, height) const writtenFile = await resizeImage(libraryItem.media.coverPath, path, width, height)
if (!writtenFile) return res.sendStatus(500) if (!writtenFile) return res.sendStatus(500)
// Set owner and permissions of cache image
await filePerms.setDefault(path)
if (global.XAccel) { if (global.XAccel) {
Logger.debug(`Use X-Accel to serve static file ${writtenFile}`) Logger.debug(`Use X-Accel to serve static file ${writtenFile}`)
return res.status(204).header({ 'X-Accel-Redirect': global.XAccel + writtenFile }).send() return res.status(204).header({ 'X-Accel-Redirect': global.XAccel + writtenFile }).send()
@ -160,11 +155,8 @@ class CacheManager {
let writtenFile = await resizeImage(author.imagePath, path, width, height) let writtenFile = await resizeImage(author.imagePath, path, width, height)
if (!writtenFile) return res.sendStatus(500) if (!writtenFile) return res.sendStatus(500)
// Set owner and permissions of cache image
await filePerms.setDefault(path)
var readStream = fs.createReadStream(writtenFile) var readStream = fs.createReadStream(writtenFile)
readStream.pipe(res) readStream.pipe(res)
} }
} }
module.exports = CacheManager module.exports = new CacheManager()

View File

@ -3,24 +3,20 @@ const Path = require('path')
const Logger = require('../Logger') const Logger = require('../Logger')
const readChunk = require('../libs/readChunk') const readChunk = require('../libs/readChunk')
const imageType = require('../libs/imageType') const imageType = require('../libs/imageType')
const filePerms = require('../utils/filePerms')
const globals = require('../utils/globals') const globals = require('../utils/globals')
const { downloadFile, filePathToPOSIX } = require('../utils/fileUtils') const { downloadFile, filePathToPOSIX, checkPathIsFile } = require('../utils/fileUtils')
const { extractCoverArt } = require('../utils/ffmpegHelpers') const { extractCoverArt } = require('../utils/ffmpegHelpers')
const CacheManager = require('../managers/CacheManager')
class CoverManager { class CoverManager {
constructor(cacheManager) { constructor() { }
this.cacheManager = cacheManager
this.ItemMetadataPath = Path.posix.join(global.MetadataPath, 'items')
}
getCoverDirectory(libraryItem) { getCoverDirectory(libraryItem) {
if (global.ServerSettings.storeCoverWithItem && !libraryItem.isFile && !libraryItem.isMusic) { if (global.ServerSettings.storeCoverWithItem && !libraryItem.isFile) {
return libraryItem.path return libraryItem.path
} else { } else {
return Path.posix.join(this.ItemMetadataPath, libraryItem.id) return Path.posix.join(Path.posix.join(global.MetadataPath, 'items'), libraryItem.id)
} }
} }
@ -107,11 +103,10 @@ class CoverManager {
} }
await this.removeOldCovers(coverDirPath, extname) await this.removeOldCovers(coverDirPath, extname)
await this.cacheManager.purgeCoverCache(libraryItem.id) await CacheManager.purgeCoverCache(libraryItem.id)
Logger.info(`[CoverManager] Uploaded libraryItem cover "${coverFullPath}" for "${libraryItem.media.metadata.title}"`) Logger.info(`[CoverManager] Uploaded libraryItem cover "${coverFullPath}" for "${libraryItem.media.metadata.title}"`)
await filePerms.setDefault(coverFullPath)
libraryItem.updateMediaCover(coverFullPath) libraryItem.updateMediaCover(coverFullPath)
return { return {
cover: coverFullPath cover: coverFullPath
@ -146,11 +141,9 @@ class CoverManager {
await fs.rename(temppath, coverFullPath) await fs.rename(temppath, coverFullPath)
await this.removeOldCovers(coverDirPath, '.' + imgtype.ext) await this.removeOldCovers(coverDirPath, '.' + imgtype.ext)
await this.cacheManager.purgeCoverCache(libraryItem.id) await CacheManager.purgeCoverCache(libraryItem.id)
Logger.info(`[CoverManager] Downloaded libraryItem cover "${coverFullPath}" from url "${url}" for "${libraryItem.media.metadata.title}"`) Logger.info(`[CoverManager] Downloaded libraryItem cover "${coverFullPath}" from url "${url}" for "${libraryItem.media.metadata.title}"`)
await filePerms.setDefault(coverFullPath)
libraryItem.updateMediaCover(coverFullPath) libraryItem.updateMediaCover(coverFullPath)
return { return {
cover: coverFullPath cover: coverFullPath
@ -180,6 +173,7 @@ class CoverManager {
updated: false updated: false
} }
} }
// Cover path does not exist // 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}"`) Logger.error(`[CoverManager] validate cover path does not exist "${coverPath}"`)
@ -187,8 +181,17 @@ class CoverManager {
error: 'Cover path does not exist' error: 'Cover path does not exist'
} }
} }
// Cover path is not a file
if (!await checkPathIsFile(coverPath)) {
Logger.error(`[CoverManager] validate cover path is not a file "${coverPath}"`)
return {
error: 'Cover path is not a file'
}
}
// Check valid image at path // Check valid image at path
var imgtype = await this.checkFileIsValidImage(coverPath, true) var imgtype = await this.checkFileIsValidImage(coverPath, false)
if (imgtype.error) { if (imgtype.error) {
return imgtype return imgtype
} }
@ -212,13 +215,12 @@ class CoverManager {
error: 'Failed to copy cover to dir' error: 'Failed to copy cover to dir'
} }
} }
await filePerms.setDefault(newCoverPath)
await this.removeOldCovers(coverDirPath, '.' + imgtype.ext) await this.removeOldCovers(coverDirPath, '.' + imgtype.ext)
Logger.debug(`[CoverManager] cover copy success`) Logger.debug(`[CoverManager] cover copy success`)
coverPath = newCoverPath coverPath = newCoverPath
} }
await this.cacheManager.purgeCoverCache(libraryItem.id) await CacheManager.purgeCoverCache(libraryItem.id)
libraryItem.updateMediaCover(coverPath) libraryItem.updateMediaCover(coverPath)
return { return {
@ -253,12 +255,97 @@ class CoverManager {
const success = await extractCoverArt(audioFileWithCover.metadata.path, coverFilePath) const success = await extractCoverArt(audioFileWithCover.metadata.path, coverFilePath)
if (success) { if (success) {
await filePerms.setDefault(coverFilePath)
libraryItem.updateMediaCover(coverFilePath) libraryItem.updateMediaCover(coverFilePath)
return coverFilePath return coverFilePath
} }
return false return false
} }
/**
* 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
* @returns {Promise<string>} returns cover path
*/
async saveEmbeddedCoverArtNew(audioFiles, libraryItemId, libraryItemPath) {
let audioFileWithCover = audioFiles.find(af => af.embeddedCoverArt)
if (!audioFileWithCover) return null
let coverDirPath = null
if (global.ServerSettings.storeCoverWithItem && libraryItemPath) {
coverDirPath = libraryItemPath
} else {
coverDirPath = Path.posix.join(global.MetadataPath, 'items', libraryItemId)
}
await fs.ensureDir(coverDirPath)
const coverFilename = audioFileWithCover.embeddedCoverArt === 'png' ? 'cover.png' : 'cover.jpg'
const coverFilePath = Path.join(coverDirPath, coverFilename)
const coverAlreadyExists = await fs.pathExists(coverFilePath)
if (coverAlreadyExists) {
Logger.warn(`[CoverManager] Extract embedded cover art but cover already exists for "${libraryItemPath}" - bail`)
return null
}
const success = await extractCoverArt(audioFileWithCover.metadata.path, coverFilePath)
if (success) {
return coverFilePath
}
return null
}
/**
*
* @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}>}
*/
async downloadCoverFromUrlNew(url, libraryItemId, libraryItemPath) {
try {
let coverDirPath = null
if (global.ServerSettings.storeCoverWithItem && libraryItemPath) {
coverDirPath = libraryItemPath
} else {
coverDirPath = Path.posix.join(global.MetadataPath, 'items', libraryItemId)
}
await fs.ensureDir(coverDirPath)
const temppath = Path.posix.join(coverDirPath, 'cover')
const success = await downloadFile(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'
}
}
const imgtype = await this.checkFileIsValidImage(temppath, true)
if (imgtype.error) {
return imgtype
}
const coverFullPath = Path.posix.join(coverDirPath, `cover.${imgtype.ext}`)
await fs.rename(temppath, coverFullPath)
await this.removeOldCovers(coverDirPath, '.' + imgtype.ext)
await CacheManager.purgeCoverCache(libraryItemId)
Logger.info(`[CoverManager] Downloaded libraryItem cover "${coverFullPath}" from url "${url}"`)
return {
cover: coverFullPath
}
} catch (error) {
Logger.error(`[CoverManager] Fetch cover image from url "${url}" failed`, error)
return {
error: 'Failed to fetch image from url'
}
}
}
} }
module.exports = CoverManager module.exports = new CoverManager()

View File

@ -1,10 +1,11 @@
const Sequelize = require('sequelize')
const cron = require('../libs/nodeCron') const cron = require('../libs/nodeCron')
const Logger = require('../Logger') const Logger = require('../Logger')
const Database = require('../Database') const Database = require('../Database')
const LibraryScanner = require('../scanner/LibraryScanner')
class CronManager { class CronManager {
constructor(scanner, podcastManager) { constructor(podcastManager) {
this.scanner = scanner
this.podcastManager = podcastManager this.podcastManager = podcastManager
this.libraryScanCrons = [] this.libraryScanCrons = []
@ -17,9 +18,9 @@ class CronManager {
* Initialize library scan crons & podcast download crons * Initialize library scan crons & podcast download crons
* @param {oldLibrary[]} libraries * @param {oldLibrary[]} libraries
*/ */
init(libraries) { async init(libraries) {
this.initLibraryScanCrons(libraries) this.initLibraryScanCrons(libraries)
this.initPodcastCrons() await this.initPodcastCrons()
} }
/** /**
@ -38,7 +39,7 @@ class CronManager {
Logger.debug(`[CronManager] Init library scan cron for ${library.name} on schedule ${library.settings.autoScanCronExpression}`) Logger.debug(`[CronManager] Init library scan cron for ${library.name} on schedule ${library.settings.autoScanCronExpression}`)
const libScanCron = cron.schedule(library.settings.autoScanCronExpression, () => { const libScanCron = cron.schedule(library.settings.autoScanCronExpression, () => {
Logger.debug(`[CronManager] Library scan cron executing for ${library.name}`) Logger.debug(`[CronManager] Library scan cron executing for ${library.name}`)
this.scanner.scan(library) LibraryScanner.scan(library)
}) })
this.libraryScanCrons.push({ this.libraryScanCrons.push({
libraryId: library.id, libraryId: library.id,
@ -70,23 +71,34 @@ class CronManager {
} }
} }
initPodcastCrons() { /**
* Init cron jobs for auto-download podcasts
*/
async initPodcastCrons() {
const cronExpressionMap = {} const cronExpressionMap = {}
Database.libraryItems.forEach((li) => {
if (li.mediaType === 'podcast' && li.media.autoDownloadEpisodes) { const podcastsWithAutoDownload = await Database.podcastModel.findAll({
if (!li.media.autoDownloadSchedule) { where: {
Logger.error(`[CronManager] Podcast auto download schedule is not set for ${li.media.metadata.title}`) autoDownloadEpisodes: true,
} else { autoDownloadSchedule: {
if (!cronExpressionMap[li.media.autoDownloadSchedule]) { [Sequelize.Op.not]: null
cronExpressionMap[li.media.autoDownloadSchedule] = {
expression: li.media.autoDownloadSchedule,
libraryItemIds: []
}
}
cronExpressionMap[li.media.autoDownloadSchedule].libraryItemIds.push(li.id)
} }
},
include: {
model: Database.libraryItemModel
} }
}) })
for (const podcast of podcastsWithAutoDownload) {
if (!cronExpressionMap[podcast.autoDownloadSchedule]) {
cronExpressionMap[podcast.autoDownloadSchedule] = {
expression: podcast.autoDownloadSchedule,
libraryItemIds: []
}
}
cronExpressionMap[podcast.autoDownloadSchedule].libraryItemIds.push(podcast.libraryItem.id)
}
if (!Object.keys(cronExpressionMap).length) return if (!Object.keys(cronExpressionMap).length) return
Logger.debug(`[CronManager] Found ${Object.keys(cronExpressionMap).length} podcast episode schedules to start`) Logger.debug(`[CronManager] Found ${Object.keys(cronExpressionMap).length} podcast episode schedules to start`)
@ -127,7 +139,7 @@ class CronManager {
// Get podcast library items to check // Get podcast library items to check
const libraryItems = [] const libraryItems = []
for (const libraryItemId of libraryItemIds) { for (const libraryItemId of libraryItemIds) {
const libraryItem = Database.libraryItems.find(li => li.id === libraryItemId) const libraryItem = await Database.libraryItemModel.getOldById(libraryItemId)
if (!libraryItem) { if (!libraryItem) {
Logger.error(`[CronManager] Library item ${libraryItemId} not found for episode check cron ${expression}`) Logger.error(`[CronManager] Library item ${libraryItemId} not found for episode check cron ${expression}`)
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter(lid => lid !== libraryItemId) // Filter it out podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter(lid => lid !== libraryItemId) // Filter it out

View File

@ -1,6 +1,5 @@
const Path = require('path') const Path = require('path')
const fs = require('../libs/fsExtra') const fs = require('../libs/fsExtra')
const filePerms = require('../utils/filePerms')
const DailyLog = require('../objects/DailyLog') const DailyLog = require('../objects/DailyLog')
@ -25,13 +24,11 @@ class LogManager {
async ensureLogDirs() { async ensureLogDirs() {
await fs.ensureDir(this.DailyLogPath) await fs.ensureDir(this.DailyLogPath)
await fs.ensureDir(this.ScanLogPath) await fs.ensureDir(this.ScanLogPath)
await filePerms.setDefault(Path.posix.join(global.MetadataPath, 'logs'), true)
} }
async ensureScanLogDir() { async ensureScanLogDir() {
if (!(await fs.pathExists(this.ScanLogPath))) { if (!(await fs.pathExists(this.ScanLogPath))) {
await fs.mkdir(this.ScanLogPath) await fs.mkdir(this.ScanLogPath)
await filePerms.setDefault(this.ScanLogPath)
} }
} }

View File

@ -18,7 +18,7 @@ class NotificationManager {
if (!Database.notificationSettings.isUseable) return if (!Database.notificationSettings.isUseable) return
Logger.debug(`[NotificationManager] onPodcastEpisodeDownloaded: Episode "${episode.title}" for podcast ${libraryItem.media.metadata.title}`) Logger.debug(`[NotificationManager] onPodcastEpisodeDownloaded: Episode "${episode.title}" for podcast ${libraryItem.media.metadata.title}`)
const library = await Database.models.library.getOldById(libraryItem.libraryId) const library = await Database.libraryModel.getOldById(libraryItem.libraryId)
const eventData = { const eventData = {
libraryItemId: libraryItem.id, libraryItemId: libraryItem.id,
libraryId: libraryItem.libraryId, libraryId: libraryItem.libraryId,

View File

@ -93,7 +93,7 @@ class PlaybackSessionManager {
} }
async syncLocalSession(user, sessionJson, deviceInfo) { async syncLocalSession(user, sessionJson, deviceInfo) {
const libraryItem = Database.getLibraryItem(sessionJson.libraryItemId) const libraryItem = await Database.libraryItemModel.getOldById(sessionJson.libraryItemId)
const episode = (sessionJson.episodeId && libraryItem && libraryItem.isPodcast) ? libraryItem.media.getEpisode(sessionJson.episodeId) : null const episode = (sessionJson.episodeId && libraryItem && libraryItem.isPodcast) ? libraryItem.media.getEpisode(sessionJson.episodeId) : null
if (!libraryItem || (libraryItem.isPodcast && !episode)) { if (!libraryItem || (libraryItem.isPodcast && !episode)) {
Logger.error(`[PlaybackSessionManager] syncLocalSession: Media item not found for session "${sessionJson.displayTitle}" (${sessionJson.id})`) Logger.error(`[PlaybackSessionManager] syncLocalSession: Media item not found for session "${sessionJson.displayTitle}" (${sessionJson.id})`)
@ -259,13 +259,13 @@ class PlaybackSessionManager {
} }
this.sessions.push(newPlaybackSession) this.sessions.push(newPlaybackSession)
SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions, Database.libraryItems)) SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions))
return newPlaybackSession return newPlaybackSession
} }
async syncSession(user, session, syncData) { async syncSession(user, session, syncData) {
const libraryItem = Database.libraryItems.find(li => li.id === session.libraryItemId) const libraryItem = await Database.libraryItemModel.getOldById(session.libraryItemId)
if (!libraryItem) { if (!libraryItem) {
Logger.error(`[PlaybackSessionManager] syncSession Library Item not found "${session.libraryItemId}"`) Logger.error(`[PlaybackSessionManager] syncSession Library Item not found "${session.libraryItemId}"`)
return null return null
@ -304,7 +304,7 @@ class PlaybackSessionManager {
await this.saveSession(session) await this.saveSession(session)
} }
Logger.debug(`[PlaybackSessionManager] closeSession "${session.id}"`) Logger.debug(`[PlaybackSessionManager] closeSession "${session.id}"`)
SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions, Database.libraryItems)) SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions))
SocketAuthority.clientEmitter(session.userId, 'user_session_closed', session.id) SocketAuthority.clientEmitter(session.userId, 'user_session_closed', session.id)
return this.removeSession(session.id) return this.removeSession(session.id)
} }

View File

@ -6,7 +6,6 @@ const fs = require('../libs/fsExtra')
const { getPodcastFeed } = require('../utils/podcastUtils') const { getPodcastFeed } = require('../utils/podcastUtils')
const { removeFile, downloadFile } = require('../utils/fileUtils') const { removeFile, downloadFile } = require('../utils/fileUtils')
const filePerms = require('../utils/filePerms')
const { levenshteinDistance } = require('../utils/index') const { levenshteinDistance } = require('../utils/index')
const opmlParser = require('../utils/parsers/parseOPML') const opmlParser = require('../utils/parsers/parseOPML')
const opmlGenerator = require('../utils/generators/opmlGenerator') const opmlGenerator = require('../utils/generators/opmlGenerator')
@ -96,7 +95,6 @@ class PodcastManager {
if (!(await fs.pathExists(this.currentDownload.libraryItem.path))) { if (!(await fs.pathExists(this.currentDownload.libraryItem.path))) {
Logger.warn(`[PodcastManager] Podcast episode download: Podcast folder no longer exists at "${this.currentDownload.libraryItem.path}" - Creating it`) Logger.warn(`[PodcastManager] Podcast episode download: Podcast folder no longer exists at "${this.currentDownload.libraryItem.path}" - Creating it`)
await fs.mkdir(this.currentDownload.libraryItem.path) await fs.mkdir(this.currentDownload.libraryItem.path)
await filePerms.setDefault(this.currentDownload.libraryItem.path)
} }
let success = false let success = false
@ -150,7 +148,7 @@ class PodcastManager {
return false return false
} }
const libraryItem = Database.libraryItems.find(li => li.id === this.currentDownload.libraryItem.id) const libraryItem = await Database.libraryItemModel.getOldById(this.currentDownload.libraryItem.id)
if (!libraryItem) { if (!libraryItem) {
Logger.error(`[PodcastManager] Podcast Episode finished but library item was not found ${this.currentDownload.libraryItem.id}`) Logger.error(`[PodcastManager] Podcast Episode finished but library item was not found ${this.currentDownload.libraryItem.id}`)
return false return false
@ -372,8 +370,13 @@ class PodcastManager {
} }
} }
generateOPMLFileText(libraryItems) { /**
return opmlGenerator.generate(libraryItems) * OPML file string for podcasts in a library
* @param {import('../models/Podcast')[]} podcasts
* @returns {string} XML string
*/
generateOPMLFileText(podcasts) {
return opmlGenerator.generate(podcasts)
} }
getDownloadQueueDetails(libraryId = null) { getDownloadQueueDetails(libraryId = null) {

View File

@ -6,27 +6,28 @@ const Database = require('../Database')
const fs = require('../libs/fsExtra') const fs = require('../libs/fsExtra')
const Feed = require('../objects/Feed') const Feed = require('../objects/Feed')
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
class RssFeedManager { class RssFeedManager {
constructor() { } constructor() { }
async validateFeedEntity(feedObj) { async validateFeedEntity(feedObj) {
if (feedObj.entityType === 'collection') { if (feedObj.entityType === 'collection') {
const collection = await Database.models.collection.getById(feedObj.entityId) const collection = await Database.collectionModel.getOldById(feedObj.entityId)
if (!collection) { if (!collection) {
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Collection "${feedObj.entityId}" not found`) Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Collection "${feedObj.entityId}" not found`)
return false return false
} }
} else if (feedObj.entityType === 'libraryItem') { } else if (feedObj.entityType === 'libraryItem') {
if (!Database.libraryItems.some(li => li.id === feedObj.entityId)) { const libraryItemExists = await Database.libraryItemModel.checkExistsById(feedObj.entityId)
if (!libraryItemExists) {
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Library item "${feedObj.entityId}" not found`) Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Library item "${feedObj.entityId}" not found`)
return false return false
} }
} else if (feedObj.entityType === 'series') { } else if (feedObj.entityType === 'series') {
const series = Database.series.find(s => s.id === feedObj.entityId) const series = await Database.seriesModel.getOldById(feedObj.entityId)
const hasSeriesBook = series ? Database.libraryItems.some(li => li.mediaType === 'book' && li.media.metadata.hasSeries(series.id) && li.media.tracks.length) : false if (!series) {
if (!hasSeriesBook) { Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Series "${feedObj.entityId}" not found`)
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Series "${feedObj.entityId}" not found or has no audio tracks`)
return false return false
} }
} else { } else {
@ -40,7 +41,7 @@ class RssFeedManager {
* Validate all feeds and remove invalid * Validate all feeds and remove invalid
*/ */
async init() { async init() {
const feeds = await Database.models.feed.getOldFeeds() const feeds = await Database.feedModel.getOldFeeds()
for (const feed of feeds) { for (const feed of feeds) {
// Remove invalid feeds // Remove invalid feeds
if (!await this.validateFeedEntity(feed)) { if (!await this.validateFeedEntity(feed)) {
@ -51,29 +52,29 @@ class RssFeedManager {
/** /**
* Find open feed for an entity (e.g. collection id, playlist id, library item id) * Find open feed for an entity (e.g. collection id, playlist id, library item id)
* @param {string} entityId * @param {string} entityId
* @returns {Promise<objects.Feed>} oldFeed * @returns {Promise<objects.Feed>} oldFeed
*/ */
findFeedForEntityId(entityId) { findFeedForEntityId(entityId) {
return Database.models.feed.findOneOld({ entityId }) return Database.feedModel.findOneOld({ entityId })
} }
/** /**
* Find open feed for a slug * Find open feed for a slug
* @param {string} slug * @param {string} slug
* @returns {Promise<objects.Feed>} oldFeed * @returns {Promise<objects.Feed>} oldFeed
*/ */
findFeedBySlug(slug) { findFeedBySlug(slug) {
return Database.models.feed.findOneOld({ slug }) return Database.feedModel.findOneOld({ slug })
} }
/** /**
* Find open feed for a slug * Find open feed for a slug
* @param {string} slug * @param {string} slug
* @returns {Promise<objects.Feed>} oldFeed * @returns {Promise<objects.Feed>} oldFeed
*/ */
findFeed(id) { findFeed(id) {
return Database.models.feed.findByPkOld(id) return Database.feedModel.findByPkOld(id)
} }
async getFeed(req, res) { async getFeed(req, res) {
@ -86,7 +87,7 @@ class RssFeedManager {
// Check if feed needs to be updated // Check if feed needs to be updated
if (feed.entityType === 'libraryItem') { if (feed.entityType === 'libraryItem') {
const libraryItem = Database.getLibraryItem(feed.entityId) const libraryItem = await Database.libraryItemModel.getOldById(feed.entityId)
let mostRecentlyUpdatedAt = libraryItem.updatedAt let mostRecentlyUpdatedAt = libraryItem.updatedAt
if (libraryItem.isPodcast) { if (libraryItem.isPodcast) {
@ -102,9 +103,9 @@ class RssFeedManager {
await Database.updateFeed(feed) await Database.updateFeed(feed)
} }
} else if (feed.entityType === 'collection') { } else if (feed.entityType === 'collection') {
const collection = await Database.models.collection.getById(feed.entityId) const collection = await Database.collectionModel.findByPk(feed.entityId)
if (collection) { if (collection) {
const collectionExpanded = collection.toJSONExpanded(Database.libraryItems) const collectionExpanded = await collection.getOldJsonExpanded()
// Find most recently updated item in collection // Find most recently updated item in collection
let mostRecentlyUpdatedAt = collectionExpanded.lastUpdate let mostRecentlyUpdatedAt = collectionExpanded.lastUpdate
@ -122,11 +123,12 @@ class RssFeedManager {
} }
} }
} else if (feed.entityType === 'series') { } else if (feed.entityType === 'series') {
const series = Database.series.find(s => s.id === feed.entityId) const series = await Database.seriesModel.getOldById(feed.entityId)
if (series) { if (series) {
const seriesJson = series.toJSON() const seriesJson = series.toJSON()
// Get books in series that have audio tracks // Get books in series that have audio tracks
seriesJson.books = Database.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(series.id) && li.media.tracks.length) seriesJson.books = (await libraryItemsBookFilters.getLibraryItemsForSeries(series)).filter(li => li.media.numTracks)
// Find most recently updated item in series // Find most recently updated item in series
let mostRecentlyUpdatedAt = seriesJson.updatedAt let mostRecentlyUpdatedAt = seriesJson.updatedAt
@ -260,5 +262,11 @@ class RssFeedManager {
if (!feed) return if (!feed) return
return this.handleCloseFeed(feed) return this.handleCloseFeed(feed)
} }
async getFeeds() {
const feeds = await Database.models.feed.getOldFeeds()
Logger.info(`[RssFeedManager] Fetched all feeds`)
return feeds
}
} }
module.exports = RssFeedManager module.exports = RssFeedManager

View File

@ -1,88 +1,171 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model, literal } = require('sequelize')
const oldAuthor = require('../objects/entities/Author') const oldAuthor = require('../objects/entities/Author')
module.exports = (sequelize) => { class Author extends Model {
class Author extends Model { constructor(values, options) {
static async getOldAuthors() { super(values, options)
const authors = await this.findAll()
return authors.map(au => au.getOldAuthor())
}
getOldAuthor() { /** @type {UUIDV4} */
return new oldAuthor({ this.id
id: this.id, /** @type {string} */
asin: this.asin, this.name
name: this.name, /** @type {string} */
description: this.description, this.lastFirst
imagePath: this.imagePath, /** @type {string} */
libraryId: this.libraryId, this.asin
addedAt: this.createdAt.valueOf(), /** @type {string} */
updatedAt: this.updatedAt.valueOf() this.description
}) /** @type {string} */
} this.imagePath
/** @type {UUIDV4} */
this.libraryId
/** @type {Date} */
this.updatedAt
/** @type {Date} */
this.createdAt
}
static updateFromOld(oldAuthor) { static async getOldAuthors() {
const author = this.getFromOld(oldAuthor) const authors = await this.findAll()
return this.update(author, { return authors.map(au => au.getOldAuthor())
where: { }
id: author.id
}
})
}
static createFromOld(oldAuthor) { getOldAuthor() {
const author = this.getFromOld(oldAuthor) return new oldAuthor({
return this.create(author) id: this.id,
} asin: this.asin,
name: this.name,
description: this.description,
imagePath: this.imagePath,
libraryId: this.libraryId,
addedAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf()
})
}
static createBulkFromOld(oldAuthors) { static updateFromOld(oldAuthor) {
const authors = oldAuthors.map(this.getFromOld) const author = this.getFromOld(oldAuthor)
return this.bulkCreate(authors) return this.update(author, {
} where: {
id: author.id
static getFromOld(oldAuthor) {
return {
id: oldAuthor.id,
name: oldAuthor.name,
lastFirst: oldAuthor.lastFirst,
asin: oldAuthor.asin,
description: oldAuthor.description,
imagePath: oldAuthor.imagePath,
libraryId: oldAuthor.libraryId
} }
} })
}
static removeById(authorId) { static createFromOld(oldAuthor) {
return this.destroy({ const author = this.getFromOld(oldAuthor)
where: { return this.create(author)
id: authorId }
}
}) static createBulkFromOld(oldAuthors) {
const authors = oldAuthors.map(this.getFromOld)
return this.bulkCreate(authors)
}
static getFromOld(oldAuthor) {
return {
id: oldAuthor.id,
name: oldAuthor.name,
lastFirst: oldAuthor.lastFirst,
asin: oldAuthor.asin,
description: oldAuthor.description,
imagePath: oldAuthor.imagePath,
libraryId: oldAuthor.libraryId
} }
} }
Author.init({ static removeById(authorId) {
id: { return this.destroy({
type: DataTypes.UUID, where: {
defaultValue: DataTypes.UUIDV4, id: authorId
primaryKey: true }
}, })
name: DataTypes.STRING, }
lastFirst: DataTypes.STRING,
asin: DataTypes.STRING,
description: DataTypes.TEXT,
imagePath: DataTypes.STRING
}, {
sequelize,
modelName: 'author'
})
const { library } = sequelize.models /**
library.hasMany(Author, { * Get oldAuthor by id
onDelete: 'CASCADE' * @param {string} authorId
}) * @returns {Promise<oldAuthor>}
Author.belongsTo(library) */
static async getOldById(authorId) {
const author = await this.findByPk(authorId)
if (!author) return null
return author.getOldAuthor()
}
return Author /**
} * Check if author exists
* @param {string} authorId
* @returns {Promise<boolean>}
*/
static async checkExistsById(authorId) {
return (await this.count({ where: { id: authorId } })) > 0
}
/**
* Get old author by name and libraryId. name case insensitive
* TODO: Look for authors ignoring punctuation
*
* @param {string} authorName
* @param {string} libraryId
* @returns {Promise<oldAuthor>}
*/
static async getOldByNameAndLibrary(authorName, libraryId) {
const author = (await this.findOne({
where: [
literal(`name = '${authorName}' COLLATE NOCASE`),
{
libraryId
}
]
}))?.getOldAuthor()
return author
}
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
name: DataTypes.STRING,
lastFirst: DataTypes.STRING,
asin: DataTypes.STRING,
description: DataTypes.TEXT,
imagePath: DataTypes.STRING
}, {
sequelize,
modelName: 'author',
indexes: [
{
fields: [{
name: 'name',
collate: 'NOCASE'
}]
},
// {
// fields: [{
// name: 'lastFirst',
// collate: 'NOCASE'
// }]
// },
{
fields: ['libraryId']
}
]
})
const { library } = sequelize.models
library.hasMany(Author, {
onDelete: 'CASCADE'
})
Author.belongsTo(library)
}
}
module.exports = Author

View File

@ -1,178 +1,273 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model } = require('sequelize')
const Logger = require('../Logger') const Logger = require('../Logger')
module.exports = (sequelize) => { /**
class Book extends Model { * @typedef EBookFileObject
static getOldBook(libraryItemExpanded) { * @property {string} ino
const bookExpanded = libraryItemExpanded.media * @property {string} ebookFormat
let authors = [] * @property {number} addedAt
if (bookExpanded.authors?.length) { * @property {number} updatedAt
authors = bookExpanded.authors.map(au => { * @property {{filename:string, ext:string, path:string, relPath:string, size:number, mtimeMs:number, ctimeMs:number, birthtimeMs:number}} metadata
return { */
id: au.id,
name: au.name
}
})
} else if (bookExpanded.bookAuthors?.length) {
authors = bookExpanded.bookAuthors.map(ba => {
if (ba.author) {
return {
id: ba.author.id,
name: ba.author.name
}
} else {
Logger.error(`[Book] Invalid bookExpanded bookAuthors: no author`, ba)
return null
}
}).filter(a => a)
}
let series = [] /**
if (bookExpanded.series?.length) { * @typedef ChapterObject
series = bookExpanded.series.map(se => { * @property {number} id
return { * @property {number} start
id: se.id, * @property {number} end
name: se.name, * @property {string} title
sequence: se.bookSeries.sequence */
}
})
} else if (bookExpanded.bookSeries?.length) {
series = bookExpanded.bookSeries.map(bs => {
if (bs.series) {
return {
id: bs.series.id,
name: bs.series.name,
sequence: bs.sequence
}
} else {
Logger.error(`[Book] Invalid bookExpanded bookSeries: no series`, bs)
return null
}
}).filter(s => s)
}
return { /**
id: bookExpanded.id, * @typedef AudioFileObject
libraryItemId: libraryItemExpanded.id, * @property {number} index
coverPath: bookExpanded.coverPath, * @property {string} ino
tags: bookExpanded.tags, * @property {{filename:string, ext:string, path:string, relPath:string, size:number, mtimeMs:number, ctimeMs:number, birthtimeMs:number}} metadata
audioFiles: bookExpanded.audioFiles, * @property {number} addedAt
chapters: bookExpanded.chapters, * @property {number} updatedAt
ebookFile: bookExpanded.ebookFile, * @property {number} trackNumFromMeta
metadata: { * @property {number} discNumFromMeta
title: bookExpanded.title, * @property {number} trackNumFromFilename
subtitle: bookExpanded.subtitle, * @property {number} discNumFromFilename
authors: authors, * @property {boolean} manuallyVerified
narrators: bookExpanded.narrators, * @property {string} format
series: series, * @property {number} duration
genres: bookExpanded.genres, * @property {number} bitRate
publishedYear: bookExpanded.publishedYear, * @property {string} language
publishedDate: bookExpanded.publishedDate, * @property {string} codec
publisher: bookExpanded.publisher, * @property {string} timeBase
description: bookExpanded.description, * @property {number} channels
isbn: bookExpanded.isbn, * @property {string} channelLayout
asin: bookExpanded.asin, * @property {ChapterObject[]} chapters
language: bookExpanded.language, * @property {Object} metaTags
explicit: bookExpanded.explicit, * @property {string} mimeType
abridged: bookExpanded.abridged */
class Book extends Model {
constructor(values, options) {
super(values, options)
/** @type {string} */
this.id
/** @type {string} */
this.title
/** @type {string} */
this.titleIgnorePrefix
/** @type {string} */
this.publishedYear
/** @type {string} */
this.publishedDate
/** @type {string} */
this.publisher
/** @type {string} */
this.description
/** @type {string} */
this.isbn
/** @type {string} */
this.asin
/** @type {string} */
this.language
/** @type {boolean} */
this.explicit
/** @type {boolean} */
this.abridged
/** @type {string} */
this.coverPath
/** @type {number} */
this.duration
/** @type {string[]} */
this.narrators
/** @type {AudioFileObject[]} */
this.audioFiles
/** @type {EBookFileObject} */
this.ebookFile
/** @type {ChapterObject[]} */
this.chapters
/** @type {string[]} */
this.tags
/** @type {string[]} */
this.genres
/** @type {Date} */
this.updatedAt
/** @type {Date} */
this.createdAt
}
static getOldBook(libraryItemExpanded) {
const bookExpanded = libraryItemExpanded.media
let authors = []
if (bookExpanded.authors?.length) {
authors = bookExpanded.authors.map(au => {
return {
id: au.id,
name: au.name
} }
}
}
/**
* @param {object} oldBook
* @returns {boolean} true if updated
*/
static saveFromOld(oldBook) {
const book = this.getFromOld(oldBook)
return this.update(book, {
where: {
id: book.id
}
}).then(result => result[0] > 0).catch((error) => {
Logger.error(`[Book] Failed to save book ${book.id}`, error)
return false
}) })
} else if (bookExpanded.bookAuthors?.length) {
authors = bookExpanded.bookAuthors.map(ba => {
if (ba.author) {
return {
id: ba.author.id,
name: ba.author.name
}
} else {
Logger.error(`[Book] Invalid bookExpanded bookAuthors: no author`, ba)
return null
}
}).filter(a => a)
} }
static getFromOld(oldBook) { let series = []
return { if (bookExpanded.series?.length) {
id: oldBook.id, series = bookExpanded.series.map(se => {
title: oldBook.metadata.title, return {
titleIgnorePrefix: oldBook.metadata.titleIgnorePrefix, id: se.id,
subtitle: oldBook.metadata.subtitle, name: se.name,
publishedYear: oldBook.metadata.publishedYear, sequence: se.bookSeries.sequence
publishedDate: oldBook.metadata.publishedDate, }
publisher: oldBook.metadata.publisher, })
description: oldBook.metadata.description, } else if (bookExpanded.bookSeries?.length) {
isbn: oldBook.metadata.isbn, series = bookExpanded.bookSeries.map(bs => {
asin: oldBook.metadata.asin, if (bs.series) {
language: oldBook.metadata.language, return {
explicit: !!oldBook.metadata.explicit, id: bs.series.id,
abridged: !!oldBook.metadata.abridged, name: bs.series.name,
narrators: oldBook.metadata.narrators, sequence: bs.sequence
ebookFile: oldBook.ebookFile?.toJSON() || null, }
coverPath: oldBook.coverPath, } else {
duration: oldBook.duration, Logger.error(`[Book] Invalid bookExpanded bookSeries: no series`, bs)
audioFiles: oldBook.audioFiles?.map(af => af.toJSON()) || [], return null
chapters: oldBook.chapters, }
tags: oldBook.tags, }).filter(s => s)
genres: oldBook.metadata.genres }
return {
id: bookExpanded.id,
libraryItemId: libraryItemExpanded.id,
coverPath: bookExpanded.coverPath,
tags: bookExpanded.tags,
audioFiles: bookExpanded.audioFiles,
chapters: bookExpanded.chapters,
ebookFile: bookExpanded.ebookFile,
metadata: {
title: bookExpanded.title,
subtitle: bookExpanded.subtitle,
authors: authors,
narrators: bookExpanded.narrators,
series: series,
genres: bookExpanded.genres,
publishedYear: bookExpanded.publishedYear,
publishedDate: bookExpanded.publishedDate,
publisher: bookExpanded.publisher,
description: bookExpanded.description,
isbn: bookExpanded.isbn,
asin: bookExpanded.asin,
language: bookExpanded.language,
explicit: bookExpanded.explicit,
abridged: bookExpanded.abridged
} }
} }
} }
Book.init({ /**
id: { * @param {object} oldBook
type: DataTypes.UUID, * @returns {boolean} true if updated
defaultValue: DataTypes.UUIDV4, */
primaryKey: true static saveFromOld(oldBook) {
}, const book = this.getFromOld(oldBook)
title: DataTypes.STRING, return this.update(book, {
titleIgnorePrefix: DataTypes.STRING, where: {
subtitle: DataTypes.STRING, id: book.id
publishedYear: DataTypes.STRING,
publishedDate: DataTypes.STRING,
publisher: DataTypes.STRING,
description: DataTypes.TEXT,
isbn: DataTypes.STRING,
asin: DataTypes.STRING,
language: DataTypes.STRING,
explicit: DataTypes.BOOLEAN,
abridged: DataTypes.BOOLEAN,
coverPath: DataTypes.STRING,
duration: DataTypes.FLOAT,
narrators: DataTypes.JSON,
audioFiles: DataTypes.JSON,
ebookFile: DataTypes.JSON,
chapters: DataTypes.JSON,
tags: DataTypes.JSON,
genres: DataTypes.JSON
}, {
sequelize,
modelName: 'book',
indexes: [
{
fields: [{
name: 'title',
collate: 'NOCASE'
}]
},
{
fields: [{
name: 'titleIgnorePrefix',
collate: 'NOCASE'
}]
},
{
fields: ['publishedYear']
},
{
fields: ['duration']
} }
] }).then(result => result[0] > 0).catch((error) => {
}) Logger.error(`[Book] Failed to save book ${book.id}`, error)
return false
})
}
return Book static getFromOld(oldBook) {
} return {
id: oldBook.id,
title: oldBook.metadata.title,
titleIgnorePrefix: oldBook.metadata.titleIgnorePrefix,
subtitle: oldBook.metadata.subtitle,
publishedYear: oldBook.metadata.publishedYear,
publishedDate: oldBook.metadata.publishedDate,
publisher: oldBook.metadata.publisher,
description: oldBook.metadata.description,
isbn: oldBook.metadata.isbn,
asin: oldBook.metadata.asin,
language: oldBook.metadata.language,
explicit: !!oldBook.metadata.explicit,
abridged: !!oldBook.metadata.abridged,
narrators: oldBook.metadata.narrators,
ebookFile: oldBook.ebookFile?.toJSON() || null,
coverPath: oldBook.coverPath,
duration: oldBook.duration,
audioFiles: oldBook.audioFiles?.map(af => af.toJSON()) || [],
chapters: oldBook.chapters,
tags: oldBook.tags,
genres: oldBook.metadata.genres
}
}
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
title: DataTypes.STRING,
titleIgnorePrefix: DataTypes.STRING,
subtitle: DataTypes.STRING,
publishedYear: DataTypes.STRING,
publishedDate: DataTypes.STRING,
publisher: DataTypes.STRING,
description: DataTypes.TEXT,
isbn: DataTypes.STRING,
asin: DataTypes.STRING,
language: DataTypes.STRING,
explicit: DataTypes.BOOLEAN,
abridged: DataTypes.BOOLEAN,
coverPath: DataTypes.STRING,
duration: DataTypes.FLOAT,
narrators: DataTypes.JSON,
audioFiles: DataTypes.JSON,
ebookFile: DataTypes.JSON,
chapters: DataTypes.JSON,
tags: DataTypes.JSON,
genres: DataTypes.JSON
}, {
sequelize,
modelName: 'book',
indexes: [
{
fields: [{
name: 'title',
collate: 'NOCASE'
}]
},
// {
// fields: [{
// name: 'titleIgnorePrefix',
// collate: 'NOCASE'
// }]
// },
{
fields: ['publishedYear']
},
// {
// fields: ['duration']
// }
]
})
}
}
module.exports = Book

View File

@ -1,41 +1,57 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model } = require('sequelize')
module.exports = (sequelize) => { class BookAuthor extends Model {
class BookAuthor extends Model { constructor(values, options) {
static removeByIds(authorId = null, bookId = null) { super(values, options)
const where = {}
if (authorId) where.authorId = authorId /** @type {UUIDV4} */
if (bookId) where.bookId = bookId this.id
return this.destroy({ /** @type {UUIDV4} */
where this.bookId
}) /** @type {UUIDV4} */
} this.authorId
/** @type {Date} */
this.createdAt
} }
BookAuthor.init({ static removeByIds(authorId = null, bookId = null) {
id: { const where = {}
type: DataTypes.UUID, if (authorId) where.authorId = authorId
defaultValue: DataTypes.UUIDV4, if (bookId) where.bookId = bookId
primaryKey: true return this.destroy({
} where
}, { })
sequelize, }
modelName: 'bookAuthor',
timestamps: true,
updatedAt: false
})
// Super Many-to-Many /**
// ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship * Initialize model
const { book, author } = sequelize.models * @param {import('../Database').sequelize} sequelize
book.belongsToMany(author, { through: BookAuthor }) */
author.belongsToMany(book, { through: BookAuthor }) static init(sequelize) {
super.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
}
}, {
sequelize,
modelName: 'bookAuthor',
timestamps: true,
updatedAt: false
})
book.hasMany(BookAuthor) // Super Many-to-Many
BookAuthor.belongsTo(book) // ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship
const { book, author } = sequelize.models
book.belongsToMany(author, { through: BookAuthor })
author.belongsToMany(book, { through: BookAuthor })
author.hasMany(BookAuthor) book.hasMany(BookAuthor)
BookAuthor.belongsTo(author) BookAuthor.belongsTo(book)
return BookAuthor author.hasMany(BookAuthor)
} BookAuthor.belongsTo(author)
}
}
module.exports = BookAuthor

View File

@ -1,42 +1,65 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model } = require('sequelize')
module.exports = (sequelize) => { class BookSeries extends Model {
class BookSeries extends Model { constructor(values, options) {
static removeByIds(seriesId = null, bookId = null) { super(values, options)
const where = {}
if (seriesId) where.seriesId = seriesId /** @type {UUIDV4} */
if (bookId) where.bookId = bookId this.id
return this.destroy({ /** @type {string} */
where this.sequence
}) /** @type {UUIDV4} */
} this.bookId
/** @type {UUIDV4} */
this.seriesId
/** @type {Date} */
this.createdAt
} }
BookSeries.init({ static removeByIds(seriesId = null, bookId = null) {
id: { const where = {}
type: DataTypes.UUID, if (seriesId) where.seriesId = seriesId
defaultValue: DataTypes.UUIDV4, if (bookId) where.bookId = bookId
primaryKey: true return this.destroy({
}, where
sequence: DataTypes.STRING })
}, { }
sequelize,
modelName: 'bookSeries',
timestamps: true,
updatedAt: false
})
// Super Many-to-Many /**
// ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship * Initialize model
const { book, series } = sequelize.models * @param {import('../Database').sequelize} sequelize
book.belongsToMany(series, { through: BookSeries }) */
series.belongsToMany(book, { through: BookSeries }) static init(sequelize) {
super.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
sequence: DataTypes.STRING
}, {
sequelize,
modelName: 'bookSeries',
timestamps: true,
updatedAt: false
})
book.hasMany(BookSeries) // Super Many-to-Many
BookSeries.belongsTo(book) // ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship
const { book, series } = sequelize.models
book.belongsToMany(series, { through: BookSeries })
series.belongsToMany(book, { through: BookSeries })
series.hasMany(BookSeries) book.hasMany(BookSeries, {
BookSeries.belongsTo(series) onDelete: 'CASCADE'
})
BookSeries.belongsTo(book)
return BookSeries series.hasMany(BookSeries, {
} onDelete: 'CASCADE'
})
BookSeries.belongsTo(series)
}
}
module.exports = BookSeries

View File

@ -1,284 +1,342 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model, Sequelize } = require('sequelize')
const oldCollection = require('../objects/Collection') const oldCollection = require('../objects/Collection')
const { areEquivalent } = require('../utils/index')
module.exports = (sequelize) => {
class Collection extends Model { class Collection extends Model {
/** constructor(values, options) {
* Get all old collections super(values, options)
* @returns {Promise<oldCollection[]>}
*/ /** @type {UUIDV4} */
static async getOldCollections() { this.id
const collections = await this.findAll({ /** @type {string} */
include: { this.name
model: sequelize.models.book, /** @type {string} */
include: sequelize.models.libraryItem this.description
}, /** @type {UUIDV4} */
order: [[sequelize.models.book, sequelize.models.collectionBook, 'order', 'ASC']] this.libraryId
}) /** @type {Date} */
return collections.map(c => this.getOldCollection(c)) this.updatedAt
/** @type {Date} */
this.createdAt
}
/**
* Get all old collections
* @returns {Promise<oldCollection[]>}
*/
static async getOldCollections() {
const collections = await this.findAll({
include: {
model: this.sequelize.models.book,
include: this.sequelize.models.libraryItem
},
order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']]
})
return collections.map(c => this.getOldCollection(c))
}
/**
* Get all old collections toJSONExpanded, items filtered for user permissions
* @param {[oldUser]} user
* @param {[string]} libraryId
* @param {[string[]]} include
* @returns {Promise<object[]>} oldCollection.toJSONExpanded
*/
static async getOldCollectionsJsonExpanded(user, libraryId, include) {
let collectionWhere = null
if (libraryId) {
collectionWhere = {
libraryId
}
} }
/** // Optionally include rssfeed for collection
* Get all old collections toJSONExpanded, items filtered for user permissions const collectionIncludes = []
* @param {[oldUser]} user if (include.includes('rssfeed')) {
* @param {[string]} libraryId collectionIncludes.push({
* @param {[string[]]} include model: this.sequelize.models.feed
* @returns {Promise<object[]>} oldCollection.toJSONExpanded
*/
static async getOldCollectionsJsonExpanded(user, libraryId, include) {
let collectionWhere = null
if (libraryId) {
collectionWhere = {
libraryId
}
}
// Optionally include rssfeed for collection
const collectionIncludes = []
if (include.includes('rssfeed')) {
collectionIncludes.push({
model: sequelize.models.feed
})
}
const collections = await this.findAll({
where: collectionWhere,
include: [
{
model: sequelize.models.book,
include: [
{
model: sequelize.models.libraryItem
},
{
model: sequelize.models.author,
through: {
attributes: []
}
},
{
model: sequelize.models.series,
through: {
attributes: ['sequence']
}
},
]
},
...collectionIncludes
],
order: [[sequelize.models.book, sequelize.models.collectionBook, 'order', 'ASC']]
}) })
// TODO: Handle user permission restrictions on initial query }
return collections.map(c => {
const oldCollection = this.getOldCollection(c)
// Filter books using user permissions const collections = await this.findAll({
const books = c.books?.filter(b => { where: collectionWhere,
if (user) { include: [
if (b.tags?.length && !user.checkCanAccessLibraryItemWithTags(b.tags)) { {
return false model: this.sequelize.models.book,
} include: [
if (b.explicit === true && !user.canAccessExplicitContent) { {
return false model: this.sequelize.models.libraryItem
} },
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
},
]
},
...collectionIncludes
],
order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']]
})
// TODO: Handle user permission restrictions on initial query
return collections.map(c => {
const oldCollection = this.getOldCollection(c)
// Filter books using user permissions
const books = c.books?.filter(b => {
if (user) {
if (b.tags?.length && !user.checkCanAccessLibraryItemWithTags(b.tags)) {
return false
}
if (b.explicit === true && !user.canAccessExplicitContent) {
return false
} }
return true
}) || []
// Map to library items
const libraryItems = books.map(b => {
const libraryItem = b.libraryItem
delete b.libraryItem
libraryItem.media = b
return sequelize.models.libraryItem.getOldLibraryItem(libraryItem)
})
// Users with restricted permissions will not see this collection
if (!books.length && oldCollection.books.length) {
return null
} }
return true
}) || []
const collectionExpanded = oldCollection.toJSONExpanded(libraryItems) // Map to library items
const libraryItems = books.map(b => {
// Map feed if found const libraryItem = b.libraryItem
if (c.feeds?.length) { delete b.libraryItem
collectionExpanded.rssFeed = sequelize.models.feed.getOldFeed(c.feeds[0]) libraryItem.media = b
} return this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem)
return collectionExpanded
}).filter(c => c)
}
/**
* Get old collection from Collection
* @param {Collection} collectionExpanded
* @returns {oldCollection}
*/
static getOldCollection(collectionExpanded) {
const libraryItemIds = collectionExpanded.books?.map(b => b.libraryItem?.id || null).filter(lid => lid) || []
return new oldCollection({
id: collectionExpanded.id,
libraryId: collectionExpanded.libraryId,
name: collectionExpanded.name,
description: collectionExpanded.description,
books: libraryItemIds,
lastUpdate: collectionExpanded.updatedAt.valueOf(),
createdAt: collectionExpanded.createdAt.valueOf()
}) })
}
static createFromOld(oldCollection) { // Users with restricted permissions will not see this collection
const collection = this.getFromOld(oldCollection) if (!books.length && oldCollection.books.length) {
return this.create(collection) return null
}
static async fullUpdateFromOld(oldCollection, collectionBooks) {
const existingCollection = await this.findByPk(oldCollection.id, {
include: sequelize.models.collectionBook
})
if (!existingCollection) return false
let hasUpdates = false
const collection = this.getFromOld(oldCollection)
for (const cb of collectionBooks) {
const existingCb = existingCollection.collectionBooks.find(i => i.bookId === cb.bookId)
if (!existingCb) {
await sequelize.models.collectionBook.create(cb)
hasUpdates = true
} else if (existingCb.order != cb.order) {
await existingCb.update({ order: cb.order })
hasUpdates = true
}
}
for (const cb of existingCollection.collectionBooks) {
// collectionBook was removed
if (!collectionBooks.some(i => i.bookId === cb.bookId)) {
await cb.destroy()
hasUpdates = true
}
} }
let hasCollectionUpdates = false const collectionExpanded = oldCollection.toJSONExpanded(libraryItems)
for (const key in collection) {
let existingValue = existingCollection[key]
if (existingValue instanceof Date) existingValue = existingValue.valueOf()
if (!areEquivalent(collection[key], existingValue)) {
hasCollectionUpdates = true
}
}
if (hasCollectionUpdates) {
existingCollection.update(collection)
hasUpdates = true
}
return hasUpdates
}
static getFromOld(oldCollection) { // Map feed if found
return { if (c.feeds?.length) {
id: oldCollection.id, collectionExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(c.feeds[0])
name: oldCollection.name,
description: oldCollection.description,
libraryId: oldCollection.libraryId
} }
}
static removeById(collectionId) { return collectionExpanded
return this.destroy({ }).filter(c => c)
where: { }
id: collectionId
}
})
}
/** /**
* Get collection by id * Get old collection toJSONExpanded, items filtered for user permissions
* @param {string} collectionId * @param {[oldUser]} user
* @returns {Promise<oldCollection|null>} returns null if not found * @param {[string[]]} include
*/ * @returns {Promise<object>} oldCollection.toJSONExpanded
static async getById(collectionId) { */
if (!collectionId) return null async getOldJsonExpanded(user, include) {
const collection = await this.findByPk(collectionId, { this.books = await this.getBooks({
include: { include: [
model: sequelize.models.book, {
include: sequelize.models.libraryItem model: this.sequelize.models.libraryItem
},
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
}, },
order: [[sequelize.models.book, sequelize.models.collectionBook, 'order', 'ASC']]
})
if (!collection) return null
return this.getOldCollection(collection)
}
/** ],
* Remove all collections belonging to library order: [Sequelize.literal('`collectionBook.order` ASC')]
* @param {string} libraryId }) || []
* @returns {Promise<number>} number of collections destroyed
*/ const oldCollection = this.sequelize.models.collection.getOldCollection(this)
static async removeAllForLibrary(libraryId) {
if (!libraryId) return 0 // Filter books using user permissions
return this.destroy({ // TODO: Handle user permission restrictions on initial query
where: { const books = this.books?.filter(b => {
libraryId if (user) {
if (b.tags?.length && !user.checkCanAccessLibraryItemWithTags(b.tags)) {
return false
} }
}) if (b.explicit === true && !user.canAccessExplicitContent) {
return false
}
}
return true
}) || []
// Map to library items
const libraryItems = books.map(b => {
const libraryItem = b.libraryItem
delete b.libraryItem
libraryItem.media = b
return this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem)
})
// Users with restricted permissions will not see this collection
if (!books.length && oldCollection.books.length) {
return null
} }
/** const collectionExpanded = oldCollection.toJSONExpanded(libraryItems)
* Get all collections for a library
* @param {string} libraryId if (include?.includes('rssfeed')) {
* @returns {Promise<oldCollection[]>} const feeds = await this.getFeeds()
*/ if (feeds?.length) {
static async getAllForLibrary(libraryId) { collectionExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(feeds[0])
if (!libraryId) return [] }
const collections = await this.findAll({
where: {
libraryId
},
include: {
model: sequelize.models.book,
include: sequelize.models.libraryItem
},
order: [[sequelize.models.book, sequelize.models.collectionBook, 'order', 'ASC']]
})
return collections.map(c => this.getOldCollection(c))
} }
static async getAllForBook(bookId) { return collectionExpanded
const collections = await this.findAll({ }
include: {
model: sequelize.models.book, /**
where: { * Get old collection from Collection
id: bookId * @param {Collection} collectionExpanded
}, * @returns {oldCollection}
required: true, */
include: sequelize.models.libraryItem static getOldCollection(collectionExpanded) {
}, const libraryItemIds = collectionExpanded.books?.map(b => b.libraryItem?.id || null).filter(lid => lid) || []
order: [[sequelize.models.book, sequelize.models.collectionBook, 'order', 'ASC']] return new oldCollection({
}) id: collectionExpanded.id,
return collections.map(c => this.getOldCollection(c)) libraryId: collectionExpanded.libraryId,
name: collectionExpanded.name,
description: collectionExpanded.description,
books: libraryItemIds,
lastUpdate: collectionExpanded.updatedAt.valueOf(),
createdAt: collectionExpanded.createdAt.valueOf()
})
}
static createFromOld(oldCollection) {
const collection = this.getFromOld(oldCollection)
return this.create(collection)
}
static getFromOld(oldCollection) {
return {
id: oldCollection.id,
name: oldCollection.name,
description: oldCollection.description,
libraryId: oldCollection.libraryId
} }
} }
Collection.init({ static removeById(collectionId) {
id: { return this.destroy({
type: DataTypes.UUID, where: {
defaultValue: DataTypes.UUIDV4, id: collectionId
primaryKey: true }
}, })
name: DataTypes.STRING, }
description: DataTypes.TEXT
}, {
sequelize,
modelName: 'collection'
})
const { library } = sequelize.models /**
* Get old collection by id
* @param {string} collectionId
* @returns {Promise<oldCollection|null>} returns null if not found
*/
static async getOldById(collectionId) {
if (!collectionId) return null
const collection = await this.findByPk(collectionId, {
include: {
model: this.sequelize.models.book,
include: this.sequelize.models.libraryItem
},
order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']]
})
if (!collection) return null
return this.getOldCollection(collection)
}
library.hasMany(Collection) /**
Collection.belongsTo(library) * Get old collection from current
* @returns {Promise<oldCollection>}
*/
async getOld() {
this.books = await this.getBooks({
include: [
{
model: this.sequelize.models.libraryItem
},
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
},
return Collection ],
} order: [Sequelize.literal('`collectionBook.order` ASC')]
}) || []
return this.sequelize.models.collection.getOldCollection(this)
}
/**
* Remove all collections belonging to library
* @param {string} libraryId
* @returns {Promise<number>} number of collections destroyed
*/
static async removeAllForLibrary(libraryId) {
if (!libraryId) return 0
return this.destroy({
where: {
libraryId
}
})
}
static async getAllForBook(bookId) {
const collections = await this.findAll({
include: {
model: this.sequelize.models.book,
where: {
id: bookId
},
required: true,
include: this.sequelize.models.libraryItem
},
order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']]
})
return collections.map(c => this.getOldCollection(c))
}
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
name: DataTypes.STRING,
description: DataTypes.TEXT
}, {
sequelize,
modelName: 'collection'
})
const { library } = sequelize.models
library.hasMany(Collection)
Collection.belongsTo(library)
}
}
module.exports = Collection

View File

@ -1,46 +1,61 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model } = require('sequelize')
module.exports = (sequelize) => { class CollectionBook extends Model {
class CollectionBook extends Model { constructor(values, options) {
static removeByIds(collectionId, bookId) { super(values, options)
return this.destroy({
where: { /** @type {UUIDV4} */
bookId, this.id
collectionId /** @type {number} */
} this.order
}) /** @type {UUIDV4} */
} this.bookId
/** @type {UUIDV4} */
this.collectionId
/** @type {Date} */
this.createdAt
} }
CollectionBook.init({ static removeByIds(collectionId, bookId) {
id: { return this.destroy({
type: DataTypes.UUID, where: {
defaultValue: DataTypes.UUIDV4, bookId,
primaryKey: true collectionId
}, }
order: DataTypes.INTEGER })
}, { }
sequelize,
timestamps: true,
updatedAt: false,
modelName: 'collectionBook'
})
// Super Many-to-Many static init(sequelize) {
// ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship super.init({
const { book, collection } = sequelize.models id: {
book.belongsToMany(collection, { through: CollectionBook }) type: DataTypes.UUID,
collection.belongsToMany(book, { through: CollectionBook }) defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
order: DataTypes.INTEGER
}, {
sequelize,
timestamps: true,
updatedAt: false,
modelName: 'collectionBook'
})
book.hasMany(CollectionBook, { // Super Many-to-Many
onDelete: 'CASCADE' // ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship
}) const { book, collection } = sequelize.models
CollectionBook.belongsTo(book) book.belongsToMany(collection, { through: CollectionBook })
collection.belongsToMany(book, { through: CollectionBook })
collection.hasMany(CollectionBook, { book.hasMany(CollectionBook, {
onDelete: 'CASCADE' onDelete: 'CASCADE'
}) })
CollectionBook.belongsTo(collection) CollectionBook.belongsTo(book)
return CollectionBook collection.hasMany(CollectionBook, {
} onDelete: 'CASCADE'
})
CollectionBook.belongsTo(collection)
}
}
module.exports = CollectionBook

View File

@ -1,116 +1,147 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model } = require('sequelize')
const oldDevice = require('../objects/DeviceInfo') const oldDevice = require('../objects/DeviceInfo')
module.exports = (sequelize) => { class Device extends Model {
class Device extends Model { constructor(values, options) {
getOldDevice() { super(values, options)
let browserVersion = null
let sdkVersion = null
if (this.clientName === 'Abs Android') {
sdkVersion = this.deviceVersion || null
} else {
browserVersion = this.deviceVersion || null
}
return new oldDevice({ /** @type {UUIDV4} */
id: this.id, this.id
deviceId: this.deviceId, /** @type {string} */
userId: this.userId, this.deviceId
ipAddress: this.ipAddress, /** @type {string} */
browserName: this.extraData.browserName || null, this.clientName
browserVersion, /** @type {string} */
osName: this.extraData.osName || null, this.clientVersion
osVersion: this.extraData.osVersion || null, /** @type {string} */
clientVersion: this.clientVersion || null, this.ipAddress
manufacturer: this.extraData.manufacturer || null, /** @type {string} */
model: this.extraData.model || null, this.deviceName
sdkVersion, /** @type {string} */
deviceName: this.deviceName, this.deviceVersion
clientName: this.clientName /** @type {object} */
}) this.extraData
/** @type {UUIDV4} */
this.userId
/** @type {Date} */
this.createdAt
/** @type {Date} */
this.updatedAt
}
getOldDevice() {
let browserVersion = null
let sdkVersion = null
if (this.clientName === 'Abs Android') {
sdkVersion = this.deviceVersion || null
} else {
browserVersion = this.deviceVersion || null
} }
static async getOldDeviceByDeviceId(deviceId) { return new oldDevice({
const device = await this.findOne({ id: this.id,
where: { deviceId: this.deviceId,
deviceId userId: this.userId,
} ipAddress: this.ipAddress,
}) browserName: this.extraData.browserName || null,
if (!device) return null browserVersion,
return device.getOldDevice() osName: this.extraData.osName || null,
osVersion: this.extraData.osVersion || null,
clientVersion: this.clientVersion || null,
manufacturer: this.extraData.manufacturer || null,
model: this.extraData.model || null,
sdkVersion,
deviceName: this.deviceName,
clientName: this.clientName
})
}
static async getOldDeviceByDeviceId(deviceId) {
const device = await this.findOne({
where: {
deviceId
}
})
if (!device) return null
return device.getOldDevice()
}
static createFromOld(oldDevice) {
const device = this.getFromOld(oldDevice)
return this.create(device)
}
static updateFromOld(oldDevice) {
const device = this.getFromOld(oldDevice)
return this.update(device, {
where: {
id: device.id
}
})
}
static getFromOld(oldDeviceInfo) {
let extraData = {}
if (oldDeviceInfo.manufacturer) {
extraData.manufacturer = oldDeviceInfo.manufacturer
}
if (oldDeviceInfo.model) {
extraData.model = oldDeviceInfo.model
}
if (oldDeviceInfo.osName) {
extraData.osName = oldDeviceInfo.osName
}
if (oldDeviceInfo.osVersion) {
extraData.osVersion = oldDeviceInfo.osVersion
}
if (oldDeviceInfo.browserName) {
extraData.browserName = oldDeviceInfo.browserName
} }
static createFromOld(oldDevice) { return {
const device = this.getFromOld(oldDevice) id: oldDeviceInfo.id,
return this.create(device) deviceId: oldDeviceInfo.deviceId,
} clientName: oldDeviceInfo.clientName || null,
clientVersion: oldDeviceInfo.clientVersion || null,
static updateFromOld(oldDevice) { ipAddress: oldDeviceInfo.ipAddress,
const device = this.getFromOld(oldDevice) deviceName: oldDeviceInfo.deviceName || null,
return this.update(device, { deviceVersion: oldDeviceInfo.sdkVersion || oldDeviceInfo.browserVersion || null,
where: { userId: oldDeviceInfo.userId,
id: device.id extraData
}
})
}
static getFromOld(oldDeviceInfo) {
let extraData = {}
if (oldDeviceInfo.manufacturer) {
extraData.manufacturer = oldDeviceInfo.manufacturer
}
if (oldDeviceInfo.model) {
extraData.model = oldDeviceInfo.model
}
if (oldDeviceInfo.osName) {
extraData.osName = oldDeviceInfo.osName
}
if (oldDeviceInfo.osVersion) {
extraData.osVersion = oldDeviceInfo.osVersion
}
if (oldDeviceInfo.browserName) {
extraData.browserName = oldDeviceInfo.browserName
}
return {
id: oldDeviceInfo.id,
deviceId: oldDeviceInfo.deviceId,
clientName: oldDeviceInfo.clientName || null,
clientVersion: oldDeviceInfo.clientVersion || null,
ipAddress: oldDeviceInfo.ipAddress,
deviceName: oldDeviceInfo.deviceName || null,
deviceVersion: oldDeviceInfo.sdkVersion || oldDeviceInfo.browserVersion || null,
userId: oldDeviceInfo.userId,
extraData
}
} }
} }
Device.init({ /**
id: { * Initialize model
type: DataTypes.UUID, * @param {import('../Database').sequelize} sequelize
defaultValue: DataTypes.UUIDV4, */
primaryKey: true static init(sequelize) {
}, super.init({
deviceId: DataTypes.STRING, id: {
clientName: DataTypes.STRING, // e.g. Abs Web, Abs Android type: DataTypes.UUID,
clientVersion: DataTypes.STRING, // e.g. Server version or mobile version defaultValue: DataTypes.UUIDV4,
ipAddress: DataTypes.STRING, primaryKey: true
deviceName: DataTypes.STRING, // e.g. Windows 10 Chrome, Google Pixel 6, Apple iPhone 10,3 },
deviceVersion: DataTypes.STRING, // e.g. Browser version or Android SDK deviceId: DataTypes.STRING,
extraData: DataTypes.JSON clientName: DataTypes.STRING, // e.g. Abs Web, Abs Android
}, { clientVersion: DataTypes.STRING, // e.g. Server version or mobile version
sequelize, ipAddress: DataTypes.STRING,
modelName: 'device' deviceName: DataTypes.STRING, // e.g. Windows 10 Chrome, Google Pixel 6, Apple iPhone 10,3
}) deviceVersion: DataTypes.STRING, // e.g. Browser version or Android SDK
extraData: DataTypes.JSON
}, {
sequelize,
modelName: 'device'
})
const { user } = sequelize.models const { user } = sequelize.models
user.hasMany(Device, { user.hasMany(Device, {
onDelete: 'CASCADE' onDelete: 'CASCADE'
}) })
Device.belongsTo(user) Device.belongsTo(user)
}
}
return Device module.exports = Device
}

View File

@ -1,307 +1,361 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model } = require('sequelize')
const oldFeed = require('../objects/Feed') const oldFeed = require('../objects/Feed')
const areEquivalent = require('../utils/areEquivalent') const areEquivalent = require('../utils/areEquivalent')
/*
* Polymorphic association: https://sequelize.org/docs/v6/advanced-association-concepts/polymorphic-associations/
* Feeds can be created from LibraryItem, Collection, Playlist or Series
*/
module.exports = (sequelize) => {
class Feed extends Model {
static async getOldFeeds() {
const feeds = await this.findAll({
include: {
model: sequelize.models.feedEpisode
}
})
return feeds.map(f => this.getOldFeed(f))
}
/** class Feed extends Model {
* Get old feed from Feed and optionally Feed with FeedEpisodes constructor(values, options) {
* @param {Feed} feedExpanded super(values, options)
* @returns {oldFeed}
*/ /** @type {UUIDV4} */
static getOldFeed(feedExpanded) { this.id
const episodes = feedExpanded.feedEpisodes?.map((feedEpisode) => feedEpisode.getOldEpisode()) /** @type {string} */
return new oldFeed({ this.slug
id: feedExpanded.id, /** @type {string} */
slug: feedExpanded.slug, this.entityType
userId: feedExpanded.userId, /** @type {UUIDV4} */
entityType: feedExpanded.entityType, this.entityId
entityId: feedExpanded.entityId, /** @type {Date} */
entityUpdatedAt: feedExpanded.entityUpdatedAt?.valueOf() || null, this.entityUpdatedAt
coverPath: feedExpanded.coverPath || null, /** @type {string} */
meta: { this.serverAddress
title: feedExpanded.title, /** @type {string} */
description: feedExpanded.description, this.feedURL
author: feedExpanded.author, /** @type {string} */
imageUrl: feedExpanded.imageURL, this.imageURL
feedUrl: feedExpanded.feedURL, /** @type {string} */
link: feedExpanded.siteURL, this.siteURL
explicit: feedExpanded.explicit, /** @type {string} */
type: feedExpanded.podcastType, this.title
language: feedExpanded.language, /** @type {string} */
preventIndexing: feedExpanded.preventIndexing, this.description
ownerName: feedExpanded.ownerName, /** @type {string} */
ownerEmail: feedExpanded.ownerEmail this.author
}, /** @type {string} */
serverAddress: feedExpanded.serverAddress, this.podcastType
/** @type {string} */
this.language
/** @type {string} */
this.ownerName
/** @type {string} */
this.ownerEmail
/** @type {boolean} */
this.explicit
/** @type {boolean} */
this.preventIndexing
/** @type {string} */
this.coverPath
/** @type {UUIDV4} */
this.userId
/** @type {Date} */
this.createdAt
/** @type {Date} */
this.updatedAt
}
static async getOldFeeds() {
const feeds = await this.findAll({
include: {
model: this.sequelize.models.feedEpisode
}
})
return feeds.map(f => this.getOldFeed(f))
}
/**
* Get old feed from Feed and optionally Feed with FeedEpisodes
* @param {Feed} feedExpanded
* @returns {oldFeed}
*/
static getOldFeed(feedExpanded) {
const episodes = feedExpanded.feedEpisodes?.map((feedEpisode) => feedEpisode.getOldEpisode())
return new oldFeed({
id: feedExpanded.id,
slug: feedExpanded.slug,
userId: feedExpanded.userId,
entityType: feedExpanded.entityType,
entityId: feedExpanded.entityId,
entityUpdatedAt: feedExpanded.entityUpdatedAt?.valueOf() || null,
coverPath: feedExpanded.coverPath || null,
meta: {
title: feedExpanded.title,
description: feedExpanded.description,
author: feedExpanded.author,
imageUrl: feedExpanded.imageURL,
feedUrl: feedExpanded.feedURL, feedUrl: feedExpanded.feedURL,
episodes: episodes || [], link: feedExpanded.siteURL,
createdAt: feedExpanded.createdAt.valueOf(), explicit: feedExpanded.explicit,
updatedAt: feedExpanded.updatedAt.valueOf() type: feedExpanded.podcastType,
}) language: feedExpanded.language,
} preventIndexing: feedExpanded.preventIndexing,
ownerName: feedExpanded.ownerName,
ownerEmail: feedExpanded.ownerEmail
},
serverAddress: feedExpanded.serverAddress,
feedUrl: feedExpanded.feedURL,
episodes: episodes || [],
createdAt: feedExpanded.createdAt.valueOf(),
updatedAt: feedExpanded.updatedAt.valueOf()
})
}
static removeById(feedId) { static removeById(feedId) {
return this.destroy({ return this.destroy({
where: { where: {
id: feedId id: feedId
}
})
}
/**
* Find all library item ids that have an open feed (used in library filter)
* @returns {Promise<Array<String>>} array of library item ids
*/
static async findAllLibraryItemIds() {
const feeds = await this.findAll({
attributes: ['entityId'],
where: {
entityType: 'libraryItem'
}
})
return feeds.map(f => f.entityId).filter(f => f) || []
}
/**
* Find feed where and return oldFeed
* @param {object} where sequelize where object
* @returns {Promise<objects.Feed>} oldFeed
*/
static async findOneOld(where) {
if (!where) return null
const feedExpanded = await this.findOne({
where,
include: {
model: sequelize.models.feedEpisode
}
})
if (!feedExpanded) return null
return this.getOldFeed(feedExpanded)
}
/**
* Find feed and return oldFeed
* @param {string} id
* @returns {Promise<objects.Feed>} oldFeed
*/
static async findByPkOld(id) {
if (!id) return null
const feedExpanded = await this.findByPk(id, {
include: {
model: sequelize.models.feedEpisode
}
})
if (!feedExpanded) return null
return this.getOldFeed(feedExpanded)
}
static async fullCreateFromOld(oldFeed) {
const feedObj = this.getFromOld(oldFeed)
const newFeed = await this.create(feedObj)
if (oldFeed.episodes?.length) {
for (const oldFeedEpisode of oldFeed.episodes) {
const feedEpisode = sequelize.models.feedEpisode.getFromOld(oldFeedEpisode)
feedEpisode.feedId = newFeed.id
await sequelize.models.feedEpisode.create(feedEpisode)
}
} }
} })
}
static async fullUpdateFromOld(oldFeed) { /**
const oldFeedEpisodes = oldFeed.episodes || [] * Find all library item ids that have an open feed (used in library filter)
const feedObj = this.getFromOld(oldFeed) * @returns {Promise<Array<String>>} array of library item ids
*/
const existingFeed = await this.findByPk(feedObj.id, { static async findAllLibraryItemIds() {
include: sequelize.models.feedEpisode const feeds = await this.findAll({
}) attributes: ['entityId'],
if (!existingFeed) return false where: {
entityType: 'libraryItem'
let hasUpdates = false
for (const feedEpisode of existingFeed.feedEpisodes) {
const oldFeedEpisode = oldFeedEpisodes.find(ep => ep.id === feedEpisode.id)
// Episode removed
if (!oldFeedEpisode) {
feedEpisode.destroy()
} else {
let episodeHasUpdates = false
const oldFeedEpisodeCleaned = sequelize.models.feedEpisode.getFromOld(oldFeedEpisode)
for (const key in oldFeedEpisodeCleaned) {
if (!areEquivalent(oldFeedEpisodeCleaned[key], feedEpisode[key])) {
episodeHasUpdates = true
}
}
if (episodeHasUpdates) {
await feedEpisode.update(oldFeedEpisodeCleaned)
hasUpdates = true
}
}
} }
})
return feeds.map(f => f.entityId).filter(f => f) || []
}
let feedHasUpdates = false /**
for (const key in feedObj) { * Find feed where and return oldFeed
let existingValue = existingFeed[key] * @param {object} where sequelize where object
if (existingValue instanceof Date) existingValue = existingValue.valueOf() * @returns {Promise<objects.Feed>} oldFeed
*/
if (!areEquivalent(existingValue, feedObj[key])) { static async findOneOld(where) {
feedHasUpdates = true if (!where) return null
} const feedExpanded = await this.findOne({
where,
include: {
model: this.sequelize.models.feedEpisode
} }
})
if (!feedExpanded) return null
return this.getOldFeed(feedExpanded)
}
if (feedHasUpdates) { /**
await existingFeed.update(feedObj) * Find feed and return oldFeed
hasUpdates = true * @param {string} id
* @returns {Promise<objects.Feed>} oldFeed
*/
static async findByPkOld(id) {
if (!id) return null
const feedExpanded = await this.findByPk(id, {
include: {
model: this.sequelize.models.feedEpisode
} }
})
if (!feedExpanded) return null
return this.getOldFeed(feedExpanded)
}
return hasUpdates static async fullCreateFromOld(oldFeed) {
} const feedObj = this.getFromOld(oldFeed)
const newFeed = await this.create(feedObj)
static getFromOld(oldFeed) { if (oldFeed.episodes?.length) {
const oldFeedMeta = oldFeed.meta || {} for (const oldFeedEpisode of oldFeed.episodes) {
return { const feedEpisode = this.sequelize.models.feedEpisode.getFromOld(oldFeedEpisode)
id: oldFeed.id, feedEpisode.feedId = newFeed.id
slug: oldFeed.slug, await this.sequelize.models.feedEpisode.create(feedEpisode)
entityType: oldFeed.entityType,
entityId: oldFeed.entityId,
entityUpdatedAt: oldFeed.entityUpdatedAt,
serverAddress: oldFeed.serverAddress,
feedURL: oldFeed.feedUrl,
coverPath: oldFeed.coverPath || null,
imageURL: oldFeedMeta.imageUrl,
siteURL: oldFeedMeta.link,
title: oldFeedMeta.title,
description: oldFeedMeta.description,
author: oldFeedMeta.author,
podcastType: oldFeedMeta.type || null,
language: oldFeedMeta.language || null,
ownerName: oldFeedMeta.ownerName || null,
ownerEmail: oldFeedMeta.ownerEmail || null,
explicit: !!oldFeedMeta.explicit,
preventIndexing: !!oldFeedMeta.preventIndexing,
userId: oldFeed.userId
} }
} }
getEntity(options) {
if (!this.entityType) return Promise.resolve(null)
const mixinMethodName = `get${sequelize.uppercaseFirst(this.entityType)}`
return this[mixinMethodName](options)
}
} }
Feed.init({ static async fullUpdateFromOld(oldFeed) {
id: { const oldFeedEpisodes = oldFeed.episodes || []
type: DataTypes.UUID, const feedObj = this.getFromOld(oldFeed)
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
slug: DataTypes.STRING,
entityType: DataTypes.STRING,
entityId: DataTypes.UUIDV4,
entityUpdatedAt: DataTypes.DATE,
serverAddress: DataTypes.STRING,
feedURL: DataTypes.STRING,
imageURL: DataTypes.STRING,
siteURL: DataTypes.STRING,
title: DataTypes.STRING,
description: DataTypes.TEXT,
author: DataTypes.STRING,
podcastType: DataTypes.STRING,
language: DataTypes.STRING,
ownerName: DataTypes.STRING,
ownerEmail: DataTypes.STRING,
explicit: DataTypes.BOOLEAN,
preventIndexing: DataTypes.BOOLEAN,
coverPath: DataTypes.STRING
}, {
sequelize,
modelName: 'feed'
})
const { user, libraryItem, collection, series, playlist } = sequelize.models const existingFeed = await this.findByPk(feedObj.id, {
include: this.sequelize.models.feedEpisode
})
if (!existingFeed) return false
user.hasMany(Feed) let hasUpdates = false
Feed.belongsTo(user) for (const feedEpisode of existingFeed.feedEpisodes) {
const oldFeedEpisode = oldFeedEpisodes.find(ep => ep.id === feedEpisode.id)
libraryItem.hasMany(Feed, { // Episode removed
foreignKey: 'entityId', if (!oldFeedEpisode) {
constraints: false, feedEpisode.destroy()
scope: { } else {
entityType: 'libraryItem' let episodeHasUpdates = false
} const oldFeedEpisodeCleaned = this.sequelize.models.feedEpisode.getFromOld(oldFeedEpisode)
}) for (const key in oldFeedEpisodeCleaned) {
Feed.belongsTo(libraryItem, { foreignKey: 'entityId', constraints: false }) if (!areEquivalent(oldFeedEpisodeCleaned[key], feedEpisode[key])) {
episodeHasUpdates = true
collection.hasMany(Feed, { }
foreignKey: 'entityId', }
constraints: false, if (episodeHasUpdates) {
scope: { await feedEpisode.update(oldFeedEpisodeCleaned)
entityType: 'collection' hasUpdates = true
} }
})
Feed.belongsTo(collection, { foreignKey: 'entityId', constraints: false })
series.hasMany(Feed, {
foreignKey: 'entityId',
constraints: false,
scope: {
entityType: 'series'
}
})
Feed.belongsTo(series, { foreignKey: 'entityId', constraints: false })
playlist.hasMany(Feed, {
foreignKey: 'entityId',
constraints: false,
scope: {
entityType: 'playlist'
}
})
Feed.belongsTo(playlist, { foreignKey: 'entityId', constraints: false })
Feed.addHook('afterFind', findResult => {
if (!findResult) return
if (!Array.isArray(findResult)) findResult = [findResult]
for (const instance of findResult) {
if (instance.entityType === 'libraryItem' && instance.libraryItem !== undefined) {
instance.entity = instance.libraryItem
instance.dataValues.entity = instance.dataValues.libraryItem
} else if (instance.entityType === 'collection' && instance.collection !== undefined) {
instance.entity = instance.collection
instance.dataValues.entity = instance.dataValues.collection
} else if (instance.entityType === 'series' && instance.series !== undefined) {
instance.entity = instance.series
instance.dataValues.entity = instance.dataValues.series
} else if (instance.entityType === 'playlist' && instance.playlist !== undefined) {
instance.entity = instance.playlist
instance.dataValues.entity = instance.dataValues.playlist
} }
// To prevent mistakes:
delete instance.libraryItem
delete instance.dataValues.libraryItem
delete instance.collection
delete instance.dataValues.collection
delete instance.series
delete instance.dataValues.series
delete instance.playlist
delete instance.dataValues.playlist
} }
})
return Feed let feedHasUpdates = false
} for (const key in feedObj) {
let existingValue = existingFeed[key]
if (existingValue instanceof Date) existingValue = existingValue.valueOf()
if (!areEquivalent(existingValue, feedObj[key])) {
feedHasUpdates = true
}
}
if (feedHasUpdates) {
await existingFeed.update(feedObj)
hasUpdates = true
}
return hasUpdates
}
static getFromOld(oldFeed) {
const oldFeedMeta = oldFeed.meta || {}
return {
id: oldFeed.id,
slug: oldFeed.slug,
entityType: oldFeed.entityType,
entityId: oldFeed.entityId,
entityUpdatedAt: oldFeed.entityUpdatedAt,
serverAddress: oldFeed.serverAddress,
feedURL: oldFeed.feedUrl,
coverPath: oldFeed.coverPath || null,
imageURL: oldFeedMeta.imageUrl,
siteURL: oldFeedMeta.link,
title: oldFeedMeta.title,
description: oldFeedMeta.description,
author: oldFeedMeta.author,
podcastType: oldFeedMeta.type || null,
language: oldFeedMeta.language || null,
ownerName: oldFeedMeta.ownerName || null,
ownerEmail: oldFeedMeta.ownerEmail || null,
explicit: !!oldFeedMeta.explicit,
preventIndexing: !!oldFeedMeta.preventIndexing,
userId: oldFeed.userId
}
}
getEntity(options) {
if (!this.entityType) return Promise.resolve(null)
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.entityType)}`
return this[mixinMethodName](options)
}
/**
* Initialize model
*
* Polymorphic association: Feeds can be created from LibraryItem, Collection, Playlist or Series
* @see https://sequelize.org/docs/v6/advanced-association-concepts/polymorphic-associations/
*
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
slug: DataTypes.STRING,
entityType: DataTypes.STRING,
entityId: DataTypes.UUIDV4,
entityUpdatedAt: DataTypes.DATE,
serverAddress: DataTypes.STRING,
feedURL: DataTypes.STRING,
imageURL: DataTypes.STRING,
siteURL: DataTypes.STRING,
title: DataTypes.STRING,
description: DataTypes.TEXT,
author: DataTypes.STRING,
podcastType: DataTypes.STRING,
language: DataTypes.STRING,
ownerName: DataTypes.STRING,
ownerEmail: DataTypes.STRING,
explicit: DataTypes.BOOLEAN,
preventIndexing: DataTypes.BOOLEAN,
coverPath: DataTypes.STRING
}, {
sequelize,
modelName: 'feed'
})
const { user, libraryItem, collection, series, playlist } = sequelize.models
user.hasMany(Feed)
Feed.belongsTo(user)
libraryItem.hasMany(Feed, {
foreignKey: 'entityId',
constraints: false,
scope: {
entityType: 'libraryItem'
}
})
Feed.belongsTo(libraryItem, { foreignKey: 'entityId', constraints: false })
collection.hasMany(Feed, {
foreignKey: 'entityId',
constraints: false,
scope: {
entityType: 'collection'
}
})
Feed.belongsTo(collection, { foreignKey: 'entityId', constraints: false })
series.hasMany(Feed, {
foreignKey: 'entityId',
constraints: false,
scope: {
entityType: 'series'
}
})
Feed.belongsTo(series, { foreignKey: 'entityId', constraints: false })
playlist.hasMany(Feed, {
foreignKey: 'entityId',
constraints: false,
scope: {
entityType: 'playlist'
}
})
Feed.belongsTo(playlist, { foreignKey: 'entityId', constraints: false })
Feed.addHook('afterFind', findResult => {
if (!findResult) return
if (!Array.isArray(findResult)) findResult = [findResult]
for (const instance of findResult) {
if (instance.entityType === 'libraryItem' && instance.libraryItem !== undefined) {
instance.entity = instance.libraryItem
instance.dataValues.entity = instance.dataValues.libraryItem
} else if (instance.entityType === 'collection' && instance.collection !== undefined) {
instance.entity = instance.collection
instance.dataValues.entity = instance.dataValues.collection
} else if (instance.entityType === 'series' && instance.series !== undefined) {
instance.entity = instance.series
instance.dataValues.entity = instance.dataValues.series
} else if (instance.entityType === 'playlist' && instance.playlist !== undefined) {
instance.entity = instance.playlist
instance.dataValues.entity = instance.dataValues.playlist
}
// To prevent mistakes:
delete instance.libraryItem
delete instance.dataValues.libraryItem
delete instance.collection
delete instance.dataValues.collection
delete instance.series
delete instance.dataValues.series
delete instance.playlist
delete instance.dataValues.playlist
}
})
}
}
module.exports = Feed

View File

@ -1,82 +1,125 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model } = require('sequelize')
module.exports = (sequelize) => { class FeedEpisode extends Model {
class FeedEpisode extends Model { constructor(values, options) {
getOldEpisode() { super(values, options)
const enclosure = {
url: this.enclosureURL,
size: this.enclosureSize,
type: this.enclosureType
}
return {
id: this.id,
title: this.title,
description: this.description,
enclosure,
pubDate: this.pubDate,
link: this.siteURL,
author: this.author,
explicit: this.explicit,
duration: this.duration,
season: this.season,
episode: this.episode,
episodeType: this.episodeType,
fullPath: this.filePath
}
}
static getFromOld(oldFeedEpisode) { /** @type {UUIDV4} */
return { this.id
id: oldFeedEpisode.id, /** @type {string} */
title: oldFeedEpisode.title, this.title
author: oldFeedEpisode.author, /** @type {string} */
description: oldFeedEpisode.description, this.description
siteURL: oldFeedEpisode.link, /** @type {string} */
enclosureURL: oldFeedEpisode.enclosure?.url || null, this.siteURL
enclosureType: oldFeedEpisode.enclosure?.type || null, /** @type {string} */
enclosureSize: oldFeedEpisode.enclosure?.size || null, this.enclosureURL
pubDate: oldFeedEpisode.pubDate, /** @type {string} */
season: oldFeedEpisode.season || null, this.enclosureType
episode: oldFeedEpisode.episode || null, /** @type {BigInt} */
episodeType: oldFeedEpisode.episodeType || null, this.enclosureSize
duration: oldFeedEpisode.duration, /** @type {string} */
filePath: oldFeedEpisode.fullPath, this.pubDate
explicit: !!oldFeedEpisode.explicit /** @type {string} */
} this.season
/** @type {string} */
this.episode
/** @type {string} */
this.episodeType
/** @type {number} */
this.duration
/** @type {string} */
this.filePath
/** @type {boolean} */
this.explicit
/** @type {UUIDV4} */
this.feedId
/** @type {Date} */
this.createdAt
/** @type {Date} */
this.updatedAt
}
getOldEpisode() {
const enclosure = {
url: this.enclosureURL,
size: this.enclosureSize,
type: this.enclosureType
}
return {
id: this.id,
title: this.title,
description: this.description,
enclosure,
pubDate: this.pubDate,
link: this.siteURL,
author: this.author,
explicit: this.explicit,
duration: this.duration,
season: this.season,
episode: this.episode,
episodeType: this.episodeType,
fullPath: this.filePath
} }
} }
FeedEpisode.init({ static getFromOld(oldFeedEpisode) {
id: { return {
type: DataTypes.UUID, id: oldFeedEpisode.id,
defaultValue: DataTypes.UUIDV4, title: oldFeedEpisode.title,
primaryKey: true author: oldFeedEpisode.author,
}, description: oldFeedEpisode.description,
title: DataTypes.STRING, siteURL: oldFeedEpisode.link,
author: DataTypes.STRING, enclosureURL: oldFeedEpisode.enclosure?.url || null,
description: DataTypes.TEXT, enclosureType: oldFeedEpisode.enclosure?.type || null,
siteURL: DataTypes.STRING, enclosureSize: oldFeedEpisode.enclosure?.size || null,
enclosureURL: DataTypes.STRING, pubDate: oldFeedEpisode.pubDate,
enclosureType: DataTypes.STRING, season: oldFeedEpisode.season || null,
enclosureSize: DataTypes.BIGINT, episode: oldFeedEpisode.episode || null,
pubDate: DataTypes.STRING, episodeType: oldFeedEpisode.episodeType || null,
season: DataTypes.STRING, duration: oldFeedEpisode.duration,
episode: DataTypes.STRING, filePath: oldFeedEpisode.fullPath,
episodeType: DataTypes.STRING, explicit: !!oldFeedEpisode.explicit
duration: DataTypes.FLOAT, }
filePath: DataTypes.STRING, }
explicit: DataTypes.BOOLEAN
}, {
sequelize,
modelName: 'feedEpisode'
})
const { feed } = sequelize.models /**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
title: DataTypes.STRING,
author: DataTypes.STRING,
description: DataTypes.TEXT,
siteURL: DataTypes.STRING,
enclosureURL: DataTypes.STRING,
enclosureType: DataTypes.STRING,
enclosureSize: DataTypes.BIGINT,
pubDate: DataTypes.STRING,
season: DataTypes.STRING,
episode: DataTypes.STRING,
episodeType: DataTypes.STRING,
duration: DataTypes.FLOAT,
filePath: DataTypes.STRING,
explicit: DataTypes.BOOLEAN
}, {
sequelize,
modelName: 'feedEpisode'
})
feed.hasMany(FeedEpisode, { const { feed } = sequelize.models
onDelete: 'CASCADE'
})
FeedEpisode.belongsTo(feed)
return FeedEpisode feed.hasMany(FeedEpisode, {
} onDelete: 'CASCADE'
})
FeedEpisode.belongsTo(feed)
}
}
module.exports = FeedEpisode

View File

@ -2,217 +2,261 @@ const { DataTypes, Model } = require('sequelize')
const Logger = require('../Logger') const Logger = require('../Logger')
const oldLibrary = require('../objects/Library') const oldLibrary = require('../objects/Library')
module.exports = (sequelize) => { /**
class Library extends Model { * @typedef LibrarySettingsObject
/** * @property {number} coverAspectRatio BookCoverAspectRatio
* Get all old libraries * @property {boolean} disableWatcher
* @returns {Promise<oldLibrary[]>} * @property {boolean} skipMatchingMediaWithAsin
*/ * @property {boolean} skipMatchingMediaWithIsbn
static async getAllOldLibraries() { * @property {string} autoScanCronExpression
const libraries = await this.findAll({ * @property {boolean} audiobooksOnly
include: sequelize.models.libraryFolder, * @property {boolean} hideSingleBookSeries Do not show series that only have 1 book
order: [['displayOrder', 'ASC']] */
})
return libraries.map(lib => this.getOldLibrary(lib))
}
/** class Library extends Model {
* Convert expanded Library to oldLibrary constructor(values, options) {
* @param {Library} libraryExpanded super(values, options)
* @returns {Promise<oldLibrary>}
*/
static getOldLibrary(libraryExpanded) {
const folders = libraryExpanded.libraryFolders.map(folder => {
return {
id: folder.id,
fullPath: folder.path,
libraryId: folder.libraryId,
addedAt: folder.createdAt.valueOf()
}
})
return new oldLibrary({
id: libraryExpanded.id,
oldLibraryId: libraryExpanded.extraData?.oldLibraryId || null,
name: libraryExpanded.name,
folders,
displayOrder: libraryExpanded.displayOrder,
icon: libraryExpanded.icon,
mediaType: libraryExpanded.mediaType,
provider: libraryExpanded.provider,
settings: libraryExpanded.settings,
createdAt: libraryExpanded.createdAt.valueOf(),
lastUpdate: libraryExpanded.updatedAt.valueOf()
})
}
/** /** @type {UUIDV4} */
* @param {object} oldLibrary this.id
* @returns {Library|null} /** @type {string} */
*/ this.name
static async createFromOld(oldLibrary) { /** @type {number} */
const library = this.getFromOld(oldLibrary) this.displayOrder
/** @type {string} */
this.icon
/** @type {string} */
this.mediaType
/** @type {string} */
this.provider
/** @type {Date} */
this.lastScan
/** @type {string} */
this.lastScanVersion
/** @type {LibrarySettingsObject} */
this.settings
/** @type {Object} */
this.extraData
/** @type {Date} */
this.createdAt
/** @type {Date} */
this.updatedAt
}
library.libraryFolders = oldLibrary.folders.map(folder => { /**
return { * Get all old libraries
id: folder.id, * @returns {Promise<oldLibrary[]>}
path: folder.fullPath */
} static async getAllOldLibraries() {
}) const libraries = await this.findAll({
include: this.sequelize.models.libraryFolder,
order: [['displayOrder', 'ASC']]
})
return libraries.map(lib => this.getOldLibrary(lib))
}
return this.create(library, { /**
include: sequelize.models.libraryFolder * Convert expanded Library to oldLibrary
}).catch((error) => { * @param {Library} libraryExpanded
Logger.error(`[Library] Failed to create library ${library.id}`, error) * @returns {Promise<oldLibrary>}
return null */
}) static getOldLibrary(libraryExpanded) {
} const folders = libraryExpanded.libraryFolders.map(folder => {
/**
* Update library and library folders
* @param {object} oldLibrary
* @returns
*/
static async updateFromOld(oldLibrary) {
const existingLibrary = await this.findByPk(oldLibrary.id, {
include: sequelize.models.libraryFolder
})
if (!existingLibrary) {
Logger.error(`[Library] Failed to update library ${oldLibrary.id} - not found`)
return null
}
const library = this.getFromOld(oldLibrary)
const libraryFolders = oldLibrary.folders.map(folder => {
return {
id: folder.id,
path: folder.fullPath,
libraryId: library.id
}
})
for (const libraryFolder of libraryFolders) {
const existingLibraryFolder = existingLibrary.libraryFolders.find(lf => lf.id === libraryFolder.id)
if (!existingLibraryFolder) {
await sequelize.models.libraryFolder.create(libraryFolder)
} else if (existingLibraryFolder.path !== libraryFolder.path) {
await existingLibraryFolder.update({ path: libraryFolder.path })
}
}
const libraryFoldersRemoved = existingLibrary.libraryFolders.filter(lf => !libraryFolders.some(_lf => _lf.id === lf.id))
for (const existingLibraryFolder of libraryFoldersRemoved) {
await existingLibraryFolder.destroy()
}
return existingLibrary.update(library)
}
static getFromOld(oldLibrary) {
const extraData = {}
if (oldLibrary.oldLibraryId) {
extraData.oldLibraryId = oldLibrary.oldLibraryId
}
return { return {
id: oldLibrary.id, id: folder.id,
name: oldLibrary.name, fullPath: folder.path,
displayOrder: oldLibrary.displayOrder, libraryId: folder.libraryId,
icon: oldLibrary.icon || null, addedAt: folder.createdAt.valueOf()
mediaType: oldLibrary.mediaType || null, }
provider: oldLibrary.provider, })
settings: oldLibrary.settings?.toJSON() || {}, return new oldLibrary({
createdAt: oldLibrary.createdAt, id: libraryExpanded.id,
updatedAt: oldLibrary.lastUpdate, oldLibraryId: libraryExpanded.extraData?.oldLibraryId || null,
extraData name: libraryExpanded.name,
folders,
displayOrder: libraryExpanded.displayOrder,
icon: libraryExpanded.icon,
mediaType: libraryExpanded.mediaType,
provider: libraryExpanded.provider,
settings: libraryExpanded.settings,
createdAt: libraryExpanded.createdAt.valueOf(),
lastUpdate: libraryExpanded.updatedAt.valueOf()
})
}
/**
* @param {object} oldLibrary
* @returns {Library|null}
*/
static async createFromOld(oldLibrary) {
const library = this.getFromOld(oldLibrary)
library.libraryFolders = oldLibrary.folders.map(folder => {
return {
id: folder.id,
path: folder.fullPath
}
})
return this.create(library, {
include: this.sequelize.models.libraryFolder
}).catch((error) => {
Logger.error(`[Library] Failed to create library ${library.id}`, error)
return null
})
}
/**
* Update library and library folders
* @param {object} oldLibrary
* @returns
*/
static async updateFromOld(oldLibrary) {
const existingLibrary = await this.findByPk(oldLibrary.id, {
include: this.sequelize.models.libraryFolder
})
if (!existingLibrary) {
Logger.error(`[Library] Failed to update library ${oldLibrary.id} - not found`)
return null
}
const library = this.getFromOld(oldLibrary)
const libraryFolders = oldLibrary.folders.map(folder => {
return {
id: folder.id,
path: folder.fullPath,
libraryId: library.id
}
})
for (const libraryFolder of libraryFolders) {
const existingLibraryFolder = existingLibrary.libraryFolders.find(lf => lf.id === libraryFolder.id)
if (!existingLibraryFolder) {
await this.sequelize.models.libraryFolder.create(libraryFolder)
} else if (existingLibraryFolder.path !== libraryFolder.path) {
await existingLibraryFolder.update({ path: libraryFolder.path })
} }
} }
/** const libraryFoldersRemoved = existingLibrary.libraryFolders.filter(lf => !libraryFolders.some(_lf => _lf.id === lf.id))
* Destroy library by id for (const existingLibraryFolder of libraryFoldersRemoved) {
* @param {string} libraryId await existingLibraryFolder.destroy()
* @returns
*/
static removeById(libraryId) {
return this.destroy({
where: {
id: libraryId
}
})
} }
/** return existingLibrary.update(library)
* Get all library ids }
* @returns {Promise<string[]>} array of library ids
*/
static async getAllLibraryIds() {
const libraries = await this.findAll({
attributes: ['id', 'displayOrder'],
order: [['displayOrder', 'ASC']]
})
return libraries.map(l => l.id)
}
/** static getFromOld(oldLibrary) {
* Find Library by primary key & return oldLibrary const extraData = {}
* @param {string} libraryId if (oldLibrary.oldLibraryId) {
* @returns {Promise<oldLibrary|null>} Returns null if not found extraData.oldLibraryId = oldLibrary.oldLibraryId
*/
static async getOldById(libraryId) {
if (!libraryId) return null
const library = await this.findByPk(libraryId, {
include: sequelize.models.libraryFolder
})
if (!library) return null
return this.getOldLibrary(library)
} }
return {
/** id: oldLibrary.id,
* Get the largest value in the displayOrder column name: oldLibrary.name,
* Used for setting a new libraries display order displayOrder: oldLibrary.displayOrder,
* @returns {Promise<number>} icon: oldLibrary.icon || null,
*/ mediaType: oldLibrary.mediaType || null,
static getMaxDisplayOrder() { provider: oldLibrary.provider,
return this.max('displayOrder') || 0 settings: oldLibrary.settings?.toJSON() || {},
createdAt: oldLibrary.createdAt,
updatedAt: oldLibrary.lastUpdate,
extraData
} }
}
/** /**
* Updates displayOrder to be sequential * Destroy library by id
* Used after removing a library * @param {string} libraryId
*/ * @returns
static async resetDisplayOrder() { */
const libraries = await this.findAll({ static removeById(libraryId) {
order: [['displayOrder', 'ASC']] return this.destroy({
}) where: {
for (let i = 0; i < libraries.length; i++) { id: libraryId
const library = libraries[i] }
if (library.displayOrder !== i + 1) { })
Logger.dev(`[Library] Updating display order of library from ${library.displayOrder} to ${i + 1}`) }
await library.update({ displayOrder: i + 1 }).catch((error) => {
Logger.error(`[Library] Failed to update library display order to ${i + 1}`, error) /**
}) * Get all library ids
} * @returns {Promise<string[]>} array of library ids
*/
static async getAllLibraryIds() {
const libraries = await this.findAll({
attributes: ['id', 'displayOrder'],
order: [['displayOrder', 'ASC']]
})
return libraries.map(l => l.id)
}
/**
* Find Library by primary key & return oldLibrary
* @param {string} libraryId
* @returns {Promise<oldLibrary|null>} Returns null if not found
*/
static async getOldById(libraryId) {
if (!libraryId) return null
const library = await this.findByPk(libraryId, {
include: this.sequelize.models.libraryFolder
})
if (!library) return null
return this.getOldLibrary(library)
}
/**
* Get the largest value in the displayOrder column
* Used for setting a new libraries display order
* @returns {Promise<number>}
*/
static getMaxDisplayOrder() {
return this.max('displayOrder') || 0
}
/**
* Updates displayOrder to be sequential
* Used after removing a library
*/
static async resetDisplayOrder() {
const libraries = await this.findAll({
order: [['displayOrder', 'ASC']]
})
for (let i = 0; i < libraries.length; i++) {
const library = libraries[i]
if (library.displayOrder !== i + 1) {
Logger.dev(`[Library] Updating display order of library from ${library.displayOrder} to ${i + 1}`)
await library.update({ displayOrder: i + 1 }).catch((error) => {
Logger.error(`[Library] Failed to update library display order to ${i + 1}`, error)
})
} }
} }
} }
Library.init({ /**
id: { * Initialize model
type: DataTypes.UUID, * @param {import('../Database').sequelize} sequelize
defaultValue: DataTypes.UUIDV4, */
primaryKey: true static init(sequelize) {
}, super.init({
name: DataTypes.STRING, id: {
displayOrder: DataTypes.INTEGER, type: DataTypes.UUID,
icon: DataTypes.STRING, defaultValue: DataTypes.UUIDV4,
mediaType: DataTypes.STRING, primaryKey: true
provider: DataTypes.STRING, },
lastScan: DataTypes.DATE, name: DataTypes.STRING,
lastScanVersion: DataTypes.STRING, displayOrder: DataTypes.INTEGER,
settings: DataTypes.JSON, icon: DataTypes.STRING,
extraData: DataTypes.JSON mediaType: DataTypes.STRING,
}, { provider: DataTypes.STRING,
sequelize, lastScan: DataTypes.DATE,
modelName: 'library' lastScanVersion: DataTypes.STRING,
}) settings: DataTypes.JSON,
extraData: DataTypes.JSON
}, {
sequelize,
modelName: 'library'
})
}
}
return Library module.exports = Library
}

View File

@ -1,36 +1,55 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model } = require('sequelize')
module.exports = (sequelize) => { class LibraryFolder extends Model {
class LibraryFolder extends Model { constructor(values, options) {
/** super(values, options)
* Gets all library folder path strings
* @returns {Promise<string[]>} array of library folder paths /** @type {UUIDV4} */
*/ this.id
static async getAllLibraryFolderPaths() { /** @type {string} */
const libraryFolders = await this.findAll({ this.path
attributes: ['path'] /** @type {UUIDV4} */
}) this.libraryId
return libraryFolders.map(l => l.path) /** @type {Date} */
} this.createdAt
/** @type {Date} */
this.updatedAt
} }
LibraryFolder.init({ /**
id: { * Gets all library folder path strings
type: DataTypes.UUID, * @returns {Promise<string[]>} array of library folder paths
defaultValue: DataTypes.UUIDV4, */
primaryKey: true static async getAllLibraryFolderPaths() {
}, const libraryFolders = await this.findAll({
path: DataTypes.STRING attributes: ['path']
}, { })
sequelize, return libraryFolders.map(l => l.path)
modelName: 'libraryFolder' }
})
const { library } = sequelize.models /**
library.hasMany(LibraryFolder, { * Initialize model
onDelete: 'CASCADE' * @param {import('../Database').sequelize} sequelize
}) */
LibraryFolder.belongsTo(library) static init(sequelize) {
super.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
path: DataTypes.STRING
}, {
sequelize,
modelName: 'libraryFolder'
})
return LibraryFolder const { library } = sequelize.models
} library.hasMany(LibraryFolder, {
onDelete: 'CASCADE'
})
LibraryFolder.belongsTo(library)
}
}
module.exports = LibraryFolder

File diff suppressed because it is too large Load Diff

View File

@ -1,148 +1,184 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model } = require('sequelize')
/* class MediaProgress extends Model {
* Polymorphic association: https://sequelize.org/docs/v6/advanced-association-concepts/polymorphic-associations/ constructor(values, options) {
* Book has many MediaProgress. PodcastEpisode has many MediaProgress. super(values, options)
*/
module.exports = (sequelize) => {
class MediaProgress extends Model {
getOldMediaProgress() {
const isPodcastEpisode = this.mediaItemType === 'podcastEpisode'
return { /** @type {UUIDV4} */
id: this.id, this.id
userId: this.userId, /** @type {UUIDV4} */
libraryItemId: this.extraData?.libraryItemId || null, this.mediaItemId
episodeId: isPodcastEpisode ? this.mediaItemId : null, /** @type {string} */
mediaItemId: this.mediaItemId, this.mediaItemType
mediaItemType: this.mediaItemType, /** @type {number} */
duration: this.duration, this.duration
progress: this.extraData?.progress || 0, /** @type {number} */
currentTime: this.currentTime, this.currentTime
isFinished: !!this.isFinished, /** @type {boolean} */
hideFromContinueListening: !!this.hideFromContinueListening, this.isFinished
ebookLocation: this.ebookLocation, /** @type {boolean} */
ebookProgress: this.ebookProgress, this.hideFromContinueListening
lastUpdate: this.updatedAt.valueOf(), /** @type {string} */
startedAt: this.createdAt.valueOf(), this.ebookLocation
finishedAt: this.finishedAt?.valueOf() || null /** @type {number} */
} this.ebookProgress
} /** @type {Date} */
this.finishedAt
/** @type {Object} */
this.extraData
/** @type {UUIDV4} */
this.userId
/** @type {Date} */
this.updatedAt
/** @type {Date} */
this.createdAt
}
static upsertFromOld(oldMediaProgress) { getOldMediaProgress() {
const mediaProgress = this.getFromOld(oldMediaProgress) const isPodcastEpisode = this.mediaItemType === 'podcastEpisode'
return this.upsert(mediaProgress)
}
static getFromOld(oldMediaProgress) { return {
return { id: this.id,
id: oldMediaProgress.id, userId: this.userId,
userId: oldMediaProgress.userId, libraryItemId: this.extraData?.libraryItemId || null,
mediaItemId: oldMediaProgress.mediaItemId, episodeId: isPodcastEpisode ? this.mediaItemId : null,
mediaItemType: oldMediaProgress.mediaItemType, mediaItemId: this.mediaItemId,
duration: oldMediaProgress.duration, mediaItemType: this.mediaItemType,
currentTime: oldMediaProgress.currentTime, duration: this.duration,
ebookLocation: oldMediaProgress.ebookLocation || null, progress: this.extraData?.progress || 0,
ebookProgress: oldMediaProgress.ebookProgress || null, currentTime: this.currentTime,
isFinished: !!oldMediaProgress.isFinished, isFinished: !!this.isFinished,
hideFromContinueListening: !!oldMediaProgress.hideFromContinueListening, hideFromContinueListening: !!this.hideFromContinueListening,
finishedAt: oldMediaProgress.finishedAt, ebookLocation: this.ebookLocation,
createdAt: oldMediaProgress.startedAt || oldMediaProgress.lastUpdate, ebookProgress: this.ebookProgress,
updatedAt: oldMediaProgress.lastUpdate, lastUpdate: this.updatedAt.valueOf(),
extraData: { startedAt: this.createdAt.valueOf(),
libraryItemId: oldMediaProgress.libraryItemId, finishedAt: this.finishedAt?.valueOf() || null
progress: oldMediaProgress.progress
}
}
}
static removeById(mediaProgressId) {
return this.destroy({
where: {
id: mediaProgressId
}
})
}
getMediaItem(options) {
if (!this.mediaItemType) return Promise.resolve(null)
const mixinMethodName = `get${sequelize.uppercaseFirst(this.mediaItemType)}`
return this[mixinMethodName](options)
} }
} }
static upsertFromOld(oldMediaProgress) {
const mediaProgress = this.getFromOld(oldMediaProgress)
return this.upsert(mediaProgress)
}
MediaProgress.init({ static getFromOld(oldMediaProgress) {
id: { return {
type: DataTypes.UUID, id: oldMediaProgress.id,
defaultValue: DataTypes.UUIDV4, userId: oldMediaProgress.userId,
primaryKey: true mediaItemId: oldMediaProgress.mediaItemId,
}, mediaItemType: oldMediaProgress.mediaItemType,
mediaItemId: DataTypes.UUIDV4, duration: oldMediaProgress.duration,
mediaItemType: DataTypes.STRING, currentTime: oldMediaProgress.currentTime,
duration: DataTypes.FLOAT, ebookLocation: oldMediaProgress.ebookLocation || null,
currentTime: DataTypes.FLOAT, ebookProgress: oldMediaProgress.ebookProgress || null,
isFinished: DataTypes.BOOLEAN, isFinished: !!oldMediaProgress.isFinished,
hideFromContinueListening: DataTypes.BOOLEAN, hideFromContinueListening: !!oldMediaProgress.hideFromContinueListening,
ebookLocation: DataTypes.STRING, finishedAt: oldMediaProgress.finishedAt,
ebookProgress: DataTypes.FLOAT, createdAt: oldMediaProgress.startedAt || oldMediaProgress.lastUpdate,
finishedAt: DataTypes.DATE, updatedAt: oldMediaProgress.lastUpdate,
extraData: DataTypes.JSON extraData: {
}, { libraryItemId: oldMediaProgress.libraryItemId,
sequelize, progress: oldMediaProgress.progress
modelName: 'mediaProgress',
indexes: [
{
fields: ['updatedAt']
} }
]
})
const { book, podcastEpisode, user } = sequelize.models
book.hasMany(MediaProgress, {
foreignKey: 'mediaItemId',
constraints: false,
scope: {
mediaItemType: 'book'
} }
}) }
MediaProgress.belongsTo(book, { foreignKey: 'mediaItemId', constraints: false })
podcastEpisode.hasMany(MediaProgress, { static removeById(mediaProgressId) {
foreignKey: 'mediaItemId', return this.destroy({
constraints: false, where: {
scope: { id: mediaProgressId
mediaItemType: 'podcastEpisode'
}
})
MediaProgress.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false })
MediaProgress.addHook('afterFind', findResult => {
if (!findResult) return
if (!Array.isArray(findResult)) findResult = [findResult]
for (const instance of findResult) {
if (instance.mediaItemType === 'book' && instance.book !== undefined) {
instance.mediaItem = instance.book
instance.dataValues.mediaItem = instance.dataValues.book
} else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) {
instance.mediaItem = instance.podcastEpisode
instance.dataValues.mediaItem = instance.dataValues.podcastEpisode
} }
// To prevent mistakes: })
delete instance.book }
delete instance.dataValues.book
delete instance.podcastEpisode
delete instance.dataValues.podcastEpisode
}
})
user.hasMany(MediaProgress, { getMediaItem(options) {
onDelete: 'CASCADE' if (!this.mediaItemType) return Promise.resolve(null)
}) const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaItemType)}`
MediaProgress.belongsTo(user) return this[mixinMethodName](options)
}
return MediaProgress /**
} * Initialize model
*
* Polymorphic association: Book has many MediaProgress. PodcastEpisode has many MediaProgress.
* @see https://sequelize.org/docs/v6/advanced-association-concepts/polymorphic-associations/
*
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
mediaItemId: DataTypes.UUIDV4,
mediaItemType: DataTypes.STRING,
duration: DataTypes.FLOAT,
currentTime: DataTypes.FLOAT,
isFinished: DataTypes.BOOLEAN,
hideFromContinueListening: DataTypes.BOOLEAN,
ebookLocation: DataTypes.STRING,
ebookProgress: DataTypes.FLOAT,
finishedAt: DataTypes.DATE,
extraData: DataTypes.JSON
}, {
sequelize,
modelName: 'mediaProgress',
indexes: [
{
fields: ['updatedAt']
}
]
})
const { book, podcastEpisode, user } = sequelize.models
book.hasMany(MediaProgress, {
foreignKey: 'mediaItemId',
constraints: false,
scope: {
mediaItemType: 'book'
}
})
MediaProgress.belongsTo(book, { foreignKey: 'mediaItemId', constraints: false })
podcastEpisode.hasMany(MediaProgress, {
foreignKey: 'mediaItemId',
constraints: false,
scope: {
mediaItemType: 'podcastEpisode'
}
})
MediaProgress.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false })
MediaProgress.addHook('afterFind', findResult => {
if (!findResult) return
if (!Array.isArray(findResult)) findResult = [findResult]
for (const instance of findResult) {
if (instance.mediaItemType === 'book' && instance.book !== undefined) {
instance.mediaItem = instance.book
instance.dataValues.mediaItem = instance.dataValues.book
} else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) {
instance.mediaItem = instance.podcastEpisode
instance.dataValues.mediaItem = instance.dataValues.podcastEpisode
}
// To prevent mistakes:
delete instance.book
delete instance.dataValues.book
delete instance.podcastEpisode
delete instance.dataValues.podcastEpisode
}
})
user.hasMany(MediaProgress, {
onDelete: 'CASCADE'
})
MediaProgress.belongsTo(user)
}
}
module.exports = MediaProgress

View File

@ -2,197 +2,251 @@ const { DataTypes, Model } = require('sequelize')
const oldPlaybackSession = require('../objects/PlaybackSession') const oldPlaybackSession = require('../objects/PlaybackSession')
module.exports = (sequelize) => {
class PlaybackSession extends Model {
static async getOldPlaybackSessions(where = null) {
const playbackSessions = await this.findAll({
where,
include: [
{
model: sequelize.models.device
}
]
})
return playbackSessions.map(session => this.getOldPlaybackSession(session))
}
static async getById(sessionId) { class PlaybackSession extends Model {
const playbackSession = await this.findByPk(sessionId, { constructor(values, options) {
include: [ super(values, options)
{
model: sequelize.models.device
}
]
})
if (!playbackSession) return null
return this.getOldPlaybackSession(playbackSession)
}
static getOldPlaybackSession(playbackSessionExpanded) { /** @type {UUIDV4} */
const isPodcastEpisode = playbackSessionExpanded.mediaItemType === 'podcastEpisode' this.id
/** @type {UUIDV4} */
this.mediaItemId
/** @type {string} */
this.mediaItemType
/** @type {string} */
this.displayTitle
/** @type {string} */
this.displayAuthor
/** @type {number} */
this.duration
/** @type {number} */
this.playMethod
/** @type {string} */
this.mediaPlayer
/** @type {number} */
this.startTime
/** @type {number} */
this.currentTime
/** @type {string} */
this.serverVersion
/** @type {string} */
this.coverPath
/** @type {number} */
this.timeListening
/** @type {Object} */
this.mediaMetadata
/** @type {string} */
this.date
/** @type {string} */
this.dayOfWeek
/** @type {Object} */
this.extraData
/** @type {UUIDV4} */
this.userId
/** @type {UUIDV4} */
this.deviceId
/** @type {UUIDV4} */
this.libraryId
/** @type {Date} */
this.updatedAt
/** @type {Date} */
this.createdAt
}
return new oldPlaybackSession({ static async getOldPlaybackSessions(where = null) {
id: playbackSessionExpanded.id, const playbackSessions = await this.findAll({
userId: playbackSessionExpanded.userId, where,
libraryId: playbackSessionExpanded.libraryId, include: [
libraryItemId: playbackSessionExpanded.extraData?.libraryItemId || null, {
bookId: isPodcastEpisode ? null : playbackSessionExpanded.mediaItemId, model: this.sequelize.models.device
episodeId: isPodcastEpisode ? playbackSessionExpanded.mediaItemId : null,
mediaType: isPodcastEpisode ? 'podcast' : 'book',
mediaMetadata: playbackSessionExpanded.mediaMetadata,
chapters: null,
displayTitle: playbackSessionExpanded.displayTitle,
displayAuthor: playbackSessionExpanded.displayAuthor,
coverPath: playbackSessionExpanded.coverPath,
duration: playbackSessionExpanded.duration,
playMethod: playbackSessionExpanded.playMethod,
mediaPlayer: playbackSessionExpanded.mediaPlayer,
deviceInfo: playbackSessionExpanded.device?.getOldDevice() || null,
serverVersion: playbackSessionExpanded.serverVersion,
date: playbackSessionExpanded.date,
dayOfWeek: playbackSessionExpanded.dayOfWeek,
timeListening: playbackSessionExpanded.timeListening,
startTime: playbackSessionExpanded.startTime,
currentTime: playbackSessionExpanded.currentTime,
startedAt: playbackSessionExpanded.createdAt.valueOf(),
updatedAt: playbackSessionExpanded.updatedAt.valueOf()
})
}
static removeById(sessionId) {
return this.destroy({
where: {
id: sessionId
} }
}) ]
} })
return playbackSessions.map(session => this.getOldPlaybackSession(session))
}
static createFromOld(oldPlaybackSession) { static async getById(sessionId) {
const playbackSession = this.getFromOld(oldPlaybackSession) const playbackSession = await this.findByPk(sessionId, {
return this.create(playbackSession) include: [
} {
model: this.sequelize.models.device
static updateFromOld(oldPlaybackSession) {
const playbackSession = this.getFromOld(oldPlaybackSession)
return this.update(playbackSession, {
where: {
id: playbackSession.id
} }
}) ]
} })
if (!playbackSession) return null
return this.getOldPlaybackSession(playbackSession)
}
static getFromOld(oldPlaybackSession) { static getOldPlaybackSession(playbackSessionExpanded) {
return { const isPodcastEpisode = playbackSessionExpanded.mediaItemType === 'podcastEpisode'
id: oldPlaybackSession.id,
mediaItemId: oldPlaybackSession.episodeId || oldPlaybackSession.bookId, return new oldPlaybackSession({
mediaItemType: oldPlaybackSession.episodeId ? 'podcastEpisode' : 'book', id: playbackSessionExpanded.id,
libraryId: oldPlaybackSession.libraryId, userId: playbackSessionExpanded.userId,
displayTitle: oldPlaybackSession.displayTitle, libraryId: playbackSessionExpanded.libraryId,
displayAuthor: oldPlaybackSession.displayAuthor, libraryItemId: playbackSessionExpanded.extraData?.libraryItemId || null,
duration: oldPlaybackSession.duration, bookId: isPodcastEpisode ? null : playbackSessionExpanded.mediaItemId,
playMethod: oldPlaybackSession.playMethod, episodeId: isPodcastEpisode ? playbackSessionExpanded.mediaItemId : null,
mediaPlayer: oldPlaybackSession.mediaPlayer, mediaType: isPodcastEpisode ? 'podcast' : 'book',
startTime: oldPlaybackSession.startTime, mediaMetadata: playbackSessionExpanded.mediaMetadata,
currentTime: oldPlaybackSession.currentTime, chapters: null,
serverVersion: oldPlaybackSession.serverVersion || null, displayTitle: playbackSessionExpanded.displayTitle,
createdAt: oldPlaybackSession.startedAt, displayAuthor: playbackSessionExpanded.displayAuthor,
updatedAt: oldPlaybackSession.updatedAt, coverPath: playbackSessionExpanded.coverPath,
userId: oldPlaybackSession.userId, duration: playbackSessionExpanded.duration,
deviceId: oldPlaybackSession.deviceInfo?.id || null, playMethod: playbackSessionExpanded.playMethod,
timeListening: oldPlaybackSession.timeListening, mediaPlayer: playbackSessionExpanded.mediaPlayer,
coverPath: oldPlaybackSession.coverPath, deviceInfo: playbackSessionExpanded.device?.getOldDevice() || null,
mediaMetadata: oldPlaybackSession.mediaMetadata, serverVersion: playbackSessionExpanded.serverVersion,
date: oldPlaybackSession.date, date: playbackSessionExpanded.date,
dayOfWeek: oldPlaybackSession.dayOfWeek, dayOfWeek: playbackSessionExpanded.dayOfWeek,
extraData: { timeListening: playbackSessionExpanded.timeListening,
libraryItemId: oldPlaybackSession.libraryItemId startTime: playbackSessionExpanded.startTime,
} currentTime: playbackSessionExpanded.currentTime,
startedAt: playbackSessionExpanded.createdAt.valueOf(),
updatedAt: playbackSessionExpanded.updatedAt.valueOf()
})
}
static removeById(sessionId) {
return this.destroy({
where: {
id: sessionId
} }
} })
}
getMediaItem(options) { static createFromOld(oldPlaybackSession) {
if (!this.mediaItemType) return Promise.resolve(null) const playbackSession = this.getFromOld(oldPlaybackSession)
const mixinMethodName = `get${sequelize.uppercaseFirst(this.mediaItemType)}` return this.create(playbackSession)
return this[mixinMethodName](options) }
static updateFromOld(oldPlaybackSession) {
const playbackSession = this.getFromOld(oldPlaybackSession)
return this.update(playbackSession, {
where: {
id: playbackSession.id
}
})
}
static getFromOld(oldPlaybackSession) {
return {
id: oldPlaybackSession.id,
mediaItemId: oldPlaybackSession.episodeId || oldPlaybackSession.bookId,
mediaItemType: oldPlaybackSession.episodeId ? 'podcastEpisode' : 'book',
libraryId: oldPlaybackSession.libraryId,
displayTitle: oldPlaybackSession.displayTitle,
displayAuthor: oldPlaybackSession.displayAuthor,
duration: oldPlaybackSession.duration,
playMethod: oldPlaybackSession.playMethod,
mediaPlayer: oldPlaybackSession.mediaPlayer,
startTime: oldPlaybackSession.startTime,
currentTime: oldPlaybackSession.currentTime,
serverVersion: oldPlaybackSession.serverVersion || null,
createdAt: oldPlaybackSession.startedAt,
updatedAt: oldPlaybackSession.updatedAt,
userId: oldPlaybackSession.userId,
deviceId: oldPlaybackSession.deviceInfo?.id || null,
timeListening: oldPlaybackSession.timeListening,
coverPath: oldPlaybackSession.coverPath,
mediaMetadata: oldPlaybackSession.mediaMetadata,
date: oldPlaybackSession.date,
dayOfWeek: oldPlaybackSession.dayOfWeek,
extraData: {
libraryItemId: oldPlaybackSession.libraryItemId
}
} }
} }
PlaybackSession.init({ getMediaItem(options) {
id: { if (!this.mediaItemType) return Promise.resolve(null)
type: DataTypes.UUID, const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaItemType)}`
defaultValue: DataTypes.UUIDV4, return this[mixinMethodName](options)
primaryKey: true }
},
mediaItemId: DataTypes.UUIDV4,
mediaItemType: DataTypes.STRING,
displayTitle: DataTypes.STRING,
displayAuthor: DataTypes.STRING,
duration: DataTypes.FLOAT,
playMethod: DataTypes.INTEGER,
mediaPlayer: DataTypes.STRING,
startTime: DataTypes.FLOAT,
currentTime: DataTypes.FLOAT,
serverVersion: DataTypes.STRING,
coverPath: DataTypes.STRING,
timeListening: DataTypes.INTEGER,
mediaMetadata: DataTypes.JSON,
date: DataTypes.STRING,
dayOfWeek: DataTypes.STRING,
extraData: DataTypes.JSON
}, {
sequelize,
modelName: 'playbackSession'
})
const { book, podcastEpisode, user, device, library } = sequelize.models /**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
mediaItemId: DataTypes.UUIDV4,
mediaItemType: DataTypes.STRING,
displayTitle: DataTypes.STRING,
displayAuthor: DataTypes.STRING,
duration: DataTypes.FLOAT,
playMethod: DataTypes.INTEGER,
mediaPlayer: DataTypes.STRING,
startTime: DataTypes.FLOAT,
currentTime: DataTypes.FLOAT,
serverVersion: DataTypes.STRING,
coverPath: DataTypes.STRING,
timeListening: DataTypes.INTEGER,
mediaMetadata: DataTypes.JSON,
date: DataTypes.STRING,
dayOfWeek: DataTypes.STRING,
extraData: DataTypes.JSON
}, {
sequelize,
modelName: 'playbackSession'
})
user.hasMany(PlaybackSession) const { book, podcastEpisode, user, device, library } = sequelize.models
PlaybackSession.belongsTo(user)
device.hasMany(PlaybackSession) user.hasMany(PlaybackSession)
PlaybackSession.belongsTo(device) PlaybackSession.belongsTo(user)
library.hasMany(PlaybackSession) device.hasMany(PlaybackSession)
PlaybackSession.belongsTo(library) PlaybackSession.belongsTo(device)
book.hasMany(PlaybackSession, { library.hasMany(PlaybackSession)
foreignKey: 'mediaItemId', PlaybackSession.belongsTo(library)
constraints: false,
scope: {
mediaItemType: 'book'
}
})
PlaybackSession.belongsTo(book, { foreignKey: 'mediaItemId', constraints: false })
podcastEpisode.hasOne(PlaybackSession, { book.hasMany(PlaybackSession, {
foreignKey: 'mediaItemId', foreignKey: 'mediaItemId',
constraints: false, constraints: false,
scope: { scope: {
mediaItemType: 'podcastEpisode' mediaItemType: 'book'
}
})
PlaybackSession.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false })
PlaybackSession.addHook('afterFind', findResult => {
if (!findResult) return
if (!Array.isArray(findResult)) findResult = [findResult]
for (const instance of findResult) {
if (instance.mediaItemType === 'book' && instance.book !== undefined) {
instance.mediaItem = instance.book
instance.dataValues.mediaItem = instance.dataValues.book
} else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) {
instance.mediaItem = instance.podcastEpisode
instance.dataValues.mediaItem = instance.dataValues.podcastEpisode
} }
// To prevent mistakes: })
delete instance.book PlaybackSession.belongsTo(book, { foreignKey: 'mediaItemId', constraints: false })
delete instance.dataValues.book
delete instance.podcastEpisode
delete instance.dataValues.podcastEpisode
}
})
return PlaybackSession podcastEpisode.hasOne(PlaybackSession, {
} foreignKey: 'mediaItemId',
constraints: false,
scope: {
mediaItemType: 'podcastEpisode'
}
})
PlaybackSession.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false })
PlaybackSession.addHook('afterFind', findResult => {
if (!findResult) return
if (!Array.isArray(findResult)) findResult = [findResult]
for (const instance of findResult) {
if (instance.mediaItemType === 'book' && instance.book !== undefined) {
instance.mediaItem = instance.book
instance.dataValues.mediaItem = instance.dataValues.book
} else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) {
instance.mediaItem = instance.podcastEpisode
instance.dataValues.mediaItem = instance.dataValues.podcastEpisode
}
// To prevent mistakes:
delete instance.book
delete instance.dataValues.book
delete instance.podcastEpisode
delete instance.dataValues.podcastEpisode
}
})
}
}
module.exports = PlaybackSession

View File

@ -1,312 +1,343 @@
const { DataTypes, Model, Op } = require('sequelize') const { DataTypes, Model, Op, literal } = require('sequelize')
const Logger = require('../Logger') const Logger = require('../Logger')
const oldPlaylist = require('../objects/Playlist') const oldPlaylist = require('../objects/Playlist')
const { areEquivalent } = require('../utils/index')
module.exports = (sequelize) => { class Playlist extends Model {
class Playlist extends Model { constructor(values, options) {
static async getOldPlaylists() { super(values, options)
const playlists = await this.findAll({
include: {
model: sequelize.models.playlistMediaItem,
include: [
{
model: sequelize.models.book,
include: sequelize.models.libraryItem
},
{
model: sequelize.models.podcastEpisode,
include: {
model: sequelize.models.podcast,
include: sequelize.models.libraryItem
}
}
]
},
order: [['playlistMediaItems', 'order', 'ASC']]
})
return playlists.map(p => this.getOldPlaylist(p))
}
static getOldPlaylist(playlistExpanded) { /** @type {UUIDV4} */
const items = playlistExpanded.playlistMediaItems.map(pmi => { this.id
const libraryItemId = pmi.mediaItem?.podcast?.libraryItem?.id || pmi.mediaItem?.libraryItem?.id || null /** @type {string} */
if (!libraryItemId) { this.name
Logger.error(`[Playlist] Invalid playlist media item - No library item id found`, JSON.stringify(pmi, null, 2)) /** @type {string} */
return null this.description
} /** @type {UUIDV4} */
return { this.libraryId
episodeId: pmi.mediaItemType === 'podcastEpisode' ? pmi.mediaItemId : '', /** @type {UUIDV4} */
libraryItemId this.userId
} /** @type {Date} */
}).filter(pmi => pmi) this.createdAt
/** @type {Date} */
this.updatedAt
}
return new oldPlaylist({ static async getOldPlaylists() {
id: playlistExpanded.id, const playlists = await this.findAll({
libraryId: playlistExpanded.libraryId, include: {
userId: playlistExpanded.userId, model: this.sequelize.models.playlistMediaItem,
name: playlistExpanded.name,
description: playlistExpanded.description,
items,
lastUpdate: playlistExpanded.updatedAt.valueOf(),
createdAt: playlistExpanded.createdAt.valueOf()
})
}
static createFromOld(oldPlaylist) {
const playlist = this.getFromOld(oldPlaylist)
return this.create(playlist)
}
static async fullUpdateFromOld(oldPlaylist, playlistMediaItems) {
const existingPlaylist = await this.findByPk(oldPlaylist.id, {
include: sequelize.models.playlistMediaItem
})
if (!existingPlaylist) return false
let hasUpdates = false
const playlist = this.getFromOld(oldPlaylist)
for (const pmi of playlistMediaItems) {
const existingPmi = existingPlaylist.playlistMediaItems.find(i => i.mediaItemId === pmi.mediaItemId)
if (!existingPmi) {
await sequelize.models.playlistMediaItem.create(pmi)
hasUpdates = true
} else if (existingPmi.order != pmi.order) {
await existingPmi.update({ order: pmi.order })
hasUpdates = true
}
}
for (const pmi of existingPlaylist.playlistMediaItems) {
// Pmi was removed
if (!playlistMediaItems.some(i => i.mediaItemId === pmi.mediaItemId)) {
await pmi.destroy()
hasUpdates = true
}
}
let hasPlaylistUpdates = false
for (const key in playlist) {
let existingValue = existingPlaylist[key]
if (existingValue instanceof Date) existingValue = existingValue.valueOf()
if (!areEquivalent(playlist[key], existingValue)) {
hasPlaylistUpdates = true
}
}
if (hasPlaylistUpdates) {
existingPlaylist.update(playlist)
hasUpdates = true
}
return hasUpdates
}
static getFromOld(oldPlaylist) {
return {
id: oldPlaylist.id,
name: oldPlaylist.name,
description: oldPlaylist.description,
userId: oldPlaylist.userId,
libraryId: oldPlaylist.libraryId
}
}
static removeById(playlistId) {
return this.destroy({
where: {
id: playlistId
}
})
}
/**
* Get playlist by id
* @param {string} playlistId
* @returns {Promise<oldPlaylist|null>} returns null if not found
*/
static async getById(playlistId) {
if (!playlistId) return null
const playlist = await this.findByPk(playlistId, {
include: {
model: sequelize.models.playlistMediaItem,
include: [
{
model: sequelize.models.book,
include: sequelize.models.libraryItem
},
{
model: sequelize.models.podcastEpisode,
include: {
model: sequelize.models.podcast,
include: sequelize.models.libraryItem
}
}
]
},
order: [['playlistMediaItems', 'order', 'ASC']]
})
if (!playlist) return null
return this.getOldPlaylist(playlist)
}
/**
* Get playlists for user and optionally for library
* @param {string} userId
* @param {[string]} libraryId optional
* @returns {Promise<oldPlaylist[]>}
*/
static async getPlaylistsForUserAndLibrary(userId, libraryId = null) {
if (!userId && !libraryId) return []
const whereQuery = {}
if (userId) {
whereQuery.userId = userId
}
if (libraryId) {
whereQuery.libraryId = libraryId
}
const playlists = await this.findAll({
where: whereQuery,
include: {
model: sequelize.models.playlistMediaItem,
include: [
{
model: sequelize.models.book,
include: sequelize.models.libraryItem
},
{
model: sequelize.models.podcastEpisode,
include: {
model: sequelize.models.podcast,
include: sequelize.models.libraryItem
}
}
]
},
order: [['playlistMediaItems', 'order', 'ASC']]
})
return playlists.map(p => this.getOldPlaylist(p))
}
/**
* Get number of playlists for a user and library
* @param {string} userId
* @param {string} libraryId
* @returns
*/
static async getNumPlaylistsForUserAndLibrary(userId, libraryId) {
return this.count({
where: {
userId,
libraryId
}
})
}
/**
* Get all playlists for mediaItemIds
* @param {string[]} mediaItemIds
* @returns {Promise<oldPlaylist[]>}
*/
static async getPlaylistsForMediaItemIds(mediaItemIds) {
if (!mediaItemIds?.length) return []
const playlistMediaItemsExpanded = await sequelize.models.playlistMediaItem.findAll({
where: {
mediaItemId: {
[Op.in]: mediaItemIds
}
},
include: [ include: [
{ {
model: sequelize.models.playlist, model: this.sequelize.models.book,
include: this.sequelize.models.libraryItem
},
{
model: this.sequelize.models.podcastEpisode,
include: { include: {
model: sequelize.models.playlistMediaItem, model: this.sequelize.models.podcast,
include: [ include: this.sequelize.models.libraryItem
{
model: sequelize.models.book,
include: sequelize.models.libraryItem
},
{
model: sequelize.models.podcastEpisode,
include: {
model: sequelize.models.podcast,
include: sequelize.models.libraryItem
}
}
]
} }
} }
], ]
order: [['playlist', 'playlistMediaItems', 'order', 'ASC']] },
}) order: [['playlistMediaItems', 'order', 'ASC']]
return playlistMediaItemsExpanded.map(pmie => { })
pmie.playlist.playlistMediaItems = pmie.playlist.playlistMediaItems.map(pmi => { return playlists.map(p => this.getOldPlaylist(p))
if (pmi.mediaItemType === 'book' && pmi.book !== undefined) { }
pmi.mediaItem = pmi.book
pmi.dataValues.mediaItem = pmi.dataValues.book
} else if (pmi.mediaItemType === 'podcastEpisode' && pmi.podcastEpisode !== undefined) {
pmi.mediaItem = pmi.podcastEpisode
pmi.dataValues.mediaItem = pmi.dataValues.podcastEpisode
}
delete pmi.book
delete pmi.dataValues.book
delete pmi.podcastEpisode
delete pmi.dataValues.podcastEpisode
return pmi
})
return this.getOldPlaylist(pmie.playlist) static getOldPlaylist(playlistExpanded) {
}) const items = playlistExpanded.playlistMediaItems.map(pmi => {
const libraryItemId = pmi.mediaItem?.podcast?.libraryItem?.id || pmi.mediaItem?.libraryItem?.id || null
if (!libraryItemId) {
Logger.error(`[Playlist] Invalid playlist media item - No library item id found`, JSON.stringify(pmi, null, 2))
return null
}
return {
episodeId: pmi.mediaItemType === 'podcastEpisode' ? pmi.mediaItemId : '',
libraryItemId
}
}).filter(pmi => pmi)
return new oldPlaylist({
id: playlistExpanded.id,
libraryId: playlistExpanded.libraryId,
userId: playlistExpanded.userId,
name: playlistExpanded.name,
description: playlistExpanded.description,
items,
lastUpdate: playlistExpanded.updatedAt.valueOf(),
createdAt: playlistExpanded.createdAt.valueOf()
})
}
/**
* Get old playlist toJSONExpanded
* @param {[string[]]} include
* @returns {Promise<object>} oldPlaylist.toJSONExpanded
*/
async getOldJsonExpanded(include) {
this.playlistMediaItems = await this.getPlaylistMediaItems({
include: [
{
model: this.sequelize.models.book,
include: this.sequelize.models.libraryItem
},
{
model: this.sequelize.models.podcastEpisode,
include: {
model: this.sequelize.models.podcast,
include: this.sequelize.models.libraryItem
}
}
],
order: [['order', 'ASC']]
}) || []
const oldPlaylist = this.sequelize.models.playlist.getOldPlaylist(this)
const libraryItemIds = oldPlaylist.items.map(i => i.libraryItemId)
let libraryItems = await this.sequelize.models.libraryItem.getAllOldLibraryItems({
id: libraryItemIds
})
const playlistExpanded = oldPlaylist.toJSONExpanded(libraryItems)
if (include?.includes('rssfeed')) {
const feeds = await this.getFeeds()
if (feeds?.length) {
playlistExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(feeds[0])
}
}
return playlistExpanded
}
static createFromOld(oldPlaylist) {
const playlist = this.getFromOld(oldPlaylist)
return this.create(playlist)
}
static getFromOld(oldPlaylist) {
return {
id: oldPlaylist.id,
name: oldPlaylist.name,
description: oldPlaylist.description,
userId: oldPlaylist.userId,
libraryId: oldPlaylist.libraryId
} }
} }
Playlist.init({ static removeById(playlistId) {
id: { return this.destroy({
type: DataTypes.UUID, where: {
defaultValue: DataTypes.UUIDV4, id: playlistId
primaryKey: true
},
name: DataTypes.STRING,
description: DataTypes.TEXT
}, {
sequelize,
modelName: 'playlist'
})
const { library, user } = sequelize.models
library.hasMany(Playlist)
Playlist.belongsTo(library)
user.hasMany(Playlist)
Playlist.belongsTo(user)
Playlist.addHook('afterFind', findResult => {
if (!findResult) return
if (!Array.isArray(findResult)) findResult = [findResult]
for (const instance of findResult) {
if (instance.playlistMediaItems?.length) {
instance.playlistMediaItems = instance.playlistMediaItems.map(pmi => {
if (pmi.mediaItemType === 'book' && pmi.book !== undefined) {
pmi.mediaItem = pmi.book
pmi.dataValues.mediaItem = pmi.dataValues.book
} else if (pmi.mediaItemType === 'podcastEpisode' && pmi.podcastEpisode !== undefined) {
pmi.mediaItem = pmi.podcastEpisode
pmi.dataValues.mediaItem = pmi.dataValues.podcastEpisode
}
// To prevent mistakes:
delete pmi.book
delete pmi.dataValues.book
delete pmi.podcastEpisode
delete pmi.dataValues.podcastEpisode
return pmi
})
} }
})
}
/**
* Get playlist by id
* @param {string} playlistId
* @returns {Promise<oldPlaylist|null>} returns null if not found
*/
static async getById(playlistId) {
if (!playlistId) return null
const playlist = await this.findByPk(playlistId, {
include: {
model: this.sequelize.models.playlistMediaItem,
include: [
{
model: this.sequelize.models.book,
include: this.sequelize.models.libraryItem
},
{
model: this.sequelize.models.podcastEpisode,
include: {
model: this.sequelize.models.podcast,
include: this.sequelize.models.libraryItem
}
}
]
},
order: [['playlistMediaItems', 'order', 'ASC']]
})
if (!playlist) return null
return this.getOldPlaylist(playlist)
}
/**
* Get playlists for user and optionally for library
* @param {string} userId
* @param {[string]} libraryId optional
* @returns {Promise<Playlist[]>}
*/
static async getPlaylistsForUserAndLibrary(userId, libraryId = null) {
if (!userId && !libraryId) return []
const whereQuery = {}
if (userId) {
whereQuery.userId = userId
} }
}) if (libraryId) {
whereQuery.libraryId = libraryId
}
const playlists = await this.findAll({
where: whereQuery,
include: {
model: this.sequelize.models.playlistMediaItem,
include: [
{
model: this.sequelize.models.book,
include: this.sequelize.models.libraryItem
},
{
model: this.sequelize.models.podcastEpisode,
include: {
model: this.sequelize.models.podcast,
include: this.sequelize.models.libraryItem
}
}
]
},
order: [
[literal('name COLLATE NOCASE'), 'ASC'],
['playlistMediaItems', 'order', 'ASC']
]
})
return playlists
}
return Playlist /**
} * Get number of playlists for a user and library
* @param {string} userId
* @param {string} libraryId
* @returns
*/
static async getNumPlaylistsForUserAndLibrary(userId, libraryId) {
return this.count({
where: {
userId,
libraryId
}
})
}
/**
* Get all playlists for mediaItemIds
* @param {string[]} mediaItemIds
* @returns {Promise<Playlist[]>}
*/
static async getPlaylistsForMediaItemIds(mediaItemIds) {
if (!mediaItemIds?.length) return []
const playlistMediaItemsExpanded = await this.sequelize.models.playlistMediaItem.findAll({
where: {
mediaItemId: {
[Op.in]: mediaItemIds
}
},
include: [
{
model: this.sequelize.models.playlist,
include: {
model: this.sequelize.models.playlistMediaItem,
include: [
{
model: this.sequelize.models.book,
include: this.sequelize.models.libraryItem
},
{
model: this.sequelize.models.podcastEpisode,
include: {
model: this.sequelize.models.podcast,
include: this.sequelize.models.libraryItem
}
}
]
}
}
],
order: [['playlist', 'playlistMediaItems', 'order', 'ASC']]
})
const playlists = []
for (const playlistMediaItem of playlistMediaItemsExpanded) {
const playlist = playlistMediaItem.playlist
if (playlists.some(p => p.id === playlist.id)) continue
playlist.playlistMediaItems = playlist.playlistMediaItems.map(pmi => {
if (pmi.mediaItemType === 'book' && pmi.book !== undefined) {
pmi.mediaItem = pmi.book
pmi.dataValues.mediaItem = pmi.dataValues.book
} else if (pmi.mediaItemType === 'podcastEpisode' && pmi.podcastEpisode !== undefined) {
pmi.mediaItem = pmi.podcastEpisode
pmi.dataValues.mediaItem = pmi.dataValues.podcastEpisode
}
delete pmi.book
delete pmi.dataValues.book
delete pmi.podcastEpisode
delete pmi.dataValues.podcastEpisode
return pmi
})
playlists.push(playlist)
}
return playlists
}
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
name: DataTypes.STRING,
description: DataTypes.TEXT
}, {
sequelize,
modelName: 'playlist'
})
const { library, user } = sequelize.models
library.hasMany(Playlist)
Playlist.belongsTo(library)
user.hasMany(Playlist, {
onDelete: 'CASCADE'
})
Playlist.belongsTo(user)
Playlist.addHook('afterFind', findResult => {
if (!findResult) return
if (!Array.isArray(findResult)) findResult = [findResult]
for (const instance of findResult) {
if (instance.playlistMediaItems?.length) {
instance.playlistMediaItems = instance.playlistMediaItems.map(pmi => {
if (pmi.mediaItemType === 'book' && pmi.book !== undefined) {
pmi.mediaItem = pmi.book
pmi.dataValues.mediaItem = pmi.dataValues.book
} else if (pmi.mediaItemType === 'podcastEpisode' && pmi.podcastEpisode !== undefined) {
pmi.mediaItem = pmi.podcastEpisode
pmi.dataValues.mediaItem = pmi.dataValues.podcastEpisode
}
// To prevent mistakes:
delete pmi.book
delete pmi.dataValues.book
delete pmi.podcastEpisode
delete pmi.dataValues.podcastEpisode
return pmi
})
}
}
})
}
}
module.exports = Playlist

View File

@ -1,84 +1,105 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model } = require('sequelize')
module.exports = (sequelize) => { class PlaylistMediaItem extends Model {
class PlaylistMediaItem extends Model { constructor(values, options) {
static removeByIds(playlistId, mediaItemId) { super(values, options)
return this.destroy({
where: {
playlistId,
mediaItemId
}
})
}
getMediaItem(options) { /** @type {UUIDV4} */
if (!this.mediaItemType) return Promise.resolve(null) this.id
const mixinMethodName = `get${sequelize.uppercaseFirst(this.mediaItemType)}` /** @type {UUIDV4} */
return this[mixinMethodName](options) this.mediaItemId
} /** @type {string} */
this.mediaItemType
/** @type {number} */
this.order
/** @type {UUIDV4} */
this.playlistId
/** @type {Date} */
this.createdAt
} }
PlaylistMediaItem.init({ static removeByIds(playlistId, mediaItemId) {
id: { return this.destroy({
type: DataTypes.UUID, where: {
defaultValue: DataTypes.UUIDV4, playlistId,
primaryKey: true mediaItemId
},
mediaItemId: DataTypes.UUIDV4,
mediaItemType: DataTypes.STRING,
order: DataTypes.INTEGER
}, {
sequelize,
timestamps: true,
updatedAt: false,
modelName: 'playlistMediaItem'
})
const { book, podcastEpisode, playlist } = sequelize.models
book.hasMany(PlaylistMediaItem, {
foreignKey: 'mediaItemId',
constraints: false,
scope: {
mediaItemType: 'book'
}
})
PlaylistMediaItem.belongsTo(book, { foreignKey: 'mediaItemId', constraints: false })
podcastEpisode.hasOne(PlaylistMediaItem, {
foreignKey: 'mediaItemId',
constraints: false,
scope: {
mediaItemType: 'podcastEpisode'
}
})
PlaylistMediaItem.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false })
PlaylistMediaItem.addHook('afterFind', findResult => {
if (!findResult) return
if (!Array.isArray(findResult)) findResult = [findResult]
for (const instance of findResult) {
if (instance.mediaItemType === 'book' && instance.book !== undefined) {
instance.mediaItem = instance.book
instance.dataValues.mediaItem = instance.dataValues.book
} else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) {
instance.mediaItem = instance.podcastEpisode
instance.dataValues.mediaItem = instance.dataValues.podcastEpisode
} }
// To prevent mistakes: })
delete instance.book }
delete instance.dataValues.book
delete instance.podcastEpisode
delete instance.dataValues.podcastEpisode
}
})
playlist.hasMany(PlaylistMediaItem, { getMediaItem(options) {
onDelete: 'CASCADE' if (!this.mediaItemType) return Promise.resolve(null)
}) const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaItemType)}`
PlaylistMediaItem.belongsTo(playlist) return this[mixinMethodName](options)
}
return PlaylistMediaItem /**
} * Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
mediaItemId: DataTypes.UUIDV4,
mediaItemType: DataTypes.STRING,
order: DataTypes.INTEGER
}, {
sequelize,
timestamps: true,
updatedAt: false,
modelName: 'playlistMediaItem'
})
const { book, podcastEpisode, playlist } = sequelize.models
book.hasMany(PlaylistMediaItem, {
foreignKey: 'mediaItemId',
constraints: false,
scope: {
mediaItemType: 'book'
}
})
PlaylistMediaItem.belongsTo(book, { foreignKey: 'mediaItemId', constraints: false })
podcastEpisode.hasOne(PlaylistMediaItem, {
foreignKey: 'mediaItemId',
constraints: false,
scope: {
mediaItemType: 'podcastEpisode'
}
})
PlaylistMediaItem.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false })
PlaylistMediaItem.addHook('afterFind', findResult => {
if (!findResult) return
if (!Array.isArray(findResult)) findResult = [findResult]
for (const instance of findResult) {
if (instance.mediaItemType === 'book' && instance.book !== undefined) {
instance.mediaItem = instance.book
instance.dataValues.mediaItem = instance.dataValues.book
} else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) {
instance.mediaItem = instance.podcastEpisode
instance.dataValues.mediaItem = instance.dataValues.podcastEpisode
}
// To prevent mistakes:
delete instance.book
delete instance.dataValues.book
delete instance.podcastEpisode
delete instance.dataValues.podcastEpisode
}
})
playlist.hasMany(PlaylistMediaItem, {
onDelete: 'CASCADE'
})
PlaylistMediaItem.belongsTo(playlist)
}
}
module.exports = PlaylistMediaItem

View File

@ -1,100 +1,155 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model } = require('sequelize')
module.exports = (sequelize) => { class Podcast extends Model {
class Podcast extends Model { constructor(values, options) {
static getOldPodcast(libraryItemExpanded) { super(values, options)
const podcastExpanded = libraryItemExpanded.media
const podcastEpisodes = podcastExpanded.podcastEpisodes?.map(ep => ep.getOldPodcastEpisode(libraryItemExpanded.id)).sort((a, b) => a.index - b.index)
return {
id: podcastExpanded.id,
libraryItemId: libraryItemExpanded.id,
metadata: {
title: podcastExpanded.title,
author: podcastExpanded.author,
description: podcastExpanded.description,
releaseDate: podcastExpanded.releaseDate,
genres: podcastExpanded.genres,
feedUrl: podcastExpanded.feedURL,
imageUrl: podcastExpanded.imageURL,
itunesPageUrl: podcastExpanded.itunesPageURL,
itunesId: podcastExpanded.itunesId,
itunesArtistId: podcastExpanded.itunesArtistId,
explicit: podcastExpanded.explicit,
language: podcastExpanded.language,
type: podcastExpanded.podcastType
},
coverPath: podcastExpanded.coverPath,
tags: podcastExpanded.tags,
episodes: podcastEpisodes || [],
autoDownloadEpisodes: podcastExpanded.autoDownloadEpisodes,
autoDownloadSchedule: podcastExpanded.autoDownloadSchedule,
lastEpisodeCheck: podcastExpanded.lastEpisodeCheck?.valueOf() || null,
maxEpisodesToKeep: podcastExpanded.maxEpisodesToKeep,
maxNewEpisodesToDownload: podcastExpanded.maxNewEpisodesToDownload
}
}
static getFromOld(oldPodcast) { /** @type {string} */
const oldPodcastMetadata = oldPodcast.metadata this.id
return { /** @type {string} */
id: oldPodcast.id, this.title
title: oldPodcastMetadata.title, /** @type {string} */
titleIgnorePrefix: oldPodcastMetadata.titleIgnorePrefix, this.titleIgnorePrefix
author: oldPodcastMetadata.author, /** @type {string} */
releaseDate: oldPodcastMetadata.releaseDate, this.author
feedURL: oldPodcastMetadata.feedUrl, /** @type {string} */
imageURL: oldPodcastMetadata.imageUrl, this.releaseDate
description: oldPodcastMetadata.description, /** @type {string} */
itunesPageURL: oldPodcastMetadata.itunesPageUrl, this.feedURL
itunesId: oldPodcastMetadata.itunesId, /** @type {string} */
itunesArtistId: oldPodcastMetadata.itunesArtistId, this.imageURL
language: oldPodcastMetadata.language, /** @type {string} */
podcastType: oldPodcastMetadata.type, this.description
explicit: !!oldPodcastMetadata.explicit, /** @type {string} */
autoDownloadEpisodes: !!oldPodcast.autoDownloadEpisodes, this.itunesPageURL
autoDownloadSchedule: oldPodcast.autoDownloadSchedule, /** @type {string} */
lastEpisodeCheck: oldPodcast.lastEpisodeCheck, this.itunesId
maxEpisodesToKeep: oldPodcast.maxEpisodesToKeep, /** @type {string} */
maxNewEpisodesToDownload: oldPodcast.maxNewEpisodesToDownload, this.itunesArtistId
coverPath: oldPodcast.coverPath, /** @type {string} */
tags: oldPodcast.tags, this.language
genres: oldPodcastMetadata.genres /** @type {string} */
} this.podcastType
/** @type {boolean} */
this.explicit
/** @type {boolean} */
this.autoDownloadEpisodes
/** @type {string} */
this.autoDownloadSchedule
/** @type {Date} */
this.lastEpisodeCheck
/** @type {number} */
this.maxEpisodesToKeep
/** @type {string} */
this.coverPath
/** @type {string[]} */
this.tags
/** @type {string[]} */
this.genres
/** @type {Date} */
this.createdAt
/** @type {Date} */
this.updatedAt
}
static getOldPodcast(libraryItemExpanded) {
const podcastExpanded = libraryItemExpanded.media
const podcastEpisodes = podcastExpanded.podcastEpisodes?.map(ep => ep.getOldPodcastEpisode(libraryItemExpanded.id).toJSON()).sort((a, b) => a.index - b.index)
return {
id: podcastExpanded.id,
libraryItemId: libraryItemExpanded.id,
metadata: {
title: podcastExpanded.title,
author: podcastExpanded.author,
description: podcastExpanded.description,
releaseDate: podcastExpanded.releaseDate,
genres: podcastExpanded.genres,
feedUrl: podcastExpanded.feedURL,
imageUrl: podcastExpanded.imageURL,
itunesPageUrl: podcastExpanded.itunesPageURL,
itunesId: podcastExpanded.itunesId,
itunesArtistId: podcastExpanded.itunesArtistId,
explicit: podcastExpanded.explicit,
language: podcastExpanded.language,
type: podcastExpanded.podcastType
},
coverPath: podcastExpanded.coverPath,
tags: podcastExpanded.tags,
episodes: podcastEpisodes || [],
autoDownloadEpisodes: podcastExpanded.autoDownloadEpisodes,
autoDownloadSchedule: podcastExpanded.autoDownloadSchedule,
lastEpisodeCheck: podcastExpanded.lastEpisodeCheck?.valueOf() || null,
maxEpisodesToKeep: podcastExpanded.maxEpisodesToKeep,
maxNewEpisodesToDownload: podcastExpanded.maxNewEpisodesToDownload
} }
} }
Podcast.init({ static getFromOld(oldPodcast) {
id: { const oldPodcastMetadata = oldPodcast.metadata
type: DataTypes.UUID, return {
defaultValue: DataTypes.UUIDV4, id: oldPodcast.id,
primaryKey: true title: oldPodcastMetadata.title,
}, titleIgnorePrefix: oldPodcastMetadata.titleIgnorePrefix,
title: DataTypes.STRING, author: oldPodcastMetadata.author,
titleIgnorePrefix: DataTypes.STRING, releaseDate: oldPodcastMetadata.releaseDate,
author: DataTypes.STRING, feedURL: oldPodcastMetadata.feedUrl,
releaseDate: DataTypes.STRING, imageURL: oldPodcastMetadata.imageUrl,
feedURL: DataTypes.STRING, description: oldPodcastMetadata.description,
imageURL: DataTypes.STRING, itunesPageURL: oldPodcastMetadata.itunesPageUrl,
description: DataTypes.TEXT, itunesId: oldPodcastMetadata.itunesId,
itunesPageURL: DataTypes.STRING, itunesArtistId: oldPodcastMetadata.itunesArtistId,
itunesId: DataTypes.STRING, language: oldPodcastMetadata.language,
itunesArtistId: DataTypes.STRING, podcastType: oldPodcastMetadata.type,
language: DataTypes.STRING, explicit: !!oldPodcastMetadata.explicit,
podcastType: DataTypes.STRING, autoDownloadEpisodes: !!oldPodcast.autoDownloadEpisodes,
explicit: DataTypes.BOOLEAN, autoDownloadSchedule: oldPodcast.autoDownloadSchedule,
lastEpisodeCheck: oldPodcast.lastEpisodeCheck,
maxEpisodesToKeep: oldPodcast.maxEpisodesToKeep,
maxNewEpisodesToDownload: oldPodcast.maxNewEpisodesToDownload,
coverPath: oldPodcast.coverPath,
tags: oldPodcast.tags,
genres: oldPodcastMetadata.genres
}
}
autoDownloadEpisodes: DataTypes.BOOLEAN, /**
autoDownloadSchedule: DataTypes.STRING, * Initialize model
lastEpisodeCheck: DataTypes.DATE, * @param {import('../Database').sequelize} sequelize
maxEpisodesToKeep: DataTypes.INTEGER, */
maxNewEpisodesToDownload: DataTypes.INTEGER, static init(sequelize) {
coverPath: DataTypes.STRING, super.init({
tags: DataTypes.JSON, id: {
genres: DataTypes.JSON type: DataTypes.UUID,
}, { defaultValue: DataTypes.UUIDV4,
sequelize, primaryKey: true
modelName: 'podcast' },
}) title: DataTypes.STRING,
titleIgnorePrefix: DataTypes.STRING,
author: DataTypes.STRING,
releaseDate: DataTypes.STRING,
feedURL: DataTypes.STRING,
imageURL: DataTypes.STRING,
description: DataTypes.TEXT,
itunesPageURL: DataTypes.STRING,
itunesId: DataTypes.STRING,
itunesArtistId: DataTypes.STRING,
language: DataTypes.STRING,
podcastType: DataTypes.STRING,
explicit: DataTypes.BOOLEAN,
return Podcast autoDownloadEpisodes: DataTypes.BOOLEAN,
} autoDownloadSchedule: DataTypes.STRING,
lastEpisodeCheck: DataTypes.DATE,
maxEpisodesToKeep: DataTypes.INTEGER,
maxNewEpisodesToDownload: DataTypes.INTEGER,
coverPath: DataTypes.STRING,
tags: DataTypes.JSON,
genres: DataTypes.JSON
}, {
sequelize,
modelName: 'podcast'
})
}
}
module.exports = Podcast

View File

@ -1,102 +1,162 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model } = require('sequelize')
const oldPodcastEpisode = require('../objects/entities/PodcastEpisode')
module.exports = (sequelize) => { /**
class PodcastEpisode extends Model { * @typedef ChapterObject
getOldPodcastEpisode(libraryItemId = null) { * @property {number} id
let enclosure = null * @property {number} start
if (this.enclosureURL) { * @property {number} end
enclosure = { * @property {string} title
url: this.enclosureURL, */
type: this.enclosureType,
length: this.enclosureSize !== null ? String(this.enclosureSize) : null class PodcastEpisode extends Model {
} constructor(values, options) {
} super(values, options)
return {
libraryItemId: libraryItemId || null, /** @type {string} */
podcastId: this.podcastId, this.id
id: this.id, /** @type {number} */
oldEpisodeId: this.extraData?.oldEpisodeId || null, this.index
index: this.index, /** @type {string} */
season: this.season, this.season
episode: this.episode, /** @type {string} */
episodeType: this.episodeType, this.episode
title: this.title, /** @type {string} */
subtitle: this.subtitle, this.episodeType
description: this.description, /** @type {string} */
enclosure, this.title
pubDate: this.pubDate, /** @type {string} */
chapters: this.chapters, this.subtitle
audioFile: this.audioFile, /** @type {string} */
publishedAt: this.publishedAt?.valueOf() || null, this.description
addedAt: this.createdAt.valueOf(), /** @type {string} */
updatedAt: this.updatedAt.valueOf() this.pubDate
/** @type {string} */
this.enclosureURL
/** @type {BigInt} */
this.enclosureSize
/** @type {string} */
this.enclosureType
/** @type {Date} */
this.publishedAt
/** @type {import('./Book').AudioFileObject} */
this.audioFile
/** @type {ChapterObject[]} */
this.chapters
/** @type {Object} */
this.extraData
/** @type {string} */
this.podcastId
/** @type {Date} */
this.createdAt
/** @type {Date} */
this.updatedAt
}
/**
* @param {string} libraryItemId
* @returns {oldPodcastEpisode}
*/
getOldPodcastEpisode(libraryItemId = null) {
let enclosure = null
if (this.enclosureURL) {
enclosure = {
url: this.enclosureURL,
type: this.enclosureType,
length: this.enclosureSize !== null ? String(this.enclosureSize) : null
} }
} }
return new oldPodcastEpisode({
libraryItemId: libraryItemId || null,
podcastId: this.podcastId,
id: this.id,
oldEpisodeId: this.extraData?.oldEpisodeId || null,
index: this.index,
season: this.season,
episode: this.episode,
episodeType: this.episodeType,
title: this.title,
subtitle: this.subtitle,
description: this.description,
enclosure,
pubDate: this.pubDate,
chapters: this.chapters,
audioFile: this.audioFile,
publishedAt: this.publishedAt?.valueOf() || null,
addedAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf()
})
}
static createFromOld(oldEpisode) { static createFromOld(oldEpisode) {
const podcastEpisode = this.getFromOld(oldEpisode) const podcastEpisode = this.getFromOld(oldEpisode)
return this.create(podcastEpisode) return this.create(podcastEpisode)
}
static getFromOld(oldEpisode) {
const extraData = {}
if (oldEpisode.oldEpisodeId) {
extraData.oldEpisodeId = oldEpisode.oldEpisodeId
} }
return {
static getFromOld(oldEpisode) { id: oldEpisode.id,
const extraData = {} index: oldEpisode.index,
if (oldEpisode.oldEpisodeId) { season: oldEpisode.season,
extraData.oldEpisodeId = oldEpisode.oldEpisodeId episode: oldEpisode.episode,
} episodeType: oldEpisode.episodeType,
return { title: oldEpisode.title,
id: oldEpisode.id, subtitle: oldEpisode.subtitle,
index: oldEpisode.index, description: oldEpisode.description,
season: oldEpisode.season, pubDate: oldEpisode.pubDate,
episode: oldEpisode.episode, enclosureURL: oldEpisode.enclosure?.url || null,
episodeType: oldEpisode.episodeType, enclosureSize: oldEpisode.enclosure?.length || null,
title: oldEpisode.title, enclosureType: oldEpisode.enclosure?.type || null,
subtitle: oldEpisode.subtitle, publishedAt: oldEpisode.publishedAt,
description: oldEpisode.description, podcastId: oldEpisode.podcastId,
pubDate: oldEpisode.pubDate, audioFile: oldEpisode.audioFile?.toJSON() || null,
enclosureURL: oldEpisode.enclosure?.url || null, chapters: oldEpisode.chapters,
enclosureSize: oldEpisode.enclosure?.length || null, extraData
enclosureType: oldEpisode.enclosure?.type || null,
publishedAt: oldEpisode.publishedAt,
podcastId: oldEpisode.podcastId,
audioFile: oldEpisode.audioFile?.toJSON() || null,
chapters: oldEpisode.chapters,
extraData
}
} }
} }
PodcastEpisode.init({ /**
id: { * Initialize model
type: DataTypes.UUID, * @param {import('../Database').sequelize} sequelize
defaultValue: DataTypes.UUIDV4, */
primaryKey: true static init(sequelize) {
}, super.init({
index: DataTypes.INTEGER, id: {
season: DataTypes.STRING, type: DataTypes.UUID,
episode: DataTypes.STRING, defaultValue: DataTypes.UUIDV4,
episodeType: DataTypes.STRING, primaryKey: true
title: DataTypes.STRING, },
subtitle: DataTypes.STRING(1000), index: DataTypes.INTEGER,
description: DataTypes.TEXT, season: DataTypes.STRING,
pubDate: DataTypes.STRING, episode: DataTypes.STRING,
enclosureURL: DataTypes.STRING, episodeType: DataTypes.STRING,
enclosureSize: DataTypes.BIGINT, title: DataTypes.STRING,
enclosureType: DataTypes.STRING, subtitle: DataTypes.STRING(1000),
publishedAt: DataTypes.DATE, description: DataTypes.TEXT,
pubDate: DataTypes.STRING,
enclosureURL: DataTypes.STRING,
enclosureSize: DataTypes.BIGINT,
enclosureType: DataTypes.STRING,
publishedAt: DataTypes.DATE,
audioFile: DataTypes.JSON, audioFile: DataTypes.JSON,
chapters: DataTypes.JSON, chapters: DataTypes.JSON,
extraData: DataTypes.JSON extraData: DataTypes.JSON
}, { }, {
sequelize, sequelize,
modelName: 'podcastEpisode' modelName: 'podcastEpisode'
}) })
const { podcast } = sequelize.models const { podcast } = sequelize.models
podcast.hasMany(PodcastEpisode, { podcast.hasMany(PodcastEpisode, {
onDelete: 'CASCADE' onDelete: 'CASCADE'
}) })
PodcastEpisode.belongsTo(podcast) PodcastEpisode.belongsTo(podcast)
}
}
return PodcastEpisode module.exports = PodcastEpisode
}

View File

@ -1,82 +1,161 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model, literal } = require('sequelize')
const oldSeries = require('../objects/entities/Series') const oldSeries = require('../objects/entities/Series')
module.exports = (sequelize) => { class Series extends Model {
class Series extends Model { constructor(values, options) {
static async getAllOldSeries() { super(values, options)
const series = await this.findAll()
return series.map(se => se.getOldSeries())
}
getOldSeries() { /** @type {UUIDV4} */
return new oldSeries({ this.id
id: this.id, /** @type {string} */
name: this.name, this.name
description: this.description, /** @type {string} */
libraryId: this.libraryId, this.nameIgnorePrefix
addedAt: this.createdAt.valueOf(), /** @type {string} */
updatedAt: this.updatedAt.valueOf() this.description
}) /** @type {UUIDV4} */
} this.libraryId
/** @type {Date} */
this.createdAt
/** @type {Date} */
this.updatedAt
}
static updateFromOld(oldSeries) { static async getAllOldSeries() {
const series = this.getFromOld(oldSeries) const series = await this.findAll()
return this.update(series, { return series.map(se => se.getOldSeries())
where: { }
id: series.id
}
})
}
static createFromOld(oldSeries) { getOldSeries() {
const series = this.getFromOld(oldSeries) return new oldSeries({
return this.create(series) id: this.id,
} name: this.name,
description: this.description,
libraryId: this.libraryId,
addedAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf()
})
}
static createBulkFromOld(oldSeriesObjs) { static updateFromOld(oldSeries) {
const series = oldSeriesObjs.map(this.getFromOld) const series = this.getFromOld(oldSeries)
return this.bulkCreate(series) return this.update(series, {
} where: {
id: series.id
static getFromOld(oldSeries) {
return {
id: oldSeries.id,
name: oldSeries.name,
nameIgnorePrefix: oldSeries.nameIgnorePrefix,
description: oldSeries.description,
libraryId: oldSeries.libraryId
} }
} })
}
static removeById(seriesId) { static createFromOld(oldSeries) {
return this.destroy({ const series = this.getFromOld(oldSeries)
where: { return this.create(series)
id: seriesId }
}
}) static createBulkFromOld(oldSeriesObjs) {
const series = oldSeriesObjs.map(this.getFromOld)
return this.bulkCreate(series)
}
static getFromOld(oldSeries) {
return {
id: oldSeries.id,
name: oldSeries.name,
nameIgnorePrefix: oldSeries.nameIgnorePrefix,
description: oldSeries.description,
libraryId: oldSeries.libraryId
} }
} }
Series.init({ static removeById(seriesId) {
id: { return this.destroy({
type: DataTypes.UUID, where: {
defaultValue: DataTypes.UUIDV4, id: seriesId
primaryKey: true }
}, })
name: DataTypes.STRING, }
nameIgnorePrefix: DataTypes.STRING,
description: DataTypes.TEXT
}, {
sequelize,
modelName: 'series'
})
const { library } = sequelize.models /**
library.hasMany(Series, { * Get oldSeries by id
onDelete: 'CASCADE' * @param {string} seriesId
}) * @returns {Promise<oldSeries>}
Series.belongsTo(library) */
static async getOldById(seriesId) {
const series = await this.findByPk(seriesId)
if (!series) return null
return series.getOldSeries()
}
return Series /**
} * Check if series exists
* @param {string} seriesId
* @returns {Promise<boolean>}
*/
static async checkExistsById(seriesId) {
return (await this.count({ where: { id: seriesId } })) > 0
}
/**
* Get old series by name and libraryId. name case insensitive
*
* @param {string} seriesName
* @param {string} libraryId
* @returns {Promise<oldSeries>}
*/
static async getOldByNameAndLibrary(seriesName, libraryId) {
const series = (await this.findOne({
where: [
literal(`name = '${seriesName}' COLLATE NOCASE`),
{
libraryId
}
]
}))?.getOldSeries()
return series
}
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
name: DataTypes.STRING,
nameIgnorePrefix: DataTypes.STRING,
description: DataTypes.TEXT
}, {
sequelize,
modelName: 'series',
indexes: [
{
fields: [{
name: 'name',
collate: 'NOCASE'
}]
},
// {
// fields: [{
// name: 'nameIgnorePrefix',
// collate: 'NOCASE'
// }]
// },
{
fields: ['libraryId']
}
]
})
const { library } = sequelize.models
library.hasMany(Series, {
onDelete: 'CASCADE'
})
Series.belongsTo(library)
}
}
module.exports = Series

View File

@ -4,42 +4,59 @@ const oldEmailSettings = require('../objects/settings/EmailSettings')
const oldServerSettings = require('../objects/settings/ServerSettings') const oldServerSettings = require('../objects/settings/ServerSettings')
const oldNotificationSettings = require('../objects/settings/NotificationSettings') const oldNotificationSettings = require('../objects/settings/NotificationSettings')
module.exports = (sequelize) => { class Setting extends Model {
class Setting extends Model { constructor(values, options) {
static async getOldSettings() { super(values, options)
const settings = (await this.findAll()).map(se => se.value)
/** @type {string} */
this.key
/** @type {Object} */
this.value
/** @type {Date} */
this.createdAt
/** @type {Date} */
this.updatedAt
}
static async getOldSettings() {
const settings = (await this.findAll()).map(se => se.value)
const emailSettingsJson = settings.find(se => se.id === 'email-settings') const emailSettingsJson = settings.find(se => se.id === 'email-settings')
const serverSettingsJson = settings.find(se => se.id === 'server-settings') const serverSettingsJson = settings.find(se => se.id === 'server-settings')
const notificationSettingsJson = settings.find(se => se.id === 'notification-settings') const notificationSettingsJson = settings.find(se => se.id === 'notification-settings')
return { return {
settings, settings,
emailSettings: new oldEmailSettings(emailSettingsJson), emailSettings: new oldEmailSettings(emailSettingsJson),
serverSettings: new oldServerSettings(serverSettingsJson), serverSettings: new oldServerSettings(serverSettingsJson),
notificationSettings: new oldNotificationSettings(notificationSettingsJson) notificationSettings: new oldNotificationSettings(notificationSettingsJson)
}
}
static updateSettingObj(setting) {
return this.upsert({
key: setting.id,
value: setting
})
} }
} }
Setting.init({ static updateSettingObj(setting) {
key: { return this.upsert({
type: DataTypes.STRING, key: setting.id,
primaryKey: true value: setting
}, })
value: DataTypes.JSON }
}, {
sequelize,
modelName: 'setting'
})
return Setting /**
} * Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init({
key: {
type: DataTypes.STRING,
primaryKey: true
},
value: DataTypes.JSON
}, {
sequelize,
modelName: 'setting'
})
}
}
module.exports = Setting

View File

@ -3,238 +3,273 @@ const { DataTypes, Model, Op } = require('sequelize')
const Logger = require('../Logger') const Logger = require('../Logger')
const oldUser = require('../objects/user/User') const oldUser = require('../objects/user/User')
module.exports = (sequelize) => { class User extends Model {
class User extends Model { constructor(values, options) {
/** super(values, options)
* Get all oldUsers
* @returns {Promise<oldUser>}
*/
static async getOldUsers() {
const users = await this.findAll({
include: sequelize.models.mediaProgress
})
return users.map(u => this.getOldUser(u))
}
static getOldUser(userExpanded) { /** @type {UUIDV4} */
const mediaProgress = userExpanded.mediaProgresses.map(mp => mp.getOldMediaProgress()) this.id
/** @type {string} */
this.username
/** @type {string} */
this.email
/** @type {string} */
this.pash
/** @type {string} */
this.type
/** @type {boolean} */
this.isActive
/** @type {boolean} */
this.isLocked
/** @type {Date} */
this.lastSeen
/** @type {Object} */
this.permissions
/** @type {Object} */
this.bookmarks
/** @type {Object} */
this.extraData
/** @type {Date} */
this.createdAt
/** @type {Date} */
this.updatedAt
}
const librariesAccessible = userExpanded.permissions?.librariesAccessible || [] /**
const itemTagsSelected = userExpanded.permissions?.itemTagsSelected || [] * Get all oldUsers
const permissions = userExpanded.permissions || {} * @returns {Promise<oldUser>}
delete permissions.librariesAccessible */
delete permissions.itemTagsSelected static async getOldUsers() {
const users = await this.findAll({
include: this.sequelize.models.mediaProgress
})
return users.map(u => this.getOldUser(u))
}
return new oldUser({ static getOldUser(userExpanded) {
id: userExpanded.id, const mediaProgress = userExpanded.mediaProgresses.map(mp => mp.getOldMediaProgress())
oldUserId: userExpanded.extraData?.oldUserId || null,
username: userExpanded.username,
pash: userExpanded.pash,
type: userExpanded.type,
token: userExpanded.token,
mediaProgress,
seriesHideFromContinueListening: userExpanded.extraData?.seriesHideFromContinueListening || [],
bookmarks: userExpanded.bookmarks,
isActive: userExpanded.isActive,
isLocked: userExpanded.isLocked,
lastSeen: userExpanded.lastSeen?.valueOf() || null,
createdAt: userExpanded.createdAt.valueOf(),
permissions,
librariesAccessible,
itemTagsSelected
})
}
static createFromOld(oldUser) { const librariesAccessible = userExpanded.permissions?.librariesAccessible || []
const user = this.getFromOld(oldUser) const itemTagsSelected = userExpanded.permissions?.itemTagsSelected || []
return this.create(user) const permissions = userExpanded.permissions || {}
} delete permissions.librariesAccessible
delete permissions.itemTagsSelected
static updateFromOld(oldUser) { return new oldUser({
const user = this.getFromOld(oldUser) id: userExpanded.id,
return this.update(user, { oldUserId: userExpanded.extraData?.oldUserId || null,
where: { username: userExpanded.username,
id: user.id pash: userExpanded.pash,
} type: userExpanded.type,
}).then((result) => result[0] > 0).catch((error) => { token: userExpanded.token,
Logger.error(`[User] Failed to save user ${oldUser.id}`, error) mediaProgress,
return false seriesHideFromContinueListening: userExpanded.extraData?.seriesHideFromContinueListening || [],
}) bookmarks: userExpanded.bookmarks,
} isActive: userExpanded.isActive,
isLocked: userExpanded.isLocked,
lastSeen: userExpanded.lastSeen?.valueOf() || null,
createdAt: userExpanded.createdAt.valueOf(),
permissions,
librariesAccessible,
itemTagsSelected
})
}
static getFromOld(oldUser) { static createFromOld(oldUser) {
return { const user = this.getFromOld(oldUser)
id: oldUser.id, return this.create(user)
username: oldUser.username, }
pash: oldUser.pash || null,
type: oldUser.type || null, static updateFromOld(oldUser) {
token: oldUser.token || null, const user = this.getFromOld(oldUser)
isActive: !!oldUser.isActive, return this.update(user, {
lastSeen: oldUser.lastSeen || null, where: {
extraData: { id: user.id
seriesHideFromContinueListening: oldUser.seriesHideFromContinueListening || [],
oldUserId: oldUser.oldUserId
},
createdAt: oldUser.createdAt || Date.now(),
permissions: {
...oldUser.permissions,
librariesAccessible: oldUser.librariesAccessible || [],
itemTagsSelected: oldUser.itemTagsSelected || []
},
bookmarks: oldUser.bookmarks
} }
} }).then((result) => result[0] > 0).catch((error) => {
Logger.error(`[User] Failed to save user ${oldUser.id}`, error)
return false
})
}
static removeById(userId) { static getFromOld(oldUser) {
return this.destroy({ return {
where: { id: oldUser.id,
id: userId username: oldUser.username,
} pash: oldUser.pash || null,
}) type: oldUser.type || null,
} token: oldUser.token || null,
isActive: !!oldUser.isActive,
/** lastSeen: oldUser.lastSeen || null,
* Create root user extraData: {
* @param {string} username seriesHideFromContinueListening: oldUser.seriesHideFromContinueListening || [],
* @param {string} pash oldUserId: oldUser.oldUserId
* @param {Auth} auth },
* @returns {oldUser} createdAt: oldUser.createdAt || Date.now(),
*/ permissions: {
static async createRootUser(username, pash, auth) { ...oldUser.permissions,
const userId = uuidv4() librariesAccessible: oldUser.librariesAccessible || [],
itemTagsSelected: oldUser.itemTagsSelected || []
const token = await auth.generateAccessToken({ userId, username }) },
bookmarks: oldUser.bookmarks
const newRoot = new oldUser({
id: userId,
type: 'root',
username,
pash,
token,
isActive: true,
createdAt: Date.now()
})
await this.createFromOld(newRoot)
return newRoot
}
/**
* Get a user by id or by the old database id
* @temp User ids were updated in v2.3.0 migration and old API tokens may still use that id
* @param {string} userId
* @returns {Promise<oldUser|null>} null if not found
*/
static async getUserByIdOrOldId(userId) {
if (!userId) return null
const user = await this.findOne({
where: {
[Op.or]: [
{
id: userId
},
{
extraData: {
[Op.substring]: userId
}
}
]
},
include: sequelize.models.mediaProgress
})
if (!user) return null
return this.getOldUser(user)
}
/**
* Get user by username case insensitive
* @param {string} username
* @returns {Promise<oldUser|null>} returns null if not found
*/
static async getUserByUsername(username) {
if (!username) return null
const user = await this.findOne({
where: {
username: {
[Op.like]: username
}
},
include: sequelize.models.mediaProgress
})
if (!user) return null
return this.getOldUser(user)
}
/**
* Get user by id
* @param {string} userId
* @returns {Promise<oldUser|null>} returns null if not found
*/
static async getUserById(userId) {
if (!userId) return null
const user = await this.findByPk(userId, {
include: sequelize.models.mediaProgress
})
if (!user) return null
return this.getOldUser(user)
}
/**
* Get array of user id and username
* @returns {object[]} { id, username }
*/
static async getMinifiedUserObjects() {
const users = await this.findAll({
attributes: ['id', 'username']
})
return users.map(u => {
return {
id: u.id,
username: u.username
}
})
}
/**
* Return true if root user exists
* @returns {boolean}
*/
static async getHasRootUser() {
const count = await this.count({
where: {
type: 'root'
}
})
return count > 0
} }
} }
User.init({ static removeById(userId) {
id: { return this.destroy({
type: DataTypes.UUID, where: {
defaultValue: DataTypes.UUIDV4, id: userId
primaryKey: true }
}, })
username: DataTypes.STRING, }
email: DataTypes.STRING,
pash: DataTypes.STRING,
type: DataTypes.STRING,
token: DataTypes.STRING,
isActive: {
type: DataTypes.BOOLEAN,
defaultValue: false
},
isLocked: {
type: DataTypes.BOOLEAN,
defaultValue: false
},
lastSeen: DataTypes.DATE,
permissions: DataTypes.JSON,
bookmarks: DataTypes.JSON,
extraData: DataTypes.JSON
}, {
sequelize,
modelName: 'user'
})
return User /**
} * Create root user
* @param {string} username
* @param {string} pash
* @param {Auth} auth
* @returns {oldUser}
*/
static async createRootUser(username, pash, auth) {
const userId = uuidv4()
const token = await auth.generateAccessToken({ userId, username })
const newRoot = new oldUser({
id: userId,
type: 'root',
username,
pash,
token,
isActive: true,
createdAt: Date.now()
})
await this.createFromOld(newRoot)
return newRoot
}
/**
* Get a user by id or by the old database id
* @temp User ids were updated in v2.3.0 migration and old API tokens may still use that id
* @param {string} userId
* @returns {Promise<oldUser|null>} null if not found
*/
static async getUserByIdOrOldId(userId) {
if (!userId) return null
const user = await this.findOne({
where: {
[Op.or]: [
{
id: userId
},
{
extraData: {
[Op.substring]: userId
}
}
]
},
include: this.sequelize.models.mediaProgress
})
if (!user) return null
return this.getOldUser(user)
}
/**
* Get user by username case insensitive
* @param {string} username
* @returns {Promise<oldUser|null>} returns null if not found
*/
static async getUserByUsername(username) {
if (!username) return null
const user = await this.findOne({
where: {
username: {
[Op.like]: username
}
},
include: this.sequelize.models.mediaProgress
})
if (!user) return null
return this.getOldUser(user)
}
/**
* Get user by id
* @param {string} userId
* @returns {Promise<oldUser|null>} returns null if not found
*/
static async getUserById(userId) {
if (!userId) return null
const user = await this.findByPk(userId, {
include: this.sequelize.models.mediaProgress
})
if (!user) return null
return this.getOldUser(user)
}
/**
* Get array of user id and username
* @returns {object[]} { id, username }
*/
static async getMinifiedUserObjects() {
const users = await this.findAll({
attributes: ['id', 'username']
})
return users.map(u => {
return {
id: u.id,
username: u.username
}
})
}
/**
* Return true if root user exists
* @returns {boolean}
*/
static async getHasRootUser() {
const count = await this.count({
where: {
type: 'root'
}
})
return count > 0
}
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
username: DataTypes.STRING,
email: DataTypes.STRING,
pash: DataTypes.STRING,
type: DataTypes.STRING,
token: DataTypes.STRING,
isActive: {
type: DataTypes.BOOLEAN,
defaultValue: false
},
isLocked: {
type: DataTypes.BOOLEAN,
defaultValue: false
},
lastSeen: DataTypes.DATE,
permissions: DataTypes.JSON,
bookmarks: DataTypes.JSON,
extraData: DataTypes.JSON
}, {
sequelize,
modelName: 'user'
})
}
}
module.exports = User

View File

@ -61,6 +61,10 @@ class FeedMeta {
} }
getRSSData() { getRSSData() {
const blockTags = [
{ 'itunes:block': 'yes' },
{ 'googleplay:block': 'yes' }
]
return { return {
title: this.title, title: this.title,
description: this.description || '', description: this.description || '',
@ -94,8 +98,7 @@ class FeedMeta {
] ]
}, },
{ 'itunes:explicit': !!this.explicit }, { 'itunes:explicit': !!this.explicit },
{ 'itunes:block': this.preventIndexing?"Yes":"No" }, ...(this.preventIndexing ? blockTags : [])
{ 'googleplay:block': this.preventIndexing?"yes":"no" }
] ]
} }
} }

Some files were not shown because too many files have changed in this diff Show More