mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-12-24 12:49:38 +00:00
Merge remote-tracking branch 'remotes/upstream/master' into allow-mrss-item-enclosures-for-podcasts
This commit is contained in:
commit
18dfbdd983
83 changed files with 2002 additions and 1276 deletions
|
|
@ -406,21 +406,6 @@ class Database {
|
|||
return Promise.all(oldBooks.map((oldBook) => this.models.book.saveFromOld(oldBook)))
|
||||
}
|
||||
|
||||
createBulkCollectionBooks(collectionBooks) {
|
||||
if (!this.sequelize) return false
|
||||
return this.models.collectionBook.bulkCreate(collectionBooks)
|
||||
}
|
||||
|
||||
createPlaylistMediaItem(playlistMediaItem) {
|
||||
if (!this.sequelize) return false
|
||||
return this.models.playlistMediaItem.create(playlistMediaItem)
|
||||
}
|
||||
|
||||
createBulkPlaylistMediaItems(playlistMediaItems) {
|
||||
if (!this.sequelize) return false
|
||||
return this.models.playlistMediaItem.bulkCreate(playlistMediaItems)
|
||||
}
|
||||
|
||||
async createLibraryItem(oldLibraryItem) {
|
||||
if (!this.sequelize) return false
|
||||
await oldLibraryItem.saveMetadata()
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ const util = require('util')
|
|||
const fs = require('./libs/fsExtra')
|
||||
const fileUpload = require('./libs/expressFileupload')
|
||||
const cookieParser = require('cookie-parser')
|
||||
const axios = require('axios')
|
||||
|
||||
const { version } = require('../package.json')
|
||||
|
||||
|
|
@ -53,7 +54,36 @@ class Server {
|
|||
global.RouterBasePath = ROUTER_BASE_PATH
|
||||
global.XAccel = process.env.USE_X_ACCEL
|
||||
global.AllowCors = process.env.ALLOW_CORS === '1'
|
||||
global.DisableSsrfRequestFilter = process.env.DISABLE_SSRF_REQUEST_FILTER === '1'
|
||||
|
||||
if (process.env.EXP_PROXY_SUPPORT === '1') {
|
||||
// https://github.com/advplyr/audiobookshelf/pull/3754
|
||||
Logger.info(`[Server] Experimental Proxy Support Enabled, SSRF Request Filter was Disabled`)
|
||||
global.DisableSsrfRequestFilter = () => true
|
||||
|
||||
axios.defaults.maxRedirects = 0
|
||||
axios.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if ([301, 302].includes(error.response?.status)) {
|
||||
return axios({
|
||||
...error.config,
|
||||
url: error.response.headers.location
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
} else if (process.env.DISABLE_SSRF_REQUEST_FILTER === '1') {
|
||||
Logger.info(`[Server] SSRF Request Filter Disabled`)
|
||||
global.DisableSsrfRequestFilter = () => true
|
||||
} else if (process.env.SSRF_REQUEST_FILTER_WHITELIST?.length) {
|
||||
const whitelistedUrls = process.env.SSRF_REQUEST_FILTER_WHITELIST.split(',').map((url) => url.trim())
|
||||
if (whitelistedUrls.length) {
|
||||
Logger.info(`[Server] SSRF Request Filter Whitelisting: ${whitelistedUrls.join(',')}`)
|
||||
global.DisableSsrfRequestFilter = (url) => whitelistedUrls.includes(new URL(url).hostname)
|
||||
}
|
||||
}
|
||||
|
||||
if (!fs.pathExistsSync(global.ConfigPath)) {
|
||||
fs.mkdirSync(global.ConfigPath)
|
||||
|
|
|
|||
|
|
@ -190,7 +190,9 @@ class FolderWatcher extends EventEmitter {
|
|||
return
|
||||
}
|
||||
Logger.debug('[Watcher] File Added', path)
|
||||
this.addFileUpdate(libraryId, path, 'added')
|
||||
if (!this.addFileUpdate(libraryId, path, 'added')) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.filesBeingAdded.has(path)) {
|
||||
this.filesBeingAdded.add(path)
|
||||
|
|
@ -261,22 +263,23 @@ class FolderWatcher extends EventEmitter {
|
|||
* @param {string} libraryId
|
||||
* @param {string} path
|
||||
* @param {string} type
|
||||
* @returns {boolean} - If file was added to pending updates
|
||||
*/
|
||||
addFileUpdate(libraryId, path, type) {
|
||||
if (this.pendingFilePaths.includes(path)) return
|
||||
if (this.pendingFilePaths.includes(path)) return false
|
||||
|
||||
// Get file library
|
||||
const libwatcher = this.libraryWatchers.find((lw) => lw.id === libraryId)
|
||||
if (!libwatcher) {
|
||||
Logger.error(`[Watcher] Invalid library id from watcher ${libraryId}`)
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
// Get file folder
|
||||
const folder = libwatcher.libraryFolders.find((fold) => isSameOrSubPath(fold.path, path))
|
||||
if (!folder) {
|
||||
Logger.error(`[Watcher] New file folder not found in library "${libwatcher.name}" with path "${path}"`)
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
const folderPath = filePathToPOSIX(folder.path)
|
||||
|
|
@ -285,14 +288,14 @@ class FolderWatcher extends EventEmitter {
|
|||
|
||||
if (Path.extname(relPath).toLowerCase() === '.part') {
|
||||
Logger.debug(`[Watcher] Ignoring .part file "${relPath}"`)
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
// 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
|
||||
return false
|
||||
}
|
||||
|
||||
Logger.debug(`[Watcher] Modified file in library "${libwatcher.name}" and folder "${folder.id}" with relPath "${relPath}"`)
|
||||
|
|
@ -318,6 +321,7 @@ class FolderWatcher extends EventEmitter {
|
|||
})
|
||||
|
||||
this.handlePendingFileUpdatesTimeout()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -5,13 +5,17 @@ const SocketAuthority = require('../SocketAuthority')
|
|||
const Database = require('../Database')
|
||||
|
||||
const RssFeedManager = require('../managers/RssFeedManager')
|
||||
const Collection = require('../objects/Collection')
|
||||
|
||||
/**
|
||||
* @typedef RequestUserObject
|
||||
* @property {import('../models/User')} user
|
||||
*
|
||||
* @typedef {Request & RequestUserObject} RequestWithUser
|
||||
*
|
||||
* @typedef RequestEntityObject
|
||||
* @property {import('../models/Collection')} collection
|
||||
*
|
||||
* @typedef {RequestWithUser & RequestEntityObject} CollectionControllerRequest
|
||||
*/
|
||||
|
||||
class CollectionController {
|
||||
|
|
@ -25,36 +29,71 @@ class CollectionController {
|
|||
* @param {Response} res
|
||||
*/
|
||||
async create(req, res) {
|
||||
const newCollection = new Collection()
|
||||
req.body.userId = req.user.id
|
||||
if (!newCollection.setData(req.body)) {
|
||||
const reqBody = req.body || {}
|
||||
|
||||
// Validation
|
||||
if (!reqBody.name || !reqBody.libraryId) {
|
||||
return res.status(400).send('Invalid collection data')
|
||||
}
|
||||
if (reqBody.description && typeof reqBody.description !== 'string') {
|
||||
return res.status(400).send('Invalid collection description')
|
||||
}
|
||||
const libraryItemIds = (reqBody.books || []).filter((b) => !!b && typeof b == 'string')
|
||||
if (!libraryItemIds.length) {
|
||||
return res.status(400).send('Invalid collection data. No books')
|
||||
}
|
||||
|
||||
// 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++
|
||||
})
|
||||
// Load library items
|
||||
const libraryItems = await Database.libraryItemModel.findAll({
|
||||
attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
|
||||
where: {
|
||||
id: libraryItemIds,
|
||||
libraryId: reqBody.libraryId,
|
||||
mediaType: 'book'
|
||||
}
|
||||
}
|
||||
if (collectionBooksToAdd.length) {
|
||||
await Database.createBulkCollectionBooks(collectionBooksToAdd)
|
||||
})
|
||||
if (libraryItems.length !== libraryItemIds.length) {
|
||||
return res.status(400).send('Invalid collection data. Invalid books')
|
||||
}
|
||||
|
||||
const jsonExpanded = newCollection.toJSONExpanded(libraryItemsInCollection)
|
||||
/** @type {import('../models/Collection')} */
|
||||
let newCollection = null
|
||||
|
||||
const transaction = await Database.sequelize.transaction()
|
||||
try {
|
||||
// Create collection
|
||||
newCollection = await Database.collectionModel.create(
|
||||
{
|
||||
libraryId: reqBody.libraryId,
|
||||
name: reqBody.name,
|
||||
description: reqBody.description || null
|
||||
},
|
||||
{ transaction }
|
||||
)
|
||||
|
||||
// Create collectionBooks
|
||||
const collectionBookPayloads = libraryItemIds.map((llid, index) => {
|
||||
const libraryItem = libraryItems.find((li) => li.id === llid)
|
||||
return {
|
||||
collectionId: newCollection.id,
|
||||
bookId: libraryItem.mediaId,
|
||||
order: index + 1
|
||||
}
|
||||
})
|
||||
await Database.collectionBookModel.bulkCreate(collectionBookPayloads, { transaction })
|
||||
|
||||
await transaction.commit()
|
||||
} catch (error) {
|
||||
await transaction.rollback()
|
||||
Logger.error('[CollectionController] create:', error)
|
||||
return res.status(500).send('Failed to create collection')
|
||||
}
|
||||
|
||||
// Load books expanded
|
||||
newCollection.books = await newCollection.getBooksExpandedWithLibraryItem()
|
||||
|
||||
// Note: The old collection model stores expanded libraryItems in the books property
|
||||
const jsonExpanded = newCollection.toOldJSONExpanded()
|
||||
SocketAuthority.emitter('collection_added', jsonExpanded)
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
|
|
@ -75,7 +114,7 @@ class CollectionController {
|
|||
/**
|
||||
* GET: /api/collections/:id
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {CollectionControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async findOne(req, res) {
|
||||
|
|
@ -94,7 +133,7 @@ class CollectionController {
|
|||
* PATCH: /api/collections/:id
|
||||
* Update collection
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {CollectionControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async update(req, res) {
|
||||
|
|
@ -158,7 +197,7 @@ class CollectionController {
|
|||
*
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {CollectionControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async delete(req, res) {
|
||||
|
|
@ -178,7 +217,7 @@ class CollectionController {
|
|||
* Add a single book to a collection
|
||||
* Req.body { id: <library item id> }
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {CollectionControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async addBook(req, res) {
|
||||
|
|
@ -212,7 +251,7 @@ class CollectionController {
|
|||
* Remove a single book from a collection. Re-order books
|
||||
* TODO: bookId is actually libraryItemId. Clients need updating to use bookId
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {CollectionControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async removeBook(req, res) {
|
||||
|
|
@ -257,29 +296,31 @@ class CollectionController {
|
|||
* Add multiple books to collection
|
||||
* Req.body { books: <Array of library item ids> }
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {CollectionControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async addBatch(req, res) {
|
||||
// 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')
|
||||
return res.status(400).send('Invalid request body')
|
||||
}
|
||||
|
||||
// Get library items associated with ids
|
||||
const libraryItems = await Database.libraryItemModel.findAll({
|
||||
attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
|
||||
where: {
|
||||
id: {
|
||||
[Sequelize.Op.in]: bookIdsToAdd
|
||||
}
|
||||
},
|
||||
include: {
|
||||
model: Database.bookModel
|
||||
id: bookIdsToAdd,
|
||||
libraryId: req.collection.libraryId,
|
||||
mediaType: 'book'
|
||||
}
|
||||
})
|
||||
if (!libraryItems.length) {
|
||||
return res.status(400).send('Invalid request body. No valid books')
|
||||
}
|
||||
|
||||
// Get collection books already in collection
|
||||
/** @type {import('../models/CollectionBook')[]} */
|
||||
const collectionBooks = await req.collection.getCollectionBooks()
|
||||
|
||||
let order = collectionBooks.length + 1
|
||||
|
|
@ -288,10 +329,10 @@ class CollectionController {
|
|||
|
||||
// Check and set new collection books to add
|
||||
for (const libraryItem of libraryItems) {
|
||||
if (!collectionBooks.some((cb) => cb.bookId === libraryItem.media.id)) {
|
||||
if (!collectionBooks.some((cb) => cb.bookId === libraryItem.mediaId)) {
|
||||
collectionBooksToAdd.push({
|
||||
collectionId: req.collection.id,
|
||||
bookId: libraryItem.media.id,
|
||||
bookId: libraryItem.mediaId,
|
||||
order: order++
|
||||
})
|
||||
hasUpdated = true
|
||||
|
|
@ -302,7 +343,8 @@ class CollectionController {
|
|||
|
||||
let jsonExpanded = null
|
||||
if (hasUpdated) {
|
||||
await Database.createBulkCollectionBooks(collectionBooksToAdd)
|
||||
await Database.collectionBookModel.bulkCreate(collectionBooksToAdd)
|
||||
|
||||
jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||
SocketAuthority.emitter('collection_updated', jsonExpanded)
|
||||
} else {
|
||||
|
|
@ -316,7 +358,7 @@ class CollectionController {
|
|||
* Remove multiple books from collection
|
||||
* Req.body { books: <Array of library item ids> }
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {CollectionControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async removeBatch(req, res) {
|
||||
|
|
@ -329,9 +371,7 @@ class CollectionController {
|
|||
// Get library items associated with ids
|
||||
const libraryItems = await Database.libraryItemModel.findAll({
|
||||
where: {
|
||||
id: {
|
||||
[Sequelize.Op.in]: bookIdsToRemove
|
||||
}
|
||||
id: bookIdsToRemove
|
||||
},
|
||||
include: {
|
||||
model: Database.bookModel
|
||||
|
|
@ -339,6 +379,7 @@ class CollectionController {
|
|||
})
|
||||
|
||||
// Get collection books already in collection
|
||||
/** @type {import('../models/CollectionBook')[]} */
|
||||
const collectionBooks = await req.collection.getCollectionBooks({
|
||||
order: [['order', 'ASC']]
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1217,7 +1217,7 @@ class LibraryController {
|
|||
Logger.error(`[LibraryController] Non-root user "${req.user.username}" attempted to match library items`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
Scanner.matchLibraryItems(req.library)
|
||||
Scanner.matchLibraryItems(this, req.library)
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -456,10 +456,24 @@ class LibraryItemController {
|
|||
* @param {Response} res
|
||||
*/
|
||||
async match(req, res) {
|
||||
var libraryItem = req.libraryItem
|
||||
const libraryItem = req.libraryItem
|
||||
const reqBody = req.body || {}
|
||||
|
||||
var options = req.body || {}
|
||||
var matchResult = await Scanner.quickMatchLibraryItem(libraryItem, options)
|
||||
const options = {}
|
||||
const matchOptions = ['provider', 'title', 'author', 'isbn', 'asin']
|
||||
for (const key of matchOptions) {
|
||||
if (reqBody[key] && typeof reqBody[key] === 'string') {
|
||||
options[key] = reqBody[key]
|
||||
}
|
||||
}
|
||||
if (reqBody.overrideCover !== undefined) {
|
||||
options.overrideCover = !!reqBody.overrideCover
|
||||
}
|
||||
if (reqBody.overrideDetails !== undefined) {
|
||||
options.overrideDetails = !!reqBody.overrideDetails
|
||||
}
|
||||
|
||||
var matchResult = await Scanner.quickMatchLibraryItem(this, libraryItem, options)
|
||||
res.json(matchResult)
|
||||
}
|
||||
|
||||
|
|
@ -642,7 +656,6 @@ class LibraryItemController {
|
|||
let itemsUpdated = 0
|
||||
let itemsUnmatched = 0
|
||||
|
||||
const options = req.body.options || {}
|
||||
if (!req.body.libraryItemIds?.length) {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
|
@ -656,8 +669,20 @@ class LibraryItemController {
|
|||
|
||||
res.sendStatus(200)
|
||||
|
||||
const reqBodyOptions = req.body.options || {}
|
||||
const options = {}
|
||||
if (reqBodyOptions.provider && typeof reqBodyOptions.provider === 'string') {
|
||||
options.provider = reqBodyOptions.provider
|
||||
}
|
||||
if (reqBodyOptions.overrideCover !== undefined) {
|
||||
options.overrideCover = !!reqBodyOptions.overrideCover
|
||||
}
|
||||
if (reqBodyOptions.overrideDetails !== undefined) {
|
||||
options.overrideDetails = !!reqBodyOptions.overrideDetails
|
||||
}
|
||||
|
||||
for (const libraryItem of libraryItems) {
|
||||
const matchResult = await Scanner.quickMatchLibraryItem(libraryItem, options)
|
||||
const matchResult = await Scanner.quickMatchLibraryItem(this, libraryItem, options)
|
||||
if (matchResult.updated) {
|
||||
itemsUpdated++
|
||||
} else if (matchResult.warning) {
|
||||
|
|
|
|||
|
|
@ -3,13 +3,16 @@ const Logger = require('../Logger')
|
|||
const SocketAuthority = require('../SocketAuthority')
|
||||
const Database = require('../Database')
|
||||
|
||||
const Playlist = require('../objects/Playlist')
|
||||
|
||||
/**
|
||||
* @typedef RequestUserObject
|
||||
* @property {import('../models/User')} user
|
||||
*
|
||||
* @typedef {Request & RequestUserObject} RequestWithUser
|
||||
*
|
||||
* @typedef RequestEntityObject
|
||||
* @property {import('../models/Playlist')} playlist
|
||||
*
|
||||
* @typedef {RequestWithUser & RequestEntityObject} PlaylistControllerRequest
|
||||
*/
|
||||
|
||||
class PlaylistController {
|
||||
|
|
@ -23,48 +26,103 @@ class PlaylistController {
|
|||
* @param {Response} res
|
||||
*/
|
||||
async create(req, res) {
|
||||
const oldPlaylist = new Playlist()
|
||||
req.body.userId = req.user.id
|
||||
const success = oldPlaylist.setData(req.body)
|
||||
if (!success) {
|
||||
return res.status(400).send('Invalid playlist request data')
|
||||
const reqBody = req.body || {}
|
||||
|
||||
// Validation
|
||||
if (!reqBody.name || !reqBody.libraryId) {
|
||||
return res.status(400).send('Invalid playlist data')
|
||||
}
|
||||
if (reqBody.description && typeof reqBody.description !== 'string') {
|
||||
return res.status(400).send('Invalid playlist description')
|
||||
}
|
||||
const items = reqBody.items || []
|
||||
const isPodcast = items.some((i) => i.episodeId)
|
||||
const libraryItemIds = new Set()
|
||||
for (const item of items) {
|
||||
if (!item.libraryItemId || typeof item.libraryItemId !== 'string') {
|
||||
return res.status(400).send('Invalid playlist item')
|
||||
}
|
||||
if (isPodcast && (!item.episodeId || typeof item.episodeId !== 'string')) {
|
||||
return res.status(400).send('Invalid playlist item episodeId')
|
||||
} else if (!isPodcast && item.episodeId) {
|
||||
return res.status(400).send('Invalid playlist item episodeId')
|
||||
}
|
||||
libraryItemIds.add(item.libraryItemId)
|
||||
}
|
||||
|
||||
// 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({
|
||||
// Load library items
|
||||
const libraryItems = await Database.libraryItemModel.findAll({
|
||||
attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
|
||||
where: {
|
||||
id: libraryItemIds
|
||||
id: Array.from(libraryItemIds),
|
||||
libraryId: reqBody.libraryId,
|
||||
mediaType: isPodcast ? 'podcast' : 'book'
|
||||
}
|
||||
})
|
||||
if (libraryItems.length !== libraryItemIds.size) {
|
||||
return res.status(400).send('Invalid playlist data. Invalid items')
|
||||
}
|
||||
|
||||
// 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++
|
||||
// Validate podcast episodes
|
||||
if (isPodcast) {
|
||||
const podcastEpisodeIds = items.map((i) => i.episodeId)
|
||||
const podcastEpisodes = await Database.podcastEpisodeModel.findAll({
|
||||
attributes: ['id'],
|
||||
where: {
|
||||
id: podcastEpisodeIds
|
||||
}
|
||||
})
|
||||
}
|
||||
if (mediaItemsToAdd.length) {
|
||||
await Database.createBulkPlaylistMediaItems(mediaItemsToAdd)
|
||||
if (podcastEpisodes.length !== podcastEpisodeIds.length) {
|
||||
return res.status(400).send('Invalid playlist data. Invalid podcast episodes')
|
||||
}
|
||||
}
|
||||
|
||||
const jsonExpanded = await newPlaylist.getOldJsonExpanded()
|
||||
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
|
||||
res.json(jsonExpanded)
|
||||
const transaction = await Database.sequelize.transaction()
|
||||
try {
|
||||
// Create playlist
|
||||
const newPlaylist = await Database.playlistModel.create(
|
||||
{
|
||||
libraryId: reqBody.libraryId,
|
||||
userId: req.user.id,
|
||||
name: reqBody.name,
|
||||
description: reqBody.description || null
|
||||
},
|
||||
{ transaction }
|
||||
)
|
||||
|
||||
// Create playlistMediaItems
|
||||
const playlistItemPayloads = []
|
||||
for (const [index, item] of items.entries()) {
|
||||
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
|
||||
playlistItemPayloads.push({
|
||||
playlistId: newPlaylist.id,
|
||||
mediaItemId: item.episodeId || libraryItem.mediaId,
|
||||
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
|
||||
order: index + 1
|
||||
})
|
||||
}
|
||||
|
||||
await Database.playlistMediaItemModel.bulkCreate(playlistItemPayloads, { transaction })
|
||||
|
||||
await transaction.commit()
|
||||
|
||||
newPlaylist.playlistMediaItems = await newPlaylist.getMediaItemsExpandedWithLibraryItem()
|
||||
|
||||
const jsonExpanded = newPlaylist.toOldJSONExpanded()
|
||||
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
|
||||
res.json(jsonExpanded)
|
||||
} catch (error) {
|
||||
await transaction.rollback()
|
||||
Logger.error('[PlaylistController] create:', error)
|
||||
res.status(500).send('Failed to create playlist')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated - Use /api/libraries/:libraryId/playlists
|
||||
* This is not used by Abs web client or mobile apps
|
||||
* TODO: Remove this endpoint or make it the primary
|
||||
*
|
||||
* GET: /api/playlists
|
||||
* Get all playlists for user
|
||||
*
|
||||
|
|
@ -72,68 +130,89 @@ class PlaylistController {
|
|||
* @param {Response} res
|
||||
*/
|
||||
async findAllForUser(req, res) {
|
||||
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)
|
||||
}
|
||||
const playlistsForUser = await Database.playlistModel.getOldPlaylistsForUserAndLibrary(req.user.id)
|
||||
res.json({
|
||||
playlists
|
||||
playlists: playlistsForUser
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/playlists/:id
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {PlaylistControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async findOne(req, res) {
|
||||
const jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||
res.json(jsonExpanded)
|
||||
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
|
||||
res.json(req.playlist.toOldJSONExpanded())
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH: /api/playlists/:id
|
||||
* Update playlist
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* Used for updating name and description or reordering items
|
||||
*
|
||||
* @param {PlaylistControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async update(req, res) {
|
||||
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
|
||||
// Validation
|
||||
const reqBody = req.body || {}
|
||||
if (reqBody.libraryId || reqBody.userId) {
|
||||
// Could allow support for this if needed with additional validation
|
||||
return res.status(400).send('Invalid playlist data. Cannot update libraryId or userId')
|
||||
}
|
||||
if (reqBody.name && typeof reqBody.name !== 'string') {
|
||||
return res.status(400).send('Invalid playlist name')
|
||||
}
|
||||
if (reqBody.description && typeof reqBody.description !== 'string') {
|
||||
return res.status(400).send('Invalid playlist description')
|
||||
}
|
||||
if (reqBody.items && (!Array.isArray(reqBody.items) || reqBody.items.some((i) => !i.libraryItemId || typeof i.libraryItemId !== 'string' || (i.episodeId && typeof i.episodeId !== 'string')))) {
|
||||
return res.status(400).send('Invalid playlist items')
|
||||
}
|
||||
|
||||
// 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 playlistUpdatePayload = {}
|
||||
if (reqBody.name) playlistUpdatePayload.name = reqBody.name
|
||||
if (reqBody.description) playlistUpdatePayload.description = reqBody.description
|
||||
|
||||
// Update name and description
|
||||
let wasUpdated = false
|
||||
if (Object.keys(playlistUpdatePayload).length) {
|
||||
req.playlist.set(playlistUpdatePayload)
|
||||
const changed = req.playlist.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 set then update order of playlist media items
|
||||
if (reqBody.items?.length) {
|
||||
const libraryItemIds = Array.from(new Set(reqBody.items.map((i) => i.libraryItemId)))
|
||||
const libraryItems = await Database.libraryItemModel.findAll({
|
||||
attributes: ['id', 'mediaId', 'mediaType'],
|
||||
where: {
|
||||
id: libraryItemIds
|
||||
}
|
||||
})
|
||||
const existingPlaylistMediaItems = await updatedPlaylist.getPlaylistMediaItems({
|
||||
if (libraryItems.length !== libraryItemIds.length) {
|
||||
return res.status(400).send('Invalid playlist items. Items not found')
|
||||
}
|
||||
/** @type {import('../models/PlaylistMediaItem')[]} */
|
||||
const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({
|
||||
order: [['order', 'ASC']]
|
||||
})
|
||||
if (existingPlaylistMediaItems.length !== reqBody.items.length) {
|
||||
return res.status(400).send('Invalid playlist items. Length mismatch')
|
||||
}
|
||||
|
||||
// Set an array of mediaItemId
|
||||
const newMediaItemIdOrder = []
|
||||
for (const item of req.body.items) {
|
||||
for (const item of reqBody.items) {
|
||||
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
|
||||
if (!libraryItem) {
|
||||
continue
|
||||
}
|
||||
const mediaItemId = item.episodeId || libraryItem.mediaId
|
||||
newMediaItemIdOrder.push(mediaItemId)
|
||||
}
|
||||
|
|
@ -146,21 +225,21 @@ class PlaylistController {
|
|||
})
|
||||
|
||||
// Update order on playlistMediaItem records
|
||||
let order = 1
|
||||
for (const playlistMediaItem of existingPlaylistMediaItems) {
|
||||
if (playlistMediaItem.order !== order) {
|
||||
for (const [index, playlistMediaItem] of existingPlaylistMediaItems.entries()) {
|
||||
if (playlistMediaItem.order !== index + 1) {
|
||||
await playlistMediaItem.update({
|
||||
order
|
||||
order: index + 1
|
||||
})
|
||||
wasUpdated = true
|
||||
}
|
||||
order++
|
||||
}
|
||||
}
|
||||
|
||||
const jsonExpanded = await updatedPlaylist.getOldJsonExpanded()
|
||||
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
|
||||
|
||||
const jsonExpanded = req.playlist.toOldJSONExpanded()
|
||||
if (wasUpdated) {
|
||||
SocketAuthority.clientEmitter(updatedPlaylist.userId, 'playlist_updated', jsonExpanded)
|
||||
SocketAuthority.clientEmitter(req.playlist.userId, 'playlist_updated', jsonExpanded)
|
||||
}
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
|
|
@ -169,11 +248,13 @@ class PlaylistController {
|
|||
* DELETE: /api/playlists/:id
|
||||
* Remove playlist
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {PlaylistControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async delete(req, res) {
|
||||
const jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
|
||||
const jsonExpanded = req.playlist.toOldJSONExpanded()
|
||||
|
||||
await req.playlist.destroy()
|
||||
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
|
||||
res.sendStatus(200)
|
||||
|
|
@ -183,12 +264,13 @@ class PlaylistController {
|
|||
* POST: /api/playlists/:id/item
|
||||
* Add item to playlist
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* This is not used by Abs web client or mobile apps. Only the batch endpoints are used.
|
||||
*
|
||||
* @param {PlaylistControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async addItem(req, res) {
|
||||
const oldPlaylist = await Database.playlistModel.getById(req.playlist.id)
|
||||
const itemToAdd = req.body
|
||||
const itemToAdd = req.body || {}
|
||||
|
||||
if (!itemToAdd.libraryItemId) {
|
||||
return res.status(400).send('Request body has no libraryItemId')
|
||||
|
|
@ -198,12 +280,9 @@ class PlaylistController {
|
|||
if (!libraryItem) {
|
||||
return res.status(400).send('Library item not found')
|
||||
}
|
||||
if (libraryItem.libraryId !== oldPlaylist.libraryId) {
|
||||
if (libraryItem.libraryId !== req.playlist.libraryId) {
|
||||
return res.status(400).send('Library item in different library')
|
||||
}
|
||||
if (oldPlaylist.containsItem(itemToAdd)) {
|
||||
return res.status(400).send('Item already in playlist')
|
||||
}
|
||||
if ((itemToAdd.episodeId && !libraryItem.isPodcast) || (libraryItem.isPodcast && !itemToAdd.episodeId)) {
|
||||
return res.status(400).send('Invalid item to add for this library type')
|
||||
}
|
||||
|
|
@ -211,15 +290,38 @@ class PlaylistController {
|
|||
return res.status(400).send('Episode not found in library item')
|
||||
}
|
||||
|
||||
const playlistMediaItem = {
|
||||
playlistId: oldPlaylist.id,
|
||||
mediaItemId: itemToAdd.episodeId || libraryItem.media.id,
|
||||
mediaItemType: itemToAdd.episodeId ? 'podcastEpisode' : 'book',
|
||||
order: oldPlaylist.items.length + 1
|
||||
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
|
||||
|
||||
if (req.playlist.checkHasMediaItem(itemToAdd.libraryItemId, itemToAdd.episodeId)) {
|
||||
return res.status(400).send('Item already in playlist')
|
||||
}
|
||||
|
||||
const jsonExpanded = req.playlist.toOldJSONExpanded()
|
||||
|
||||
const playlistMediaItem = {
|
||||
playlistId: req.playlist.id,
|
||||
mediaItemId: itemToAdd.episodeId || libraryItem.media.id,
|
||||
mediaItemType: itemToAdd.episodeId ? 'podcastEpisode' : 'book',
|
||||
order: req.playlist.playlistMediaItems.length + 1
|
||||
}
|
||||
await Database.playlistMediaItemModel.create(playlistMediaItem)
|
||||
|
||||
// Add the new item to to the old json expanded to prevent having to fully reload the playlist media items
|
||||
if (itemToAdd.episodeId) {
|
||||
const episode = libraryItem.media.episodes.find((ep) => ep.id === itemToAdd.episodeId)
|
||||
jsonExpanded.items.push({
|
||||
episodeId: itemToAdd.episodeId,
|
||||
episode: episode.toJSONExpanded(),
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryItem: libraryItem.toJSONMinified()
|
||||
})
|
||||
} else {
|
||||
jsonExpanded.items.push({
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryItem: libraryItem.toJSONExpanded()
|
||||
})
|
||||
}
|
||||
|
||||
await Database.createPlaylistMediaItem(playlistMediaItem)
|
||||
const jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_updated', jsonExpanded)
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
|
|
@ -228,43 +330,36 @@ class PlaylistController {
|
|||
* DELETE: /api/playlists/:id/item/:libraryItemId/:episodeId?
|
||||
* Remove item from playlist
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {PlaylistControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async removeItem(req, res) {
|
||||
const oldLibraryItem = await Database.libraryItemModel.getOldById(req.params.libraryItemId)
|
||||
if (!oldLibraryItem) {
|
||||
return res.status(404).send('Library item not found')
|
||||
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
|
||||
|
||||
let playlistMediaItem = null
|
||||
if (req.params.episodeId) {
|
||||
playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItemId === req.params.episodeId)
|
||||
} else {
|
||||
playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItem.libraryItem?.id === req.params.libraryItemId)
|
||||
}
|
||||
|
||||
// Get playlist media items
|
||||
const mediaItemId = req.params.episodeId || oldLibraryItem.media.id
|
||||
const playlistMediaItems = await req.playlist.getPlaylistMediaItems({
|
||||
order: [['order', 'ASC']]
|
||||
})
|
||||
|
||||
// Check if media item to delete is in playlist
|
||||
const mediaItemToRemove = playlistMediaItems.find((pmi) => pmi.mediaItemId === mediaItemId)
|
||||
if (!mediaItemToRemove) {
|
||||
if (!playlistMediaItem) {
|
||||
return res.status(404).send('Media item not found in playlist')
|
||||
}
|
||||
|
||||
// Remove record
|
||||
await mediaItemToRemove.destroy()
|
||||
await playlistMediaItem.destroy()
|
||||
req.playlist.playlistMediaItems = req.playlist.playlistMediaItems.filter((pmi) => pmi.id !== playlistMediaItem.id)
|
||||
|
||||
// Update playlist media items order
|
||||
let order = 1
|
||||
for (const mediaItem of playlistMediaItems) {
|
||||
if (mediaItem.mediaItemId === mediaItemId) continue
|
||||
if (mediaItem.order !== order) {
|
||||
for (const [index, mediaItem] of req.playlist.playlistMediaItems.entries()) {
|
||||
if (mediaItem.order !== index + 1) {
|
||||
await mediaItem.update({
|
||||
order
|
||||
order: index + 1
|
||||
})
|
||||
}
|
||||
order++
|
||||
}
|
||||
|
||||
const jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||
const jsonExpanded = req.playlist.toOldJSONExpanded()
|
||||
|
||||
// Playlist is removed when there are no items
|
||||
if (!jsonExpanded.items.length) {
|
||||
|
|
@ -282,64 +377,68 @@ class PlaylistController {
|
|||
* POST: /api/playlists/:id/batch/add
|
||||
* Batch add playlist items
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {PlaylistControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async addBatch(req, res) {
|
||||
if (!req.body.items?.length) {
|
||||
return res.status(400).send('Invalid request body')
|
||||
}
|
||||
const itemsToAdd = req.body.items
|
||||
|
||||
const libraryItemIds = itemsToAdd.map((i) => i.libraryItemId).filter((i) => i)
|
||||
if (!libraryItemIds.length) {
|
||||
return res.status(400).send('Invalid request body')
|
||||
if (!req.body.items?.length || !Array.isArray(req.body.items) || req.body.items.some((i) => !i?.libraryItemId || typeof i.libraryItemId !== 'string' || (i.episodeId && typeof i.episodeId !== 'string'))) {
|
||||
return res.status(400).send('Invalid request body items')
|
||||
}
|
||||
|
||||
// Find all library items
|
||||
const libraryItems = await Database.libraryItemModel.findAll({
|
||||
where: {
|
||||
id: libraryItemIds
|
||||
}
|
||||
})
|
||||
const libraryItemIds = new Set(req.body.items.map((i) => i.libraryItemId).filter((i) => i))
|
||||
|
||||
// Get all existing playlist media items
|
||||
const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({
|
||||
order: [['order', 'ASC']]
|
||||
})
|
||||
const oldLibraryItems = await Database.libraryItemModel.getAllOldLibraryItems({ id: Array.from(libraryItemIds) })
|
||||
if (oldLibraryItems.length !== libraryItemIds.size) {
|
||||
return res.status(400).send('Invalid request body items')
|
||||
}
|
||||
|
||||
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
|
||||
|
||||
const mediaItemsToAdd = []
|
||||
const jsonExpanded = req.playlist.toOldJSONExpanded()
|
||||
|
||||
// Setup array of playlistMediaItem records to add
|
||||
let order = existingPlaylistMediaItems.length + 1
|
||||
for (const item of itemsToAdd) {
|
||||
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
|
||||
if (!libraryItem) {
|
||||
return res.status(404).send('Item not found with id ' + item.libraryItemId)
|
||||
let order = req.playlist.playlistMediaItems.length + 1
|
||||
for (const item of req.body.items) {
|
||||
const libraryItem = oldLibraryItems.find((li) => li.id === item.libraryItemId)
|
||||
|
||||
const mediaItemId = item.episodeId || libraryItem.media.id
|
||||
if (req.playlist.playlistMediaItems.some((pmi) => pmi.mediaItemId === mediaItemId)) {
|
||||
// Already exists in playlist
|
||||
continue
|
||||
} else {
|
||||
const mediaItemId = item.episodeId || libraryItem.mediaId
|
||||
if (existingPlaylistMediaItems.some((pmi) => pmi.mediaItemId === mediaItemId)) {
|
||||
// Already exists in playlist
|
||||
continue
|
||||
mediaItemsToAdd.push({
|
||||
playlistId: req.playlist.id,
|
||||
mediaItemId,
|
||||
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
|
||||
order: order++
|
||||
})
|
||||
|
||||
// Add the new item to to the old json expanded to prevent having to fully reload the playlist media items
|
||||
if (item.episodeId) {
|
||||
const episode = libraryItem.media.episodes.find((ep) => ep.id === item.episodeId)
|
||||
jsonExpanded.items.push({
|
||||
episodeId: item.episodeId,
|
||||
episode: episode.toJSONExpanded(),
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryItem: libraryItem.toJSONMinified()
|
||||
})
|
||||
} else {
|
||||
mediaItemsToAdd.push({
|
||||
playlistId: req.playlist.id,
|
||||
mediaItemId,
|
||||
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
|
||||
order: order++
|
||||
jsonExpanded.items.push({
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryItem: libraryItem.toJSONExpanded()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let jsonExpanded = null
|
||||
if (mediaItemsToAdd.length) {
|
||||
await Database.createBulkPlaylistMediaItems(mediaItemsToAdd)
|
||||
jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||
await Database.playlistMediaItemModel.bulkCreate(mediaItemsToAdd)
|
||||
|
||||
SocketAuthority.clientEmitter(req.playlist.userId, 'playlist_updated', jsonExpanded)
|
||||
} else {
|
||||
jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||
}
|
||||
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
|
||||
|
|
@ -347,50 +446,40 @@ class PlaylistController {
|
|||
* POST: /api/playlists/:id/batch/remove
|
||||
* Batch remove playlist items
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {PlaylistControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async removeBatch(req, res) {
|
||||
if (!req.body.items?.length) {
|
||||
return res.status(400).send('Invalid request body')
|
||||
if (!req.body.items?.length || !Array.isArray(req.body.items) || req.body.items.some((i) => !i?.libraryItemId || typeof i.libraryItemId !== 'string' || (i.episodeId && typeof i.episodeId !== 'string'))) {
|
||||
return res.status(400).send('Invalid request body items')
|
||||
}
|
||||
|
||||
const itemsToRemove = req.body.items
|
||||
const libraryItemIds = itemsToRemove.map((i) => i.libraryItemId).filter((i) => i)
|
||||
if (!libraryItemIds.length) {
|
||||
return res.status(400).send('Invalid request body')
|
||||
}
|
||||
|
||||
// Find all library items
|
||||
const libraryItems = await Database.libraryItemModel.findAll({
|
||||
where: {
|
||||
id: libraryItemIds
|
||||
}
|
||||
})
|
||||
|
||||
// Get all existing playlist media items for playlist
|
||||
const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({
|
||||
order: [['order', 'ASC']]
|
||||
})
|
||||
let numMediaItems = existingPlaylistMediaItems.length
|
||||
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
|
||||
|
||||
// Remove playlist media items
|
||||
let hasUpdated = false
|
||||
for (const item of itemsToRemove) {
|
||||
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()
|
||||
for (const item of req.body.items) {
|
||||
let playlistMediaItem = null
|
||||
if (item.episodeId) {
|
||||
playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItemId === item.episodeId)
|
||||
} else {
|
||||
playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItem.libraryItem?.id === item.libraryItemId)
|
||||
}
|
||||
if (!playlistMediaItem) {
|
||||
Logger.warn(`[PlaylistController] Playlist item not found in playlist ${req.playlist.id}`, item)
|
||||
continue
|
||||
}
|
||||
|
||||
await playlistMediaItem.destroy()
|
||||
req.playlist.playlistMediaItems = req.playlist.playlistMediaItems.filter((pmi) => pmi.id !== playlistMediaItem.id)
|
||||
|
||||
hasUpdated = true
|
||||
numMediaItems--
|
||||
}
|
||||
|
||||
const jsonExpanded = await req.playlist.getOldJsonExpanded()
|
||||
const jsonExpanded = req.playlist.toOldJSONExpanded()
|
||||
if (hasUpdated) {
|
||||
// Playlist is removed when there are no items
|
||||
if (!numMediaItems) {
|
||||
if (!req.playlist.playlistMediaItems.length) {
|
||||
Logger.info(`[PlaylistController] Playlist "${req.playlist.name}" has no more items - removing it`)
|
||||
await req.playlist.destroy()
|
||||
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
|
||||
|
|
@ -425,33 +514,41 @@ class PlaylistController {
|
|||
return res.status(400).send('Collection has no books')
|
||||
}
|
||||
|
||||
const oldPlaylist = new Playlist()
|
||||
oldPlaylist.setData({
|
||||
userId: req.user.id,
|
||||
libraryId: collection.libraryId,
|
||||
name: collection.name,
|
||||
description: collection.description || null
|
||||
})
|
||||
const transaction = await Database.sequelize.transaction()
|
||||
try {
|
||||
const playlist = await Database.playlistModel.create(
|
||||
{
|
||||
userId: req.user.id,
|
||||
libraryId: collection.libraryId,
|
||||
name: collection.name,
|
||||
description: collection.description || null
|
||||
},
|
||||
{ transaction }
|
||||
)
|
||||
|
||||
// Create Playlist record
|
||||
const newPlaylist = await Database.playlistModel.createFromOld(oldPlaylist)
|
||||
const mediaItemsToAdd = []
|
||||
for (const [index, libraryItem] of collectionExpanded.books.entries()) {
|
||||
mediaItemsToAdd.push({
|
||||
playlistId: playlist.id,
|
||||
mediaItemId: libraryItem.media.id,
|
||||
mediaItemType: 'book',
|
||||
order: index + 1
|
||||
})
|
||||
}
|
||||
await Database.playlistMediaItemModel.bulkCreate(mediaItemsToAdd, { transaction })
|
||||
|
||||
// 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 transaction.commit()
|
||||
|
||||
playlist.playlistMediaItems = await playlist.getMediaItemsExpandedWithLibraryItem()
|
||||
|
||||
const jsonExpanded = playlist.toOldJSONExpanded()
|
||||
SocketAuthority.clientEmitter(playlist.userId, 'playlist_added', jsonExpanded)
|
||||
res.json(jsonExpanded)
|
||||
} catch (error) {
|
||||
await transaction.rollback()
|
||||
Logger.error('[PlaylistController] createFromCollection:', error)
|
||||
res.status(500).send('Failed to create playlist')
|
||||
}
|
||||
await Database.createBulkPlaylistMediaItems(mediaItemsToAdd)
|
||||
|
||||
const jsonExpanded = await newPlaylist.getOldJsonExpanded()
|
||||
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ const Database = require('../Database')
|
|||
|
||||
const { PlayMethod } = require('../utils/constants')
|
||||
const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUtils')
|
||||
const zipHelpers = require('../utils/zipHelpers')
|
||||
|
||||
const PlaybackSession = require('../objects/PlaybackSession')
|
||||
const ShareManager = require('../managers/ShareManager')
|
||||
|
|
@ -210,6 +211,65 @@ class ShareController {
|
|||
res.sendFile(audioTrackPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Public route - requires share_session_id cookie
|
||||
*
|
||||
* GET: /api/share/:slug/download
|
||||
* Downloads media item share
|
||||
*
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async downloadMediaItemShare(req, res) {
|
||||
if (!req.cookies.share_session_id) {
|
||||
return res.status(404).send('Share session not set')
|
||||
}
|
||||
|
||||
const { slug } = req.params
|
||||
const mediaItemShare = ShareManager.findBySlug(slug)
|
||||
if (!mediaItemShare) {
|
||||
return res.status(404)
|
||||
}
|
||||
if (!mediaItemShare.isDownloadable) {
|
||||
return res.status(403).send('Download is not allowed for this item')
|
||||
}
|
||||
|
||||
const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id)
|
||||
if (!playbackSession || playbackSession.mediaItemShareId !== mediaItemShare.id) {
|
||||
return res.status(404).send('Share session not found')
|
||||
}
|
||||
|
||||
const libraryItem = await Database.libraryItemModel.findByPk(playbackSession.libraryItemId, {
|
||||
attributes: ['id', 'path', 'relPath', 'isFile']
|
||||
})
|
||||
if (!libraryItem) {
|
||||
return res.status(404).send('Library item not found')
|
||||
}
|
||||
|
||||
const itemPath = libraryItem.path
|
||||
const itemTitle = playbackSession.displayTitle
|
||||
|
||||
Logger.info(`[ShareController] Requested download for book "${itemTitle}" at "${itemPath}"`)
|
||||
|
||||
try {
|
||||
if (libraryItem.isFile) {
|
||||
const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(itemPath))
|
||||
if (audioMimeType) {
|
||||
res.setHeader('Content-Type', audioMimeType)
|
||||
}
|
||||
await new Promise((resolve, reject) => res.download(itemPath, libraryItem.relPath, (error) => (error ? reject(error) : resolve())))
|
||||
} else {
|
||||
const filename = `${itemTitle}.zip`
|
||||
await zipHelpers.zipDirectoryPipe(itemPath, filename, res)
|
||||
}
|
||||
|
||||
Logger.info(`[ShareController] Downloaded item "${itemTitle}" at "${itemPath}"`)
|
||||
} catch (error) {
|
||||
Logger.error(`[ShareController] Download failed for item "${itemTitle}" at "${itemPath}"`, error)
|
||||
res.status(500).send('Failed to download the item')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public route - requires share_session_id cookie
|
||||
*
|
||||
|
|
@ -259,7 +319,7 @@ class ShareController {
|
|||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const { slug, expiresAt, mediaItemType, mediaItemId } = req.body
|
||||
const { slug, expiresAt, mediaItemType, mediaItemId, isDownloadable } = req.body
|
||||
|
||||
if (!slug?.trim?.() || typeof mediaItemType !== 'string' || typeof mediaItemId !== 'string') {
|
||||
return res.status(400).send('Missing or invalid required fields')
|
||||
|
|
@ -298,7 +358,8 @@ class ShareController {
|
|||
expiresAt: expiresAt || null,
|
||||
mediaItemId,
|
||||
mediaItemType,
|
||||
userId: req.user.id
|
||||
userId: req.user.id,
|
||||
isDownloadable
|
||||
})
|
||||
|
||||
ShareManager.openMediaItemShare(mediaItemShare)
|
||||
|
|
|
|||
|
|
@ -98,11 +98,22 @@ class RssFeedManager {
|
|||
podcastId: feed.entity.mediaId
|
||||
},
|
||||
attributes: ['id', 'updatedAt'],
|
||||
order: [['createdAt', 'DESC']]
|
||||
order: [['updatedAt', 'DESC']]
|
||||
})
|
||||
|
||||
if (mostRecentPodcastEpisode && mostRecentPodcastEpisode.updatedAt > newEntityUpdatedAt) {
|
||||
newEntityUpdatedAt = mostRecentPodcastEpisode.updatedAt
|
||||
}
|
||||
} else {
|
||||
const book = await Database.bookModel.findOne({
|
||||
where: {
|
||||
id: feed.entity.mediaId
|
||||
},
|
||||
attributes: ['id', 'updatedAt']
|
||||
})
|
||||
if (book && book.updatedAt > newEntityUpdatedAt) {
|
||||
newEntityUpdatedAt = book.updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
return newEntityUpdatedAt > feed.entityUpdatedAt
|
||||
|
|
@ -111,7 +122,7 @@ class RssFeedManager {
|
|||
attributes: ['id', 'updatedAt'],
|
||||
include: {
|
||||
model: Database.bookModel,
|
||||
attributes: ['id'],
|
||||
attributes: ['id', 'audioFiles', 'updatedAt'],
|
||||
through: {
|
||||
attributes: []
|
||||
},
|
||||
|
|
@ -122,13 +133,16 @@ class RssFeedManager {
|
|||
}
|
||||
})
|
||||
|
||||
const totalBookTracks = feed.entity.books.reduce((total, book) => total + book.includedAudioFiles.length, 0)
|
||||
if (feed.feedEpisodes.length !== totalBookTracks) {
|
||||
return true
|
||||
}
|
||||
|
||||
let newEntityUpdatedAt = feed.entity.updatedAt
|
||||
|
||||
const mostRecentItemUpdatedAt = feed.entity.books.reduce((mostRecent, book) => {
|
||||
if (book.libraryItem.updatedAt > mostRecent) {
|
||||
return book.libraryItem.updatedAt
|
||||
}
|
||||
return mostRecent
|
||||
let updatedAt = book.libraryItem.updatedAt > book.updatedAt ? book.libraryItem.updatedAt : book.updatedAt
|
||||
return updatedAt > mostRecent ? updatedAt : mostRecent
|
||||
}, 0)
|
||||
|
||||
if (mostRecentItemUpdatedAt > newEntityUpdatedAt) {
|
||||
|
|
@ -151,6 +165,9 @@ class RssFeedManager {
|
|||
let feed = await Database.feedModel.findOne({
|
||||
where: {
|
||||
slug: req.params.slug
|
||||
},
|
||||
include: {
|
||||
model: Database.feedEpisodeModel
|
||||
}
|
||||
})
|
||||
if (!feed) {
|
||||
|
|
@ -163,8 +180,6 @@ class RssFeedManager {
|
|||
if (feedRequiresUpdate) {
|
||||
Logger.info(`[RssFeedManager] Feed "${feed.title}" requires update - updating feed`)
|
||||
feed = await feed.updateFeedForEntity()
|
||||
} else {
|
||||
feed.feedEpisodes = await feed.getFeedEpisodes()
|
||||
}
|
||||
|
||||
const xml = feed.buildXml(req.originalHostPrefix)
|
||||
|
|
@ -342,7 +357,6 @@ class RssFeedManager {
|
|||
}
|
||||
})
|
||||
if (!feed) {
|
||||
Logger.warn(`[RssFeedManager] closeFeedForEntityId: Feed not found for entity id ${entityId}`)
|
||||
return false
|
||||
}
|
||||
return this.handleCloseFeed(feed)
|
||||
|
|
|
|||
|
|
@ -11,3 +11,5 @@ Please add a record of every database migration that you create to this file. Th
|
|||
| v2.17.3 | v2.17.3-fk-constraints | Changes the foreign key constraints for tables due to sequelize bug dropping constraints in v2.17.0 migration |
|
||||
| v2.17.4 | v2.17.4-use-subfolder-for-oidc-redirect-uris | Save subfolder to OIDC redirect URIs to support existing installations |
|
||||
| v2.17.5 | v2.17.5-remove-host-from-feed-urls | removes the host (serverAddress) from URL columns in the feeds and feedEpisodes tables |
|
||||
| v2.17.6 | v2.17.6-share-add-isdownloadable | Adds the isDownloadable column to the mediaItemShares table |
|
||||
| v2.17.7 | v2.17.7-add-indices | Adds indices to the libraryItems and books tables to reduce query times |
|
||||
|
|
|
|||
68
server/migrations/v2.17.6-share-add-isdownloadable.js
Normal file
68
server/migrations/v2.17.6-share-add-isdownloadable.js
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* @typedef MigrationContext
|
||||
* @property {import('sequelize').QueryInterface} queryInterface - a Sequelize QueryInterface object.
|
||||
* @property {import('../Logger')} logger - a Logger object.
|
||||
*
|
||||
* @typedef MigrationOptions
|
||||
* @property {MigrationContext} context - an object containing the migration context.
|
||||
*/
|
||||
|
||||
const migrationVersion = '2.17.6'
|
||||
const migrationName = `${migrationVersion}-share-add-isdownloadable`
|
||||
const loggerPrefix = `[${migrationVersion} migration]`
|
||||
|
||||
/**
|
||||
* This migration script adds the isDownloadable column to the mediaItemShares table.
|
||||
*
|
||||
* @param {MigrationOptions} options - an object containing the migration context.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function up({ context: { queryInterface, logger } }) {
|
||||
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
|
||||
|
||||
if (await queryInterface.tableExists('mediaItemShares')) {
|
||||
const tableDescription = await queryInterface.describeTable('mediaItemShares')
|
||||
if (!tableDescription.isDownloadable) {
|
||||
logger.info(`${loggerPrefix} Adding isDownloadable column to mediaItemShares table`)
|
||||
await queryInterface.addColumn('mediaItemShares', 'isDownloadable', {
|
||||
type: queryInterface.sequelize.Sequelize.DataTypes.BOOLEAN,
|
||||
defaultValue: false,
|
||||
allowNull: false
|
||||
})
|
||||
logger.info(`${loggerPrefix} Added isDownloadable column to mediaItemShares table`)
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} isDownloadable column already exists in mediaItemShares table`)
|
||||
}
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} mediaItemShares table does not exist`)
|
||||
}
|
||||
|
||||
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* This migration script removes the isDownloadable column from the mediaItemShares table.
|
||||
*
|
||||
* @param {MigrationOptions} options - an object containing the migration context.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function down({ context: { queryInterface, logger } }) {
|
||||
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
|
||||
|
||||
if (await queryInterface.tableExists('mediaItemShares')) {
|
||||
const tableDescription = await queryInterface.describeTable('mediaItemShares')
|
||||
if (tableDescription.isDownloadable) {
|
||||
logger.info(`${loggerPrefix} Removing isDownloadable column from mediaItemShares table`)
|
||||
await queryInterface.removeColumn('mediaItemShares', 'isDownloadable')
|
||||
logger.info(`${loggerPrefix} Removed isDownloadable column from mediaItemShares table`)
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} isDownloadable column does not exist in mediaItemShares table`)
|
||||
}
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} mediaItemShares table does not exist`)
|
||||
}
|
||||
|
||||
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
module.exports = { up, down }
|
||||
83
server/migrations/v2.17.7-add-indices.js
Normal file
83
server/migrations/v2.17.7-add-indices.js
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* @typedef MigrationContext
|
||||
* @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
|
||||
* @property {import('../Logger')} logger - a Logger object.
|
||||
*
|
||||
* @typedef MigrationOptions
|
||||
* @property {MigrationContext} context - an object containing the migration context.
|
||||
*/
|
||||
|
||||
const migrationVersion = '2.17.7'
|
||||
const migrationName = `${migrationVersion}-add-indices`
|
||||
const loggerPrefix = `[${migrationVersion} migration]`
|
||||
|
||||
/**
|
||||
* This upward migration adds some indices to the libraryItems and books tables to improve query performance
|
||||
*
|
||||
* @param {MigrationOptions} options - an object containing the migration context.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function up({ context: { queryInterface, logger } }) {
|
||||
// Upwards migration script
|
||||
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
|
||||
|
||||
await addIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', 'size'])
|
||||
await addIndex(queryInterface, logger, 'books', ['duration'])
|
||||
|
||||
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* This downward migration script removes the indices added in the upward migration script
|
||||
*
|
||||
* @param {MigrationOptions} options - an object containing the migration context.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function down({ context: { queryInterface, logger } }) {
|
||||
// Downward migration script
|
||||
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
|
||||
|
||||
await removeIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', 'size'])
|
||||
await removeIndex(queryInterface, logger, 'books', ['duration'])
|
||||
|
||||
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to add an index to a table. If the index already exists, it logs a message and continues.
|
||||
*
|
||||
* @param {import('sequelize').QueryInterface} queryInterface
|
||||
* @param {import ('../Logger')} logger
|
||||
* @param {string} tableName
|
||||
* @param {string[]} columns
|
||||
*/
|
||||
async function addIndex(queryInterface, logger, tableName, columns) {
|
||||
try {
|
||||
logger.info(`${loggerPrefix} adding index [${columns.join(', ')}] to table "${tableName}"`)
|
||||
await queryInterface.addIndex(tableName, columns)
|
||||
logger.info(`${loggerPrefix} added index [${columns.join(', ')}] to table "${tableName}"`)
|
||||
} catch (error) {
|
||||
if (error.name === 'SequelizeDatabaseError' && error.message.includes('already exists')) {
|
||||
logger.info(`${loggerPrefix} index [${columns.join(', ')}] for table "${tableName}" already exists`)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to remove an index from a table.
|
||||
* Sequelize implemets it using DROP INDEX IF EXISTS, so it won't throw an error if the index doesn't exist.
|
||||
*
|
||||
* @param {import('sequelize').QueryInterface} queryInterface
|
||||
* @param {import ('../Logger')} logger
|
||||
* @param {string} tableName
|
||||
* @param {string[]} columns
|
||||
*/
|
||||
async function removeIndex(queryInterface, logger, tableName, columns) {
|
||||
logger.info(`${loggerPrefix} removing index [${columns.join(', ')}] from table "${tableName}"`)
|
||||
await queryInterface.removeIndex(tableName, columns)
|
||||
logger.info(`${loggerPrefix} removed index [${columns.join(', ')}] from table "${tableName}"`)
|
||||
}
|
||||
|
||||
module.exports = { up, down }
|
||||
|
|
@ -321,10 +321,10 @@ class Book extends Model {
|
|||
// },
|
||||
{
|
||||
fields: ['publishedYear']
|
||||
},
|
||||
{
|
||||
fields: ['duration']
|
||||
}
|
||||
// {
|
||||
// fields: ['duration']
|
||||
// }
|
||||
]
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
const { DataTypes, Model, Sequelize } = require('sequelize')
|
||||
|
||||
const oldCollection = require('../objects/Collection')
|
||||
|
||||
class Collection extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
|
@ -26,12 +24,12 @@ class Collection extends Model {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get all old collections toJSONExpanded, items filtered for user permissions
|
||||
* Get all toOldJSONExpanded, items filtered for user permissions
|
||||
*
|
||||
* @param {import('./User')} user
|
||||
* @param {string} [libraryId]
|
||||
* @param {string[]} [include]
|
||||
* @returns {Promise<oldCollection[]>} oldCollection.toJSONExpanded
|
||||
* @async
|
||||
*/
|
||||
static async getOldCollectionsJsonExpanded(user, libraryId, include) {
|
||||
let collectionWhere = null
|
||||
|
|
@ -79,8 +77,6 @@ class Collection extends Model {
|
|||
// 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) => {
|
||||
|
|
@ -95,20 +91,14 @@ class Collection extends Model {
|
|||
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) {
|
||||
if (!books.length && c.books.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const collectionExpanded = oldCollection.toJSONExpanded(libraryItems)
|
||||
this.books = books
|
||||
|
||||
const collectionExpanded = c.toOldJSONExpanded()
|
||||
|
||||
// Map feed if found
|
||||
if (c.feeds?.length) {
|
||||
|
|
@ -153,69 +143,6 @@ class Collection extends Model {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {oldCollection} oldCollection
|
||||
* @returns {Promise<Collection>}
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
static removeById(collectionId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
id: collectionId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all collections belonging to library
|
||||
* @param {string} libraryId
|
||||
|
|
@ -286,64 +213,37 @@ class Collection extends Model {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get old collection toJSONExpanded, items filtered for user permissions
|
||||
* Get toOldJSONExpanded, items filtered for user permissions
|
||||
*
|
||||
* @param {import('./User')|null} user
|
||||
* @param {string[]} [include]
|
||||
* @returns {Promise<oldCollection>} oldCollection.toJSONExpanded
|
||||
* @async
|
||||
*/
|
||||
async getOldJsonExpanded(user, include) {
|
||||
this.books =
|
||||
(await this.getBooks({
|
||||
include: [
|
||||
{
|
||||
model: this.sequelize.models.libraryItem
|
||||
},
|
||||
{
|
||||
model: this.sequelize.models.author,
|
||||
through: {
|
||||
attributes: []
|
||||
}
|
||||
},
|
||||
{
|
||||
model: this.sequelize.models.series,
|
||||
through: {
|
||||
attributes: ['sequence']
|
||||
}
|
||||
}
|
||||
],
|
||||
order: [Sequelize.literal('`collectionBook.order` ASC')]
|
||||
})) || []
|
||||
this.books = await this.getBooksExpandedWithLibraryItem()
|
||||
|
||||
// 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
|
||||
}
|
||||
if (user) {
|
||||
const books = this.books.filter((b) => {
|
||||
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 && this.books.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Users with restricted permissions will not see this collection
|
||||
if (!books.length && this.books.length) {
|
||||
return null
|
||||
this.books = books
|
||||
}
|
||||
|
||||
const collectionExpanded = this.toOldJSONExpanded(libraryItems)
|
||||
const collectionExpanded = this.toOldJSONExpanded()
|
||||
|
||||
if (include?.includes('rssfeed')) {
|
||||
const feeds = await this.getFeeds()
|
||||
|
|
@ -357,10 +257,10 @@ class Collection extends Model {
|
|||
|
||||
/**
|
||||
*
|
||||
* @param {string[]} libraryItemIds
|
||||
* @param {string[]} [libraryItemIds=[]]
|
||||
* @returns
|
||||
*/
|
||||
toOldJSON(libraryItemIds) {
|
||||
toOldJSON(libraryItemIds = []) {
|
||||
return {
|
||||
id: this.id,
|
||||
libraryId: this.libraryId,
|
||||
|
|
@ -372,19 +272,19 @@ class Collection extends Model {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../objects/LibraryItem')} oldLibraryItems
|
||||
* @returns
|
||||
*/
|
||||
toOldJSONExpanded(oldLibraryItems) {
|
||||
const json = this.toOldJSON(oldLibraryItems.map((li) => li.id))
|
||||
json.books = json.books
|
||||
.map((libraryItemId) => {
|
||||
const book = oldLibraryItems.find((li) => li.id === libraryItemId)
|
||||
return book ? book.toJSONExpanded() : null
|
||||
})
|
||||
.filter((b) => !!b)
|
||||
toOldJSONExpanded() {
|
||||
if (!this.books) {
|
||||
throw new Error('Books are required to expand Collection')
|
||||
}
|
||||
|
||||
const json = this.toOldJSON()
|
||||
json.books = this.books.map((book) => {
|
||||
const libraryItem = book.libraryItem
|
||||
delete book.libraryItem
|
||||
libraryItem.media = book
|
||||
return this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem).toJSONExpanded()
|
||||
})
|
||||
|
||||
return json
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,15 +16,6 @@ class CollectionBook extends Model {
|
|||
this.createdAt
|
||||
}
|
||||
|
||||
static removeByIds(collectionId, bookId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
bookId,
|
||||
collectionId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static init(sequelize) {
|
||||
super.init(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -107,6 +107,9 @@ class Feed extends Model {
|
|||
entityUpdatedAt = libraryItem.media.podcastEpisodes.reduce((mostRecent, episode) => {
|
||||
return episode.updatedAt > mostRecent ? episode.updatedAt : mostRecent
|
||||
}, entityUpdatedAt)
|
||||
} else if (libraryItem.media.updatedAt > entityUpdatedAt) {
|
||||
// Book feeds will use Book.updatedAt if more recent
|
||||
entityUpdatedAt = libraryItem.media.updatedAt
|
||||
}
|
||||
|
||||
const feedObj = {
|
||||
|
|
@ -187,7 +190,8 @@ class Feed extends Model {
|
|||
const booksWithTracks = collectionExpanded.books.filter((book) => book.includedAudioFiles.length)
|
||||
|
||||
const entityUpdatedAt = booksWithTracks.reduce((mostRecent, book) => {
|
||||
return book.libraryItem.updatedAt > mostRecent ? book.libraryItem.updatedAt : mostRecent
|
||||
const updatedAt = book.libraryItem.updatedAt > book.updatedAt ? book.libraryItem.updatedAt : book.updatedAt
|
||||
return updatedAt > mostRecent ? updatedAt : mostRecent
|
||||
}, collectionExpanded.updatedAt)
|
||||
|
||||
const firstBookWithCover = booksWithTracks.find((book) => book.coverPath)
|
||||
|
|
@ -275,7 +279,8 @@ class Feed extends Model {
|
|||
static getFeedObjForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions = null) {
|
||||
const booksWithTracks = seriesExpanded.books.filter((book) => book.includedAudioFiles.length)
|
||||
const entityUpdatedAt = booksWithTracks.reduce((mostRecent, book) => {
|
||||
return book.libraryItem.updatedAt > mostRecent ? book.libraryItem.updatedAt : mostRecent
|
||||
const updatedAt = book.libraryItem.updatedAt > book.updatedAt ? book.libraryItem.updatedAt : book.updatedAt
|
||||
return updatedAt > mostRecent ? updatedAt : mostRecent
|
||||
}, seriesExpanded.updatedAt)
|
||||
|
||||
const firstBookWithCover = booksWithTracks.find((book) => book.coverPath)
|
||||
|
|
@ -516,17 +521,24 @@ class Feed extends Model {
|
|||
try {
|
||||
const updatedFeed = await this.update(feedObj, { transaction })
|
||||
|
||||
// Remove existing feed episodes
|
||||
await feedEpisodeModel.destroy({
|
||||
where: {
|
||||
feedId: this.id
|
||||
},
|
||||
transaction
|
||||
})
|
||||
const existingFeedEpisodeIds = this.feedEpisodes.map((ep) => ep.id)
|
||||
|
||||
// Create new feed episodes
|
||||
updatedFeed.feedEpisodes = await feedEpisodeCreateFunc(feedEpisodeCreateFuncEntity, updatedFeed, this.slug, transaction)
|
||||
|
||||
const newFeedEpisodeIds = updatedFeed.feedEpisodes.map((ep) => ep.id)
|
||||
const feedEpisodeIdsToRemove = existingFeedEpisodeIds.filter((epid) => !newFeedEpisodeIds.includes(epid))
|
||||
|
||||
if (feedEpisodeIdsToRemove.length) {
|
||||
Logger.info(`[Feed] Removing ${feedEpisodeIdsToRemove.length} episodes from feed ${this.id}`)
|
||||
await feedEpisodeModel.destroy({
|
||||
where: {
|
||||
id: feedEpisodeIdsToRemove
|
||||
},
|
||||
transaction
|
||||
})
|
||||
}
|
||||
|
||||
await transaction.commit()
|
||||
|
||||
return updatedFeed
|
||||
|
|
|
|||
|
|
@ -53,9 +53,10 @@ class FeedEpisode extends Model {
|
|||
* @param {import('./Feed')} feed
|
||||
* @param {string} slug
|
||||
* @param {import('./PodcastEpisode')} episode
|
||||
* @param {string} [existingEpisodeId]
|
||||
*/
|
||||
static getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode) {
|
||||
const episodeId = uuidv4()
|
||||
static getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode, existingEpisodeId = null) {
|
||||
const episodeId = existingEpisodeId || uuidv4()
|
||||
return {
|
||||
id: episodeId,
|
||||
title: episode.title,
|
||||
|
|
@ -94,11 +95,18 @@ class FeedEpisode extends Model {
|
|||
libraryItemExpanded.media.podcastEpisodes.sort((a, b) => new Date(a.pubDate) - new Date(b.pubDate))
|
||||
}
|
||||
|
||||
let numExisting = 0
|
||||
for (const episode of libraryItemExpanded.media.podcastEpisodes) {
|
||||
feedEpisodeObjs.push(this.getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode))
|
||||
// Check for existing episode by filepath
|
||||
const existingEpisode = feed.feedEpisodes?.find((feedEpisode) => {
|
||||
return feedEpisode.filePath === episode.audioFile.metadata.path
|
||||
})
|
||||
numExisting = existingEpisode ? numExisting + 1 : numExisting
|
||||
|
||||
feedEpisodeObjs.push(this.getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode, existingEpisode?.id))
|
||||
}
|
||||
Logger.info(`[FeedEpisode] Creating ${feedEpisodeObjs.length} episodes for feed ${feed.id}`)
|
||||
return this.bulkCreate(feedEpisodeObjs, { transaction })
|
||||
Logger.info(`[FeedEpisode] Upserting ${feedEpisodeObjs.length} episodes for feed ${feed.id} (${numExisting} existing)`)
|
||||
return this.bulkCreate(feedEpisodeObjs, { transaction, updateOnDuplicate: ['title', 'author', 'description', 'siteURL', 'enclosureURL', 'enclosureType', 'enclosureSize', 'pubDate', 'season', 'episode', 'episodeType', 'duration', 'filePath', 'explicit'] })
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -127,18 +135,19 @@ class FeedEpisode extends Model {
|
|||
* @param {string} slug
|
||||
* @param {import('./Book').AudioFileObject} audioTrack
|
||||
* @param {boolean} useChapterTitles
|
||||
* @param {string} [existingEpisodeId]
|
||||
*/
|
||||
static getFeedEpisodeObjFromAudiobookTrack(book, pubDateStart, feed, slug, audioTrack, useChapterTitles) {
|
||||
static getFeedEpisodeObjFromAudiobookTrack(book, pubDateStart, feed, slug, audioTrack, useChapterTitles, existingEpisodeId = null) {
|
||||
// Example: <pubDate>Fri, 04 Feb 2015 00:00:00 GMT</pubDate>
|
||||
let timeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset pubdate to ensure correct order
|
||||
let episodeId = uuidv4()
|
||||
let episodeId = existingEpisodeId || uuidv4()
|
||||
|
||||
// e.g. Track 1 will have a pub date before Track 2
|
||||
const audiobookPubDate = date.format(new Date(pubDateStart.valueOf() + timeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
|
||||
|
||||
const contentUrl = `/feed/${slug}/item/${episodeId}/media${Path.extname(audioTrack.metadata.filename)}`
|
||||
|
||||
let title = audioTrack.title
|
||||
let title = Path.basename(audioTrack.metadata.filename, Path.extname(audioTrack.metadata.filename))
|
||||
if (book.trackList.length == 1) {
|
||||
// If audiobook is a single file, use book title instead of chapter/file title
|
||||
title = book.title
|
||||
|
|
@ -179,11 +188,18 @@ class FeedEpisode extends Model {
|
|||
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(libraryItemExpanded.media)
|
||||
|
||||
const feedEpisodeObjs = []
|
||||
let numExisting = 0
|
||||
for (const track of libraryItemExpanded.media.trackList) {
|
||||
feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(libraryItemExpanded.media, libraryItemExpanded.createdAt, feed, slug, track, useChapterTitles))
|
||||
// Check for existing episode by filepath
|
||||
const existingEpisode = feed.feedEpisodes?.find((episode) => {
|
||||
return episode.filePath === track.metadata.path
|
||||
})
|
||||
numExisting = existingEpisode ? numExisting + 1 : numExisting
|
||||
|
||||
feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(libraryItemExpanded.media, libraryItemExpanded.createdAt, feed, slug, track, useChapterTitles, existingEpisode?.id))
|
||||
}
|
||||
Logger.info(`[FeedEpisode] Creating ${feedEpisodeObjs.length} episodes for feed ${feed.id}`)
|
||||
return this.bulkCreate(feedEpisodeObjs, { transaction })
|
||||
Logger.info(`[FeedEpisode] Upserting ${feedEpisodeObjs.length} episodes for feed ${feed.id} (${numExisting} existing)`)
|
||||
return this.bulkCreate(feedEpisodeObjs, { transaction, updateOnDuplicate: ['title', 'author', 'description', 'siteURL', 'enclosureURL', 'enclosureType', 'enclosureSize', 'pubDate', 'season', 'episode', 'episodeType', 'duration', 'filePath', 'explicit'] })
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -200,14 +216,21 @@ class FeedEpisode extends Model {
|
|||
}).libraryItem.createdAt
|
||||
|
||||
const feedEpisodeObjs = []
|
||||
let numExisting = 0
|
||||
for (const book of books) {
|
||||
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(book)
|
||||
for (const track of book.trackList) {
|
||||
feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(book, earliestLibraryItemCreatedAt, feed, slug, track, useChapterTitles))
|
||||
// Check for existing episode by filepath
|
||||
const existingEpisode = feed.feedEpisodes?.find((episode) => {
|
||||
return episode.filePath === track.metadata.path
|
||||
})
|
||||
numExisting = existingEpisode ? numExisting + 1 : numExisting
|
||||
|
||||
feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(book, earliestLibraryItemCreatedAt, feed, slug, track, useChapterTitles, existingEpisode?.id))
|
||||
}
|
||||
}
|
||||
Logger.info(`[FeedEpisode] Creating ${feedEpisodeObjs.length} episodes for feed ${feed.id}`)
|
||||
return this.bulkCreate(feedEpisodeObjs, { transaction })
|
||||
Logger.info(`[FeedEpisode] Upserting ${feedEpisodeObjs.length} episodes for feed ${feed.id} (${numExisting} existing)`)
|
||||
return this.bulkCreate(feedEpisodeObjs, { transaction, updateOnDuplicate: ['title', 'author', 'description', 'siteURL', 'enclosureURL', 'enclosureType', 'enclosureSize', 'pubDate', 'season', 'episode', 'episodeType', 'duration', 'filePath', 'explicit'] })
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ class LibraryItem extends Model {
|
|||
}
|
||||
|
||||
/**
|
||||
* Currently unused because this is too slow and uses too much mem
|
||||
*
|
||||
* @param {import('sequelize').WhereOptions} [where]
|
||||
* @returns {Array<objects.LibraryItem>} old library items
|
||||
*/
|
||||
|
|
@ -1061,6 +1061,9 @@ class LibraryItem extends Model {
|
|||
{
|
||||
fields: ['libraryId', 'mediaType']
|
||||
},
|
||||
{
|
||||
fields: ['libraryId', 'mediaType', 'size']
|
||||
},
|
||||
{
|
||||
fields: ['libraryId', 'mediaId', 'mediaType']
|
||||
},
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const { DataTypes, Model } = require('sequelize')
|
|||
* @property {Object} extraData
|
||||
* @property {Date} createdAt
|
||||
* @property {Date} updatedAt
|
||||
* @property {boolean} isDownloadable
|
||||
*
|
||||
* @typedef {MediaItemShareObject & MediaItemShare} MediaItemShareModel
|
||||
*/
|
||||
|
|
@ -25,11 +26,40 @@ const { DataTypes, Model } = require('sequelize')
|
|||
* @property {Date} expiresAt
|
||||
* @property {Date} createdAt
|
||||
* @property {Date} updatedAt
|
||||
* @property {boolean} isDownloadable
|
||||
*/
|
||||
|
||||
class MediaItemShare extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
/** @type {UUIDV4} */
|
||||
this.id
|
||||
/** @type {UUIDV4} */
|
||||
this.mediaItemId
|
||||
/** @type {string} */
|
||||
this.mediaItemType
|
||||
/** @type {string} */
|
||||
this.slug
|
||||
/** @type {string} */
|
||||
this.pash
|
||||
/** @type {UUIDV4} */
|
||||
this.userId
|
||||
/** @type {Date} */
|
||||
this.expiresAt
|
||||
/** @type {Object} */
|
||||
this.extraData
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
/** @type {boolean} */
|
||||
this.isDownloadable
|
||||
|
||||
// Expanded properties
|
||||
|
||||
/** @type {import('./Book')|import('./PodcastEpisode')} */
|
||||
this.mediaItem
|
||||
}
|
||||
|
||||
toJSONForClient() {
|
||||
|
|
@ -40,7 +70,8 @@ class MediaItemShare extends Model {
|
|||
slug: this.slug,
|
||||
expiresAt: this.expiresAt,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt
|
||||
updatedAt: this.updatedAt,
|
||||
isDownloadable: this.isDownloadable
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -114,7 +145,8 @@ class MediaItemShare extends Model {
|
|||
slug: DataTypes.STRING,
|
||||
pash: DataTypes.STRING,
|
||||
expiresAt: DataTypes.DATE,
|
||||
extraData: DataTypes.JSON
|
||||
extraData: DataTypes.JSON,
|
||||
isDownloadable: DataTypes.BOOLEAN
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
const { DataTypes, Model, Op, literal } = require('sequelize')
|
||||
const { DataTypes, Model, Op } = require('sequelize')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
const oldPlaylist = require('../objects/Playlist')
|
||||
|
||||
class Playlist extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
|
@ -21,134 +19,23 @@ class Playlist extends Model {
|
|||
this.createdAt
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
}
|
||||
|
||||
static getOldPlaylist(playlistExpanded) {
|
||||
const items = playlistExpanded.playlistMediaItems
|
||||
.map((pmi) => {
|
||||
const mediaItem = pmi.mediaItem || pmi.dataValues?.mediaItem
|
||||
const libraryItemId = mediaItem?.podcast?.libraryItem?.id || 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)
|
||||
// Expanded properties
|
||||
|
||||
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()
|
||||
})
|
||||
/** @type {import('./PlaylistMediaItem')[]} - only set when expanded */
|
||||
this.playlistMediaItems
|
||||
}
|
||||
|
||||
/**
|
||||
* Get old playlist toJSONExpanded
|
||||
* @param {string[]} [include]
|
||||
* @returns {Promise<oldPlaylist>} 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)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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 old playlists for user and optionally for library
|
||||
* Get old playlists for user and library
|
||||
*
|
||||
* @param {string} userId
|
||||
* @param {string} [libraryId]
|
||||
* @returns {Promise<oldPlaylist[]>}
|
||||
* @param {string} libraryId
|
||||
* @async
|
||||
*/
|
||||
static async getOldPlaylistsForUserAndLibrary(userId, libraryId = null) {
|
||||
static async getOldPlaylistsForUserAndLibrary(userId, libraryId) {
|
||||
if (!userId && !libraryId) return []
|
||||
|
||||
const whereQuery = {}
|
||||
if (userId) {
|
||||
whereQuery.userId = userId
|
||||
|
|
@ -163,7 +50,23 @@ class Playlist extends Model {
|
|||
include: [
|
||||
{
|
||||
model: this.sequelize.models.book,
|
||||
include: this.sequelize.models.libraryItem
|
||||
include: [
|
||||
{
|
||||
model: this.sequelize.models.libraryItem
|
||||
},
|
||||
{
|
||||
model: this.sequelize.models.author,
|
||||
through: {
|
||||
attributes: []
|
||||
}
|
||||
},
|
||||
{
|
||||
model: this.sequelize.models.series,
|
||||
through: {
|
||||
attributes: ['sequence']
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
model: this.sequelize.models.podcastEpisode,
|
||||
|
|
@ -174,42 +77,13 @@ class Playlist extends Model {
|
|||
}
|
||||
]
|
||||
},
|
||||
order: [
|
||||
[literal('name COLLATE NOCASE'), 'ASC'],
|
||||
['playlistMediaItems', 'order', 'ASC']
|
||||
]
|
||||
order: [['playlistMediaItems', 'order', 'ASC']]
|
||||
})
|
||||
|
||||
const oldPlaylists = []
|
||||
for (const playlistExpanded of playlistsExpanded) {
|
||||
const oldPlaylist = this.getOldPlaylist(playlistExpanded)
|
||||
const libraryItems = []
|
||||
for (const pmi of playlistExpanded.playlistMediaItems) {
|
||||
let mediaItem = pmi.mediaItem || pmi.dataValues.mediaItem
|
||||
// Sort by name asc
|
||||
playlistsExpanded.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
if (!mediaItem) {
|
||||
Logger.error(`[Playlist] Invalid playlist media item - No media item found`, JSON.stringify(mediaItem, null, 2))
|
||||
continue
|
||||
}
|
||||
let libraryItem = mediaItem.libraryItem || mediaItem.podcast?.libraryItem
|
||||
|
||||
if (mediaItem.podcast) {
|
||||
libraryItem.media = mediaItem.podcast
|
||||
libraryItem.media.podcastEpisodes = [mediaItem]
|
||||
delete mediaItem.podcast.libraryItem
|
||||
} else {
|
||||
libraryItem.media = mediaItem
|
||||
delete mediaItem.libraryItem
|
||||
}
|
||||
|
||||
const oldLibraryItem = this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem)
|
||||
libraryItems.push(oldLibraryItem)
|
||||
}
|
||||
const oldPlaylistJson = oldPlaylist.toJSONExpanded(libraryItems)
|
||||
oldPlaylists.push(oldPlaylistJson)
|
||||
}
|
||||
|
||||
return oldPlaylists
|
||||
return playlistsExpanded.map((playlist) => playlist.toOldJSONExpanded())
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -345,6 +219,117 @@ class Playlist extends Model {
|
|||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all media items in playlist expanded with library item
|
||||
*
|
||||
* @returns {Promise<import('./PlaylistMediaItem')[]>}
|
||||
*/
|
||||
getMediaItemsExpandedWithLibraryItem() {
|
||||
return this.getPlaylistMediaItems({
|
||||
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']
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
model: this.sequelize.models.podcastEpisode,
|
||||
include: [
|
||||
{
|
||||
model: this.sequelize.models.podcast,
|
||||
include: this.sequelize.models.libraryItem
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
order: [['order', 'ASC']]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get playlists toOldJSONExpanded
|
||||
*
|
||||
* @async
|
||||
*/
|
||||
async getOldJsonExpanded() {
|
||||
this.playlistMediaItems = await this.getMediaItemsExpandedWithLibraryItem()
|
||||
return this.toOldJSONExpanded()
|
||||
}
|
||||
|
||||
/**
|
||||
* Old model used libraryItemId instead of bookId
|
||||
*
|
||||
* @param {string} libraryItemId
|
||||
* @param {string} [episodeId]
|
||||
*/
|
||||
checkHasMediaItem(libraryItemId, episodeId) {
|
||||
if (!this.playlistMediaItems) {
|
||||
throw new Error('playlistMediaItems are required to check Playlist')
|
||||
}
|
||||
if (episodeId) {
|
||||
return this.playlistMediaItems.some((pmi) => pmi.mediaItemId === episodeId)
|
||||
}
|
||||
return this.playlistMediaItems.some((pmi) => pmi.mediaItem.libraryItem.id === libraryItemId)
|
||||
}
|
||||
|
||||
toOldJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
libraryId: this.libraryId,
|
||||
userId: this.userId,
|
||||
description: this.description,
|
||||
lastUpdate: this.updatedAt.valueOf(),
|
||||
createdAt: this.createdAt.valueOf()
|
||||
}
|
||||
}
|
||||
|
||||
toOldJSONExpanded() {
|
||||
if (!this.playlistMediaItems) {
|
||||
throw new Error('playlistMediaItems are required to expand Playlist')
|
||||
}
|
||||
|
||||
const json = this.toOldJSON()
|
||||
json.items = this.playlistMediaItems.map((pmi) => {
|
||||
if (pmi.mediaItemType === 'book') {
|
||||
const libraryItem = pmi.mediaItem.libraryItem
|
||||
delete pmi.mediaItem.libraryItem
|
||||
libraryItem.media = pmi.mediaItem
|
||||
return {
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryItem: this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem).toJSONExpanded()
|
||||
}
|
||||
}
|
||||
|
||||
const libraryItem = pmi.mediaItem.podcast.libraryItem
|
||||
delete pmi.mediaItem.podcast.libraryItem
|
||||
libraryItem.media = pmi.mediaItem.podcast
|
||||
return {
|
||||
episodeId: pmi.mediaItemId,
|
||||
episode: pmi.mediaItem.toOldJSONExpanded(libraryItem.id),
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryItem: this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem).toJSONMinified()
|
||||
}
|
||||
})
|
||||
|
||||
return json
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Playlist
|
||||
|
|
|
|||
|
|
@ -16,15 +16,11 @@ class PlaylistMediaItem extends Model {
|
|||
this.playlistId
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
}
|
||||
|
||||
static removeByIds(playlistId, mediaItemId) {
|
||||
return this.destroy({
|
||||
where: {
|
||||
playlistId,
|
||||
mediaItemId
|
||||
}
|
||||
})
|
||||
// Expanded properties
|
||||
|
||||
/** @type {import('./Book')|import('./PodcastEpisode')} - only set when expanded */
|
||||
this.mediaItem
|
||||
}
|
||||
|
||||
getMediaItem(options) {
|
||||
|
|
|
|||
|
|
@ -170,6 +170,62 @@ class PodcastEpisode extends Model {
|
|||
})
|
||||
PodcastEpisode.belongsTo(podcast)
|
||||
}
|
||||
|
||||
/**
|
||||
* AudioTrack object used in old model
|
||||
*
|
||||
* @returns {import('./Book').AudioFileObject|null}
|
||||
*/
|
||||
get track() {
|
||||
if (!this.audioFile) return null
|
||||
const track = structuredClone(this.audioFile)
|
||||
track.startOffset = 0
|
||||
track.title = this.audioFile.metadata.title
|
||||
return track
|
||||
}
|
||||
|
||||
toOldJSON(libraryItemId) {
|
||||
let enclosure = null
|
||||
if (this.enclosureURL) {
|
||||
enclosure = {
|
||||
url: this.enclosureURL,
|
||||
type: this.enclosureType,
|
||||
length: this.enclosureSize !== null ? String(this.enclosureSize) : null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
libraryItemId: libraryItemId,
|
||||
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,
|
||||
guid: this.extraData?.guid || null,
|
||||
pubDate: this.pubDate,
|
||||
chapters: this.chapters?.map((ch) => ({ ...ch })) || [],
|
||||
audioFile: this.audioFile || null,
|
||||
publishedAt: this.publishedAt?.valueOf() || null,
|
||||
addedAt: this.createdAt.valueOf(),
|
||||
updatedAt: this.updatedAt.valueOf()
|
||||
}
|
||||
}
|
||||
|
||||
toOldJSONExpanded(libraryItemId) {
|
||||
const json = this.toOldJSON(libraryItemId)
|
||||
|
||||
json.audioTrack = this.track
|
||||
json.size = this.audioFile?.metadata.size || 0
|
||||
json.duration = this.audioFile?.duration || 0
|
||||
|
||||
return json
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PodcastEpisode
|
||||
|
|
|
|||
|
|
@ -1,115 +0,0 @@
|
|||
const uuidv4 = require("uuid").v4
|
||||
|
||||
class Collection {
|
||||
constructor(collection) {
|
||||
this.id = null
|
||||
this.libraryId = null
|
||||
|
||||
this.name = null
|
||||
this.description = null
|
||||
|
||||
this.cover = null
|
||||
this.coverFullPath = null
|
||||
this.books = []
|
||||
|
||||
this.lastUpdate = null
|
||||
this.createdAt = null
|
||||
|
||||
if (collection) {
|
||||
this.construct(collection)
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
libraryId: this.libraryId,
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
cover: this.cover,
|
||||
coverFullPath: this.coverFullPath,
|
||||
books: [...this.books],
|
||||
lastUpdate: this.lastUpdate,
|
||||
createdAt: this.createdAt
|
||||
}
|
||||
}
|
||||
|
||||
toJSONExpanded(libraryItems, minifiedBooks = false) {
|
||||
const json = this.toJSON()
|
||||
json.books = json.books.map(bookId => {
|
||||
const book = libraryItems.find(li => li.id === bookId)
|
||||
return book ? minifiedBooks ? book.toJSONMinified() : book.toJSONExpanded() : null
|
||||
}).filter(b => !!b)
|
||||
return json
|
||||
}
|
||||
|
||||
// Expanded and filtered out items not accessible to user
|
||||
toJSONExpandedForUser(user, libraryItems) {
|
||||
const json = this.toJSON()
|
||||
json.books = json.books.map(libraryItemId => {
|
||||
const libraryItem = libraryItems.find(li => li.id === libraryItemId)
|
||||
return libraryItem ? libraryItem.toJSONExpanded() : null
|
||||
}).filter(li => {
|
||||
return li && user.checkCanAccessLibraryItem(li)
|
||||
})
|
||||
return json
|
||||
}
|
||||
|
||||
construct(collection) {
|
||||
this.id = collection.id
|
||||
this.libraryId = collection.libraryId
|
||||
this.name = collection.name
|
||||
this.description = collection.description || null
|
||||
this.cover = collection.cover || null
|
||||
this.coverFullPath = collection.coverFullPath || null
|
||||
this.books = collection.books ? [...collection.books] : []
|
||||
this.lastUpdate = collection.lastUpdate || null
|
||||
this.createdAt = collection.createdAt || null
|
||||
}
|
||||
|
||||
setData(data) {
|
||||
if (!data.libraryId || !data.name) {
|
||||
return false
|
||||
}
|
||||
this.id = uuidv4()
|
||||
this.libraryId = data.libraryId
|
||||
this.name = data.name
|
||||
this.description = data.description || null
|
||||
this.cover = data.cover || null
|
||||
this.coverFullPath = data.coverFullPath || null
|
||||
this.books = data.books ? [...data.books] : []
|
||||
this.lastUpdate = Date.now()
|
||||
this.createdAt = Date.now()
|
||||
return true
|
||||
}
|
||||
|
||||
addBook(bookId) {
|
||||
this.books.push(bookId)
|
||||
this.lastUpdate = Date.now()
|
||||
}
|
||||
|
||||
removeBook(bookId) {
|
||||
this.books = this.books.filter(bid => bid !== bookId)
|
||||
this.lastUpdate = Date.now()
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
let hasUpdates = false
|
||||
for (const key in payload) {
|
||||
if (key === 'books') {
|
||||
if (payload.books && this.books.join(',') !== payload.books.join(',')) {
|
||||
this.books = [...payload.books]
|
||||
hasUpdates = true
|
||||
}
|
||||
} else if (this[key] !== undefined && this[key] !== payload[key]) {
|
||||
hasUpdates = true
|
||||
this[key] = payload[key]
|
||||
}
|
||||
}
|
||||
if (hasUpdates) {
|
||||
this.lastUpdate = Date.now()
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
}
|
||||
module.exports = Collection
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
const uuidv4 = require("uuid").v4
|
||||
|
||||
class Playlist {
|
||||
constructor(playlist) {
|
||||
this.id = null
|
||||
this.libraryId = null
|
||||
this.userId = null
|
||||
|
||||
this.name = null
|
||||
this.description = null
|
||||
|
||||
this.coverPath = null
|
||||
|
||||
// Array of objects like { libraryItemId: "", episodeId: "" } (episodeId optional)
|
||||
this.items = []
|
||||
|
||||
this.lastUpdate = null
|
||||
this.createdAt = null
|
||||
|
||||
if (playlist) {
|
||||
this.construct(playlist)
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
libraryId: this.libraryId,
|
||||
userId: this.userId,
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
coverPath: this.coverPath,
|
||||
items: [...this.items],
|
||||
lastUpdate: this.lastUpdate,
|
||||
createdAt: this.createdAt
|
||||
}
|
||||
}
|
||||
|
||||
// Expands the items array
|
||||
toJSONExpanded(libraryItems) {
|
||||
var json = this.toJSON()
|
||||
json.items = json.items.map(item => {
|
||||
const libraryItem = libraryItems.find(li => li.id === item.libraryItemId)
|
||||
if (!libraryItem) {
|
||||
// Not found
|
||||
return null
|
||||
}
|
||||
if (item.episodeId) {
|
||||
if (!libraryItem.isPodcast) {
|
||||
// Invalid
|
||||
return null
|
||||
}
|
||||
const episode = libraryItem.media.episodes.find(ep => ep.id === item.episodeId)
|
||||
if (!episode) {
|
||||
// Not found
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
episode: episode.toJSONExpanded(),
|
||||
libraryItem: libraryItem.toJSONMinified()
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
...item,
|
||||
libraryItem: libraryItem.toJSONExpanded()
|
||||
}
|
||||
}
|
||||
}).filter(i => i)
|
||||
return json
|
||||
}
|
||||
|
||||
construct(playlist) {
|
||||
this.id = playlist.id
|
||||
this.libraryId = playlist.libraryId
|
||||
this.userId = playlist.userId
|
||||
this.name = playlist.name
|
||||
this.description = playlist.description || null
|
||||
this.coverPath = playlist.coverPath || null
|
||||
this.items = playlist.items ? playlist.items.map(i => ({ ...i })) : []
|
||||
this.lastUpdate = playlist.lastUpdate || null
|
||||
this.createdAt = playlist.createdAt || null
|
||||
}
|
||||
|
||||
setData(data) {
|
||||
if (!data.userId || !data.libraryId || !data.name) {
|
||||
return false
|
||||
}
|
||||
this.id = uuidv4()
|
||||
this.userId = data.userId
|
||||
this.libraryId = data.libraryId
|
||||
this.name = data.name
|
||||
this.description = data.description || null
|
||||
this.coverPath = data.coverPath || null
|
||||
this.items = data.items ? data.items.map(i => ({ ...i })) : []
|
||||
this.lastUpdate = Date.now()
|
||||
this.createdAt = Date.now()
|
||||
return true
|
||||
}
|
||||
|
||||
addItem(libraryItemId, episodeId = null) {
|
||||
this.items.push({
|
||||
libraryItemId,
|
||||
episodeId: episodeId || null
|
||||
})
|
||||
this.lastUpdate = Date.now()
|
||||
}
|
||||
|
||||
removeItem(libraryItemId, episodeId = null) {
|
||||
if (episodeId) this.items = this.items.filter(i => i.libraryItemId !== libraryItemId || i.episodeId !== episodeId)
|
||||
else this.items = this.items.filter(i => i.libraryItemId !== libraryItemId)
|
||||
this.lastUpdate = Date.now()
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
let hasUpdates = false
|
||||
for (const key in payload) {
|
||||
if (key === 'items') {
|
||||
if (payload.items && JSON.stringify(payload.items) !== JSON.stringify(this.items)) {
|
||||
this.items = payload.items.map(i => ({ ...i }))
|
||||
hasUpdates = true
|
||||
}
|
||||
} else if (this[key] !== undefined && this[key] !== payload[key]) {
|
||||
hasUpdates = true
|
||||
this[key] = payload[key]
|
||||
}
|
||||
}
|
||||
if (hasUpdates) {
|
||||
this.lastUpdate = Date.now()
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
containsItem(item) {
|
||||
if (item.episodeId) return this.items.some(i => i.libraryItemId === item.libraryItemId && i.episodeId === item.episodeId)
|
||||
return this.items.some(i => i.libraryItemId === item.libraryItemId)
|
||||
}
|
||||
|
||||
hasItemsForLibraryItem(libraryItemId) {
|
||||
return this.items.some(i => i.libraryItemId === libraryItemId)
|
||||
}
|
||||
|
||||
removeItemsForLibraryItem(libraryItemId) {
|
||||
this.items = this.items.filter(i => i.libraryItemId !== libraryItemId)
|
||||
}
|
||||
}
|
||||
module.exports = Playlist
|
||||
|
|
@ -15,6 +15,7 @@ class PublicRouter {
|
|||
this.router.get('/share/:slug', ShareController.getMediaItemShareBySlug.bind(this))
|
||||
this.router.get('/share/:slug/track/:index', ShareController.getMediaItemShareAudioTrack.bind(this))
|
||||
this.router.get('/share/:slug/cover', ShareController.getMediaItemShareCoverImage.bind(this))
|
||||
this.router.get('/share/:slug/download', ShareController.downloadMediaItemShare.bind(this))
|
||||
this.router.patch('/share/:slug/progress', ShareController.updateMediaItemShareProgress.bind(this))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,36 +13,58 @@ const LibraryScanner = require('./LibraryScanner')
|
|||
const CoverManager = require('../managers/CoverManager')
|
||||
const TaskManager = require('../managers/TaskManager')
|
||||
|
||||
/**
|
||||
* @typedef QuickMatchOptions
|
||||
* @property {string} [provider]
|
||||
* @property {string} [title]
|
||||
* @property {string} [author]
|
||||
* @property {string} [isbn] - This override is currently unused in Abs clients
|
||||
* @property {string} [asin] - This override is currently unused in Abs clients
|
||||
* @property {boolean} [overrideCover]
|
||||
* @property {boolean} [overrideDetails]
|
||||
*/
|
||||
|
||||
class Scanner {
|
||||
constructor() {}
|
||||
|
||||
async quickMatchLibraryItem(libraryItem, options = {}) {
|
||||
var provider = options.provider || 'google'
|
||||
var searchTitle = options.title || libraryItem.media.metadata.title
|
||||
var searchAuthor = options.author || libraryItem.media.metadata.authorName
|
||||
var overrideDefaults = options.overrideDefaults || false
|
||||
/**
|
||||
*
|
||||
* @param {import('../routers/ApiRouter')} apiRouterCtx
|
||||
* @param {import('../objects/LibraryItem')} libraryItem
|
||||
* @param {QuickMatchOptions} options
|
||||
* @returns {Promise<{updated: boolean, libraryItem: import('../objects/LibraryItem')}>}
|
||||
*/
|
||||
async quickMatchLibraryItem(apiRouterCtx, libraryItem, options = {}) {
|
||||
const provider = options.provider || 'google'
|
||||
const searchTitle = options.title || libraryItem.media.metadata.title
|
||||
const searchAuthor = options.author || libraryItem.media.metadata.authorName
|
||||
|
||||
// Set to override existing metadata if scannerPreferMatchedMetadata setting is true and
|
||||
// the overrideDefaults option is not set or set to false.
|
||||
if (overrideDefaults == false && Database.serverSettings.scannerPreferMatchedMetadata) {
|
||||
// If overrideCover and overrideDetails is not sent in options than use the server setting to determine if we should override
|
||||
if (options.overrideCover === undefined && options.overrideDetails === undefined && Database.serverSettings.scannerPreferMatchedMetadata) {
|
||||
options.overrideCover = true
|
||||
options.overrideDetails = true
|
||||
}
|
||||
|
||||
var updatePayload = {}
|
||||
var hasUpdated = false
|
||||
let updatePayload = {}
|
||||
let hasUpdated = false
|
||||
|
||||
let existingAuthors = [] // Used for checking if authors or series are now empty
|
||||
let existingSeries = []
|
||||
|
||||
if (libraryItem.isBook) {
|
||||
var searchISBN = options.isbn || libraryItem.media.metadata.isbn
|
||||
var searchASIN = options.asin || libraryItem.media.metadata.asin
|
||||
existingAuthors = libraryItem.media.metadata.authors.map((a) => a.id)
|
||||
existingSeries = libraryItem.media.metadata.series.map((s) => s.id)
|
||||
|
||||
var results = await BookFinder.search(libraryItem, provider, searchTitle, searchAuthor, searchISBN, searchASIN, { maxFuzzySearches: 2 })
|
||||
const searchISBN = options.isbn || libraryItem.media.metadata.isbn
|
||||
const searchASIN = options.asin || libraryItem.media.metadata.asin
|
||||
|
||||
const results = await BookFinder.search(libraryItem, provider, searchTitle, searchAuthor, searchISBN, searchASIN, { maxFuzzySearches: 2 })
|
||||
if (!results.length) {
|
||||
return {
|
||||
warning: `No ${provider} match found`
|
||||
}
|
||||
}
|
||||
var matchData = results[0]
|
||||
const matchData = results[0]
|
||||
|
||||
// Update cover if not set OR overrideCover flag
|
||||
if (matchData.cover && (!libraryItem.media.coverPath || options.overrideCover)) {
|
||||
|
|
@ -58,13 +80,13 @@ class Scanner {
|
|||
updatePayload = await this.quickMatchBookBuildUpdatePayload(libraryItem, matchData, options)
|
||||
} else if (libraryItem.isPodcast) {
|
||||
// Podcast quick match
|
||||
var results = await PodcastFinder.search(searchTitle)
|
||||
const results = await PodcastFinder.search(searchTitle)
|
||||
if (!results.length) {
|
||||
return {
|
||||
warning: `No ${provider} match found`
|
||||
}
|
||||
}
|
||||
var matchData = results[0]
|
||||
const matchData = results[0]
|
||||
|
||||
// Update cover if not set OR overrideCover flag
|
||||
if (matchData.cover && (!libraryItem.media.coverPath || options.overrideCover)) {
|
||||
|
|
@ -95,6 +117,19 @@ class Scanner {
|
|||
|
||||
await Database.updateLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
|
||||
// Check if any authors or series are now empty and should be removed
|
||||
if (libraryItem.isBook) {
|
||||
const authorsRemoved = existingAuthors.filter((aid) => !libraryItem.media.metadata.authors.find((au) => au.id === aid))
|
||||
const seriesRemoved = existingSeries.filter((sid) => !libraryItem.media.metadata.series.find((se) => se.id === sid))
|
||||
|
||||
if (authorsRemoved.length) {
|
||||
await apiRouterCtx.checkRemoveAuthorsWithNoBooks(authorsRemoved)
|
||||
}
|
||||
if (seriesRemoved.length) {
|
||||
await apiRouterCtx.checkRemoveEmptySeries(seriesRemoved)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -149,6 +184,13 @@ class Scanner {
|
|||
return updatePayload
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../objects/LibraryItem')} libraryItem
|
||||
* @param {*} matchData
|
||||
* @param {QuickMatchOptions} options
|
||||
* @returns
|
||||
*/
|
||||
async quickMatchBookBuildUpdatePayload(libraryItem, matchData, options) {
|
||||
// Update media metadata if not set OR overrideDetails flag
|
||||
const detailKeysToUpdate = ['title', 'subtitle', 'description', 'narrator', 'publisher', 'publishedYear', 'genres', 'tags', 'language', 'explicit', 'abridged', 'asin', 'isbn']
|
||||
|
|
@ -307,12 +349,13 @@ class Scanner {
|
|||
/**
|
||||
* Quick match library items
|
||||
*
|
||||
* @param {import('../routers/ApiRouter')} apiRouterCtx
|
||||
* @param {import('../models/Library')} library
|
||||
* @param {import('../objects/LibraryItem')[]} libraryItems
|
||||
* @param {LibraryScan} libraryScan
|
||||
* @returns {Promise<boolean>} false if scan canceled
|
||||
*/
|
||||
async matchLibraryItemsChunk(library, libraryItems, libraryScan) {
|
||||
async matchLibraryItemsChunk(apiRouterCtx, library, libraryItems, libraryScan) {
|
||||
for (let i = 0; i < libraryItems.length; i++) {
|
||||
const libraryItem = libraryItems[i]
|
||||
|
||||
|
|
@ -327,7 +370,7 @@ class Scanner {
|
|||
}
|
||||
|
||||
Logger.debug(`[Scanner] matchLibraryItems: Quick matching "${libraryItem.media.metadata.title}" (${i + 1} of ${libraryItems.length})`)
|
||||
const result = await this.quickMatchLibraryItem(libraryItem, { provider: library.provider })
|
||||
const result = await this.quickMatchLibraryItem(apiRouterCtx, 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) {
|
||||
|
|
@ -346,9 +389,10 @@ class Scanner {
|
|||
/**
|
||||
* Quick match all library items for library
|
||||
*
|
||||
* @param {import('../routers/ApiRouter')} apiRouterCtx
|
||||
* @param {import('../models/Library')} library
|
||||
*/
|
||||
async matchLibraryItems(library) {
|
||||
async matchLibraryItems(apiRouterCtx, library) {
|
||||
if (library.mediaType === 'podcast') {
|
||||
Logger.error(`[Scanner] matchLibraryItems: Match all not supported for podcasts yet`)
|
||||
return
|
||||
|
|
@ -388,7 +432,7 @@ class Scanner {
|
|||
hasMoreChunks = libraryItems.length === limit
|
||||
let oldLibraryItems = libraryItems.map((li) => Database.libraryItemModel.getOldLibraryItem(li))
|
||||
|
||||
const shouldContinue = await this.matchLibraryItemsChunk(library, oldLibraryItems, libraryScan)
|
||||
const shouldContinue = await this.matchLibraryItemsChunk(apiRouterCtx, library, oldLibraryItems, libraryScan)
|
||||
if (!shouldContinue) {
|
||||
isCanceled = true
|
||||
break
|
||||
|
|
|
|||
|
|
@ -277,8 +277,8 @@ module.exports.downloadFile = (url, filepath, contentTypeFilter = null) => {
|
|||
'User-Agent': 'audiobookshelf (+https://audiobookshelf.org)'
|
||||
},
|
||||
timeout: 30000,
|
||||
httpAgent: global.DisableSsrfRequestFilter ? null : ssrfFilter(url),
|
||||
httpsAgent: global.DisableSsrfRequestFilter ? null : ssrfFilter(url)
|
||||
httpAgent: global.DisableSsrfRequestFilter?.(url) ? null : ssrfFilter(url),
|
||||
httpsAgent: global.DisableSsrfRequestFilter?.(url) ? null : ssrfFilter(url)
|
||||
})
|
||||
.then((response) => {
|
||||
// Validate content type
|
||||
|
|
|
|||
|
|
@ -248,8 +248,8 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => {
|
|||
Accept: 'application/rss+xml, application/xhtml+xml, application/xml, */*;q=0.8',
|
||||
'User-Agent': userAgent
|
||||
},
|
||||
httpAgent: global.DisableSsrfRequestFilter ? null : ssrfFilter(feedUrl),
|
||||
httpsAgent: global.DisableSsrfRequestFilter ? null : ssrfFilter(feedUrl)
|
||||
httpAgent: global.DisableSsrfRequestFilter?.(feedUrl) ? null : ssrfFilter(feedUrl),
|
||||
httpsAgent: global.DisableSsrfRequestFilter?.(feedUrl) ? null : ssrfFilter(feedUrl)
|
||||
})
|
||||
.then(async (data) => {
|
||||
// Adding support for ios-8859-1 encoded RSS feeds.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ module.exports.zipDirectoryPipe = (path, filename, res) => {
|
|||
res.attachment(filename)
|
||||
|
||||
const archive = archiver('zip', {
|
||||
zlib: { level: 9 } // Sets the compression level.
|
||||
zlib: { level: 0 } // Sets the compression level.
|
||||
})
|
||||
|
||||
// listen for all archive data to be written
|
||||
|
|
@ -49,4 +49,4 @@ module.exports.zipDirectoryPipe = (path, filename, res) => {
|
|||
|
||||
archive.finalize()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue