mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Update Playlist model & controller to remove usage of old Playlist object, remove old Playlist
This commit is contained in:
		
							parent
							
								
									6780ef9b37
								
							
						
					
					
						commit
						9785bc02ea
					
				| @ -406,16 +406,6 @@ class Database { | ||||
|     return Promise.all(oldBooks.map((oldBook) => this.models.book.saveFromOld(oldBook))) | ||||
|   } | ||||
| 
 | ||||
|   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() | ||||
|  | ||||
| @ -35,6 +35,9 @@ class CollectionController { | ||||
|     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') | ||||
|  | ||||
| @ -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 refactor it and 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, req.params.libraryId) | ||||
|     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) | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|  | ||||
| @ -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,6 +16,11 @@ class PlaylistMediaItem extends Model { | ||||
|     this.playlistId | ||||
|     /** @type {Date} */ | ||||
|     this.createdAt | ||||
| 
 | ||||
|     // Expanded properties
 | ||||
| 
 | ||||
|     /** @type {import('./Book')|import('./PodcastEpisode')} - only set when expanded */ | ||||
|     this.mediaItem | ||||
|   } | ||||
| 
 | ||||
|   static removeByIds(playlistId, mediaItemId) { | ||||
|  | ||||
| @ -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,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 | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user