From 2bc6dcf5767829f91de2b5f83091fbfc1ed41db7 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sun, 22 Mar 2026 18:50:44 -0700 Subject: [PATCH 1/2] Prevent loading full podcast when adding episodes to playlist --- server/controllers/PlaylistController.js | 66 +++----------- server/utils/playlistHelpers.js | 105 +++++++++++++++++++++++ 2 files changed, 117 insertions(+), 54 deletions(-) create mode 100644 server/utils/playlistHelpers.js diff --git a/server/controllers/PlaylistController.js b/server/controllers/PlaylistController.js index bc1a7a45..4593c47e 100644 --- a/server/controllers/PlaylistController.js +++ b/server/controllers/PlaylistController.js @@ -3,6 +3,7 @@ const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') const Database = require('../Database') const htmlSanitizer = require('../utils/htmlSanitizer') +const { resolvePlaylistRequestItems } = require('../utils/playlistHelpers') /** * @typedef RequestUserObject @@ -282,19 +283,10 @@ class PlaylistController { return res.status(400).send('Request body has no libraryItemId') } - const libraryItem = await Database.libraryItemModel.getExpandedById(itemToAdd.libraryItemId) - if (!libraryItem) { + const [resolvedItem] = (await resolvePlaylistRequestItems([itemToAdd], req.playlist.libraryId)) || [] + if (!resolvedItem) { return res.status(400).send('Library item not found') } - if (libraryItem.libraryId !== req.playlist.libraryId) { - return res.status(400).send('Library item in different library') - } - if ((itemToAdd.episodeId && !libraryItem.isPodcast) || (libraryItem.isPodcast && !itemToAdd.episodeId)) { - return res.status(400).send('Invalid item to add for this library type') - } - if (itemToAdd.episodeId && !libraryItem.media.podcastEpisodes.some((pe) => pe.id === itemToAdd.episodeId)) { - return res.status(400).send('Episode not found in library item') - } req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem() @@ -306,27 +298,13 @@ class PlaylistController { const playlistMediaItem = { playlistId: req.playlist.id, - mediaItemId: itemToAdd.episodeId || libraryItem.media.id, - mediaItemType: itemToAdd.episodeId ? 'podcastEpisode' : 'book', + mediaItemId: resolvedItem.mediaItemId, + mediaItemType: resolvedItem.mediaItemType, 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.podcastEpisodes.find((ep) => ep.id === itemToAdd.episodeId) - jsonExpanded.items.push({ - episodeId: itemToAdd.episodeId, - episode: episode.toOldJSONExpanded(libraryItem.id), - libraryItemId: libraryItem.id, - libraryItem: libraryItem.toOldJSONMinified() - }) - } else { - jsonExpanded.items.push({ - libraryItemId: libraryItem.id, - libraryItem: libraryItem.toOldJSONExpanded() - }) - } + jsonExpanded.items.push(resolvedItem.jsonItem) SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_updated', jsonExpanded) res.json(jsonExpanded) @@ -391,11 +369,8 @@ class PlaylistController { return res.status(400).send('Invalid request body items') } - // Find all library items - const libraryItemIds = new Set(req.body.items.map((i) => i.libraryItemId).filter((i) => i)) - - const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({ id: Array.from(libraryItemIds) }) - if (libraryItems.length !== libraryItemIds.size) { + const resolvedItems = await resolvePlaylistRequestItems(req.body.items, req.playlist.libraryId) + if (!resolvedItems || resolvedItems.some((item) => !item)) { return res.status(400).send('Invalid request body items') } @@ -406,10 +381,8 @@ class PlaylistController { // Setup array of playlistMediaItem records to add let order = req.playlist.playlistMediaItems.length + 1 - for (const item of req.body.items) { - const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId) - - const mediaItemId = item.episodeId || libraryItem.media.id + for (const resolvedItem of resolvedItems) { + const { mediaItemId, mediaItemType, jsonItem } = resolvedItem if (req.playlist.playlistMediaItems.some((pmi) => pmi.mediaItemId === mediaItemId)) { // Already exists in playlist continue @@ -417,25 +390,10 @@ class PlaylistController { mediaItemsToAdd.push({ playlistId: req.playlist.id, mediaItemId, - mediaItemType: item.episodeId ? 'podcastEpisode' : 'book', + mediaItemType, 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.podcastEpisodes.find((ep) => ep.id === item.episodeId) - jsonExpanded.items.push({ - episodeId: item.episodeId, - episode: episode.toOldJSONExpanded(libraryItem.id), - libraryItemId: libraryItem.id, - libraryItem: libraryItem.toOldJSONMinified() - }) - } else { - jsonExpanded.items.push({ - libraryItemId: libraryItem.id, - libraryItem: libraryItem.toOldJSONExpanded() - }) - } + jsonExpanded.items.push(jsonItem) } } diff --git a/server/utils/playlistHelpers.js b/server/utils/playlistHelpers.js new file mode 100644 index 00000000..149ce656 --- /dev/null +++ b/server/utils/playlistHelpers.js @@ -0,0 +1,105 @@ +const Database = require('../Database') + +async function getPodcastEpisodesWithLibraryItems(episodeIds) { + if (!episodeIds.length) return [] + + return Database.podcastEpisodeModel.findAll({ + where: { + id: episodeIds + }, + include: [ + { + model: Database.podcastModel, + include: [ + { + model: Database.libraryItemModel + } + ] + } + ] + }) +} + +async function resolvePlaylistRequestItems(items, libraryId) { + const libraryItemIds = Array.from(new Set(items.map((i) => i.libraryItemId).filter((i) => i))) + const episodeIds = Array.from(new Set(items.map((i) => i.episodeId).filter((i) => i))) + const bookLibraryItemIds = Array.from(new Set(items.filter((i) => !i.episodeId).map((i) => i.libraryItemId))) + + const libraryItems = await Database.libraryItemModel.findAll({ + attributes: ['id', 'mediaId', 'mediaType', 'libraryId'], + where: { + id: libraryItemIds, + libraryId + } + }) + if (libraryItems.length !== libraryItemIds.length) { + return null + } + + const libraryItemsById = new Map(libraryItems.map((libraryItem) => [libraryItem.id, libraryItem])) + + // Books still need their fully expanded library item because the playlist response embeds the whole book object. + const bookLibraryItems = bookLibraryItemIds.length + ? await Database.libraryItemModel.findAllExpandedWhere({ + id: bookLibraryItemIds, + libraryId, + mediaType: 'book' + }) + : [] + const bookLibraryItemsById = new Map(bookLibraryItems.map((libraryItem) => [libraryItem.id, libraryItem])) + + // For podcast adds, load only the requested episodes plus their owning podcast/library item. + // The old expanded library-item query pulled every episode for the podcast, which is what blew up memory. + const podcastEpisodes = await getPodcastEpisodesWithLibraryItems(episodeIds) + if (podcastEpisodes.length !== episodeIds.length) { + return null + } + const podcastEpisodesById = new Map(podcastEpisodes.map((episode) => [episode.id, episode])) + + return items.map((item) => { + const libraryItem = libraryItemsById.get(item.libraryItemId) + if (!libraryItem) return null + + if (item.episodeId) { + const episode = podcastEpisodesById.get(item.episodeId) + if (libraryItem.mediaType !== 'podcast' || !episode?.podcast?.libraryItem || episode.podcast.libraryItem.id !== item.libraryItemId) { + return null + } + + const episodeLibraryItem = episode.podcast.libraryItem + episodeLibraryItem.media = episode.podcast + + return { + item, + mediaItemId: item.episodeId, + mediaItemType: 'podcastEpisode', + jsonItem: { + episodeId: item.episodeId, + episode: episode.toOldJSONExpanded(episodeLibraryItem.id), + libraryItemId: episodeLibraryItem.id, + libraryItem: episodeLibraryItem.toOldJSONMinified() + } + } + } + + const expandedBookLibraryItem = bookLibraryItemsById.get(item.libraryItemId) + if (libraryItem.mediaType !== 'book' || !expandedBookLibraryItem) { + return null + } + + return { + item, + mediaItemId: expandedBookLibraryItem.media.id, + mediaItemType: 'book', + jsonItem: { + libraryItemId: expandedBookLibraryItem.id, + libraryItem: expandedBookLibraryItem.toOldJSONExpanded() + } + } + }) +} + +module.exports = { + getPodcastEpisodesWithLibraryItems, + resolvePlaylistRequestItems +} From 2b8f0082df26d146770132aa3d1b19ea56ec56bb Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sun, 22 Mar 2026 18:59:44 -0700 Subject: [PATCH 2/2] Inline minimal podcast episode findAll --- server/utils/playlistHelpers.js | 47 +++++++++++++++------------------ 1 file changed, 21 insertions(+), 26 deletions(-) diff --git a/server/utils/playlistHelpers.js b/server/utils/playlistHelpers.js index 149ce656..b2ac1fde 100644 --- a/server/utils/playlistHelpers.js +++ b/server/utils/playlistHelpers.js @@ -1,30 +1,12 @@ const Database = require('../Database') -async function getPodcastEpisodesWithLibraryItems(episodeIds) { - if (!episodeIds.length) return [] - - return Database.podcastEpisodeModel.findAll({ - where: { - id: episodeIds - }, - include: [ - { - model: Database.podcastModel, - include: [ - { - model: Database.libraryItemModel - } - ] - } - ] - }) -} - async function resolvePlaylistRequestItems(items, libraryId) { + // Get lists of items, episodes, and books to simplify later operations const libraryItemIds = Array.from(new Set(items.map((i) => i.libraryItemId).filter((i) => i))) const episodeIds = Array.from(new Set(items.map((i) => i.episodeId).filter((i) => i))) const bookLibraryItemIds = Array.from(new Set(items.filter((i) => !i.episodeId).map((i) => i.libraryItemId))) + // Load library items for later mapping to episodes and books. const libraryItems = await Database.libraryItemModel.findAll({ attributes: ['id', 'mediaId', 'mediaType', 'libraryId'], where: { @@ -49,17 +31,30 @@ async function resolvePlaylistRequestItems(items, libraryId) { const bookLibraryItemsById = new Map(bookLibraryItems.map((libraryItem) => [libraryItem.id, libraryItem])) // For podcast adds, load only the requested episodes plus their owning podcast/library item. - // The old expanded library-item query pulled every episode for the podcast, which is what blew up memory. - const podcastEpisodes = await getPodcastEpisodesWithLibraryItems(episodeIds) - if (podcastEpisodes.length !== episodeIds.length) { - return null - } + const podcastEpisodes = episodeIds.length + ? await Database.podcastEpisodeModel.findAll({ + where: { + id: episodeIds + }, + include: [ + { + model: Database.podcastModel, + include: [ + { + model: Database.libraryItemModel + } + ] + } + ] + }) + : [] const podcastEpisodesById = new Map(podcastEpisodes.map((episode) => [episode.id, episode])) return items.map((item) => { const libraryItem = libraryItemsById.get(item.libraryItemId) if (!libraryItem) return null + // If this is an episode item, create the object with owning library item if (item.episodeId) { const episode = podcastEpisodesById.get(item.episodeId) if (libraryItem.mediaType !== 'podcast' || !episode?.podcast?.libraryItem || episode.podcast.libraryItem.id !== item.libraryItemId) { @@ -82,6 +77,7 @@ async function resolvePlaylistRequestItems(items, libraryId) { } } + // If not a podcast, this is a book const expandedBookLibraryItem = bookLibraryItemsById.get(item.libraryItemId) if (libraryItem.mediaType !== 'book' || !expandedBookLibraryItem) { return null @@ -100,6 +96,5 @@ async function resolvePlaylistRequestItems(items, libraryId) { } module.exports = { - getPodcastEpisodesWithLibraryItems, resolvePlaylistRequestItems }