mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-08-03 07:01:56 +00:00
Before the new minified listening stats path could return 502 because PlaybackSession.afterFind assumed Sequelize model instances even for raw stats rows, and some payloads could serialize invalid totals. After this hotfix the stats helper returns successfully again, with the current dataset benchmarking at about 124ms for full stats and 101ms for minified stats instead of crashing.
629 lines
35 KiB
JavaScript
629 lines
35 KiB
JavaScript
const express = require('express')
|
|
const Path = require('path')
|
|
const sequelize = require('sequelize')
|
|
|
|
const Logger = require('../Logger')
|
|
const Database = require('../Database')
|
|
const SocketAuthority = require('../SocketAuthority')
|
|
|
|
const fs = require('../libs/fsExtra')
|
|
const date = require('../libs/dateAndTime')
|
|
|
|
const CacheManager = require('../managers/CacheManager')
|
|
const RssFeedManager = require('../managers/RssFeedManager')
|
|
|
|
const LibraryController = require('../controllers/LibraryController')
|
|
const UserController = require('../controllers/UserController')
|
|
const CollectionController = require('../controllers/CollectionController')
|
|
const PlaylistController = require('../controllers/PlaylistController')
|
|
const MeController = require('../controllers/MeController')
|
|
const BackupController = require('../controllers/BackupController')
|
|
const LibraryItemController = require('../controllers/LibraryItemController')
|
|
const SeriesController = require('../controllers/SeriesController')
|
|
const FileSystemController = require('../controllers/FileSystemController')
|
|
const AuthorController = require('../controllers/AuthorController')
|
|
const SessionController = require('../controllers/SessionController')
|
|
const PodcastController = require('../controllers/PodcastController')
|
|
const NotificationController = require('../controllers/NotificationController')
|
|
const EmailController = require('../controllers/EmailController')
|
|
const SearchController = require('../controllers/SearchController')
|
|
const CacheController = require('../controllers/CacheController')
|
|
const ToolsController = require('../controllers/ToolsController')
|
|
const RSSFeedController = require('../controllers/RSSFeedController')
|
|
const CustomMetadataProviderController = require('../controllers/CustomMetadataProviderController')
|
|
const MiscController = require('../controllers/MiscController')
|
|
const ShareController = require('../controllers/ShareController')
|
|
const StatsController = require('../controllers/StatsController')
|
|
const ApiKeyController = require('../controllers/ApiKeyController')
|
|
|
|
class ApiRouter {
|
|
constructor(Server) {
|
|
/** @type {import('../Auth')} */
|
|
this.auth = Server.auth
|
|
/** @type {import('../managers/PlaybackSessionManager')} */
|
|
this.playbackSessionManager = Server.playbackSessionManager
|
|
/** @type {import('../managers/AbMergeManager')} */
|
|
this.abMergeManager = Server.abMergeManager
|
|
/** @type {import('../managers/BackupManager')} */
|
|
this.backupManager = Server.backupManager
|
|
/** @type {import('../managers/PodcastManager')} */
|
|
this.podcastManager = Server.podcastManager
|
|
/** @type {import('../managers/AudioMetadataManager')} */
|
|
this.audioMetadataManager = Server.audioMetadataManager
|
|
/** @type {import('../managers/CronManager')} */
|
|
this.cronManager = Server.cronManager
|
|
/** @type {import('../managers/EmailManager')} */
|
|
this.emailManager = Server.emailManager
|
|
this.apiCacheManager = Server.apiCacheManager
|
|
|
|
this.router = express()
|
|
this.router.disable('x-powered-by')
|
|
this.init()
|
|
}
|
|
|
|
init() {
|
|
//
|
|
// Library Routes
|
|
//
|
|
this.router.get(/^\/libraries/, this.apiCacheManager.middleware)
|
|
this.router.post('/libraries', LibraryController.create.bind(this))
|
|
this.router.get('/libraries', LibraryController.findAll.bind(this))
|
|
this.router.get('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.findOne.bind(this))
|
|
this.router.patch('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.update.bind(this))
|
|
this.router.delete('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.delete.bind(this))
|
|
|
|
this.router.get('/libraries/:id/items', LibraryController.middleware.bind(this), LibraryController.getLibraryItems.bind(this))
|
|
this.router.delete('/libraries/:id/issues', LibraryController.middleware.bind(this), LibraryController.removeLibraryItemsWithIssues.bind(this))
|
|
this.router.get('/libraries/:id/episode-downloads', LibraryController.middleware.bind(this), LibraryController.getEpisodeDownloadQueue.bind(this))
|
|
this.router.get('/libraries/:id/series', LibraryController.middleware.bind(this), LibraryController.getAllSeriesForLibrary.bind(this))
|
|
this.router.get('/libraries/:id/series/:seriesId', LibraryController.middleware.bind(this), LibraryController.getSeriesForLibrary.bind(this))
|
|
this.router.get('/libraries/:id/collections', LibraryController.middleware.bind(this), LibraryController.getCollectionsForLibrary.bind(this))
|
|
this.router.get('/libraries/:id/playlists', LibraryController.middleware.bind(this), LibraryController.getUserPlaylistsForLibrary.bind(this))
|
|
this.router.get('/libraries/:id/personalized', LibraryController.middleware.bind(this), LibraryController.getUserPersonalizedShelves.bind(this))
|
|
this.router.get('/libraries/:id/filterdata', LibraryController.middleware.bind(this), LibraryController.getLibraryFilterData.bind(this))
|
|
this.router.get('/libraries/:id/search', LibraryController.middleware.bind(this), LibraryController.search.bind(this))
|
|
this.router.get('/libraries/:id/stats', LibraryController.middleware.bind(this), LibraryController.stats.bind(this))
|
|
this.router.get('/libraries/:id/authors', LibraryController.middleware.bind(this), LibraryController.getAuthors.bind(this))
|
|
this.router.get('/libraries/:id/narrators', LibraryController.middleware.bind(this), LibraryController.getNarrators.bind(this))
|
|
this.router.patch('/libraries/:id/narrators/:narratorId', LibraryController.middleware.bind(this), LibraryController.updateNarrator.bind(this))
|
|
this.router.delete('/libraries/:id/narrators/:narratorId', LibraryController.middleware.bind(this), LibraryController.removeNarrator.bind(this))
|
|
this.router.get('/libraries/:id/matchall', LibraryController.middleware.bind(this), LibraryController.matchAll.bind(this))
|
|
this.router.post('/libraries/:id/scan', LibraryController.middleware.bind(this), LibraryController.scan.bind(this))
|
|
this.router.get('/libraries/:id/recent-episodes', LibraryController.middleware.bind(this), LibraryController.getRecentEpisodes.bind(this))
|
|
this.router.get('/libraries/:id/opml', LibraryController.middleware.bind(this), LibraryController.getOPMLFile.bind(this))
|
|
this.router.post('/libraries/order', LibraryController.reorder.bind(this))
|
|
this.router.post('/libraries/:id/remove-metadata', LibraryController.middleware.bind(this), LibraryController.removeAllMetadataFiles.bind(this))
|
|
this.router.get('/libraries/:id/podcast-titles', LibraryController.middleware.bind(this), LibraryController.getPodcastTitles.bind(this))
|
|
this.router.get('/libraries/:id/download', LibraryController.middleware.bind(this), LibraryController.downloadMultiple.bind(this))
|
|
|
|
//
|
|
// Item Routes
|
|
//
|
|
this.router.post('/items/batch/delete', LibraryItemController.batchDelete.bind(this))
|
|
this.router.post('/items/batch/update', LibraryItemController.batchUpdate.bind(this))
|
|
this.router.post('/items/batch/get', LibraryItemController.batchGet.bind(this))
|
|
this.router.post('/items/batch/quickmatch', LibraryItemController.batchQuickMatch.bind(this))
|
|
this.router.post('/items/batch/scan', LibraryItemController.batchScan.bind(this))
|
|
|
|
this.router.get('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.findOne.bind(this))
|
|
this.router.delete('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.delete.bind(this))
|
|
this.router.get('/items/:id/download', LibraryItemController.middleware.bind(this), LibraryItemController.download.bind(this))
|
|
this.router.patch('/items/:id/media', LibraryItemController.middleware.bind(this), LibraryItemController.updateMedia.bind(this))
|
|
this.router.get('/items/:id/cover', LibraryItemController.getCover.bind(this))
|
|
this.router.post('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.uploadCover.bind(this))
|
|
this.router.patch('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.updateCover.bind(this))
|
|
this.router.delete('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.removeCover.bind(this))
|
|
this.router.post('/items/:id/match', LibraryItemController.middleware.bind(this), LibraryItemController.match.bind(this))
|
|
this.router.post('/items/:id/play', LibraryItemController.middleware.bind(this), LibraryItemController.startPlaybackSession.bind(this))
|
|
this.router.post('/items/:id/play/:episodeId', LibraryItemController.middleware.bind(this), LibraryItemController.startEpisodePlaybackSession.bind(this))
|
|
this.router.patch('/items/:id/tracks', LibraryItemController.middleware.bind(this), LibraryItemController.updateTracks.bind(this))
|
|
this.router.post('/items/:id/scan', LibraryItemController.middleware.bind(this), LibraryItemController.scan.bind(this))
|
|
this.router.get('/items/:id/metadata-object', LibraryItemController.middleware.bind(this), LibraryItemController.getMetadataObject.bind(this))
|
|
this.router.post('/items/:id/chapters', LibraryItemController.middleware.bind(this), LibraryItemController.updateMediaChapters.bind(this))
|
|
this.router.get('/items/:id/ffprobe/:fileid', LibraryItemController.middleware.bind(this), LibraryItemController.getFFprobeData.bind(this))
|
|
this.router.get('/items/:id/file/:fileid', LibraryItemController.middleware.bind(this), LibraryItemController.getLibraryFile.bind(this))
|
|
this.router.delete('/items/:id/file/:fileid', LibraryItemController.middleware.bind(this), LibraryItemController.deleteLibraryFile.bind(this))
|
|
this.router.get('/items/:id/file/:fileid/download', LibraryItemController.middleware.bind(this), LibraryItemController.downloadLibraryFile.bind(this))
|
|
this.router.get('/items/:id/ebook/:fileid?', LibraryItemController.middleware.bind(this), LibraryItemController.getEBookFile.bind(this))
|
|
this.router.patch('/items/:id/ebook/:fileid/status', LibraryItemController.middleware.bind(this), LibraryItemController.updateEbookFileStatus.bind(this))
|
|
|
|
//
|
|
// User Routes
|
|
//
|
|
this.router.post('/users', UserController.middleware.bind(this), UserController.create.bind(this))
|
|
this.router.get('/users', UserController.middleware.bind(this), UserController.findAll.bind(this))
|
|
this.router.get('/users/online', UserController.getOnlineUsers.bind(this))
|
|
this.router.get('/users/:id', UserController.middleware.bind(this), UserController.findOne.bind(this))
|
|
this.router.patch('/users/:id', UserController.middleware.bind(this), UserController.update.bind(this))
|
|
this.router.delete('/users/:id', UserController.middleware.bind(this), UserController.delete.bind(this))
|
|
this.router.patch('/users/:id/openid-unlink', UserController.middleware.bind(this), UserController.unlinkFromOpenID.bind(this))
|
|
this.router.get('/users/:id/listening-sessions', UserController.middleware.bind(this), UserController.getListeningSessions.bind(this))
|
|
this.router.get('/users/:id/listening-stats', UserController.middleware.bind(this), UserController.getListeningStats.bind(this))
|
|
|
|
//
|
|
// Collection Routes
|
|
//
|
|
this.router.post('/collections', CollectionController.middleware.bind(this), CollectionController.create.bind(this))
|
|
this.router.get('/collections', CollectionController.findAll.bind(this))
|
|
this.router.get('/collections/:id', CollectionController.middleware.bind(this), CollectionController.findOne.bind(this))
|
|
this.router.patch('/collections/:id', CollectionController.middleware.bind(this), CollectionController.update.bind(this))
|
|
this.router.delete('/collections/:id', CollectionController.middleware.bind(this), CollectionController.delete.bind(this))
|
|
this.router.post('/collections/:id/book', CollectionController.middleware.bind(this), CollectionController.addBook.bind(this))
|
|
this.router.delete('/collections/:id/book/:bookId', CollectionController.middleware.bind(this), CollectionController.removeBook.bind(this))
|
|
this.router.post('/collections/:id/batch/add', CollectionController.middleware.bind(this), CollectionController.addBatch.bind(this))
|
|
this.router.post('/collections/:id/batch/remove', CollectionController.middleware.bind(this), CollectionController.removeBatch.bind(this))
|
|
|
|
//
|
|
// Playlist Routes
|
|
//
|
|
this.router.post('/playlists', PlaylistController.create.bind(this))
|
|
this.router.get('/playlists', PlaylistController.findAllForUser.bind(this))
|
|
this.router.get('/playlists/:id', PlaylistController.middleware.bind(this), PlaylistController.findOne.bind(this))
|
|
this.router.patch('/playlists/:id', PlaylistController.middleware.bind(this), PlaylistController.update.bind(this))
|
|
this.router.delete('/playlists/:id', PlaylistController.middleware.bind(this), PlaylistController.delete.bind(this))
|
|
this.router.post('/playlists/:id/item', PlaylistController.middleware.bind(this), PlaylistController.addItem.bind(this))
|
|
this.router.delete('/playlists/:id/item/:libraryItemId/:episodeId?', PlaylistController.middleware.bind(this), PlaylistController.removeItem.bind(this))
|
|
this.router.post('/playlists/:id/batch/add', PlaylistController.middleware.bind(this), PlaylistController.addBatch.bind(this))
|
|
this.router.post('/playlists/:id/batch/remove', PlaylistController.middleware.bind(this), PlaylistController.removeBatch.bind(this))
|
|
this.router.post('/playlists/collection/:collectionId', PlaylistController.createFromCollection.bind(this))
|
|
|
|
//
|
|
// Current User Routes (Me)
|
|
//
|
|
this.router.get('/me', MeController.getCurrentUser.bind(this))
|
|
this.router.get('/me/sessions', MeController.getSessions.bind(this))
|
|
this.router.delete('/me/sessions/:id', MeController.deleteSession.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/item/listening-sessions/:libraryItemId/:episodeId?', MeController.getItemListeningSessions.bind(this))
|
|
this.router.get('/me/listening-stats', MeController.getListeningStats.bind(this))
|
|
this.router.get('/me/progress/:id/remove-from-continue-listening', MeController.removeItemFromContinueListening.bind(this))
|
|
this.router.get('/me/progress/:id/:episodeId?', MeController.getMediaProgress.bind(this))
|
|
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/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))
|
|
this.router.patch('/me/password', this.auth.authRateLimiter, MeController.updatePassword.bind(this))
|
|
this.router.get('/me/items-in-progress', MeController.getAllLibraryItemsInProgress.bind(this))
|
|
this.router.get('/me/series/:id/remove-from-continue-listening', MeController.removeSeriesFromContinueListening.bind(this))
|
|
this.router.get('/me/series/:id/readd-to-continue-listening', MeController.readdSeriesFromContinueListening.bind(this))
|
|
this.router.get('/me/stats/year/:year', MeController.getStatsForYear.bind(this))
|
|
this.router.post('/me/ereader-devices', MeController.updateUserEReaderDevices.bind(this))
|
|
|
|
//
|
|
// Backup Routes
|
|
//
|
|
this.router.get('/backups', BackupController.middleware.bind(this), BackupController.getAll.bind(this))
|
|
this.router.post('/backups', BackupController.middleware.bind(this), BackupController.create.bind(this))
|
|
this.router.delete('/backups/:id', BackupController.middleware.bind(this), BackupController.delete.bind(this))
|
|
this.router.get('/backups/:id/download', BackupController.middleware.bind(this), BackupController.download.bind(this))
|
|
this.router.get('/backups/:id/apply', BackupController.middleware.bind(this), BackupController.apply.bind(this))
|
|
this.router.post('/backups/upload', BackupController.middleware.bind(this), BackupController.upload.bind(this))
|
|
this.router.patch('/backups/path', BackupController.middleware.bind(this), BackupController.updatePath.bind(this))
|
|
|
|
//
|
|
// File System Routes
|
|
//
|
|
this.router.get('/filesystem', FileSystemController.getPaths.bind(this))
|
|
this.router.post('/filesystem/pathexists', FileSystemController.checkPathExists.bind(this))
|
|
|
|
//
|
|
// Author Routes
|
|
//
|
|
this.router.get('/authors/:id', AuthorController.middleware.bind(this), AuthorController.findOne.bind(this))
|
|
this.router.patch('/authors/:id', AuthorController.middleware.bind(this), AuthorController.update.bind(this))
|
|
this.router.delete('/authors/:id', AuthorController.middleware.bind(this), AuthorController.delete.bind(this))
|
|
this.router.post('/authors/:id/match', AuthorController.middleware.bind(this), AuthorController.match.bind(this))
|
|
this.router.get('/authors/:id/image', AuthorController.getImage.bind(this))
|
|
this.router.post('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.uploadImage.bind(this))
|
|
this.router.delete('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.deleteImage.bind(this))
|
|
|
|
//
|
|
// Series Routes
|
|
//
|
|
this.router.get('/series/:id', SeriesController.middleware.bind(this), SeriesController.findOne.bind(this))
|
|
this.router.patch('/series/:id', SeriesController.middleware.bind(this), SeriesController.update.bind(this))
|
|
|
|
//
|
|
// Playback Session Routes
|
|
//
|
|
this.router.get('/sessions', SessionController.getAllWithUserData.bind(this))
|
|
this.router.delete('/sessions/:id', SessionController.middleware.bind(this), SessionController.delete.bind(this))
|
|
this.router.get('/sessions/open', SessionController.getOpenSessions.bind(this))
|
|
this.router.post('/sessions/batch/delete', SessionController.batchDelete.bind(this))
|
|
this.router.post('/session/local', SessionController.syncLocal.bind(this))
|
|
this.router.post('/session/local-all', SessionController.syncLocalSessions.bind(this))
|
|
// TODO: Update these endpoints because they are only for open playback sessions
|
|
this.router.get('/session/:id', SessionController.openSessionMiddleware.bind(this), SessionController.getOpenSession.bind(this))
|
|
this.router.post('/session/:id/sync', SessionController.openSessionMiddleware.bind(this), SessionController.sync.bind(this))
|
|
this.router.post('/session/:id/close', SessionController.openSessionMiddleware.bind(this), SessionController.close.bind(this))
|
|
|
|
//
|
|
// Podcast Routes
|
|
//
|
|
this.router.post('/podcasts', PodcastController.create.bind(this))
|
|
this.router.post('/podcasts/feed', PodcastController.getPodcastFeed.bind(this))
|
|
this.router.post('/podcasts/opml/parse', PodcastController.getFeedsFromOPMLText.bind(this))
|
|
this.router.post('/podcasts/opml/create', PodcastController.bulkCreatePodcastsFromOpmlFeedUrls.bind(this))
|
|
this.router.get('/podcasts/:id/checknew', PodcastController.middleware.bind(this), PodcastController.checkNewEpisodes.bind(this))
|
|
this.router.get('/podcasts/:id/downloads', PodcastController.middleware.bind(this), PodcastController.getEpisodeDownloads.bind(this))
|
|
this.router.get('/podcasts/:id/clear-queue', PodcastController.middleware.bind(this), PodcastController.clearEpisodeDownloadQueue.bind(this))
|
|
this.router.get('/podcasts/:id/search-episode', PodcastController.middleware.bind(this), PodcastController.findEpisode.bind(this))
|
|
this.router.post('/podcasts/:id/download-episodes', PodcastController.middleware.bind(this), PodcastController.downloadEpisodes.bind(this))
|
|
this.router.post('/podcasts/:id/match-episodes', PodcastController.middleware.bind(this), PodcastController.quickMatchEpisodes.bind(this))
|
|
this.router.get('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.getEpisode.bind(this))
|
|
this.router.patch('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.updateEpisode.bind(this))
|
|
this.router.delete('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.removeEpisode.bind(this))
|
|
|
|
//
|
|
// Notification Routes (Admin and up)
|
|
//
|
|
this.router.get('/notifications', NotificationController.middleware.bind(this), NotificationController.get.bind(this))
|
|
this.router.patch('/notifications', NotificationController.middleware.bind(this), NotificationController.update.bind(this))
|
|
this.router.get('/notificationdata', NotificationController.middleware.bind(this), NotificationController.getData.bind(this))
|
|
this.router.get('/notifications/test', NotificationController.middleware.bind(this), NotificationController.fireTestEvent.bind(this))
|
|
this.router.post('/notifications', NotificationController.middleware.bind(this), NotificationController.createNotification.bind(this))
|
|
this.router.delete('/notifications/:id', NotificationController.middleware.bind(this), NotificationController.deleteNotification.bind(this))
|
|
this.router.patch('/notifications/:id', NotificationController.middleware.bind(this), NotificationController.updateNotification.bind(this))
|
|
this.router.get('/notifications/:id/test', NotificationController.middleware.bind(this), NotificationController.sendNotificationTest.bind(this))
|
|
|
|
//
|
|
// Email Routes (Admin and up)
|
|
//
|
|
this.router.get('/emails/settings', EmailController.adminMiddleware.bind(this), EmailController.getSettings.bind(this))
|
|
this.router.patch('/emails/settings', EmailController.adminMiddleware.bind(this), EmailController.updateSettings.bind(this))
|
|
this.router.post('/emails/test', EmailController.adminMiddleware.bind(this), EmailController.sendTest.bind(this))
|
|
this.router.post('/emails/ereader-devices', EmailController.adminMiddleware.bind(this), EmailController.updateEReaderDevices.bind(this))
|
|
this.router.post('/emails/send-ebook-to-device', EmailController.sendEBookToDevice.bind(this))
|
|
|
|
//
|
|
// Search Routes
|
|
//
|
|
this.router.get('/search/covers', SearchController.findCovers.bind(this))
|
|
this.router.get('/search/books', SearchController.findBooks.bind(this))
|
|
this.router.get('/search/podcast', SearchController.findPodcasts.bind(this))
|
|
this.router.get('/search/authors', SearchController.findAuthor.bind(this))
|
|
this.router.get('/search/chapters', SearchController.findChapters.bind(this))
|
|
this.router.get('/search/providers', SearchController.getAllProviders.bind(this))
|
|
|
|
//
|
|
// Cache Routes (Admin and up)
|
|
//
|
|
this.router.post('/cache/purge', CacheController.purgeCache.bind(this))
|
|
this.router.post('/cache/items/purge', CacheController.purgeItemsCache.bind(this))
|
|
|
|
//
|
|
// Tools Routes (Admin and up)
|
|
//
|
|
this.router.post('/tools/item/:id/encode-m4b', ToolsController.middleware.bind(this), ToolsController.encodeM4b.bind(this))
|
|
this.router.delete('/tools/item/:id/encode-m4b', ToolsController.middleware.bind(this), ToolsController.cancelM4bEncode.bind(this))
|
|
this.router.post('/tools/item/:id/embed-metadata', ToolsController.middleware.bind(this), ToolsController.embedAudioFileMetadata.bind(this))
|
|
this.router.post('/tools/batch/embed-metadata', ToolsController.middleware.bind(this), ToolsController.batchEmbedMetadata.bind(this))
|
|
|
|
//
|
|
// RSS Feed Routes (Admin and up)
|
|
//
|
|
this.router.get('/feeds', RSSFeedController.middleware.bind(this), RSSFeedController.getAll.bind(this))
|
|
this.router.post('/feeds/item/:itemId/open', RSSFeedController.middleware.bind(this), RSSFeedController.openRSSFeedForItem.bind(this))
|
|
this.router.post('/feeds/collection/:collectionId/open', RSSFeedController.middleware.bind(this), RSSFeedController.openRSSFeedForCollection.bind(this))
|
|
this.router.post('/feeds/series/:seriesId/open', RSSFeedController.middleware.bind(this), RSSFeedController.openRSSFeedForSeries.bind(this))
|
|
this.router.post('/feeds/:id/close', RSSFeedController.middleware.bind(this), RSSFeedController.closeRSSFeed.bind(this))
|
|
|
|
//
|
|
// Custom Metadata Provider routes
|
|
//
|
|
this.router.get('/custom-metadata-providers', CustomMetadataProviderController.middleware.bind(this), CustomMetadataProviderController.getAll.bind(this))
|
|
this.router.post('/custom-metadata-providers', CustomMetadataProviderController.middleware.bind(this), CustomMetadataProviderController.create.bind(this))
|
|
this.router.delete('/custom-metadata-providers/:id', CustomMetadataProviderController.middleware.bind(this), CustomMetadataProviderController.delete.bind(this))
|
|
|
|
//
|
|
// Share routes
|
|
//
|
|
this.router.post('/share/mediaitem', ShareController.createMediaItemShare.bind(this))
|
|
this.router.delete('/share/mediaitem/:id', ShareController.deleteMediaItemShare.bind(this))
|
|
|
|
//
|
|
// Stats Routes
|
|
//
|
|
this.router.get('/stats/year/:year', StatsController.middleware.bind(this), StatsController.getAdminStatsForYear.bind(this))
|
|
this.router.get('/stats/server', StatsController.middleware.bind(this), StatsController.getServerStats.bind(this))
|
|
|
|
//
|
|
// API Key Routes
|
|
//
|
|
this.router.get('/api-keys', ApiKeyController.middleware.bind(this), ApiKeyController.getAll.bind(this))
|
|
this.router.post('/api-keys', ApiKeyController.middleware.bind(this), ApiKeyController.create.bind(this))
|
|
this.router.patch('/api-keys/:id', ApiKeyController.middleware.bind(this), ApiKeyController.update.bind(this))
|
|
this.router.delete('/api-keys/:id', ApiKeyController.middleware.bind(this), ApiKeyController.delete.bind(this))
|
|
|
|
//
|
|
// Misc Routes
|
|
//
|
|
this.router.post('/upload', MiscController.handleUpload.bind(this))
|
|
this.router.get('/tasks', MiscController.getTasks.bind(this))
|
|
this.router.patch('/settings', MiscController.updateServerSettings.bind(this))
|
|
this.router.patch('/sorting-prefixes', MiscController.updateSortingPrefixes.bind(this))
|
|
this.router.post('/authorize', MiscController.authorize.bind(this))
|
|
this.router.get('/tags', MiscController.getAllTags.bind(this))
|
|
this.router.post('/tags/rename', MiscController.renameTag.bind(this))
|
|
this.router.delete('/tags/:tag', MiscController.deleteTag.bind(this))
|
|
this.router.get('/genres', MiscController.getAllGenres.bind(this))
|
|
this.router.post('/genres/rename', MiscController.renameGenre.bind(this))
|
|
this.router.delete('/genres/:genre', MiscController.deleteGenre.bind(this))
|
|
this.router.post('/validate-cron', MiscController.validateCronExpression.bind(this))
|
|
this.router.get('/auth-settings', MiscController.getAuthSettings.bind(this))
|
|
this.router.patch('/auth-settings', MiscController.updateAuthSettings.bind(this))
|
|
this.router.post('/watcher/update', MiscController.updateWatchedPath.bind(this))
|
|
this.router.get('/logger-data', MiscController.getLoggerData.bind(this))
|
|
}
|
|
|
|
//
|
|
// Helper Methods
|
|
//
|
|
/**
|
|
* Remove library item and associated entities
|
|
* @param {string} libraryItemId
|
|
* @param {string[]} mediaItemIds array of bookId or podcastEpisodeId
|
|
* @param {string} libraryId
|
|
*/
|
|
async handleDeleteLibraryItem(libraryItemId, mediaItemIds, libraryId) {
|
|
const numProgressRemoved = await Database.mediaProgressModel.destroy({
|
|
where: {
|
|
mediaItemId: mediaItemIds
|
|
}
|
|
})
|
|
if (numProgressRemoved > 0) {
|
|
Logger.info(`[ApiRouter] Removed ${numProgressRemoved} media progress entries for library item "${libraryItemId}"`)
|
|
}
|
|
|
|
// remove item from playlists
|
|
await Database.playlistModel.removeMediaItemsFromPlaylists(mediaItemIds)
|
|
|
|
// Close rss feed - remove from db and emit socket event
|
|
await RssFeedManager.closeFeedForEntityId(libraryItemId)
|
|
|
|
// purge cover cache
|
|
await CacheManager.purgeCoverCache(libraryItemId)
|
|
|
|
// Remove metadata file if in /metadata/items dir
|
|
if (global.MetadataPath) {
|
|
const itemMetadataPath = Path.join(global.MetadataPath, 'items', libraryItemId)
|
|
if (await fs.pathExists(itemMetadataPath)) {
|
|
Logger.info(`[ApiRouter] Removing item metadata at "${itemMetadataPath}"`)
|
|
await fs.remove(itemMetadataPath)
|
|
}
|
|
}
|
|
|
|
await Database.libraryItemModel.removeById(libraryItemId)
|
|
|
|
SocketAuthority.emitter('item_removed', {
|
|
id: libraryItemId,
|
|
libraryId
|
|
})
|
|
}
|
|
|
|
/**
|
|
* After deleting book(s), remove empty series
|
|
*
|
|
* @param {string[]} seriesIds
|
|
*/
|
|
async checkRemoveEmptySeries(seriesIds) {
|
|
if (!seriesIds?.length) return
|
|
|
|
const transaction = await Database.sequelize.transaction()
|
|
try {
|
|
const seriesToRemove = (
|
|
await Database.seriesModel.findAll({
|
|
where: [
|
|
{
|
|
id: seriesIds
|
|
},
|
|
sequelize.where(sequelize.literal('(SELECT count(*) FROM bookSeries bs WHERE bs.seriesId = series.id)'), 0)
|
|
],
|
|
attributes: ['id', 'name', 'libraryId'],
|
|
include: {
|
|
model: Database.bookModel,
|
|
attributes: ['id'],
|
|
required: false // Ensure it includes series even if no books exist
|
|
},
|
|
transaction
|
|
})
|
|
).map((s) => ({ id: s.id, name: s.name, libraryId: s.libraryId }))
|
|
|
|
if (seriesToRemove.length) {
|
|
await Database.seriesModel.destroy({
|
|
where: {
|
|
id: seriesToRemove.map((s) => s.id)
|
|
},
|
|
transaction
|
|
})
|
|
}
|
|
|
|
await transaction.commit()
|
|
|
|
seriesToRemove.forEach(({ id, name, libraryId }) => {
|
|
Logger.info(`[ApiRouter] Series "${name}" is now empty. Removing series`)
|
|
|
|
// Remove series from library filter data
|
|
Database.removeSeriesFromFilterData(libraryId, id)
|
|
SocketAuthority.emitter('series_removed', { id: id, libraryId: libraryId })
|
|
})
|
|
// Close rss feeds - remove from db and emit socket event
|
|
if (seriesToRemove.length) {
|
|
await RssFeedManager.closeFeedsForEntityIds(seriesToRemove.map((s) => s.id))
|
|
}
|
|
} catch (error) {
|
|
await transaction.rollback()
|
|
Logger.error(`[ApiRouter] Error removing empty series: ${error.message}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove authors with no books and unset asin, description and imagePath
|
|
* Note: Other implementation is in BookScanner.checkAuthorsRemovedFromBooks (can be merged)
|
|
*
|
|
* @param {string[]} authorIds
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async checkRemoveAuthorsWithNoBooks(authorIds) {
|
|
if (!authorIds?.length) return
|
|
|
|
const transaction = await Database.sequelize.transaction()
|
|
try {
|
|
// Select authors with locking to prevent concurrent updates
|
|
const bookAuthorsToRemove = (
|
|
await Database.authorModel.findAll({
|
|
where: [
|
|
{
|
|
id: authorIds,
|
|
asin: {
|
|
[sequelize.Op.or]: [null, '']
|
|
},
|
|
description: {
|
|
[sequelize.Op.or]: [null, '']
|
|
},
|
|
imagePath: {
|
|
[sequelize.Op.or]: [null, '']
|
|
}
|
|
},
|
|
sequelize.where(sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 0)
|
|
],
|
|
attributes: ['id', 'name', 'libraryId'],
|
|
raw: true,
|
|
transaction
|
|
})
|
|
).map((au) => ({ id: au.id, name: au.name, libraryId: au.libraryId }))
|
|
|
|
if (bookAuthorsToRemove.length) {
|
|
await Database.authorModel.destroy({
|
|
where: {
|
|
id: bookAuthorsToRemove.map((au) => au.id)
|
|
},
|
|
transaction
|
|
})
|
|
}
|
|
|
|
await transaction.commit()
|
|
|
|
// Remove all book authors after completing remove from database
|
|
bookAuthorsToRemove.forEach(({ id, name, libraryId }) => {
|
|
Database.removeAuthorFromFilterData(libraryId, id)
|
|
// TODO: Clients were expecting full author in payload but its unnecessary
|
|
SocketAuthority.emitter('author_removed', { id, libraryId })
|
|
Logger.info(`[ApiRouter] Removed author "${name}" with no books`)
|
|
})
|
|
} catch (error) {
|
|
await transaction.rollback()
|
|
Logger.error(`[ApiRouter] Error removing authors: ${error.message}`)
|
|
}
|
|
}
|
|
|
|
async getUserListeningSessionsHelper(userId) {
|
|
const userSessions = await Database.getPlaybackSessions({ userId })
|
|
return userSessions.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
}
|
|
|
|
async getUserListeningSessionsPageHelper(userId, page = 0, itemsPerPage = 10, mediaItemId = null) {
|
|
const where = { userId }
|
|
if (mediaItemId) where.mediaItemId = mediaItemId
|
|
|
|
const start = page * itemsPerPage
|
|
const [total, sessions] = await Promise.all([
|
|
Database.countPlaybackSessions(where),
|
|
Database.getPlaybackSessions(where, {
|
|
limit: itemsPerPage,
|
|
offset: start,
|
|
order: [['updatedAt', 'DESC']]
|
|
})
|
|
])
|
|
|
|
return {
|
|
total,
|
|
numPages: Math.ceil(total / itemsPerPage),
|
|
page,
|
|
itemsPerPage,
|
|
sessions
|
|
}
|
|
}
|
|
|
|
async getUserItemListeningSessionsHelper(userId, mediaItemId) {
|
|
const userSessions = await Database.getPlaybackSessions({ userId, mediaItemId })
|
|
return userSessions.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
}
|
|
|
|
async getUserListeningStatsHelpers(userId, options = {}) {
|
|
const startedAt = Date.now()
|
|
const today = date.format(new Date(), 'YYYY-MM-DD')
|
|
const includeItems = options.includeItems !== false
|
|
const includeRecentSessions = options.includeRecentSessions !== false
|
|
|
|
const tasks = [Database.getPlaybackSessionsForStats({ userId })]
|
|
if (includeRecentSessions) {
|
|
tasks.push(
|
|
Database.getPlaybackSessions({ userId }, {
|
|
limit: 10,
|
|
offset: 0,
|
|
order: [['updatedAt', 'DESC']]
|
|
})
|
|
)
|
|
}
|
|
const [listeningSessions, recentSessions = []] = await Promise.all(tasks)
|
|
|
|
const listeningStats = {
|
|
totalTime: 0,
|
|
days: {},
|
|
dayOfWeek: {},
|
|
today: 0
|
|
}
|
|
if (includeItems) listeningStats.items = {}
|
|
if (includeRecentSessions) listeningStats.recentSessions = recentSessions
|
|
listeningSessions.forEach((s) => {
|
|
const libraryItemId = s.extraData?.libraryItemId || null
|
|
const numericListening = Number(s.timeListening)
|
|
const sessionTimeListening = Number.isFinite(numericListening)
|
|
? numericListening
|
|
: 0
|
|
|
|
if (s.dayOfWeek) {
|
|
if (!listeningStats.dayOfWeek[s.dayOfWeek]) listeningStats.dayOfWeek[s.dayOfWeek] = 0
|
|
listeningStats.dayOfWeek[s.dayOfWeek] += sessionTimeListening
|
|
}
|
|
if (s.date && sessionTimeListening > 0) {
|
|
if (!listeningStats.days[s.date]) listeningStats.days[s.date] = 0
|
|
listeningStats.days[s.date] += sessionTimeListening
|
|
|
|
if (s.date === today) {
|
|
listeningStats.today += sessionTimeListening
|
|
}
|
|
}
|
|
if (!libraryItemId) {
|
|
listeningStats.totalTime += sessionTimeListening
|
|
return
|
|
}
|
|
|
|
if (includeItems) {
|
|
if (!listeningStats.items[libraryItemId]) {
|
|
listeningStats.items[libraryItemId] = {
|
|
id: libraryItemId,
|
|
timeListening: sessionTimeListening,
|
|
mediaMetadata: s.mediaMetadata,
|
|
lastUpdate: s.updatedAt
|
|
}
|
|
} else {
|
|
listeningStats.items[libraryItemId].timeListening += sessionTimeListening
|
|
}
|
|
}
|
|
|
|
listeningStats.totalTime += sessionTimeListening
|
|
})
|
|
Logger.debug(
|
|
`[ApiRouter] Listening stats for user "${userId}" aggregated ${listeningSessions.length} sessions in ${Date.now() - startedAt}ms includeItems=${includeItems} includeRecentSessions=${includeRecentSessions}`
|
|
)
|
|
return listeningStats
|
|
}
|
|
}
|
|
module.exports = ApiRouter
|