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
$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
# Temp downloading tone library to the ffmpeg dir
echo "Getting 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
echo "Good to go on Ffmpeg (& tone)... hopefully"

View File

@ -171,7 +171,7 @@ export default {
},
async fetchCategories() {
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) => {
return data
})

View File

@ -99,6 +99,11 @@ export default {
id: 'config-item-metadata-utils',
title: this.$strings.HeaderItemMetadataUtils,
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
// 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 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
},
async init(bookshelf) {
if (this.entityName === 'series') {
this.booksPerFetch = 50
} else {
this.booksPerFetch = 100
}
this.checkUpdateSearchParams()
this.initSizeData(bookshelf)

View File

@ -219,7 +219,7 @@ export default {
return this.mediaMetadata.series
},
seriesSequence() {
return this.series ? this.series.sequence : null
return this.series?.sequence || null
},
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 === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
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
},
episodeProgress() {

View File

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

View File

@ -103,7 +103,7 @@ export default {
return this.$store.state.libraries.currentLibraryId
},
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: {

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 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' }" />
<p class="text-center text-error" :style="{ fontSize: sizeMultiplier + 'rem' }">Invalid Cover</p>
<img v-if="width > 100" src="/Logo.png" class="mb-2" :style="{ height: 40 * sizeMultiplier + 'px' }" />
<p class="text-center text-error" :style="{ fontSize: invalidCoverFontSize + 'rem' }">Invalid Cover</p>
</div>
</div>
@ -58,6 +58,9 @@ export default {
sizeMultiplier() {
return this.width / 120
},
invalidCoverFontSize() {
return Math.max(this.sizeMultiplier * 0.8, 0.5)
},
placeholderCoverPadding() {
return 0.8 * this.sizeMultiplier
},

View File

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

View File

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

View File

@ -132,6 +132,8 @@ export default {
return
}
this.processing = true
const payload = {
serverAddress: window.origin,
slug: this.newFeedSlug,
@ -151,6 +153,9 @@ export default {
const errorMsg = error.response ? error.response.data : null
this.$toast.error(errorMsg || 'Failed to open RSS Feed')
})
.finally(() => {
this.processing = false
})
},
copyToClipboard(str) {
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) {
var basename = Path.basename(filename, Path.extname(filename))
var numbersinpath = basename.match(/\d{1,5}/g)
if (!numbersinpath || !numbersinpath.length) {
var numbersinpath = basename.match(/\d+/g)
if (!numbersinpath?.length) {
return {
index: -1,
filename

View File

@ -18,7 +18,7 @@
</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">
<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>
@ -58,26 +58,32 @@ export default {
return {}
},
computed: {
currentLibraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
isBookLibrary() {
return this.currentLibraryMediaType === 'book'
},
user() {
return this.$store.state.user.user
},
totalItems() {
return this.libraryStats ? this.libraryStats.totalItems : 0
return this.libraryStats?.totalItems || 0
},
totalAuthors() {
return this.libraryStats ? this.libraryStats.totalAuthors : 0
return this.libraryStats?.totalAuthors || 0
},
numAudioTracks() {
return this.libraryStats ? this.libraryStats.numAudioTracks : 0
return this.libraryStats?.numAudioTracks || 0
},
totalDuration() {
return this.libraryStats ? this.libraryStats.totalDuration : 0
return this.libraryStats?.totalDuration || 0
},
totalHours() {
return Math.round(this.totalDuration / (60 * 60))
},
totalSizePretty() {
var totalSize = this.libraryStats ? this.libraryStats.totalSize : 0
var totalSize = this.libraryStats?.totalSize || 0
return this.$bytesPretty(totalSize, 1)
},
totalSizeNum() {

View File

@ -11,10 +11,6 @@
<ui-btn @click="clickAddLibrary">{{ $strings.ButtonAddYourFirstLibrary }}</ui-btn>
</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">
**<strong>{{ $strings.ButtonMatchBooks }}</strong> {{ $strings.MessageMatchBooksDescription }}
</p>

View File

@ -71,11 +71,6 @@ export default {
text: this.$strings.ButtonScan,
action: 'scan',
value: 'scan'
},
{
text: this.$strings.ButtonForceReScan,
action: 'force-scan',
value: 'force-scan'
}
]
if (this.isBookLibrary) {
@ -137,26 +132,6 @@ export default {
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() {
const payload = {
message: this.$getString('MessageConfirmDeleteLibrary', [this.library.name]),

View File

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

View File

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

View File

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

View File

@ -55,6 +55,7 @@ export default {
else if (pageName === 'library-stats') return this.$strings.HeaderLibraryStats
else if (pageName === 'users') return this.$strings.HeaderUsers
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
}
return this.$strings.HeaderSettings

View File

@ -36,7 +36,10 @@
</ui-tooltip>
</div>
<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 class="flex items-center py-2 mb-2">
@ -157,10 +160,10 @@
</div>
<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-tooltip :text="$strings.LabelSettingsDisableWatcherHelp">
<ui-toggle-switch labeledBy="settings-disable-watcher" v-model="scannerEnableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', !val)" />
<ui-tooltip :text="$strings.LabelSettingsEnableWatcherHelp">
<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>
</p>
</ui-tooltip>
@ -259,9 +262,12 @@ export default {
updatingServerSettings: false,
homepageUseBookshelfView: false,
useBookshelfView: false,
scannerEnableWatcher: false,
isPurgingCache: false,
hasPrefixesChanged: false,
newServerSettings: {},
showConfirmPurgeCache: false,
savingPrefixes: false,
metadataFileFormats: [
{
text: '.json',
@ -304,15 +310,36 @@ export default {
}
},
methods: {
updateSortingPrefixes(val) {
if (!val || !val.length) {
sortingPrefixesUpdated(val) {
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')
return
}
var prefixes = val.map((prefix) => prefix.trim().toLowerCase())
this.updateServerSettings({
sortingPrefixes: prefixes
})
this.savingPrefixes = true
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) {
this.updateServerSettings({
@ -337,6 +364,9 @@ export default {
this.updateSettingsKey('metadataFileFormat', val)
},
updateSettingsKey(key, val) {
if (key === 'scannerDisableWatcher') {
this.newServerSettings.scannerDisableWatcher = val
}
this.updateServerSettings({
[key]: val
})
@ -363,6 +393,7 @@ export default {
initServerSettings() {
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
this.newServerSettings.sortingPrefixes = [...(this.newServerSettings.sortingPrefixes || [])]
this.scannerEnableWatcher = !this.newServerSettings.scannerDisableWatcher
this.homepageUseBookshelfView = this.newServerSettings.homeBookshelfView != this.$constants.BookshelfView.DETAIL
this.useBookshelfView = this.newServerSettings.bookshelfView != this.$constants.BookshelfView.DETAIL

View File

@ -22,7 +22,7 @@
</div>
</template>
</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>
<p v-if="!top10Authors.length">{{ $strings.MessageNoAuthors }}</p>
<template v-for="(author, index) in top10Authors">
@ -114,43 +114,49 @@ export default {
return this.$store.state.user.user
},
totalItems() {
return this.libraryStats ? this.libraryStats.totalItems : 0
return this.libraryStats?.totalItems || 0
},
genresWithCount() {
return this.libraryStats ? this.libraryStats.genresWithCount : []
return this.libraryStats?.genresWithCount || []
},
top5Genres() {
return this.genresWithCount.slice(0, 5)
return this.genresWithCount?.slice(0, 5) || []
},
top10LongestItems() {
return this.libraryStats ? this.libraryStats.longestItems || [] : []
return this.libraryStats?.longestItems || []
},
longestItemDuration() {
if (!this.top10LongestItems.length) return 0
return this.top10LongestItems[0].duration
},
top10LargestItems() {
return this.libraryStats ? this.libraryStats.largestItems || [] : []
return this.libraryStats?.largestItems || []
},
largestItemSize() {
if (!this.top10LargestItems.length) return 0
return this.top10LargestItems[0].size
},
authorsWithCount() {
return this.libraryStats ? this.libraryStats.authorsWithCount : []
return this.libraryStats?.authorsWithCount || []
},
mostUsedAuthorCount() {
if (!this.authorsWithCount.length) return 0
return this.authorsWithCount[0].count
},
top10Authors() {
return this.authorsWithCount.slice(0, 10)
return this.authorsWithCount?.slice(0, 10) || []
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
currentLibraryName() {
return this.$store.getters['libraries/getCurrentLibraryName']
},
currentLibraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
isBookLibrary() {
return this.currentLibraryMediaType === 'book'
}
},
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">
<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">
<th class="w-16 text-left">{{ $strings.LabelItem }}</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.LabelLastUpdate }}</th>
</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>
<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>
<template v-if="item.media && item.media.metadata && item.episode">
<p>{{ item.episode.title || 'Unknown' }}</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>
<p>{{ item.displayTitle || 'Unknown' }}</p>
<p v-if="item.displaySubtitle" class="text-white text-opacity-50 text-sm font-sans">{{ item.displaySubtitle }}</p>
</td>
<td class="text-center">
<p class="text-sm">{{ Math.floor(item.progress * 100) }}%</p>
@ -124,9 +119,6 @@ export default {
mediaProgress() {
return this.user.mediaProgress.sort((a, b) => b.lastUpdate - a.lastUpdate)
},
mediaProgressWithMedia() {
return this.mediaProgress.filter((mp) => mp.media)
},
totalListeningTime() {
return this.listeningStats.totalTime || 0
},

View File

@ -160,7 +160,7 @@ export default {
}
// 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)
return false
})
@ -761,6 +761,7 @@ export default {
if (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('rss_feed_open', this.rssFeedOpen)
this.$root.socket.on('rss_feed_closed', this.rssFeedClosed)
@ -769,6 +770,7 @@ export default {
this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished)
},
beforeDestroy() {
this.$eventBus.$off(`${this.libraryItem.id}_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_closed', this.rssFeedClosed)

View File

@ -234,6 +234,10 @@ export const mutations = {
setNumUserPlaylists(state, 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) {
if (!libraryItem || !state.filterData) return
if (state.currentLibraryId !== libraryItem.libraryId) return

View File

@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Lösche {0} Episoden",
"HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "RSS-Feed ist geöffnet",
"HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Gespeicherte Hörfortschritte",
"HeaderSchedule": "Zeitplan",
"HeaderScheduleLibraryScans": "Automatische Bibliotheksscans",
@ -201,6 +202,7 @@
"LabelClosePlayer": "Player schließen",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Serien zusammenfassen",
"LabelCollection": "Sammlung",
"LabelCollections": "Sammlungen",
"LabelComplete": "Vollständig",
"LabelConfirmPassword": "Passwort bestätigen",
@ -222,7 +224,7 @@
"LabelDirectory": "Verzeichnis",
"LabelDiscFromFilename": "CD aus dem Dateinamen",
"LabelDiscFromMetadata": "CD aus den Metadaten",
"LabelDiscover": "Discover",
"LabelDiscover": "Finden",
"LabelDownload": "Herunterladen",
"LabelDownloadNEpisodes": "Download {0} episodes",
"LabelDuration": "Laufzeit",
@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Überwachung 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",
"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",
"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",
@ -428,6 +433,7 @@
"LabelShowAll": "Alles anzeigen",
"LabelSize": "Größe",
"LabelSleepTimer": "Einschlaf-Timer",
"LabelSlug": "URL Teil",
"LabelStart": "Start",
"LabelStarted": "Gestartet",
"LabelStartedAt": "Gestartet am",
@ -475,7 +481,7 @@
"LabelTrackFromMetadata": "Titel aus Metadaten",
"LabelTracks": "Dateien",
"LabelTracksMultiTrack": "Mehrfachdatei",
"LabelTracksNone": "No tracks",
"LabelTracksNone": "Keine Dateien",
"LabelTracksSingleTrack": "Einzeldatei",
"LabelType": "Typ",
"LabelUnabridged": "Ungekürzt",
@ -496,7 +502,7 @@
"LabelViewBookmarks": "Lesezeichen anzeigen",
"LabelViewChapters": "Kapitel anzeigen",
"LabelViewQueue": "Spieler-Warteschlange anzeigen",
"LabelVolume": "Volume",
"LabelVolume": "Volumen",
"LabelWeekdaysToRun": "Wochentage für die Ausführung",
"LabelYourAudiobookDuration": "Laufzeit Ihres Mediums",
"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)",
"MessageChapterStartIsAfter": "Ungültige Kapitelstartzeit: Kapitelanfang > Mediumende (Kapitelanfang liegt nach dem Ende des Mediums)",
"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?",
"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?",
"MessageConfirmDeleteSession": "Sind Sie sicher, dass Sie diese Sitzung löschen möchten?",
"MessageConfirmForceReScan": "Sind Sie sicher, dass Sie einen erneuten Scanvorgang erzwingen wollen?",
@ -560,7 +567,7 @@
"MessageMarkAllEpisodesNotFinished": "Alle Episoden als ungehört markieren",
"MessageMarkAsFinished": "Als beendet 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",
"MessageNoAuthors": "Keine Autoren",
"MessageNoBackups": "Keine Sicherungen",
@ -596,7 +603,7 @@
"MessagePauseChapter": "Kapitelwiedergabe pausieren",
"MessagePlayChapter": "Kapitelanfang anhören",
"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.",
"MessageRemoveChapter": "Kapitel löschen",
"MessageRemoveEpisodes": "Entferne {0} Episode(n)",

View File

@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Remove {0} Episodes",
"HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "RSS Feed is Open",
"HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Saved Media Progress",
"HeaderSchedule": "Schedule",
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
@ -201,6 +202,7 @@
"LabelClosePlayer": "Close player",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Collapse Series",
"LabelCollection": "Collection",
"LabelCollections": "Collections",
"LabelComplete": "Complete",
"LabelConfirmPassword": "Confirm Password",
@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Disable Watcher",
"LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library",
"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",
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
"LabelSettingsFindCovers": "Find covers",
@ -428,6 +433,7 @@
"LabelShowAll": "Show All",
"LabelSize": "Size",
"LabelSleepTimer": "Sleep timer",
"LabelSlug": "Slug",
"LabelStart": "Start",
"LabelStarted": "Started",
"LabelStartedAt": "Started At",
@ -516,6 +522,7 @@
"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",
"MessageCheckingCron": "Checking cron...",
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",

View File

@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Remover {0} Episodios",
"HeaderRSSFeedGeneral": "Detalles RSS",
"HeaderRSSFeedIsOpen": "Fuente RSS esta abierta",
"HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Guardar Progreso de multimedia",
"HeaderSchedule": "Horario",
"HeaderScheduleLibraryScans": "Programar Escaneo Automático de Biblioteca",
@ -201,6 +202,7 @@
"LabelClosePlayer": "Close player",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Colapsar Series",
"LabelCollection": "Collection",
"LabelCollections": "Colecciones",
"LabelComplete": "Completo",
"LabelConfirmPassword": "Confirmar Contraseña",
@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Deshabilitar Watcher",
"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",
"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",
"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",
@ -428,6 +433,7 @@
"LabelShowAll": "Mostrar Todos",
"LabelSize": "Tamaño",
"LabelSleepTimer": "Temporizador para Dormir",
"LabelSlug": "Slug",
"LabelStart": "Iniciar",
"LabelStarted": "Indiciado",
"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",
"MessageChapterStartIsAfter": "El comienzo del capítulo es después del final de su audiolibro",
"MessageCheckingCron": "Checking cron...",
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
"MessageConfirmDeleteBackup": "Esta seguro que desea eliminar el respaldo {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Esta seguro que desea eliminar permanentemente la biblioteca \"{0}\"?",

View File

@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Suppression de {0} épisodes",
"HeaderRSSFeedGeneral": "Détails de flux RSS",
"HeaderRSSFeedIsOpen": "Le Flux RSS est actif",
"HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Progression de la sauvegarde des médias",
"HeaderSchedule": "Programmation",
"HeaderScheduleLibraryScans": "Analyse automatique de la bibliothèque",
@ -201,6 +202,7 @@
"LabelClosePlayer": "Fermer le lecteur",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Réduire les séries",
"LabelCollection": "Collection",
"LabelCollections": "Collections",
"LabelComplete": "Complet",
"LabelConfirmPassword": "Confirmer le mot de passe",
@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Désactiver la surveillance",
"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*",
"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",
"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",
@ -428,6 +433,7 @@
"LabelShowAll": "Afficher Tout",
"LabelSize": "Taille",
"LabelSleepTimer": "Minuterie",
"LabelSlug": "Slug",
"LabelStart": "Démarrer",
"LabelStarted": "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",
"MessageChapterStartIsAfter": "Le premier chapitre est situé au début de votre livre audio",
"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} ?",
"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} » ?",

View File

@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Remove {0} Episodes",
"HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "RSS Feed is Open",
"HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Saved Media Progress",
"HeaderSchedule": "Schedule",
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
@ -201,6 +202,7 @@
"LabelClosePlayer": "Close player",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Collapse Series",
"LabelCollection": "Collection",
"LabelCollections": "Collections",
"LabelComplete": "Complete",
"LabelConfirmPassword": "Confirm Password",
@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Disable Watcher",
"LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library",
"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",
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
"LabelSettingsFindCovers": "Find covers",
@ -428,6 +433,7 @@
"LabelShowAll": "Show All",
"LabelSize": "Size",
"LabelSleepTimer": "Sleep timer",
"LabelSlug": "Slug",
"LabelStart": "Start",
"LabelStarted": "Started",
"LabelStartedAt": "Started At",
@ -516,6 +522,7 @@
"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",
"MessageCheckingCron": "Checking cron...",
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",

View File

@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Remove {0} Episodes",
"HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "RSS Feed is Open",
"HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Saved Media Progress",
"HeaderSchedule": "Schedule",
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
@ -201,6 +202,7 @@
"LabelClosePlayer": "Close player",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Collapse Series",
"LabelCollection": "Collection",
"LabelCollections": "Collections",
"LabelComplete": "Complete",
"LabelConfirmPassword": "Confirm Password",
@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Disable Watcher",
"LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library",
"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",
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
"LabelSettingsFindCovers": "Find covers",
@ -428,6 +433,7 @@
"LabelShowAll": "Show All",
"LabelSize": "Size",
"LabelSleepTimer": "Sleep timer",
"LabelSlug": "Slug",
"LabelStart": "Start",
"LabelStarted": "Started",
"LabelStartedAt": "Started At",
@ -516,6 +522,7 @@
"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",
"MessageCheckingCron": "Checking cron...",
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",

View File

@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Ukloni {0} epizoda/-e",
"HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "RSS Feed je otvoren",
"HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Spremljen Media Progress",
"HeaderSchedule": "Schedule",
"HeaderScheduleLibraryScans": "Zakaži automatsko skeniranje biblioteke",
@ -201,6 +202,7 @@
"LabelClosePlayer": "Close player",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Collapse Series",
"LabelCollection": "Collection",
"LabelCollections": "Kolekcije",
"LabelComplete": "Complete",
"LabelConfirmPassword": "Potvrdi lozinku",
@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Isključi Watchera",
"LabelSettingsDisableWatcherForLibrary": "Isključi folder watchera za biblioteku",
"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",
"LabelSettingsExperimentalFeaturesHelp": "Features u razvoju trebaju vaš feedback i pomoć pri testiranju. Klikni da odeš to Github discussionsa.",
"LabelSettingsFindCovers": "Pronađi covers",
@ -428,6 +433,7 @@
"LabelShowAll": "Prikaži sve",
"LabelSize": "Veličina",
"LabelSleepTimer": "Sleep timer",
"LabelSlug": "Slug",
"LabelStart": "Pokreni",
"LabelStarted": "Pokrenuto",
"LabelStartedAt": "Pokrenuto",
@ -516,6 +522,7 @@
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
"MessageChapterStartIsAfter": "Početak poglavlja je nakon kraja audioknjige.",
"MessageCheckingCron": "Provjeravam cron...",
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
"MessageConfirmDeleteBackup": "Jeste li sigurni da želite obrisati backup za {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Jeste li sigurni da želite trajno obrisati biblioteku \"{0}\"?",

View File

@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Rimuovi {0} Episodi",
"HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "RSS Feed è aperto",
"HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Progressi salvati",
"HeaderSchedule": "Schedula",
"HeaderScheduleLibraryScans": "Schedula la scansione della libreria",
@ -201,6 +202,7 @@
"LabelClosePlayer": "Chiudi player",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Comprimi Serie",
"LabelCollection": "Collection",
"LabelCollections": "Raccolte",
"LabelComplete": "Completo",
"LabelConfirmPassword": "Conferma Password",
@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Disattiva Watcher",
"LabelSettingsDisableWatcherForLibrary": "Disattiva Watcher per le librerie",
"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",
"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",
@ -428,6 +433,7 @@
"LabelShowAll": "Mostra Tutto",
"LabelSize": "Dimensione",
"LabelSleepTimer": "Sleep timer",
"LabelSlug": "Slug",
"LabelStart": "Inizo",
"LabelStarted": "Iniziato",
"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",
"MessageChapterStartIsAfter": "L'inizio del capitolo è dopo la fine del tuo audiolibro",
"MessageCheckingCron": "Controllo cron...",
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
"MessageConfirmDeleteBackup": "Sei sicuro di voler eliminare il backup {0}?",
"MessageConfirmDeleteFile": "Questo eliminerà il file dal tuo file system. Sei sicuro?",
"MessageConfirmDeleteLibrary": "Sei sicuro di voler eliminare definitivamente la libreria \"{0}\"?",

View File

@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Pašalinti {0} epizodus",
"HeaderRSSFeedGeneral": "RSS informacija",
"HeaderRSSFeedIsOpen": "RSS srautas yra atidarytas",
"HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Išsaugota medijos pažanga",
"HeaderSchedule": "Tvarkaraštis",
"HeaderScheduleLibraryScans": "Nustatyti bibliotekų nuskaitymo tvarkaraštį",
@ -201,6 +202,7 @@
"LabelClosePlayer": "Uždaryti grotuvą",
"LabelCodec": "Kodekas",
"LabelCollapseSeries": "Suskleisti seriją",
"LabelCollection": "Collection",
"LabelCollections": "Kolekcijos",
"LabelComplete": "Baigta",
"LabelConfirmPassword": "Patvirtinkite slaptažodį",
@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Išjungti stebėtoją",
"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",
"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",
"LabelSettingsExperimentalFeaturesHelp": "Funkcijos, kurios yra kuriamos ir laukiami jūsų komentarai. Spustelėkite, kad atidarytumėte „GitHub“ diskusiją.",
"LabelSettingsFindCovers": "Rasti viršelius",
@ -428,6 +433,7 @@
"LabelShowAll": "Rodyti viską",
"LabelSize": "Dydis",
"LabelSleepTimer": "Miego laikmatis",
"LabelSlug": "Slug",
"LabelStart": "Pradėti",
"LabelStarted": "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",
"MessageChapterStartIsAfter": "Skyriaus pradžia yra po jūsų garso knygos pabaigos",
"MessageCheckingCron": "Tikrinamas cron...",
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
"MessageConfirmDeleteBackup": "Ar tikrai norite ištrinti atsarginę kopiją, skirtą {0}?",
"MessageConfirmDeleteFile": "Tai ištrins failą iš jūsų failų sistemos. Ar tikrai?",
"MessageConfirmDeleteLibrary": "Ar tikrai norite visam laikui ištrinti biblioteką \"{0}\"?",

View File

@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Verwijder {0} afleveringen",
"HeaderRSSFeedGeneral": "RSS-details",
"HeaderRSSFeedIsOpen": "RSS-feed is open",
"HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Opgeslagen mediavoortgang",
"HeaderSchedule": "Schema",
"HeaderScheduleLibraryScans": "Schema automatische bibliotheekscans",
@ -201,6 +202,7 @@
"LabelClosePlayer": "Sluit speler",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Series inklappen",
"LabelCollection": "Collection",
"LabelCollections": "Collecties",
"LabelComplete": "Compleet",
"LabelConfirmPassword": "Bevestig wachtwoord",
@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Watcher uitschakelen",
"LabelSettingsDisableWatcherForLibrary": "Map-watcher voor bibliotheek uitschakelen",
"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",
"LabelSettingsExperimentalFeaturesHelp": "Functies in ontwikkeling die je feedback en testing kunnen gebruiken. Klik om de Github-discussie te openen.",
"LabelSettingsFindCovers": "Zoek covers",
@ -428,6 +433,7 @@
"LabelShowAll": "Toon alle",
"LabelSize": "Grootte",
"LabelSleepTimer": "Slaaptimer",
"LabelSlug": "Slug",
"LabelStart": "Start",
"LabelStarted": "Gestart",
"LabelStartedAt": "Gestart op",
@ -516,6 +522,7 @@
"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",
"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?",
"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?",

View File

@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Usuń {0} odcinków",
"HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "Kanał RSS jest otwarty",
"HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Zapisany postęp",
"HeaderSchedule": "Harmonogram",
"HeaderScheduleLibraryScans": "Zaplanuj automatyczne skanowanie biblioteki",
@ -201,6 +202,7 @@
"LabelClosePlayer": "Zamknij odtwarzacz",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Podsumuj serię",
"LabelCollection": "Collection",
"LabelCollections": "Kolekcje",
"LabelComplete": "Ukończone",
"LabelConfirmPassword": "Potwierdź hasło",
@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Wyłącz monitorowanie",
"LabelSettingsDisableWatcherForLibrary": "Wyłącz monitorowanie folderów dla biblioteki",
"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",
"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",
@ -428,6 +433,7 @@
"LabelShowAll": "Pokaż wszystko",
"LabelSize": "Rozmiar",
"LabelSleepTimer": "Wyłącznik czasowy",
"LabelSlug": "Slug",
"LabelStart": "Rozpocznij",
"LabelStarted": "Rozpoczęty",
"LabelStartedAt": "Rozpoczęto",
@ -516,6 +522,7 @@
"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",
"MessageCheckingCron": "Sprawdzanie cron...",
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
"MessageConfirmDeleteBackup": "Czy na pewno chcesz usunąć kopię zapasową dla {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Czy na pewno chcesz trwale usunąć bibliotekę \"{0}\"?",

View File

@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Удалить {0} эпизодов",
"HeaderRSSFeedGeneral": "Сведения о RSS",
"HeaderRSSFeedIsOpen": "RSS-канал открыт",
"HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Прогресс медиа сохранен",
"HeaderSchedule": "Планировщик",
"HeaderScheduleLibraryScans": "Планировщик автоматического сканирования библиотеки",
@ -201,6 +202,7 @@
"LabelClosePlayer": "Закрыть проигрыватель",
"LabelCodec": "Кодек",
"LabelCollapseSeries": "Свернуть серии",
"LabelCollection": "Collection",
"LabelCollections": "Коллекции",
"LabelComplete": "Завершить",
"LabelConfirmPassword": "Подтвердить пароль",
@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Отключить отслеживание",
"LabelSettingsDisableWatcherForLibrary": "Отключить отслеживание для библиотеки",
"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": "Экспериментальные функции",
"LabelSettingsExperimentalFeaturesHelp": "Функционал в разработке на который Вы могли бы дать отзыв или помочь в тестировании. Нажмите для открытия обсуждения на github.",
"LabelSettingsFindCovers": "Найти обложки",
@ -428,6 +433,7 @@
"LabelShowAll": "Показать все",
"LabelSize": "Размер",
"LabelSleepTimer": "Таймер сна",
"LabelSlug": "Slug",
"LabelStart": "Начало",
"LabelStarted": "Начат",
"LabelStartedAt": "Начато В",
@ -516,6 +522,7 @@
"MessageChapterErrorStartLtPrev": "Неверное время начала, должно быть больше или равно времени начала предыдущей главы",
"MessageChapterStartIsAfter": "Глава начинается после окончания аудиокниги",
"MessageCheckingCron": "Проверка cron...",
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
"MessageConfirmDeleteBackup": "Вы уверены, что хотите удалить бэкап для {0}?",
"MessageConfirmDeleteFile": "Это удалит файл из Вашей файловой системы. Вы уверены?",
"MessageConfirmDeleteLibrary": "Вы уверены, что хотите навсегда удалить библиотеку \"{0}\"?",

View File

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

View File

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

8
package-lock.json generated
View File

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

View File

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

View File

@ -188,7 +188,7 @@ class Auth {
await Database.updateServerSettings()
// 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) {
for (const user of users) {
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.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.authors = []
this.series = []
// Cached library filter data
this.libraryFilterData = {}
/** @type {import('./objects/settings/ServerSettings')} */
this.serverSettings = null
/** @type {import('./objects/settings/NotificationSettings')} */
this.notificationSettings = null
/** @type {import('./objects/settings/EmailSettings')} */
this.emailSettings = null
}
@ -31,6 +32,105 @@ class Database {
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() {
if (!await fs.pathExists(this.dbPath)) {
Logger.info(`[Database] absdatabase.sqlite not found at ${this.dbPath}`)
@ -39,6 +139,10 @@ class Database {
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) {
this.dbPath = Path.join(global.ConfigPath, 'absdatabase.sqlite')
@ -52,9 +156,14 @@ class Database {
await this.buildModels(force)
Logger.info(`[Database] Db initialized with models:`, Object.keys(this.sequelize.models).join(', '))
await this.loadData()
}
/**
* Connect to db
* @returns {boolean}
*/
async connect() {
Logger.info(`[Database] Initializing db at "${this.dbPath}"`)
this.sequelize = new Sequelize({
@ -77,39 +186,45 @@ class Database {
}
}
/**
* Disconnect from db
*/
async disconnect() {
Logger.info(`[Database] Disconnecting sqlite db`)
await this.sequelize.close()
this.sequelize = null
}
/**
* Reconnect to db and init
*/
async reconnect() {
Logger.info(`[Database] Reconnecting sqlite db`)
await this.init()
}
buildModels(force = false) {
require('./models/User')(this.sequelize)
require('./models/Library')(this.sequelize)
require('./models/LibraryFolder')(this.sequelize)
require('./models/Book')(this.sequelize)
require('./models/Podcast')(this.sequelize)
require('./models/PodcastEpisode')(this.sequelize)
require('./models/LibraryItem')(this.sequelize)
require('./models/MediaProgress')(this.sequelize)
require('./models/Series')(this.sequelize)
require('./models/BookSeries')(this.sequelize)
require('./models/Author')(this.sequelize)
require('./models/BookAuthor')(this.sequelize)
require('./models/Collection')(this.sequelize)
require('./models/CollectionBook')(this.sequelize)
require('./models/Playlist')(this.sequelize)
require('./models/PlaylistMediaItem')(this.sequelize)
require('./models/Device')(this.sequelize)
require('./models/PlaybackSession')(this.sequelize)
require('./models/Feed')(this.sequelize)
require('./models/FeedEpisode')(this.sequelize)
require('./models/Setting')(this.sequelize)
require('./models/User').init(this.sequelize)
require('./models/Library').init(this.sequelize)
require('./models/LibraryFolder').init(this.sequelize)
require('./models/Book').init(this.sequelize)
require('./models/Podcast').init(this.sequelize)
require('./models/PodcastEpisode').init(this.sequelize)
require('./models/LibraryItem').init(this.sequelize)
require('./models/MediaProgress').init(this.sequelize)
require('./models/Series').init(this.sequelize)
require('./models/BookSeries').init(this.sequelize)
require('./models/Author').init(this.sequelize)
require('./models/BookAuthor').init(this.sequelize)
require('./models/Collection').init(this.sequelize)
require('./models/CollectionBook').init(this.sequelize)
require('./models/Playlist').init(this.sequelize)
require('./models/PlaylistMediaItem').init(this.sequelize)
require('./models/Device').init(this.sequelize)
require('./models/PlaybackSession').init(this.sequelize)
require('./models/Feed').init(this.sequelize)
require('./models/FeedEpisode').init(this.sequelize)
require('./models/Setting').init(this.sequelize)
return this.sequelize.sync({ force, alter: false })
}
@ -138,8 +253,6 @@ class Database {
await dbMigration.migrate(this.models)
}
const startTime = Date.now()
const settingsData = await this.models.setting.getOldSettings()
this.settings = settingsData.settings
this.emailSettings = settingsData.emailSettings
@ -155,22 +268,11 @@ class Database {
await dbMigration.migrationPatch2(this)
}
Logger.info(`[Database] Loading db data...`)
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`)
await this.cleanDatabase()
// Set if root user has been created
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) {
Logger.info(`[Database] Server upgrade detected from ${this.serverSettings.version} to ${packageJson.version}`)
this.serverSettings.version = packageJson.version
@ -219,9 +321,9 @@ class Database {
return Promise.all(oldUsers.map(u => this.updateUser(u)))
}
async removeUser(userId) {
removeUser(userId) {
if (!this.sequelize) return false
await this.models.user.removeById(userId)
return this.models.user.removeById(userId)
}
upsertMediaProgress(oldMediaProgress) {
@ -239,9 +341,9 @@ class Database {
return Promise.all(oldBooks.map(oldBook => this.models.book.saveFromOld(oldBook)))
}
async createLibrary(oldLibrary) {
createLibrary(oldLibrary) {
if (!this.sequelize) return false
await this.models.library.createFromOld(oldLibrary)
return this.models.library.createFromOld(oldLibrary)
}
updateLibrary(oldLibrary) {
@ -249,56 +351,9 @@ class Database {
return this.models.library.updateFromOld(oldLibrary)
}
async removeLibrary(libraryId) {
removeLibrary(libraryId) {
if (!this.sequelize) return false
await 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)
return this.models.library.removeById(libraryId)
}
createBulkCollectionBooks(collectionBooks) {
@ -306,62 +361,6 @@ class Database {
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) {
if (!this.sequelize) return false
return this.models.playlistMediaItem.create(playlistMediaItem)
@ -372,25 +371,10 @@ class Database {
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) {
if (!this.sequelize) return false
await oldLibraryItem.saveMetadata()
await this.models.libraryItem.fullCreateFromOld(oldLibraryItem)
this.libraryItems.push(oldLibraryItem)
}
async updateLibraryItem(oldLibraryItem) {
@ -399,32 +383,9 @@ class Database {
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) {
if (!this.sequelize) return false
await this.models.libraryItem.removeById(libraryItemId)
this.libraryItems = this.libraryItems.filter(li => li.id !== libraryItemId)
}
async createFeed(oldFeed) {
@ -450,31 +411,26 @@ class Database {
async createSeries(oldSeries) {
if (!this.sequelize) return false
await this.models.series.createFromOld(oldSeries)
this.series.push(oldSeries)
}
async createBulkSeries(oldSeriesObjs) {
if (!this.sequelize) return false
await this.models.series.createBulkFromOld(oldSeriesObjs)
this.series.push(...oldSeriesObjs)
}
async removeSeries(seriesId) {
if (!this.sequelize) return false
await this.models.series.removeById(seriesId)
this.series = this.series.filter(se => se.id !== seriesId)
}
async createAuthor(oldAuthor) {
if (!this.sequelize) return false
await this.models.author.createFromOld(oldAuthor)
this.authors.push(oldAuthor)
}
async createBulkAuthors(oldAuthors) {
if (!this.sequelize) return false
await this.models.author.createBulkFromOld(oldAuthors)
this.authors.push(...oldAuthors)
}
updateAuthor(oldAuthor) {
@ -485,24 +441,17 @@ class Database {
async removeAuthor(authorId) {
if (!this.sequelize) return false
await this.models.author.removeById(authorId)
this.authors = this.authors.filter(au => au.id !== authorId)
}
async createBulkBookAuthors(bookAuthors) {
if (!this.sequelize) return false
await this.models.bookAuthor.bulkCreate(bookAuthors)
this.authors.push(...bookAuthors)
}
async removeBulkBookAuthors(authorId = null, bookId = null) {
if (!this.sequelize) return false
if (!authorId && !bookId) return
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) {
@ -544,6 +493,204 @@ class Database {
if (!this.sequelize) return false
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()

View File

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

View File

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

View File

@ -1,21 +1,34 @@
const Path = require('path')
const EventEmitter = require('events')
const Watcher = require('./libs/watcher/watcher')
const Logger = require('./Logger')
const LibraryScanner = require('./scanner/LibraryScanner')
const { filePathToPOSIX } = require('./utils/fileUtils')
/**
* @typedef PendingFileUpdate
* @property {string} path
* @property {string} relPath
* @property {string} folderId
* @property {string} type
*/
class FolderWatcher extends EventEmitter {
constructor() {
super()
this.paths = [] // Not used
this.pendingFiles = [] // Not used
/** @type {{id:string, name:string, folders:import('./objects/Folder')[], paths:string[], watcher:Watcher[]}[]} */
this.libraryWatchers = []
/** @type {PendingFileUpdate[]} */
this.pendingFileUpdates = []
this.pendingDelay = 4000
this.pendingTimeout = null
/** @type {string[]} */
this.ignoreDirs = []
/** @type {string[]} */
this.pendingDirsToRemoveFromIgnore = []
this.disabled = false
}
@ -29,11 +42,12 @@ class FolderWatcher extends EventEmitter {
return
}
Logger.info(`[Watcher] Initializing watcher for "${library.name}".`)
var folderPaths = library.folderPaths
const folderPaths = library.folderPaths
folderPaths.forEach((fp) => {
Logger.debug(`[Watcher] Init watcher for library folder path "${fp}"`)
})
var watcher = new Watcher(folderPaths, {
const watcher = new Watcher(folderPaths, {
ignored: /(^|[\/\\])\../, // ignore dotfiles
renameDetection: true,
renameTimeout: 2000,
@ -144,6 +158,12 @@ class FolderWatcher extends EventEmitter {
this.addFileUpdate(libraryId, pathTo, 'renamed')
}
/**
* File update detected from watcher
* @param {string} libraryId
* @param {string} path
* @param {string} type
*/
addFileUpdate(libraryId, path, type) {
path = filePathToPOSIX(path)
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}"`)
return
}
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) {
Logger.debug(`[Watcher] Ignoring dot path "${relPath}" | Piece "${hasDotPath}"`)
return
@ -184,7 +211,8 @@ class FolderWatcher extends EventEmitter {
// Notify server of update after "pendingDelay"
clearTimeout(this.pendingTimeout)
this.pendingTimeout = setTimeout(() => {
this.emit('files', this.pendingFileUpdates)
// this.emit('files', this.pendingFileUpdates)
LibraryScanner.scanFilesChanged(this.pendingFileUpdates)
this.pendingFileUpdates = []
}, this.pendingDelay)
}
@ -195,24 +223,50 @@ class FolderWatcher extends EventEmitter {
})
}
/**
* Convert to POSIX and remove trailing slash
* @param {string} path
* @returns {string}
*/
cleanDirPath(path) {
path = filePathToPOSIX(path)
if (path.endsWith('/')) path = path.slice(0, -1)
return path
}
/**
* Ignore this directory if files are picked up by watcher
* @param {string} path
*/
addIgnoreDir(path) {
path = this.cleanDirPath(path)
if (this.ignoreDirs.includes(path)) return
this.pendingDirsToRemoveFromIgnore = this.pendingDirsToRemoveFromIgnore.filter(p => p !== path)
Logger.debug(`[Watcher] Ignoring directory "${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) {
path = this.cleanDirPath(path)
if (!this.ignoreDirs.includes(path)) return
Logger.debug(`[Watcher] No longer ignoring directory "${path}"`)
this.ignoreDirs = this.ignoreDirs.filter(p => p !== path)
if (!this.ignoreDirs.includes(path) || this.pendingDirsToRemoveFromIgnore.includes(path)) return
// 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

View File

@ -1,10 +1,13 @@
const sequelize = require('sequelize')
const fs = require('../libs/fsExtra')
const { createNewSortInstance } = require('../libs/fastSort')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const CacheManager = require('../managers/CacheManager')
const CoverManager = require('../managers/CoverManager')
const AuthorFinder = require('../finders/AuthorFinder')
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
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')) {
const seriesMap = {}
@ -67,13 +70,13 @@ class AuthorController {
// Updating/removing cover image
if (payload.imagePath !== undefined && payload.imagePath !== req.author.imagePath) {
if (!payload.imagePath && req.author.imagePath) { // If removing image then remove file
await this.cacheManager.purgeImageCache(req.author.id) // Purge cache
await this.coverManager.removeFile(req.author.imagePath)
await CacheManager.purgeImageCache(req.author.id) // Purge cache
await CoverManager.removeFile(req.author.imagePath)
} 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 (req.author.imagePath) {
await this.cacheManager.purgeImageCache(req.author.id) // Purge cache
await CacheManager.purgeImageCache(req.author.id) // Purge cache
}
payload.imagePath = imageData.path
hasUpdated = true
@ -85,7 +88,7 @@ class AuthorController {
}
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
// 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) {
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
libraryItem.media.metadata.replaceAuthor(req.author, existingAuthor)
bookAuthorsToCreate.push({
@ -113,9 +127,11 @@ class AuthorController {
// Remove old author
await Database.removeAuthor(req.author.id)
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
const numBooks = await Database.models.libraryItem.getForAuthor(existingAuthor).length
const numBooks = await Database.libraryItemModel.getForAuthor(existingAuthor).length
SocketAuthority.emitter('author_updated', existingAuthor.toJSONExpanded(numBooks))
res.json({
@ -130,7 +146,7 @@ class AuthorController {
if (hasUpdated) {
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
itemsWithAuthor.forEach(libraryItem => {
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) {
let authorData = null
const region = req.body.region || 'us'
if (req.body.asin) {
authorData = await this.authorFinder.findAuthorByASIN(req.body.asin, region)
authorData = await AuthorFinder.findAuthorByASIN(req.body.asin, region)
} else {
authorData = await this.authorFinder.findAuthorByName(req.body.q, region)
authorData = await AuthorFinder.findAuthorByName(req.body.q, region)
}
if (!authorData) {
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
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) {
req.author.imagePath = imageData.path
hasUpdates = true
@ -202,7 +207,7 @@ class AuthorController {
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))
}
@ -229,11 +234,11 @@ class AuthorController {
height: height ? parseInt(height) : null,
width: width ? parseInt(width) : null
}
return this.cacheManager.handleAuthorCache(res, author, options)
return CacheManager.handleAuthorCache(res, author, options)
}
middleware(req, res, next) {
const author = Database.authors.find(au => au.id === req.params.id)
async middleware(req, res, next) {
const author = await Database.authorModel.getOldById(req.params.id)
if (!author) return res.sendStatus(404)
if (req.method == 'DELETE' && !req.user.canDelete) {

View File

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

View File

@ -1,3 +1,4 @@
const Sequelize = require('sequelize')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
@ -7,22 +8,49 @@ const Collection = require('../objects/Collection')
class CollectionController {
constructor() { }
/**
* POST: /api/collections
* Create new collection
* @param {*} req
* @param {*} res
*/
async create(req, res) {
const newCollection = new Collection()
req.body.userId = req.user.id
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)
await Database.createCollection(newCollection)
SocketAuthority.emitter('collection_added', jsonExpanded)
res.json(jsonExpanded)
}
async findAll(req, res) {
const collectionsExpanded = await Database.models.collection.getOldCollectionsJsonExpanded(req.user)
const collectionsExpanded = await Database.collectionModel.getOldCollectionsJsonExpanded(req.user)
res.json({
collections: collectionsExpanded
})
@ -31,140 +59,275 @@ class CollectionController {
async findOne(req, res) {
const includeEntities = (req.query.include || '').split(',')
const collectionExpanded = req.collection.toJSONExpanded(Database.libraryItems)
if (includeEntities.includes('rssfeed')) {
const feedData = await this.rssFeedManager.findFeedForEntityId(collectionExpanded.id)
collectionExpanded.rssFeed = feedData?.toJSONMinified() || null
const collectionExpanded = await req.collection.getOldJsonExpanded(req.user, includeEntities)
if (!collectionExpanded) {
// This may happen if the user is restricted from all books
return res.sendStatus(404)
}
res.json(collectionExpanded)
}
/**
* PATCH: /api/collections/:id
* Update collection
* @param {*} req
* @param {*} res
*/
async update(req, res) {
const collection = req.collection
const wasUpdated = collection.update(req.body)
const jsonExpanded = collection.toJSONExpanded(Database.libraryItems)
let wasUpdated = false
// 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) {
await Database.updateCollection(collection)
SocketAuthority.emitter('collection_updated', jsonExpanded)
}
res.json(jsonExpanded)
}
async delete(req, res) {
const collection = req.collection
const jsonExpanded = collection.toJSONExpanded(Database.libraryItems)
const jsonExpanded = await req.collection.getOldJsonExpanded()
// 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)
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) {
const collection = req.collection
const libraryItem = Database.libraryItems.find(li => li.id === req.body.id)
const libraryItem = await Database.libraryItemModel.getOldById(req.body.id)
if (!libraryItem) {
return res.status(500).send('Book not found')
return res.status(404).send('Book not found')
}
if (libraryItem.libraryId !== collection.libraryId) {
return res.status(500).send('Book in different library')
if (libraryItem.libraryId !== req.collection.libraryId) {
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 = {
collectionId: collection.id,
bookId: libraryItem.media.id,
order: collection.books.length
// Check if book is already in collection
const collectionBooks = await req.collection.getCollectionBooks()
if (collectionBooks.some(cb => cb.bookId === libraryItem.media.id)) {
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)
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) {
const collection = req.collection
const libraryItem = Database.libraryItems.find(li => li.id === req.params.bookId)
const libraryItem = await Database.libraryItemModel.getOldById(req.params.bookId)
if (!libraryItem) {
return res.sendStatus(404)
}
if (collection.books.includes(req.params.bookId)) {
collection.removeBook(req.params.bookId)
const jsonExpanded = collection.toJSONExpanded(Database.libraryItems)
// Get books in collection ordered
const collectionBooks = await req.collection.getCollectionBooks({
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)
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) {
const collection = req.collection
if (!req.body.books || !req.body.books.length) {
// filter out invalid libraryItemIds
const bookIdsToAdd = (req.body.books || []).filter(b => !!b && typeof b == 'string')
if (!bookIdsToAdd.length) {
return res.status(500).send('Invalid request body')
}
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 = []
let hasUpdated = false
let order = collection.books.length
for (const libraryItemId of bookIdsToAdd) {
const libraryItem = Database.libraryItems.find(li => li.id === libraryItemId)
if (!libraryItem) continue
if (!collection.books.includes(libraryItemId)) {
collection.addBook(libraryItemId)
// Check and set new collection books to add
for (const libraryItem of libraryItems) {
if (!collectionBooks.some(cb => cb.bookId === libraryItem.media.id)) {
collectionBooksToAdd.push({
collectionId: collection.id,
collectionId: req.collection.id,
bookId: libraryItem.media.id,
order: order++
})
hasUpdated = true
} else {
Logger.warn(`[CollectionController] addBatch: Library item ${libraryItem.id} already in collection`)
}
}
let jsonExpanded = null
if (hasUpdated) {
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) {
const collection = req.collection
if (!req.body.books || !req.body.books.length) {
// filter out invalid libraryItemIds
const bookIdsToRemove = (req.body.books || []).filter(b => !!b && typeof b == 'string')
if (!bookIdsToRemove.length) {
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)) {
collection.removeBook(libraryItemId)
// Get library items associated with ids
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
}
order++
}
let jsonExpanded = await req.collection.getOldJsonExpanded()
if (hasUpdated) {
await Database.updateCollection(collection)
SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(Database.libraryItems))
SocketAuthority.emitter('collection_updated', jsonExpanded)
}
res.json(collection.toJSONExpanded(Database.libraryItems))
res.json(jsonExpanded)
}
async middleware(req, res, next) {
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) {
return res.status(404).send('Collection not found')
}

View File

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

View File

@ -59,7 +59,7 @@ class MeController {
// PATCH: api/me/progress/:id
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) {
return res.status(404).send('Item not found')
}
@ -75,7 +75,7 @@ class MeController {
// PATCH: api/me/progress/:id/:episodeId
async createUpdateEpisodeMediaProgress(req, res) {
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) {
return res.status(404).send('Item not found')
}
@ -101,7 +101,7 @@ class MeController {
let shouldUpdate = false
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 (req.user.createUpdateMediaProgress(libraryItem, itemProgress, itemProgress.episodeId)) {
const mediaProgress = req.user.getMediaProgress(libraryItem.id, itemProgress.episodeId)
@ -122,10 +122,10 @@ class MeController {
// POST: api/me/item/:id/bookmark
async createBookmark(req, res) {
var libraryItem = Database.libraryItems.find(li => li.id === req.params.id)
if (!libraryItem) return res.sendStatus(404)
if (!await Database.libraryItemModel.checkExistsById(req.params.id)) return res.sendStatus(404)
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)
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
res.json(bookmark)
@ -133,15 +133,17 @@ class MeController {
// PATCH: api/me/item/:id/bookmark
async updateBookmark(req, res) {
var libraryItem = Database.libraryItems.find(li => li.id === req.params.id)
if (!libraryItem) return res.sendStatus(404)
if (!await Database.libraryItemModel.checkExistsById(req.params.id)) return res.sendStatus(404)
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`)
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)
await Database.updateUser(req.user)
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
res.json(bookmark)
@ -149,16 +151,17 @@ class MeController {
// DELETE: api/me/item/:id/bookmark/:time
async removeBookmark(req, res) {
var libraryItem = Database.libraryItems.find(li => li.id === req.params.id)
if (!libraryItem) return res.sendStatus(404)
var time = Number(req.params.time)
if (!await Database.libraryItemModel.checkExistsById(req.params.id)) return res.sendStatus(404)
const time = Number(req.params.time)
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`)
return res.sendStatus(404)
}
req.user.removeBookmark(libraryItem.id, time)
req.user.removeBookmark(req.params.id, time)
await Database.updateUser(req.user)
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
res.sendStatus(200)
@ -190,7 +193,8 @@ class MeController {
Logger.error(`[MeController] syncLocalMediaProgress invalid local media progress object`, localProgress)
continue
}
const libraryItem = Database.getLibraryItem(localProgress.libraryItemId)
const libraryItem = await Database.libraryItemModel.getOldById(localProgress.libraryItemId)
if (!libraryItem) {
Logger.error(`[MeController] syncLocalMediaProgress invalid local media progress object no library item`, localProgress)
continue
@ -242,13 +246,15 @@ class MeController {
}
// 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
let itemsInProgress = []
// TODO: More efficient to do this in a single query
for (const mediaProgress of req.user.mediaProgress) {
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 (mediaProgress.episodeId && libraryItem.mediaType === 'podcast') {
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
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) {
Logger.error(`[MeController] removeSeriesFromContinueListening: Series ${req.params.id} not found`)
return res.sendStatus(404)
@ -294,7 +300,7 @@ class MeController {
// GET: api/me/series/:id/readd-to-continue-listening
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) {
Logger.error(`[MeController] readdSeriesFromContinueListening: Series ${req.params.id} not found`)
return res.sendStatus(404)
@ -310,9 +316,19 @@ class MeController {
// GET: api/me/progress/:id/remove-from-continue-listening
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)
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())
}
res.json(req.user.toJSONForBrowser())

View File

@ -1,12 +1,13 @@
const Sequelize = require('sequelize')
const Path = require('path')
const fs = require('../libs/fsExtra')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const filePerms = require('../utils/filePerms')
const libraryItemFilters = require('../utils/queries/libraryItemFilters')
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 :(
@ -14,7 +15,12 @@ const { isObject } = require('../utils/index')
class MiscController {
constructor() { }
// POST: api/upload
/**
* POST: /api/upload
* Update library item
* @param {*} req
* @param {*} res
*/
async handleUpload(req, res) {
if (!req.user.canUpload) {
Logger.warn('User attempted to upload without permission', req.user)
@ -31,7 +37,7 @@ class MiscController {
const libraryId = req.body.library
const folderId = req.body.folder
const library = await Database.models.library.getOldById(libraryId)
const library = await Database.libraryModel.getOldById(libraryId)
if (!library) {
return res.status(404).send(`Library not found with id ${libraryId}`)
}
@ -83,12 +89,15 @@ class MiscController {
})
}
await filePerms.setDefault(firstDirPath)
res.sendStatus(200)
}
// GET: api/tasks
/**
* GET: /api/tasks
* Get tasks for task manager
* @param {*} req
* @param {*} res
*/
getTasks(req, res) {
const includeArray = (req.query.include || '').split(',')
@ -105,7 +114,12 @@ class MiscController {
res.json(data)
}
// PATCH: api/settings (admin)
/**
* PATCH: /api/settings
* Update server settings
* @param {*} req
* @param {*} res
*/
async updateServerSettings(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error('User other than admin attempting to update server settings', req.user)
@ -113,7 +127,7 @@ class MiscController {
}
const settingsUpdate = req.body
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)
@ -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
* Used to authorize an API token
@ -147,26 +258,55 @@ class MiscController {
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) {
Logger.error(`[MiscController] Non-admin user attempted to getAllTags`)
return res.sendStatus(404)
}
const tags = []
Database.libraryItems.forEach((li) => {
if (li.media.tags && li.media.tags.length) {
li.media.tags.forEach((tag) => {
if (!tags.includes(tag)) tags.push(tag)
})
}
const books = await Database.bookModel.findAll({
attributes: ['tags'],
where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('tags')), {
[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({
tags: tags
})
}
// POST: api/tags/rename
/**
* POST: /api/tags/rename
* Rename tag
* Req.body { tag, newTag }
* @param {*} req
* @param {*} res
*/
async renameTag(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[MiscController] Non-admin user attempted to renameTag`)
@ -183,19 +323,26 @@ class MiscController {
let tagMerged = false
let numItemsUpdated = 0
for (const li of Database.libraryItems) {
if (!li.media.tags || !li.media.tags.length) continue
// Update filter data
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)) {
li.media.tags = li.media.tags.filter(t => t !== tag) // Remove old tag
if (!li.media.tags.includes(newTag)) {
li.media.tags.push(newTag) // Add new tag
if (libraryItem.media.tags.includes(tag)) {
libraryItem.media.tags = libraryItem.media.tags.filter(t => t !== tag) // Remove old tag
if (!libraryItem.media.tags.includes(newTag)) {
libraryItem.media.tags.push(newTag)
}
Logger.debug(`[MiscController] Rename tag "${tag}" to "${newTag}" for item "${li.media.metadata.title}"`)
await Database.updateLibraryItem(li)
SocketAuthority.emitter('item_updated', li.toJSONExpanded())
Logger.debug(`[MiscController] Rename tag "${tag}" to "${newTag}" for item "${libraryItem.media.title}"`)
await libraryItem.media.update({
tags: libraryItem.media.tags
})
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
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) {
if (!req.user.isAdminOrUp) {
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()
let numItemsUpdated = 0
for (const li of Database.libraryItems) {
if (!li.media.tags || !li.media.tags.length) continue
// Get all items with tag
const libraryItemsWithTag = await libraryItemFilters.getAllLibraryItemsWithTags([tag])
if (li.media.tags.includes(tag)) {
li.media.tags = li.media.tags.filter(t => t !== tag)
Logger.debug(`[MiscController] Remove tag "${tag}" from item "${li.media.metadata.title}"`)
await Database.updateLibraryItem(li)
SocketAuthority.emitter('item_updated', li.toJSONExpanded())
numItemsUpdated++
}
// Update filterdata
Database.removeTagFromFilterData(tag)
let numItemsUpdated = 0
// Remove tag from items
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({
@ -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) {
Logger.error(`[MiscController] Non-admin user attempted to getAllGenres`)
return res.sendStatus(404)
}
const genres = []
Database.libraryItems.forEach((li) => {
if (li.media.metadata.genres && li.media.metadata.genres.length) {
li.media.metadata.genres.forEach((genre) => {
if (!genres.includes(genre)) genres.push(genre)
})
}
const books = await Database.bookModel.findAll({
attributes: ['genres'],
where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('genres')), {
[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({
genres
})
}
// POST: api/genres/rename
/**
* POST: /api/genres/rename
* Rename genres
* Req.body { genre, newGenre }
* @param {*} req
* @param {*} res
*/
async renameGenre(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[MiscController] Non-admin user attempted to renameGenre`)
@ -269,19 +456,26 @@ class MiscController {
let genreMerged = false
let numItemsUpdated = 0
for (const li of Database.libraryItems) {
if (!li.media.metadata.genres || !li.media.metadata.genres.length) continue
// Update filter data
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)) {
li.media.metadata.genres = li.media.metadata.genres.filter(g => g !== genre) // Remove old genre
if (!li.media.metadata.genres.includes(newGenre)) {
li.media.metadata.genres.push(newGenre) // Add new genre
if (libraryItem.media.genres.includes(genre)) {
libraryItem.media.genres = libraryItem.media.genres.filter(t => t !== genre) // Remove old genre
if (!libraryItem.media.genres.includes(newGenre)) {
libraryItem.media.genres.push(newGenre)
}
Logger.debug(`[MiscController] Rename genre "${genre}" to "${newGenre}" for item "${li.media.metadata.title}"`)
await Database.updateLibraryItem(li)
SocketAuthority.emitter('item_updated', li.toJSONExpanded())
Logger.debug(`[MiscController] Rename genre "${genre}" to "${newGenre}" for item "${libraryItem.media.title}"`)
await libraryItem.media.update({
genres: libraryItem.media.genres
})
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
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) {
if (!req.user.isAdminOrUp) {
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()
let numItemsUpdated = 0
for (const li of Database.libraryItems) {
if (!li.media.metadata.genres || !li.media.metadata.genres.length) continue
// Update filter data
Database.removeGenreFromFilterData(genre)
if (li.media.metadata.genres.includes(genre)) {
li.media.metadata.genres = li.media.metadata.genres.filter(t => t !== genre)
Logger.debug(`[MiscController] Remove genre "${genre}" from item "${li.media.metadata.title}"`)
await Database.updateLibraryItem(li)
SocketAuthority.emitter('item_updated', li.toJSONExpanded())
numItemsUpdated++
}
// Get all items with genre
const libraryItemsWithGenre = await libraryItemFilters.getAllLibraryItemsWithGenres([genre])
let numItemsUpdated = 0
// Remove genre from items
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({

View File

@ -7,71 +7,187 @@ const Playlist = require('../objects/Playlist')
class PlaylistController {
constructor() { }
// POST: api/playlists
/**
* POST: /api/playlists
* Create playlist
* @param {*} req
* @param {*} res
*/
async create(req, res) {
const newPlaylist = new Playlist()
const oldPlaylist = new Playlist()
req.body.userId = req.user.id
const success = newPlaylist.setData(req.body)
const success = oldPlaylist.setData(req.body)
if (!success) {
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)
res.json(jsonExpanded)
}
// GET: api/playlists
/**
* GET: /api/playlists
* Get all playlists for user
* @param {*} req
* @param {*} 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({
playlists: playlistsForUser.map(p => p.toJSONExpanded(Database.libraryItems))
playlists
})
}
// GET: api/playlists/:id
findOne(req, res) {
res.json(req.playlist.toJSONExpanded(Database.libraryItems))
/**
* GET: /api/playlists/:id
* @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) {
const playlist = req.playlist
let wasUpdated = playlist.update(req.body)
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
const updatedPlaylist = req.playlist.set(req.body)
let wasUpdated = false
const changed = updatedPlaylist.changed()
if (changed?.length) {
await req.playlist.save()
Logger.debug(`[PlaylistController] Updated playlist ${req.playlist.id} keys [${changed.join(',')}]`)
wasUpdated = true
}
// 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) {
await Database.updatePlaylist(playlist)
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
SocketAuthority.clientEmitter(updatedPlaylist.userId, 'playlist_updated', jsonExpanded)
}
res.json(jsonExpanded)
}
// DELETE: api/playlists/:id
/**
* DELETE: /api/playlists/:id
* Remove playlist
* @param {*} req
* @param {*} res
*/
async delete(req, res) {
const playlist = req.playlist
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
await Database.removePlaylist(playlist.id)
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
const jsonExpanded = await req.playlist.getOldJsonExpanded()
await req.playlist.destroy()
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
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) {
const playlist = req.playlist
const oldPlaylist = await Database.playlistModel.getById(req.playlist.id)
const itemToAdd = req.body
if (!itemToAdd.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) {
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')
}
if (playlist.containsItem(itemToAdd)) {
if (oldPlaylist.containsItem(itemToAdd)) {
return res.status(400).send('Item already in playlist')
}
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')
}
playlist.addItem(itemToAdd.libraryItemId, itemToAdd.episodeId)
const playlistMediaItem = {
playlistId: playlist.id,
playlistId: oldPlaylist.id,
mediaItemId: itemToAdd.episodeId || libraryItem.media.id,
mediaItemType: itemToAdd.episodeId ? 'podcastEpisode' : 'book',
order: playlist.items.length
order: oldPlaylist.items.length + 1
}
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
await Database.createPlaylistMediaItem(playlistMediaItem)
const jsonExpanded = await req.playlist.getOldJsonExpanded()
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', 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) {
const playlist = req.playlist
const itemToRemove = {
libraryItemId: req.params.libraryItemId,
episodeId: req.params.episodeId || null
}
if (!playlist.containsItem(itemToRemove)) {
return res.sendStatus(404)
const oldLibraryItem = await Database.libraryItemModel.getOldById(req.params.libraryItemId)
if (!oldLibraryItem) {
return res.status(404).send('Library item not found')
}
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
if (!playlist.items.length) {
Logger.info(`[PlaylistController] Playlist "${playlist.name}" has no more items - removing it`)
await Database.removePlaylist(playlist.id)
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
if (!jsonExpanded.items.length) {
Logger.info(`[PlaylistController] Playlist "${jsonExpanded.name}" has no more items - removing it`)
await req.playlist.destroy()
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
} else {
await Database.updatePlaylist(playlist)
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_updated', 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) {
const playlist = req.playlist
if (!req.body.items || !req.body.items.length) {
return res.status(500).send('Invalid request body')
if (!req.body.items?.length) {
return res.status(400).send('Invalid request body')
}
const itemsToAdd = req.body.items
let hasUpdated = false
let order = playlist.items.length
const playlistMediaItems = []
const libraryItemIds = itemsToAdd.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
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) {
if (!item.libraryItemId) {
return res.status(400).send('Item does not have libraryItemId')
}
const libraryItem = Database.getLibraryItem(item.libraryItemId)
const libraryItem = libraryItems.find(li => li.id === item.libraryItemId)
if (!libraryItem) {
return res.status(400).send('Item not found with id ' + item.libraryItemId)
}
if (!playlist.containsItem(item)) {
playlistMediaItems.push({
playlistId: playlist.id,
mediaItemId: item.episodeId || libraryItem.media.id, // podcastEpisodeId or bookId
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
order: order++
})
playlist.addItem(item.libraryItemId, item.episodeId)
hasUpdated = true
return res.status(404).send('Item not found with id ' + item.libraryItemId)
} else {
const mediaItemId = item.episodeId || libraryItem.mediaId
if (existingPlaylistMediaItems.some(pmi => pmi.mediaItemId === mediaItemId)) {
// Already exists in playlist
continue
} else {
mediaItemsToAdd.push({
playlistId: req.playlist.id,
mediaItemId,
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
order: order++
})
}
}
}
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
if (hasUpdated) {
await Database.createBulkPlaylistMediaItems(playlistMediaItems)
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
let jsonExpanded = null
if (mediaItemsToAdd.length) {
await Database.createBulkPlaylistMediaItems(mediaItemsToAdd)
jsonExpanded = await req.playlist.getOldJsonExpanded()
SocketAuthority.clientEmitter(req.playlist.userId, 'playlist_updated', jsonExpanded)
} else {
jsonExpanded = await req.playlist.getOldJsonExpanded()
}
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) {
const playlist = req.playlist
if (!req.body.items || !req.body.items.length) {
return res.status(500).send('Invalid request body')
if (!req.body.items?.length) {
return res.status(400).send('Invalid request body')
}
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
for (const item of itemsToRemove) {
if (!item.libraryItemId) {
return res.status(400).send('Item does not have libraryItemId')
}
if (playlist.containsItem(item)) {
playlist.removeItem(item.libraryItemId, item.episodeId)
hasUpdated = true
}
const libraryItem = libraryItems.find(li => li.id === item.libraryItemId)
if (!libraryItem) continue
const mediaItemId = item.episodeId || libraryItem.mediaId
const existingMediaItem = existingPlaylistMediaItems.find(pmi => pmi.mediaItemId === mediaItemId)
if (!existingMediaItem) continue
await existingMediaItem.destroy()
hasUpdated = true
numMediaItems--
}
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
const jsonExpanded = await req.playlist.getOldJsonExpanded()
if (hasUpdated) {
// Playlist is removed when there are no items
if (!playlist.items.length) {
Logger.info(`[PlaylistController] Playlist "${playlist.name}" has no more items - removing it`)
await Database.removePlaylist(playlist.id)
if (!numMediaItems) {
Logger.info(`[PlaylistController] Playlist "${req.playlist.name}" has no more items - removing it`)
await req.playlist.destroy()
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
} else {
await Database.updatePlaylist(playlist)
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', 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) {
let collection = await Database.models.collection.getById(req.params.collectionId)
const collection = await Database.collectionModel.findByPk(req.params.collectionId)
if (!collection) {
return res.status(404).send('Collection not found')
}
// Expand collection to get library items
collection = collection.toJSONExpanded(Database.libraryItems)
// Filter out library items not accessible to user
const libraryItems = collection.books.filter(item => req.user.checkCanAccessLibraryItem(item))
if (!libraryItems.length) {
return res.status(400).send('Collection has no books accessible to user')
const collectionExpanded = await collection.getOldJsonExpanded(req.user)
if (!collectionExpanded) {
// This can happen if the user has no access to all items in collection
return res.status(404).send('Collection not found')
}
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,
libraryId: collection.libraryId,
name: collection.name,
description: collection.description || null,
items: libraryItems.map(li => ({ libraryItemId: li.id }))
}
newPlaylist.setData(newPlaylistData)
description: collection.description || null
})
const jsonExpanded = newPlaylist.toJSONExpanded(Database.libraryItems)
await Database.createPlaylist(newPlaylist)
// Create Playlist record
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)
res.json(jsonExpanded)
}
async middleware(req, res, next) {
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) {
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 { 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')
@ -19,7 +21,7 @@ class PodcastController {
}
const payload = req.body
const library = await Database.models.library.getOldById(payload.libraryId)
const library = await Database.libraryModel.getOldById(payload.libraryId)
if (!library) {
Logger.error(`[PodcastController] Create: Library not found "${payload.libraryId}"`)
return res.status(404).send('Library not found')
@ -34,9 +36,13 @@ class PodcastController {
const podcastPath = filePathToPOSIX(payload.path)
// 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) {
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')
}
@ -45,7 +51,6 @@ class PodcastController {
return false
})
if (!success) return res.status(400).send('Invalid podcast path')
await filePerms.setDefault(podcastPath)
const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath)
@ -71,7 +76,7 @@ class PodcastController {
if (payload.media.metadata.imageUrl) {
// TODO: Scan cover image to library files
// 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.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 episodesUpdated = await this.scanner.quickMatchPodcastEpisodes(req.libraryItem, { overrideDetails })
const episodesUpdated = await Scanner.quickMatchPodcastEpisodes(req.libraryItem, { overrideDetails })
if (episodesUpdated) {
await Database.updateLibraryItem(req.libraryItem)
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
@ -268,23 +273,32 @@ class PodcastController {
}
// Update/remove playlists that had this podcast episode
const playlistsWithEpisode = await Database.models.playlist.getPlaylistsForMediaItemIds([episodeId])
for (const playlist of playlistsWithEpisode) {
playlist.removeItem(libraryItem.id, episodeId)
const playlistMediaItems = await Database.playlistMediaItemModel.findAll({
where: {
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 (!playlist.items.length) {
if (!numItems) {
Logger.info(`[PodcastController] Playlist "${playlist.name}" has no more items - removing it`)
await Database.removePlaylist(playlist.id)
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', playlist.toJSONExpanded(Database.libraryItems))
const jsonExpanded = await pmi.playlist.getOldJsonExpanded()
SocketAuthority.clientEmitter(pmi.playlist.userId, 'playlist_removed', jsonExpanded)
await pmi.playlist.destroy()
} else {
await Database.updatePlaylist(playlist)
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', playlist.toJSONExpanded(Database.libraryItems))
await pmi.destroy()
const jsonExpanded = await pmi.playlist.getOldJsonExpanded()
SocketAuthority.clientEmitter(pmi.playlist.userId, 'playlist_updated', jsonExpanded)
}
}
// Remove media progress for this episode
const mediaProgressRemoved = await Database.models.mediaProgress.destroy({
const mediaProgressRemoved = await Database.mediaProgressModel.destroy({
where: {
mediaItemId: episode.id
}
@ -298,9 +312,9 @@ class PodcastController {
res.json(libraryItem.toJSON())
}
middleware(req, res, next) {
const item = Database.libraryItems.find(li => li.id === req.params.id)
if (!item || !item.media) return res.sendStatus(404)
async middleware(req, res, next) {
const item = await Database.libraryItemModel.getOldById(req.params.id)
if (!item?.media) return res.sendStatus(404)
if (!item.isPodcast) {
return res.sendStatus(500)

View File

@ -1,14 +1,23 @@
const Logger = require('../Logger')
const Database = require('../Database')
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
class RSSFeedController {
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
async openRSSFeedForItem(req, res) {
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)
// Check user can access this library item
@ -45,7 +54,7 @@ class RSSFeedController {
async openRSSFeedForCollection(req, res) {
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)
// Check request body options exist
@ -60,7 +69,7 @@ class RSSFeedController {
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)
// Check collection has audio tracks
@ -79,7 +88,7 @@ class RSSFeedController {
async openRSSFeedForSeries(req, res) {
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)
// Check request body options exist
@ -95,8 +104,9 @@ class RSSFeedController {
}
const seriesJson = series.toJSON()
// 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
if (!seriesJson.books.length) {

View File

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

View File

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

View File

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

View File

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

View File

@ -17,7 +17,7 @@ class UserController {
const includes = (req.query.include || '').split(',').map(i => i.trim())
// 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))
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) {
if (!req.user.isAdminOrUp) {
Logger.error('User other than admin attempting to get user', req.user)
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) {
const account = req.body
const username = account.username
const usernameExists = await Database.models.user.getUserByUsername(username)
const usernameExists = await Database.userModel.getUserByUsername(username)
if (usernameExists) {
return res.status(500).send('Username already taken')
}
@ -80,7 +127,7 @@ class UserController {
var shouldUpdateToken = false
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) {
return res.status(500).send('Username already taken')
}
@ -122,9 +169,13 @@ class UserController {
// Todo: check if user is logged in and cancel streams
// 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) {
await Database.removePlaylist(playlist.id)
await playlist.destroy()
}
const userJson = user.toJSONForBrowser()
@ -182,7 +233,7 @@ class UserController {
}
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) {
return res.sendStatus(404)
}

View File

@ -5,23 +5,23 @@ const { Sequelize } = require('sequelize')
const Database = require('../Database')
const getLibraryItemMinified = (libraryItemId) => {
return Database.models.libraryItem.findByPk(libraryItemId, {
return Database.libraryItemModel.findByPk(libraryItemId, {
include: [
{
model: Database.models.book,
model: Database.bookModel,
attributes: [
'id', 'title', 'subtitle', 'publishedYear', 'publishedDate', 'publisher', 'description', 'isbn', 'asin', 'language', 'explicit', 'narrators', 'coverPath', 'genres', 'tags'
],
include: [
{
model: Database.models.author,
model: Database.authorModel,
attributes: ['id', 'name'],
through: {
attributes: []
}
},
{
model: Database.models.series,
model: Database.seriesModel,
attributes: ['id', 'name'],
through: {
attributes: ['sequence']
@ -30,7 +30,7 @@ const getLibraryItemMinified = (libraryItemId) => {
]
},
{
model: Database.models.podcast,
model: Database.podcastModel,
attributes: [
'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']
@ -41,19 +41,19 @@ const getLibraryItemMinified = (libraryItemId) => {
}
const getLibraryItemExpanded = (libraryItemId) => {
return Database.models.libraryItem.findByPk(libraryItemId, {
return Database.libraryItemModel.findByPk(libraryItemId, {
include: [
{
model: Database.models.book,
model: Database.bookModel,
include: [
{
model: Database.models.author,
model: Database.authorModel,
through: {
attributes: []
}
},
{
model: Database.models.series,
model: Database.seriesModel,
through: {
attributes: ['sequence']
}
@ -61,10 +61,10 @@ const getLibraryItemExpanded = (libraryItemId) => {
]
},
{
model: Database.models.podcast,
model: Database.podcastModel,
include: [
{
model: Database.models.podcastEpisode
model: Database.podcastEpisodeModel
}
]
},

View File

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

View File

@ -9,4 +9,4 @@ class MusicFinder {
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)
}
}
module.exports = PodcastFinder
module.exports = new PodcastFinder()

View File

@ -5,7 +5,6 @@ const fs = require('../libs/fsExtra')
const workerThreads = require('worker_threads')
const Logger = require('../Logger')
const Task = require('../objects/Task')
const filePerms = require('../utils/filePerms')
const { writeConcatFile } = require('../utils/ffmpegHelpers')
const toneHelpers = require('../utils/toneHelpers')
@ -201,10 +200,6 @@ class AbMergeManager {
Logger.debug(`[AbMergeManager] Moving m4b from ${task.data.tempFilepath} to ${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()
await this.removeTask(task, false)
Logger.info(`[AbMergeManager] Ab task finished ${task.id}`)

View File

@ -1,42 +1,40 @@
const Path = require('path')
const fs = require('../libs/fsExtra')
const stream = require('stream')
const filePerms = require('../utils/filePerms')
const Logger = require('../Logger')
const { resizeImage } = require('../utils/ffmpegHelpers')
class CacheManager {
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.CoverCachePath = Path.join(this.CachePath, 'covers')
this.ImageCachePath = Path.join(this.CachePath, 'images')
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))) {
await fs.mkdir(this.CachePath)
pathsCreated = true
}
if (!(await fs.pathExists(this.CoverCachePath))) {
await fs.mkdir(this.CoverCachePath)
pathsCreated = true
}
if (!(await fs.pathExists(this.ImageCachePath))) {
await fs.mkdir(this.ImageCachePath)
pathsCreated = true
}
if (!(await fs.pathExists(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)
if (!writtenFile) return res.sendStatus(500)
// Set owner and permissions of cache image
await filePerms.setDefault(path)
if (global.XAccel) {
Logger.debug(`Use X-Accel to serve static file ${writtenFile}`)
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)
if (!writtenFile) return res.sendStatus(500)
// Set owner and permissions of cache image
await filePerms.setDefault(path)
var readStream = fs.createReadStream(writtenFile)
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 readChunk = require('../libs/readChunk')
const imageType = require('../libs/imageType')
const filePerms = require('../utils/filePerms')
const globals = require('../utils/globals')
const { downloadFile, filePathToPOSIX } = require('../utils/fileUtils')
const { downloadFile, filePathToPOSIX, checkPathIsFile } = require('../utils/fileUtils')
const { extractCoverArt } = require('../utils/ffmpegHelpers')
const CacheManager = require('../managers/CacheManager')
class CoverManager {
constructor(cacheManager) {
this.cacheManager = cacheManager
this.ItemMetadataPath = Path.posix.join(global.MetadataPath, 'items')
}
constructor() { }
getCoverDirectory(libraryItem) {
if (global.ServerSettings.storeCoverWithItem && !libraryItem.isFile && !libraryItem.isMusic) {
if (global.ServerSettings.storeCoverWithItem && !libraryItem.isFile) {
return libraryItem.path
} 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.cacheManager.purgeCoverCache(libraryItem.id)
await CacheManager.purgeCoverCache(libraryItem.id)
Logger.info(`[CoverManager] Uploaded libraryItem cover "${coverFullPath}" for "${libraryItem.media.metadata.title}"`)
await filePerms.setDefault(coverFullPath)
libraryItem.updateMediaCover(coverFullPath)
return {
cover: coverFullPath
@ -146,11 +141,9 @@ class CoverManager {
await fs.rename(temppath, coverFullPath)
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}"`)
await filePerms.setDefault(coverFullPath)
libraryItem.updateMediaCover(coverFullPath)
return {
cover: coverFullPath
@ -180,6 +173,7 @@ class CoverManager {
updated: false
}
}
// Cover path does not exist
if (!await fs.pathExists(coverPath)) {
Logger.error(`[CoverManager] validate cover path does not exist "${coverPath}"`)
@ -187,8 +181,17 @@ class CoverManager {
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
var imgtype = await this.checkFileIsValidImage(coverPath, true)
var imgtype = await this.checkFileIsValidImage(coverPath, false)
if (imgtype.error) {
return imgtype
}
@ -212,13 +215,12 @@ class CoverManager {
error: 'Failed to copy cover to dir'
}
}
await filePerms.setDefault(newCoverPath)
await this.removeOldCovers(coverDirPath, '.' + imgtype.ext)
Logger.debug(`[CoverManager] cover copy success`)
coverPath = newCoverPath
}
await this.cacheManager.purgeCoverCache(libraryItem.id)
await CacheManager.purgeCoverCache(libraryItem.id)
libraryItem.updateMediaCover(coverPath)
return {
@ -253,12 +255,97 @@ class CoverManager {
const success = await extractCoverArt(audioFileWithCover.metadata.path, coverFilePath)
if (success) {
await filePerms.setDefault(coverFilePath)
libraryItem.updateMediaCover(coverFilePath)
return coverFilePath
}
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 Logger = require('../Logger')
const Database = require('../Database')
const LibraryScanner = require('../scanner/LibraryScanner')
class CronManager {
constructor(scanner, podcastManager) {
this.scanner = scanner
constructor(podcastManager) {
this.podcastManager = podcastManager
this.libraryScanCrons = []
@ -17,9 +18,9 @@ class CronManager {
* Initialize library scan crons & podcast download crons
* @param {oldLibrary[]} libraries
*/
init(libraries) {
async init(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}`)
const libScanCron = cron.schedule(library.settings.autoScanCronExpression, () => {
Logger.debug(`[CronManager] Library scan cron executing for ${library.name}`)
this.scanner.scan(library)
LibraryScanner.scan(library)
})
this.libraryScanCrons.push({
libraryId: library.id,
@ -70,23 +71,34 @@ class CronManager {
}
}
initPodcastCrons() {
/**
* Init cron jobs for auto-download podcasts
*/
async initPodcastCrons() {
const cronExpressionMap = {}
Database.libraryItems.forEach((li) => {
if (li.mediaType === 'podcast' && li.media.autoDownloadEpisodes) {
if (!li.media.autoDownloadSchedule) {
Logger.error(`[CronManager] Podcast auto download schedule is not set for ${li.media.metadata.title}`)
} else {
if (!cronExpressionMap[li.media.autoDownloadSchedule]) {
cronExpressionMap[li.media.autoDownloadSchedule] = {
expression: li.media.autoDownloadSchedule,
libraryItemIds: []
}
}
cronExpressionMap[li.media.autoDownloadSchedule].libraryItemIds.push(li.id)
const podcastsWithAutoDownload = await Database.podcastModel.findAll({
where: {
autoDownloadEpisodes: true,
autoDownloadSchedule: {
[Sequelize.Op.not]: null
}
},
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
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
const libraryItems = []
for (const libraryItemId of libraryItemIds) {
const libraryItem = Database.libraryItems.find(li => li.id === libraryItemId)
const libraryItem = await Database.libraryItemModel.getOldById(libraryItemId)
if (!libraryItem) {
Logger.error(`[CronManager] Library item ${libraryItemId} not found for episode check cron ${expression}`)
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter(lid => lid !== libraryItemId) // Filter it out

View File

@ -1,6 +1,5 @@
const Path = require('path')
const fs = require('../libs/fsExtra')
const filePerms = require('../utils/filePerms')
const DailyLog = require('../objects/DailyLog')
@ -25,13 +24,11 @@ class LogManager {
async ensureLogDirs() {
await fs.ensureDir(this.DailyLogPath)
await fs.ensureDir(this.ScanLogPath)
await filePerms.setDefault(Path.posix.join(global.MetadataPath, 'logs'), true)
}
async ensureScanLogDir() {
if (!(await fs.pathExists(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
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 = {
libraryItemId: libraryItem.id,
libraryId: libraryItem.libraryId,

View File

@ -93,7 +93,7 @@ class PlaybackSessionManager {
}
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
if (!libraryItem || (libraryItem.isPodcast && !episode)) {
Logger.error(`[PlaybackSessionManager] syncLocalSession: Media item not found for session "${sessionJson.displayTitle}" (${sessionJson.id})`)
@ -259,13 +259,13 @@ class PlaybackSessionManager {
}
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
}
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) {
Logger.error(`[PlaybackSessionManager] syncSession Library Item not found "${session.libraryItemId}"`)
return null
@ -304,7 +304,7 @@ class PlaybackSessionManager {
await this.saveSession(session)
}
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)
return this.removeSession(session.id)
}

View File

@ -6,7 +6,6 @@ const fs = require('../libs/fsExtra')
const { getPodcastFeed } = require('../utils/podcastUtils')
const { removeFile, downloadFile } = require('../utils/fileUtils')
const filePerms = require('../utils/filePerms')
const { levenshteinDistance } = require('../utils/index')
const opmlParser = require('../utils/parsers/parseOPML')
const opmlGenerator = require('../utils/generators/opmlGenerator')
@ -96,7 +95,6 @@ class PodcastManager {
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`)
await fs.mkdir(this.currentDownload.libraryItem.path)
await filePerms.setDefault(this.currentDownload.libraryItem.path)
}
let success = false
@ -150,7 +148,7 @@ class PodcastManager {
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) {
Logger.error(`[PodcastManager] Podcast Episode finished but library item was not found ${this.currentDownload.libraryItem.id}`)
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) {

View File

@ -6,27 +6,28 @@ const Database = require('../Database')
const fs = require('../libs/fsExtra')
const Feed = require('../objects/Feed')
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
class RssFeedManager {
constructor() { }
async validateFeedEntity(feedObj) {
if (feedObj.entityType === 'collection') {
const collection = await Database.models.collection.getById(feedObj.entityId)
const collection = await Database.collectionModel.getOldById(feedObj.entityId)
if (!collection) {
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Collection "${feedObj.entityId}" not found`)
return false
}
} 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`)
return false
}
} else if (feedObj.entityType === 'series') {
const series = Database.series.find(s => s.id === feedObj.entityId)
const hasSeriesBook = series ? Database.libraryItems.some(li => li.mediaType === 'book' && li.media.metadata.hasSeries(series.id) && li.media.tracks.length) : false
if (!hasSeriesBook) {
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Series "${feedObj.entityId}" not found or has no audio tracks`)
const series = await Database.seriesModel.getOldById(feedObj.entityId)
if (!series) {
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Series "${feedObj.entityId}" not found`)
return false
}
} else {
@ -40,7 +41,7 @@ class RssFeedManager {
* Validate all feeds and remove invalid
*/
async init() {
const feeds = await Database.models.feed.getOldFeeds()
const feeds = await Database.feedModel.getOldFeeds()
for (const feed of feeds) {
// Remove invalid feeds
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)
* @param {string} entityId
* @param {string} entityId
* @returns {Promise<objects.Feed>} oldFeed
*/
findFeedForEntityId(entityId) {
return Database.models.feed.findOneOld({ entityId })
return Database.feedModel.findOneOld({ entityId })
}
/**
* Find open feed for a slug
* @param {string} slug
* @param {string} slug
* @returns {Promise<objects.Feed>} oldFeed
*/
findFeedBySlug(slug) {
return Database.models.feed.findOneOld({ slug })
return Database.feedModel.findOneOld({ slug })
}
/**
* Find open feed for a slug
* @param {string} slug
* @param {string} slug
* @returns {Promise<objects.Feed>} oldFeed
*/
findFeed(id) {
return Database.models.feed.findByPkOld(id)
return Database.feedModel.findByPkOld(id)
}
async getFeed(req, res) {
@ -86,7 +87,7 @@ class RssFeedManager {
// Check if feed needs to be updated
if (feed.entityType === 'libraryItem') {
const libraryItem = Database.getLibraryItem(feed.entityId)
const libraryItem = await Database.libraryItemModel.getOldById(feed.entityId)
let mostRecentlyUpdatedAt = libraryItem.updatedAt
if (libraryItem.isPodcast) {
@ -102,9 +103,9 @@ class RssFeedManager {
await Database.updateFeed(feed)
}
} else if (feed.entityType === 'collection') {
const collection = await Database.models.collection.getById(feed.entityId)
const collection = await Database.collectionModel.findByPk(feed.entityId)
if (collection) {
const collectionExpanded = collection.toJSONExpanded(Database.libraryItems)
const collectionExpanded = await collection.getOldJsonExpanded()
// Find most recently updated item in collection
let mostRecentlyUpdatedAt = collectionExpanded.lastUpdate
@ -122,11 +123,12 @@ class RssFeedManager {
}
}
} 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) {
const seriesJson = series.toJSON()
// 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
let mostRecentlyUpdatedAt = seriesJson.updatedAt
@ -260,5 +262,11 @@ class RssFeedManager {
if (!feed) return
return this.handleCloseFeed(feed)
}
async getFeeds() {
const feeds = await Database.models.feed.getOldFeeds()
Logger.info(`[RssFeedManager] Fetched all feeds`)
return feeds
}
}
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')
module.exports = (sequelize) => {
class Author extends Model {
static async getOldAuthors() {
const authors = await this.findAll()
return authors.map(au => au.getOldAuthor())
}
class Author extends Model {
constructor(values, options) {
super(values, options)
getOldAuthor() {
return new oldAuthor({
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()
})
}
/** @type {UUIDV4} */
this.id
/** @type {string} */
this.name
/** @type {string} */
this.lastFirst
/** @type {string} */
this.asin
/** @type {string} */
this.description
/** @type {string} */
this.imagePath
/** @type {UUIDV4} */
this.libraryId
/** @type {Date} */
this.updatedAt
/** @type {Date} */
this.createdAt
}
static updateFromOld(oldAuthor) {
const author = this.getFromOld(oldAuthor)
return this.update(author, {
where: {
id: author.id
}
})
}
static async getOldAuthors() {
const authors = await this.findAll()
return authors.map(au => au.getOldAuthor())
}
static createFromOld(oldAuthor) {
const author = this.getFromOld(oldAuthor)
return this.create(author)
}
getOldAuthor() {
return new oldAuthor({
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) {
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
static updateFromOld(oldAuthor) {
const author = this.getFromOld(oldAuthor)
return this.update(author, {
where: {
id: author.id
}
}
})
}
static removeById(authorId) {
return this.destroy({
where: {
id: authorId
}
})
static createFromOld(oldAuthor) {
const author = this.getFromOld(oldAuthor)
return this.create(author)
}
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({
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'
})
static removeById(authorId) {
return this.destroy({
where: {
id: authorId
}
})
}
const { library } = sequelize.models
library.hasMany(Author, {
onDelete: 'CASCADE'
})
Author.belongsTo(library)
/**
* Get oldAuthor by id
* @param {string} authorId
* @returns {Promise<oldAuthor>}
*/
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 Logger = require('../Logger')
module.exports = (sequelize) => {
class Book extends Model {
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
}
})
} 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)
}
/**
* @typedef EBookFileObject
* @property {string} ino
* @property {string} ebookFormat
* @property {number} addedAt
* @property {number} updatedAt
* @property {{filename:string, ext:string, path:string, relPath:string, size:number, mtimeMs:number, ctimeMs:number, birthtimeMs:number}} metadata
*/
let series = []
if (bookExpanded.series?.length) {
series = bookExpanded.series.map(se => {
return {
id: se.id,
name: se.name,
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)
}
/**
* @typedef ChapterObject
* @property {number} id
* @property {number} start
* @property {number} end
* @property {string} title
*/
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
/**
* @typedef AudioFileObject
* @property {number} index
* @property {string} ino
* @property {{filename:string, ext:string, path:string, relPath:string, size:number, mtimeMs:number, ctimeMs:number, birthtimeMs:number}} metadata
* @property {number} addedAt
* @property {number} updatedAt
* @property {number} trackNumFromMeta
* @property {number} discNumFromMeta
* @property {number} trackNumFromFilename
* @property {number} discNumFromFilename
* @property {boolean} manuallyVerified
* @property {string} format
* @property {number} duration
* @property {number} bitRate
* @property {string} language
* @property {string} codec
* @property {string} timeBase
* @property {number} channels
* @property {string} channelLayout
* @property {ChapterObject[]} chapters
* @property {Object} metaTags
* @property {string} mimeType
*/
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) {
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
let series = []
if (bookExpanded.series?.length) {
series = bookExpanded.series.map(se => {
return {
id: se.id,
name: se.name,
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,
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: {
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']
/**
* @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
})
}
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')
module.exports = (sequelize) => {
class BookAuthor extends Model {
static removeByIds(authorId = null, bookId = null) {
const where = {}
if (authorId) where.authorId = authorId
if (bookId) where.bookId = bookId
return this.destroy({
where
})
}
class BookAuthor extends Model {
constructor(values, options) {
super(values, options)
/** @type {UUIDV4} */
this.id
/** @type {UUIDV4} */
this.bookId
/** @type {UUIDV4} */
this.authorId
/** @type {Date} */
this.createdAt
}
BookAuthor.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
}
}, {
sequelize,
modelName: 'bookAuthor',
timestamps: true,
updatedAt: false
})
static removeByIds(authorId = null, bookId = null) {
const where = {}
if (authorId) where.authorId = authorId
if (bookId) where.bookId = bookId
return this.destroy({
where
})
}
// 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
const { book, author } = sequelize.models
book.belongsToMany(author, { through: BookAuthor })
author.belongsToMany(book, { through: BookAuthor })
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
}
}, {
sequelize,
modelName: 'bookAuthor',
timestamps: true,
updatedAt: false
})
book.hasMany(BookAuthor)
BookAuthor.belongsTo(book)
// 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
const { book, author } = sequelize.models
book.belongsToMany(author, { through: BookAuthor })
author.belongsToMany(book, { through: BookAuthor })
author.hasMany(BookAuthor)
BookAuthor.belongsTo(author)
book.hasMany(BookAuthor)
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')
module.exports = (sequelize) => {
class BookSeries extends Model {
static removeByIds(seriesId = null, bookId = null) {
const where = {}
if (seriesId) where.seriesId = seriesId
if (bookId) where.bookId = bookId
return this.destroy({
where
})
}
class BookSeries extends Model {
constructor(values, options) {
super(values, options)
/** @type {UUIDV4} */
this.id
/** @type {string} */
this.sequence
/** @type {UUIDV4} */
this.bookId
/** @type {UUIDV4} */
this.seriesId
/** @type {Date} */
this.createdAt
}
BookSeries.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
sequence: DataTypes.STRING
}, {
sequelize,
modelName: 'bookSeries',
timestamps: true,
updatedAt: false
})
static removeByIds(seriesId = null, bookId = null) {
const where = {}
if (seriesId) where.seriesId = seriesId
if (bookId) where.bookId = bookId
return this.destroy({
where
})
}
// 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
const { book, series } = sequelize.models
book.belongsToMany(series, { through: BookSeries })
series.belongsToMany(book, { through: BookSeries })
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
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)
BookSeries.belongsTo(book)
// 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
const { book, series } = sequelize.models
book.belongsToMany(series, { through: BookSeries })
series.belongsToMany(book, { through: BookSeries })
series.hasMany(BookSeries)
BookSeries.belongsTo(series)
book.hasMany(BookSeries, {
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 { areEquivalent } = require('../utils/index')
module.exports = (sequelize) => {
class Collection extends Model {
/**
* Get all old collections
* @returns {Promise<oldCollection[]>}
*/
static async getOldCollections() {
const collections = await this.findAll({
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))
class Collection extends Model {
constructor(values, options) {
super(values, options)
/** @type {UUIDV4} */
this.id
/** @type {string} */
this.name
/** @type {string} */
this.description
/** @type {UUIDV4} */
this.libraryId
/** @type {Date} */
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
}
}
/**
* 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
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']]
// Optionally include rssfeed for collection
const collectionIncludes = []
if (include.includes('rssfeed')) {
collectionIncludes.push({
model: this.sequelize.models.feed
})
// 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
}
const collections = await this.findAll({
where: collectionWhere,
include: [
{
model: this.sequelize.models.book,
include: [
{
model: this.sequelize.models.libraryItem
},
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
},
]
},
...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 feed if found
if (c.feeds?.length) {
collectionExpanded.rssFeed = sequelize.models.feed.getOldFeed(c.feeds[0])
}
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()
// 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)
})
}
static createFromOld(oldCollection) {
const collection = this.getFromOld(oldCollection)
return this.create(collection)
}
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
}
// Users with restricted permissions will not see this collection
if (!books.length && oldCollection.books.length) {
return null
}
let hasCollectionUpdates = false
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
}
const collectionExpanded = oldCollection.toJSONExpanded(libraryItems)
static getFromOld(oldCollection) {
return {
id: oldCollection.id,
name: oldCollection.name,
description: oldCollection.description,
libraryId: oldCollection.libraryId
// Map feed if found
if (c.feeds?.length) {
collectionExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(c.feeds[0])
}
}
static removeById(collectionId) {
return this.destroy({
where: {
id: collectionId
}
})
}
return collectionExpanded
}).filter(c => c)
}
/**
* Get collection by id
* @param {string} collectionId
* @returns {Promise<oldCollection|null>} returns null if not found
*/
static async getById(collectionId) {
if (!collectionId) return null
const collection = await this.findByPk(collectionId, {
include: {
model: sequelize.models.book,
include: sequelize.models.libraryItem
/**
* Get old collection toJSONExpanded, items filtered for user permissions
* @param {[oldUser]} user
* @param {[string[]]} include
* @returns {Promise<object>} oldCollection.toJSONExpanded
*/
async getOldJsonExpanded(user, include) {
this.books = await this.getBooks({
include: [
{
model: this.sequelize.models.libraryItem
},
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
},
order: [[sequelize.models.book, sequelize.models.collectionBook, 'order', 'ASC']]
})
if (!collection) return null
return this.getOldCollection(collection)
}
/**
* 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
],
order: [Sequelize.literal('`collectionBook.order` ASC')]
}) || []
const oldCollection = this.sequelize.models.collection.getOldCollection(this)
// Filter books using user permissions
// TODO: Handle user permission restrictions on initial query
const books = this.books?.filter(b => {
if (user) {
if (b.tags?.length && !user.checkCanAccessLibraryItemWithTags(b.tags)) {
return false
}
})
if (b.explicit === true && !user.canAccessExplicitContent) {
return false
}
}
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
}
/**
* Get all collections for a library
* @param {string} libraryId
* @returns {Promise<oldCollection[]>}
*/
static async getAllForLibrary(libraryId) {
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))
const collectionExpanded = oldCollection.toJSONExpanded(libraryItems)
if (include?.includes('rssfeed')) {
const feeds = await this.getFeeds()
if (feeds?.length) {
collectionExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(feeds[0])
}
}
static async getAllForBook(bookId) {
const collections = await this.findAll({
include: {
model: sequelize.models.book,
where: {
id: bookId
},
required: true,
include: sequelize.models.libraryItem
},
order: [[sequelize.models.book, sequelize.models.collectionBook, 'order', 'ASC']]
})
return collections.map(c => this.getOldCollection(c))
return collectionExpanded
}
/**
* 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) {
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({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
name: DataTypes.STRING,
description: DataTypes.TEXT
}, {
sequelize,
modelName: 'collection'
})
static removeById(collectionId) {
return this.destroy({
where: {
id: collectionId
}
})
}
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')
module.exports = (sequelize) => {
class CollectionBook extends Model {
static removeByIds(collectionId, bookId) {
return this.destroy({
where: {
bookId,
collectionId
}
})
}
class CollectionBook extends Model {
constructor(values, options) {
super(values, options)
/** @type {UUIDV4} */
this.id
/** @type {number} */
this.order
/** @type {UUIDV4} */
this.bookId
/** @type {UUIDV4} */
this.collectionId
/** @type {Date} */
this.createdAt
}
CollectionBook.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
order: DataTypes.INTEGER
}, {
sequelize,
timestamps: true,
updatedAt: false,
modelName: 'collectionBook'
})
static removeByIds(collectionId, bookId) {
return this.destroy({
where: {
bookId,
collectionId
}
})
}
// 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
const { book, collection } = sequelize.models
book.belongsToMany(collection, { through: CollectionBook })
collection.belongsToMany(book, { through: CollectionBook })
static init(sequelize) {
super.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
order: DataTypes.INTEGER
}, {
sequelize,
timestamps: true,
updatedAt: false,
modelName: 'collectionBook'
})
book.hasMany(CollectionBook, {
onDelete: 'CASCADE'
})
CollectionBook.belongsTo(book)
// 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
const { book, collection } = sequelize.models
book.belongsToMany(collection, { through: CollectionBook })
collection.belongsToMany(book, { through: CollectionBook })
collection.hasMany(CollectionBook, {
onDelete: 'CASCADE'
})
CollectionBook.belongsTo(collection)
book.hasMany(CollectionBook, {
onDelete: 'CASCADE'
})
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 oldDevice = require('../objects/DeviceInfo')
module.exports = (sequelize) => {
class Device extends Model {
getOldDevice() {
let browserVersion = null
let sdkVersion = null
if (this.clientName === 'Abs Android') {
sdkVersion = this.deviceVersion || null
} else {
browserVersion = this.deviceVersion || null
}
class Device extends Model {
constructor(values, options) {
super(values, options)
return new oldDevice({
id: this.id,
deviceId: this.deviceId,
userId: this.userId,
ipAddress: this.ipAddress,
browserName: this.extraData.browserName || null,
browserVersion,
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
})
/** @type {UUIDV4} */
this.id
/** @type {string} */
this.deviceId
/** @type {string} */
this.clientName
/** @type {string} */
this.clientVersion
/** @type {string} */
this.ipAddress
/** @type {string} */
this.deviceName
/** @type {string} */
this.deviceVersion
/** @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) {
const device = await this.findOne({
where: {
deviceId
}
})
if (!device) return null
return device.getOldDevice()
return new oldDevice({
id: this.id,
deviceId: this.deviceId,
userId: this.userId,
ipAddress: this.ipAddress,
browserName: this.extraData.browserName || null,
browserVersion,
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) {
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
}
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
}
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: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
deviceId: DataTypes.STRING,
clientName: DataTypes.STRING, // e.g. Abs Web, Abs Android
clientVersion: DataTypes.STRING, // e.g. Server version or mobile version
ipAddress: DataTypes.STRING,
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'
})
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
deviceId: DataTypes.STRING,
clientName: DataTypes.STRING, // e.g. Abs Web, Abs Android
clientVersion: DataTypes.STRING, // e.g. Server version or mobile version
ipAddress: DataTypes.STRING,
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, {
onDelete: 'CASCADE'
})
Device.belongsTo(user)
user.hasMany(Device, {
onDelete: 'CASCADE'
})
Device.belongsTo(user)
}
}
return Device
}
module.exports = Device

View File

@ -1,307 +1,361 @@
const { DataTypes, Model } = require('sequelize')
const oldFeed = require('../objects/Feed')
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))
}
/**
* 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,
link: feedExpanded.siteURL,
explicit: feedExpanded.explicit,
type: feedExpanded.podcastType,
language: feedExpanded.language,
preventIndexing: feedExpanded.preventIndexing,
ownerName: feedExpanded.ownerName,
ownerEmail: feedExpanded.ownerEmail
},
serverAddress: feedExpanded.serverAddress,
class Feed extends Model {
constructor(values, options) {
super(values, options)
/** @type {UUIDV4} */
this.id
/** @type {string} */
this.slug
/** @type {string} */
this.entityType
/** @type {UUIDV4} */
this.entityId
/** @type {Date} */
this.entityUpdatedAt
/** @type {string} */
this.serverAddress
/** @type {string} */
this.feedURL
/** @type {string} */
this.imageURL
/** @type {string} */
this.siteURL
/** @type {string} */
this.title
/** @type {string} */
this.description
/** @type {string} */
this.author
/** @type {string} */
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,
episodes: episodes || [],
createdAt: feedExpanded.createdAt.valueOf(),
updatedAt: feedExpanded.updatedAt.valueOf()
})
}
link: feedExpanded.siteURL,
explicit: feedExpanded.explicit,
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) {
return this.destroy({
where: {
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 removeById(feedId) {
return this.destroy({
where: {
id: feedId
}
}
})
}
static async fullUpdateFromOld(oldFeed) {
const oldFeedEpisodes = oldFeed.episodes || []
const feedObj = this.getFromOld(oldFeed)
const existingFeed = await this.findByPk(feedObj.id, {
include: sequelize.models.feedEpisode
})
if (!existingFeed) return false
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
}
}
/**
* 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) || []
}
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
}
/**
* 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: this.sequelize.models.feedEpisode
}
})
if (!feedExpanded) return null
return this.getOldFeed(feedExpanded)
}
if (feedHasUpdates) {
await existingFeed.update(feedObj)
hasUpdates = true
/**
* 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: 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) {
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
if (oldFeed.episodes?.length) {
for (const oldFeedEpisode of oldFeed.episodes) {
const feedEpisode = this.sequelize.models.feedEpisode.getFromOld(oldFeedEpisode)
feedEpisode.feedId = newFeed.id
await this.sequelize.models.feedEpisode.create(feedEpisode)
}
}
getEntity(options) {
if (!this.entityType) return Promise.resolve(null)
const mixinMethodName = `get${sequelize.uppercaseFirst(this.entityType)}`
return this[mixinMethodName](options)
}
}
Feed.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'
})
static async fullUpdateFromOld(oldFeed) {
const oldFeedEpisodes = oldFeed.episodes || []
const feedObj = this.getFromOld(oldFeed)
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)
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
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 = this.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
}
}
// 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')
module.exports = (sequelize) => {
class FeedEpisode extends Model {
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
}
}
class FeedEpisode extends Model {
constructor(values, options) {
super(values, options)
static getFromOld(oldFeedEpisode) {
return {
id: oldFeedEpisode.id,
title: oldFeedEpisode.title,
author: oldFeedEpisode.author,
description: oldFeedEpisode.description,
siteURL: oldFeedEpisode.link,
enclosureURL: oldFeedEpisode.enclosure?.url || null,
enclosureType: oldFeedEpisode.enclosure?.type || null,
enclosureSize: oldFeedEpisode.enclosure?.size || null,
pubDate: oldFeedEpisode.pubDate,
season: oldFeedEpisode.season || null,
episode: oldFeedEpisode.episode || null,
episodeType: oldFeedEpisode.episodeType || null,
duration: oldFeedEpisode.duration,
filePath: oldFeedEpisode.fullPath,
explicit: !!oldFeedEpisode.explicit
}
/** @type {UUIDV4} */
this.id
/** @type {string} */
this.title
/** @type {string} */
this.description
/** @type {string} */
this.siteURL
/** @type {string} */
this.enclosureURL
/** @type {string} */
this.enclosureType
/** @type {BigInt} */
this.enclosureSize
/** @type {string} */
this.pubDate
/** @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({
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'
})
static getFromOld(oldFeedEpisode) {
return {
id: oldFeedEpisode.id,
title: oldFeedEpisode.title,
author: oldFeedEpisode.author,
description: oldFeedEpisode.description,
siteURL: oldFeedEpisode.link,
enclosureURL: oldFeedEpisode.enclosure?.url || null,
enclosureType: oldFeedEpisode.enclosure?.type || null,
enclosureSize: oldFeedEpisode.enclosure?.size || null,
pubDate: oldFeedEpisode.pubDate,
season: oldFeedEpisode.season || null,
episode: oldFeedEpisode.episode || null,
episodeType: oldFeedEpisode.episodeType || null,
duration: oldFeedEpisode.duration,
filePath: oldFeedEpisode.fullPath,
explicit: !!oldFeedEpisode.explicit
}
}
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, {
onDelete: 'CASCADE'
})
FeedEpisode.belongsTo(feed)
const { feed } = sequelize.models
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 oldLibrary = require('../objects/Library')
module.exports = (sequelize) => {
class Library extends Model {
/**
* Get all old libraries
* @returns {Promise<oldLibrary[]>}
*/
static async getAllOldLibraries() {
const libraries = await this.findAll({
include: sequelize.models.libraryFolder,
order: [['displayOrder', 'ASC']]
})
return libraries.map(lib => this.getOldLibrary(lib))
}
/**
* @typedef LibrarySettingsObject
* @property {number} coverAspectRatio BookCoverAspectRatio
* @property {boolean} disableWatcher
* @property {boolean} skipMatchingMediaWithAsin
* @property {boolean} skipMatchingMediaWithIsbn
* @property {string} autoScanCronExpression
* @property {boolean} audiobooksOnly
* @property {boolean} hideSingleBookSeries Do not show series that only have 1 book
*/
/**
* Convert expanded Library to oldLibrary
* @param {Library} libraryExpanded
* @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()
})
}
class Library extends Model {
constructor(values, options) {
super(values, options)
/**
* @param {object} oldLibrary
* @returns {Library|null}
*/
static async createFromOld(oldLibrary) {
const library = this.getFromOld(oldLibrary)
/** @type {UUIDV4} */
this.id
/** @type {string} */
this.name
/** @type {number} */
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 {
id: folder.id,
path: folder.fullPath
}
})
/**
* Get all old libraries
* @returns {Promise<oldLibrary[]>}
*/
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
}).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: 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
}
/**
* Convert expanded Library to oldLibrary
* @param {Library} libraryExpanded
* @returns {Promise<oldLibrary>}
*/
static getOldLibrary(libraryExpanded) {
const folders = libraryExpanded.libraryFolders.map(folder => {
return {
id: oldLibrary.id,
name: oldLibrary.name,
displayOrder: oldLibrary.displayOrder,
icon: oldLibrary.icon || null,
mediaType: oldLibrary.mediaType || null,
provider: oldLibrary.provider,
settings: oldLibrary.settings?.toJSON() || {},
createdAt: oldLibrary.createdAt,
updatedAt: oldLibrary.lastUpdate,
extraData
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()
})
}
/**
* @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 })
}
}
/**
* Destroy library by id
* @param {string} libraryId
* @returns
*/
static removeById(libraryId) {
return this.destroy({
where: {
id: libraryId
}
})
const libraryFoldersRemoved = existingLibrary.libraryFolders.filter(lf => !libraryFolders.some(_lf => _lf.id === lf.id))
for (const existingLibraryFolder of libraryFoldersRemoved) {
await existingLibraryFolder.destroy()
}
/**
* 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)
}
return existingLibrary.update(library)
}
/**
* 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: sequelize.models.libraryFolder
})
if (!library) return null
return this.getOldLibrary(library)
static getFromOld(oldLibrary) {
const extraData = {}
if (oldLibrary.oldLibraryId) {
extraData.oldLibraryId = oldLibrary.oldLibraryId
}
/**
* 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
return {
id: oldLibrary.id,
name: oldLibrary.name,
displayOrder: oldLibrary.displayOrder,
icon: oldLibrary.icon || null,
mediaType: oldLibrary.mediaType || null,
provider: oldLibrary.provider,
settings: oldLibrary.settings?.toJSON() || {},
createdAt: oldLibrary.createdAt,
updatedAt: oldLibrary.lastUpdate,
extraData
}
}
/**
* 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)
})
}
/**
* Destroy library by id
* @param {string} libraryId
* @returns
*/
static removeById(libraryId) {
return this.destroy({
where: {
id: libraryId
}
})
}
/**
* 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: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
name: DataTypes.STRING,
displayOrder: DataTypes.INTEGER,
icon: DataTypes.STRING,
mediaType: DataTypes.STRING,
provider: DataTypes.STRING,
lastScan: DataTypes.DATE,
lastScanVersion: DataTypes.STRING,
settings: DataTypes.JSON,
extraData: DataTypes.JSON
}, {
sequelize,
modelName: 'library'
})
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
name: DataTypes.STRING,
displayOrder: DataTypes.INTEGER,
icon: DataTypes.STRING,
mediaType: DataTypes.STRING,
provider: DataTypes.STRING,
lastScan: DataTypes.DATE,
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')
module.exports = (sequelize) => {
class LibraryFolder extends Model {
/**
* Gets all library folder path strings
* @returns {Promise<string[]>} array of library folder paths
*/
static async getAllLibraryFolderPaths() {
const libraryFolders = await this.findAll({
attributes: ['path']
})
return libraryFolders.map(l => l.path)
}
class LibraryFolder extends Model {
constructor(values, options) {
super(values, options)
/** @type {UUIDV4} */
this.id
/** @type {string} */
this.path
/** @type {UUIDV4} */
this.libraryId
/** @type {Date} */
this.createdAt
/** @type {Date} */
this.updatedAt
}
LibraryFolder.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
path: DataTypes.STRING
}, {
sequelize,
modelName: 'libraryFolder'
})
/**
* Gets all library folder path strings
* @returns {Promise<string[]>} array of library folder paths
*/
static async getAllLibraryFolderPaths() {
const libraryFolders = await this.findAll({
attributes: ['path']
})
return libraryFolders.map(l => l.path)
}
const { library } = sequelize.models
library.hasMany(LibraryFolder, {
onDelete: 'CASCADE'
})
LibraryFolder.belongsTo(library)
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
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')
/*
* Polymorphic association: https://sequelize.org/docs/v6/advanced-association-concepts/polymorphic-associations/
* Book has many MediaProgress. PodcastEpisode has many MediaProgress.
*/
module.exports = (sequelize) => {
class MediaProgress extends Model {
getOldMediaProgress() {
const isPodcastEpisode = this.mediaItemType === 'podcastEpisode'
class MediaProgress extends Model {
constructor(values, options) {
super(values, options)
return {
id: this.id,
userId: this.userId,
libraryItemId: this.extraData?.libraryItemId || null,
episodeId: isPodcastEpisode ? this.mediaItemId : null,
mediaItemId: this.mediaItemId,
mediaItemType: this.mediaItemType,
duration: this.duration,
progress: this.extraData?.progress || 0,
currentTime: this.currentTime,
isFinished: !!this.isFinished,
hideFromContinueListening: !!this.hideFromContinueListening,
ebookLocation: this.ebookLocation,
ebookProgress: this.ebookProgress,
lastUpdate: this.updatedAt.valueOf(),
startedAt: this.createdAt.valueOf(),
finishedAt: this.finishedAt?.valueOf() || null
}
}
/** @type {UUIDV4} */
this.id
/** @type {UUIDV4} */
this.mediaItemId
/** @type {string} */
this.mediaItemType
/** @type {number} */
this.duration
/** @type {number} */
this.currentTime
/** @type {boolean} */
this.isFinished
/** @type {boolean} */
this.hideFromContinueListening
/** @type {string} */
this.ebookLocation
/** @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) {
const mediaProgress = this.getFromOld(oldMediaProgress)
return this.upsert(mediaProgress)
}
getOldMediaProgress() {
const isPodcastEpisode = this.mediaItemType === 'podcastEpisode'
static getFromOld(oldMediaProgress) {
return {
id: oldMediaProgress.id,
userId: oldMediaProgress.userId,
mediaItemId: oldMediaProgress.mediaItemId,
mediaItemType: oldMediaProgress.mediaItemType,
duration: oldMediaProgress.duration,
currentTime: oldMediaProgress.currentTime,
ebookLocation: oldMediaProgress.ebookLocation || null,
ebookProgress: oldMediaProgress.ebookProgress || null,
isFinished: !!oldMediaProgress.isFinished,
hideFromContinueListening: !!oldMediaProgress.hideFromContinueListening,
finishedAt: oldMediaProgress.finishedAt,
createdAt: oldMediaProgress.startedAt || oldMediaProgress.lastUpdate,
updatedAt: oldMediaProgress.lastUpdate,
extraData: {
libraryItemId: oldMediaProgress.libraryItemId,
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)
return {
id: this.id,
userId: this.userId,
libraryItemId: this.extraData?.libraryItemId || null,
episodeId: isPodcastEpisode ? this.mediaItemId : null,
mediaItemId: this.mediaItemId,
mediaItemType: this.mediaItemType,
duration: this.duration,
progress: this.extraData?.progress || 0,
currentTime: this.currentTime,
isFinished: !!this.isFinished,
hideFromContinueListening: !!this.hideFromContinueListening,
ebookLocation: this.ebookLocation,
ebookProgress: this.ebookProgress,
lastUpdate: this.updatedAt.valueOf(),
startedAt: this.createdAt.valueOf(),
finishedAt: this.finishedAt?.valueOf() || null
}
}
static upsertFromOld(oldMediaProgress) {
const mediaProgress = this.getFromOld(oldMediaProgress)
return this.upsert(mediaProgress)
}
MediaProgress.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']
static getFromOld(oldMediaProgress) {
return {
id: oldMediaProgress.id,
userId: oldMediaProgress.userId,
mediaItemId: oldMediaProgress.mediaItemId,
mediaItemType: oldMediaProgress.mediaItemType,
duration: oldMediaProgress.duration,
currentTime: oldMediaProgress.currentTime,
ebookLocation: oldMediaProgress.ebookLocation || null,
ebookProgress: oldMediaProgress.ebookProgress || null,
isFinished: !!oldMediaProgress.isFinished,
hideFromContinueListening: !!oldMediaProgress.hideFromContinueListening,
finishedAt: oldMediaProgress.finishedAt,
createdAt: oldMediaProgress.startedAt || oldMediaProgress.lastUpdate,
updatedAt: oldMediaProgress.lastUpdate,
extraData: {
libraryItemId: oldMediaProgress.libraryItemId,
progress: oldMediaProgress.progress
}
]
})
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
static removeById(mediaProgressId) {
return this.destroy({
where: {
id: mediaProgressId
}
// 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)
getMediaItem(options) {
if (!this.mediaItemType) return Promise.resolve(null)
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaItemType)}`
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')
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) {
const playbackSession = await this.findByPk(sessionId, {
include: [
{
model: sequelize.models.device
}
]
})
if (!playbackSession) return null
return this.getOldPlaybackSession(playbackSession)
}
class PlaybackSession extends Model {
constructor(values, options) {
super(values, options)
static getOldPlaybackSession(playbackSessionExpanded) {
const isPodcastEpisode = playbackSessionExpanded.mediaItemType === 'podcastEpisode'
/** @type {UUIDV4} */
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({
id: playbackSessionExpanded.id,
userId: playbackSessionExpanded.userId,
libraryId: playbackSessionExpanded.libraryId,
libraryItemId: playbackSessionExpanded.extraData?.libraryItemId || null,
bookId: isPodcastEpisode ? null : playbackSessionExpanded.mediaItemId,
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
static async getOldPlaybackSessions(where = null) {
const playbackSessions = await this.findAll({
where,
include: [
{
model: this.sequelize.models.device
}
})
}
]
})
return playbackSessions.map(session => this.getOldPlaybackSession(session))
}
static createFromOld(oldPlaybackSession) {
const playbackSession = this.getFromOld(oldPlaybackSession)
return this.create(playbackSession)
}
static updateFromOld(oldPlaybackSession) {
const playbackSession = this.getFromOld(oldPlaybackSession)
return this.update(playbackSession, {
where: {
id: playbackSession.id
static async getById(sessionId) {
const playbackSession = await this.findByPk(sessionId, {
include: [
{
model: this.sequelize.models.device
}
})
}
]
})
if (!playbackSession) return null
return this.getOldPlaybackSession(playbackSession)
}
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
}
static getOldPlaybackSession(playbackSessionExpanded) {
const isPodcastEpisode = playbackSessionExpanded.mediaItemType === 'podcastEpisode'
return new oldPlaybackSession({
id: playbackSessionExpanded.id,
userId: playbackSessionExpanded.userId,
libraryId: playbackSessionExpanded.libraryId,
libraryItemId: playbackSessionExpanded.extraData?.libraryItemId || null,
bookId: isPodcastEpisode ? null : playbackSessionExpanded.mediaItemId,
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
}
}
})
}
getMediaItem(options) {
if (!this.mediaItemType) return Promise.resolve(null)
const mixinMethodName = `get${sequelize.uppercaseFirst(this.mediaItemType)}`
return this[mixinMethodName](options)
static createFromOld(oldPlaybackSession) {
const playbackSession = this.getFromOld(oldPlaybackSession)
return this.create(playbackSession)
}
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({
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'
})
getMediaItem(options) {
if (!this.mediaItemType) return Promise.resolve(null)
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaItemType)}`
return this[mixinMethodName](options)
}
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)
PlaybackSession.belongsTo(user)
const { book, podcastEpisode, user, device, library } = sequelize.models
device.hasMany(PlaybackSession)
PlaybackSession.belongsTo(device)
user.hasMany(PlaybackSession)
PlaybackSession.belongsTo(user)
library.hasMany(PlaybackSession)
PlaybackSession.belongsTo(library)
device.hasMany(PlaybackSession)
PlaybackSession.belongsTo(device)
book.hasMany(PlaybackSession, {
foreignKey: 'mediaItemId',
constraints: false,
scope: {
mediaItemType: 'book'
}
})
PlaybackSession.belongsTo(book, { foreignKey: 'mediaItemId', constraints: false })
library.hasMany(PlaybackSession)
PlaybackSession.belongsTo(library)
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
book.hasMany(PlaybackSession, {
foreignKey: 'mediaItemId',
constraints: false,
scope: {
mediaItemType: 'book'
}
// To prevent mistakes:
delete instance.book
delete instance.dataValues.book
delete instance.podcastEpisode
delete instance.dataValues.podcastEpisode
}
})
})
PlaybackSession.belongsTo(book, { foreignKey: 'mediaItemId', constraints: false })
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 oldPlaylist = require('../objects/Playlist')
const { areEquivalent } = require('../utils/index')
module.exports = (sequelize) => {
class Playlist extends Model {
static async getOldPlaylists() {
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))
}
class Playlist extends Model {
constructor(values, options) {
super(values, options)
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)
/** @type {UUIDV4} */
this.id
/** @type {string} */
this.name
/** @type {string} */
this.description
/** @type {UUIDV4} */
this.libraryId
/** @type {UUIDV4} */
this.userId
/** @type {Date} */
this.createdAt
/** @type {Date} */
this.updatedAt
}
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()
})
}
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
}
},
static async getOldPlaylists() {
const playlists = await this.findAll({
include: {
model: this.sequelize.models.playlistMediaItem,
include: [
{
model: sequelize.models.playlist,
model: this.sequelize.models.book,
include: this.sequelize.models.libraryItem
},
{
model: this.sequelize.models.podcastEpisode,
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
}
}
]
model: this.sequelize.models.podcast,
include: this.sequelize.models.libraryItem
}
}
],
order: [['playlist', 'playlistMediaItems', 'order', 'ASC']]
})
return playlistMediaItemsExpanded.map(pmie => {
pmie.playlist.playlistMediaItems = pmie.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
})
]
},
order: [['playlistMediaItems', 'order', 'ASC']]
})
return playlists.map(p => this.getOldPlaylist(p))
}
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({
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)
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
})
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: 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')
module.exports = (sequelize) => {
class PlaylistMediaItem extends Model {
static removeByIds(playlistId, mediaItemId) {
return this.destroy({
where: {
playlistId,
mediaItemId
}
})
}
class PlaylistMediaItem extends Model {
constructor(values, options) {
super(values, options)
getMediaItem(options) {
if (!this.mediaItemType) return Promise.resolve(null)
const mixinMethodName = `get${sequelize.uppercaseFirst(this.mediaItemType)}`
return this[mixinMethodName](options)
}
/** @type {UUIDV4} */
this.id
/** @type {UUIDV4} */
this.mediaItemId
/** @type {string} */
this.mediaItemType
/** @type {number} */
this.order
/** @type {UUIDV4} */
this.playlistId
/** @type {Date} */
this.createdAt
}
PlaylistMediaItem.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
static removeByIds(playlistId, mediaItemId) {
return this.destroy({
where: {
playlistId,
mediaItemId
}
// 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)
getMediaItem(options) {
if (!this.mediaItemType) return Promise.resolve(null)
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaItemType)}`
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')
module.exports = (sequelize) => {
class Podcast extends Model {
static getOldPodcast(libraryItemExpanded) {
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
}
}
class Podcast extends Model {
constructor(values, options) {
super(values, options)
static getFromOld(oldPodcast) {
const oldPodcastMetadata = oldPodcast.metadata
return {
id: oldPodcast.id,
title: oldPodcastMetadata.title,
titleIgnorePrefix: oldPodcastMetadata.titleIgnorePrefix,
author: oldPodcastMetadata.author,
releaseDate: oldPodcastMetadata.releaseDate,
feedURL: oldPodcastMetadata.feedUrl,
imageURL: oldPodcastMetadata.imageUrl,
description: oldPodcastMetadata.description,
itunesPageURL: oldPodcastMetadata.itunesPageUrl,
itunesId: oldPodcastMetadata.itunesId,
itunesArtistId: oldPodcastMetadata.itunesArtistId,
language: oldPodcastMetadata.language,
podcastType: oldPodcastMetadata.type,
explicit: !!oldPodcastMetadata.explicit,
autoDownloadEpisodes: !!oldPodcast.autoDownloadEpisodes,
autoDownloadSchedule: oldPodcast.autoDownloadSchedule,
lastEpisodeCheck: oldPodcast.lastEpisodeCheck,
maxEpisodesToKeep: oldPodcast.maxEpisodesToKeep,
maxNewEpisodesToDownload: oldPodcast.maxNewEpisodesToDownload,
coverPath: oldPodcast.coverPath,
tags: oldPodcast.tags,
genres: oldPodcastMetadata.genres
}
/** @type {string} */
this.id
/** @type {string} */
this.title
/** @type {string} */
this.titleIgnorePrefix
/** @type {string} */
this.author
/** @type {string} */
this.releaseDate
/** @type {string} */
this.feedURL
/** @type {string} */
this.imageURL
/** @type {string} */
this.description
/** @type {string} */
this.itunesPageURL
/** @type {string} */
this.itunesId
/** @type {string} */
this.itunesArtistId
/** @type {string} */
this.language
/** @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({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
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,
static getFromOld(oldPodcast) {
const oldPodcastMetadata = oldPodcast.metadata
return {
id: oldPodcast.id,
title: oldPodcastMetadata.title,
titleIgnorePrefix: oldPodcastMetadata.titleIgnorePrefix,
author: oldPodcastMetadata.author,
releaseDate: oldPodcastMetadata.releaseDate,
feedURL: oldPodcastMetadata.feedUrl,
imageURL: oldPodcastMetadata.imageUrl,
description: oldPodcastMetadata.description,
itunesPageURL: oldPodcastMetadata.itunesPageUrl,
itunesId: oldPodcastMetadata.itunesId,
itunesArtistId: oldPodcastMetadata.itunesArtistId,
language: oldPodcastMetadata.language,
podcastType: oldPodcastMetadata.type,
explicit: !!oldPodcastMetadata.explicit,
autoDownloadEpisodes: !!oldPodcast.autoDownloadEpisodes,
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,
lastEpisodeCheck: DataTypes.DATE,
maxEpisodesToKeep: DataTypes.INTEGER,
maxNewEpisodesToDownload: DataTypes.INTEGER,
coverPath: DataTypes.STRING,
tags: DataTypes.JSON,
genres: DataTypes.JSON
}, {
sequelize,
modelName: 'podcast'
})
/**
* 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,
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 oldPodcastEpisode = require('../objects/entities/PodcastEpisode')
module.exports = (sequelize) => {
class PodcastEpisode extends Model {
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 {
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()
/**
* @typedef ChapterObject
* @property {number} id
* @property {number} start
* @property {number} end
* @property {string} title
*/
class PodcastEpisode extends Model {
constructor(values, options) {
super(values, options)
/** @type {string} */
this.id
/** @type {number} */
this.index
/** @type {string} */
this.season
/** @type {string} */
this.episode
/** @type {string} */
this.episodeType
/** @type {string} */
this.title
/** @type {string} */
this.subtitle
/** @type {string} */
this.description
/** @type {string} */
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) {
const podcastEpisode = this.getFromOld(oldEpisode)
return this.create(podcastEpisode)
static createFromOld(oldEpisode) {
const podcastEpisode = this.getFromOld(oldEpisode)
return this.create(podcastEpisode)
}
static getFromOld(oldEpisode) {
const extraData = {}
if (oldEpisode.oldEpisodeId) {
extraData.oldEpisodeId = oldEpisode.oldEpisodeId
}
static getFromOld(oldEpisode) {
const extraData = {}
if (oldEpisode.oldEpisodeId) {
extraData.oldEpisodeId = oldEpisode.oldEpisodeId
}
return {
id: oldEpisode.id,
index: oldEpisode.index,
season: oldEpisode.season,
episode: oldEpisode.episode,
episodeType: oldEpisode.episodeType,
title: oldEpisode.title,
subtitle: oldEpisode.subtitle,
description: oldEpisode.description,
pubDate: oldEpisode.pubDate,
enclosureURL: oldEpisode.enclosure?.url || null,
enclosureSize: oldEpisode.enclosure?.length || null,
enclosureType: oldEpisode.enclosure?.type || null,
publishedAt: oldEpisode.publishedAt,
podcastId: oldEpisode.podcastId,
audioFile: oldEpisode.audioFile?.toJSON() || null,
chapters: oldEpisode.chapters,
extraData
}
return {
id: oldEpisode.id,
index: oldEpisode.index,
season: oldEpisode.season,
episode: oldEpisode.episode,
episodeType: oldEpisode.episodeType,
title: oldEpisode.title,
subtitle: oldEpisode.subtitle,
description: oldEpisode.description,
pubDate: oldEpisode.pubDate,
enclosureURL: oldEpisode.enclosure?.url || null,
enclosureSize: oldEpisode.enclosure?.length || null,
enclosureType: oldEpisode.enclosure?.type || null,
publishedAt: oldEpisode.publishedAt,
podcastId: oldEpisode.podcastId,
audioFile: oldEpisode.audioFile?.toJSON() || null,
chapters: oldEpisode.chapters,
extraData
}
}
PodcastEpisode.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
index: DataTypes.INTEGER,
season: DataTypes.STRING,
episode: DataTypes.STRING,
episodeType: DataTypes.STRING,
title: DataTypes.STRING,
subtitle: DataTypes.STRING(1000),
description: DataTypes.TEXT,
pubDate: DataTypes.STRING,
enclosureURL: DataTypes.STRING,
enclosureSize: DataTypes.BIGINT,
enclosureType: DataTypes.STRING,
publishedAt: DataTypes.DATE,
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
index: DataTypes.INTEGER,
season: DataTypes.STRING,
episode: DataTypes.STRING,
episodeType: DataTypes.STRING,
title: DataTypes.STRING,
subtitle: DataTypes.STRING(1000),
description: DataTypes.TEXT,
pubDate: DataTypes.STRING,
enclosureURL: DataTypes.STRING,
enclosureSize: DataTypes.BIGINT,
enclosureType: DataTypes.STRING,
publishedAt: DataTypes.DATE,
audioFile: DataTypes.JSON,
chapters: DataTypes.JSON,
extraData: DataTypes.JSON
}, {
sequelize,
modelName: 'podcastEpisode'
})
audioFile: DataTypes.JSON,
chapters: DataTypes.JSON,
extraData: DataTypes.JSON
}, {
sequelize,
modelName: 'podcastEpisode'
})
const { podcast } = sequelize.models
podcast.hasMany(PodcastEpisode, {
onDelete: 'CASCADE'
})
PodcastEpisode.belongsTo(podcast)
const { podcast } = sequelize.models
podcast.hasMany(PodcastEpisode, {
onDelete: 'CASCADE'
})
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')
module.exports = (sequelize) => {
class Series extends Model {
static async getAllOldSeries() {
const series = await this.findAll()
return series.map(se => se.getOldSeries())
}
class Series extends Model {
constructor(values, options) {
super(values, options)
getOldSeries() {
return new oldSeries({
id: this.id,
name: this.name,
description: this.description,
libraryId: this.libraryId,
addedAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf()
})
}
/** @type {UUIDV4} */
this.id
/** @type {string} */
this.name
/** @type {string} */
this.nameIgnorePrefix
/** @type {string} */
this.description
/** @type {UUIDV4} */
this.libraryId
/** @type {Date} */
this.createdAt
/** @type {Date} */
this.updatedAt
}
static updateFromOld(oldSeries) {
const series = this.getFromOld(oldSeries)
return this.update(series, {
where: {
id: series.id
}
})
}
static async getAllOldSeries() {
const series = await this.findAll()
return series.map(se => se.getOldSeries())
}
static createFromOld(oldSeries) {
const series = this.getFromOld(oldSeries)
return this.create(series)
}
getOldSeries() {
return new oldSeries({
id: this.id,
name: this.name,
description: this.description,
libraryId: this.libraryId,
addedAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf()
})
}
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
static updateFromOld(oldSeries) {
const series = this.getFromOld(oldSeries)
return this.update(series, {
where: {
id: series.id
}
}
})
}
static removeById(seriesId) {
return this.destroy({
where: {
id: seriesId
}
})
static createFromOld(oldSeries) {
const series = this.getFromOld(oldSeries)
return this.create(series)
}
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({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
name: DataTypes.STRING,
nameIgnorePrefix: DataTypes.STRING,
description: DataTypes.TEXT
}, {
sequelize,
modelName: 'series'
})
static removeById(seriesId) {
return this.destroy({
where: {
id: seriesId
}
})
}
const { library } = sequelize.models
library.hasMany(Series, {
onDelete: 'CASCADE'
})
Series.belongsTo(library)
/**
* Get oldSeries by id
* @param {string} seriesId
* @returns {Promise<oldSeries>}
*/
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 oldNotificationSettings = require('../objects/settings/NotificationSettings')
module.exports = (sequelize) => {
class Setting extends Model {
static async getOldSettings() {
const settings = (await this.findAll()).map(se => se.value)
class Setting extends Model {
constructor(values, options) {
super(values, options)
/** @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 serverSettingsJson = settings.find(se => se.id === 'server-settings')
const notificationSettingsJson = settings.find(se => se.id === 'notification-settings')
const emailSettingsJson = settings.find(se => se.id === 'email-settings')
const serverSettingsJson = settings.find(se => se.id === 'server-settings')
const notificationSettingsJson = settings.find(se => se.id === 'notification-settings')
return {
settings,
emailSettings: new oldEmailSettings(emailSettingsJson),
serverSettings: new oldServerSettings(serverSettingsJson),
notificationSettings: new oldNotificationSettings(notificationSettingsJson)
}
}
static updateSettingObj(setting) {
return this.upsert({
key: setting.id,
value: setting
})
return {
settings,
emailSettings: new oldEmailSettings(emailSettingsJson),
serverSettings: new oldServerSettings(serverSettingsJson),
notificationSettings: new oldNotificationSettings(notificationSettingsJson)
}
}
Setting.init({
key: {
type: DataTypes.STRING,
primaryKey: true
},
value: DataTypes.JSON
}, {
sequelize,
modelName: 'setting'
})
static updateSettingObj(setting) {
return this.upsert({
key: setting.id,
value: 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 oldUser = require('../objects/user/User')
module.exports = (sequelize) => {
class User extends Model {
/**
* 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))
}
class User extends Model {
constructor(values, options) {
super(values, options)
static getOldUser(userExpanded) {
const mediaProgress = userExpanded.mediaProgresses.map(mp => mp.getOldMediaProgress())
/** @type {UUIDV4} */
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 || []
const permissions = userExpanded.permissions || {}
delete permissions.librariesAccessible
delete permissions.itemTagsSelected
/**
* Get all oldUsers
* @returns {Promise<oldUser>}
*/
static async getOldUsers() {
const users = await this.findAll({
include: this.sequelize.models.mediaProgress
})
return users.map(u => this.getOldUser(u))
}
return new oldUser({
id: userExpanded.id,
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 getOldUser(userExpanded) {
const mediaProgress = userExpanded.mediaProgresses.map(mp => mp.getOldMediaProgress())
static createFromOld(oldUser) {
const user = this.getFromOld(oldUser)
return this.create(user)
}
const librariesAccessible = userExpanded.permissions?.librariesAccessible || []
const itemTagsSelected = userExpanded.permissions?.itemTagsSelected || []
const permissions = userExpanded.permissions || {}
delete permissions.librariesAccessible
delete permissions.itemTagsSelected
static updateFromOld(oldUser) {
const user = this.getFromOld(oldUser)
return this.update(user, {
where: {
id: user.id
}
}).then((result) => result[0] > 0).catch((error) => {
Logger.error(`[User] Failed to save user ${oldUser.id}`, error)
return false
})
}
return new oldUser({
id: userExpanded.id,
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 getFromOld(oldUser) {
return {
id: oldUser.id,
username: oldUser.username,
pash: oldUser.pash || null,
type: oldUser.type || null,
token: oldUser.token || null,
isActive: !!oldUser.isActive,
lastSeen: oldUser.lastSeen || null,
extraData: {
seriesHideFromContinueListening: oldUser.seriesHideFromContinueListening || [],
oldUserId: oldUser.oldUserId
},
createdAt: oldUser.createdAt || Date.now(),
permissions: {
...oldUser.permissions,
librariesAccessible: oldUser.librariesAccessible || [],
itemTagsSelected: oldUser.itemTagsSelected || []
},
bookmarks: oldUser.bookmarks
static createFromOld(oldUser) {
const user = this.getFromOld(oldUser)
return this.create(user)
}
static updateFromOld(oldUser) {
const user = this.getFromOld(oldUser)
return this.update(user, {
where: {
id: user.id
}
}
}).then((result) => result[0] > 0).catch((error) => {
Logger.error(`[User] Failed to save user ${oldUser.id}`, error)
return false
})
}
static removeById(userId) {
return this.destroy({
where: {
id: userId
}
})
}
/**
* 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: 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
static getFromOld(oldUser) {
return {
id: oldUser.id,
username: oldUser.username,
pash: oldUser.pash || null,
type: oldUser.type || null,
token: oldUser.token || null,
isActive: !!oldUser.isActive,
lastSeen: oldUser.lastSeen || null,
extraData: {
seriesHideFromContinueListening: oldUser.seriesHideFromContinueListening || [],
oldUserId: oldUser.oldUserId
},
createdAt: oldUser.createdAt || Date.now(),
permissions: {
...oldUser.permissions,
librariesAccessible: oldUser.librariesAccessible || [],
itemTagsSelected: oldUser.itemTagsSelected || []
},
bookmarks: oldUser.bookmarks
}
}
User.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'
})
static removeById(userId) {
return this.destroy({
where: {
id: userId
}
})
}
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() {
const blockTags = [
{ 'itunes:block': 'yes' },
{ 'googleplay:block': 'yes' }
]
return {
title: this.title,
description: this.description || '',
@ -94,8 +98,7 @@ class FeedMeta {
]
},
{ 'itunes:explicit': !!this.explicit },
{ 'itunes:block': this.preventIndexing?"Yes":"No" },
{ 'googleplay:block': this.preventIndexing?"yes":"no" }
...(this.preventIndexing ? blockTags : [])
]
}
}

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