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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

6
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,31 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model, literal } = require('sequelize')
const oldAuthor = require('../objects/entities/Author') const oldAuthor = require('../objects/entities/Author')
module.exports = (sequelize) => {
class Author extends Model { class Author extends Model {
constructor(values, options) {
super(values, options)
/** @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 async getOldAuthors() { static async getOldAuthors() {
const authors = await this.findAll() const authors = await this.findAll()
return authors.map(au => au.getOldAuthor()) return authors.map(au => au.getOldAuthor())
@ -60,9 +82,53 @@ module.exports = (sequelize) => {
} }
}) })
} }
/**
* 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()
} }
Author.init({ /**
* 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: { id: {
type: DataTypes.UUID, type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4, defaultValue: DataTypes.UUIDV4,
@ -75,7 +141,24 @@ module.exports = (sequelize) => {
imagePath: DataTypes.STRING imagePath: DataTypes.STRING
}, { }, {
sequelize, sequelize,
modelName: 'author' modelName: 'author',
indexes: [
{
fields: [{
name: 'name',
collate: 'NOCASE'
}]
},
// {
// fields: [{
// name: 'lastFirst',
// collate: 'NOCASE'
// }]
// },
{
fields: ['libraryId']
}
]
}) })
const { library } = sequelize.models const { library } = sequelize.models
@ -83,6 +166,6 @@ module.exports = (sequelize) => {
onDelete: 'CASCADE' onDelete: 'CASCADE'
}) })
Author.belongsTo(library) Author.belongsTo(library)
return Author
} }
}
module.exports = Author

View File

@ -1,8 +1,98 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model } = require('sequelize')
const Logger = require('../Logger') const Logger = require('../Logger')
module.exports = (sequelize) => { /**
* @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
*/
/**
* @typedef ChapterObject
* @property {number} id
* @property {number} start
* @property {number} end
* @property {string} title
*/
/**
* @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 { 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) { static getOldBook(libraryItemExpanded) {
const bookExpanded = libraryItemExpanded.media const bookExpanded = libraryItemExpanded.media
let authors = [] let authors = []
@ -120,9 +210,13 @@ module.exports = (sequelize) => {
genres: oldBook.metadata.genres genres: oldBook.metadata.genres
} }
} }
}
Book.init({ /**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init({
id: { id: {
type: DataTypes.UUID, type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4, defaultValue: DataTypes.UUIDV4,
@ -159,20 +253,21 @@ module.exports = (sequelize) => {
collate: 'NOCASE' collate: 'NOCASE'
}] }]
}, },
{ // {
fields: [{ // fields: [{
name: 'titleIgnorePrefix', // name: 'titleIgnorePrefix',
collate: 'NOCASE' // collate: 'NOCASE'
}] // }]
}, // },
{ {
fields: ['publishedYear'] fields: ['publishedYear']
}, },
{ // {
fields: ['duration'] // fields: ['duration']
} // }
] ]
}) })
return Book
} }
}
module.exports = Book

View File

@ -1,7 +1,19 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model } = require('sequelize')
module.exports = (sequelize) => {
class BookAuthor extends Model { 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
}
static removeByIds(authorId = null, bookId = null) { static removeByIds(authorId = null, bookId = null) {
const where = {} const where = {}
if (authorId) where.authorId = authorId if (authorId) where.authorId = authorId
@ -10,9 +22,13 @@ module.exports = (sequelize) => {
where where
}) })
} }
}
BookAuthor.init({ /**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init({
id: { id: {
type: DataTypes.UUID, type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4, defaultValue: DataTypes.UUIDV4,
@ -36,6 +52,6 @@ module.exports = (sequelize) => {
author.hasMany(BookAuthor) author.hasMany(BookAuthor)
BookAuthor.belongsTo(author) BookAuthor.belongsTo(author)
return BookAuthor
} }
}
module.exports = BookAuthor

View File

@ -1,7 +1,21 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model } = require('sequelize')
module.exports = (sequelize) => {
class BookSeries extends Model { 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
}
static removeByIds(seriesId = null, bookId = null) { static removeByIds(seriesId = null, bookId = null) {
const where = {} const where = {}
if (seriesId) where.seriesId = seriesId if (seriesId) where.seriesId = seriesId
@ -10,9 +24,13 @@ module.exports = (sequelize) => {
where where
}) })
} }
}
BookSeries.init({ /**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init({
id: { id: {
type: DataTypes.UUID, type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4, defaultValue: DataTypes.UUIDV4,
@ -32,11 +50,16 @@ module.exports = (sequelize) => {
book.belongsToMany(series, { through: BookSeries }) book.belongsToMany(series, { through: BookSeries })
series.belongsToMany(book, { through: BookSeries }) series.belongsToMany(book, { through: BookSeries })
book.hasMany(BookSeries) book.hasMany(BookSeries, {
onDelete: 'CASCADE'
})
BookSeries.belongsTo(book) BookSeries.belongsTo(book)
series.hasMany(BookSeries) series.hasMany(BookSeries, {
onDelete: 'CASCADE'
})
BookSeries.belongsTo(series) BookSeries.belongsTo(series)
return BookSeries
} }
}
module.exports = BookSeries

View File

@ -1,10 +1,25 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model, Sequelize } = require('sequelize')
const oldCollection = require('../objects/Collection') const oldCollection = require('../objects/Collection')
const { areEquivalent } = require('../utils/index')
module.exports = (sequelize) => {
class Collection extends Model { class Collection extends Model {
constructor(values, options) {
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 * Get all old collections
* @returns {Promise<oldCollection[]>} * @returns {Promise<oldCollection[]>}
@ -12,10 +27,10 @@ module.exports = (sequelize) => {
static async getOldCollections() { static async getOldCollections() {
const collections = await this.findAll({ const collections = await this.findAll({
include: { include: {
model: sequelize.models.book, model: this.sequelize.models.book,
include: sequelize.models.libraryItem include: this.sequelize.models.libraryItem
}, },
order: [[sequelize.models.book, sequelize.models.collectionBook, 'order', 'ASC']] order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']]
}) })
return collections.map(c => this.getOldCollection(c)) return collections.map(c => this.getOldCollection(c))
} }
@ -39,7 +54,7 @@ module.exports = (sequelize) => {
const collectionIncludes = [] const collectionIncludes = []
if (include.includes('rssfeed')) { if (include.includes('rssfeed')) {
collectionIncludes.push({ collectionIncludes.push({
model: sequelize.models.feed model: this.sequelize.models.feed
}) })
} }
@ -47,19 +62,19 @@ module.exports = (sequelize) => {
where: collectionWhere, where: collectionWhere,
include: [ include: [
{ {
model: sequelize.models.book, model: this.sequelize.models.book,
include: [ include: [
{ {
model: sequelize.models.libraryItem model: this.sequelize.models.libraryItem
}, },
{ {
model: sequelize.models.author, model: this.sequelize.models.author,
through: { through: {
attributes: [] attributes: []
} }
}, },
{ {
model: sequelize.models.series, model: this.sequelize.models.series,
through: { through: {
attributes: ['sequence'] attributes: ['sequence']
} }
@ -69,7 +84,7 @@ module.exports = (sequelize) => {
}, },
...collectionIncludes ...collectionIncludes
], ],
order: [[sequelize.models.book, sequelize.models.collectionBook, 'order', 'ASC']] order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']]
}) })
// TODO: Handle user permission restrictions on initial query // TODO: Handle user permission restrictions on initial query
return collections.map(c => { return collections.map(c => {
@ -93,7 +108,7 @@ module.exports = (sequelize) => {
const libraryItem = b.libraryItem const libraryItem = b.libraryItem
delete b.libraryItem delete b.libraryItem
libraryItem.media = b libraryItem.media = b
return sequelize.models.libraryItem.getOldLibraryItem(libraryItem) return this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem)
}) })
// Users with restricted permissions will not see this collection // Users with restricted permissions will not see this collection
@ -105,13 +120,83 @@ module.exports = (sequelize) => {
// Map feed if found // Map feed if found
if (c.feeds?.length) { if (c.feeds?.length) {
collectionExpanded.rssFeed = sequelize.models.feed.getOldFeed(c.feeds[0]) collectionExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(c.feeds[0])
} }
return collectionExpanded return collectionExpanded
}).filter(c => c) }).filter(c => c)
} }
/**
* 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.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
}
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])
}
}
return collectionExpanded
}
/** /**
* Get old collection from Collection * Get old collection from Collection
* @param {Collection} collectionExpanded * @param {Collection} collectionExpanded
@ -135,48 +220,6 @@ module.exports = (sequelize) => {
return this.create(collection) 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
}
}
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
}
static getFromOld(oldCollection) { static getFromOld(oldCollection) {
return { return {
id: oldCollection.id, id: oldCollection.id,
@ -195,23 +238,53 @@ module.exports = (sequelize) => {
} }
/** /**
* Get collection by id * Get old collection by id
* @param {string} collectionId * @param {string} collectionId
* @returns {Promise<oldCollection|null>} returns null if not found * @returns {Promise<oldCollection|null>} returns null if not found
*/ */
static async getById(collectionId) { static async getOldById(collectionId) {
if (!collectionId) return null if (!collectionId) return null
const collection = await this.findByPk(collectionId, { const collection = await this.findByPk(collectionId, {
include: { include: {
model: sequelize.models.book, model: this.sequelize.models.book,
include: sequelize.models.libraryItem include: this.sequelize.models.libraryItem
}, },
order: [[sequelize.models.book, sequelize.models.collectionBook, 'order', 'ASC']] order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']]
}) })
if (!collection) return null if (!collection) return null
return this.getOldCollection(collection) return this.getOldCollection(collection)
} }
/**
* 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']
}
},
],
order: [Sequelize.literal('`collectionBook.order` ASC')]
}) || []
return this.sequelize.models.collection.getOldCollection(this)
}
/** /**
* Remove all collections belonging to library * Remove all collections belonging to library
* @param {string} libraryId * @param {string} libraryId
@ -226,43 +299,27 @@ module.exports = (sequelize) => {
}) })
} }
/**
* 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))
}
static async getAllForBook(bookId) { static async getAllForBook(bookId) {
const collections = await this.findAll({ const collections = await this.findAll({
include: { include: {
model: sequelize.models.book, model: this.sequelize.models.book,
where: { where: {
id: bookId id: bookId
}, },
required: true, required: true,
include: sequelize.models.libraryItem include: this.sequelize.models.libraryItem
}, },
order: [[sequelize.models.book, sequelize.models.collectionBook, 'order', 'ASC']] order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']]
}) })
return collections.map(c => this.getOldCollection(c)) return collections.map(c => this.getOldCollection(c))
} }
}
Collection.init({ /**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init({
id: { id: {
type: DataTypes.UUID, type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4, defaultValue: DataTypes.UUIDV4,
@ -279,6 +336,7 @@ module.exports = (sequelize) => {
library.hasMany(Collection) library.hasMany(Collection)
Collection.belongsTo(library) Collection.belongsTo(library)
return Collection
} }
}
module.exports = Collection

View File

@ -1,7 +1,21 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model } = require('sequelize')
module.exports = (sequelize) => {
class CollectionBook extends Model { 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
}
static removeByIds(collectionId, bookId) { static removeByIds(collectionId, bookId) {
return this.destroy({ return this.destroy({
where: { where: {
@ -10,9 +24,9 @@ module.exports = (sequelize) => {
} }
}) })
} }
}
CollectionBook.init({ static init(sequelize) {
super.init({
id: { id: {
type: DataTypes.UUID, type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4, defaultValue: DataTypes.UUIDV4,
@ -41,6 +55,7 @@ module.exports = (sequelize) => {
onDelete: 'CASCADE' onDelete: 'CASCADE'
}) })
CollectionBook.belongsTo(collection) CollectionBook.belongsTo(collection)
return CollectionBook
} }
}
module.exports = CollectionBook

View File

@ -1,8 +1,34 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model } = require('sequelize')
const oldDevice = require('../objects/DeviceInfo') const oldDevice = require('../objects/DeviceInfo')
module.exports = (sequelize) => {
class Device extends Model { class Device extends Model {
constructor(values, options) {
super(values, options)
/** @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() { getOldDevice() {
let browserVersion = null let browserVersion = null
let sdkVersion = null let sdkVersion = null
@ -85,9 +111,13 @@ module.exports = (sequelize) => {
extraData extraData
} }
} }
}
Device.init({ /**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init({
id: { id: {
type: DataTypes.UUID, type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4, defaultValue: DataTypes.UUIDV4,
@ -111,6 +141,7 @@ module.exports = (sequelize) => {
onDelete: 'CASCADE' onDelete: 'CASCADE'
}) })
Device.belongsTo(user) Device.belongsTo(user)
return Device
} }
}
module.exports = Device

View File

@ -1,16 +1,61 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model } = require('sequelize')
const oldFeed = require('../objects/Feed') const oldFeed = require('../objects/Feed')
const areEquivalent = require('../utils/areEquivalent') const areEquivalent = require('../utils/areEquivalent')
/*
* Polymorphic association: https://sequelize.org/docs/v6/advanced-association-concepts/polymorphic-associations/
* Feeds can be created from LibraryItem, Collection, Playlist or Series
*/
module.exports = (sequelize) => {
class Feed extends Model { 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() { static async getOldFeeds() {
const feeds = await this.findAll({ const feeds = await this.findAll({
include: { include: {
model: sequelize.models.feedEpisode model: this.sequelize.models.feedEpisode
} }
}) })
return feeds.map(f => this.getOldFeed(f)) return feeds.map(f => this.getOldFeed(f))
@ -85,7 +130,7 @@ module.exports = (sequelize) => {
const feedExpanded = await this.findOne({ const feedExpanded = await this.findOne({
where, where,
include: { include: {
model: sequelize.models.feedEpisode model: this.sequelize.models.feedEpisode
} }
}) })
if (!feedExpanded) return null if (!feedExpanded) return null
@ -101,7 +146,7 @@ module.exports = (sequelize) => {
if (!id) return null if (!id) return null
const feedExpanded = await this.findByPk(id, { const feedExpanded = await this.findByPk(id, {
include: { include: {
model: sequelize.models.feedEpisode model: this.sequelize.models.feedEpisode
} }
}) })
if (!feedExpanded) return null if (!feedExpanded) return null
@ -114,9 +159,9 @@ module.exports = (sequelize) => {
if (oldFeed.episodes?.length) { if (oldFeed.episodes?.length) {
for (const oldFeedEpisode of oldFeed.episodes) { for (const oldFeedEpisode of oldFeed.episodes) {
const feedEpisode = sequelize.models.feedEpisode.getFromOld(oldFeedEpisode) const feedEpisode = this.sequelize.models.feedEpisode.getFromOld(oldFeedEpisode)
feedEpisode.feedId = newFeed.id feedEpisode.feedId = newFeed.id
await sequelize.models.feedEpisode.create(feedEpisode) await this.sequelize.models.feedEpisode.create(feedEpisode)
} }
} }
} }
@ -126,7 +171,7 @@ module.exports = (sequelize) => {
const feedObj = this.getFromOld(oldFeed) const feedObj = this.getFromOld(oldFeed)
const existingFeed = await this.findByPk(feedObj.id, { const existingFeed = await this.findByPk(feedObj.id, {
include: sequelize.models.feedEpisode include: this.sequelize.models.feedEpisode
}) })
if (!existingFeed) return false if (!existingFeed) return false
@ -138,7 +183,7 @@ module.exports = (sequelize) => {
feedEpisode.destroy() feedEpisode.destroy()
} else { } else {
let episodeHasUpdates = false let episodeHasUpdates = false
const oldFeedEpisodeCleaned = sequelize.models.feedEpisode.getFromOld(oldFeedEpisode) const oldFeedEpisodeCleaned = this.sequelize.models.feedEpisode.getFromOld(oldFeedEpisode)
for (const key in oldFeedEpisodeCleaned) { for (const key in oldFeedEpisodeCleaned) {
if (!areEquivalent(oldFeedEpisodeCleaned[key], feedEpisode[key])) { if (!areEquivalent(oldFeedEpisodeCleaned[key], feedEpisode[key])) {
episodeHasUpdates = true episodeHasUpdates = true
@ -197,12 +242,20 @@ module.exports = (sequelize) => {
getEntity(options) { getEntity(options) {
if (!this.entityType) return Promise.resolve(null) if (!this.entityType) return Promise.resolve(null)
const mixinMethodName = `get${sequelize.uppercaseFirst(this.entityType)}` const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.entityType)}`
return this[mixinMethodName](options) return this[mixinMethodName](options)
} }
}
Feed.init({ /**
* 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: { id: {
type: DataTypes.UUID, type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4, defaultValue: DataTypes.UUIDV4,
@ -302,6 +355,7 @@ module.exports = (sequelize) => {
delete instance.dataValues.playlist delete instance.dataValues.playlist
} }
}) })
return Feed
} }
}
module.exports = Feed

View File

@ -1,7 +1,45 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model } = require('sequelize')
module.exports = (sequelize) => {
class FeedEpisode extends Model { class FeedEpisode extends Model {
constructor(values, options) {
super(values, options)
/** @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() { getOldEpisode() {
const enclosure = { const enclosure = {
url: this.enclosureURL, url: this.enclosureURL,
@ -44,9 +82,13 @@ module.exports = (sequelize) => {
explicit: !!oldFeedEpisode.explicit explicit: !!oldFeedEpisode.explicit
} }
} }
}
FeedEpisode.init({ /**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init({
id: { id: {
type: DataTypes.UUID, type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4, defaultValue: DataTypes.UUIDV4,
@ -77,6 +119,7 @@ module.exports = (sequelize) => {
onDelete: 'CASCADE' onDelete: 'CASCADE'
}) })
FeedEpisode.belongsTo(feed) FeedEpisode.belongsTo(feed)
return FeedEpisode
} }
}
module.exports = FeedEpisode

View File

@ -2,15 +2,54 @@ const { DataTypes, Model } = require('sequelize')
const Logger = require('../Logger') const Logger = require('../Logger')
const oldLibrary = require('../objects/Library') const oldLibrary = require('../objects/Library')
module.exports = (sequelize) => { /**
* @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
*/
class Library extends Model { class Library extends Model {
constructor(values, options) {
super(values, options)
/** @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
}
/** /**
* Get all old libraries * Get all old libraries
* @returns {Promise<oldLibrary[]>} * @returns {Promise<oldLibrary[]>}
*/ */
static async getAllOldLibraries() { static async getAllOldLibraries() {
const libraries = await this.findAll({ const libraries = await this.findAll({
include: sequelize.models.libraryFolder, include: this.sequelize.models.libraryFolder,
order: [['displayOrder', 'ASC']] order: [['displayOrder', 'ASC']]
}) })
return libraries.map(lib => this.getOldLibrary(lib)) return libraries.map(lib => this.getOldLibrary(lib))
@ -60,7 +99,7 @@ module.exports = (sequelize) => {
}) })
return this.create(library, { return this.create(library, {
include: sequelize.models.libraryFolder include: this.sequelize.models.libraryFolder
}).catch((error) => { }).catch((error) => {
Logger.error(`[Library] Failed to create library ${library.id}`, error) Logger.error(`[Library] Failed to create library ${library.id}`, error)
return null return null
@ -74,7 +113,7 @@ module.exports = (sequelize) => {
*/ */
static async updateFromOld(oldLibrary) { static async updateFromOld(oldLibrary) {
const existingLibrary = await this.findByPk(oldLibrary.id, { const existingLibrary = await this.findByPk(oldLibrary.id, {
include: sequelize.models.libraryFolder include: this.sequelize.models.libraryFolder
}) })
if (!existingLibrary) { if (!existingLibrary) {
Logger.error(`[Library] Failed to update library ${oldLibrary.id} - not found`) Logger.error(`[Library] Failed to update library ${oldLibrary.id} - not found`)
@ -93,7 +132,7 @@ module.exports = (sequelize) => {
for (const libraryFolder of libraryFolders) { for (const libraryFolder of libraryFolders) {
const existingLibraryFolder = existingLibrary.libraryFolders.find(lf => lf.id === libraryFolder.id) const existingLibraryFolder = existingLibrary.libraryFolders.find(lf => lf.id === libraryFolder.id)
if (!existingLibraryFolder) { if (!existingLibraryFolder) {
await sequelize.models.libraryFolder.create(libraryFolder) await this.sequelize.models.libraryFolder.create(libraryFolder)
} else if (existingLibraryFolder.path !== libraryFolder.path) { } else if (existingLibraryFolder.path !== libraryFolder.path) {
await existingLibraryFolder.update({ path: libraryFolder.path }) await existingLibraryFolder.update({ path: libraryFolder.path })
} }
@ -159,7 +198,7 @@ module.exports = (sequelize) => {
static async getOldById(libraryId) { static async getOldById(libraryId) {
if (!libraryId) return null if (!libraryId) return null
const library = await this.findByPk(libraryId, { const library = await this.findByPk(libraryId, {
include: sequelize.models.libraryFolder include: this.sequelize.models.libraryFolder
}) })
if (!library) return null if (!library) return null
return this.getOldLibrary(library) return this.getOldLibrary(library)
@ -192,9 +231,13 @@ module.exports = (sequelize) => {
} }
} }
} }
}
Library.init({ /**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init({
id: { id: {
type: DataTypes.UUID, type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4, defaultValue: DataTypes.UUIDV4,
@ -213,6 +256,7 @@ module.exports = (sequelize) => {
sequelize, sequelize,
modelName: 'library' modelName: 'library'
}) })
return Library
} }
}
module.exports = Library

View File

@ -1,7 +1,21 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model } = require('sequelize')
module.exports = (sequelize) => {
class LibraryFolder extends Model { class LibraryFolder extends Model {
constructor(values, options) {
super(values, options)
/** @type {UUIDV4} */
this.id
/** @type {string} */
this.path
/** @type {UUIDV4} */
this.libraryId
/** @type {Date} */
this.createdAt
/** @type {Date} */
this.updatedAt
}
/** /**
* Gets all library folder path strings * Gets all library folder path strings
* @returns {Promise<string[]>} array of library folder paths * @returns {Promise<string[]>} array of library folder paths
@ -12,9 +26,13 @@ module.exports = (sequelize) => {
}) })
return libraryFolders.map(l => l.path) return libraryFolders.map(l => l.path)
} }
}
LibraryFolder.init({ /**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init({
id: { id: {
type: DataTypes.UUID, type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4, defaultValue: DataTypes.UUIDV4,
@ -31,6 +49,7 @@ module.exports = (sequelize) => {
onDelete: 'CASCADE' onDelete: 'CASCADE'
}) })
LibraryFolder.belongsTo(library) LibraryFolder.belongsTo(library)
return LibraryFolder
} }
}
module.exports = LibraryFolder

View File

@ -1,56 +1,66 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model, WhereOptions } = require('sequelize')
const Logger = require('../Logger') const Logger = require('../Logger')
const oldLibraryItem = require('../objects/LibraryItem') const oldLibraryItem = require('../objects/LibraryItem')
const libraryFilters = require('../utils/queries/libraryFilters') const libraryFilters = require('../utils/queries/libraryFilters')
const { areEquivalent } = require('../utils/index') const { areEquivalent } = require('../utils/index')
const Book = require('./Book')
const Podcast = require('./Podcast')
/**
* @typedef LibraryFileObject
* @property {string} ino
* @property {boolean} isSupplementary
* @property {number} addedAt
* @property {number} updatedAt
* @property {{filename:string, ext:string, path:string, relPath:string, size:number, mtimeMs:number, ctimeMs:number, birthtimeMs:number}} metadata
*/
module.exports = (sequelize) => {
class LibraryItem extends Model { class LibraryItem extends Model {
/** constructor(values, options) {
* Loads all podcast episodes, all library items in chunks of 500, then maps them to old library items super(values, options)
* @todo this is a temporary solution until we can use the sqlite without loading all the library items on init
*
* @returns {Promise<objects.LibraryItem[]>} old library items
*/
static async loadAllLibraryItems() {
let start = Date.now()
Logger.info(`[LibraryItem] Loading podcast episodes...`)
const podcastEpisodes = await sequelize.models.podcastEpisode.findAll()
Logger.info(`[LibraryItem] Finished loading ${podcastEpisodes.length} podcast episodes in ${((Date.now() - start) / 1000).toFixed(2)}s`)
start = Date.now() /** @type {string} */
Logger.info(`[LibraryItem] Loading library items...`) this.id
let libraryItems = await this.getAllOldLibraryItemsIncremental() /** @type {string} */
Logger.info(`[LibraryItem] Finished loading ${libraryItems.length} library items in ${((Date.now() - start) / 1000).toFixed(2)}s`) this.ino
/** @type {string} */
// Map LibraryItem to old library item this.path
libraryItems = libraryItems.map(li => { /** @type {string} */
if (li.mediaType === 'podcast') { this.relPath
li.media.podcastEpisodes = podcastEpisodes.filter(pe => pe.podcastId === li.media.id) /** @type {string} */
} this.mediaId
return this.getOldLibraryItem(li) /** @type {string} */
}) this.mediaType
/** @type {boolean} */
return libraryItems this.isFile
} /** @type {boolean} */
this.isMissing
/** /** @type {boolean} */
* Loads all LibraryItem in batches of 500 this.isInvalid
* @todo temporary solution /** @type {Date} */
* this.mtime
* @param {Model<LibraryItem>[]} libraryItems /** @type {Date} */
* @param {number} offset this.ctime
* @returns {Promise<Model<LibraryItem>[]>} /** @type {Date} */
*/ this.birthtime
static async getAllOldLibraryItemsIncremental(libraryItems = [], offset = 0) { /** @type {BigInt} */
const limit = 500 this.size
const rows = await this.getLibraryItemsIncrement(offset, limit) /** @type {Date} */
libraryItems.push(...rows) this.lastScan
if (!rows.length || rows.length < limit) { /** @type {string} */
return libraryItems this.lastScanVersion
} /** @type {LibraryFileObject[]} */
Logger.info(`[LibraryItem] Loaded ${rows.length} library items. ${libraryItems.length} loaded so far.`) this.libraryFiles
return this.getAllOldLibraryItemsIncremental(libraryItems, offset + rows.length) /** @type {Object} */
this.extraData
/** @type {string} */
this.libraryId
/** @type {string} */
this.libraryFolderId
/** @type {Date} */
this.createdAt
/** @type {Date} */
this.updatedAt
} }
/** /**
@ -61,24 +71,21 @@ module.exports = (sequelize) => {
* @param {number} limit * @param {number} limit
* @returns {Promise<Model<LibraryItem>[]>} LibraryItem * @returns {Promise<Model<LibraryItem>[]>} LibraryItem
*/ */
static getLibraryItemsIncrement(offset, limit) { static getLibraryItemsIncrement(offset, limit, where = null) {
return this.findAll({ return this.findAll({
benchmark: true, where,
logging: (sql, timeMs) => {
console.log(`[Query] Elapsed ${timeMs}ms.`)
},
include: [ include: [
{ {
model: sequelize.models.book, model: this.sequelize.models.book,
include: [ include: [
{ {
model: sequelize.models.author, model: this.sequelize.models.author,
through: { through: {
attributes: ['createdAt'] attributes: ['createdAt']
} }
}, },
{ {
model: sequelize.models.series, model: this.sequelize.models.series,
through: { through: {
attributes: ['sequence', 'createdAt'] attributes: ['sequence', 'createdAt']
} }
@ -86,14 +93,14 @@ module.exports = (sequelize) => {
] ]
}, },
{ {
model: sequelize.models.podcast model: this.sequelize.models.podcast
} }
], ],
order: [ order: [
['createdAt', 'ASC'], ['createdAt', 'ASC'],
// Ensure author & series stay in the same order // Ensure author & series stay in the same order
[sequelize.models.book, sequelize.models.author, sequelize.models.bookAuthor, 'createdAt', 'ASC'], [this.sequelize.models.book, this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'],
[sequelize.models.book, sequelize.models.series, 'bookSeries', 'createdAt', 'ASC'] [this.sequelize.models.book, this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC']
], ],
offset, offset,
limit limit
@ -102,23 +109,24 @@ module.exports = (sequelize) => {
/** /**
* Currently unused because this is too slow and uses too much mem * Currently unused because this is too slow and uses too much mem
* * @param {[WhereOptions]} where
* @returns {Array<objects.LibraryItem>} old library items * @returns {Array<objects.LibraryItem>} old library items
*/ */
static async getAllOldLibraryItems() { static async getAllOldLibraryItems(where = null) {
let libraryItems = await this.findAll({ let libraryItems = await this.findAll({
where,
include: [ include: [
{ {
model: sequelize.models.book, model: this.sequelize.models.book,
include: [ include: [
{ {
model: sequelize.models.author, model: this.sequelize.models.author,
through: { through: {
attributes: [] attributes: []
} }
}, },
{ {
model: sequelize.models.series, model: this.sequelize.models.series,
through: { through: {
attributes: ['sequence'] attributes: ['sequence']
} }
@ -126,10 +134,10 @@ module.exports = (sequelize) => {
] ]
}, },
{ {
model: sequelize.models.podcast, model: this.sequelize.models.podcast,
include: [ include: [
{ {
model: sequelize.models.podcastEpisode model: this.sequelize.models.podcastEpisode
} }
] ]
} }
@ -147,9 +155,9 @@ module.exports = (sequelize) => {
static getOldLibraryItem(libraryItemExpanded) { static getOldLibraryItem(libraryItemExpanded) {
let media = null let media = null
if (libraryItemExpanded.mediaType === 'book') { if (libraryItemExpanded.mediaType === 'book') {
media = sequelize.models.book.getOldBook(libraryItemExpanded) media = this.sequelize.models.book.getOldBook(libraryItemExpanded)
} else if (libraryItemExpanded.mediaType === 'podcast') { } else if (libraryItemExpanded.mediaType === 'podcast') {
media = sequelize.models.podcast.getOldPodcast(libraryItemExpanded) media = this.sequelize.models.podcast.getOldPodcast(libraryItemExpanded)
} }
return new oldLibraryItem({ return new oldLibraryItem({
@ -180,30 +188,30 @@ module.exports = (sequelize) => {
const newLibraryItem = await this.create(this.getFromOld(oldLibraryItem)) const newLibraryItem = await this.create(this.getFromOld(oldLibraryItem))
if (oldLibraryItem.mediaType === 'book') { if (oldLibraryItem.mediaType === 'book') {
const bookObj = sequelize.models.book.getFromOld(oldLibraryItem.media) const bookObj = this.sequelize.models.book.getFromOld(oldLibraryItem.media)
bookObj.libraryItemId = newLibraryItem.id bookObj.libraryItemId = newLibraryItem.id
const newBook = await sequelize.models.book.create(bookObj) const newBook = await this.sequelize.models.book.create(bookObj)
const oldBookAuthors = oldLibraryItem.media.metadata.authors || [] const oldBookAuthors = oldLibraryItem.media.metadata.authors || []
const oldBookSeriesAll = oldLibraryItem.media.metadata.series || [] const oldBookSeriesAll = oldLibraryItem.media.metadata.series || []
for (const oldBookAuthor of oldBookAuthors) { for (const oldBookAuthor of oldBookAuthors) {
await sequelize.models.bookAuthor.create({ authorId: oldBookAuthor.id, bookId: newBook.id }) await this.sequelize.models.bookAuthor.create({ authorId: oldBookAuthor.id, bookId: newBook.id })
} }
for (const oldSeries of oldBookSeriesAll) { for (const oldSeries of oldBookSeriesAll) {
await sequelize.models.bookSeries.create({ seriesId: oldSeries.id, bookId: newBook.id, sequence: oldSeries.sequence }) await this.sequelize.models.bookSeries.create({ seriesId: oldSeries.id, bookId: newBook.id, sequence: oldSeries.sequence })
} }
} else if (oldLibraryItem.mediaType === 'podcast') { } else if (oldLibraryItem.mediaType === 'podcast') {
const podcastObj = sequelize.models.podcast.getFromOld(oldLibraryItem.media) const podcastObj = this.sequelize.models.podcast.getFromOld(oldLibraryItem.media)
podcastObj.libraryItemId = newLibraryItem.id podcastObj.libraryItemId = newLibraryItem.id
const newPodcast = await sequelize.models.podcast.create(podcastObj) const newPodcast = await this.sequelize.models.podcast.create(podcastObj)
const oldEpisodes = oldLibraryItem.media.episodes || [] const oldEpisodes = oldLibraryItem.media.episodes || []
for (const oldEpisode of oldEpisodes) { for (const oldEpisode of oldEpisodes) {
const episodeObj = sequelize.models.podcastEpisode.getFromOld(oldEpisode) const episodeObj = this.sequelize.models.podcastEpisode.getFromOld(oldEpisode)
episodeObj.libraryItemId = newLibraryItem.id episodeObj.libraryItemId = newLibraryItem.id
episodeObj.podcastId = newPodcast.id episodeObj.podcastId = newPodcast.id
await sequelize.models.podcastEpisode.create(episodeObj) await this.sequelize.models.podcastEpisode.create(episodeObj)
} }
} }
@ -214,16 +222,16 @@ module.exports = (sequelize) => {
const libraryItemExpanded = await this.findByPk(oldLibraryItem.id, { const libraryItemExpanded = await this.findByPk(oldLibraryItem.id, {
include: [ include: [
{ {
model: sequelize.models.book, model: this.sequelize.models.book,
include: [ include: [
{ {
model: sequelize.models.author, model: this.sequelize.models.author,
through: { through: {
attributes: [] attributes: []
} }
}, },
{ {
model: sequelize.models.series, model: this.sequelize.models.series,
through: { through: {
attributes: ['id', 'sequence'] attributes: ['id', 'sequence']
} }
@ -231,10 +239,10 @@ module.exports = (sequelize) => {
] ]
}, },
{ {
model: sequelize.models.podcast, model: this.sequelize.models.podcast,
include: [ include: [
{ {
model: sequelize.models.podcastEpisode model: this.sequelize.models.podcastEpisode
} }
] ]
} }
@ -248,7 +256,7 @@ module.exports = (sequelize) => {
if (libraryItemExpanded.media) { if (libraryItemExpanded.media) {
let updatedMedia = null let updatedMedia = null
if (libraryItemExpanded.mediaType === 'podcast') { if (libraryItemExpanded.mediaType === 'podcast') {
updatedMedia = sequelize.models.podcast.getFromOld(oldLibraryItem.media) updatedMedia = this.sequelize.models.podcast.getFromOld(oldLibraryItem.media)
const existingPodcastEpisodes = libraryItemExpanded.media.podcastEpisodes || [] const existingPodcastEpisodes = libraryItemExpanded.media.podcastEpisodes || []
const updatedPodcastEpisodes = oldLibraryItem.media.episodes || [] const updatedPodcastEpisodes = oldLibraryItem.media.episodes || []
@ -265,10 +273,10 @@ module.exports = (sequelize) => {
const existingEpisodeMatch = existingPodcastEpisodes.find(ep => ep.id === updatedPodcastEpisode.id) const existingEpisodeMatch = existingPodcastEpisodes.find(ep => ep.id === updatedPodcastEpisode.id)
if (!existingEpisodeMatch) { if (!existingEpisodeMatch) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${updatedPodcastEpisode.title}" was added`) Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${updatedPodcastEpisode.title}" was added`)
await sequelize.models.podcastEpisode.createFromOld(updatedPodcastEpisode) await this.sequelize.models.podcastEpisode.createFromOld(updatedPodcastEpisode)
hasUpdates = true hasUpdates = true
} else { } else {
const updatedEpisodeCleaned = sequelize.models.podcastEpisode.getFromOld(updatedPodcastEpisode) const updatedEpisodeCleaned = this.sequelize.models.podcastEpisode.getFromOld(updatedPodcastEpisode)
let episodeHasUpdates = false let episodeHasUpdates = false
for (const key in updatedEpisodeCleaned) { for (const key in updatedEpisodeCleaned) {
let existingValue = existingEpisodeMatch[key] let existingValue = existingEpisodeMatch[key]
@ -286,7 +294,7 @@ module.exports = (sequelize) => {
} }
} }
} else if (libraryItemExpanded.mediaType === 'book') { } else if (libraryItemExpanded.mediaType === 'book') {
updatedMedia = sequelize.models.book.getFromOld(oldLibraryItem.media) updatedMedia = this.sequelize.models.book.getFromOld(oldLibraryItem.media)
const existingAuthors = libraryItemExpanded.media.authors || [] const existingAuthors = libraryItemExpanded.media.authors || []
const existingSeriesAll = libraryItemExpanded.media.series || [] const existingSeriesAll = libraryItemExpanded.media.series || []
@ -297,7 +305,7 @@ module.exports = (sequelize) => {
// Author was removed from Book // Author was removed from Book
if (!updatedAuthors.some(au => au.id === existingAuthor.id)) { if (!updatedAuthors.some(au => au.id === existingAuthor.id)) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${existingAuthor.name}" was removed`) Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${existingAuthor.name}" was removed`)
await sequelize.models.bookAuthor.removeByIds(existingAuthor.id, libraryItemExpanded.media.id) await this.sequelize.models.bookAuthor.removeByIds(existingAuthor.id, libraryItemExpanded.media.id)
hasUpdates = true hasUpdates = true
} }
} }
@ -305,7 +313,7 @@ module.exports = (sequelize) => {
// Author was added // Author was added
if (!existingAuthors.some(au => au.id === updatedAuthor.id)) { if (!existingAuthors.some(au => au.id === updatedAuthor.id)) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${updatedAuthor.name}" was added`) Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${updatedAuthor.name}" was added`)
await sequelize.models.bookAuthor.create({ authorId: updatedAuthor.id, bookId: libraryItemExpanded.media.id }) await this.sequelize.models.bookAuthor.create({ authorId: updatedAuthor.id, bookId: libraryItemExpanded.media.id })
hasUpdates = true hasUpdates = true
} }
} }
@ -313,7 +321,7 @@ module.exports = (sequelize) => {
// Series was removed // Series was removed
if (!updatedSeriesAll.some(se => se.id === existingSeries.id)) { if (!updatedSeriesAll.some(se => se.id === existingSeries.id)) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${existingSeries.name}" was removed`) Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${existingSeries.name}" was removed`)
await sequelize.models.bookSeries.removeByIds(existingSeries.id, libraryItemExpanded.media.id) await this.sequelize.models.bookSeries.removeByIds(existingSeries.id, libraryItemExpanded.media.id)
hasUpdates = true hasUpdates = true
} }
} }
@ -322,7 +330,7 @@ module.exports = (sequelize) => {
const existingSeriesMatch = existingSeriesAll.find(se => se.id === updatedSeries.id) const existingSeriesMatch = existingSeriesAll.find(se => se.id === updatedSeries.id)
if (!existingSeriesMatch) { if (!existingSeriesMatch) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" was added`) Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" was added`)
await sequelize.models.bookSeries.create({ seriesId: updatedSeries.id, bookId: libraryItemExpanded.media.id, sequence: updatedSeries.sequence }) await this.sequelize.models.bookSeries.create({ seriesId: updatedSeries.id, bookId: libraryItemExpanded.media.id, sequence: updatedSeries.sequence })
hasUpdates = true hasUpdates = true
} else if (existingSeriesMatch.bookSeries.sequence !== updatedSeries.sequence) { } else if (existingSeriesMatch.bookSeries.sequence !== updatedSeries.sequence) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" sequence was updated from "${existingSeriesMatch.bookSeries.sequence}" to "${updatedSeries.sequence}"`) Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" sequence was updated from "${existingSeriesMatch.bookSeries.sequence}" to "${updatedSeries.sequence}"`)
@ -414,16 +422,16 @@ module.exports = (sequelize) => {
const libraryItem = await this.findByPk(libraryItemId, { const libraryItem = await this.findByPk(libraryItemId, {
include: [ include: [
{ {
model: sequelize.models.book, model: this.sequelize.models.book,
include: [ include: [
{ {
model: sequelize.models.author, model: this.sequelize.models.author,
through: { through: {
attributes: [] attributes: []
} }
}, },
{ {
model: sequelize.models.series, model: this.sequelize.models.series,
through: { through: {
attributes: ['sequence'] attributes: ['sequence']
} }
@ -431,13 +439,17 @@ module.exports = (sequelize) => {
] ]
}, },
{ {
model: sequelize.models.podcast, model: this.sequelize.models.podcast,
include: [ include: [
{ {
model: sequelize.models.podcastEpisode model: this.sequelize.models.podcastEpisode
} }
] ]
} }
],
order: [
[this.sequelize.models.book, this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'],
[this.sequelize.models.book, this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC']
] ]
}) })
if (!libraryItem) return null if (!libraryItem) return null
@ -466,7 +478,7 @@ module.exports = (sequelize) => {
oldLibraryItem.media.metadata.series = li.series oldLibraryItem.media.metadata.series = li.series
} }
if (li.rssFeed) { if (li.rssFeed) {
oldLibraryItem.rssFeed = sequelize.models.feed.getOldFeed(li.rssFeed).toJSONMinified() oldLibraryItem.rssFeed = this.sequelize.models.feed.getOldFeed(li.rssFeed).toJSONMinified()
} }
if (li.media.numEpisodes) { if (li.media.numEpisodes) {
oldLibraryItem.media.numEpisodes = li.media.numEpisodes oldLibraryItem.media.numEpisodes = li.media.numEpisodes
@ -678,14 +690,76 @@ module.exports = (sequelize) => {
return libraryItems.map(li => this.getOldLibraryItem(li)) return libraryItems.map(li => this.getOldLibraryItem(li))
} }
getMedia(options) { /**
if (!this.mediaType) return Promise.resolve(null) * Check if library item exists
const mixinMethodName = `get${sequelize.uppercaseFirst(this.mediaType)}` * @param {string} libraryItemId
return this[mixinMethodName](options) * @returns {Promise<boolean>}
} */
static async checkExistsById(libraryItemId) {
return (await this.count({ where: { id: libraryItemId } })) > 0
} }
LibraryItem.init({ /**
*
* @param {WhereOptions} where
* @returns {Object} oldLibraryItem
*/
static async findOneOld(where) {
const libraryItem = await this.findOne({
where,
include: [
{
model: this.sequelize.models.book,
include: [
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
}
]
},
{
model: this.sequelize.models.podcast,
include: [
{
model: this.sequelize.models.podcastEpisode
}
]
}
],
order: [
[this.sequelize.models.book, this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'],
[this.sequelize.models.book, this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC']
]
})
if (!libraryItem) return null
return this.getOldLibraryItem(libraryItem)
}
/**
*
* @param {import('sequelize').FindOptions} options
* @returns {Promise<Book|Podcast>}
*/
getMedia(options) {
if (!this.mediaType) return Promise.resolve(null)
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaType)}`
return this[mixinMethodName](options)
}
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init({
id: { id: {
type: DataTypes.UUID, type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4, defaultValue: DataTypes.UUIDV4,
@ -781,6 +855,7 @@ module.exports = (sequelize) => {
media.destroy() media.destroy()
} }
}) })
return LibraryItem
} }
}
module.exports = LibraryItem

View File

@ -1,11 +1,39 @@
const { DataTypes, Model } = require('sequelize') 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 { class MediaProgress extends Model {
constructor(values, options) {
super(values, options)
/** @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
}
getOldMediaProgress() { getOldMediaProgress() {
const isPodcastEpisode = this.mediaItemType === 'podcastEpisode' const isPodcastEpisode = this.mediaItemType === 'podcastEpisode'
@ -66,13 +94,20 @@ module.exports = (sequelize) => {
getMediaItem(options) { getMediaItem(options) {
if (!this.mediaItemType) return Promise.resolve(null) if (!this.mediaItemType) return Promise.resolve(null)
const mixinMethodName = `get${sequelize.uppercaseFirst(this.mediaItemType)}` const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaItemType)}`
return this[mixinMethodName](options) return this[mixinMethodName](options)
} }
}
/**
MediaProgress.init({ * 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: { id: {
type: DataTypes.UUID, type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4, defaultValue: DataTypes.UUIDV4,
@ -143,6 +178,7 @@ module.exports = (sequelize) => {
onDelete: 'CASCADE' onDelete: 'CASCADE'
}) })
MediaProgress.belongsTo(user) MediaProgress.belongsTo(user)
return MediaProgress
} }
}
module.exports = MediaProgress

View File

@ -2,14 +2,63 @@ const { DataTypes, Model } = require('sequelize')
const oldPlaybackSession = require('../objects/PlaybackSession') const oldPlaybackSession = require('../objects/PlaybackSession')
module.exports = (sequelize) => {
class PlaybackSession extends Model { class PlaybackSession extends Model {
constructor(values, options) {
super(values, options)
/** @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
}
static async getOldPlaybackSessions(where = null) { static async getOldPlaybackSessions(where = null) {
const playbackSessions = await this.findAll({ const playbackSessions = await this.findAll({
where, where,
include: [ include: [
{ {
model: sequelize.models.device model: this.sequelize.models.device
} }
] ]
}) })
@ -20,7 +69,7 @@ module.exports = (sequelize) => {
const playbackSession = await this.findByPk(sessionId, { const playbackSession = await this.findByPk(sessionId, {
include: [ include: [
{ {
model: sequelize.models.device model: this.sequelize.models.device
} }
] ]
}) })
@ -112,12 +161,16 @@ module.exports = (sequelize) => {
getMediaItem(options) { getMediaItem(options) {
if (!this.mediaItemType) return Promise.resolve(null) if (!this.mediaItemType) return Promise.resolve(null)
const mixinMethodName = `get${sequelize.uppercaseFirst(this.mediaItemType)}` const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaItemType)}`
return this[mixinMethodName](options) return this[mixinMethodName](options)
} }
}
PlaybackSession.init({ /**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init({
id: { id: {
type: DataTypes.UUID, type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4, defaultValue: DataTypes.UUIDV4,
@ -193,6 +246,7 @@ module.exports = (sequelize) => {
delete instance.dataValues.podcastEpisode delete instance.dataValues.podcastEpisode
} }
}) })
return PlaybackSession
} }
}
module.exports = PlaybackSession

View File

@ -1,25 +1,42 @@
const { DataTypes, Model, Op } = require('sequelize') const { DataTypes, Model, Op, literal } = require('sequelize')
const Logger = require('../Logger') const Logger = require('../Logger')
const oldPlaylist = require('../objects/Playlist') const oldPlaylist = require('../objects/Playlist')
const { areEquivalent } = require('../utils/index')
module.exports = (sequelize) => {
class Playlist extends Model { class Playlist extends Model {
constructor(values, options) {
super(values, options)
/** @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
}
static async getOldPlaylists() { static async getOldPlaylists() {
const playlists = await this.findAll({ const playlists = await this.findAll({
include: { include: {
model: sequelize.models.playlistMediaItem, model: this.sequelize.models.playlistMediaItem,
include: [ include: [
{ {
model: sequelize.models.book, model: this.sequelize.models.book,
include: sequelize.models.libraryItem include: this.sequelize.models.libraryItem
}, },
{ {
model: sequelize.models.podcastEpisode, model: this.sequelize.models.podcastEpisode,
include: { include: {
model: sequelize.models.podcast, model: this.sequelize.models.podcast,
include: sequelize.models.libraryItem include: this.sequelize.models.libraryItem
} }
} }
] ]
@ -54,54 +71,53 @@ module.exports = (sequelize) => {
}) })
} }
/**
* 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) { static createFromOld(oldPlaylist) {
const playlist = this.getFromOld(oldPlaylist) const playlist = this.getFromOld(oldPlaylist)
return this.create(playlist) 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) { static getFromOld(oldPlaylist) {
return { return {
id: oldPlaylist.id, id: oldPlaylist.id,
@ -129,17 +145,17 @@ module.exports = (sequelize) => {
if (!playlistId) return null if (!playlistId) return null
const playlist = await this.findByPk(playlistId, { const playlist = await this.findByPk(playlistId, {
include: { include: {
model: sequelize.models.playlistMediaItem, model: this.sequelize.models.playlistMediaItem,
include: [ include: [
{ {
model: sequelize.models.book, model: this.sequelize.models.book,
include: sequelize.models.libraryItem include: this.sequelize.models.libraryItem
}, },
{ {
model: sequelize.models.podcastEpisode, model: this.sequelize.models.podcastEpisode,
include: { include: {
model: sequelize.models.podcast, model: this.sequelize.models.podcast,
include: sequelize.models.libraryItem include: this.sequelize.models.libraryItem
} }
} }
] ]
@ -154,7 +170,7 @@ module.exports = (sequelize) => {
* Get playlists for user and optionally for library * Get playlists for user and optionally for library
* @param {string} userId * @param {string} userId
* @param {[string]} libraryId optional * @param {[string]} libraryId optional
* @returns {Promise<oldPlaylist[]>} * @returns {Promise<Playlist[]>}
*/ */
static async getPlaylistsForUserAndLibrary(userId, libraryId = null) { static async getPlaylistsForUserAndLibrary(userId, libraryId = null) {
if (!userId && !libraryId) return [] if (!userId && !libraryId) return []
@ -168,24 +184,27 @@ module.exports = (sequelize) => {
const playlists = await this.findAll({ const playlists = await this.findAll({
where: whereQuery, where: whereQuery,
include: { include: {
model: sequelize.models.playlistMediaItem, model: this.sequelize.models.playlistMediaItem,
include: [ include: [
{ {
model: sequelize.models.book, model: this.sequelize.models.book,
include: sequelize.models.libraryItem include: this.sequelize.models.libraryItem
}, },
{ {
model: sequelize.models.podcastEpisode, model: this.sequelize.models.podcastEpisode,
include: { include: {
model: sequelize.models.podcast, model: this.sequelize.models.podcast,
include: sequelize.models.libraryItem include: this.sequelize.models.libraryItem
} }
} }
] ]
}, },
order: [['playlistMediaItems', 'order', 'ASC']] order: [
[literal('name COLLATE NOCASE'), 'ASC'],
['playlistMediaItems', 'order', 'ASC']
]
}) })
return playlists.map(p => this.getOldPlaylist(p)) return playlists
} }
/** /**
@ -206,12 +225,12 @@ module.exports = (sequelize) => {
/** /**
* Get all playlists for mediaItemIds * Get all playlists for mediaItemIds
* @param {string[]} mediaItemIds * @param {string[]} mediaItemIds
* @returns {Promise<oldPlaylist[]>} * @returns {Promise<Playlist[]>}
*/ */
static async getPlaylistsForMediaItemIds(mediaItemIds) { static async getPlaylistsForMediaItemIds(mediaItemIds) {
if (!mediaItemIds?.length) return [] if (!mediaItemIds?.length) return []
const playlistMediaItemsExpanded = await sequelize.models.playlistMediaItem.findAll({ const playlistMediaItemsExpanded = await this.sequelize.models.playlistMediaItem.findAll({
where: { where: {
mediaItemId: { mediaItemId: {
[Op.in]: mediaItemIds [Op.in]: mediaItemIds
@ -219,19 +238,19 @@ module.exports = (sequelize) => {
}, },
include: [ include: [
{ {
model: sequelize.models.playlist, model: this.sequelize.models.playlist,
include: { include: {
model: sequelize.models.playlistMediaItem, model: this.sequelize.models.playlistMediaItem,
include: [ include: [
{ {
model: sequelize.models.book, model: this.sequelize.models.book,
include: sequelize.models.libraryItem include: this.sequelize.models.libraryItem
}, },
{ {
model: sequelize.models.podcastEpisode, model: this.sequelize.models.podcastEpisode,
include: { include: {
model: sequelize.models.podcast, model: this.sequelize.models.podcast,
include: sequelize.models.libraryItem include: this.sequelize.models.libraryItem
} }
} }
] ]
@ -240,8 +259,13 @@ module.exports = (sequelize) => {
], ],
order: [['playlist', 'playlistMediaItems', 'order', 'ASC']] order: [['playlist', 'playlistMediaItems', 'order', 'ASC']]
}) })
return playlistMediaItemsExpanded.map(pmie => {
pmie.playlist.playlistMediaItems = pmie.playlist.playlistMediaItems.map(pmi => { 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) { if (pmi.mediaItemType === 'book' && pmi.book !== undefined) {
pmi.mediaItem = pmi.book pmi.mediaItem = pmi.book
pmi.dataValues.mediaItem = pmi.dataValues.book pmi.dataValues.mediaItem = pmi.dataValues.book
@ -255,13 +279,17 @@ module.exports = (sequelize) => {
delete pmi.dataValues.podcastEpisode delete pmi.dataValues.podcastEpisode
return pmi return pmi
}) })
playlists.push(playlist)
return this.getOldPlaylist(pmie.playlist)
})
} }
return playlists
} }
Playlist.init({ /**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init({
id: { id: {
type: DataTypes.UUID, type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4, defaultValue: DataTypes.UUIDV4,
@ -278,7 +306,9 @@ module.exports = (sequelize) => {
library.hasMany(Playlist) library.hasMany(Playlist)
Playlist.belongsTo(library) Playlist.belongsTo(library)
user.hasMany(Playlist) user.hasMany(Playlist, {
onDelete: 'CASCADE'
})
Playlist.belongsTo(user) Playlist.belongsTo(user)
Playlist.addHook('afterFind', findResult => { Playlist.addHook('afterFind', findResult => {
@ -307,6 +337,7 @@ module.exports = (sequelize) => {
} }
}) })
return Playlist
} }
}
module.exports = Playlist

View File

@ -1,7 +1,23 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model } = require('sequelize')
module.exports = (sequelize) => {
class PlaylistMediaItem extends Model { class PlaylistMediaItem extends Model {
constructor(values, options) {
super(values, 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
}
static removeByIds(playlistId, mediaItemId) { static removeByIds(playlistId, mediaItemId) {
return this.destroy({ return this.destroy({
where: { where: {
@ -13,12 +29,16 @@ module.exports = (sequelize) => {
getMediaItem(options) { getMediaItem(options) {
if (!this.mediaItemType) return Promise.resolve(null) if (!this.mediaItemType) return Promise.resolve(null)
const mixinMethodName = `get${sequelize.uppercaseFirst(this.mediaItemType)}` const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaItemType)}`
return this[mixinMethodName](options) return this[mixinMethodName](options)
} }
}
PlaylistMediaItem.init({ /**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init({
id: { id: {
type: DataTypes.UUID, type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4, defaultValue: DataTypes.UUIDV4,
@ -79,6 +99,7 @@ module.exports = (sequelize) => {
onDelete: 'CASCADE' onDelete: 'CASCADE'
}) })
PlaylistMediaItem.belongsTo(playlist) PlaylistMediaItem.belongsTo(playlist)
return PlaylistMediaItem
} }
}
module.exports = PlaylistMediaItem

View File

@ -1,10 +1,60 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model } = require('sequelize')
module.exports = (sequelize) => {
class Podcast extends Model { class Podcast extends Model {
constructor(values, options) {
super(values, options)
/** @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) { static getOldPodcast(libraryItemExpanded) {
const podcastExpanded = libraryItemExpanded.media const podcastExpanded = libraryItemExpanded.media
const podcastEpisodes = podcastExpanded.podcastEpisodes?.map(ep => ep.getOldPodcastEpisode(libraryItemExpanded.id)).sort((a, b) => a.index - b.index) const podcastEpisodes = podcastExpanded.podcastEpisodes?.map(ep => ep.getOldPodcastEpisode(libraryItemExpanded.id).toJSON()).sort((a, b) => a.index - b.index)
return { return {
id: podcastExpanded.id, id: podcastExpanded.id,
libraryItemId: libraryItemExpanded.id, libraryItemId: libraryItemExpanded.id,
@ -61,9 +111,13 @@ module.exports = (sequelize) => {
genres: oldPodcastMetadata.genres genres: oldPodcastMetadata.genres
} }
} }
}
Podcast.init({ /**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init({
id: { id: {
type: DataTypes.UUID, type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4, defaultValue: DataTypes.UUIDV4,
@ -95,6 +149,7 @@ module.exports = (sequelize) => {
sequelize, sequelize,
modelName: 'podcast' modelName: 'podcast'
}) })
return Podcast
} }
}
module.exports = Podcast

View File

@ -1,7 +1,62 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model } = require('sequelize')
const oldPodcastEpisode = require('../objects/entities/PodcastEpisode')
/**
* @typedef ChapterObject
* @property {number} id
* @property {number} start
* @property {number} end
* @property {string} title
*/
module.exports = (sequelize) => {
class PodcastEpisode extends Model { 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) { getOldPodcastEpisode(libraryItemId = null) {
let enclosure = null let enclosure = null
if (this.enclosureURL) { if (this.enclosureURL) {
@ -11,7 +66,7 @@ module.exports = (sequelize) => {
length: this.enclosureSize !== null ? String(this.enclosureSize) : null length: this.enclosureSize !== null ? String(this.enclosureSize) : null
} }
} }
return { return new oldPodcastEpisode({
libraryItemId: libraryItemId || null, libraryItemId: libraryItemId || null,
podcastId: this.podcastId, podcastId: this.podcastId,
id: this.id, id: this.id,
@ -30,7 +85,7 @@ module.exports = (sequelize) => {
publishedAt: this.publishedAt?.valueOf() || null, publishedAt: this.publishedAt?.valueOf() || null,
addedAt: this.createdAt.valueOf(), addedAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf() updatedAt: this.updatedAt.valueOf()
} })
} }
static createFromOld(oldEpisode) { static createFromOld(oldEpisode) {
@ -63,9 +118,13 @@ module.exports = (sequelize) => {
extraData extraData
} }
} }
}
PodcastEpisode.init({ /**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init({
id: { id: {
type: DataTypes.UUID, type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4, defaultValue: DataTypes.UUIDV4,
@ -97,6 +156,7 @@ module.exports = (sequelize) => {
onDelete: 'CASCADE' onDelete: 'CASCADE'
}) })
PodcastEpisode.belongsTo(podcast) PodcastEpisode.belongsTo(podcast)
return PodcastEpisode
} }
}
module.exports = PodcastEpisode

View File

@ -1,9 +1,27 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model, literal } = require('sequelize')
const oldSeries = require('../objects/entities/Series') const oldSeries = require('../objects/entities/Series')
module.exports = (sequelize) => {
class Series extends Model { class Series extends Model {
constructor(values, options) {
super(values, options)
/** @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 async getAllOldSeries() { static async getAllOldSeries() {
const series = await this.findAll() const series = await this.findAll()
return series.map(se => se.getOldSeries()) return series.map(se => se.getOldSeries())
@ -56,9 +74,52 @@ module.exports = (sequelize) => {
} }
}) })
} }
/**
* 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()
} }
Series.init({ /**
* 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: { id: {
type: DataTypes.UUID, type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4, defaultValue: DataTypes.UUIDV4,
@ -69,7 +130,24 @@ module.exports = (sequelize) => {
description: DataTypes.TEXT description: DataTypes.TEXT
}, { }, {
sequelize, sequelize,
modelName: 'series' modelName: 'series',
indexes: [
{
fields: [{
name: 'name',
collate: 'NOCASE'
}]
},
// {
// fields: [{
// name: 'nameIgnorePrefix',
// collate: 'NOCASE'
// }]
// },
{
fields: ['libraryId']
}
]
}) })
const { library } = sequelize.models const { library } = sequelize.models
@ -77,6 +155,7 @@ module.exports = (sequelize) => {
onDelete: 'CASCADE' onDelete: 'CASCADE'
}) })
Series.belongsTo(library) Series.belongsTo(library)
return Series
} }
}
module.exports = Series

View File

@ -4,8 +4,20 @@ const oldEmailSettings = require('../objects/settings/EmailSettings')
const oldServerSettings = require('../objects/settings/ServerSettings') const oldServerSettings = require('../objects/settings/ServerSettings')
const oldNotificationSettings = require('../objects/settings/NotificationSettings') const oldNotificationSettings = require('../objects/settings/NotificationSettings')
module.exports = (sequelize) => {
class Setting extends Model { class Setting extends Model {
constructor(values, options) {
super(values, options)
/** @type {string} */
this.key
/** @type {Object} */
this.value
/** @type {Date} */
this.createdAt
/** @type {Date} */
this.updatedAt
}
static async getOldSettings() { static async getOldSettings() {
const settings = (await this.findAll()).map(se => se.value) const settings = (await this.findAll()).map(se => se.value)
@ -28,9 +40,13 @@ module.exports = (sequelize) => {
value: setting value: setting
}) })
} }
}
Setting.init({ /**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init({
key: { key: {
type: DataTypes.STRING, type: DataTypes.STRING,
primaryKey: true primaryKey: true
@ -40,6 +56,7 @@ module.exports = (sequelize) => {
sequelize, sequelize,
modelName: 'setting' modelName: 'setting'
}) })
return Setting
} }
}
module.exports = Setting

View File

@ -3,15 +3,45 @@ const { DataTypes, Model, Op } = require('sequelize')
const Logger = require('../Logger') const Logger = require('../Logger')
const oldUser = require('../objects/user/User') const oldUser = require('../objects/user/User')
module.exports = (sequelize) => {
class User extends Model { class User extends Model {
constructor(values, options) {
super(values, options)
/** @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
}
/** /**
* Get all oldUsers * Get all oldUsers
* @returns {Promise<oldUser>} * @returns {Promise<oldUser>}
*/ */
static async getOldUsers() { static async getOldUsers() {
const users = await this.findAll({ const users = await this.findAll({
include: sequelize.models.mediaProgress include: this.sequelize.models.mediaProgress
}) })
return users.map(u => this.getOldUser(u)) return users.map(u => this.getOldUser(u))
} }
@ -139,7 +169,7 @@ module.exports = (sequelize) => {
} }
] ]
}, },
include: sequelize.models.mediaProgress include: this.sequelize.models.mediaProgress
}) })
if (!user) return null if (!user) return null
return this.getOldUser(user) return this.getOldUser(user)
@ -158,7 +188,7 @@ module.exports = (sequelize) => {
[Op.like]: username [Op.like]: username
} }
}, },
include: sequelize.models.mediaProgress include: this.sequelize.models.mediaProgress
}) })
if (!user) return null if (!user) return null
return this.getOldUser(user) return this.getOldUser(user)
@ -172,7 +202,7 @@ module.exports = (sequelize) => {
static async getUserById(userId) { static async getUserById(userId) {
if (!userId) return null if (!userId) return null
const user = await this.findByPk(userId, { const user = await this.findByPk(userId, {
include: sequelize.models.mediaProgress include: this.sequelize.models.mediaProgress
}) })
if (!user) return null if (!user) return null
return this.getOldUser(user) return this.getOldUser(user)
@ -206,9 +236,13 @@ module.exports = (sequelize) => {
}) })
return count > 0 return count > 0
} }
}
User.init({ /**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init({
id: { id: {
type: DataTypes.UUID, type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4, defaultValue: DataTypes.UUIDV4,
@ -235,6 +269,7 @@ module.exports = (sequelize) => {
sequelize, sequelize,
modelName: 'user' modelName: 'user'
}) })
return User
} }
}
module.exports = User

View File

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

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