Merge branch 'master' into shawn/rss-feeds

This commit is contained in:
Shawn Hoffman 2023-08-22 10:30:16 -07:00 committed by GitHub
commit 24989e73ae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 1575 additions and 1193 deletions

View File

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

View File

@ -628,6 +628,11 @@ export default {
return entitiesPerShelfBefore < this.entitiesPerShelf // Books per shelf has changed
},
async init(bookshelf) {
if (this.entityName === 'series') {
this.booksPerFetch = 50
} else {
this.booksPerFetch = 100
}
this.checkUpdateSearchParams()
this.initSizeData(bookshelf)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -32,7 +32,7 @@ class Auth {
await Database.updateServerSettings()
// New token secret creation added in v2.1.0 so generate new API tokens for each user
const users = await Database.models.user.getOldUsers()
const users = await Database.userModel.getOldUsers()
if (users.length) {
for (const user of users) {
user.token = await this.generateAccessToken({ userId: user.id, username: user.username })
@ -100,7 +100,7 @@ class Auth {
return resolve(null)
}
const user = await Database.models.user.getUserByIdOrOldId(payload.userId)
const user = await Database.userModel.getUserByIdOrOldId(payload.userId)
if (user && user.username === payload.username) {
resolve(user)
} else {
@ -116,7 +116,7 @@ class Auth {
* @returns {object}
*/
async getUserLoginResponsePayload(user) {
const libraryIds = await Database.models.library.getAllLibraryIds()
const libraryIds = await Database.libraryModel.getAllLibraryIds()
return {
user: user.toJSONForBrowser(),
userDefaultLibraryId: user.getDefaultLibraryId(libraryIds),
@ -131,7 +131,7 @@ class Auth {
const username = (req.body.username || '').toLowerCase()
const password = req.body.password || ''
const user = await Database.models.user.getUserByUsername(username)
const user = await Database.userModel.getUserByUsername(username)
if (!user?.isActive) {
Logger.warn(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit} from ${ipAddress}`)
@ -178,7 +178,7 @@ class Auth {
async userChangePassword(req, res) {
var { password, newPassword } = req.body
newPassword = newPassword || ''
const matchingUser = await Database.models.user.getUserById(req.user.id)
const matchingUser = await Database.userModel.getUserById(req.user.id)
// Only root can have an empty password
if (matchingUser.type !== 'root' && !newPassword) {

View File

@ -34,6 +34,100 @@ class Database {
return this.sequelize?.models || {}
}
/** @type {typeof import('./models/User')} */
get userModel() {
return this.models.user
}
/** @type {typeof import('./models/Library')} */
get libraryModel() {
return this.models.library
}
/** @type {typeof import('./models/Author')} */
get authorModel() {
return this.models.author
}
/** @type {typeof import('./models/Series')} */
get seriesModel() {
return this.models.series
}
/** @type {typeof import('./models/Book')} */
get bookModel() {
return this.models.book
}
/** @type {typeof import('./models/BookSeries')} */
get bookSeriesModel() {
return this.models.bookSeries
}
/** @type {typeof import('./models/BookAuthor')} */
get bookAuthorModel() {
return this.models.bookAuthor
}
/** @type {typeof import('./models/Podcast')} */
get podcastModel() {
return this.models.podcast
}
/** @type {typeof import('./models/PodcastEpisode')} */
get podcastEpisodeModel() {
return this.models.podcastEpisode
}
/** @type {typeof import('./models/LibraryItem')} */
get libraryItemModel() {
return this.models.libraryItem
}
/** @type {typeof import('./models/PodcastEpisode')} */
get podcastEpisodeModel() {
return this.models.podcastEpisode
}
/** @type {typeof import('./models/MediaProgress')} */
get mediaProgressModel() {
return this.models.mediaProgress
}
/** @type {typeof import('./models/Collection')} */
get collectionModel() {
return this.models.collection
}
/** @type {typeof import('./models/CollectionBook')} */
get collectionBookModel() {
return this.models.collectionBook
}
/** @type {typeof import('./models/Playlist')} */
get playlistModel() {
return this.models.playlist
}
/** @type {typeof import('./models/PlaylistMediaItem')} */
get playlistMediaItemModel() {
return this.models.playlistMediaItem
}
/** @type {typeof import('./models/Feed')} */
get feedModel() {
return this.models.feed
}
/** @type {typeof import('./models/Feed')} */
get feedEpisodeModel() {
return this.models.feedEpisode
}
/**
* Check if db file exists
* @returns {boolean}
*/
async checkHasDb() {
if (!await fs.pathExists(this.dbPath)) {
Logger.info(`[Database] absdatabase.sqlite not found at ${this.dbPath}`)
@ -42,6 +136,10 @@ class Database {
return true
}
/**
* Connect to db, build models and run migrations
* @param {boolean} [force=false] Used for testing, drops & re-creates all tables
*/
async init(force = false) {
this.dbPath = Path.join(global.ConfigPath, 'absdatabase.sqlite')
@ -58,6 +156,10 @@ class Database {
await this.loadData()
}
/**
* Connect to db
* @returns {boolean}
*/
async connect() {
Logger.info(`[Database] Initializing db at "${this.dbPath}"`)
this.sequelize = new Sequelize({
@ -80,12 +182,18 @@ class Database {
}
}
/**
* Disconnect from db
*/
async disconnect() {
Logger.info(`[Database] Disconnecting sqlite db`)
await this.sequelize.close()
this.sequelize = null
}
/**
* Reconnect to db and init
*/
async reconnect() {
Logger.info(`[Database] Reconnecting sqlite db`)
await this.init()
@ -481,6 +589,88 @@ class Database {
}
}
}
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
})
}
/**
* 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
}
]
}
})
}
}
module.exports = new Database()

View File

@ -116,7 +116,7 @@ class Server {
await this.logManager.init()
await this.rssFeedManager.init()
const libraries = await Database.models.library.getAllOldLibraries()
const libraries = await Database.libraryModel.getAllOldLibraries()
await this.cronManager.init(libraries)
if (Database.serverSettings.scannerDisableWatcher) {
@ -253,7 +253,7 @@ class Server {
*/
async cleanUserData() {
// Get all media progress without an associated media item
const mediaProgressToRemove = await Database.models.mediaProgress.findAll({
const mediaProgressToRemove = await Database.mediaProgressModel.findAll({
where: {
'$podcastEpisode.id$': null,
'$book.id$': null
@ -261,18 +261,18 @@ class Server {
attributes: ['id'],
include: [
{
model: Database.models.book,
model: Database.bookModel,
attributes: ['id']
},
{
model: Database.models.podcastEpisode,
model: Database.podcastEpisodeModel,
attributes: ['id']
}
]
})
if (mediaProgressToRemove.length) {
// Remove media progress
const mediaProgressRemoved = await Database.models.mediaProgress.destroy({
const mediaProgressRemoved = await Database.mediaProgressModel.destroy({
where: {
id: {
[Sequelize.Op.in]: mediaProgressToRemove.map(mp => mp.id)
@ -285,7 +285,7 @@ class Server {
}
// Remove series from hide from continue listening that no longer exist
const users = await Database.models.user.getOldUsers()
const users = await Database.userModel.getOldUsers()
for (const _user of users) {
let hasUpdated = false
if (_user.seriesHideFromContinueListening.length) {

View File

@ -21,7 +21,7 @@ class AuthorController {
// Used on author landing page to include library items and items grouped in series
if (include.includes('items')) {
authorJson.libraryItems = await Database.models.libraryItem.getForAuthor(req.author, req.user)
authorJson.libraryItems = await Database.libraryItemModel.getForAuthor(req.author, req.user)
if (include.includes('series')) {
const seriesMap = {}
@ -96,7 +96,7 @@ class AuthorController {
const existingAuthor = authorNameUpdate ? Database.authors.find(au => au.id !== req.author.id && payload.name === au.name) : false
if (existingAuthor) {
const bookAuthorsToCreate = []
const itemsWithAuthor = await Database.models.libraryItem.getForAuthor(req.author)
const itemsWithAuthor = await Database.libraryItemModel.getForAuthor(req.author)
itemsWithAuthor.forEach(libraryItem => { // Replace old author with merging author for each book
libraryItem.media.metadata.replaceAuthor(req.author, existingAuthor)
bookAuthorsToCreate.push({
@ -113,9 +113,11 @@ class AuthorController {
// Remove old author
await Database.removeAuthor(req.author.id)
SocketAuthority.emitter('author_removed', req.author.toJSON())
// Update filter data
Database.removeAuthorFromFilterData(req.author.libraryId, req.author.id)
// Send updated num books for merged author
const numBooks = await Database.models.libraryItem.getForAuthor(existingAuthor).length
const numBooks = await Database.libraryItemModel.getForAuthor(existingAuthor).length
SocketAuthority.emitter('author_updated', existingAuthor.toJSONExpanded(numBooks))
res.json({
@ -130,7 +132,7 @@ class AuthorController {
if (hasUpdated) {
req.author.updatedAt = Date.now()
const itemsWithAuthor = await Database.models.libraryItem.getForAuthor(req.author)
const itemsWithAuthor = await Database.libraryItemModel.getForAuthor(req.author)
if (authorNameUpdate) { // Update author name on all books
itemsWithAuthor.forEach(libraryItem => {
libraryItem.media.metadata.updateAuthor(req.author)
@ -202,7 +204,7 @@ class AuthorController {
await Database.updateAuthor(req.author)
const numBooks = await Database.models.libraryItem.getForAuthor(req.author).length
const numBooks = await Database.libraryItemModel.getForAuthor(req.author).length
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))
}

View File

@ -22,10 +22,10 @@ class CollectionController {
}
// Create collection record
await Database.models.collection.createFromOld(newCollection)
await Database.collectionModel.createFromOld(newCollection)
// Get library items in collection
const libraryItemsInCollection = await Database.models.libraryItem.getForCollection(newCollection)
const libraryItemsInCollection = await Database.libraryItemModel.getForCollection(newCollection)
// Create collectionBook records
let order = 1
@ -50,7 +50,7 @@ class CollectionController {
}
async findAll(req, res) {
const collectionsExpanded = await Database.models.collection.getOldCollectionsJsonExpanded(req.user)
const collectionsExpanded = await Database.collectionModel.getOldCollectionsJsonExpanded(req.user)
res.json({
collections: collectionsExpanded
})
@ -96,8 +96,8 @@ class CollectionController {
if (req.body.books?.length) {
const collectionBooks = await req.collection.getCollectionBooks({
include: {
model: Database.models.book,
include: Database.models.libraryItem
model: Database.bookModel,
include: Database.libraryItemModel
},
order: [['order', 'ASC']]
})
@ -143,7 +143,7 @@ class CollectionController {
* @param {*} res
*/
async addBook(req, res) {
const libraryItem = await Database.models.libraryItem.getOldById(req.body.id)
const libraryItem = await Database.libraryItemModel.getOldById(req.body.id)
if (!libraryItem) {
return res.status(404).send('Book not found')
}
@ -158,7 +158,7 @@ class CollectionController {
}
// Create collectionBook record
await Database.models.collectionBook.create({
await Database.collectionBookModel.create({
collectionId: req.collection.id,
bookId: libraryItem.media.id,
order: collectionBooks.length + 1
@ -176,7 +176,7 @@ class CollectionController {
* @param {*} res
*/
async removeBook(req, res) {
const libraryItem = await Database.models.libraryItem.getOldById(req.params.bookId)
const libraryItem = await Database.libraryItemModel.getOldById(req.params.bookId)
if (!libraryItem) {
return res.sendStatus(404)
}
@ -227,14 +227,14 @@ class CollectionController {
}
// Get library items associated with ids
const libraryItems = await Database.models.libraryItem.findAll({
const libraryItems = await Database.libraryItemModel.findAll({
where: {
id: {
[Sequelize.Op.in]: bookIdsToAdd
}
},
include: {
model: Database.models.book
model: Database.bookModel
}
})
@ -285,14 +285,14 @@ class CollectionController {
}
// Get library items associated with ids
const libraryItems = await Database.models.libraryItem.findAll({
const libraryItems = await Database.libraryItemModel.findAll({
where: {
id: {
[Sequelize.Op.in]: bookIdsToRemove
}
},
include: {
model: Database.models.book
model: Database.bookModel
}
})
@ -327,7 +327,7 @@ class CollectionController {
async middleware(req, res, next) {
if (req.params.id) {
const collection = await Database.models.collection.findByPk(req.params.id)
const collection = await Database.collectionModel.findByPk(req.params.id)
if (!collection) {
return res.status(404).send('Collection not found')
}

View File

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

View File

@ -8,6 +8,7 @@ const Library = require('../objects/Library')
const libraryHelpers = require('../utils/libraryHelpers')
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
const libraryItemFilters = require('../utils/queries/libraryItemFilters')
const seriesFilters = require('../utils/queries/seriesFilters')
const { sort, createNewSortInstance } = require('../libs/fastSort')
const naturalSort = createNewSortInstance({
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
@ -15,6 +16,8 @@ const naturalSort = createNewSortInstance({
const Database = require('../Database')
const libraryFilters = require('../utils/queries/libraryFilters')
const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')
const authorFilters = require('../utils/queries/authorFilters')
class LibraryController {
constructor() { }
@ -48,7 +51,7 @@ class LibraryController {
const library = new Library()
let currentLargestDisplayOrder = await Database.models.library.getMaxDisplayOrder()
let currentLargestDisplayOrder = await Database.libraryModel.getMaxDisplayOrder()
if (isNaN(currentLargestDisplayOrder)) currentLargestDisplayOrder = 0
newLibraryPayload.displayOrder = currentLargestDisplayOrder + 1
library.setData(newLibraryPayload)
@ -67,7 +70,7 @@ class LibraryController {
}
async findAll(req, res) {
const libraries = await Database.models.library.getAllOldLibraries()
const libraries = await Database.libraryModel.getAllOldLibraries()
const librariesAccessible = req.user.librariesAccessible || []
if (librariesAccessible.length) {
@ -89,7 +92,7 @@ class LibraryController {
return res.json({
filterdata,
issues: filterdata.numIssues,
numUserPlaylists: await Database.models.playlist.getNumPlaylistsForUserAndLibrary(req.user.id, req.library.id),
numUserPlaylists: await Database.playlistModel.getNumPlaylistsForUserAndLibrary(req.user.id, req.library.id),
library: req.library
})
}
@ -141,17 +144,17 @@ class LibraryController {
for (const folder of library.folders) {
if (!req.body.folders.some(f => f.id === folder.id)) {
// Remove library items in folder
const libraryItemsInFolder = await Database.models.libraryItem.findAll({
const libraryItemsInFolder = await Database.libraryItemModel.findAll({
where: {
libraryFolderId: folder.id
},
attributes: ['id', 'mediaId', 'mediaType'],
include: [
{
model: Database.models.podcast,
model: Database.podcastModel,
attributes: ['id'],
include: {
model: Database.models.podcastEpisode,
model: Database.podcastEpisodeModel,
attributes: ['id']
}
}
@ -188,6 +191,8 @@ class LibraryController {
return user.checkCanAccessLibrary && user.checkCanAccessLibrary(library.id)
}
SocketAuthority.emitter('library_updated', library.toJSON(), userFilter)
await Database.resetLibraryIssuesFilterData(library.id)
}
return res.json(library.toJSON())
}
@ -205,23 +210,23 @@ class LibraryController {
this.watcher.removeLibrary(library)
// Remove collections for library
const numCollectionsRemoved = await Database.models.collection.removeAllForLibrary(library.id)
const numCollectionsRemoved = await Database.collectionModel.removeAllForLibrary(library.id)
if (numCollectionsRemoved) {
Logger.info(`[Server] Removed ${numCollectionsRemoved} collections for library "${library.name}"`)
}
// Remove items in this library
const libraryItemsInLibrary = await Database.models.libraryItem.findAll({
const libraryItemsInLibrary = await Database.libraryItemModel.findAll({
where: {
libraryId: library.id
},
attributes: ['id', 'mediaId', 'mediaType'],
include: [
{
model: Database.models.podcast,
model: Database.podcastModel,
attributes: ['id'],
include: {
model: Database.models.podcastEpisode,
model: Database.podcastEpisodeModel,
attributes: ['id']
}
}
@ -243,9 +248,15 @@ class LibraryController {
await Database.removeLibrary(library.id)
// Re-order libraries
await Database.models.library.resetDisplayOrder()
await Database.libraryModel.resetDisplayOrder()
SocketAuthority.emitter('library_removed', libraryJson)
// Remove library filter data
if (Database.libraryFilterData[library.id]) {
delete Database.libraryFilterData[library.id]
}
return res.json(libraryJson)
}
@ -267,7 +278,7 @@ class LibraryController {
}
payload.offset = payload.page * payload.limit
const { libraryItems, count } = await Database.models.libraryItem.getByFilterAndSort(req.library, req.user, payload)
const { libraryItems, count } = await Database.libraryItemModel.getByFilterAndSort(req.library, req.user, payload)
payload.results = libraryItems
payload.total = count
@ -471,12 +482,13 @@ class LibraryController {
/**
* DELETE: /libraries/:id/issues
* Remove all library items missing or invalid
* @param {*} req
* @param {*} res
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async removeLibraryItemsWithIssues(req, res) {
const libraryItemsWithIssues = await Database.models.libraryItem.findAll({
const libraryItemsWithIssues = await Database.libraryItemModel.findAll({
where: {
libraryId: req.library.id,
[Sequelize.Op.or]: [
{
isMissing: true
@ -489,10 +501,10 @@ class LibraryController {
attributes: ['id', 'mediaId', 'mediaType'],
include: [
{
model: Database.models.podcast,
model: Database.podcastModel,
attributes: ['id'],
include: {
model: Database.models.podcastEpisode,
model: Database.podcastEpisodeModel,
attributes: ['id']
}
}
@ -507,7 +519,7 @@ class LibraryController {
Logger.info(`[LibraryController] Removing ${libraryItemsWithIssues.length} items with issues`)
for (const libraryItem of libraryItemsWithIssues) {
let mediaItemIds = []
if (library.isPodcast) {
if (req.library.isPodcast) {
mediaItemIds = libraryItem.media.podcastEpisodes.map(pe => pe.id)
} else {
mediaItemIds.push(libraryItem.mediaId)
@ -516,19 +528,22 @@ class LibraryController {
await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds)
}
// Set numIssues to 0 for library filter data
if (Database.libraryFilterData[req.library.id]) {
Database.libraryFilterData[req.library.id].numIssues = 0
}
res.sendStatus(200)
}
/**
* GET: /api/libraries/:id/series
* Optional query string: `?include=rssfeed` that adds `rssFeed` to series if a feed is open
*
* @param {*} req
* @param {*} res
*/
* GET: /api/libraries/:id/series
* Optional query string: `?include=rssfeed` that adds `rssFeed` to series if a feed is open
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async getAllSeriesForLibrary(req, res) {
const libraryItems = req.libraryItems
const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v)
const payload = {
@ -543,45 +558,10 @@ class LibraryController {
include: include.join(',')
}
let series = libraryHelpers.getSeriesFromBooks(libraryItems, Database.series, null, payload.filterBy, req.user, payload.minified, req.library.settings.hideSingleBookSeries)
const direction = payload.sortDesc ? 'desc' : 'asc'
series = naturalSort(series).by([
{
[direction]: (se) => {
if (payload.sortBy === 'numBooks') {
return se.books.length
} else if (payload.sortBy === 'totalDuration') {
return se.totalDuration
} else if (payload.sortBy === 'addedAt') {
return se.addedAt
} else if (payload.sortBy === 'lastBookUpdated') {
return Math.max(...(se.books).map(x => x.updatedAt), 0)
} else if (payload.sortBy === 'lastBookAdded') {
return Math.max(...(se.books).map(x => x.addedAt), 0)
} else { // sort by name
return Database.serverSettings.sortingIgnorePrefix ? se.nameIgnorePrefixSort : se.name
}
}
}
])
payload.total = series.length
if (payload.limit) {
const startIndex = payload.page * payload.limit
series = series.slice(startIndex, startIndex + payload.limit)
}
// add rssFeed when "include=rssfeed" is in query string
if (include.includes('rssfeed')) {
series = await Promise.all(series.map(async (se) => {
const feedData = await this.rssFeedManager.findFeedForEntityId(se.id)
se.rssFeed = feedData?.toJSONMinified() || null
return se
}))
}
const offset = payload.page * payload.limit
const { series, count } = await seriesFilters.getFilteredSeries(req.library, req.user, payload.filterBy, payload.sortBy, payload.sortDesc, include, payload.limit, offset)
payload.total = count
payload.results = series
res.json(payload)
}
@ -644,7 +624,7 @@ class LibraryController {
}
// TODO: Create paginated queries
let collections = await Database.models.collection.getOldCollectionsJsonExpanded(req.user, req.library.id, include)
let collections = await Database.collectionModel.getOldCollectionsJsonExpanded(req.user, req.library.id, include)
payload.total = collections.length
@ -664,7 +644,7 @@ class LibraryController {
* @param {*} res
*/
async getUserPlaylistsForLibrary(req, res) {
let playlistsForUser = await Database.models.playlist.getPlaylistsForUserAndLibrary(req.user.id, req.library.id)
let playlistsForUser = await Database.playlistModel.getPlaylistsForUserAndLibrary(req.user.id, req.library.id)
playlistsForUser = await Promise.all(playlistsForUser.map(async p => p.getOldJsonExpanded()))
const payload = {
@ -685,8 +665,8 @@ class LibraryController {
/**
* GET: /api/libraries/:id/filterdata
* @param {*} req
* @param {*} res
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async getLibraryFilterData(req, res) {
const filterData = await libraryFilters.getFilterData(req.library)
@ -694,44 +674,30 @@ class LibraryController {
}
/**
* GET: /api/libraries/:id/personalized2
* TODO: new endpoint
* @param {*} req
* @param {*} res
* GET: /api/libraries/:id/personalized
* Home page shelves
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async getUserPersonalizedShelves(req, res) {
const limitPerShelf = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) || 10 : 10
const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v)
const shelves = await Database.models.libraryItem.getPersonalizedShelves(req.library, req.user, include, limitPerShelf)
const shelves = await Database.libraryItemModel.getPersonalizedShelves(req.library, req.user, include, limitPerShelf)
res.json(shelves)
}
/**
* GET: /api/libraries/:id/personalized
* TODO: remove after personalized2 is ready
* @param {*} req
* @param {*} res
*/
async getLibraryUserPersonalizedOptimal(req, res) {
const limitPerShelf = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) || 10 : 10
const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v)
const categories = await libraryHelpers.buildPersonalizedShelves(this, req.user, req.libraryItems, req.library, limitPerShelf, include)
res.json(categories)
}
/**
* POST: /api/libraries/order
* Change the display order of libraries
* @param {*} req
* @param {*} res
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async reorder(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error('[LibraryController] ReorderLibraries invalid user', req.user)
return res.sendStatus(403)
}
const libraries = await Database.models.library.getAllOldLibraries()
const libraries = await Database.libraryModel.getAllOldLibraries()
const orderdata = req.body
let hasUpdates = false
@ -759,99 +725,62 @@ class LibraryController {
})
}
// GET: Global library search
search(req, res) {
/**
* GET: /api/libraries/:id/search
* Search library items with query
* ?q=search
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async search(req, res) {
if (!req.query.q) {
return res.status(400).send('No query string')
}
const libraryItems = req.libraryItems
const maxResults = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12
const limit = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12
const query = req.query.q.trim().toLowerCase()
const itemMatches = []
const authorMatches = {}
const narratorMatches = {}
const seriesMatches = {}
const tagMatches = {}
libraryItems.forEach((li) => {
const queryResult = li.searchQuery(req.query.q)
if (queryResult.matchKey) {
itemMatches.push({
libraryItem: li.toJSONExpanded(),
matchKey: queryResult.matchKey,
matchText: queryResult.matchText
})
}
if (queryResult.series?.length) {
queryResult.series.forEach((se) => {
if (!seriesMatches[se.id]) {
const _series = Database.series.find(_se => _se.id === se.id)
if (_series) seriesMatches[se.id] = { series: _series.toJSON(), books: [li.toJSON()] }
} else {
seriesMatches[se.id].books.push(li.toJSON())
}
})
}
if (queryResult.authors?.length) {
queryResult.authors.forEach((au) => {
if (!authorMatches[au.id]) {
const _author = Database.authors.find(_au => _au.id === au.id)
if (_author) {
authorMatches[au.id] = _author.toJSON()
authorMatches[au.id].numBooks = 1
}
} else {
authorMatches[au.id].numBooks++
}
})
}
if (queryResult.tags?.length) {
queryResult.tags.forEach((tag) => {
if (!tagMatches[tag]) {
tagMatches[tag] = { name: tag, books: [li.toJSON()] }
} else {
tagMatches[tag].books.push(li.toJSON())
}
})
}
if (queryResult.narrators?.length) {
queryResult.narrators.forEach((narrator) => {
if (!narratorMatches[narrator]) {
narratorMatches[narrator] = { name: narrator, books: [li.toJSON()] }
} else {
narratorMatches[narrator].books.push(li.toJSON())
}
})
}
})
const itemKey = req.library.mediaType
const results = {
[itemKey]: itemMatches.slice(0, maxResults),
tags: Object.values(tagMatches).slice(0, maxResults),
authors: Object.values(authorMatches).slice(0, maxResults),
series: Object.values(seriesMatches).slice(0, maxResults),
narrators: Object.values(narratorMatches).slice(0, maxResults)
}
res.json(results)
const matches = await libraryItemFilters.search(req.user, req.library, query, limit)
res.json(matches)
}
/**
* GET: /api/libraries/:id/stats
* Get stats for library
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async stats(req, res) {
var libraryItems = req.libraryItems
var authorsWithCount = libraryHelpers.getAuthorsWithCount(libraryItems)
var genresWithCount = libraryHelpers.getGenresWithCount(libraryItems)
var durationStats = libraryHelpers.getItemDurationStats(libraryItems)
var sizeStats = libraryHelpers.getItemSizeStats(libraryItems)
var stats = {
totalItems: libraryItems.length,
totalAuthors: Object.keys(authorsWithCount).length,
totalGenres: Object.keys(genresWithCount).length,
totalDuration: durationStats.totalDuration,
longestItems: durationStats.longestItems,
numAudioTracks: durationStats.numAudioTracks,
totalSize: libraryHelpers.getLibraryItemsTotalSize(libraryItems),
largestItems: sizeStats.largestItems,
authorsWithCount,
genresWithCount
const stats = {
largestItems: await libraryItemFilters.getLargestItems(req.library.id, 10)
}
if (req.library.isBook) {
const authors = await authorFilters.getAuthorsWithCount(req.library.id)
const genres = await libraryItemsBookFilters.getGenresWithCount(req.library.id)
const bookStats = await libraryItemsBookFilters.getBookLibraryStats(req.library.id)
const longestBooks = await libraryItemsBookFilters.getLongestBooks(req.library.id, 10)
stats.totalAuthors = authors.length
stats.authorsWithCount = authors
stats.totalGenres = genres.length
stats.genresWithCount = genres
stats.totalItems = bookStats.totalItems
stats.longestItems = longestBooks
stats.totalSize = bookStats.totalSize
stats.totalDuration = bookStats.totalDuration
stats.numAudioTracks = bookStats.numAudioFiles
} else {
const genres = await libraryItemsPodcastFilters.getGenresWithCount(req.library.id)
const podcastStats = await libraryItemsPodcastFilters.getPodcastLibraryStats(req.library.id)
const longestPodcasts = await libraryItemsPodcastFilters.getLongestPodcasts(req.library.id, 10)
stats.totalGenres = genres.length
stats.genresWithCount = genres
stats.totalItems = podcastStats.totalItems
stats.longestItems = longestPodcasts
stats.totalSize = podcastStats.totalSize
stats.totalDuration = podcastStats.totalDuration
stats.numAudioTracks = podcastStats.numAudioFiles
}
res.json(stats)
}
@ -859,18 +788,18 @@ class LibraryController {
/**
* GET: /api/libraries/:id/authors
* Get authors for library
* @param {*} req
* @param {*} res
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async getAuthors(req, res) {
const { bookWhere, replacements } = libraryItemsBookFilters.getUserPermissionBookWhereQuery(req.user)
const authors = await Database.models.author.findAll({
const authors = await Database.authorModel.findAll({
where: {
libraryId: req.library.id
},
replacements,
include: {
model: Database.models.book,
model: Database.bookModel,
attributes: ['id', 'tags', 'explicit'],
where: bookWhere,
required: true,
@ -903,12 +832,12 @@ class LibraryController {
*/
async getNarrators(req, res) {
// Get all books with narrators
const booksWithNarrators = await Database.models.book.findAll({
const booksWithNarrators = await Database.bookModel.findAll({
where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('narrators')), {
[Sequelize.Op.gt]: 0
}),
include: {
model: Database.models.libraryItem,
model: Database.libraryItemModel,
attributes: ['id', 'libraryId'],
where: {
libraryId: req.library.id
@ -975,7 +904,7 @@ class LibraryController {
await libraryItem.media.update({
narrators: libraryItem.media.narrators
})
const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(libraryItem)
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
itemsUpdated.push(oldLibraryItem)
}
@ -1015,7 +944,7 @@ class LibraryController {
await libraryItem.media.update({
narrators: libraryItem.media.narrators
})
const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(libraryItem)
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
itemsUpdated.push(oldLibraryItem)
}
@ -1048,10 +977,16 @@ class LibraryController {
}
res.sendStatus(200)
await this.scanner.scan(req.library, options)
await Database.resetLibraryIssuesFilterData(req.library.id)
Logger.info('[LibraryController] Scan complete')
}
// GET: api/libraries/:id/recent-episode
/**
* GET: /api/libraries/:id/recent-episodes
* Used for latest page
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async getRecentEpisodes(req, res) {
if (!req.library.isPodcast) {
return res.sendStatus(404)
@ -1059,40 +994,37 @@ class LibraryController {
const payload = {
episodes: [],
total: 0,
limit: req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 0,
page: req.query.page && !isNaN(req.query.page) ? Number(req.query.page) : 0,
}
var allUnfinishedEpisodes = []
for (const libraryItem of req.libraryItems) {
const unfinishedEpisodes = libraryItem.media.episodes.filter(ep => {
const userProgress = req.user.getMediaProgress(libraryItem.id, ep.id)
return !userProgress || !userProgress.isFinished
}).map(_ep => {
const ep = _ep.toJSONExpanded()
ep.podcast = libraryItem.media.toJSONMinified()
ep.libraryItemId = libraryItem.id
ep.libraryId = libraryItem.libraryId
return ep
})
allUnfinishedEpisodes.push(...unfinishedEpisodes)
}
payload.total = allUnfinishedEpisodes.length
allUnfinishedEpisodes = sort(allUnfinishedEpisodes).desc(ep => ep.publishedAt)
if (payload.limit) {
var startIndex = payload.page * payload.limit
allUnfinishedEpisodes = allUnfinishedEpisodes.slice(startIndex, startIndex + payload.limit)
}
payload.episodes = allUnfinishedEpisodes
const offset = payload.page * payload.limit
payload.episodes = await libraryItemsPodcastFilters.getRecentEpisodes(req.user, req.library, payload.limit, offset)
res.json(payload)
}
getOPMLFile(req, res) {
const opmlText = this.podcastManager.generateOPMLFileText(req.libraryItems)
/**
* GET: /api/libraries/:id/opml
* Get OPML file for a podcast library
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async getOPMLFile(req, res) {
const userPermissionPodcastWhere = libraryItemsPodcastFilters.getUserPermissionPodcastWhereQuery(req.user)
const podcasts = await Database.podcastModel.findAll({
attributes: ['id', 'feedURL', 'title', 'description', 'itunesPageURL', 'language'],
where: userPermissionPodcastWhere.podcastWhere,
replacements: userPermissionPodcastWhere.replacements,
include: {
model: Database.libraryItemModel,
attributes: ['id', 'libraryId'],
where: {
libraryId: req.library.id
}
}
})
const opmlText = this.podcastManager.generateOPMLFileText(podcasts)
res.type('application/xml')
res.send(opmlText)
}
@ -1109,7 +1041,7 @@ class LibraryController {
return res.sendStatus(403)
}
const library = await Database.models.library.getOldById(req.params.id)
const library = await Database.libraryModel.getOldById(req.params.id)
if (!library) {
return res.status(404).send('Library not found')
}
@ -1122,9 +1054,9 @@ class LibraryController {
/**
* Middleware that is not using libraryItems from memory
* @param {*} req
* @param {*} res
* @param {*} next
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
async middlewareNew(req, res, next) {
if (!req.user.checkCanAccessLibrary(req.params.id)) {
@ -1132,7 +1064,7 @@ class LibraryController {
return res.sendStatus(403)
}
const library = await Database.models.library.getOldById(req.params.id)
const library = await Database.libraryModel.getOldById(req.params.id)
if (!library) {
return res.status(404).send('Library not found')
}

View File

@ -78,6 +78,7 @@ class LibraryItemController {
Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
})
}
await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId)
res.sendStatus(200)
}
@ -124,7 +125,7 @@ class LibraryItemController {
// Book specific - Get all series being removed from this item
let seriesRemoved = []
if (libraryItem.isBook && mediaPayload.metadata?.series) {
const seriesIdsInUpdate = (mediaPayload.metadata?.series || []).map(se => se.id)
const seriesIdsInUpdate = mediaPayload.metadata.series?.map(se => se.id) || []
seriesRemoved = libraryItem.media.metadata.series.filter(se => !seriesIdsInUpdate.includes(se.id))
}
@ -313,7 +314,7 @@ class LibraryItemController {
return res.status(400).send('Invalid request body')
}
const itemsToDelete = await Database.models.libraryItem.getAllOldLibraryItems({
const itemsToDelete = await Database.libraryItemModel.getAllOldLibraryItems({
id: libraryItemIds
})
@ -332,6 +333,8 @@ class LibraryItemController {
})
}
}
await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId)
res.sendStatus(200)
}
@ -346,7 +349,7 @@ class LibraryItemController {
for (const updatePayload of updatePayloads) {
const mediaPayload = updatePayload.mediaPayload
const libraryItem = await Database.models.libraryItem.getOldById(updatePayload.id)
const libraryItem = await Database.libraryItemModel.getOldById(updatePayload.id)
if (!libraryItem) return null
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId)
@ -384,7 +387,7 @@ class LibraryItemController {
if (!libraryItemIds.length) {
return res.status(403).send('Invalid payload')
}
const libraryItems = await Database.models.libraryItem.getAllOldLibraryItems({
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({
id: libraryItemIds
})
res.json({
@ -456,9 +459,11 @@ class LibraryItemController {
await this.scanner.scanLibraryItemByRequest(libraryItem)
}
}
await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId)
}
// POST: api/items/:id/scan (admin)
// POST: api/items/:id/scan
async scan(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[LibraryItemController] Non-admin user attempted to scan library item`, req.user)
@ -471,6 +476,7 @@ class LibraryItemController {
}
const result = await this.scanner.scanLibraryItemByRequest(req.libraryItem)
await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId)
res.json({
result: Object.keys(ScanResult).find(key => ScanResult[key] == result)
})
@ -694,7 +700,7 @@ class LibraryItemController {
}
async middleware(req, res, next) {
req.libraryItem = await Database.models.libraryItem.getOldById(req.params.id)
req.libraryItem = await Database.libraryItemModel.getOldById(req.params.id)
if (!req.libraryItem?.media) return res.sendStatus(404)
// Check user can access this library item

View File

@ -59,7 +59,7 @@ class MeController {
// PATCH: api/me/progress/:id
async createUpdateMediaProgress(req, res) {
const libraryItem = await Database.models.libraryItem.getOldById(req.params.id)
const libraryItem = await Database.libraryItemModel.getOldById(req.params.id)
if (!libraryItem) {
return res.status(404).send('Item not found')
}
@ -75,7 +75,7 @@ class MeController {
// PATCH: api/me/progress/:id/:episodeId
async createUpdateEpisodeMediaProgress(req, res) {
const episodeId = req.params.episodeId
const libraryItem = await Database.models.libraryItem.getOldById(req.params.id)
const libraryItem = await Database.libraryItemModel.getOldById(req.params.id)
if (!libraryItem) {
return res.status(404).send('Item not found')
}
@ -101,7 +101,7 @@ class MeController {
let shouldUpdate = false
for (const itemProgress of itemProgressPayloads) {
const libraryItem = await Database.models.libraryItem.getOldById(itemProgress.libraryItemId)
const libraryItem = await Database.libraryItemModel.getOldById(itemProgress.libraryItemId)
if (libraryItem) {
if (req.user.createUpdateMediaProgress(libraryItem, itemProgress, itemProgress.episodeId)) {
const mediaProgress = req.user.getMediaProgress(libraryItem.id, itemProgress.episodeId)
@ -122,7 +122,7 @@ class MeController {
// POST: api/me/item/:id/bookmark
async createBookmark(req, res) {
if (!await Database.models.libraryItem.checkExistsById(req.params.id)) return res.sendStatus(404)
if (!await Database.libraryItemModel.checkExistsById(req.params.id)) return res.sendStatus(404)
const { time, title } = req.body
const bookmark = req.user.createBookmark(req.params.id, time, title)
@ -133,7 +133,7 @@ class MeController {
// PATCH: api/me/item/:id/bookmark
async updateBookmark(req, res) {
if (!await Database.models.libraryItem.checkExistsById(req.params.id)) return res.sendStatus(404)
if (!await Database.libraryItemModel.checkExistsById(req.params.id)) return res.sendStatus(404)
const { time, title } = req.body
if (!req.user.findBookmark(req.params.id, time)) {
@ -151,7 +151,7 @@ class MeController {
// DELETE: api/me/item/:id/bookmark/:time
async removeBookmark(req, res) {
if (!await Database.models.libraryItem.checkExistsById(req.params.id)) return res.sendStatus(404)
if (!await Database.libraryItemModel.checkExistsById(req.params.id)) return res.sendStatus(404)
const time = Number(req.params.time)
if (isNaN(time)) return res.sendStatus(500)

View File

@ -38,7 +38,7 @@ class MiscController {
const libraryId = req.body.library
const folderId = req.body.folder
const library = await Database.models.library.getOldById(libraryId)
const library = await Database.libraryModel.getOldById(libraryId)
if (!library) {
return res.status(404).send(`Library not found with id ${libraryId}`)
}
@ -177,7 +177,7 @@ class MiscController {
}
const tags = []
const books = await Database.models.book.findAll({
const books = await Database.bookModel.findAll({
attributes: ['tags'],
where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('tags')), {
[Sequelize.Op.gt]: 0
@ -189,7 +189,7 @@ class MiscController {
}
}
const podcasts = await Database.models.podcast.findAll({
const podcasts = await Database.podcastModel.findAll({
attributes: ['tags'],
where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('tags')), {
[Sequelize.Op.gt]: 0
@ -248,7 +248,7 @@ class MiscController {
await libraryItem.media.update({
tags: libraryItem.media.tags
})
const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(libraryItem)
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
numItemsUpdated++
}
@ -289,7 +289,7 @@ class MiscController {
await libraryItem.media.update({
tags: libraryItem.media.tags
})
const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(libraryItem)
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
numItemsUpdated++
}
@ -311,7 +311,7 @@ class MiscController {
return res.sendStatus(404)
}
const genres = []
const books = await Database.models.book.findAll({
const books = await Database.bookModel.findAll({
attributes: ['genres'],
where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('genres')), {
[Sequelize.Op.gt]: 0
@ -323,7 +323,7 @@ class MiscController {
}
}
const podcasts = await Database.models.podcast.findAll({
const podcasts = await Database.podcastModel.findAll({
attributes: ['genres'],
where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('genres')), {
[Sequelize.Op.gt]: 0
@ -382,7 +382,7 @@ class MiscController {
await libraryItem.media.update({
genres: libraryItem.media.genres
})
const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(libraryItem)
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
numItemsUpdated++
}
@ -423,7 +423,7 @@ class MiscController {
await libraryItem.media.update({
genres: libraryItem.media.genres
})
const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(libraryItem)
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
numItemsUpdated++
}

View File

@ -22,11 +22,11 @@ class PlaylistController {
}
// Create Playlist record
const newPlaylist = await Database.models.playlist.createFromOld(oldPlaylist)
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.models.libraryItem.findAll({
const libraryItemsInPlaylist = await Database.libraryItemModel.findAll({
where: {
id: libraryItemIds
}
@ -62,7 +62,7 @@ class PlaylistController {
* @param {*} res
*/
async findAllForUser(req, res) {
const playlistsForUser = await Database.models.playlist.findAll({
const playlistsForUser = await Database.playlistModel.findAll({
where: {
userId: req.user.id
}
@ -106,7 +106,7 @@ class PlaylistController {
// 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.models.libraryItem.findAll({
const libraryItems = await Database.libraryItemModel.findAll({
where: {
id: libraryItemIds
}
@ -173,14 +173,14 @@ class PlaylistController {
* @param {*} res
*/
async addItem(req, res) {
const oldPlaylist = await Database.models.playlist.getById(req.playlist.id)
const oldPlaylist = await Database.playlistModel.getById(req.playlist.id)
const itemToAdd = req.body
if (!itemToAdd.libraryItemId) {
return res.status(400).send('Request body has no libraryItemId')
}
const libraryItem = await Database.models.libraryItem.getOldById(itemToAdd.libraryItemId)
const libraryItem = await Database.libraryItemModel.getOldById(itemToAdd.libraryItemId)
if (!libraryItem) {
return res.status(400).send('Library item not found')
}
@ -217,7 +217,7 @@ class PlaylistController {
* @param {*} res
*/
async removeItem(req, res) {
const oldLibraryItem = await Database.models.libraryItem.getOldById(req.params.libraryItemId)
const oldLibraryItem = await Database.libraryItemModel.getOldById(req.params.libraryItemId)
if (!oldLibraryItem) {
return res.status(404).send('Library item not found')
}
@ -281,7 +281,7 @@ class PlaylistController {
}
// Find all library items
const libraryItems = await Database.models.libraryItem.findAll({
const libraryItems = await Database.libraryItemModel.findAll({
where: {
id: libraryItemIds
}
@ -345,7 +345,7 @@ class PlaylistController {
}
// Find all library items
const libraryItems = await Database.models.libraryItem.findAll({
const libraryItems = await Database.libraryItemModel.findAll({
where: {
id: libraryItemIds
}
@ -391,7 +391,7 @@ class PlaylistController {
* @param {*} res
*/
async createFromCollection(req, res) {
const collection = await Database.models.collection.findByPk(req.params.collectionId)
const collection = await Database.collectionModel.findByPk(req.params.collectionId)
if (!collection) {
return res.status(404).send('Collection not found')
}
@ -416,7 +416,7 @@ class PlaylistController {
})
// Create Playlist record
const newPlaylist = await Database.models.playlist.createFromOld(oldPlaylist)
const newPlaylist = await Database.playlistModel.createFromOld(oldPlaylist)
// Create PlaylistMediaItem records
const mediaItemsToAdd = []
@ -438,7 +438,7 @@ class PlaylistController {
async middleware(req, res, next) {
if (req.params.id) {
const playlist = await Database.models.playlist.findByPk(req.params.id)
const playlist = await Database.playlistModel.findByPk(req.params.id)
if (!playlist) {
return res.status(404).send('Playlist not found')
}

View File

@ -19,7 +19,7 @@ class PodcastController {
}
const payload = req.body
const library = await Database.models.library.getOldById(payload.libraryId)
const library = await Database.libraryModel.getOldById(payload.libraryId)
if (!library) {
Logger.error(`[PodcastController] Create: Library not found "${payload.libraryId}"`)
return res.status(404).send('Library not found')
@ -34,7 +34,7 @@ class PodcastController {
const podcastPath = filePathToPOSIX(payload.path)
// Check if a library item with this podcast folder exists already
const existingLibraryItem = (await Database.models.libraryItem.count({
const existingLibraryItem = (await Database.libraryItemModel.count({
where: {
path: podcastPath
}
@ -272,13 +272,13 @@ class PodcastController {
}
// Update/remove playlists that had this podcast episode
const playlistMediaItems = await Database.models.playlistMediaItem.findAll({
const playlistMediaItems = await Database.playlistMediaItemModel.findAll({
where: {
mediaItemId: episodeId
},
include: {
model: Database.models.playlist,
include: Database.models.playlistMediaItem
model: Database.playlistModel,
include: Database.playlistMediaItemModel
}
})
for (const pmi of playlistMediaItems) {
@ -297,7 +297,7 @@ class PodcastController {
}
// Remove media progress for this episode
const mediaProgressRemoved = await Database.models.mediaProgress.destroy({
const mediaProgressRemoved = await Database.mediaProgressModel.destroy({
where: {
mediaItemId: episode.id
}
@ -312,7 +312,7 @@ class PodcastController {
}
async middleware(req, res, next) {
const item = await Database.models.libraryItem.getOldById(req.params.id)
const item = await Database.libraryItemModel.getOldById(req.params.id)
if (!item?.media) return res.sendStatus(404)
if (!item.isPodcast) {

View File

@ -17,7 +17,7 @@ class RSSFeedController {
async openRSSFeedForItem(req, res) {
const options = req.body || {}
const item = await Database.models.libraryItem.getOldById(req.params.itemId)
const item = await Database.libraryItemModel.getOldById(req.params.itemId)
if (!item) return res.sendStatus(404)
// Check user can access this library item
@ -54,7 +54,7 @@ class RSSFeedController {
async openRSSFeedForCollection(req, res) {
const options = req.body || {}
const collection = await Database.models.collection.findByPk(req.params.collectionId)
const collection = await Database.collectionModel.findByPk(req.params.collectionId)
if (!collection) return res.sendStatus(404)
// Check request body options exist

View File

@ -49,7 +49,7 @@ class SessionController {
return res.sendStatus(404)
}
const minifiedUserObjects = await Database.models.user.getMinifiedUserObjects()
const minifiedUserObjects = await Database.userModel.getMinifiedUserObjects()
const openSessions = this.playbackSessionManager.sessions.map(se => {
return {
...se.toJSON(),

View File

@ -106,7 +106,7 @@ class ToolsController {
}
if (req.params.id) {
const item = await Database.models.libraryItem.getOldById(req.params.id)
const item = await Database.libraryItemModel.getOldById(req.params.id)
if (!item?.media) return res.sendStatus(404)
// Check user can access this library item

View File

@ -17,7 +17,7 @@ class UserController {
const includes = (req.query.include || '').split(',').map(i => i.trim())
// Minimal toJSONForBrowser does not include mediaProgress and bookmarks
const allUsers = await Database.models.user.getOldUsers()
const allUsers = await Database.userModel.getOldUsers()
const users = allUsers.map(u => u.toJSONForBrowser(hideRootToken, true))
if (includes.includes('latestSession')) {
@ -47,20 +47,20 @@ class UserController {
}
// Get user media progress with associated mediaItem
const mediaProgresses = await Database.models.mediaProgress.findAll({
const mediaProgresses = await Database.mediaProgressModel.findAll({
where: {
userId: req.reqUser.id
},
include: [
{
model: Database.models.book,
model: Database.bookModel,
attributes: ['id', 'title', 'coverPath', 'updatedAt']
},
{
model: Database.models.podcastEpisode,
model: Database.podcastEpisodeModel,
attributes: ['id', 'title'],
include: {
model: Database.models.podcast,
model: Database.podcastModel,
attributes: ['id', 'title', 'coverPath', 'updatedAt']
}
}
@ -92,7 +92,7 @@ class UserController {
const account = req.body
const username = account.username
const usernameExists = await Database.models.user.getUserByUsername(username)
const usernameExists = await Database.userModel.getUserByUsername(username)
if (usernameExists) {
return res.status(500).send('Username already taken')
}
@ -127,7 +127,7 @@ class UserController {
var shouldUpdateToken = false
if (account.username !== undefined && account.username !== user.username) {
const usernameExists = await Database.models.user.getUserByUsername(account.username)
const usernameExists = await Database.userModel.getUserByUsername(account.username)
if (usernameExists) {
return res.status(500).send('Username already taken')
}
@ -169,7 +169,7 @@ class UserController {
// Todo: check if user is logged in and cancel streams
// Remove user playlists
const userPlaylists = await Database.models.playlist.findAll({
const userPlaylists = await Database.playlistModel.findAll({
where: {
userId: user.id
}
@ -233,7 +233,7 @@ class UserController {
}
if (req.params.id) {
req.reqUser = await Database.models.user.getUserById(req.params.id)
req.reqUser = await Database.userModel.getUserById(req.params.id)
if (!req.reqUser) {
return res.sendStatus(404)
}

View File

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

View File

@ -77,7 +77,7 @@ class CronManager {
async initPodcastCrons() {
const cronExpressionMap = {}
const podcastsWithAutoDownload = await Database.models.podcast.findAll({
const podcastsWithAutoDownload = await Database.podcastModel.findAll({
where: {
autoDownloadEpisodes: true,
autoDownloadSchedule: {
@ -85,7 +85,7 @@ class CronManager {
}
},
include: {
model: Database.models.libraryItem
model: Database.libraryItemModel
}
})
@ -139,7 +139,7 @@ class CronManager {
// Get podcast library items to check
const libraryItems = []
for (const libraryItemId of libraryItemIds) {
const libraryItem = await Database.models.libraryItem.getOldById(libraryItemId)
const libraryItem = await Database.libraryItemModel.getOldById(libraryItemId)
if (!libraryItem) {
Logger.error(`[CronManager] Library item ${libraryItemId} not found for episode check cron ${expression}`)
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter(lid => lid !== libraryItemId) // Filter it out

View File

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

View File

@ -265,7 +265,7 @@ class PlaybackSessionManager {
}
async syncSession(user, session, syncData) {
const libraryItem = await Database.models.libraryItem.getOldById(session.libraryItemId)
const libraryItem = await Database.libraryItemModel.getOldById(session.libraryItemId)
if (!libraryItem) {
Logger.error(`[PlaybackSessionManager] syncSession Library Item not found "${session.libraryItemId}"`)
return null

View File

@ -150,7 +150,7 @@ class PodcastManager {
return false
}
const libraryItem = await Database.models.libraryItem.getOldById(this.currentDownload.libraryItem.id)
const libraryItem = await Database.libraryItemModel.getOldById(this.currentDownload.libraryItem.id)
if (!libraryItem) {
Logger.error(`[PodcastManager] Podcast Episode finished but library item was not found ${this.currentDownload.libraryItem.id}`)
return false
@ -372,8 +372,13 @@ class PodcastManager {
}
}
generateOPMLFileText(libraryItems) {
return opmlGenerator.generate(libraryItems)
/**
* OPML file string for podcasts in a library
* @param {import('../models/Podcast')[]} podcasts
* @returns {string} XML string
*/
generateOPMLFileText(podcasts) {
return opmlGenerator.generate(podcasts)
}
getDownloadQueueDetails(libraryId = null) {

View File

@ -13,13 +13,13 @@ class RssFeedManager {
async validateFeedEntity(feedObj) {
if (feedObj.entityType === 'collection') {
const collection = await Database.models.collection.getOldById(feedObj.entityId)
const collection = await Database.collectionModel.getOldById(feedObj.entityId)
if (!collection) {
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Collection "${feedObj.entityId}" not found`)
return false
}
} else if (feedObj.entityType === 'libraryItem') {
const libraryItemExists = await Database.models.libraryItem.checkExistsById(feedObj.entityId)
const libraryItemExists = await Database.libraryItemModel.checkExistsById(feedObj.entityId)
if (!libraryItemExists) {
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Library item "${feedObj.entityId}" not found`)
return false
@ -41,7 +41,7 @@ class RssFeedManager {
* Validate all feeds and remove invalid
*/
async init() {
const feeds = await Database.models.feed.getOldFeeds()
const feeds = await Database.feedModel.getOldFeeds()
for (const feed of feeds) {
// Remove invalid feeds
if (!await this.validateFeedEntity(feed)) {
@ -56,7 +56,7 @@ class RssFeedManager {
* @returns {Promise<objects.Feed>} oldFeed
*/
findFeedForEntityId(entityId) {
return Database.models.feed.findOneOld({ entityId })
return Database.feedModel.findOneOld({ entityId })
}
/**
@ -65,7 +65,7 @@ class RssFeedManager {
* @returns {Promise<objects.Feed>} oldFeed
*/
findFeedBySlug(slug) {
return Database.models.feed.findOneOld({ slug })
return Database.feedModel.findOneOld({ slug })
}
/**
@ -74,7 +74,7 @@ class RssFeedManager {
* @returns {Promise<objects.Feed>} oldFeed
*/
findFeed(id) {
return Database.models.feed.findByPkOld(id)
return Database.feedModel.findByPkOld(id)
}
async getFeed(req, res) {
@ -103,7 +103,7 @@ class RssFeedManager {
await Database.updateFeed(feed)
}
} else if (feed.entityType === 'collection') {
const collection = await Database.models.collection.findByPk(feed.entityId)
const collection = await Database.collectionModel.findByPk(feed.entityId)
if (collection) {
const collectionExpanded = await collection.getOldJsonExpanded()

View File

@ -83,6 +83,15 @@ class Author extends Model {
})
}
/**
* Check if author exists
* @param {string} authorId
* @returns {Promise<boolean>}
*/
static async checkExistsById(authorId) {
return (await this.count({ where: { id: authorId } })) > 0
}
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize

View File

@ -54,7 +54,7 @@ class Podcast extends Model {
static getOldPodcast(libraryItemExpanded) {
const podcastExpanded = libraryItemExpanded.media
const podcastEpisodes = podcastExpanded.podcastEpisodes?.map(ep => ep.getOldPodcastEpisode(libraryItemExpanded.id)).sort((a, b) => a.index - b.index)
const podcastEpisodes = podcastExpanded.podcastEpisodes?.map(ep => ep.getOldPodcastEpisode(libraryItemExpanded.id).toJSON()).sort((a, b) => a.index - b.index)
return {
id: podcastExpanded.id,
libraryItemId: libraryItemExpanded.id,

View File

@ -1,4 +1,5 @@
const { DataTypes, Model } = require('sequelize')
const oldPodcastEpisode = require('../objects/entities/PodcastEpisode')
class PodcastEpisode extends Model {
constructor(values, options) {
@ -44,6 +45,10 @@ class PodcastEpisode extends Model {
this.updatedAt
}
/**
* @param {string} libraryItemId
* @returns {oldPodcastEpisode}
*/
getOldPodcastEpisode(libraryItemId = null) {
let enclosure = null
if (this.enclosureURL) {
@ -53,7 +58,7 @@ class PodcastEpisode extends Model {
length: this.enclosureSize !== null ? String(this.enclosureSize) : null
}
}
return {
return new oldPodcastEpisode({
libraryItemId: libraryItemId || null,
podcastId: this.podcastId,
id: this.id,
@ -72,7 +77,7 @@ class PodcastEpisode extends Model {
publishedAt: this.publishedAt?.valueOf() || null,
addedAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf()
}
})
}
static createFromOld(oldEpisode) {

View File

@ -75,6 +75,15 @@ class Series extends Model {
})
}
/**
* Check if series exists
* @param {string} seriesId
* @returns {Promise<boolean>}
*/
static async checkExistsById(seriesId) {
return (await this.count({ where: { id: seriesId } })) > 0
}
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
@ -91,7 +100,24 @@ class Series extends Model {
description: DataTypes.TEXT
}, {
sequelize,
modelName: 'series'
modelName: 'series',
indexes: [
{
fields: [{
name: 'name',
collate: 'NOCASE'
}]
},
{
fields: [{
name: 'nameIgnorePrefix',
collate: 'NOCASE'
}]
},
{
fields: ['libraryId']
}
]
})
const { library } = sequelize.models

View File

@ -1,5 +1,5 @@
const uuidv4 = require("uuid").v4
const { getTitleIgnorePrefix } = require('../../utils/index')
const { getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index')
class Series {
constructor(series) {
@ -33,6 +33,7 @@ class Series {
return {
id: this.id,
name: this.name,
nameIgnorePrefix: getTitlePrefixAtEnd(this.name),
description: this.description,
addedAt: this.addedAt,
updatedAt: this.updatedAt,

View File

@ -78,23 +78,22 @@ class ApiRouter {
this.router.get('/libraries/:id/items', LibraryController.middleware.bind(this), LibraryController.getLibraryItems.bind(this))
this.router.delete('/libraries/:id/issues', LibraryController.middlewareNew.bind(this), LibraryController.removeLibraryItemsWithIssues.bind(this))
this.router.get('/libraries/:id/episode-downloads', LibraryController.middlewareNew.bind(this), LibraryController.getEpisodeDownloadQueue.bind(this))
this.router.get('/libraries/:id/series', LibraryController.middleware.bind(this), LibraryController.getAllSeriesForLibrary.bind(this))
this.router.get('/libraries/:id/series', LibraryController.middlewareNew.bind(this), LibraryController.getAllSeriesForLibrary.bind(this))
this.router.get('/libraries/:id/series/:seriesId', LibraryController.middlewareNew.bind(this), LibraryController.getSeriesForLibrary.bind(this))
this.router.get('/libraries/:id/collections', LibraryController.middlewareNew.bind(this), LibraryController.getCollectionsForLibrary.bind(this))
this.router.get('/libraries/:id/playlists', LibraryController.middlewareNew.bind(this), LibraryController.getUserPlaylistsForLibrary.bind(this))
this.router.get('/libraries/:id/personalized2', LibraryController.middlewareNew.bind(this), LibraryController.getUserPersonalizedShelves.bind(this))
this.router.get('/libraries/:id/personalized', LibraryController.middleware.bind(this), LibraryController.getLibraryUserPersonalizedOptimal.bind(this))
this.router.get('/libraries/:id/personalized', LibraryController.middlewareNew.bind(this), LibraryController.getUserPersonalizedShelves.bind(this))
this.router.get('/libraries/:id/filterdata', LibraryController.middlewareNew.bind(this), LibraryController.getLibraryFilterData.bind(this))
this.router.get('/libraries/:id/search', LibraryController.middleware.bind(this), LibraryController.search.bind(this))
this.router.get('/libraries/:id/stats', LibraryController.middleware.bind(this), LibraryController.stats.bind(this))
this.router.get('/libraries/:id/search', LibraryController.middlewareNew.bind(this), LibraryController.search.bind(this))
this.router.get('/libraries/:id/stats', LibraryController.middlewareNew.bind(this), LibraryController.stats.bind(this))
this.router.get('/libraries/:id/authors', LibraryController.middlewareNew.bind(this), LibraryController.getAuthors.bind(this))
this.router.get('/libraries/:id/narrators', LibraryController.middlewareNew.bind(this), LibraryController.getNarrators.bind(this))
this.router.patch('/libraries/:id/narrators/:narratorId', LibraryController.middlewareNew.bind(this), LibraryController.updateNarrator.bind(this))
this.router.delete('/libraries/:id/narrators/:narratorId', LibraryController.middlewareNew.bind(this), LibraryController.removeNarrator.bind(this))
this.router.get('/libraries/:id/matchall', LibraryController.middleware.bind(this), LibraryController.matchAll.bind(this))
this.router.post('/libraries/:id/scan', LibraryController.middleware.bind(this), LibraryController.scan.bind(this))
this.router.get('/libraries/:id/recent-episodes', LibraryController.middleware.bind(this), LibraryController.getRecentEpisodes.bind(this))
this.router.get('/libraries/:id/opml', LibraryController.middleware.bind(this), LibraryController.getOPMLFile.bind(this))
this.router.get('/libraries/:id/matchall', LibraryController.middlewareNew.bind(this), LibraryController.matchAll.bind(this))
this.router.post('/libraries/:id/scan', LibraryController.middlewareNew.bind(this), LibraryController.scan.bind(this))
this.router.get('/libraries/:id/recent-episodes', LibraryController.middlewareNew.bind(this), LibraryController.getRecentEpisodes.bind(this))
this.router.get('/libraries/:id/opml', LibraryController.middlewareNew.bind(this), LibraryController.getOPMLFile.bind(this))
this.router.post('/libraries/order', LibraryController.reorder.bind(this))
//
@ -361,7 +360,7 @@ class ApiRouter {
*/
async handleDeleteLibraryItem(mediaType, libraryItemId, mediaItemIds) {
// Remove media progress for this library item from all users
const users = await Database.models.user.getOldUsers()
const users = await Database.userModel.getOldUsers()
for (const user of users) {
for (const mediaProgress of user.getAllMediaProgressForLibraryItem(libraryItemId)) {
await Database.removeMediaProgress(mediaProgress.id)
@ -373,14 +372,14 @@ class ApiRouter {
// Remove series if empty
if (mediaType === 'book') {
// TODO: update filter data
const bookSeries = await Database.models.bookSeries.findAll({
const bookSeries = await Database.bookSeriesModel.findAll({
where: {
bookId: mediaItemIds[0]
},
include: {
model: Database.models.series,
model: Database.seriesModel,
include: {
model: Database.models.book
model: Database.bookModel
}
}
})
@ -392,7 +391,7 @@ class ApiRouter {
}
// remove item from playlists
const playlistsWithItem = await Database.models.playlist.getPlaylistsForMediaItemIds(mediaItemIds)
const playlistsWithItem = await Database.playlistModel.getPlaylistsForMediaItemIds(mediaItemIds)
for (const playlist of playlistsWithItem) {
let numMediaItems = playlist.playlistMediaItems.length
@ -445,23 +444,23 @@ class ApiRouter {
/**
* Used when a series is removed from a book
* Series is removed if it only has 1 book
* TODO: Update filter data
* @param {UUIDV4} bookId
* @param {UUIDV4[]} seriesIds
*
* @param {string} bookId
* @param {string[]} seriesIds
*/
async checkRemoveEmptySeries(bookId, seriesIds) {
if (!seriesIds?.length) return
const bookSeries = await Database.models.bookSeries.findAll({
const bookSeries = await Database.bookSeriesModel.findAll({
where: {
bookId,
seriesId: seriesIds
},
include: [
{
model: Database.models.series,
model: Database.seriesModel,
include: {
model: Database.models.book
model: Database.bookModel
}
}
]
@ -473,10 +472,20 @@ class ApiRouter {
}
}
/**
* Remove an empty series & close an open RSS feed
* @param {import('../models/Series')} series
*/
async removeEmptySeries(series) {
await this.rssFeedManager.closeFeedForEntityId(series.id)
Logger.info(`[ApiRouter] Series "${series.name}" is now empty. Removing series`)
await Database.removeSeries(series.id)
// Remove series from library filter data
Database.removeSeriesFromFilterData(series.libraryId, series.id)
SocketAuthority.emitter('series_removed', {
id: series.id,
libraryId: series.libraryId
})
}
async getUserListeningSessionsHelper(userId) {
@ -487,7 +496,7 @@ class ApiRouter {
async getAllSessionsWithUserData() {
const sessions = await Database.getPlaybackSessions()
sessions.sort((a, b) => b.updatedAt - a.updatedAt)
const minifiedUserObjects = await Database.models.user.getMinifiedUserObjects()
const minifiedUserObjects = await Database.userModel.getMinifiedUserObjects()
return sessions.map(se => {
return {
...se,
@ -547,7 +556,7 @@ class ApiRouter {
const mediaMetadata = mediaPayload.metadata
// Create new authors if in payload
if (mediaMetadata.authors && mediaMetadata.authors.length) {
if (mediaMetadata.authors?.length) {
const newAuthors = []
for (let i = 0; i < mediaMetadata.authors.length; i++) {
const authorName = (mediaMetadata.authors[i].name || '').trim()
@ -556,6 +565,12 @@ class ApiRouter {
continue
}
// Ensure the ID for the author exists
if (mediaMetadata.authors[i].id && !(await Database.checkAuthorExists(libraryId, mediaMetadata.authors[i].id))) {
Logger.warn(`[ApiRouter] Author id "${mediaMetadata.authors[i].id}" does not exist`)
mediaMetadata.authors[i].id = null
}
if (!mediaMetadata.authors[i].id || mediaMetadata.authors[i].id.startsWith('new')) {
let author = Database.authors.find(au => au.libraryId === libraryId && au.checkNameEquals(authorName))
if (!author) {
@ -563,6 +578,8 @@ class ApiRouter {
author.setData(mediaMetadata.authors[i], libraryId)
Logger.debug(`[ApiRouter] Created new author "${author.name}"`)
newAuthors.push(author)
// Update filter data
Database.addAuthorToFilterData(libraryId, author.name, author.id)
}
// Update ID in original payload
@ -585,6 +602,12 @@ class ApiRouter {
continue
}
// Ensure the ID for the series exists
if (mediaMetadata.series[i].id && !(await Database.checkSeriesExists(libraryId, mediaMetadata.series[i].id))) {
Logger.warn(`[ApiRouter] Series id "${mediaMetadata.series[i].id}" does not exist`)
mediaMetadata.series[i].id = null
}
if (!mediaMetadata.series[i].id || mediaMetadata.series[i].id.startsWith('new')) {
let seriesItem = Database.series.find(se => se.libraryId === libraryId && se.checkNameEquals(seriesName))
if (!seriesItem) {
@ -592,6 +615,8 @@ class ApiRouter {
seriesItem.setData(mediaMetadata.series[i], libraryId)
Logger.debug(`[ApiRouter] Created new series "${seriesItem.name}"`)
newSeries.push(seriesItem)
// Update filter data
Database.addSeriesToFilterData(libraryId, seriesItem.name, seriesItem.id)
}
// Update ID in original payload

View File

@ -67,7 +67,7 @@ class Scanner {
}
async scanLibraryItemByRequest(libraryItem) {
const library = await Database.models.library.getOldById(libraryItem.libraryId)
const library = await Database.libraryModel.getOldById(libraryItem.libraryId)
if (!library) {
Logger.error(`[Scanner] Scan libraryItem by id library not found "${libraryItem.libraryId}"`)
return ScanResult.NOTHING
@ -486,6 +486,8 @@ class Scanner {
_author = new Author()
_author.setData(tempMinAuthor, libraryItem.libraryId)
newAuthors.push(_author)
// Update filter data
Database.addAuthorToFilterData(libraryItem.libraryId, _author.name, _author.id)
}
return {
@ -502,11 +504,17 @@ class Scanner {
const newSeries = []
libraryItem.media.metadata.series = libraryItem.media.metadata.series.map((tempMinSeries) => {
let _series = Database.series.find(se => se.libraryId === libraryItem.libraryId && se.checkNameEquals(tempMinSeries.name))
if (!_series) _series = newSeries.find(se => se.libraryId === libraryItem.libraryId && se.checkNameEquals(tempMinSeries.name)) // Check new unsaved series
if (!_series) {
// Check new unsaved series
_series = newSeries.find(se => se.libraryId === libraryItem.libraryId && se.checkNameEquals(tempMinSeries.name))
}
if (!_series) { // Must create new series
_series = new Series()
_series.setData(tempMinSeries, libraryItem.libraryId)
newSeries.push(_series)
// Update filter data
Database.addSeriesToFilterData(libraryItem.libraryId, _series.name, _series.id)
}
return {
id: _series.id,
@ -553,25 +561,30 @@ class Scanner {
for (const folderId in folderGroups) {
const libraryId = folderGroups[folderId].libraryId
const library = await Database.models.library.getOldById(libraryId)
const library = await Database.libraryModel.getOldById(libraryId)
if (!library) {
Logger.error(`[Scanner] Library not found in files changed ${libraryId}`)
continue;
continue
}
const folder = library.getFolderById(folderId)
if (!folder) {
Logger.error(`[Scanner] Folder is not in library in files changed "${folderId}", Library "${library.name}"`)
continue;
continue
}
const relFilePaths = folderGroups[folderId].fileUpdates.map(fileUpdate => fileUpdate.relPath)
const fileUpdateGroup = groupFilesIntoLibraryItemPaths(library.mediaType, relFilePaths, false)
if (!Object.keys(fileUpdateGroup).length) {
Logger.info(`[Scanner] No important changes to scan for in folder "${folderId}"`)
continue;
continue
}
const folderScanResults = await this.scanFolderUpdates(library, folder, fileUpdateGroup)
Logger.debug(`[Scanner] Folder scan results`, folderScanResults)
// If something was updated then reset numIssues filter data for library
if (Object.values(folderScanResults).some(scanResult => scanResult !== ScanResult.NOTHING && scanResult !== ScanResult.UPTODATE)) {
await Database.resetLibraryIssuesFilterData(libraryId)
}
}
this.scanningFilesChanged = false
@ -599,7 +612,7 @@ class Scanner {
const altDir = `${itemDir}/${firstNest}`
const fullPath = Path.posix.join(filePathToPOSIX(folder.fullPath), itemDir)
const childLibraryItem = await Database.models.libraryItem.findOne({
const childLibraryItem = await Database.libraryItemModel.findOne({
attributes: ['id', 'path'],
where: {
path: {
@ -615,7 +628,7 @@ class Scanner {
}
const altFullPath = Path.posix.join(filePathToPOSIX(folder.fullPath), altDir)
const altChildLibraryItem = await Database.models.libraryItem.findOne({
const altChildLibraryItem = await Database.libraryItemModel.findOne({
attributes: ['id', 'path'],
where: {
path: {
@ -648,12 +661,12 @@ class Scanner {
}
// Check if book dir group is already an item
let existingLibraryItem = await Database.models.libraryItem.findOneOld({
let existingLibraryItem = await Database.libraryItemModel.findOneOld({
path: potentialChildDirs
})
if (!existingLibraryItem) {
existingLibraryItem = await Database.models.libraryItem.findOneOld({
existingLibraryItem = await Database.libraryItemModel.findOneOld({
ino: dirIno
})
if (existingLibraryItem) {
@ -688,11 +701,11 @@ class Scanner {
}
// Check if a library item is a subdirectory of this dir
const childItem = await Database.models.libraryItem.findOne({
const childItem = await Database.libraryItemModel.findOne({
attributes: ['id', 'path'],
where: {
path: {
[Sequelize.Op.startsWith]: fullPath
[Sequelize.Op.startsWith]: fullPath + '/'
}
}
})
@ -924,6 +937,8 @@ class Scanner {
author.setData({ name: authorName }, libraryItem.libraryId)
await Database.createAuthor(author)
SocketAuthority.emitter('author_added', author.toJSON())
// Update filter data
Database.addAuthorToFilterData(libraryItem.libraryId, author.name, author.id)
}
authorPayload.push(author.toJSONMinimal())
}
@ -940,6 +955,8 @@ class Scanner {
seriesItem = new Series()
seriesItem.setData({ name: seriesMatchItem.series }, libraryItem.libraryId)
await Database.createSeries(seriesItem)
// Update filter data
Database.addSeriesToFilterData(libraryItem.libraryId, seriesItem.name, seriesItem.id)
SocketAuthority.emitter('series_added', seriesItem.toJSON())
}
seriesPayload.push(seriesItem.toJSONMinimal(seriesMatchItem.sequence))

View File

@ -1,23 +1,29 @@
const xml = require('../../libs/xml')
module.exports.generate = (libraryItems, indent = true) => {
/**
* Generate OPML file string for podcasts in a library
* @param {import('../../models/Podcast')[]} podcasts
* @param {boolean} [indent=true]
* @returns {string}
*/
module.exports.generate = (podcasts, indent = true) => {
const bodyItems = []
libraryItems.forEach((item) => {
if (!item.media.metadata.feedUrl) return
podcasts.forEach((podcast) => {
if (!podcast.feedURL) return
const feedAttributes = {
type: 'rss',
text: item.media.metadata.title,
title: item.media.metadata.title,
xmlUrl: item.media.metadata.feedUrl
text: podcast.title,
title: podcast.title,
xmlUrl: podcast.feedURL
}
if (item.media.metadata.description) {
feedAttributes.description = item.media.metadata.description
if (podcast.description) {
feedAttributes.description = podcast.description
}
if (item.media.metadata.itunesPageUrl) {
feedAttributes.htmlUrl = item.media.metadata.itunesPageUrl
if (podcast.itunesPageUrl) {
feedAttributes.htmlUrl = podcast.itunesPageUrl
}
if (item.media.metadata.language) {
feedAttributes.language = item.media.metadata.language
if (podcast.language) {
feedAttributes.language = podcast.language
}
bodyItems.push({
outline: {

View File

@ -1,5 +1,4 @@
const { sort, createNewSortInstance } = require('../libs/fastSort')
const Logger = require('../Logger')
const { createNewSortInstance } = require('../libs/fastSort')
const Database = require('../Database')
const { getTitlePrefixAtEnd, isNullOrNaN, getTitleIgnorePrefix } = require('../utils/index')
const naturalSort = createNewSortInstance({
@ -72,7 +71,7 @@ module.exports = {
} else if (filterBy === 'issues') {
filtered = filtered.filter(li => li.hasIssues)
} else if (filterBy === 'feed-open') {
const libraryItemIdsWithFeed = await Database.models.feed.findAllLibraryItemIds()
const libraryItemIdsWithFeed = await Database.feedModel.findAllLibraryItemIds()
filtered = filtered.filter(li => libraryItemIdsWithFeed.includes(li.id))
} else if (filterBy === 'abridged') {
filtered = filtered.filter(li => !!li.media.metadata?.abridged)
@ -126,60 +125,6 @@ module.exports = {
return true
},
getDistinctFilterDataNew(libraryItems) {
const data = {
authors: [],
genres: [],
tags: [],
series: [],
narrators: [],
languages: [],
publishers: []
}
libraryItems.forEach((li) => {
const mediaMetadata = li.media.metadata
if (mediaMetadata.authors?.length) {
mediaMetadata.authors.forEach((author) => {
if (author && !data.authors.some(au => au.id === author.id)) data.authors.push({ id: author.id, name: author.name })
})
}
if (mediaMetadata.series?.length) {
mediaMetadata.series.forEach((series) => {
if (series && !data.series.some(se => se.id === series.id)) data.series.push({ id: series.id, name: series.name })
})
}
if (mediaMetadata.genres?.length) {
mediaMetadata.genres.forEach((genre) => {
if (genre && !data.genres.includes(genre)) data.genres.push(genre)
})
}
if (li.media.tags.length) {
li.media.tags.forEach((tag) => {
if (tag && !data.tags.includes(tag)) data.tags.push(tag)
})
}
if (mediaMetadata.narrators?.length) {
mediaMetadata.narrators.forEach((narrator) => {
if (narrator && !data.narrators.includes(narrator)) data.narrators.push(narrator)
})
}
if (mediaMetadata.publisher && !data.publishers.includes(mediaMetadata.publisher)) {
data.publishers.push(mediaMetadata.publisher)
}
if (mediaMetadata.language && !data.languages.includes(mediaMetadata.language)) {
data.languages.push(mediaMetadata.language)
}
})
data.authors = naturalSort(data.authors).asc(au => au.name)
data.genres = naturalSort(data.genres).asc()
data.tags = naturalSort(data.tags).asc()
data.series = naturalSort(data.series).asc(se => se.name)
data.narrators = naturalSort(data.narrators).asc()
data.publishers = naturalSort(data.publishers).asc()
data.languages = naturalSort(data.languages).asc()
return data
},
getSeriesFromBooks(books, allSeries, filterSeries, filterBy, user, minified, hideSingleBookSeries) {
const _series = {}
const seriesToFilterOut = {}
@ -246,89 +191,6 @@ module.exports = {
})
},
getBooksNextInSeries(seriesWithUserAb, limit, minified = false) {
var incompleteSeires = seriesWithUserAb.filter((series) => series.books.some((book) => !book.userAudiobook || (!book.userAudiobook.isRead && book.userAudiobook.progress == 0)))
var booksNextInSeries = []
incompleteSeires.forEach((series) => {
var dateLastRead = series.books.filter((data) => data.userAudiobook && data.userAudiobook.isRead).sort((a, b) => { return b.userAudiobook.finishedAt - a.userAudiobook.finishedAt })[0].userAudiobook.finishedAt
var nextUnreadBook = series.books.filter((data) => !data.userAudiobook || (!data.userAudiobook.isRead && data.userAudiobook.progress == 0))[0]
nextUnreadBook.DateLastReadSeries = dateLastRead
booksNextInSeries.push(nextUnreadBook)
})
return booksNextInSeries.sort((a, b) => { return b.DateLastReadSeries - a.DateLastReadSeries }).map(b => minified ? b.book.toJSONMinified() : b.book.toJSONExpanded()).slice(0, limit)
},
getGenresWithCount(libraryItems) {
var genresMap = {}
libraryItems.forEach((li) => {
var genres = li.media.metadata.genres || []
genres.forEach((genre) => {
if (genresMap[genre]) genresMap[genre].count++
else
genresMap[genre] = {
genre,
count: 1
}
})
})
return Object.values(genresMap).sort((a, b) => b.count - a.count)
},
getAuthorsWithCount(libraryItems) {
var authorsMap = {}
libraryItems.forEach((li) => {
var authors = li.media.metadata.authors || []
authors.forEach((author) => {
if (authorsMap[author.id]) authorsMap[author.id].count++
else
authorsMap[author.id] = {
id: author.id,
name: author.name,
count: 1
}
})
})
return Object.values(authorsMap).sort((a, b) => b.count - a.count)
},
getItemDurationStats(libraryItems) {
var sorted = sort(libraryItems).desc(li => li.media.duration)
var top10 = sorted.slice(0, 10).map(li => ({ id: li.id, title: li.media.metadata.title, duration: li.media.duration })).filter(i => i.duration > 0)
var totalDuration = 0
var numAudioTracks = 0
libraryItems.forEach((li) => {
totalDuration += li.media.duration
numAudioTracks += li.media.numTracks
})
return {
totalDuration,
numAudioTracks,
longestItems: top10
}
},
getItemSizeStats(libraryItems) {
var sorted = sort(libraryItems).desc(li => li.media.size)
var top10 = sorted.slice(0, 10).map(li => ({ id: li.id, title: li.media.metadata.title, size: li.media.size })).filter(i => i.size > 0)
var totalSize = 0
libraryItems.forEach((li) => {
totalSize += li.media.size
})
return {
totalSize,
largestItems: top10
}
},
getLibraryItemsTotalSize(libraryItems) {
var totalSize = 0
libraryItems.forEach((li) => {
totalSize += li.media.size
})
return totalSize
},
collapseBookSeries(libraryItems, series, filterSeries, hideSingleBookSeries) {
// Get series from the library items. If this list is being collapsed after filtering for a series,
// don't collapse that series, only books that are in other series.
@ -356,550 +218,5 @@ module.exports = {
})
return filteredLibraryItems
},
async buildPersonalizedShelves(ctx, user, libraryItems, library, maxEntitiesPerShelf, include) {
const mediaType = library.mediaType
const isPodcastLibrary = mediaType === 'podcast'
const includeRssFeed = include.includes('rssfeed')
const includeNumEpisodesIncomplete = include.includes('numepisodesincomplete') // Podcasts only
const hideSingleBookSeries = library.settings.hideSingleBookSeries
const shelves = [
{
id: 'continue-listening',
label: 'Continue Listening',
labelStringKey: 'LabelContinueListening',
type: isPodcastLibrary ? 'episode' : mediaType,
entities: []
},
{
id: 'continue-reading',
label: 'Continue Reading',
labelStringKey: 'LabelContinueReading',
type: 'book',
entities: []
},
{
id: 'continue-series',
label: 'Continue Series',
labelStringKey: 'LabelContinueSeries',
type: mediaType,
entities: []
},
{
id: 'episodes-recently-added',
label: 'Newest Episodes',
labelStringKey: 'LabelNewestEpisodes',
type: 'episode',
entities: []
},
{
id: 'recently-added',
label: 'Recently Added',
labelStringKey: 'LabelRecentlyAdded',
type: mediaType,
entities: []
},
{
id: 'recent-series',
label: 'Recent Series',
labelStringKey: 'LabelRecentSeries',
type: 'series',
entities: []
},
{
id: 'recommended',
label: 'Recommended',
labelStringKey: 'LabelRecommended',
type: mediaType,
entities: []
},
{
id: 'listen-again',
label: 'Listen Again',
labelStringKey: 'LabelListenAgain',
type: isPodcastLibrary ? 'episode' : mediaType,
entities: []
},
{
id: 'read-again',
label: 'Read Again',
labelStringKey: 'LabelReadAgain',
type: 'book',
entities: []
},
{
id: 'newest-authors',
label: 'Newest Authors',
labelStringKey: 'LabelNewestAuthors',
type: 'authors',
entities: []
}
]
const categoryMap = {}
shelves.forEach((shelf) => {
categoryMap[shelf.id] = {
id: shelf.id,
biggest: 0,
smallest: 0,
items: []
}
})
const seriesMap = {}
const authorMap = {}
// For use with recommended
const topGenresListened = {}
const topAuthorsListened = {}
const topTagsListened = {}
const notStartedBooks = []
for (const libraryItem of libraryItems) {
if (libraryItem.addedAt > categoryMap['recently-added'].smallest) {
const libraryItemObj = libraryItem.toJSONMinified()
// add numEpisodesIncomplete if "include=numEpisodesIncomplete" was put in query string (only for podcasts)
if (includeNumEpisodesIncomplete && libraryItem.isPodcast) {
libraryItemObj.numEpisodesIncomplete = user.getNumEpisodesIncompleteForPodcast(libraryItem)
}
const indexToPut = categoryMap['recently-added'].items.findIndex(i => libraryItem.addedAt > i.addedAt)
if (indexToPut >= 0) {
categoryMap['recently-added'].items.splice(indexToPut, 0, libraryItemObj)
} else {
categoryMap['recently-added'].items.push(libraryItemObj)
}
if (categoryMap['recently-added'].items.length > maxEntitiesPerShelf) {
// Remove last item
categoryMap['recently-added'].items.pop()
categoryMap['recently-added'].smallest = categoryMap['recently-added'].items[categoryMap['recently-added'].items.length - 1].addedAt
}
categoryMap['recently-added'].biggest = categoryMap['recently-added'].items[0].addedAt
}
const allItemProgress = user.getAllMediaProgressForLibraryItem(libraryItem.id)
if (libraryItem.isPodcast) {
// Podcast categories
const podcastEpisodes = libraryItem.media.episodes || []
for (const episode of podcastEpisodes) {
const mediaProgress = allItemProgress.find(mp => mp.episodeId === episode.id)
// Newest episodes
if (!mediaProgress?.isFinished && episode.addedAt > categoryMap['episodes-recently-added'].smallest) {
const libraryItemWithEpisode = {
...libraryItem.toJSONMinified(),
recentEpisode: episode.toJSON()
}
const indexToPut = categoryMap['episodes-recently-added'].items.findIndex(i => episode.addedAt > i.recentEpisode.addedAt)
if (indexToPut >= 0) {
categoryMap['episodes-recently-added'].items.splice(indexToPut, 0, libraryItemWithEpisode)
} else {
categoryMap['episodes-recently-added'].items.push(libraryItemWithEpisode)
}
if (categoryMap['episodes-recently-added'].items.length > maxEntitiesPerShelf) {
// Remove last item
categoryMap['episodes-recently-added'].items.pop()
categoryMap['episodes-recently-added'].smallest = categoryMap['episodes-recently-added'].items[categoryMap['episodes-recently-added'].items.length - 1].recentEpisode.addedAt
}
categoryMap['episodes-recently-added'].biggest = categoryMap['episodes-recently-added'].items[0].recentEpisode.addedAt
}
// Episode recently listened and finished
if (mediaProgress) {
if (mediaProgress.isFinished) {
if (mediaProgress.finishedAt > categoryMap['listen-again'].smallest) { // Item belongs on shelf
const libraryItemWithEpisode = {
...libraryItem.toJSONMinified(),
recentEpisode: episode.toJSON(),
finishedAt: mediaProgress.finishedAt
}
const indexToPut = categoryMap['listen-again'].items.findIndex(i => mediaProgress.finishedAt > i.finishedAt)
if (indexToPut >= 0) {
categoryMap['listen-again'].items.splice(indexToPut, 0, libraryItemWithEpisode)
} else {
categoryMap['listen-again'].items.push(libraryItemWithEpisode)
}
if (categoryMap['listen-again'].items.length > maxEntitiesPerShelf) {
// Remove last item
categoryMap['listen-again'].items.pop()
categoryMap['listen-again'].smallest = categoryMap['listen-again'].items[categoryMap['listen-again'].items.length - 1].finishedAt
}
categoryMap['listen-again'].biggest = categoryMap['listen-again'].items[0].finishedAt
}
} else if (mediaProgress.inProgress && !mediaProgress.hideFromContinueListening) { // Handle most recently listened
if (mediaProgress.lastUpdate > categoryMap['continue-listening'].smallest) { // Item belongs on shelf
const libraryItemWithEpisode = {
...libraryItem.toJSONMinified(),
recentEpisode: episode.toJSON(),
progressLastUpdate: mediaProgress.lastUpdate
}
const indexToPut = categoryMap['continue-listening'].items.findIndex(i => mediaProgress.lastUpdate > i.progressLastUpdate)
if (indexToPut >= 0) {
categoryMap['continue-listening'].items.splice(indexToPut, 0, libraryItemWithEpisode)
} else {
categoryMap['continue-listening'].items.push(libraryItemWithEpisode)
}
if (categoryMap['continue-listening'].items.length > maxEntitiesPerShelf) {
// Remove last item
categoryMap['continue-listening'].items.pop()
categoryMap['continue-listening'].smallest = categoryMap['continue-listening'].items[categoryMap['continue-listening'].items.length - 1].progressLastUpdate
}
categoryMap['continue-listening'].biggest = categoryMap['continue-listening'].items[0].progressLastUpdate
}
}
}
}
} else if (libraryItem.isBook) {
// Book categories
const mediaProgress = allItemProgress.length ? allItemProgress[0] : null
// Used for recommended. Tally up most listened to authors/genres/tags
if (mediaProgress && (mediaProgress.inProgress || mediaProgress.isFinished)) {
libraryItem.media.metadata.authors.forEach((author) => {
topAuthorsListened[author.id] = (topAuthorsListened[author.id] || 0) + 1
})
libraryItem.media.metadata.genres.forEach((genre) => {
topGenresListened[genre] = (topGenresListened[genre] || 0) + 1
})
libraryItem.media.tags.forEach((tag) => {
topTagsListened[tag] = (topTagsListened[tag] || 0) + 1
})
} else {
// Insert in random position to add randomization to equal weighted items
notStartedBooks.splice(Math.floor(Math.random() * (notStartedBooks.length + 1)), 0, libraryItem)
}
// Newest series
if (libraryItem.media.metadata.series.length) {
for (const librarySeries of libraryItem.media.metadata.series) {
const bookInProgress = mediaProgress && (mediaProgress.inProgress || mediaProgress.isFinished)
const bookActive = mediaProgress && mediaProgress.inProgress && !mediaProgress.isFinished
const libraryItemJson = libraryItem.toJSONMinified()
libraryItemJson.seriesSequence = librarySeries.sequence
const hideFromContinueListening = user.checkShouldHideSeriesFromContinueListening(librarySeries.id)
if (!seriesMap[librarySeries.id]) {
const seriesObj = Database.series.find(se => se.id === librarySeries.id)
if (seriesObj) {
const series = {
...seriesObj.toJSON(),
books: [libraryItemJson],
inProgress: bookInProgress,
hasActiveBook: bookActive,
hideFromContinueListening,
bookInProgressLastUpdate: bookInProgress ? mediaProgress.lastUpdate : null,
firstBookUnread: bookInProgress ? null : libraryItemJson
}
seriesMap[librarySeries.id] = series
const indexToPut = categoryMap['recent-series'].items.findIndex(i => series.addedAt > i.addedAt)
if (indexToPut >= 0) {
categoryMap['recent-series'].items.splice(indexToPut, 0, series)
} else {
categoryMap['recent-series'].items.push(series)
}
}
} else {
// series already in map - add book
seriesMap[librarySeries.id].books.push(libraryItemJson)
if (bookInProgress) { // Update if this series is in progress
seriesMap[librarySeries.id].inProgress = true
if (seriesMap[librarySeries.id].bookInProgressLastUpdate < mediaProgress.lastUpdate) {
seriesMap[librarySeries.id].bookInProgressLastUpdate = mediaProgress.lastUpdate
}
} else if (!seriesMap[librarySeries.id].firstBookUnread) {
seriesMap[librarySeries.id].firstBookUnread = libraryItemJson
} else if (libraryItemJson.seriesSequence) {
// If current firstBookUnread has a series sequence greater than this series sequence, then update firstBookUnread
const firstBookUnreadSequence = seriesMap[librarySeries.id].firstBookUnread.seriesSequence
if (!firstBookUnreadSequence || String(firstBookUnreadSequence).localeCompare(String(librarySeries.sequence), undefined, { sensitivity: 'base', numeric: true }) > 0) {
seriesMap[librarySeries.id].firstBookUnread = libraryItemJson
}
}
// Update if series has an active (progress < 100%) book
if (bookActive) {
seriesMap[librarySeries.id].hasActiveBook = true
}
}
}
}
// Newest authors
if (libraryItem.media.metadata.authors.length) {
for (const libraryAuthor of libraryItem.media.metadata.authors) {
if (!authorMap[libraryAuthor.id]) {
const authorObj = Database.authors.find(au => au.id === libraryAuthor.id)
if (authorObj) {
const author = {
...authorObj.toJSON(),
numBooks: 1
}
if (author.addedAt > categoryMap['newest-authors'].smallest) {
const indexToPut = categoryMap['newest-authors'].items.findIndex(i => author.addedAt > i.addedAt)
if (indexToPut >= 0) {
categoryMap['newest-authors'].items.splice(indexToPut, 0, author)
} else {
categoryMap['newest-authors'].items.push(author)
}
// Max authors is 10
if (categoryMap['newest-authors'].items.length > 10) {
categoryMap['newest-authors'].items.pop()
categoryMap['newest-authors'].smallest = categoryMap['newest-authors'].items[categoryMap['newest-authors'].items.length - 1].addedAt
}
categoryMap['newest-authors'].biggest = categoryMap['newest-authors'].items[0].addedAt
}
authorMap[libraryAuthor.id] = author
}
} else {
authorMap[libraryAuthor.id].numBooks++
}
}
}
// Book listening and finished
if (mediaProgress) {
const categoryId = libraryItem.media.isEBookOnly ? 'read-again' : 'listen-again'
// Handle most recently finished
if (mediaProgress.isFinished) {
if (mediaProgress.finishedAt > categoryMap[categoryId].smallest) { // Item belongs on shelf
const libraryItemObj = {
...libraryItem.toJSONMinified(),
finishedAt: mediaProgress.finishedAt
}
const indexToPut = categoryMap[categoryId].items.findIndex(i => mediaProgress.finishedAt > i.finishedAt)
if (indexToPut >= 0) {
categoryMap[categoryId].items.splice(indexToPut, 0, libraryItemObj)
} else {
categoryMap[categoryId].items.push(libraryItemObj)
}
if (categoryMap[categoryId].items.length > maxEntitiesPerShelf) {
// Remove last item
categoryMap[categoryId].items.pop()
categoryMap[categoryId].smallest = categoryMap[categoryId].items[categoryMap[categoryId].items.length - 1].finishedAt
}
categoryMap[categoryId].biggest = categoryMap[categoryId].items[0].finishedAt
}
} else if (mediaProgress.inProgress && !mediaProgress.hideFromContinueListening) { // Handle most recently listened
const categoryId = libraryItem.media.isEBookOnly ? 'continue-reading' : 'continue-listening'
if (mediaProgress.lastUpdate > categoryMap[categoryId].smallest) { // Item belongs on shelf
const libraryItemObj = {
...libraryItem.toJSONMinified(),
progressLastUpdate: mediaProgress.lastUpdate
}
const indexToPut = categoryMap[categoryId].items.findIndex(i => mediaProgress.lastUpdate > i.progressLastUpdate)
if (indexToPut >= 0) {
categoryMap[categoryId].items.splice(indexToPut, 0, libraryItemObj)
} else { // Should only happen when array is < max
categoryMap[categoryId].items.push(libraryItemObj)
}
if (categoryMap[categoryId].items.length > maxEntitiesPerShelf) {
// Remove last item
categoryMap[categoryId].items.pop()
categoryMap[categoryId].smallest = categoryMap[categoryId].items[categoryMap[categoryId].items.length - 1].progressLastUpdate
}
categoryMap[categoryId].biggest = categoryMap[categoryId].items[0].progressLastUpdate
}
}
}
}
}
// For Continue Series - Find next book in series for series that are in progress
for (const seriesId in seriesMap) {
seriesMap[seriesId].books = naturalSort(seriesMap[seriesId].books).asc(li => li.seriesSequence)
if (seriesMap[seriesId].inProgress && !seriesMap[seriesId].hideFromContinueListening) {
// take the first book unread with the smallest series sequence
// unless the user is already listening to a book from this series
const hasActiveBook = seriesMap[seriesId].hasActiveBook
const nextBookInSeries = seriesMap[seriesId].firstBookUnread
if (!hasActiveBook && nextBookInSeries) {
const bookForContinueSeries = {
...nextBookInSeries,
prevBookInProgressLastUpdate: seriesMap[seriesId].bookInProgressLastUpdate
}
bookForContinueSeries.media.metadata.series = {
id: seriesId,
name: seriesMap[seriesId].name,
sequence: nextBookInSeries.seriesSequence
}
const indexToPut = categoryMap['continue-series'].items.findIndex(i => i.prevBookInProgressLastUpdate < bookForContinueSeries.prevBookInProgressLastUpdate)
if (!categoryMap['continue-series'].items.find(book => book.id === bookForContinueSeries.id)) {
if (indexToPut >= 0) {
categoryMap['continue-series'].items.splice(indexToPut, 0, bookForContinueSeries)
} else if (categoryMap['continue-series'].items.length < 10) { // Max 10 books
categoryMap['continue-series'].items.push(bookForContinueSeries)
}
}
}
}
}
// For recommended
if (!isPodcastLibrary && notStartedBooks.length) {
const genresCount = Object.values(topGenresListened).reduce((a, b) => a + b, 0)
const authorsCount = Object.values(topAuthorsListened).reduce((a, b) => a + b, 0)
const tagsCount = Object.values(topTagsListened).reduce((a, b) => a + b, 0)
for (const libraryItem of notStartedBooks) {
// dont include books in an unfinished series and books that are not first in an unstarted series
let shouldContinue = !libraryItem.media.metadata.series.length
libraryItem.media.metadata.series.forEach((se) => {
if (seriesMap[se.id]) {
if (seriesMap[se.id].inProgress) {
shouldContinue = false
return
} else if (seriesMap[se.id].books[0].id === libraryItem.id) {
shouldContinue = true
}
}
})
if (!shouldContinue) {
continue;
}
let totalWeight = 0
if (authorsCount > 0) {
libraryItem.media.metadata.authors.forEach((author) => {
if (topAuthorsListened[author.id]) {
totalWeight += topAuthorsListened[author.id] / authorsCount
}
})
}
if (genresCount > 0) {
libraryItem.media.metadata.genres.forEach((genre) => {
if (topGenresListened[genre]) {
totalWeight += topGenresListened[genre] / genresCount
}
})
}
if (tagsCount > 0) {
libraryItem.media.tags.forEach((tag) => {
if (topTagsListened[tag]) {
totalWeight += topTagsListened[tag] / tagsCount
}
})
}
if (!categoryMap.recommended.smallest || totalWeight > categoryMap.recommended.smallest) {
const libraryItemObj = {
...libraryItem.toJSONMinified(),
weight: totalWeight
}
const indexToPut = categoryMap.recommended.items.findIndex(i => totalWeight > i.weight)
if (indexToPut >= 0) {
categoryMap.recommended.items.splice(indexToPut, 0, libraryItemObj)
} else {
categoryMap.recommended.items.push(libraryItemObj)
}
if (categoryMap.recommended.items.length > maxEntitiesPerShelf) {
categoryMap.recommended.items.pop()
categoryMap.recommended.smallest = categoryMap.recommended.items[categoryMap.recommended.items.length - 1].weight
}
}
}
}
// Sort series books by sequence
if (categoryMap['recent-series'].items.length) {
if (hideSingleBookSeries) {
categoryMap['recent-series'].items = categoryMap['recent-series'].items.filter(seriesItem => seriesItem.books.length > 1)
}
// Limit series shown to 5
categoryMap['recent-series'].items = categoryMap['recent-series'].items.slice(0, 5)
for (const seriesItem of categoryMap['recent-series'].items) {
seriesItem.books = naturalSort(seriesItem.books).asc(li => li.seriesSequence)
}
}
const categoriesWithItems = Object.values(categoryMap).filter(cat => cat.items.length)
const finalShelves = []
for (const categoryWithItems of categoriesWithItems) {
const shelf = shelves.find(s => s.id === categoryWithItems.id)
shelf.entities = categoryWithItems.items
// Add rssFeed to entities if query string "include=rssfeed" was on request
if (includeRssFeed) {
if (shelf.type === 'book' || shelf.type === 'podcast') {
shelf.entities = await Promise.all(shelf.entities.map(async (item) => {
const feed = await ctx.rssFeedManager.findFeedForEntityId(item.id)
item.rssFeed = feed?.toJSONMinified() || null
return item
}))
} else if (shelf.type === 'series') {
shelf.entities = await Promise.all(shelf.entities.map(async (series) => {
const feed = await ctx.rssFeedManager.findFeedForEntityId(series.id)
series.rssFeed = feed?.toJSONMinified() || null
return series
}))
}
}
finalShelves.push(shelf)
}
return finalShelves
},
groupMusicLibraryItemsIntoAlbums(libraryItems) {
const albums = {}
libraryItems.forEach((li) => {
const albumTitle = li.media.metadata.album
const albumArtist = li.media.metadata.albumArtist
if (albumTitle && !albums[albumTitle]) {
albums[albumTitle] = {
title: albumTitle,
artist: albumArtist,
libraryItemId: li.media.coverPath ? li.id : null,
numTracks: 1
}
} else if (albumTitle && albums[albumTitle].artist === albumArtist) {
if (!albums[albumTitle].libraryItemId && li.media.coverPath) albums[albumTitle].libraryItemId = li.id
albums[albumTitle].numTracks++
} else {
if (albumTitle) {
Logger.warn(`Music track "${li.media.metadata.title}" with album "${albumTitle}" has a different album artist then another track in the same album. This track album artist is "${albumArtist}" but the album artist is already set to "${albums[albumTitle].artist}"`)
}
if (!albums['_none_']) albums['_none_'] = { title: 'No Album', artist: 'Various Artists', libraryItemId: null, numTracks: 0 }
albums['_none_'].numTracks++
}
})
return Object.values(albums)
}
}

View File

@ -0,0 +1,70 @@
const Sequelize = require('sequelize')
const Database = require('../../Database')
module.exports = {
/**
* Get authors with count of num books
* @param {string} libraryId
* @returns {{id:string, name:string, count:number}}
*/
async getAuthorsWithCount(libraryId) {
const authors = await Database.authorModel.findAll({
where: [
{
libraryId
},
Sequelize.where(Sequelize.literal('count'), {
[Sequelize.Op.gt]: 0
})
],
attributes: [
'id',
'name',
[Sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 'count']
],
order: [
['count', 'DESC']
]
})
return authors.map(au => {
return {
id: au.id,
name: au.name,
count: au.dataValues.count
}
})
},
/**
* Search authors
* @param {string} libraryId
* @param {string} query
* @param {number} limit
* @param {number} offset
* @returns {object[]} oldAuthor with numBooks
*/
async search(libraryId, query, limit, offset) {
const authors = await Database.authorModel.findAll({
where: {
name: {
[Sequelize.Op.substring]: query
},
libraryId
},
attributes: {
include: [
[Sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 'numBooks']
]
},
limit,
offset
})
const authorMatches = []
for (const author of authors) {
const oldAuthor = author.getOldAuthor().toJSON()
oldAuthor.numBooks = author.dataValues.numBooks
authorMatches.push(oldAuthor)
}
return authorMatches
}
}

View File

@ -15,8 +15,8 @@ module.exports = {
/**
* Get library items using filter and sort
* @param {oldLibrary} library
* @param {oldUser} user
* @param {import('../../objects/Library')} library
* @param {import('../../objects/user/User')} user
* @param {object} options
* @returns {object} { libraryItems:LibraryItem[], count:number }
*/
@ -41,20 +41,20 @@ module.exports = {
/**
* Get library items for continue listening & continue reading shelves
* @param {oldLibrary} library
* @param {oldUser} user
* @param {import('../../objects/Library')} library
* @param {import('../../objects/user/User')} user
* @param {string[]} include
* @param {number} limit
* @returns {object} { items:LibraryItem[], count:number }
* @returns {Promise<{ items:import('../../models/LibraryItem')[], count:number }>}
*/
async getMediaItemsInProgress(library, user, include, limit) {
if (library.mediaType === 'book') {
const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, 'progress', 'in-progress', 'progress', true, false, include, limit, 0)
return {
items: libraryItems.map(li => {
const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(li).toJSONMinified()
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
if (li.rssFeed) {
oldLibraryItem.rssFeed = Database.models.feed.getOldFeed(li.rssFeed).toJSONMinified()
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified()
}
return oldLibraryItem
}),
@ -65,7 +65,7 @@ module.exports = {
return {
count,
items: libraryItems.map(li => {
const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(li).toJSONMinified()
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
oldLibraryItem.recentEpisode = li.recentEpisode
return oldLibraryItem
})
@ -86,9 +86,9 @@ module.exports = {
const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, 'recent', null, 'addedAt', true, false, include, limit, 0)
return {
libraryItems: libraryItems.map(li => {
const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(li).toJSONMinified()
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
if (li.rssFeed) {
oldLibraryItem.rssFeed = Database.models.feed.getOldFeed(li.rssFeed).toJSONMinified()
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified()
}
if (li.size && !oldLibraryItem.media.size) {
oldLibraryItem.media.size = li.size
@ -101,9 +101,9 @@ module.exports = {
const { libraryItems, count } = await libraryItemsPodcastFilters.getFilteredLibraryItems(library.id, user, 'recent', null, 'addedAt', true, include, limit, 0)
return {
libraryItems: libraryItems.map(li => {
const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(li).toJSONMinified()
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
if (li.rssFeed) {
oldLibraryItem.rssFeed = Database.models.feed.getOldFeed(li.rssFeed).toJSONMinified()
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified()
}
if (li.size && !oldLibraryItem.media.size) {
oldLibraryItem.media.size = li.size
@ -127,9 +127,9 @@ module.exports = {
const { libraryItems, count } = await libraryItemsBookFilters.getContinueSeriesLibraryItems(library.id, user, include, limit, 0)
return {
libraryItems: libraryItems.map(li => {
const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(li).toJSONMinified()
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
if (li.rssFeed) {
oldLibraryItem.rssFeed = Database.models.feed.getOldFeed(li.rssFeed).toJSONMinified()
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified()
}
if (li.series) {
oldLibraryItem.media.metadata.series = li.series
@ -153,9 +153,9 @@ module.exports = {
const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, 'progress', 'finished', 'progress', true, false, include, limit, 0)
return {
items: libraryItems.map(li => {
const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(li).toJSONMinified()
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
if (li.rssFeed) {
oldLibraryItem.rssFeed = Database.models.feed.getOldFeed(li.rssFeed).toJSONMinified()
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified()
}
return oldLibraryItem
}),
@ -166,7 +166,7 @@ module.exports = {
return {
count,
items: libraryItems.map(li => {
const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(li).toJSONMinified()
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
oldLibraryItem.recentEpisode = li.recentEpisode
return oldLibraryItem
})
@ -176,19 +176,19 @@ module.exports = {
/**
* Get series for recent series shelf
* @param {oldLibrary} library
* @param {oldUser} user
* @param {import('../../objects/Library')} library
* @param {import('../../objects/user/User')} user
* @param {string[]} include
* @param {number} limit
* @returns {object} { series:oldSeries[], count:number}
* @returns {{ series:import('../../objects/entities/Series')[], count:number}}
*/
async getSeriesMostRecentlyAdded(library, user, include, limit) {
if (library.mediaType !== 'book') return { series: [], count: 0 }
if (!library.isBook) return { series: [], count: 0 }
const seriesIncludes = []
if (include.includes('rssfeed')) {
seriesIncludes.push({
model: Database.models.feed
model: Database.feedModel
})
}
@ -221,7 +221,7 @@ module.exports = {
}))
}
const { rows: series, count } = await Database.models.series.findAndCountAll({
const { rows: series, count } = await Database.seriesModel.findAndCountAll({
where: seriesWhere,
limit,
offset: 0,
@ -230,12 +230,12 @@ module.exports = {
replacements: userPermissionBookWhere.replacements,
include: [
{
model: Database.models.bookSeries,
model: Database.bookSeriesModel,
include: {
model: Database.models.book,
model: Database.bookModel,
where: userPermissionBookWhere.bookWhere,
include: {
model: Database.models.libraryItem
model: Database.libraryItemModel
}
},
separate: true
@ -252,7 +252,7 @@ module.exports = {
const oldSeries = s.getOldSeries().toJSON()
if (s.feeds?.length) {
oldSeries.rssFeed = Database.models.feed.getOldFeed(s.feeds[0]).toJSONMinified()
oldSeries.rssFeed = Database.feedModel.getOldFeed(s.feeds[0]).toJSONMinified()
}
// TODO: Sort books by sequence in query
@ -268,7 +268,7 @@ module.exports = {
const libraryItem = bs.book.libraryItem.toJSON()
delete bs.book.libraryItem
libraryItem.media = bs.book
const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(libraryItem).toJSONMinified()
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONMinified()
return oldLibraryItem
})
allOldSeries.push(oldSeries)
@ -291,7 +291,7 @@ module.exports = {
async getNewestAuthors(library, user, limit) {
if (library.mediaType !== 'book') return { authors: [], count: 0 }
const { rows: authors, count } = await Database.models.author.findAndCountAll({
const { rows: authors, count } = await Database.authorModel.findAndCountAll({
where: {
libraryId: library.id,
createdAt: {
@ -299,7 +299,7 @@ module.exports = {
}
},
include: {
model: Database.models.bookAuthor,
model: Database.bookAuthorModel,
required: true // Must belong to a book
},
limit,
@ -332,9 +332,9 @@ module.exports = {
const { libraryItems, count } = await libraryItemsBookFilters.getDiscoverLibraryItems(library.id, user, include, limit)
return {
libraryItems: libraryItems.map(li => {
const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(li).toJSONMinified()
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
if (li.rssFeed) {
oldLibraryItem.rssFeed = Database.models.feed.getOldFeed(li.rssFeed).toJSONMinified()
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified()
}
return oldLibraryItem
}),
@ -356,7 +356,7 @@ module.exports = {
return {
count,
libraryItems: libraryItems.map(li => {
const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(li).toJSONMinified()
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
oldLibraryItem.recentEpisode = li.recentEpisode
return oldLibraryItem
})
@ -390,7 +390,7 @@ module.exports = {
/**
* Get filter data used in filter menus
* @param {oldLibrary} oldLibrary
* @param {import('../../objects/Library')} oldLibrary
* @returns {Promise<object>}
*/
async getFilterData(oldLibrary) {
@ -417,9 +417,9 @@ module.exports = {
}
if (oldLibrary.isPodcast) {
const podcasts = await Database.models.podcast.findAll({
const podcasts = await Database.podcastModel.findAll({
include: {
model: Database.models.libraryItem,
model: Database.libraryItemModel,
attributes: [],
where: {
libraryId: oldLibrary.id
@ -436,9 +436,9 @@ module.exports = {
}
}
} else {
const books = await Database.models.book.findAll({
const books = await Database.bookModel.findAll({
include: {
model: Database.models.libraryItem,
model: Database.libraryItemModel,
attributes: ['isMissing', 'isInvalid'],
where: {
libraryId: oldLibrary.id
@ -461,7 +461,7 @@ module.exports = {
if (book.language) data.languages.add(book.language)
}
const series = await Database.models.series.findAll({
const series = await Database.seriesModel.findAll({
where: {
libraryId: oldLibrary.id
},
@ -469,7 +469,7 @@ module.exports = {
})
series.forEach((s) => data.series.push({ id: s.id, name: s.name }))
const authors = await Database.models.author.findAll({
const authors = await Database.authorModel.findAll({
where: {
libraryId: oldLibrary.id
},

View File

@ -1,15 +1,17 @@
const Sequelize = require('sequelize')
const Database = require('../../Database')
const libraryItemsBookFilters = require('./libraryItemsBookFilters')
const libraryItemsPodcastFilters = require('./libraryItemsPodcastFilters')
module.exports = {
/**
* Get all library items that have tags
* @param {string[]} tags
* @returns {Promise<LibraryItem[]>}
* @returns {Promise<import('../../models/LibraryItem')[]>}
*/
async getAllLibraryItemsWithTags(tags) {
const libraryItems = []
const booksWithTag = await Database.models.book.findAll({
const booksWithTag = await Database.bookModel.findAll({
where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:tags))`), {
[Sequelize.Op.gte]: 1
}),
@ -18,16 +20,16 @@ module.exports = {
},
include: [
{
model: Database.models.libraryItem
model: Database.libraryItemModel
},
{
model: Database.models.author,
model: Database.authorModel,
through: {
attributes: []
}
},
{
model: Database.models.series,
model: Database.seriesModel,
through: {
attributes: ['sequence']
}
@ -39,7 +41,7 @@ module.exports = {
libraryItem.media = book
libraryItems.push(libraryItem)
}
const podcastsWithTag = await Database.models.podcast.findAll({
const podcastsWithTag = await Database.podcastModel.findAll({
where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:tags))`), {
[Sequelize.Op.gte]: 1
}),
@ -48,10 +50,10 @@ module.exports = {
},
include: [
{
model: Database.models.libraryItem
model: Database.libraryItemModel
},
{
model: Database.models.podcastEpisode
model: Database.podcastEpisodeModel
}
]
})
@ -70,7 +72,7 @@ module.exports = {
*/
async getAllLibraryItemsWithGenres(genres) {
const libraryItems = []
const booksWithGenre = await Database.models.book.findAll({
const booksWithGenre = await Database.bookModel.findAll({
where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(genres) WHERE json_valid(genres) AND json_each.value IN (:genres))`), {
[Sequelize.Op.gte]: 1
}),
@ -79,16 +81,16 @@ module.exports = {
},
include: [
{
model: Database.models.libraryItem
model: Database.libraryItemModel
},
{
model: Database.models.author,
model: Database.authorModel,
through: {
attributes: []
}
},
{
model: Database.models.series,
model: Database.seriesModel,
through: {
attributes: ['sequence']
}
@ -100,7 +102,7 @@ module.exports = {
libraryItem.media = book
libraryItems.push(libraryItem)
}
const podcastsWithGenre = await Database.models.podcast.findAll({
const podcastsWithGenre = await Database.podcastModel.findAll({
where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(genres) WHERE json_valid(genres) AND json_each.value IN (:genres))`), {
[Sequelize.Op.gte]: 1
}),
@ -109,10 +111,10 @@ module.exports = {
},
include: [
{
model: Database.models.libraryItem
model: Database.libraryItemModel
},
{
model: Database.models.podcastEpisode
model: Database.podcastEpisodeModel
}
]
})
@ -127,11 +129,11 @@ module.exports = {
/**
* Get all library items that have narrators
* @param {string[]} narrators
* @returns {Promise<LibraryItem[]>}
* @returns {Promise<import('../../models/LibraryItem')[]>}
*/
async getAllLibraryItemsWithNarrators(narrators) {
const libraryItems = []
const booksWithGenre = await Database.models.book.findAll({
const booksWithGenre = await Database.bookModel.findAll({
where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(narrators) WHERE json_valid(narrators) AND json_each.value IN (:narrators))`), {
[Sequelize.Op.gte]: 1
}),
@ -140,16 +142,16 @@ module.exports = {
},
include: [
{
model: Database.models.libraryItem
model: Database.libraryItemModel
},
{
model: Database.models.author,
model: Database.authorModel,
through: {
attributes: []
}
},
{
model: Database.models.series,
model: Database.seriesModel,
through: {
attributes: ['sequence']
}
@ -162,5 +164,57 @@ module.exports = {
libraryItems.push(libraryItem)
}
return libraryItems
},
/**
* Search library items
* @param {import('../../objects/user/User')} oldUser
* @param {import('../../objects/Library')} oldLibrary
* @param {string} query
* @param {number} limit
* @returns {{book:object[], narrators:object[], authors:object[], tags:object[], series:object[], podcast:object[]}}
*/
search(oldUser, oldLibrary, query, limit) {
if (oldLibrary.isBook) {
return libraryItemsBookFilters.search(oldUser, oldLibrary, query, limit, 0)
} else {
return libraryItemsPodcastFilters.search(oldUser, oldLibrary, query, limit, 0)
}
},
/**
* Get largest items in library
* @param {string} libraryId
* @param {number} limit
* @returns {Promise<{ id:string, title:string, size:number }[]>}
*/
async getLargestItems(libraryId, limit) {
const libraryItems = await Database.libraryItemModel.findAll({
attributes: ['id', 'mediaId', 'mediaType', 'size'],
where: {
libraryId
},
include: [
{
model: Database.bookModel,
attributes: ['id', 'title']
},
{
model: Database.podcastModel,
attributes: ['id', 'title']
}
],
order: [
['size', 'DESC']
],
limit
})
return libraryItems.map(libraryItem => {
return {
id: libraryItem.id,
title: libraryItem.media.title,
size: libraryItem.size
}
})
}
}

View File

@ -1,12 +1,13 @@
const Sequelize = require('sequelize')
const Database = require('../../Database')
const Logger = require('../../Logger')
const authorFilters = require('./authorFilters')
module.exports = {
/**
* User permissions to restrict books for explicit content & tags
* @param {oldUser} user
* @returns {object} { bookWhere:Sequelize.WhereOptions, replacements:string[] }
* @param {import('../../objects/user/User')} user
* @returns {{ bookWhere:Sequelize.WhereOptions, replacements:object }}
*/
getUserPermissionBookWhereQuery(user) {
const bookWhere = []
@ -278,7 +279,7 @@ module.exports = {
* @returns {object} { booksToExclude, bookSeriesToInclude }
*/
async getCollapseSeriesBooksToExclude(bookFindOptions, seriesWhere) {
const allSeries = await Database.models.series.findAll({
const allSeries = await Database.seriesModel.findAll({
attributes: [
'id',
'name',
@ -289,7 +290,7 @@ module.exports = {
where: seriesWhere,
include: [
{
model: Database.models.book,
model: Database.bookModel,
attributes: ['id', 'title'],
through: {
attributes: ['id', 'seriesId', 'bookId', 'sequence']
@ -373,10 +374,10 @@ module.exports = {
}
let seriesInclude = {
model: Database.models.bookSeries,
model: Database.bookSeriesModel,
attributes: ['id', 'seriesId', 'sequence', 'createdAt'],
include: {
model: Database.models.series,
model: Database.seriesModel,
attributes: ['id', 'name', 'nameIgnorePrefix']
},
order: [
@ -386,10 +387,10 @@ module.exports = {
}
let authorInclude = {
model: Database.models.bookAuthor,
model: Database.bookAuthorModel,
attributes: ['authorId', 'createdAt'],
include: {
model: Database.models.author,
model: Database.authorModel,
attributes: ['id', 'name']
},
order: [
@ -404,13 +405,13 @@ module.exports = {
const bookIncludes = []
if (includeRSSFeed) {
libraryItemIncludes.push({
model: Database.models.feed,
model: Database.feedModel,
required: filterGroup === 'feed-open'
})
}
if (filterGroup === 'feed-open' && !includeRSSFeed) {
libraryItemIncludes.push({
model: Database.models.feed,
model: Database.feedModel,
required: true
})
} else if (filterGroup === 'ebooks' && filterValue === 'supplementary') {
@ -420,7 +421,7 @@ module.exports = {
}
} else if (filterGroup === 'missing' && filterValue === 'authors') {
authorInclude = {
model: Database.models.author,
model: Database.authorModel,
attributes: ['id'],
through: {
attributes: []
@ -428,7 +429,7 @@ module.exports = {
}
} else if ((filterGroup === 'series' && filterValue === 'no-series') || (filterGroup === 'missing' && filterValue === 'series')) {
seriesInclude = {
model: Database.models.series,
model: Database.seriesModel,
attributes: ['id'],
through: {
attributes: []
@ -436,7 +437,7 @@ module.exports = {
}
} else if (filterGroup === 'authors') {
bookIncludes.push({
model: Database.models.author,
model: Database.authorModel,
attributes: ['id', 'name'],
where: {
id: filterValue
@ -447,7 +448,7 @@ module.exports = {
})
} else if (filterGroup === 'series') {
bookIncludes.push({
model: Database.models.series,
model: Database.seriesModel,
attributes: ['id', 'name'],
where: {
id: filterValue
@ -471,7 +472,7 @@ module.exports = {
]
} else if (filterGroup === 'progress' && user) {
bookIncludes.push({
model: Database.models.mediaProgress,
model: Database.mediaProgressModel,
attributes: ['id', 'isFinished', 'currentTime', 'ebookProgress', 'updatedAt'],
where: {
userId: user.id
@ -512,7 +513,7 @@ module.exports = {
where: seriesBookWhere,
include: [
{
model: Database.models.libraryItem,
model: Database.libraryItemModel,
required: true,
where: libraryItemWhere,
include: libraryItemIncludes
@ -541,7 +542,7 @@ module.exports = {
}
}
const { rows: books, count } = await Database.models.book.findAndCountAll({
const { rows: books, count } = await Database.bookModel.findAndCountAll({
where: bookWhere,
distinct: true,
attributes: bookAttributes,
@ -552,7 +553,7 @@ module.exports = {
},
include: [
{
model: Database.models.libraryItem,
model: Database.libraryItemModel,
required: true,
where: libraryItemWhere,
include: libraryItemIncludes
@ -632,7 +633,7 @@ module.exports = {
const libraryItemIncludes = []
if (include.includes('rssfeed')) {
libraryItemIncludes.push({
model: Database.models.feed
model: Database.feedModel
})
}
@ -642,7 +643,7 @@ module.exports = {
const userPermissionBookWhere = this.getUserPermissionBookWhereQuery(user)
bookWhere.push(...userPermissionBookWhere.bookWhere)
const { rows: series, count } = await Database.models.series.findAndCountAll({
const { rows: series, count } = await Database.seriesModel.findAndCountAll({
where: [
{
libraryId
@ -669,7 +670,7 @@ module.exports = {
...userPermissionBookWhere.replacements
},
include: {
model: Database.models.bookSeries,
model: Database.bookSeriesModel,
attributes: ['bookId', 'sequence'],
separate: true,
subQuery: false,
@ -682,21 +683,21 @@ module.exports = {
}
},
include: {
model: Database.models.book,
model: Database.bookModel,
where: bookWhere,
include: [
{
model: Database.models.libraryItem,
model: Database.libraryItemModel,
include: libraryItemIncludes
},
{
model: Database.models.author,
model: Database.authorModel,
through: {
attributes: []
}
},
{
model: Database.models.mediaProgress,
model: Database.mediaProgressModel,
where: {
userId: user.id
},
@ -751,7 +752,7 @@ module.exports = {
const userPermissionBookWhere = this.getUserPermissionBookWhereQuery(user)
// Step 1: Get the first book of every series that hasnt been started yet
const seriesNotStarted = await Database.models.series.findAll({
const seriesNotStarted = await Database.seriesModel.findAll({
where: [
{
libraryId
@ -764,12 +765,12 @@ module.exports = {
},
attributes: ['id'],
include: {
model: Database.models.bookSeries,
model: Database.bookSeriesModel,
attributes: ['bookId', 'sequence'],
separate: true,
required: true,
include: {
model: Database.models.book,
model: Database.bookModel,
where: userPermissionBookWhere.bookWhere
},
order: [
@ -788,12 +789,12 @@ module.exports = {
const libraryItemIncludes = []
if (include.includes('rssfeed')) {
libraryItemIncludes.push({
model: Database.models.feed
model: Database.feedModel
})
}
// Step 2: Get books not started and not in a series OR is the first book of a series not started (ordered randomly)
const { rows: books, count } = await Database.models.book.findAndCountAll({
const { rows: books, count } = await Database.bookModel.findAndCountAll({
where: [
{
'$mediaProgresses.isFinished$': {
@ -816,32 +817,32 @@ module.exports = {
replacements: userPermissionBookWhere.replacements,
include: [
{
model: Database.models.libraryItem,
model: Database.libraryItemModel,
where: {
libraryId
},
include: libraryItemIncludes
},
{
model: Database.models.mediaProgress,
model: Database.mediaProgressModel,
where: {
userId: user.id
},
required: false
},
{
model: Database.models.bookAuthor,
model: Database.bookAuthorModel,
attributes: ['authorId'],
include: {
model: Database.models.author
model: Database.authorModel
},
separate: true
},
{
model: Database.models.bookSeries,
model: Database.bookSeriesModel,
attributes: ['seriesId', 'sequence'],
include: {
model: Database.models.series
model: Database.seriesModel
},
separate: true
}
@ -883,10 +884,10 @@ module.exports = {
return []
}
const books = await Database.models.book.findAll({
const books = await Database.bookModel.findAll({
include: [
{
model: Database.models.libraryItem,
model: Database.libraryItemModel,
where: {
id: {
[Sequelize.Op.in]: collection.books
@ -894,13 +895,13 @@ module.exports = {
}
},
{
model: Database.models.author,
model: Database.authorModel,
through: {
attributes: []
}
},
{
model: Database.models.series,
model: Database.seriesModel,
through: {
attributes: ['sequence']
}
@ -918,12 +919,260 @@ module.exports = {
/**
* Get library items for series
* @param {oldSeries} oldSeries
* @param {[oldUser]} oldUser
* @returns {Promise<oldLibraryItem[]>}
* @param {import('../../objects/entities/Series')} oldSeries
* @param {import('../../objects/user/User')} [oldUser]
* @returns {Promise<import('../../objects/LibraryItem')[]>}
*/
async getLibraryItemsForSeries(oldSeries, oldUser) {
const { libraryItems } = await this.getFilteredLibraryItems(oldSeries.libraryId, oldUser, 'series', oldSeries.id, null, null, false, [], null, null)
return libraryItems.map(li => Database.models.libraryItem.getOldLibraryItem(li))
return libraryItems.map(li => Database.libraryItemModel.getOldLibraryItem(li))
},
/**
* Search books, authors, series
* @param {import('../../objects/user/User')} oldUser
* @param {import('../../objects/Library')} oldLibrary
* @param {string} query
* @param {number} limit
* @param {number} offset
* @returns {{book:object[], narrators:object[], authors:object[], tags:object[], series:object[]}}
*/
async search(oldUser, oldLibrary, query, limit, offset) {
const userPermissionBookWhere = this.getUserPermissionBookWhereQuery(oldUser)
// Search title, subtitle, asin, isbn
const books = await Database.bookModel.findAll({
where: [
{
[Sequelize.Op.or]: [
{
title: {
[Sequelize.Op.substring]: query
}
},
{
subtitle: {
[Sequelize.Op.substring]: query
}
},
{
asin: {
[Sequelize.Op.substring]: query
}
},
{
isbn: {
[Sequelize.Op.substring]: query
}
}
]
},
...userPermissionBookWhere.bookWhere
],
replacements: userPermissionBookWhere.replacements,
include: [
{
model: Database.libraryItemModel,
where: {
libraryId: oldLibrary.id
}
},
{
model: Database.bookSeriesModel,
include: {
model: Database.seriesModel
},
separate: true
},
{
model: Database.bookAuthorModel,
include: {
model: Database.authorModel
},
separate: true
}
],
subQuery: false,
distinct: true,
limit,
offset
})
const itemMatches = []
for (const book of books) {
const libraryItem = book.libraryItem
delete book.libraryItem
libraryItem.media = book
let matchText = null
let matchKey = null
for (const key of ['title', 'subtitle', 'asin', 'isbn']) {
if (book[key]?.toLowerCase().includes(query)) {
matchText = book[key]
matchKey = key
break
}
}
if (matchKey) {
itemMatches.push({
matchText,
matchKey,
libraryItem: Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONExpanded()
})
}
}
// Search narrators
const narratorMatches = []
const [narratorResults] = await Database.sequelize.query(`SELECT value, count(*) AS numBooks FROM books b, libraryItems li, json_each(b.narrators) WHERE json_valid(b.narrators) AND json_each.value LIKE :query AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value LIMIT :limit OFFSET :offset;`, {
replacements: {
query: `%${query}%`,
libraryId: oldLibrary.id,
limit,
offset
},
raw: true
})
for (const row of narratorResults) {
narratorMatches.push({
name: row.value,
numBooks: row.numBooks
})
}
// Search tags
const tagMatches = []
const [tagResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM books b, libraryItems li, json_each(b.tags) WHERE json_valid(b.tags) AND json_each.value LIKE :query AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value LIMIT :limit OFFSET :offset;`, {
replacements: {
query: `%${query}%`,
libraryId: oldLibrary.id,
limit,
offset
},
raw: true
})
for (const row of tagResults) {
tagMatches.push({
name: row.value,
numItems: row.numItems
})
}
// Search series
const allSeries = await Database.seriesModel.findAll({
where: {
name: {
[Sequelize.Op.substring]: query
},
libraryId: oldLibrary.id
},
replacements: userPermissionBookWhere.replacements,
include: {
separate: true,
model: Database.bookSeriesModel,
include: {
model: Database.bookModel,
where: userPermissionBookWhere.bookWhere,
include: {
model: Database.libraryItemModel
}
}
},
subQuery: false,
distinct: true,
limit,
offset
})
const seriesMatches = []
for (const series of allSeries) {
const books = series.bookSeries.map((bs) => {
const libraryItem = bs.book.libraryItem
libraryItem.media = bs.book
return Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSON()
})
seriesMatches.push({
series: series.getOldSeries().toJSON(),
books
})
}
// Search authors
const authorMatches = await authorFilters.search(oldLibrary.id, query, limit, offset)
return {
book: itemMatches,
narrators: narratorMatches,
tags: tagMatches,
series: seriesMatches,
authors: authorMatches
}
},
/**
* Genres with num books
* @param {string} libraryId
* @returns {{genre:string, count:number}[]}
*/
async getGenresWithCount(libraryId) {
const genres = []
const [genreResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM books b, libraryItems li, json_each(b.genres) WHERE json_valid(b.genres) AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC;`, {
replacements: {
libraryId
},
raw: true
})
for (const row of genreResults) {
genres.push({
genre: row.value,
count: row.numItems
})
}
return genres
},
/**
* Get stats for book library
* @param {string} libraryId
* @returns {Promise<{ totalSize:number, totalDuration:number, numAudioFiles:number, totalItems:number}>}
*/
async getBookLibraryStats(libraryId) {
const [statResults] = await Database.sequelize.query(`SELECT SUM(li.size) AS totalSize, SUM(b.duration) AS totalDuration, SUM(json_array_length(b.audioFiles)) AS numAudioFiles, COUNT(*) AS totalItems FROM libraryItems li, books b WHERE b.id = li.mediaId AND li.libraryId = :libraryId;`, {
replacements: {
libraryId
}
})
return statResults[0]
},
/**
* Get longest books in library
* @param {string} libraryId
* @param {number} limit
* @returns {Promise<{ id:string, title:string, duration:number }[]>}
*/
async getLongestBooks(libraryId, limit) {
const books = await Database.bookModel.findAll({
attributes: ['id', 'title', 'duration'],
include: {
model: Database.libraryItemModel,
attributes: ['id', 'libraryId'],
where: {
libraryId
}
},
order: [
['duration', 'DESC']
],
limit
})
return books.map(book => {
return {
id: book.libraryItem.id,
title: book.title,
duration: book.duration
}
})
}
}

View File

@ -6,8 +6,8 @@ const Logger = require('../../Logger')
module.exports = {
/**
* User permissions to restrict podcasts for explicit content & tags
* @param {oldUser} user
* @returns {object} { podcastWhere:Sequelize.WhereOptions, replacements:string[] }
* @param {import('../../objects/user/User')} user
* @returns {{ podcastWhere:Sequelize.WhereOptions, replacements:object }}
*/
getUserPermissionPodcastWhereQuery(user) {
const podcastWhere = []
@ -112,7 +112,7 @@ module.exports = {
const libraryItemIncludes = []
if (includeRSSFeed) {
libraryItemIncludes.push({
model: Database.models.feed,
model: Database.feedModel,
required: filterGroup === 'feed-open'
})
}
@ -146,7 +146,7 @@ module.exports = {
replacements = { ...replacements, ...userPermissionPodcastWhere.replacements }
podcastWhere.push(...userPermissionPodcastWhere.podcastWhere)
const { rows: podcasts, count } = await Database.models.podcast.findAndCountAll({
const { rows: podcasts, count } = await Database.podcastModel.findAndCountAll({
where: podcastWhere,
replacements,
distinct: true,
@ -158,7 +158,7 @@ module.exports = {
},
include: [
{
model: Database.models.libraryItem,
model: Database.libraryItemModel,
required: true,
where: libraryItemWhere,
include: libraryItemIncludes
@ -219,7 +219,7 @@ module.exports = {
}
if (filterGroup === 'progress') {
podcastEpisodeIncludes.push({
model: Database.models.mediaProgress,
model: Database.mediaProgressModel,
where: {
userId: user.id
},
@ -255,16 +255,16 @@ module.exports = {
const userPermissionPodcastWhere = this.getUserPermissionPodcastWhereQuery(user)
const { rows: podcastEpisodes, count } = await Database.models.podcastEpisode.findAndCountAll({
const { rows: podcastEpisodes, count } = await Database.podcastEpisodeModel.findAndCountAll({
where: podcastEpisodeWhere,
replacements: userPermissionPodcastWhere.replacements,
include: [
{
model: Database.models.podcast,
model: Database.podcastModel,
where: userPermissionPodcastWhere.podcastWhere,
include: [
{
model: Database.models.libraryItem,
model: Database.libraryItemModel,
where: libraryItemWhere
}
]
@ -283,7 +283,7 @@ module.exports = {
const podcast = ep.podcast.toJSON()
delete podcast.libraryItem
libraryItem.media = podcast
libraryItem.recentEpisode = ep.getOldPodcastEpisode(libraryItem.id)
libraryItem.recentEpisode = ep.getOldPodcastEpisode(libraryItem.id).toJSON()
return libraryItem
})
@ -291,5 +291,239 @@ module.exports = {
libraryItems,
count
}
},
/**
* Search podcasts
* @param {import('../../objects/user/User')} oldUser
* @param {import('../../objects/Library')} oldLibrary
* @param {string} query
* @param {number} limit
* @param {number} offset
* @returns {{podcast:object[], tags:object[]}}
*/
async search(oldUser, oldLibrary, query, limit, offset) {
const userPermissionPodcastWhere = this.getUserPermissionPodcastWhereQuery(user)
// Search title, author, itunesId, itunesArtistId
const podcasts = await Database.podcastModel.findAll({
where: [
{
[Sequelize.Op.or]: [
{
title: {
[Sequelize.Op.substring]: query
}
},
{
author: {
[Sequelize.Op.substring]: query
}
},
{
itunesId: {
[Sequelize.Op.substring]: query
}
},
{
itunesArtistId: {
[Sequelize.Op.substring]: query
}
}
]
},
...userPermissionPodcastWhere.podcastWhere
],
replacements: userPermissionPodcastWhere.replacements,
include: [
{
model: Database.libraryItemModel,
where: {
libraryId: oldLibrary.id
}
}
],
subQuery: false,
distinct: true,
limit,
offset
})
const itemMatches = []
for (const podcast of podcasts) {
const libraryItem = podcast.libraryItem
delete podcast.libraryItem
libraryItem.media = podcast
let matchText = null
let matchKey = null
for (const key of ['title', 'author', 'itunesId', 'itunesArtistId']) {
if (podcast[key]?.toLowerCase().includes(query)) {
matchText = podcast[key]
matchKey = key
break
}
}
if (matchKey) {
itemMatches.push({
matchText,
matchKey,
libraryItem: Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONExpanded()
})
}
}
// Search tags
const tagMatches = []
const [tagResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM podcasts p, libraryItems li, json_each(p.tags) WHERE json_valid(p.tags) AND json_each.value LIKE :query AND p.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value LIMIT :limit OFFSET :offset;`, {
replacements: {
query: `%${query}%`,
libraryId: oldLibrary.id,
limit,
offset
},
raw: true
})
for (const row of tagResults) {
tagMatches.push({
name: row.value,
numItems: row.numItems
})
}
return {
podcast: itemMatches,
tags: tagMatches
}
},
/**
* Most recent podcast episodes not finished
* @param {import('../../objects/user/User')} oldUser
* @param {import('../../objects/Library')} oldLibrary
* @param {number} limit
* @param {number} offset
* @returns {Promise<object[]>}
*/
async getRecentEpisodes(oldUser, oldLibrary, limit, offset) {
const userPermissionPodcastWhere = this.getUserPermissionPodcastWhereQuery(oldUser)
const episodes = await Database.podcastEpisodeModel.findAll({
where: {
'$mediaProgresses.isFinished$': {
[Sequelize.Op.or]: [null, false]
}
},
replacements: userPermissionPodcastWhere.replacements,
include: [
{
model: Database.podcastModel,
where: userPermissionPodcastWhere.podcastWhere,
required: true,
include: {
model: Database.libraryItemModel,
where: {
libraryId: oldLibrary.id
}
}
},
{
model: Database.mediaProgressModel,
where: {
userId: oldUser.id
},
required: false
}
],
order: [
['publishedAt', 'DESC']
],
subQuery: false,
limit,
offset
})
const episodeResults = episodes.map((ep) => {
const libraryItem = ep.podcast.libraryItem
libraryItem.media = ep.podcast
const oldPodcast = Database.podcastModel.getOldPodcast(libraryItem)
const oldPodcastEpisode = ep.getOldPodcastEpisode(libraryItem.id).toJSONExpanded()
oldPodcastEpisode.podcast = oldPodcast
oldPodcastEpisode.libraryId = libraryItem.libraryId
return oldPodcastEpisode
})
return episodeResults
},
/**
* Get stats for podcast library
* @param {string} libraryId
* @returns {Promise<{ totalSize:number, totalDuration:number, numAudioFiles:number, totalItems:number}>}
*/
async getPodcastLibraryStats(libraryId) {
const [statResults] = await Database.sequelize.query(`SELECT SUM(json_extract(pe.audioFile, '$.duration')) AS totalDuration, SUM(li.size) AS totalSize, COUNT(DISTINCT(li.id)) AS totalItems, COUNT(pe.id) AS numAudioFiles FROM libraryItems li, podcasts p LEFT OUTER JOIN podcastEpisodes pe ON pe.podcastId = p.id WHERE p.id = li.mediaId AND li.libraryId = :libraryId;`, {
replacements: {
libraryId
}
})
return statResults[0]
},
/**
* Genres with num podcasts
* @param {string} libraryId
* @returns {{genre:string, count:number}[]}
*/
async getGenresWithCount(libraryId) {
const genres = []
const [genreResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM podcasts p, libraryItems li, json_each(p.genres) WHERE json_valid(p.genres) AND p.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC;`, {
replacements: {
libraryId
},
raw: true
})
for (const row of genreResults) {
genres.push({
genre: row.value,
count: row.numItems
})
}
return genres
},
/**
* Get longest podcasts in library
* @param {string} libraryId
* @param {number} limit
* @returns {Promise<{ id:string, title:string, duration:number }[]>}
*/
async getLongestPodcasts(libraryId, limit) {
const podcasts = await Database.podcastModel.findAll({
attributes: [
'id',
'title',
[Sequelize.literal(`(SELECT SUM(json_extract(pe.audioFile, '$.duration')) FROM podcastEpisodes pe WHERE pe.podcastId = podcast.id)`), 'duration']
],
include: {
model: Database.libraryItemModel,
attributes: ['id', 'libraryId'],
where: {
libraryId
}
},
order: [
['duration', 'DESC']
],
limit
})
return podcasts.map(podcast => {
return {
id: podcast.libraryItem.id,
title: podcast.title,
duration: podcast.dataValues.duration
}
})
}
}

View File

@ -0,0 +1,206 @@
const Sequelize = require('sequelize')
const Logger = require('../../Logger')
const Database = require('../../Database')
const libraryItemsBookFilters = require('./libraryItemsBookFilters')
module.exports = {
decode(text) {
return Buffer.from(decodeURIComponent(text), 'base64').toString()
},
/**
* Get series filtered and sorted
*
* @param {import('../../objects/Library')} library
* @param {import('../../objects/user/User')} user
* @param {string} filterBy
* @param {string} sortBy
* @param {boolean} sortDesc
* @param {string[]} include
* @param {number} limit
* @param {number} offset
* @returns {Promise<{ series:object[], count:number }>}
*/
async getFilteredSeries(library, user, filterBy, sortBy, sortDesc, include, limit, offset) {
let filterValue = null
let filterGroup = null
if (filterBy) {
const searchGroups = ['genres', 'tags', 'authors', 'progress', 'narrators', 'publishers', 'languages']
const group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
filterGroup = group || filterBy
filterValue = group ? this.decode(filterBy.replace(`${group}.`, '')) : null
}
const seriesIncludes = []
if (include.includes('rssfeed')) {
seriesIncludes.push({
model: Database.feedModel
})
}
const userPermissionBookWhere = libraryItemsBookFilters.getUserPermissionBookWhereQuery(user)
const seriesWhere = [
{
libraryId: library.id
}
]
// Handle library setting to hide single book series
// TODO: Merge with existing query
if (library.settings.hideSingleBookSeries) {
seriesWhere.push(Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id)`), {
[Sequelize.Op.gt]: 1
}))
}
// Handle filters
// TODO: Simplify and break-out
let attrQuery = null
if (['genres', 'tags', 'narrators'].includes(filterGroup)) {
attrQuery = `SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (SELECT count(*) FROM json_each(b.${filterGroup}) WHERE json_valid(b.${filterGroup}) AND json_each.value = :filterValue) > 0`
userPermissionBookWhere.replacements.filterValue = filterValue
} else if (filterGroup === 'authors') {
attrQuery = 'SELECT count(*) FROM books b, bookSeries bs, bookAuthors ba WHERE bs.seriesId = series.id AND bs.bookId = b.id AND ba.bookId = b.id AND ba.authorId = :filterValue'
userPermissionBookWhere.replacements.filterValue = filterValue
} else if (filterGroup === 'publishers') {
attrQuery = 'SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id AND b.publisher = :filterValue'
userPermissionBookWhere.replacements.filterValue = filterValue
} else if (filterGroup === 'languages') {
attrQuery = 'SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id AND b.language = :filterValue'
userPermissionBookWhere.replacements.filterValue = filterValue
} else if (filterGroup === 'progress') {
if (filterValue === 'not-finished') {
attrQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished IS NULL OR mp.isFinished = 0)'
} else if (filterValue === 'finished') {
const progQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished IS NULL OR mp.isFinished = 0)'
seriesWhere.push(Sequelize.where(Sequelize.literal(`(${progQuery})`), 0))
} else if (filterValue === 'not-started') {
const progQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished = 1 OR mp.currentTime > 0)'
seriesWhere.push(Sequelize.where(Sequelize.literal(`(${progQuery})`), 0))
} else if (filterValue === 'in-progress') {
attrQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.currentTime > 0 OR mp.ebookProgress > 0) AND mp.isFinished = 0'
}
}
// Handle user permissions to only include series with at least 1 book
// TODO: Simplify to a single query
if (userPermissionBookWhere.bookWhere.length) {
if (!attrQuery) attrQuery = 'SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id'
if (!user.canAccessExplicitContent) {
attrQuery += ' AND b.explicit = 0'
}
if (!user.permissions.accessAllTags && user.itemTagsSelected.length) {
if (user.permissions.selectedTagsNotAccessible) {
attrQuery += ' AND (SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected)) = 0'
} else {
attrQuery += ' AND (SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected)) > 0'
}
}
}
if (attrQuery) {
seriesWhere.push(Sequelize.where(Sequelize.literal(`(${attrQuery})`), {
[Sequelize.Op.gt]: 0
}))
}
const order = []
let seriesAttributes = {
include: []
}
// Handle sort order
const dir = sortDesc ? 'DESC' : 'ASC'
if (sortBy === 'numBooks') {
seriesAttributes.include.push([Sequelize.literal('(SELECT count(*) FROM bookSeries bs WHERE bs.seriesId = series.id)'), 'numBooks'])
order.push(['numBooks', dir])
} else if (sortBy === 'addedAt') {
order.push(['createdAt', dir])
} else if (sortBy === 'name') {
if (global.ServerSettings.sortingIgnorePrefix) {
order.push([Sequelize.literal('nameIgnorePrefix COLLATE NOCASE'), dir])
} else {
order.push([Sequelize.literal('`series`.`name` COLLATE NOCASE'), dir])
}
} else if (sortBy === 'totalDuration') {
seriesAttributes.include.push([Sequelize.literal('(SELECT SUM(b.duration) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND b.id = bs.bookId)'), 'totalDuration'])
order.push(['totalDuration', dir])
} else if (sortBy === 'lastBookAdded') {
seriesAttributes.include.push([Sequelize.literal('(SELECT MAX(b.createdAt) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND b.id = bs.bookId)'), 'mostRecentBookAdded'])
order.push(['mostRecentBookAdded', dir])
} else if (sortBy === 'lastBookUpdated') {
seriesAttributes.include.push([Sequelize.literal('(SELECT MAX(b.updatedAt) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND b.id = bs.bookId)'), 'mostRecentBookUpdated'])
order.push(['mostRecentBookUpdated', dir])
}
const { rows: series, count } = await Database.seriesModel.findAndCountAll({
where: seriesWhere,
limit,
offset,
distinct: true,
subQuery: false,
benchmark: true,
logging: (sql, timeMs) => {
console.log(`[Query] Series filter/sort. Elapsed ${timeMs}ms`)
},
attributes: seriesAttributes,
replacements: userPermissionBookWhere.replacements,
include: [
{
model: Database.bookSeriesModel,
include: {
model: Database.bookModel,
where: userPermissionBookWhere.bookWhere,
include: [
{
model: Database.libraryItemModel
}
]
},
separate: true
},
...seriesIncludes
],
order
})
// Map series to old series
const allOldSeries = []
for (const s of series) {
const oldSeries = s.getOldSeries().toJSON()
if (s.dataValues.totalDuration) {
oldSeries.totalDuration = s.dataValues.totalDuration
}
if (s.feeds?.length) {
oldSeries.rssFeed = Database.feedModel.getOldFeed(s.feeds[0]).toJSONMinified()
}
// TODO: Sort books by sequence in query
s.bookSeries.sort((a, b) => {
if (!a.sequence) return 1
if (!b.sequence) return -1
return a.sequence.localeCompare(b.sequence, undefined, {
numeric: true,
sensitivity: 'base'
})
})
oldSeries.books = s.bookSeries.map(bs => {
const libraryItem = bs.book.libraryItem.toJSON()
delete bs.book.libraryItem
libraryItem.media = bs.book
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONMinified()
return oldLibraryItem
})
allOldSeries.push(oldSeries)
}
return {
series: allOldSeries,
count
}
}
}