This commit is contained in:
Rapha149 2026-05-06 13:51:21 +02:00 committed by GitHub
commit 8e28f36579
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 310 additions and 12 deletions

View file

@ -65,6 +65,14 @@
<div cy-id="ebookFormat" v-if="ebookFormat" class="absolute" :style="{ bottom: 0.375 + 'em', left: 0.375 + 'em' }">
<span class="text-white/80" :style="{ fontSize: 0.8 + 'em' }">{{ ebookFormat }}</span>
</div>
<!-- Unfavorite star icon -->
<div v-if="!isFavorite && !isSelectionMode"
class="absolute text-gray-300 hover:text-yellow-400 left-0 z-10 cursor-pointer hover:scale-110 transform duration-150"
:style="{ padding: 0.375 + 'em', bottom: ebookFormat ? '1.25em' : '0px' }"
@click.stop.prevent="toggleFavorite">
<span class="material-symbols" aria-hidden="true" :style="{ fontSize: 1.5 + 'em' }">star</span>
</div>
</div>
<!-- Processing/loading spinner overlay -->
@ -93,6 +101,14 @@
<span class="material-symbols" aria-hidden="true" :style="{ fontSize: 1.5 + 'em' }">public</span>
</div>
<!-- Favorite star icon -->
<div v-if="isFavorite && !isSelectionMode"
class="absolute text-yellow-400 left-0 z-10 cursor-pointer hover:scale-110 transform duration-150"
:style="{ padding: 0.375 + 'em', bottom: ebookFormat ? '1.25em' : '0px' }"
@click.stop.prevent="toggleFavorite">
<span class="material-symbols fill" aria-hidden="true" :style="{ fontSize: 1.5 + 'em', textShadow: '0 0 3px black' }">star</span>
</div>
<!-- Series sequence -->
<div cy-id="seriesSequence" v-if="seriesSequence && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black/90 box-shadow-md z-10" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }">
<p :style="{ fontSize: 0.8 + 'em' }">#{{ seriesSequence }}</p>
@ -653,6 +669,9 @@ export default {
mediaItemShare() {
return this._libraryItem.mediaItemShare || null
},
isFavorite() {
return this.store.getters['user/getIsLibraryItemFavorite'](this.libraryItemId)
},
showSubtitles() {
return !this.isPodcast && this.store.getters['user/getUserSetting']('showSubtitles')
}
@ -757,6 +776,22 @@ export default {
toast.error(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedFailed : this.$strings.ToastItemMarkedAsNotFinishedFailed)
})
},
toggleFavorite() {
const axios = this.$axios || this.$nuxt.$axios
const endpoint = `/api/me/item/${this.libraryItemId}/favorite`
if (this.isFavorite) {
axios.$delete(endpoint).catch(error => {
console.error('Failed to remove favorite', error)
this.$toast.error('Failed to remove from favorites')
})
} else {
axios.$post(endpoint).catch(error => {
console.error('Failed to add favorite', error)
this.$toast.error('Failed to add to favorites')
})
}
},
editPodcast() {
this.$emit('editPodcast', this.libraryItem)
},

View file

@ -158,6 +158,11 @@ export default {
text: this.$strings.LabelAll,
value: 'all'
},
{
text: this.$strings.LabelFavorite,
value: 'favorite',
sublist: false
},
{
text: this.$strings.LabelGenre,
textPlural: this.$strings.LabelGenres,
@ -266,6 +271,11 @@ export default {
text: this.$strings.LabelAll,
value: 'all'
},
{
text: this.$strings.LabelFavorite,
value: 'favorite',
sublist: false
},
{
text: this.$strings.LabelGenre,
textPlural: this.$strings.LabelGenres,

View file

@ -29,6 +29,9 @@
{{ title }}
<widgets-explicit-indicator v-if="isExplicit" />
<widgets-abridged-indicator v-if="isAbridged" />
<button class="ml-2 cursor-pointer hover:scale-110 transform duration-150 flex items-center" @click="toggleFavorite">
<span class="material-symbols hover:text-yellow-400" :class="[isFavorite ? 'fill text-yellow-400' : 'text-gray-300']" :style="{ fontSize: '.9em' }">star</span>
</button>
</div>
</h1>
@ -228,6 +231,9 @@ export default {
isAbridged() {
return !!this.mediaMetadata.abridged
},
isFavorite() {
return this.$store.getters['user/getIsLibraryItemFavorite'](this.libraryItemId)
},
showPlayButton() {
if (this.isMissing || this.isInvalid) return false
if (this.isPodcast) return this.podcastEpisodes.length
@ -530,6 +536,22 @@ export default {
this.$toast.error(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedFailed : this.$strings.ToastItemMarkedAsNotFinishedFailed)
})
},
toggleFavorite() {
const axios = this.$axios || this.$nuxt.$axios
const endpoint = `/api/me/item/${this.libraryItemId}/favorite`
if (this.isFavorite) {
axios.$delete(endpoint).catch(error => {
console.error('Failed to remove favorite', error)
this.$toast.error('Failed to remove from favorites')
})
} else {
axios.$post(endpoint).catch(error => {
console.error('Failed to add favorite', error)
this.$toast.error('Failed to add to favorites')
})
}
},
playItem(startTime = null) {
let episodeId = null
const queueItems = []

View file

@ -4,9 +4,15 @@
<div id="bookshelf" class="w-full overflow-y-auto px-2 py-6 sm:px-4 md:p-12 relative">
<div class="w-full max-w-3xl mx-auto py-4">
<p class="text-xl mb-2 font-semibold px-4 md:px-0">{{ $strings.HeaderLatestEpisodes }}</p>
<p v-if="!recentEpisodes.length && !processing" class="text-center text-xl">{{ $strings.MessageNoEpisodes }}</p>
<template v-for="(episode, index) in episodesMapped">
<div class="flex items-center mb-2 px-4 md:px-0">
<p class="text-xl font-semibold">{{ $strings.HeaderLatestEpisodes }}</p>
<div class="flex items-center ml-4 cursor-pointer" @click="toggleOnlyShowFavorites">
<span class="material-symbols text-xl" :class="onlyShowFavorites ? 'fill text-yellow-400' : 'text-gray-400'">{{ onlyShowFavorites ? 'check_box' : 'check_box_outline_blank' }}</span>
<span class="text-sm ml-1 text-gray-300">{{ $strings.LabelOnlyFavorites }}</span>
</div>
</div>
<p v-if="!filteredEpisodes.length && !processing" class="text-center text-xl">{{ $strings.MessageNoEpisodes }}</p>
<template v-for="(episode, index) in filteredEpisodes">
<div :key="episode.id" class="flex py-5 cursor-pointer relative" @click.stop="clickEpisode(episode)">
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](episode.libraryItemId, episode.updatedAt)" :width="96" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" class="hidden md:block" />
<div class="grow pl-4 max-w-2xl">
@ -19,6 +25,7 @@
<nuxt-link :to="`/item/${episode.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ episode.podcast.metadata.title }}</nuxt-link>
</div>
<widgets-explicit-indicator v-if="episode.podcast.metadata.explicit" />
<span v-if="$store.getters['user/getIsLibraryItemFavorite'](episode.libraryItemId)" class="material-symbols fill text-yellow-400 text-sm ml-1 !block" :style="{ fontSize: '.9em' }">star</span>
</div>
<p class="text-xs text-gray-300 mb-1">{{ $dateDistanceFromNow(episode.publishedAt) }}</p>
</div>
@ -30,6 +37,7 @@
<nuxt-link :to="`/item/${episode.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ episode.podcast.metadata.title }}</nuxt-link>
</div>
<widgets-explicit-indicator v-if="episode.podcast.metadata.explicit" />
<span v-if="$store.getters['user/getIsLibraryItemFavorite'](episode.libraryItemId)" class="material-symbols fill text-yellow-400 text-sm ml-1 !block" :style="{ fontSize: '.9em' }">star</span>
</div>
<p class="text-xs text-gray-300 mb-1">{{ $dateDistanceFromNow(episode.publishedAt) }}</p>
</div>
@ -70,7 +78,7 @@
<div v-if="episode.progress" class="absolute bottom-0 left-0 h-0.5 pointer-events-none bg-warning" :style="{ width: episode.progress.progress * 100 + '%' }" />
</div>
<div :key="index" v-if="index !== recentEpisodes.length" class="w-full h-px bg-white/10" />
<div :key="index" v-if="index !== filteredEpisodes.length" class="w-full h-px bg-white/10" />
</template>
</div>
</div>
@ -130,6 +138,13 @@ export default {
}
})
},
onlyShowFavorites() {
return this.$store.getters['user/getUserSetting']('podcastLatestOnlyFavorites')
},
filteredEpisodes() {
if (!this.onlyShowFavorites) return this.episodesMapped
return this.episodesMapped.filter((ep) => this.$store.getters['user/getIsLibraryItemFavorite'](ep.libraryItemId))
},
playerQueueItems() {
return this.$store.state.playerQueueItems || []
},
@ -145,6 +160,9 @@ export default {
}
},
methods: {
toggleOnlyShowFavorites() {
this.$store.dispatch('user/updateUserSettings', { podcastLatestOnlyFavorites: !this.onlyShowFavorites })
},
async toggleEpisodeFinished(episode, confirmed = false) {
if (this.episodesProcessingMap[episode.id]) {
console.warn('Episode is already processing')

View file

@ -5,6 +5,7 @@ export const state = () => ({
orderBy: 'media.metadata.title',
orderDesc: false,
filterBy: 'all',
podcastLatestOnlyFavorites: false,
playbackRate: 1,
playbackRateIncrementDecrement: 0.1,
bookshelfCoverSize: 120,
@ -37,6 +38,9 @@ export const getters = {
return li.libraryItemId == libraryItemId
})
},
getIsLibraryItemFavorite: (state) => (libraryItemId) => {
return state.user?.favorites?.includes(libraryItemId) || false
},
getUserBookmarksForItem: (state) => (libraryItemId) => {
if (!state.user?.bookmarks) return []
return state.user.bookmarks.filter((bm) => bm.libraryItemId === libraryItemId)

View file

@ -367,6 +367,7 @@
"LabelExplicitChecked": "Explicit (Altersbeschränkung) (angehakt)",
"LabelExplicitUnchecked": "Not Explicit (Altersbeschränkung) (nicht angehakt)",
"LabelExportOPML": "OPML exportieren",
"LabelFavorite": "Favorit",
"LabelFeedURL": "Feed-URL",
"LabelFetchingMetadata": "Abholen der Metadaten",
"LabelFile": "Datei",
@ -497,6 +498,7 @@
"LabelNumberOfBooks": "Anzahl der Hörbücher",
"LabelNumberOfChapters": "Anzahl an Kapiteln:",
"LabelNumberOfEpisodes": "Anzahl der Episoden",
"LabelOnlyFavorites": "Nur Favoriten",
"LabelOpenIDAdvancedPermsClaimDescription": "Name des OpenID-Claims, der erweiterte Berechtigungen für Benutzeraktionen innerhalb der Anwendung enthält, die auf Nicht-Admin-Rollen angewendet werden (<b>wenn konfiguriert</b>). Wenn der Claim in der Antwort fehlt, wird der Zugang zu ABS verweigert. Fehlt eine einzelne Option, wird sie als <code>false</code> behandelt. Stelle sicher, dass der Claim des Identitätsanbieters der erwarteten Struktur entspricht:",
"LabelOpenIDClaims": "Lass die folgenden Optionen leer, um die erweiterte Zuweisung von Gruppen und Berechtigungen zu deaktivieren und automatisch die 'User'-Gruppe zuzuweisen.",
"LabelOpenIDGroupClaimDescription": "Name des OpenID-Claims, der eine Liste der Benutzergruppen enthält. Wird häufig als <code>groups</code> bezeichnet. <b>Wenn konfiguriert</b>, wird die Anwendung automatisch Rollen basierend auf den Gruppenmitgliedschaften des Benutzers zuweisen, vorausgesetzt, dass diese Gruppen im Claim als 'admin', 'user' oder 'guest' benannt sind (Groß/Kleinschreibung ist irrelevant). Der Claim eine Liste sein, und wenn ein Benutzer mehreren Gruppen angehört, wird die Anwendung die Rolle zuordnen, die dem höchsten Zugriffslevel entspricht. Wenn keine Gruppe übereinstimmt, wird der Zugang verweigert.",

View file

@ -367,6 +367,7 @@
"LabelExplicitChecked": "Explicit (checked)",
"LabelExplicitUnchecked": "Not Explicit (unchecked)",
"LabelExportOPML": "Export OPML",
"LabelFavorite": "Favorite",
"LabelFeedURL": "Feed URL",
"LabelFetchingMetadata": "Fetching Metadata",
"LabelFile": "File",
@ -497,6 +498,7 @@
"LabelNumberOfBooks": "Number of Books",
"LabelNumberOfChapters": "Number of chapters:",
"LabelNumberOfEpisodes": "# of Episodes",
"LabelOnlyFavorites": "Only Favorites",
"LabelOpenIDAdvancedPermsClaimDescription": "Name of the OpenID claim that contains advanced permissions for user actions within the application which will apply to non-admin roles (<b>if configured</b>). If the claim is missing from the response, access to ABS will be denied. If a single option is missing, it will be treated as <code>false</code>. Ensure the identity provider's claim matches the expected structure:",
"LabelOpenIDClaims": "Leave the following options empty to disable advanced group and permissions assignment, automatically assigning 'User' group then.",
"LabelOpenIDGroupClaimDescription": "Name of the OpenID claim that contains a list of the user's groups. Commonly referred to as <code>groups</code>. <b>If configured</b>, the application will automatically assign roles based on the user's group memberships, provided that these groups are named case-insensitively 'admin', 'user', or 'guest' in the claim. The claim should contain a list, and if a user belongs to multiple groups, the application will assign the role corresponding to the highest level of access. If no group matches, access will be denied.",

View file

@ -102,6 +102,11 @@ class Database {
return this.models.libraryItem
}
/** @type {typeof import('./models/UserFavorite')} */
get userFavoriteModel() {
return this.models.userFavorite
}
/** @type {typeof import('./models/PodcastEpisode')} */
get podcastEpisodeModel() {
return this.models.podcastEpisode
@ -329,6 +334,7 @@ class Database {
require('./models/Podcast').init(this.sequelize)
require('./models/PodcastEpisode').init(this.sequelize)
require('./models/LibraryItem').init(this.sequelize)
require('./models/UserFavorite').init(this.sequelize)
require('./models/MediaProgress').init(this.sequelize)
require('./models/Series').init(this.sequelize)
require('./models/BookSeries').init(this.sequelize)

View file

@ -198,6 +198,67 @@ class MeController {
res.sendStatus(200)
}
/**
* POST: /api/me/item/:id/favorite
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async addFavorite(req, res) {
const libraryItemId = req.params.id
await Database.userFavoriteModel.create({
userId: req.user.id,
libraryItemId
})
// Reload favorites for the user
await req.user.reload({
include: [
Database.mediaProgressModel,
{
model: Database.libraryItemModel,
as: 'favorites',
attributes: ['id'],
through: { attributes: [] }
}
]
})
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toOldJSONForBrowser())
res.sendStatus(200)
}
/**
* DELETE: /api/me/item/:id/favorite
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async removeFavorite(req, res) {
const libraryItemId = req.params.id
await Database.userFavoriteModel.destroy({
where: { userId: req.user.id, libraryItemId }
})
// Reload favorites for the user
await req.user.reload({
include: [
Database.mediaProgressModel,
{
model: Database.libraryItemModel,
as: 'favorites',
attributes: ['id'],
through: { attributes: [] }
}
]
})
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toOldJSONForBrowser())
res.sendStatus(200)
}
/**
* POST: /api/me/item/:id/bookmark
*

View file

@ -32,7 +32,7 @@ class UserController {
const includes = (req.query.include || '').split(',').map((i) => i.trim())
// Minimal toJSONForBrowser does not include mediaProgress and bookmarks
// Minimal toJSONForBrowser does not include mediaProgress, bookmarks and favorites
const allUsers = await Database.userModel.findAll()
const users = allUsers.map((u) => u.toOldJSONForBrowser(hideRootToken, true))

View file

@ -114,6 +114,8 @@ class User extends Model {
this.updatedAt
/** @type {import('./MediaProgress')[]?} - Only included when extended */
this.mediaProgresses
/** @type {import('./LibraryItem')[]?} - Only included when extended */
this.favorites
}
// Excludes "root" since their can only be 1 root user
@ -357,7 +359,15 @@ class User extends Model {
[sequelize.Op.like]: username
}
},
include: this.sequelize.models.mediaProgress
include: [
this.sequelize.models.mediaProgress,
{
model: this.sequelize.models.libraryItem,
as: 'favorites',
attributes: ['id'],
through: { attributes: [] }
}
]
})
if (user) userCache.set(user)
@ -382,7 +392,15 @@ class User extends Model {
[sequelize.Op.like]: email
}
},
include: this.sequelize.models.mediaProgress
include: [
this.sequelize.models.mediaProgress,
{
model: this.sequelize.models.libraryItem,
as: 'favorites',
attributes: ['id'],
through: { attributes: [] }
}
]
})
if (user) userCache.set(user)
@ -402,7 +420,15 @@ class User extends Model {
if (cachedUser) return cachedUser
const user = await this.findByPk(userId, {
include: this.sequelize.models.mediaProgress
include: [
this.sequelize.models.mediaProgress,
{
model: this.sequelize.models.libraryItem,
as: 'favorites',
attributes: ['id'],
through: { attributes: [] }
}
]
})
if (user) userCache.set(user)
@ -426,7 +452,15 @@ class User extends Model {
where: {
[sequelize.Op.or]: [{ id: userId }, { 'extraData.oldUserId': userId }]
},
include: this.sequelize.models.mediaProgress
include: [
this.sequelize.models.mediaProgress,
{
model: this.sequelize.models.libraryItem,
as: 'favorites',
attributes: ['id'],
through: { attributes: [] }
}
]
})
if (user) userCache.set(user)
@ -447,7 +481,15 @@ class User extends Model {
const user = await this.findOne({
where: sequelize.where(sequelize.literal(`extraData->>"authOpenIDSub"`), sub),
include: this.sequelize.models.mediaProgress
include: [
this.sequelize.models.mediaProgress,
{
model: this.sequelize.models.libraryItem,
as: 'favorites',
attributes: ['id'],
through: { attributes: [] }
}
]
})
if (user) userCache.set(user)
@ -621,6 +663,7 @@ class User extends Model {
isOldToken: this.isOldToken,
mediaProgress: this.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || [],
seriesHideFromContinueListening: [...seriesHideFromContinueListening],
favorites: this.favorites?.map(f => f.id) || [],
bookmarks: this.bookmarks?.map((b) => ({ ...b })) || [],
isActive: this.isActive,
isLocked: this.isLocked,
@ -634,6 +677,7 @@ class User extends Model {
if (minimal) {
delete json.mediaProgress
delete json.bookmarks
delete json.favorites
}
return json
}

View file

@ -0,0 +1,66 @@
const { DataTypes, Model } = require('sequelize')
class UserFavorite extends Model {
constructor(values, options) {
super(values, options)
/** @type {UUIDV4} */
this.id
/** @type {UUIDV4} */
this.libraryItemId
/** @type {UUIDV4} */
this.userId
}
static init(sequelize) {
super.init(
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
libraryItemId: DataTypes.UUID,
userId: DataTypes.UUID
},
{
sequelize,
modelName: 'userFavorite',
indexes: [
{
fields: ['userId']
},
{
fields: ['libraryItemId']
},
{
unique: true,
fields: ['libraryItemId', 'userId'],
}
]
}
)
const { libraryItem, user } = sequelize.models
libraryItem.hasMany(UserFavorite, {
foreignKey: 'libraryItemId',
onDelete: 'CASCADE'
})
UserFavorite.belongsTo(libraryItem, { foreignKey: 'libraryItemId' })
user.hasMany(UserFavorite, {
foreignKey: 'userId',
onDelete: 'CASCADE'
})
user.belongsToMany(libraryItem, {
through: UserFavorite,
foreignKey: 'userId',
otherKey: 'libraryItemId',
as: 'favorites'
})
UserFavorite.belongsTo(user, { foreignKey: 'userId' })
}
}
module.exports = UserFavorite

View file

@ -179,6 +179,8 @@ class ApiRouter {
this.router.patch('/me/progress/batch/update', MeController.batchUpdateMediaProgress.bind(this))
this.router.patch('/me/progress/:libraryItemId/:episodeId?', MeController.createUpdateMediaProgress.bind(this))
this.router.delete('/me/progress/:id', MeController.removeMediaProgress.bind(this))
this.router.post('/me/item/:id/favorite', MeController.addFavorite.bind(this))
this.router.delete('/me/item/:id/favorite', MeController.removeFavorite.bind(this))
this.router.post('/me/item/:id/bookmark', MeController.createBookmark.bind(this))
this.router.patch('/me/item/:id/bookmark', MeController.updateBookmark.bind(this))
this.router.delete('/me/item/:id/bookmark/:time', MeController.removeBookmark.bind(this))

View file

@ -106,7 +106,11 @@ module.exports = {
let mediaWhere = {}
const replacements = {}
if (group === 'progress') {
if (group === 'favorite') {
mediaWhere['$libraryItem.userFavorites.userId$'] = {
[Sequelize.Op.not]: null
}
} else if (group === 'progress') {
if (value === 'not-finished') {
mediaWhere['$mediaProgresses.isFinished$'] = {
[Sequelize.Op.or]: [null, false]
@ -531,6 +535,15 @@ module.exports = {
libraryItemWhere['createdAt'] = {
[Sequelize.Op.gte]: new Date(new Date() - 60 * 24 * 60 * 60 * 1000) // 60 days ago
}
} else if (filterGroup === 'favorite' && user) {
libraryItemIncludes.push({
model: Database.userFavoriteModel,
attributes: ['userId'],
where: {
userId: user.id
},
required: true
})
}
// When sorting by progress but not filtering by progress, include media progresses

View file

@ -52,7 +52,11 @@ module.exports = {
let mediaWhere = {}
const replacements = {}
if (['genres', 'tags'].includes(group)) {
if (group === 'favorite') {
mediaWhere['$libraryItem.userFavorites.userId$'] = {
[Sequelize.Op.not]: null
}
} else if (['genres', 'tags'].includes(group)) {
mediaWhere[group] = Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(${group}) WHERE json_valid(${group}) AND json_each.value = :filterValue)`), {
[Sequelize.Op.gte]: 1
})
@ -172,6 +176,15 @@ module.exports = {
libraryItemWhere['createdAt'] = {
[Sequelize.Op.gte]: new Date(new Date() - 60 * 24 * 60 * 60 * 1000) // 60 days ago
}
} else if (filterGroup === 'favorite' && user) {
libraryItemIncludes.push({
model: Database.userFavoriteModel,
attributes: ['userId'],
where: {
userId: user.id
},
required: true
})
}
const podcastIncludes = []