mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-05-22 11:21:31 +00:00
feat: Add native podcast episode bookmarking
This commit is contained in:
parent
8b89b27654
commit
ca6c7d7958
7 changed files with 54 additions and 29 deletions
|
|
@ -55,7 +55,7 @@
|
||||||
@showPlayerQueueItems="showPlayerQueueItemsModal = true"
|
@showPlayerQueueItems="showPlayerQueueItemsModal = true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :playback-rate="currentPlaybackRate" :library-item-id="libraryItemId" @select="selectBookmark" />
|
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :playback-rate="currentPlaybackRate" :library-item-id="libraryItemId" :episode-id="$store.state.streamEpisodeId" @select="selectBookmark" />
|
||||||
|
|
||||||
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-type="sleepTimerType" :remaining="sleepTimerRemaining" :has-chapters="!!chapters.length" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
|
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-type="sleepTimerType" :remaining="sleepTimerRemaining" :has-chapters="!!chapters.length" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
|
||||||
|
|
||||||
|
|
@ -116,7 +116,7 @@ export default {
|
||||||
},
|
},
|
||||||
bookmarks() {
|
bookmarks() {
|
||||||
if (!this.libraryItemId) return []
|
if (!this.libraryItemId) return []
|
||||||
return this.$store.getters['user/getUserBookmarksForItem'](this.libraryItemId)
|
return this.$store.getters['user/getUserBookmarksForItem'](this.libraryItemId, this.$store.state.streamEpisodeId)
|
||||||
},
|
},
|
||||||
streamLibraryItem() {
|
streamLibraryItem() {
|
||||||
return this.$store.state.streamLibraryItem
|
return this.$store.state.streamLibraryItem
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ export default {
|
||||||
default: 0
|
default: 0
|
||||||
},
|
},
|
||||||
libraryItemId: String,
|
libraryItemId: String,
|
||||||
|
episodeId: String,
|
||||||
playbackRate: Number,
|
playbackRate: Number,
|
||||||
hideCreate: Boolean
|
hideCreate: Boolean
|
||||||
},
|
},
|
||||||
|
|
@ -92,8 +93,9 @@ export default {
|
||||||
this.showBookmarkTitleInput = true
|
this.showBookmarkTitleInput = true
|
||||||
},
|
},
|
||||||
deleteBookmark(bm) {
|
deleteBookmark(bm) {
|
||||||
|
const deleteUrl = this.episodeId ? `/api/me/item/${this.libraryItemId}/bookmark/${bm.time}?episode=${this.episodeId}` : `/api/me/item/${this.libraryItemId}/bookmark/${bm.time}`
|
||||||
this.$axios
|
this.$axios
|
||||||
.$delete(`/api/me/item/${this.libraryItemId}/bookmark/${bm.time}`)
|
.$delete(deleteUrl)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success(this.$strings.ToastBookmarkRemoveSuccess)
|
this.$toast.success(this.$strings.ToastBookmarkRemoveSuccess)
|
||||||
})
|
})
|
||||||
|
|
@ -114,6 +116,7 @@ export default {
|
||||||
title: this.newBookmarkTitle,
|
title: this.newBookmarkTitle,
|
||||||
time: Math.floor(this.currentTime)
|
time: Math.floor(this.currentTime)
|
||||||
}
|
}
|
||||||
|
if (this.episodeId) bookmark.episodeId = this.episodeId
|
||||||
this.$axios
|
this.$axios
|
||||||
.$post(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
|
.$post(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
</button>
|
</button>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
<ui-tooltip v-if="!isPodcast && !hideBookmarks" direction="top" :text="$strings.LabelViewBookmarks">
|
<ui-tooltip v-if="!hideBookmarks" direction="top" :text="$strings.LabelViewBookmarks">
|
||||||
<button :aria-label="$strings.LabelViewBookmarks" class="text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showBookmarks')">
|
<button :aria-label="$strings.LabelViewBookmarks" class="text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showBookmarks')">
|
||||||
<span class="material-symbols text-2xl">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
|
<span class="material-symbols text-2xl">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -37,10 +37,15 @@ export const getters = {
|
||||||
return li.libraryItemId == libraryItemId
|
return li.libraryItemId == libraryItemId
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
getUserBookmarksForItem: (state) => (libraryItemId) => {
|
getUserBookmarksForItem:
|
||||||
if (!state.user?.bookmarks) return []
|
(state) =>
|
||||||
return state.user.bookmarks.filter((bm) => bm.libraryItemId === libraryItemId)
|
(libraryItemId, episodeId = null) => {
|
||||||
},
|
if (!state.user?.bookmarks) return []
|
||||||
|
return state.user.bookmarks.filter((bm) => {
|
||||||
|
if (episodeId && bm.episodeId !== episodeId) return false
|
||||||
|
return bm.libraryItemId === libraryItemId
|
||||||
|
})
|
||||||
|
},
|
||||||
getUserSetting: (state) => (key) => {
|
getUserSetting: (state) => (key) => {
|
||||||
return state.settings?.[key] || null
|
return state.settings?.[key] || null
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,7 @@ class MeController {
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { time, title } = req.body
|
const { time, title, episodeId } = req.body
|
||||||
if (isNullOrNaN(time)) {
|
if (isNullOrNaN(time)) {
|
||||||
Logger.error(`[MeController] createBookmark invalid time`, time)
|
Logger.error(`[MeController] createBookmark invalid time`, time)
|
||||||
return res.status(400).send('Invalid time')
|
return res.status(400).send('Invalid time')
|
||||||
|
|
@ -226,7 +226,7 @@ class MeController {
|
||||||
return res.status(400).send('Invalid title')
|
return res.status(400).send('Invalid title')
|
||||||
}
|
}
|
||||||
|
|
||||||
const bookmark = await req.user.createBookmark(req.params.id, time, title)
|
const bookmark = await req.user.createBookmark(req.params.id, time, title, episodeId)
|
||||||
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toOldJSONForBrowser())
|
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toOldJSONForBrowser())
|
||||||
res.json(bookmark)
|
res.json(bookmark)
|
||||||
}
|
}
|
||||||
|
|
@ -249,7 +249,7 @@ class MeController {
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { time, title } = req.body
|
const { time, title, episodeId } = req.body
|
||||||
if (isNullOrNaN(time)) {
|
if (isNullOrNaN(time)) {
|
||||||
Logger.error(`[MeController] updateBookmark invalid time`, time)
|
Logger.error(`[MeController] updateBookmark invalid time`, time)
|
||||||
return res.status(400).send('Invalid time')
|
return res.status(400).send('Invalid time')
|
||||||
|
|
@ -259,7 +259,7 @@ class MeController {
|
||||||
return res.status(400).send('Invalid title')
|
return res.status(400).send('Invalid title')
|
||||||
}
|
}
|
||||||
|
|
||||||
const bookmark = await req.user.updateBookmark(req.params.id, time, title)
|
const bookmark = await req.user.updateBookmark(req.params.id, time, title, episodeId)
|
||||||
if (!bookmark) {
|
if (!bookmark) {
|
||||||
Logger.error(`[MeController] updateBookmark not found for library item id "${req.params.id}" and time "${time}"`)
|
Logger.error(`[MeController] updateBookmark not found for library item id "${req.params.id}" and time "${time}"`)
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
|
|
@ -291,13 +291,14 @@ class MeController {
|
||||||
if (isNaN(time)) {
|
if (isNaN(time)) {
|
||||||
return res.status(400).send('Invalid time')
|
return res.status(400).send('Invalid time')
|
||||||
}
|
}
|
||||||
|
const episodeId = req.query.episode
|
||||||
|
|
||||||
if (!req.user.findBookmark(req.params.id, time)) {
|
if (!req.user.findBookmark(req.params.id, time, episodeId)) {
|
||||||
Logger.error(`[MeController] removeBookmark not found`)
|
Logger.error(`[MeController] removeBookmark not found`)
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
|
|
||||||
await req.user.removeBookmark(req.params.id, time)
|
await req.user.removeBookmark(req.params.id, time, episodeId)
|
||||||
|
|
||||||
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toOldJSONForBrowser())
|
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toOldJSONForBrowser())
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
|
|
|
||||||
|
|
@ -833,13 +833,16 @@ class User extends Model {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find bookmark
|
* Find bookmark
|
||||||
* TODO: Bookmarks should use mediaItemId instead of libraryItemId to support podcast episodes
|
|
||||||
*
|
*
|
||||||
* @param {string} libraryItemId
|
* @param {string} libraryItemId
|
||||||
* @param {number} time
|
* @param {number} time
|
||||||
|
* @param {string} [episodeId]
|
||||||
* @returns {AudioBookmarkObject|null}
|
* @returns {AudioBookmarkObject|null}
|
||||||
*/
|
*/
|
||||||
findBookmark(libraryItemId, time) {
|
findBookmark(libraryItemId, time, episodeId = null) {
|
||||||
|
if (episodeId) {
|
||||||
|
return this.bookmarks.find((bm) => bm.libraryItemId === libraryItemId && bm.episodeId === episodeId && bm.time == time)
|
||||||
|
}
|
||||||
return this.bookmarks.find((bm) => bm.libraryItemId === libraryItemId && bm.time == time)
|
return this.bookmarks.find((bm) => bm.libraryItemId === libraryItemId && bm.time == time)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -849,10 +852,11 @@ class User extends Model {
|
||||||
* @param {string} libraryItemId
|
* @param {string} libraryItemId
|
||||||
* @param {number} time
|
* @param {number} time
|
||||||
* @param {string} title
|
* @param {string} title
|
||||||
|
* @param {string} [episodeId]
|
||||||
* @returns {Promise<AudioBookmarkObject>}
|
* @returns {Promise<AudioBookmarkObject>}
|
||||||
*/
|
*/
|
||||||
async createBookmark(libraryItemId, time, title) {
|
async createBookmark(libraryItemId, time, title, episodeId = null) {
|
||||||
const existingBookmark = this.findBookmark(libraryItemId, time)
|
const existingBookmark = this.findBookmark(libraryItemId, time, episodeId)
|
||||||
if (existingBookmark) {
|
if (existingBookmark) {
|
||||||
Logger.warn('[User] Create Bookmark already exists for this time')
|
Logger.warn('[User] Create Bookmark already exists for this time')
|
||||||
if (existingBookmark.title !== title) {
|
if (existingBookmark.title !== title) {
|
||||||
|
|
@ -869,6 +873,7 @@ class User extends Model {
|
||||||
title,
|
title,
|
||||||
createdAt: Date.now()
|
createdAt: Date.now()
|
||||||
}
|
}
|
||||||
|
if (episodeId) newBookmark.episodeId = episodeId
|
||||||
this.bookmarks.push(newBookmark)
|
this.bookmarks.push(newBookmark)
|
||||||
this.changed('bookmarks', true)
|
this.changed('bookmarks', true)
|
||||||
await this.save()
|
await this.save()
|
||||||
|
|
@ -881,10 +886,11 @@ class User extends Model {
|
||||||
* @param {string} libraryItemId
|
* @param {string} libraryItemId
|
||||||
* @param {number} time
|
* @param {number} time
|
||||||
* @param {string} title
|
* @param {string} title
|
||||||
|
* @param {string} [episodeId]
|
||||||
* @returns {Promise<AudioBookmarkObject>}
|
* @returns {Promise<AudioBookmarkObject>}
|
||||||
*/
|
*/
|
||||||
async updateBookmark(libraryItemId, time, title) {
|
async updateBookmark(libraryItemId, time, title, episodeId = null) {
|
||||||
const bookmark = this.findBookmark(libraryItemId, time)
|
const bookmark = this.findBookmark(libraryItemId, time, episodeId)
|
||||||
if (!bookmark) {
|
if (!bookmark) {
|
||||||
Logger.error(`[User] updateBookmark not found`)
|
Logger.error(`[User] updateBookmark not found`)
|
||||||
return null
|
return null
|
||||||
|
|
@ -900,14 +906,19 @@ class User extends Model {
|
||||||
*
|
*
|
||||||
* @param {string} libraryItemId
|
* @param {string} libraryItemId
|
||||||
* @param {number} time
|
* @param {number} time
|
||||||
|
* @param {string} [episodeId]
|
||||||
* @returns {Promise<boolean>} - true if bookmark was removed
|
* @returns {Promise<boolean>} - true if bookmark was removed
|
||||||
*/
|
*/
|
||||||
async removeBookmark(libraryItemId, time) {
|
async removeBookmark(libraryItemId, time, episodeId = null) {
|
||||||
if (!this.findBookmark(libraryItemId, time)) {
|
if (!this.findBookmark(libraryItemId, time, episodeId)) {
|
||||||
Logger.error(`[User] removeBookmark not found`)
|
Logger.error(`[User] removeBookmark not found`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
this.bookmarks = this.bookmarks.filter((bm) => bm.libraryItemId !== libraryItemId || bm.time !== time)
|
if (episodeId) {
|
||||||
|
this.bookmarks = this.bookmarks.filter((bm) => bm.libraryItemId !== libraryItemId || bm.episodeId !== episodeId || bm.time !== time)
|
||||||
|
} else {
|
||||||
|
this.bookmarks = this.bookmarks.filter((bm) => bm.libraryItemId !== libraryItemId || bm.time !== time)
|
||||||
|
}
|
||||||
this.changed('bookmarks', true)
|
this.changed('bookmarks', true)
|
||||||
await this.save()
|
await this.save()
|
||||||
return true
|
return true
|
||||||
|
|
|
||||||
|
|
@ -222,7 +222,7 @@ describe('MeController - IDOR Security Tests', () => {
|
||||||
it('should allow user to create bookmark for accessible library item', async () => {
|
it('should allow user to create bookmark for accessible library item', async () => {
|
||||||
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id)
|
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id)
|
||||||
|
|
||||||
const bookmark = { libraryItemId: libraryItem1.id, time: 100, title: 'Test Bookmark', createdAt: Date.now() }
|
const bookmark = { libraryItemId: libraryItem1.id, time: 100, title: 'Test Bookmark', episodeId: 'test-ep-1', createdAt: Date.now() }
|
||||||
|
|
||||||
const fakeReq = {
|
const fakeReq = {
|
||||||
user: {
|
user: {
|
||||||
|
|
@ -234,7 +234,7 @@ describe('MeController - IDOR Security Tests', () => {
|
||||||
toOldJSONForBrowser: () => ({ id: user2.id, username: user2.username })
|
toOldJSONForBrowser: () => ({ id: user2.id, username: user2.username })
|
||||||
},
|
},
|
||||||
params: { id: libraryItem1.id },
|
params: { id: libraryItem1.id },
|
||||||
body: { time: 100, title: 'Test Bookmark' }
|
body: { time: 100, title: 'Test Bookmark', episodeId: 'test-ep-1' }
|
||||||
}
|
}
|
||||||
const fakeRes = {
|
const fakeRes = {
|
||||||
sendStatus: sinon.spy(),
|
sendStatus: sinon.spy(),
|
||||||
|
|
@ -247,6 +247,7 @@ describe('MeController - IDOR Security Tests', () => {
|
||||||
|
|
||||||
await MeController.createBookmark(fakeReq, fakeRes)
|
await MeController.createBookmark(fakeReq, fakeRes)
|
||||||
|
|
||||||
|
expect(fakeReq.user.createBookmark.calledWith(libraryItem1.id, 100, 'Test Bookmark', 'test-ep-1')).to.be.true
|
||||||
expect(fakeRes.json.calledOnce).to.be.true
|
expect(fakeRes.json.calledOnce).to.be.true
|
||||||
expect(fakeRes.json.calledWith(bookmark)).to.be.true
|
expect(fakeRes.json.calledWith(bookmark)).to.be.true
|
||||||
|
|
||||||
|
|
@ -343,7 +344,7 @@ describe('MeController - IDOR Security Tests', () => {
|
||||||
it('should allow user to update bookmark for accessible library item', async () => {
|
it('should allow user to update bookmark for accessible library item', async () => {
|
||||||
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id)
|
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id)
|
||||||
|
|
||||||
const bookmark = { libraryItemId: libraryItem1.id, time: 100, title: 'Updated Title' }
|
const bookmark = { libraryItemId: libraryItem1.id, time: 100, title: 'Updated Title', episodeId: 'test-ep-1' }
|
||||||
|
|
||||||
const fakeReq = {
|
const fakeReq = {
|
||||||
user: {
|
user: {
|
||||||
|
|
@ -355,7 +356,7 @@ describe('MeController - IDOR Security Tests', () => {
|
||||||
toOldJSONForBrowser: () => ({ id: user1.id, username: user1.username })
|
toOldJSONForBrowser: () => ({ id: user1.id, username: user1.username })
|
||||||
},
|
},
|
||||||
params: { id: libraryItem1.id },
|
params: { id: libraryItem1.id },
|
||||||
body: { time: 100, title: 'Updated Title' }
|
body: { time: 100, title: 'Updated Title', episodeId: 'test-ep-1' }
|
||||||
}
|
}
|
||||||
const fakeRes = {
|
const fakeRes = {
|
||||||
sendStatus: sinon.spy(),
|
sendStatus: sinon.spy(),
|
||||||
|
|
@ -368,6 +369,7 @@ describe('MeController - IDOR Security Tests', () => {
|
||||||
|
|
||||||
await MeController.updateBookmark(fakeReq, fakeRes)
|
await MeController.updateBookmark(fakeReq, fakeRes)
|
||||||
|
|
||||||
|
expect(fakeReq.user.updateBookmark.calledWith(libraryItem1.id, 100, 'Updated Title', 'test-ep-1')).to.be.true
|
||||||
expect(fakeRes.json.calledOnce).to.be.true
|
expect(fakeRes.json.calledOnce).to.be.true
|
||||||
expect(fakeRes.json.calledWith(bookmark)).to.be.true
|
expect(fakeRes.json.calledWith(bookmark)).to.be.true
|
||||||
|
|
||||||
|
|
@ -415,11 +417,12 @@ describe('MeController - IDOR Security Tests', () => {
|
||||||
id: user1.id,
|
id: user1.id,
|
||||||
username: user1.username,
|
username: user1.username,
|
||||||
checkCanAccessLibraryItem: () => true,
|
checkCanAccessLibraryItem: () => true,
|
||||||
findBookmark: sinon.stub().returns({ libraryItemId: libraryItem1.id, time: 100, title: 'Test Bookmark' }),
|
findBookmark: sinon.stub().returns({ libraryItemId: libraryItem1.id, time: 100, title: 'Test Bookmark', episodeId: 'test-ep-1' }),
|
||||||
removeBookmark: sinon.stub().resolves(true),
|
removeBookmark: sinon.stub().resolves(true),
|
||||||
toOldJSONForBrowser: () => ({ id: user1.id, username: user1.username })
|
toOldJSONForBrowser: () => ({ id: user1.id, username: user1.username })
|
||||||
},
|
},
|
||||||
params: { id: libraryItem1.id, time: '100' }
|
params: { id: libraryItem1.id, time: '100' },
|
||||||
|
query: { episode: 'test-ep-1' }
|
||||||
}
|
}
|
||||||
const fakeRes = {
|
const fakeRes = {
|
||||||
sendStatus: sinon.spy(),
|
sendStatus: sinon.spy(),
|
||||||
|
|
@ -431,6 +434,8 @@ describe('MeController - IDOR Security Tests', () => {
|
||||||
|
|
||||||
await MeController.removeBookmark(fakeReq, fakeRes)
|
await MeController.removeBookmark(fakeReq, fakeRes)
|
||||||
|
|
||||||
|
expect(fakeReq.user.findBookmark.calledWith(libraryItem1.id, 100, 'test-ep-1')).to.be.true
|
||||||
|
expect(fakeReq.user.removeBookmark.calledWith(libraryItem1.id, 100, 'test-ep-1')).to.be.true
|
||||||
expect(fakeRes.sendStatus.calledWith(200)).to.be.true
|
expect(fakeRes.sendStatus.calledWith(200)).to.be.true
|
||||||
|
|
||||||
Database.libraryItemModel.getExpandedById.restore()
|
Database.libraryItemModel.getExpandedById.restore()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue