From a5999fb9df566656cd00c35dcee7e4cc12134130 Mon Sep 17 00:00:00 2001 From: Rapha149 <49787110+Rapha149@users.noreply.github.com> Date: Mon, 16 Mar 2026 19:46:45 +0100 Subject: [PATCH 1/3] Add favorite property for items and associated filters. --- client/components/cards/LazyBookCard.vue | 35 ++++++++++ .../controls/LibraryFilterSelect.vue | 10 +++ client/pages/item/_id/index.vue | 22 +++++++ .../pages/library/_library/podcast/latest.vue | 26 ++++++-- client/store/user.js | 4 ++ client/strings/de.json | 2 + client/strings/en-us.json | 2 + server/Database.js | 6 ++ server/controllers/MeController.js | 61 +++++++++++++++++ server/models/User.js | 53 +++++++++++++-- server/models/UserFavorite.js | 66 +++++++++++++++++++ server/routers/ApiRouter.js | 2 + .../utils/queries/libraryItemsBookFilters.js | 15 ++++- .../queries/libraryItemsPodcastFilters.js | 15 ++++- 14 files changed, 308 insertions(+), 11 deletions(-) create mode 100644 server/models/UserFavorite.js diff --git a/client/components/cards/LazyBookCard.vue b/client/components/cards/LazyBookCard.vue index 51f657db..cb3ee600 100644 --- a/client/components/cards/LazyBookCard.vue +++ b/client/components/cards/LazyBookCard.vue @@ -65,6 +65,14 @@
#{{ seriesSequence }}
@@ -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) }, diff --git a/client/components/controls/LibraryFilterSelect.vue b/client/components/controls/LibraryFilterSelect.vue index 4834a1a2..837b6c89 100644 --- a/client/components/controls/LibraryFilterSelect.vue +++ b/client/components/controls/LibraryFilterSelect.vue @@ -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, diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index 1d8f0f20..437b7390 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -29,6 +29,9 @@ {{ title }}{{ $strings.HeaderLatestEpisodes }}
-{{ $strings.MessageNoEpisodes }}
- +{{ $strings.HeaderLatestEpisodes }}
+{{ $strings.MessageNoEpisodes }}
+{{ $dateDistanceFromNow(episode.publishedAt) }}
{{ $dateDistanceFromNow(episode.publishedAt) }}
@@ -70,7 +78,7 @@ - + @@ -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') diff --git a/client/store/user.js b/client/store/user.js index 96e79d12..bb21524e 100644 --- a/client/store/user.js +++ b/client/store/user.js @@ -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) diff --git a/client/strings/de.json b/client/strings/de.json index 27634865..b11aa77a 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -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 (wenn konfiguriert). Wenn der Claim in der Antwort fehlt, wird der Zugang zu ABS verweigert. Fehlt eine einzelne Option, wird sie alsfalse 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 groups bezeichnet. Wenn konfiguriert, 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.",
diff --git a/client/strings/en-us.json b/client/strings/en-us.json
index fb2bcb28..97d41dd6 100644
--- a/client/strings/en-us.json
+++ b/client/strings/en-us.json
@@ -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 (if configured). 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 false. 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 groups. If configured, 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.",
diff --git a/server/Database.js b/server/Database.js
index 213c2c61..6f3eb41f 100644
--- a/server/Database.js
+++ b/server/Database.js
@@ -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)
diff --git a/server/controllers/MeController.js b/server/controllers/MeController.js
index c5968f52..5588d877 100644
--- a/server/controllers/MeController.js
+++ b/server/controllers/MeController.js
@@ -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
*
diff --git a/server/models/User.js b/server/models/User.js
index 936efde1..7296a543 100644
--- a/server/models/User.js
+++ b/server/models/User.js
@@ -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,
diff --git a/server/models/UserFavorite.js b/server/models/UserFavorite.js
new file mode 100644
index 00000000..3c1ce7f9
--- /dev/null
+++ b/server/models/UserFavorite.js
@@ -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
diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js
index db04bf5e..44bfddc7 100644
--- a/server/routers/ApiRouter.js
+++ b/server/routers/ApiRouter.js
@@ -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))
diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js
index fbe0c4f0..1d464625 100644
--- a/server/utils/queries/libraryItemsBookFilters.js
+++ b/server/utils/queries/libraryItemsBookFilters.js
@@ -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
diff --git a/server/utils/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js
index 8bb5dc11..32c45e79 100644
--- a/server/utils/queries/libraryItemsPodcastFilters.js
+++ b/server/utils/queries/libraryItemsPodcastFilters.js
@@ -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 = []
From 81201efd441aa49ce4956989784fcf3071d1c189 Mon Sep 17 00:00:00 2001
From: Rapha149 <49787110+Rapha149@users.noreply.github.com>
Date: Tue, 17 Mar 2026 11:46:46 +0100
Subject: [PATCH 2/3] Add shadow behind star icon on cover.
---
client/components/cards/LazyBookCard.vue | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/client/components/cards/LazyBookCard.vue b/client/components/cards/LazyBookCard.vue
index cb3ee600..143fb4cb 100644
--- a/client/components/cards/LazyBookCard.vue
+++ b/client/components/cards/LazyBookCard.vue
@@ -106,7 +106,7 @@
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">
-
+
From ac841856df420625303e117c6f302dab1b0a36d1 Mon Sep 17 00:00:00 2001
From: Rapha149 <49787110+Rapha149@users.noreply.github.com>
Date: Fri, 20 Mar 2026 14:40:29 +0100
Subject: [PATCH 3/3] Hide favorites in get all users api call.
---
server/controllers/UserController.js | 2 +-
server/models/User.js | 1 +
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/server/controllers/UserController.js b/server/controllers/UserController.js
index 3ec10539..b0212598 100644
--- a/server/controllers/UserController.js
+++ b/server/controllers/UserController.js
@@ -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))
diff --git a/server/models/User.js b/server/models/User.js
index 7296a543..b3b2fd80 100644
--- a/server/models/User.js
+++ b/server/models/User.js
@@ -677,6 +677,7 @@ class User extends Model {
if (minimal) {
delete json.mediaProgress
delete json.bookmarks
+ delete json.favorites
}
return json
}