mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-26 03:01:39 +00:00
add progress and bookmarks specific endpoints
This commit is contained in:
parent
82aec5f60c
commit
4883d0f60c
3 changed files with 207 additions and 0 deletions
|
|
@ -26,6 +26,52 @@ class MeController {
|
||||||
res.json(req.user.toOldJSONForBrowser())
|
res.json(req.user.toOldJSONForBrowser())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET: /api/me/progress
|
||||||
|
*
|
||||||
|
* @param {RequestWithUser} req
|
||||||
|
* @param {Response} res
|
||||||
|
*/
|
||||||
|
getAllMediaProgress(req, res) {
|
||||||
|
const mediaProgress = req.user.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || []
|
||||||
|
res.json(mediaProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET: /api/me/bookmarks
|
||||||
|
*
|
||||||
|
* @param {RequestWithUser} req
|
||||||
|
* @param {Response} res
|
||||||
|
*/
|
||||||
|
getAllBookmarks(req, res) {
|
||||||
|
const bookmarks = req.user.bookmarks?.map((bookmark) => ({ ...bookmark })) || []
|
||||||
|
res.json(bookmarks)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET: /api/me/bookmarks/:libraryItemId
|
||||||
|
*
|
||||||
|
* Podcast episodes belong to their podcast's library item, so bookmarks for
|
||||||
|
* a podcast are retrieved with the podcast library item id.
|
||||||
|
*
|
||||||
|
* @param {RequestWithUser} req
|
||||||
|
* @param {Response} res
|
||||||
|
*/
|
||||||
|
async getBookmarksForLibraryItem(req, res) {
|
||||||
|
const libraryItem = await Database.libraryItemModel.getExpandedById(req.params.libraryItemId)
|
||||||
|
if (!libraryItem) {
|
||||||
|
return res.sendStatus(404)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
|
||||||
|
Logger.error(`[MeController] User "${req.user.username}" attempted to access bookmarks for library item "${req.params.libraryItemId}" without access`)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookmarks = req.user.bookmarks?.filter((bookmark) => bookmark.libraryItemId === libraryItem.id).map((bookmark) => ({ ...bookmark })) || []
|
||||||
|
res.json(bookmarks)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET: /api/me/listening-sessions
|
* GET: /api/me/listening-sessions
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,9 @@ class ApiRouter {
|
||||||
// Current User Routes (Me)
|
// Current User Routes (Me)
|
||||||
//
|
//
|
||||||
this.router.get('/me', MeController.getCurrentUser.bind(this))
|
this.router.get('/me', MeController.getCurrentUser.bind(this))
|
||||||
|
this.router.get('/me/progress', MeController.getAllMediaProgress.bind(this))
|
||||||
|
this.router.get('/me/bookmarks', MeController.getAllBookmarks.bind(this))
|
||||||
|
this.router.get('/me/bookmarks/:libraryItemId', MeController.getBookmarksForLibraryItem.bind(this))
|
||||||
this.router.get('/me/listening-sessions', MeController.getListeningSessions.bind(this))
|
this.router.get('/me/listening-sessions', MeController.getListeningSessions.bind(this))
|
||||||
this.router.get('/me/item/listening-sessions/:libraryItemId/:episodeId?', MeController.getItemListeningSessions.bind(this))
|
this.router.get('/me/item/listening-sessions/:libraryItemId/:episodeId?', MeController.getItemListeningSessions.bind(this))
|
||||||
this.router.get('/me/listening-stats', MeController.getListeningStats.bind(this))
|
this.router.get('/me/listening-stats', MeController.getListeningStats.bind(this))
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,164 @@ describe('MeController - IDOR Security Tests', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('current user progress and bookmark reads', () => {
|
||||||
|
let user1, user2
|
||||||
|
let bookLibraryItem, podcastLibraryItem, podcastEpisode
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
user1 = await Database.userModel.create({
|
||||||
|
username: 'user1',
|
||||||
|
pash: 'hashed_password_1',
|
||||||
|
type: 'user',
|
||||||
|
isActive: true,
|
||||||
|
permissions: {
|
||||||
|
accessAllLibraries: true,
|
||||||
|
accessAllTags: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
user2 = await Database.userModel.create({
|
||||||
|
username: 'user2',
|
||||||
|
pash: 'hashed_password_2',
|
||||||
|
type: 'user',
|
||||||
|
isActive: true,
|
||||||
|
permissions: {
|
||||||
|
accessAllLibraries: false,
|
||||||
|
accessAllTags: true,
|
||||||
|
librariesAccessible: []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const bookLibrary = await Database.libraryModel.create({ name: 'Book Library', mediaType: 'book' })
|
||||||
|
const bookLibraryFolder = await Database.libraryFolderModel.create({ path: '/books', libraryId: bookLibrary.id })
|
||||||
|
const book = await Database.bookModel.create({ title: 'Test Book', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
||||||
|
bookLibraryItem = await Database.libraryItemModel.create({
|
||||||
|
libraryFiles: [],
|
||||||
|
mediaId: book.id,
|
||||||
|
mediaType: 'book',
|
||||||
|
libraryId: bookLibrary.id,
|
||||||
|
libraryFolderId: bookLibraryFolder.id
|
||||||
|
})
|
||||||
|
|
||||||
|
const podcastLibrary = await Database.libraryModel.create({ name: 'Podcast Library', mediaType: 'podcast' })
|
||||||
|
const podcastLibraryFolder = await Database.libraryFolderModel.create({ path: '/podcasts', libraryId: podcastLibrary.id })
|
||||||
|
const podcast = await Database.podcastModel.create({ title: 'Test Podcast', tags: [], genres: [] })
|
||||||
|
podcastLibraryItem = await Database.libraryItemModel.create({
|
||||||
|
libraryFiles: [],
|
||||||
|
mediaId: podcast.id,
|
||||||
|
mediaType: 'podcast',
|
||||||
|
libraryId: podcastLibrary.id,
|
||||||
|
libraryFolderId: podcastLibraryFolder.id
|
||||||
|
})
|
||||||
|
podcastEpisode = await Database.podcastEpisodeModel.create({
|
||||||
|
podcastId: podcast.id,
|
||||||
|
title: 'Episode 1',
|
||||||
|
index: 1,
|
||||||
|
audioFile: { ino: '1', metadata: { filename: 'episode-1.mp3', ext: '.mp3', path: '/podcasts/episode-1.mp3', relPath: 'episode-1.mp3' } }
|
||||||
|
})
|
||||||
|
|
||||||
|
await Database.mediaProgressModel.create({
|
||||||
|
userId: user1.id,
|
||||||
|
mediaItemId: book.id,
|
||||||
|
mediaItemType: 'book',
|
||||||
|
duration: 1000,
|
||||||
|
currentTime: 500,
|
||||||
|
isFinished: false,
|
||||||
|
extraData: { libraryItemId: bookLibraryItem.id, progress: 0.5 }
|
||||||
|
})
|
||||||
|
await Database.mediaProgressModel.create({
|
||||||
|
userId: user1.id,
|
||||||
|
mediaItemId: podcastEpisode.id,
|
||||||
|
podcastId: podcast.id,
|
||||||
|
mediaItemType: 'podcastEpisode',
|
||||||
|
duration: 1000,
|
||||||
|
currentTime: 250,
|
||||||
|
isFinished: false,
|
||||||
|
extraData: { libraryItemId: podcastLibraryItem.id, progress: 0.25 }
|
||||||
|
})
|
||||||
|
await Database.mediaProgressModel.create({
|
||||||
|
userId: user2.id,
|
||||||
|
mediaItemId: book.id,
|
||||||
|
mediaItemType: 'book',
|
||||||
|
duration: 1000,
|
||||||
|
currentTime: 750,
|
||||||
|
isFinished: false,
|
||||||
|
extraData: { libraryItemId: bookLibraryItem.id, progress: 0.75 }
|
||||||
|
})
|
||||||
|
|
||||||
|
user1.mediaProgresses = await user1.getMediaProgresses()
|
||||||
|
user2.mediaProgresses = await user2.getMediaProgresses()
|
||||||
|
user1.bookmarks = [
|
||||||
|
{ libraryItemId: bookLibraryItem.id, time: 100, title: 'Book Bookmark' },
|
||||||
|
{ libraryItemId: podcastLibraryItem.id, episodeId: podcastEpisode.id, time: 200, title: 'Episode Bookmark' }
|
||||||
|
]
|
||||||
|
user2.bookmarks = [{ libraryItemId: bookLibraryItem.id, time: 300, title: 'Other User Bookmark' }]
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should register the current-user progress and bookmark read routes', () => {
|
||||||
|
const routes = apiRouter.router._router.stack.filter((layer) => layer.route).map((layer) => `${Object.keys(layer.route.methods)[0].toUpperCase()} ${layer.route.path}`)
|
||||||
|
|
||||||
|
expect(routes).to.include('GET /me/progress')
|
||||||
|
expect(routes).to.include('GET /me/bookmarks')
|
||||||
|
expect(routes).to.include('GET /me/bookmarks/:libraryItemId')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return only the authenticated user's media progress, including podcast episode progress", () => {
|
||||||
|
const fakeRes = { json: sinon.spy() }
|
||||||
|
|
||||||
|
MeController.getAllMediaProgress({ user: user1 }, fakeRes)
|
||||||
|
|
||||||
|
const mediaProgress = fakeRes.json.firstCall.args[0]
|
||||||
|
expect(mediaProgress).to.have.length(2)
|
||||||
|
expect(mediaProgress.every((progress) => progress.userId === user1.id)).to.be.true
|
||||||
|
const podcastProgress = mediaProgress.find((progress) => progress.mediaItemId === podcastEpisode.id)
|
||||||
|
expect(podcastProgress).to.include({
|
||||||
|
id: user1.mediaProgresses.find((progress) => progress.mediaItemId === podcastEpisode.id).id,
|
||||||
|
userId: user1.id,
|
||||||
|
libraryItemId: podcastLibraryItem.id,
|
||||||
|
episodeId: podcastEpisode.id,
|
||||||
|
mediaItemId: podcastEpisode.id,
|
||||||
|
mediaItemType: 'podcastEpisode',
|
||||||
|
duration: 1000,
|
||||||
|
progress: 0.25,
|
||||||
|
currentTime: 250,
|
||||||
|
isFinished: false,
|
||||||
|
hideFromContinueListening: false,
|
||||||
|
ebookLocation: null,
|
||||||
|
ebookProgress: null,
|
||||||
|
finishedAt: null
|
||||||
|
})
|
||||||
|
expect(podcastProgress.lastUpdate).to.be.a('number')
|
||||||
|
expect(podcastProgress.startedAt).to.be.a('number')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return only the authenticated user's bookmarks", () => {
|
||||||
|
const fakeRes = { json: sinon.spy() }
|
||||||
|
|
||||||
|
MeController.getAllBookmarks({ user: user1 }, fakeRes)
|
||||||
|
|
||||||
|
expect(fakeRes.json.calledWith(user1.bookmarks)).to.be.true
|
||||||
|
expect(fakeRes.json.firstCall.args[0]).to.not.deep.include(user2.bookmarks[0])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return podcast episode bookmarks using the parent podcast library item id', async () => {
|
||||||
|
const fakeRes = { json: sinon.spy(), sendStatus: sinon.spy() }
|
||||||
|
|
||||||
|
await MeController.getBookmarksForLibraryItem({ user: user1, params: { libraryItemId: podcastLibraryItem.id } }, fakeRes)
|
||||||
|
|
||||||
|
expect(fakeRes.sendStatus.notCalled).to.be.true
|
||||||
|
expect(fakeRes.json.calledWith([user1.bookmarks[1]])).to.be.true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should prevent a user from reading bookmarks for an inaccessible library item', async () => {
|
||||||
|
const fakeRes = { json: sinon.spy(), sendStatus: sinon.spy() }
|
||||||
|
|
||||||
|
await MeController.getBookmarksForLibraryItem({ user: user2, params: { libraryItemId: podcastLibraryItem.id } }, fakeRes)
|
||||||
|
|
||||||
|
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
|
||||||
|
expect(fakeRes.json.notCalled).to.be.true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('Bookmark Operations - Authorization Checks', () => {
|
describe('Bookmark Operations - Authorization Checks', () => {
|
||||||
let user1, user2
|
let user1, user2
|
||||||
let library1, library2
|
let library1, library2
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue