mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-05-21 19:01:30 +00:00
Merge remote-tracking branch 'origin/master' into auth_passportjs
This commit is contained in:
commit
f0f03efe17
138 changed files with 11777 additions and 7343 deletions
|
|
@ -188,7 +188,7 @@ class Auth {
|
|||
await Database.updateServerSettings()
|
||||
|
||||
// New token secret creation added in v2.1.0 so generate new API tokens for each user
|
||||
const users = await Database.models.user.getOldUsers()
|
||||
const users = await Database.userModel.getOldUsers()
|
||||
if (users.length) {
|
||||
for (const user of users) {
|
||||
user.token = await this.generateAccessToken({ userId: user.id, username: user.username })
|
||||
|
|
|
|||
|
|
@ -15,15 +15,16 @@ class Database {
|
|||
this.isNew = false // New absdatabase.sqlite created
|
||||
this.hasRootUser = false // Used to show initialization page in web ui
|
||||
|
||||
// Temporarily using format of old DB
|
||||
// TODO: below data should be loaded from the DB as needed
|
||||
this.libraryItems = []
|
||||
this.settings = []
|
||||
this.authors = []
|
||||
this.series = []
|
||||
|
||||
// Cached library filter data
|
||||
this.libraryFilterData = {}
|
||||
|
||||
/** @type {import('./objects/settings/ServerSettings')} */
|
||||
this.serverSettings = null
|
||||
/** @type {import('./objects/settings/NotificationSettings')} */
|
||||
this.notificationSettings = null
|
||||
/** @type {import('./objects/settings/EmailSettings')} */
|
||||
this.emailSettings = null
|
||||
}
|
||||
|
||||
|
|
@ -31,6 +32,105 @@ class Database {
|
|||
return this.sequelize?.models || {}
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/User')} */
|
||||
get userModel() {
|
||||
return this.models.user
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/Library')} */
|
||||
get libraryModel() {
|
||||
return this.models.library
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/LibraryFolder')} */
|
||||
get libraryFolderModel() {
|
||||
return this.models.libraryFolder
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/Author')} */
|
||||
get authorModel() {
|
||||
return this.models.author
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/Series')} */
|
||||
get seriesModel() {
|
||||
return this.models.series
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/Book')} */
|
||||
get bookModel() {
|
||||
return this.models.book
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/BookSeries')} */
|
||||
get bookSeriesModel() {
|
||||
return this.models.bookSeries
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/BookAuthor')} */
|
||||
get bookAuthorModel() {
|
||||
return this.models.bookAuthor
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/Podcast')} */
|
||||
get podcastModel() {
|
||||
return this.models.podcast
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/PodcastEpisode')} */
|
||||
get podcastEpisodeModel() {
|
||||
return this.models.podcastEpisode
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/LibraryItem')} */
|
||||
get libraryItemModel() {
|
||||
return this.models.libraryItem
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/PodcastEpisode')} */
|
||||
get podcastEpisodeModel() {
|
||||
return this.models.podcastEpisode
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/MediaProgress')} */
|
||||
get mediaProgressModel() {
|
||||
return this.models.mediaProgress
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/Collection')} */
|
||||
get collectionModel() {
|
||||
return this.models.collection
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/CollectionBook')} */
|
||||
get collectionBookModel() {
|
||||
return this.models.collectionBook
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/Playlist')} */
|
||||
get playlistModel() {
|
||||
return this.models.playlist
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/PlaylistMediaItem')} */
|
||||
get playlistMediaItemModel() {
|
||||
return this.models.playlistMediaItem
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/Feed')} */
|
||||
get feedModel() {
|
||||
return this.models.feed
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/Feed')} */
|
||||
get feedEpisodeModel() {
|
||||
return this.models.feedEpisode
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if db file exists
|
||||
* @returns {boolean}
|
||||
*/
|
||||
async checkHasDb() {
|
||||
if (!await fs.pathExists(this.dbPath)) {
|
||||
Logger.info(`[Database] absdatabase.sqlite not found at ${this.dbPath}`)
|
||||
|
|
@ -39,6 +139,10 @@ class Database {
|
|||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to db, build models and run migrations
|
||||
* @param {boolean} [force=false] Used for testing, drops & re-creates all tables
|
||||
*/
|
||||
async init(force = false) {
|
||||
this.dbPath = Path.join(global.ConfigPath, 'absdatabase.sqlite')
|
||||
|
||||
|
|
@ -52,9 +156,14 @@ class Database {
|
|||
await this.buildModels(force)
|
||||
Logger.info(`[Database] Db initialized with models:`, Object.keys(this.sequelize.models).join(', '))
|
||||
|
||||
|
||||
await this.loadData()
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to db
|
||||
* @returns {boolean}
|
||||
*/
|
||||
async connect() {
|
||||
Logger.info(`[Database] Initializing db at "${this.dbPath}"`)
|
||||
this.sequelize = new Sequelize({
|
||||
|
|
@ -77,39 +186,45 @@ class Database {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from db
|
||||
*/
|
||||
async disconnect() {
|
||||
Logger.info(`[Database] Disconnecting sqlite db`)
|
||||
await this.sequelize.close()
|
||||
this.sequelize = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect to db and init
|
||||
*/
|
||||
async reconnect() {
|
||||
Logger.info(`[Database] Reconnecting sqlite db`)
|
||||
await this.init()
|
||||
}
|
||||
|
||||
buildModels(force = false) {
|
||||
require('./models/User')(this.sequelize)
|
||||
require('./models/Library')(this.sequelize)
|
||||
require('./models/LibraryFolder')(this.sequelize)
|
||||
require('./models/Book')(this.sequelize)
|
||||
require('./models/Podcast')(this.sequelize)
|
||||
require('./models/PodcastEpisode')(this.sequelize)
|
||||
require('./models/LibraryItem')(this.sequelize)
|
||||
require('./models/MediaProgress')(this.sequelize)
|
||||
require('./models/Series')(this.sequelize)
|
||||
require('./models/BookSeries')(this.sequelize)
|
||||
require('./models/Author')(this.sequelize)
|
||||
require('./models/BookAuthor')(this.sequelize)
|
||||
require('./models/Collection')(this.sequelize)
|
||||
require('./models/CollectionBook')(this.sequelize)
|
||||
require('./models/Playlist')(this.sequelize)
|
||||
require('./models/PlaylistMediaItem')(this.sequelize)
|
||||
require('./models/Device')(this.sequelize)
|
||||
require('./models/PlaybackSession')(this.sequelize)
|
||||
require('./models/Feed')(this.sequelize)
|
||||
require('./models/FeedEpisode')(this.sequelize)
|
||||
require('./models/Setting')(this.sequelize)
|
||||
require('./models/User').init(this.sequelize)
|
||||
require('./models/Library').init(this.sequelize)
|
||||
require('./models/LibraryFolder').init(this.sequelize)
|
||||
require('./models/Book').init(this.sequelize)
|
||||
require('./models/Podcast').init(this.sequelize)
|
||||
require('./models/PodcastEpisode').init(this.sequelize)
|
||||
require('./models/LibraryItem').init(this.sequelize)
|
||||
require('./models/MediaProgress').init(this.sequelize)
|
||||
require('./models/Series').init(this.sequelize)
|
||||
require('./models/BookSeries').init(this.sequelize)
|
||||
require('./models/Author').init(this.sequelize)
|
||||
require('./models/BookAuthor').init(this.sequelize)
|
||||
require('./models/Collection').init(this.sequelize)
|
||||
require('./models/CollectionBook').init(this.sequelize)
|
||||
require('./models/Playlist').init(this.sequelize)
|
||||
require('./models/PlaylistMediaItem').init(this.sequelize)
|
||||
require('./models/Device').init(this.sequelize)
|
||||
require('./models/PlaybackSession').init(this.sequelize)
|
||||
require('./models/Feed').init(this.sequelize)
|
||||
require('./models/FeedEpisode').init(this.sequelize)
|
||||
require('./models/Setting').init(this.sequelize)
|
||||
|
||||
return this.sequelize.sync({ force, alter: false })
|
||||
}
|
||||
|
|
@ -138,8 +253,6 @@ class Database {
|
|||
await dbMigration.migrate(this.models)
|
||||
}
|
||||
|
||||
const startTime = Date.now()
|
||||
|
||||
const settingsData = await this.models.setting.getOldSettings()
|
||||
this.settings = settingsData.settings
|
||||
this.emailSettings = settingsData.emailSettings
|
||||
|
|
@ -155,22 +268,11 @@ class Database {
|
|||
await dbMigration.migrationPatch2(this)
|
||||
}
|
||||
|
||||
Logger.info(`[Database] Loading db data...`)
|
||||
|
||||
this.libraryItems = await this.models.libraryItem.loadAllLibraryItems()
|
||||
Logger.info(`[Database] Loaded ${this.libraryItems.length} library items`)
|
||||
|
||||
this.authors = await this.models.author.getOldAuthors()
|
||||
Logger.info(`[Database] Loaded ${this.authors.length} authors`)
|
||||
|
||||
this.series = await this.models.series.getAllOldSeries()
|
||||
Logger.info(`[Database] Loaded ${this.series.length} series`)
|
||||
await this.cleanDatabase()
|
||||
|
||||
// Set if root user has been created
|
||||
this.hasRootUser = await this.models.user.getHasRootUser()
|
||||
|
||||
Logger.info(`[Database] Db data loaded in ${((Date.now() - startTime) / 1000).toFixed(2)}s`)
|
||||
|
||||
if (packageJson.version !== this.serverSettings.version) {
|
||||
Logger.info(`[Database] Server upgrade detected from ${this.serverSettings.version} to ${packageJson.version}`)
|
||||
this.serverSettings.version = packageJson.version
|
||||
|
|
@ -219,9 +321,9 @@ class Database {
|
|||
return Promise.all(oldUsers.map(u => this.updateUser(u)))
|
||||
}
|
||||
|
||||
async removeUser(userId) {
|
||||
removeUser(userId) {
|
||||
if (!this.sequelize) return false
|
||||
await this.models.user.removeById(userId)
|
||||
return this.models.user.removeById(userId)
|
||||
}
|
||||
|
||||
upsertMediaProgress(oldMediaProgress) {
|
||||
|
|
@ -239,9 +341,9 @@ class Database {
|
|||
return Promise.all(oldBooks.map(oldBook => this.models.book.saveFromOld(oldBook)))
|
||||
}
|
||||
|
||||
async createLibrary(oldLibrary) {
|
||||
createLibrary(oldLibrary) {
|
||||
if (!this.sequelize) return false
|
||||
await this.models.library.createFromOld(oldLibrary)
|
||||
return this.models.library.createFromOld(oldLibrary)
|
||||
}
|
||||
|
||||
updateLibrary(oldLibrary) {
|
||||
|
|
@ -249,56 +351,9 @@ class Database {
|
|||
return this.models.library.updateFromOld(oldLibrary)
|
||||
}
|
||||
|
||||
async removeLibrary(libraryId) {
|
||||
removeLibrary(libraryId) {
|
||||
if (!this.sequelize) return false
|
||||
await this.models.library.removeById(libraryId)
|
||||
}
|
||||
|
||||
async createCollection(oldCollection) {
|
||||
if (!this.sequelize) return false
|
||||
const newCollection = await this.models.collection.createFromOld(oldCollection)
|
||||
// Create CollectionBooks
|
||||
if (newCollection) {
|
||||
const collectionBooks = []
|
||||
oldCollection.books.forEach((libraryItemId) => {
|
||||
const libraryItem = this.libraryItems.find(li => li.id === libraryItemId)
|
||||
if (libraryItem) {
|
||||
collectionBooks.push({
|
||||
collectionId: newCollection.id,
|
||||
bookId: libraryItem.media.id
|
||||
})
|
||||
}
|
||||
})
|
||||
if (collectionBooks.length) {
|
||||
await this.createBulkCollectionBooks(collectionBooks)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateCollection(oldCollection) {
|
||||
if (!this.sequelize) return false
|
||||
const collectionBooks = []
|
||||
let order = 1
|
||||
oldCollection.books.forEach((libraryItemId) => {
|
||||
const libraryItem = this.getLibraryItem(libraryItemId)
|
||||
if (!libraryItem) return
|
||||
collectionBooks.push({
|
||||
collectionId: oldCollection.id,
|
||||
bookId: libraryItem.media.id,
|
||||
order: order++
|
||||
})
|
||||
})
|
||||
return this.models.collection.fullUpdateFromOld(oldCollection, collectionBooks)
|
||||
}
|
||||
|
||||
async removeCollection(collectionId) {
|
||||
if (!this.sequelize) return false
|
||||
await this.models.collection.removeById(collectionId)
|
||||
}
|
||||
|
||||
createCollectionBook(collectionBook) {
|
||||
if (!this.sequelize) return false
|
||||
return this.models.collectionBook.create(collectionBook)
|
||||
return this.models.library.removeById(libraryId)
|
||||
}
|
||||
|
||||
createBulkCollectionBooks(collectionBooks) {
|
||||
|
|
@ -306,62 +361,6 @@ class Database {
|
|||
return this.models.collectionBook.bulkCreate(collectionBooks)
|
||||
}
|
||||
|
||||
removeCollectionBook(collectionId, bookId) {
|
||||
if (!this.sequelize) return false
|
||||
return this.models.collectionBook.removeByIds(collectionId, bookId)
|
||||
}
|
||||
|
||||
async createPlaylist(oldPlaylist) {
|
||||
if (!this.sequelize) return false
|
||||
const newPlaylist = await this.models.playlist.createFromOld(oldPlaylist)
|
||||
if (newPlaylist) {
|
||||
const playlistMediaItems = []
|
||||
let order = 1
|
||||
for (const mediaItemObj of oldPlaylist.items) {
|
||||
const libraryItem = this.libraryItems.find(li => li.id === mediaItemObj.libraryItemId)
|
||||
if (!libraryItem) continue
|
||||
|
||||
let mediaItemId = libraryItem.media.id // bookId
|
||||
let mediaItemType = 'book'
|
||||
if (mediaItemObj.episodeId) {
|
||||
mediaItemType = 'podcastEpisode'
|
||||
mediaItemId = mediaItemObj.episodeId
|
||||
}
|
||||
playlistMediaItems.push({
|
||||
playlistId: newPlaylist.id,
|
||||
mediaItemId,
|
||||
mediaItemType,
|
||||
order: order++
|
||||
})
|
||||
}
|
||||
if (playlistMediaItems.length) {
|
||||
await this.createBulkPlaylistMediaItems(playlistMediaItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updatePlaylist(oldPlaylist) {
|
||||
if (!this.sequelize) return false
|
||||
const playlistMediaItems = []
|
||||
let order = 1
|
||||
oldPlaylist.items.forEach((item) => {
|
||||
const libraryItem = this.getLibraryItem(item.libraryItemId)
|
||||
if (!libraryItem) return
|
||||
playlistMediaItems.push({
|
||||
playlistId: oldPlaylist.id,
|
||||
mediaItemId: item.episodeId || libraryItem.media.id,
|
||||
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
|
||||
order: order++
|
||||
})
|
||||
})
|
||||
return this.models.playlist.fullUpdateFromOld(oldPlaylist, playlistMediaItems)
|
||||
}
|
||||
|
||||
async removePlaylist(playlistId) {
|
||||
if (!this.sequelize) return false
|
||||
await this.models.playlist.removeById(playlistId)
|
||||
}
|
||||
|
||||
createPlaylistMediaItem(playlistMediaItem) {
|
||||
if (!this.sequelize) return false
|
||||
return this.models.playlistMediaItem.create(playlistMediaItem)
|
||||
|
|
@ -372,25 +371,10 @@ class Database {
|
|||
return this.models.playlistMediaItem.bulkCreate(playlistMediaItems)
|
||||
}
|
||||
|
||||
removePlaylistMediaItem(playlistId, mediaItemId) {
|
||||
if (!this.sequelize) return false
|
||||
return this.models.playlistMediaItem.removeByIds(playlistId, mediaItemId)
|
||||
}
|
||||
|
||||
getLibraryItem(libraryItemId) {
|
||||
if (!this.sequelize || !libraryItemId) return false
|
||||
|
||||
// Temp support for old library item ids from mobile
|
||||
if (libraryItemId.startsWith('li_')) return this.libraryItems.find(li => li.oldLibraryItemId === libraryItemId)
|
||||
|
||||
return this.libraryItems.find(li => li.id === libraryItemId)
|
||||
}
|
||||
|
||||
async createLibraryItem(oldLibraryItem) {
|
||||
if (!this.sequelize) return false
|
||||
await oldLibraryItem.saveMetadata()
|
||||
await this.models.libraryItem.fullCreateFromOld(oldLibraryItem)
|
||||
this.libraryItems.push(oldLibraryItem)
|
||||
}
|
||||
|
||||
async updateLibraryItem(oldLibraryItem) {
|
||||
|
|
@ -399,32 +383,9 @@ class Database {
|
|||
return this.models.libraryItem.fullUpdateFromOld(oldLibraryItem)
|
||||
}
|
||||
|
||||
async updateBulkLibraryItems(oldLibraryItems) {
|
||||
if (!this.sequelize) return false
|
||||
let updatesMade = 0
|
||||
for (const oldLibraryItem of oldLibraryItems) {
|
||||
await oldLibraryItem.saveMetadata()
|
||||
const hasUpdates = await this.models.libraryItem.fullUpdateFromOld(oldLibraryItem)
|
||||
if (hasUpdates) {
|
||||
updatesMade++
|
||||
}
|
||||
}
|
||||
return updatesMade
|
||||
}
|
||||
|
||||
async createBulkLibraryItems(oldLibraryItems) {
|
||||
if (!this.sequelize) return false
|
||||
for (const oldLibraryItem of oldLibraryItems) {
|
||||
await oldLibraryItem.saveMetadata()
|
||||
await this.models.libraryItem.fullCreateFromOld(oldLibraryItem)
|
||||
this.libraryItems.push(oldLibraryItem)
|
||||
}
|
||||
}
|
||||
|
||||
async removeLibraryItem(libraryItemId) {
|
||||
if (!this.sequelize) return false
|
||||
await this.models.libraryItem.removeById(libraryItemId)
|
||||
this.libraryItems = this.libraryItems.filter(li => li.id !== libraryItemId)
|
||||
}
|
||||
|
||||
async createFeed(oldFeed) {
|
||||
|
|
@ -450,31 +411,26 @@ class Database {
|
|||
async createSeries(oldSeries) {
|
||||
if (!this.sequelize) return false
|
||||
await this.models.series.createFromOld(oldSeries)
|
||||
this.series.push(oldSeries)
|
||||
}
|
||||
|
||||
async createBulkSeries(oldSeriesObjs) {
|
||||
if (!this.sequelize) return false
|
||||
await this.models.series.createBulkFromOld(oldSeriesObjs)
|
||||
this.series.push(...oldSeriesObjs)
|
||||
}
|
||||
|
||||
async removeSeries(seriesId) {
|
||||
if (!this.sequelize) return false
|
||||
await this.models.series.removeById(seriesId)
|
||||
this.series = this.series.filter(se => se.id !== seriesId)
|
||||
}
|
||||
|
||||
async createAuthor(oldAuthor) {
|
||||
if (!this.sequelize) return false
|
||||
await this.models.author.createFromOld(oldAuthor)
|
||||
this.authors.push(oldAuthor)
|
||||
}
|
||||
|
||||
async createBulkAuthors(oldAuthors) {
|
||||
if (!this.sequelize) return false
|
||||
await this.models.author.createBulkFromOld(oldAuthors)
|
||||
this.authors.push(...oldAuthors)
|
||||
}
|
||||
|
||||
updateAuthor(oldAuthor) {
|
||||
|
|
@ -485,24 +441,17 @@ class Database {
|
|||
async removeAuthor(authorId) {
|
||||
if (!this.sequelize) return false
|
||||
await this.models.author.removeById(authorId)
|
||||
this.authors = this.authors.filter(au => au.id !== authorId)
|
||||
}
|
||||
|
||||
async createBulkBookAuthors(bookAuthors) {
|
||||
if (!this.sequelize) return false
|
||||
await this.models.bookAuthor.bulkCreate(bookAuthors)
|
||||
this.authors.push(...bookAuthors)
|
||||
}
|
||||
|
||||
async removeBulkBookAuthors(authorId = null, bookId = null) {
|
||||
if (!this.sequelize) return false
|
||||
if (!authorId && !bookId) return
|
||||
await this.models.bookAuthor.removeByIds(authorId, bookId)
|
||||
this.authors = this.authors.filter(au => {
|
||||
if (authorId && au.authorId !== authorId) return true
|
||||
if (bookId && au.bookId !== bookId) return true
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
getPlaybackSessions(where = null) {
|
||||
|
|
@ -544,6 +493,204 @@ class Database {
|
|||
if (!this.sequelize) return false
|
||||
return this.models.device.createFromOld(oldDevice)
|
||||
}
|
||||
|
||||
replaceTagInFilterData(oldTag, newTag) {
|
||||
for (const libraryId in this.libraryFilterData) {
|
||||
const indexOf = this.libraryFilterData[libraryId].tags.findIndex(n => n === oldTag)
|
||||
if (indexOf >= 0) {
|
||||
this.libraryFilterData[libraryId].tags.splice(indexOf, 1, newTag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeTagFromFilterData(tag) {
|
||||
for (const libraryId in this.libraryFilterData) {
|
||||
this.libraryFilterData[libraryId].tags = this.libraryFilterData[libraryId].tags.filter(t => t !== tag)
|
||||
}
|
||||
}
|
||||
|
||||
addTagsToFilterData(libraryId, tags) {
|
||||
if (!this.libraryFilterData[libraryId] || !tags?.length) return
|
||||
tags.forEach((t) => {
|
||||
if (!this.libraryFilterData[libraryId].tags.includes(t)) {
|
||||
this.libraryFilterData[libraryId].tags.push(t)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
replaceGenreInFilterData(oldGenre, newGenre) {
|
||||
for (const libraryId in this.libraryFilterData) {
|
||||
const indexOf = this.libraryFilterData[libraryId].genres.findIndex(n => n === oldGenre)
|
||||
if (indexOf >= 0) {
|
||||
this.libraryFilterData[libraryId].genres.splice(indexOf, 1, newGenre)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeGenreFromFilterData(genre) {
|
||||
for (const libraryId in this.libraryFilterData) {
|
||||
this.libraryFilterData[libraryId].genres = this.libraryFilterData[libraryId].genres.filter(g => g !== genre)
|
||||
}
|
||||
}
|
||||
|
||||
addGenresToFilterData(libraryId, genres) {
|
||||
if (!this.libraryFilterData[libraryId] || !genres?.length) return
|
||||
genres.forEach((g) => {
|
||||
if (!this.libraryFilterData[libraryId].genres.includes(g)) {
|
||||
this.libraryFilterData[libraryId].genres.push(g)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
replaceNarratorInFilterData(oldNarrator, newNarrator) {
|
||||
for (const libraryId in this.libraryFilterData) {
|
||||
const indexOf = this.libraryFilterData[libraryId].narrators.findIndex(n => n === oldNarrator)
|
||||
if (indexOf >= 0) {
|
||||
this.libraryFilterData[libraryId].narrators.splice(indexOf, 1, newNarrator)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeNarratorFromFilterData(narrator) {
|
||||
for (const libraryId in this.libraryFilterData) {
|
||||
this.libraryFilterData[libraryId].narrators = this.libraryFilterData[libraryId].narrators.filter(n => n !== narrator)
|
||||
}
|
||||
}
|
||||
|
||||
addNarratorsToFilterData(libraryId, narrators) {
|
||||
if (!this.libraryFilterData[libraryId] || !narrators?.length) return
|
||||
narrators.forEach((n) => {
|
||||
if (!this.libraryFilterData[libraryId].narrators.includes(n)) {
|
||||
this.libraryFilterData[libraryId].narrators.push(n)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
removeSeriesFromFilterData(libraryId, seriesId) {
|
||||
if (!this.libraryFilterData[libraryId]) return
|
||||
this.libraryFilterData[libraryId].series = this.libraryFilterData[libraryId].series.filter(se => se.id !== seriesId)
|
||||
}
|
||||
|
||||
addSeriesToFilterData(libraryId, seriesName, seriesId) {
|
||||
if (!this.libraryFilterData[libraryId]) return
|
||||
// Check if series is already added
|
||||
if (this.libraryFilterData[libraryId].series.some(se => se.id === seriesId)) return
|
||||
this.libraryFilterData[libraryId].series.push({
|
||||
id: seriesId,
|
||||
name: seriesName
|
||||
})
|
||||
}
|
||||
|
||||
removeAuthorFromFilterData(libraryId, authorId) {
|
||||
if (!this.libraryFilterData[libraryId]) return
|
||||
this.libraryFilterData[libraryId].authors = this.libraryFilterData[libraryId].authors.filter(au => au.id !== authorId)
|
||||
}
|
||||
|
||||
addAuthorToFilterData(libraryId, authorName, authorId) {
|
||||
if (!this.libraryFilterData[libraryId]) return
|
||||
// Check if author is already added
|
||||
if (this.libraryFilterData[libraryId].authors.some(au => au.id === authorId)) return
|
||||
this.libraryFilterData[libraryId].authors.push({
|
||||
id: authorId,
|
||||
name: authorName
|
||||
})
|
||||
}
|
||||
|
||||
addPublisherToFilterData(libraryId, publisher) {
|
||||
if (!this.libraryFilterData[libraryId] || !publisher || this.libraryFilterData[libraryId].publishers.includes(publisher)) return
|
||||
this.libraryFilterData[libraryId].publishers.push(publisher)
|
||||
}
|
||||
|
||||
addLanguageToFilterData(libraryId, language) {
|
||||
if (!this.libraryFilterData[libraryId] || !language || this.libraryFilterData[libraryId].languages.includes(language)) return
|
||||
this.libraryFilterData[libraryId].languages.push(language)
|
||||
}
|
||||
|
||||
/**
|
||||
* Used when updating items to make sure author id exists
|
||||
* If library filter data is set then use that for check
|
||||
* otherwise lookup in db
|
||||
* @param {string} libraryId
|
||||
* @param {string} authorId
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async checkAuthorExists(libraryId, authorId) {
|
||||
if (!this.libraryFilterData[libraryId]) {
|
||||
return this.authorModel.checkExistsById(authorId)
|
||||
}
|
||||
return this.libraryFilterData[libraryId].authors.some(au => au.id === authorId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Used when updating items to make sure series id exists
|
||||
* If library filter data is set then use that for check
|
||||
* otherwise lookup in db
|
||||
* @param {string} libraryId
|
||||
* @param {string} seriesId
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async checkSeriesExists(libraryId, seriesId) {
|
||||
if (!this.libraryFilterData[libraryId]) {
|
||||
return this.seriesModel.checkExistsById(seriesId)
|
||||
}
|
||||
return this.libraryFilterData[libraryId].series.some(se => se.id === seriesId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset numIssues for library
|
||||
* @param {string} libraryId
|
||||
*/
|
||||
async resetLibraryIssuesFilterData(libraryId) {
|
||||
if (!this.libraryFilterData[libraryId]) return // Do nothing if filter data is not set
|
||||
|
||||
this.libraryFilterData[libraryId].numIssues = await this.libraryItemModel.count({
|
||||
where: {
|
||||
libraryId,
|
||||
[Sequelize.Op.or]: [
|
||||
{
|
||||
isMissing: true
|
||||
},
|
||||
{
|
||||
isInvalid: true
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean invalid records in database
|
||||
* Series should have atleast one Book
|
||||
* Book and Podcast must have an associated LibraryItem
|
||||
*/
|
||||
async cleanDatabase() {
|
||||
// Remove invalid Podcast records
|
||||
const podcastsWithNoLibraryItem = await this.podcastModel.findAll({
|
||||
where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM libraryItems li WHERE li.mediaId = podcast.id)`), 0)
|
||||
})
|
||||
for (const podcast of podcastsWithNoLibraryItem) {
|
||||
Logger.warn(`Found podcast "${podcast.title}" with no libraryItem - removing it`)
|
||||
await podcast.destroy()
|
||||
}
|
||||
|
||||
// Remove invalid Book records
|
||||
const booksWithNoLibraryItem = await this.bookModel.findAll({
|
||||
where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM libraryItems li WHERE li.mediaId = book.id)`), 0)
|
||||
})
|
||||
for (const book of booksWithNoLibraryItem) {
|
||||
Logger.warn(`Found book "${book.title}" with no libraryItem - removing it`)
|
||||
await book.destroy()
|
||||
}
|
||||
|
||||
// Remove empty series
|
||||
const emptySeries = await this.seriesModel.findAll({
|
||||
where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM bookSeries bs WHERE bs.seriesId = series.id)`), 0)
|
||||
})
|
||||
for (const series of emptySeries) {
|
||||
Logger.warn(`Found series "${series.name}" with no books - removing it`)
|
||||
await series.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new Database()
|
||||
|
|
@ -9,24 +9,19 @@ const rateLimit = require('./libs/expressRateLimit')
|
|||
const { version } = require('../package.json')
|
||||
|
||||
// Utils
|
||||
const filePerms = require('./utils/filePerms')
|
||||
const fileUtils = require('./utils/fileUtils')
|
||||
const Logger = require('./Logger')
|
||||
|
||||
const Auth = require('./Auth')
|
||||
const Watcher = require('./Watcher')
|
||||
const Scanner = require('./scanner/Scanner')
|
||||
const Database = require('./Database')
|
||||
const SocketAuthority = require('./SocketAuthority')
|
||||
|
||||
const routes = require('./routes/index')
|
||||
|
||||
const ApiRouter = require('./routers/ApiRouter')
|
||||
const HlsRouter = require('./routers/HlsRouter')
|
||||
|
||||
const NotificationManager = require('./managers/NotificationManager')
|
||||
const EmailManager = require('./managers/EmailManager')
|
||||
const CoverManager = require('./managers/CoverManager')
|
||||
const AbMergeManager = require('./managers/AbMergeManager')
|
||||
const CacheManager = require('./managers/CacheManager')
|
||||
const LogManager = require('./managers/LogManager')
|
||||
|
|
@ -37,6 +32,7 @@ const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
|
|||
const RssFeedManager = require('./managers/RssFeedManager')
|
||||
const CronManager = require('./managers/CronManager')
|
||||
const TaskManager = require('./managers/TaskManager')
|
||||
const LibraryScanner = require('./scanner/LibraryScanner')
|
||||
|
||||
//Import the main Passport and Express-Session library
|
||||
const passport = require('passport')
|
||||
|
|
@ -58,11 +54,9 @@ class Server {
|
|||
|
||||
if (!fs.pathExistsSync(global.ConfigPath)) {
|
||||
fs.mkdirSync(global.ConfigPath)
|
||||
filePerms.setDefaultDirSync(global.ConfigPath, false)
|
||||
}
|
||||
if (!fs.pathExistsSync(global.MetadataPath)) {
|
||||
fs.mkdirSync(global.MetadataPath)
|
||||
filePerms.setDefaultDirSync(global.MetadataPath, false)
|
||||
}
|
||||
|
||||
this.watcher = new Watcher()
|
||||
|
|
@ -74,16 +68,12 @@ class Server {
|
|||
this.emailManager = new EmailManager()
|
||||
this.backupManager = new BackupManager()
|
||||
this.logManager = new LogManager()
|
||||
this.cacheManager = new CacheManager()
|
||||
this.abMergeManager = new AbMergeManager(this.taskManager)
|
||||
this.playbackSessionManager = new PlaybackSessionManager()
|
||||
this.coverManager = new CoverManager(this.cacheManager)
|
||||
this.podcastManager = new PodcastManager(this.watcher, this.notificationManager, this.taskManager)
|
||||
this.audioMetadataManager = new AudioMetadataMangaer(this.taskManager)
|
||||
this.rssFeedManager = new RssFeedManager()
|
||||
|
||||
this.scanner = new Scanner(this.coverManager, this.taskManager)
|
||||
this.cronManager = new CronManager(this.scanner, this.podcastManager)
|
||||
this.cronManager = new CronManager(this.podcastManager)
|
||||
|
||||
// Routers
|
||||
this.apiRouter = new ApiRouter(this)
|
||||
|
|
@ -99,6 +89,14 @@ class Server {
|
|||
this.auth.isAuthenticated(req, res, next)
|
||||
}
|
||||
|
||||
cancelLibraryScan(libraryId) {
|
||||
LibraryScanner.setCancelLibraryScan(libraryId)
|
||||
}
|
||||
|
||||
getLibrariesScanning() {
|
||||
return LibraryScanner.librariesScanning
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize database, backups, logs, rss feeds, cron jobs & watcher
|
||||
* Cleanup stale/invalid data
|
||||
|
|
@ -115,22 +113,20 @@ class Server {
|
|||
}
|
||||
|
||||
await this.cleanUserData() // Remove invalid user item progress
|
||||
await this.cacheManager.ensureCachePaths()
|
||||
await CacheManager.ensureCachePaths()
|
||||
|
||||
await this.backupManager.init()
|
||||
await this.logManager.init()
|
||||
await this.apiRouter.checkRemoveEmptySeries(Database.series) // Remove empty series
|
||||
await this.rssFeedManager.init()
|
||||
|
||||
const libraries = await Database.models.library.getAllOldLibraries()
|
||||
this.cronManager.init(libraries)
|
||||
const libraries = await Database.libraryModel.getAllOldLibraries()
|
||||
await this.cronManager.init(libraries)
|
||||
|
||||
if (Database.serverSettings.scannerDisableWatcher) {
|
||||
Logger.info(`[Server] Watcher is disabled`)
|
||||
this.watcher.disabled = true
|
||||
} else {
|
||||
this.watcher.initWatcher(libraries)
|
||||
this.watcher.on('files', this.filesChanged.bind(this))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -269,17 +265,12 @@ class Server {
|
|||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
async filesChanged(fileUpdates) {
|
||||
Logger.info('[Server]', fileUpdates.length, 'Files Changed')
|
||||
await this.scanner.scanFilesChanged(fileUpdates)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove user media progress for items that no longer exist & remove seriesHideFrom that no longer exist
|
||||
*/
|
||||
async cleanUserData() {
|
||||
// Get all media progress without an associated media item
|
||||
const mediaProgressToRemove = await Database.models.mediaProgress.findAll({
|
||||
const mediaProgressToRemove = await Database.mediaProgressModel.findAll({
|
||||
where: {
|
||||
'$podcastEpisode.id$': null,
|
||||
'$book.id$': null
|
||||
|
|
@ -287,18 +278,18 @@ class Server {
|
|||
attributes: ['id'],
|
||||
include: [
|
||||
{
|
||||
model: Database.models.book,
|
||||
model: Database.bookModel,
|
||||
attributes: ['id']
|
||||
},
|
||||
{
|
||||
model: Database.models.podcastEpisode,
|
||||
model: Database.podcastEpisodeModel,
|
||||
attributes: ['id']
|
||||
}
|
||||
]
|
||||
})
|
||||
if (mediaProgressToRemove.length) {
|
||||
// Remove media progress
|
||||
const mediaProgressRemoved = await Database.models.mediaProgress.destroy({
|
||||
const mediaProgressRemoved = await Database.mediaProgressModel.destroy({
|
||||
where: {
|
||||
id: {
|
||||
[Sequelize.Op.in]: mediaProgressToRemove.map(mp => mp.id)
|
||||
|
|
@ -311,12 +302,19 @@ class Server {
|
|||
}
|
||||
|
||||
// Remove series from hide from continue listening that no longer exist
|
||||
const users = await Database.models.user.getOldUsers()
|
||||
const users = await Database.userModel.getOldUsers()
|
||||
for (const _user of users) {
|
||||
let hasUpdated = false
|
||||
if (_user.seriesHideFromContinueListening.length) {
|
||||
const seriesHiding = (await Database.seriesModel.findAll({
|
||||
where: {
|
||||
id: _user.seriesHideFromContinueListening
|
||||
},
|
||||
attributes: ['id'],
|
||||
raw: true
|
||||
})).map(se => se.id)
|
||||
_user.seriesHideFromContinueListening = _user.seriesHideFromContinueListening.filter(seriesId => {
|
||||
if (!Database.series.some(se => se.id === seriesId)) { // Series removed
|
||||
if (!seriesHiding.includes(seriesId)) { // Series removed
|
||||
hasUpdated = true
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,11 @@ class SocketAuthority {
|
|||
this.clients = {}
|
||||
}
|
||||
|
||||
// returns an array of User.toJSONForPublic with `connections` for the # of socket connections
|
||||
// a user can have many socket connections
|
||||
/**
|
||||
* returns an array of User.toJSONForPublic with `connections` for the # of socket connections
|
||||
* a user can have many socket connections
|
||||
* @returns {object[]}
|
||||
*/
|
||||
getUsersOnline() {
|
||||
const onlineUsersMap = {}
|
||||
Object.values(this.clients).filter(c => c.user).forEach(client => {
|
||||
|
|
@ -19,7 +22,7 @@ class SocketAuthority {
|
|||
onlineUsersMap[client.user.id].connections++
|
||||
} else {
|
||||
onlineUsersMap[client.user.id] = {
|
||||
...client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, Database.libraryItems),
|
||||
...client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions),
|
||||
connections: 1
|
||||
}
|
||||
}
|
||||
|
|
@ -31,9 +34,12 @@ class SocketAuthority {
|
|||
return Object.values(this.clients).filter(c => c.user && c.user.id === userId)
|
||||
}
|
||||
|
||||
// Emits event to all authorized clients
|
||||
// optional filter function to only send event to specific users
|
||||
// TODO: validate that filter is actually a function
|
||||
/**
|
||||
* Emits event to all authorized clients
|
||||
* @param {string} evt
|
||||
* @param {any} data
|
||||
* @param {Function} [filter] optional filter function to only send event to specific users
|
||||
*/
|
||||
emitter(evt, data, filter = null) {
|
||||
for (const socketId in this.clients) {
|
||||
if (this.clients[socketId].user) {
|
||||
|
|
@ -89,7 +95,7 @@ class SocketAuthority {
|
|||
socket.on('auth', (token) => this.authenticateSocket(socket, token))
|
||||
|
||||
// Scanning
|
||||
socket.on('cancel_scan', this.cancelScan.bind(this))
|
||||
socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId))
|
||||
|
||||
// Logs
|
||||
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
|
||||
|
|
@ -108,7 +114,7 @@ class SocketAuthority {
|
|||
delete this.clients[socket.id]
|
||||
} else {
|
||||
Logger.debug('[SocketAuthority] User Offline ' + _client.user.username)
|
||||
this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, Database.libraryItems))
|
||||
this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions))
|
||||
|
||||
const disconnectTime = Date.now() - _client.connected_at
|
||||
Logger.info(`[SocketAuthority] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`)
|
||||
|
|
@ -165,7 +171,7 @@ class SocketAuthority {
|
|||
|
||||
Logger.debug(`[SocketAuthority] User Online ${client.user.username}`)
|
||||
|
||||
this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, Database.libraryItems))
|
||||
this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions))
|
||||
|
||||
// Update user lastSeen
|
||||
user.lastSeen = Date.now()
|
||||
|
|
@ -174,7 +180,7 @@ class SocketAuthority {
|
|||
const initialPayload = {
|
||||
userId: client.user.id,
|
||||
username: client.user.username,
|
||||
librariesScanning: this.Server.scanner.librariesScanning
|
||||
librariesScanning: this.Server.getLibrariesScanning()
|
||||
}
|
||||
if (user.isAdminOrUp) {
|
||||
initialPayload.usersOnline = this.getUsersOnline()
|
||||
|
|
@ -191,7 +197,7 @@ class SocketAuthority {
|
|||
|
||||
if (client.user) {
|
||||
Logger.debug('[SocketAuthority] User Offline ' + client.user.username)
|
||||
this.adminEmitter('user_offline', client.user.toJSONForPublic(null, Database.libraryItems))
|
||||
this.adminEmitter('user_offline', client.user.toJSONForPublic())
|
||||
}
|
||||
|
||||
delete this.clients[socketId].user
|
||||
|
|
@ -203,7 +209,7 @@ class SocketAuthority {
|
|||
|
||||
cancelScan(id) {
|
||||
Logger.debug('[SocketAuthority] Cancel scan', id)
|
||||
this.Server.scanner.setCancelLibraryScan(id)
|
||||
this.Server.cancelLibraryScan(id)
|
||||
}
|
||||
}
|
||||
module.exports = new SocketAuthority()
|
||||
|
|
@ -1,21 +1,34 @@
|
|||
const Path = require('path')
|
||||
const EventEmitter = require('events')
|
||||
const Watcher = require('./libs/watcher/watcher')
|
||||
const Logger = require('./Logger')
|
||||
const LibraryScanner = require('./scanner/LibraryScanner')
|
||||
|
||||
const { filePathToPOSIX } = require('./utils/fileUtils')
|
||||
|
||||
/**
|
||||
* @typedef PendingFileUpdate
|
||||
* @property {string} path
|
||||
* @property {string} relPath
|
||||
* @property {string} folderId
|
||||
* @property {string} type
|
||||
*/
|
||||
class FolderWatcher extends EventEmitter {
|
||||
constructor() {
|
||||
super()
|
||||
this.paths = [] // Not used
|
||||
this.pendingFiles = [] // Not used
|
||||
|
||||
/** @type {{id:string, name:string, folders:import('./objects/Folder')[], paths:string[], watcher:Watcher[]}[]} */
|
||||
this.libraryWatchers = []
|
||||
/** @type {PendingFileUpdate[]} */
|
||||
this.pendingFileUpdates = []
|
||||
this.pendingDelay = 4000
|
||||
this.pendingTimeout = null
|
||||
|
||||
/** @type {string[]} */
|
||||
this.ignoreDirs = []
|
||||
/** @type {string[]} */
|
||||
this.pendingDirsToRemoveFromIgnore = []
|
||||
|
||||
this.disabled = false
|
||||
}
|
||||
|
||||
|
|
@ -29,11 +42,12 @@ class FolderWatcher extends EventEmitter {
|
|||
return
|
||||
}
|
||||
Logger.info(`[Watcher] Initializing watcher for "${library.name}".`)
|
||||
var folderPaths = library.folderPaths
|
||||
|
||||
const folderPaths = library.folderPaths
|
||||
folderPaths.forEach((fp) => {
|
||||
Logger.debug(`[Watcher] Init watcher for library folder path "${fp}"`)
|
||||
})
|
||||
var watcher = new Watcher(folderPaths, {
|
||||
const watcher = new Watcher(folderPaths, {
|
||||
ignored: /(^|[\/\\])\../, // ignore dotfiles
|
||||
renameDetection: true,
|
||||
renameTimeout: 2000,
|
||||
|
|
@ -144,6 +158,12 @@ class FolderWatcher extends EventEmitter {
|
|||
this.addFileUpdate(libraryId, pathTo, 'renamed')
|
||||
}
|
||||
|
||||
/**
|
||||
* File update detected from watcher
|
||||
* @param {string} libraryId
|
||||
* @param {string} path
|
||||
* @param {string} type
|
||||
*/
|
||||
addFileUpdate(libraryId, path, type) {
|
||||
path = filePathToPOSIX(path)
|
||||
if (this.pendingFilePaths.includes(path)) return
|
||||
|
|
@ -161,11 +181,18 @@ class FolderWatcher extends EventEmitter {
|
|||
Logger.error(`[Watcher] New file folder not found in library "${libwatcher.name}" with path "${path}"`)
|
||||
return
|
||||
}
|
||||
|
||||
const folderFullPath = filePathToPOSIX(folder.fullPath)
|
||||
|
||||
var relPath = path.replace(folderFullPath, '')
|
||||
const relPath = path.replace(folderFullPath, '')
|
||||
|
||||
var hasDotPath = relPath.split('/').find(p => p.startsWith('.'))
|
||||
if (Path.extname(relPath).toLowerCase() === '.part') {
|
||||
Logger.debug(`[Watcher] Ignoring .part file "${relPath}"`)
|
||||
return
|
||||
}
|
||||
|
||||
// Ignore files/folders starting with "."
|
||||
const hasDotPath = relPath.split('/').find(p => p.startsWith('.'))
|
||||
if (hasDotPath) {
|
||||
Logger.debug(`[Watcher] Ignoring dot path "${relPath}" | Piece "${hasDotPath}"`)
|
||||
return
|
||||
|
|
@ -184,7 +211,8 @@ class FolderWatcher extends EventEmitter {
|
|||
// Notify server of update after "pendingDelay"
|
||||
clearTimeout(this.pendingTimeout)
|
||||
this.pendingTimeout = setTimeout(() => {
|
||||
this.emit('files', this.pendingFileUpdates)
|
||||
// this.emit('files', this.pendingFileUpdates)
|
||||
LibraryScanner.scanFilesChanged(this.pendingFileUpdates)
|
||||
this.pendingFileUpdates = []
|
||||
}, this.pendingDelay)
|
||||
}
|
||||
|
|
@ -195,24 +223,50 @@ class FolderWatcher extends EventEmitter {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to POSIX and remove trailing slash
|
||||
* @param {string} path
|
||||
* @returns {string}
|
||||
*/
|
||||
cleanDirPath(path) {
|
||||
path = filePathToPOSIX(path)
|
||||
if (path.endsWith('/')) path = path.slice(0, -1)
|
||||
return path
|
||||
}
|
||||
|
||||
/**
|
||||
* Ignore this directory if files are picked up by watcher
|
||||
* @param {string} path
|
||||
*/
|
||||
addIgnoreDir(path) {
|
||||
path = this.cleanDirPath(path)
|
||||
if (this.ignoreDirs.includes(path)) return
|
||||
this.pendingDirsToRemoveFromIgnore = this.pendingDirsToRemoveFromIgnore.filter(p => p !== path)
|
||||
Logger.debug(`[Watcher] Ignoring directory "${path}"`)
|
||||
this.ignoreDirs.push(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* When downloading a podcast episode we dont want the scanner triggering for that podcast
|
||||
* when the episode finishes the watcher may have a delayed response so a timeout is added
|
||||
* to prevent the watcher from picking up the episode
|
||||
*
|
||||
* @param {string} path
|
||||
*/
|
||||
removeIgnoreDir(path) {
|
||||
path = this.cleanDirPath(path)
|
||||
if (!this.ignoreDirs.includes(path)) return
|
||||
Logger.debug(`[Watcher] No longer ignoring directory "${path}"`)
|
||||
this.ignoreDirs = this.ignoreDirs.filter(p => p !== path)
|
||||
if (!this.ignoreDirs.includes(path) || this.pendingDirsToRemoveFromIgnore.includes(path)) return
|
||||
|
||||
// Add a 5 second delay before removing the ignore from this dir
|
||||
this.pendingDirsToRemoveFromIgnore.push(path)
|
||||
setTimeout(() => {
|
||||
if (this.pendingDirsToRemoveFromIgnore.includes(path)) {
|
||||
this.pendingDirsToRemoveFromIgnore = this.pendingDirsToRemoveFromIgnore.filter(p => p !== path)
|
||||
Logger.debug(`[Watcher] No longer ignoring directory "${path}"`)
|
||||
this.ignoreDirs = this.ignoreDirs.filter(p => p !== path)
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
}
|
||||
}
|
||||
module.exports = FolderWatcher
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
|
||||
const sequelize = require('sequelize')
|
||||
const fs = require('../libs/fsExtra')
|
||||
const { createNewSortInstance } = require('../libs/fastSort')
|
||||
|
||||
const Logger = require('../Logger')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const Database = require('../Database')
|
||||
const CacheManager = require('../managers/CacheManager')
|
||||
const CoverManager = require('../managers/CoverManager')
|
||||
const AuthorFinder = require('../finders/AuthorFinder')
|
||||
|
||||
const { reqSupportsWebp } = require('../utils/index')
|
||||
|
||||
|
|
@ -21,7 +24,7 @@ class AuthorController {
|
|||
|
||||
// Used on author landing page to include library items and items grouped in series
|
||||
if (include.includes('items')) {
|
||||
authorJson.libraryItems = await Database.models.libraryItem.getForAuthor(req.author, req.user)
|
||||
authorJson.libraryItems = await Database.libraryItemModel.getForAuthor(req.author, req.user)
|
||||
|
||||
if (include.includes('series')) {
|
||||
const seriesMap = {}
|
||||
|
|
@ -67,13 +70,13 @@ class AuthorController {
|
|||
// Updating/removing cover image
|
||||
if (payload.imagePath !== undefined && payload.imagePath !== req.author.imagePath) {
|
||||
if (!payload.imagePath && req.author.imagePath) { // If removing image then remove file
|
||||
await this.cacheManager.purgeImageCache(req.author.id) // Purge cache
|
||||
await this.coverManager.removeFile(req.author.imagePath)
|
||||
await CacheManager.purgeImageCache(req.author.id) // Purge cache
|
||||
await CoverManager.removeFile(req.author.imagePath)
|
||||
} else if (payload.imagePath.startsWith('http')) { // Check if image path is a url
|
||||
const imageData = await this.authorFinder.saveAuthorImage(req.author.id, payload.imagePath)
|
||||
const imageData = await AuthorFinder.saveAuthorImage(req.author.id, payload.imagePath)
|
||||
if (imageData) {
|
||||
if (req.author.imagePath) {
|
||||
await this.cacheManager.purgeImageCache(req.author.id) // Purge cache
|
||||
await CacheManager.purgeImageCache(req.author.id) // Purge cache
|
||||
}
|
||||
payload.imagePath = imageData.path
|
||||
hasUpdated = true
|
||||
|
|
@ -85,7 +88,7 @@ class AuthorController {
|
|||
}
|
||||
|
||||
if (req.author.imagePath) {
|
||||
await this.cacheManager.purgeImageCache(req.author.id) // Purge cache
|
||||
await CacheManager.purgeImageCache(req.author.id) // Purge cache
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -93,10 +96,21 @@ class AuthorController {
|
|||
const authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name
|
||||
|
||||
// Check if author name matches another author and merge the authors
|
||||
const existingAuthor = authorNameUpdate ? Database.authors.find(au => au.id !== req.author.id && payload.name === au.name) : false
|
||||
let existingAuthor = null
|
||||
if (authorNameUpdate) {
|
||||
const author = await Database.authorModel.findOne({
|
||||
where: {
|
||||
id: {
|
||||
[sequelize.Op.not]: req.author.id
|
||||
},
|
||||
name: payload.name
|
||||
}
|
||||
})
|
||||
existingAuthor = author?.getOldAuthor()
|
||||
}
|
||||
if (existingAuthor) {
|
||||
const bookAuthorsToCreate = []
|
||||
const itemsWithAuthor = await Database.models.libraryItem.getForAuthor(req.author)
|
||||
const itemsWithAuthor = await Database.libraryItemModel.getForAuthor(req.author)
|
||||
itemsWithAuthor.forEach(libraryItem => { // Replace old author with merging author for each book
|
||||
libraryItem.media.metadata.replaceAuthor(req.author, existingAuthor)
|
||||
bookAuthorsToCreate.push({
|
||||
|
|
@ -113,9 +127,11 @@ class AuthorController {
|
|||
// Remove old author
|
||||
await Database.removeAuthor(req.author.id)
|
||||
SocketAuthority.emitter('author_removed', req.author.toJSON())
|
||||
// Update filter data
|
||||
Database.removeAuthorFromFilterData(req.author.libraryId, req.author.id)
|
||||
|
||||
// Send updated num books for merged author
|
||||
const numBooks = await Database.models.libraryItem.getForAuthor(existingAuthor).length
|
||||
const numBooks = await Database.libraryItemModel.getForAuthor(existingAuthor).length
|
||||
SocketAuthority.emitter('author_updated', existingAuthor.toJSONExpanded(numBooks))
|
||||
|
||||
res.json({
|
||||
|
|
@ -130,7 +146,7 @@ class AuthorController {
|
|||
if (hasUpdated) {
|
||||
req.author.updatedAt = Date.now()
|
||||
|
||||
const itemsWithAuthor = await Database.models.libraryItem.getForAuthor(req.author)
|
||||
const itemsWithAuthor = await Database.libraryItemModel.getForAuthor(req.author)
|
||||
if (authorNameUpdate) { // Update author name on all books
|
||||
itemsWithAuthor.forEach(libraryItem => {
|
||||
libraryItem.media.metadata.updateAuthor(req.author)
|
||||
|
|
@ -151,24 +167,13 @@ class AuthorController {
|
|||
}
|
||||
}
|
||||
|
||||
async search(req, res) {
|
||||
var q = (req.query.q || '').toLowerCase()
|
||||
if (!q) return res.json([])
|
||||
var limit = (req.query.limit && !isNaN(req.query.limit)) ? Number(req.query.limit) : 25
|
||||
var authors = Database.authors.filter(au => au.name?.toLowerCase().includes(q))
|
||||
authors = authors.slice(0, limit)
|
||||
res.json({
|
||||
results: authors
|
||||
})
|
||||
}
|
||||
|
||||
async match(req, res) {
|
||||
let authorData = null
|
||||
const region = req.body.region || 'us'
|
||||
if (req.body.asin) {
|
||||
authorData = await this.authorFinder.findAuthorByASIN(req.body.asin, region)
|
||||
authorData = await AuthorFinder.findAuthorByASIN(req.body.asin, region)
|
||||
} else {
|
||||
authorData = await this.authorFinder.findAuthorByName(req.body.q, region)
|
||||
authorData = await AuthorFinder.findAuthorByName(req.body.q, region)
|
||||
}
|
||||
if (!authorData) {
|
||||
return res.status(404).send('Author not found')
|
||||
|
|
@ -183,9 +188,9 @@ class AuthorController {
|
|||
|
||||
// Only updates image if there was no image before or the author ASIN was updated
|
||||
if (authorData.image && (!req.author.imagePath || hasUpdates)) {
|
||||
this.cacheManager.purgeImageCache(req.author.id)
|
||||
await CacheManager.purgeImageCache(req.author.id)
|
||||
|
||||
const imageData = await this.authorFinder.saveAuthorImage(req.author.id, authorData.image)
|
||||
const imageData = await AuthorFinder.saveAuthorImage(req.author.id, authorData.image)
|
||||
if (imageData) {
|
||||
req.author.imagePath = imageData.path
|
||||
hasUpdates = true
|
||||
|
|
@ -202,7 +207,7 @@ class AuthorController {
|
|||
|
||||
await Database.updateAuthor(req.author)
|
||||
|
||||
const numBooks = await Database.models.libraryItem.getForAuthor(req.author).length
|
||||
const numBooks = await Database.libraryItemModel.getForAuthor(req.author).length
|
||||
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))
|
||||
}
|
||||
|
||||
|
|
@ -229,11 +234,11 @@ class AuthorController {
|
|||
height: height ? parseInt(height) : null,
|
||||
width: width ? parseInt(width) : null
|
||||
}
|
||||
return this.cacheManager.handleAuthorCache(res, author, options)
|
||||
return CacheManager.handleAuthorCache(res, author, options)
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
const author = Database.authors.find(au => au.id === req.params.id)
|
||||
async middleware(req, res, next) {
|
||||
const author = await Database.authorModel.getOldById(req.params.id)
|
||||
if (!author) return res.sendStatus(404)
|
||||
|
||||
if (req.method == 'DELETE' && !req.user.canDelete) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
const Logger = require('../Logger')
|
||||
const CacheManager = require('../managers/CacheManager')
|
||||
|
||||
class CacheController {
|
||||
constructor() { }
|
||||
|
|
@ -8,7 +8,7 @@ class CacheController {
|
|||
if (!req.user.isAdminOrUp) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
await this.cacheManager.purgeAll()
|
||||
await CacheManager.purgeAll()
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
|
|
@ -17,7 +17,7 @@ class CacheController {
|
|||
if (!req.user.isAdminOrUp) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
await this.cacheManager.purgeItems()
|
||||
await CacheManager.purgeItems()
|
||||
res.sendStatus(200)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
const Sequelize = require('sequelize')
|
||||
const Logger = require('../Logger')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const Database = require('../Database')
|
||||
|
|
@ -7,22 +8,49 @@ const Collection = require('../objects/Collection')
|
|||
class CollectionController {
|
||||
constructor() { }
|
||||
|
||||
/**
|
||||
* POST: /api/collections
|
||||
* Create new collection
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
async create(req, res) {
|
||||
const newCollection = new Collection()
|
||||
req.body.userId = req.user.id
|
||||
if (!newCollection.setData(req.body)) {
|
||||
return res.status(500).send('Invalid collection data')
|
||||
return res.status(400).send('Invalid collection data')
|
||||
}
|
||||
|
||||
// Create collection record
|
||||
await Database.collectionModel.createFromOld(newCollection)
|
||||
|
||||
// Get library items in collection
|
||||
const libraryItemsInCollection = await Database.libraryItemModel.getForCollection(newCollection)
|
||||
|
||||
// Create collectionBook records
|
||||
let order = 1
|
||||
const collectionBooksToAdd = []
|
||||
for (const libraryItemId of newCollection.books) {
|
||||
const libraryItem = libraryItemsInCollection.find(li => li.id === libraryItemId)
|
||||
if (libraryItem) {
|
||||
collectionBooksToAdd.push({
|
||||
collectionId: newCollection.id,
|
||||
bookId: libraryItem.media.id,
|
||||
order: order++
|
||||
})
|
||||
}
|
||||
}
|
||||
if (collectionBooksToAdd.length) {
|
||||
await Database.createBulkCollectionBooks(collectionBooksToAdd)
|
||||
}
|
||||
|
||||
const libraryItemsInCollection = await Database.models.libraryItem.getForCollection(newCollection)
|
||||
const jsonExpanded = newCollection.toJSONExpanded(libraryItemsInCollection)
|
||||
await Database.createCollection(newCollection)
|
||||
SocketAuthority.emitter('collection_added', jsonExpanded)
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
|
||||
async findAll(req, res) {
|
||||
const collectionsExpanded = await Database.models.collection.getOldCollectionsJsonExpanded(req.user)
|
||||
const collectionsExpanded = await Database.collectionModel.getOldCollectionsJsonExpanded(req.user)
|
||||
res.json({
|
||||
collections: collectionsExpanded
|
||||
})
|
||||
|
|
@ -31,140 +59,275 @@ class CollectionController {
|
|||
async findOne(req, res) {
|
||||
const includeEntities = (req.query.include || '').split(',')
|
||||
|
||||
const collectionExpanded = req.collection.toJSONExpanded(Database.libraryItems)
|
||||
|
||||
if (includeEntities.includes('rssfeed')) {
|
||||
const feedData = await this.rssFeedManager.findFeedForEntityId(collectionExpanded.id)
|
||||
collectionExpanded.rssFeed = feedData?.toJSONMinified() || null
|
||||
const collectionExpanded = await req.collection.getOldJsonExpanded(req.user, includeEntities)
|
||||
if (!collectionExpanded) {
|
||||
// This may happen if the user is restricted from all books
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
res.json(collectionExpanded)
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH: /api/collections/:id
|
||||
* Update collection
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
async update(req, res) {
|
||||
const collection = req.collection
|
||||
const wasUpdated = collection.update(req.body)
|
||||
const jsonExpanded = collection.toJSONExpanded(Database.libraryItems)
|
||||
let wasUpdated = false
|
||||
|
||||
// Update description and name if defined
|
||||
const collectionUpdatePayload = {}
|
||||
if (req.body.description !== undefined && req.body.description !== req.collection.description) {
|
||||
collectionUpdatePayload.description = req.body.description
|
||||
wasUpdated = true
|
||||
}
|
||||
if (req.body.name !== undefined && req.body.name !== req.collection.name) {
|
||||
collectionUpdatePayload.name = req.body.name
|
||||
wasUpdated = true
|
||||
}
|
||||
|
||||
if (wasUpdated) {
|
||||
await req.collection.update(collectionUpdatePayload)
|
||||
}
|
||||
|
||||
// If books array is passed in then update order in collection
|
||||
if (req.body.books?.length) {
|
||||
const collectionBooks = await req.collection.getCollectionBooks({
|
||||
include: {
|
||||
model: Database.bookModel,
|
||||
include: Database.libraryItemModel
|
||||
},
|
||||
order: [['order', 'ASC']]
|
||||
})
|
||||
collectionBooks.sort((a, b) => {
|
||||
const aIndex = req.body.books.findIndex(lid => lid === a.book.libraryItem.id)
|
||||
const bIndex = req.body.books.findIndex(lid => lid === b.book.libraryItem.id)
|
||||
return aIndex - bIndex
|
||||
})
|
||||
for (let i = 0; i < collectionBooks.length; i++) {
|
||||
if (collectionBooks[i].order !== i + 1) {
|
||||
await collectionBooks[i].update({
|
||||
order: i + 1
|
||||
})
|
||||
wasUpdated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||
if (wasUpdated) {
|
||||
await Database.updateCollection(collection)
|
||||
SocketAuthority.emitter('collection_updated', jsonExpanded)
|
||||
}
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
|
||||
async delete(req, res) {
|
||||
const collection = req.collection
|
||||
const jsonExpanded = collection.toJSONExpanded(Database.libraryItems)
|
||||
const jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||
|
||||
// Close rss feed - remove from db and emit socket event
|
||||
await this.rssFeedManager.closeFeedForEntityId(collection.id)
|
||||
await this.rssFeedManager.closeFeedForEntityId(req.collection.id)
|
||||
|
||||
await req.collection.destroy()
|
||||
|
||||
await Database.removeCollection(collection.id)
|
||||
SocketAuthority.emitter('collection_removed', jsonExpanded)
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
/**
|
||||
* POST: /api/collections/:id/book
|
||||
* Add a single book to a collection
|
||||
* Req.body { id: <library item id> }
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
async addBook(req, res) {
|
||||
const collection = req.collection
|
||||
const libraryItem = Database.libraryItems.find(li => li.id === req.body.id)
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(req.body.id)
|
||||
if (!libraryItem) {
|
||||
return res.status(500).send('Book not found')
|
||||
return res.status(404).send('Book not found')
|
||||
}
|
||||
if (libraryItem.libraryId !== collection.libraryId) {
|
||||
return res.status(500).send('Book in different library')
|
||||
if (libraryItem.libraryId !== req.collection.libraryId) {
|
||||
return res.status(400).send('Book in different library')
|
||||
}
|
||||
if (collection.books.includes(req.body.id)) {
|
||||
return res.status(500).send('Book already in collection')
|
||||
}
|
||||
collection.addBook(req.body.id)
|
||||
const jsonExpanded = collection.toJSONExpanded(Database.libraryItems)
|
||||
|
||||
const collectionBook = {
|
||||
collectionId: collection.id,
|
||||
bookId: libraryItem.media.id,
|
||||
order: collection.books.length
|
||||
// Check if book is already in collection
|
||||
const collectionBooks = await req.collection.getCollectionBooks()
|
||||
if (collectionBooks.some(cb => cb.bookId === libraryItem.media.id)) {
|
||||
return res.status(400).send('Book already in collection')
|
||||
}
|
||||
await Database.createCollectionBook(collectionBook)
|
||||
|
||||
// Create collectionBook record
|
||||
await Database.collectionBookModel.create({
|
||||
collectionId: req.collection.id,
|
||||
bookId: libraryItem.media.id,
|
||||
order: collectionBooks.length + 1
|
||||
})
|
||||
const jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||
SocketAuthority.emitter('collection_updated', jsonExpanded)
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
|
||||
// DELETE: api/collections/:id/book/:bookId
|
||||
/**
|
||||
* DELETE: /api/collections/:id/book/:bookId
|
||||
* Remove a single book from a collection. Re-order books
|
||||
* TODO: bookId is actually libraryItemId. Clients need updating to use bookId
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
async removeBook(req, res) {
|
||||
const collection = req.collection
|
||||
const libraryItem = Database.libraryItems.find(li => li.id === req.params.bookId)
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(req.params.bookId)
|
||||
if (!libraryItem) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
if (collection.books.includes(req.params.bookId)) {
|
||||
collection.removeBook(req.params.bookId)
|
||||
const jsonExpanded = collection.toJSONExpanded(Database.libraryItems)
|
||||
// Get books in collection ordered
|
||||
const collectionBooks = await req.collection.getCollectionBooks({
|
||||
order: [['order', 'ASC']]
|
||||
})
|
||||
|
||||
let jsonExpanded = null
|
||||
const collectionBookToRemove = collectionBooks.find(cb => cb.bookId === libraryItem.media.id)
|
||||
if (collectionBookToRemove) {
|
||||
// Remove collection book record
|
||||
await collectionBookToRemove.destroy()
|
||||
|
||||
// Update order on collection books
|
||||
let order = 1
|
||||
for (const collectionBook of collectionBooks) {
|
||||
if (collectionBook.bookId === libraryItem.media.id) continue
|
||||
if (collectionBook.order !== order) {
|
||||
await collectionBook.update({
|
||||
order
|
||||
})
|
||||
}
|
||||
order++
|
||||
}
|
||||
|
||||
jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||
SocketAuthority.emitter('collection_updated', jsonExpanded)
|
||||
await Database.updateCollection(collection)
|
||||
} else {
|
||||
jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||
}
|
||||
res.json(collection.toJSONExpanded(Database.libraryItems))
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
|
||||
// POST: api/collections/:id/batch/add
|
||||
/**
|
||||
* POST: /api/collections/:id/batch/add
|
||||
* Add multiple books to collection
|
||||
* Req.body { books: <Array of library item ids> }
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
async addBatch(req, res) {
|
||||
const collection = req.collection
|
||||
if (!req.body.books || !req.body.books.length) {
|
||||
// filter out invalid libraryItemIds
|
||||
const bookIdsToAdd = (req.body.books || []).filter(b => !!b && typeof b == 'string')
|
||||
if (!bookIdsToAdd.length) {
|
||||
return res.status(500).send('Invalid request body')
|
||||
}
|
||||
const bookIdsToAdd = req.body.books
|
||||
|
||||
// Get library items associated with ids
|
||||
const libraryItems = await Database.libraryItemModel.findAll({
|
||||
where: {
|
||||
id: {
|
||||
[Sequelize.Op.in]: bookIdsToAdd
|
||||
}
|
||||
},
|
||||
include: {
|
||||
model: Database.bookModel
|
||||
}
|
||||
})
|
||||
|
||||
// Get collection books already in collection
|
||||
const collectionBooks = await req.collection.getCollectionBooks()
|
||||
|
||||
let order = collectionBooks.length + 1
|
||||
const collectionBooksToAdd = []
|
||||
let hasUpdated = false
|
||||
|
||||
let order = collection.books.length
|
||||
for (const libraryItemId of bookIdsToAdd) {
|
||||
const libraryItem = Database.libraryItems.find(li => li.id === libraryItemId)
|
||||
if (!libraryItem) continue
|
||||
if (!collection.books.includes(libraryItemId)) {
|
||||
collection.addBook(libraryItemId)
|
||||
// Check and set new collection books to add
|
||||
for (const libraryItem of libraryItems) {
|
||||
if (!collectionBooks.some(cb => cb.bookId === libraryItem.media.id)) {
|
||||
collectionBooksToAdd.push({
|
||||
collectionId: collection.id,
|
||||
collectionId: req.collection.id,
|
||||
bookId: libraryItem.media.id,
|
||||
order: order++
|
||||
})
|
||||
hasUpdated = true
|
||||
} else {
|
||||
Logger.warn(`[CollectionController] addBatch: Library item ${libraryItem.id} already in collection`)
|
||||
}
|
||||
}
|
||||
|
||||
let jsonExpanded = null
|
||||
if (hasUpdated) {
|
||||
await Database.createBulkCollectionBooks(collectionBooksToAdd)
|
||||
SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(Database.libraryItems))
|
||||
jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||
SocketAuthority.emitter('collection_updated', jsonExpanded)
|
||||
} else {
|
||||
jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||
}
|
||||
res.json(collection.toJSONExpanded(Database.libraryItems))
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
|
||||
// POST: api/collections/:id/batch/remove
|
||||
/**
|
||||
* POST: /api/collections/:id/batch/remove
|
||||
* Remove multiple books from collection
|
||||
* Req.body { books: <Array of library item ids> }
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
async removeBatch(req, res) {
|
||||
const collection = req.collection
|
||||
if (!req.body.books || !req.body.books.length) {
|
||||
// filter out invalid libraryItemIds
|
||||
const bookIdsToRemove = (req.body.books || []).filter(b => !!b && typeof b == 'string')
|
||||
if (!bookIdsToRemove.length) {
|
||||
return res.status(500).send('Invalid request body')
|
||||
}
|
||||
var bookIdsToRemove = req.body.books
|
||||
let hasUpdated = false
|
||||
for (const libraryItemId of bookIdsToRemove) {
|
||||
const libraryItem = Database.libraryItems.find(li => li.id === libraryItemId)
|
||||
if (!libraryItem) continue
|
||||
|
||||
if (collection.books.includes(libraryItemId)) {
|
||||
collection.removeBook(libraryItemId)
|
||||
// Get library items associated with ids
|
||||
const libraryItems = await Database.libraryItemModel.findAll({
|
||||
where: {
|
||||
id: {
|
||||
[Sequelize.Op.in]: bookIdsToRemove
|
||||
}
|
||||
},
|
||||
include: {
|
||||
model: Database.bookModel
|
||||
}
|
||||
})
|
||||
|
||||
// Get collection books already in collection
|
||||
const collectionBooks = await req.collection.getCollectionBooks({
|
||||
order: [['order', 'ASC']]
|
||||
})
|
||||
|
||||
// Remove collection books and update order
|
||||
let order = 1
|
||||
let hasUpdated = false
|
||||
for (const collectionBook of collectionBooks) {
|
||||
if (libraryItems.some(li => li.media.id === collectionBook.bookId)) {
|
||||
await collectionBook.destroy()
|
||||
hasUpdated = true
|
||||
continue
|
||||
} else if (collectionBook.order !== order) {
|
||||
await collectionBook.update({
|
||||
order
|
||||
})
|
||||
hasUpdated = true
|
||||
}
|
||||
order++
|
||||
}
|
||||
|
||||
let jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||
if (hasUpdated) {
|
||||
await Database.updateCollection(collection)
|
||||
SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(Database.libraryItems))
|
||||
SocketAuthority.emitter('collection_updated', jsonExpanded)
|
||||
}
|
||||
res.json(collection.toJSONExpanded(Database.libraryItems))
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
|
||||
async middleware(req, res, next) {
|
||||
if (req.params.id) {
|
||||
const collection = await Database.models.collection.getById(req.params.id)
|
||||
const collection = await Database.collectionModel.findByPk(req.params.id)
|
||||
if (!collection) {
|
||||
return res.status(404).send('Collection not found')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ class EmailController {
|
|||
async sendEBookToDevice(req, res) {
|
||||
Logger.debug(`[EmailController] Send ebook to device request for libraryItemId=${req.body.libraryItemId}, deviceName=${req.body.deviceName}`)
|
||||
|
||||
const libraryItem = Database.getLibraryItem(req.body.libraryItemId)
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(req.body.libraryItemId)
|
||||
if (!libraryItem) {
|
||||
return res.status(404).send('Library item not found')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ class FileSystemController {
|
|||
})
|
||||
|
||||
// Do not include existing mapped library paths in response
|
||||
const libraryFoldersPaths = await Database.models.libraryFolder.getAllLibraryFolderPaths()
|
||||
const libraryFoldersPaths = await Database.libraryFolderModel.getAllLibraryFolderPaths()
|
||||
libraryFoldersPaths.forEach((path) => {
|
||||
let dir = path || ''
|
||||
if (dir.includes(global.appRoot)) dir = dir.replace(global.appRoot, '')
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -8,11 +8,24 @@ const zipHelpers = require('../utils/zipHelpers')
|
|||
const { reqSupportsWebp } = require('../utils/index')
|
||||
const { ScanResult } = require('../utils/constants')
|
||||
const { getAudioMimeTypeFromExtname } = require('../utils/fileUtils')
|
||||
const LibraryItemScanner = require('../scanner/LibraryItemScanner')
|
||||
const AudioFileScanner = require('../scanner/AudioFileScanner')
|
||||
const Scanner = require('../scanner/Scanner')
|
||||
const CacheManager = require('../managers/CacheManager')
|
||||
const CoverManager = require('../managers/CoverManager')
|
||||
|
||||
class LibraryItemController {
|
||||
constructor() { }
|
||||
|
||||
// Example expand with authors: api/items/:id?expanded=1&include=authors
|
||||
/**
|
||||
* GET: /api/items/:id
|
||||
* Optional query params:
|
||||
* ?include=progress,rssfeed,downloads
|
||||
* ?expanded=1
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
async findOne(req, res) {
|
||||
const includeEntities = (req.query.include || '').split(',')
|
||||
if (req.query.expanded == 1) {
|
||||
|
|
@ -29,17 +42,7 @@ class LibraryItemController {
|
|||
item.rssFeed = feedData?.toJSONMinified() || null
|
||||
}
|
||||
|
||||
if (item.mediaType == 'book') {
|
||||
if (includeEntities.includes('authors')) {
|
||||
item.media.metadata.authors = item.media.metadata.authors.map(au => {
|
||||
var author = Database.authors.find(_au => _au.id === au.id)
|
||||
if (!author) return null
|
||||
return {
|
||||
...author
|
||||
}
|
||||
}).filter(au => au)
|
||||
}
|
||||
} else if (includeEntities.includes('downloads')) {
|
||||
if (item.mediaType === 'podcast' && includeEntities.includes('downloads')) {
|
||||
const downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id)
|
||||
item.episodeDownloadsQueued = downloadsInQueue.map(d => d.toJSONForClient())
|
||||
if (this.podcastManager.currentDownload?.libraryItemId === req.libraryItem.id) {
|
||||
|
|
@ -56,7 +59,7 @@ class LibraryItemController {
|
|||
var libraryItem = req.libraryItem
|
||||
// Item has cover and update is removing cover so purge it from cache
|
||||
if (libraryItem.media.coverPath && req.body.media && (req.body.media.coverPath === '' || req.body.media.coverPath === null)) {
|
||||
await this.cacheManager.purgeCoverCache(libraryItem.id)
|
||||
await CacheManager.purgeCoverCache(libraryItem.id)
|
||||
}
|
||||
|
||||
const hasUpdates = libraryItem.update(req.body)
|
||||
|
|
@ -71,13 +74,14 @@ class LibraryItemController {
|
|||
async delete(req, res) {
|
||||
const hardDelete = req.query.hard == 1 // Delete from file system
|
||||
const libraryItemPath = req.libraryItem.path
|
||||
await this.handleDeleteLibraryItem(req.libraryItem)
|
||||
await this.handleDeleteLibraryItem(req.libraryItem.mediaType, req.libraryItem.id, [req.libraryItem.media.id])
|
||||
if (hardDelete) {
|
||||
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
|
||||
await fs.remove(libraryItemPath).catch((error) => {
|
||||
Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
|
||||
})
|
||||
}
|
||||
await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId)
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
|
|
@ -103,7 +107,7 @@ class LibraryItemController {
|
|||
|
||||
// Item has cover and update is removing cover so purge it from cache
|
||||
if (libraryItem.media.coverPath && (mediaPayload.coverPath === '' || mediaPayload.coverPath === null)) {
|
||||
await this.cacheManager.purgeCoverCache(libraryItem.id)
|
||||
await CacheManager.purgeCoverCache(libraryItem.id)
|
||||
}
|
||||
|
||||
// Book specific
|
||||
|
|
@ -124,7 +128,7 @@ class LibraryItemController {
|
|||
// Book specific - Get all series being removed from this item
|
||||
let seriesRemoved = []
|
||||
if (libraryItem.isBook && mediaPayload.metadata?.series) {
|
||||
const seriesIdsInUpdate = (mediaPayload.metadata?.series || []).map(se => se.id)
|
||||
const seriesIdsInUpdate = mediaPayload.metadata.series?.map(se => se.id) || []
|
||||
seriesRemoved = libraryItem.media.metadata.series.filter(se => !seriesIdsInUpdate.includes(se.id))
|
||||
}
|
||||
|
||||
|
|
@ -135,7 +139,7 @@ class LibraryItemController {
|
|||
if (seriesRemoved.length) {
|
||||
// Check remove empty series
|
||||
Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`)
|
||||
await this.checkRemoveEmptySeries(seriesRemoved)
|
||||
await this.checkRemoveEmptySeries(libraryItem.media.id, seriesRemoved.map(se => se.id))
|
||||
}
|
||||
|
||||
if (isPodcastAutoDownloadUpdated) {
|
||||
|
|
@ -164,10 +168,10 @@ class LibraryItemController {
|
|||
var result = null
|
||||
if (req.body && req.body.url) {
|
||||
Logger.debug(`[LibraryItemController] Requesting download cover from url "${req.body.url}"`)
|
||||
result = await this.coverManager.downloadCoverFromUrl(libraryItem, req.body.url)
|
||||
result = await CoverManager.downloadCoverFromUrl(libraryItem, req.body.url)
|
||||
} else if (req.files && req.files.cover) {
|
||||
Logger.debug(`[LibraryItemController] Handling uploaded cover`)
|
||||
result = await this.coverManager.uploadCover(libraryItem, req.files.cover)
|
||||
result = await CoverManager.uploadCover(libraryItem, req.files.cover)
|
||||
} else {
|
||||
return res.status(400).send('Invalid request no file or url')
|
||||
}
|
||||
|
|
@ -193,7 +197,7 @@ class LibraryItemController {
|
|||
return res.status(400).send('Invalid request no cover path')
|
||||
}
|
||||
|
||||
const validationResult = await this.coverManager.validateCoverPath(req.body.cover, libraryItem)
|
||||
const validationResult = await CoverManager.validateCoverPath(req.body.cover, libraryItem)
|
||||
if (validationResult.error) {
|
||||
return res.status(500).send(validationResult.error)
|
||||
}
|
||||
|
|
@ -213,7 +217,7 @@ class LibraryItemController {
|
|||
|
||||
if (libraryItem.media.coverPath) {
|
||||
libraryItem.updateMediaCover('')
|
||||
await this.cacheManager.purgeCoverCache(libraryItem.id)
|
||||
await CacheManager.purgeCoverCache(libraryItem.id)
|
||||
await Database.updateLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
}
|
||||
|
|
@ -242,7 +246,7 @@ class LibraryItemController {
|
|||
height: height ? parseInt(height) : null,
|
||||
width: width ? parseInt(width) : null
|
||||
}
|
||||
return this.cacheManager.handleCoverCache(res, libraryItem, options)
|
||||
return CacheManager.handleCoverCache(res, libraryItem, options)
|
||||
}
|
||||
|
||||
// GET: api/items/:id/stream
|
||||
|
|
@ -296,7 +300,7 @@ class LibraryItemController {
|
|||
var libraryItem = req.libraryItem
|
||||
|
||||
var options = req.body || {}
|
||||
var matchResult = await this.scanner.quickMatchLibraryItem(libraryItem, options)
|
||||
var matchResult = await Scanner.quickMatchLibraryItem(libraryItem, options)
|
||||
res.json(matchResult)
|
||||
}
|
||||
|
||||
|
|
@ -309,18 +313,23 @@ class LibraryItemController {
|
|||
const hardDelete = req.query.hard == 1 // Delete files from filesystem
|
||||
|
||||
const { libraryItemIds } = req.body
|
||||
if (!libraryItemIds || !libraryItemIds.length) {
|
||||
return res.sendStatus(500)
|
||||
if (!libraryItemIds?.length) {
|
||||
return res.status(400).send('Invalid request body')
|
||||
}
|
||||
|
||||
const itemsToDelete = Database.libraryItems.filter(li => libraryItemIds.includes(li.id))
|
||||
const itemsToDelete = await Database.libraryItemModel.getAllOldLibraryItems({
|
||||
id: libraryItemIds
|
||||
})
|
||||
|
||||
if (!itemsToDelete.length) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
for (let i = 0; i < itemsToDelete.length; i++) {
|
||||
const libraryItemPath = itemsToDelete[i].path
|
||||
Logger.info(`[LibraryItemController] Deleting Library Item "${itemsToDelete[i].media.metadata.title}"`)
|
||||
await this.handleDeleteLibraryItem(itemsToDelete[i])
|
||||
|
||||
const libraryId = itemsToDelete[0].libraryId
|
||||
for (const libraryItem of itemsToDelete) {
|
||||
const libraryItemPath = libraryItem.path
|
||||
Logger.info(`[LibraryItemController] Deleting Library Item "${libraryItem.media.metadata.title}"`)
|
||||
await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, [libraryItem.media.id])
|
||||
if (hardDelete) {
|
||||
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
|
||||
await fs.remove(libraryItemPath).catch((error) => {
|
||||
|
|
@ -328,28 +337,42 @@ class LibraryItemController {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
await Database.resetLibraryIssuesFilterData(libraryId)
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
// POST: api/items/batch/update
|
||||
async batchUpdate(req, res) {
|
||||
var updatePayloads = req.body
|
||||
if (!updatePayloads || !updatePayloads.length) {
|
||||
const updatePayloads = req.body
|
||||
if (!updatePayloads?.length) {
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
var itemsUpdated = 0
|
||||
let itemsUpdated = 0
|
||||
|
||||
for (let i = 0; i < updatePayloads.length; i++) {
|
||||
var mediaPayload = updatePayloads[i].mediaPayload
|
||||
var libraryItem = Database.libraryItems.find(_li => _li.id === updatePayloads[i].id)
|
||||
for (const updatePayload of updatePayloads) {
|
||||
const mediaPayload = updatePayload.mediaPayload
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(updatePayload.id)
|
||||
if (!libraryItem) return null
|
||||
|
||||
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId)
|
||||
|
||||
var hasUpdates = libraryItem.media.update(mediaPayload)
|
||||
if (hasUpdates) {
|
||||
let seriesRemoved = []
|
||||
if (libraryItem.isBook && mediaPayload.metadata?.series) {
|
||||
const seriesIdsInUpdate = (mediaPayload.metadata?.series || []).map(se => se.id)
|
||||
seriesRemoved = libraryItem.media.metadata.series.filter(se => !seriesIdsInUpdate.includes(se.id))
|
||||
}
|
||||
|
||||
if (libraryItem.media.update(mediaPayload)) {
|
||||
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
|
||||
|
||||
if (seriesRemoved.length) {
|
||||
// Check remove empty series
|
||||
Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`)
|
||||
await this.checkRemoveEmptySeries(libraryItem.media.id, seriesRemoved.map(se => se.id))
|
||||
}
|
||||
|
||||
await Database.updateLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
itemsUpdated++
|
||||
|
|
@ -368,13 +391,11 @@ class LibraryItemController {
|
|||
if (!libraryItemIds.length) {
|
||||
return res.status(403).send('Invalid payload')
|
||||
}
|
||||
const libraryItems = []
|
||||
libraryItemIds.forEach((lid) => {
|
||||
const li = Database.libraryItems.find(_li => _li.id === lid)
|
||||
if (li) libraryItems.push(li.toJSONExpanded())
|
||||
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({
|
||||
id: libraryItemIds
|
||||
})
|
||||
res.json({
|
||||
libraryItems
|
||||
libraryItems: libraryItems.map(li => li.toJSONExpanded())
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -393,7 +414,9 @@ class LibraryItemController {
|
|||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
const libraryItems = req.body.libraryItemIds.map(lid => Database.getLibraryItem(lid)).filter(li => li)
|
||||
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({
|
||||
id: req.body.libraryItemIds
|
||||
})
|
||||
if (!libraryItems?.length) {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
|
@ -401,7 +424,7 @@ class LibraryItemController {
|
|||
res.sendStatus(200)
|
||||
|
||||
for (const libraryItem of libraryItems) {
|
||||
const matchResult = await this.scanner.quickMatchLibraryItem(libraryItem, options)
|
||||
const matchResult = await Scanner.quickMatchLibraryItem(libraryItem, options)
|
||||
if (matchResult.updated) {
|
||||
itemsUpdated++
|
||||
} else if (matchResult.warning) {
|
||||
|
|
@ -428,23 +451,31 @@ class LibraryItemController {
|
|||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
const libraryItems = req.body.libraryItemIds.map(lid => Database.getLibraryItem(lid)).filter(li => li)
|
||||
const libraryItems = await Database.libraryItemModel.findAll({
|
||||
where: {
|
||||
id: req.body.libraryItemIds
|
||||
},
|
||||
attributes: ['id', 'libraryId', 'isFile']
|
||||
})
|
||||
if (!libraryItems?.length) {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
res.sendStatus(200)
|
||||
|
||||
const libraryId = libraryItems[0].libraryId
|
||||
for (const libraryItem of libraryItems) {
|
||||
if (libraryItem.isFile) {
|
||||
Logger.warn(`[LibraryItemController] Re-scanning file library items not yet supported`)
|
||||
} else {
|
||||
await this.scanner.scanLibraryItemByRequest(libraryItem)
|
||||
await LibraryItemScanner.scanLibraryItem(libraryItem.id)
|
||||
}
|
||||
}
|
||||
|
||||
await Database.resetLibraryIssuesFilterData(libraryId)
|
||||
}
|
||||
|
||||
// POST: api/items/:id/scan (admin)
|
||||
// POST: api/items/:id/scan
|
||||
async scan(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[LibraryItemController] Non-admin user attempted to scan library item`, req.user)
|
||||
|
|
@ -456,7 +487,8 @@ class LibraryItemController {
|
|||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
const result = await this.scanner.scanLibraryItemByRequest(req.libraryItem)
|
||||
const result = await LibraryItemScanner.scanLibraryItem(req.libraryItem.id)
|
||||
await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId)
|
||||
res.json({
|
||||
result: Object.keys(ScanResult).find(key => ScanResult[key] == result)
|
||||
})
|
||||
|
|
@ -529,7 +561,7 @@ class LibraryItemController {
|
|||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
const ffprobeData = await this.scanner.probeAudioFile(audioFile)
|
||||
const ffprobeData = await AudioFileScanner.probeAudioFile(audioFile)
|
||||
res.json(ffprobeData)
|
||||
}
|
||||
|
||||
|
|
@ -680,7 +712,7 @@ class LibraryItemController {
|
|||
}
|
||||
|
||||
async middleware(req, res, next) {
|
||||
req.libraryItem = await Database.models.libraryItem.getOldById(req.params.id)
|
||||
req.libraryItem = await Database.libraryItemModel.getOldById(req.params.id)
|
||||
if (!req.libraryItem?.media) return res.sendStatus(404)
|
||||
|
||||
// Check user can access this library item
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ class MeController {
|
|||
|
||||
// PATCH: api/me/progress/:id
|
||||
async createUpdateMediaProgress(req, res) {
|
||||
const libraryItem = Database.libraryItems.find(ab => ab.id === req.params.id)
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(req.params.id)
|
||||
if (!libraryItem) {
|
||||
return res.status(404).send('Item not found')
|
||||
}
|
||||
|
|
@ -75,7 +75,7 @@ class MeController {
|
|||
// PATCH: api/me/progress/:id/:episodeId
|
||||
async createUpdateEpisodeMediaProgress(req, res) {
|
||||
const episodeId = req.params.episodeId
|
||||
const libraryItem = Database.libraryItems.find(ab => ab.id === req.params.id)
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(req.params.id)
|
||||
if (!libraryItem) {
|
||||
return res.status(404).send('Item not found')
|
||||
}
|
||||
|
|
@ -101,7 +101,7 @@ class MeController {
|
|||
|
||||
let shouldUpdate = false
|
||||
for (const itemProgress of itemProgressPayloads) {
|
||||
const libraryItem = Database.libraryItems.find(li => li.id === itemProgress.libraryItemId) // Make sure this library item exists
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(itemProgress.libraryItemId)
|
||||
if (libraryItem) {
|
||||
if (req.user.createUpdateMediaProgress(libraryItem, itemProgress, itemProgress.episodeId)) {
|
||||
const mediaProgress = req.user.getMediaProgress(libraryItem.id, itemProgress.episodeId)
|
||||
|
|
@ -122,10 +122,10 @@ class MeController {
|
|||
|
||||
// POST: api/me/item/:id/bookmark
|
||||
async createBookmark(req, res) {
|
||||
var libraryItem = Database.libraryItems.find(li => li.id === req.params.id)
|
||||
if (!libraryItem) return res.sendStatus(404)
|
||||
if (!await Database.libraryItemModel.checkExistsById(req.params.id)) return res.sendStatus(404)
|
||||
|
||||
const { time, title } = req.body
|
||||
var bookmark = req.user.createBookmark(libraryItem.id, time, title)
|
||||
const bookmark = req.user.createBookmark(req.params.id, time, title)
|
||||
await Database.updateUser(req.user)
|
||||
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||
res.json(bookmark)
|
||||
|
|
@ -133,15 +133,17 @@ class MeController {
|
|||
|
||||
// PATCH: api/me/item/:id/bookmark
|
||||
async updateBookmark(req, res) {
|
||||
var libraryItem = Database.libraryItems.find(li => li.id === req.params.id)
|
||||
if (!libraryItem) return res.sendStatus(404)
|
||||
if (!await Database.libraryItemModel.checkExistsById(req.params.id)) return res.sendStatus(404)
|
||||
|
||||
const { time, title } = req.body
|
||||
if (!req.user.findBookmark(libraryItem.id, time)) {
|
||||
if (!req.user.findBookmark(req.params.id, time)) {
|
||||
Logger.error(`[MeController] updateBookmark not found`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
var bookmark = req.user.updateBookmark(libraryItem.id, time, title)
|
||||
|
||||
const bookmark = req.user.updateBookmark(req.params.id, time, title)
|
||||
if (!bookmark) return res.sendStatus(500)
|
||||
|
||||
await Database.updateUser(req.user)
|
||||
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||
res.json(bookmark)
|
||||
|
|
@ -149,16 +151,17 @@ class MeController {
|
|||
|
||||
// DELETE: api/me/item/:id/bookmark/:time
|
||||
async removeBookmark(req, res) {
|
||||
var libraryItem = Database.libraryItems.find(li => li.id === req.params.id)
|
||||
if (!libraryItem) return res.sendStatus(404)
|
||||
var time = Number(req.params.time)
|
||||
if (!await Database.libraryItemModel.checkExistsById(req.params.id)) return res.sendStatus(404)
|
||||
|
||||
const time = Number(req.params.time)
|
||||
if (isNaN(time)) return res.sendStatus(500)
|
||||
|
||||
if (!req.user.findBookmark(libraryItem.id, time)) {
|
||||
if (!req.user.findBookmark(req.params.id, time)) {
|
||||
Logger.error(`[MeController] removeBookmark not found`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
req.user.removeBookmark(libraryItem.id, time)
|
||||
|
||||
req.user.removeBookmark(req.params.id, time)
|
||||
await Database.updateUser(req.user)
|
||||
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||
res.sendStatus(200)
|
||||
|
|
@ -190,7 +193,8 @@ class MeController {
|
|||
Logger.error(`[MeController] syncLocalMediaProgress invalid local media progress object`, localProgress)
|
||||
continue
|
||||
}
|
||||
const libraryItem = Database.getLibraryItem(localProgress.libraryItemId)
|
||||
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(localProgress.libraryItemId)
|
||||
if (!libraryItem) {
|
||||
Logger.error(`[MeController] syncLocalMediaProgress invalid local media progress object no library item`, localProgress)
|
||||
continue
|
||||
|
|
@ -242,13 +246,15 @@ class MeController {
|
|||
}
|
||||
|
||||
// GET: api/me/items-in-progress
|
||||
getAllLibraryItemsInProgress(req, res) {
|
||||
async getAllLibraryItemsInProgress(req, res) {
|
||||
const limit = !isNaN(req.query.limit) ? Number(req.query.limit) || 25 : 25
|
||||
|
||||
let itemsInProgress = []
|
||||
// TODO: More efficient to do this in a single query
|
||||
for (const mediaProgress of req.user.mediaProgress) {
|
||||
if (!mediaProgress.isFinished && (mediaProgress.progress > 0 || mediaProgress.ebookProgress > 0)) {
|
||||
const libraryItem = Database.getLibraryItem(mediaProgress.libraryItemId)
|
||||
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(mediaProgress.libraryItemId)
|
||||
if (libraryItem) {
|
||||
if (mediaProgress.episodeId && libraryItem.mediaType === 'podcast') {
|
||||
const episode = libraryItem.media.episodes.find(ep => ep.id === mediaProgress.episodeId)
|
||||
|
|
@ -278,7 +284,7 @@ class MeController {
|
|||
|
||||
// GET: api/me/series/:id/remove-from-continue-listening
|
||||
async removeSeriesFromContinueListening(req, res) {
|
||||
const series = Database.series.find(se => se.id === req.params.id)
|
||||
const series = await Database.seriesModel.getOldById(req.params.id)
|
||||
if (!series) {
|
||||
Logger.error(`[MeController] removeSeriesFromContinueListening: Series ${req.params.id} not found`)
|
||||
return res.sendStatus(404)
|
||||
|
|
@ -294,7 +300,7 @@ class MeController {
|
|||
|
||||
// GET: api/me/series/:id/readd-to-continue-listening
|
||||
async readdSeriesFromContinueListening(req, res) {
|
||||
const series = Database.series.find(se => se.id === req.params.id)
|
||||
const series = await Database.seriesModel.getOldById(req.params.id)
|
||||
if (!series) {
|
||||
Logger.error(`[MeController] readdSeriesFromContinueListening: Series ${req.params.id} not found`)
|
||||
return res.sendStatus(404)
|
||||
|
|
@ -310,9 +316,19 @@ class MeController {
|
|||
|
||||
// GET: api/me/progress/:id/remove-from-continue-listening
|
||||
async removeItemFromContinueListening(req, res) {
|
||||
const mediaProgress = req.user.mediaProgress.find(mp => mp.id === req.params.id)
|
||||
if (!mediaProgress) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
const hasUpdated = req.user.removeProgressFromContinueListening(req.params.id)
|
||||
if (hasUpdated) {
|
||||
await Database.updateUser(req.user)
|
||||
await Database.mediaProgressModel.update({
|
||||
hideFromContinueListening: true
|
||||
}, {
|
||||
where: {
|
||||
id: mediaProgress.id
|
||||
}
|
||||
})
|
||||
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||
}
|
||||
res.json(req.user.toJSONForBrowser())
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
const Sequelize = require('sequelize')
|
||||
const Path = require('path')
|
||||
const fs = require('../libs/fsExtra')
|
||||
const Logger = require('../Logger')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const Database = require('../Database')
|
||||
|
||||
const filePerms = require('../utils/filePerms')
|
||||
const libraryItemFilters = require('../utils/queries/libraryItemFilters')
|
||||
const patternValidation = require('../libs/nodeCron/pattern-validation')
|
||||
const { isObject } = require('../utils/index')
|
||||
const { isObject, getTitleIgnorePrefix } = require('../utils/index')
|
||||
|
||||
//
|
||||
// This is a controller for routes that don't have a home yet :(
|
||||
|
|
@ -14,7 +15,12 @@ const { isObject } = require('../utils/index')
|
|||
class MiscController {
|
||||
constructor() { }
|
||||
|
||||
// POST: api/upload
|
||||
/**
|
||||
* POST: /api/upload
|
||||
* Update library item
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
async handleUpload(req, res) {
|
||||
if (!req.user.canUpload) {
|
||||
Logger.warn('User attempted to upload without permission', req.user)
|
||||
|
|
@ -31,7 +37,7 @@ class MiscController {
|
|||
const libraryId = req.body.library
|
||||
const folderId = req.body.folder
|
||||
|
||||
const library = await Database.models.library.getOldById(libraryId)
|
||||
const library = await Database.libraryModel.getOldById(libraryId)
|
||||
if (!library) {
|
||||
return res.status(404).send(`Library not found with id ${libraryId}`)
|
||||
}
|
||||
|
|
@ -83,12 +89,15 @@ class MiscController {
|
|||
})
|
||||
}
|
||||
|
||||
await filePerms.setDefault(firstDirPath)
|
||||
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
// GET: api/tasks
|
||||
/**
|
||||
* GET: /api/tasks
|
||||
* Get tasks for task manager
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
getTasks(req, res) {
|
||||
const includeArray = (req.query.include || '').split(',')
|
||||
|
||||
|
|
@ -105,7 +114,12 @@ class MiscController {
|
|||
res.json(data)
|
||||
}
|
||||
|
||||
// PATCH: api/settings (admin)
|
||||
/**
|
||||
* PATCH: /api/settings
|
||||
* Update server settings
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
async updateServerSettings(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error('User other than admin attempting to update server settings', req.user)
|
||||
|
|
@ -113,7 +127,7 @@ class MiscController {
|
|||
}
|
||||
const settingsUpdate = req.body
|
||||
if (!settingsUpdate || !isObject(settingsUpdate)) {
|
||||
return res.status(500).send('Invalid settings update object')
|
||||
return res.status(400).send('Invalid settings update object')
|
||||
}
|
||||
|
||||
const madeUpdates = Database.serverSettings.update(settingsUpdate)
|
||||
|
|
@ -131,6 +145,103 @@ class MiscController {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH: /api/sorting-prefixes
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
async updateSortingPrefixes(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error('User other than admin attempting to update server sorting prefixes', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
let sortingPrefixes = req.body.sortingPrefixes
|
||||
if (!sortingPrefixes?.length || !Array.isArray(sortingPrefixes)) {
|
||||
return res.status(400).send('Invalid request body')
|
||||
}
|
||||
sortingPrefixes = [...new Set(sortingPrefixes.map(p => p?.trim?.().toLowerCase()).filter(p => p))]
|
||||
if (!sortingPrefixes.length) {
|
||||
return res.status(400).send('Invalid sortingPrefixes in request body')
|
||||
}
|
||||
|
||||
Logger.debug(`[MiscController] Updating sorting prefixes ${sortingPrefixes.join(', ')}`)
|
||||
Database.serverSettings.sortingPrefixes = sortingPrefixes
|
||||
await Database.updateServerSettings()
|
||||
|
||||
let rowsUpdated = 0
|
||||
// Update titleIgnorePrefix column on books
|
||||
const books = await Database.bookModel.findAll({
|
||||
attributes: ['id', 'title', 'titleIgnorePrefix']
|
||||
})
|
||||
const bulkUpdateBooks = []
|
||||
books.forEach((book) => {
|
||||
const titleIgnorePrefix = getTitleIgnorePrefix(book.title)
|
||||
if (titleIgnorePrefix !== book.titleIgnorePrefix) {
|
||||
bulkUpdateBooks.push({
|
||||
id: book.id,
|
||||
titleIgnorePrefix
|
||||
})
|
||||
}
|
||||
})
|
||||
if (bulkUpdateBooks.length) {
|
||||
Logger.info(`[MiscController] Updating titleIgnorePrefix on ${bulkUpdateBooks.length} books`)
|
||||
rowsUpdated += bulkUpdateBooks.length
|
||||
await Database.bookModel.bulkCreate(bulkUpdateBooks, {
|
||||
updateOnDuplicate: ['titleIgnorePrefix']
|
||||
})
|
||||
}
|
||||
|
||||
// Update titleIgnorePrefix column on podcasts
|
||||
const podcasts = await Database.podcastModel.findAll({
|
||||
attributes: ['id', 'title', 'titleIgnorePrefix']
|
||||
})
|
||||
const bulkUpdatePodcasts = []
|
||||
podcasts.forEach((podcast) => {
|
||||
const titleIgnorePrefix = getTitleIgnorePrefix(podcast.title)
|
||||
if (titleIgnorePrefix !== podcast.titleIgnorePrefix) {
|
||||
bulkUpdatePodcasts.push({
|
||||
id: podcast.id,
|
||||
titleIgnorePrefix
|
||||
})
|
||||
}
|
||||
})
|
||||
if (bulkUpdatePodcasts.length) {
|
||||
Logger.info(`[MiscController] Updating titleIgnorePrefix on ${bulkUpdatePodcasts.length} podcasts`)
|
||||
rowsUpdated += bulkUpdatePodcasts.length
|
||||
await Database.podcastModel.bulkCreate(bulkUpdatePodcasts, {
|
||||
updateOnDuplicate: ['titleIgnorePrefix']
|
||||
})
|
||||
}
|
||||
|
||||
// Update nameIgnorePrefix column on series
|
||||
const allSeries = await Database.seriesModel.findAll({
|
||||
attributes: ['id', 'name', 'nameIgnorePrefix']
|
||||
})
|
||||
const bulkUpdateSeries = []
|
||||
allSeries.forEach((series) => {
|
||||
const nameIgnorePrefix = getTitleIgnorePrefix(series.name)
|
||||
if (nameIgnorePrefix !== series.nameIgnorePrefix) {
|
||||
bulkUpdateSeries.push({
|
||||
id: series.id,
|
||||
nameIgnorePrefix
|
||||
})
|
||||
}
|
||||
})
|
||||
if (bulkUpdateSeries.length) {
|
||||
Logger.info(`[MiscController] Updating nameIgnorePrefix on ${bulkUpdateSeries.length} series`)
|
||||
rowsUpdated += bulkUpdateSeries.length
|
||||
await Database.seriesModel.bulkCreate(bulkUpdateSeries, {
|
||||
updateOnDuplicate: ['nameIgnorePrefix']
|
||||
})
|
||||
}
|
||||
|
||||
res.json({
|
||||
rowsUpdated,
|
||||
serverSettings: Database.serverSettings.toJSONForBrowser()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST: /api/authorize
|
||||
* Used to authorize an API token
|
||||
|
|
@ -147,26 +258,55 @@ class MiscController {
|
|||
res.json(userResponse)
|
||||
}
|
||||
|
||||
// GET: api/tags
|
||||
getAllTags(req, res) {
|
||||
/**
|
||||
* GET: /api/tags
|
||||
* Get all tags
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
async getAllTags(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[MiscController] Non-admin user attempted to getAllTags`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
const tags = []
|
||||
Database.libraryItems.forEach((li) => {
|
||||
if (li.media.tags && li.media.tags.length) {
|
||||
li.media.tags.forEach((tag) => {
|
||||
if (!tags.includes(tag)) tags.push(tag)
|
||||
})
|
||||
}
|
||||
const books = await Database.bookModel.findAll({
|
||||
attributes: ['tags'],
|
||||
where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('tags')), {
|
||||
[Sequelize.Op.gt]: 0
|
||||
})
|
||||
})
|
||||
for (const book of books) {
|
||||
for (const tag of book.tags) {
|
||||
if (!tags.includes(tag)) tags.push(tag)
|
||||
}
|
||||
}
|
||||
|
||||
const podcasts = await Database.podcastModel.findAll({
|
||||
attributes: ['tags'],
|
||||
where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('tags')), {
|
||||
[Sequelize.Op.gt]: 0
|
||||
})
|
||||
})
|
||||
for (const podcast of podcasts) {
|
||||
for (const tag of podcast.tags) {
|
||||
if (!tags.includes(tag)) tags.push(tag)
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
tags: tags
|
||||
})
|
||||
}
|
||||
|
||||
// POST: api/tags/rename
|
||||
/**
|
||||
* POST: /api/tags/rename
|
||||
* Rename tag
|
||||
* Req.body { tag, newTag }
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
async renameTag(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[MiscController] Non-admin user attempted to renameTag`)
|
||||
|
|
@ -183,19 +323,26 @@ class MiscController {
|
|||
let tagMerged = false
|
||||
let numItemsUpdated = 0
|
||||
|
||||
for (const li of Database.libraryItems) {
|
||||
if (!li.media.tags || !li.media.tags.length) continue
|
||||
// Update filter data
|
||||
Database.replaceTagInFilterData(tag, newTag)
|
||||
|
||||
if (li.media.tags.includes(newTag)) tagMerged = true // new tag is an existing tag so this is a merge
|
||||
const libraryItemsWithTag = await libraryItemFilters.getAllLibraryItemsWithTags([tag, newTag])
|
||||
for (const libraryItem of libraryItemsWithTag) {
|
||||
if (libraryItem.media.tags.includes(newTag)) {
|
||||
tagMerged = true // new tag is an existing tag so this is a merge
|
||||
}
|
||||
|
||||
if (li.media.tags.includes(tag)) {
|
||||
li.media.tags = li.media.tags.filter(t => t !== tag) // Remove old tag
|
||||
if (!li.media.tags.includes(newTag)) {
|
||||
li.media.tags.push(newTag) // Add new tag
|
||||
if (libraryItem.media.tags.includes(tag)) {
|
||||
libraryItem.media.tags = libraryItem.media.tags.filter(t => t !== tag) // Remove old tag
|
||||
if (!libraryItem.media.tags.includes(newTag)) {
|
||||
libraryItem.media.tags.push(newTag)
|
||||
}
|
||||
Logger.debug(`[MiscController] Rename tag "${tag}" to "${newTag}" for item "${li.media.metadata.title}"`)
|
||||
await Database.updateLibraryItem(li)
|
||||
SocketAuthority.emitter('item_updated', li.toJSONExpanded())
|
||||
Logger.debug(`[MiscController] Rename tag "${tag}" to "${newTag}" for item "${libraryItem.media.title}"`)
|
||||
await libraryItem.media.update({
|
||||
tags: libraryItem.media.tags
|
||||
})
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
|
||||
numItemsUpdated++
|
||||
}
|
||||
}
|
||||
|
|
@ -206,7 +353,13 @@ class MiscController {
|
|||
})
|
||||
}
|
||||
|
||||
// DELETE: api/tags/:tag
|
||||
/**
|
||||
* DELETE: /api/tags/:tag
|
||||
* Remove a tag
|
||||
* :tag param is base64 encoded
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
async deleteTag(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[MiscController] Non-admin user attempted to deleteTag`)
|
||||
|
|
@ -215,17 +368,23 @@ class MiscController {
|
|||
|
||||
const tag = Buffer.from(decodeURIComponent(req.params.tag), 'base64').toString()
|
||||
|
||||
let numItemsUpdated = 0
|
||||
for (const li of Database.libraryItems) {
|
||||
if (!li.media.tags || !li.media.tags.length) continue
|
||||
// Get all items with tag
|
||||
const libraryItemsWithTag = await libraryItemFilters.getAllLibraryItemsWithTags([tag])
|
||||
|
||||
if (li.media.tags.includes(tag)) {
|
||||
li.media.tags = li.media.tags.filter(t => t !== tag)
|
||||
Logger.debug(`[MiscController] Remove tag "${tag}" from item "${li.media.metadata.title}"`)
|
||||
await Database.updateLibraryItem(li)
|
||||
SocketAuthority.emitter('item_updated', li.toJSONExpanded())
|
||||
numItemsUpdated++
|
||||
}
|
||||
// Update filterdata
|
||||
Database.removeTagFromFilterData(tag)
|
||||
|
||||
let numItemsUpdated = 0
|
||||
// Remove tag from items
|
||||
for (const libraryItem of libraryItemsWithTag) {
|
||||
Logger.debug(`[MiscController] Remove tag "${tag}" from item "${libraryItem.media.title}"`)
|
||||
libraryItem.media.tags = libraryItem.media.tags.filter(t => t !== tag)
|
||||
await libraryItem.media.update({
|
||||
tags: libraryItem.media.tags
|
||||
})
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
|
||||
numItemsUpdated++
|
||||
}
|
||||
|
||||
res.json({
|
||||
|
|
@ -233,26 +392,54 @@ class MiscController {
|
|||
})
|
||||
}
|
||||
|
||||
// GET: api/genres
|
||||
getAllGenres(req, res) {
|
||||
/**
|
||||
* GET: /api/genres
|
||||
* Get all genres
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
async getAllGenres(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[MiscController] Non-admin user attempted to getAllGenres`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
const genres = []
|
||||
Database.libraryItems.forEach((li) => {
|
||||
if (li.media.metadata.genres && li.media.metadata.genres.length) {
|
||||
li.media.metadata.genres.forEach((genre) => {
|
||||
if (!genres.includes(genre)) genres.push(genre)
|
||||
})
|
||||
}
|
||||
const books = await Database.bookModel.findAll({
|
||||
attributes: ['genres'],
|
||||
where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('genres')), {
|
||||
[Sequelize.Op.gt]: 0
|
||||
})
|
||||
})
|
||||
for (const book of books) {
|
||||
for (const tag of book.genres) {
|
||||
if (!genres.includes(tag)) genres.push(tag)
|
||||
}
|
||||
}
|
||||
|
||||
const podcasts = await Database.podcastModel.findAll({
|
||||
attributes: ['genres'],
|
||||
where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('genres')), {
|
||||
[Sequelize.Op.gt]: 0
|
||||
})
|
||||
})
|
||||
for (const podcast of podcasts) {
|
||||
for (const tag of podcast.genres) {
|
||||
if (!genres.includes(tag)) genres.push(tag)
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
genres
|
||||
})
|
||||
}
|
||||
|
||||
// POST: api/genres/rename
|
||||
/**
|
||||
* POST: /api/genres/rename
|
||||
* Rename genres
|
||||
* Req.body { genre, newGenre }
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
async renameGenre(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[MiscController] Non-admin user attempted to renameGenre`)
|
||||
|
|
@ -269,19 +456,26 @@ class MiscController {
|
|||
let genreMerged = false
|
||||
let numItemsUpdated = 0
|
||||
|
||||
for (const li of Database.libraryItems) {
|
||||
if (!li.media.metadata.genres || !li.media.metadata.genres.length) continue
|
||||
// Update filter data
|
||||
Database.replaceGenreInFilterData(genre, newGenre)
|
||||
|
||||
if (li.media.metadata.genres.includes(newGenre)) genreMerged = true // new genre is an existing genre so this is a merge
|
||||
const libraryItemsWithGenre = await libraryItemFilters.getAllLibraryItemsWithGenres([genre, newGenre])
|
||||
for (const libraryItem of libraryItemsWithGenre) {
|
||||
if (libraryItem.media.genres.includes(newGenre)) {
|
||||
genreMerged = true // new genre is an existing genre so this is a merge
|
||||
}
|
||||
|
||||
if (li.media.metadata.genres.includes(genre)) {
|
||||
li.media.metadata.genres = li.media.metadata.genres.filter(g => g !== genre) // Remove old genre
|
||||
if (!li.media.metadata.genres.includes(newGenre)) {
|
||||
li.media.metadata.genres.push(newGenre) // Add new genre
|
||||
if (libraryItem.media.genres.includes(genre)) {
|
||||
libraryItem.media.genres = libraryItem.media.genres.filter(t => t !== genre) // Remove old genre
|
||||
if (!libraryItem.media.genres.includes(newGenre)) {
|
||||
libraryItem.media.genres.push(newGenre)
|
||||
}
|
||||
Logger.debug(`[MiscController] Rename genre "${genre}" to "${newGenre}" for item "${li.media.metadata.title}"`)
|
||||
await Database.updateLibraryItem(li)
|
||||
SocketAuthority.emitter('item_updated', li.toJSONExpanded())
|
||||
Logger.debug(`[MiscController] Rename genre "${genre}" to "${newGenre}" for item "${libraryItem.media.title}"`)
|
||||
await libraryItem.media.update({
|
||||
genres: libraryItem.media.genres
|
||||
})
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
|
||||
numItemsUpdated++
|
||||
}
|
||||
}
|
||||
|
|
@ -292,7 +486,13 @@ class MiscController {
|
|||
})
|
||||
}
|
||||
|
||||
// DELETE: api/genres/:genre
|
||||
/**
|
||||
* DELETE: /api/genres/:genre
|
||||
* Remove a genre
|
||||
* :genre param is base64 encoded
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
async deleteGenre(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[MiscController] Non-admin user attempted to deleteGenre`)
|
||||
|
|
@ -301,17 +501,23 @@ class MiscController {
|
|||
|
||||
const genre = Buffer.from(decodeURIComponent(req.params.genre), 'base64').toString()
|
||||
|
||||
let numItemsUpdated = 0
|
||||
for (const li of Database.libraryItems) {
|
||||
if (!li.media.metadata.genres || !li.media.metadata.genres.length) continue
|
||||
// Update filter data
|
||||
Database.removeGenreFromFilterData(genre)
|
||||
|
||||
if (li.media.metadata.genres.includes(genre)) {
|
||||
li.media.metadata.genres = li.media.metadata.genres.filter(t => t !== genre)
|
||||
Logger.debug(`[MiscController] Remove genre "${genre}" from item "${li.media.metadata.title}"`)
|
||||
await Database.updateLibraryItem(li)
|
||||
SocketAuthority.emitter('item_updated', li.toJSONExpanded())
|
||||
numItemsUpdated++
|
||||
}
|
||||
// Get all items with genre
|
||||
const libraryItemsWithGenre = await libraryItemFilters.getAllLibraryItemsWithGenres([genre])
|
||||
|
||||
let numItemsUpdated = 0
|
||||
// Remove genre from items
|
||||
for (const libraryItem of libraryItemsWithGenre) {
|
||||
Logger.debug(`[MiscController] Remove genre "${genre}" from item "${libraryItem.media.title}"`)
|
||||
libraryItem.media.genres = libraryItem.media.genres.filter(g => g !== genre)
|
||||
await libraryItem.media.update({
|
||||
genres: libraryItem.media.genres
|
||||
})
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
|
||||
numItemsUpdated++
|
||||
}
|
||||
|
||||
res.json({
|
||||
|
|
|
|||
|
|
@ -7,71 +7,187 @@ const Playlist = require('../objects/Playlist')
|
|||
class PlaylistController {
|
||||
constructor() { }
|
||||
|
||||
// POST: api/playlists
|
||||
/**
|
||||
* POST: /api/playlists
|
||||
* Create playlist
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
async create(req, res) {
|
||||
const newPlaylist = new Playlist()
|
||||
const oldPlaylist = new Playlist()
|
||||
req.body.userId = req.user.id
|
||||
const success = newPlaylist.setData(req.body)
|
||||
const success = oldPlaylist.setData(req.body)
|
||||
if (!success) {
|
||||
return res.status(400).send('Invalid playlist request data')
|
||||
}
|
||||
const jsonExpanded = newPlaylist.toJSONExpanded(Database.libraryItems)
|
||||
await Database.createPlaylist(newPlaylist)
|
||||
|
||||
// Create Playlist record
|
||||
const newPlaylist = await Database.playlistModel.createFromOld(oldPlaylist)
|
||||
|
||||
// Lookup all library items in playlist
|
||||
const libraryItemIds = oldPlaylist.items.map(i => i.libraryItemId).filter(i => i)
|
||||
const libraryItemsInPlaylist = await Database.libraryItemModel.findAll({
|
||||
where: {
|
||||
id: libraryItemIds
|
||||
}
|
||||
})
|
||||
|
||||
// Create playlistMediaItem records
|
||||
const mediaItemsToAdd = []
|
||||
let order = 1
|
||||
for (const mediaItemObj of oldPlaylist.items) {
|
||||
const libraryItem = libraryItemsInPlaylist.find(li => li.id === mediaItemObj.libraryItemId)
|
||||
if (!libraryItem) continue
|
||||
|
||||
mediaItemsToAdd.push({
|
||||
mediaItemId: mediaItemObj.episodeId || libraryItem.mediaId,
|
||||
mediaItemType: mediaItemObj.episodeId ? 'podcastEpisode' : 'book',
|
||||
playlistId: oldPlaylist.id,
|
||||
order: order++
|
||||
})
|
||||
}
|
||||
if (mediaItemsToAdd.length) {
|
||||
await Database.createBulkPlaylistMediaItems(mediaItemsToAdd)
|
||||
}
|
||||
|
||||
const jsonExpanded = await newPlaylist.getOldJsonExpanded()
|
||||
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
|
||||
// GET: api/playlists
|
||||
/**
|
||||
* GET: /api/playlists
|
||||
* Get all playlists for user
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
async findAllForUser(req, res) {
|
||||
const playlistsForUser = await Database.models.playlist.getPlaylistsForUserAndLibrary(req.user.id)
|
||||
const playlistsForUser = await Database.playlistModel.findAll({
|
||||
where: {
|
||||
userId: req.user.id
|
||||
}
|
||||
})
|
||||
const playlists = []
|
||||
for (const playlist of playlistsForUser) {
|
||||
const jsonExpanded = await playlist.getOldJsonExpanded()
|
||||
playlists.push(jsonExpanded)
|
||||
}
|
||||
res.json({
|
||||
playlists: playlistsForUser.map(p => p.toJSONExpanded(Database.libraryItems))
|
||||
playlists
|
||||
})
|
||||
}
|
||||
|
||||
// GET: api/playlists/:id
|
||||
findOne(req, res) {
|
||||
res.json(req.playlist.toJSONExpanded(Database.libraryItems))
|
||||
/**
|
||||
* GET: /api/playlists/:id
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
async findOne(req, res) {
|
||||
const jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
|
||||
// PATCH: api/playlists/:id
|
||||
/**
|
||||
* PATCH: /api/playlists/:id
|
||||
* Update playlist
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
async update(req, res) {
|
||||
const playlist = req.playlist
|
||||
let wasUpdated = playlist.update(req.body)
|
||||
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
|
||||
const updatedPlaylist = req.playlist.set(req.body)
|
||||
let wasUpdated = false
|
||||
const changed = updatedPlaylist.changed()
|
||||
if (changed?.length) {
|
||||
await req.playlist.save()
|
||||
Logger.debug(`[PlaylistController] Updated playlist ${req.playlist.id} keys [${changed.join(',')}]`)
|
||||
wasUpdated = true
|
||||
}
|
||||
|
||||
// If array of items is passed in then update order of playlist media items
|
||||
const libraryItemIds = req.body.items?.map(i => i.libraryItemId).filter(i => i) || []
|
||||
if (libraryItemIds.length) {
|
||||
const libraryItems = await Database.libraryItemModel.findAll({
|
||||
where: {
|
||||
id: libraryItemIds
|
||||
}
|
||||
})
|
||||
const existingPlaylistMediaItems = await updatedPlaylist.getPlaylistMediaItems({
|
||||
order: [['order', 'ASC']]
|
||||
})
|
||||
|
||||
// Set an array of mediaItemId
|
||||
const newMediaItemIdOrder = []
|
||||
for (const item of req.body.items) {
|
||||
const libraryItem = libraryItems.find(li => li.id === item.libraryItemId)
|
||||
if (!libraryItem) {
|
||||
continue
|
||||
}
|
||||
const mediaItemId = item.episodeId || libraryItem.mediaId
|
||||
newMediaItemIdOrder.push(mediaItemId)
|
||||
}
|
||||
|
||||
// Sort existing playlist media items into new order
|
||||
existingPlaylistMediaItems.sort((a, b) => {
|
||||
const aIndex = newMediaItemIdOrder.findIndex(i => i === a.mediaItemId)
|
||||
const bIndex = newMediaItemIdOrder.findIndex(i => i === b.mediaItemId)
|
||||
return aIndex - bIndex
|
||||
})
|
||||
|
||||
// Update order on playlistMediaItem records
|
||||
let order = 1
|
||||
for (const playlistMediaItem of existingPlaylistMediaItems) {
|
||||
if (playlistMediaItem.order !== order) {
|
||||
await playlistMediaItem.update({
|
||||
order
|
||||
})
|
||||
wasUpdated = true
|
||||
}
|
||||
order++
|
||||
}
|
||||
}
|
||||
|
||||
const jsonExpanded = await updatedPlaylist.getOldJsonExpanded()
|
||||
if (wasUpdated) {
|
||||
await Database.updatePlaylist(playlist)
|
||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
|
||||
SocketAuthority.clientEmitter(updatedPlaylist.userId, 'playlist_updated', jsonExpanded)
|
||||
}
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
|
||||
// DELETE: api/playlists/:id
|
||||
/**
|
||||
* DELETE: /api/playlists/:id
|
||||
* Remove playlist
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
async delete(req, res) {
|
||||
const playlist = req.playlist
|
||||
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
|
||||
await Database.removePlaylist(playlist.id)
|
||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
|
||||
const jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||
await req.playlist.destroy()
|
||||
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
// POST: api/playlists/:id/item
|
||||
/**
|
||||
* POST: /api/playlists/:id/item
|
||||
* Add item to playlist
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
async addItem(req, res) {
|
||||
const playlist = req.playlist
|
||||
const oldPlaylist = await Database.playlistModel.getById(req.playlist.id)
|
||||
const itemToAdd = req.body
|
||||
|
||||
if (!itemToAdd.libraryItemId) {
|
||||
return res.status(400).send('Request body has no libraryItemId')
|
||||
}
|
||||
|
||||
const libraryItem = Database.libraryItems.find(li => li.id === itemToAdd.libraryItemId)
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(itemToAdd.libraryItemId)
|
||||
if (!libraryItem) {
|
||||
return res.status(400).send('Library item not found')
|
||||
}
|
||||
if (libraryItem.libraryId !== playlist.libraryId) {
|
||||
if (libraryItem.libraryId !== oldPlaylist.libraryId) {
|
||||
return res.status(400).send('Library item in different library')
|
||||
}
|
||||
if (playlist.containsItem(itemToAdd)) {
|
||||
if (oldPlaylist.containsItem(itemToAdd)) {
|
||||
return res.status(400).send('Item already in playlist')
|
||||
}
|
||||
if ((itemToAdd.episodeId && !libraryItem.isPodcast) || (libraryItem.isPodcast && !itemToAdd.episodeId)) {
|
||||
|
|
@ -81,160 +197,248 @@ class PlaylistController {
|
|||
return res.status(400).send('Episode not found in library item')
|
||||
}
|
||||
|
||||
playlist.addItem(itemToAdd.libraryItemId, itemToAdd.episodeId)
|
||||
|
||||
const playlistMediaItem = {
|
||||
playlistId: playlist.id,
|
||||
playlistId: oldPlaylist.id,
|
||||
mediaItemId: itemToAdd.episodeId || libraryItem.media.id,
|
||||
mediaItemType: itemToAdd.episodeId ? 'podcastEpisode' : 'book',
|
||||
order: playlist.items.length
|
||||
order: oldPlaylist.items.length + 1
|
||||
}
|
||||
|
||||
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
|
||||
await Database.createPlaylistMediaItem(playlistMediaItem)
|
||||
const jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
|
||||
// DELETE: api/playlists/:id/item/:libraryItemId/:episodeId?
|
||||
/**
|
||||
* DELETE: /api/playlists/:id/item/:libraryItemId/:episodeId?
|
||||
* Remove item from playlist
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
async removeItem(req, res) {
|
||||
const playlist = req.playlist
|
||||
const itemToRemove = {
|
||||
libraryItemId: req.params.libraryItemId,
|
||||
episodeId: req.params.episodeId || null
|
||||
}
|
||||
if (!playlist.containsItem(itemToRemove)) {
|
||||
return res.sendStatus(404)
|
||||
const oldLibraryItem = await Database.libraryItemModel.getOldById(req.params.libraryItemId)
|
||||
if (!oldLibraryItem) {
|
||||
return res.status(404).send('Library item not found')
|
||||
}
|
||||
|
||||
playlist.removeItem(itemToRemove.libraryItemId, itemToRemove.episodeId)
|
||||
// Get playlist media items
|
||||
const mediaItemId = req.params.episodeId || oldLibraryItem.media.id
|
||||
const playlistMediaItems = await req.playlist.getPlaylistMediaItems({
|
||||
order: [['order', 'ASC']]
|
||||
})
|
||||
|
||||
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
|
||||
// Check if media item to delete is in playlist
|
||||
const mediaItemToRemove = playlistMediaItems.find(pmi => pmi.mediaItemId === mediaItemId)
|
||||
if (!mediaItemToRemove) {
|
||||
return res.status(404).send('Media item not found in playlist')
|
||||
}
|
||||
|
||||
// Remove record
|
||||
await mediaItemToRemove.destroy()
|
||||
|
||||
// Update playlist media items order
|
||||
let order = 1
|
||||
for (const mediaItem of playlistMediaItems) {
|
||||
if (mediaItem.mediaItemId === mediaItemId) continue
|
||||
if (mediaItem.order !== order) {
|
||||
await mediaItem.update({
|
||||
order
|
||||
})
|
||||
}
|
||||
order++
|
||||
}
|
||||
|
||||
const jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||
|
||||
// Playlist is removed when there are no items
|
||||
if (!playlist.items.length) {
|
||||
Logger.info(`[PlaylistController] Playlist "${playlist.name}" has no more items - removing it`)
|
||||
await Database.removePlaylist(playlist.id)
|
||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
|
||||
if (!jsonExpanded.items.length) {
|
||||
Logger.info(`[PlaylistController] Playlist "${jsonExpanded.name}" has no more items - removing it`)
|
||||
await req.playlist.destroy()
|
||||
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
|
||||
} else {
|
||||
await Database.updatePlaylist(playlist)
|
||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
|
||||
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_updated', jsonExpanded)
|
||||
}
|
||||
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
|
||||
// POST: api/playlists/:id/batch/add
|
||||
/**
|
||||
* POST: /api/playlists/:id/batch/add
|
||||
* Batch add playlist items
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
async addBatch(req, res) {
|
||||
const playlist = req.playlist
|
||||
if (!req.body.items || !req.body.items.length) {
|
||||
return res.status(500).send('Invalid request body')
|
||||
if (!req.body.items?.length) {
|
||||
return res.status(400).send('Invalid request body')
|
||||
}
|
||||
const itemsToAdd = req.body.items
|
||||
let hasUpdated = false
|
||||
|
||||
let order = playlist.items.length
|
||||
const playlistMediaItems = []
|
||||
const libraryItemIds = itemsToAdd.map(i => i.libraryItemId).filter(i => i)
|
||||
if (!libraryItemIds.length) {
|
||||
return res.status(400).send('Invalid request body')
|
||||
}
|
||||
|
||||
// Find all library items
|
||||
const libraryItems = await Database.libraryItemModel.findAll({
|
||||
where: {
|
||||
id: libraryItemIds
|
||||
}
|
||||
})
|
||||
|
||||
// Get all existing playlist media items
|
||||
const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({
|
||||
order: [['order', 'ASC']]
|
||||
})
|
||||
|
||||
const mediaItemsToAdd = []
|
||||
|
||||
// Setup array of playlistMediaItem records to add
|
||||
let order = existingPlaylistMediaItems.length + 1
|
||||
for (const item of itemsToAdd) {
|
||||
if (!item.libraryItemId) {
|
||||
return res.status(400).send('Item does not have libraryItemId')
|
||||
}
|
||||
|
||||
const libraryItem = Database.getLibraryItem(item.libraryItemId)
|
||||
const libraryItem = libraryItems.find(li => li.id === item.libraryItemId)
|
||||
if (!libraryItem) {
|
||||
return res.status(400).send('Item not found with id ' + item.libraryItemId)
|
||||
}
|
||||
|
||||
if (!playlist.containsItem(item)) {
|
||||
playlistMediaItems.push({
|
||||
playlistId: playlist.id,
|
||||
mediaItemId: item.episodeId || libraryItem.media.id, // podcastEpisodeId or bookId
|
||||
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
|
||||
order: order++
|
||||
})
|
||||
playlist.addItem(item.libraryItemId, item.episodeId)
|
||||
hasUpdated = true
|
||||
return res.status(404).send('Item not found with id ' + item.libraryItemId)
|
||||
} else {
|
||||
const mediaItemId = item.episodeId || libraryItem.mediaId
|
||||
if (existingPlaylistMediaItems.some(pmi => pmi.mediaItemId === mediaItemId)) {
|
||||
// Already exists in playlist
|
||||
continue
|
||||
} else {
|
||||
mediaItemsToAdd.push({
|
||||
playlistId: req.playlist.id,
|
||||
mediaItemId,
|
||||
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
|
||||
order: order++
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
|
||||
if (hasUpdated) {
|
||||
await Database.createBulkPlaylistMediaItems(playlistMediaItems)
|
||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
|
||||
let jsonExpanded = null
|
||||
if (mediaItemsToAdd.length) {
|
||||
await Database.createBulkPlaylistMediaItems(mediaItemsToAdd)
|
||||
jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||
SocketAuthority.clientEmitter(req.playlist.userId, 'playlist_updated', jsonExpanded)
|
||||
} else {
|
||||
jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||
}
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
|
||||
// POST: api/playlists/:id/batch/remove
|
||||
/**
|
||||
* POST: /api/playlists/:id/batch/remove
|
||||
* Batch remove playlist items
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
async removeBatch(req, res) {
|
||||
const playlist = req.playlist
|
||||
if (!req.body.items || !req.body.items.length) {
|
||||
return res.status(500).send('Invalid request body')
|
||||
if (!req.body.items?.length) {
|
||||
return res.status(400).send('Invalid request body')
|
||||
}
|
||||
|
||||
const itemsToRemove = req.body.items
|
||||
const libraryItemIds = itemsToRemove.map(i => i.libraryItemId).filter(i => i)
|
||||
if (!libraryItemIds.length) {
|
||||
return res.status(400).send('Invalid request body')
|
||||
}
|
||||
|
||||
// Find all library items
|
||||
const libraryItems = await Database.libraryItemModel.findAll({
|
||||
where: {
|
||||
id: libraryItemIds
|
||||
}
|
||||
})
|
||||
|
||||
// Get all existing playlist media items for playlist
|
||||
const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({
|
||||
order: [['order', 'ASC']]
|
||||
})
|
||||
let numMediaItems = existingPlaylistMediaItems.length
|
||||
|
||||
// Remove playlist media items
|
||||
let hasUpdated = false
|
||||
for (const item of itemsToRemove) {
|
||||
if (!item.libraryItemId) {
|
||||
return res.status(400).send('Item does not have libraryItemId')
|
||||
}
|
||||
|
||||
if (playlist.containsItem(item)) {
|
||||
playlist.removeItem(item.libraryItemId, item.episodeId)
|
||||
hasUpdated = true
|
||||
}
|
||||
const libraryItem = libraryItems.find(li => li.id === item.libraryItemId)
|
||||
if (!libraryItem) continue
|
||||
const mediaItemId = item.episodeId || libraryItem.mediaId
|
||||
const existingMediaItem = existingPlaylistMediaItems.find(pmi => pmi.mediaItemId === mediaItemId)
|
||||
if (!existingMediaItem) continue
|
||||
await existingMediaItem.destroy()
|
||||
hasUpdated = true
|
||||
numMediaItems--
|
||||
}
|
||||
|
||||
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
|
||||
const jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||
if (hasUpdated) {
|
||||
// Playlist is removed when there are no items
|
||||
if (!playlist.items.length) {
|
||||
Logger.info(`[PlaylistController] Playlist "${playlist.name}" has no more items - removing it`)
|
||||
await Database.removePlaylist(playlist.id)
|
||||
if (!numMediaItems) {
|
||||
Logger.info(`[PlaylistController] Playlist "${req.playlist.name}" has no more items - removing it`)
|
||||
await req.playlist.destroy()
|
||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
|
||||
} else {
|
||||
await Database.updatePlaylist(playlist)
|
||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
|
||||
}
|
||||
}
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
|
||||
// POST: api/playlists/collection/:collectionId
|
||||
/**
|
||||
* POST: /api/playlists/collection/:collectionId
|
||||
* Create a playlist from a collection
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
async createFromCollection(req, res) {
|
||||
let collection = await Database.models.collection.getById(req.params.collectionId)
|
||||
const collection = await Database.collectionModel.findByPk(req.params.collectionId)
|
||||
if (!collection) {
|
||||
return res.status(404).send('Collection not found')
|
||||
}
|
||||
// Expand collection to get library items
|
||||
collection = collection.toJSONExpanded(Database.libraryItems)
|
||||
|
||||
// Filter out library items not accessible to user
|
||||
const libraryItems = collection.books.filter(item => req.user.checkCanAccessLibraryItem(item))
|
||||
|
||||
if (!libraryItems.length) {
|
||||
return res.status(400).send('Collection has no books accessible to user')
|
||||
const collectionExpanded = await collection.getOldJsonExpanded(req.user)
|
||||
if (!collectionExpanded) {
|
||||
// This can happen if the user has no access to all items in collection
|
||||
return res.status(404).send('Collection not found')
|
||||
}
|
||||
|
||||
const newPlaylist = new Playlist()
|
||||
// Playlists cannot be empty
|
||||
if (!collectionExpanded.books.length) {
|
||||
return res.status(400).send('Collection has no books')
|
||||
}
|
||||
|
||||
const newPlaylistData = {
|
||||
const oldPlaylist = new Playlist()
|
||||
oldPlaylist.setData({
|
||||
userId: req.user.id,
|
||||
libraryId: collection.libraryId,
|
||||
name: collection.name,
|
||||
description: collection.description || null,
|
||||
items: libraryItems.map(li => ({ libraryItemId: li.id }))
|
||||
}
|
||||
newPlaylist.setData(newPlaylistData)
|
||||
description: collection.description || null
|
||||
})
|
||||
|
||||
const jsonExpanded = newPlaylist.toJSONExpanded(Database.libraryItems)
|
||||
await Database.createPlaylist(newPlaylist)
|
||||
// Create Playlist record
|
||||
const newPlaylist = await Database.playlistModel.createFromOld(oldPlaylist)
|
||||
|
||||
// Create PlaylistMediaItem records
|
||||
const mediaItemsToAdd = []
|
||||
let order = 1
|
||||
for (const libraryItem of collectionExpanded.books) {
|
||||
mediaItemsToAdd.push({
|
||||
playlistId: newPlaylist.id,
|
||||
mediaItemId: libraryItem.media.id,
|
||||
mediaItemType: 'book',
|
||||
order: order++
|
||||
})
|
||||
}
|
||||
await Database.createBulkPlaylistMediaItems(mediaItemsToAdd)
|
||||
|
||||
const jsonExpanded = await newPlaylist.getOldJsonExpanded()
|
||||
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
|
||||
async middleware(req, res, next) {
|
||||
if (req.params.id) {
|
||||
const playlist = await Database.models.playlist.getById(req.params.id)
|
||||
const playlist = await Database.playlistModel.findByPk(req.params.id)
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ const fs = require('../libs/fsExtra')
|
|||
|
||||
const { getPodcastFeed, findMatchingEpisodes } = require('../utils/podcastUtils')
|
||||
const { getFileTimestampsWithIno, filePathToPOSIX } = require('../utils/fileUtils')
|
||||
const filePerms = require('../utils/filePerms')
|
||||
|
||||
const Scanner = require('../scanner/Scanner')
|
||||
const CoverManager = require('../managers/CoverManager')
|
||||
|
||||
const LibraryItem = require('../objects/LibraryItem')
|
||||
|
||||
|
|
@ -19,7 +21,7 @@ class PodcastController {
|
|||
}
|
||||
const payload = req.body
|
||||
|
||||
const library = await Database.models.library.getOldById(payload.libraryId)
|
||||
const library = await Database.libraryModel.getOldById(payload.libraryId)
|
||||
if (!library) {
|
||||
Logger.error(`[PodcastController] Create: Library not found "${payload.libraryId}"`)
|
||||
return res.status(404).send('Library not found')
|
||||
|
|
@ -34,9 +36,13 @@ class PodcastController {
|
|||
const podcastPath = filePathToPOSIX(payload.path)
|
||||
|
||||
// Check if a library item with this podcast folder exists already
|
||||
const existingLibraryItem = Database.libraryItems.find(li => li.path === podcastPath && li.libraryId === library.id)
|
||||
const existingLibraryItem = (await Database.libraryItemModel.count({
|
||||
where: {
|
||||
path: podcastPath
|
||||
}
|
||||
})) > 0
|
||||
if (existingLibraryItem) {
|
||||
Logger.error(`[PodcastController] Podcast already exists with name "${existingLibraryItem.media.metadata.title}" at path "${podcastPath}"`)
|
||||
Logger.error(`[PodcastController] Podcast already exists at path "${podcastPath}"`)
|
||||
return res.status(400).send('Podcast already exists')
|
||||
}
|
||||
|
||||
|
|
@ -45,7 +51,6 @@ class PodcastController {
|
|||
return false
|
||||
})
|
||||
if (!success) return res.status(400).send('Invalid podcast path')
|
||||
await filePerms.setDefault(podcastPath)
|
||||
|
||||
const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath)
|
||||
|
||||
|
|
@ -71,7 +76,7 @@ class PodcastController {
|
|||
if (payload.media.metadata.imageUrl) {
|
||||
// TODO: Scan cover image to library files
|
||||
// Podcast cover will always go into library item folder
|
||||
const coverResponse = await this.coverManager.downloadCoverFromUrl(libraryItem, payload.media.metadata.imageUrl, true)
|
||||
const coverResponse = await CoverManager.downloadCoverFromUrl(libraryItem, payload.media.metadata.imageUrl, true)
|
||||
if (coverResponse) {
|
||||
if (coverResponse.error) {
|
||||
Logger.error(`[PodcastController] Download cover error from "${payload.media.metadata.imageUrl}": ${coverResponse.error}`)
|
||||
|
|
@ -198,7 +203,7 @@ class PodcastController {
|
|||
}
|
||||
|
||||
const overrideDetails = req.query.override === '1'
|
||||
const episodesUpdated = await this.scanner.quickMatchPodcastEpisodes(req.libraryItem, { overrideDetails })
|
||||
const episodesUpdated = await Scanner.quickMatchPodcastEpisodes(req.libraryItem, { overrideDetails })
|
||||
if (episodesUpdated) {
|
||||
await Database.updateLibraryItem(req.libraryItem)
|
||||
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
|
||||
|
|
@ -268,23 +273,32 @@ class PodcastController {
|
|||
}
|
||||
|
||||
// Update/remove playlists that had this podcast episode
|
||||
const playlistsWithEpisode = await Database.models.playlist.getPlaylistsForMediaItemIds([episodeId])
|
||||
for (const playlist of playlistsWithEpisode) {
|
||||
playlist.removeItem(libraryItem.id, episodeId)
|
||||
const playlistMediaItems = await Database.playlistMediaItemModel.findAll({
|
||||
where: {
|
||||
mediaItemId: episodeId
|
||||
},
|
||||
include: {
|
||||
model: Database.playlistModel,
|
||||
include: Database.playlistMediaItemModel
|
||||
}
|
||||
})
|
||||
for (const pmi of playlistMediaItems) {
|
||||
const numItems = pmi.playlist.playlistMediaItems.length - 1
|
||||
|
||||
// If playlist is now empty then remove it
|
||||
if (!playlist.items.length) {
|
||||
if (!numItems) {
|
||||
Logger.info(`[PodcastController] Playlist "${playlist.name}" has no more items - removing it`)
|
||||
await Database.removePlaylist(playlist.id)
|
||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', playlist.toJSONExpanded(Database.libraryItems))
|
||||
const jsonExpanded = await pmi.playlist.getOldJsonExpanded()
|
||||
SocketAuthority.clientEmitter(pmi.playlist.userId, 'playlist_removed', jsonExpanded)
|
||||
await pmi.playlist.destroy()
|
||||
} else {
|
||||
await Database.updatePlaylist(playlist)
|
||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', playlist.toJSONExpanded(Database.libraryItems))
|
||||
await pmi.destroy()
|
||||
const jsonExpanded = await pmi.playlist.getOldJsonExpanded()
|
||||
SocketAuthority.clientEmitter(pmi.playlist.userId, 'playlist_updated', jsonExpanded)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove media progress for this episode
|
||||
const mediaProgressRemoved = await Database.models.mediaProgress.destroy({
|
||||
const mediaProgressRemoved = await Database.mediaProgressModel.destroy({
|
||||
where: {
|
||||
mediaItemId: episode.id
|
||||
}
|
||||
|
|
@ -298,9 +312,9 @@ class PodcastController {
|
|||
res.json(libraryItem.toJSON())
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
const item = Database.libraryItems.find(li => li.id === req.params.id)
|
||||
if (!item || !item.media) return res.sendStatus(404)
|
||||
async middleware(req, res, next) {
|
||||
const item = await Database.libraryItemModel.getOldById(req.params.id)
|
||||
if (!item?.media) return res.sendStatus(404)
|
||||
|
||||
if (!item.isPodcast) {
|
||||
return res.sendStatus(500)
|
||||
|
|
|
|||
|
|
@ -1,14 +1,23 @@
|
|||
const Logger = require('../Logger')
|
||||
const Database = require('../Database')
|
||||
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
|
||||
|
||||
class RSSFeedController {
|
||||
constructor() { }
|
||||
|
||||
async getAll(req, res) {
|
||||
const feeds = await this.rssFeedManager.getFeeds()
|
||||
res.json({
|
||||
feeds: feeds.map(f => f.toJSON()),
|
||||
minified: feeds.map(f => f.toJSONMinified())
|
||||
})
|
||||
}
|
||||
|
||||
// POST: api/feeds/item/:itemId/open
|
||||
async openRSSFeedForItem(req, res) {
|
||||
const options = req.body || {}
|
||||
|
||||
const item = Database.libraryItems.find(li => li.id === req.params.itemId)
|
||||
const item = await Database.libraryItemModel.getOldById(req.params.itemId)
|
||||
if (!item) return res.sendStatus(404)
|
||||
|
||||
// Check user can access this library item
|
||||
|
|
@ -45,7 +54,7 @@ class RSSFeedController {
|
|||
async openRSSFeedForCollection(req, res) {
|
||||
const options = req.body || {}
|
||||
|
||||
const collection = await Database.models.collection.getById(req.params.collectionId)
|
||||
const collection = await Database.collectionModel.findByPk(req.params.collectionId)
|
||||
if (!collection) return res.sendStatus(404)
|
||||
|
||||
// Check request body options exist
|
||||
|
|
@ -60,7 +69,7 @@ class RSSFeedController {
|
|||
return res.status(400).send('Slug already in use')
|
||||
}
|
||||
|
||||
const collectionExpanded = collection.toJSONExpanded(Database.libraryItems)
|
||||
const collectionExpanded = await collection.getOldJsonExpanded()
|
||||
const collectionItemsWithTracks = collectionExpanded.books.filter(li => li.media.tracks.length)
|
||||
|
||||
// Check collection has audio tracks
|
||||
|
|
@ -79,7 +88,7 @@ class RSSFeedController {
|
|||
async openRSSFeedForSeries(req, res) {
|
||||
const options = req.body || {}
|
||||
|
||||
const series = Database.series.find(se => se.id === req.params.seriesId)
|
||||
const series = await Database.seriesModel.getOldById(req.params.seriesId)
|
||||
if (!series) return res.sendStatus(404)
|
||||
|
||||
// Check request body options exist
|
||||
|
|
@ -95,8 +104,9 @@ class RSSFeedController {
|
|||
}
|
||||
|
||||
const seriesJson = series.toJSON()
|
||||
|
||||
// Get books in series that have audio tracks
|
||||
seriesJson.books = Database.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(series.id) && li.media.tracks.length)
|
||||
seriesJson.books = (await libraryItemsBookFilters.getLibraryItemsForSeries(series)).filter(li => li.media.numTracks)
|
||||
|
||||
// Check series has audio tracks
|
||||
if (!seriesJson.books.length) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
const Logger = require("../Logger")
|
||||
const BookFinder = require('../finders/BookFinder')
|
||||
const PodcastFinder = require('../finders/PodcastFinder')
|
||||
const AuthorFinder = require('../finders/AuthorFinder')
|
||||
const MusicFinder = require('../finders/MusicFinder')
|
||||
|
||||
class SearchController {
|
||||
constructor() { }
|
||||
|
|
@ -7,7 +11,7 @@ class SearchController {
|
|||
const provider = req.query.provider || 'google'
|
||||
const title = req.query.title || ''
|
||||
const author = req.query.author || ''
|
||||
const results = await this.bookFinder.search(provider, title, author)
|
||||
const results = await BookFinder.search(provider, title, author)
|
||||
res.json(results)
|
||||
}
|
||||
|
||||
|
|
@ -21,8 +25,8 @@ class SearchController {
|
|||
}
|
||||
|
||||
let results = null
|
||||
if (podcast) results = await this.podcastFinder.findCovers(query.title)
|
||||
else results = await this.bookFinder.findCovers(query.provider || 'google', query.title, query.author || null)
|
||||
if (podcast) results = await PodcastFinder.findCovers(query.title)
|
||||
else results = await BookFinder.findCovers(query.provider || 'google', query.title, query.author || null)
|
||||
res.json({
|
||||
results
|
||||
})
|
||||
|
|
@ -30,20 +34,20 @@ class SearchController {
|
|||
|
||||
async findPodcasts(req, res) {
|
||||
const term = req.query.term
|
||||
const results = await this.podcastFinder.search(term)
|
||||
const results = await PodcastFinder.search(term)
|
||||
res.json(results)
|
||||
}
|
||||
|
||||
async findAuthor(req, res) {
|
||||
const query = req.query.q
|
||||
const author = await this.authorFinder.findAuthorByName(query)
|
||||
const author = await AuthorFinder.findAuthorByName(query)
|
||||
res.json(author)
|
||||
}
|
||||
|
||||
async findChapters(req, res) {
|
||||
const asin = req.query.asin
|
||||
const region = (req.query.region || 'us').toLowerCase()
|
||||
const chapterData = await this.bookFinder.findChapters(asin, region)
|
||||
const chapterData = await BookFinder.findChapters(asin, region)
|
||||
if (!chapterData) {
|
||||
return res.json({ error: 'Chapters not found' })
|
||||
}
|
||||
|
|
@ -51,7 +55,7 @@ class SearchController {
|
|||
}
|
||||
|
||||
async findMusicTrack(req, res) {
|
||||
const tracks = await this.musicFinder.searchTrack(req.query || {})
|
||||
const tracks = await MusicFinder.searchTrack(req.query || {})
|
||||
res.json({
|
||||
tracks
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
const Logger = require('../Logger')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const Database = require('../Database')
|
||||
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
|
||||
|
||||
class SeriesController {
|
||||
constructor() { }
|
||||
|
|
@ -25,7 +26,7 @@ class SeriesController {
|
|||
const libraryItemsInSeries = req.libraryItemsInSeries
|
||||
const libraryItemsFinished = libraryItemsInSeries.filter(li => {
|
||||
const mediaProgress = req.user.getMediaProgress(li.id)
|
||||
return mediaProgress && mediaProgress.isFinished
|
||||
return mediaProgress?.isFinished
|
||||
})
|
||||
seriesJson.progress = {
|
||||
libraryItemIds: libraryItemsInSeries.map(li => li.id),
|
||||
|
|
@ -42,17 +43,6 @@ class SeriesController {
|
|||
res.json(seriesJson)
|
||||
}
|
||||
|
||||
async search(req, res) {
|
||||
var q = (req.query.q || '').toLowerCase()
|
||||
if (!q) return res.json([])
|
||||
var limit = (req.query.limit && !isNaN(req.query.limit)) ? Number(req.query.limit) : 25
|
||||
var series = Database.series.filter(se => se.name.toLowerCase().includes(q))
|
||||
series = series.slice(0, limit)
|
||||
res.json({
|
||||
results: series
|
||||
})
|
||||
}
|
||||
|
||||
async update(req, res) {
|
||||
const hasUpdated = req.series.update(req.body)
|
||||
if (hasUpdated) {
|
||||
|
|
@ -62,18 +52,17 @@ class SeriesController {
|
|||
res.json(req.series.toJSON())
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
const series = Database.series.find(se => se.id === req.params.id)
|
||||
async middleware(req, res, next) {
|
||||
const series = await Database.seriesModel.getOldById(req.params.id)
|
||||
if (!series) return res.sendStatus(404)
|
||||
|
||||
/**
|
||||
* Filter out any library items not accessible to user
|
||||
*/
|
||||
const libraryItems = Database.libraryItems.filter(li => li.media.metadata.hasSeries?.(series.id))
|
||||
const libraryItemsAccessible = libraryItems.filter(li => req.user.checkCanAccessLibraryItem(li))
|
||||
if (libraryItems.length && !libraryItemsAccessible.length) {
|
||||
Logger.warn(`[SeriesController] User attempted to access series "${series.id}" without access to any of the books`, req.user)
|
||||
return res.sendStatus(403)
|
||||
const libraryItems = await libraryItemsBookFilters.getLibraryItemsForSeries(series, req.user)
|
||||
if (!libraryItems.length) {
|
||||
Logger.warn(`[SeriesController] User attempted to access series "${series.id}" with no accessible books`, req.user)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
if (req.method == 'DELETE' && !req.user.canDelete) {
|
||||
|
|
@ -85,7 +74,7 @@ class SeriesController {
|
|||
}
|
||||
|
||||
req.series = series
|
||||
req.libraryItemsInSeries = libraryItemsAccessible
|
||||
req.libraryItemsInSeries = libraryItems
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ class SessionController {
|
|||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
const minifiedUserObjects = await Database.models.user.getMinifiedUserObjects()
|
||||
const minifiedUserObjects = await Database.userModel.getMinifiedUserObjects()
|
||||
const openSessions = this.playbackSessionManager.sessions.map(se => {
|
||||
return {
|
||||
...se.toJSON(),
|
||||
|
|
@ -62,9 +62,9 @@ class SessionController {
|
|||
})
|
||||
}
|
||||
|
||||
getOpenSession(req, res) {
|
||||
var libraryItem = Database.getLibraryItem(req.session.libraryItemId)
|
||||
var sessionForClient = req.session.toJSONForClient(libraryItem)
|
||||
async getOpenSession(req, res) {
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(req.session.libraryItemId)
|
||||
const sessionForClient = req.session.toJSONForClient(libraryItem)
|
||||
res.json(sessionForClient)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ class ToolsController {
|
|||
|
||||
const libraryItems = []
|
||||
for (const libraryItemId of libraryItemIds) {
|
||||
const libraryItem = Database.getLibraryItem(libraryItemId)
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(libraryItemId)
|
||||
if (!libraryItem) {
|
||||
Logger.error(`[ToolsController] Batch embed metadata library item (${libraryItemId}) not found`)
|
||||
return res.sendStatus(404)
|
||||
|
|
@ -99,15 +99,15 @@ class ToolsController {
|
|||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
async middleware(req, res, next) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[LibraryItemController] Non-root user attempted to access tools route`, req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
if (req.params.id) {
|
||||
const item = Database.libraryItems.find(li => li.id === req.params.id)
|
||||
if (!item || !item.media) return res.sendStatus(404)
|
||||
const item = await Database.libraryItemModel.getOldById(req.params.id)
|
||||
if (!item?.media) return res.sendStatus(404)
|
||||
|
||||
// Check user can access this library item
|
||||
if (!req.user.checkCanAccessLibraryItem(item)) {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ class UserController {
|
|||
const includes = (req.query.include || '').split(',').map(i => i.trim())
|
||||
|
||||
// Minimal toJSONForBrowser does not include mediaProgress and bookmarks
|
||||
const allUsers = await Database.models.user.getOldUsers()
|
||||
const allUsers = await Database.userModel.getOldUsers()
|
||||
const users = allUsers.map(u => u.toJSONForBrowser(hideRootToken, true))
|
||||
|
||||
if (includes.includes('latestSession')) {
|
||||
|
|
@ -32,20 +32,67 @@ class UserController {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/users/:id
|
||||
* Get a single user toJSONForBrowser
|
||||
* Media progress items include: `displayTitle`, `displaySubtitle` (for podcasts), `coverPath` and `mediaUpdatedAt`
|
||||
*
|
||||
* @param {import("express").Request} req
|
||||
* @param {import("express").Response} res
|
||||
*/
|
||||
async findOne(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error('User other than admin attempting to get user', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
res.json(this.userJsonWithItemProgressDetails(req.reqUser, !req.user.isRoot))
|
||||
// Get user media progress with associated mediaItem
|
||||
const mediaProgresses = await Database.mediaProgressModel.findAll({
|
||||
where: {
|
||||
userId: req.reqUser.id
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: Database.bookModel,
|
||||
attributes: ['id', 'title', 'coverPath', 'updatedAt']
|
||||
},
|
||||
{
|
||||
model: Database.podcastEpisodeModel,
|
||||
attributes: ['id', 'title'],
|
||||
include: {
|
||||
model: Database.podcastModel,
|
||||
attributes: ['id', 'title', 'coverPath', 'updatedAt']
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const oldMediaProgresses = mediaProgresses.map(mp => {
|
||||
const oldMediaProgress = mp.getOldMediaProgress()
|
||||
oldMediaProgress.displayTitle = mp.mediaItem?.title
|
||||
if (mp.mediaItem?.podcast) {
|
||||
oldMediaProgress.displaySubtitle = mp.mediaItem.podcast?.title
|
||||
oldMediaProgress.coverPath = mp.mediaItem.podcast?.coverPath
|
||||
oldMediaProgress.mediaUpdatedAt = mp.mediaItem.podcast?.updatedAt
|
||||
} else if (mp.mediaItem) {
|
||||
oldMediaProgress.coverPath = mp.mediaItem.coverPath
|
||||
oldMediaProgress.mediaUpdatedAt = mp.mediaItem.updatedAt
|
||||
}
|
||||
return oldMediaProgress
|
||||
})
|
||||
|
||||
const userJson = req.reqUser.toJSONForBrowser(!req.user.isRoot)
|
||||
|
||||
userJson.mediaProgress = oldMediaProgresses
|
||||
|
||||
res.json(userJson)
|
||||
}
|
||||
|
||||
async create(req, res) {
|
||||
const account = req.body
|
||||
const username = account.username
|
||||
|
||||
const usernameExists = await Database.models.user.getUserByUsername(username)
|
||||
const usernameExists = await Database.userModel.getUserByUsername(username)
|
||||
if (usernameExists) {
|
||||
return res.status(500).send('Username already taken')
|
||||
}
|
||||
|
|
@ -80,7 +127,7 @@ class UserController {
|
|||
var shouldUpdateToken = false
|
||||
|
||||
if (account.username !== undefined && account.username !== user.username) {
|
||||
const usernameExists = await Database.models.user.getUserByUsername(account.username)
|
||||
const usernameExists = await Database.userModel.getUserByUsername(account.username)
|
||||
if (usernameExists) {
|
||||
return res.status(500).send('Username already taken')
|
||||
}
|
||||
|
|
@ -122,9 +169,13 @@ class UserController {
|
|||
// Todo: check if user is logged in and cancel streams
|
||||
|
||||
// Remove user playlists
|
||||
const userPlaylists = await Database.models.playlist.getPlaylistsForUserAndLibrary(user.id)
|
||||
const userPlaylists = await Database.playlistModel.findAll({
|
||||
where: {
|
||||
userId: user.id
|
||||
}
|
||||
})
|
||||
for (const playlist of userPlaylists) {
|
||||
await Database.removePlaylist(playlist.id)
|
||||
await playlist.destroy()
|
||||
}
|
||||
|
||||
const userJson = user.toJSONForBrowser()
|
||||
|
|
@ -182,7 +233,7 @@ class UserController {
|
|||
}
|
||||
|
||||
if (req.params.id) {
|
||||
req.reqUser = await Database.models.user.getUserById(req.params.id)
|
||||
req.reqUser = await Database.userModel.getUserById(req.params.id)
|
||||
if (!req.reqUser) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,12 +4,9 @@ const Path = require('path')
|
|||
const Audnexus = require('../providers/Audnexus')
|
||||
|
||||
const { downloadFile } = require('../utils/fileUtils')
|
||||
const filePerms = require('../utils/filePerms')
|
||||
|
||||
class AuthorFinder {
|
||||
constructor() {
|
||||
this.AuthorPath = Path.join(global.MetadataPath, 'authors')
|
||||
|
||||
this.audnexus = new Audnexus()
|
||||
}
|
||||
|
||||
|
|
@ -37,12 +34,11 @@ class AuthorFinder {
|
|||
}
|
||||
|
||||
async saveAuthorImage(authorId, url) {
|
||||
var authorDir = this.AuthorPath
|
||||
var authorDir = Path.join(global.MetadataPath, 'authors')
|
||||
var relAuthorDir = Path.posix.join('/metadata', 'authors')
|
||||
|
||||
if (!await fs.pathExists(authorDir)) {
|
||||
await fs.ensureDir(authorDir)
|
||||
await filePerms.setDefault(authorDir)
|
||||
}
|
||||
|
||||
var imageExtension = url.toLowerCase().split('.').pop()
|
||||
|
|
@ -61,4 +57,4 @@ class AuthorFinder {
|
|||
}
|
||||
}
|
||||
}
|
||||
module.exports = AuthorFinder
|
||||
module.exports = new AuthorFinder()
|
||||
|
|
@ -253,4 +253,4 @@ class BookFinder {
|
|||
return this.audnexus.getChaptersByASIN(asin, region)
|
||||
}
|
||||
}
|
||||
module.exports = BookFinder
|
||||
module.exports = new BookFinder()
|
||||
|
|
@ -9,4 +9,4 @@ class MusicFinder {
|
|||
return this.musicBrainz.searchTrack(options)
|
||||
}
|
||||
}
|
||||
module.exports = MusicFinder
|
||||
module.exports = new MusicFinder()
|
||||
|
|
@ -22,4 +22,4 @@ class PodcastFinder {
|
|||
return results.map(r => r.cover).filter(r => r)
|
||||
}
|
||||
}
|
||||
module.exports = PodcastFinder
|
||||
module.exports = new PodcastFinder()
|
||||
|
|
@ -5,7 +5,6 @@ const fs = require('../libs/fsExtra')
|
|||
const workerThreads = require('worker_threads')
|
||||
const Logger = require('../Logger')
|
||||
const Task = require('../objects/Task')
|
||||
const filePerms = require('../utils/filePerms')
|
||||
const { writeConcatFile } = require('../utils/ffmpegHelpers')
|
||||
const toneHelpers = require('../utils/toneHelpers')
|
||||
|
||||
|
|
@ -201,10 +200,6 @@ class AbMergeManager {
|
|||
Logger.debug(`[AbMergeManager] Moving m4b from ${task.data.tempFilepath} to ${task.data.targetFilepath}`)
|
||||
await fs.move(task.data.tempFilepath, task.data.targetFilepath)
|
||||
|
||||
// Set file permissions and ownership
|
||||
await filePerms.setDefault(task.data.targetFilepath)
|
||||
await filePerms.setDefault(task.data.itemCachePath)
|
||||
|
||||
task.setFinished()
|
||||
await this.removeTask(task, false)
|
||||
Logger.info(`[AbMergeManager] Ab task finished ${task.id}`)
|
||||
|
|
|
|||
|
|
@ -1,42 +1,40 @@
|
|||
const Path = require('path')
|
||||
const fs = require('../libs/fsExtra')
|
||||
const stream = require('stream')
|
||||
const filePerms = require('../utils/filePerms')
|
||||
const Logger = require('../Logger')
|
||||
const { resizeImage } = require('../utils/ffmpegHelpers')
|
||||
|
||||
class CacheManager {
|
||||
constructor() {
|
||||
this.CachePath = null
|
||||
this.CoverCachePath = null
|
||||
this.ImageCachePath = null
|
||||
this.ItemCachePath = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Create cache directory paths if they dont exist
|
||||
*/
|
||||
async ensureCachePaths() { // Creates cache paths if necessary and sets owner and permissions
|
||||
this.CachePath = Path.join(global.MetadataPath, 'cache')
|
||||
this.CoverCachePath = Path.join(this.CachePath, 'covers')
|
||||
this.ImageCachePath = Path.join(this.CachePath, 'images')
|
||||
this.ItemCachePath = Path.join(this.CachePath, 'items')
|
||||
}
|
||||
|
||||
async ensureCachePaths() { // Creates cache paths if necessary and sets owner and permissions
|
||||
var pathsCreated = false
|
||||
if (!(await fs.pathExists(this.CachePath))) {
|
||||
await fs.mkdir(this.CachePath)
|
||||
pathsCreated = true
|
||||
}
|
||||
|
||||
if (!(await fs.pathExists(this.CoverCachePath))) {
|
||||
await fs.mkdir(this.CoverCachePath)
|
||||
pathsCreated = true
|
||||
}
|
||||
|
||||
if (!(await fs.pathExists(this.ImageCachePath))) {
|
||||
await fs.mkdir(this.ImageCachePath)
|
||||
pathsCreated = true
|
||||
}
|
||||
|
||||
if (!(await fs.pathExists(this.ItemCachePath))) {
|
||||
await fs.mkdir(this.ItemCachePath)
|
||||
pathsCreated = true
|
||||
}
|
||||
|
||||
if (pathsCreated) {
|
||||
await filePerms.setDefault(this.CachePath)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -74,9 +72,6 @@ class CacheManager {
|
|||
const writtenFile = await resizeImage(libraryItem.media.coverPath, path, width, height)
|
||||
if (!writtenFile) return res.sendStatus(500)
|
||||
|
||||
// Set owner and permissions of cache image
|
||||
await filePerms.setDefault(path)
|
||||
|
||||
if (global.XAccel) {
|
||||
Logger.debug(`Use X-Accel to serve static file ${writtenFile}`)
|
||||
return res.status(204).header({ 'X-Accel-Redirect': global.XAccel + writtenFile }).send()
|
||||
|
|
@ -160,11 +155,8 @@ class CacheManager {
|
|||
let writtenFile = await resizeImage(author.imagePath, path, width, height)
|
||||
if (!writtenFile) return res.sendStatus(500)
|
||||
|
||||
// Set owner and permissions of cache image
|
||||
await filePerms.setDefault(path)
|
||||
|
||||
var readStream = fs.createReadStream(writtenFile)
|
||||
readStream.pipe(res)
|
||||
}
|
||||
}
|
||||
module.exports = CacheManager
|
||||
module.exports = new CacheManager()
|
||||
|
|
@ -3,24 +3,20 @@ const Path = require('path')
|
|||
const Logger = require('../Logger')
|
||||
const readChunk = require('../libs/readChunk')
|
||||
const imageType = require('../libs/imageType')
|
||||
const filePerms = require('../utils/filePerms')
|
||||
|
||||
const globals = require('../utils/globals')
|
||||
const { downloadFile, filePathToPOSIX } = require('../utils/fileUtils')
|
||||
const { downloadFile, filePathToPOSIX, checkPathIsFile } = require('../utils/fileUtils')
|
||||
const { extractCoverArt } = require('../utils/ffmpegHelpers')
|
||||
const CacheManager = require('../managers/CacheManager')
|
||||
|
||||
class CoverManager {
|
||||
constructor(cacheManager) {
|
||||
this.cacheManager = cacheManager
|
||||
|
||||
this.ItemMetadataPath = Path.posix.join(global.MetadataPath, 'items')
|
||||
}
|
||||
constructor() { }
|
||||
|
||||
getCoverDirectory(libraryItem) {
|
||||
if (global.ServerSettings.storeCoverWithItem && !libraryItem.isFile && !libraryItem.isMusic) {
|
||||
if (global.ServerSettings.storeCoverWithItem && !libraryItem.isFile) {
|
||||
return libraryItem.path
|
||||
} else {
|
||||
return Path.posix.join(this.ItemMetadataPath, libraryItem.id)
|
||||
return Path.posix.join(Path.posix.join(global.MetadataPath, 'items'), libraryItem.id)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -107,11 +103,10 @@ class CoverManager {
|
|||
}
|
||||
|
||||
await this.removeOldCovers(coverDirPath, extname)
|
||||
await this.cacheManager.purgeCoverCache(libraryItem.id)
|
||||
await CacheManager.purgeCoverCache(libraryItem.id)
|
||||
|
||||
Logger.info(`[CoverManager] Uploaded libraryItem cover "${coverFullPath}" for "${libraryItem.media.metadata.title}"`)
|
||||
|
||||
await filePerms.setDefault(coverFullPath)
|
||||
libraryItem.updateMediaCover(coverFullPath)
|
||||
return {
|
||||
cover: coverFullPath
|
||||
|
|
@ -146,11 +141,9 @@ class CoverManager {
|
|||
await fs.rename(temppath, coverFullPath)
|
||||
|
||||
await this.removeOldCovers(coverDirPath, '.' + imgtype.ext)
|
||||
await this.cacheManager.purgeCoverCache(libraryItem.id)
|
||||
await CacheManager.purgeCoverCache(libraryItem.id)
|
||||
|
||||
Logger.info(`[CoverManager] Downloaded libraryItem cover "${coverFullPath}" from url "${url}" for "${libraryItem.media.metadata.title}"`)
|
||||
|
||||
await filePerms.setDefault(coverFullPath)
|
||||
libraryItem.updateMediaCover(coverFullPath)
|
||||
return {
|
||||
cover: coverFullPath
|
||||
|
|
@ -180,6 +173,7 @@ class CoverManager {
|
|||
updated: false
|
||||
}
|
||||
}
|
||||
|
||||
// Cover path does not exist
|
||||
if (!await fs.pathExists(coverPath)) {
|
||||
Logger.error(`[CoverManager] validate cover path does not exist "${coverPath}"`)
|
||||
|
|
@ -187,8 +181,17 @@ class CoverManager {
|
|||
error: 'Cover path does not exist'
|
||||
}
|
||||
}
|
||||
|
||||
// Cover path is not a file
|
||||
if (!await checkPathIsFile(coverPath)) {
|
||||
Logger.error(`[CoverManager] validate cover path is not a file "${coverPath}"`)
|
||||
return {
|
||||
error: 'Cover path is not a file'
|
||||
}
|
||||
}
|
||||
|
||||
// Check valid image at path
|
||||
var imgtype = await this.checkFileIsValidImage(coverPath, true)
|
||||
var imgtype = await this.checkFileIsValidImage(coverPath, false)
|
||||
if (imgtype.error) {
|
||||
return imgtype
|
||||
}
|
||||
|
|
@ -212,13 +215,12 @@ class CoverManager {
|
|||
error: 'Failed to copy cover to dir'
|
||||
}
|
||||
}
|
||||
await filePerms.setDefault(newCoverPath)
|
||||
await this.removeOldCovers(coverDirPath, '.' + imgtype.ext)
|
||||
Logger.debug(`[CoverManager] cover copy success`)
|
||||
coverPath = newCoverPath
|
||||
}
|
||||
|
||||
await this.cacheManager.purgeCoverCache(libraryItem.id)
|
||||
await CacheManager.purgeCoverCache(libraryItem.id)
|
||||
|
||||
libraryItem.updateMediaCover(coverPath)
|
||||
return {
|
||||
|
|
@ -253,12 +255,97 @@ class CoverManager {
|
|||
|
||||
const success = await extractCoverArt(audioFileWithCover.metadata.path, coverFilePath)
|
||||
if (success) {
|
||||
await filePerms.setDefault(coverFilePath)
|
||||
|
||||
libraryItem.updateMediaCover(coverFilePath)
|
||||
return coverFilePath
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract cover art from audio file and save for library item
|
||||
* @param {import('../models/Book').AudioFileObject[]} audioFiles
|
||||
* @param {string} libraryItemId
|
||||
* @param {string} [libraryItemPath] null for isFile library items
|
||||
* @returns {Promise<string>} returns cover path
|
||||
*/
|
||||
async saveEmbeddedCoverArtNew(audioFiles, libraryItemId, libraryItemPath) {
|
||||
let audioFileWithCover = audioFiles.find(af => af.embeddedCoverArt)
|
||||
if (!audioFileWithCover) return null
|
||||
|
||||
let coverDirPath = null
|
||||
if (global.ServerSettings.storeCoverWithItem && libraryItemPath) {
|
||||
coverDirPath = libraryItemPath
|
||||
} else {
|
||||
coverDirPath = Path.posix.join(global.MetadataPath, 'items', libraryItemId)
|
||||
}
|
||||
await fs.ensureDir(coverDirPath)
|
||||
|
||||
const coverFilename = audioFileWithCover.embeddedCoverArt === 'png' ? 'cover.png' : 'cover.jpg'
|
||||
const coverFilePath = Path.join(coverDirPath, coverFilename)
|
||||
|
||||
const coverAlreadyExists = await fs.pathExists(coverFilePath)
|
||||
if (coverAlreadyExists) {
|
||||
Logger.warn(`[CoverManager] Extract embedded cover art but cover already exists for "${libraryItemPath}" - bail`)
|
||||
return null
|
||||
}
|
||||
|
||||
const success = await extractCoverArt(audioFileWithCover.metadata.path, coverFilePath)
|
||||
if (success) {
|
||||
return coverFilePath
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} url
|
||||
* @param {string} libraryItemId
|
||||
* @param {string} [libraryItemPath] null if library item isFile or is from adding new podcast
|
||||
* @returns {Promise<{error:string}|{cover:string}>}
|
||||
*/
|
||||
async downloadCoverFromUrlNew(url, libraryItemId, libraryItemPath) {
|
||||
try {
|
||||
let coverDirPath = null
|
||||
if (global.ServerSettings.storeCoverWithItem && libraryItemPath) {
|
||||
coverDirPath = libraryItemPath
|
||||
} else {
|
||||
coverDirPath = Path.posix.join(global.MetadataPath, 'items', libraryItemId)
|
||||
}
|
||||
|
||||
await fs.ensureDir(coverDirPath)
|
||||
|
||||
const temppath = Path.posix.join(coverDirPath, 'cover')
|
||||
const success = await downloadFile(url, temppath).then(() => true).catch((err) => {
|
||||
Logger.error(`[CoverManager] Download image file failed for "${url}"`, err)
|
||||
return false
|
||||
})
|
||||
if (!success) {
|
||||
return {
|
||||
error: 'Failed to download image from url'
|
||||
}
|
||||
}
|
||||
|
||||
const imgtype = await this.checkFileIsValidImage(temppath, true)
|
||||
if (imgtype.error) {
|
||||
return imgtype
|
||||
}
|
||||
|
||||
const coverFullPath = Path.posix.join(coverDirPath, `cover.${imgtype.ext}`)
|
||||
await fs.rename(temppath, coverFullPath)
|
||||
|
||||
await this.removeOldCovers(coverDirPath, '.' + imgtype.ext)
|
||||
await CacheManager.purgeCoverCache(libraryItemId)
|
||||
|
||||
Logger.info(`[CoverManager] Downloaded libraryItem cover "${coverFullPath}" from url "${url}"`)
|
||||
return {
|
||||
cover: coverFullPath
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`[CoverManager] Fetch cover image from url "${url}" failed`, error)
|
||||
return {
|
||||
error: 'Failed to fetch image from url'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = CoverManager
|
||||
module.exports = new CoverManager()
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
const Sequelize = require('sequelize')
|
||||
const cron = require('../libs/nodeCron')
|
||||
const Logger = require('../Logger')
|
||||
const Database = require('../Database')
|
||||
const LibraryScanner = require('../scanner/LibraryScanner')
|
||||
|
||||
class CronManager {
|
||||
constructor(scanner, podcastManager) {
|
||||
this.scanner = scanner
|
||||
constructor(podcastManager) {
|
||||
this.podcastManager = podcastManager
|
||||
|
||||
this.libraryScanCrons = []
|
||||
|
|
@ -17,9 +18,9 @@ class CronManager {
|
|||
* Initialize library scan crons & podcast download crons
|
||||
* @param {oldLibrary[]} libraries
|
||||
*/
|
||||
init(libraries) {
|
||||
async init(libraries) {
|
||||
this.initLibraryScanCrons(libraries)
|
||||
this.initPodcastCrons()
|
||||
await this.initPodcastCrons()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -38,7 +39,7 @@ class CronManager {
|
|||
Logger.debug(`[CronManager] Init library scan cron for ${library.name} on schedule ${library.settings.autoScanCronExpression}`)
|
||||
const libScanCron = cron.schedule(library.settings.autoScanCronExpression, () => {
|
||||
Logger.debug(`[CronManager] Library scan cron executing for ${library.name}`)
|
||||
this.scanner.scan(library)
|
||||
LibraryScanner.scan(library)
|
||||
})
|
||||
this.libraryScanCrons.push({
|
||||
libraryId: library.id,
|
||||
|
|
@ -70,23 +71,34 @@ class CronManager {
|
|||
}
|
||||
}
|
||||
|
||||
initPodcastCrons() {
|
||||
/**
|
||||
* Init cron jobs for auto-download podcasts
|
||||
*/
|
||||
async initPodcastCrons() {
|
||||
const cronExpressionMap = {}
|
||||
Database.libraryItems.forEach((li) => {
|
||||
if (li.mediaType === 'podcast' && li.media.autoDownloadEpisodes) {
|
||||
if (!li.media.autoDownloadSchedule) {
|
||||
Logger.error(`[CronManager] Podcast auto download schedule is not set for ${li.media.metadata.title}`)
|
||||
} else {
|
||||
if (!cronExpressionMap[li.media.autoDownloadSchedule]) {
|
||||
cronExpressionMap[li.media.autoDownloadSchedule] = {
|
||||
expression: li.media.autoDownloadSchedule,
|
||||
libraryItemIds: []
|
||||
}
|
||||
}
|
||||
cronExpressionMap[li.media.autoDownloadSchedule].libraryItemIds.push(li.id)
|
||||
|
||||
const podcastsWithAutoDownload = await Database.podcastModel.findAll({
|
||||
where: {
|
||||
autoDownloadEpisodes: true,
|
||||
autoDownloadSchedule: {
|
||||
[Sequelize.Op.not]: null
|
||||
}
|
||||
},
|
||||
include: {
|
||||
model: Database.libraryItemModel
|
||||
}
|
||||
})
|
||||
|
||||
for (const podcast of podcastsWithAutoDownload) {
|
||||
if (!cronExpressionMap[podcast.autoDownloadSchedule]) {
|
||||
cronExpressionMap[podcast.autoDownloadSchedule] = {
|
||||
expression: podcast.autoDownloadSchedule,
|
||||
libraryItemIds: []
|
||||
}
|
||||
}
|
||||
cronExpressionMap[podcast.autoDownloadSchedule].libraryItemIds.push(podcast.libraryItem.id)
|
||||
}
|
||||
|
||||
if (!Object.keys(cronExpressionMap).length) return
|
||||
|
||||
Logger.debug(`[CronManager] Found ${Object.keys(cronExpressionMap).length} podcast episode schedules to start`)
|
||||
|
|
@ -127,7 +139,7 @@ class CronManager {
|
|||
// Get podcast library items to check
|
||||
const libraryItems = []
|
||||
for (const libraryItemId of libraryItemIds) {
|
||||
const libraryItem = Database.libraryItems.find(li => li.id === libraryItemId)
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(libraryItemId)
|
||||
if (!libraryItem) {
|
||||
Logger.error(`[CronManager] Library item ${libraryItemId} not found for episode check cron ${expression}`)
|
||||
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter(lid => lid !== libraryItemId) // Filter it out
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
const Path = require('path')
|
||||
const fs = require('../libs/fsExtra')
|
||||
const filePerms = require('../utils/filePerms')
|
||||
|
||||
const DailyLog = require('../objects/DailyLog')
|
||||
|
||||
|
|
@ -25,13 +24,11 @@ class LogManager {
|
|||
async ensureLogDirs() {
|
||||
await fs.ensureDir(this.DailyLogPath)
|
||||
await fs.ensureDir(this.ScanLogPath)
|
||||
await filePerms.setDefault(Path.posix.join(global.MetadataPath, 'logs'), true)
|
||||
}
|
||||
|
||||
async ensureScanLogDir() {
|
||||
if (!(await fs.pathExists(this.ScanLogPath))) {
|
||||
await fs.mkdir(this.ScanLogPath)
|
||||
await filePerms.setDefault(this.ScanLogPath)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ class PlaybackSessionManager {
|
|||
}
|
||||
|
||||
async syncLocalSession(user, sessionJson, deviceInfo) {
|
||||
const libraryItem = Database.getLibraryItem(sessionJson.libraryItemId)
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(sessionJson.libraryItemId)
|
||||
const episode = (sessionJson.episodeId && libraryItem && libraryItem.isPodcast) ? libraryItem.media.getEpisode(sessionJson.episodeId) : null
|
||||
if (!libraryItem || (libraryItem.isPodcast && !episode)) {
|
||||
Logger.error(`[PlaybackSessionManager] syncLocalSession: Media item not found for session "${sessionJson.displayTitle}" (${sessionJson.id})`)
|
||||
|
|
@ -259,13 +259,13 @@ class PlaybackSessionManager {
|
|||
}
|
||||
|
||||
this.sessions.push(newPlaybackSession)
|
||||
SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions, Database.libraryItems))
|
||||
SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions))
|
||||
|
||||
return newPlaybackSession
|
||||
}
|
||||
|
||||
async syncSession(user, session, syncData) {
|
||||
const libraryItem = Database.libraryItems.find(li => li.id === session.libraryItemId)
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(session.libraryItemId)
|
||||
if (!libraryItem) {
|
||||
Logger.error(`[PlaybackSessionManager] syncSession Library Item not found "${session.libraryItemId}"`)
|
||||
return null
|
||||
|
|
@ -304,7 +304,7 @@ class PlaybackSessionManager {
|
|||
await this.saveSession(session)
|
||||
}
|
||||
Logger.debug(`[PlaybackSessionManager] closeSession "${session.id}"`)
|
||||
SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions, Database.libraryItems))
|
||||
SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions))
|
||||
SocketAuthority.clientEmitter(session.userId, 'user_session_closed', session.id)
|
||||
return this.removeSession(session.id)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ const fs = require('../libs/fsExtra')
|
|||
|
||||
const { getPodcastFeed } = require('../utils/podcastUtils')
|
||||
const { removeFile, downloadFile } = require('../utils/fileUtils')
|
||||
const filePerms = require('../utils/filePerms')
|
||||
const { levenshteinDistance } = require('../utils/index')
|
||||
const opmlParser = require('../utils/parsers/parseOPML')
|
||||
const opmlGenerator = require('../utils/generators/opmlGenerator')
|
||||
|
|
@ -96,7 +95,6 @@ class PodcastManager {
|
|||
if (!(await fs.pathExists(this.currentDownload.libraryItem.path))) {
|
||||
Logger.warn(`[PodcastManager] Podcast episode download: Podcast folder no longer exists at "${this.currentDownload.libraryItem.path}" - Creating it`)
|
||||
await fs.mkdir(this.currentDownload.libraryItem.path)
|
||||
await filePerms.setDefault(this.currentDownload.libraryItem.path)
|
||||
}
|
||||
|
||||
let success = false
|
||||
|
|
@ -150,7 +148,7 @@ class PodcastManager {
|
|||
return false
|
||||
}
|
||||
|
||||
const libraryItem = Database.libraryItems.find(li => li.id === this.currentDownload.libraryItem.id)
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(this.currentDownload.libraryItem.id)
|
||||
if (!libraryItem) {
|
||||
Logger.error(`[PodcastManager] Podcast Episode finished but library item was not found ${this.currentDownload.libraryItem.id}`)
|
||||
return false
|
||||
|
|
@ -372,8 +370,13 @@ class PodcastManager {
|
|||
}
|
||||
}
|
||||
|
||||
generateOPMLFileText(libraryItems) {
|
||||
return opmlGenerator.generate(libraryItems)
|
||||
/**
|
||||
* OPML file string for podcasts in a library
|
||||
* @param {import('../models/Podcast')[]} podcasts
|
||||
* @returns {string} XML string
|
||||
*/
|
||||
generateOPMLFileText(podcasts) {
|
||||
return opmlGenerator.generate(podcasts)
|
||||
}
|
||||
|
||||
getDownloadQueueDetails(libraryId = null) {
|
||||
|
|
|
|||
|
|
@ -6,27 +6,28 @@ const Database = require('../Database')
|
|||
|
||||
const fs = require('../libs/fsExtra')
|
||||
const Feed = require('../objects/Feed')
|
||||
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
|
||||
|
||||
class RssFeedManager {
|
||||
constructor() { }
|
||||
|
||||
async validateFeedEntity(feedObj) {
|
||||
if (feedObj.entityType === 'collection') {
|
||||
const collection = await Database.models.collection.getById(feedObj.entityId)
|
||||
const collection = await Database.collectionModel.getOldById(feedObj.entityId)
|
||||
if (!collection) {
|
||||
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Collection "${feedObj.entityId}" not found`)
|
||||
return false
|
||||
}
|
||||
} else if (feedObj.entityType === 'libraryItem') {
|
||||
if (!Database.libraryItems.some(li => li.id === feedObj.entityId)) {
|
||||
const libraryItemExists = await Database.libraryItemModel.checkExistsById(feedObj.entityId)
|
||||
if (!libraryItemExists) {
|
||||
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Library item "${feedObj.entityId}" not found`)
|
||||
return false
|
||||
}
|
||||
} else if (feedObj.entityType === 'series') {
|
||||
const series = Database.series.find(s => s.id === feedObj.entityId)
|
||||
const hasSeriesBook = series ? Database.libraryItems.some(li => li.mediaType === 'book' && li.media.metadata.hasSeries(series.id) && li.media.tracks.length) : false
|
||||
if (!hasSeriesBook) {
|
||||
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Series "${feedObj.entityId}" not found or has no audio tracks`)
|
||||
const series = await Database.seriesModel.getOldById(feedObj.entityId)
|
||||
if (!series) {
|
||||
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Series "${feedObj.entityId}" not found`)
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
|
|
@ -40,7 +41,7 @@ class RssFeedManager {
|
|||
* Validate all feeds and remove invalid
|
||||
*/
|
||||
async init() {
|
||||
const feeds = await Database.models.feed.getOldFeeds()
|
||||
const feeds = await Database.feedModel.getOldFeeds()
|
||||
for (const feed of feeds) {
|
||||
// Remove invalid feeds
|
||||
if (!await this.validateFeedEntity(feed)) {
|
||||
|
|
@ -51,29 +52,29 @@ class RssFeedManager {
|
|||
|
||||
/**
|
||||
* Find open feed for an entity (e.g. collection id, playlist id, library item id)
|
||||
* @param {string} entityId
|
||||
* @param {string} entityId
|
||||
* @returns {Promise<objects.Feed>} oldFeed
|
||||
*/
|
||||
findFeedForEntityId(entityId) {
|
||||
return Database.models.feed.findOneOld({ entityId })
|
||||
return Database.feedModel.findOneOld({ entityId })
|
||||
}
|
||||
|
||||
/**
|
||||
* Find open feed for a slug
|
||||
* @param {string} slug
|
||||
* @param {string} slug
|
||||
* @returns {Promise<objects.Feed>} oldFeed
|
||||
*/
|
||||
findFeedBySlug(slug) {
|
||||
return Database.models.feed.findOneOld({ slug })
|
||||
return Database.feedModel.findOneOld({ slug })
|
||||
}
|
||||
|
||||
/**
|
||||
* Find open feed for a slug
|
||||
* @param {string} slug
|
||||
* @param {string} slug
|
||||
* @returns {Promise<objects.Feed>} oldFeed
|
||||
*/
|
||||
findFeed(id) {
|
||||
return Database.models.feed.findByPkOld(id)
|
||||
return Database.feedModel.findByPkOld(id)
|
||||
}
|
||||
|
||||
async getFeed(req, res) {
|
||||
|
|
@ -86,7 +87,7 @@ class RssFeedManager {
|
|||
|
||||
// Check if feed needs to be updated
|
||||
if (feed.entityType === 'libraryItem') {
|
||||
const libraryItem = Database.getLibraryItem(feed.entityId)
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(feed.entityId)
|
||||
|
||||
let mostRecentlyUpdatedAt = libraryItem.updatedAt
|
||||
if (libraryItem.isPodcast) {
|
||||
|
|
@ -102,9 +103,9 @@ class RssFeedManager {
|
|||
await Database.updateFeed(feed)
|
||||
}
|
||||
} else if (feed.entityType === 'collection') {
|
||||
const collection = await Database.models.collection.getById(feed.entityId)
|
||||
const collection = await Database.collectionModel.findByPk(feed.entityId)
|
||||
if (collection) {
|
||||
const collectionExpanded = collection.toJSONExpanded(Database.libraryItems)
|
||||
const collectionExpanded = await collection.getOldJsonExpanded()
|
||||
|
||||
// Find most recently updated item in collection
|
||||
let mostRecentlyUpdatedAt = collectionExpanded.lastUpdate
|
||||
|
|
@ -122,11 +123,12 @@ class RssFeedManager {
|
|||
}
|
||||
}
|
||||
} else if (feed.entityType === 'series') {
|
||||
const series = Database.series.find(s => s.id === feed.entityId)
|
||||
const series = await Database.seriesModel.getOldById(feed.entityId)
|
||||
if (series) {
|
||||
const seriesJson = series.toJSON()
|
||||
|
||||
// Get books in series that have audio tracks
|
||||
seriesJson.books = Database.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(series.id) && li.media.tracks.length)
|
||||
seriesJson.books = (await libraryItemsBookFilters.getLibraryItemsForSeries(series)).filter(li => li.media.numTracks)
|
||||
|
||||
// Find most recently updated item in series
|
||||
let mostRecentlyUpdatedAt = seriesJson.updatedAt
|
||||
|
|
@ -260,5 +262,11 @@ class RssFeedManager {
|
|||
if (!feed) return
|
||||
return this.handleCloseFeed(feed)
|
||||
}
|
||||
|
||||
async getFeeds() {
|
||||
const feeds = await Database.models.feed.getOldFeeds()
|
||||
Logger.info(`[RssFeedManager] Fetched all feeds`)
|
||||
return feeds
|
||||
}
|
||||
}
|
||||
module.exports = RssFeedManager
|
||||
|
|
|
|||
|
|
@ -1,88 +1,171 @@
|
|||
const { DataTypes, Model } = require('sequelize')
|
||||
const { DataTypes, Model, literal } = require('sequelize')
|
||||
|
||||
const oldAuthor = require('../objects/entities/Author')
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
class Author extends Model {
|
||||
static async getOldAuthors() {
|
||||
const authors = await this.findAll()
|
||||
return authors.map(au => au.getOldAuthor())
|
||||
}
|
||||
class Author extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
getOldAuthor() {
|
||||
return new oldAuthor({
|
||||
id: this.id,
|
||||
asin: this.asin,
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
imagePath: this.imagePath,
|
||||
libraryId: this.libraryId,
|
||||
addedAt: this.createdAt.valueOf(),
|
||||
updatedAt: this.updatedAt.valueOf()
|
||||
})
|
||||
}
|
||||
/** @type {UUIDV4} */
|
||||
this.id
|
||||
/** @type {string} */
|
||||
this.name
|
||||
/** @type {string} */
|
||||
this.lastFirst
|
||||
/** @type {string} */
|
||||
this.asin
|
||||
/** @type {string} */
|
||||
this.description
|
||||
/** @type {string} */
|
||||
this.imagePath
|
||||
/** @type {UUIDV4} */
|
||||
this.libraryId
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
}
|
||||
|
||||
static updateFromOld(oldAuthor) {
|
||||
const author = this.getFromOld(oldAuthor)
|
||||
return this.update(author, {
|
||||
where: {
|
||||
id: author.id
|
||||
}
|
||||
})
|
||||
}
|
||||
static async getOldAuthors() {
|
||||
const authors = await this.findAll()
|
||||
return authors.map(au => au.getOldAuthor())
|
||||
}
|
||||
|
||||
static createFromOld(oldAuthor) {
|
||||
const author = this.getFromOld(oldAuthor)
|
||||
return this.create(author)
|
||||
}
|
||||
getOldAuthor() {
|
||||
return new oldAuthor({
|
||||
id: this.id,
|
||||
asin: this.asin,
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
imagePath: this.imagePath,
|
||||
libraryId: this.libraryId,
|
||||
addedAt: this.createdAt.valueOf(),
|
||||
updatedAt: this.updatedAt.valueOf()
|
||||
})
|
||||
}
|
||||
|
||||
static createBulkFromOld(oldAuthors) {
|
||||
const authors = oldAuthors.map(this.getFromOld)
|
||||
return this.bulkCreate(authors)
|
||||
}
|
||||
|
||||
static getFromOld(oldAuthor) {
|
||||
return {
|
||||
id: oldAuthor.id,
|
||||
name: oldAuthor.name,
|
||||
lastFirst: oldAuthor.lastFirst,
|
||||
asin: oldAuthor.asin,
|
||||
description: oldAuthor.description,
|
||||
imagePath: oldAuthor.imagePath,
|
||||
libraryId: oldAuthor.libraryId
|
||||
static updateFromOld(oldAuthor) {
|
||||
const author = this.getFromOld(oldAuthor)
|
||||
return this.update(author, {
|
||||
where: {
|
||||
id: author.id
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static removeById(authorId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
id: authorId
|
||||
}
|
||||
})
|
||||
static createFromOld(oldAuthor) {
|
||||
const author = this.getFromOld(oldAuthor)
|
||||
return this.create(author)
|
||||
}
|
||||
|
||||
static createBulkFromOld(oldAuthors) {
|
||||
const authors = oldAuthors.map(this.getFromOld)
|
||||
return this.bulkCreate(authors)
|
||||
}
|
||||
|
||||
static getFromOld(oldAuthor) {
|
||||
return {
|
||||
id: oldAuthor.id,
|
||||
name: oldAuthor.name,
|
||||
lastFirst: oldAuthor.lastFirst,
|
||||
asin: oldAuthor.asin,
|
||||
description: oldAuthor.description,
|
||||
imagePath: oldAuthor.imagePath,
|
||||
libraryId: oldAuthor.libraryId
|
||||
}
|
||||
}
|
||||
|
||||
Author.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
name: DataTypes.STRING,
|
||||
lastFirst: DataTypes.STRING,
|
||||
asin: DataTypes.STRING,
|
||||
description: DataTypes.TEXT,
|
||||
imagePath: DataTypes.STRING
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'author'
|
||||
})
|
||||
static removeById(authorId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
id: authorId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const { library } = sequelize.models
|
||||
library.hasMany(Author, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
Author.belongsTo(library)
|
||||
/**
|
||||
* Get oldAuthor by id
|
||||
* @param {string} authorId
|
||||
* @returns {Promise<oldAuthor>}
|
||||
*/
|
||||
static async getOldById(authorId) {
|
||||
const author = await this.findByPk(authorId)
|
||||
if (!author) return null
|
||||
return author.getOldAuthor()
|
||||
}
|
||||
|
||||
return Author
|
||||
}
|
||||
/**
|
||||
* Check if author exists
|
||||
* @param {string} authorId
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
static async checkExistsById(authorId) {
|
||||
return (await this.count({ where: { id: authorId } })) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Get old author by name and libraryId. name case insensitive
|
||||
* TODO: Look for authors ignoring punctuation
|
||||
*
|
||||
* @param {string} authorName
|
||||
* @param {string} libraryId
|
||||
* @returns {Promise<oldAuthor>}
|
||||
*/
|
||||
static async getOldByNameAndLibrary(authorName, libraryId) {
|
||||
const author = (await this.findOne({
|
||||
where: [
|
||||
literal(`name = '${authorName}' COLLATE NOCASE`),
|
||||
{
|
||||
libraryId
|
||||
}
|
||||
]
|
||||
}))?.getOldAuthor()
|
||||
return author
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize model
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
*/
|
||||
static init(sequelize) {
|
||||
super.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
name: DataTypes.STRING,
|
||||
lastFirst: DataTypes.STRING,
|
||||
asin: DataTypes.STRING,
|
||||
description: DataTypes.TEXT,
|
||||
imagePath: DataTypes.STRING
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'author',
|
||||
indexes: [
|
||||
{
|
||||
fields: [{
|
||||
name: 'name',
|
||||
collate: 'NOCASE'
|
||||
}]
|
||||
},
|
||||
// {
|
||||
// fields: [{
|
||||
// name: 'lastFirst',
|
||||
// collate: 'NOCASE'
|
||||
// }]
|
||||
// },
|
||||
{
|
||||
fields: ['libraryId']
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const { library } = sequelize.models
|
||||
library.hasMany(Author, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
Author.belongsTo(library)
|
||||
}
|
||||
}
|
||||
module.exports = Author
|
||||
|
|
|
|||
|
|
@ -1,178 +1,273 @@
|
|||
const { DataTypes, Model } = require('sequelize')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
class Book extends Model {
|
||||
static getOldBook(libraryItemExpanded) {
|
||||
const bookExpanded = libraryItemExpanded.media
|
||||
let authors = []
|
||||
if (bookExpanded.authors?.length) {
|
||||
authors = bookExpanded.authors.map(au => {
|
||||
return {
|
||||
id: au.id,
|
||||
name: au.name
|
||||
}
|
||||
})
|
||||
} else if (bookExpanded.bookAuthors?.length) {
|
||||
authors = bookExpanded.bookAuthors.map(ba => {
|
||||
if (ba.author) {
|
||||
return {
|
||||
id: ba.author.id,
|
||||
name: ba.author.name
|
||||
}
|
||||
} else {
|
||||
Logger.error(`[Book] Invalid bookExpanded bookAuthors: no author`, ba)
|
||||
return null
|
||||
}
|
||||
}).filter(a => a)
|
||||
}
|
||||
/**
|
||||
* @typedef EBookFileObject
|
||||
* @property {string} ino
|
||||
* @property {string} ebookFormat
|
||||
* @property {number} addedAt
|
||||
* @property {number} updatedAt
|
||||
* @property {{filename:string, ext:string, path:string, relPath:string, size:number, mtimeMs:number, ctimeMs:number, birthtimeMs:number}} metadata
|
||||
*/
|
||||
|
||||
let series = []
|
||||
if (bookExpanded.series?.length) {
|
||||
series = bookExpanded.series.map(se => {
|
||||
return {
|
||||
id: se.id,
|
||||
name: se.name,
|
||||
sequence: se.bookSeries.sequence
|
||||
}
|
||||
})
|
||||
} else if (bookExpanded.bookSeries?.length) {
|
||||
series = bookExpanded.bookSeries.map(bs => {
|
||||
if (bs.series) {
|
||||
return {
|
||||
id: bs.series.id,
|
||||
name: bs.series.name,
|
||||
sequence: bs.sequence
|
||||
}
|
||||
} else {
|
||||
Logger.error(`[Book] Invalid bookExpanded bookSeries: no series`, bs)
|
||||
return null
|
||||
}
|
||||
}).filter(s => s)
|
||||
}
|
||||
/**
|
||||
* @typedef ChapterObject
|
||||
* @property {number} id
|
||||
* @property {number} start
|
||||
* @property {number} end
|
||||
* @property {string} title
|
||||
*/
|
||||
|
||||
return {
|
||||
id: bookExpanded.id,
|
||||
libraryItemId: libraryItemExpanded.id,
|
||||
coverPath: bookExpanded.coverPath,
|
||||
tags: bookExpanded.tags,
|
||||
audioFiles: bookExpanded.audioFiles,
|
||||
chapters: bookExpanded.chapters,
|
||||
ebookFile: bookExpanded.ebookFile,
|
||||
metadata: {
|
||||
title: bookExpanded.title,
|
||||
subtitle: bookExpanded.subtitle,
|
||||
authors: authors,
|
||||
narrators: bookExpanded.narrators,
|
||||
series: series,
|
||||
genres: bookExpanded.genres,
|
||||
publishedYear: bookExpanded.publishedYear,
|
||||
publishedDate: bookExpanded.publishedDate,
|
||||
publisher: bookExpanded.publisher,
|
||||
description: bookExpanded.description,
|
||||
isbn: bookExpanded.isbn,
|
||||
asin: bookExpanded.asin,
|
||||
language: bookExpanded.language,
|
||||
explicit: bookExpanded.explicit,
|
||||
abridged: bookExpanded.abridged
|
||||
/**
|
||||
* @typedef AudioFileObject
|
||||
* @property {number} index
|
||||
* @property {string} ino
|
||||
* @property {{filename:string, ext:string, path:string, relPath:string, size:number, mtimeMs:number, ctimeMs:number, birthtimeMs:number}} metadata
|
||||
* @property {number} addedAt
|
||||
* @property {number} updatedAt
|
||||
* @property {number} trackNumFromMeta
|
||||
* @property {number} discNumFromMeta
|
||||
* @property {number} trackNumFromFilename
|
||||
* @property {number} discNumFromFilename
|
||||
* @property {boolean} manuallyVerified
|
||||
* @property {string} format
|
||||
* @property {number} duration
|
||||
* @property {number} bitRate
|
||||
* @property {string} language
|
||||
* @property {string} codec
|
||||
* @property {string} timeBase
|
||||
* @property {number} channels
|
||||
* @property {string} channelLayout
|
||||
* @property {ChapterObject[]} chapters
|
||||
* @property {Object} metaTags
|
||||
* @property {string} mimeType
|
||||
*/
|
||||
|
||||
class Book extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
/** @type {string} */
|
||||
this.id
|
||||
/** @type {string} */
|
||||
this.title
|
||||
/** @type {string} */
|
||||
this.titleIgnorePrefix
|
||||
/** @type {string} */
|
||||
this.publishedYear
|
||||
/** @type {string} */
|
||||
this.publishedDate
|
||||
/** @type {string} */
|
||||
this.publisher
|
||||
/** @type {string} */
|
||||
this.description
|
||||
/** @type {string} */
|
||||
this.isbn
|
||||
/** @type {string} */
|
||||
this.asin
|
||||
/** @type {string} */
|
||||
this.language
|
||||
/** @type {boolean} */
|
||||
this.explicit
|
||||
/** @type {boolean} */
|
||||
this.abridged
|
||||
/** @type {string} */
|
||||
this.coverPath
|
||||
/** @type {number} */
|
||||
this.duration
|
||||
/** @type {string[]} */
|
||||
this.narrators
|
||||
/** @type {AudioFileObject[]} */
|
||||
this.audioFiles
|
||||
/** @type {EBookFileObject} */
|
||||
this.ebookFile
|
||||
/** @type {ChapterObject[]} */
|
||||
this.chapters
|
||||
/** @type {string[]} */
|
||||
this.tags
|
||||
/** @type {string[]} */
|
||||
this.genres
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
}
|
||||
|
||||
static getOldBook(libraryItemExpanded) {
|
||||
const bookExpanded = libraryItemExpanded.media
|
||||
let authors = []
|
||||
if (bookExpanded.authors?.length) {
|
||||
authors = bookExpanded.authors.map(au => {
|
||||
return {
|
||||
id: au.id,
|
||||
name: au.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} oldBook
|
||||
* @returns {boolean} true if updated
|
||||
*/
|
||||
static saveFromOld(oldBook) {
|
||||
const book = this.getFromOld(oldBook)
|
||||
return this.update(book, {
|
||||
where: {
|
||||
id: book.id
|
||||
}
|
||||
}).then(result => result[0] > 0).catch((error) => {
|
||||
Logger.error(`[Book] Failed to save book ${book.id}`, error)
|
||||
return false
|
||||
})
|
||||
} else if (bookExpanded.bookAuthors?.length) {
|
||||
authors = bookExpanded.bookAuthors.map(ba => {
|
||||
if (ba.author) {
|
||||
return {
|
||||
id: ba.author.id,
|
||||
name: ba.author.name
|
||||
}
|
||||
} else {
|
||||
Logger.error(`[Book] Invalid bookExpanded bookAuthors: no author`, ba)
|
||||
return null
|
||||
}
|
||||
}).filter(a => a)
|
||||
}
|
||||
|
||||
static getFromOld(oldBook) {
|
||||
return {
|
||||
id: oldBook.id,
|
||||
title: oldBook.metadata.title,
|
||||
titleIgnorePrefix: oldBook.metadata.titleIgnorePrefix,
|
||||
subtitle: oldBook.metadata.subtitle,
|
||||
publishedYear: oldBook.metadata.publishedYear,
|
||||
publishedDate: oldBook.metadata.publishedDate,
|
||||
publisher: oldBook.metadata.publisher,
|
||||
description: oldBook.metadata.description,
|
||||
isbn: oldBook.metadata.isbn,
|
||||
asin: oldBook.metadata.asin,
|
||||
language: oldBook.metadata.language,
|
||||
explicit: !!oldBook.metadata.explicit,
|
||||
abridged: !!oldBook.metadata.abridged,
|
||||
narrators: oldBook.metadata.narrators,
|
||||
ebookFile: oldBook.ebookFile?.toJSON() || null,
|
||||
coverPath: oldBook.coverPath,
|
||||
duration: oldBook.duration,
|
||||
audioFiles: oldBook.audioFiles?.map(af => af.toJSON()) || [],
|
||||
chapters: oldBook.chapters,
|
||||
tags: oldBook.tags,
|
||||
genres: oldBook.metadata.genres
|
||||
let series = []
|
||||
if (bookExpanded.series?.length) {
|
||||
series = bookExpanded.series.map(se => {
|
||||
return {
|
||||
id: se.id,
|
||||
name: se.name,
|
||||
sequence: se.bookSeries.sequence
|
||||
}
|
||||
})
|
||||
} else if (bookExpanded.bookSeries?.length) {
|
||||
series = bookExpanded.bookSeries.map(bs => {
|
||||
if (bs.series) {
|
||||
return {
|
||||
id: bs.series.id,
|
||||
name: bs.series.name,
|
||||
sequence: bs.sequence
|
||||
}
|
||||
} else {
|
||||
Logger.error(`[Book] Invalid bookExpanded bookSeries: no series`, bs)
|
||||
return null
|
||||
}
|
||||
}).filter(s => s)
|
||||
}
|
||||
|
||||
return {
|
||||
id: bookExpanded.id,
|
||||
libraryItemId: libraryItemExpanded.id,
|
||||
coverPath: bookExpanded.coverPath,
|
||||
tags: bookExpanded.tags,
|
||||
audioFiles: bookExpanded.audioFiles,
|
||||
chapters: bookExpanded.chapters,
|
||||
ebookFile: bookExpanded.ebookFile,
|
||||
metadata: {
|
||||
title: bookExpanded.title,
|
||||
subtitle: bookExpanded.subtitle,
|
||||
authors: authors,
|
||||
narrators: bookExpanded.narrators,
|
||||
series: series,
|
||||
genres: bookExpanded.genres,
|
||||
publishedYear: bookExpanded.publishedYear,
|
||||
publishedDate: bookExpanded.publishedDate,
|
||||
publisher: bookExpanded.publisher,
|
||||
description: bookExpanded.description,
|
||||
isbn: bookExpanded.isbn,
|
||||
asin: bookExpanded.asin,
|
||||
language: bookExpanded.language,
|
||||
explicit: bookExpanded.explicit,
|
||||
abridged: bookExpanded.abridged
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Book.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
title: DataTypes.STRING,
|
||||
titleIgnorePrefix: DataTypes.STRING,
|
||||
subtitle: DataTypes.STRING,
|
||||
publishedYear: DataTypes.STRING,
|
||||
publishedDate: DataTypes.STRING,
|
||||
publisher: DataTypes.STRING,
|
||||
description: DataTypes.TEXT,
|
||||
isbn: DataTypes.STRING,
|
||||
asin: DataTypes.STRING,
|
||||
language: DataTypes.STRING,
|
||||
explicit: DataTypes.BOOLEAN,
|
||||
abridged: DataTypes.BOOLEAN,
|
||||
coverPath: DataTypes.STRING,
|
||||
duration: DataTypes.FLOAT,
|
||||
|
||||
narrators: DataTypes.JSON,
|
||||
audioFiles: DataTypes.JSON,
|
||||
ebookFile: DataTypes.JSON,
|
||||
chapters: DataTypes.JSON,
|
||||
tags: DataTypes.JSON,
|
||||
genres: DataTypes.JSON
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'book',
|
||||
indexes: [
|
||||
{
|
||||
fields: [{
|
||||
name: 'title',
|
||||
collate: 'NOCASE'
|
||||
}]
|
||||
},
|
||||
{
|
||||
fields: [{
|
||||
name: 'titleIgnorePrefix',
|
||||
collate: 'NOCASE'
|
||||
}]
|
||||
},
|
||||
{
|
||||
fields: ['publishedYear']
|
||||
},
|
||||
{
|
||||
fields: ['duration']
|
||||
/**
|
||||
* @param {object} oldBook
|
||||
* @returns {boolean} true if updated
|
||||
*/
|
||||
static saveFromOld(oldBook) {
|
||||
const book = this.getFromOld(oldBook)
|
||||
return this.update(book, {
|
||||
where: {
|
||||
id: book.id
|
||||
}
|
||||
]
|
||||
})
|
||||
}).then(result => result[0] > 0).catch((error) => {
|
||||
Logger.error(`[Book] Failed to save book ${book.id}`, error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
return Book
|
||||
}
|
||||
static getFromOld(oldBook) {
|
||||
return {
|
||||
id: oldBook.id,
|
||||
title: oldBook.metadata.title,
|
||||
titleIgnorePrefix: oldBook.metadata.titleIgnorePrefix,
|
||||
subtitle: oldBook.metadata.subtitle,
|
||||
publishedYear: oldBook.metadata.publishedYear,
|
||||
publishedDate: oldBook.metadata.publishedDate,
|
||||
publisher: oldBook.metadata.publisher,
|
||||
description: oldBook.metadata.description,
|
||||
isbn: oldBook.metadata.isbn,
|
||||
asin: oldBook.metadata.asin,
|
||||
language: oldBook.metadata.language,
|
||||
explicit: !!oldBook.metadata.explicit,
|
||||
abridged: !!oldBook.metadata.abridged,
|
||||
narrators: oldBook.metadata.narrators,
|
||||
ebookFile: oldBook.ebookFile?.toJSON() || null,
|
||||
coverPath: oldBook.coverPath,
|
||||
duration: oldBook.duration,
|
||||
audioFiles: oldBook.audioFiles?.map(af => af.toJSON()) || [],
|
||||
chapters: oldBook.chapters,
|
||||
tags: oldBook.tags,
|
||||
genres: oldBook.metadata.genres
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize model
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
*/
|
||||
static init(sequelize) {
|
||||
super.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
title: DataTypes.STRING,
|
||||
titleIgnorePrefix: DataTypes.STRING,
|
||||
subtitle: DataTypes.STRING,
|
||||
publishedYear: DataTypes.STRING,
|
||||
publishedDate: DataTypes.STRING,
|
||||
publisher: DataTypes.STRING,
|
||||
description: DataTypes.TEXT,
|
||||
isbn: DataTypes.STRING,
|
||||
asin: DataTypes.STRING,
|
||||
language: DataTypes.STRING,
|
||||
explicit: DataTypes.BOOLEAN,
|
||||
abridged: DataTypes.BOOLEAN,
|
||||
coverPath: DataTypes.STRING,
|
||||
duration: DataTypes.FLOAT,
|
||||
|
||||
narrators: DataTypes.JSON,
|
||||
audioFiles: DataTypes.JSON,
|
||||
ebookFile: DataTypes.JSON,
|
||||
chapters: DataTypes.JSON,
|
||||
tags: DataTypes.JSON,
|
||||
genres: DataTypes.JSON
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'book',
|
||||
indexes: [
|
||||
{
|
||||
fields: [{
|
||||
name: 'title',
|
||||
collate: 'NOCASE'
|
||||
}]
|
||||
},
|
||||
// {
|
||||
// fields: [{
|
||||
// name: 'titleIgnorePrefix',
|
||||
// collate: 'NOCASE'
|
||||
// }]
|
||||
// },
|
||||
{
|
||||
fields: ['publishedYear']
|
||||
},
|
||||
// {
|
||||
// fields: ['duration']
|
||||
// }
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Book
|
||||
|
|
@ -1,41 +1,57 @@
|
|||
const { DataTypes, Model } = require('sequelize')
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
class BookAuthor extends Model {
|
||||
static removeByIds(authorId = null, bookId = null) {
|
||||
const where = {}
|
||||
if (authorId) where.authorId = authorId
|
||||
if (bookId) where.bookId = bookId
|
||||
return this.destroy({
|
||||
where
|
||||
})
|
||||
}
|
||||
class BookAuthor extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
/** @type {UUIDV4} */
|
||||
this.id
|
||||
/** @type {UUIDV4} */
|
||||
this.bookId
|
||||
/** @type {UUIDV4} */
|
||||
this.authorId
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
}
|
||||
|
||||
BookAuthor.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
}
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'bookAuthor',
|
||||
timestamps: true,
|
||||
updatedAt: false
|
||||
})
|
||||
static removeByIds(authorId = null, bookId = null) {
|
||||
const where = {}
|
||||
if (authorId) where.authorId = authorId
|
||||
if (bookId) where.bookId = bookId
|
||||
return this.destroy({
|
||||
where
|
||||
})
|
||||
}
|
||||
|
||||
// Super Many-to-Many
|
||||
// ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship
|
||||
const { book, author } = sequelize.models
|
||||
book.belongsToMany(author, { through: BookAuthor })
|
||||
author.belongsToMany(book, { through: BookAuthor })
|
||||
/**
|
||||
* Initialize model
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
*/
|
||||
static init(sequelize) {
|
||||
super.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
}
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'bookAuthor',
|
||||
timestamps: true,
|
||||
updatedAt: false
|
||||
})
|
||||
|
||||
book.hasMany(BookAuthor)
|
||||
BookAuthor.belongsTo(book)
|
||||
// Super Many-to-Many
|
||||
// ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship
|
||||
const { book, author } = sequelize.models
|
||||
book.belongsToMany(author, { through: BookAuthor })
|
||||
author.belongsToMany(book, { through: BookAuthor })
|
||||
|
||||
author.hasMany(BookAuthor)
|
||||
BookAuthor.belongsTo(author)
|
||||
book.hasMany(BookAuthor)
|
||||
BookAuthor.belongsTo(book)
|
||||
|
||||
return BookAuthor
|
||||
}
|
||||
author.hasMany(BookAuthor)
|
||||
BookAuthor.belongsTo(author)
|
||||
}
|
||||
}
|
||||
module.exports = BookAuthor
|
||||
|
|
@ -1,42 +1,65 @@
|
|||
const { DataTypes, Model } = require('sequelize')
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
class BookSeries extends Model {
|
||||
static removeByIds(seriesId = null, bookId = null) {
|
||||
const where = {}
|
||||
if (seriesId) where.seriesId = seriesId
|
||||
if (bookId) where.bookId = bookId
|
||||
return this.destroy({
|
||||
where
|
||||
})
|
||||
}
|
||||
class BookSeries extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
/** @type {UUIDV4} */
|
||||
this.id
|
||||
/** @type {string} */
|
||||
this.sequence
|
||||
/** @type {UUIDV4} */
|
||||
this.bookId
|
||||
/** @type {UUIDV4} */
|
||||
this.seriesId
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
}
|
||||
|
||||
BookSeries.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
sequence: DataTypes.STRING
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'bookSeries',
|
||||
timestamps: true,
|
||||
updatedAt: false
|
||||
})
|
||||
static removeByIds(seriesId = null, bookId = null) {
|
||||
const where = {}
|
||||
if (seriesId) where.seriesId = seriesId
|
||||
if (bookId) where.bookId = bookId
|
||||
return this.destroy({
|
||||
where
|
||||
})
|
||||
}
|
||||
|
||||
// Super Many-to-Many
|
||||
// ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship
|
||||
const { book, series } = sequelize.models
|
||||
book.belongsToMany(series, { through: BookSeries })
|
||||
series.belongsToMany(book, { through: BookSeries })
|
||||
/**
|
||||
* Initialize model
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
*/
|
||||
static init(sequelize) {
|
||||
super.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
sequence: DataTypes.STRING
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'bookSeries',
|
||||
timestamps: true,
|
||||
updatedAt: false
|
||||
})
|
||||
|
||||
book.hasMany(BookSeries)
|
||||
BookSeries.belongsTo(book)
|
||||
// Super Many-to-Many
|
||||
// ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship
|
||||
const { book, series } = sequelize.models
|
||||
book.belongsToMany(series, { through: BookSeries })
|
||||
series.belongsToMany(book, { through: BookSeries })
|
||||
|
||||
series.hasMany(BookSeries)
|
||||
BookSeries.belongsTo(series)
|
||||
book.hasMany(BookSeries, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
BookSeries.belongsTo(book)
|
||||
|
||||
return BookSeries
|
||||
}
|
||||
series.hasMany(BookSeries, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
BookSeries.belongsTo(series)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BookSeries
|
||||
|
|
@ -1,284 +1,342 @@
|
|||
const { DataTypes, Model } = require('sequelize')
|
||||
const { DataTypes, Model, Sequelize } = require('sequelize')
|
||||
|
||||
const oldCollection = require('../objects/Collection')
|
||||
const { areEquivalent } = require('../utils/index')
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
class Collection extends Model {
|
||||
/**
|
||||
* Get all old collections
|
||||
* @returns {Promise<oldCollection[]>}
|
||||
*/
|
||||
static async getOldCollections() {
|
||||
const collections = await this.findAll({
|
||||
include: {
|
||||
model: sequelize.models.book,
|
||||
include: sequelize.models.libraryItem
|
||||
},
|
||||
order: [[sequelize.models.book, sequelize.models.collectionBook, 'order', 'ASC']]
|
||||
})
|
||||
return collections.map(c => this.getOldCollection(c))
|
||||
|
||||
class Collection extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
/** @type {UUIDV4} */
|
||||
this.id
|
||||
/** @type {string} */
|
||||
this.name
|
||||
/** @type {string} */
|
||||
this.description
|
||||
/** @type {UUIDV4} */
|
||||
this.libraryId
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
}
|
||||
/**
|
||||
* Get all old collections
|
||||
* @returns {Promise<oldCollection[]>}
|
||||
*/
|
||||
static async getOldCollections() {
|
||||
const collections = await this.findAll({
|
||||
include: {
|
||||
model: this.sequelize.models.book,
|
||||
include: this.sequelize.models.libraryItem
|
||||
},
|
||||
order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']]
|
||||
})
|
||||
return collections.map(c => this.getOldCollection(c))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all old collections toJSONExpanded, items filtered for user permissions
|
||||
* @param {[oldUser]} user
|
||||
* @param {[string]} libraryId
|
||||
* @param {[string[]]} include
|
||||
* @returns {Promise<object[]>} oldCollection.toJSONExpanded
|
||||
*/
|
||||
static async getOldCollectionsJsonExpanded(user, libraryId, include) {
|
||||
let collectionWhere = null
|
||||
if (libraryId) {
|
||||
collectionWhere = {
|
||||
libraryId
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all old collections toJSONExpanded, items filtered for user permissions
|
||||
* @param {[oldUser]} user
|
||||
* @param {[string]} libraryId
|
||||
* @param {[string[]]} include
|
||||
* @returns {Promise<object[]>} oldCollection.toJSONExpanded
|
||||
*/
|
||||
static async getOldCollectionsJsonExpanded(user, libraryId, include) {
|
||||
let collectionWhere = null
|
||||
if (libraryId) {
|
||||
collectionWhere = {
|
||||
libraryId
|
||||
}
|
||||
}
|
||||
|
||||
// Optionally include rssfeed for collection
|
||||
const collectionIncludes = []
|
||||
if (include.includes('rssfeed')) {
|
||||
collectionIncludes.push({
|
||||
model: sequelize.models.feed
|
||||
})
|
||||
}
|
||||
|
||||
const collections = await this.findAll({
|
||||
where: collectionWhere,
|
||||
include: [
|
||||
{
|
||||
model: sequelize.models.book,
|
||||
include: [
|
||||
{
|
||||
model: sequelize.models.libraryItem
|
||||
},
|
||||
{
|
||||
model: sequelize.models.author,
|
||||
through: {
|
||||
attributes: []
|
||||
}
|
||||
},
|
||||
{
|
||||
model: sequelize.models.series,
|
||||
through: {
|
||||
attributes: ['sequence']
|
||||
}
|
||||
},
|
||||
|
||||
]
|
||||
},
|
||||
...collectionIncludes
|
||||
],
|
||||
order: [[sequelize.models.book, sequelize.models.collectionBook, 'order', 'ASC']]
|
||||
// Optionally include rssfeed for collection
|
||||
const collectionIncludes = []
|
||||
if (include.includes('rssfeed')) {
|
||||
collectionIncludes.push({
|
||||
model: this.sequelize.models.feed
|
||||
})
|
||||
// TODO: Handle user permission restrictions on initial query
|
||||
return collections.map(c => {
|
||||
const oldCollection = this.getOldCollection(c)
|
||||
}
|
||||
|
||||
// Filter books using user permissions
|
||||
const books = c.books?.filter(b => {
|
||||
if (user) {
|
||||
if (b.tags?.length && !user.checkCanAccessLibraryItemWithTags(b.tags)) {
|
||||
return false
|
||||
}
|
||||
if (b.explicit === true && !user.canAccessExplicitContent) {
|
||||
return false
|
||||
}
|
||||
const collections = await this.findAll({
|
||||
where: collectionWhere,
|
||||
include: [
|
||||
{
|
||||
model: this.sequelize.models.book,
|
||||
include: [
|
||||
{
|
||||
model: this.sequelize.models.libraryItem
|
||||
},
|
||||
{
|
||||
model: this.sequelize.models.author,
|
||||
through: {
|
||||
attributes: []
|
||||
}
|
||||
},
|
||||
{
|
||||
model: this.sequelize.models.series,
|
||||
through: {
|
||||
attributes: ['sequence']
|
||||
}
|
||||
},
|
||||
|
||||
]
|
||||
},
|
||||
...collectionIncludes
|
||||
],
|
||||
order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']]
|
||||
})
|
||||
// TODO: Handle user permission restrictions on initial query
|
||||
return collections.map(c => {
|
||||
const oldCollection = this.getOldCollection(c)
|
||||
|
||||
// Filter books using user permissions
|
||||
const books = c.books?.filter(b => {
|
||||
if (user) {
|
||||
if (b.tags?.length && !user.checkCanAccessLibraryItemWithTags(b.tags)) {
|
||||
return false
|
||||
}
|
||||
if (b.explicit === true && !user.canAccessExplicitContent) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}) || []
|
||||
|
||||
// Map to library items
|
||||
const libraryItems = books.map(b => {
|
||||
const libraryItem = b.libraryItem
|
||||
delete b.libraryItem
|
||||
libraryItem.media = b
|
||||
return sequelize.models.libraryItem.getOldLibraryItem(libraryItem)
|
||||
})
|
||||
|
||||
// Users with restricted permissions will not see this collection
|
||||
if (!books.length && oldCollection.books.length) {
|
||||
return null
|
||||
}
|
||||
return true
|
||||
}) || []
|
||||
|
||||
const collectionExpanded = oldCollection.toJSONExpanded(libraryItems)
|
||||
|
||||
// Map feed if found
|
||||
if (c.feeds?.length) {
|
||||
collectionExpanded.rssFeed = sequelize.models.feed.getOldFeed(c.feeds[0])
|
||||
}
|
||||
|
||||
return collectionExpanded
|
||||
}).filter(c => c)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get old collection from Collection
|
||||
* @param {Collection} collectionExpanded
|
||||
* @returns {oldCollection}
|
||||
*/
|
||||
static getOldCollection(collectionExpanded) {
|
||||
const libraryItemIds = collectionExpanded.books?.map(b => b.libraryItem?.id || null).filter(lid => lid) || []
|
||||
return new oldCollection({
|
||||
id: collectionExpanded.id,
|
||||
libraryId: collectionExpanded.libraryId,
|
||||
name: collectionExpanded.name,
|
||||
description: collectionExpanded.description,
|
||||
books: libraryItemIds,
|
||||
lastUpdate: collectionExpanded.updatedAt.valueOf(),
|
||||
createdAt: collectionExpanded.createdAt.valueOf()
|
||||
// Map to library items
|
||||
const libraryItems = books.map(b => {
|
||||
const libraryItem = b.libraryItem
|
||||
delete b.libraryItem
|
||||
libraryItem.media = b
|
||||
return this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem)
|
||||
})
|
||||
}
|
||||
|
||||
static createFromOld(oldCollection) {
|
||||
const collection = this.getFromOld(oldCollection)
|
||||
return this.create(collection)
|
||||
}
|
||||
|
||||
static async fullUpdateFromOld(oldCollection, collectionBooks) {
|
||||
const existingCollection = await this.findByPk(oldCollection.id, {
|
||||
include: sequelize.models.collectionBook
|
||||
})
|
||||
if (!existingCollection) return false
|
||||
|
||||
let hasUpdates = false
|
||||
const collection = this.getFromOld(oldCollection)
|
||||
|
||||
for (const cb of collectionBooks) {
|
||||
const existingCb = existingCollection.collectionBooks.find(i => i.bookId === cb.bookId)
|
||||
if (!existingCb) {
|
||||
await sequelize.models.collectionBook.create(cb)
|
||||
hasUpdates = true
|
||||
} else if (existingCb.order != cb.order) {
|
||||
await existingCb.update({ order: cb.order })
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
for (const cb of existingCollection.collectionBooks) {
|
||||
// collectionBook was removed
|
||||
if (!collectionBooks.some(i => i.bookId === cb.bookId)) {
|
||||
await cb.destroy()
|
||||
hasUpdates = true
|
||||
}
|
||||
// Users with restricted permissions will not see this collection
|
||||
if (!books.length && oldCollection.books.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
let hasCollectionUpdates = false
|
||||
for (const key in collection) {
|
||||
let existingValue = existingCollection[key]
|
||||
if (existingValue instanceof Date) existingValue = existingValue.valueOf()
|
||||
if (!areEquivalent(collection[key], existingValue)) {
|
||||
hasCollectionUpdates = true
|
||||
}
|
||||
}
|
||||
if (hasCollectionUpdates) {
|
||||
existingCollection.update(collection)
|
||||
hasUpdates = true
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
const collectionExpanded = oldCollection.toJSONExpanded(libraryItems)
|
||||
|
||||
static getFromOld(oldCollection) {
|
||||
return {
|
||||
id: oldCollection.id,
|
||||
name: oldCollection.name,
|
||||
description: oldCollection.description,
|
||||
libraryId: oldCollection.libraryId
|
||||
// Map feed if found
|
||||
if (c.feeds?.length) {
|
||||
collectionExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(c.feeds[0])
|
||||
}
|
||||
}
|
||||
|
||||
static removeById(collectionId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
id: collectionId
|
||||
}
|
||||
})
|
||||
}
|
||||
return collectionExpanded
|
||||
}).filter(c => c)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get collection by id
|
||||
* @param {string} collectionId
|
||||
* @returns {Promise<oldCollection|null>} returns null if not found
|
||||
*/
|
||||
static async getById(collectionId) {
|
||||
if (!collectionId) return null
|
||||
const collection = await this.findByPk(collectionId, {
|
||||
include: {
|
||||
model: sequelize.models.book,
|
||||
include: sequelize.models.libraryItem
|
||||
/**
|
||||
* Get old collection toJSONExpanded, items filtered for user permissions
|
||||
* @param {[oldUser]} user
|
||||
* @param {[string[]]} include
|
||||
* @returns {Promise<object>} oldCollection.toJSONExpanded
|
||||
*/
|
||||
async getOldJsonExpanded(user, include) {
|
||||
this.books = await this.getBooks({
|
||||
include: [
|
||||
{
|
||||
model: this.sequelize.models.libraryItem
|
||||
},
|
||||
{
|
||||
model: this.sequelize.models.author,
|
||||
through: {
|
||||
attributes: []
|
||||
}
|
||||
},
|
||||
{
|
||||
model: this.sequelize.models.series,
|
||||
through: {
|
||||
attributes: ['sequence']
|
||||
}
|
||||
},
|
||||
order: [[sequelize.models.book, sequelize.models.collectionBook, 'order', 'ASC']]
|
||||
})
|
||||
if (!collection) return null
|
||||
return this.getOldCollection(collection)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all collections belonging to library
|
||||
* @param {string} libraryId
|
||||
* @returns {Promise<number>} number of collections destroyed
|
||||
*/
|
||||
static async removeAllForLibrary(libraryId) {
|
||||
if (!libraryId) return 0
|
||||
return this.destroy({
|
||||
where: {
|
||||
libraryId
|
||||
],
|
||||
order: [Sequelize.literal('`collectionBook.order` ASC')]
|
||||
}) || []
|
||||
|
||||
const oldCollection = this.sequelize.models.collection.getOldCollection(this)
|
||||
|
||||
// Filter books using user permissions
|
||||
// TODO: Handle user permission restrictions on initial query
|
||||
const books = this.books?.filter(b => {
|
||||
if (user) {
|
||||
if (b.tags?.length && !user.checkCanAccessLibraryItemWithTags(b.tags)) {
|
||||
return false
|
||||
}
|
||||
})
|
||||
if (b.explicit === true && !user.canAccessExplicitContent) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}) || []
|
||||
|
||||
// Map to library items
|
||||
const libraryItems = books.map(b => {
|
||||
const libraryItem = b.libraryItem
|
||||
delete b.libraryItem
|
||||
libraryItem.media = b
|
||||
return this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem)
|
||||
})
|
||||
|
||||
// Users with restricted permissions will not see this collection
|
||||
if (!books.length && oldCollection.books.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all collections for a library
|
||||
* @param {string} libraryId
|
||||
* @returns {Promise<oldCollection[]>}
|
||||
*/
|
||||
static async getAllForLibrary(libraryId) {
|
||||
if (!libraryId) return []
|
||||
const collections = await this.findAll({
|
||||
where: {
|
||||
libraryId
|
||||
},
|
||||
include: {
|
||||
model: sequelize.models.book,
|
||||
include: sequelize.models.libraryItem
|
||||
},
|
||||
order: [[sequelize.models.book, sequelize.models.collectionBook, 'order', 'ASC']]
|
||||
})
|
||||
return collections.map(c => this.getOldCollection(c))
|
||||
const collectionExpanded = oldCollection.toJSONExpanded(libraryItems)
|
||||
|
||||
if (include?.includes('rssfeed')) {
|
||||
const feeds = await this.getFeeds()
|
||||
if (feeds?.length) {
|
||||
collectionExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(feeds[0])
|
||||
}
|
||||
}
|
||||
|
||||
static async getAllForBook(bookId) {
|
||||
const collections = await this.findAll({
|
||||
include: {
|
||||
model: sequelize.models.book,
|
||||
where: {
|
||||
id: bookId
|
||||
},
|
||||
required: true,
|
||||
include: sequelize.models.libraryItem
|
||||
},
|
||||
order: [[sequelize.models.book, sequelize.models.collectionBook, 'order', 'ASC']]
|
||||
})
|
||||
return collections.map(c => this.getOldCollection(c))
|
||||
return collectionExpanded
|
||||
}
|
||||
|
||||
/**
|
||||
* Get old collection from Collection
|
||||
* @param {Collection} collectionExpanded
|
||||
* @returns {oldCollection}
|
||||
*/
|
||||
static getOldCollection(collectionExpanded) {
|
||||
const libraryItemIds = collectionExpanded.books?.map(b => b.libraryItem?.id || null).filter(lid => lid) || []
|
||||
return new oldCollection({
|
||||
id: collectionExpanded.id,
|
||||
libraryId: collectionExpanded.libraryId,
|
||||
name: collectionExpanded.name,
|
||||
description: collectionExpanded.description,
|
||||
books: libraryItemIds,
|
||||
lastUpdate: collectionExpanded.updatedAt.valueOf(),
|
||||
createdAt: collectionExpanded.createdAt.valueOf()
|
||||
})
|
||||
}
|
||||
|
||||
static createFromOld(oldCollection) {
|
||||
const collection = this.getFromOld(oldCollection)
|
||||
return this.create(collection)
|
||||
}
|
||||
|
||||
static getFromOld(oldCollection) {
|
||||
return {
|
||||
id: oldCollection.id,
|
||||
name: oldCollection.name,
|
||||
description: oldCollection.description,
|
||||
libraryId: oldCollection.libraryId
|
||||
}
|
||||
}
|
||||
|
||||
Collection.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
name: DataTypes.STRING,
|
||||
description: DataTypes.TEXT
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'collection'
|
||||
})
|
||||
static removeById(collectionId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
id: collectionId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const { library } = sequelize.models
|
||||
/**
|
||||
* Get old collection by id
|
||||
* @param {string} collectionId
|
||||
* @returns {Promise<oldCollection|null>} returns null if not found
|
||||
*/
|
||||
static async getOldById(collectionId) {
|
||||
if (!collectionId) return null
|
||||
const collection = await this.findByPk(collectionId, {
|
||||
include: {
|
||||
model: this.sequelize.models.book,
|
||||
include: this.sequelize.models.libraryItem
|
||||
},
|
||||
order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']]
|
||||
})
|
||||
if (!collection) return null
|
||||
return this.getOldCollection(collection)
|
||||
}
|
||||
|
||||
library.hasMany(Collection)
|
||||
Collection.belongsTo(library)
|
||||
/**
|
||||
* Get old collection from current
|
||||
* @returns {Promise<oldCollection>}
|
||||
*/
|
||||
async getOld() {
|
||||
this.books = await this.getBooks({
|
||||
include: [
|
||||
{
|
||||
model: this.sequelize.models.libraryItem
|
||||
},
|
||||
{
|
||||
model: this.sequelize.models.author,
|
||||
through: {
|
||||
attributes: []
|
||||
}
|
||||
},
|
||||
{
|
||||
model: this.sequelize.models.series,
|
||||
through: {
|
||||
attributes: ['sequence']
|
||||
}
|
||||
},
|
||||
|
||||
return Collection
|
||||
}
|
||||
],
|
||||
order: [Sequelize.literal('`collectionBook.order` ASC')]
|
||||
}) || []
|
||||
|
||||
return this.sequelize.models.collection.getOldCollection(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all collections belonging to library
|
||||
* @param {string} libraryId
|
||||
* @returns {Promise<number>} number of collections destroyed
|
||||
*/
|
||||
static async removeAllForLibrary(libraryId) {
|
||||
if (!libraryId) return 0
|
||||
return this.destroy({
|
||||
where: {
|
||||
libraryId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static async getAllForBook(bookId) {
|
||||
const collections = await this.findAll({
|
||||
include: {
|
||||
model: this.sequelize.models.book,
|
||||
where: {
|
||||
id: bookId
|
||||
},
|
||||
required: true,
|
||||
include: this.sequelize.models.libraryItem
|
||||
},
|
||||
order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']]
|
||||
})
|
||||
return collections.map(c => this.getOldCollection(c))
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize model
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
*/
|
||||
static init(sequelize) {
|
||||
super.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
name: DataTypes.STRING,
|
||||
description: DataTypes.TEXT
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'collection'
|
||||
})
|
||||
|
||||
const { library } = sequelize.models
|
||||
|
||||
library.hasMany(Collection)
|
||||
Collection.belongsTo(library)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Collection
|
||||
|
|
@ -1,46 +1,61 @@
|
|||
const { DataTypes, Model } = require('sequelize')
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
class CollectionBook extends Model {
|
||||
static removeByIds(collectionId, bookId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
bookId,
|
||||
collectionId
|
||||
}
|
||||
})
|
||||
}
|
||||
class CollectionBook extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
/** @type {UUIDV4} */
|
||||
this.id
|
||||
/** @type {number} */
|
||||
this.order
|
||||
/** @type {UUIDV4} */
|
||||
this.bookId
|
||||
/** @type {UUIDV4} */
|
||||
this.collectionId
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
}
|
||||
|
||||
CollectionBook.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
order: DataTypes.INTEGER
|
||||
}, {
|
||||
sequelize,
|
||||
timestamps: true,
|
||||
updatedAt: false,
|
||||
modelName: 'collectionBook'
|
||||
})
|
||||
static removeByIds(collectionId, bookId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
bookId,
|
||||
collectionId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Super Many-to-Many
|
||||
// ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship
|
||||
const { book, collection } = sequelize.models
|
||||
book.belongsToMany(collection, { through: CollectionBook })
|
||||
collection.belongsToMany(book, { through: CollectionBook })
|
||||
static init(sequelize) {
|
||||
super.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
order: DataTypes.INTEGER
|
||||
}, {
|
||||
sequelize,
|
||||
timestamps: true,
|
||||
updatedAt: false,
|
||||
modelName: 'collectionBook'
|
||||
})
|
||||
|
||||
book.hasMany(CollectionBook, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
CollectionBook.belongsTo(book)
|
||||
// Super Many-to-Many
|
||||
// ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship
|
||||
const { book, collection } = sequelize.models
|
||||
book.belongsToMany(collection, { through: CollectionBook })
|
||||
collection.belongsToMany(book, { through: CollectionBook })
|
||||
|
||||
collection.hasMany(CollectionBook, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
CollectionBook.belongsTo(collection)
|
||||
book.hasMany(CollectionBook, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
CollectionBook.belongsTo(book)
|
||||
|
||||
return CollectionBook
|
||||
}
|
||||
collection.hasMany(CollectionBook, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
CollectionBook.belongsTo(collection)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CollectionBook
|
||||
|
|
@ -1,116 +1,147 @@
|
|||
const { DataTypes, Model } = require('sequelize')
|
||||
const oldDevice = require('../objects/DeviceInfo')
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
class Device extends Model {
|
||||
getOldDevice() {
|
||||
let browserVersion = null
|
||||
let sdkVersion = null
|
||||
if (this.clientName === 'Abs Android') {
|
||||
sdkVersion = this.deviceVersion || null
|
||||
} else {
|
||||
browserVersion = this.deviceVersion || null
|
||||
}
|
||||
class Device extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
return new oldDevice({
|
||||
id: this.id,
|
||||
deviceId: this.deviceId,
|
||||
userId: this.userId,
|
||||
ipAddress: this.ipAddress,
|
||||
browserName: this.extraData.browserName || null,
|
||||
browserVersion,
|
||||
osName: this.extraData.osName || null,
|
||||
osVersion: this.extraData.osVersion || null,
|
||||
clientVersion: this.clientVersion || null,
|
||||
manufacturer: this.extraData.manufacturer || null,
|
||||
model: this.extraData.model || null,
|
||||
sdkVersion,
|
||||
deviceName: this.deviceName,
|
||||
clientName: this.clientName
|
||||
})
|
||||
/** @type {UUIDV4} */
|
||||
this.id
|
||||
/** @type {string} */
|
||||
this.deviceId
|
||||
/** @type {string} */
|
||||
this.clientName
|
||||
/** @type {string} */
|
||||
this.clientVersion
|
||||
/** @type {string} */
|
||||
this.ipAddress
|
||||
/** @type {string} */
|
||||
this.deviceName
|
||||
/** @type {string} */
|
||||
this.deviceVersion
|
||||
/** @type {object} */
|
||||
this.extraData
|
||||
/** @type {UUIDV4} */
|
||||
this.userId
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
}
|
||||
|
||||
getOldDevice() {
|
||||
let browserVersion = null
|
||||
let sdkVersion = null
|
||||
if (this.clientName === 'Abs Android') {
|
||||
sdkVersion = this.deviceVersion || null
|
||||
} else {
|
||||
browserVersion = this.deviceVersion || null
|
||||
}
|
||||
|
||||
static async getOldDeviceByDeviceId(deviceId) {
|
||||
const device = await this.findOne({
|
||||
where: {
|
||||
deviceId
|
||||
}
|
||||
})
|
||||
if (!device) return null
|
||||
return device.getOldDevice()
|
||||
return new oldDevice({
|
||||
id: this.id,
|
||||
deviceId: this.deviceId,
|
||||
userId: this.userId,
|
||||
ipAddress: this.ipAddress,
|
||||
browserName: this.extraData.browserName || null,
|
||||
browserVersion,
|
||||
osName: this.extraData.osName || null,
|
||||
osVersion: this.extraData.osVersion || null,
|
||||
clientVersion: this.clientVersion || null,
|
||||
manufacturer: this.extraData.manufacturer || null,
|
||||
model: this.extraData.model || null,
|
||||
sdkVersion,
|
||||
deviceName: this.deviceName,
|
||||
clientName: this.clientName
|
||||
})
|
||||
}
|
||||
|
||||
static async getOldDeviceByDeviceId(deviceId) {
|
||||
const device = await this.findOne({
|
||||
where: {
|
||||
deviceId
|
||||
}
|
||||
})
|
||||
if (!device) return null
|
||||
return device.getOldDevice()
|
||||
}
|
||||
|
||||
static createFromOld(oldDevice) {
|
||||
const device = this.getFromOld(oldDevice)
|
||||
return this.create(device)
|
||||
}
|
||||
|
||||
static updateFromOld(oldDevice) {
|
||||
const device = this.getFromOld(oldDevice)
|
||||
return this.update(device, {
|
||||
where: {
|
||||
id: device.id
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static getFromOld(oldDeviceInfo) {
|
||||
let extraData = {}
|
||||
|
||||
if (oldDeviceInfo.manufacturer) {
|
||||
extraData.manufacturer = oldDeviceInfo.manufacturer
|
||||
}
|
||||
if (oldDeviceInfo.model) {
|
||||
extraData.model = oldDeviceInfo.model
|
||||
}
|
||||
if (oldDeviceInfo.osName) {
|
||||
extraData.osName = oldDeviceInfo.osName
|
||||
}
|
||||
if (oldDeviceInfo.osVersion) {
|
||||
extraData.osVersion = oldDeviceInfo.osVersion
|
||||
}
|
||||
if (oldDeviceInfo.browserName) {
|
||||
extraData.browserName = oldDeviceInfo.browserName
|
||||
}
|
||||
|
||||
static createFromOld(oldDevice) {
|
||||
const device = this.getFromOld(oldDevice)
|
||||
return this.create(device)
|
||||
}
|
||||
|
||||
static updateFromOld(oldDevice) {
|
||||
const device = this.getFromOld(oldDevice)
|
||||
return this.update(device, {
|
||||
where: {
|
||||
id: device.id
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static getFromOld(oldDeviceInfo) {
|
||||
let extraData = {}
|
||||
|
||||
if (oldDeviceInfo.manufacturer) {
|
||||
extraData.manufacturer = oldDeviceInfo.manufacturer
|
||||
}
|
||||
if (oldDeviceInfo.model) {
|
||||
extraData.model = oldDeviceInfo.model
|
||||
}
|
||||
if (oldDeviceInfo.osName) {
|
||||
extraData.osName = oldDeviceInfo.osName
|
||||
}
|
||||
if (oldDeviceInfo.osVersion) {
|
||||
extraData.osVersion = oldDeviceInfo.osVersion
|
||||
}
|
||||
if (oldDeviceInfo.browserName) {
|
||||
extraData.browserName = oldDeviceInfo.browserName
|
||||
}
|
||||
|
||||
return {
|
||||
id: oldDeviceInfo.id,
|
||||
deviceId: oldDeviceInfo.deviceId,
|
||||
clientName: oldDeviceInfo.clientName || null,
|
||||
clientVersion: oldDeviceInfo.clientVersion || null,
|
||||
ipAddress: oldDeviceInfo.ipAddress,
|
||||
deviceName: oldDeviceInfo.deviceName || null,
|
||||
deviceVersion: oldDeviceInfo.sdkVersion || oldDeviceInfo.browserVersion || null,
|
||||
userId: oldDeviceInfo.userId,
|
||||
extraData
|
||||
}
|
||||
return {
|
||||
id: oldDeviceInfo.id,
|
||||
deviceId: oldDeviceInfo.deviceId,
|
||||
clientName: oldDeviceInfo.clientName || null,
|
||||
clientVersion: oldDeviceInfo.clientVersion || null,
|
||||
ipAddress: oldDeviceInfo.ipAddress,
|
||||
deviceName: oldDeviceInfo.deviceName || null,
|
||||
deviceVersion: oldDeviceInfo.sdkVersion || oldDeviceInfo.browserVersion || null,
|
||||
userId: oldDeviceInfo.userId,
|
||||
extraData
|
||||
}
|
||||
}
|
||||
|
||||
Device.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
deviceId: DataTypes.STRING,
|
||||
clientName: DataTypes.STRING, // e.g. Abs Web, Abs Android
|
||||
clientVersion: DataTypes.STRING, // e.g. Server version or mobile version
|
||||
ipAddress: DataTypes.STRING,
|
||||
deviceName: DataTypes.STRING, // e.g. Windows 10 Chrome, Google Pixel 6, Apple iPhone 10,3
|
||||
deviceVersion: DataTypes.STRING, // e.g. Browser version or Android SDK
|
||||
extraData: DataTypes.JSON
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'device'
|
||||
})
|
||||
/**
|
||||
* Initialize model
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
*/
|
||||
static init(sequelize) {
|
||||
super.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
deviceId: DataTypes.STRING,
|
||||
clientName: DataTypes.STRING, // e.g. Abs Web, Abs Android
|
||||
clientVersion: DataTypes.STRING, // e.g. Server version or mobile version
|
||||
ipAddress: DataTypes.STRING,
|
||||
deviceName: DataTypes.STRING, // e.g. Windows 10 Chrome, Google Pixel 6, Apple iPhone 10,3
|
||||
deviceVersion: DataTypes.STRING, // e.g. Browser version or Android SDK
|
||||
extraData: DataTypes.JSON
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'device'
|
||||
})
|
||||
|
||||
const { user } = sequelize.models
|
||||
const { user } = sequelize.models
|
||||
|
||||
user.hasMany(Device, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
Device.belongsTo(user)
|
||||
user.hasMany(Device, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
Device.belongsTo(user)
|
||||
}
|
||||
}
|
||||
|
||||
return Device
|
||||
}
|
||||
module.exports = Device
|
||||
|
|
@ -1,307 +1,361 @@
|
|||
const { DataTypes, Model } = require('sequelize')
|
||||
const oldFeed = require('../objects/Feed')
|
||||
const areEquivalent = require('../utils/areEquivalent')
|
||||
/*
|
||||
* Polymorphic association: https://sequelize.org/docs/v6/advanced-association-concepts/polymorphic-associations/
|
||||
* Feeds can be created from LibraryItem, Collection, Playlist or Series
|
||||
*/
|
||||
module.exports = (sequelize) => {
|
||||
class Feed extends Model {
|
||||
static async getOldFeeds() {
|
||||
const feeds = await this.findAll({
|
||||
include: {
|
||||
model: sequelize.models.feedEpisode
|
||||
}
|
||||
})
|
||||
return feeds.map(f => this.getOldFeed(f))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get old feed from Feed and optionally Feed with FeedEpisodes
|
||||
* @param {Feed} feedExpanded
|
||||
* @returns {oldFeed}
|
||||
*/
|
||||
static getOldFeed(feedExpanded) {
|
||||
const episodes = feedExpanded.feedEpisodes?.map((feedEpisode) => feedEpisode.getOldEpisode())
|
||||
return new oldFeed({
|
||||
id: feedExpanded.id,
|
||||
slug: feedExpanded.slug,
|
||||
userId: feedExpanded.userId,
|
||||
entityType: feedExpanded.entityType,
|
||||
entityId: feedExpanded.entityId,
|
||||
entityUpdatedAt: feedExpanded.entityUpdatedAt?.valueOf() || null,
|
||||
coverPath: feedExpanded.coverPath || null,
|
||||
meta: {
|
||||
title: feedExpanded.title,
|
||||
description: feedExpanded.description,
|
||||
author: feedExpanded.author,
|
||||
imageUrl: feedExpanded.imageURL,
|
||||
feedUrl: feedExpanded.feedURL,
|
||||
link: feedExpanded.siteURL,
|
||||
explicit: feedExpanded.explicit,
|
||||
type: feedExpanded.podcastType,
|
||||
language: feedExpanded.language,
|
||||
preventIndexing: feedExpanded.preventIndexing,
|
||||
ownerName: feedExpanded.ownerName,
|
||||
ownerEmail: feedExpanded.ownerEmail
|
||||
},
|
||||
serverAddress: feedExpanded.serverAddress,
|
||||
class Feed extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
/** @type {UUIDV4} */
|
||||
this.id
|
||||
/** @type {string} */
|
||||
this.slug
|
||||
/** @type {string} */
|
||||
this.entityType
|
||||
/** @type {UUIDV4} */
|
||||
this.entityId
|
||||
/** @type {Date} */
|
||||
this.entityUpdatedAt
|
||||
/** @type {string} */
|
||||
this.serverAddress
|
||||
/** @type {string} */
|
||||
this.feedURL
|
||||
/** @type {string} */
|
||||
this.imageURL
|
||||
/** @type {string} */
|
||||
this.siteURL
|
||||
/** @type {string} */
|
||||
this.title
|
||||
/** @type {string} */
|
||||
this.description
|
||||
/** @type {string} */
|
||||
this.author
|
||||
/** @type {string} */
|
||||
this.podcastType
|
||||
/** @type {string} */
|
||||
this.language
|
||||
/** @type {string} */
|
||||
this.ownerName
|
||||
/** @type {string} */
|
||||
this.ownerEmail
|
||||
/** @type {boolean} */
|
||||
this.explicit
|
||||
/** @type {boolean} */
|
||||
this.preventIndexing
|
||||
/** @type {string} */
|
||||
this.coverPath
|
||||
/** @type {UUIDV4} */
|
||||
this.userId
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
}
|
||||
|
||||
static async getOldFeeds() {
|
||||
const feeds = await this.findAll({
|
||||
include: {
|
||||
model: this.sequelize.models.feedEpisode
|
||||
}
|
||||
})
|
||||
return feeds.map(f => this.getOldFeed(f))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get old feed from Feed and optionally Feed with FeedEpisodes
|
||||
* @param {Feed} feedExpanded
|
||||
* @returns {oldFeed}
|
||||
*/
|
||||
static getOldFeed(feedExpanded) {
|
||||
const episodes = feedExpanded.feedEpisodes?.map((feedEpisode) => feedEpisode.getOldEpisode())
|
||||
return new oldFeed({
|
||||
id: feedExpanded.id,
|
||||
slug: feedExpanded.slug,
|
||||
userId: feedExpanded.userId,
|
||||
entityType: feedExpanded.entityType,
|
||||
entityId: feedExpanded.entityId,
|
||||
entityUpdatedAt: feedExpanded.entityUpdatedAt?.valueOf() || null,
|
||||
coverPath: feedExpanded.coverPath || null,
|
||||
meta: {
|
||||
title: feedExpanded.title,
|
||||
description: feedExpanded.description,
|
||||
author: feedExpanded.author,
|
||||
imageUrl: feedExpanded.imageURL,
|
||||
feedUrl: feedExpanded.feedURL,
|
||||
episodes: episodes || [],
|
||||
createdAt: feedExpanded.createdAt.valueOf(),
|
||||
updatedAt: feedExpanded.updatedAt.valueOf()
|
||||
})
|
||||
}
|
||||
link: feedExpanded.siteURL,
|
||||
explicit: feedExpanded.explicit,
|
||||
type: feedExpanded.podcastType,
|
||||
language: feedExpanded.language,
|
||||
preventIndexing: feedExpanded.preventIndexing,
|
||||
ownerName: feedExpanded.ownerName,
|
||||
ownerEmail: feedExpanded.ownerEmail
|
||||
},
|
||||
serverAddress: feedExpanded.serverAddress,
|
||||
feedUrl: feedExpanded.feedURL,
|
||||
episodes: episodes || [],
|
||||
createdAt: feedExpanded.createdAt.valueOf(),
|
||||
updatedAt: feedExpanded.updatedAt.valueOf()
|
||||
})
|
||||
}
|
||||
|
||||
static removeById(feedId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
id: feedId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all library item ids that have an open feed (used in library filter)
|
||||
* @returns {Promise<Array<String>>} array of library item ids
|
||||
*/
|
||||
static async findAllLibraryItemIds() {
|
||||
const feeds = await this.findAll({
|
||||
attributes: ['entityId'],
|
||||
where: {
|
||||
entityType: 'libraryItem'
|
||||
}
|
||||
})
|
||||
return feeds.map(f => f.entityId).filter(f => f) || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Find feed where and return oldFeed
|
||||
* @param {object} where sequelize where object
|
||||
* @returns {Promise<objects.Feed>} oldFeed
|
||||
*/
|
||||
static async findOneOld(where) {
|
||||
if (!where) return null
|
||||
const feedExpanded = await this.findOne({
|
||||
where,
|
||||
include: {
|
||||
model: sequelize.models.feedEpisode
|
||||
}
|
||||
})
|
||||
if (!feedExpanded) return null
|
||||
return this.getOldFeed(feedExpanded)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find feed and return oldFeed
|
||||
* @param {string} id
|
||||
* @returns {Promise<objects.Feed>} oldFeed
|
||||
*/
|
||||
static async findByPkOld(id) {
|
||||
if (!id) return null
|
||||
const feedExpanded = await this.findByPk(id, {
|
||||
include: {
|
||||
model: sequelize.models.feedEpisode
|
||||
}
|
||||
})
|
||||
if (!feedExpanded) return null
|
||||
return this.getOldFeed(feedExpanded)
|
||||
}
|
||||
|
||||
static async fullCreateFromOld(oldFeed) {
|
||||
const feedObj = this.getFromOld(oldFeed)
|
||||
const newFeed = await this.create(feedObj)
|
||||
|
||||
if (oldFeed.episodes?.length) {
|
||||
for (const oldFeedEpisode of oldFeed.episodes) {
|
||||
const feedEpisode = sequelize.models.feedEpisode.getFromOld(oldFeedEpisode)
|
||||
feedEpisode.feedId = newFeed.id
|
||||
await sequelize.models.feedEpisode.create(feedEpisode)
|
||||
}
|
||||
static removeById(feedId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
id: feedId
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static async fullUpdateFromOld(oldFeed) {
|
||||
const oldFeedEpisodes = oldFeed.episodes || []
|
||||
const feedObj = this.getFromOld(oldFeed)
|
||||
|
||||
const existingFeed = await this.findByPk(feedObj.id, {
|
||||
include: sequelize.models.feedEpisode
|
||||
})
|
||||
if (!existingFeed) return false
|
||||
|
||||
let hasUpdates = false
|
||||
for (const feedEpisode of existingFeed.feedEpisodes) {
|
||||
const oldFeedEpisode = oldFeedEpisodes.find(ep => ep.id === feedEpisode.id)
|
||||
// Episode removed
|
||||
if (!oldFeedEpisode) {
|
||||
feedEpisode.destroy()
|
||||
} else {
|
||||
let episodeHasUpdates = false
|
||||
const oldFeedEpisodeCleaned = sequelize.models.feedEpisode.getFromOld(oldFeedEpisode)
|
||||
for (const key in oldFeedEpisodeCleaned) {
|
||||
if (!areEquivalent(oldFeedEpisodeCleaned[key], feedEpisode[key])) {
|
||||
episodeHasUpdates = true
|
||||
}
|
||||
}
|
||||
if (episodeHasUpdates) {
|
||||
await feedEpisode.update(oldFeedEpisodeCleaned)
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Find all library item ids that have an open feed (used in library filter)
|
||||
* @returns {Promise<Array<String>>} array of library item ids
|
||||
*/
|
||||
static async findAllLibraryItemIds() {
|
||||
const feeds = await this.findAll({
|
||||
attributes: ['entityId'],
|
||||
where: {
|
||||
entityType: 'libraryItem'
|
||||
}
|
||||
})
|
||||
return feeds.map(f => f.entityId).filter(f => f) || []
|
||||
}
|
||||
|
||||
let feedHasUpdates = false
|
||||
for (const key in feedObj) {
|
||||
let existingValue = existingFeed[key]
|
||||
if (existingValue instanceof Date) existingValue = existingValue.valueOf()
|
||||
|
||||
if (!areEquivalent(existingValue, feedObj[key])) {
|
||||
feedHasUpdates = true
|
||||
}
|
||||
/**
|
||||
* Find feed where and return oldFeed
|
||||
* @param {object} where sequelize where object
|
||||
* @returns {Promise<objects.Feed>} oldFeed
|
||||
*/
|
||||
static async findOneOld(where) {
|
||||
if (!where) return null
|
||||
const feedExpanded = await this.findOne({
|
||||
where,
|
||||
include: {
|
||||
model: this.sequelize.models.feedEpisode
|
||||
}
|
||||
})
|
||||
if (!feedExpanded) return null
|
||||
return this.getOldFeed(feedExpanded)
|
||||
}
|
||||
|
||||
if (feedHasUpdates) {
|
||||
await existingFeed.update(feedObj)
|
||||
hasUpdates = true
|
||||
/**
|
||||
* Find feed and return oldFeed
|
||||
* @param {string} id
|
||||
* @returns {Promise<objects.Feed>} oldFeed
|
||||
*/
|
||||
static async findByPkOld(id) {
|
||||
if (!id) return null
|
||||
const feedExpanded = await this.findByPk(id, {
|
||||
include: {
|
||||
model: this.sequelize.models.feedEpisode
|
||||
}
|
||||
})
|
||||
if (!feedExpanded) return null
|
||||
return this.getOldFeed(feedExpanded)
|
||||
}
|
||||
|
||||
return hasUpdates
|
||||
}
|
||||
static async fullCreateFromOld(oldFeed) {
|
||||
const feedObj = this.getFromOld(oldFeed)
|
||||
const newFeed = await this.create(feedObj)
|
||||
|
||||
static getFromOld(oldFeed) {
|
||||
const oldFeedMeta = oldFeed.meta || {}
|
||||
return {
|
||||
id: oldFeed.id,
|
||||
slug: oldFeed.slug,
|
||||
entityType: oldFeed.entityType,
|
||||
entityId: oldFeed.entityId,
|
||||
entityUpdatedAt: oldFeed.entityUpdatedAt,
|
||||
serverAddress: oldFeed.serverAddress,
|
||||
feedURL: oldFeed.feedUrl,
|
||||
coverPath: oldFeed.coverPath || null,
|
||||
imageURL: oldFeedMeta.imageUrl,
|
||||
siteURL: oldFeedMeta.link,
|
||||
title: oldFeedMeta.title,
|
||||
description: oldFeedMeta.description,
|
||||
author: oldFeedMeta.author,
|
||||
podcastType: oldFeedMeta.type || null,
|
||||
language: oldFeedMeta.language || null,
|
||||
ownerName: oldFeedMeta.ownerName || null,
|
||||
ownerEmail: oldFeedMeta.ownerEmail || null,
|
||||
explicit: !!oldFeedMeta.explicit,
|
||||
preventIndexing: !!oldFeedMeta.preventIndexing,
|
||||
userId: oldFeed.userId
|
||||
if (oldFeed.episodes?.length) {
|
||||
for (const oldFeedEpisode of oldFeed.episodes) {
|
||||
const feedEpisode = this.sequelize.models.feedEpisode.getFromOld(oldFeedEpisode)
|
||||
feedEpisode.feedId = newFeed.id
|
||||
await this.sequelize.models.feedEpisode.create(feedEpisode)
|
||||
}
|
||||
}
|
||||
|
||||
getEntity(options) {
|
||||
if (!this.entityType) return Promise.resolve(null)
|
||||
const mixinMethodName = `get${sequelize.uppercaseFirst(this.entityType)}`
|
||||
return this[mixinMethodName](options)
|
||||
}
|
||||
}
|
||||
|
||||
Feed.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
slug: DataTypes.STRING,
|
||||
entityType: DataTypes.STRING,
|
||||
entityId: DataTypes.UUIDV4,
|
||||
entityUpdatedAt: DataTypes.DATE,
|
||||
serverAddress: DataTypes.STRING,
|
||||
feedURL: DataTypes.STRING,
|
||||
imageURL: DataTypes.STRING,
|
||||
siteURL: DataTypes.STRING,
|
||||
title: DataTypes.STRING,
|
||||
description: DataTypes.TEXT,
|
||||
author: DataTypes.STRING,
|
||||
podcastType: DataTypes.STRING,
|
||||
language: DataTypes.STRING,
|
||||
ownerName: DataTypes.STRING,
|
||||
ownerEmail: DataTypes.STRING,
|
||||
explicit: DataTypes.BOOLEAN,
|
||||
preventIndexing: DataTypes.BOOLEAN,
|
||||
coverPath: DataTypes.STRING
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'feed'
|
||||
})
|
||||
static async fullUpdateFromOld(oldFeed) {
|
||||
const oldFeedEpisodes = oldFeed.episodes || []
|
||||
const feedObj = this.getFromOld(oldFeed)
|
||||
|
||||
const { user, libraryItem, collection, series, playlist } = sequelize.models
|
||||
const existingFeed = await this.findByPk(feedObj.id, {
|
||||
include: this.sequelize.models.feedEpisode
|
||||
})
|
||||
if (!existingFeed) return false
|
||||
|
||||
user.hasMany(Feed)
|
||||
Feed.belongsTo(user)
|
||||
|
||||
libraryItem.hasMany(Feed, {
|
||||
foreignKey: 'entityId',
|
||||
constraints: false,
|
||||
scope: {
|
||||
entityType: 'libraryItem'
|
||||
}
|
||||
})
|
||||
Feed.belongsTo(libraryItem, { foreignKey: 'entityId', constraints: false })
|
||||
|
||||
collection.hasMany(Feed, {
|
||||
foreignKey: 'entityId',
|
||||
constraints: false,
|
||||
scope: {
|
||||
entityType: 'collection'
|
||||
}
|
||||
})
|
||||
Feed.belongsTo(collection, { foreignKey: 'entityId', constraints: false })
|
||||
|
||||
series.hasMany(Feed, {
|
||||
foreignKey: 'entityId',
|
||||
constraints: false,
|
||||
scope: {
|
||||
entityType: 'series'
|
||||
}
|
||||
})
|
||||
Feed.belongsTo(series, { foreignKey: 'entityId', constraints: false })
|
||||
|
||||
playlist.hasMany(Feed, {
|
||||
foreignKey: 'entityId',
|
||||
constraints: false,
|
||||
scope: {
|
||||
entityType: 'playlist'
|
||||
}
|
||||
})
|
||||
Feed.belongsTo(playlist, { foreignKey: 'entityId', constraints: false })
|
||||
|
||||
Feed.addHook('afterFind', findResult => {
|
||||
if (!findResult) return
|
||||
|
||||
if (!Array.isArray(findResult)) findResult = [findResult]
|
||||
for (const instance of findResult) {
|
||||
if (instance.entityType === 'libraryItem' && instance.libraryItem !== undefined) {
|
||||
instance.entity = instance.libraryItem
|
||||
instance.dataValues.entity = instance.dataValues.libraryItem
|
||||
} else if (instance.entityType === 'collection' && instance.collection !== undefined) {
|
||||
instance.entity = instance.collection
|
||||
instance.dataValues.entity = instance.dataValues.collection
|
||||
} else if (instance.entityType === 'series' && instance.series !== undefined) {
|
||||
instance.entity = instance.series
|
||||
instance.dataValues.entity = instance.dataValues.series
|
||||
} else if (instance.entityType === 'playlist' && instance.playlist !== undefined) {
|
||||
instance.entity = instance.playlist
|
||||
instance.dataValues.entity = instance.dataValues.playlist
|
||||
let hasUpdates = false
|
||||
for (const feedEpisode of existingFeed.feedEpisodes) {
|
||||
const oldFeedEpisode = oldFeedEpisodes.find(ep => ep.id === feedEpisode.id)
|
||||
// Episode removed
|
||||
if (!oldFeedEpisode) {
|
||||
feedEpisode.destroy()
|
||||
} else {
|
||||
let episodeHasUpdates = false
|
||||
const oldFeedEpisodeCleaned = this.sequelize.models.feedEpisode.getFromOld(oldFeedEpisode)
|
||||
for (const key in oldFeedEpisodeCleaned) {
|
||||
if (!areEquivalent(oldFeedEpisodeCleaned[key], feedEpisode[key])) {
|
||||
episodeHasUpdates = true
|
||||
}
|
||||
}
|
||||
if (episodeHasUpdates) {
|
||||
await feedEpisode.update(oldFeedEpisodeCleaned)
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
// To prevent mistakes:
|
||||
delete instance.libraryItem
|
||||
delete instance.dataValues.libraryItem
|
||||
delete instance.collection
|
||||
delete instance.dataValues.collection
|
||||
delete instance.series
|
||||
delete instance.dataValues.series
|
||||
delete instance.playlist
|
||||
delete instance.dataValues.playlist
|
||||
}
|
||||
})
|
||||
|
||||
return Feed
|
||||
}
|
||||
let feedHasUpdates = false
|
||||
for (const key in feedObj) {
|
||||
let existingValue = existingFeed[key]
|
||||
if (existingValue instanceof Date) existingValue = existingValue.valueOf()
|
||||
|
||||
if (!areEquivalent(existingValue, feedObj[key])) {
|
||||
feedHasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
if (feedHasUpdates) {
|
||||
await existingFeed.update(feedObj)
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
static getFromOld(oldFeed) {
|
||||
const oldFeedMeta = oldFeed.meta || {}
|
||||
return {
|
||||
id: oldFeed.id,
|
||||
slug: oldFeed.slug,
|
||||
entityType: oldFeed.entityType,
|
||||
entityId: oldFeed.entityId,
|
||||
entityUpdatedAt: oldFeed.entityUpdatedAt,
|
||||
serverAddress: oldFeed.serverAddress,
|
||||
feedURL: oldFeed.feedUrl,
|
||||
coverPath: oldFeed.coverPath || null,
|
||||
imageURL: oldFeedMeta.imageUrl,
|
||||
siteURL: oldFeedMeta.link,
|
||||
title: oldFeedMeta.title,
|
||||
description: oldFeedMeta.description,
|
||||
author: oldFeedMeta.author,
|
||||
podcastType: oldFeedMeta.type || null,
|
||||
language: oldFeedMeta.language || null,
|
||||
ownerName: oldFeedMeta.ownerName || null,
|
||||
ownerEmail: oldFeedMeta.ownerEmail || null,
|
||||
explicit: !!oldFeedMeta.explicit,
|
||||
preventIndexing: !!oldFeedMeta.preventIndexing,
|
||||
userId: oldFeed.userId
|
||||
}
|
||||
}
|
||||
|
||||
getEntity(options) {
|
||||
if (!this.entityType) return Promise.resolve(null)
|
||||
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.entityType)}`
|
||||
return this[mixinMethodName](options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize model
|
||||
*
|
||||
* Polymorphic association: Feeds can be created from LibraryItem, Collection, Playlist or Series
|
||||
* @see https://sequelize.org/docs/v6/advanced-association-concepts/polymorphic-associations/
|
||||
*
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
*/
|
||||
static init(sequelize) {
|
||||
super.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
slug: DataTypes.STRING,
|
||||
entityType: DataTypes.STRING,
|
||||
entityId: DataTypes.UUIDV4,
|
||||
entityUpdatedAt: DataTypes.DATE,
|
||||
serverAddress: DataTypes.STRING,
|
||||
feedURL: DataTypes.STRING,
|
||||
imageURL: DataTypes.STRING,
|
||||
siteURL: DataTypes.STRING,
|
||||
title: DataTypes.STRING,
|
||||
description: DataTypes.TEXT,
|
||||
author: DataTypes.STRING,
|
||||
podcastType: DataTypes.STRING,
|
||||
language: DataTypes.STRING,
|
||||
ownerName: DataTypes.STRING,
|
||||
ownerEmail: DataTypes.STRING,
|
||||
explicit: DataTypes.BOOLEAN,
|
||||
preventIndexing: DataTypes.BOOLEAN,
|
||||
coverPath: DataTypes.STRING
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'feed'
|
||||
})
|
||||
|
||||
const { user, libraryItem, collection, series, playlist } = sequelize.models
|
||||
|
||||
user.hasMany(Feed)
|
||||
Feed.belongsTo(user)
|
||||
|
||||
libraryItem.hasMany(Feed, {
|
||||
foreignKey: 'entityId',
|
||||
constraints: false,
|
||||
scope: {
|
||||
entityType: 'libraryItem'
|
||||
}
|
||||
})
|
||||
Feed.belongsTo(libraryItem, { foreignKey: 'entityId', constraints: false })
|
||||
|
||||
collection.hasMany(Feed, {
|
||||
foreignKey: 'entityId',
|
||||
constraints: false,
|
||||
scope: {
|
||||
entityType: 'collection'
|
||||
}
|
||||
})
|
||||
Feed.belongsTo(collection, { foreignKey: 'entityId', constraints: false })
|
||||
|
||||
series.hasMany(Feed, {
|
||||
foreignKey: 'entityId',
|
||||
constraints: false,
|
||||
scope: {
|
||||
entityType: 'series'
|
||||
}
|
||||
})
|
||||
Feed.belongsTo(series, { foreignKey: 'entityId', constraints: false })
|
||||
|
||||
playlist.hasMany(Feed, {
|
||||
foreignKey: 'entityId',
|
||||
constraints: false,
|
||||
scope: {
|
||||
entityType: 'playlist'
|
||||
}
|
||||
})
|
||||
Feed.belongsTo(playlist, { foreignKey: 'entityId', constraints: false })
|
||||
|
||||
Feed.addHook('afterFind', findResult => {
|
||||
if (!findResult) return
|
||||
|
||||
if (!Array.isArray(findResult)) findResult = [findResult]
|
||||
for (const instance of findResult) {
|
||||
if (instance.entityType === 'libraryItem' && instance.libraryItem !== undefined) {
|
||||
instance.entity = instance.libraryItem
|
||||
instance.dataValues.entity = instance.dataValues.libraryItem
|
||||
} else if (instance.entityType === 'collection' && instance.collection !== undefined) {
|
||||
instance.entity = instance.collection
|
||||
instance.dataValues.entity = instance.dataValues.collection
|
||||
} else if (instance.entityType === 'series' && instance.series !== undefined) {
|
||||
instance.entity = instance.series
|
||||
instance.dataValues.entity = instance.dataValues.series
|
||||
} else if (instance.entityType === 'playlist' && instance.playlist !== undefined) {
|
||||
instance.entity = instance.playlist
|
||||
instance.dataValues.entity = instance.dataValues.playlist
|
||||
}
|
||||
|
||||
// To prevent mistakes:
|
||||
delete instance.libraryItem
|
||||
delete instance.dataValues.libraryItem
|
||||
delete instance.collection
|
||||
delete instance.dataValues.collection
|
||||
delete instance.series
|
||||
delete instance.dataValues.series
|
||||
delete instance.playlist
|
||||
delete instance.dataValues.playlist
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Feed
|
||||
|
|
@ -1,82 +1,125 @@
|
|||
const { DataTypes, Model } = require('sequelize')
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
class FeedEpisode extends Model {
|
||||
getOldEpisode() {
|
||||
const enclosure = {
|
||||
url: this.enclosureURL,
|
||||
size: this.enclosureSize,
|
||||
type: this.enclosureType
|
||||
}
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.title,
|
||||
description: this.description,
|
||||
enclosure,
|
||||
pubDate: this.pubDate,
|
||||
link: this.siteURL,
|
||||
author: this.author,
|
||||
explicit: this.explicit,
|
||||
duration: this.duration,
|
||||
season: this.season,
|
||||
episode: this.episode,
|
||||
episodeType: this.episodeType,
|
||||
fullPath: this.filePath
|
||||
}
|
||||
}
|
||||
class FeedEpisode extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
static getFromOld(oldFeedEpisode) {
|
||||
return {
|
||||
id: oldFeedEpisode.id,
|
||||
title: oldFeedEpisode.title,
|
||||
author: oldFeedEpisode.author,
|
||||
description: oldFeedEpisode.description,
|
||||
siteURL: oldFeedEpisode.link,
|
||||
enclosureURL: oldFeedEpisode.enclosure?.url || null,
|
||||
enclosureType: oldFeedEpisode.enclosure?.type || null,
|
||||
enclosureSize: oldFeedEpisode.enclosure?.size || null,
|
||||
pubDate: oldFeedEpisode.pubDate,
|
||||
season: oldFeedEpisode.season || null,
|
||||
episode: oldFeedEpisode.episode || null,
|
||||
episodeType: oldFeedEpisode.episodeType || null,
|
||||
duration: oldFeedEpisode.duration,
|
||||
filePath: oldFeedEpisode.fullPath,
|
||||
explicit: !!oldFeedEpisode.explicit
|
||||
}
|
||||
/** @type {UUIDV4} */
|
||||
this.id
|
||||
/** @type {string} */
|
||||
this.title
|
||||
/** @type {string} */
|
||||
this.description
|
||||
/** @type {string} */
|
||||
this.siteURL
|
||||
/** @type {string} */
|
||||
this.enclosureURL
|
||||
/** @type {string} */
|
||||
this.enclosureType
|
||||
/** @type {BigInt} */
|
||||
this.enclosureSize
|
||||
/** @type {string} */
|
||||
this.pubDate
|
||||
/** @type {string} */
|
||||
this.season
|
||||
/** @type {string} */
|
||||
this.episode
|
||||
/** @type {string} */
|
||||
this.episodeType
|
||||
/** @type {number} */
|
||||
this.duration
|
||||
/** @type {string} */
|
||||
this.filePath
|
||||
/** @type {boolean} */
|
||||
this.explicit
|
||||
/** @type {UUIDV4} */
|
||||
this.feedId
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
}
|
||||
|
||||
getOldEpisode() {
|
||||
const enclosure = {
|
||||
url: this.enclosureURL,
|
||||
size: this.enclosureSize,
|
||||
type: this.enclosureType
|
||||
}
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.title,
|
||||
description: this.description,
|
||||
enclosure,
|
||||
pubDate: this.pubDate,
|
||||
link: this.siteURL,
|
||||
author: this.author,
|
||||
explicit: this.explicit,
|
||||
duration: this.duration,
|
||||
season: this.season,
|
||||
episode: this.episode,
|
||||
episodeType: this.episodeType,
|
||||
fullPath: this.filePath
|
||||
}
|
||||
}
|
||||
|
||||
FeedEpisode.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
title: DataTypes.STRING,
|
||||
author: DataTypes.STRING,
|
||||
description: DataTypes.TEXT,
|
||||
siteURL: DataTypes.STRING,
|
||||
enclosureURL: DataTypes.STRING,
|
||||
enclosureType: DataTypes.STRING,
|
||||
enclosureSize: DataTypes.BIGINT,
|
||||
pubDate: DataTypes.STRING,
|
||||
season: DataTypes.STRING,
|
||||
episode: DataTypes.STRING,
|
||||
episodeType: DataTypes.STRING,
|
||||
duration: DataTypes.FLOAT,
|
||||
filePath: DataTypes.STRING,
|
||||
explicit: DataTypes.BOOLEAN
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'feedEpisode'
|
||||
})
|
||||
static getFromOld(oldFeedEpisode) {
|
||||
return {
|
||||
id: oldFeedEpisode.id,
|
||||
title: oldFeedEpisode.title,
|
||||
author: oldFeedEpisode.author,
|
||||
description: oldFeedEpisode.description,
|
||||
siteURL: oldFeedEpisode.link,
|
||||
enclosureURL: oldFeedEpisode.enclosure?.url || null,
|
||||
enclosureType: oldFeedEpisode.enclosure?.type || null,
|
||||
enclosureSize: oldFeedEpisode.enclosure?.size || null,
|
||||
pubDate: oldFeedEpisode.pubDate,
|
||||
season: oldFeedEpisode.season || null,
|
||||
episode: oldFeedEpisode.episode || null,
|
||||
episodeType: oldFeedEpisode.episodeType || null,
|
||||
duration: oldFeedEpisode.duration,
|
||||
filePath: oldFeedEpisode.fullPath,
|
||||
explicit: !!oldFeedEpisode.explicit
|
||||
}
|
||||
}
|
||||
|
||||
const { feed } = sequelize.models
|
||||
/**
|
||||
* Initialize model
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
*/
|
||||
static init(sequelize) {
|
||||
super.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
title: DataTypes.STRING,
|
||||
author: DataTypes.STRING,
|
||||
description: DataTypes.TEXT,
|
||||
siteURL: DataTypes.STRING,
|
||||
enclosureURL: DataTypes.STRING,
|
||||
enclosureType: DataTypes.STRING,
|
||||
enclosureSize: DataTypes.BIGINT,
|
||||
pubDate: DataTypes.STRING,
|
||||
season: DataTypes.STRING,
|
||||
episode: DataTypes.STRING,
|
||||
episodeType: DataTypes.STRING,
|
||||
duration: DataTypes.FLOAT,
|
||||
filePath: DataTypes.STRING,
|
||||
explicit: DataTypes.BOOLEAN
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'feedEpisode'
|
||||
})
|
||||
|
||||
feed.hasMany(FeedEpisode, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
FeedEpisode.belongsTo(feed)
|
||||
const { feed } = sequelize.models
|
||||
|
||||
return FeedEpisode
|
||||
}
|
||||
feed.hasMany(FeedEpisode, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
FeedEpisode.belongsTo(feed)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FeedEpisode
|
||||
|
|
@ -2,217 +2,261 @@ const { DataTypes, Model } = require('sequelize')
|
|||
const Logger = require('../Logger')
|
||||
const oldLibrary = require('../objects/Library')
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
class Library extends Model {
|
||||
/**
|
||||
* Get all old libraries
|
||||
* @returns {Promise<oldLibrary[]>}
|
||||
*/
|
||||
static async getAllOldLibraries() {
|
||||
const libraries = await this.findAll({
|
||||
include: sequelize.models.libraryFolder,
|
||||
order: [['displayOrder', 'ASC']]
|
||||
})
|
||||
return libraries.map(lib => this.getOldLibrary(lib))
|
||||
}
|
||||
/**
|
||||
* @typedef LibrarySettingsObject
|
||||
* @property {number} coverAspectRatio BookCoverAspectRatio
|
||||
* @property {boolean} disableWatcher
|
||||
* @property {boolean} skipMatchingMediaWithAsin
|
||||
* @property {boolean} skipMatchingMediaWithIsbn
|
||||
* @property {string} autoScanCronExpression
|
||||
* @property {boolean} audiobooksOnly
|
||||
* @property {boolean} hideSingleBookSeries Do not show series that only have 1 book
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convert expanded Library to oldLibrary
|
||||
* @param {Library} libraryExpanded
|
||||
* @returns {Promise<oldLibrary>}
|
||||
*/
|
||||
static getOldLibrary(libraryExpanded) {
|
||||
const folders = libraryExpanded.libraryFolders.map(folder => {
|
||||
return {
|
||||
id: folder.id,
|
||||
fullPath: folder.path,
|
||||
libraryId: folder.libraryId,
|
||||
addedAt: folder.createdAt.valueOf()
|
||||
}
|
||||
})
|
||||
return new oldLibrary({
|
||||
id: libraryExpanded.id,
|
||||
oldLibraryId: libraryExpanded.extraData?.oldLibraryId || null,
|
||||
name: libraryExpanded.name,
|
||||
folders,
|
||||
displayOrder: libraryExpanded.displayOrder,
|
||||
icon: libraryExpanded.icon,
|
||||
mediaType: libraryExpanded.mediaType,
|
||||
provider: libraryExpanded.provider,
|
||||
settings: libraryExpanded.settings,
|
||||
createdAt: libraryExpanded.createdAt.valueOf(),
|
||||
lastUpdate: libraryExpanded.updatedAt.valueOf()
|
||||
})
|
||||
}
|
||||
class Library extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
/**
|
||||
* @param {object} oldLibrary
|
||||
* @returns {Library|null}
|
||||
*/
|
||||
static async createFromOld(oldLibrary) {
|
||||
const library = this.getFromOld(oldLibrary)
|
||||
/** @type {UUIDV4} */
|
||||
this.id
|
||||
/** @type {string} */
|
||||
this.name
|
||||
/** @type {number} */
|
||||
this.displayOrder
|
||||
/** @type {string} */
|
||||
this.icon
|
||||
/** @type {string} */
|
||||
this.mediaType
|
||||
/** @type {string} */
|
||||
this.provider
|
||||
/** @type {Date} */
|
||||
this.lastScan
|
||||
/** @type {string} */
|
||||
this.lastScanVersion
|
||||
/** @type {LibrarySettingsObject} */
|
||||
this.settings
|
||||
/** @type {Object} */
|
||||
this.extraData
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
}
|
||||
|
||||
library.libraryFolders = oldLibrary.folders.map(folder => {
|
||||
return {
|
||||
id: folder.id,
|
||||
path: folder.fullPath
|
||||
}
|
||||
})
|
||||
/**
|
||||
* Get all old libraries
|
||||
* @returns {Promise<oldLibrary[]>}
|
||||
*/
|
||||
static async getAllOldLibraries() {
|
||||
const libraries = await this.findAll({
|
||||
include: this.sequelize.models.libraryFolder,
|
||||
order: [['displayOrder', 'ASC']]
|
||||
})
|
||||
return libraries.map(lib => this.getOldLibrary(lib))
|
||||
}
|
||||
|
||||
return this.create(library, {
|
||||
include: sequelize.models.libraryFolder
|
||||
}).catch((error) => {
|
||||
Logger.error(`[Library] Failed to create library ${library.id}`, error)
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update library and library folders
|
||||
* @param {object} oldLibrary
|
||||
* @returns
|
||||
*/
|
||||
static async updateFromOld(oldLibrary) {
|
||||
const existingLibrary = await this.findByPk(oldLibrary.id, {
|
||||
include: sequelize.models.libraryFolder
|
||||
})
|
||||
if (!existingLibrary) {
|
||||
Logger.error(`[Library] Failed to update library ${oldLibrary.id} - not found`)
|
||||
return null
|
||||
}
|
||||
|
||||
const library = this.getFromOld(oldLibrary)
|
||||
|
||||
const libraryFolders = oldLibrary.folders.map(folder => {
|
||||
return {
|
||||
id: folder.id,
|
||||
path: folder.fullPath,
|
||||
libraryId: library.id
|
||||
}
|
||||
})
|
||||
for (const libraryFolder of libraryFolders) {
|
||||
const existingLibraryFolder = existingLibrary.libraryFolders.find(lf => lf.id === libraryFolder.id)
|
||||
if (!existingLibraryFolder) {
|
||||
await sequelize.models.libraryFolder.create(libraryFolder)
|
||||
} else if (existingLibraryFolder.path !== libraryFolder.path) {
|
||||
await existingLibraryFolder.update({ path: libraryFolder.path })
|
||||
}
|
||||
}
|
||||
|
||||
const libraryFoldersRemoved = existingLibrary.libraryFolders.filter(lf => !libraryFolders.some(_lf => _lf.id === lf.id))
|
||||
for (const existingLibraryFolder of libraryFoldersRemoved) {
|
||||
await existingLibraryFolder.destroy()
|
||||
}
|
||||
|
||||
return existingLibrary.update(library)
|
||||
}
|
||||
|
||||
static getFromOld(oldLibrary) {
|
||||
const extraData = {}
|
||||
if (oldLibrary.oldLibraryId) {
|
||||
extraData.oldLibraryId = oldLibrary.oldLibraryId
|
||||
}
|
||||
/**
|
||||
* Convert expanded Library to oldLibrary
|
||||
* @param {Library} libraryExpanded
|
||||
* @returns {Promise<oldLibrary>}
|
||||
*/
|
||||
static getOldLibrary(libraryExpanded) {
|
||||
const folders = libraryExpanded.libraryFolders.map(folder => {
|
||||
return {
|
||||
id: oldLibrary.id,
|
||||
name: oldLibrary.name,
|
||||
displayOrder: oldLibrary.displayOrder,
|
||||
icon: oldLibrary.icon || null,
|
||||
mediaType: oldLibrary.mediaType || null,
|
||||
provider: oldLibrary.provider,
|
||||
settings: oldLibrary.settings?.toJSON() || {},
|
||||
createdAt: oldLibrary.createdAt,
|
||||
updatedAt: oldLibrary.lastUpdate,
|
||||
extraData
|
||||
id: folder.id,
|
||||
fullPath: folder.path,
|
||||
libraryId: folder.libraryId,
|
||||
addedAt: folder.createdAt.valueOf()
|
||||
}
|
||||
})
|
||||
return new oldLibrary({
|
||||
id: libraryExpanded.id,
|
||||
oldLibraryId: libraryExpanded.extraData?.oldLibraryId || null,
|
||||
name: libraryExpanded.name,
|
||||
folders,
|
||||
displayOrder: libraryExpanded.displayOrder,
|
||||
icon: libraryExpanded.icon,
|
||||
mediaType: libraryExpanded.mediaType,
|
||||
provider: libraryExpanded.provider,
|
||||
settings: libraryExpanded.settings,
|
||||
createdAt: libraryExpanded.createdAt.valueOf(),
|
||||
lastUpdate: libraryExpanded.updatedAt.valueOf()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} oldLibrary
|
||||
* @returns {Library|null}
|
||||
*/
|
||||
static async createFromOld(oldLibrary) {
|
||||
const library = this.getFromOld(oldLibrary)
|
||||
|
||||
library.libraryFolders = oldLibrary.folders.map(folder => {
|
||||
return {
|
||||
id: folder.id,
|
||||
path: folder.fullPath
|
||||
}
|
||||
})
|
||||
|
||||
return this.create(library, {
|
||||
include: this.sequelize.models.libraryFolder
|
||||
}).catch((error) => {
|
||||
Logger.error(`[Library] Failed to create library ${library.id}`, error)
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update library and library folders
|
||||
* @param {object} oldLibrary
|
||||
* @returns
|
||||
*/
|
||||
static async updateFromOld(oldLibrary) {
|
||||
const existingLibrary = await this.findByPk(oldLibrary.id, {
|
||||
include: this.sequelize.models.libraryFolder
|
||||
})
|
||||
if (!existingLibrary) {
|
||||
Logger.error(`[Library] Failed to update library ${oldLibrary.id} - not found`)
|
||||
return null
|
||||
}
|
||||
|
||||
const library = this.getFromOld(oldLibrary)
|
||||
|
||||
const libraryFolders = oldLibrary.folders.map(folder => {
|
||||
return {
|
||||
id: folder.id,
|
||||
path: folder.fullPath,
|
||||
libraryId: library.id
|
||||
}
|
||||
})
|
||||
for (const libraryFolder of libraryFolders) {
|
||||
const existingLibraryFolder = existingLibrary.libraryFolders.find(lf => lf.id === libraryFolder.id)
|
||||
if (!existingLibraryFolder) {
|
||||
await this.sequelize.models.libraryFolder.create(libraryFolder)
|
||||
} else if (existingLibraryFolder.path !== libraryFolder.path) {
|
||||
await existingLibraryFolder.update({ path: libraryFolder.path })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy library by id
|
||||
* @param {string} libraryId
|
||||
* @returns
|
||||
*/
|
||||
static removeById(libraryId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
id: libraryId
|
||||
}
|
||||
})
|
||||
const libraryFoldersRemoved = existingLibrary.libraryFolders.filter(lf => !libraryFolders.some(_lf => _lf.id === lf.id))
|
||||
for (const existingLibraryFolder of libraryFoldersRemoved) {
|
||||
await existingLibraryFolder.destroy()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all library ids
|
||||
* @returns {Promise<string[]>} array of library ids
|
||||
*/
|
||||
static async getAllLibraryIds() {
|
||||
const libraries = await this.findAll({
|
||||
attributes: ['id', 'displayOrder'],
|
||||
order: [['displayOrder', 'ASC']]
|
||||
})
|
||||
return libraries.map(l => l.id)
|
||||
}
|
||||
return existingLibrary.update(library)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find Library by primary key & return oldLibrary
|
||||
* @param {string} libraryId
|
||||
* @returns {Promise<oldLibrary|null>} Returns null if not found
|
||||
*/
|
||||
static async getOldById(libraryId) {
|
||||
if (!libraryId) return null
|
||||
const library = await this.findByPk(libraryId, {
|
||||
include: sequelize.models.libraryFolder
|
||||
})
|
||||
if (!library) return null
|
||||
return this.getOldLibrary(library)
|
||||
static getFromOld(oldLibrary) {
|
||||
const extraData = {}
|
||||
if (oldLibrary.oldLibraryId) {
|
||||
extraData.oldLibraryId = oldLibrary.oldLibraryId
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the largest value in the displayOrder column
|
||||
* Used for setting a new libraries display order
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
static getMaxDisplayOrder() {
|
||||
return this.max('displayOrder') || 0
|
||||
return {
|
||||
id: oldLibrary.id,
|
||||
name: oldLibrary.name,
|
||||
displayOrder: oldLibrary.displayOrder,
|
||||
icon: oldLibrary.icon || null,
|
||||
mediaType: oldLibrary.mediaType || null,
|
||||
provider: oldLibrary.provider,
|
||||
settings: oldLibrary.settings?.toJSON() || {},
|
||||
createdAt: oldLibrary.createdAt,
|
||||
updatedAt: oldLibrary.lastUpdate,
|
||||
extraData
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates displayOrder to be sequential
|
||||
* Used after removing a library
|
||||
*/
|
||||
static async resetDisplayOrder() {
|
||||
const libraries = await this.findAll({
|
||||
order: [['displayOrder', 'ASC']]
|
||||
})
|
||||
for (let i = 0; i < libraries.length; i++) {
|
||||
const library = libraries[i]
|
||||
if (library.displayOrder !== i + 1) {
|
||||
Logger.dev(`[Library] Updating display order of library from ${library.displayOrder} to ${i + 1}`)
|
||||
await library.update({ displayOrder: i + 1 }).catch((error) => {
|
||||
Logger.error(`[Library] Failed to update library display order to ${i + 1}`, error)
|
||||
})
|
||||
}
|
||||
/**
|
||||
* Destroy library by id
|
||||
* @param {string} libraryId
|
||||
* @returns
|
||||
*/
|
||||
static removeById(libraryId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
id: libraryId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all library ids
|
||||
* @returns {Promise<string[]>} array of library ids
|
||||
*/
|
||||
static async getAllLibraryIds() {
|
||||
const libraries = await this.findAll({
|
||||
attributes: ['id', 'displayOrder'],
|
||||
order: [['displayOrder', 'ASC']]
|
||||
})
|
||||
return libraries.map(l => l.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find Library by primary key & return oldLibrary
|
||||
* @param {string} libraryId
|
||||
* @returns {Promise<oldLibrary|null>} Returns null if not found
|
||||
*/
|
||||
static async getOldById(libraryId) {
|
||||
if (!libraryId) return null
|
||||
const library = await this.findByPk(libraryId, {
|
||||
include: this.sequelize.models.libraryFolder
|
||||
})
|
||||
if (!library) return null
|
||||
return this.getOldLibrary(library)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the largest value in the displayOrder column
|
||||
* Used for setting a new libraries display order
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
static getMaxDisplayOrder() {
|
||||
return this.max('displayOrder') || 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates displayOrder to be sequential
|
||||
* Used after removing a library
|
||||
*/
|
||||
static async resetDisplayOrder() {
|
||||
const libraries = await this.findAll({
|
||||
order: [['displayOrder', 'ASC']]
|
||||
})
|
||||
for (let i = 0; i < libraries.length; i++) {
|
||||
const library = libraries[i]
|
||||
if (library.displayOrder !== i + 1) {
|
||||
Logger.dev(`[Library] Updating display order of library from ${library.displayOrder} to ${i + 1}`)
|
||||
await library.update({ displayOrder: i + 1 }).catch((error) => {
|
||||
Logger.error(`[Library] Failed to update library display order to ${i + 1}`, error)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Library.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
name: DataTypes.STRING,
|
||||
displayOrder: DataTypes.INTEGER,
|
||||
icon: DataTypes.STRING,
|
||||
mediaType: DataTypes.STRING,
|
||||
provider: DataTypes.STRING,
|
||||
lastScan: DataTypes.DATE,
|
||||
lastScanVersion: DataTypes.STRING,
|
||||
settings: DataTypes.JSON,
|
||||
extraData: DataTypes.JSON
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'library'
|
||||
})
|
||||
/**
|
||||
* Initialize model
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
*/
|
||||
static init(sequelize) {
|
||||
super.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
name: DataTypes.STRING,
|
||||
displayOrder: DataTypes.INTEGER,
|
||||
icon: DataTypes.STRING,
|
||||
mediaType: DataTypes.STRING,
|
||||
provider: DataTypes.STRING,
|
||||
lastScan: DataTypes.DATE,
|
||||
lastScanVersion: DataTypes.STRING,
|
||||
settings: DataTypes.JSON,
|
||||
extraData: DataTypes.JSON
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'library'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return Library
|
||||
}
|
||||
module.exports = Library
|
||||
|
|
@ -1,36 +1,55 @@
|
|||
const { DataTypes, Model } = require('sequelize')
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
class LibraryFolder extends Model {
|
||||
/**
|
||||
* Gets all library folder path strings
|
||||
* @returns {Promise<string[]>} array of library folder paths
|
||||
*/
|
||||
static async getAllLibraryFolderPaths() {
|
||||
const libraryFolders = await this.findAll({
|
||||
attributes: ['path']
|
||||
})
|
||||
return libraryFolders.map(l => l.path)
|
||||
}
|
||||
class LibraryFolder extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
/** @type {UUIDV4} */
|
||||
this.id
|
||||
/** @type {string} */
|
||||
this.path
|
||||
/** @type {UUIDV4} */
|
||||
this.libraryId
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
}
|
||||
|
||||
LibraryFolder.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
path: DataTypes.STRING
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'libraryFolder'
|
||||
})
|
||||
/**
|
||||
* Gets all library folder path strings
|
||||
* @returns {Promise<string[]>} array of library folder paths
|
||||
*/
|
||||
static async getAllLibraryFolderPaths() {
|
||||
const libraryFolders = await this.findAll({
|
||||
attributes: ['path']
|
||||
})
|
||||
return libraryFolders.map(l => l.path)
|
||||
}
|
||||
|
||||
const { library } = sequelize.models
|
||||
library.hasMany(LibraryFolder, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
LibraryFolder.belongsTo(library)
|
||||
/**
|
||||
* Initialize model
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
*/
|
||||
static init(sequelize) {
|
||||
super.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
path: DataTypes.STRING
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'libraryFolder'
|
||||
})
|
||||
|
||||
return LibraryFolder
|
||||
}
|
||||
const { library } = sequelize.models
|
||||
library.hasMany(LibraryFolder, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
LibraryFolder.belongsTo(library)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = LibraryFolder
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,148 +1,184 @@
|
|||
const { DataTypes, Model } = require('sequelize')
|
||||
|
||||
/*
|
||||
* Polymorphic association: https://sequelize.org/docs/v6/advanced-association-concepts/polymorphic-associations/
|
||||
* Book has many MediaProgress. PodcastEpisode has many MediaProgress.
|
||||
*/
|
||||
module.exports = (sequelize) => {
|
||||
class MediaProgress extends Model {
|
||||
getOldMediaProgress() {
|
||||
const isPodcastEpisode = this.mediaItemType === 'podcastEpisode'
|
||||
class MediaProgress extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
userId: this.userId,
|
||||
libraryItemId: this.extraData?.libraryItemId || null,
|
||||
episodeId: isPodcastEpisode ? this.mediaItemId : null,
|
||||
mediaItemId: this.mediaItemId,
|
||||
mediaItemType: this.mediaItemType,
|
||||
duration: this.duration,
|
||||
progress: this.extraData?.progress || 0,
|
||||
currentTime: this.currentTime,
|
||||
isFinished: !!this.isFinished,
|
||||
hideFromContinueListening: !!this.hideFromContinueListening,
|
||||
ebookLocation: this.ebookLocation,
|
||||
ebookProgress: this.ebookProgress,
|
||||
lastUpdate: this.updatedAt.valueOf(),
|
||||
startedAt: this.createdAt.valueOf(),
|
||||
finishedAt: this.finishedAt?.valueOf() || null
|
||||
}
|
||||
}
|
||||
/** @type {UUIDV4} */
|
||||
this.id
|
||||
/** @type {UUIDV4} */
|
||||
this.mediaItemId
|
||||
/** @type {string} */
|
||||
this.mediaItemType
|
||||
/** @type {number} */
|
||||
this.duration
|
||||
/** @type {number} */
|
||||
this.currentTime
|
||||
/** @type {boolean} */
|
||||
this.isFinished
|
||||
/** @type {boolean} */
|
||||
this.hideFromContinueListening
|
||||
/** @type {string} */
|
||||
this.ebookLocation
|
||||
/** @type {number} */
|
||||
this.ebookProgress
|
||||
/** @type {Date} */
|
||||
this.finishedAt
|
||||
/** @type {Object} */
|
||||
this.extraData
|
||||
/** @type {UUIDV4} */
|
||||
this.userId
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
}
|
||||
|
||||
static upsertFromOld(oldMediaProgress) {
|
||||
const mediaProgress = this.getFromOld(oldMediaProgress)
|
||||
return this.upsert(mediaProgress)
|
||||
}
|
||||
getOldMediaProgress() {
|
||||
const isPodcastEpisode = this.mediaItemType === 'podcastEpisode'
|
||||
|
||||
static getFromOld(oldMediaProgress) {
|
||||
return {
|
||||
id: oldMediaProgress.id,
|
||||
userId: oldMediaProgress.userId,
|
||||
mediaItemId: oldMediaProgress.mediaItemId,
|
||||
mediaItemType: oldMediaProgress.mediaItemType,
|
||||
duration: oldMediaProgress.duration,
|
||||
currentTime: oldMediaProgress.currentTime,
|
||||
ebookLocation: oldMediaProgress.ebookLocation || null,
|
||||
ebookProgress: oldMediaProgress.ebookProgress || null,
|
||||
isFinished: !!oldMediaProgress.isFinished,
|
||||
hideFromContinueListening: !!oldMediaProgress.hideFromContinueListening,
|
||||
finishedAt: oldMediaProgress.finishedAt,
|
||||
createdAt: oldMediaProgress.startedAt || oldMediaProgress.lastUpdate,
|
||||
updatedAt: oldMediaProgress.lastUpdate,
|
||||
extraData: {
|
||||
libraryItemId: oldMediaProgress.libraryItemId,
|
||||
progress: oldMediaProgress.progress
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static removeById(mediaProgressId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
id: mediaProgressId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
getMediaItem(options) {
|
||||
if (!this.mediaItemType) return Promise.resolve(null)
|
||||
const mixinMethodName = `get${sequelize.uppercaseFirst(this.mediaItemType)}`
|
||||
return this[mixinMethodName](options)
|
||||
return {
|
||||
id: this.id,
|
||||
userId: this.userId,
|
||||
libraryItemId: this.extraData?.libraryItemId || null,
|
||||
episodeId: isPodcastEpisode ? this.mediaItemId : null,
|
||||
mediaItemId: this.mediaItemId,
|
||||
mediaItemType: this.mediaItemType,
|
||||
duration: this.duration,
|
||||
progress: this.extraData?.progress || 0,
|
||||
currentTime: this.currentTime,
|
||||
isFinished: !!this.isFinished,
|
||||
hideFromContinueListening: !!this.hideFromContinueListening,
|
||||
ebookLocation: this.ebookLocation,
|
||||
ebookProgress: this.ebookProgress,
|
||||
lastUpdate: this.updatedAt.valueOf(),
|
||||
startedAt: this.createdAt.valueOf(),
|
||||
finishedAt: this.finishedAt?.valueOf() || null
|
||||
}
|
||||
}
|
||||
|
||||
static upsertFromOld(oldMediaProgress) {
|
||||
const mediaProgress = this.getFromOld(oldMediaProgress)
|
||||
return this.upsert(mediaProgress)
|
||||
}
|
||||
|
||||
MediaProgress.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
mediaItemId: DataTypes.UUIDV4,
|
||||
mediaItemType: DataTypes.STRING,
|
||||
duration: DataTypes.FLOAT,
|
||||
currentTime: DataTypes.FLOAT,
|
||||
isFinished: DataTypes.BOOLEAN,
|
||||
hideFromContinueListening: DataTypes.BOOLEAN,
|
||||
ebookLocation: DataTypes.STRING,
|
||||
ebookProgress: DataTypes.FLOAT,
|
||||
finishedAt: DataTypes.DATE,
|
||||
extraData: DataTypes.JSON
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'mediaProgress',
|
||||
indexes: [
|
||||
{
|
||||
fields: ['updatedAt']
|
||||
static getFromOld(oldMediaProgress) {
|
||||
return {
|
||||
id: oldMediaProgress.id,
|
||||
userId: oldMediaProgress.userId,
|
||||
mediaItemId: oldMediaProgress.mediaItemId,
|
||||
mediaItemType: oldMediaProgress.mediaItemType,
|
||||
duration: oldMediaProgress.duration,
|
||||
currentTime: oldMediaProgress.currentTime,
|
||||
ebookLocation: oldMediaProgress.ebookLocation || null,
|
||||
ebookProgress: oldMediaProgress.ebookProgress || null,
|
||||
isFinished: !!oldMediaProgress.isFinished,
|
||||
hideFromContinueListening: !!oldMediaProgress.hideFromContinueListening,
|
||||
finishedAt: oldMediaProgress.finishedAt,
|
||||
createdAt: oldMediaProgress.startedAt || oldMediaProgress.lastUpdate,
|
||||
updatedAt: oldMediaProgress.lastUpdate,
|
||||
extraData: {
|
||||
libraryItemId: oldMediaProgress.libraryItemId,
|
||||
progress: oldMediaProgress.progress
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const { book, podcastEpisode, user } = sequelize.models
|
||||
|
||||
book.hasMany(MediaProgress, {
|
||||
foreignKey: 'mediaItemId',
|
||||
constraints: false,
|
||||
scope: {
|
||||
mediaItemType: 'book'
|
||||
}
|
||||
})
|
||||
MediaProgress.belongsTo(book, { foreignKey: 'mediaItemId', constraints: false })
|
||||
}
|
||||
|
||||
podcastEpisode.hasMany(MediaProgress, {
|
||||
foreignKey: 'mediaItemId',
|
||||
constraints: false,
|
||||
scope: {
|
||||
mediaItemType: 'podcastEpisode'
|
||||
}
|
||||
})
|
||||
MediaProgress.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false })
|
||||
|
||||
MediaProgress.addHook('afterFind', findResult => {
|
||||
if (!findResult) return
|
||||
|
||||
if (!Array.isArray(findResult)) findResult = [findResult]
|
||||
|
||||
for (const instance of findResult) {
|
||||
if (instance.mediaItemType === 'book' && instance.book !== undefined) {
|
||||
instance.mediaItem = instance.book
|
||||
instance.dataValues.mediaItem = instance.dataValues.book
|
||||
} else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) {
|
||||
instance.mediaItem = instance.podcastEpisode
|
||||
instance.dataValues.mediaItem = instance.dataValues.podcastEpisode
|
||||
static removeById(mediaProgressId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
id: mediaProgressId
|
||||
}
|
||||
// To prevent mistakes:
|
||||
delete instance.book
|
||||
delete instance.dataValues.book
|
||||
delete instance.podcastEpisode
|
||||
delete instance.dataValues.podcastEpisode
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
user.hasMany(MediaProgress, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
MediaProgress.belongsTo(user)
|
||||
getMediaItem(options) {
|
||||
if (!this.mediaItemType) return Promise.resolve(null)
|
||||
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaItemType)}`
|
||||
return this[mixinMethodName](options)
|
||||
}
|
||||
|
||||
return MediaProgress
|
||||
}
|
||||
/**
|
||||
* Initialize model
|
||||
*
|
||||
* Polymorphic association: Book has many MediaProgress. PodcastEpisode has many MediaProgress.
|
||||
* @see https://sequelize.org/docs/v6/advanced-association-concepts/polymorphic-associations/
|
||||
*
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
*/
|
||||
static init(sequelize) {
|
||||
super.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
mediaItemId: DataTypes.UUIDV4,
|
||||
mediaItemType: DataTypes.STRING,
|
||||
duration: DataTypes.FLOAT,
|
||||
currentTime: DataTypes.FLOAT,
|
||||
isFinished: DataTypes.BOOLEAN,
|
||||
hideFromContinueListening: DataTypes.BOOLEAN,
|
||||
ebookLocation: DataTypes.STRING,
|
||||
ebookProgress: DataTypes.FLOAT,
|
||||
finishedAt: DataTypes.DATE,
|
||||
extraData: DataTypes.JSON
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'mediaProgress',
|
||||
indexes: [
|
||||
{
|
||||
fields: ['updatedAt']
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const { book, podcastEpisode, user } = sequelize.models
|
||||
|
||||
book.hasMany(MediaProgress, {
|
||||
foreignKey: 'mediaItemId',
|
||||
constraints: false,
|
||||
scope: {
|
||||
mediaItemType: 'book'
|
||||
}
|
||||
})
|
||||
MediaProgress.belongsTo(book, { foreignKey: 'mediaItemId', constraints: false })
|
||||
|
||||
podcastEpisode.hasMany(MediaProgress, {
|
||||
foreignKey: 'mediaItemId',
|
||||
constraints: false,
|
||||
scope: {
|
||||
mediaItemType: 'podcastEpisode'
|
||||
}
|
||||
})
|
||||
MediaProgress.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false })
|
||||
|
||||
MediaProgress.addHook('afterFind', findResult => {
|
||||
if (!findResult) return
|
||||
|
||||
if (!Array.isArray(findResult)) findResult = [findResult]
|
||||
|
||||
for (const instance of findResult) {
|
||||
if (instance.mediaItemType === 'book' && instance.book !== undefined) {
|
||||
instance.mediaItem = instance.book
|
||||
instance.dataValues.mediaItem = instance.dataValues.book
|
||||
} else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) {
|
||||
instance.mediaItem = instance.podcastEpisode
|
||||
instance.dataValues.mediaItem = instance.dataValues.podcastEpisode
|
||||
}
|
||||
// To prevent mistakes:
|
||||
delete instance.book
|
||||
delete instance.dataValues.book
|
||||
delete instance.podcastEpisode
|
||||
delete instance.dataValues.podcastEpisode
|
||||
}
|
||||
})
|
||||
|
||||
user.hasMany(MediaProgress, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
MediaProgress.belongsTo(user)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MediaProgress
|
||||
|
|
@ -2,197 +2,251 @@ const { DataTypes, Model } = require('sequelize')
|
|||
|
||||
const oldPlaybackSession = require('../objects/PlaybackSession')
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
class PlaybackSession extends Model {
|
||||
static async getOldPlaybackSessions(where = null) {
|
||||
const playbackSessions = await this.findAll({
|
||||
where,
|
||||
include: [
|
||||
{
|
||||
model: sequelize.models.device
|
||||
}
|
||||
]
|
||||
})
|
||||
return playbackSessions.map(session => this.getOldPlaybackSession(session))
|
||||
}
|
||||
|
||||
static async getById(sessionId) {
|
||||
const playbackSession = await this.findByPk(sessionId, {
|
||||
include: [
|
||||
{
|
||||
model: sequelize.models.device
|
||||
}
|
||||
]
|
||||
})
|
||||
if (!playbackSession) return null
|
||||
return this.getOldPlaybackSession(playbackSession)
|
||||
}
|
||||
class PlaybackSession extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
static getOldPlaybackSession(playbackSessionExpanded) {
|
||||
const isPodcastEpisode = playbackSessionExpanded.mediaItemType === 'podcastEpisode'
|
||||
/** @type {UUIDV4} */
|
||||
this.id
|
||||
/** @type {UUIDV4} */
|
||||
this.mediaItemId
|
||||
/** @type {string} */
|
||||
this.mediaItemType
|
||||
/** @type {string} */
|
||||
this.displayTitle
|
||||
/** @type {string} */
|
||||
this.displayAuthor
|
||||
/** @type {number} */
|
||||
this.duration
|
||||
/** @type {number} */
|
||||
this.playMethod
|
||||
/** @type {string} */
|
||||
this.mediaPlayer
|
||||
/** @type {number} */
|
||||
this.startTime
|
||||
/** @type {number} */
|
||||
this.currentTime
|
||||
/** @type {string} */
|
||||
this.serverVersion
|
||||
/** @type {string} */
|
||||
this.coverPath
|
||||
/** @type {number} */
|
||||
this.timeListening
|
||||
/** @type {Object} */
|
||||
this.mediaMetadata
|
||||
/** @type {string} */
|
||||
this.date
|
||||
/** @type {string} */
|
||||
this.dayOfWeek
|
||||
/** @type {Object} */
|
||||
this.extraData
|
||||
/** @type {UUIDV4} */
|
||||
this.userId
|
||||
/** @type {UUIDV4} */
|
||||
this.deviceId
|
||||
/** @type {UUIDV4} */
|
||||
this.libraryId
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
}
|
||||
|
||||
return new oldPlaybackSession({
|
||||
id: playbackSessionExpanded.id,
|
||||
userId: playbackSessionExpanded.userId,
|
||||
libraryId: playbackSessionExpanded.libraryId,
|
||||
libraryItemId: playbackSessionExpanded.extraData?.libraryItemId || null,
|
||||
bookId: isPodcastEpisode ? null : playbackSessionExpanded.mediaItemId,
|
||||
episodeId: isPodcastEpisode ? playbackSessionExpanded.mediaItemId : null,
|
||||
mediaType: isPodcastEpisode ? 'podcast' : 'book',
|
||||
mediaMetadata: playbackSessionExpanded.mediaMetadata,
|
||||
chapters: null,
|
||||
displayTitle: playbackSessionExpanded.displayTitle,
|
||||
displayAuthor: playbackSessionExpanded.displayAuthor,
|
||||
coverPath: playbackSessionExpanded.coverPath,
|
||||
duration: playbackSessionExpanded.duration,
|
||||
playMethod: playbackSessionExpanded.playMethod,
|
||||
mediaPlayer: playbackSessionExpanded.mediaPlayer,
|
||||
deviceInfo: playbackSessionExpanded.device?.getOldDevice() || null,
|
||||
serverVersion: playbackSessionExpanded.serverVersion,
|
||||
date: playbackSessionExpanded.date,
|
||||
dayOfWeek: playbackSessionExpanded.dayOfWeek,
|
||||
timeListening: playbackSessionExpanded.timeListening,
|
||||
startTime: playbackSessionExpanded.startTime,
|
||||
currentTime: playbackSessionExpanded.currentTime,
|
||||
startedAt: playbackSessionExpanded.createdAt.valueOf(),
|
||||
updatedAt: playbackSessionExpanded.updatedAt.valueOf()
|
||||
})
|
||||
}
|
||||
|
||||
static removeById(sessionId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
id: sessionId
|
||||
static async getOldPlaybackSessions(where = null) {
|
||||
const playbackSessions = await this.findAll({
|
||||
where,
|
||||
include: [
|
||||
{
|
||||
model: this.sequelize.models.device
|
||||
}
|
||||
})
|
||||
}
|
||||
]
|
||||
})
|
||||
return playbackSessions.map(session => this.getOldPlaybackSession(session))
|
||||
}
|
||||
|
||||
static createFromOld(oldPlaybackSession) {
|
||||
const playbackSession = this.getFromOld(oldPlaybackSession)
|
||||
return this.create(playbackSession)
|
||||
}
|
||||
|
||||
static updateFromOld(oldPlaybackSession) {
|
||||
const playbackSession = this.getFromOld(oldPlaybackSession)
|
||||
return this.update(playbackSession, {
|
||||
where: {
|
||||
id: playbackSession.id
|
||||
static async getById(sessionId) {
|
||||
const playbackSession = await this.findByPk(sessionId, {
|
||||
include: [
|
||||
{
|
||||
model: this.sequelize.models.device
|
||||
}
|
||||
})
|
||||
}
|
||||
]
|
||||
})
|
||||
if (!playbackSession) return null
|
||||
return this.getOldPlaybackSession(playbackSession)
|
||||
}
|
||||
|
||||
static getFromOld(oldPlaybackSession) {
|
||||
return {
|
||||
id: oldPlaybackSession.id,
|
||||
mediaItemId: oldPlaybackSession.episodeId || oldPlaybackSession.bookId,
|
||||
mediaItemType: oldPlaybackSession.episodeId ? 'podcastEpisode' : 'book',
|
||||
libraryId: oldPlaybackSession.libraryId,
|
||||
displayTitle: oldPlaybackSession.displayTitle,
|
||||
displayAuthor: oldPlaybackSession.displayAuthor,
|
||||
duration: oldPlaybackSession.duration,
|
||||
playMethod: oldPlaybackSession.playMethod,
|
||||
mediaPlayer: oldPlaybackSession.mediaPlayer,
|
||||
startTime: oldPlaybackSession.startTime,
|
||||
currentTime: oldPlaybackSession.currentTime,
|
||||
serverVersion: oldPlaybackSession.serverVersion || null,
|
||||
createdAt: oldPlaybackSession.startedAt,
|
||||
updatedAt: oldPlaybackSession.updatedAt,
|
||||
userId: oldPlaybackSession.userId,
|
||||
deviceId: oldPlaybackSession.deviceInfo?.id || null,
|
||||
timeListening: oldPlaybackSession.timeListening,
|
||||
coverPath: oldPlaybackSession.coverPath,
|
||||
mediaMetadata: oldPlaybackSession.mediaMetadata,
|
||||
date: oldPlaybackSession.date,
|
||||
dayOfWeek: oldPlaybackSession.dayOfWeek,
|
||||
extraData: {
|
||||
libraryItemId: oldPlaybackSession.libraryItemId
|
||||
}
|
||||
static getOldPlaybackSession(playbackSessionExpanded) {
|
||||
const isPodcastEpisode = playbackSessionExpanded.mediaItemType === 'podcastEpisode'
|
||||
|
||||
return new oldPlaybackSession({
|
||||
id: playbackSessionExpanded.id,
|
||||
userId: playbackSessionExpanded.userId,
|
||||
libraryId: playbackSessionExpanded.libraryId,
|
||||
libraryItemId: playbackSessionExpanded.extraData?.libraryItemId || null,
|
||||
bookId: isPodcastEpisode ? null : playbackSessionExpanded.mediaItemId,
|
||||
episodeId: isPodcastEpisode ? playbackSessionExpanded.mediaItemId : null,
|
||||
mediaType: isPodcastEpisode ? 'podcast' : 'book',
|
||||
mediaMetadata: playbackSessionExpanded.mediaMetadata,
|
||||
chapters: null,
|
||||
displayTitle: playbackSessionExpanded.displayTitle,
|
||||
displayAuthor: playbackSessionExpanded.displayAuthor,
|
||||
coverPath: playbackSessionExpanded.coverPath,
|
||||
duration: playbackSessionExpanded.duration,
|
||||
playMethod: playbackSessionExpanded.playMethod,
|
||||
mediaPlayer: playbackSessionExpanded.mediaPlayer,
|
||||
deviceInfo: playbackSessionExpanded.device?.getOldDevice() || null,
|
||||
serverVersion: playbackSessionExpanded.serverVersion,
|
||||
date: playbackSessionExpanded.date,
|
||||
dayOfWeek: playbackSessionExpanded.dayOfWeek,
|
||||
timeListening: playbackSessionExpanded.timeListening,
|
||||
startTime: playbackSessionExpanded.startTime,
|
||||
currentTime: playbackSessionExpanded.currentTime,
|
||||
startedAt: playbackSessionExpanded.createdAt.valueOf(),
|
||||
updatedAt: playbackSessionExpanded.updatedAt.valueOf()
|
||||
})
|
||||
}
|
||||
|
||||
static removeById(sessionId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
id: sessionId
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
getMediaItem(options) {
|
||||
if (!this.mediaItemType) return Promise.resolve(null)
|
||||
const mixinMethodName = `get${sequelize.uppercaseFirst(this.mediaItemType)}`
|
||||
return this[mixinMethodName](options)
|
||||
static createFromOld(oldPlaybackSession) {
|
||||
const playbackSession = this.getFromOld(oldPlaybackSession)
|
||||
return this.create(playbackSession)
|
||||
}
|
||||
|
||||
static updateFromOld(oldPlaybackSession) {
|
||||
const playbackSession = this.getFromOld(oldPlaybackSession)
|
||||
return this.update(playbackSession, {
|
||||
where: {
|
||||
id: playbackSession.id
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static getFromOld(oldPlaybackSession) {
|
||||
return {
|
||||
id: oldPlaybackSession.id,
|
||||
mediaItemId: oldPlaybackSession.episodeId || oldPlaybackSession.bookId,
|
||||
mediaItemType: oldPlaybackSession.episodeId ? 'podcastEpisode' : 'book',
|
||||
libraryId: oldPlaybackSession.libraryId,
|
||||
displayTitle: oldPlaybackSession.displayTitle,
|
||||
displayAuthor: oldPlaybackSession.displayAuthor,
|
||||
duration: oldPlaybackSession.duration,
|
||||
playMethod: oldPlaybackSession.playMethod,
|
||||
mediaPlayer: oldPlaybackSession.mediaPlayer,
|
||||
startTime: oldPlaybackSession.startTime,
|
||||
currentTime: oldPlaybackSession.currentTime,
|
||||
serverVersion: oldPlaybackSession.serverVersion || null,
|
||||
createdAt: oldPlaybackSession.startedAt,
|
||||
updatedAt: oldPlaybackSession.updatedAt,
|
||||
userId: oldPlaybackSession.userId,
|
||||
deviceId: oldPlaybackSession.deviceInfo?.id || null,
|
||||
timeListening: oldPlaybackSession.timeListening,
|
||||
coverPath: oldPlaybackSession.coverPath,
|
||||
mediaMetadata: oldPlaybackSession.mediaMetadata,
|
||||
date: oldPlaybackSession.date,
|
||||
dayOfWeek: oldPlaybackSession.dayOfWeek,
|
||||
extraData: {
|
||||
libraryItemId: oldPlaybackSession.libraryItemId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PlaybackSession.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
mediaItemId: DataTypes.UUIDV4,
|
||||
mediaItemType: DataTypes.STRING,
|
||||
displayTitle: DataTypes.STRING,
|
||||
displayAuthor: DataTypes.STRING,
|
||||
duration: DataTypes.FLOAT,
|
||||
playMethod: DataTypes.INTEGER,
|
||||
mediaPlayer: DataTypes.STRING,
|
||||
startTime: DataTypes.FLOAT,
|
||||
currentTime: DataTypes.FLOAT,
|
||||
serverVersion: DataTypes.STRING,
|
||||
coverPath: DataTypes.STRING,
|
||||
timeListening: DataTypes.INTEGER,
|
||||
mediaMetadata: DataTypes.JSON,
|
||||
date: DataTypes.STRING,
|
||||
dayOfWeek: DataTypes.STRING,
|
||||
extraData: DataTypes.JSON
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'playbackSession'
|
||||
})
|
||||
getMediaItem(options) {
|
||||
if (!this.mediaItemType) return Promise.resolve(null)
|
||||
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaItemType)}`
|
||||
return this[mixinMethodName](options)
|
||||
}
|
||||
|
||||
const { book, podcastEpisode, user, device, library } = sequelize.models
|
||||
/**
|
||||
* Initialize model
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
*/
|
||||
static init(sequelize) {
|
||||
super.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
mediaItemId: DataTypes.UUIDV4,
|
||||
mediaItemType: DataTypes.STRING,
|
||||
displayTitle: DataTypes.STRING,
|
||||
displayAuthor: DataTypes.STRING,
|
||||
duration: DataTypes.FLOAT,
|
||||
playMethod: DataTypes.INTEGER,
|
||||
mediaPlayer: DataTypes.STRING,
|
||||
startTime: DataTypes.FLOAT,
|
||||
currentTime: DataTypes.FLOAT,
|
||||
serverVersion: DataTypes.STRING,
|
||||
coverPath: DataTypes.STRING,
|
||||
timeListening: DataTypes.INTEGER,
|
||||
mediaMetadata: DataTypes.JSON,
|
||||
date: DataTypes.STRING,
|
||||
dayOfWeek: DataTypes.STRING,
|
||||
extraData: DataTypes.JSON
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'playbackSession'
|
||||
})
|
||||
|
||||
user.hasMany(PlaybackSession)
|
||||
PlaybackSession.belongsTo(user)
|
||||
const { book, podcastEpisode, user, device, library } = sequelize.models
|
||||
|
||||
device.hasMany(PlaybackSession)
|
||||
PlaybackSession.belongsTo(device)
|
||||
user.hasMany(PlaybackSession)
|
||||
PlaybackSession.belongsTo(user)
|
||||
|
||||
library.hasMany(PlaybackSession)
|
||||
PlaybackSession.belongsTo(library)
|
||||
device.hasMany(PlaybackSession)
|
||||
PlaybackSession.belongsTo(device)
|
||||
|
||||
book.hasMany(PlaybackSession, {
|
||||
foreignKey: 'mediaItemId',
|
||||
constraints: false,
|
||||
scope: {
|
||||
mediaItemType: 'book'
|
||||
}
|
||||
})
|
||||
PlaybackSession.belongsTo(book, { foreignKey: 'mediaItemId', constraints: false })
|
||||
library.hasMany(PlaybackSession)
|
||||
PlaybackSession.belongsTo(library)
|
||||
|
||||
podcastEpisode.hasOne(PlaybackSession, {
|
||||
foreignKey: 'mediaItemId',
|
||||
constraints: false,
|
||||
scope: {
|
||||
mediaItemType: 'podcastEpisode'
|
||||
}
|
||||
})
|
||||
PlaybackSession.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false })
|
||||
|
||||
PlaybackSession.addHook('afterFind', findResult => {
|
||||
if (!findResult) return
|
||||
|
||||
if (!Array.isArray(findResult)) findResult = [findResult]
|
||||
|
||||
for (const instance of findResult) {
|
||||
if (instance.mediaItemType === 'book' && instance.book !== undefined) {
|
||||
instance.mediaItem = instance.book
|
||||
instance.dataValues.mediaItem = instance.dataValues.book
|
||||
} else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) {
|
||||
instance.mediaItem = instance.podcastEpisode
|
||||
instance.dataValues.mediaItem = instance.dataValues.podcastEpisode
|
||||
book.hasMany(PlaybackSession, {
|
||||
foreignKey: 'mediaItemId',
|
||||
constraints: false,
|
||||
scope: {
|
||||
mediaItemType: 'book'
|
||||
}
|
||||
// To prevent mistakes:
|
||||
delete instance.book
|
||||
delete instance.dataValues.book
|
||||
delete instance.podcastEpisode
|
||||
delete instance.dataValues.podcastEpisode
|
||||
}
|
||||
})
|
||||
})
|
||||
PlaybackSession.belongsTo(book, { foreignKey: 'mediaItemId', constraints: false })
|
||||
|
||||
return PlaybackSession
|
||||
}
|
||||
podcastEpisode.hasOne(PlaybackSession, {
|
||||
foreignKey: 'mediaItemId',
|
||||
constraints: false,
|
||||
scope: {
|
||||
mediaItemType: 'podcastEpisode'
|
||||
}
|
||||
})
|
||||
PlaybackSession.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false })
|
||||
|
||||
PlaybackSession.addHook('afterFind', findResult => {
|
||||
if (!findResult) return
|
||||
|
||||
if (!Array.isArray(findResult)) findResult = [findResult]
|
||||
|
||||
for (const instance of findResult) {
|
||||
if (instance.mediaItemType === 'book' && instance.book !== undefined) {
|
||||
instance.mediaItem = instance.book
|
||||
instance.dataValues.mediaItem = instance.dataValues.book
|
||||
} else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) {
|
||||
instance.mediaItem = instance.podcastEpisode
|
||||
instance.dataValues.mediaItem = instance.dataValues.podcastEpisode
|
||||
}
|
||||
// To prevent mistakes:
|
||||
delete instance.book
|
||||
delete instance.dataValues.book
|
||||
delete instance.podcastEpisode
|
||||
delete instance.dataValues.podcastEpisode
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PlaybackSession
|
||||
|
|
|
|||
|
|
@ -1,312 +1,343 @@
|
|||
const { DataTypes, Model, Op } = require('sequelize')
|
||||
const { DataTypes, Model, Op, literal } = require('sequelize')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
const oldPlaylist = require('../objects/Playlist')
|
||||
const { areEquivalent } = require('../utils/index')
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
class Playlist extends Model {
|
||||
static async getOldPlaylists() {
|
||||
const playlists = await this.findAll({
|
||||
include: {
|
||||
model: sequelize.models.playlistMediaItem,
|
||||
include: [
|
||||
{
|
||||
model: sequelize.models.book,
|
||||
include: sequelize.models.libraryItem
|
||||
},
|
||||
{
|
||||
model: sequelize.models.podcastEpisode,
|
||||
include: {
|
||||
model: sequelize.models.podcast,
|
||||
include: sequelize.models.libraryItem
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
order: [['playlistMediaItems', 'order', 'ASC']]
|
||||
})
|
||||
return playlists.map(p => this.getOldPlaylist(p))
|
||||
}
|
||||
class Playlist extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
static getOldPlaylist(playlistExpanded) {
|
||||
const items = playlistExpanded.playlistMediaItems.map(pmi => {
|
||||
const libraryItemId = pmi.mediaItem?.podcast?.libraryItem?.id || pmi.mediaItem?.libraryItem?.id || null
|
||||
if (!libraryItemId) {
|
||||
Logger.error(`[Playlist] Invalid playlist media item - No library item id found`, JSON.stringify(pmi, null, 2))
|
||||
return null
|
||||
}
|
||||
return {
|
||||
episodeId: pmi.mediaItemType === 'podcastEpisode' ? pmi.mediaItemId : '',
|
||||
libraryItemId
|
||||
}
|
||||
}).filter(pmi => pmi)
|
||||
/** @type {UUIDV4} */
|
||||
this.id
|
||||
/** @type {string} */
|
||||
this.name
|
||||
/** @type {string} */
|
||||
this.description
|
||||
/** @type {UUIDV4} */
|
||||
this.libraryId
|
||||
/** @type {UUIDV4} */
|
||||
this.userId
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
}
|
||||
|
||||
return new oldPlaylist({
|
||||
id: playlistExpanded.id,
|
||||
libraryId: playlistExpanded.libraryId,
|
||||
userId: playlistExpanded.userId,
|
||||
name: playlistExpanded.name,
|
||||
description: playlistExpanded.description,
|
||||
items,
|
||||
lastUpdate: playlistExpanded.updatedAt.valueOf(),
|
||||
createdAt: playlistExpanded.createdAt.valueOf()
|
||||
})
|
||||
}
|
||||
|
||||
static createFromOld(oldPlaylist) {
|
||||
const playlist = this.getFromOld(oldPlaylist)
|
||||
return this.create(playlist)
|
||||
}
|
||||
|
||||
static async fullUpdateFromOld(oldPlaylist, playlistMediaItems) {
|
||||
const existingPlaylist = await this.findByPk(oldPlaylist.id, {
|
||||
include: sequelize.models.playlistMediaItem
|
||||
})
|
||||
if (!existingPlaylist) return false
|
||||
|
||||
let hasUpdates = false
|
||||
const playlist = this.getFromOld(oldPlaylist)
|
||||
|
||||
for (const pmi of playlistMediaItems) {
|
||||
const existingPmi = existingPlaylist.playlistMediaItems.find(i => i.mediaItemId === pmi.mediaItemId)
|
||||
if (!existingPmi) {
|
||||
await sequelize.models.playlistMediaItem.create(pmi)
|
||||
hasUpdates = true
|
||||
} else if (existingPmi.order != pmi.order) {
|
||||
await existingPmi.update({ order: pmi.order })
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
for (const pmi of existingPlaylist.playlistMediaItems) {
|
||||
// Pmi was removed
|
||||
if (!playlistMediaItems.some(i => i.mediaItemId === pmi.mediaItemId)) {
|
||||
await pmi.destroy()
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
let hasPlaylistUpdates = false
|
||||
for (const key in playlist) {
|
||||
let existingValue = existingPlaylist[key]
|
||||
if (existingValue instanceof Date) existingValue = existingValue.valueOf()
|
||||
|
||||
if (!areEquivalent(playlist[key], existingValue)) {
|
||||
hasPlaylistUpdates = true
|
||||
}
|
||||
}
|
||||
if (hasPlaylistUpdates) {
|
||||
existingPlaylist.update(playlist)
|
||||
hasUpdates = true
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
static getFromOld(oldPlaylist) {
|
||||
return {
|
||||
id: oldPlaylist.id,
|
||||
name: oldPlaylist.name,
|
||||
description: oldPlaylist.description,
|
||||
userId: oldPlaylist.userId,
|
||||
libraryId: oldPlaylist.libraryId
|
||||
}
|
||||
}
|
||||
|
||||
static removeById(playlistId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
id: playlistId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get playlist by id
|
||||
* @param {string} playlistId
|
||||
* @returns {Promise<oldPlaylist|null>} returns null if not found
|
||||
*/
|
||||
static async getById(playlistId) {
|
||||
if (!playlistId) return null
|
||||
const playlist = await this.findByPk(playlistId, {
|
||||
include: {
|
||||
model: sequelize.models.playlistMediaItem,
|
||||
include: [
|
||||
{
|
||||
model: sequelize.models.book,
|
||||
include: sequelize.models.libraryItem
|
||||
},
|
||||
{
|
||||
model: sequelize.models.podcastEpisode,
|
||||
include: {
|
||||
model: sequelize.models.podcast,
|
||||
include: sequelize.models.libraryItem
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
order: [['playlistMediaItems', 'order', 'ASC']]
|
||||
})
|
||||
if (!playlist) return null
|
||||
return this.getOldPlaylist(playlist)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get playlists for user and optionally for library
|
||||
* @param {string} userId
|
||||
* @param {[string]} libraryId optional
|
||||
* @returns {Promise<oldPlaylist[]>}
|
||||
*/
|
||||
static async getPlaylistsForUserAndLibrary(userId, libraryId = null) {
|
||||
if (!userId && !libraryId) return []
|
||||
const whereQuery = {}
|
||||
if (userId) {
|
||||
whereQuery.userId = userId
|
||||
}
|
||||
if (libraryId) {
|
||||
whereQuery.libraryId = libraryId
|
||||
}
|
||||
const playlists = await this.findAll({
|
||||
where: whereQuery,
|
||||
include: {
|
||||
model: sequelize.models.playlistMediaItem,
|
||||
include: [
|
||||
{
|
||||
model: sequelize.models.book,
|
||||
include: sequelize.models.libraryItem
|
||||
},
|
||||
{
|
||||
model: sequelize.models.podcastEpisode,
|
||||
include: {
|
||||
model: sequelize.models.podcast,
|
||||
include: sequelize.models.libraryItem
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
order: [['playlistMediaItems', 'order', 'ASC']]
|
||||
})
|
||||
return playlists.map(p => this.getOldPlaylist(p))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of playlists for a user and library
|
||||
* @param {string} userId
|
||||
* @param {string} libraryId
|
||||
* @returns
|
||||
*/
|
||||
static async getNumPlaylistsForUserAndLibrary(userId, libraryId) {
|
||||
return this.count({
|
||||
where: {
|
||||
userId,
|
||||
libraryId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all playlists for mediaItemIds
|
||||
* @param {string[]} mediaItemIds
|
||||
* @returns {Promise<oldPlaylist[]>}
|
||||
*/
|
||||
static async getPlaylistsForMediaItemIds(mediaItemIds) {
|
||||
if (!mediaItemIds?.length) return []
|
||||
|
||||
const playlistMediaItemsExpanded = await sequelize.models.playlistMediaItem.findAll({
|
||||
where: {
|
||||
mediaItemId: {
|
||||
[Op.in]: mediaItemIds
|
||||
}
|
||||
},
|
||||
static async getOldPlaylists() {
|
||||
const playlists = await this.findAll({
|
||||
include: {
|
||||
model: this.sequelize.models.playlistMediaItem,
|
||||
include: [
|
||||
{
|
||||
model: sequelize.models.playlist,
|
||||
model: this.sequelize.models.book,
|
||||
include: this.sequelize.models.libraryItem
|
||||
},
|
||||
{
|
||||
model: this.sequelize.models.podcastEpisode,
|
||||
include: {
|
||||
model: sequelize.models.playlistMediaItem,
|
||||
include: [
|
||||
{
|
||||
model: sequelize.models.book,
|
||||
include: sequelize.models.libraryItem
|
||||
},
|
||||
{
|
||||
model: sequelize.models.podcastEpisode,
|
||||
include: {
|
||||
model: sequelize.models.podcast,
|
||||
include: sequelize.models.libraryItem
|
||||
}
|
||||
}
|
||||
]
|
||||
model: this.sequelize.models.podcast,
|
||||
include: this.sequelize.models.libraryItem
|
||||
}
|
||||
}
|
||||
],
|
||||
order: [['playlist', 'playlistMediaItems', 'order', 'ASC']]
|
||||
})
|
||||
return playlistMediaItemsExpanded.map(pmie => {
|
||||
pmie.playlist.playlistMediaItems = pmie.playlist.playlistMediaItems.map(pmi => {
|
||||
if (pmi.mediaItemType === 'book' && pmi.book !== undefined) {
|
||||
pmi.mediaItem = pmi.book
|
||||
pmi.dataValues.mediaItem = pmi.dataValues.book
|
||||
} else if (pmi.mediaItemType === 'podcastEpisode' && pmi.podcastEpisode !== undefined) {
|
||||
pmi.mediaItem = pmi.podcastEpisode
|
||||
pmi.dataValues.mediaItem = pmi.dataValues.podcastEpisode
|
||||
}
|
||||
delete pmi.book
|
||||
delete pmi.dataValues.book
|
||||
delete pmi.podcastEpisode
|
||||
delete pmi.dataValues.podcastEpisode
|
||||
return pmi
|
||||
})
|
||||
]
|
||||
},
|
||||
order: [['playlistMediaItems', 'order', 'ASC']]
|
||||
})
|
||||
return playlists.map(p => this.getOldPlaylist(p))
|
||||
}
|
||||
|
||||
return this.getOldPlaylist(pmie.playlist)
|
||||
})
|
||||
static getOldPlaylist(playlistExpanded) {
|
||||
const items = playlistExpanded.playlistMediaItems.map(pmi => {
|
||||
const libraryItemId = pmi.mediaItem?.podcast?.libraryItem?.id || pmi.mediaItem?.libraryItem?.id || null
|
||||
if (!libraryItemId) {
|
||||
Logger.error(`[Playlist] Invalid playlist media item - No library item id found`, JSON.stringify(pmi, null, 2))
|
||||
return null
|
||||
}
|
||||
return {
|
||||
episodeId: pmi.mediaItemType === 'podcastEpisode' ? pmi.mediaItemId : '',
|
||||
libraryItemId
|
||||
}
|
||||
}).filter(pmi => pmi)
|
||||
|
||||
return new oldPlaylist({
|
||||
id: playlistExpanded.id,
|
||||
libraryId: playlistExpanded.libraryId,
|
||||
userId: playlistExpanded.userId,
|
||||
name: playlistExpanded.name,
|
||||
description: playlistExpanded.description,
|
||||
items,
|
||||
lastUpdate: playlistExpanded.updatedAt.valueOf(),
|
||||
createdAt: playlistExpanded.createdAt.valueOf()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get old playlist toJSONExpanded
|
||||
* @param {[string[]]} include
|
||||
* @returns {Promise<object>} oldPlaylist.toJSONExpanded
|
||||
*/
|
||||
async getOldJsonExpanded(include) {
|
||||
this.playlistMediaItems = await this.getPlaylistMediaItems({
|
||||
include: [
|
||||
{
|
||||
model: this.sequelize.models.book,
|
||||
include: this.sequelize.models.libraryItem
|
||||
},
|
||||
{
|
||||
model: this.sequelize.models.podcastEpisode,
|
||||
include: {
|
||||
model: this.sequelize.models.podcast,
|
||||
include: this.sequelize.models.libraryItem
|
||||
}
|
||||
}
|
||||
],
|
||||
order: [['order', 'ASC']]
|
||||
}) || []
|
||||
|
||||
const oldPlaylist = this.sequelize.models.playlist.getOldPlaylist(this)
|
||||
const libraryItemIds = oldPlaylist.items.map(i => i.libraryItemId)
|
||||
|
||||
let libraryItems = await this.sequelize.models.libraryItem.getAllOldLibraryItems({
|
||||
id: libraryItemIds
|
||||
})
|
||||
|
||||
const playlistExpanded = oldPlaylist.toJSONExpanded(libraryItems)
|
||||
|
||||
if (include?.includes('rssfeed')) {
|
||||
const feeds = await this.getFeeds()
|
||||
if (feeds?.length) {
|
||||
playlistExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(feeds[0])
|
||||
}
|
||||
}
|
||||
|
||||
return playlistExpanded
|
||||
}
|
||||
|
||||
static createFromOld(oldPlaylist) {
|
||||
const playlist = this.getFromOld(oldPlaylist)
|
||||
return this.create(playlist)
|
||||
}
|
||||
|
||||
static getFromOld(oldPlaylist) {
|
||||
return {
|
||||
id: oldPlaylist.id,
|
||||
name: oldPlaylist.name,
|
||||
description: oldPlaylist.description,
|
||||
userId: oldPlaylist.userId,
|
||||
libraryId: oldPlaylist.libraryId
|
||||
}
|
||||
}
|
||||
|
||||
Playlist.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
name: DataTypes.STRING,
|
||||
description: DataTypes.TEXT
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'playlist'
|
||||
})
|
||||
|
||||
const { library, user } = sequelize.models
|
||||
library.hasMany(Playlist)
|
||||
Playlist.belongsTo(library)
|
||||
|
||||
user.hasMany(Playlist)
|
||||
Playlist.belongsTo(user)
|
||||
|
||||
Playlist.addHook('afterFind', findResult => {
|
||||
if (!findResult) return
|
||||
|
||||
if (!Array.isArray(findResult)) findResult = [findResult]
|
||||
|
||||
for (const instance of findResult) {
|
||||
if (instance.playlistMediaItems?.length) {
|
||||
instance.playlistMediaItems = instance.playlistMediaItems.map(pmi => {
|
||||
if (pmi.mediaItemType === 'book' && pmi.book !== undefined) {
|
||||
pmi.mediaItem = pmi.book
|
||||
pmi.dataValues.mediaItem = pmi.dataValues.book
|
||||
} else if (pmi.mediaItemType === 'podcastEpisode' && pmi.podcastEpisode !== undefined) {
|
||||
pmi.mediaItem = pmi.podcastEpisode
|
||||
pmi.dataValues.mediaItem = pmi.dataValues.podcastEpisode
|
||||
}
|
||||
// To prevent mistakes:
|
||||
delete pmi.book
|
||||
delete pmi.dataValues.book
|
||||
delete pmi.podcastEpisode
|
||||
delete pmi.dataValues.podcastEpisode
|
||||
return pmi
|
||||
})
|
||||
static removeById(playlistId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
id: playlistId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get playlist by id
|
||||
* @param {string} playlistId
|
||||
* @returns {Promise<oldPlaylist|null>} returns null if not found
|
||||
*/
|
||||
static async getById(playlistId) {
|
||||
if (!playlistId) return null
|
||||
const playlist = await this.findByPk(playlistId, {
|
||||
include: {
|
||||
model: this.sequelize.models.playlistMediaItem,
|
||||
include: [
|
||||
{
|
||||
model: this.sequelize.models.book,
|
||||
include: this.sequelize.models.libraryItem
|
||||
},
|
||||
{
|
||||
model: this.sequelize.models.podcastEpisode,
|
||||
include: {
|
||||
model: this.sequelize.models.podcast,
|
||||
include: this.sequelize.models.libraryItem
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
order: [['playlistMediaItems', 'order', 'ASC']]
|
||||
})
|
||||
if (!playlist) return null
|
||||
return this.getOldPlaylist(playlist)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get playlists for user and optionally for library
|
||||
* @param {string} userId
|
||||
* @param {[string]} libraryId optional
|
||||
* @returns {Promise<Playlist[]>}
|
||||
*/
|
||||
static async getPlaylistsForUserAndLibrary(userId, libraryId = null) {
|
||||
if (!userId && !libraryId) return []
|
||||
const whereQuery = {}
|
||||
if (userId) {
|
||||
whereQuery.userId = userId
|
||||
}
|
||||
})
|
||||
if (libraryId) {
|
||||
whereQuery.libraryId = libraryId
|
||||
}
|
||||
const playlists = await this.findAll({
|
||||
where: whereQuery,
|
||||
include: {
|
||||
model: this.sequelize.models.playlistMediaItem,
|
||||
include: [
|
||||
{
|
||||
model: this.sequelize.models.book,
|
||||
include: this.sequelize.models.libraryItem
|
||||
},
|
||||
{
|
||||
model: this.sequelize.models.podcastEpisode,
|
||||
include: {
|
||||
model: this.sequelize.models.podcast,
|
||||
include: this.sequelize.models.libraryItem
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
order: [
|
||||
[literal('name COLLATE NOCASE'), 'ASC'],
|
||||
['playlistMediaItems', 'order', 'ASC']
|
||||
]
|
||||
})
|
||||
return playlists
|
||||
}
|
||||
|
||||
return Playlist
|
||||
}
|
||||
/**
|
||||
* Get number of playlists for a user and library
|
||||
* @param {string} userId
|
||||
* @param {string} libraryId
|
||||
* @returns
|
||||
*/
|
||||
static async getNumPlaylistsForUserAndLibrary(userId, libraryId) {
|
||||
return this.count({
|
||||
where: {
|
||||
userId,
|
||||
libraryId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all playlists for mediaItemIds
|
||||
* @param {string[]} mediaItemIds
|
||||
* @returns {Promise<Playlist[]>}
|
||||
*/
|
||||
static async getPlaylistsForMediaItemIds(mediaItemIds) {
|
||||
if (!mediaItemIds?.length) return []
|
||||
|
||||
const playlistMediaItemsExpanded = await this.sequelize.models.playlistMediaItem.findAll({
|
||||
where: {
|
||||
mediaItemId: {
|
||||
[Op.in]: mediaItemIds
|
||||
}
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: this.sequelize.models.playlist,
|
||||
include: {
|
||||
model: this.sequelize.models.playlistMediaItem,
|
||||
include: [
|
||||
{
|
||||
model: this.sequelize.models.book,
|
||||
include: this.sequelize.models.libraryItem
|
||||
},
|
||||
{
|
||||
model: this.sequelize.models.podcastEpisode,
|
||||
include: {
|
||||
model: this.sequelize.models.podcast,
|
||||
include: this.sequelize.models.libraryItem
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
order: [['playlist', 'playlistMediaItems', 'order', 'ASC']]
|
||||
})
|
||||
|
||||
const playlists = []
|
||||
for (const playlistMediaItem of playlistMediaItemsExpanded) {
|
||||
const playlist = playlistMediaItem.playlist
|
||||
if (playlists.some(p => p.id === playlist.id)) continue
|
||||
|
||||
playlist.playlistMediaItems = playlist.playlistMediaItems.map(pmi => {
|
||||
if (pmi.mediaItemType === 'book' && pmi.book !== undefined) {
|
||||
pmi.mediaItem = pmi.book
|
||||
pmi.dataValues.mediaItem = pmi.dataValues.book
|
||||
} else if (pmi.mediaItemType === 'podcastEpisode' && pmi.podcastEpisode !== undefined) {
|
||||
pmi.mediaItem = pmi.podcastEpisode
|
||||
pmi.dataValues.mediaItem = pmi.dataValues.podcastEpisode
|
||||
}
|
||||
delete pmi.book
|
||||
delete pmi.dataValues.book
|
||||
delete pmi.podcastEpisode
|
||||
delete pmi.dataValues.podcastEpisode
|
||||
return pmi
|
||||
})
|
||||
playlists.push(playlist)
|
||||
}
|
||||
return playlists
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize model
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
*/
|
||||
static init(sequelize) {
|
||||
super.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
name: DataTypes.STRING,
|
||||
description: DataTypes.TEXT
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'playlist'
|
||||
})
|
||||
|
||||
const { library, user } = sequelize.models
|
||||
library.hasMany(Playlist)
|
||||
Playlist.belongsTo(library)
|
||||
|
||||
user.hasMany(Playlist, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
Playlist.belongsTo(user)
|
||||
|
||||
Playlist.addHook('afterFind', findResult => {
|
||||
if (!findResult) return
|
||||
|
||||
if (!Array.isArray(findResult)) findResult = [findResult]
|
||||
|
||||
for (const instance of findResult) {
|
||||
if (instance.playlistMediaItems?.length) {
|
||||
instance.playlistMediaItems = instance.playlistMediaItems.map(pmi => {
|
||||
if (pmi.mediaItemType === 'book' && pmi.book !== undefined) {
|
||||
pmi.mediaItem = pmi.book
|
||||
pmi.dataValues.mediaItem = pmi.dataValues.book
|
||||
} else if (pmi.mediaItemType === 'podcastEpisode' && pmi.podcastEpisode !== undefined) {
|
||||
pmi.mediaItem = pmi.podcastEpisode
|
||||
pmi.dataValues.mediaItem = pmi.dataValues.podcastEpisode
|
||||
}
|
||||
// To prevent mistakes:
|
||||
delete pmi.book
|
||||
delete pmi.dataValues.book
|
||||
delete pmi.podcastEpisode
|
||||
delete pmi.dataValues.podcastEpisode
|
||||
return pmi
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Playlist
|
||||
|
|
@ -1,84 +1,105 @@
|
|||
const { DataTypes, Model } = require('sequelize')
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
class PlaylistMediaItem extends Model {
|
||||
static removeByIds(playlistId, mediaItemId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
playlistId,
|
||||
mediaItemId
|
||||
}
|
||||
})
|
||||
}
|
||||
class PlaylistMediaItem extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
getMediaItem(options) {
|
||||
if (!this.mediaItemType) return Promise.resolve(null)
|
||||
const mixinMethodName = `get${sequelize.uppercaseFirst(this.mediaItemType)}`
|
||||
return this[mixinMethodName](options)
|
||||
}
|
||||
/** @type {UUIDV4} */
|
||||
this.id
|
||||
/** @type {UUIDV4} */
|
||||
this.mediaItemId
|
||||
/** @type {string} */
|
||||
this.mediaItemType
|
||||
/** @type {number} */
|
||||
this.order
|
||||
/** @type {UUIDV4} */
|
||||
this.playlistId
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
}
|
||||
|
||||
PlaylistMediaItem.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
mediaItemId: DataTypes.UUIDV4,
|
||||
mediaItemType: DataTypes.STRING,
|
||||
order: DataTypes.INTEGER
|
||||
}, {
|
||||
sequelize,
|
||||
timestamps: true,
|
||||
updatedAt: false,
|
||||
modelName: 'playlistMediaItem'
|
||||
})
|
||||
|
||||
const { book, podcastEpisode, playlist } = sequelize.models
|
||||
|
||||
book.hasMany(PlaylistMediaItem, {
|
||||
foreignKey: 'mediaItemId',
|
||||
constraints: false,
|
||||
scope: {
|
||||
mediaItemType: 'book'
|
||||
}
|
||||
})
|
||||
PlaylistMediaItem.belongsTo(book, { foreignKey: 'mediaItemId', constraints: false })
|
||||
|
||||
podcastEpisode.hasOne(PlaylistMediaItem, {
|
||||
foreignKey: 'mediaItemId',
|
||||
constraints: false,
|
||||
scope: {
|
||||
mediaItemType: 'podcastEpisode'
|
||||
}
|
||||
})
|
||||
PlaylistMediaItem.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false })
|
||||
|
||||
PlaylistMediaItem.addHook('afterFind', findResult => {
|
||||
if (!findResult) return
|
||||
|
||||
if (!Array.isArray(findResult)) findResult = [findResult]
|
||||
|
||||
for (const instance of findResult) {
|
||||
if (instance.mediaItemType === 'book' && instance.book !== undefined) {
|
||||
instance.mediaItem = instance.book
|
||||
instance.dataValues.mediaItem = instance.dataValues.book
|
||||
} else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) {
|
||||
instance.mediaItem = instance.podcastEpisode
|
||||
instance.dataValues.mediaItem = instance.dataValues.podcastEpisode
|
||||
static removeByIds(playlistId, mediaItemId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
playlistId,
|
||||
mediaItemId
|
||||
}
|
||||
// To prevent mistakes:
|
||||
delete instance.book
|
||||
delete instance.dataValues.book
|
||||
delete instance.podcastEpisode
|
||||
delete instance.dataValues.podcastEpisode
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
playlist.hasMany(PlaylistMediaItem, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
PlaylistMediaItem.belongsTo(playlist)
|
||||
getMediaItem(options) {
|
||||
if (!this.mediaItemType) return Promise.resolve(null)
|
||||
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaItemType)}`
|
||||
return this[mixinMethodName](options)
|
||||
}
|
||||
|
||||
return PlaylistMediaItem
|
||||
}
|
||||
/**
|
||||
* Initialize model
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
*/
|
||||
static init(sequelize) {
|
||||
super.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
mediaItemId: DataTypes.UUIDV4,
|
||||
mediaItemType: DataTypes.STRING,
|
||||
order: DataTypes.INTEGER
|
||||
}, {
|
||||
sequelize,
|
||||
timestamps: true,
|
||||
updatedAt: false,
|
||||
modelName: 'playlistMediaItem'
|
||||
})
|
||||
|
||||
const { book, podcastEpisode, playlist } = sequelize.models
|
||||
|
||||
book.hasMany(PlaylistMediaItem, {
|
||||
foreignKey: 'mediaItemId',
|
||||
constraints: false,
|
||||
scope: {
|
||||
mediaItemType: 'book'
|
||||
}
|
||||
})
|
||||
PlaylistMediaItem.belongsTo(book, { foreignKey: 'mediaItemId', constraints: false })
|
||||
|
||||
podcastEpisode.hasOne(PlaylistMediaItem, {
|
||||
foreignKey: 'mediaItemId',
|
||||
constraints: false,
|
||||
scope: {
|
||||
mediaItemType: 'podcastEpisode'
|
||||
}
|
||||
})
|
||||
PlaylistMediaItem.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false })
|
||||
|
||||
PlaylistMediaItem.addHook('afterFind', findResult => {
|
||||
if (!findResult) return
|
||||
|
||||
if (!Array.isArray(findResult)) findResult = [findResult]
|
||||
|
||||
for (const instance of findResult) {
|
||||
if (instance.mediaItemType === 'book' && instance.book !== undefined) {
|
||||
instance.mediaItem = instance.book
|
||||
instance.dataValues.mediaItem = instance.dataValues.book
|
||||
} else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) {
|
||||
instance.mediaItem = instance.podcastEpisode
|
||||
instance.dataValues.mediaItem = instance.dataValues.podcastEpisode
|
||||
}
|
||||
// To prevent mistakes:
|
||||
delete instance.book
|
||||
delete instance.dataValues.book
|
||||
delete instance.podcastEpisode
|
||||
delete instance.dataValues.podcastEpisode
|
||||
}
|
||||
})
|
||||
|
||||
playlist.hasMany(PlaylistMediaItem, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
PlaylistMediaItem.belongsTo(playlist)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PlaylistMediaItem
|
||||
|
|
|
|||
|
|
@ -1,100 +1,155 @@
|
|||
const { DataTypes, Model } = require('sequelize')
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
class Podcast extends Model {
|
||||
static getOldPodcast(libraryItemExpanded) {
|
||||
const podcastExpanded = libraryItemExpanded.media
|
||||
const podcastEpisodes = podcastExpanded.podcastEpisodes?.map(ep => ep.getOldPodcastEpisode(libraryItemExpanded.id)).sort((a, b) => a.index - b.index)
|
||||
return {
|
||||
id: podcastExpanded.id,
|
||||
libraryItemId: libraryItemExpanded.id,
|
||||
metadata: {
|
||||
title: podcastExpanded.title,
|
||||
author: podcastExpanded.author,
|
||||
description: podcastExpanded.description,
|
||||
releaseDate: podcastExpanded.releaseDate,
|
||||
genres: podcastExpanded.genres,
|
||||
feedUrl: podcastExpanded.feedURL,
|
||||
imageUrl: podcastExpanded.imageURL,
|
||||
itunesPageUrl: podcastExpanded.itunesPageURL,
|
||||
itunesId: podcastExpanded.itunesId,
|
||||
itunesArtistId: podcastExpanded.itunesArtistId,
|
||||
explicit: podcastExpanded.explicit,
|
||||
language: podcastExpanded.language,
|
||||
type: podcastExpanded.podcastType
|
||||
},
|
||||
coverPath: podcastExpanded.coverPath,
|
||||
tags: podcastExpanded.tags,
|
||||
episodes: podcastEpisodes || [],
|
||||
autoDownloadEpisodes: podcastExpanded.autoDownloadEpisodes,
|
||||
autoDownloadSchedule: podcastExpanded.autoDownloadSchedule,
|
||||
lastEpisodeCheck: podcastExpanded.lastEpisodeCheck?.valueOf() || null,
|
||||
maxEpisodesToKeep: podcastExpanded.maxEpisodesToKeep,
|
||||
maxNewEpisodesToDownload: podcastExpanded.maxNewEpisodesToDownload
|
||||
}
|
||||
}
|
||||
class Podcast extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
static getFromOld(oldPodcast) {
|
||||
const oldPodcastMetadata = oldPodcast.metadata
|
||||
return {
|
||||
id: oldPodcast.id,
|
||||
title: oldPodcastMetadata.title,
|
||||
titleIgnorePrefix: oldPodcastMetadata.titleIgnorePrefix,
|
||||
author: oldPodcastMetadata.author,
|
||||
releaseDate: oldPodcastMetadata.releaseDate,
|
||||
feedURL: oldPodcastMetadata.feedUrl,
|
||||
imageURL: oldPodcastMetadata.imageUrl,
|
||||
description: oldPodcastMetadata.description,
|
||||
itunesPageURL: oldPodcastMetadata.itunesPageUrl,
|
||||
itunesId: oldPodcastMetadata.itunesId,
|
||||
itunesArtistId: oldPodcastMetadata.itunesArtistId,
|
||||
language: oldPodcastMetadata.language,
|
||||
podcastType: oldPodcastMetadata.type,
|
||||
explicit: !!oldPodcastMetadata.explicit,
|
||||
autoDownloadEpisodes: !!oldPodcast.autoDownloadEpisodes,
|
||||
autoDownloadSchedule: oldPodcast.autoDownloadSchedule,
|
||||
lastEpisodeCheck: oldPodcast.lastEpisodeCheck,
|
||||
maxEpisodesToKeep: oldPodcast.maxEpisodesToKeep,
|
||||
maxNewEpisodesToDownload: oldPodcast.maxNewEpisodesToDownload,
|
||||
coverPath: oldPodcast.coverPath,
|
||||
tags: oldPodcast.tags,
|
||||
genres: oldPodcastMetadata.genres
|
||||
}
|
||||
/** @type {string} */
|
||||
this.id
|
||||
/** @type {string} */
|
||||
this.title
|
||||
/** @type {string} */
|
||||
this.titleIgnorePrefix
|
||||
/** @type {string} */
|
||||
this.author
|
||||
/** @type {string} */
|
||||
this.releaseDate
|
||||
/** @type {string} */
|
||||
this.feedURL
|
||||
/** @type {string} */
|
||||
this.imageURL
|
||||
/** @type {string} */
|
||||
this.description
|
||||
/** @type {string} */
|
||||
this.itunesPageURL
|
||||
/** @type {string} */
|
||||
this.itunesId
|
||||
/** @type {string} */
|
||||
this.itunesArtistId
|
||||
/** @type {string} */
|
||||
this.language
|
||||
/** @type {string} */
|
||||
this.podcastType
|
||||
/** @type {boolean} */
|
||||
this.explicit
|
||||
/** @type {boolean} */
|
||||
this.autoDownloadEpisodes
|
||||
/** @type {string} */
|
||||
this.autoDownloadSchedule
|
||||
/** @type {Date} */
|
||||
this.lastEpisodeCheck
|
||||
/** @type {number} */
|
||||
this.maxEpisodesToKeep
|
||||
/** @type {string} */
|
||||
this.coverPath
|
||||
/** @type {string[]} */
|
||||
this.tags
|
||||
/** @type {string[]} */
|
||||
this.genres
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
}
|
||||
|
||||
static getOldPodcast(libraryItemExpanded) {
|
||||
const podcastExpanded = libraryItemExpanded.media
|
||||
const podcastEpisodes = podcastExpanded.podcastEpisodes?.map(ep => ep.getOldPodcastEpisode(libraryItemExpanded.id).toJSON()).sort((a, b) => a.index - b.index)
|
||||
return {
|
||||
id: podcastExpanded.id,
|
||||
libraryItemId: libraryItemExpanded.id,
|
||||
metadata: {
|
||||
title: podcastExpanded.title,
|
||||
author: podcastExpanded.author,
|
||||
description: podcastExpanded.description,
|
||||
releaseDate: podcastExpanded.releaseDate,
|
||||
genres: podcastExpanded.genres,
|
||||
feedUrl: podcastExpanded.feedURL,
|
||||
imageUrl: podcastExpanded.imageURL,
|
||||
itunesPageUrl: podcastExpanded.itunesPageURL,
|
||||
itunesId: podcastExpanded.itunesId,
|
||||
itunesArtistId: podcastExpanded.itunesArtistId,
|
||||
explicit: podcastExpanded.explicit,
|
||||
language: podcastExpanded.language,
|
||||
type: podcastExpanded.podcastType
|
||||
},
|
||||
coverPath: podcastExpanded.coverPath,
|
||||
tags: podcastExpanded.tags,
|
||||
episodes: podcastEpisodes || [],
|
||||
autoDownloadEpisodes: podcastExpanded.autoDownloadEpisodes,
|
||||
autoDownloadSchedule: podcastExpanded.autoDownloadSchedule,
|
||||
lastEpisodeCheck: podcastExpanded.lastEpisodeCheck?.valueOf() || null,
|
||||
maxEpisodesToKeep: podcastExpanded.maxEpisodesToKeep,
|
||||
maxNewEpisodesToDownload: podcastExpanded.maxNewEpisodesToDownload
|
||||
}
|
||||
}
|
||||
|
||||
Podcast.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
title: DataTypes.STRING,
|
||||
titleIgnorePrefix: DataTypes.STRING,
|
||||
author: DataTypes.STRING,
|
||||
releaseDate: DataTypes.STRING,
|
||||
feedURL: DataTypes.STRING,
|
||||
imageURL: DataTypes.STRING,
|
||||
description: DataTypes.TEXT,
|
||||
itunesPageURL: DataTypes.STRING,
|
||||
itunesId: DataTypes.STRING,
|
||||
itunesArtistId: DataTypes.STRING,
|
||||
language: DataTypes.STRING,
|
||||
podcastType: DataTypes.STRING,
|
||||
explicit: DataTypes.BOOLEAN,
|
||||
static getFromOld(oldPodcast) {
|
||||
const oldPodcastMetadata = oldPodcast.metadata
|
||||
return {
|
||||
id: oldPodcast.id,
|
||||
title: oldPodcastMetadata.title,
|
||||
titleIgnorePrefix: oldPodcastMetadata.titleIgnorePrefix,
|
||||
author: oldPodcastMetadata.author,
|
||||
releaseDate: oldPodcastMetadata.releaseDate,
|
||||
feedURL: oldPodcastMetadata.feedUrl,
|
||||
imageURL: oldPodcastMetadata.imageUrl,
|
||||
description: oldPodcastMetadata.description,
|
||||
itunesPageURL: oldPodcastMetadata.itunesPageUrl,
|
||||
itunesId: oldPodcastMetadata.itunesId,
|
||||
itunesArtistId: oldPodcastMetadata.itunesArtistId,
|
||||
language: oldPodcastMetadata.language,
|
||||
podcastType: oldPodcastMetadata.type,
|
||||
explicit: !!oldPodcastMetadata.explicit,
|
||||
autoDownloadEpisodes: !!oldPodcast.autoDownloadEpisodes,
|
||||
autoDownloadSchedule: oldPodcast.autoDownloadSchedule,
|
||||
lastEpisodeCheck: oldPodcast.lastEpisodeCheck,
|
||||
maxEpisodesToKeep: oldPodcast.maxEpisodesToKeep,
|
||||
maxNewEpisodesToDownload: oldPodcast.maxNewEpisodesToDownload,
|
||||
coverPath: oldPodcast.coverPath,
|
||||
tags: oldPodcast.tags,
|
||||
genres: oldPodcastMetadata.genres
|
||||
}
|
||||
}
|
||||
|
||||
autoDownloadEpisodes: DataTypes.BOOLEAN,
|
||||
autoDownloadSchedule: DataTypes.STRING,
|
||||
lastEpisodeCheck: DataTypes.DATE,
|
||||
maxEpisodesToKeep: DataTypes.INTEGER,
|
||||
maxNewEpisodesToDownload: DataTypes.INTEGER,
|
||||
coverPath: DataTypes.STRING,
|
||||
tags: DataTypes.JSON,
|
||||
genres: DataTypes.JSON
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'podcast'
|
||||
})
|
||||
/**
|
||||
* Initialize model
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
*/
|
||||
static init(sequelize) {
|
||||
super.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
title: DataTypes.STRING,
|
||||
titleIgnorePrefix: DataTypes.STRING,
|
||||
author: DataTypes.STRING,
|
||||
releaseDate: DataTypes.STRING,
|
||||
feedURL: DataTypes.STRING,
|
||||
imageURL: DataTypes.STRING,
|
||||
description: DataTypes.TEXT,
|
||||
itunesPageURL: DataTypes.STRING,
|
||||
itunesId: DataTypes.STRING,
|
||||
itunesArtistId: DataTypes.STRING,
|
||||
language: DataTypes.STRING,
|
||||
podcastType: DataTypes.STRING,
|
||||
explicit: DataTypes.BOOLEAN,
|
||||
|
||||
return Podcast
|
||||
}
|
||||
autoDownloadEpisodes: DataTypes.BOOLEAN,
|
||||
autoDownloadSchedule: DataTypes.STRING,
|
||||
lastEpisodeCheck: DataTypes.DATE,
|
||||
maxEpisodesToKeep: DataTypes.INTEGER,
|
||||
maxNewEpisodesToDownload: DataTypes.INTEGER,
|
||||
coverPath: DataTypes.STRING,
|
||||
tags: DataTypes.JSON,
|
||||
genres: DataTypes.JSON
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'podcast'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Podcast
|
||||
|
|
@ -1,102 +1,162 @@
|
|||
const { DataTypes, Model } = require('sequelize')
|
||||
const oldPodcastEpisode = require('../objects/entities/PodcastEpisode')
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
class PodcastEpisode extends Model {
|
||||
getOldPodcastEpisode(libraryItemId = null) {
|
||||
let enclosure = null
|
||||
if (this.enclosureURL) {
|
||||
enclosure = {
|
||||
url: this.enclosureURL,
|
||||
type: this.enclosureType,
|
||||
length: this.enclosureSize !== null ? String(this.enclosureSize) : null
|
||||
}
|
||||
}
|
||||
return {
|
||||
libraryItemId: libraryItemId || null,
|
||||
podcastId: this.podcastId,
|
||||
id: this.id,
|
||||
oldEpisodeId: this.extraData?.oldEpisodeId || null,
|
||||
index: this.index,
|
||||
season: this.season,
|
||||
episode: this.episode,
|
||||
episodeType: this.episodeType,
|
||||
title: this.title,
|
||||
subtitle: this.subtitle,
|
||||
description: this.description,
|
||||
enclosure,
|
||||
pubDate: this.pubDate,
|
||||
chapters: this.chapters,
|
||||
audioFile: this.audioFile,
|
||||
publishedAt: this.publishedAt?.valueOf() || null,
|
||||
addedAt: this.createdAt.valueOf(),
|
||||
updatedAt: this.updatedAt.valueOf()
|
||||
/**
|
||||
* @typedef ChapterObject
|
||||
* @property {number} id
|
||||
* @property {number} start
|
||||
* @property {number} end
|
||||
* @property {string} title
|
||||
*/
|
||||
|
||||
class PodcastEpisode extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
/** @type {string} */
|
||||
this.id
|
||||
/** @type {number} */
|
||||
this.index
|
||||
/** @type {string} */
|
||||
this.season
|
||||
/** @type {string} */
|
||||
this.episode
|
||||
/** @type {string} */
|
||||
this.episodeType
|
||||
/** @type {string} */
|
||||
this.title
|
||||
/** @type {string} */
|
||||
this.subtitle
|
||||
/** @type {string} */
|
||||
this.description
|
||||
/** @type {string} */
|
||||
this.pubDate
|
||||
/** @type {string} */
|
||||
this.enclosureURL
|
||||
/** @type {BigInt} */
|
||||
this.enclosureSize
|
||||
/** @type {string} */
|
||||
this.enclosureType
|
||||
/** @type {Date} */
|
||||
this.publishedAt
|
||||
/** @type {import('./Book').AudioFileObject} */
|
||||
this.audioFile
|
||||
/** @type {ChapterObject[]} */
|
||||
this.chapters
|
||||
/** @type {Object} */
|
||||
this.extraData
|
||||
/** @type {string} */
|
||||
this.podcastId
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} libraryItemId
|
||||
* @returns {oldPodcastEpisode}
|
||||
*/
|
||||
getOldPodcastEpisode(libraryItemId = null) {
|
||||
let enclosure = null
|
||||
if (this.enclosureURL) {
|
||||
enclosure = {
|
||||
url: this.enclosureURL,
|
||||
type: this.enclosureType,
|
||||
length: this.enclosureSize !== null ? String(this.enclosureSize) : null
|
||||
}
|
||||
}
|
||||
return new oldPodcastEpisode({
|
||||
libraryItemId: libraryItemId || null,
|
||||
podcastId: this.podcastId,
|
||||
id: this.id,
|
||||
oldEpisodeId: this.extraData?.oldEpisodeId || null,
|
||||
index: this.index,
|
||||
season: this.season,
|
||||
episode: this.episode,
|
||||
episodeType: this.episodeType,
|
||||
title: this.title,
|
||||
subtitle: this.subtitle,
|
||||
description: this.description,
|
||||
enclosure,
|
||||
pubDate: this.pubDate,
|
||||
chapters: this.chapters,
|
||||
audioFile: this.audioFile,
|
||||
publishedAt: this.publishedAt?.valueOf() || null,
|
||||
addedAt: this.createdAt.valueOf(),
|
||||
updatedAt: this.updatedAt.valueOf()
|
||||
})
|
||||
}
|
||||
|
||||
static createFromOld(oldEpisode) {
|
||||
const podcastEpisode = this.getFromOld(oldEpisode)
|
||||
return this.create(podcastEpisode)
|
||||
static createFromOld(oldEpisode) {
|
||||
const podcastEpisode = this.getFromOld(oldEpisode)
|
||||
return this.create(podcastEpisode)
|
||||
}
|
||||
|
||||
static getFromOld(oldEpisode) {
|
||||
const extraData = {}
|
||||
if (oldEpisode.oldEpisodeId) {
|
||||
extraData.oldEpisodeId = oldEpisode.oldEpisodeId
|
||||
}
|
||||
|
||||
static getFromOld(oldEpisode) {
|
||||
const extraData = {}
|
||||
if (oldEpisode.oldEpisodeId) {
|
||||
extraData.oldEpisodeId = oldEpisode.oldEpisodeId
|
||||
}
|
||||
return {
|
||||
id: oldEpisode.id,
|
||||
index: oldEpisode.index,
|
||||
season: oldEpisode.season,
|
||||
episode: oldEpisode.episode,
|
||||
episodeType: oldEpisode.episodeType,
|
||||
title: oldEpisode.title,
|
||||
subtitle: oldEpisode.subtitle,
|
||||
description: oldEpisode.description,
|
||||
pubDate: oldEpisode.pubDate,
|
||||
enclosureURL: oldEpisode.enclosure?.url || null,
|
||||
enclosureSize: oldEpisode.enclosure?.length || null,
|
||||
enclosureType: oldEpisode.enclosure?.type || null,
|
||||
publishedAt: oldEpisode.publishedAt,
|
||||
podcastId: oldEpisode.podcastId,
|
||||
audioFile: oldEpisode.audioFile?.toJSON() || null,
|
||||
chapters: oldEpisode.chapters,
|
||||
extraData
|
||||
}
|
||||
return {
|
||||
id: oldEpisode.id,
|
||||
index: oldEpisode.index,
|
||||
season: oldEpisode.season,
|
||||
episode: oldEpisode.episode,
|
||||
episodeType: oldEpisode.episodeType,
|
||||
title: oldEpisode.title,
|
||||
subtitle: oldEpisode.subtitle,
|
||||
description: oldEpisode.description,
|
||||
pubDate: oldEpisode.pubDate,
|
||||
enclosureURL: oldEpisode.enclosure?.url || null,
|
||||
enclosureSize: oldEpisode.enclosure?.length || null,
|
||||
enclosureType: oldEpisode.enclosure?.type || null,
|
||||
publishedAt: oldEpisode.publishedAt,
|
||||
podcastId: oldEpisode.podcastId,
|
||||
audioFile: oldEpisode.audioFile?.toJSON() || null,
|
||||
chapters: oldEpisode.chapters,
|
||||
extraData
|
||||
}
|
||||
}
|
||||
|
||||
PodcastEpisode.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
index: DataTypes.INTEGER,
|
||||
season: DataTypes.STRING,
|
||||
episode: DataTypes.STRING,
|
||||
episodeType: DataTypes.STRING,
|
||||
title: DataTypes.STRING,
|
||||
subtitle: DataTypes.STRING(1000),
|
||||
description: DataTypes.TEXT,
|
||||
pubDate: DataTypes.STRING,
|
||||
enclosureURL: DataTypes.STRING,
|
||||
enclosureSize: DataTypes.BIGINT,
|
||||
enclosureType: DataTypes.STRING,
|
||||
publishedAt: DataTypes.DATE,
|
||||
/**
|
||||
* Initialize model
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
*/
|
||||
static init(sequelize) {
|
||||
super.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
index: DataTypes.INTEGER,
|
||||
season: DataTypes.STRING,
|
||||
episode: DataTypes.STRING,
|
||||
episodeType: DataTypes.STRING,
|
||||
title: DataTypes.STRING,
|
||||
subtitle: DataTypes.STRING(1000),
|
||||
description: DataTypes.TEXT,
|
||||
pubDate: DataTypes.STRING,
|
||||
enclosureURL: DataTypes.STRING,
|
||||
enclosureSize: DataTypes.BIGINT,
|
||||
enclosureType: DataTypes.STRING,
|
||||
publishedAt: DataTypes.DATE,
|
||||
|
||||
audioFile: DataTypes.JSON,
|
||||
chapters: DataTypes.JSON,
|
||||
extraData: DataTypes.JSON
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'podcastEpisode'
|
||||
})
|
||||
audioFile: DataTypes.JSON,
|
||||
chapters: DataTypes.JSON,
|
||||
extraData: DataTypes.JSON
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'podcastEpisode'
|
||||
})
|
||||
|
||||
const { podcast } = sequelize.models
|
||||
podcast.hasMany(PodcastEpisode, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
PodcastEpisode.belongsTo(podcast)
|
||||
const { podcast } = sequelize.models
|
||||
podcast.hasMany(PodcastEpisode, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
PodcastEpisode.belongsTo(podcast)
|
||||
}
|
||||
}
|
||||
|
||||
return PodcastEpisode
|
||||
}
|
||||
module.exports = PodcastEpisode
|
||||
|
|
@ -1,82 +1,161 @@
|
|||
const { DataTypes, Model } = require('sequelize')
|
||||
const { DataTypes, Model, literal } = require('sequelize')
|
||||
|
||||
const oldSeries = require('../objects/entities/Series')
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
class Series extends Model {
|
||||
static async getAllOldSeries() {
|
||||
const series = await this.findAll()
|
||||
return series.map(se => se.getOldSeries())
|
||||
}
|
||||
class Series extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
getOldSeries() {
|
||||
return new oldSeries({
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
libraryId: this.libraryId,
|
||||
addedAt: this.createdAt.valueOf(),
|
||||
updatedAt: this.updatedAt.valueOf()
|
||||
})
|
||||
}
|
||||
/** @type {UUIDV4} */
|
||||
this.id
|
||||
/** @type {string} */
|
||||
this.name
|
||||
/** @type {string} */
|
||||
this.nameIgnorePrefix
|
||||
/** @type {string} */
|
||||
this.description
|
||||
/** @type {UUIDV4} */
|
||||
this.libraryId
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
}
|
||||
|
||||
static updateFromOld(oldSeries) {
|
||||
const series = this.getFromOld(oldSeries)
|
||||
return this.update(series, {
|
||||
where: {
|
||||
id: series.id
|
||||
}
|
||||
})
|
||||
}
|
||||
static async getAllOldSeries() {
|
||||
const series = await this.findAll()
|
||||
return series.map(se => se.getOldSeries())
|
||||
}
|
||||
|
||||
static createFromOld(oldSeries) {
|
||||
const series = this.getFromOld(oldSeries)
|
||||
return this.create(series)
|
||||
}
|
||||
getOldSeries() {
|
||||
return new oldSeries({
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
libraryId: this.libraryId,
|
||||
addedAt: this.createdAt.valueOf(),
|
||||
updatedAt: this.updatedAt.valueOf()
|
||||
})
|
||||
}
|
||||
|
||||
static createBulkFromOld(oldSeriesObjs) {
|
||||
const series = oldSeriesObjs.map(this.getFromOld)
|
||||
return this.bulkCreate(series)
|
||||
}
|
||||
|
||||
static getFromOld(oldSeries) {
|
||||
return {
|
||||
id: oldSeries.id,
|
||||
name: oldSeries.name,
|
||||
nameIgnorePrefix: oldSeries.nameIgnorePrefix,
|
||||
description: oldSeries.description,
|
||||
libraryId: oldSeries.libraryId
|
||||
static updateFromOld(oldSeries) {
|
||||
const series = this.getFromOld(oldSeries)
|
||||
return this.update(series, {
|
||||
where: {
|
||||
id: series.id
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static removeById(seriesId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
id: seriesId
|
||||
}
|
||||
})
|
||||
static createFromOld(oldSeries) {
|
||||
const series = this.getFromOld(oldSeries)
|
||||
return this.create(series)
|
||||
}
|
||||
|
||||
static createBulkFromOld(oldSeriesObjs) {
|
||||
const series = oldSeriesObjs.map(this.getFromOld)
|
||||
return this.bulkCreate(series)
|
||||
}
|
||||
|
||||
static getFromOld(oldSeries) {
|
||||
return {
|
||||
id: oldSeries.id,
|
||||
name: oldSeries.name,
|
||||
nameIgnorePrefix: oldSeries.nameIgnorePrefix,
|
||||
description: oldSeries.description,
|
||||
libraryId: oldSeries.libraryId
|
||||
}
|
||||
}
|
||||
|
||||
Series.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
name: DataTypes.STRING,
|
||||
nameIgnorePrefix: DataTypes.STRING,
|
||||
description: DataTypes.TEXT
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'series'
|
||||
})
|
||||
static removeById(seriesId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
id: seriesId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const { library } = sequelize.models
|
||||
library.hasMany(Series, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
Series.belongsTo(library)
|
||||
/**
|
||||
* Get oldSeries by id
|
||||
* @param {string} seriesId
|
||||
* @returns {Promise<oldSeries>}
|
||||
*/
|
||||
static async getOldById(seriesId) {
|
||||
const series = await this.findByPk(seriesId)
|
||||
if (!series) return null
|
||||
return series.getOldSeries()
|
||||
}
|
||||
|
||||
return Series
|
||||
}
|
||||
/**
|
||||
* Check if series exists
|
||||
* @param {string} seriesId
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
static async checkExistsById(seriesId) {
|
||||
return (await this.count({ where: { id: seriesId } })) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Get old series by name and libraryId. name case insensitive
|
||||
*
|
||||
* @param {string} seriesName
|
||||
* @param {string} libraryId
|
||||
* @returns {Promise<oldSeries>}
|
||||
*/
|
||||
static async getOldByNameAndLibrary(seriesName, libraryId) {
|
||||
const series = (await this.findOne({
|
||||
where: [
|
||||
literal(`name = '${seriesName}' COLLATE NOCASE`),
|
||||
{
|
||||
libraryId
|
||||
}
|
||||
]
|
||||
}))?.getOldSeries()
|
||||
return series
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize model
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
*/
|
||||
static init(sequelize) {
|
||||
super.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
name: DataTypes.STRING,
|
||||
nameIgnorePrefix: DataTypes.STRING,
|
||||
description: DataTypes.TEXT
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'series',
|
||||
indexes: [
|
||||
{
|
||||
fields: [{
|
||||
name: 'name',
|
||||
collate: 'NOCASE'
|
||||
}]
|
||||
},
|
||||
// {
|
||||
// fields: [{
|
||||
// name: 'nameIgnorePrefix',
|
||||
// collate: 'NOCASE'
|
||||
// }]
|
||||
// },
|
||||
{
|
||||
fields: ['libraryId']
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const { library } = sequelize.models
|
||||
library.hasMany(Series, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
Series.belongsTo(library)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Series
|
||||
|
|
@ -4,42 +4,59 @@ const oldEmailSettings = require('../objects/settings/EmailSettings')
|
|||
const oldServerSettings = require('../objects/settings/ServerSettings')
|
||||
const oldNotificationSettings = require('../objects/settings/NotificationSettings')
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
class Setting extends Model {
|
||||
static async getOldSettings() {
|
||||
const settings = (await this.findAll()).map(se => se.value)
|
||||
class Setting extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
/** @type {string} */
|
||||
this.key
|
||||
/** @type {Object} */
|
||||
this.value
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
}
|
||||
|
||||
static async getOldSettings() {
|
||||
const settings = (await this.findAll()).map(se => se.value)
|
||||
|
||||
|
||||
const emailSettingsJson = settings.find(se => se.id === 'email-settings')
|
||||
const serverSettingsJson = settings.find(se => se.id === 'server-settings')
|
||||
const notificationSettingsJson = settings.find(se => se.id === 'notification-settings')
|
||||
const emailSettingsJson = settings.find(se => se.id === 'email-settings')
|
||||
const serverSettingsJson = settings.find(se => se.id === 'server-settings')
|
||||
const notificationSettingsJson = settings.find(se => se.id === 'notification-settings')
|
||||
|
||||
return {
|
||||
settings,
|
||||
emailSettings: new oldEmailSettings(emailSettingsJson),
|
||||
serverSettings: new oldServerSettings(serverSettingsJson),
|
||||
notificationSettings: new oldNotificationSettings(notificationSettingsJson)
|
||||
}
|
||||
}
|
||||
|
||||
static updateSettingObj(setting) {
|
||||
return this.upsert({
|
||||
key: setting.id,
|
||||
value: setting
|
||||
})
|
||||
return {
|
||||
settings,
|
||||
emailSettings: new oldEmailSettings(emailSettingsJson),
|
||||
serverSettings: new oldServerSettings(serverSettingsJson),
|
||||
notificationSettings: new oldNotificationSettings(notificationSettingsJson)
|
||||
}
|
||||
}
|
||||
|
||||
Setting.init({
|
||||
key: {
|
||||
type: DataTypes.STRING,
|
||||
primaryKey: true
|
||||
},
|
||||
value: DataTypes.JSON
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'setting'
|
||||
})
|
||||
static updateSettingObj(setting) {
|
||||
return this.upsert({
|
||||
key: setting.id,
|
||||
value: setting
|
||||
})
|
||||
}
|
||||
|
||||
return Setting
|
||||
}
|
||||
/**
|
||||
* Initialize model
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
*/
|
||||
static init(sequelize) {
|
||||
super.init({
|
||||
key: {
|
||||
type: DataTypes.STRING,
|
||||
primaryKey: true
|
||||
},
|
||||
value: DataTypes.JSON
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'setting'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Setting
|
||||
|
|
@ -3,238 +3,273 @@ const { DataTypes, Model, Op } = require('sequelize')
|
|||
const Logger = require('../Logger')
|
||||
const oldUser = require('../objects/user/User')
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
class User extends Model {
|
||||
/**
|
||||
* Get all oldUsers
|
||||
* @returns {Promise<oldUser>}
|
||||
*/
|
||||
static async getOldUsers() {
|
||||
const users = await this.findAll({
|
||||
include: sequelize.models.mediaProgress
|
||||
})
|
||||
return users.map(u => this.getOldUser(u))
|
||||
}
|
||||
class User extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
static getOldUser(userExpanded) {
|
||||
const mediaProgress = userExpanded.mediaProgresses.map(mp => mp.getOldMediaProgress())
|
||||
/** @type {UUIDV4} */
|
||||
this.id
|
||||
/** @type {string} */
|
||||
this.username
|
||||
/** @type {string} */
|
||||
this.email
|
||||
/** @type {string} */
|
||||
this.pash
|
||||
/** @type {string} */
|
||||
this.type
|
||||
/** @type {boolean} */
|
||||
this.isActive
|
||||
/** @type {boolean} */
|
||||
this.isLocked
|
||||
/** @type {Date} */
|
||||
this.lastSeen
|
||||
/** @type {Object} */
|
||||
this.permissions
|
||||
/** @type {Object} */
|
||||
this.bookmarks
|
||||
/** @type {Object} */
|
||||
this.extraData
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
}
|
||||
|
||||
const librariesAccessible = userExpanded.permissions?.librariesAccessible || []
|
||||
const itemTagsSelected = userExpanded.permissions?.itemTagsSelected || []
|
||||
const permissions = userExpanded.permissions || {}
|
||||
delete permissions.librariesAccessible
|
||||
delete permissions.itemTagsSelected
|
||||
/**
|
||||
* Get all oldUsers
|
||||
* @returns {Promise<oldUser>}
|
||||
*/
|
||||
static async getOldUsers() {
|
||||
const users = await this.findAll({
|
||||
include: this.sequelize.models.mediaProgress
|
||||
})
|
||||
return users.map(u => this.getOldUser(u))
|
||||
}
|
||||
|
||||
return new oldUser({
|
||||
id: userExpanded.id,
|
||||
oldUserId: userExpanded.extraData?.oldUserId || null,
|
||||
username: userExpanded.username,
|
||||
pash: userExpanded.pash,
|
||||
type: userExpanded.type,
|
||||
token: userExpanded.token,
|
||||
mediaProgress,
|
||||
seriesHideFromContinueListening: userExpanded.extraData?.seriesHideFromContinueListening || [],
|
||||
bookmarks: userExpanded.bookmarks,
|
||||
isActive: userExpanded.isActive,
|
||||
isLocked: userExpanded.isLocked,
|
||||
lastSeen: userExpanded.lastSeen?.valueOf() || null,
|
||||
createdAt: userExpanded.createdAt.valueOf(),
|
||||
permissions,
|
||||
librariesAccessible,
|
||||
itemTagsSelected
|
||||
})
|
||||
}
|
||||
static getOldUser(userExpanded) {
|
||||
const mediaProgress = userExpanded.mediaProgresses.map(mp => mp.getOldMediaProgress())
|
||||
|
||||
static createFromOld(oldUser) {
|
||||
const user = this.getFromOld(oldUser)
|
||||
return this.create(user)
|
||||
}
|
||||
const librariesAccessible = userExpanded.permissions?.librariesAccessible || []
|
||||
const itemTagsSelected = userExpanded.permissions?.itemTagsSelected || []
|
||||
const permissions = userExpanded.permissions || {}
|
||||
delete permissions.librariesAccessible
|
||||
delete permissions.itemTagsSelected
|
||||
|
||||
static updateFromOld(oldUser) {
|
||||
const user = this.getFromOld(oldUser)
|
||||
return this.update(user, {
|
||||
where: {
|
||||
id: user.id
|
||||
}
|
||||
}).then((result) => result[0] > 0).catch((error) => {
|
||||
Logger.error(`[User] Failed to save user ${oldUser.id}`, error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
return new oldUser({
|
||||
id: userExpanded.id,
|
||||
oldUserId: userExpanded.extraData?.oldUserId || null,
|
||||
username: userExpanded.username,
|
||||
pash: userExpanded.pash,
|
||||
type: userExpanded.type,
|
||||
token: userExpanded.token,
|
||||
mediaProgress,
|
||||
seriesHideFromContinueListening: userExpanded.extraData?.seriesHideFromContinueListening || [],
|
||||
bookmarks: userExpanded.bookmarks,
|
||||
isActive: userExpanded.isActive,
|
||||
isLocked: userExpanded.isLocked,
|
||||
lastSeen: userExpanded.lastSeen?.valueOf() || null,
|
||||
createdAt: userExpanded.createdAt.valueOf(),
|
||||
permissions,
|
||||
librariesAccessible,
|
||||
itemTagsSelected
|
||||
})
|
||||
}
|
||||
|
||||
static getFromOld(oldUser) {
|
||||
return {
|
||||
id: oldUser.id,
|
||||
username: oldUser.username,
|
||||
pash: oldUser.pash || null,
|
||||
type: oldUser.type || null,
|
||||
token: oldUser.token || null,
|
||||
isActive: !!oldUser.isActive,
|
||||
lastSeen: oldUser.lastSeen || null,
|
||||
extraData: {
|
||||
seriesHideFromContinueListening: oldUser.seriesHideFromContinueListening || [],
|
||||
oldUserId: oldUser.oldUserId
|
||||
},
|
||||
createdAt: oldUser.createdAt || Date.now(),
|
||||
permissions: {
|
||||
...oldUser.permissions,
|
||||
librariesAccessible: oldUser.librariesAccessible || [],
|
||||
itemTagsSelected: oldUser.itemTagsSelected || []
|
||||
},
|
||||
bookmarks: oldUser.bookmarks
|
||||
static createFromOld(oldUser) {
|
||||
const user = this.getFromOld(oldUser)
|
||||
return this.create(user)
|
||||
}
|
||||
|
||||
static updateFromOld(oldUser) {
|
||||
const user = this.getFromOld(oldUser)
|
||||
return this.update(user, {
|
||||
where: {
|
||||
id: user.id
|
||||
}
|
||||
}
|
||||
}).then((result) => result[0] > 0).catch((error) => {
|
||||
Logger.error(`[User] Failed to save user ${oldUser.id}`, error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
static removeById(userId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
id: userId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create root user
|
||||
* @param {string} username
|
||||
* @param {string} pash
|
||||
* @param {Auth} auth
|
||||
* @returns {oldUser}
|
||||
*/
|
||||
static async createRootUser(username, pash, auth) {
|
||||
const userId = uuidv4()
|
||||
|
||||
const token = await auth.generateAccessToken({ userId, username })
|
||||
|
||||
const newRoot = new oldUser({
|
||||
id: userId,
|
||||
type: 'root',
|
||||
username,
|
||||
pash,
|
||||
token,
|
||||
isActive: true,
|
||||
createdAt: Date.now()
|
||||
})
|
||||
await this.createFromOld(newRoot)
|
||||
return newRoot
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user by id or by the old database id
|
||||
* @temp User ids were updated in v2.3.0 migration and old API tokens may still use that id
|
||||
* @param {string} userId
|
||||
* @returns {Promise<oldUser|null>} null if not found
|
||||
*/
|
||||
static async getUserByIdOrOldId(userId) {
|
||||
if (!userId) return null
|
||||
const user = await this.findOne({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{
|
||||
id: userId
|
||||
},
|
||||
{
|
||||
extraData: {
|
||||
[Op.substring]: userId
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
include: sequelize.models.mediaProgress
|
||||
})
|
||||
if (!user) return null
|
||||
return this.getOldUser(user)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by username case insensitive
|
||||
* @param {string} username
|
||||
* @returns {Promise<oldUser|null>} returns null if not found
|
||||
*/
|
||||
static async getUserByUsername(username) {
|
||||
if (!username) return null
|
||||
const user = await this.findOne({
|
||||
where: {
|
||||
username: {
|
||||
[Op.like]: username
|
||||
}
|
||||
},
|
||||
include: sequelize.models.mediaProgress
|
||||
})
|
||||
if (!user) return null
|
||||
return this.getOldUser(user)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by id
|
||||
* @param {string} userId
|
||||
* @returns {Promise<oldUser|null>} returns null if not found
|
||||
*/
|
||||
static async getUserById(userId) {
|
||||
if (!userId) return null
|
||||
const user = await this.findByPk(userId, {
|
||||
include: sequelize.models.mediaProgress
|
||||
})
|
||||
if (!user) return null
|
||||
return this.getOldUser(user)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get array of user id and username
|
||||
* @returns {object[]} { id, username }
|
||||
*/
|
||||
static async getMinifiedUserObjects() {
|
||||
const users = await this.findAll({
|
||||
attributes: ['id', 'username']
|
||||
})
|
||||
return users.map(u => {
|
||||
return {
|
||||
id: u.id,
|
||||
username: u.username
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if root user exists
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static async getHasRootUser() {
|
||||
const count = await this.count({
|
||||
where: {
|
||||
type: 'root'
|
||||
}
|
||||
})
|
||||
return count > 0
|
||||
static getFromOld(oldUser) {
|
||||
return {
|
||||
id: oldUser.id,
|
||||
username: oldUser.username,
|
||||
pash: oldUser.pash || null,
|
||||
type: oldUser.type || null,
|
||||
token: oldUser.token || null,
|
||||
isActive: !!oldUser.isActive,
|
||||
lastSeen: oldUser.lastSeen || null,
|
||||
extraData: {
|
||||
seriesHideFromContinueListening: oldUser.seriesHideFromContinueListening || [],
|
||||
oldUserId: oldUser.oldUserId
|
||||
},
|
||||
createdAt: oldUser.createdAt || Date.now(),
|
||||
permissions: {
|
||||
...oldUser.permissions,
|
||||
librariesAccessible: oldUser.librariesAccessible || [],
|
||||
itemTagsSelected: oldUser.itemTagsSelected || []
|
||||
},
|
||||
bookmarks: oldUser.bookmarks
|
||||
}
|
||||
}
|
||||
|
||||
User.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
username: DataTypes.STRING,
|
||||
email: DataTypes.STRING,
|
||||
pash: DataTypes.STRING,
|
||||
type: DataTypes.STRING,
|
||||
token: DataTypes.STRING,
|
||||
isActive: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false
|
||||
},
|
||||
isLocked: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false
|
||||
},
|
||||
lastSeen: DataTypes.DATE,
|
||||
permissions: DataTypes.JSON,
|
||||
bookmarks: DataTypes.JSON,
|
||||
extraData: DataTypes.JSON
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'user'
|
||||
})
|
||||
static removeById(userId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
id: userId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return User
|
||||
}
|
||||
/**
|
||||
* Create root user
|
||||
* @param {string} username
|
||||
* @param {string} pash
|
||||
* @param {Auth} auth
|
||||
* @returns {oldUser}
|
||||
*/
|
||||
static async createRootUser(username, pash, auth) {
|
||||
const userId = uuidv4()
|
||||
|
||||
const token = await auth.generateAccessToken({ userId, username })
|
||||
|
||||
const newRoot = new oldUser({
|
||||
id: userId,
|
||||
type: 'root',
|
||||
username,
|
||||
pash,
|
||||
token,
|
||||
isActive: true,
|
||||
createdAt: Date.now()
|
||||
})
|
||||
await this.createFromOld(newRoot)
|
||||
return newRoot
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user by id or by the old database id
|
||||
* @temp User ids were updated in v2.3.0 migration and old API tokens may still use that id
|
||||
* @param {string} userId
|
||||
* @returns {Promise<oldUser|null>} null if not found
|
||||
*/
|
||||
static async getUserByIdOrOldId(userId) {
|
||||
if (!userId) return null
|
||||
const user = await this.findOne({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{
|
||||
id: userId
|
||||
},
|
||||
{
|
||||
extraData: {
|
||||
[Op.substring]: userId
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
include: this.sequelize.models.mediaProgress
|
||||
})
|
||||
if (!user) return null
|
||||
return this.getOldUser(user)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by username case insensitive
|
||||
* @param {string} username
|
||||
* @returns {Promise<oldUser|null>} returns null if not found
|
||||
*/
|
||||
static async getUserByUsername(username) {
|
||||
if (!username) return null
|
||||
const user = await this.findOne({
|
||||
where: {
|
||||
username: {
|
||||
[Op.like]: username
|
||||
}
|
||||
},
|
||||
include: this.sequelize.models.mediaProgress
|
||||
})
|
||||
if (!user) return null
|
||||
return this.getOldUser(user)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by id
|
||||
* @param {string} userId
|
||||
* @returns {Promise<oldUser|null>} returns null if not found
|
||||
*/
|
||||
static async getUserById(userId) {
|
||||
if (!userId) return null
|
||||
const user = await this.findByPk(userId, {
|
||||
include: this.sequelize.models.mediaProgress
|
||||
})
|
||||
if (!user) return null
|
||||
return this.getOldUser(user)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get array of user id and username
|
||||
* @returns {object[]} { id, username }
|
||||
*/
|
||||
static async getMinifiedUserObjects() {
|
||||
const users = await this.findAll({
|
||||
attributes: ['id', 'username']
|
||||
})
|
||||
return users.map(u => {
|
||||
return {
|
||||
id: u.id,
|
||||
username: u.username
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if root user exists
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static async getHasRootUser() {
|
||||
const count = await this.count({
|
||||
where: {
|
||||
type: 'root'
|
||||
}
|
||||
})
|
||||
return count > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize model
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
*/
|
||||
static init(sequelize) {
|
||||
super.init({
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
username: DataTypes.STRING,
|
||||
email: DataTypes.STRING,
|
||||
pash: DataTypes.STRING,
|
||||
type: DataTypes.STRING,
|
||||
token: DataTypes.STRING,
|
||||
isActive: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false
|
||||
},
|
||||
isLocked: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false
|
||||
},
|
||||
lastSeen: DataTypes.DATE,
|
||||
permissions: DataTypes.JSON,
|
||||
bookmarks: DataTypes.JSON,
|
||||
extraData: DataTypes.JSON
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'user'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = User
|
||||
|
|
@ -61,6 +61,10 @@ class FeedMeta {
|
|||
}
|
||||
|
||||
getRSSData() {
|
||||
const blockTags = [
|
||||
{ 'itunes:block': 'yes' },
|
||||
{ 'googleplay:block': 'yes' }
|
||||
]
|
||||
return {
|
||||
title: this.title,
|
||||
description: this.description || '',
|
||||
|
|
@ -94,8 +98,7 @@ class FeedMeta {
|
|||
]
|
||||
},
|
||||
{ 'itunes:explicit': !!this.explicit },
|
||||
{ 'itunes:block': this.preventIndexing?"Yes":"No" },
|
||||
{ 'googleplay:block': this.preventIndexing?"yes":"no" }
|
||||
...(this.preventIndexing ? blockTags : [])
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,9 +116,9 @@ class Library {
|
|||
}
|
||||
|
||||
update(payload) {
|
||||
var hasUpdates = false
|
||||
let hasUpdates = false
|
||||
|
||||
var keysToCheck = ['name', 'provider', 'mediaType', 'icon']
|
||||
const keysToCheck = ['name', 'provider', 'mediaType', 'icon']
|
||||
keysToCheck.forEach((key) => {
|
||||
if (payload[key] && payload[key] !== this[key]) {
|
||||
this[key] = payload[key]
|
||||
|
|
@ -135,18 +135,18 @@ class Library {
|
|||
hasUpdates = true
|
||||
}
|
||||
if (payload.folders) {
|
||||
var newFolders = payload.folders.filter(f => !f.id)
|
||||
var removedFolders = this.folders.filter(f => !payload.folders.find(_f => _f.id === f.id))
|
||||
const newFolders = payload.folders.filter(f => !f.id)
|
||||
const removedFolders = this.folders.filter(f => !payload.folders.some(_f => _f.id === f.id))
|
||||
|
||||
if (removedFolders.length) {
|
||||
var removedFolderIds = removedFolders.map(f => f.id)
|
||||
const removedFolderIds = removedFolders.map(f => f.id)
|
||||
this.folders = this.folders.filter(f => !removedFolderIds.includes(f.id))
|
||||
}
|
||||
|
||||
if (newFolders.length) {
|
||||
newFolders.forEach((folderData) => {
|
||||
folderData.libraryId = this.id
|
||||
var newFolder = new Folder()
|
||||
const newFolder = new Folder()
|
||||
newFolder.setData(folderData)
|
||||
this.folders.push(newFolder)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const Podcast = require('./mediaTypes/Podcast')
|
|||
const Video = require('./mediaTypes/Video')
|
||||
const Music = require('./mediaTypes/Music')
|
||||
const { areEquivalent, copyValue, cleanStringForSearch } = require('../utils/index')
|
||||
const { filePathToPOSIX } = require('../utils/fileUtils')
|
||||
const { filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')
|
||||
|
||||
class LibraryItem {
|
||||
constructor(libraryItem = null) {
|
||||
|
|
@ -40,6 +40,7 @@ class LibraryItem {
|
|||
this.mediaType = null
|
||||
this.media = null
|
||||
|
||||
/** @type {LibraryFile[]} */
|
||||
this.libraryFiles = []
|
||||
|
||||
if (libraryItem) {
|
||||
|
|
@ -337,183 +338,6 @@ class LibraryItem {
|
|||
return hasUpdated
|
||||
}
|
||||
|
||||
// Data pulled from scandir during a scan, check it with current data
|
||||
checkScanData(dataFound) {
|
||||
let hasUpdated = false
|
||||
|
||||
if (this.isMissing) {
|
||||
// Item no longer missing
|
||||
this.isMissing = false
|
||||
hasUpdated = true
|
||||
}
|
||||
|
||||
if (dataFound.isFile !== this.isFile && dataFound.isFile !== undefined) {
|
||||
Logger.info(`[LibraryItem] Check scan item isFile toggled from ${this.isFile} => ${dataFound.isFile}`)
|
||||
this.isFile = dataFound.isFile
|
||||
hasUpdated = true
|
||||
}
|
||||
|
||||
if (dataFound.ino !== this.ino) {
|
||||
Logger.warn(`[LibraryItem] Check scan item changed inode "${this.ino}" -> "${dataFound.ino}"`)
|
||||
this.ino = dataFound.ino
|
||||
hasUpdated = true
|
||||
}
|
||||
|
||||
if (dataFound.folderId !== this.folderId) {
|
||||
Logger.warn(`[LibraryItem] Check scan item changed folder ${this.folderId} -> ${dataFound.folderId}`)
|
||||
this.folderId = dataFound.folderId
|
||||
hasUpdated = true
|
||||
}
|
||||
|
||||
if (dataFound.path !== this.path) {
|
||||
Logger.warn(`[LibraryItem] Check scan item changed path "${this.path}" -> "${dataFound.path}" (inode ${this.ino})`)
|
||||
this.path = dataFound.path
|
||||
this.relPath = dataFound.relPath
|
||||
hasUpdated = true
|
||||
}
|
||||
|
||||
['mtimeMs', 'ctimeMs', 'birthtimeMs'].forEach((key) => {
|
||||
if (dataFound[key] != this[key]) {
|
||||
this[key] = dataFound[key] || 0
|
||||
hasUpdated = true
|
||||
}
|
||||
})
|
||||
|
||||
const newLibraryFiles = []
|
||||
const existingLibraryFiles = []
|
||||
|
||||
dataFound.libraryFiles.forEach((lf) => {
|
||||
const fileFoundCheck = this.checkFileFound(lf, true)
|
||||
if (fileFoundCheck === null) {
|
||||
newLibraryFiles.push(lf)
|
||||
} else if (fileFoundCheck && lf.metadata.format !== 'abs' && lf.metadata.filename !== 'metadata.json') { // Ignore abs file updates
|
||||
hasUpdated = true
|
||||
existingLibraryFiles.push(lf)
|
||||
} else {
|
||||
existingLibraryFiles.push(lf)
|
||||
}
|
||||
})
|
||||
|
||||
const filesRemoved = []
|
||||
|
||||
// Remove files not found (inodes will all be up to date at this point)
|
||||
this.libraryFiles = this.libraryFiles.filter(lf => {
|
||||
if (!dataFound.libraryFiles.find(_lf => _lf.ino === lf.ino)) {
|
||||
// Check if removing cover path
|
||||
if (lf.metadata.path === this.media.coverPath) {
|
||||
Logger.debug(`[LibraryItem] "${this.media.metadata.title}" check scan cover removed`)
|
||||
this.media.updateCover('')
|
||||
}
|
||||
filesRemoved.push(lf.toJSON())
|
||||
this.media.removeFileWithInode(lf.ino)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if (filesRemoved.length) {
|
||||
if (this.media.mediaType === 'book') {
|
||||
this.media.checkUpdateMissingTracks()
|
||||
}
|
||||
hasUpdated = true
|
||||
}
|
||||
|
||||
// Add library files to library item
|
||||
if (newLibraryFiles.length) {
|
||||
newLibraryFiles.forEach((lf) => this.libraryFiles.push(lf.clone()))
|
||||
hasUpdated = true
|
||||
}
|
||||
|
||||
// Check if invalid
|
||||
this.isInvalid = !this.media.hasMediaEntities
|
||||
|
||||
// If cover path is in item folder, make sure libraryFile exists for it
|
||||
if (this.media.coverPath && this.media.coverPath.startsWith(this.path)) {
|
||||
const lf = this.libraryFiles.find(lf => lf.metadata.path === this.media.coverPath)
|
||||
if (!lf) {
|
||||
Logger.warn(`[LibraryItem] Invalid cover path - library file dne "${this.media.coverPath}"`)
|
||||
this.media.updateCover('')
|
||||
hasUpdated = true
|
||||
}
|
||||
}
|
||||
|
||||
if (hasUpdated) {
|
||||
this.setLastScan()
|
||||
}
|
||||
|
||||
return {
|
||||
updated: hasUpdated,
|
||||
newLibraryFiles,
|
||||
filesRemoved,
|
||||
existingLibraryFiles // Existing file data may get re-scanned if forceRescan is set
|
||||
}
|
||||
}
|
||||
|
||||
// Set metadata from files
|
||||
async syncFiles(preferOpfMetadata, librarySettings) {
|
||||
let hasUpdated = false
|
||||
|
||||
if (this.isBook) {
|
||||
// Add/update ebook files (ebooks that were removed are removed in checkScanData)
|
||||
if (librarySettings.audiobooksOnly) {
|
||||
hasUpdated = this.media.ebookFile
|
||||
if (hasUpdated) {
|
||||
// If library was set to audiobooks only then set primary ebook as supplementary
|
||||
Logger.info(`[LibraryItem] Library is audiobooks only so setting ebook "${this.media.ebookFile.metadata.filename}" as supplementary`)
|
||||
}
|
||||
this.setPrimaryEbook(null)
|
||||
} else if (this.media.ebookFile) {
|
||||
const matchingLibraryFile = this.libraryFiles.find(lf => lf.ino === this.media.ebookFile.ino)
|
||||
if (matchingLibraryFile && this.media.ebookFile.updateFromLibraryFile(matchingLibraryFile)) {
|
||||
hasUpdated = true
|
||||
}
|
||||
// Set any other ebook files as supplementary
|
||||
const suppEbookLibraryFiles = this.libraryFiles.filter(lf => lf.isEBookFile && !lf.isSupplementary && this.media.ebookFile.ino !== lf.ino)
|
||||
if (suppEbookLibraryFiles.length) {
|
||||
for (const libraryFile of suppEbookLibraryFiles) {
|
||||
libraryFile.isSupplementary = true
|
||||
}
|
||||
hasUpdated = true
|
||||
}
|
||||
} else {
|
||||
const ebookLibraryFiles = this.libraryFiles.filter(lf => lf.isEBookFile && !lf.isSupplementary)
|
||||
|
||||
// Prefer epub ebook then fallback to first other ebook file
|
||||
const ebookLibraryFile = ebookLibraryFiles.find(lf => lf.metadata.format === 'epub') || ebookLibraryFiles[0]
|
||||
if (ebookLibraryFile) {
|
||||
this.setPrimaryEbook(ebookLibraryFile)
|
||||
hasUpdated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set cover image if not set
|
||||
const imageFiles = this.libraryFiles.filter(lf => lf.fileType === 'image')
|
||||
if (imageFiles.length && !this.media.coverPath) {
|
||||
// attempt to find a file called cover.<ext> otherwise just fall back to the first image found
|
||||
const coverMatch = imageFiles.find(iFile => /\/cover\.[^.\/]*$/.test(iFile.metadata.path))
|
||||
if (coverMatch) {
|
||||
this.media.coverPath = coverMatch.metadata.path
|
||||
} else {
|
||||
this.media.coverPath = imageFiles[0].metadata.path
|
||||
}
|
||||
Logger.info('[LibraryItem] Set media cover path', this.media.coverPath)
|
||||
hasUpdated = true
|
||||
}
|
||||
|
||||
// Parse metadata files
|
||||
const textMetadataFiles = this.libraryFiles.filter(lf => lf.fileType === 'metadata' || lf.fileType === 'text')
|
||||
if (textMetadataFiles.length) {
|
||||
if (await this.media.syncMetadataFiles(textMetadataFiles, preferOpfMetadata)) {
|
||||
hasUpdated = true
|
||||
}
|
||||
}
|
||||
|
||||
if (hasUpdated) {
|
||||
this.updatedAt = Date.now()
|
||||
}
|
||||
return hasUpdated
|
||||
}
|
||||
|
||||
searchQuery(query) {
|
||||
query = cleanStringForSearch(query)
|
||||
return this.media.searchQuery(query)
|
||||
|
|
@ -525,19 +349,20 @@ class LibraryItem {
|
|||
|
||||
/**
|
||||
* Save metadata.json/metadata.abs file
|
||||
* @returns {boolean} true if saved
|
||||
* @returns {Promise<LibraryFile>} null if not saved
|
||||
*/
|
||||
async saveMetadata() {
|
||||
if (this.mediaType === 'video' || this.mediaType === 'music') return
|
||||
if (this.isSavingMetadata) return null
|
||||
|
||||
if (this.isSavingMetadata) return
|
||||
this.isSavingMetadata = true
|
||||
|
||||
let metadataPath = Path.join(global.MetadataPath, 'items', this.id)
|
||||
if (global.ServerSettings.storeMetadataWithItem && !this.isFile) {
|
||||
let storeMetadataWithItem = global.ServerSettings.storeMetadataWithItem
|
||||
if (storeMetadataWithItem && !this.isFile) {
|
||||
metadataPath = this.path
|
||||
} else {
|
||||
// Make sure metadata book dir exists
|
||||
storeMetadataWithItem = false
|
||||
await fs.ensureDir(metadataPath)
|
||||
}
|
||||
|
||||
|
|
@ -552,20 +377,37 @@ class LibraryItem {
|
|||
}
|
||||
|
||||
return fs.writeFile(metadataFilePath, JSON.stringify(this.media.toJSONForMetadataFile(), null, 2)).then(async () => {
|
||||
this.isSavingMetadata = false
|
||||
// Add metadata.json to libraryFiles array if it is new
|
||||
if (global.ServerSettings.storeMetadataWithItem && !this.libraryFiles.some(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath))) {
|
||||
const newLibraryFile = new LibraryFile()
|
||||
await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`)
|
||||
this.libraryFiles.push(newLibraryFile)
|
||||
let metadataLibraryFile = this.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath))
|
||||
if (storeMetadataWithItem) {
|
||||
if (!metadataLibraryFile) {
|
||||
metadataLibraryFile = new LibraryFile()
|
||||
await metadataLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`)
|
||||
this.libraryFiles.push(metadataLibraryFile)
|
||||
} else {
|
||||
const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath)
|
||||
if (fileTimestamps) {
|
||||
metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs
|
||||
metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs
|
||||
metadataLibraryFile.metadata.size = fileTimestamps.size
|
||||
metadataLibraryFile.ino = fileTimestamps.ino
|
||||
}
|
||||
}
|
||||
const libraryItemDirTimestamps = await getFileTimestampsWithIno(this.path)
|
||||
if (libraryItemDirTimestamps) {
|
||||
this.mtimeMs = libraryItemDirTimestamps.mtimeMs
|
||||
this.ctimeMs = libraryItemDirTimestamps.ctimeMs
|
||||
}
|
||||
}
|
||||
|
||||
Logger.debug(`[LibraryItem] Success saving abmetadata to "${metadataFilePath}"`)
|
||||
|
||||
return true
|
||||
return metadataLibraryFile
|
||||
}).catch((error) => {
|
||||
this.isSavingMetadata = false
|
||||
Logger.error(`[LibraryItem] Failed to save json file at "${metadataFilePath}"`, error)
|
||||
return false
|
||||
return null
|
||||
}).finally(() => {
|
||||
this.isSavingMetadata = false
|
||||
})
|
||||
} else {
|
||||
// Remove metadata.json if it exists
|
||||
|
|
@ -576,19 +418,37 @@ class LibraryItem {
|
|||
}
|
||||
|
||||
return abmetadataGenerator.generate(this, metadataFilePath).then(async (success) => {
|
||||
this.isSavingMetadata = false
|
||||
if (!success) Logger.error(`[LibraryItem] Failed saving abmetadata to "${metadataFilePath}"`)
|
||||
else {
|
||||
// Add metadata.abs to libraryFiles array if it is new
|
||||
if (global.ServerSettings.storeMetadataWithItem && !this.libraryFiles.some(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath))) {
|
||||
const newLibraryFile = new LibraryFile()
|
||||
await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.abs`)
|
||||
this.libraryFiles.push(newLibraryFile)
|
||||
}
|
||||
|
||||
Logger.debug(`[LibraryItem] Success saving abmetadata to "${metadataFilePath}"`)
|
||||
if (!success) {
|
||||
Logger.error(`[LibraryItem] Failed saving abmetadata to "${metadataFilePath}"`)
|
||||
return null
|
||||
}
|
||||
return success
|
||||
// Add metadata.abs to libraryFiles array if it is new
|
||||
let metadataLibraryFile = this.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath))
|
||||
if (storeMetadataWithItem) {
|
||||
if (!metadataLibraryFile) {
|
||||
metadataLibraryFile = new LibraryFile()
|
||||
await metadataLibraryFile.setDataFromPath(metadataFilePath, `metadata.abs`)
|
||||
this.libraryFiles.push(metadataLibraryFile)
|
||||
} else {
|
||||
const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath)
|
||||
if (fileTimestamps) {
|
||||
metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs
|
||||
metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs
|
||||
metadataLibraryFile.metadata.size = fileTimestamps.size
|
||||
metadataLibraryFile.ino = fileTimestamps.ino
|
||||
}
|
||||
}
|
||||
const libraryItemDirTimestamps = await getFileTimestampsWithIno(this.path)
|
||||
if (libraryItemDirTimestamps) {
|
||||
this.mtimeMs = libraryItemDirTimestamps.mtimeMs
|
||||
this.ctimeMs = libraryItemDirTimestamps.ctimeMs
|
||||
}
|
||||
}
|
||||
|
||||
Logger.debug(`[LibraryItem] Success saving abmetadata to "${metadataFilePath}"`)
|
||||
return metadataLibraryFile
|
||||
}).finally(() => {
|
||||
this.isSavingMetadata = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,11 @@ class PlaybackSession {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Session data to send to clients
|
||||
* @param {[oldLibraryItem]} libraryItem optional
|
||||
* @returns {object}
|
||||
*/
|
||||
toJSONForClient(libraryItem) {
|
||||
return {
|
||||
id: this.id,
|
||||
|
|
@ -105,8 +110,8 @@ class PlaybackSession {
|
|||
startedAt: this.startedAt,
|
||||
updatedAt: this.updatedAt,
|
||||
audioTracks: this.audioTracks.map(at => at.toJSON()),
|
||||
videoTrack: this.videoTrack ? this.videoTrack.toJSON() : null,
|
||||
libraryItem: libraryItem.toJSONExpanded()
|
||||
videoTrack: this.videoTrack?.toJSON() || null,
|
||||
libraryItem: libraryItem?.toJSONExpanded() || null
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ class AudioFile {
|
|||
constructor(data) {
|
||||
this.index = null
|
||||
this.ino = null
|
||||
/** @type {FileMetadata} */
|
||||
this.metadata = null
|
||||
this.addedAt = null
|
||||
this.updatedAt = null
|
||||
|
|
@ -27,6 +28,7 @@ class AudioFile {
|
|||
this.embeddedCoverArt = null
|
||||
|
||||
// Tags scraped from the audio file
|
||||
/** @type {AudioMetaTags} */
|
||||
this.metaTags = null
|
||||
|
||||
this.manuallyVerified = false
|
||||
|
|
@ -64,7 +66,7 @@ class AudioFile {
|
|||
channelLayout: this.channelLayout,
|
||||
chapters: this.chapters,
|
||||
embeddedCoverArt: this.embeddedCoverArt,
|
||||
metaTags: this.metaTags ? this.metaTags.toJSON() : {},
|
||||
metaTags: this.metaTags?.toJSON() || {},
|
||||
mimeType: this.mimeType
|
||||
}
|
||||
}
|
||||
|
|
@ -114,11 +116,16 @@ class AudioFile {
|
|||
return !this.invalid && !this.exclude
|
||||
}
|
||||
|
||||
// New scanner creates AudioFile from MediaFileScanner
|
||||
// New scanner creates AudioFile from AudioFileScanner
|
||||
setDataFromProbe(libraryFile, probeData) {
|
||||
this.ino = libraryFile.ino || null
|
||||
|
||||
this.metadata = libraryFile.metadata.clone()
|
||||
if (libraryFile.metadata instanceof FileMetadata) {
|
||||
this.metadata = libraryFile.metadata.clone()
|
||||
} else {
|
||||
this.metadata = new FileMetadata(libraryFile.metadata)
|
||||
}
|
||||
|
||||
this.addedAt = Date.now()
|
||||
this.updatedAt = Date.now()
|
||||
|
||||
|
|
@ -163,11 +170,16 @@ class AudioFile {
|
|||
return new AudioFile(this.toJSON())
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {AudioFile} scannedAudioFile
|
||||
* @returns {boolean} true if updates were made
|
||||
*/
|
||||
updateFromScan(scannedAudioFile) {
|
||||
let hasUpdated = false
|
||||
|
||||
const newjson = scannedAudioFile.toJSON()
|
||||
const ignoreKeys = ['manuallyVerified', 'exclude', 'addedAt', 'updatedAt']
|
||||
const ignoreKeys = ['manuallyVerified', 'ctimeMs', 'addedAt', 'updatedAt']
|
||||
|
||||
for (const key in newjson) {
|
||||
if (key === 'metadata') {
|
||||
|
|
|
|||
|
|
@ -4,10 +4,6 @@ const PodcastMetadata = require('../metadata/PodcastMetadata')
|
|||
const { areEquivalent, copyValue, cleanStringForSearch } = require('../../utils/index')
|
||||
const abmetadataGenerator = require('../../utils/generators/abmetadataGenerator')
|
||||
const { readTextFile, filePathToPOSIX } = require('../../utils/fileUtils')
|
||||
const { createNewSortInstance } = require('../../libs/fastSort')
|
||||
const naturalSort = createNewSortInstance({
|
||||
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
|
||||
})
|
||||
|
||||
class Podcast {
|
||||
constructor(podcast) {
|
||||
|
|
|
|||
|
|
@ -330,10 +330,6 @@ class BookMetadata {
|
|||
{
|
||||
tag: 'tagASIN',
|
||||
key: 'asin'
|
||||
},
|
||||
{
|
||||
tag: 'tagOverdriveMediaMarker',
|
||||
key: 'overdriveMediaMarker'
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ class ServerSettings {
|
|||
|
||||
// Sorting
|
||||
this.sortingIgnorePrefix = false
|
||||
this.sortingPrefixes = ['the']
|
||||
this.sortingPrefixes = ['the', 'a']
|
||||
|
||||
// Misc Flags
|
||||
this.chromecastEnabled = false
|
||||
|
|
|
|||
|
|
@ -117,23 +117,20 @@ class User {
|
|||
return json
|
||||
}
|
||||
|
||||
// Data broadcasted
|
||||
toJSONForPublic(sessions, libraryItems) {
|
||||
var userSession = sessions ? sessions.find(s => s.userId === this.id) : null
|
||||
var session = null
|
||||
if (userSession) {
|
||||
var libraryItem = libraryItems.find(li => li.id === userSession.libraryItemId)
|
||||
if (libraryItem) {
|
||||
session = userSession.toJSONForClient(libraryItem)
|
||||
}
|
||||
}
|
||||
/**
|
||||
* User data for clients
|
||||
* @param {[oldPlaybackSession[]]} sessions optional array of open playback sessions
|
||||
* @returns {object}
|
||||
*/
|
||||
toJSONForPublic(sessions) {
|
||||
const userSession = sessions?.find(s => s.userId === this.id) || null
|
||||
const session = userSession?.toJSONForClient() || null
|
||||
return {
|
||||
id: this.id,
|
||||
oldUserId: this.oldUserId,
|
||||
username: this.username,
|
||||
type: this.type,
|
||||
session,
|
||||
mostRecent: this.getMostRecentItemProgress(libraryItems),
|
||||
lastSeen: this.lastSeen,
|
||||
createdAt: this.createdAt
|
||||
}
|
||||
|
|
@ -269,45 +266,6 @@ class User {
|
|||
return libraryIds.find(lid => this.checkCanAccessLibrary(lid)) || null
|
||||
}
|
||||
|
||||
// Returns most recent media progress w/ `media` object and optionally an `episode` object
|
||||
getMostRecentItemProgress(libraryItems) {
|
||||
if (!this.mediaProgress.length) return null
|
||||
var mediaProgressObjects = this.mediaProgress.map(lip => lip.toJSON())
|
||||
mediaProgressObjects.sort((a, b) => b.lastUpdate - a.lastUpdate)
|
||||
|
||||
var libraryItemMedia = null
|
||||
var progressEpisode = null
|
||||
// Find the most recent progress that still has a libraryItem and episode
|
||||
var mostRecentProgress = mediaProgressObjects.find((progress) => {
|
||||
const libraryItem = libraryItems.find(li => li.id === progress.libraryItemId)
|
||||
if (!libraryItem) {
|
||||
Logger.warn('[User] Library item not found for users progress ' + progress.libraryItemId)
|
||||
return false
|
||||
} else if (progress.episodeId) {
|
||||
const episode = libraryItem.mediaType === 'podcast' ? libraryItem.media.getEpisode(progress.episodeId) : null
|
||||
if (!episode) {
|
||||
Logger.warn(`[User] Episode ${progress.episodeId} not found for user media progress, podcast: ${libraryItem.media.metadata.title}`)
|
||||
return false
|
||||
} else {
|
||||
libraryItemMedia = libraryItem.media.toJSONExpanded()
|
||||
progressEpisode = episode.toJSON()
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
libraryItemMedia = libraryItem.media.toJSONExpanded()
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
if (!mostRecentProgress) return null
|
||||
|
||||
return {
|
||||
...mostRecentProgress,
|
||||
media: libraryItemMedia,
|
||||
episode: progressEpisode
|
||||
}
|
||||
}
|
||||
|
||||
getMediaProgress(libraryItemId, episodeId = null) {
|
||||
if (!this.mediaProgress) return null
|
||||
return this.mediaProgress.find(lip => {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
const sequelize = require('sequelize')
|
||||
const express = require('express')
|
||||
const Path = require('path')
|
||||
|
||||
|
|
@ -8,6 +9,8 @@ const SocketAuthority = require('../SocketAuthority')
|
|||
const fs = require('../libs/fsExtra')
|
||||
const date = require('../libs/dateAndTime')
|
||||
|
||||
const CacheManager = require('../managers/CacheManager')
|
||||
|
||||
const LibraryController = require('../controllers/LibraryController')
|
||||
const UserController = require('../controllers/UserController')
|
||||
const CollectionController = require('../controllers/CollectionController')
|
||||
|
|
@ -28,24 +31,16 @@ const ToolsController = require('../controllers/ToolsController')
|
|||
const RSSFeedController = require('../controllers/RSSFeedController')
|
||||
const MiscController = require('../controllers/MiscController')
|
||||
|
||||
const BookFinder = require('../finders/BookFinder')
|
||||
const AuthorFinder = require('../finders/AuthorFinder')
|
||||
const PodcastFinder = require('../finders/PodcastFinder')
|
||||
const MusicFinder = require('../finders/MusicFinder')
|
||||
|
||||
const Author = require('../objects/entities/Author')
|
||||
const Series = require('../objects/entities/Series')
|
||||
|
||||
class ApiRouter {
|
||||
constructor(Server) {
|
||||
this.auth = Server.auth
|
||||
this.scanner = Server.scanner
|
||||
this.playbackSessionManager = Server.playbackSessionManager
|
||||
this.abMergeManager = Server.abMergeManager
|
||||
this.backupManager = Server.backupManager
|
||||
this.coverManager = Server.coverManager
|
||||
this.watcher = Server.watcher
|
||||
this.cacheManager = Server.cacheManager
|
||||
this.podcastManager = Server.podcastManager
|
||||
this.audioMetadataManager = Server.audioMetadataManager
|
||||
this.rssFeedManager = Server.rssFeedManager
|
||||
|
|
@ -54,11 +49,6 @@ class ApiRouter {
|
|||
this.emailManager = Server.emailManager
|
||||
this.taskManager = Server.taskManager
|
||||
|
||||
this.bookFinder = new BookFinder()
|
||||
this.authorFinder = new AuthorFinder()
|
||||
this.podcastFinder = new PodcastFinder()
|
||||
this.musicFinder = new MusicFinder()
|
||||
|
||||
this.router = express()
|
||||
this.router.disable('x-powered-by')
|
||||
this.init()
|
||||
|
|
@ -74,17 +64,14 @@ class ApiRouter {
|
|||
this.router.patch('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.update.bind(this))
|
||||
this.router.delete('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.delete.bind(this))
|
||||
|
||||
this.router.get('/libraries/:id/items2', LibraryController.middlewareNew.bind(this), LibraryController.getLibraryItemsNew.bind(this))
|
||||
this.router.get('/libraries/:id/items', LibraryController.middleware.bind(this), LibraryController.getLibraryItems.bind(this))
|
||||
this.router.delete('/libraries/:id/issues', LibraryController.middleware.bind(this), LibraryController.removeLibraryItemsWithIssues.bind(this))
|
||||
this.router.get('/libraries/:id/episode-downloads', LibraryController.middleware.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/:seriesId', LibraryController.middleware.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/collections', LibraryController.middleware.bind(this), LibraryController.getCollectionsForLibrary.bind(this))
|
||||
this.router.get('/libraries/:id/playlists', LibraryController.middleware.bind(this), LibraryController.getUserPlaylistsForLibrary.bind(this))
|
||||
this.router.get('/libraries/:id/albums', LibraryController.middleware.bind(this), LibraryController.getAlbumsForLibrary.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.middleware.bind(this), LibraryController.getUserPersonalizedShelves.bind(this))
|
||||
this.router.get('/libraries/:id/filterdata', LibraryController.middleware.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))
|
||||
|
|
@ -210,7 +197,6 @@ class ApiRouter {
|
|||
//
|
||||
// Author Routes
|
||||
//
|
||||
this.router.get('/authors/search', AuthorController.search.bind(this))
|
||||
this.router.get('/authors/:id', AuthorController.middleware.bind(this), AuthorController.findOne.bind(this))
|
||||
this.router.patch('/authors/:id', AuthorController.middleware.bind(this), AuthorController.update.bind(this))
|
||||
this.router.post('/authors/:id/match', AuthorController.middleware.bind(this), AuthorController.match.bind(this))
|
||||
|
|
@ -219,7 +205,6 @@ class ApiRouter {
|
|||
//
|
||||
// Series Routes
|
||||
//
|
||||
this.router.get('/series/search', SeriesController.search.bind(this))
|
||||
this.router.get('/series/:id', SeriesController.middleware.bind(this), SeriesController.findOne.bind(this))
|
||||
this.router.patch('/series/:id', SeriesController.middleware.bind(this), SeriesController.update.bind(this))
|
||||
|
||||
|
|
@ -297,9 +282,10 @@ class ApiRouter {
|
|||
this.router.post('/tools/item/:id/embed-metadata', ToolsController.middleware.bind(this), ToolsController.embedAudioFileMetadata.bind(this))
|
||||
this.router.post('/tools/batch/embed-metadata', ToolsController.middleware.bind(this), ToolsController.batchEmbedMetadata.bind(this))
|
||||
|
||||
//
|
||||
//
|
||||
// RSS Feed Routes (Admin and up)
|
||||
//
|
||||
this.router.get('/feeds', RSSFeedController.middleware.bind(this), RSSFeedController.getAll.bind(this))
|
||||
this.router.post('/feeds/item/:itemId/open', RSSFeedController.middleware.bind(this), RSSFeedController.openRSSFeedForItem.bind(this))
|
||||
this.router.post('/feeds/collection/:collectionId/open', RSSFeedController.middleware.bind(this), RSSFeedController.openRSSFeedForCollection.bind(this))
|
||||
this.router.post('/feeds/series/:seriesId/open', RSSFeedController.middleware.bind(this), RSSFeedController.openRSSFeedForSeries.bind(this))
|
||||
|
|
@ -311,6 +297,7 @@ class ApiRouter {
|
|||
this.router.post('/upload', MiscController.handleUpload.bind(this))
|
||||
this.router.get('/tasks', MiscController.getTasks.bind(this))
|
||||
this.router.patch('/settings', MiscController.updateServerSettings.bind(this))
|
||||
this.router.patch('/sorting-prefixes', MiscController.updateSortingPrefixes.bind(this))
|
||||
this.router.post('/authorize', MiscController.authorize.bind(this))
|
||||
this.router.get('/tags', MiscController.getAllTags.bind(this))
|
||||
this.router.post('/tags/rename', MiscController.renameTag.bind(this))
|
||||
|
|
@ -353,112 +340,142 @@ class ApiRouter {
|
|||
//
|
||||
// Helper Methods
|
||||
//
|
||||
userJsonWithItemProgressDetails(user, hideRootToken = false) {
|
||||
const json = user.toJSONForBrowser(hideRootToken)
|
||||
|
||||
json.mediaProgress = json.mediaProgress.map(lip => {
|
||||
const libraryItem = Database.libraryItems.find(li => li.id === lip.libraryItemId)
|
||||
if (!libraryItem) {
|
||||
Logger.warn('[ApiRouter] Library item not found for users progress ' + lip.libraryItemId)
|
||||
lip.media = null
|
||||
} else {
|
||||
if (lip.episodeId) {
|
||||
const episode = libraryItem.mediaType === 'podcast' ? libraryItem.media.getEpisode(lip.episodeId) : null
|
||||
if (!episode) {
|
||||
Logger.warn(`[ApiRouter] Episode ${lip.episodeId} not found for user media progress, podcast: ${libraryItem.media.metadata.title}`)
|
||||
lip.media = null
|
||||
} else {
|
||||
lip.media = libraryItem.media.toJSONExpanded()
|
||||
lip.episode = episode.toJSON()
|
||||
}
|
||||
} else {
|
||||
lip.media = libraryItem.media.toJSONExpanded()
|
||||
}
|
||||
}
|
||||
return lip
|
||||
}).filter(lip => !!lip)
|
||||
|
||||
return json
|
||||
}
|
||||
|
||||
async handleDeleteLibraryItem(libraryItem) {
|
||||
/**
|
||||
* Remove library item and associated entities
|
||||
* @param {string} mediaType
|
||||
* @param {string} libraryItemId
|
||||
* @param {string[]} mediaItemIds array of bookId or podcastEpisodeId
|
||||
*/
|
||||
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(libraryItem.id)) {
|
||||
for (const mediaProgress of user.getAllMediaProgressForLibraryItem(libraryItemId)) {
|
||||
await Database.removeMediaProgress(mediaProgress.id)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Remove open sessions for library item
|
||||
let mediaItemIds = []
|
||||
if (libraryItem.isBook) {
|
||||
// remove book from collections
|
||||
const collectionsWithBook = await Database.models.collection.getAllForBook(libraryItem.media.id)
|
||||
for (const collection of collectionsWithBook) {
|
||||
collection.removeBook(libraryItem.id)
|
||||
await Database.removeCollectionBook(collection.id, libraryItem.media.id)
|
||||
SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(Database.libraryItems))
|
||||
|
||||
// Remove series if empty
|
||||
if (mediaType === 'book') {
|
||||
// TODO: update filter data
|
||||
const bookSeries = await Database.bookSeriesModel.findAll({
|
||||
where: {
|
||||
bookId: mediaItemIds[0]
|
||||
},
|
||||
include: {
|
||||
model: Database.seriesModel,
|
||||
include: {
|
||||
model: Database.bookModel
|
||||
}
|
||||
}
|
||||
})
|
||||
for (const bs of bookSeries) {
|
||||
if (bs.series.books.length === 1) {
|
||||
await this.removeEmptySeries(bs.series)
|
||||
}
|
||||
}
|
||||
|
||||
// Check remove empty series
|
||||
await this.checkRemoveEmptySeries(libraryItem.media.metadata.series, libraryItem.id)
|
||||
|
||||
mediaItemIds.push(libraryItem.media.id)
|
||||
} else if (libraryItem.isPodcast) {
|
||||
mediaItemIds.push(...libraryItem.media.episodes.map(ep => ep.id))
|
||||
}
|
||||
|
||||
// remove item from playlists
|
||||
const playlistsWithItem = await Database.models.playlist.getPlaylistsForMediaItemIds(mediaItemIds)
|
||||
const playlistsWithItem = await Database.playlistModel.getPlaylistsForMediaItemIds(mediaItemIds)
|
||||
for (const playlist of playlistsWithItem) {
|
||||
playlist.removeItemsForLibraryItem(libraryItem.id)
|
||||
let numMediaItems = playlist.playlistMediaItems.length
|
||||
|
||||
let order = 1
|
||||
// Remove items in playlist and re-order
|
||||
for (const playlistMediaItem of playlist.playlistMediaItems) {
|
||||
if (mediaItemIds.includes(playlistMediaItem.mediaItemId)) {
|
||||
await playlistMediaItem.destroy()
|
||||
numMediaItems--
|
||||
} else {
|
||||
if (playlistMediaItem.order !== order) {
|
||||
playlistMediaItem.update({
|
||||
order
|
||||
})
|
||||
}
|
||||
order++
|
||||
}
|
||||
}
|
||||
|
||||
// If playlist is now empty then remove it
|
||||
if (!playlist.items.length) {
|
||||
const jsonExpanded = await playlist.getOldJsonExpanded()
|
||||
if (!numMediaItems) {
|
||||
Logger.info(`[ApiRouter] Playlist "${playlist.name}" has no more items - removing it`)
|
||||
await Database.removePlaylist(playlist.id)
|
||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', playlist.toJSONExpanded(Database.libraryItems))
|
||||
await playlist.destroy()
|
||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
|
||||
} else {
|
||||
await Database.updatePlaylist(playlist)
|
||||
|
||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', playlist.toJSONExpanded(Database.libraryItems))
|
||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
|
||||
}
|
||||
}
|
||||
|
||||
// Close rss feed - remove from db and emit socket event
|
||||
await this.rssFeedManager.closeFeedForEntityId(libraryItem.id)
|
||||
await this.rssFeedManager.closeFeedForEntityId(libraryItemId)
|
||||
|
||||
// purge cover cache
|
||||
if (libraryItem.media.coverPath) {
|
||||
await this.cacheManager.purgeCoverCache(libraryItem.id)
|
||||
}
|
||||
await CacheManager.purgeCoverCache(libraryItemId)
|
||||
|
||||
const itemMetadataPath = Path.join(global.MetadataPath, 'items', libraryItem.id)
|
||||
const itemMetadataPath = Path.join(global.MetadataPath, 'items', libraryItemId)
|
||||
if (await fs.pathExists(itemMetadataPath)) {
|
||||
Logger.debug(`[ApiRouter] Removing item metadata path "${itemMetadataPath}"`)
|
||||
await fs.remove(itemMetadataPath)
|
||||
}
|
||||
|
||||
await Database.removeLibraryItem(libraryItem.id)
|
||||
SocketAuthority.emitter('item_removed', libraryItem.toJSONExpanded())
|
||||
await Database.removeLibraryItem(libraryItemId)
|
||||
|
||||
SocketAuthority.emitter('item_removed', {
|
||||
id: libraryItemId
|
||||
})
|
||||
}
|
||||
|
||||
async checkRemoveEmptySeries(seriesToCheck, excludeLibraryItemId = null) {
|
||||
if (!seriesToCheck || !seriesToCheck.length) return
|
||||
/**
|
||||
* Used when a series is removed from a book
|
||||
* Series is removed if it only has 1 book
|
||||
*
|
||||
* @param {string} bookId
|
||||
* @param {string[]} seriesIds
|
||||
*/
|
||||
async checkRemoveEmptySeries(bookId, seriesIds) {
|
||||
if (!seriesIds?.length) return
|
||||
|
||||
for (const series of seriesToCheck) {
|
||||
const otherLibraryItemsInSeries = Database.libraryItems.filter(li => li.id !== excludeLibraryItemId && li.isBook && li.media.metadata.hasSeries(series.id))
|
||||
if (!otherLibraryItemsInSeries.length) {
|
||||
// Close open RSS feed for series
|
||||
await this.rssFeedManager.closeFeedForEntityId(series.id)
|
||||
Logger.info(`[ApiRouter] Series "${series.name}" is now empty. Removing series`)
|
||||
await Database.removeSeries(series.id)
|
||||
// TODO: Socket events for series?
|
||||
const bookSeries = await Database.bookSeriesModel.findAll({
|
||||
where: {
|
||||
bookId,
|
||||
seriesId: seriesIds
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: Database.seriesModel,
|
||||
include: {
|
||||
model: Database.bookModel
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
for (const bs of bookSeries) {
|
||||
if (bs.series.books.length === 1) {
|
||||
await this.removeEmptySeries(bs.series)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const userSessions = await Database.getPlaybackSessions({ userId })
|
||||
return userSessions.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
|
|
@ -467,7 +484,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,
|
||||
|
|
@ -527,7 +544,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()
|
||||
|
|
@ -536,13 +553,21 @@ 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))
|
||||
let author = await Database.authorModel.getOldByNameAndLibrary(authorName, libraryId)
|
||||
if (!author) {
|
||||
author = new Author()
|
||||
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
|
||||
|
|
@ -565,13 +590,21 @@ 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))
|
||||
let seriesItem = await Database.seriesModel.getOldByNameAndLibrary(seriesName, libraryId)
|
||||
if (!seriesItem) {
|
||||
seriesItem = new Series()
|
||||
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
|
||||
|
|
|
|||
209
server/scanner/AudioFileScanner.js
Normal file
209
server/scanner/AudioFileScanner.js
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
const Path = require('path')
|
||||
const Logger = require('../Logger')
|
||||
const prober = require('../utils/prober')
|
||||
const LibraryItem = require('../models/LibraryItem')
|
||||
const AudioFile = require('../objects/files/AudioFile')
|
||||
|
||||
class AudioFileScanner {
|
||||
constructor() { }
|
||||
|
||||
/**
|
||||
* Is array of numbers sequential, i.e. 1, 2, 3, 4
|
||||
* @param {number[]} nums
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isSequential(nums) {
|
||||
if (!nums?.length) return false
|
||||
if (nums.length === 1) return true
|
||||
let prev = nums[0]
|
||||
for (let i = 1; i < nums.length; i++) {
|
||||
if (nums[i] - prev > 1) return false
|
||||
prev = nums[i]
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove
|
||||
* @param {number[]} nums
|
||||
* @returns {number[]}
|
||||
*/
|
||||
removeDupes(nums) {
|
||||
if (!nums || !nums.length) return []
|
||||
if (nums.length === 1) return nums
|
||||
|
||||
let nodupes = [nums[0]]
|
||||
nums.forEach((num) => {
|
||||
if (num > nodupes[nodupes.length - 1]) nodupes.push(num)
|
||||
})
|
||||
return nodupes
|
||||
}
|
||||
|
||||
/**
|
||||
* Order audio files by track/disc number
|
||||
* @param {string} libraryItemRelPath
|
||||
* @param {import('../models/Book').AudioFileObject[]} audioFiles
|
||||
* @returns {import('../models/Book').AudioFileObject[]}
|
||||
*/
|
||||
runSmartTrackOrder(libraryItemRelPath, audioFiles) {
|
||||
if (!audioFiles.length) return []
|
||||
|
||||
let discsFromFilename = []
|
||||
let tracksFromFilename = []
|
||||
let discsFromMeta = []
|
||||
let tracksFromMeta = []
|
||||
|
||||
audioFiles.forEach((af) => {
|
||||
if (af.discNumFromFilename !== null) discsFromFilename.push(af.discNumFromFilename)
|
||||
if (af.discNumFromMeta !== null) discsFromMeta.push(af.discNumFromMeta)
|
||||
if (af.trackNumFromFilename !== null) tracksFromFilename.push(af.trackNumFromFilename)
|
||||
if (af.trackNumFromMeta !== null) tracksFromMeta.push(af.trackNumFromMeta)
|
||||
})
|
||||
discsFromFilename.sort((a, b) => a - b)
|
||||
discsFromMeta.sort((a, b) => a - b)
|
||||
tracksFromFilename.sort((a, b) => a - b)
|
||||
tracksFromMeta.sort((a, b) => a - b)
|
||||
|
||||
let discKey = null
|
||||
if (discsFromMeta.length === audioFiles.length && this.isSequential(discsFromMeta)) {
|
||||
discKey = 'discNumFromMeta'
|
||||
} else if (discsFromFilename.length === audioFiles.length && this.isSequential(discsFromFilename)) {
|
||||
discKey = 'discNumFromFilename'
|
||||
}
|
||||
|
||||
let trackKey = null
|
||||
tracksFromFilename = this.removeDupes(tracksFromFilename)
|
||||
tracksFromMeta = this.removeDupes(tracksFromMeta)
|
||||
if (tracksFromFilename.length > tracksFromMeta.length) {
|
||||
trackKey = 'trackNumFromFilename'
|
||||
} else {
|
||||
trackKey = 'trackNumFromMeta'
|
||||
}
|
||||
|
||||
if (discKey !== null) {
|
||||
Logger.debug(`[AudioFileScanner] Smart track order for "${libraryItemRelPath}" using disc key ${discKey} and track key ${trackKey}`)
|
||||
audioFiles.sort((a, b) => {
|
||||
let Dx = a[discKey] - b[discKey]
|
||||
if (Dx === 0) Dx = a[trackKey] - b[trackKey]
|
||||
return Dx
|
||||
})
|
||||
} else {
|
||||
Logger.debug(`[AudioFileScanner] Smart track order for "${libraryItemRelPath}" using track key ${trackKey}`)
|
||||
audioFiles.sort((a, b) => a[trackKey] - b[trackKey])
|
||||
}
|
||||
|
||||
for (let i = 0; i < audioFiles.length; i++) {
|
||||
audioFiles[i].index = i + 1
|
||||
}
|
||||
return audioFiles
|
||||
}
|
||||
|
||||
/**
|
||||
* Get track and disc number from audio filename
|
||||
* @param {{title:string, subtitle:string, series:string, sequence:string, publishedYear:string, narrators:string}} mediaMetadataFromScan
|
||||
* @param {LibraryItem.LibraryFileObject} audioLibraryFile
|
||||
* @returns {{trackNumber:number, discNumber:number}}
|
||||
*/
|
||||
getTrackAndDiscNumberFromFilename(mediaMetadataFromScan, audioLibraryFile) {
|
||||
const { title, author, series, publishedYear } = mediaMetadataFromScan
|
||||
const { filename, path } = audioLibraryFile.metadata
|
||||
let partbasename = Path.basename(filename, Path.extname(filename))
|
||||
|
||||
// Remove title, author, series, and publishedYear from filename if there
|
||||
if (title) partbasename = partbasename.replace(title, '')
|
||||
if (author) partbasename = partbasename.replace(author, '')
|
||||
if (series) partbasename = partbasename.replace(series, '')
|
||||
if (publishedYear) partbasename = partbasename.replace(publishedYear)
|
||||
|
||||
// Look for disc number
|
||||
let discNumber = null
|
||||
const discMatch = partbasename.match(/\b(disc|cd) ?(\d\d?)\b/i)
|
||||
if (discMatch && discMatch.length > 2 && discMatch[2]) {
|
||||
if (!isNaN(discMatch[2])) {
|
||||
discNumber = Number(discMatch[2])
|
||||
}
|
||||
|
||||
// Remove disc number from filename
|
||||
partbasename = partbasename.replace(/\b(disc|cd) ?(\d\d?)\b/i, '')
|
||||
}
|
||||
|
||||
// Look for disc number in folder path e.g. /Book Title/CD01/audiofile.mp3
|
||||
const pathdir = Path.dirname(path).split('/').pop()
|
||||
if (pathdir && /^cd\d{1,3}$/i.test(pathdir)) {
|
||||
const discFromFolder = Number(pathdir.replace(/cd/i, ''))
|
||||
if (!isNaN(discFromFolder) && discFromFolder !== null) discNumber = discFromFolder
|
||||
}
|
||||
|
||||
const numbersinpath = partbasename.match(/\d{1,4}/g)
|
||||
const trackNumber = numbersinpath && numbersinpath.length ? parseInt(numbersinpath[0]) : null
|
||||
return {
|
||||
trackNumber,
|
||||
discNumber
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} mediaType
|
||||
* @param {LibraryItem.LibraryFileObject} libraryFile
|
||||
* @param {{title:string, subtitle:string, series:string, sequence:string, publishedYear:string, narrators:string}} mediaMetadataFromScan
|
||||
* @returns {Promise<AudioFile>}
|
||||
*/
|
||||
async scan(mediaType, libraryFile, mediaMetadataFromScan) {
|
||||
const probeData = await prober.probe(libraryFile.metadata.path)
|
||||
|
||||
if (probeData.error) {
|
||||
Logger.error(`[AudioFileScanner] ${probeData.error} : "${libraryFile.metadata.path}"`)
|
||||
return null
|
||||
}
|
||||
|
||||
if (!probeData.audioStream) {
|
||||
Logger.error('[AudioFileScanner] Invalid audio file no audio stream')
|
||||
return null
|
||||
}
|
||||
|
||||
const audioFile = new AudioFile()
|
||||
audioFile.trackNumFromMeta = probeData.audioMetaTags.trackNumber
|
||||
audioFile.discNumFromMeta = probeData.audioMetaTags.discNumber
|
||||
if (mediaType === 'book') {
|
||||
const { trackNumber, discNumber } = this.getTrackAndDiscNumberFromFilename(mediaMetadataFromScan, libraryFile)
|
||||
audioFile.trackNumFromFilename = trackNumber
|
||||
audioFile.discNumFromFilename = discNumber
|
||||
}
|
||||
audioFile.setDataFromProbe(libraryFile, probeData)
|
||||
|
||||
return audioFile
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan LibraryFiles and return AudioFiles
|
||||
* @param {string} mediaType
|
||||
* @param {import('./LibraryItemScanData')} libraryItemScanData
|
||||
* @param {LibraryItem.LibraryFileObject[]} audioLibraryFiles
|
||||
* @returns {Promise<AudioFile[]>}
|
||||
*/
|
||||
async executeMediaFileScans(mediaType, libraryItemScanData, audioLibraryFiles) {
|
||||
const batchSize = 32
|
||||
const results = []
|
||||
for (let batch = 0; batch < audioLibraryFiles.length; batch += batchSize) {
|
||||
const proms = []
|
||||
for (let i = batch; i < Math.min(batch + batchSize, audioLibraryFiles.length); i++) {
|
||||
proms.push(this.scan(mediaType, audioLibraryFiles[i], libraryItemScanData.mediaMetadata))
|
||||
}
|
||||
results.push(...await Promise.all(proms).then((scanResults) => scanResults.filter(sr => sr)))
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {AudioFile} audioFile
|
||||
* @returns {object}
|
||||
*/
|
||||
probeAudioFile(audioFile) {
|
||||
Logger.debug(`[AudioFileScanner] Running ffprobe for audio file at "${audioFile.metadata.path}"`)
|
||||
return prober.rawProbe(audioFile.metadata.path)
|
||||
}
|
||||
}
|
||||
module.exports = new AudioFileScanner()
|
||||
1102
server/scanner/BookScanner.js
Normal file
1102
server/scanner/BookScanner.js
Normal file
File diff suppressed because it is too large
Load diff
307
server/scanner/LibraryItemScanData.js
Normal file
307
server/scanner/LibraryItemScanData.js
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
const packageJson = require('../../package.json')
|
||||
const { LogLevel } = require('../utils/constants')
|
||||
const LibraryItem = require('../models/LibraryItem')
|
||||
const globals = require('../utils/globals')
|
||||
|
||||
class LibraryItemScanData {
|
||||
constructor(data) {
|
||||
/** @type {string} */
|
||||
this.libraryFolderId = data.libraryFolderId
|
||||
/** @type {string} */
|
||||
this.libraryId = data.libraryId
|
||||
/** @type {string} */
|
||||
this.mediaType = data.mediaType
|
||||
/** @type {string} */
|
||||
this.ino = data.ino
|
||||
/** @type {number} */
|
||||
this.mtimeMs = data.mtimeMs
|
||||
/** @type {number} */
|
||||
this.ctimeMs = data.ctimeMs
|
||||
/** @type {number} */
|
||||
this.birthtimeMs = data.birthtimeMs
|
||||
/** @type {string} */
|
||||
this.path = data.path
|
||||
/** @type {string} */
|
||||
this.relPath = data.relPath
|
||||
/** @type {boolean} */
|
||||
this.isFile = data.isFile
|
||||
/** @type {{author:string, title:string, subtitle:string, series:string, sequence:string, publishedYear:string, narrators:string}} */
|
||||
this.mediaMetadata = data.mediaMetadata
|
||||
/** @type {import('../objects/files/LibraryFile')[]} */
|
||||
this.libraryFiles = data.libraryFiles
|
||||
|
||||
// Set after check
|
||||
/** @type {boolean} */
|
||||
this.hasChanges
|
||||
/** @type {boolean} */
|
||||
this.hasPathChange
|
||||
/** @type {LibraryItem.LibraryFileObject[]} */
|
||||
this.libraryFilesRemoved = []
|
||||
/** @type {LibraryItem.LibraryFileObject[]} */
|
||||
this.libraryFilesAdded = []
|
||||
/** @type {LibraryItem.LibraryFileObject[]} */
|
||||
this.libraryFilesModified = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to create a library item
|
||||
*/
|
||||
get libraryItemObject() {
|
||||
let size = 0
|
||||
this.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0))
|
||||
return {
|
||||
ino: this.ino,
|
||||
path: this.path,
|
||||
relPath: this.relPath,
|
||||
mediaType: this.mediaType,
|
||||
isFile: this.isFile,
|
||||
mtime: this.mtimeMs,
|
||||
ctime: this.ctimeMs,
|
||||
birthtime: this.birthtimeMs,
|
||||
lastScan: Date.now(),
|
||||
lastScanVersion: packageJson.version,
|
||||
libraryFiles: this.libraryFiles,
|
||||
libraryId: this.libraryId,
|
||||
libraryFolderId: this.libraryFolderId,
|
||||
size
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {boolean} */
|
||||
get hasLibraryFileChanges() {
|
||||
return this.libraryFilesRemoved.length + this.libraryFilesModified.length + this.libraryFilesAdded.length
|
||||
}
|
||||
|
||||
/** @type {boolean} */
|
||||
get hasAudioFileChanges() {
|
||||
return (this.audioLibraryFilesRemoved.length + this.audioLibraryFilesAdded.length + this.audioLibraryFilesModified.length) > 0
|
||||
}
|
||||
|
||||
/** @type {LibraryItem.LibraryFileObject[]} */
|
||||
get audioLibraryFilesModified() {
|
||||
return this.libraryFilesModified.filter(lf => globals.SupportedAudioTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
|
||||
}
|
||||
|
||||
/** @type {LibraryItem.LibraryFileObject[]} */
|
||||
get audioLibraryFilesRemoved() {
|
||||
return this.libraryFilesRemoved.filter(lf => globals.SupportedAudioTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
|
||||
}
|
||||
|
||||
/** @type {LibraryItem.LibraryFileObject[]} */
|
||||
get audioLibraryFilesAdded() {
|
||||
return this.libraryFilesAdded.filter(lf => globals.SupportedAudioTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
|
||||
}
|
||||
|
||||
/** @type {LibraryItem.LibraryFileObject[]} */
|
||||
get audioLibraryFiles() {
|
||||
return this.libraryFiles.filter(lf => globals.SupportedAudioTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
|
||||
}
|
||||
|
||||
/** @type {LibraryItem.LibraryFileObject[]} */
|
||||
get imageLibraryFiles() {
|
||||
return this.libraryFiles.filter(lf => globals.SupportedImageTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
|
||||
}
|
||||
|
||||
/** @type {LibraryItem.LibraryFileObject[]} */
|
||||
get ebookLibraryFiles() {
|
||||
return this.libraryFiles.filter(lf => globals.SupportedEbookTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
|
||||
}
|
||||
|
||||
/** @type {LibraryItem.LibraryFileObject} */
|
||||
get descTxtLibraryFile() {
|
||||
return this.libraryFiles.find(lf => lf.metadata.filename === 'desc.txt')
|
||||
}
|
||||
|
||||
/** @type {LibraryItem.LibraryFileObject} */
|
||||
get readerTxtLibraryFile() {
|
||||
return this.libraryFiles.find(lf => lf.metadata.filename === 'reader.txt')
|
||||
}
|
||||
|
||||
/** @type {LibraryItem.LibraryFileObject} */
|
||||
get metadataAbsLibraryFile() {
|
||||
return this.libraryFiles.find(lf => lf.metadata.filename === 'metadata.abs')
|
||||
}
|
||||
|
||||
/** @type {LibraryItem.LibraryFileObject} */
|
||||
get metadataJsonLibraryFile() {
|
||||
return this.libraryFiles.find(lf => lf.metadata.filename === 'metadata.json')
|
||||
}
|
||||
|
||||
/** @type {LibraryItem.LibraryFileObject} */
|
||||
get metadataOpfLibraryFile() {
|
||||
return this.libraryFiles.find(lf => lf.metadata.ext.toLowerCase() === '.opf')
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {LibraryItem} existingLibraryItem
|
||||
* @param {import('./LibraryScan')} libraryScan
|
||||
* @returns {boolean} true if changes found
|
||||
*/
|
||||
async checkLibraryItemData(existingLibraryItem, libraryScan) {
|
||||
const keysToCompare = ['libraryFolderId', 'ino', 'path', 'relPath', 'isFile']
|
||||
this.hasChanges = false
|
||||
this.hasPathChange = false
|
||||
for (const key of keysToCompare) {
|
||||
if (existingLibraryItem[key] !== this[key]) {
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" key "${key}" changed from "${existingLibraryItem[key]}" to "${this[key]}"`)
|
||||
existingLibraryItem[key] = this[key]
|
||||
this.hasChanges = true
|
||||
|
||||
if (key === 'relPath') {
|
||||
this.hasPathChange = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check mtime, ctime and birthtime
|
||||
if (existingLibraryItem.mtime?.valueOf() !== this.mtimeMs) {
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" key "mtime" changed from "${existingLibraryItem.mtime?.valueOf()}" to "${this.mtimeMs}"`)
|
||||
existingLibraryItem.mtime = this.mtimeMs
|
||||
this.hasChanges = true
|
||||
}
|
||||
if (existingLibraryItem.birthtime?.valueOf() !== this.birthtimeMs) {
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" key "birthtime" changed from "${existingLibraryItem.birthtime?.valueOf()}" to "${this.birthtimeMs}"`)
|
||||
existingLibraryItem.birthtime = this.birthtimeMs
|
||||
this.hasChanges = true
|
||||
}
|
||||
if (existingLibraryItem.ctime?.valueOf() !== this.ctimeMs) {
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" key "ctime" changed from "${existingLibraryItem.ctime?.valueOf()}" to "${this.ctimeMs}"`)
|
||||
existingLibraryItem.ctime = this.ctimeMs
|
||||
this.hasChanges = true
|
||||
}
|
||||
if (existingLibraryItem.isMissing) {
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" was missing but now found`)
|
||||
existingLibraryItem.isMissing = false
|
||||
this.hasChanges = true
|
||||
}
|
||||
|
||||
this.libraryFilesRemoved = []
|
||||
this.libraryFilesModified = []
|
||||
let libraryFilesAdded = this.libraryFiles.map(lf => lf)
|
||||
|
||||
for (const existingLibraryFile of existingLibraryItem.libraryFiles) {
|
||||
// Find matching library file using path first and fallback to using inode value
|
||||
let matchingLibraryFile = this.libraryFiles.find(lf => lf.metadata.path === existingLibraryFile.metadata.path)
|
||||
if (!matchingLibraryFile) {
|
||||
matchingLibraryFile = this.libraryFiles.find(lf => lf.ino === existingLibraryFile.ino)
|
||||
if (matchingLibraryFile) {
|
||||
libraryScan.addLog(LogLevel.INFO, `Library file with path "${existingLibraryFile.metadata.path}" not found, but found file with matching inode value "${existingLibraryFile.ino}" at path "${matchingLibraryFile.metadata.path}"`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchingLibraryFile) { // Library file removed
|
||||
libraryScan.addLog(LogLevel.INFO, `Library file "${existingLibraryFile.metadata.path}" was removed from library item "${existingLibraryItem.relPath}"`)
|
||||
this.libraryFilesRemoved.push(existingLibraryFile)
|
||||
existingLibraryItem.libraryFiles = existingLibraryItem.libraryFiles.filter(lf => lf !== existingLibraryFile)
|
||||
this.hasChanges = true
|
||||
} else {
|
||||
libraryFilesAdded = libraryFilesAdded.filter(lf => lf !== matchingLibraryFile)
|
||||
if (this.compareUpdateLibraryFile(existingLibraryItem.path, existingLibraryFile, matchingLibraryFile, libraryScan)) {
|
||||
this.libraryFilesModified.push(existingLibraryFile)
|
||||
this.hasChanges = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log new library files found
|
||||
if (libraryFilesAdded.length) {
|
||||
this.hasChanges = true
|
||||
for (const libraryFile of libraryFilesAdded) {
|
||||
libraryScan.addLog(LogLevel.INFO, `New library file found with path "${libraryFile.metadata.path}" for library item "${existingLibraryItem.relPath}"`)
|
||||
if (libraryFile.isEBookFile) {
|
||||
// Set all new ebook files as supplementary
|
||||
libraryFile.isSupplementary = true
|
||||
}
|
||||
existingLibraryItem.libraryFiles.push(libraryFile.toJSON())
|
||||
}
|
||||
}
|
||||
|
||||
this.libraryFilesAdded = libraryFilesAdded
|
||||
|
||||
if (this.hasChanges) {
|
||||
existingLibraryItem.size = 0
|
||||
existingLibraryItem.libraryFiles.forEach((lf) => existingLibraryItem.size += lf.metadata.size)
|
||||
|
||||
existingLibraryItem.lastScan = Date.now()
|
||||
existingLibraryItem.lastScanVersion = packageJson.version
|
||||
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" changed: [${existingLibraryItem.changed()?.join(',') || ''}]`)
|
||||
|
||||
if (this.hasLibraryFileChanges) {
|
||||
existingLibraryItem.changed('libraryFiles', true)
|
||||
}
|
||||
await existingLibraryItem.save()
|
||||
return true
|
||||
} else {
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" is up-to-date`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing library file with scanned in library file data
|
||||
* @param {string} libraryItemPath
|
||||
* @param {LibraryItem.LibraryFileObject} existingLibraryFile
|
||||
* @param {import('../objects/files/LibraryFile')} scannedLibraryFile
|
||||
* @param {import('./LibraryScan')} libraryScan
|
||||
* @returns {boolean} false if no changes
|
||||
*/
|
||||
compareUpdateLibraryFile(libraryItemPath, existingLibraryFile, scannedLibraryFile, libraryScan) {
|
||||
let hasChanges = false
|
||||
|
||||
if (existingLibraryFile.ino !== scannedLibraryFile.ino) {
|
||||
existingLibraryFile.ino = scannedLibraryFile.ino
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
for (const key in existingLibraryFile.metadata) {
|
||||
if (existingLibraryFile.metadata[key] !== scannedLibraryFile.metadata[key]) {
|
||||
if (key !== 'path' && key !== 'relPath') {
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Library file "${existingLibraryFile.metadata.relPath}" for library item "${libraryItemPath}" key "${key}" changed from "${existingLibraryFile.metadata[key]}" to "${scannedLibraryFile.metadata[key]}"`)
|
||||
} else {
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Library file for library item "${libraryItemPath}" key "${key}" changed from "${existingLibraryFile.metadata[key]}" to "${scannedLibraryFile.metadata[key]}"`)
|
||||
}
|
||||
existingLibraryFile.metadata[key] = scannedLibraryFile.metadata[key]
|
||||
hasChanges = true
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
existingLibraryFile.updatedAt = Date.now()
|
||||
}
|
||||
|
||||
return hasChanges
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if existing audio file on Book was removed
|
||||
* @param {import('../models/Book').AudioFileObject} existingAudioFile
|
||||
* @returns {boolean} true if audio file was removed
|
||||
*/
|
||||
checkAudioFileRemoved(existingAudioFile) {
|
||||
if (!this.audioLibraryFilesRemoved.length) return false
|
||||
// First check exact path
|
||||
if (this.audioLibraryFilesRemoved.some(af => af.metadata.path === existingAudioFile.metadata.path)) {
|
||||
return true
|
||||
}
|
||||
// Fallback to check inode value
|
||||
return this.audioLibraryFilesRemoved.some(af => af.ino === existingAudioFile.ino)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if existing ebook file on Book was removed
|
||||
* @param {import('../models/Book').EBookFileObject} ebookFile
|
||||
* @returns {boolean} true if ebook file was removed
|
||||
*/
|
||||
checkEbookFileRemoved(ebookFile) {
|
||||
if (!this.ebookLibraryFiles.length) return true
|
||||
|
||||
if (this.ebookLibraryFiles.some(lf => lf.metadata.path === ebookFile.metadata.path)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !this.ebookLibraryFiles.some(lf => lf.ino === ebookFile.ino)
|
||||
}
|
||||
}
|
||||
module.exports = LibraryItemScanData
|
||||
209
server/scanner/LibraryItemScanner.js
Normal file
209
server/scanner/LibraryItemScanner.js
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
const Path = require('path')
|
||||
const { LogLevel, ScanResult } = require('../utils/constants')
|
||||
|
||||
const fileUtils = require('../utils/fileUtils')
|
||||
const scanUtils = require('../utils/scandir')
|
||||
const libraryFilters = require('../utils/queries/libraryFilters')
|
||||
const Database = require('../Database')
|
||||
const LibraryScan = require('./LibraryScan')
|
||||
const LibraryItemScanData = require('./LibraryItemScanData')
|
||||
const BookScanner = require('./BookScanner')
|
||||
const PodcastScanner = require('./PodcastScanner')
|
||||
const ScanLogger = require('./ScanLogger')
|
||||
const LibraryItem = require('../models/LibraryItem')
|
||||
const LibraryFile = require('../objects/files/LibraryFile')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
|
||||
class LibraryItemScanner {
|
||||
constructor() { }
|
||||
|
||||
/**
|
||||
* Scan single library item
|
||||
*
|
||||
* @param {string} libraryItemId
|
||||
* @returns {number} ScanResult
|
||||
*/
|
||||
async scanLibraryItem(libraryItemId) {
|
||||
// TODO: Add task manager
|
||||
const libraryItem = await Database.libraryItemModel.findByPk(libraryItemId)
|
||||
if (!libraryItem) {
|
||||
Logger.error(`[LibraryItemScanner] Library item not found "${libraryItemId}"`)
|
||||
return ScanResult.NOTHING
|
||||
}
|
||||
|
||||
const library = await Database.libraryModel.findByPk(libraryItem.libraryId, {
|
||||
include: {
|
||||
model: Database.libraryFolderModel,
|
||||
where: {
|
||||
id: libraryItem.libraryFolderId
|
||||
}
|
||||
}
|
||||
})
|
||||
if (!library) {
|
||||
Logger.error(`[LibraryItemScanner] Library "${libraryItem.libraryId}" not found for library item "${libraryItem.id}"`)
|
||||
return ScanResult.NOTHING
|
||||
}
|
||||
|
||||
// Make sure library filter data is set
|
||||
// this is used to check for existing authors & series
|
||||
await libraryFilters.getFilterData(library.mediaType, library.id)
|
||||
|
||||
const scanLogger = new ScanLogger()
|
||||
scanLogger.verbose = true
|
||||
scanLogger.setData('libraryItem', libraryItemId)
|
||||
|
||||
const libraryItemPath = fileUtils.filePathToPOSIX(libraryItem.path)
|
||||
const folder = library.libraryFolders[0]
|
||||
const libraryItemScanData = await this.getLibraryItemScanData(libraryItemPath, library, folder, false)
|
||||
|
||||
if (await libraryItemScanData.checkLibraryItemData(libraryItem, scanLogger)) {
|
||||
if (libraryItemScanData.hasLibraryFileChanges || libraryItemScanData.hasPathChange) {
|
||||
const expandedLibraryItem = await this.rescanLibraryItem(libraryItem, libraryItemScanData, library.settings, scanLogger)
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(expandedLibraryItem)
|
||||
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
|
||||
|
||||
await this.checkAuthorsAndSeriesRemovedFromBooks(library.id, scanLogger)
|
||||
} else {
|
||||
// TODO: Temporary while using old model to socket emit
|
||||
const oldLibraryItem = await Database.libraryItemModel.getOldById(libraryItem.id)
|
||||
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
|
||||
}
|
||||
|
||||
return ScanResult.UPDATED
|
||||
}
|
||||
return ScanResult.UPTODATE
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove empty authors and series
|
||||
* @param {string} libraryId
|
||||
* @param {ScanLogger} scanLogger
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async checkAuthorsAndSeriesRemovedFromBooks(libraryId, scanLogger) {
|
||||
if (scanLogger.authorsRemovedFromBooks.length) {
|
||||
await BookScanner.checkAuthorsRemovedFromBooks(libraryId, scanLogger)
|
||||
}
|
||||
if (scanLogger.seriesRemovedFromBooks.length) {
|
||||
await BookScanner.checkSeriesRemovedFromBooks(libraryId, scanLogger)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} libraryItemPath
|
||||
* @param {import('../models/Library')} library
|
||||
* @param {import('../models/LibraryFolder')} folder
|
||||
* @param {boolean} isSingleMediaItem
|
||||
* @returns {Promise<LibraryItemScanData>}
|
||||
*/
|
||||
async getLibraryItemScanData(libraryItemPath, library, folder, isSingleMediaItem) {
|
||||
const libraryFolderPath = fileUtils.filePathToPOSIX(folder.path)
|
||||
const libraryItemDir = libraryItemPath.replace(libraryFolderPath, '').slice(1)
|
||||
|
||||
let libraryItemData = {}
|
||||
|
||||
let fileItems = []
|
||||
|
||||
if (isSingleMediaItem) { // Single media item in root of folder
|
||||
fileItems = [
|
||||
{
|
||||
fullpath: libraryItemPath,
|
||||
path: libraryItemDir // actually the relPath (only filename here)
|
||||
}
|
||||
]
|
||||
libraryItemData = {
|
||||
path: libraryItemPath, // full path
|
||||
relPath: libraryItemDir, // only filename
|
||||
mediaMetadata: {
|
||||
title: Path.basename(libraryItemDir, Path.extname(libraryItemDir))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fileItems = await fileUtils.recurseFiles(libraryItemPath)
|
||||
libraryItemData = scanUtils.getDataFromMediaDir(library.mediaType, libraryFolderPath, libraryItemDir)
|
||||
}
|
||||
|
||||
const libraryFiles = []
|
||||
for (let i = 0; i < fileItems.length; i++) {
|
||||
const fileItem = fileItems[i]
|
||||
const newLibraryFile = new LibraryFile()
|
||||
// fileItem.path is the relative path
|
||||
await newLibraryFile.setDataFromPath(fileItem.fullpath, fileItem.path)
|
||||
libraryFiles.push(newLibraryFile)
|
||||
}
|
||||
|
||||
const libraryItemStats = await fileUtils.getFileTimestampsWithIno(libraryItemData.path)
|
||||
return new LibraryItemScanData({
|
||||
libraryFolderId: folder.id,
|
||||
libraryId: library.id,
|
||||
mediaType: library.mediaType,
|
||||
ino: libraryItemStats.ino,
|
||||
mtimeMs: libraryItemStats.mtimeMs || 0,
|
||||
ctimeMs: libraryItemStats.ctimeMs || 0,
|
||||
birthtimeMs: libraryItemStats.birthtimeMs || 0,
|
||||
path: libraryItemData.path,
|
||||
relPath: libraryItemData.relPath,
|
||||
isFile: isSingleMediaItem,
|
||||
mediaMetadata: libraryItemData.mediaMetadata || null,
|
||||
libraryFiles
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../models/LibraryItem')} existingLibraryItem
|
||||
* @param {LibraryItemScanData} libraryItemData
|
||||
* @param {import('../models/Library').LibrarySettingsObject} librarySettings
|
||||
* @param {LibraryScan} libraryScan
|
||||
* @returns {Promise<LibraryItem>}
|
||||
*/
|
||||
async rescanLibraryItem(existingLibraryItem, libraryItemData, librarySettings, libraryScan) {
|
||||
let newLibraryItem = null
|
||||
if (existingLibraryItem.mediaType === 'book') {
|
||||
newLibraryItem = await BookScanner.rescanExistingBookLibraryItem(existingLibraryItem, libraryItemData, librarySettings, libraryScan)
|
||||
} else {
|
||||
newLibraryItem = await PodcastScanner.rescanExistingPodcastLibraryItem(existingLibraryItem, libraryItemData, librarySettings, libraryScan)
|
||||
}
|
||||
return newLibraryItem
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {LibraryItemScanData} libraryItemData
|
||||
* @param {import('../models/Library').LibrarySettingsObject} librarySettings
|
||||
* @param {LibraryScan} libraryScan
|
||||
* @returns {Promise<LibraryItem>}
|
||||
*/
|
||||
async scanNewLibraryItem(libraryItemData, librarySettings, libraryScan) {
|
||||
let newLibraryItem = null
|
||||
if (libraryItemData.mediaType === 'book') {
|
||||
newLibraryItem = await BookScanner.scanNewBookLibraryItem(libraryItemData, librarySettings, libraryScan)
|
||||
} else {
|
||||
newLibraryItem = await PodcastScanner.scanNewPodcastLibraryItem(libraryItemData, librarySettings, libraryScan)
|
||||
}
|
||||
if (newLibraryItem) {
|
||||
libraryScan.addLog(LogLevel.INFO, `Created new library item "${newLibraryItem.relPath}"`)
|
||||
}
|
||||
return newLibraryItem
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan library item folder coming from Watcher
|
||||
* @param {string} libraryItemPath
|
||||
* @param {import('../models/Library')} library
|
||||
* @param {import('../models/LibraryFolder')} folder
|
||||
* @param {boolean} isSingleMediaItem
|
||||
* @returns {Promise<LibraryItem>} ScanResult
|
||||
*/
|
||||
async scanPotentialNewLibraryItem(libraryItemPath, library, folder, isSingleMediaItem) {
|
||||
const libraryItemScanData = await this.getLibraryItemScanData(libraryItemPath, library, folder, isSingleMediaItem)
|
||||
|
||||
const scanLogger = new ScanLogger()
|
||||
scanLogger.verbose = true
|
||||
scanLogger.setData('libraryItem', libraryItemScanData.relPath)
|
||||
|
||||
return this.scanNewLibraryItem(libraryItemScanData, library.settings, scanLogger)
|
||||
}
|
||||
}
|
||||
module.exports = new LibraryItemScanner()
|
||||
|
|
@ -6,18 +6,16 @@ const date = require('../libs/dateAndTime')
|
|||
const Logger = require('../Logger')
|
||||
const Library = require('../objects/Library')
|
||||
const { LogLevel } = require('../utils/constants')
|
||||
const filePerms = require('../utils/filePerms')
|
||||
const { secondsToTimestamp } = require('../utils/index')
|
||||
|
||||
class LibraryScan {
|
||||
constructor() {
|
||||
this.id = null
|
||||
this.type = null
|
||||
/** @type {import('../objects/Library')} */
|
||||
this.library = null
|
||||
this.verbose = false
|
||||
|
||||
this.scanOptions = null
|
||||
|
||||
this.startedAt = null
|
||||
this.finishedAt = null
|
||||
this.elapsed = null
|
||||
|
|
@ -26,6 +24,11 @@ class LibraryScan {
|
|||
this.resultsAdded = 0
|
||||
this.resultsUpdated = 0
|
||||
|
||||
/** @type {string[]} */
|
||||
this.authorsRemovedFromBooks = []
|
||||
/** @type {string[]} */
|
||||
this.seriesRemovedFromBooks = []
|
||||
|
||||
this.logs = []
|
||||
}
|
||||
|
||||
|
|
@ -34,12 +37,6 @@ class LibraryScan {
|
|||
get libraryMediaType() { return this.library.mediaType }
|
||||
get folders() { return this.library.folders }
|
||||
|
||||
get _scanOptions() { return this.scanOptions || {} }
|
||||
get forceRescan() { return !!this._scanOptions.forceRescan }
|
||||
get preferAudioMetadata() { return !!this._scanOptions.preferAudioMetadata }
|
||||
get preferOpfMetadata() { return !!this._scanOptions.preferOpfMetadata }
|
||||
get preferOverdriveMediaMarker() { return !!this._scanOptions.preferOverdriveMediaMarker }
|
||||
get findCovers() { return !!this._scanOptions.findCovers }
|
||||
get timestamp() {
|
||||
return (new Date()).toISOString()
|
||||
}
|
||||
|
|
@ -74,7 +71,6 @@ class LibraryScan {
|
|||
id: this.id,
|
||||
type: this.type,
|
||||
library: this.library.toJSON(),
|
||||
scanOptions: this.scanOptions ? this.scanOptions.toJSON() : null,
|
||||
startedAt: this.startedAt,
|
||||
finishedAt: this.finishedAt,
|
||||
elapsed: this.elapsed,
|
||||
|
|
@ -84,13 +80,11 @@ class LibraryScan {
|
|||
}
|
||||
}
|
||||
|
||||
setData(library, scanOptions, type = 'scan') {
|
||||
setData(library, type = 'scan') {
|
||||
this.id = uuidv4()
|
||||
this.type = type
|
||||
this.library = new Library(library.toJSON()) // clone library
|
||||
|
||||
this.scanOptions = scanOptions
|
||||
|
||||
this.startedAt = Date.now()
|
||||
}
|
||||
|
||||
|
|
@ -117,7 +111,7 @@ class LibraryScan {
|
|||
}
|
||||
|
||||
if (this.verbose) {
|
||||
Logger.debug(`[LibraryScan] "${this.libraryName}":`, args)
|
||||
Logger.debug(`[LibraryScan] "${this.libraryName}":`, ...args)
|
||||
}
|
||||
this.logs.push(logObj)
|
||||
}
|
||||
|
|
@ -132,7 +126,6 @@ class LibraryScan {
|
|||
logLines.push(JSON.stringify(l))
|
||||
})
|
||||
await fs.writeFile(outputPath, logLines.join('\n') + '\n')
|
||||
await filePerms.setDefault(outputPath)
|
||||
|
||||
Logger.info(`[LibraryScan] Scan log saved "${outputPath}"`)
|
||||
}
|
||||
|
|
|
|||
528
server/scanner/LibraryScanner.js
Normal file
528
server/scanner/LibraryScanner.js
Normal file
|
|
@ -0,0 +1,528 @@
|
|||
const sequelize = require('sequelize')
|
||||
const Path = require('path')
|
||||
const packageJson = require('../../package.json')
|
||||
const Logger = require('../Logger')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const Database = require('../Database')
|
||||
const fs = require('../libs/fsExtra')
|
||||
const fileUtils = require('../utils/fileUtils')
|
||||
const scanUtils = require('../utils/scandir')
|
||||
const { LogLevel, ScanResult } = require('../utils/constants')
|
||||
const libraryFilters = require('../utils/queries/libraryFilters')
|
||||
const LibraryItemScanner = require('./LibraryItemScanner')
|
||||
const LibraryScan = require('./LibraryScan')
|
||||
const LibraryItemScanData = require('./LibraryItemScanData')
|
||||
|
||||
class LibraryScanner {
|
||||
constructor() {
|
||||
this.cancelLibraryScan = {}
|
||||
this.librariesScanning = []
|
||||
|
||||
this.scanningFilesChanged = false
|
||||
/** @type {import('../Watcher').PendingFileUpdate[][]} */
|
||||
this.pendingFileUpdatesToScan = []
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} libraryId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isLibraryScanning(libraryId) {
|
||||
return this.librariesScanning.some(ls => ls.id === libraryId)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} libraryId
|
||||
*/
|
||||
setCancelLibraryScan(libraryId) {
|
||||
const libraryScanning = this.librariesScanning.find(ls => ls.id === libraryId)
|
||||
if (!libraryScanning) return
|
||||
this.cancelLibraryScan[libraryId] = true
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../objects/Library')} library
|
||||
* @param {*} options
|
||||
*/
|
||||
async scan(library, options = {}) {
|
||||
if (this.isLibraryScanning(library.id)) {
|
||||
Logger.error(`[Scanner] Already scanning ${library.id}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!library.folders.length) {
|
||||
Logger.warn(`[Scanner] Library has no folders to scan "${library.name}"`)
|
||||
return
|
||||
}
|
||||
|
||||
const libraryScan = new LibraryScan()
|
||||
libraryScan.setData(library)
|
||||
libraryScan.verbose = true
|
||||
this.librariesScanning.push(libraryScan.getScanEmitData)
|
||||
|
||||
SocketAuthority.emitter('scan_start', libraryScan.getScanEmitData)
|
||||
|
||||
Logger.info(`[Scanner] Starting library scan ${libraryScan.id} for ${libraryScan.libraryName}`)
|
||||
|
||||
const canceled = await this.scanLibrary(libraryScan)
|
||||
|
||||
if (canceled) {
|
||||
Logger.info(`[Scanner] Library scan canceled for "${libraryScan.libraryName}"`)
|
||||
delete this.cancelLibraryScan[libraryScan.libraryId]
|
||||
}
|
||||
|
||||
libraryScan.setComplete()
|
||||
|
||||
Logger.info(`[Scanner] Library scan ${libraryScan.id} completed in ${libraryScan.elapsedTimestamp} | ${libraryScan.resultStats}`)
|
||||
this.librariesScanning = this.librariesScanning.filter(ls => ls.id !== library.id)
|
||||
|
||||
if (canceled && !libraryScan.totalResults) {
|
||||
const emitData = libraryScan.getScanEmitData
|
||||
emitData.results = null
|
||||
SocketAuthority.emitter('scan_complete', emitData)
|
||||
return
|
||||
}
|
||||
|
||||
SocketAuthority.emitter('scan_complete', libraryScan.getScanEmitData)
|
||||
|
||||
if (libraryScan.totalResults) {
|
||||
libraryScan.saveLog()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('./LibraryScan')} libraryScan
|
||||
* @returns {boolean} true if scan canceled
|
||||
*/
|
||||
async scanLibrary(libraryScan) {
|
||||
// Make sure library filter data is set
|
||||
// this is used to check for existing authors & series
|
||||
await libraryFilters.getFilterData(libraryScan.library.mediaType, libraryScan.libraryId)
|
||||
|
||||
/** @type {LibraryItemScanData[]} */
|
||||
let libraryItemDataFound = []
|
||||
|
||||
// Scan each library folder
|
||||
for (let i = 0; i < libraryScan.folders.length; i++) {
|
||||
const folder = libraryScan.folders[i]
|
||||
const itemDataFoundInFolder = await this.scanFolder(libraryScan.library, folder)
|
||||
libraryScan.addLog(LogLevel.INFO, `${itemDataFoundInFolder.length} item data found in folder "${folder.fullPath}"`)
|
||||
libraryItemDataFound = libraryItemDataFound.concat(itemDataFoundInFolder)
|
||||
}
|
||||
|
||||
if (this.cancelLibraryScan[libraryScan.libraryId]) return true
|
||||
|
||||
const existingLibraryItems = await Database.libraryItemModel.findAll({
|
||||
where: {
|
||||
libraryId: libraryScan.libraryId
|
||||
}
|
||||
})
|
||||
|
||||
if (this.cancelLibraryScan[libraryScan.libraryId]) return true
|
||||
|
||||
const libraryItemIdsMissing = []
|
||||
let oldLibraryItemsUpdated = []
|
||||
for (const existingLibraryItem of existingLibraryItems) {
|
||||
// First try to find matching library item with exact file path
|
||||
let libraryItemData = libraryItemDataFound.find(lid => lid.path === existingLibraryItem.path)
|
||||
if (!libraryItemData) {
|
||||
// Fallback to finding matching library item with matching inode value
|
||||
libraryItemData = libraryItemDataFound.find(lid => lid.ino === existingLibraryItem.ino)
|
||||
if (libraryItemData) {
|
||||
libraryScan.addLog(LogLevel.INFO, `Library item with path "${existingLibraryItem.path}" was not found, but library item inode "${existingLibraryItem.ino}" was found at path "${libraryItemData.path}"`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!libraryItemData) {
|
||||
// Podcast folder can have no episodes and still be valid
|
||||
if (libraryScan.libraryMediaType === 'podcast' && await fs.pathExists(existingLibraryItem.path)) {
|
||||
libraryScan.addLog(LogLevel.INFO, `Library item "${existingLibraryItem.relPath}" folder exists but has no episodes`)
|
||||
} else {
|
||||
libraryScan.addLog(LogLevel.WARN, `Library Item "${existingLibraryItem.path}" (inode: ${existingLibraryItem.ino}) is missing`)
|
||||
libraryScan.resultsMissing++
|
||||
if (!existingLibraryItem.isMissing) {
|
||||
libraryItemIdsMissing.push(existingLibraryItem.id)
|
||||
|
||||
// TODO: Temporary while using old model to socket emit
|
||||
const oldLibraryItem = await Database.libraryItemModel.getOldById(existingLibraryItem.id)
|
||||
oldLibraryItem.isMissing = true
|
||||
oldLibraryItem.updatedAt = Date.now()
|
||||
oldLibraryItemsUpdated.push(oldLibraryItem)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
libraryItemDataFound = libraryItemDataFound.filter(lidf => lidf !== libraryItemData)
|
||||
if (await libraryItemData.checkLibraryItemData(existingLibraryItem, libraryScan)) {
|
||||
libraryScan.resultsUpdated++
|
||||
if (libraryItemData.hasLibraryFileChanges || libraryItemData.hasPathChange) {
|
||||
const libraryItem = await LibraryItemScanner.rescanLibraryItem(existingLibraryItem, libraryItemData, libraryScan.library.settings, libraryScan)
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
||||
oldLibraryItemsUpdated.push(oldLibraryItem)
|
||||
} else {
|
||||
// TODO: Temporary while using old model to socket emit
|
||||
const oldLibraryItem = await Database.libraryItemModel.getOldById(existingLibraryItem.id)
|
||||
oldLibraryItemsUpdated.push(oldLibraryItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emit item updates in chunks of 10 to client
|
||||
if (oldLibraryItemsUpdated.length === 10) {
|
||||
// TODO: Should only emit to clients where library item is accessible
|
||||
SocketAuthority.emitter('items_updated', oldLibraryItemsUpdated.map(li => li.toJSONExpanded()))
|
||||
oldLibraryItemsUpdated = []
|
||||
}
|
||||
|
||||
if (this.cancelLibraryScan[libraryScan.libraryId]) return true
|
||||
}
|
||||
// Emit item updates to client
|
||||
if (oldLibraryItemsUpdated.length) {
|
||||
// TODO: Should only emit to clients where library item is accessible
|
||||
SocketAuthority.emitter('items_updated', oldLibraryItemsUpdated.map(li => li.toJSONExpanded()))
|
||||
}
|
||||
|
||||
// Authors and series that were removed from books should be removed if they are now empty
|
||||
await LibraryItemScanner.checkAuthorsAndSeriesRemovedFromBooks(libraryScan.libraryId, libraryScan)
|
||||
|
||||
// Update missing library items
|
||||
if (libraryItemIdsMissing.length) {
|
||||
libraryScan.addLog(LogLevel.INFO, `Updating ${libraryItemIdsMissing.length} library items missing`)
|
||||
await Database.libraryItemModel.update({
|
||||
isMissing: true,
|
||||
lastScan: Date.now(),
|
||||
lastScanVersion: packageJson.version
|
||||
}, {
|
||||
where: {
|
||||
id: libraryItemIdsMissing
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (this.cancelLibraryScan[libraryScan.libraryId]) return true
|
||||
|
||||
// Add new library items
|
||||
if (libraryItemDataFound.length) {
|
||||
let newOldLibraryItems = []
|
||||
for (const libraryItemData of libraryItemDataFound) {
|
||||
const newLibraryItem = await LibraryItemScanner.scanNewLibraryItem(libraryItemData, libraryScan.library.settings, libraryScan)
|
||||
if (newLibraryItem) {
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(newLibraryItem)
|
||||
newOldLibraryItems.push(oldLibraryItem)
|
||||
|
||||
libraryScan.resultsAdded++
|
||||
}
|
||||
|
||||
// Emit new items in chunks of 10 to client
|
||||
if (newOldLibraryItems.length === 10) {
|
||||
// TODO: Should only emit to clients where library item is accessible
|
||||
SocketAuthority.emitter('items_added', newOldLibraryItems.map(li => li.toJSONExpanded()))
|
||||
newOldLibraryItems = []
|
||||
}
|
||||
|
||||
if (this.cancelLibraryScan[libraryScan.libraryId]) return true
|
||||
}
|
||||
// Emit new items to client
|
||||
if (newOldLibraryItems.length) {
|
||||
// TODO: Should only emit to clients where library item is accessible
|
||||
SocketAuthority.emitter('items_added', newOldLibraryItems.map(li => li.toJSONExpanded()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scan data for library folder
|
||||
* @param {import('../objects/Library')} library
|
||||
* @param {import('../objects/Folder')} folder
|
||||
* @returns {LibraryItemScanData[]}
|
||||
*/
|
||||
async scanFolder(library, folder) {
|
||||
const folderPath = fileUtils.filePathToPOSIX(folder.fullPath)
|
||||
|
||||
const pathExists = await fs.pathExists(folderPath)
|
||||
if (!pathExists) {
|
||||
Logger.error(`[scandir] Invalid folder path does not exist "${folderPath}"`)
|
||||
return []
|
||||
}
|
||||
|
||||
const fileItems = await fileUtils.recurseFiles(folderPath)
|
||||
const libraryItemGrouping = scanUtils.groupFileItemsIntoLibraryItemDirs(library.mediaType, fileItems, library.settings.audiobooksOnly)
|
||||
|
||||
if (!Object.keys(libraryItemGrouping).length) {
|
||||
Logger.error(`Root path has no media folders: ${folderPath}`)
|
||||
return []
|
||||
}
|
||||
|
||||
const items = []
|
||||
for (const libraryItemPath in libraryItemGrouping) {
|
||||
let isFile = false // item is not in a folder
|
||||
let libraryItemData = null
|
||||
let fileObjs = []
|
||||
if (libraryItemPath === libraryItemGrouping[libraryItemPath]) {
|
||||
// Media file in root only get title
|
||||
libraryItemData = {
|
||||
mediaMetadata: {
|
||||
title: Path.basename(libraryItemPath, Path.extname(libraryItemPath))
|
||||
},
|
||||
path: Path.posix.join(folderPath, libraryItemPath),
|
||||
relPath: libraryItemPath
|
||||
}
|
||||
fileObjs = await scanUtils.buildLibraryFile(folderPath, [libraryItemPath])
|
||||
isFile = true
|
||||
} else {
|
||||
libraryItemData = scanUtils.getDataFromMediaDir(library.mediaType, folderPath, libraryItemPath)
|
||||
fileObjs = await scanUtils.buildLibraryFile(libraryItemData.path, libraryItemGrouping[libraryItemPath])
|
||||
}
|
||||
|
||||
const libraryItemFolderStats = await fileUtils.getFileTimestampsWithIno(libraryItemData.path)
|
||||
|
||||
if (!libraryItemFolderStats.ino) {
|
||||
Logger.warn(`[LibraryScanner] Library item folder "${libraryItemData.path}" has no inode value`)
|
||||
continue
|
||||
}
|
||||
|
||||
items.push(new LibraryItemScanData({
|
||||
libraryFolderId: folder.id,
|
||||
libraryId: folder.libraryId,
|
||||
mediaType: library.mediaType,
|
||||
ino: libraryItemFolderStats.ino,
|
||||
mtimeMs: libraryItemFolderStats.mtimeMs || 0,
|
||||
ctimeMs: libraryItemFolderStats.ctimeMs || 0,
|
||||
birthtimeMs: libraryItemFolderStats.birthtimeMs || 0,
|
||||
path: libraryItemData.path,
|
||||
relPath: libraryItemData.relPath,
|
||||
isFile,
|
||||
mediaMetadata: libraryItemData.mediaMetadata || null,
|
||||
libraryFiles: fileObjs
|
||||
}))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan files changed from Watcher
|
||||
* @param {import('../Watcher').PendingFileUpdate[]} fileUpdates
|
||||
*/
|
||||
async scanFilesChanged(fileUpdates) {
|
||||
if (!fileUpdates?.length) return
|
||||
|
||||
// If already scanning files from watcher then add these updates to queue
|
||||
if (this.scanningFilesChanged) {
|
||||
this.pendingFileUpdatesToScan.push(fileUpdates)
|
||||
Logger.debug(`[LibraryScanner] Already scanning files from watcher - file updates pushed to queue (size ${this.pendingFileUpdatesToScan.length})`)
|
||||
return
|
||||
}
|
||||
this.scanningFilesChanged = true
|
||||
|
||||
// files grouped by folder
|
||||
const folderGroups = this.getFileUpdatesGrouped(fileUpdates)
|
||||
|
||||
for (const folderId in folderGroups) {
|
||||
const libraryId = folderGroups[folderId].libraryId
|
||||
// const library = await Database.libraryModel.getOldById(libraryId)
|
||||
const library = await Database.libraryModel.findByPk(libraryId, {
|
||||
include: {
|
||||
model: Database.libraryFolderModel,
|
||||
where: {
|
||||
id: folderId
|
||||
}
|
||||
}
|
||||
})
|
||||
if (!library) {
|
||||
Logger.error(`[LibraryScanner] Library "${libraryId}" not found in files changed ${libraryId}`)
|
||||
continue
|
||||
}
|
||||
const folder = library.libraryFolders[0]
|
||||
|
||||
const relFilePaths = folderGroups[folderId].fileUpdates.map(fileUpdate => fileUpdate.relPath)
|
||||
const fileUpdateGroup = scanUtils.groupFilesIntoLibraryItemPaths(library.mediaType, relFilePaths)
|
||||
|
||||
if (!Object.keys(fileUpdateGroup).length) {
|
||||
Logger.info(`[LibraryScanner] No important changes to scan for in folder "${folderId}"`)
|
||||
continue
|
||||
}
|
||||
const folderScanResults = await this.scanFolderUpdates(library, folder, fileUpdateGroup)
|
||||
Logger.debug(`[LibraryScanner] 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
|
||||
|
||||
if (this.pendingFileUpdatesToScan.length) {
|
||||
Logger.debug(`[LibraryScanner] File updates finished scanning with more updates in queue (${this.pendingFileUpdatesToScan.length})`)
|
||||
this.scanFilesChanged(this.pendingFileUpdatesToScan.shift())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Group array of PendingFileUpdate from Watcher by folder
|
||||
* @param {import('../Watcher').PendingFileUpdate[]} fileUpdates
|
||||
* @returns {Record<string,{libraryId:string, folderId:string, fileUpdates:import('../Watcher').PendingFileUpdate[]}>}
|
||||
*/
|
||||
getFileUpdatesGrouped(fileUpdates) {
|
||||
const folderGroups = {}
|
||||
fileUpdates.forEach((file) => {
|
||||
if (folderGroups[file.folderId]) {
|
||||
folderGroups[file.folderId].fileUpdates.push(file)
|
||||
} else {
|
||||
folderGroups[file.folderId] = {
|
||||
libraryId: file.libraryId,
|
||||
folderId: file.folderId,
|
||||
fileUpdates: [file]
|
||||
}
|
||||
}
|
||||
})
|
||||
return folderGroups
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan grouped paths for library folder coming from Watcher
|
||||
* @param {import('../models/Library')} library
|
||||
* @param {import('../models/LibraryFolder')} folder
|
||||
* @param {Record<string, string[]>} fileUpdateGroup
|
||||
* @returns {Promise<Record<string,number>>}
|
||||
*/
|
||||
async scanFolderUpdates(library, folder, fileUpdateGroup) {
|
||||
// Make sure library filter data is set
|
||||
// this is used to check for existing authors & series
|
||||
await libraryFilters.getFilterData(library.mediaType, library.id)
|
||||
Logger.debug(`[Scanner] Scanning file update groups in folder "${folder.id}" of library "${library.name}"`)
|
||||
Logger.debug(`[Scanner] scanFolderUpdates fileUpdateGroup`, fileUpdateGroup)
|
||||
|
||||
// First pass - Remove files in parent dirs of items and remap the fileupdate group
|
||||
// Test Case: Moving audio files from library item folder to author folder should trigger a re-scan of the item
|
||||
const updateGroup = { ...fileUpdateGroup }
|
||||
for (const itemDir in updateGroup) {
|
||||
if (itemDir == fileUpdateGroup[itemDir]) continue // Media in root path
|
||||
|
||||
const itemDirNestedFiles = fileUpdateGroup[itemDir].filter(b => b.includes('/'))
|
||||
if (!itemDirNestedFiles.length) continue
|
||||
|
||||
const firstNest = itemDirNestedFiles[0].split('/').shift()
|
||||
const altDir = `${itemDir}/${firstNest}`
|
||||
|
||||
const fullPath = Path.posix.join(fileUtils.filePathToPOSIX(folder.path), itemDir)
|
||||
const childLibraryItem = await Database.libraryItemModel.findOne({
|
||||
attributes: ['id', 'path'],
|
||||
where: {
|
||||
path: {
|
||||
[sequelize.Op.not]: fullPath
|
||||
},
|
||||
path: {
|
||||
[sequelize.Op.startsWith]: fullPath
|
||||
}
|
||||
}
|
||||
})
|
||||
if (!childLibraryItem) {
|
||||
continue
|
||||
}
|
||||
|
||||
const altFullPath = Path.posix.join(fileUtils.filePathToPOSIX(folder.path), altDir)
|
||||
const altChildLibraryItem = await Database.libraryItemModel.findOne({
|
||||
attributes: ['id', 'path'],
|
||||
where: {
|
||||
path: {
|
||||
[sequelize.Op.not]: altFullPath
|
||||
},
|
||||
path: {
|
||||
[sequelize.Op.startsWith]: altFullPath
|
||||
}
|
||||
}
|
||||
})
|
||||
if (altChildLibraryItem) {
|
||||
continue
|
||||
}
|
||||
|
||||
delete fileUpdateGroup[itemDir]
|
||||
fileUpdateGroup[altDir] = itemDirNestedFiles.map((f) => f.split('/').slice(1).join('/'))
|
||||
Logger.warn(`[LibraryScanner] Some files were modified in a parent directory of a library item "${childLibraryItem.path}" - ignoring`)
|
||||
}
|
||||
|
||||
// Second pass: Check for new/updated/removed items
|
||||
const itemGroupingResults = {}
|
||||
for (const itemDir in fileUpdateGroup) {
|
||||
const fullPath = Path.posix.join(fileUtils.filePathToPOSIX(folder.path), itemDir)
|
||||
|
||||
const itemDirParts = itemDir.split('/').slice(0, -1)
|
||||
|
||||
const potentialChildDirs = [fullPath]
|
||||
for (let i = 0; i < itemDirParts.length; i++) {
|
||||
potentialChildDirs.push(Path.posix.join(fileUtils.filePathToPOSIX(folder.path), itemDir.split('/').slice(0, -1 - i).join('/')))
|
||||
}
|
||||
|
||||
// Check if book dir group is already an item
|
||||
let existingLibraryItem = await Database.libraryItemModel.findOneOld({
|
||||
path: potentialChildDirs
|
||||
})
|
||||
|
||||
if (!existingLibraryItem) {
|
||||
const dirIno = await fileUtils.getIno(fullPath)
|
||||
existingLibraryItem = await Database.libraryItemModel.findOneOld({
|
||||
ino: dirIno
|
||||
})
|
||||
if (existingLibraryItem) {
|
||||
Logger.debug(`[LibraryScanner] scanFolderUpdates: Library item found by inode value=${dirIno}. "${existingLibraryItem.relPath} => ${itemDir}"`)
|
||||
// Update library item paths for scan
|
||||
existingLibraryItem.path = fullPath
|
||||
existingLibraryItem.relPath = itemDir
|
||||
}
|
||||
}
|
||||
if (existingLibraryItem) {
|
||||
// Is the item exactly - check if was deleted
|
||||
if (existingLibraryItem.path === fullPath) {
|
||||
const exists = await fs.pathExists(fullPath)
|
||||
if (!exists) {
|
||||
Logger.info(`[LibraryScanner] Scanning file update group and library item was deleted "${existingLibraryItem.media.metadata.title}" - marking as missing`)
|
||||
existingLibraryItem.setMissing()
|
||||
await Database.updateLibraryItem(existingLibraryItem)
|
||||
SocketAuthority.emitter('item_updated', existingLibraryItem.toJSONExpanded())
|
||||
|
||||
itemGroupingResults[itemDir] = ScanResult.REMOVED
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Scan library item for updates
|
||||
Logger.debug(`[LibraryScanner] Folder update for relative path "${itemDir}" is in library item "${existingLibraryItem.media.metadata.title}" - scan for updates`)
|
||||
itemGroupingResults[itemDir] = await LibraryItemScanner.scanLibraryItem(existingLibraryItem.id)
|
||||
continue
|
||||
} else if (library.settings.audiobooksOnly && !fileUpdateGroup[itemDir].some?.(scanUtils.checkFilepathIsAudioFile)) {
|
||||
Logger.debug(`[LibraryScanner] Folder update for relative path "${itemDir}" has no audio files`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if a library item is a subdirectory of this dir
|
||||
const childItem = await Database.libraryItemModel.findOne({
|
||||
attributes: ['id', 'path'],
|
||||
where: {
|
||||
path: {
|
||||
[sequelize.Op.startsWith]: fullPath + '/'
|
||||
}
|
||||
}
|
||||
})
|
||||
if (childItem) {
|
||||
Logger.warn(`[LibraryScanner] Files were modified in a parent directory of a library item "${childItem.path}" - ignoring`)
|
||||
itemGroupingResults[itemDir] = ScanResult.NOTHING
|
||||
continue
|
||||
}
|
||||
|
||||
Logger.debug(`[LibraryScanner] Folder update group must be a new item "${itemDir}" in library "${library.name}"`)
|
||||
const isSingleMediaItem = itemDir === fileUpdateGroup[itemDir]
|
||||
const newLibraryItem = await LibraryItemScanner.scanPotentialNewLibraryItem(fullPath, library, folder, isSingleMediaItem)
|
||||
if (newLibraryItem) {
|
||||
const oldNewLibraryItem = Database.libraryItemModel.getOldLibraryItem(newLibraryItem)
|
||||
SocketAuthority.emitter('item_added', oldNewLibraryItem.toJSONExpanded())
|
||||
}
|
||||
itemGroupingResults[itemDir] = newLibraryItem ? ScanResult.ADDED : ScanResult.NOTHING
|
||||
}
|
||||
|
||||
return itemGroupingResults
|
||||
}
|
||||
}
|
||||
module.exports = new LibraryScanner()
|
||||
|
|
@ -1,333 +0,0 @@
|
|||
const Path = require('path')
|
||||
|
||||
const AudioFile = require('../objects/files/AudioFile')
|
||||
const VideoFile = require('../objects/files/VideoFile')
|
||||
|
||||
const prober = require('../utils/prober')
|
||||
const toneProber = require('../utils/toneProber')
|
||||
const Logger = require('../Logger')
|
||||
const { LogLevel } = require('../utils/constants')
|
||||
|
||||
class MediaFileScanner {
|
||||
constructor() { }
|
||||
|
||||
getTrackAndDiscNumberFromFilename(mediaMetadataFromScan, audioLibraryFile) {
|
||||
const { title, author, series, publishedYear } = mediaMetadataFromScan
|
||||
const { filename, path } = audioLibraryFile.metadata
|
||||
let partbasename = Path.basename(filename, Path.extname(filename))
|
||||
|
||||
// Remove title, author, series, and publishedYear from filename if there
|
||||
if (title) partbasename = partbasename.replace(title, '')
|
||||
if (author) partbasename = partbasename.replace(author, '')
|
||||
if (series) partbasename = partbasename.replace(series, '')
|
||||
if (publishedYear) partbasename = partbasename.replace(publishedYear)
|
||||
|
||||
// Look for disc number
|
||||
let discNumber = null
|
||||
const discMatch = partbasename.match(/\b(disc|cd) ?(\d\d?)\b/i)
|
||||
if (discMatch && discMatch.length > 2 && discMatch[2]) {
|
||||
if (!isNaN(discMatch[2])) {
|
||||
discNumber = Number(discMatch[2])
|
||||
}
|
||||
|
||||
// Remove disc number from filename
|
||||
partbasename = partbasename.replace(/\b(disc|cd) ?(\d\d?)\b/i, '')
|
||||
}
|
||||
|
||||
// Look for disc number in folder path e.g. /Book Title/CD01/audiofile.mp3
|
||||
const pathdir = Path.dirname(path).split('/').pop()
|
||||
if (pathdir && /^cd\d{1,3}$/i.test(pathdir)) {
|
||||
const discFromFolder = Number(pathdir.replace(/cd/i, ''))
|
||||
if (!isNaN(discFromFolder) && discFromFolder !== null) discNumber = discFromFolder
|
||||
}
|
||||
|
||||
const numbersinpath = partbasename.match(/\d{1,4}/g)
|
||||
const trackNumber = numbersinpath && numbersinpath.length ? parseInt(numbersinpath[0]) : null
|
||||
return {
|
||||
trackNumber,
|
||||
discNumber
|
||||
}
|
||||
}
|
||||
|
||||
getAverageScanDurationMs(results) {
|
||||
if (!results.length) return 0
|
||||
let total = 0
|
||||
for (let i = 0; i < results.length; i++) total += results[i].elapsed
|
||||
return Math.floor(total / results.length)
|
||||
}
|
||||
|
||||
async scan(mediaType, libraryFile, mediaMetadataFromScan, verbose = false) {
|
||||
const probeStart = Date.now()
|
||||
|
||||
const probeData = await prober.probe(libraryFile.metadata.path, verbose)
|
||||
|
||||
if (probeData.error) {
|
||||
Logger.error(`[MediaFileScanner] ${probeData.error} : "${libraryFile.metadata.path}"`)
|
||||
return null
|
||||
}
|
||||
|
||||
if (mediaType === 'video') {
|
||||
if (!probeData.videoStream) {
|
||||
Logger.error('[MediaFileScanner] Invalid video file no video stream')
|
||||
return null
|
||||
}
|
||||
|
||||
const videoFile = new VideoFile()
|
||||
videoFile.setDataFromProbe(libraryFile, probeData)
|
||||
|
||||
return {
|
||||
videoFile,
|
||||
elapsed: Date.now() - probeStart
|
||||
}
|
||||
} else {
|
||||
if (!probeData.audioStream) {
|
||||
Logger.error('[MediaFileScanner] Invalid audio file no audio stream')
|
||||
return null
|
||||
}
|
||||
|
||||
const audioFile = new AudioFile()
|
||||
audioFile.trackNumFromMeta = probeData.audioMetaTags.trackNumber
|
||||
audioFile.discNumFromMeta = probeData.audioMetaTags.discNumber
|
||||
if (mediaType === 'book') {
|
||||
const { trackNumber, discNumber } = this.getTrackAndDiscNumberFromFilename(mediaMetadataFromScan, libraryFile)
|
||||
audioFile.trackNumFromFilename = trackNumber
|
||||
audioFile.discNumFromFilename = discNumber
|
||||
}
|
||||
audioFile.setDataFromProbe(libraryFile, probeData)
|
||||
|
||||
return {
|
||||
audioFile,
|
||||
elapsed: Date.now() - probeStart
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Returns array of { MediaFile, elapsed, averageScanDuration } from audio file scan objects
|
||||
async executeMediaFileScans(libraryItem, mediaLibraryFiles) {
|
||||
const mediaType = libraryItem.mediaType
|
||||
|
||||
const scanStart = Date.now()
|
||||
const mediaMetadata = libraryItem.media.metadata || null
|
||||
const batchSize = 32
|
||||
const results = []
|
||||
for (let batch = 0; batch < mediaLibraryFiles.length; batch += batchSize) {
|
||||
const proms = []
|
||||
for (let i = batch; i < Math.min(batch + batchSize, mediaLibraryFiles.length); i++) {
|
||||
proms.push(this.scan(mediaType, mediaLibraryFiles[i], mediaMetadata))
|
||||
}
|
||||
results.push(...await Promise.all(proms).then((scanResults) => scanResults.filter(sr => sr)))
|
||||
}
|
||||
|
||||
return {
|
||||
audioFiles: results.filter(r => r.audioFile).map(r => r.audioFile),
|
||||
videoFiles: results.filter(r => r.videoFile).map(r => r.videoFile),
|
||||
elapsed: Date.now() - scanStart,
|
||||
averageScanDuration: this.getAverageScanDurationMs(results)
|
||||
}
|
||||
}
|
||||
|
||||
isSequential(nums) {
|
||||
if (!nums || !nums.length) return false
|
||||
if (nums.length === 1) return true
|
||||
let prev = nums[0]
|
||||
for (let i = 1; i < nums.length; i++) {
|
||||
if (nums[i] - prev > 1) return false
|
||||
prev = nums[i]
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
removeDupes(nums) {
|
||||
if (!nums || !nums.length) return []
|
||||
if (nums.length === 1) return nums
|
||||
|
||||
var nodupes = [nums[0]]
|
||||
nums.forEach((num) => {
|
||||
if (num > nodupes[nodupes.length - 1]) nodupes.push(num)
|
||||
})
|
||||
return nodupes
|
||||
}
|
||||
|
||||
runSmartTrackOrder(libraryItem, audioFiles) {
|
||||
var discsFromFilename = []
|
||||
var tracksFromFilename = []
|
||||
var discsFromMeta = []
|
||||
var tracksFromMeta = []
|
||||
|
||||
audioFiles.forEach((af) => {
|
||||
if (af.discNumFromFilename !== null) discsFromFilename.push(af.discNumFromFilename)
|
||||
if (af.discNumFromMeta !== null) discsFromMeta.push(af.discNumFromMeta)
|
||||
if (af.trackNumFromFilename !== null) tracksFromFilename.push(af.trackNumFromFilename)
|
||||
if (af.trackNumFromMeta !== null) tracksFromMeta.push(af.trackNumFromMeta)
|
||||
})
|
||||
discsFromFilename.sort((a, b) => a - b)
|
||||
discsFromMeta.sort((a, b) => a - b)
|
||||
tracksFromFilename.sort((a, b) => a - b)
|
||||
tracksFromMeta.sort((a, b) => a - b)
|
||||
|
||||
var discKey = null
|
||||
if (discsFromMeta.length === audioFiles.length && this.isSequential(discsFromMeta)) {
|
||||
discKey = 'discNumFromMeta'
|
||||
} else if (discsFromFilename.length === audioFiles.length && this.isSequential(discsFromFilename)) {
|
||||
discKey = 'discNumFromFilename'
|
||||
}
|
||||
|
||||
var trackKey = null
|
||||
tracksFromFilename = this.removeDupes(tracksFromFilename)
|
||||
tracksFromMeta = this.removeDupes(tracksFromMeta)
|
||||
if (tracksFromFilename.length > tracksFromMeta.length) {
|
||||
trackKey = 'trackNumFromFilename'
|
||||
} else {
|
||||
trackKey = 'trackNumFromMeta'
|
||||
}
|
||||
|
||||
if (discKey !== null) {
|
||||
Logger.debug(`[MediaFileScanner] Smart track order for "${libraryItem.media.metadata.title}" using disc key ${discKey} and track key ${trackKey}`)
|
||||
audioFiles.sort((a, b) => {
|
||||
let Dx = a[discKey] - b[discKey]
|
||||
if (Dx === 0) Dx = a[trackKey] - b[trackKey]
|
||||
return Dx
|
||||
})
|
||||
} else {
|
||||
Logger.debug(`[MediaFileScanner] Smart track order for "${libraryItem.media.metadata.title}" using track key ${trackKey}`)
|
||||
audioFiles.sort((a, b) => a[trackKey] - b[trackKey])
|
||||
}
|
||||
|
||||
for (let i = 0; i < audioFiles.length; i++) {
|
||||
audioFiles[i].index = i + 1
|
||||
var existingAF = libraryItem.media.findFileWithInode(audioFiles[i].ino)
|
||||
if (existingAF) {
|
||||
if (existingAF.updateFromScan) existingAF.updateFromScan(audioFiles[i])
|
||||
} else {
|
||||
libraryItem.media.addAudioFile(audioFiles[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans media files for a library item and adds them as audio tracks and sets library item metadata
|
||||
* @async
|
||||
* @param {Array<LibraryFile>} mediaLibraryFiles - Media files for this library item
|
||||
* @param {LibraryItem} libraryItem
|
||||
* @param {LibraryScan} [libraryScan=null] - Optional when doing a library scan to use LibraryScan config/logs
|
||||
* @return {Promise<Boolean>} True if any updates were made
|
||||
*/
|
||||
async scanMediaFiles(mediaLibraryFiles, libraryItem, libraryScan = null) {
|
||||
const preferAudioMetadata = libraryScan ? !!libraryScan.preferAudioMetadata : !!global.ServerSettings.scannerPreferAudioMetadata
|
||||
|
||||
let hasUpdated = false
|
||||
|
||||
const mediaScanResult = await this.executeMediaFileScans(libraryItem, mediaLibraryFiles)
|
||||
|
||||
if (libraryItem.mediaType === 'video') {
|
||||
if (mediaScanResult.videoFiles.length) {
|
||||
// TODO: Check for updates etc
|
||||
hasUpdated = true
|
||||
libraryItem.media.setVideoFile(mediaScanResult.videoFiles[0])
|
||||
}
|
||||
} else if (mediaScanResult.audioFiles.length) {
|
||||
if (libraryScan) {
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Library Item "${libraryItem.path}" Media file scan took ${mediaScanResult.elapsed}ms for ${mediaScanResult.audioFiles.length} with average time of ${mediaScanResult.averageScanDuration}ms per MB`)
|
||||
}
|
||||
Logger.debug(`Library Item "${libraryItem.path}" Media file scan took ${mediaScanResult.elapsed}ms with ${mediaScanResult.audioFiles.length} audio files averaging ${mediaScanResult.averageScanDuration}ms per MB`)
|
||||
|
||||
const newAudioFiles = mediaScanResult.audioFiles.filter(af => {
|
||||
return !libraryItem.media.findFileWithInode(af.ino)
|
||||
})
|
||||
|
||||
// Book: Adding audio files to book media
|
||||
if (libraryItem.mediaType === 'book') {
|
||||
const mediaScanFileInodes = mediaScanResult.audioFiles.map(af => af.ino)
|
||||
// Filter for existing valid track audio files not included in the audio files scanned
|
||||
const existingAudioFiles = libraryItem.media.audioFiles.filter(af => af.isValidTrack && !mediaScanFileInodes.includes(af.ino))
|
||||
|
||||
if (newAudioFiles.length) {
|
||||
// Single Track Audiobooks
|
||||
if (mediaScanFileInodes.length + existingAudioFiles.length === 1) {
|
||||
const af = mediaScanResult.audioFiles[0]
|
||||
af.index = 1
|
||||
libraryItem.media.addAudioFile(af)
|
||||
hasUpdated = true
|
||||
} else {
|
||||
const allAudioFiles = existingAudioFiles.concat(mediaScanResult.audioFiles)
|
||||
this.runSmartTrackOrder(libraryItem, allAudioFiles)
|
||||
hasUpdated = true
|
||||
}
|
||||
} else {
|
||||
// Only update metadata not index
|
||||
mediaScanResult.audioFiles.forEach((af) => {
|
||||
const existingAF = libraryItem.media.findFileWithInode(af.ino)
|
||||
if (existingAF) {
|
||||
af.index = existingAF.index
|
||||
if (existingAF.updateFromScan && existingAF.updateFromScan(af)) {
|
||||
hasUpdated = true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Set book details from audio file ID3 tags, optional prefer
|
||||
if (libraryItem.media.setMetadataFromAudioFile(preferAudioMetadata)) {
|
||||
hasUpdated = true
|
||||
}
|
||||
|
||||
if (hasUpdated) {
|
||||
libraryItem.media.rebuildTracks()
|
||||
}
|
||||
} else if (libraryItem.mediaType === 'podcast') { // Podcast Media Type
|
||||
const existingAudioFiles = mediaScanResult.audioFiles.filter(af => libraryItem.media.findFileWithInode(af.ino))
|
||||
|
||||
if (newAudioFiles.length) {
|
||||
let newIndex = Math.max(...libraryItem.media.episodes.filter(ep => ep.index == null || isNaN(ep.index)).map(ep => Number(ep.index))) + 1
|
||||
newAudioFiles.forEach((newAudioFile) => {
|
||||
libraryItem.media.addNewEpisodeFromAudioFile(newAudioFile, newIndex++)
|
||||
})
|
||||
hasUpdated = true
|
||||
}
|
||||
|
||||
// Update audio file metadata for audio files already there
|
||||
existingAudioFiles.forEach((af) => {
|
||||
const podcastEpisode = libraryItem.media.findEpisodeWithInode(af.ino)
|
||||
af.index = 1
|
||||
if (podcastEpisode?.audioFile.updateFromScan(af)) {
|
||||
hasUpdated = true
|
||||
|
||||
podcastEpisode.setDataFromAudioMetaTags(podcastEpisode.audioFile.metaTags, false)
|
||||
}
|
||||
})
|
||||
|
||||
if (libraryItem.media.setMetadataFromAudioFile(preferAudioMetadata)) {
|
||||
hasUpdated = true
|
||||
}
|
||||
} else if (libraryItem.mediaType === 'music') { // Music
|
||||
// Only one audio file in library item
|
||||
if (newAudioFiles.length) { // New audio file
|
||||
libraryItem.media.setAudioFile(newAudioFiles[0])
|
||||
hasUpdated = true
|
||||
} else if (libraryItem.media.audioFile && libraryItem.media.audioFile.updateFromScan(mediaScanResult.audioFiles[0])) {
|
||||
hasUpdated = true
|
||||
console.log('Updated from scan')
|
||||
}
|
||||
|
||||
if (libraryItem.media.setMetadataFromAudioFile()) {
|
||||
hasUpdated = true
|
||||
}
|
||||
|
||||
// If the audio track has no title meta tag then use the audio file name
|
||||
if (!libraryItem.media.metadata.title && libraryItem.media.audioFile) {
|
||||
const audioFileName = libraryItem.media.audioFile.metadata.filename
|
||||
libraryItem.media.metadata.title = Path.basename(audioFileName, Path.extname(audioFileName))
|
||||
hasUpdated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hasUpdated
|
||||
}
|
||||
|
||||
probeAudioFile(audioFile) {
|
||||
Logger.debug(`[MediaFileScanner] Running ffprobe for audio file at "${audioFile.metadata.path}"`)
|
||||
return prober.rawProbe(audioFile.metadata.path)
|
||||
}
|
||||
}
|
||||
module.exports = new MediaFileScanner()
|
||||
|
|
@ -42,13 +42,8 @@ class MediaProbeData {
|
|||
}
|
||||
}
|
||||
|
||||
getEmbeddedCoverArt(videoStream) {
|
||||
const ImageCodecs = ['mjpeg', 'jpeg', 'png']
|
||||
return ImageCodecs.includes(videoStream.codec) ? videoStream.codec : null
|
||||
}
|
||||
|
||||
setData(data) {
|
||||
this.embeddedCoverArt = data.video_stream ? this.getEmbeddedCoverArt(data.video_stream) : null
|
||||
this.embeddedCoverArt = data.video_stream?.codec || null
|
||||
this.format = data.format
|
||||
this.duration = data.duration
|
||||
this.size = data.size
|
||||
|
|
|
|||
647
server/scanner/PodcastScanner.js
Normal file
647
server/scanner/PodcastScanner.js
Normal file
|
|
@ -0,0 +1,647 @@
|
|||
const uuidv4 = require("uuid").v4
|
||||
const Path = require('path')
|
||||
const { LogLevel } = require('../utils/constants')
|
||||
const { getTitleIgnorePrefix } = require('../utils/index')
|
||||
const abmetadataGenerator = require('../utils/generators/abmetadataGenerator')
|
||||
const AudioFileScanner = require('./AudioFileScanner')
|
||||
const Database = require('../Database')
|
||||
const { readTextFile, filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')
|
||||
const AudioFile = require('../objects/files/AudioFile')
|
||||
const CoverManager = require('../managers/CoverManager')
|
||||
const LibraryFile = require('../objects/files/LibraryFile')
|
||||
const fsExtra = require("../libs/fsExtra")
|
||||
const PodcastEpisode = require("../models/PodcastEpisode")
|
||||
|
||||
/**
|
||||
* Metadata for podcasts pulled from files
|
||||
* @typedef PodcastMetadataObject
|
||||
* @property {string} title
|
||||
* @property {string} titleIgnorePrefix
|
||||
* @property {string} author
|
||||
* @property {string} releaseDate
|
||||
* @property {string} feedURL
|
||||
* @property {string} imageURL
|
||||
* @property {string} description
|
||||
* @property {string} itunesPageURL
|
||||
* @property {string} itunesId
|
||||
* @property {string} language
|
||||
* @property {string} podcastType
|
||||
* @property {string[]} genres
|
||||
* @property {string[]} tags
|
||||
* @property {boolean} explicit
|
||||
*/
|
||||
|
||||
class PodcastScanner {
|
||||
constructor() { }
|
||||
|
||||
/**
|
||||
* @param {import('../models/LibraryItem')} existingLibraryItem
|
||||
* @param {import('./LibraryItemScanData')} libraryItemData
|
||||
* @param {import('../models/Library').LibrarySettingsObject} librarySettings
|
||||
* @param {import('./LibraryScan')} libraryScan
|
||||
* @returns {Promise<import('../models/LibraryItem')>}
|
||||
*/
|
||||
async rescanExistingPodcastLibraryItem(existingLibraryItem, libraryItemData, librarySettings, libraryScan) {
|
||||
/** @type {import('../models/Podcast')} */
|
||||
const media = await existingLibraryItem.getMedia({
|
||||
include: [
|
||||
{
|
||||
model: Database.podcastEpisodeModel
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
/** @type {import('../models/PodcastEpisode')[]} */
|
||||
let existingPodcastEpisodes = media.podcastEpisodes
|
||||
|
||||
/** @type {AudioFile[]} */
|
||||
let newAudioFiles = []
|
||||
|
||||
if (libraryItemData.hasAudioFileChanges || libraryItemData.audioLibraryFiles.length !== existingPodcastEpisodes.length) {
|
||||
// Filter out and destroy episodes that were removed
|
||||
existingPodcastEpisodes = await Promise.all(existingPodcastEpisodes.filter(async ep => {
|
||||
if (libraryItemData.checkAudioFileRemoved(ep.audioFile)) {
|
||||
libraryScan.addLog(LogLevel.INFO, `Podcast episode "${ep.title}" audio file was removed`)
|
||||
// TODO: Should clean up other data linked to this episode
|
||||
await ep.destroy()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}))
|
||||
|
||||
// Update audio files that were modified
|
||||
if (libraryItemData.audioLibraryFilesModified.length) {
|
||||
let scannedAudioFiles = await AudioFileScanner.executeMediaFileScans(existingLibraryItem.mediaType, libraryItemData, libraryItemData.audioLibraryFilesModified)
|
||||
|
||||
for (const podcastEpisode of existingPodcastEpisodes) {
|
||||
let matchedScannedAudioFile = scannedAudioFiles.find(saf => saf.metadata.path === podcastEpisode.audioFile.metadata.path)
|
||||
if (!matchedScannedAudioFile) {
|
||||
matchedScannedAudioFile = scannedAudioFiles.find(saf => saf.ino === podcastEpisode.audioFile.ino)
|
||||
}
|
||||
|
||||
if (matchedScannedAudioFile) {
|
||||
scannedAudioFiles = scannedAudioFiles.filter(saf => saf !== matchedScannedAudioFile)
|
||||
const audioFile = new AudioFile(podcastEpisode.audioFile)
|
||||
audioFile.updateFromScan(matchedScannedAudioFile)
|
||||
podcastEpisode.audioFile = audioFile.toJSON()
|
||||
podcastEpisode.changed('audioFile', true)
|
||||
|
||||
// Set metadata and save episode
|
||||
this.setPodcastEpisodeMetadataFromAudioFile(podcastEpisode, libraryScan)
|
||||
libraryScan.addLog(LogLevel.INFO, `Podcast episode "${podcastEpisode.title}" keys changed [${podcastEpisode.changed()?.join(', ')}]`)
|
||||
await podcastEpisode.save()
|
||||
}
|
||||
}
|
||||
|
||||
// Modified audio files that were not found as a podcast episode
|
||||
if (scannedAudioFiles.length) {
|
||||
newAudioFiles.push(...scannedAudioFiles)
|
||||
}
|
||||
}
|
||||
|
||||
// Add new audio files scanned in
|
||||
if (libraryItemData.audioLibraryFilesAdded.length) {
|
||||
const scannedAudioFiles = await AudioFileScanner.executeMediaFileScans(existingLibraryItem.mediaType, libraryItemData, libraryItemData.audioLibraryFilesAdded)
|
||||
newAudioFiles.push(...scannedAudioFiles)
|
||||
}
|
||||
|
||||
// Create new podcast episodes from new found audio files
|
||||
for (const newAudioFile of newAudioFiles) {
|
||||
const newEpisode = {
|
||||
title: newAudioFile.metaTags.tagTitle || newAudioFile.metadata.filenameNoExt,
|
||||
subtitle: null,
|
||||
season: null,
|
||||
episode: null,
|
||||
episodeType: null,
|
||||
pubDate: null,
|
||||
publishedAt: null,
|
||||
description: null,
|
||||
audioFile: newAudioFile.toJSON(),
|
||||
chapters: newAudioFile.chapters || [],
|
||||
podcastId: media.id
|
||||
}
|
||||
const newPodcastEpisode = Database.podcastEpisodeModel.build(newEpisode)
|
||||
// Set metadata and save new episode
|
||||
this.setPodcastEpisodeMetadataFromAudioFile(newPodcastEpisode, libraryScan)
|
||||
libraryScan.addLog(LogLevel.INFO, `New Podcast episode "${newPodcastEpisode.title}" added`)
|
||||
await newPodcastEpisode.save()
|
||||
existingPodcastEpisodes.push(newPodcastEpisode)
|
||||
}
|
||||
}
|
||||
|
||||
let hasMediaChanges = false
|
||||
|
||||
// Check if cover was removed
|
||||
if (media.coverPath && !libraryItemData.imageLibraryFiles.some(lf => lf.metadata.path === media.coverPath)) {
|
||||
media.coverPath = null
|
||||
hasMediaChanges = true
|
||||
}
|
||||
|
||||
// Check if cover is not set and image files were found
|
||||
if (!media.coverPath && libraryItemData.imageLibraryFiles.length) {
|
||||
// Prefer using a cover image with the name "cover" otherwise use the first image
|
||||
const coverMatch = libraryItemData.imageLibraryFiles.find(iFile => /\/cover\.[^.\/]*$/.test(iFile.metadata.path))
|
||||
media.coverPath = coverMatch?.metadata.path || libraryItemData.imageLibraryFiles[0].metadata.path
|
||||
hasMediaChanges = true
|
||||
}
|
||||
|
||||
// TODO: When metadata file is stored in /metadata/items/{libraryItemId}.[abs|json] we should load this
|
||||
const podcastMetadata = await this.getPodcastMetadataFromScanData(existingPodcastEpisodes, libraryItemData, libraryScan)
|
||||
|
||||
for (const key in podcastMetadata) {
|
||||
// Ignore unset metadata and empty arrays
|
||||
if (podcastMetadata[key] === undefined || (Array.isArray(podcastMetadata[key]) && !podcastMetadata[key].length)) continue
|
||||
|
||||
if (key === 'genres') {
|
||||
const existingGenres = media.genres || []
|
||||
if (podcastMetadata.genres.some(g => !existingGenres.includes(g)) || existingGenres.some(g => !podcastMetadata.genres.includes(g))) {
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Updating podcast genres "${existingGenres.join(',')}" => "${podcastMetadata.genres.join(',')}" for podcast "${podcastMetadata.title}"`)
|
||||
media.genres = podcastMetadata.genres
|
||||
media.changed('genres', true)
|
||||
hasMediaChanges = true
|
||||
}
|
||||
} else if (key === 'tags') {
|
||||
const existingTags = media.tags || []
|
||||
if (podcastMetadata.tags.some(t => !existingTags.includes(t)) || existingTags.some(t => !podcastMetadata.tags.includes(t))) {
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Updating podcast tags "${existingTags.join(',')}" => "${podcastMetadata.tags.join(',')}" for podcast "${podcastMetadata.title}"`)
|
||||
media.tags = podcastMetadata.tags
|
||||
media.changed('tags', true)
|
||||
hasMediaChanges = true
|
||||
}
|
||||
} else if (podcastMetadata[key] !== media[key]) {
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Updating podcast ${key} "${media[key]}" => "${podcastMetadata[key]}" for podcast "${podcastMetadata.title}"`)
|
||||
media[key] = podcastMetadata[key]
|
||||
hasMediaChanges = true
|
||||
}
|
||||
}
|
||||
|
||||
// If no cover then extract cover from audio file if available
|
||||
if (!media.coverPath && existingPodcastEpisodes.length) {
|
||||
const audioFiles = existingPodcastEpisodes.map(ep => ep.audioFile)
|
||||
const extractedCoverPath = await CoverManager.saveEmbeddedCoverArtNew(audioFiles, existingLibraryItem.id, existingLibraryItem.path)
|
||||
if (extractedCoverPath) {
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Updating podcast "${podcastMetadata.title}" extracted embedded cover art from audio file to path "${extractedCoverPath}"`)
|
||||
media.coverPath = extractedCoverPath
|
||||
hasMediaChanges = true
|
||||
}
|
||||
}
|
||||
|
||||
existingLibraryItem.media = media
|
||||
|
||||
let libraryItemUpdated = false
|
||||
|
||||
// Save Podcast changes to db
|
||||
if (hasMediaChanges) {
|
||||
await media.save()
|
||||
await this.saveMetadataFile(existingLibraryItem, libraryScan)
|
||||
libraryItemUpdated = global.ServerSettings.storeMetadataWithItem
|
||||
}
|
||||
|
||||
if (libraryItemUpdated) {
|
||||
existingLibraryItem.changed('libraryFiles', true)
|
||||
await existingLibraryItem.save()
|
||||
}
|
||||
|
||||
return existingLibraryItem
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('./LibraryItemScanData')} libraryItemData
|
||||
* @param {import('../models/Library').LibrarySettingsObject} librarySettings
|
||||
* @param {import('./LibraryScan')} libraryScan
|
||||
* @returns {Promise<import('../models/LibraryItem')>}
|
||||
*/
|
||||
async scanNewPodcastLibraryItem(libraryItemData, librarySettings, libraryScan) {
|
||||
// Scan audio files found
|
||||
let scannedAudioFiles = await AudioFileScanner.executeMediaFileScans(libraryItemData.mediaType, libraryItemData, libraryItemData.audioLibraryFiles)
|
||||
|
||||
// Do not add library items that have no valid audio files
|
||||
if (!scannedAudioFiles.length) {
|
||||
libraryScan.addLog(LogLevel.WARN, `Library item at path "${libraryItemData.relPath}" has no audio files - ignoring`)
|
||||
return null
|
||||
}
|
||||
|
||||
const newPodcastEpisodes = []
|
||||
|
||||
// Create podcast episodes from audio files
|
||||
for (const audioFile of scannedAudioFiles) {
|
||||
const newEpisode = {
|
||||
title: audioFile.metaTags.tagTitle || audioFile.metadata.filenameNoExt,
|
||||
subtitle: null,
|
||||
season: null,
|
||||
episode: null,
|
||||
episodeType: null,
|
||||
pubDate: null,
|
||||
publishedAt: null,
|
||||
description: null,
|
||||
audioFile: audioFile.toJSON(),
|
||||
chapters: audioFile.chapters || []
|
||||
}
|
||||
|
||||
// Set metadata and save new episode
|
||||
this.setPodcastEpisodeMetadataFromAudioFile(newEpisode, libraryScan)
|
||||
libraryScan.addLog(LogLevel.INFO, `New Podcast episode "${newEpisode.title}" found`)
|
||||
newPodcastEpisodes.push(newEpisode)
|
||||
}
|
||||
|
||||
const podcastMetadata = await this.getPodcastMetadataFromScanData(newPodcastEpisodes, libraryItemData, libraryScan)
|
||||
podcastMetadata.explicit = !!podcastMetadata.explicit // Ensure boolean
|
||||
|
||||
// Set cover image from library file
|
||||
if (libraryItemData.imageLibraryFiles.length) {
|
||||
// Prefer using a cover image with the name "cover" otherwise use the first image
|
||||
const coverMatch = libraryItemData.imageLibraryFiles.find(iFile => /\/cover\.[^.\/]*$/.test(iFile.metadata.path))
|
||||
podcastMetadata.coverPath = coverMatch?.metadata.path || libraryItemData.imageLibraryFiles[0].metadata.path
|
||||
}
|
||||
|
||||
// Set default podcastType to episodic
|
||||
if (!podcastMetadata.podcastType) {
|
||||
podcastMetadata.podcastType = 'episodic'
|
||||
}
|
||||
|
||||
const podcastObject = {
|
||||
...podcastMetadata,
|
||||
autoDownloadEpisodes: false,
|
||||
autoDownloadSchedule: '0 * * * *',
|
||||
lastEpisodeCheck: 0,
|
||||
maxEpisodesToKeep: 0,
|
||||
maxNewEpisodesToDownload: 3,
|
||||
podcastEpisodes: newPodcastEpisodes
|
||||
}
|
||||
|
||||
const libraryItemObj = libraryItemData.libraryItemObject
|
||||
libraryItemObj.id = uuidv4() // Generate library item id ahead of time to use for saving extracted cover image
|
||||
libraryItemObj.isMissing = false
|
||||
libraryItemObj.isInvalid = false
|
||||
libraryItemObj.extraData = {}
|
||||
|
||||
// If cover was not found in folder then check embedded covers in audio files
|
||||
if (!podcastObject.coverPath && scannedAudioFiles.length) {
|
||||
// Extract and save embedded cover art
|
||||
podcastObject.coverPath = await CoverManager.saveEmbeddedCoverArtNew(scannedAudioFiles, libraryItemObj.id, libraryItemObj.path)
|
||||
}
|
||||
|
||||
libraryItemObj.podcast = podcastObject
|
||||
const libraryItem = await Database.libraryItemModel.create(libraryItemObj, {
|
||||
include: {
|
||||
model: Database.podcastModel,
|
||||
include: Database.podcastEpisodeModel
|
||||
}
|
||||
})
|
||||
|
||||
Database.addGenresToFilterData(libraryItemData.libraryId, libraryItem.podcast.genres)
|
||||
Database.addTagsToFilterData(libraryItemData.libraryId, libraryItem.podcast.tags)
|
||||
|
||||
// Load for emitting to client
|
||||
libraryItem.media = await libraryItem.getMedia({
|
||||
include: Database.podcastEpisodeModel
|
||||
})
|
||||
|
||||
await this.saveMetadataFile(libraryItem, libraryScan)
|
||||
if (global.ServerSettings.storeMetadataWithItem) {
|
||||
libraryItem.changed('libraryFiles', true)
|
||||
await libraryItem.save()
|
||||
}
|
||||
|
||||
return libraryItem
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {PodcastEpisode[]} podcastEpisodes Not the models for new podcasts
|
||||
* @param {import('./LibraryItemScanData')} libraryItemData
|
||||
* @param {import('./LibraryScan')} libraryScan
|
||||
* @returns {Promise<PodcastMetadataObject>}
|
||||
*/
|
||||
async getPodcastMetadataFromScanData(podcastEpisodes, libraryItemData, libraryScan) {
|
||||
const podcastMetadata = {
|
||||
title: libraryItemData.mediaMetadata.title,
|
||||
titleIgnorePrefix: getTitleIgnorePrefix(libraryItemData.mediaMetadata.title),
|
||||
author: undefined,
|
||||
releaseDate: undefined,
|
||||
feedURL: undefined,
|
||||
imageURL: undefined,
|
||||
description: undefined,
|
||||
itunesPageURL: undefined,
|
||||
itunesId: undefined,
|
||||
itunesArtistId: undefined,
|
||||
language: undefined,
|
||||
podcastType: undefined,
|
||||
explicit: undefined,
|
||||
tags: [],
|
||||
genres: []
|
||||
}
|
||||
|
||||
if (podcastEpisodes.length) {
|
||||
const audioFileMetaTags = podcastEpisodes[0].audioFile.metaTags
|
||||
const overrideExistingDetails = Database.serverSettings.scannerPreferAudioMetadata
|
||||
|
||||
const MetadataMapArray = [
|
||||
{
|
||||
tag: 'tagAlbum',
|
||||
altTag: 'tagSeries',
|
||||
key: 'title'
|
||||
},
|
||||
{
|
||||
tag: 'tagArtist',
|
||||
key: 'author'
|
||||
},
|
||||
{
|
||||
tag: 'tagGenre',
|
||||
key: 'genres'
|
||||
},
|
||||
{
|
||||
tag: 'tagLanguage',
|
||||
key: 'language'
|
||||
},
|
||||
{
|
||||
tag: 'tagItunesId',
|
||||
key: 'itunesId'
|
||||
},
|
||||
{
|
||||
tag: 'tagPodcastType',
|
||||
key: 'podcastType',
|
||||
}
|
||||
]
|
||||
|
||||
MetadataMapArray.forEach((mapping) => {
|
||||
let value = audioFileMetaTags[mapping.tag]
|
||||
let tagToUse = mapping.tag
|
||||
if (!value && mapping.altTag) {
|
||||
value = audioFileMetaTags[mapping.altTag]
|
||||
tagToUse = mapping.altTag
|
||||
}
|
||||
|
||||
if (value && typeof value === 'string') {
|
||||
value = value.trim() // Trim whitespace
|
||||
|
||||
if (mapping.key === 'genres' && (!podcastMetadata.genres.length || overrideExistingDetails)) {
|
||||
podcastMetadata.genres = this.parseGenresString(value)
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Mapping metadata to key ${tagToUse} => ${mapping.key}: ${podcastMetadata.genres.join(', ')}`)
|
||||
} else if (!podcastMetadata[mapping.key] || overrideExistingDetails) {
|
||||
podcastMetadata[mapping.key] = value
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Mapping metadata to key ${tagToUse} => ${mapping.key}: ${podcastMetadata[mapping.key]}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// If metadata.json or metadata.abs use this for metadata
|
||||
const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile || libraryItemData.metadataAbsLibraryFile
|
||||
const metadataText = metadataLibraryFile ? await readTextFile(metadataLibraryFile.metadata.path) : null
|
||||
if (metadataText) {
|
||||
libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataLibraryFile.metadata.path}" - preferring`)
|
||||
let abMetadata = null
|
||||
if (!!libraryItemData.metadataJsonLibraryFile) {
|
||||
abMetadata = abmetadataGenerator.parseJson(metadataText)
|
||||
} else {
|
||||
abMetadata = abmetadataGenerator.parse(metadataText, 'podcast')
|
||||
}
|
||||
|
||||
if (abMetadata) {
|
||||
if (abMetadata.tags?.length) {
|
||||
podcastMetadata.tags = abMetadata.tags
|
||||
}
|
||||
for (const key in abMetadata.metadata) {
|
||||
if (abMetadata.metadata[key] === undefined) continue
|
||||
|
||||
// TODO: New podcast model changed some keys, need to update the abmetadataGenerator
|
||||
let newModelKey = key
|
||||
if (key === 'feedUrl') newModelKey = 'feedURL'
|
||||
else if (key === 'imageUrl') newModelKey = 'imageURL'
|
||||
else if (key === 'itunesPageUrl') newModelKey = 'itunesPageURL'
|
||||
else if (key === 'type') newModelKey = 'podcastType'
|
||||
|
||||
podcastMetadata[newModelKey] = abMetadata.metadata[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
podcastMetadata.titleIgnorePrefix = getTitleIgnorePrefix(podcastMetadata.title)
|
||||
|
||||
return podcastMetadata
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a genre string into multiple genres
|
||||
* @example "Fantasy;Sci-Fi;History" => ["Fantasy", "Sci-Fi", "History"]
|
||||
* @param {string} genreTag
|
||||
* @returns {string[]}
|
||||
*/
|
||||
parseGenresString(genreTag) {
|
||||
if (!genreTag?.length) return []
|
||||
const separators = ['/', '//', ';']
|
||||
for (let i = 0; i < separators.length; i++) {
|
||||
if (genreTag.includes(separators[i])) {
|
||||
return genreTag.split(separators[i]).map(genre => genre.trim()).filter(g => !!g)
|
||||
}
|
||||
}
|
||||
return [genreTag]
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../models/LibraryItem')} libraryItem
|
||||
* @param {import('./LibraryScan')} libraryScan
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async saveMetadataFile(libraryItem, libraryScan) {
|
||||
let metadataPath = Path.join(global.MetadataPath, 'items', libraryItem.id)
|
||||
let storeMetadataWithItem = global.ServerSettings.storeMetadataWithItem
|
||||
if (storeMetadataWithItem) {
|
||||
metadataPath = libraryItem.path
|
||||
} else {
|
||||
// Make sure metadata book dir exists
|
||||
storeMetadataWithItem = false
|
||||
await fsExtra.ensureDir(metadataPath)
|
||||
}
|
||||
|
||||
const metadataFileFormat = global.ServerSettings.metadataFileFormat
|
||||
const metadataFilePath = Path.join(metadataPath, `metadata.${metadataFileFormat}`)
|
||||
if (metadataFileFormat === 'json') {
|
||||
// Remove metadata.abs if it exists
|
||||
if (await fsExtra.pathExists(Path.join(metadataPath, `metadata.abs`))) {
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Removing metadata.abs for item "${libraryItem.media.title}"`)
|
||||
await fsExtra.remove(Path.join(metadataPath, `metadata.abs`))
|
||||
libraryItem.libraryFiles = libraryItem.libraryFiles.filter(lf => lf.metadata.path !== filePathToPOSIX(Path.join(metadataPath, `metadata.abs`)))
|
||||
}
|
||||
|
||||
// TODO: Update to not use `metadata` so it fits the updated model
|
||||
const jsonObject = {
|
||||
tags: libraryItem.media.tags || [],
|
||||
metadata: {
|
||||
title: libraryItem.media.title,
|
||||
author: libraryItem.media.author,
|
||||
description: libraryItem.media.description,
|
||||
releaseDate: libraryItem.media.releaseDate,
|
||||
genres: libraryItem.media.genres || [],
|
||||
feedUrl: libraryItem.media.feedURL,
|
||||
imageUrl: libraryItem.media.imageURL,
|
||||
itunesPageUrl: libraryItem.media.itunesPageURL,
|
||||
itunesId: libraryItem.media.itunesId,
|
||||
itunesArtistId: libraryItem.media.itunesArtistId,
|
||||
asin: libraryItem.media.asin,
|
||||
language: libraryItem.media.language,
|
||||
explicit: !!libraryItem.media.explicit,
|
||||
type: libraryItem.media.podcastType
|
||||
}
|
||||
}
|
||||
return fsExtra.writeFile(metadataFilePath, JSON.stringify(jsonObject, null, 2)).then(async () => {
|
||||
// Add metadata.json to libraryFiles array if it is new
|
||||
let metadataLibraryFile = libraryItem.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath))
|
||||
if (storeMetadataWithItem) {
|
||||
if (!metadataLibraryFile) {
|
||||
const newLibraryFile = new LibraryFile()
|
||||
await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`)
|
||||
metadataLibraryFile = newLibraryFile.toJSON()
|
||||
libraryItem.libraryFiles.push(metadataLibraryFile)
|
||||
} else {
|
||||
const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath)
|
||||
if (fileTimestamps) {
|
||||
metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs
|
||||
metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs
|
||||
metadataLibraryFile.metadata.size = fileTimestamps.size
|
||||
metadataLibraryFile.ino = fileTimestamps.ino
|
||||
}
|
||||
}
|
||||
const libraryItemDirTimestamps = await getFileTimestampsWithIno(libraryItem.path)
|
||||
if (libraryItemDirTimestamps) {
|
||||
libraryItem.mtime = libraryItemDirTimestamps.mtimeMs
|
||||
libraryItem.ctime = libraryItemDirTimestamps.ctimeMs
|
||||
let size = 0
|
||||
libraryItem.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0))
|
||||
libraryItem.size = size
|
||||
}
|
||||
}
|
||||
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Success saving abmetadata to "${metadataFilePath}"`)
|
||||
|
||||
return metadataLibraryFile
|
||||
}).catch((error) => {
|
||||
libraryScan.addLog(LogLevel.ERROR, `Failed to save json file at "${metadataFilePath}"`, error)
|
||||
return null
|
||||
})
|
||||
} else {
|
||||
// Remove metadata.json if it exists
|
||||
if (await fsExtra.pathExists(Path.join(metadataPath, `metadata.json`))) {
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Removing metadata.json for item "${libraryItem.media.title}"`)
|
||||
await fsExtra.remove(Path.join(metadataPath, `metadata.json`))
|
||||
libraryItem.libraryFiles = libraryItem.libraryFiles.filter(lf => lf.metadata.path !== filePathToPOSIX(Path.join(metadataPath, `metadata.json`)))
|
||||
}
|
||||
|
||||
return abmetadataGenerator.generateFromNewModel(libraryItem, metadataFilePath).then(async (success) => {
|
||||
if (!success) {
|
||||
libraryScan.addLog(LogLevel.ERROR, `Failed saving abmetadata to "${metadataFilePath}"`)
|
||||
return null
|
||||
}
|
||||
// Add metadata.abs to libraryFiles array if it is new
|
||||
let metadataLibraryFile = libraryItem.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath))
|
||||
if (storeMetadataWithItem) {
|
||||
if (!metadataLibraryFile) {
|
||||
const newLibraryFile = new LibraryFile()
|
||||
await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.abs`)
|
||||
metadataLibraryFile = newLibraryFile.toJSON()
|
||||
libraryItem.libraryFiles.push(metadataLibraryFile)
|
||||
} else {
|
||||
const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath)
|
||||
if (fileTimestamps) {
|
||||
metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs
|
||||
metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs
|
||||
metadataLibraryFile.metadata.size = fileTimestamps.size
|
||||
metadataLibraryFile.ino = fileTimestamps.ino
|
||||
}
|
||||
}
|
||||
const libraryItemDirTimestamps = await getFileTimestampsWithIno(libraryItem.path)
|
||||
if (libraryItemDirTimestamps) {
|
||||
libraryItem.mtime = libraryItemDirTimestamps.mtimeMs
|
||||
libraryItem.ctime = libraryItemDirTimestamps.ctimeMs
|
||||
let size = 0
|
||||
libraryItem.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0))
|
||||
libraryItem.size = size
|
||||
}
|
||||
}
|
||||
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Success saving abmetadata to "${metadataFilePath}"`)
|
||||
return metadataLibraryFile
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {PodcastEpisode} podcastEpisode Not the model when creating new podcast
|
||||
* @param {import('./ScanLogger')} scanLogger
|
||||
*/
|
||||
setPodcastEpisodeMetadataFromAudioFile(podcastEpisode, scanLogger) {
|
||||
const MetadataMapArray = [
|
||||
{
|
||||
tag: 'tagComment',
|
||||
altTag: 'tagSubtitle',
|
||||
key: 'description'
|
||||
},
|
||||
{
|
||||
tag: 'tagSubtitle',
|
||||
key: 'subtitle'
|
||||
},
|
||||
{
|
||||
tag: 'tagDate',
|
||||
key: 'pubDate'
|
||||
},
|
||||
{
|
||||
tag: 'tagDisc',
|
||||
key: 'season',
|
||||
},
|
||||
{
|
||||
tag: 'tagTrack',
|
||||
altTag: 'tagSeriesPart',
|
||||
key: 'episode'
|
||||
},
|
||||
{
|
||||
tag: 'tagTitle',
|
||||
key: 'title'
|
||||
},
|
||||
{
|
||||
tag: 'tagEpisodeType',
|
||||
key: 'episodeType'
|
||||
}
|
||||
]
|
||||
|
||||
const audioFileMetaTags = podcastEpisode.audioFile.metaTags
|
||||
const overrideExistingDetails = Database.serverSettings.scannerPreferAudioMetadata
|
||||
MetadataMapArray.forEach((mapping) => {
|
||||
let value = audioFileMetaTags[mapping.tag]
|
||||
let tagToUse = mapping.tag
|
||||
if (!value && mapping.altTag) {
|
||||
tagToUse = mapping.altTag
|
||||
value = audioFileMetaTags[mapping.altTag]
|
||||
}
|
||||
|
||||
if (value && typeof value === 'string') {
|
||||
value = value.trim() // Trim whitespace
|
||||
|
||||
if (mapping.key === 'pubDate' && (!podcastEpisode.pubDate || overrideExistingDetails)) {
|
||||
const pubJsDate = new Date(value)
|
||||
if (pubJsDate && !isNaN(pubJsDate)) {
|
||||
podcastEpisode.publishedAt = pubJsDate.valueOf()
|
||||
podcastEpisode.pubDate = value
|
||||
scanLogger.addLog(LogLevel.DEBUG, `Mapping metadata to key ${tagToUse} => ${mapping.key}: ${podcastEpisode[mapping.key]}`)
|
||||
} else {
|
||||
scanLogger.addLog(LogLevel.WARN, `Mapping pubDate with tag ${tagToUse} has invalid date "${value}"`)
|
||||
}
|
||||
} else if (mapping.key === 'episodeType' && (!podcastEpisode.episodeType || overrideExistingDetails)) {
|
||||
if (['full', 'trailer', 'bonus'].includes(value)) {
|
||||
podcastEpisode.episodeType = value
|
||||
scanLogger.addLog(LogLevel.DEBUG, `Mapping metadata to key ${tagToUse} => ${mapping.key}: ${podcastEpisode[mapping.key]}`)
|
||||
} else {
|
||||
scanLogger.addLog(LogLevel.WARN, `Mapping episodeType with invalid value "${value}". Must be one of [full, trailer, bonus].`)
|
||||
}
|
||||
} else if (!podcastEpisode[mapping.key] || overrideExistingDetails) {
|
||||
podcastEpisode[mapping.key] = value
|
||||
scanLogger.addLog(LogLevel.DEBUG, `Mapping metadata to key ${tagToUse} => ${mapping.key}: ${podcastEpisode[mapping.key]}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
module.exports = new PodcastScanner()
|
||||
70
server/scanner/ScanLogger.js
Normal file
70
server/scanner/ScanLogger.js
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
const uuidv4 = require("uuid").v4
|
||||
const Logger = require('../Logger')
|
||||
const { LogLevel } = require('../utils/constants')
|
||||
|
||||
class ScanLogger {
|
||||
constructor() {
|
||||
this.id = null
|
||||
this.type = null
|
||||
this.name = null
|
||||
this.verbose = false
|
||||
|
||||
this.startedAt = null
|
||||
this.finishedAt = null
|
||||
this.elapsed = null
|
||||
|
||||
/** @type {string[]} */
|
||||
this.authorsRemovedFromBooks = []
|
||||
/** @type {string[]} */
|
||||
this.seriesRemovedFromBooks = []
|
||||
|
||||
this.logs = []
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
type: this.type,
|
||||
name: this.name,
|
||||
startedAt: this.startedAt,
|
||||
finishedAt: this.finishedAt,
|
||||
elapsed: this.elapsed
|
||||
}
|
||||
}
|
||||
|
||||
setData(type, name) {
|
||||
this.id = uuidv4()
|
||||
this.type = type
|
||||
this.name = name
|
||||
this.startedAt = Date.now()
|
||||
}
|
||||
|
||||
setComplete() {
|
||||
this.finishedAt = Date.now()
|
||||
this.elapsed = this.finishedAt - this.startedAt
|
||||
}
|
||||
|
||||
getLogLevelString(level) {
|
||||
for (const key in LogLevel) {
|
||||
if (LogLevel[key] === level) {
|
||||
return key
|
||||
}
|
||||
}
|
||||
return 'UNKNOWN'
|
||||
}
|
||||
|
||||
addLog(level, ...args) {
|
||||
const logObj = {
|
||||
timestamp: (new Date()).toISOString(),
|
||||
message: args.join(' '),
|
||||
levelName: this.getLogLevelString(level),
|
||||
level
|
||||
}
|
||||
|
||||
if (this.verbose) {
|
||||
Logger.debug(`[Scan] "${this.name}":`, ...args)
|
||||
}
|
||||
this.logs.push(logObj)
|
||||
}
|
||||
}
|
||||
module.exports = ScanLogger
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
class ScanOptions {
|
||||
constructor() {
|
||||
this.forceRescan = false
|
||||
|
||||
// Server settings
|
||||
this.parseSubtitles = false
|
||||
this.findCovers = false
|
||||
this.storeCoverWithItem = false
|
||||
this.preferAudioMetadata = false
|
||||
this.preferOpfMetadata = false
|
||||
this.preferMatchedMetadata = false
|
||||
this.preferOverdriveMediaMarker = false
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
forceRescan: this.forceRescan,
|
||||
parseSubtitles: this.parseSubtitles,
|
||||
findCovers: this.findCovers,
|
||||
storeCoverWithItem: this.storeCoverWithItem,
|
||||
preferAudioMetadata: this.preferAudioMetadata,
|
||||
preferOpfMetadata: this.preferOpfMetadata,
|
||||
preferMatchedMetadata: this.preferMatchedMetadata,
|
||||
preferOverdriveMediaMarker: this.preferOverdriveMediaMarker
|
||||
}
|
||||
}
|
||||
|
||||
setData(options, serverSettings) {
|
||||
this.forceRescan = !!options.forceRescan
|
||||
|
||||
this.parseSubtitles = !!serverSettings.scannerParseSubtitle
|
||||
this.findCovers = !!serverSettings.scannerFindCovers
|
||||
this.storeCoverWithItem = serverSettings.storeCoverWithItem
|
||||
this.preferAudioMetadata = serverSettings.scannerPreferAudioMetadata
|
||||
this.preferOpfMetadata = serverSettings.scannerPreferOpfMetadata
|
||||
this.scannerPreferMatchedMetadata = serverSettings.scannerPreferMatchedMetadata
|
||||
this.preferOverdriveMediaMarker = serverSettings.scannerPreferOverdriveMediaMarker
|
||||
}
|
||||
}
|
||||
module.exports = ScanOptions
|
||||
|
|
@ -1,713 +1,20 @@
|
|||
const fs = require('../libs/fsExtra')
|
||||
const Path = require('path')
|
||||
const Logger = require('../Logger')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const Database = require('../Database')
|
||||
|
||||
// Utils
|
||||
const { groupFilesIntoLibraryItemPaths, getLibraryItemFileData, scanFolder, checkFilepathIsAudioFile } = require('../utils/scandir')
|
||||
const { comparePaths } = require('../utils/index')
|
||||
const { getIno, filePathToPOSIX } = require('../utils/fileUtils')
|
||||
const { ScanResult, LogLevel } = require('../utils/constants')
|
||||
const { findMatchingEpisodesInFeed, getPodcastFeed } = require('../utils/podcastUtils')
|
||||
|
||||
const MediaFileScanner = require('./MediaFileScanner')
|
||||
const BookFinder = require('../finders/BookFinder')
|
||||
const PodcastFinder = require('../finders/PodcastFinder')
|
||||
const LibraryItem = require('../objects/LibraryItem')
|
||||
const LibraryScan = require('./LibraryScan')
|
||||
const ScanOptions = require('./ScanOptions')
|
||||
|
||||
const Author = require('../objects/entities/Author')
|
||||
const Series = require('../objects/entities/Series')
|
||||
const Task = require('../objects/Task')
|
||||
const LibraryScanner = require('./LibraryScanner')
|
||||
const CoverManager = require('../managers/CoverManager')
|
||||
|
||||
class Scanner {
|
||||
constructor(coverManager, taskManager) {
|
||||
this.coverManager = coverManager
|
||||
this.taskManager = taskManager
|
||||
|
||||
this.cancelLibraryScan = {}
|
||||
this.librariesScanning = []
|
||||
|
||||
// Watcher file update scan vars
|
||||
this.pendingFileUpdatesToScan = []
|
||||
this.scanningFilesChanged = false
|
||||
|
||||
this.bookFinder = new BookFinder()
|
||||
this.podcastFinder = new PodcastFinder()
|
||||
}
|
||||
|
||||
isLibraryScanning(libraryId) {
|
||||
return this.librariesScanning.find(ls => ls.id === libraryId)
|
||||
}
|
||||
|
||||
setCancelLibraryScan(libraryId) {
|
||||
var libraryScanning = this.librariesScanning.find(ls => ls.id === libraryId)
|
||||
if (!libraryScanning) return
|
||||
this.cancelLibraryScan[libraryId] = true
|
||||
}
|
||||
|
||||
getScanResultDescription(result) {
|
||||
switch (result) {
|
||||
case ScanResult.ADDED:
|
||||
return 'Added to library'
|
||||
case ScanResult.NOTHING:
|
||||
return 'No updates necessary'
|
||||
case ScanResult.REMOVED:
|
||||
return 'Removed from library'
|
||||
case ScanResult.UPDATED:
|
||||
return 'Item was updated'
|
||||
case ScanResult.UPTODATE:
|
||||
return 'No updates necessary'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
async scanLibraryItemByRequest(libraryItem) {
|
||||
const library = await Database.models.library.getOldById(libraryItem.libraryId)
|
||||
if (!library) {
|
||||
Logger.error(`[Scanner] Scan libraryItem by id library not found "${libraryItem.libraryId}"`)
|
||||
return ScanResult.NOTHING
|
||||
}
|
||||
const folder = library.folders.find(f => f.id === libraryItem.folderId)
|
||||
if (!folder) {
|
||||
Logger.error(`[Scanner] Scan libraryItem by id folder not found "${libraryItem.folderId}" in library "${library.name}"`)
|
||||
return ScanResult.NOTHING
|
||||
}
|
||||
Logger.info(`[Scanner] Scanning Library Item "${libraryItem.media.metadata.title}"`)
|
||||
|
||||
const task = new Task()
|
||||
task.setData('scan-item', `Scan ${libraryItem.media.metadata.title}`, '', true, {
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryId: library.id,
|
||||
mediaType: library.mediaType
|
||||
})
|
||||
this.taskManager.addTask(task)
|
||||
|
||||
const result = await this.scanLibraryItem(library, folder, libraryItem)
|
||||
|
||||
task.setFinished(this.getScanResultDescription(result))
|
||||
this.taskManager.taskFinished(task)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async scanLibraryItem(library, folder, libraryItem) {
|
||||
const libraryMediaType = library.mediaType
|
||||
|
||||
// TODO: Support for single media item
|
||||
const libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, libraryItem.path, false)
|
||||
if (!libraryItemData) {
|
||||
return ScanResult.NOTHING
|
||||
}
|
||||
let hasUpdated = false
|
||||
|
||||
const checkRes = libraryItem.checkScanData(libraryItemData)
|
||||
if (checkRes.updated) hasUpdated = true
|
||||
|
||||
// Sync other files first so that local images are used as cover art
|
||||
if (await libraryItem.syncFiles(Database.serverSettings.scannerPreferOpfMetadata, library.settings)) {
|
||||
hasUpdated = true
|
||||
}
|
||||
|
||||
// Scan all audio files
|
||||
if (libraryItem.hasAudioFiles) {
|
||||
const libraryAudioFiles = libraryItem.libraryFiles.filter(lf => lf.fileType === 'audio')
|
||||
if (await MediaFileScanner.scanMediaFiles(libraryAudioFiles, libraryItem)) {
|
||||
hasUpdated = true
|
||||
}
|
||||
|
||||
// Extract embedded cover art if cover is not already in directory
|
||||
if (libraryItem.media.hasEmbeddedCoverArt && !libraryItem.media.coverPath) {
|
||||
const coverPath = await this.coverManager.saveEmbeddedCoverArt(libraryItem)
|
||||
if (coverPath) {
|
||||
Logger.debug(`[Scanner] Saved embedded cover art "${coverPath}"`)
|
||||
hasUpdated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.createNewAuthorsAndSeries(libraryItem)
|
||||
|
||||
// Library Item is invalid - (a book has no audio files or ebook files)
|
||||
if (!libraryItem.hasMediaEntities && libraryItem.mediaType !== 'podcast') {
|
||||
libraryItem.setInvalid()
|
||||
hasUpdated = true
|
||||
} else if (libraryItem.isInvalid) {
|
||||
libraryItem.isInvalid = false
|
||||
hasUpdated = true
|
||||
}
|
||||
|
||||
if (hasUpdated) {
|
||||
await Database.updateLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
return ScanResult.UPDATED
|
||||
}
|
||||
return ScanResult.UPTODATE
|
||||
}
|
||||
|
||||
async scan(library, options = {}) {
|
||||
if (this.isLibraryScanning(library.id)) {
|
||||
Logger.error(`[Scanner] Already scanning ${library.id}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!library.folders.length) {
|
||||
Logger.warn(`[Scanner] Library has no folders to scan "${library.name}"`)
|
||||
return
|
||||
}
|
||||
|
||||
const scanOptions = new ScanOptions()
|
||||
scanOptions.setData(options, Database.serverSettings)
|
||||
|
||||
const libraryScan = new LibraryScan()
|
||||
libraryScan.setData(library, scanOptions)
|
||||
libraryScan.verbose = false
|
||||
this.librariesScanning.push(libraryScan.getScanEmitData)
|
||||
|
||||
SocketAuthority.emitter('scan_start', libraryScan.getScanEmitData)
|
||||
|
||||
Logger.info(`[Scanner] Starting library scan ${libraryScan.id} for ${libraryScan.libraryName}`)
|
||||
|
||||
const canceled = await this.scanLibrary(libraryScan)
|
||||
|
||||
if (canceled) {
|
||||
Logger.info(`[Scanner] Library scan canceled for "${libraryScan.libraryName}"`)
|
||||
delete this.cancelLibraryScan[libraryScan.libraryId]
|
||||
}
|
||||
|
||||
libraryScan.setComplete()
|
||||
|
||||
Logger.info(`[Scanner] Library scan ${libraryScan.id} completed in ${libraryScan.elapsedTimestamp} | ${libraryScan.resultStats}`)
|
||||
this.librariesScanning = this.librariesScanning.filter(ls => ls.id !== library.id)
|
||||
|
||||
if (canceled && !libraryScan.totalResults) {
|
||||
const emitData = libraryScan.getScanEmitData
|
||||
emitData.results = null
|
||||
SocketAuthority.emitter('scan_complete', emitData)
|
||||
return
|
||||
}
|
||||
|
||||
SocketAuthority.emitter('scan_complete', libraryScan.getScanEmitData)
|
||||
|
||||
if (libraryScan.totalResults) {
|
||||
libraryScan.saveLog()
|
||||
}
|
||||
}
|
||||
|
||||
async scanLibrary(libraryScan) {
|
||||
let libraryItemDataFound = []
|
||||
|
||||
// Scan each library
|
||||
for (let i = 0; i < libraryScan.folders.length; i++) {
|
||||
const folder = libraryScan.folders[i]
|
||||
const itemDataFoundInFolder = await scanFolder(libraryScan.library, folder)
|
||||
libraryScan.addLog(LogLevel.INFO, `${itemDataFoundInFolder.length} item data found in folder "${folder.fullPath}"`)
|
||||
libraryItemDataFound = libraryItemDataFound.concat(itemDataFoundInFolder)
|
||||
}
|
||||
|
||||
if (this.cancelLibraryScan[libraryScan.libraryId]) return true
|
||||
|
||||
// Remove items with no inode
|
||||
libraryItemDataFound = libraryItemDataFound.filter(lid => lid.ino)
|
||||
const libraryItemsInLibrary = Database.libraryItems.filter(li => li.libraryId === libraryScan.libraryId)
|
||||
|
||||
const MaxSizePerChunk = 2.5e9
|
||||
const itemDataToRescanChunks = []
|
||||
const newItemDataToScanChunks = []
|
||||
let itemsToUpdate = []
|
||||
let itemDataToRescan = []
|
||||
let itemDataToRescanSize = 0
|
||||
let newItemDataToScan = []
|
||||
let newItemDataToScanSize = 0
|
||||
const itemsToFindCovers = []
|
||||
|
||||
// Check for existing & removed library items
|
||||
for (let i = 0; i < libraryItemsInLibrary.length; i++) {
|
||||
const libraryItem = libraryItemsInLibrary[i]
|
||||
// Find library item folder with matching inode or matching path
|
||||
const dataFound = libraryItemDataFound.find(lid => lid.ino === libraryItem.ino || comparePaths(lid.relPath, libraryItem.relPath))
|
||||
if (!dataFound) {
|
||||
// Podcast folder can have no episodes and still be valid
|
||||
if (libraryScan.libraryMediaType === 'podcast' && await fs.pathExists(libraryItem.path)) {
|
||||
Logger.info(`[Scanner] Library item "${libraryItem.media.metadata.title}" folder exists but has no episodes`)
|
||||
if (libraryItem.isMissing) {
|
||||
libraryScan.resultsUpdated++
|
||||
libraryItem.isMissing = false
|
||||
libraryItem.setLastScan()
|
||||
itemsToUpdate.push(libraryItem)
|
||||
}
|
||||
} else {
|
||||
libraryScan.addLog(LogLevel.WARN, `Library Item "${libraryItem.media.metadata.title}" is missing`)
|
||||
Logger.warn(`[Scanner] Library item "${libraryItem.media.metadata.title}" is missing (inode "${libraryItem.ino}")`)
|
||||
libraryScan.resultsMissing++
|
||||
libraryItem.setMissing()
|
||||
itemsToUpdate.push(libraryItem)
|
||||
}
|
||||
} else {
|
||||
const checkRes = libraryItem.checkScanData(dataFound)
|
||||
if (checkRes.newLibraryFiles.length || libraryScan.scanOptions.forceRescan) { // Item has new files
|
||||
checkRes.libraryItem = libraryItem
|
||||
checkRes.scanData = dataFound
|
||||
|
||||
// If this item will go over max size then push current chunk
|
||||
if (libraryItem.audioFileTotalSize + itemDataToRescanSize > MaxSizePerChunk && itemDataToRescan.length > 0) {
|
||||
itemDataToRescanChunks.push(itemDataToRescan)
|
||||
itemDataToRescanSize = 0
|
||||
itemDataToRescan = []
|
||||
}
|
||||
|
||||
itemDataToRescan.push(checkRes)
|
||||
itemDataToRescanSize += libraryItem.audioFileTotalSize
|
||||
if (itemDataToRescanSize >= MaxSizePerChunk) {
|
||||
itemDataToRescanChunks.push(itemDataToRescan)
|
||||
itemDataToRescanSize = 0
|
||||
itemDataToRescan = []
|
||||
}
|
||||
|
||||
} else if (libraryScan.findCovers && libraryItem.media.shouldSearchForCover) { // Search cover
|
||||
libraryScan.resultsUpdated++
|
||||
itemsToFindCovers.push(libraryItem)
|
||||
itemsToUpdate.push(libraryItem)
|
||||
} else if (checkRes.updated) { // Updated but no scan required
|
||||
libraryScan.resultsUpdated++
|
||||
itemsToUpdate.push(libraryItem)
|
||||
}
|
||||
libraryItemDataFound = libraryItemDataFound.filter(lid => lid.ino !== dataFound.ino)
|
||||
}
|
||||
}
|
||||
if (itemDataToRescan.length) itemDataToRescanChunks.push(itemDataToRescan)
|
||||
|
||||
// Potential NEW Library Items
|
||||
for (let i = 0; i < libraryItemDataFound.length; i++) {
|
||||
const dataFound = libraryItemDataFound[i]
|
||||
|
||||
const hasMediaFile = dataFound.libraryFiles.some(lf => lf.isMediaFile)
|
||||
if (!hasMediaFile) {
|
||||
libraryScan.addLog(LogLevel.WARN, `Item found "${libraryItemDataFound.path}" has no media files`)
|
||||
} else {
|
||||
// If this item will go over max size then push current chunk
|
||||
let mediaFileSize = 0
|
||||
dataFound.libraryFiles.filter(lf => lf.fileType === 'audio' || lf.fileType === 'video').forEach(lf => mediaFileSize += lf.metadata.size)
|
||||
if (mediaFileSize + newItemDataToScanSize > MaxSizePerChunk && newItemDataToScan.length > 0) {
|
||||
newItemDataToScanChunks.push(newItemDataToScan)
|
||||
newItemDataToScanSize = 0
|
||||
newItemDataToScan = []
|
||||
}
|
||||
|
||||
newItemDataToScan.push(dataFound)
|
||||
newItemDataToScanSize += mediaFileSize
|
||||
|
||||
if (newItemDataToScanSize >= MaxSizePerChunk) {
|
||||
newItemDataToScanChunks.push(newItemDataToScan)
|
||||
newItemDataToScanSize = 0
|
||||
newItemDataToScan = []
|
||||
}
|
||||
}
|
||||
}
|
||||
if (newItemDataToScan.length) newItemDataToScanChunks.push(newItemDataToScan)
|
||||
|
||||
// Library Items not requiring a scan but require a search for cover
|
||||
for (let i = 0; i < itemsToFindCovers.length; i++) {
|
||||
const libraryItem = itemsToFindCovers[i]
|
||||
const updatedCover = await this.searchForCover(libraryItem, libraryScan)
|
||||
libraryItem.media.updateLastCoverSearch(updatedCover)
|
||||
}
|
||||
|
||||
if (itemsToUpdate.length) {
|
||||
await this.updateLibraryItemChunk(itemsToUpdate)
|
||||
if (this.cancelLibraryScan[libraryScan.libraryId]) return true
|
||||
}
|
||||
|
||||
// Chunking will be removed when legacy single threaded scanner is removed
|
||||
for (let i = 0; i < itemDataToRescanChunks.length; i++) {
|
||||
await this.rescanLibraryItemDataChunk(itemDataToRescanChunks[i], libraryScan)
|
||||
if (this.cancelLibraryScan[libraryScan.libraryId]) return true
|
||||
}
|
||||
for (let i = 0; i < newItemDataToScanChunks.length; i++) {
|
||||
await this.scanNewLibraryItemDataChunk(newItemDataToScanChunks[i], libraryScan)
|
||||
if (this.cancelLibraryScan[libraryScan.libraryId]) return true
|
||||
}
|
||||
}
|
||||
|
||||
async updateLibraryItemChunk(itemsToUpdate) {
|
||||
await Database.updateBulkLibraryItems(itemsToUpdate)
|
||||
SocketAuthority.emitter('items_updated', itemsToUpdate.map(li => li.toJSONExpanded()))
|
||||
}
|
||||
|
||||
async rescanLibraryItemDataChunk(itemDataToRescan, libraryScan) {
|
||||
var itemsUpdated = await Promise.all(itemDataToRescan.map((lid) => {
|
||||
return this.rescanLibraryItem(lid, libraryScan)
|
||||
}))
|
||||
|
||||
itemsUpdated = itemsUpdated.filter(li => li) // Filter out nulls
|
||||
|
||||
for (const libraryItem of itemsUpdated) {
|
||||
// Temp authors & series are inserted - create them if found
|
||||
await this.createNewAuthorsAndSeries(libraryItem)
|
||||
}
|
||||
|
||||
if (itemsUpdated.length) {
|
||||
libraryScan.resultsUpdated += itemsUpdated.length
|
||||
await Database.updateBulkLibraryItems(itemsUpdated)
|
||||
SocketAuthority.emitter('items_updated', itemsUpdated.map(li => li.toJSONExpanded()))
|
||||
}
|
||||
}
|
||||
|
||||
async scanNewLibraryItemDataChunk(newLibraryItemsData, libraryScan) {
|
||||
let newLibraryItems = await Promise.all(newLibraryItemsData.map((lid) => {
|
||||
return this.scanNewLibraryItem(lid, libraryScan.library, libraryScan)
|
||||
}))
|
||||
newLibraryItems = newLibraryItems.filter(li => li) // Filter out nulls
|
||||
|
||||
for (const libraryItem of newLibraryItems) {
|
||||
// Temp authors & series are inserted - create them if found
|
||||
await this.createNewAuthorsAndSeries(libraryItem)
|
||||
}
|
||||
|
||||
libraryScan.resultsAdded += newLibraryItems.length
|
||||
await Database.createBulkLibraryItems(newLibraryItems)
|
||||
SocketAuthority.emitter('items_added', newLibraryItems.map(li => li.toJSONExpanded()))
|
||||
}
|
||||
|
||||
async rescanLibraryItem(libraryItemCheckData, libraryScan) {
|
||||
const { newLibraryFiles, filesRemoved, existingLibraryFiles, libraryItem, scanData, updated } = libraryItemCheckData
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Library "${libraryScan.libraryName}" Re-scanning "${libraryItem.path}"`)
|
||||
let hasUpdated = updated
|
||||
|
||||
// Sync other files first to use local images as cover before extracting audio file cover
|
||||
if (await libraryItem.syncFiles(libraryScan.preferOpfMetadata, libraryScan.library.settings)) {
|
||||
hasUpdated = true
|
||||
}
|
||||
|
||||
// forceRescan all existing audio files - will probe and update ID3 tag metadata
|
||||
const existingAudioFiles = existingLibraryFiles.filter(lf => lf.fileType === 'audio')
|
||||
if (libraryScan.scanOptions.forceRescan && existingAudioFiles.length) {
|
||||
if (await MediaFileScanner.scanMediaFiles(existingAudioFiles, libraryItem, libraryScan)) {
|
||||
hasUpdated = true
|
||||
}
|
||||
}
|
||||
// Scan new audio files
|
||||
const newAudioFiles = newLibraryFiles.filter(lf => lf.fileType === 'audio')
|
||||
const removedAudioFiles = filesRemoved.filter(lf => lf.fileType === 'audio')
|
||||
if (newAudioFiles.length || removedAudioFiles.length) {
|
||||
if (await MediaFileScanner.scanMediaFiles(newAudioFiles, libraryItem, libraryScan)) {
|
||||
hasUpdated = true
|
||||
}
|
||||
}
|
||||
// If an audio file has embedded cover art and no cover is set yet, extract & use it
|
||||
if (newAudioFiles.length || libraryScan.scanOptions.forceRescan) {
|
||||
if (libraryItem.media.hasEmbeddedCoverArt && !libraryItem.media.coverPath) {
|
||||
const savedCoverPath = await this.coverManager.saveEmbeddedCoverArt(libraryItem)
|
||||
if (savedCoverPath) {
|
||||
hasUpdated = true
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Saved embedded cover art "${savedCoverPath}"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Library Item is invalid - (a book has no audio files or ebook files)
|
||||
if (!libraryItem.hasMediaEntities && libraryItem.mediaType !== 'podcast') {
|
||||
libraryItem.setInvalid()
|
||||
hasUpdated = true
|
||||
} else if (libraryItem.isInvalid) {
|
||||
libraryItem.isInvalid = false
|
||||
hasUpdated = true
|
||||
}
|
||||
|
||||
// Scan for cover if enabled and has no cover (and author or title has changed OR has been 7 days since last lookup)
|
||||
if (libraryScan.findCovers && !libraryItem.media.coverPath && libraryItem.media.shouldSearchForCover) {
|
||||
const updatedCover = await this.searchForCover(libraryItem, libraryScan)
|
||||
libraryItem.media.updateLastCoverSearch(updatedCover)
|
||||
hasUpdated = true
|
||||
}
|
||||
|
||||
return hasUpdated ? libraryItem : null
|
||||
}
|
||||
|
||||
async scanNewLibraryItem(libraryItemData, library, libraryScan = null) {
|
||||
if (libraryScan) libraryScan.addLog(LogLevel.DEBUG, `Scanning new library item "${libraryItemData.path}"`)
|
||||
else Logger.debug(`[Scanner] Scanning new item "${libraryItemData.path}"`)
|
||||
|
||||
const preferOpfMetadata = libraryScan ? !!libraryScan.preferOpfMetadata : !!global.ServerSettings.scannerPreferOpfMetadata
|
||||
const findCovers = libraryScan ? !!libraryScan.findCovers : !!global.ServerSettings.scannerFindCovers
|
||||
|
||||
const libraryItem = new LibraryItem()
|
||||
libraryItem.setData(library.mediaType, libraryItemData)
|
||||
libraryItem.setLastScan()
|
||||
|
||||
const mediaFiles = libraryItemData.libraryFiles.filter(lf => lf.fileType === 'audio' || lf.fileType === 'video')
|
||||
if (mediaFiles.length) {
|
||||
await MediaFileScanner.scanMediaFiles(mediaFiles, libraryItem, libraryScan)
|
||||
}
|
||||
|
||||
await libraryItem.syncFiles(preferOpfMetadata, library.settings)
|
||||
|
||||
if (!libraryItem.hasMediaEntities) {
|
||||
Logger.warn(`[Scanner] Library item has no media files "${libraryItemData.path}"`)
|
||||
return null
|
||||
}
|
||||
|
||||
// Extract embedded cover art if cover is not already in directory
|
||||
if (libraryItem.media.hasEmbeddedCoverArt && !libraryItem.media.coverPath) {
|
||||
const coverPath = await this.coverManager.saveEmbeddedCoverArt(libraryItem)
|
||||
if (coverPath) {
|
||||
if (libraryScan) libraryScan.addLog(LogLevel.DEBUG, `Saved embedded cover art "${coverPath}"`)
|
||||
else Logger.debug(`[Scanner] Saved embedded cover art "${coverPath}"`)
|
||||
}
|
||||
}
|
||||
|
||||
// Scan for cover if enabled and has no cover
|
||||
if (library.isBook) {
|
||||
if (libraryItem && findCovers && !libraryItem.media.coverPath && libraryItem.media.shouldSearchForCover) {
|
||||
const updatedCover = await this.searchForCover(libraryItem, libraryScan)
|
||||
libraryItem.media.updateLastCoverSearch(updatedCover)
|
||||
}
|
||||
}
|
||||
|
||||
return libraryItem
|
||||
}
|
||||
|
||||
// Any series or author object on library item with an id starting with "new"
|
||||
// will create a new author/series OR find a matching author/series
|
||||
async createNewAuthorsAndSeries(libraryItem) {
|
||||
if (libraryItem.mediaType !== 'book') return
|
||||
|
||||
// Create or match all new authors and series
|
||||
if (libraryItem.media.metadata.authors.some(au => au.id.startsWith('new'))) {
|
||||
const newAuthors = []
|
||||
libraryItem.media.metadata.authors = libraryItem.media.metadata.authors.map((tempMinAuthor) => {
|
||||
let _author = Database.authors.find(au => au.libraryId === libraryItem.libraryId && au.checkNameEquals(tempMinAuthor.name))
|
||||
if (!_author) _author = newAuthors.find(au => au.libraryId === libraryItem.libraryId && au.checkNameEquals(tempMinAuthor.name)) // Check new unsaved authors
|
||||
if (!_author) { // Must create new author
|
||||
_author = new Author()
|
||||
_author.setData(tempMinAuthor, libraryItem.libraryId)
|
||||
newAuthors.push(_author)
|
||||
}
|
||||
|
||||
return {
|
||||
id: _author.id,
|
||||
name: _author.name
|
||||
}
|
||||
})
|
||||
if (newAuthors.length) {
|
||||
await Database.createBulkAuthors(newAuthors)
|
||||
SocketAuthority.emitter('authors_added', newAuthors.map(au => au.toJSON()))
|
||||
}
|
||||
}
|
||||
if (libraryItem.media.metadata.series.some(se => se.id.startsWith('new'))) {
|
||||
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) { // Must create new series
|
||||
_series = new Series()
|
||||
_series.setData(tempMinSeries, libraryItem.libraryId)
|
||||
newSeries.push(_series)
|
||||
}
|
||||
return {
|
||||
id: _series.id,
|
||||
name: _series.name,
|
||||
sequence: tempMinSeries.sequence
|
||||
}
|
||||
})
|
||||
if (newSeries.length) {
|
||||
await Database.createBulkSeries(newSeries)
|
||||
SocketAuthority.emitter('multiple_series_added', newSeries.map(se => se.toJSON()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getFileUpdatesGrouped(fileUpdates) {
|
||||
var folderGroups = {}
|
||||
fileUpdates.forEach((file) => {
|
||||
if (folderGroups[file.folderId]) {
|
||||
folderGroups[file.folderId].fileUpdates.push(file)
|
||||
} else {
|
||||
folderGroups[file.folderId] = {
|
||||
libraryId: file.libraryId,
|
||||
folderId: file.folderId,
|
||||
fileUpdates: [file]
|
||||
}
|
||||
}
|
||||
})
|
||||
return folderGroups
|
||||
}
|
||||
|
||||
async scanFilesChanged(fileUpdates) {
|
||||
if (!fileUpdates?.length) return
|
||||
|
||||
// If already scanning files from watcher then add these updates to queue
|
||||
if (this.scanningFilesChanged) {
|
||||
this.pendingFileUpdatesToScan.push(fileUpdates)
|
||||
Logger.debug(`[Scanner] Already scanning files from watcher - file updates pushed to queue (size ${this.pendingFileUpdatesToScan.length})`)
|
||||
return
|
||||
}
|
||||
this.scanningFilesChanged = true
|
||||
|
||||
// files grouped by folder
|
||||
const folderGroups = this.getFileUpdatesGrouped(fileUpdates)
|
||||
|
||||
for (const folderId in folderGroups) {
|
||||
const libraryId = folderGroups[folderId].libraryId
|
||||
const library = await Database.models.library.getOldById(libraryId)
|
||||
if (!library) {
|
||||
Logger.error(`[Scanner] Library not found in files changed ${libraryId}`)
|
||||
continue;
|
||||
}
|
||||
const folder = library.getFolderById(folderId)
|
||||
if (!folder) {
|
||||
Logger.error(`[Scanner] Folder is not in library in files changed "${folderId}", Library "${library.name}"`)
|
||||
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;
|
||||
}
|
||||
const folderScanResults = await this.scanFolderUpdates(library, folder, fileUpdateGroup)
|
||||
Logger.debug(`[Scanner] Folder scan results`, folderScanResults)
|
||||
}
|
||||
|
||||
this.scanningFilesChanged = false
|
||||
|
||||
if (this.pendingFileUpdatesToScan.length) {
|
||||
Logger.debug(`[Scanner] File updates finished scanning with more updates in queue (${this.pendingFileUpdatesToScan.length})`)
|
||||
this.scanFilesChanged(this.pendingFileUpdatesToScan.shift())
|
||||
}
|
||||
}
|
||||
|
||||
async scanFolderUpdates(library, folder, fileUpdateGroup) {
|
||||
Logger.debug(`[Scanner] Scanning file update groups in folder "${folder.id}" of library "${library.name}"`)
|
||||
Logger.debug(`[Scanner] scanFolderUpdates fileUpdateGroup`, fileUpdateGroup)
|
||||
|
||||
// First pass - Remove files in parent dirs of items and remap the fileupdate group
|
||||
// Test Case: Moving audio files from library item folder to author folder should trigger a re-scan of the item
|
||||
const updateGroup = { ...fileUpdateGroup }
|
||||
for (const itemDir in updateGroup) {
|
||||
if (itemDir == fileUpdateGroup[itemDir]) continue; // Media in root path
|
||||
|
||||
const itemDirNestedFiles = fileUpdateGroup[itemDir].filter(b => b.includes('/'))
|
||||
if (!itemDirNestedFiles.length) continue;
|
||||
|
||||
const firstNest = itemDirNestedFiles[0].split('/').shift()
|
||||
const altDir = `${itemDir}/${firstNest}`
|
||||
|
||||
const fullPath = Path.posix.join(filePathToPOSIX(folder.fullPath), itemDir)
|
||||
const childLibraryItem = Database.libraryItems.find(li => li.path !== fullPath && li.path.startsWith(fullPath))
|
||||
if (!childLibraryItem) {
|
||||
continue
|
||||
}
|
||||
const altFullPath = Path.posix.join(filePathToPOSIX(folder.fullPath), altDir)
|
||||
const altChildLibraryItem = Database.libraryItems.find(li => li.path !== altFullPath && li.path.startsWith(altFullPath))
|
||||
if (altChildLibraryItem) {
|
||||
continue
|
||||
}
|
||||
|
||||
delete fileUpdateGroup[itemDir]
|
||||
fileUpdateGroup[altDir] = itemDirNestedFiles.map((f) => f.split('/').slice(1).join('/'))
|
||||
Logger.warn(`[Scanner] Some files were modified in a parent directory of a library item "${childLibraryItem.title}" - ignoring`)
|
||||
}
|
||||
|
||||
// Second pass: Check for new/updated/removed items
|
||||
const itemGroupingResults = {}
|
||||
for (const itemDir in fileUpdateGroup) {
|
||||
const fullPath = Path.posix.join(filePathToPOSIX(folder.fullPath), itemDir)
|
||||
const dirIno = await getIno(fullPath)
|
||||
|
||||
// Check if book dir group is already an item
|
||||
let existingLibraryItem = Database.libraryItems.find(li => fullPath.startsWith(li.path))
|
||||
if (!existingLibraryItem) {
|
||||
existingLibraryItem = Database.libraryItems.find(li => li.ino === dirIno)
|
||||
if (existingLibraryItem) {
|
||||
Logger.debug(`[Scanner] scanFolderUpdates: Library item found by inode value=${dirIno}. "${existingLibraryItem.relPath} => ${itemDir}"`)
|
||||
// Update library item paths for scan and all library item paths will get updated in LibraryItem.checkScanData
|
||||
existingLibraryItem.path = fullPath
|
||||
existingLibraryItem.relPath = itemDir
|
||||
}
|
||||
}
|
||||
if (existingLibraryItem) {
|
||||
// Is the item exactly - check if was deleted
|
||||
if (existingLibraryItem.path === fullPath) {
|
||||
const exists = await fs.pathExists(fullPath)
|
||||
if (!exists) {
|
||||
Logger.info(`[Scanner] Scanning file update group and library item was deleted "${existingLibraryItem.media.metadata.title}" - marking as missing`)
|
||||
existingLibraryItem.setMissing()
|
||||
await Database.updateLibraryItem(existingLibraryItem)
|
||||
SocketAuthority.emitter('item_updated', existingLibraryItem.toJSONExpanded())
|
||||
|
||||
itemGroupingResults[itemDir] = ScanResult.REMOVED
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Scan library item for updates
|
||||
Logger.debug(`[Scanner] Folder update for relative path "${itemDir}" is in library item "${existingLibraryItem.media.metadata.title}" - scan for updates`)
|
||||
itemGroupingResults[itemDir] = await this.scanLibraryItem(library, folder, existingLibraryItem)
|
||||
continue
|
||||
} else if (library.settings.audiobooksOnly && !fileUpdateGroup[itemDir].some(checkFilepathIsAudioFile)) {
|
||||
Logger.debug(`[Scanner] Folder update for relative path "${itemDir}" has no audio files`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if a library item is a subdirectory of this dir
|
||||
var childItem = Database.libraryItems.find(li => (li.path + '/').startsWith(fullPath + '/'))
|
||||
if (childItem) {
|
||||
Logger.warn(`[Scanner] Files were modified in a parent directory of a library item "${childItem.media.metadata.title}" - ignoring`)
|
||||
itemGroupingResults[itemDir] = ScanResult.NOTHING
|
||||
continue
|
||||
}
|
||||
|
||||
Logger.debug(`[Scanner] Folder update group must be a new item "${itemDir}" in library "${library.name}"`)
|
||||
var isSingleMediaItem = itemDir === fileUpdateGroup[itemDir]
|
||||
var newLibraryItem = await this.scanPotentialNewLibraryItem(library, folder, fullPath, isSingleMediaItem)
|
||||
if (newLibraryItem) {
|
||||
await this.createNewAuthorsAndSeries(newLibraryItem)
|
||||
await Database.createLibraryItem(newLibraryItem)
|
||||
SocketAuthority.emitter('item_added', newLibraryItem.toJSONExpanded())
|
||||
}
|
||||
itemGroupingResults[itemDir] = newLibraryItem ? ScanResult.ADDED : ScanResult.NOTHING
|
||||
}
|
||||
|
||||
return itemGroupingResults
|
||||
}
|
||||
|
||||
async scanPotentialNewLibraryItem(library, folder, fullPath, isSingleMediaItem = false) {
|
||||
const libraryItemData = await getLibraryItemFileData(library.mediaType, folder, fullPath, isSingleMediaItem)
|
||||
if (!libraryItemData) return null
|
||||
return this.scanNewLibraryItem(libraryItemData, library)
|
||||
}
|
||||
|
||||
async searchForCover(libraryItem, libraryScan = null) {
|
||||
const options = {
|
||||
titleDistance: 2,
|
||||
authorDistance: 2
|
||||
}
|
||||
const scannerCoverProvider = Database.serverSettings.scannerCoverProvider
|
||||
const results = await this.bookFinder.findCovers(scannerCoverProvider, libraryItem.media.metadata.title, libraryItem.media.metadata.authorName, options)
|
||||
if (results.length) {
|
||||
if (libraryScan) libraryScan.addLog(LogLevel.DEBUG, `Found best cover for "${libraryItem.media.metadata.title}"`)
|
||||
else Logger.debug(`[Scanner] Found best cover for "${libraryItem.media.metadata.title}"`)
|
||||
|
||||
// If the first cover result fails, attempt to download the second
|
||||
for (let i = 0; i < results.length && i < 2; i++) {
|
||||
|
||||
// Downloads and updates the book cover
|
||||
const result = await this.coverManager.downloadCoverFromUrl(libraryItem, results[i])
|
||||
|
||||
if (result.error) {
|
||||
Logger.error(`[Scanner] Failed to download cover from url "${results[i]}" | Attempt ${i + 1}`, result.error)
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
constructor() { }
|
||||
|
||||
async quickMatchLibraryItem(libraryItem, options = {}) {
|
||||
var provider = options.provider || 'google'
|
||||
|
|
@ -729,7 +36,7 @@ class Scanner {
|
|||
var searchISBN = options.isbn || libraryItem.media.metadata.isbn
|
||||
var searchASIN = options.asin || libraryItem.media.metadata.asin
|
||||
|
||||
var results = await this.bookFinder.search(provider, searchTitle, searchAuthor, searchISBN, searchASIN)
|
||||
var results = await BookFinder.search(provider, searchTitle, searchAuthor, searchISBN, searchASIN)
|
||||
if (!results.length) {
|
||||
return {
|
||||
warning: `No ${provider} match found`
|
||||
|
|
@ -740,7 +47,7 @@ class Scanner {
|
|||
// Update cover if not set OR overrideCover flag
|
||||
if (matchData.cover && (!libraryItem.media.coverPath || options.overrideCover)) {
|
||||
Logger.debug(`[Scanner] Updating cover "${matchData.cover}"`)
|
||||
var coverResult = await this.coverManager.downloadCoverFromUrl(libraryItem, matchData.cover)
|
||||
var coverResult = await CoverManager.downloadCoverFromUrl(libraryItem, matchData.cover)
|
||||
if (!coverResult || coverResult.error || !coverResult.cover) {
|
||||
Logger.warn(`[Scanner] Match cover "${matchData.cover}" failed to use: ${coverResult ? coverResult.error : 'Unknown Error'}`)
|
||||
} else {
|
||||
|
|
@ -750,7 +57,7 @@ class Scanner {
|
|||
|
||||
updatePayload = await this.quickMatchBookBuildUpdatePayload(libraryItem, matchData, options)
|
||||
} else if (libraryItem.isPodcast) { // Podcast quick match
|
||||
var results = await this.podcastFinder.search(searchTitle)
|
||||
var results = await PodcastFinder.search(searchTitle)
|
||||
if (!results.length) {
|
||||
return {
|
||||
warning: `No ${provider} match found`
|
||||
|
|
@ -761,7 +68,7 @@ class Scanner {
|
|||
// Update cover if not set OR overrideCover flag
|
||||
if (matchData.cover && (!libraryItem.media.coverPath || options.overrideCover)) {
|
||||
Logger.debug(`[Scanner] Updating cover "${matchData.cover}"`)
|
||||
var coverResult = await this.coverManager.downloadCoverFromUrl(libraryItem, matchData.cover)
|
||||
var coverResult = await CoverManager.downloadCoverFromUrl(libraryItem, matchData.cover)
|
||||
if (!coverResult || coverResult.error || !coverResult.cover) {
|
||||
Logger.warn(`[Scanner] Match cover "${matchData.cover}" failed to use: ${coverResult ? coverResult.error : 'Unknown Error'}`)
|
||||
} else {
|
||||
|
|
@ -878,12 +185,14 @@ class Scanner {
|
|||
}
|
||||
const authorPayload = []
|
||||
for (const authorName of matchData.author) {
|
||||
let author = Database.authors.find(au => au.libraryId === libraryItem.libraryId && au.checkNameEquals(authorName))
|
||||
let author = await Database.authorModel.getOldByNameAndLibrary(authorName, libraryItem.libraryId)
|
||||
if (!author) {
|
||||
author = new Author()
|
||||
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())
|
||||
}
|
||||
|
|
@ -895,11 +204,13 @@ class Scanner {
|
|||
if (!Array.isArray(matchData.series)) matchData.series = [{ series: matchData.series, sequence: matchData.sequence }]
|
||||
const seriesPayload = []
|
||||
for (const seriesMatchItem of matchData.series) {
|
||||
let seriesItem = Database.series.find(se => se.libraryId === libraryItem.libraryId && se.checkNameEquals(seriesMatchItem.series))
|
||||
let seriesItem = await Database.seriesModel.getOldByNameAndLibrary(seriesMatchItem.series, libraryItem.libraryId)
|
||||
if (!seriesItem) {
|
||||
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))
|
||||
|
|
@ -969,72 +280,81 @@ class Scanner {
|
|||
return false
|
||||
}
|
||||
|
||||
async matchLibraryItems(library) {
|
||||
if (library.mediaType === 'podcast') {
|
||||
Logger.error(`[Scanner] matchLibraryItems: Match all not supported for podcasts yet`)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.isLibraryScanning(library.id)) {
|
||||
Logger.error(`[Scanner] matchLibraryItems: Already scanning ${library.id}`)
|
||||
return
|
||||
}
|
||||
|
||||
const itemsInLibrary = Database.libraryItems.filter(li => li.libraryId === library.id)
|
||||
if (!itemsInLibrary.length) {
|
||||
Logger.error(`[Scanner] matchLibraryItems: Library has no items ${library.id}`)
|
||||
return
|
||||
}
|
||||
|
||||
const provider = library.provider
|
||||
|
||||
var libraryScan = new LibraryScan()
|
||||
libraryScan.setData(library, null, 'match')
|
||||
this.librariesScanning.push(libraryScan.getScanEmitData)
|
||||
SocketAuthority.emitter('scan_start', libraryScan.getScanEmitData)
|
||||
|
||||
Logger.info(`[Scanner] matchLibraryItems: Starting library match scan ${libraryScan.id} for ${libraryScan.libraryName}`)
|
||||
|
||||
for (let i = 0; i < itemsInLibrary.length; i++) {
|
||||
var libraryItem = itemsInLibrary[i]
|
||||
async matchLibraryItemsChunk(library, libraryItems, libraryScan) {
|
||||
for (let i = 0; i < libraryItems.length; i++) {
|
||||
const libraryItem = libraryItems[i]
|
||||
|
||||
if (libraryItem.media.metadata.asin && library.settings.skipMatchingMediaWithAsin) {
|
||||
Logger.debug(`[Scanner] matchLibraryItems: Skipping "${libraryItem.media.metadata.title
|
||||
}" because it already has an ASIN (${i + 1} of ${itemsInLibrary.length})`)
|
||||
continue;
|
||||
}" because it already has an ASIN (${i + 1} of ${libraryItems.length})`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (libraryItem.media.metadata.isbn && library.settings.skipMatchingMediaWithIsbn) {
|
||||
Logger.debug(`[Scanner] matchLibraryItems: Skipping "${libraryItem.media.metadata.title
|
||||
}" because it already has an ISBN (${i + 1} of ${itemsInLibrary.length})`)
|
||||
continue;
|
||||
}" because it already has an ISBN (${i + 1} of ${libraryItems.length})`)
|
||||
continue
|
||||
}
|
||||
|
||||
Logger.debug(`[Scanner] matchLibraryItems: Quick matching "${libraryItem.media.metadata.title}" (${i + 1} of ${itemsInLibrary.length})`)
|
||||
var result = await this.quickMatchLibraryItem(libraryItem, { provider })
|
||||
Logger.debug(`[Scanner] matchLibraryItems: Quick matching "${libraryItem.media.metadata.title}" (${i + 1} of ${libraryItems.length})`)
|
||||
const result = await this.quickMatchLibraryItem(libraryItem, { provider: library.provider })
|
||||
if (result.warning) {
|
||||
Logger.warn(`[Scanner] matchLibraryItems: Match warning ${result.warning} for library item "${libraryItem.media.metadata.title}"`)
|
||||
} else if (result.updated) {
|
||||
libraryScan.resultsUpdated++
|
||||
}
|
||||
|
||||
if (this.cancelLibraryScan[libraryScan.libraryId]) {
|
||||
if (LibraryScanner.cancelLibraryScan[libraryScan.libraryId]) {
|
||||
Logger.info(`[Scanner] matchLibraryItems: Library match scan canceled for "${libraryScan.libraryName}"`)
|
||||
delete this.cancelLibraryScan[libraryScan.libraryId]
|
||||
var scanData = libraryScan.getScanEmitData
|
||||
scanData.results = null
|
||||
SocketAuthority.emitter('scan_complete', scanData)
|
||||
this.librariesScanning = this.librariesScanning.filter(ls => ls.id !== library.id)
|
||||
return
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
this.librariesScanning = this.librariesScanning.filter(ls => ls.id !== library.id)
|
||||
SocketAuthority.emitter('scan_complete', libraryScan.getScanEmitData)
|
||||
return true
|
||||
}
|
||||
|
||||
probeAudioFile(audioFile) {
|
||||
return MediaFileScanner.probeAudioFile(audioFile)
|
||||
async matchLibraryItems(library) {
|
||||
if (library.mediaType === 'podcast') {
|
||||
Logger.error(`[Scanner] matchLibraryItems: Match all not supported for podcasts yet`)
|
||||
return
|
||||
}
|
||||
|
||||
if (LibraryScanner.isLibraryScanning(library.id)) {
|
||||
Logger.error(`[Scanner] Library "${library.name}" is already scanning`)
|
||||
return
|
||||
}
|
||||
|
||||
const limit = 100
|
||||
let offset = 0
|
||||
|
||||
const libraryScan = new LibraryScan()
|
||||
libraryScan.setData(library, null, 'match')
|
||||
LibraryScanner.librariesScanning.push(libraryScan.getScanEmitData)
|
||||
SocketAuthority.emitter('scan_start', libraryScan.getScanEmitData)
|
||||
|
||||
Logger.info(`[Scanner] matchLibraryItems: Starting library match scan ${libraryScan.id} for ${libraryScan.libraryName}`)
|
||||
|
||||
let hasMoreChunks = true
|
||||
while (hasMoreChunks) {
|
||||
const libraryItems = await Database.libraryItemModel.getLibraryItemsIncrement(offset, limit, { libraryId: library.id })
|
||||
if (!libraryItems.length) {
|
||||
Logger.error(`[Scanner] matchLibraryItems: Library has no items ${library.id}`)
|
||||
SocketAuthority.emitter('scan_complete', libraryScan.getScanEmitData)
|
||||
return
|
||||
}
|
||||
offset += limit
|
||||
hasMoreChunks = libraryItems.length < limit
|
||||
let oldLibraryItems = libraryItems.map(li => Database.libraryItemModel.getOldLibraryItem(li))
|
||||
|
||||
const shouldContinue = await this.matchLibraryItemsChunk(library, oldLibraryItems, libraryScan)
|
||||
if (!shouldContinue) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
delete LibraryScanner.cancelLibraryScan[libraryScan.libraryId]
|
||||
LibraryScanner.librariesScanning = LibraryScanner.librariesScanning.filter(ls => ls.id !== library.id)
|
||||
SocketAuthority.emitter('scan_complete', libraryScan.getScanEmitData)
|
||||
}
|
||||
}
|
||||
module.exports = Scanner
|
||||
module.exports = new Scanner()
|
||||
|
|
|
|||
|
|
@ -1,120 +0,0 @@
|
|||
const fs = require('../libs/fsExtra')
|
||||
const Path = require('path')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
// Modified from:
|
||||
// https://github.com/isaacs/chmodr/blob/master/chmodr.js
|
||||
|
||||
// If a party has r, add x
|
||||
// so that dirs are listable
|
||||
const dirMode = mode => {
|
||||
if (mode & 0o400)
|
||||
mode |= 0o100
|
||||
if (mode & 0o40)
|
||||
mode |= 0o10
|
||||
if (mode & 0o4)
|
||||
mode |= 0o1
|
||||
return mode
|
||||
}
|
||||
|
||||
const chmodrKid = (p, child, mode, uid, gid, cb) => {
|
||||
if (typeof child === 'string')
|
||||
return fs.lstat(Path.resolve(p, child), (er, stats) => {
|
||||
if (er)
|
||||
return cb(er)
|
||||
stats.name = child
|
||||
chmodrKid(p, stats, mode, uid, gid, cb)
|
||||
})
|
||||
|
||||
if (child.isDirectory()) {
|
||||
chmodr(Path.resolve(p, child.name), mode, uid, gid, er => {
|
||||
if (er)
|
||||
return cb(er)
|
||||
|
||||
var _path = Path.resolve(p, child.name)
|
||||
fs.chmod(_path, dirMode(mode)).then(() => {
|
||||
fs.chown(_path, uid, gid, cb)
|
||||
})
|
||||
})
|
||||
} else {
|
||||
var _path = Path.resolve(p, child.name)
|
||||
fs.chmod(_path, mode).then(() => {
|
||||
fs.chown(_path, uid, gid, cb)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const chmodr = (p, mode, uid, gid, cb) => {
|
||||
fs.readdir(p, { withFileTypes: true }, (er, children) => {
|
||||
// any error other than ENOTDIR means it's not readable, or
|
||||
// doesn't exist. give up.
|
||||
if (er && er.code !== 'ENOTDIR') return cb(er)
|
||||
if (er) { // Is a file
|
||||
return fs.chmod(p, mode).then(() => {
|
||||
fs.chown(p, uid, gid, cb)
|
||||
})
|
||||
}
|
||||
if (!children.length) {
|
||||
return fs.chmod(p, dirMode(mode)).then(() => {
|
||||
fs.chown(p, uid, gid, cb)
|
||||
})
|
||||
}
|
||||
|
||||
let len = children.length
|
||||
let errState = null
|
||||
const then = er => {
|
||||
if (errState) return
|
||||
if (er) return cb(errState = er)
|
||||
if (--len === 0) {
|
||||
return fs.chmod(p, dirMode(mode)).then(() => {
|
||||
fs.chown(p, uid, gid, cb)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
children.forEach(child => chmodrKid(p, child, mode, uid, gid, then))
|
||||
})
|
||||
}
|
||||
|
||||
// Set custom permissions
|
||||
module.exports.set = (path, mode, uid, gid, silent = false) => {
|
||||
return new Promise((resolve) => {
|
||||
if (!silent) Logger.debug(`[FilePerms] Setting permission "${mode}" for uid ${uid} and gid ${gid} | "${path}"`)
|
||||
chmodr(path, mode, uid, gid, resolve)
|
||||
})
|
||||
}
|
||||
|
||||
// Default permissions 0o744 and global Uid/Gid
|
||||
module.exports.setDefault = (path, silent = false) => {
|
||||
const mode = 0o744
|
||||
const uid = global.Uid
|
||||
const gid = global.Gid
|
||||
return new Promise((resolve) => {
|
||||
if (isNaN(uid) || isNaN(gid)) {
|
||||
return resolve()
|
||||
}
|
||||
if (!silent) Logger.debug(`Setting permission "${mode}" for uid ${uid} and gid ${gid} | "${path}"`)
|
||||
chmodr(path, mode, uid, gid, resolve)
|
||||
})
|
||||
}
|
||||
|
||||
// Default permissions 0o744 and global Uid/Gid
|
||||
// Used for setting default permission to initial config/metadata directories
|
||||
module.exports.setDefaultDirSync = (path, silent = false) => {
|
||||
const mode = 0o744
|
||||
const uid = global.Uid
|
||||
const gid = global.Gid
|
||||
if (isNaN(uid) || isNaN(gid)) {
|
||||
return true
|
||||
}
|
||||
if (!silent) Logger.debug(`[FilePerms] Setting dir permission "${mode}" for uid ${uid} and gid ${gid} | "${path}"`)
|
||||
try {
|
||||
fs.chmodSync(path, mode)
|
||||
fs.chownSync(path, uid, gid)
|
||||
return true
|
||||
} catch (error) {
|
||||
Logger.error(`[FilePerms] Error setting dir permissions for path "${path}"`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -59,6 +59,20 @@ async function getFileSize(path) {
|
|||
}
|
||||
module.exports.getFileSize = getFileSize
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} filepath
|
||||
* @returns {boolean}
|
||||
*/
|
||||
async function checkPathIsFile(filepath) {
|
||||
try {
|
||||
const stat = await fs.stat(filepath)
|
||||
return stat.isFile()
|
||||
} catch (err) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
module.exports.checkPathIsFile = checkPathIsFile
|
||||
|
||||
function getIno(path) {
|
||||
return fs.stat(path, { bigint: true }).then((data => String(data.ino))).catch((err) => {
|
||||
|
|
@ -68,6 +82,11 @@ function getIno(path) {
|
|||
}
|
||||
module.exports.getIno = getIno
|
||||
|
||||
/**
|
||||
* Read contents of file
|
||||
* @param {string} path
|
||||
* @returns {string}
|
||||
*/
|
||||
async function readTextFile(path) {
|
||||
try {
|
||||
var data = await fs.readFile(path)
|
||||
|
|
@ -92,6 +111,12 @@ function bytesPretty(bytes, decimals = 0) {
|
|||
}
|
||||
module.exports.bytesPretty = bytesPretty
|
||||
|
||||
/**
|
||||
* Get array of files inside dir
|
||||
* @param {string} path
|
||||
* @param {string} [relPathToReplace]
|
||||
* @returns {{name:string, path:string, dirpath:string, reldirpath:string, fullpath:string, extension:string, deep:number}[]}
|
||||
*/
|
||||
async function recurseFiles(path, relPathToReplace = null) {
|
||||
path = filePathToPOSIX(path)
|
||||
if (!path.endsWith('/')) path = path + '/'
|
||||
|
|
@ -113,7 +138,7 @@ async function recurseFiles(path, relPathToReplace = null) {
|
|||
realPath: true,
|
||||
normalizePath: true
|
||||
}
|
||||
var list = await rra.list(path, options)
|
||||
let list = await rra.list(path, options)
|
||||
if (list.error) {
|
||||
Logger.error('[fileUtils] Recurse files error', list.error)
|
||||
return []
|
||||
|
|
@ -127,10 +152,10 @@ async function recurseFiles(path, relPathToReplace = null) {
|
|||
return false
|
||||
}
|
||||
|
||||
var relpath = item.fullname.replace(relPathToReplace, '')
|
||||
var reldirname = Path.dirname(relpath)
|
||||
const relpath = item.fullname.replace(relPathToReplace, '')
|
||||
let reldirname = Path.dirname(relpath)
|
||||
if (reldirname === '.') reldirname = ''
|
||||
var dirname = Path.dirname(item.fullname)
|
||||
const dirname = Path.dirname(item.fullname)
|
||||
|
||||
// Directory has a file named ".ignore" flag directory and ignore
|
||||
if (item.name === '.ignore' && reldirname && reldirname !== '.' && !directoriesToIgnore.includes(dirname)) {
|
||||
|
|
@ -139,9 +164,13 @@ async function recurseFiles(path, relPathToReplace = null) {
|
|||
return false
|
||||
}
|
||||
|
||||
if (item.extension === '.part') {
|
||||
Logger.debug(`[fileUtils] Ignoring .part file "${relpath}"`)
|
||||
return false
|
||||
}
|
||||
|
||||
// Ignore any file if a directory or the filename starts with "."
|
||||
var pathStartsWithPeriod = relpath.split('/').find(p => p.startsWith('.'))
|
||||
if (pathStartsWithPeriod) {
|
||||
if (relpath.split('/').find(p => p.startsWith('.'))) {
|
||||
Logger.debug(`[fileUtils] Ignoring path has . "${relpath}"`)
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
const fs = require('../../libs/fsExtra')
|
||||
const filePerms = require('../filePerms')
|
||||
const package = require('../../../package.json')
|
||||
const Logger = require('../../Logger')
|
||||
const { getId } = require('../index')
|
||||
|
|
@ -24,7 +23,7 @@ const CurrentAbMetadataVersion = 2
|
|||
|
||||
const commaSeparatedToArray = (v) => {
|
||||
if (!v) return []
|
||||
return v.split(',').map(_v => _v.trim()).filter(_v => _v)
|
||||
return [...new Set(v.split(',').map(_v => _v.trim()).filter(_v => _v))]
|
||||
}
|
||||
|
||||
const podcastMetadataMapper = {
|
||||
|
|
@ -41,7 +40,7 @@ const podcastMetadataMapper = {
|
|||
from: (v) => v || null
|
||||
},
|
||||
genres: {
|
||||
to: (m) => m.genres.join(', '),
|
||||
to: (m) => m.genres?.join(', ') || '',
|
||||
from: (v) => commaSeparatedToArray(v)
|
||||
},
|
||||
feedUrl: {
|
||||
|
|
@ -68,11 +67,15 @@ const bookMetadataMapper = {
|
|||
from: (v) => v || null
|
||||
},
|
||||
authors: {
|
||||
to: (m) => m.authorName || '',
|
||||
to: (m) => {
|
||||
if (m.authorName !== undefined) return m.authorName
|
||||
if (!m.authors?.length) return ''
|
||||
return m.authors.map(au => au.name).join(', ')
|
||||
},
|
||||
from: (v) => commaSeparatedToArray(v)
|
||||
},
|
||||
narrators: {
|
||||
to: (m) => m.narratorName || '',
|
||||
to: (m) => m.narrators?.join(', ') || '',
|
||||
from: (v) => commaSeparatedToArray(v)
|
||||
},
|
||||
publishedYear: {
|
||||
|
|
@ -96,11 +99,19 @@ const bookMetadataMapper = {
|
|||
from: (v) => v || null
|
||||
},
|
||||
genres: {
|
||||
to: (m) => m.genres.join(', '),
|
||||
to: (m) => m.genres?.join(', ') || '',
|
||||
from: (v) => commaSeparatedToArray(v)
|
||||
},
|
||||
series: {
|
||||
to: (m) => m.seriesName,
|
||||
to: (m) => {
|
||||
if (m.seriesName !== undefined) return m.seriesName
|
||||
if (!m.series?.length) return ''
|
||||
return m.series.map((se) => {
|
||||
const sequence = se.bookSeries?.sequence || ''
|
||||
if (!sequence) return se.name
|
||||
return `${se.name} #${sequence}`
|
||||
}).join(', ')
|
||||
},
|
||||
from: (v) => {
|
||||
return commaSeparatedToArray(v).map(series => { // Return array of { name, sequence }
|
||||
let sequence = null
|
||||
|
|
@ -165,15 +176,50 @@ function generate(libraryItem, outputPath) {
|
|||
fileString += `title=${chapter.title}\n`
|
||||
})
|
||||
}
|
||||
return fs.writeFile(outputPath, fileString).then(() => {
|
||||
return filePerms.setDefault(outputPath, true).then(() => true)
|
||||
}).catch((error) => {
|
||||
return fs.writeFile(outputPath, fileString).then(() => true).catch((error) => {
|
||||
Logger.error(`[absMetaFileGenerator] Failed to save abs file`, error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
module.exports.generate = generate
|
||||
|
||||
function generateFromNewModel(libraryItem, outputPath) {
|
||||
let fileString = `;ABMETADATA${CurrentAbMetadataVersion}\n`
|
||||
fileString += `#audiobookshelf v${package.version}\n\n`
|
||||
|
||||
const mediaType = libraryItem.mediaType
|
||||
|
||||
fileString += `media=${mediaType}\n`
|
||||
fileString += `tags=${JSON.stringify(libraryItem.media.tags || '')}\n`
|
||||
|
||||
const metadataMapper = metadataMappers[mediaType]
|
||||
for (const key in metadataMapper) {
|
||||
fileString += `${key}=${metadataMapper[key].to(libraryItem.media)}\n`
|
||||
}
|
||||
|
||||
// Description block
|
||||
if (libraryItem.media.description) {
|
||||
fileString += '\n[DESCRIPTION]\n'
|
||||
fileString += libraryItem.media.description + '\n'
|
||||
}
|
||||
|
||||
// Book chapters
|
||||
if (mediaType == 'book' && libraryItem.media.chapters?.length) {
|
||||
fileString += '\n'
|
||||
libraryItem.media.chapters.forEach((chapter) => {
|
||||
fileString += `[CHAPTER]\n`
|
||||
fileString += `start=${chapter.start}\n`
|
||||
fileString += `end=${chapter.end}\n`
|
||||
fileString += `title=${chapter.title}\n`
|
||||
})
|
||||
}
|
||||
return fs.writeFile(outputPath, fileString).then(() => true).catch((error) => {
|
||||
Logger.error(`[absMetaFileGenerator] Failed to save abs file`, error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
module.exports.generateFromNewModel = generateFromNewModel
|
||||
|
||||
function parseSections(lines) {
|
||||
if (!lines || !lines.length || !lines[0].startsWith('[')) { // First line must be section start
|
||||
return []
|
||||
|
|
@ -324,6 +370,10 @@ function parseAbMetadataText(text, mediaType) {
|
|||
|
||||
mediaDetails.chapters.sort((a, b) => a.start - b.start)
|
||||
|
||||
if (mediaDetails.chapters.length) {
|
||||
mediaDetails.chapters = cleanChaptersArray(mediaDetails.chapters, mediaDetails.metadata.title) || []
|
||||
}
|
||||
|
||||
return mediaDetails
|
||||
}
|
||||
module.exports.parse = parseAbMetadataText
|
||||
|
|
@ -401,7 +451,10 @@ function checkArraysChanged(abmetadataArray, mediaArray) {
|
|||
function parseJsonMetadataText(text) {
|
||||
try {
|
||||
const abmetadataData = JSON.parse(text)
|
||||
if (abmetadataData.metadata?.series?.length) {
|
||||
if (!abmetadataData.metadata) abmetadataData.metadata = {}
|
||||
|
||||
if (abmetadataData.metadata.series?.length) {
|
||||
abmetadataData.metadata.series = [...new Set(abmetadataData.metadata.series.map(t => t?.trim()).filter(t => t))]
|
||||
abmetadataData.metadata.series = abmetadataData.metadata.series.map(series => {
|
||||
let sequence = null
|
||||
let name = series
|
||||
|
|
@ -418,12 +471,30 @@ function parseJsonMetadataText(text) {
|
|||
}
|
||||
})
|
||||
}
|
||||
// clean tags & remove dupes
|
||||
if (abmetadataData.tags?.length) {
|
||||
abmetadataData.tags = [...new Set(abmetadataData.tags.map(t => t?.trim()).filter(t => t))]
|
||||
}
|
||||
if (abmetadataData.chapters?.length) {
|
||||
abmetadataData.chapters = cleanChaptersArray(abmetadataData.chapters, abmetadataData.metadata.title)
|
||||
}
|
||||
// clean remove dupes
|
||||
if (abmetadataData.metadata.authors?.length) {
|
||||
abmetadataData.metadata.authors = [...new Set(abmetadataData.metadata.authors.map(t => t?.trim()).filter(t => t))]
|
||||
}
|
||||
if (abmetadataData.metadata.narrators?.length) {
|
||||
abmetadataData.metadata.narrators = [...new Set(abmetadataData.metadata.narrators.map(t => t?.trim()).filter(t => t))]
|
||||
}
|
||||
if (abmetadataData.metadata.genres?.length) {
|
||||
abmetadataData.metadata.genres = [...new Set(abmetadataData.metadata.genres.map(t => t?.trim()).filter(t => t))]
|
||||
}
|
||||
return abmetadataData
|
||||
} catch (error) {
|
||||
Logger.error(`[abmetadataGenerator] Invalid metadata.json JSON`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
module.exports.parseJson = parseJsonMetadataText
|
||||
|
||||
function cleanChaptersArray(chaptersArray, mediaTitle) {
|
||||
const chapters = []
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -147,10 +147,22 @@ const getTitleParts = (title) => {
|
|||
return [title, null]
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove sortingPrefixes from title
|
||||
* @example "The Good Book" => "Good Book"
|
||||
* @param {string} title
|
||||
* @returns {string}
|
||||
*/
|
||||
module.exports.getTitleIgnorePrefix = (title) => {
|
||||
return getTitleParts(title)[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Put sorting prefix at the end of title
|
||||
* @example "The Good Book" => "Good Book, The"
|
||||
* @param {string} title
|
||||
* @returns {string}
|
||||
*/
|
||||
module.exports.getTitlePrefixAtEnd = (title) => {
|
||||
let [sort, prefix] = getTitleParts(title)
|
||||
return prefix ? `${sort}, ${prefix}` : title
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
@ -7,180 +6,7 @@ const naturalSort = createNewSortInstance({
|
|||
})
|
||||
|
||||
module.exports = {
|
||||
decode(text) {
|
||||
return Buffer.from(decodeURIComponent(text), 'base64').toString()
|
||||
},
|
||||
|
||||
async getFilteredLibraryItems(libraryItems, filterBy, user) {
|
||||
let filtered = libraryItems
|
||||
|
||||
const searchGroups = ['genres', 'tags', 'series', 'authors', 'progress', 'narrators', 'publishers', 'missing', 'languages', 'tracks', 'ebooks']
|
||||
const group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
|
||||
if (group) {
|
||||
const filterVal = filterBy.replace(`${group}.`, '')
|
||||
const filter = this.decode(filterVal)
|
||||
if (group === 'genres') filtered = filtered.filter(li => li.media.metadata.genres?.includes(filter))
|
||||
else if (group === 'tags') filtered = filtered.filter(li => li.media.tags.includes(filter))
|
||||
else if (group === 'series') {
|
||||
if (filter === 'no-series') filtered = filtered.filter(li => li.isBook && !li.media.metadata.series.length)
|
||||
else {
|
||||
filtered = filtered.filter(li => li.isBook && li.media.metadata.hasSeries(filter))
|
||||
}
|
||||
}
|
||||
else if (group === 'authors') filtered = filtered.filter(li => li.isBook && li.media.metadata.hasAuthor(filter))
|
||||
else if (group === 'narrators') filtered = filtered.filter(li => li.isBook && li.media.metadata.hasNarrator(filter))
|
||||
else if (group === 'publishers') filtered = filtered.filter(li => li.isBook && li.media.metadata.publisher === filter)
|
||||
else if (group === 'progress') {
|
||||
filtered = filtered.filter(li => {
|
||||
const itemProgress = user.getMediaProgress(li.id)
|
||||
if (filter === 'finished' && (itemProgress && itemProgress.isFinished)) return true
|
||||
if (filter === 'not-started' && (!itemProgress || itemProgress.notStarted)) return true
|
||||
if (filter === 'not-finished' && (!itemProgress || !itemProgress.isFinished)) return true
|
||||
if (filter === 'in-progress' && (itemProgress && itemProgress.inProgress)) return true
|
||||
return false
|
||||
})
|
||||
} else if (group == 'missing') {
|
||||
filtered = filtered.filter(li => {
|
||||
if (li.isBook) {
|
||||
if (filter === 'asin' && !li.media.metadata.asin) return true
|
||||
if (filter === 'isbn' && !li.media.metadata.isbn) return true
|
||||
if (filter === 'subtitle' && !li.media.metadata.subtitle) return true
|
||||
if (filter === 'authors' && !li.media.metadata.authors.length) return true
|
||||
if (filter === 'publishedYear' && !li.media.metadata.publishedYear) return true
|
||||
if (filter === 'series' && !li.media.metadata.series.length) return true
|
||||
if (filter === 'description' && !li.media.metadata.description) return true
|
||||
if (filter === 'genres' && !li.media.metadata.genres.length) return true
|
||||
if (filter === 'tags' && !li.media.tags.length) return true
|
||||
if (filter === 'narrators' && !li.media.metadata.narrators.length) return true
|
||||
if (filter === 'publisher' && !li.media.metadata.publisher) return true
|
||||
if (filter === 'language' && !li.media.metadata.language) return true
|
||||
if (filter === 'cover' && !li.media.coverPath) return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
})
|
||||
} else if (group === 'languages') {
|
||||
filtered = filtered.filter(li => li.media.metadata.language === filter)
|
||||
} else if (group === 'tracks') {
|
||||
if (filter === 'none') filtered = filtered.filter(li => li.isBook && !li.media.numTracks)
|
||||
else if (filter === 'single') filtered = filtered.filter(li => li.isBook && li.media.numTracks === 1)
|
||||
else if (filter === 'multi') filtered = filtered.filter(li => li.isBook && li.media.numTracks > 1)
|
||||
} else if (group === 'ebooks') {
|
||||
if (filter === 'ebook') filtered = filtered.filter(li => li.media.ebookFile)
|
||||
else if (filter === 'supplementary') filtered = filtered.filter(li => li.libraryFiles.some(lf => lf.isEBookFile && lf.ino !== li.media.ebookFile?.ino))
|
||||
}
|
||||
} else if (filterBy === 'issues') {
|
||||
filtered = filtered.filter(li => li.hasIssues)
|
||||
} else if (filterBy === 'feed-open') {
|
||||
const libraryItemIdsWithFeed = await Database.models.feed.findAllLibraryItemIds()
|
||||
filtered = filtered.filter(li => libraryItemIdsWithFeed.includes(li.id))
|
||||
} else if (filterBy === 'abridged') {
|
||||
filtered = filtered.filter(li => !!li.media.metadata?.abridged)
|
||||
} else if (filterBy === 'ebook') {
|
||||
filtered = filtered.filter(li => li.media.ebookFile)
|
||||
}
|
||||
|
||||
return filtered
|
||||
},
|
||||
|
||||
// Returns false if should be filtered out
|
||||
checkFilterForSeriesLibraryItem(libraryItem, filterBy) {
|
||||
const searchGroups = ['genres', 'tags', 'authors', 'progress', 'narrators', 'publishers', 'languages']
|
||||
const group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
|
||||
if (group) {
|
||||
const filterVal = filterBy.replace(`${group}.`, '')
|
||||
const filter = this.decode(filterVal)
|
||||
|
||||
if (group === 'genres') return libraryItem.media.metadata.genres.includes(filter)
|
||||
else if (group === 'tags') return libraryItem.media.tags.includes(filter)
|
||||
else if (group === 'authors') return libraryItem.isBook && libraryItem.media.metadata.hasAuthor(filter)
|
||||
else if (group === 'narrators') return libraryItem.isBook && libraryItem.media.metadata.hasNarrator(filter)
|
||||
else if (group === 'publishers') return libraryItem.isBook && libraryItem.media.metadata.publisher === filter
|
||||
else if (group === 'languages') {
|
||||
return libraryItem.media.metadata.language === filter
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
|
||||
// Return false to filter out series
|
||||
checkSeriesProgressFilter(series, filterBy, user) {
|
||||
const filter = this.decode(filterBy.split('.')[1])
|
||||
|
||||
let someBookHasProgress = false
|
||||
let someBookIsUnfinished = false
|
||||
for (const libraryItem of series.books) {
|
||||
const itemProgress = user.getMediaProgress(libraryItem.id)
|
||||
if (!itemProgress || !itemProgress.isFinished) someBookIsUnfinished = true
|
||||
if (itemProgress && itemProgress.progress > 0) someBookHasProgress = true
|
||||
|
||||
if (filter === 'finished' && (!itemProgress || !itemProgress.isFinished)) return false
|
||||
if (filter === 'not-started' && itemProgress) return false
|
||||
}
|
||||
|
||||
if (!someBookIsUnfinished && (filter === 'not-finished' || filter === 'in-progress')) { // Completely finished series
|
||||
return false
|
||||
} else if (!someBookHasProgress && filter === 'in-progress') { // Series not started
|
||||
return false
|
||||
}
|
||||
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) {
|
||||
getSeriesFromBooks(books, filterSeries, hideSingleBookSeries) {
|
||||
const _series = {}
|
||||
const seriesToFilterOut = {}
|
||||
books.forEach((libraryItem) => {
|
||||
|
|
@ -188,23 +14,10 @@ module.exports = {
|
|||
const bookSeries = (libraryItem.media.metadata.series || []).filter(se => !seriesToFilterOut[se.id])
|
||||
if (!bookSeries.length) return
|
||||
|
||||
if (filterBy && user && !filterBy.startsWith('progress.')) { // Series progress filters are evaluated after grouping
|
||||
// If a single book in a series is filtered out then filter out the entire series
|
||||
if (!this.checkFilterForSeriesLibraryItem(libraryItem, filterBy)) {
|
||||
// filter out this library item
|
||||
bookSeries.forEach((bookSeriesObj) => {
|
||||
// flag series to filter it out
|
||||
seriesToFilterOut[bookSeriesObj.id] = true
|
||||
delete _series[bookSeriesObj.id]
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
bookSeries.forEach((bookSeriesObj) => {
|
||||
const series = allSeries.find(se => se.id === bookSeriesObj.id)
|
||||
// const series = allSeries.find(se => se.id === bookSeriesObj.id)
|
||||
|
||||
const abJson = minified ? libraryItem.toJSONMinified() : libraryItem.toJSONExpanded()
|
||||
const abJson = libraryItem.toJSONMinified()
|
||||
abJson.sequence = bookSeriesObj.sequence
|
||||
if (filterSeries) {
|
||||
abJson.filterSeriesSequence = libraryItem.media.metadata.getSeries(filterSeries).sequence
|
||||
|
|
@ -217,10 +30,8 @@ module.exports = {
|
|||
nameIgnorePrefixSort: getTitleIgnorePrefix(bookSeriesObj.name),
|
||||
type: 'series',
|
||||
books: [abJson],
|
||||
addedAt: series ? series.addedAt : 0,
|
||||
totalDuration: isNullOrNaN(abJson.media.duration) ? 0 : Number(abJson.media.duration)
|
||||
}
|
||||
|
||||
} else {
|
||||
_series[bookSeriesObj.id].books.push(abJson)
|
||||
_series[bookSeriesObj.id].totalDuration += isNullOrNaN(abJson.media.duration) ? 0 : Number(abJson.media.duration)
|
||||
|
|
@ -235,105 +46,17 @@ module.exports = {
|
|||
seriesItems = seriesItems.filter(se => se.books.length > 1)
|
||||
}
|
||||
|
||||
// check progress filter
|
||||
if (filterBy && filterBy.startsWith('progress.') && user) {
|
||||
seriesItems = seriesItems.filter(se => this.checkSeriesProgressFilter(se, filterBy, user))
|
||||
}
|
||||
|
||||
return seriesItems.map((series) => {
|
||||
series.books = naturalSort(series.books).asc(li => li.sequence)
|
||||
return series
|
||||
})
|
||||
},
|
||||
|
||||
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) {
|
||||
collapseBookSeries(libraryItems, 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.
|
||||
const seriesObjects = this
|
||||
.getSeriesFromBooks(libraryItems, series, filterSeries, null, null, true, hideSingleBookSeries)
|
||||
.getSeriesFromBooks(libraryItems, filterSeries, hideSingleBookSeries)
|
||||
.filter(s => s.id != filterSeries)
|
||||
|
||||
const filteredLibraryItems = []
|
||||
|
|
@ -358,548 +81,150 @@ 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: []
|
||||
async handleCollapseSubseries(payload, seriesId, user, library) {
|
||||
const seriesWithBooks = await Database.seriesModel.findByPk(seriesId, {
|
||||
include: {
|
||||
model: Database.bookModel,
|
||||
through: {
|
||||
attributes: ['sequence']
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: Database.libraryItemModel
|
||||
},
|
||||
{
|
||||
model: Database.authorModel,
|
||||
through: {
|
||||
attributes: []
|
||||
}
|
||||
},
|
||||
{
|
||||
model: Database.seriesModel,
|
||||
through: {
|
||||
attributes: ['sequence']
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!seriesWithBooks) {
|
||||
payload.total = 0
|
||||
return []
|
||||
}
|
||||
|
||||
// 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
|
||||
const books = seriesWithBooks.books
|
||||
payload.total = books.length
|
||||
|
||||
if (!hasActiveBook && nextBookInSeries) {
|
||||
const bookForContinueSeries = {
|
||||
...nextBookInSeries,
|
||||
prevBookInProgressLastUpdate: seriesMap[seriesId].bookInProgressLastUpdate
|
||||
}
|
||||
bookForContinueSeries.media.metadata.series = {
|
||||
id: seriesId,
|
||||
name: seriesMap[seriesId].name,
|
||||
sequence: nextBookInSeries.seriesSequence
|
||||
}
|
||||
let libraryItems = books.map((book) => {
|
||||
const libraryItem = book.libraryItem
|
||||
libraryItem.media = book
|
||||
return Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
||||
}).filter(li => {
|
||||
return user.checkCanAccessLibraryItem(li)
|
||||
})
|
||||
|
||||
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)
|
||||
const collapsedItems = this.collapseBookSeries(libraryItems, seriesId, library.settings.hideSingleBookSeries)
|
||||
if (!(collapsedItems.length == 1 && collapsedItems[0].collapsedSeries)) {
|
||||
libraryItems = collapsedItems
|
||||
payload.total = libraryItems.length
|
||||
}
|
||||
|
||||
const sortingIgnorePrefix = Database.serverSettings.sortingIgnorePrefix
|
||||
|
||||
let sortArray = []
|
||||
const direction = payload.sortDesc ? 'desc' : 'asc'
|
||||
if (!payload.sortBy || payload.sortBy === 'sequence') {
|
||||
sortArray = [
|
||||
{
|
||||
[direction]: (li) => li.media.metadata.getSeries(seriesId).sequence
|
||||
},
|
||||
{ // If no series sequence then fallback to sorting by title (or collapsed series name for sub-series)
|
||||
[direction]: (li) => {
|
||||
if (sortingIgnorePrefix) {
|
||||
return li.collapsedSeries?.nameIgnorePrefix || li.media.metadata.titleIgnorePrefix
|
||||
} else {
|
||||
return li.collapsedSeries?.name || li.media.metadata.title
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
]
|
||||
} else {
|
||||
// If series are collapsed and not sorting by title or sequence,
|
||||
// sort all collapsed series to the end in alphabetical order
|
||||
if (payload.sortBy !== 'media.metadata.title') {
|
||||
sortArray.push({
|
||||
asc: (li) => {
|
||||
if (li.collapsedSeries) {
|
||||
return sortingIgnorePrefix ? li.collapsedSeries.nameIgnorePrefix : li.collapsedSeries.name
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
})
|
||||
if (!shouldContinue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let totalWeight = 0
|
||||
|
||||
if (authorsCount > 0) {
|
||||
libraryItem.media.metadata.authors.forEach((author) => {
|
||||
if (topAuthorsListened[author.id]) {
|
||||
totalWeight += topAuthorsListened[author.id] / authorsCount
|
||||
}
|
||||
sortArray.push({
|
||||
[direction]: (li) => {
|
||||
if (payload.sortBy === 'media.metadata.title') {
|
||||
if (sortingIgnorePrefix) {
|
||||
return li.collapsedSeries?.nameIgnorePrefix || li.media.metadata.titleIgnorePrefix
|
||||
} else {
|
||||
return li.collapsedSeries?.name || li.media.metadata.title
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
return payload.sortBy.split('.').reduce((a, b) => a[b], li)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
libraryItems = naturalSort(libraryItems).by(sortArray)
|
||||
|
||||
for (const seriesItem of categoryMap['recent-series'].items) {
|
||||
seriesItem.books = naturalSort(seriesItem.books).asc(li => li.seriesSequence)
|
||||
}
|
||||
if (payload.limit) {
|
||||
const startIndex = payload.page * payload.limit
|
||||
libraryItems = libraryItems.slice(startIndex, startIndex + payload.limit)
|
||||
}
|
||||
|
||||
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
|
||||
}))
|
||||
}
|
||||
return Promise.all(libraryItems.map(async li => {
|
||||
const filteredSeries = li.media.metadata.getSeries(seriesId)
|
||||
const json = li.toJSONMinified()
|
||||
json.media.metadata.series = {
|
||||
id: filteredSeries.id,
|
||||
sequence: filteredSeries.sequence
|
||||
}
|
||||
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
|
||||
if (li.collapsedSeries) {
|
||||
json.collapsedSeries = {
|
||||
id: li.collapsedSeries.id,
|
||||
name: li.collapsedSeries.name,
|
||||
nameIgnorePrefix: li.collapsedSeries.nameIgnorePrefix,
|
||||
libraryItemIds: li.collapsedSeries.books.map(b => b.id),
|
||||
numBooks: li.collapsedSeries.books.length
|
||||
}
|
||||
} 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++
|
||||
|
||||
// If collapsing by series and filtering by a series, generate the list of sequences the collapsed
|
||||
// series represents in the filtered series
|
||||
json.collapsedSeries.seriesSequenceList =
|
||||
naturalSort(li.collapsedSeries.books.filter(b => b.filterSeriesSequence).map(b => b.filterSeriesSequence)).asc()
|
||||
.reduce((ranges, currentSequence) => {
|
||||
let lastRange = ranges.at(-1)
|
||||
let isNumber = /^(\d+|\d+\.\d*|\d*\.\d+)$/.test(currentSequence)
|
||||
if (isNumber) currentSequence = parseFloat(currentSequence)
|
||||
|
||||
if (lastRange && isNumber && lastRange.isNumber && ((lastRange.end + 1) == currentSequence)) {
|
||||
lastRange.end = currentSequence
|
||||
}
|
||||
else {
|
||||
ranges.push({ start: currentSequence, end: currentSequence, isNumber: isNumber })
|
||||
}
|
||||
|
||||
return ranges
|
||||
}, [])
|
||||
.map(r => r.start == r.end ? r.start : `${r.start}-${r.end}`)
|
||||
.join(', ')
|
||||
}
|
||||
})
|
||||
|
||||
return Object.values(albums)
|
||||
return json
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1620,12 +1620,11 @@ async function migrationPatch2BookSeries(ctx, offset = 0) {
|
|||
*/
|
||||
module.exports.migrationPatch2 = async (ctx) => {
|
||||
const queryInterface = ctx.sequelize.getQueryInterface()
|
||||
const libraryItemsIndexes = await queryInterface.showIndex('libraryItems')
|
||||
const feedTableDescription = await queryInterface.describeTable('feeds')
|
||||
const authorsTableDescription = await queryInterface.describeTable('authors')
|
||||
const bookAuthorsTableDescription = await queryInterface.describeTable('bookAuthors')
|
||||
|
||||
if (feedTableDescription?.coverPath && authorsTableDescription?.lastFirst && bookAuthorsTableDescription?.createdAt && libraryItemsIndexes.some(lii => lii.name === 'library_items_created_at')) {
|
||||
if (feedTableDescription?.coverPath && authorsTableDescription?.lastFirst && bookAuthorsTableDescription?.createdAt) {
|
||||
Logger.info(`[dbMigration] Migration patch 2.3.3+ - columns already on model`)
|
||||
return false
|
||||
}
|
||||
|
|
@ -1633,20 +1632,7 @@ module.exports.migrationPatch2 = async (ctx) => {
|
|||
|
||||
try {
|
||||
await queryInterface.sequelize.transaction(t => {
|
||||
const queries = [
|
||||
queryInterface.addIndex('libraryItems', {
|
||||
fields: ['mediaId'],
|
||||
transaction: t
|
||||
}),
|
||||
queryInterface.addIndex('libraryItems', {
|
||||
fields: ['createdAt'],
|
||||
transaction: t
|
||||
}),
|
||||
queryInterface.addIndex('mediaProgresses', {
|
||||
fields: ['updatedAt'],
|
||||
transaction: t
|
||||
})
|
||||
]
|
||||
const queries = []
|
||||
if (!bookAuthorsTableDescription?.createdAt) {
|
||||
queries.push(...[
|
||||
queryInterface.addColumn('bookAuthors', 'createdAt', {
|
||||
|
|
|
|||
|
|
@ -159,8 +159,8 @@ module.exports.parseOpfMetadataXML = async (xml) => {
|
|||
}
|
||||
|
||||
const creators = parseCreators(metadata)
|
||||
const authors = (fetchCreators(creators, 'aut') || []).filter(au => au && au.trim())
|
||||
const narrators = (fetchNarrators(creators, metadata) || []).filter(nrt => nrt && nrt.trim())
|
||||
const authors = (fetchCreators(creators, 'aut') || []).map(au => au?.trim()).filter(au => au)
|
||||
const narrators = (fetchNarrators(creators, metadata) || []).map(nrt => nrt?.trim()).filter(nrt => nrt)
|
||||
const data = {
|
||||
title: fetchTitle(metadata),
|
||||
subtitle: fetchSubtitle(metadata),
|
||||
|
|
|
|||
|
|
@ -278,7 +278,12 @@ function parseProbeData(data, verbose = false) {
|
|||
}
|
||||
}
|
||||
|
||||
// Updated probe returns MediaProbeData object
|
||||
/**
|
||||
* Run ffprobe on audio filepath
|
||||
* @param {string} filepath
|
||||
* @param {boolean} [verbose=false]
|
||||
* @returns {import('../scanner/MediaProbeData')|{error:string}}
|
||||
*/
|
||||
function probe(filepath, verbose = false) {
|
||||
if (process.env.FFPROBE_PATH) {
|
||||
ffprobe.FFPROBE_PATH = process.env.FFPROBE_PATH
|
||||
|
|
|
|||
70
server/utils/queries/authorFilters.js
Normal file
70
server/utils/queries/authorFilters.js
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,12 @@
|
|||
const Sequelize = require('sequelize')
|
||||
const Database = require('../../Database')
|
||||
const Logger = require('../../Logger')
|
||||
const Database = require('../../Database')
|
||||
const libraryItemsBookFilters = require('./libraryItemsBookFilters')
|
||||
const libraryItemsPodcastFilters = require('./libraryItemsPodcastFilters')
|
||||
const { createNewSortInstance } = require('../../libs/fastSort')
|
||||
const naturalSort = createNewSortInstance({
|
||||
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
|
||||
})
|
||||
|
||||
module.exports = {
|
||||
decode(text) {
|
||||
|
|
@ -11,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 }
|
||||
*/
|
||||
|
|
@ -37,31 +41,31 @@ 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)
|
||||
const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, 'progress', 'in-progress', 'progress', true, false, include, limit, 0, true)
|
||||
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
|
||||
}),
|
||||
count
|
||||
}
|
||||
} else {
|
||||
const { libraryItems, count } = await libraryItemsPodcastFilters.getFilteredPodcastEpisodes(library.id, user, 'progress', 'in-progress', 'progress', true, limit, 0)
|
||||
const { libraryItems, count } = await libraryItemsPodcastFilters.getFilteredPodcastEpisodes(library.id, user, 'progress', 'in-progress', 'progress', true, limit, 0, true)
|
||||
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
|
||||
})
|
||||
|
|
@ -82,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
|
||||
|
|
@ -97,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
|
||||
|
|
@ -123,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
|
||||
|
|
@ -149,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
|
||||
}),
|
||||
|
|
@ -162,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
|
||||
})
|
||||
|
|
@ -172,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
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -217,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,
|
||||
|
|
@ -226,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
|
||||
|
|
@ -248,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
|
||||
|
|
@ -261,12 +265,17 @@ module.exports = {
|
|||
})
|
||||
})
|
||||
oldSeries.books = s.bookSeries.map(bs => {
|
||||
const libraryItem = bs.book.libraryItem.toJSON()
|
||||
const libraryItem = bs.book.libraryItem?.toJSON()
|
||||
if (!libraryItem) {
|
||||
Logger.warn(`Book series book has no libraryItem`, bs, bs.book, 'series=', series)
|
||||
return null
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
}).filter(b => b)
|
||||
allOldSeries.push(oldSeries)
|
||||
}
|
||||
|
||||
|
|
@ -287,7 +296,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: {
|
||||
|
|
@ -295,7 +304,7 @@ module.exports = {
|
|||
}
|
||||
},
|
||||
include: {
|
||||
model: Database.models.bookAuthor,
|
||||
model: Database.bookAuthorModel,
|
||||
required: true // Must belong to a book
|
||||
},
|
||||
limit,
|
||||
|
|
@ -328,9 +337,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
|
||||
}),
|
||||
|
|
@ -352,7 +361,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
|
||||
})
|
||||
|
|
@ -382,5 +391,110 @@ module.exports = {
|
|||
*/
|
||||
getLibraryItemsForCollection(collection) {
|
||||
return libraryItemsBookFilters.getLibraryItemsForCollection(collection)
|
||||
},
|
||||
|
||||
/**
|
||||
* Get filter data used in filter menus
|
||||
* @param {string} mediaType
|
||||
* @param {string} libraryId
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
async getFilterData(mediaType, libraryId) {
|
||||
const cachedFilterData = Database.libraryFilterData[libraryId]
|
||||
if (cachedFilterData) {
|
||||
const cacheElapsed = Date.now() - cachedFilterData.loadedAt
|
||||
// Cache library filters for 30 mins
|
||||
// TODO: Keep cached filter data up-to-date on updates
|
||||
if (cacheElapsed < 1000 * 60 * 30) {
|
||||
return cachedFilterData
|
||||
}
|
||||
}
|
||||
const start = Date.now() // Temp for checking load times
|
||||
|
||||
const data = {
|
||||
authors: [],
|
||||
genres: new Set(),
|
||||
tags: new Set(),
|
||||
series: [],
|
||||
narrators: new Set(),
|
||||
languages: new Set(),
|
||||
publishers: new Set(),
|
||||
numIssues: 0
|
||||
}
|
||||
|
||||
if (mediaType === 'podcast') {
|
||||
const podcasts = await Database.podcastModel.findAll({
|
||||
include: {
|
||||
model: Database.libraryItemModel,
|
||||
attributes: [],
|
||||
where: {
|
||||
libraryId: libraryId
|
||||
}
|
||||
},
|
||||
attributes: ['tags', 'genres']
|
||||
})
|
||||
for (const podcast of podcasts) {
|
||||
if (podcast.tags?.length) {
|
||||
podcast.tags.forEach((tag) => data.tags.add(tag))
|
||||
}
|
||||
if (podcast.genres?.length) {
|
||||
podcast.genres.forEach((genre) => data.genres.add(genre))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const books = await Database.bookModel.findAll({
|
||||
include: {
|
||||
model: Database.libraryItemModel,
|
||||
attributes: ['isMissing', 'isInvalid'],
|
||||
where: {
|
||||
libraryId: libraryId
|
||||
}
|
||||
},
|
||||
attributes: ['tags', 'genres', 'publisher', 'narrators', 'language']
|
||||
})
|
||||
for (const book of books) {
|
||||
if (book.libraryItem.isMissing || book.libraryItem.isInvalid) data.numIssues++
|
||||
if (book.tags?.length) {
|
||||
book.tags.forEach((tag) => data.tags.add(tag))
|
||||
}
|
||||
if (book.genres?.length) {
|
||||
book.genres.forEach((genre) => data.genres.add(genre))
|
||||
}
|
||||
if (book.narrators?.length) {
|
||||
book.narrators.forEach((narrator) => data.narrators.add(narrator))
|
||||
}
|
||||
if (book.publisher) data.publishers.add(book.publisher)
|
||||
if (book.language) data.languages.add(book.language)
|
||||
}
|
||||
|
||||
const series = await Database.seriesModel.findAll({
|
||||
where: {
|
||||
libraryId: libraryId
|
||||
},
|
||||
attributes: ['id', 'name']
|
||||
})
|
||||
series.forEach((s) => data.series.push({ id: s.id, name: s.name }))
|
||||
|
||||
const authors = await Database.authorModel.findAll({
|
||||
where: {
|
||||
libraryId: libraryId
|
||||
},
|
||||
attributes: ['id', 'name']
|
||||
})
|
||||
authors.forEach((a) => data.authors.push({ id: a.id, name: a.name }))
|
||||
}
|
||||
|
||||
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()
|
||||
data.loadedAt = Date.now()
|
||||
Database.libraryFilterData[libraryId] = data
|
||||
|
||||
Logger.debug(`Loaded filterdata in ${((Date.now() - start) / 1000).toFixed(2)}s`)
|
||||
return data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
220
server/utils/queries/libraryItemFilters.js
Normal file
220
server/utils/queries/libraryItemFilters.js
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
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<import('../../models/LibraryItem')[]>}
|
||||
*/
|
||||
async getAllLibraryItemsWithTags(tags) {
|
||||
const libraryItems = []
|
||||
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
|
||||
}),
|
||||
replacements: {
|
||||
tags
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: Database.libraryItemModel
|
||||
},
|
||||
{
|
||||
model: Database.authorModel,
|
||||
through: {
|
||||
attributes: []
|
||||
}
|
||||
},
|
||||
{
|
||||
model: Database.seriesModel,
|
||||
through: {
|
||||
attributes: ['sequence']
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
for (const book of booksWithTag) {
|
||||
const libraryItem = book.libraryItem
|
||||
libraryItem.media = book
|
||||
libraryItems.push(libraryItem)
|
||||
}
|
||||
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
|
||||
}),
|
||||
replacements: {
|
||||
tags
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: Database.libraryItemModel
|
||||
},
|
||||
{
|
||||
model: Database.podcastEpisodeModel
|
||||
}
|
||||
]
|
||||
})
|
||||
for (const podcast of podcastsWithTag) {
|
||||
const libraryItem = podcast.libraryItem
|
||||
libraryItem.media = podcast
|
||||
libraryItems.push(libraryItem)
|
||||
}
|
||||
return libraryItems
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all library items that have genres
|
||||
* @param {string[]} genres
|
||||
* @returns {Promise<LibraryItem[]>}
|
||||
*/
|
||||
async getAllLibraryItemsWithGenres(genres) {
|
||||
const libraryItems = []
|
||||
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
|
||||
}),
|
||||
replacements: {
|
||||
genres
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: Database.libraryItemModel
|
||||
},
|
||||
{
|
||||
model: Database.authorModel,
|
||||
through: {
|
||||
attributes: []
|
||||
}
|
||||
},
|
||||
{
|
||||
model: Database.seriesModel,
|
||||
through: {
|
||||
attributes: ['sequence']
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
for (const book of booksWithGenre) {
|
||||
const libraryItem = book.libraryItem
|
||||
libraryItem.media = book
|
||||
libraryItems.push(libraryItem)
|
||||
}
|
||||
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
|
||||
}),
|
||||
replacements: {
|
||||
genres
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: Database.libraryItemModel
|
||||
},
|
||||
{
|
||||
model: Database.podcastEpisodeModel
|
||||
}
|
||||
]
|
||||
})
|
||||
for (const podcast of podcastsWithGenre) {
|
||||
const libraryItem = podcast.libraryItem
|
||||
libraryItem.media = podcast
|
||||
libraryItems.push(libraryItem)
|
||||
}
|
||||
return libraryItems
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all library items that have narrators
|
||||
* @param {string[]} narrators
|
||||
* @returns {Promise<import('../../models/LibraryItem')[]>}
|
||||
*/
|
||||
async getAllLibraryItemsWithNarrators(narrators) {
|
||||
const libraryItems = []
|
||||
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
|
||||
}),
|
||||
replacements: {
|
||||
narrators
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: Database.libraryItemModel
|
||||
},
|
||||
{
|
||||
model: Database.authorModel,
|
||||
through: {
|
||||
attributes: []
|
||||
}
|
||||
},
|
||||
{
|
||||
model: Database.seriesModel,
|
||||
through: {
|
||||
attributes: ['sequence']
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
for (const book of booksWithGenre) {
|
||||
const libraryItem = book.libraryItem
|
||||
libraryItem.media = book
|
||||
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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = []
|
||||
|
|
@ -210,7 +211,7 @@ module.exports = {
|
|||
mediaWhere[key] = {
|
||||
[Sequelize.Op.or]: [null, '']
|
||||
}
|
||||
} else if (['genres', 'tags', 'narrator'].includes(value)) {
|
||||
} else if (['genres', 'tags', 'narrators'].includes(value)) {
|
||||
mediaWhere[value] = {
|
||||
[Sequelize.Op.or]: [null, Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col(value)), 0)]
|
||||
}
|
||||
|
|
@ -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']
|
||||
|
|
@ -336,9 +337,10 @@ module.exports = {
|
|||
* @param {string[]} include
|
||||
* @param {number} limit
|
||||
* @param {number} offset
|
||||
* @param {boolean} isHomePage for home page shelves
|
||||
* @returns {object} { libraryItems:LibraryItem[], count:number }
|
||||
*/
|
||||
async getFilteredLibraryItems(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, collapseseries, include, limit, offset) {
|
||||
async getFilteredLibraryItems(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, collapseseries, include, limit, offset, isHomePage = false) {
|
||||
// TODO: Handle collapse sub-series
|
||||
if (filterGroup === 'series' && collapseseries) {
|
||||
collapseseries = false
|
||||
|
|
@ -373,10 +375,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 +388,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 +406,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 +422,7 @@ module.exports = {
|
|||
}
|
||||
} else if (filterGroup === 'missing' && filterValue === 'authors') {
|
||||
authorInclude = {
|
||||
model: Database.models.author,
|
||||
model: Database.authorModel,
|
||||
attributes: ['id'],
|
||||
through: {
|
||||
attributes: []
|
||||
|
|
@ -428,7 +430,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 +438,7 @@ module.exports = {
|
|||
}
|
||||
} else if (filterGroup === 'authors') {
|
||||
bookIncludes.push({
|
||||
model: Database.models.author,
|
||||
model: Database.authorModel,
|
||||
attributes: ['id', 'name'],
|
||||
where: {
|
||||
id: filterValue
|
||||
|
|
@ -447,7 +449,7 @@ module.exports = {
|
|||
})
|
||||
} else if (filterGroup === 'series') {
|
||||
bookIncludes.push({
|
||||
model: Database.models.series,
|
||||
model: Database.seriesModel,
|
||||
attributes: ['id', 'name'],
|
||||
where: {
|
||||
id: filterValue
|
||||
|
|
@ -470,12 +472,17 @@ module.exports = {
|
|||
}
|
||||
]
|
||||
} else if (filterGroup === 'progress' && user) {
|
||||
const mediaProgressWhere = {
|
||||
userId: user.id
|
||||
}
|
||||
// Respect hide from continue listening for home page shelf
|
||||
if (isHomePage) {
|
||||
mediaProgressWhere.hideFromContinueListening = false
|
||||
}
|
||||
bookIncludes.push({
|
||||
model: Database.models.mediaProgress,
|
||||
model: Database.mediaProgressModel,
|
||||
attributes: ['id', 'isFinished', 'currentTime', 'ebookProgress', 'updatedAt'],
|
||||
where: {
|
||||
userId: user.id
|
||||
},
|
||||
where: mediaProgressWhere,
|
||||
required: false
|
||||
})
|
||||
} else if (filterGroup === 'recent') {
|
||||
|
|
@ -512,7 +519,7 @@ module.exports = {
|
|||
where: seriesBookWhere,
|
||||
include: [
|
||||
{
|
||||
model: Database.models.libraryItem,
|
||||
model: Database.libraryItemModel,
|
||||
required: true,
|
||||
where: libraryItemWhere,
|
||||
include: libraryItemIncludes
|
||||
|
|
@ -537,22 +544,18 @@ module.exports = {
|
|||
if (global.ServerSettings.sortingIgnorePrefix) {
|
||||
bookAttributes.include.push([Sequelize.literal(`IFNULL((SELECT s.nameIgnorePrefix FROM bookSeries AS bs, series AS s WHERE bs.seriesId = s.id AND bs.bookId = book.id AND bs.id IN (${bookSeriesToInclude.map(v => `"${v.id}"`).join(', ')})), titleIgnorePrefix)`), 'display_title'])
|
||||
} else {
|
||||
bookAttributes.include.push([Sequelize.literal(`IFNULL((SELECT s.name FROM bookSeries AS bs, series AS s WHERE bs.seriesId = s.id AND bs.bookId = book.id AND bs.id IN (${bookSeriesToInclude.map(v => `"${v.id}"`).join(', ')})), title)`), 'display_title'])
|
||||
bookAttributes.include.push([Sequelize.literal(`IFNULL((SELECT s.name FROM bookSeries AS bs, series AS s WHERE bs.seriesId = s.id AND bs.bookId = book.id AND bs.id IN (${bookSeriesToInclude.map(v => `"${v.id}"`).join(', ')})), \`book\`.\`title\`)`), 'display_title'])
|
||||
}
|
||||
}
|
||||
|
||||
const { rows: books, count } = await Database.models.book.findAndCountAll({
|
||||
const { rows: books, count } = await Database.bookModel.findAndCountAll({
|
||||
where: bookWhere,
|
||||
distinct: true,
|
||||
attributes: bookAttributes,
|
||||
replacements,
|
||||
benchmark: true,
|
||||
logging: (sql, timeMs) => {
|
||||
console.log(`[Query] Elapsed ${timeMs}ms`)
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: Database.models.libraryItem,
|
||||
model: Database.libraryItemModel,
|
||||
required: true,
|
||||
where: libraryItemWhere,
|
||||
include: libraryItemIncludes
|
||||
|
|
@ -563,7 +566,7 @@ module.exports = {
|
|||
],
|
||||
order: sortOrder,
|
||||
subQuery: false,
|
||||
limit,
|
||||
limit: limit || null,
|
||||
offset
|
||||
})
|
||||
|
||||
|
|
@ -632,7 +635,7 @@ module.exports = {
|
|||
const libraryItemIncludes = []
|
||||
if (include.includes('rssfeed')) {
|
||||
libraryItemIncludes.push({
|
||||
model: Database.models.feed
|
||||
model: Database.feedModel
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -642,9 +645,12 @@ 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: [
|
||||
{
|
||||
id: {
|
||||
[Sequelize.Op.notIn]: user.seriesHideFromContinueListening
|
||||
},
|
||||
libraryId
|
||||
},
|
||||
// TODO: Simplify queries
|
||||
|
|
@ -669,7 +675,7 @@ module.exports = {
|
|||
...userPermissionBookWhere.replacements
|
||||
},
|
||||
include: {
|
||||
model: Database.models.bookSeries,
|
||||
model: Database.bookSeriesModel,
|
||||
attributes: ['bookId', 'sequence'],
|
||||
separate: true,
|
||||
subQuery: false,
|
||||
|
|
@ -682,21 +688,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 +757,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 +770,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 +794,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 +822,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
|
||||
}
|
||||
|
|
@ -882,24 +888,25 @@ module.exports = {
|
|||
Logger.error(`[libraryItemsBookFilters] Invalid collection`, collection)
|
||||
return []
|
||||
}
|
||||
const books = await Database.models.book.findAll({
|
||||
where: {
|
||||
id: {
|
||||
[Sequelize.Op.in]: collection.books
|
||||
}
|
||||
},
|
||||
|
||||
const books = await Database.bookModel.findAll({
|
||||
include: [
|
||||
{
|
||||
model: Database.models.libraryItem
|
||||
model: Database.libraryItemModel,
|
||||
where: {
|
||||
id: {
|
||||
[Sequelize.Op.in]: collection.books
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
model: sequelize.models.author,
|
||||
model: Database.authorModel,
|
||||
through: {
|
||||
attributes: []
|
||||
}
|
||||
},
|
||||
{
|
||||
model: sequelize.models.series,
|
||||
model: Database.seriesModel,
|
||||
through: {
|
||||
attributes: ['sequence']
|
||||
}
|
||||
|
|
@ -913,5 +920,264 @@ module.exports = {
|
|||
libraryItem.media = book
|
||||
return libraryItem
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Get library items for series
|
||||
* @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.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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = []
|
||||
|
|
@ -81,7 +81,7 @@ module.exports = {
|
|||
if (global.ServerSettings.sortingIgnorePrefix) {
|
||||
return [[Sequelize.literal('titleIgnorePrefix COLLATE NOCASE'), dir]]
|
||||
} else {
|
||||
return [[Sequelize.literal('title COLLATE NOCASE'), dir]]
|
||||
return [[Sequelize.literal('`podcast`.`title` COLLATE NOCASE'), dir]]
|
||||
}
|
||||
} else if (sortBy === 'media.numTracks') {
|
||||
return [['numEpisodes', dir]]
|
||||
|
|
@ -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
|
||||
|
|
@ -166,7 +166,7 @@ module.exports = {
|
|||
],
|
||||
order: this.getOrder(sortBy, sortDesc),
|
||||
subQuery: false,
|
||||
limit,
|
||||
limit: limit || null,
|
||||
offset
|
||||
})
|
||||
|
||||
|
|
@ -204,9 +204,10 @@ module.exports = {
|
|||
* @param {string} sortDesc
|
||||
* @param {number} limit
|
||||
* @param {number} offset
|
||||
* @param {boolean} isHomePage for home page shelves
|
||||
* @returns {object} {libraryItems:LibraryItem[], count:number}
|
||||
*/
|
||||
async getFilteredPodcastEpisodes(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, limit, offset) {
|
||||
async getFilteredPodcastEpisodes(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, limit, offset, isHomePage = false) {
|
||||
if (sortBy === 'progress' && filterGroup !== 'progress') {
|
||||
Logger.warn('Cannot sort podcast episodes by progress without filtering by progress')
|
||||
sortBy = 'createdAt'
|
||||
|
|
@ -218,11 +219,16 @@ module.exports = {
|
|||
libraryId
|
||||
}
|
||||
if (filterGroup === 'progress') {
|
||||
const mediaProgressWhere = {
|
||||
userId: user.id
|
||||
}
|
||||
// Respect hide from continue listening for home page shelf
|
||||
if (isHomePage) {
|
||||
mediaProgressWhere.hideFromContinueListening = false
|
||||
}
|
||||
podcastEpisodeIncludes.push({
|
||||
model: Database.models.mediaProgress,
|
||||
where: {
|
||||
userId: user.id
|
||||
},
|
||||
model: Database.mediaProgressModel,
|
||||
where: mediaProgressWhere,
|
||||
attributes: ['id', 'isFinished', 'currentTime', 'updatedAt']
|
||||
})
|
||||
|
||||
|
|
@ -255,16 +261,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 +289,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 +297,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(oldUser)
|
||||
// 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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
202
server/utils/queries/seriesFilters.js
Normal file
202
server/utils/queries/seriesFilters.js
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
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,
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
const Path = require('path')
|
||||
const fs = require('../libs/fsExtra')
|
||||
const Logger = require('../Logger')
|
||||
const { recurseFiles, getFileTimestampsWithIno, filePathToPOSIX } = require('./fileUtils')
|
||||
const { filePathToPOSIX } = require('./fileUtils')
|
||||
const globals = require('./globals')
|
||||
const LibraryFile = require('../objects/files/LibraryFile')
|
||||
|
||||
|
|
@ -22,9 +20,12 @@ function checkFilepathIsAudioFile(filepath) {
|
|||
}
|
||||
module.exports.checkFilepathIsAudioFile = checkFilepathIsAudioFile
|
||||
|
||||
// TODO: Function needs to be re-done
|
||||
// Input: array of relative file paths
|
||||
// Output: map of files grouped into potential item dirs
|
||||
/**
|
||||
* TODO: Function needs to be re-done
|
||||
* @param {string} mediaType
|
||||
* @param {string[]} paths array of relative file paths
|
||||
* @returns {Record<string,string[]>} map of files grouped into potential libarary item dirs
|
||||
*/
|
||||
function groupFilesIntoLibraryItemPaths(mediaType, paths) {
|
||||
// Step 1: Clean path, Remove leading "/", Filter out non-media files in root dir
|
||||
var nonMediaFilePaths = []
|
||||
|
|
@ -85,10 +86,24 @@ function groupFilesIntoLibraryItemPaths(mediaType, paths) {
|
|||
|
||||
// Step 4: Add in non-media files if they fit into item group
|
||||
if (nonMediaFilePaths.length) {
|
||||
|
||||
for (const nonMediaFilePath of nonMediaFilePaths) {
|
||||
const pathDir = Path.dirname(nonMediaFilePath)
|
||||
if (itemGroup[pathDir]) {
|
||||
itemGroup[pathDir].push(nonMediaFilePath)
|
||||
const filename = Path.basename(nonMediaFilePath)
|
||||
const dirparts = pathDir.split('/')
|
||||
const numparts = dirparts.length
|
||||
let _path = ''
|
||||
|
||||
// Iterate over directories in path
|
||||
for (let i = 0; i < numparts; i++) {
|
||||
const dirpart = dirparts.shift()
|
||||
_path = Path.posix.join(_path, dirpart)
|
||||
if (itemGroup[_path]) { // Directory is a group
|
||||
const relpath = Path.posix.join(dirparts.join('/'), filename)
|
||||
itemGroup[_path].push(relpath)
|
||||
} else if (!dirparts.length) {
|
||||
itemGroup[_path] = [filename]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -97,8 +112,12 @@ function groupFilesIntoLibraryItemPaths(mediaType, paths) {
|
|||
}
|
||||
module.exports.groupFilesIntoLibraryItemPaths = groupFilesIntoLibraryItemPaths
|
||||
|
||||
// Input: array of relative file items (see recurseFiles)
|
||||
// Output: map of files grouped into potential libarary item dirs
|
||||
/**
|
||||
* @param {string} mediaType
|
||||
* @param {{name:string, path:string, dirpath:string, reldirpath:string, fullpath:string, extension:string, deep:number}[]} fileItems (see recurseFiles)
|
||||
* @param {boolean} [audiobooksOnly=false]
|
||||
* @returns {Record<string,string[]>} map of files grouped into potential libarary item dirs
|
||||
*/
|
||||
function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems, audiobooksOnly = false) {
|
||||
// Handle music where every audio file is a library item
|
||||
if (mediaType === 'music') {
|
||||
|
|
@ -173,8 +192,15 @@ function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems, audiobooksOnly
|
|||
})
|
||||
return libraryItemGroup
|
||||
}
|
||||
module.exports.groupFileItemsIntoLibraryItemDirs = groupFileItemsIntoLibraryItemDirs
|
||||
|
||||
function cleanFileObjects(libraryItemPath, files) {
|
||||
/**
|
||||
* Get LibraryFile from filepath
|
||||
* @param {string} libraryItemPath
|
||||
* @param {string[]} files
|
||||
* @returns {import('../objects/files/LibraryFile')}
|
||||
*/
|
||||
function buildLibraryFile(libraryItemPath, files) {
|
||||
return Promise.all(files.map(async (file) => {
|
||||
const filePath = Path.posix.join(libraryItemPath, file)
|
||||
const newLibraryFile = new LibraryFile()
|
||||
|
|
@ -182,73 +208,7 @@ function cleanFileObjects(libraryItemPath, files) {
|
|||
return newLibraryFile
|
||||
}))
|
||||
}
|
||||
|
||||
// Scan folder
|
||||
async function scanFolder(library, folder) {
|
||||
const folderPath = filePathToPOSIX(folder.fullPath)
|
||||
|
||||
const pathExists = await fs.pathExists(folderPath)
|
||||
if (!pathExists) {
|
||||
Logger.error(`[scandir] Invalid folder path does not exist "${folderPath}"`)
|
||||
return []
|
||||
}
|
||||
|
||||
const fileItems = await recurseFiles(folderPath)
|
||||
const libraryItemGrouping = groupFileItemsIntoLibraryItemDirs(library.mediaType, fileItems, library.settings.audiobooksOnly)
|
||||
|
||||
if (!Object.keys(libraryItemGrouping).length) {
|
||||
Logger.error(`Root path has no media folders: ${folderPath}`)
|
||||
return []
|
||||
}
|
||||
|
||||
const items = []
|
||||
for (const libraryItemPath in libraryItemGrouping) {
|
||||
let isFile = false // item is not in a folder
|
||||
let libraryItemData = null
|
||||
let fileObjs = []
|
||||
if (library.mediaType === 'music') {
|
||||
libraryItemData = {
|
||||
path: Path.posix.join(folderPath, libraryItemPath),
|
||||
relPath: libraryItemPath
|
||||
}
|
||||
fileObjs = await cleanFileObjects(folderPath, [libraryItemPath])
|
||||
isFile = true
|
||||
} else if (libraryItemPath === libraryItemGrouping[libraryItemPath]) {
|
||||
// Media file in root only get title
|
||||
libraryItemData = {
|
||||
mediaMetadata: {
|
||||
title: Path.basename(libraryItemPath, Path.extname(libraryItemPath))
|
||||
},
|
||||
path: Path.posix.join(folderPath, libraryItemPath),
|
||||
relPath: libraryItemPath
|
||||
}
|
||||
fileObjs = await cleanFileObjects(folderPath, [libraryItemPath])
|
||||
isFile = true
|
||||
} else {
|
||||
libraryItemData = getDataFromMediaDir(library.mediaType, folderPath, libraryItemPath)
|
||||
fileObjs = await cleanFileObjects(libraryItemData.path, libraryItemGrouping[libraryItemPath])
|
||||
}
|
||||
|
||||
const libraryItemFolderStats = await getFileTimestampsWithIno(libraryItemData.path)
|
||||
items.push({
|
||||
folderId: folder.id,
|
||||
libraryId: folder.libraryId,
|
||||
ino: libraryItemFolderStats.ino,
|
||||
mtimeMs: libraryItemFolderStats.mtimeMs || 0,
|
||||
ctimeMs: libraryItemFolderStats.ctimeMs || 0,
|
||||
birthtimeMs: libraryItemFolderStats.birthtimeMs || 0,
|
||||
path: libraryItemData.path,
|
||||
relPath: libraryItemData.relPath,
|
||||
isFile,
|
||||
media: {
|
||||
metadata: libraryItemData.mediaMetadata || null
|
||||
},
|
||||
libraryFiles: fileObjs
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
module.exports.scanFolder = scanFolder
|
||||
module.exports.buildLibraryFile = buildLibraryFile
|
||||
|
||||
// Input relative filepath, output all details that can be parsed
|
||||
function getBookDataFromDir(folderPath, relPath, parseSubtitle = false) {
|
||||
|
|
@ -365,60 +325,4 @@ function getDataFromMediaDir(libraryMediaType, folderPath, relPath) {
|
|||
return getPodcastDataFromDir(folderPath, relPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Called from Scanner.js
|
||||
async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath, isSingleMediaItem) {
|
||||
libraryItemPath = filePathToPOSIX(libraryItemPath)
|
||||
const folderFullPath = filePathToPOSIX(folder.fullPath)
|
||||
|
||||
const libraryItemDir = libraryItemPath.replace(folderFullPath, '').slice(1)
|
||||
let libraryItemData = {}
|
||||
|
||||
let fileItems = []
|
||||
|
||||
if (isSingleMediaItem) { // Single media item in root of folder
|
||||
fileItems = [
|
||||
{
|
||||
fullpath: libraryItemPath,
|
||||
path: libraryItemDir // actually the relPath (only filename here)
|
||||
}
|
||||
]
|
||||
libraryItemData = {
|
||||
path: libraryItemPath, // full path
|
||||
relPath: libraryItemDir, // only filename
|
||||
mediaMetadata: {
|
||||
title: Path.basename(libraryItemDir, Path.extname(libraryItemDir))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fileItems = await recurseFiles(libraryItemPath)
|
||||
libraryItemData = getDataFromMediaDir(libraryMediaType, folderFullPath, libraryItemDir)
|
||||
}
|
||||
|
||||
const libraryItemDirStats = await getFileTimestampsWithIno(libraryItemData.path)
|
||||
const libraryItem = {
|
||||
ino: libraryItemDirStats.ino,
|
||||
mtimeMs: libraryItemDirStats.mtimeMs || 0,
|
||||
ctimeMs: libraryItemDirStats.ctimeMs || 0,
|
||||
birthtimeMs: libraryItemDirStats.birthtimeMs || 0,
|
||||
folderId: folder.id,
|
||||
libraryId: folder.libraryId,
|
||||
path: libraryItemData.path,
|
||||
relPath: libraryItemData.relPath,
|
||||
isFile: isSingleMediaItem,
|
||||
media: {
|
||||
metadata: libraryItemData.mediaMetadata || null
|
||||
},
|
||||
libraryFiles: []
|
||||
}
|
||||
|
||||
for (let i = 0; i < fileItems.length; i++) {
|
||||
const fileItem = fileItems[i]
|
||||
const newLibraryFile = new LibraryFile()
|
||||
// fileItem.path is the relative path
|
||||
await newLibraryFile.setDataFromPath(fileItem.fullpath, fileItem.path)
|
||||
libraryItem.libraryFiles.push(newLibraryFile)
|
||||
}
|
||||
return libraryItem
|
||||
}
|
||||
module.exports.getLibraryItemFileData = getLibraryItemFileData
|
||||
module.exports.getDataFromMediaDir = getDataFromMediaDir
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue