mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-02-28 21:19:42 +00:00
Merge cc28a2cd06 into 1d0b7e383a
This commit is contained in:
commit
69ab2756bc
3 changed files with 235 additions and 0 deletions
|
|
@ -388,6 +388,22 @@ class LibraryItem extends Model {
|
|||
}
|
||||
Logger.debug(`Loaded ${continueSeriesPayload.libraryItems.length} of ${continueSeriesPayload.count} items for "Continue Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
|
||||
} else if (library.isPodcast) {
|
||||
start = Date.now()
|
||||
// "Continue Series" shelf for serial podcasts
|
||||
const continueSeriesPayload = await libraryFilters.getPodcastEpisodesContinueSeries(library, user, limit)
|
||||
if (continueSeriesPayload.libraryItems.length) {
|
||||
shelves.push({
|
||||
id: 'continue-series',
|
||||
label: 'Continue Series',
|
||||
labelStringKey: 'LabelContinueSeries',
|
||||
type: 'episode',
|
||||
entities: continueSeriesPayload.libraryItems,
|
||||
total: continueSeriesPayload.count
|
||||
})
|
||||
}
|
||||
Logger.debug(`Loaded ${continueSeriesPayload.libraryItems.length} of ${continueSeriesPayload.count} episodes for "Continue Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
|
||||
|
||||
start = Date.now()
|
||||
// "Newest Episodes" shelf
|
||||
const newestEpisodesPayload = await libraryFilters.getNewestPodcastEpisodes(library, user, limit)
|
||||
if (newestEpisodesPayload.libraryItems.length) {
|
||||
|
|
|
|||
|
|
@ -410,6 +410,31 @@ module.exports = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get podcast episodes for continue series shelf
|
||||
* Returns the next episode for podcasts that have at least 1 finished episode and at least 1 unfinished episode
|
||||
* - Serial podcasts: oldest unfinished episode (oldest to newest)
|
||||
* - Episodic podcasts: newest unfinished episode (latest to oldest)
|
||||
* An episode is unfinished if: has progress with isFinished = false OR no progress entry
|
||||
* @param {import('../../models/Library')} library
|
||||
* @param {import('../../models/User')} user
|
||||
* @param {number} limit
|
||||
* @returns {Promise<{libraryItems:oldLibraryItem[], count:number}>}
|
||||
*/
|
||||
async getPodcastEpisodesContinueSeries(library, user, limit) {
|
||||
if (library.mediaType !== 'podcast') return { libraryItems: [], count: 0 }
|
||||
|
||||
const { libraryItems, count } = await libraryItemsPodcastFilters.getContinueSeriesPodcastEpisodes(user, library, limit, 0)
|
||||
return {
|
||||
count,
|
||||
libraryItems: libraryItems.map((li) => {
|
||||
const oldLibraryItem = li.toOldJSONMinified()
|
||||
oldLibraryItem.recentEpisode = li.recentEpisode
|
||||
return oldLibraryItem
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get library items for an author, optional use user permissions
|
||||
* @param {import('../../models/Author')} author
|
||||
|
|
|
|||
|
|
@ -629,5 +629,199 @@ module.exports = {
|
|||
duration: podcast.dataValues.duration
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Get SQL condition for unfinished episodes (isFinished = false OR no progress entry)
|
||||
* @param {string} episodeAlias - Table alias for podcastEpisodes (default: 'pe')
|
||||
* @param {boolean} excludeHidden - Whether to exclude episodes with hideFromContinueListening (default: false)
|
||||
* @returns {string}
|
||||
*/
|
||||
getUnfinishedEpisodeCondition(episodeAlias = 'pe', excludeHidden = false) {
|
||||
if (excludeHidden) {
|
||||
// Episode is unfinished if: no progress OR (progress exists with isFinished=false AND hideFromContinueListening!=true)
|
||||
return `((SELECT count(*) FROM mediaProgresses WHERE mediaItemId = ${episodeAlias}.id AND userId = :userId) = 0 OR ((SELECT isFinished FROM mediaProgresses WHERE mediaItemId = ${episodeAlias}.id AND userId = :userId) = 0 AND (SELECT hideFromContinueListening FROM mediaProgresses WHERE mediaItemId = ${episodeAlias}.id AND userId = :userId) != 1))`
|
||||
}
|
||||
return `(SELECT isFinished FROM mediaProgresses WHERE mediaItemId = ${episodeAlias}.id AND userId = :userId) = 0 OR (SELECT count(*) FROM mediaProgresses WHERE mediaItemId = ${episodeAlias}.id AND userId = :userId) = 0`
|
||||
},
|
||||
|
||||
/**
|
||||
* Get continue series podcast episodes
|
||||
* Returns the next episode for podcasts that have at least 1 finished episode and at least 1 unfinished episode
|
||||
* - Serial podcasts: oldest unfinished episode (oldest to newest)
|
||||
* - Episodic podcasts: newest unfinished episode (latest to oldest)
|
||||
* An episode is unfinished if: has progress with isFinished = false OR no progress entry
|
||||
* @param {import('../../models/User')} user
|
||||
* @param {import('../../models/Library')} library
|
||||
* @param {number} limit
|
||||
* @param {number} offset
|
||||
* @returns {Promise<{ libraryItems: import('../../models/LibraryItem')[], count: number }>}
|
||||
*/
|
||||
async getContinueSeriesPodcastEpisodes(user, library, limit, offset) {
|
||||
if (library.mediaType !== 'podcast') return { libraryItems: [], count: 0 }
|
||||
|
||||
const userPermissionPodcastWhere = this.getUserPermissionPodcastWhereQuery(user)
|
||||
const userId = user.id
|
||||
|
||||
// Find qualifying podcasts: must have at least 1 finished and 1 unfinished episode (excluding hidden)
|
||||
const unfinishedCondition = this.getUnfinishedEpisodeCondition('pe', true)
|
||||
const { rows: podcasts, count } = await Database.podcastModel.findAndCountAll({
|
||||
where: [
|
||||
Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM podcastEpisodes pe INNER JOIN mediaProgresses mp ON mp.mediaItemId = pe.id AND mp.userId = :userId WHERE pe.podcastId = podcast.id AND mp.isFinished = 1)`), {
|
||||
[Sequelize.Op.gte]: 1
|
||||
}),
|
||||
Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM podcastEpisodes pe WHERE pe.podcastId = podcast.id AND (${unfinishedCondition}))`), {
|
||||
[Sequelize.Op.gte]: 1
|
||||
}),
|
||||
...userPermissionPodcastWhere.podcastWhere
|
||||
],
|
||||
attributes: {
|
||||
include: [[Sequelize.literal(`(SELECT max(mp.updatedAt) FROM podcastEpisodes pe INNER JOIN mediaProgresses mp ON mp.mediaItemId = pe.id AND mp.userId = :userId WHERE pe.podcastId = podcast.id AND mp.isFinished = 1)`), 'recent_progress']]
|
||||
},
|
||||
replacements: { userId, ...userPermissionPodcastWhere.replacements },
|
||||
include: [
|
||||
{
|
||||
model: Database.libraryItemModel,
|
||||
required: true,
|
||||
where: { libraryId: library.id }
|
||||
}
|
||||
],
|
||||
order: [[Sequelize.literal('recent_progress DESC')]],
|
||||
distinct: true,
|
||||
subQuery: false,
|
||||
limit,
|
||||
offset
|
||||
})
|
||||
|
||||
if (podcasts.length === 0) return { libraryItems: [], count }
|
||||
|
||||
// Separate serial and episodic podcasts (default to episodic if type is unknown)
|
||||
const serialPodcasts = podcasts.filter((p) => p.podcastType === 'serial')
|
||||
const episodicPodcasts = podcasts.filter((p) => p.podcastType !== 'serial')
|
||||
|
||||
const unfinishedConditionSubquery = this.getUnfinishedEpisodeCondition('pe2', true)
|
||||
const episodeMap = new Map()
|
||||
|
||||
// Get oldest unfinished episode for serial podcasts
|
||||
if (serialPodcasts.length > 0) {
|
||||
const serialPodcastIds = serialPodcasts.map((p) => p.id)
|
||||
const [serialEpisodes] = await Database.sequelize.query(
|
||||
`SELECT pe.id, pe.podcastId
|
||||
FROM podcastEpisodes pe
|
||||
INNER JOIN (
|
||||
SELECT pe2.podcastId, MIN(pe2.publishedAt) as targetPublishedAt
|
||||
FROM podcastEpisodes pe2
|
||||
WHERE pe2.podcastId IN (:podcastIds) AND (${unfinishedConditionSubquery})
|
||||
GROUP BY pe2.podcastId
|
||||
) AS target ON pe.podcastId = target.podcastId AND pe.publishedAt = target.targetPublishedAt
|
||||
WHERE pe.podcastId IN (:podcastIds)
|
||||
AND pe.id = (SELECT pe3.id FROM podcastEpisodes pe3 WHERE pe3.podcastId = pe.podcastId AND pe3.publishedAt = target.targetPublishedAt ORDER BY pe3.id ASC LIMIT 1)`,
|
||||
{
|
||||
replacements: { podcastIds: serialPodcastIds, userId },
|
||||
type: Sequelize.QueryTypes.SELECT
|
||||
}
|
||||
)
|
||||
|
||||
for (const row of serialEpisodes) {
|
||||
episodeMap.set(row.podcastId, row.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Get newest unfinished episode for episodic podcasts
|
||||
if (episodicPodcasts.length > 0) {
|
||||
const episodicPodcastIds = episodicPodcasts.map((p) => p.id)
|
||||
const [episodicEpisodes] = await Database.sequelize.query(
|
||||
`SELECT pe.id, pe.podcastId
|
||||
FROM podcastEpisodes pe
|
||||
INNER JOIN (
|
||||
SELECT pe2.podcastId, MAX(pe2.publishedAt) as targetPublishedAt
|
||||
FROM podcastEpisodes pe2
|
||||
WHERE pe2.podcastId IN (:podcastIds) AND (${unfinishedConditionSubquery})
|
||||
GROUP BY pe2.podcastId
|
||||
) AS target ON pe.podcastId = target.podcastId AND pe.publishedAt = target.targetPublishedAt
|
||||
WHERE pe.podcastId IN (:podcastIds)
|
||||
AND pe.id = (SELECT pe3.id FROM podcastEpisodes pe3 WHERE pe3.podcastId = pe.podcastId AND pe3.publishedAt = target.targetPublishedAt ORDER BY pe3.id ASC LIMIT 1)`,
|
||||
{
|
||||
replacements: { podcastIds: episodicPodcastIds, userId },
|
||||
type: Sequelize.QueryTypes.SELECT
|
||||
}
|
||||
)
|
||||
|
||||
for (const row of episodicEpisodes) {
|
||||
episodeMap.set(row.podcastId, row.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (episodeMap.size === 0) return { libraryItems: [], count }
|
||||
|
||||
// Fetch all episodes with progress data
|
||||
const episodeIds = Array.from(episodeMap.values())
|
||||
const episodes = await Database.podcastEpisodeModel.findAll({
|
||||
where: { id: { [Sequelize.Op.in]: episodeIds } },
|
||||
include: [
|
||||
{
|
||||
model: Database.mediaProgressModel,
|
||||
where: { userId },
|
||||
required: false,
|
||||
attributes: ['id', 'isFinished', 'currentTime', 'updatedAt', 'hideFromContinueListening']
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// Create episode lookup map
|
||||
const episodesById = new Map(episodes.map((e) => [e.id, e]))
|
||||
|
||||
// Build library items, sorted by podcast type
|
||||
// Serial: oldest to newest (by episode publishedAt ASC)
|
||||
// Episodic: latest to oldest (by episode publishedAt DESC)
|
||||
// Filter out episodes with hideFromContinueListening
|
||||
const serialItems = serialPodcasts
|
||||
.map((podcast) => {
|
||||
const episodeId = episodeMap.get(podcast.id)
|
||||
const episode = episodeId ? episodesById.get(episodeId) : null
|
||||
if (!episode) return null
|
||||
|
||||
// Skip if episode is hidden from continue listening
|
||||
const progress = episode.mediaProgresses?.[0]
|
||||
if (progress?.hideFromContinueListening) return null
|
||||
|
||||
const libraryItem = podcast.libraryItem
|
||||
const podcastData = podcast
|
||||
delete podcastData.libraryItem
|
||||
|
||||
libraryItem.media = podcastData
|
||||
libraryItem.recentEpisode = episode.toOldJSON(libraryItem.id)
|
||||
return { libraryItem, sortKey: episode.publishedAt || 0 }
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.sortKey - b.sortKey) // Oldest to newest
|
||||
|
||||
const episodicItems = episodicPodcasts
|
||||
.map((podcast) => {
|
||||
const episodeId = episodeMap.get(podcast.id)
|
||||
const episode = episodeId ? episodesById.get(episodeId) : null
|
||||
if (!episode) return null
|
||||
|
||||
// Skip if episode is hidden from continue listening
|
||||
const progress = episode.mediaProgresses?.[0]
|
||||
if (progress?.hideFromContinueListening) return null
|
||||
|
||||
const libraryItem = podcast.libraryItem
|
||||
const podcastData = podcast
|
||||
delete podcastData.libraryItem
|
||||
|
||||
libraryItem.media = podcastData
|
||||
libraryItem.recentEpisode = episode.toOldJSON(libraryItem.id)
|
||||
return { libraryItem, sortKey: episode.publishedAt || 0 }
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => b.sortKey - a.sortKey) // Latest to oldest
|
||||
|
||||
// Combine: serial first (oldest to newest), then episodic (latest to oldest)
|
||||
const libraryItems = [...serialItems, ...episodicItems].map((item) => item.libraryItem)
|
||||
|
||||
// Count reflects total qualifying podcasts (hidden episodes already excluded in qualification query)
|
||||
// Final filter is a safety check and shouldn't remove items
|
||||
return { libraryItems, count }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue