mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-27 11:41:36 +00:00
Merge branch 'master' into social
This commit is contained in:
commit
12b7976ef3
245 changed files with 11554 additions and 3676 deletions
|
|
@ -1,5 +1,6 @@
|
|||
const bcrypt = require('./libs/bcryptjs')
|
||||
const jwt = require('./libs/jsonwebtoken')
|
||||
const requestIp = require('./libs/requestIp')
|
||||
const Logger = require('./Logger')
|
||||
|
||||
class Auth {
|
||||
|
|
@ -125,14 +126,16 @@ class Auth {
|
|||
}
|
||||
|
||||
async login(req, res, feeds) {
|
||||
const ipAddress = requestIp.getClientIp(req)
|
||||
var username = (req.body.username || '').toLowerCase()
|
||||
var password = req.body.password || ''
|
||||
|
||||
var user = this.users.find(u => u.username.toLowerCase() === username)
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
Logger.debug(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit}`)
|
||||
Logger.warn(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit} from ${ipAddress}`)
|
||||
if (req.rateLimit.remaining <= 2) {
|
||||
Logger.error(`[Auth] Failed login attempt for username ${username} from ip ${ipAddress}. Attempts: ${req.rateLimit.current}`)
|
||||
return res.status(401).send(`Invalid user or password (${req.rateLimit.remaining === 0 ? '1 attempt remaining' : `${req.rateLimit.remaining + 1} attempts remaining`})`)
|
||||
}
|
||||
return res.status(401).send('Invalid user or password')
|
||||
|
|
@ -152,9 +155,9 @@ class Auth {
|
|||
if (compare) {
|
||||
res.json(this.getUserLoginResponsePayload(user, feeds))
|
||||
} else {
|
||||
Logger.debug(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit}`)
|
||||
Logger.warn(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit} from ${ipAddress}`)
|
||||
if (req.rateLimit.remaining <= 2) {
|
||||
Logger.error(`[Auth] Failed login attempt for user ${user.username}. Attempts: ${req.rateLimit.current}`)
|
||||
Logger.error(`[Auth] Failed login attempt for user ${user.username} from ip ${ipAddress}. Attempts: ${req.rateLimit.current}`)
|
||||
return res.status(401).send(`Invalid user or password (${req.rateLimit.remaining === 0 ? '1 attempt remaining' : `${req.rateLimit.remaining + 1} attempts remaining`})`)
|
||||
}
|
||||
return res.status(401).send('Invalid user or password')
|
||||
|
|
|
|||
35
server/Db.js
35
server/Db.js
|
|
@ -4,13 +4,13 @@ const Logger = require('./Logger')
|
|||
const { version } = require('../package.json')
|
||||
const LibraryItem = require('./objects/LibraryItem')
|
||||
const User = require('./objects/user/User')
|
||||
const UserCollection = require('./objects/UserCollection')
|
||||
const Collection = require('./objects/Collection')
|
||||
const Library = require('./objects/Library')
|
||||
const Author = require('./objects/entities/Author')
|
||||
const Series = require('./objects/entities/Series')
|
||||
const ServerSettings = require('./objects/settings/ServerSettings')
|
||||
const NotificationSettings = require('./objects/settings/NotificationSettings')
|
||||
const PlaybackSession = require('./objects/PlaybackSession')
|
||||
const Feed = require('./objects/Feed')
|
||||
|
||||
class Db {
|
||||
constructor() {
|
||||
|
|
@ -24,15 +24,16 @@ class Db {
|
|||
this.SeriesPath = Path.join(global.ConfigPath, 'series')
|
||||
this.FeedsPath = Path.join(global.ConfigPath, 'feeds')
|
||||
|
||||
this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath)
|
||||
this.usersDb = new njodb.Database(this.UsersPath)
|
||||
this.sessionsDb = new njodb.Database(this.SessionsPath)
|
||||
this.librariesDb = new njodb.Database(this.LibrariesPath, { datastores: 2 })
|
||||
this.settingsDb = new njodb.Database(this.SettingsPath, { datastores: 2 })
|
||||
this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2 })
|
||||
this.authorsDb = new njodb.Database(this.AuthorsPath)
|
||||
this.seriesDb = new njodb.Database(this.SeriesPath, { datastores: 2 })
|
||||
this.feedsDb = new njodb.Database(this.FeedsPath, { datastores: 2 })
|
||||
const staleTime = 1000 * 60 * 2
|
||||
this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath, { lockoptions: { stale: staleTime } })
|
||||
this.usersDb = new njodb.Database(this.UsersPath, { lockoptions: { stale: staleTime } })
|
||||
this.sessionsDb = new njodb.Database(this.SessionsPath, { lockoptions: { stale: staleTime } })
|
||||
this.librariesDb = new njodb.Database(this.LibrariesPath, { datastores: 2, lockoptions: { stale: staleTime } })
|
||||
this.settingsDb = new njodb.Database(this.SettingsPath, { datastores: 2, lockoptions: { stale: staleTime } })
|
||||
this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2, lockoptions: { stale: staleTime } })
|
||||
this.authorsDb = new njodb.Database(this.AuthorsPath, { lockoptions: { stale: staleTime } })
|
||||
this.seriesDb = new njodb.Database(this.SeriesPath, { datastores: 2, lockoptions: { stale: staleTime } })
|
||||
this.feedsDb = new njodb.Database(this.FeedsPath, { datastores: 2, lockoptions: { stale: staleTime } })
|
||||
|
||||
this.libraryItems = []
|
||||
this.users = []
|
||||
|
|
@ -43,6 +44,7 @@ class Db {
|
|||
this.series = []
|
||||
|
||||
this.serverSettings = null
|
||||
this.notificationSettings = null
|
||||
|
||||
// Stores previous version only if upgraded
|
||||
this.previousVersion = null
|
||||
|
|
@ -125,6 +127,10 @@ class Db {
|
|||
this.serverSettings = new ServerSettings()
|
||||
await this.insertEntity('settings', this.serverSettings)
|
||||
}
|
||||
if (!this.notificationSettings) {
|
||||
this.notificationSettings = new NotificationSettings()
|
||||
await this.insertEntity('settings', this.notificationSettings)
|
||||
}
|
||||
global.ServerSettings = this.serverSettings.toJSON()
|
||||
}
|
||||
|
||||
|
|
@ -166,10 +172,15 @@ class Db {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
var notificationSettings = this.settings.find(s => s.id === 'notification-settings')
|
||||
if (notificationSettings) {
|
||||
this.notificationSettings = new NotificationSettings(notificationSettings)
|
||||
}
|
||||
}
|
||||
})
|
||||
var p5 = this.collectionsDb.select(() => true).then((results) => {
|
||||
this.collections = results.data.map(l => new UserCollection(l))
|
||||
this.collections = results.data.map(l => new Collection(l))
|
||||
Logger.info(`[DB] ${this.collections.length} Collections Loaded`)
|
||||
})
|
||||
var p6 = this.authorsDb.select(() => true).then((results) => {
|
||||
|
|
|
|||
111
server/Server.js
111
server/Server.js
|
|
@ -23,6 +23,7 @@ const ApiRouter = require('./routers/ApiRouter')
|
|||
const HlsRouter = require('./routers/HlsRouter')
|
||||
const StaticRouter = require('./routers/StaticRouter')
|
||||
|
||||
const NotificationManager = require('./managers/NotificationManager')
|
||||
const CoverManager = require('./managers/CoverManager')
|
||||
const AbMergeManager = require('./managers/AbMergeManager')
|
||||
const CacheManager = require('./managers/CacheManager')
|
||||
|
|
@ -33,9 +34,10 @@ const PodcastManager = require('./managers/PodcastManager')
|
|||
const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
|
||||
const RssFeedManager = require('./managers/RssFeedManager')
|
||||
const CronManager = require('./managers/CronManager')
|
||||
const TaskManager = require('./managers/TaskManager')
|
||||
|
||||
class Server {
|
||||
constructor(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH) {
|
||||
constructor(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) {
|
||||
this.Port = PORT
|
||||
this.Host = HOST
|
||||
global.Source = SOURCE
|
||||
|
|
@ -43,6 +45,7 @@ class Server {
|
|||
global.Gid = isNaN(GID) ? 0 : Number(GID)
|
||||
global.ConfigPath = Path.normalize(CONFIG_PATH)
|
||||
global.MetadataPath = Path.normalize(METADATA_PATH)
|
||||
global.RouterBasePath = ROUTER_BASE_PATH
|
||||
|
||||
// Fix backslash if not on Windows
|
||||
if (process.platform !== 'win32') {
|
||||
|
|
@ -64,21 +67,23 @@ class Server {
|
|||
this.auth = new Auth(this.db)
|
||||
|
||||
// Managers
|
||||
this.taskManager = new TaskManager(this.emitter.bind(this))
|
||||
this.notificationManager = new NotificationManager(this.db, this.emitter.bind(this))
|
||||
this.backupManager = new BackupManager(this.db, this.emitter.bind(this))
|
||||
this.logManager = new LogManager(this.db)
|
||||
this.cacheManager = new CacheManager()
|
||||
this.abMergeManager = new AbMergeManager(this.db, this.clientEmitter.bind(this))
|
||||
this.abMergeManager = new AbMergeManager(this.db, this.taskManager, this.clientEmitter.bind(this))
|
||||
this.playbackSessionManager = new PlaybackSessionManager(this.db, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||
this.coverManager = new CoverManager(this.db, this.cacheManager)
|
||||
this.podcastManager = new PodcastManager(this.db, this.watcher, this.emitter.bind(this))
|
||||
this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||
this.podcastManager = new PodcastManager(this.db, this.watcher, this.emitter.bind(this), this.notificationManager)
|
||||
this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.taskManager, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||
this.rssFeedManager = new RssFeedManager(this.db, this.emitter.bind(this))
|
||||
|
||||
this.scanner = new Scanner(this.db, this.coverManager, this.emitter.bind(this))
|
||||
this.cronManager = new CronManager(this.db, this.scanner, this.podcastManager)
|
||||
|
||||
// Routers
|
||||
this.apiRouter = new ApiRouter(this.db, this.auth, this.scanner, this.playbackSessionManager, this.abMergeManager, this.coverManager, this.backupManager, this.watcher, this.cacheManager, this.podcastManager, this.audioMetadataManager, this.rssFeedManager, this.cronManager, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||
this.apiRouter = new ApiRouter(this.db, this.auth, this.scanner, this.playbackSessionManager, this.abMergeManager, this.coverManager, this.backupManager, this.watcher, this.cacheManager, this.podcastManager, this.audioMetadataManager, this.rssFeedManager, this.cronManager, this.notificationManager, this.taskManager, this.getUsersOnline.bind(this), this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||
this.hlsRouter = new HlsRouter(this.db, this.auth, this.playbackSessionManager, this.emitter.bind(this))
|
||||
this.staticRouter = new StaticRouter(this.db)
|
||||
|
||||
|
|
@ -90,8 +95,7 @@ class Server {
|
|||
this.clients = {}
|
||||
}
|
||||
|
||||
get usersOnline() {
|
||||
// TODO: Map open user sessions
|
||||
getUsersOnline() {
|
||||
return Object.values(this.clients).filter(c => c.user).map(client => {
|
||||
return client.user.toJSONForPublic(this.playbackSessionManager.sessions, this.db.libraryItems)
|
||||
})
|
||||
|
|
@ -124,7 +128,6 @@ class Server {
|
|||
|
||||
async init() {
|
||||
Logger.info('[Server] Init v' + version)
|
||||
await this.abMergeManager.removeOrphanDownloads()
|
||||
await this.playbackSessionManager.removeOrphanStreams()
|
||||
|
||||
var previousVersion = await this.db.checkPreviousVersion() // Returns null if same server version
|
||||
|
|
@ -143,7 +146,7 @@ class Server {
|
|||
await this.auth.initTokenSecret()
|
||||
}
|
||||
|
||||
await this.checkUserMediaProgress() // Remove invalid user item progress
|
||||
await this.cleanUserData() // Remove invalid user item progress
|
||||
await this.purgeMetadata() // Remove metadata folders without library item
|
||||
await this.playbackSessionManager.removeInvalidSessions()
|
||||
await this.cacheManager.ensureCachePaths()
|
||||
|
|
@ -168,29 +171,32 @@ class Server {
|
|||
await this.init()
|
||||
|
||||
const app = express()
|
||||
const router = express.Router()
|
||||
app.use(global.RouterBasePath, router)
|
||||
|
||||
this.server = http.createServer(app)
|
||||
|
||||
app.use(this.auth.cors)
|
||||
app.use(fileUpload())
|
||||
app.use(express.urlencoded({ extended: true, limit: "5mb" }));
|
||||
app.use(express.json({ limit: "5mb" }))
|
||||
router.use(this.auth.cors)
|
||||
router.use(fileUpload())
|
||||
router.use(express.urlencoded({ extended: true, limit: "5mb" }));
|
||||
router.use(express.json({ limit: "5mb" }))
|
||||
|
||||
// Static path to generated nuxt
|
||||
const distPath = Path.join(global.appRoot, '/client/dist')
|
||||
app.use(express.static(distPath))
|
||||
router.use(express.static(distPath))
|
||||
|
||||
// Metadata folder static path
|
||||
app.use('/metadata', this.authMiddleware.bind(this), express.static(global.MetadataPath))
|
||||
router.use('/metadata', this.authMiddleware.bind(this), express.static(global.MetadataPath))
|
||||
|
||||
// Static folder
|
||||
app.use(express.static(Path.join(global.appRoot, 'static')))
|
||||
router.use(express.static(Path.join(global.appRoot, 'static')))
|
||||
|
||||
app.use('/api', this.authMiddleware.bind(this), this.apiRouter.router)
|
||||
app.use('/hls', this.authMiddleware.bind(this), this.hlsRouter.router)
|
||||
app.use('/s', this.authMiddleware.bind(this), this.staticRouter.router)
|
||||
router.use('/api', this.authMiddleware.bind(this), this.apiRouter.router)
|
||||
router.use('/hls', this.authMiddleware.bind(this), this.hlsRouter.router)
|
||||
router.use('/s', this.authMiddleware.bind(this), this.staticRouter.router)
|
||||
|
||||
// EBook static file routes
|
||||
app.get('/ebook/:library/:folder/*', (req, res) => {
|
||||
router.get('/ebook/:library/:folder/*', (req, res) => {
|
||||
var library = this.db.libraries.find(lib => lib.id === req.params.library)
|
||||
if (!library) return res.sendStatus(404)
|
||||
var folder = library.folders.find(fol => fol.id === req.params.folder)
|
||||
|
|
@ -202,14 +208,14 @@ class Server {
|
|||
})
|
||||
|
||||
// RSS Feed temp route
|
||||
app.get('/feed/:id', (req, res) => {
|
||||
router.get('/feed/:id', (req, res) => {
|
||||
Logger.info(`[Server] Requesting rss feed ${req.params.id}`)
|
||||
this.rssFeedManager.getFeed(req, res)
|
||||
})
|
||||
app.get('/feed/:id/cover', (req, res) => {
|
||||
router.get('/feed/:id/cover', (req, res) => {
|
||||
this.rssFeedManager.getFeedCover(req, res)
|
||||
})
|
||||
app.get('/feed/:id/item/:episodeId/*', (req, res) => {
|
||||
router.get('/feed/:id/item/:episodeId/*', (req, res) => {
|
||||
Logger.debug(`[Server] Requesting rss feed episode ${req.params.id}/${req.params.episodeId}`)
|
||||
this.rssFeedManager.getFeedItem(req, res)
|
||||
})
|
||||
|
|
@ -217,35 +223,38 @@ class Server {
|
|||
// Client dynamic routes
|
||||
const dyanimicRoutes = [
|
||||
'/item/:id',
|
||||
'/item/:id/manage',
|
||||
'/author/:id',
|
||||
'/audiobook/:id/chapters',
|
||||
'/audiobook/:id/edit',
|
||||
'/audiobook/:id/manage',
|
||||
'/library/:library',
|
||||
'/library/:library/search',
|
||||
'/library/:library/bookshelf/:id?',
|
||||
'/library/:library/authors',
|
||||
'/library/:library/series/:id?',
|
||||
'/library/:library/podcast/search',
|
||||
'/library/:library/podcast/latest',
|
||||
'/config/users/:id',
|
||||
'/config/users/:id/sessions',
|
||||
'/collection/:id'
|
||||
]
|
||||
dyanimicRoutes.forEach((route) => app.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))
|
||||
dyanimicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))
|
||||
|
||||
app.post('/login', this.getLoginRateLimiter(), (req, res) => this.auth.login(req, res, this.rssFeedManager.feedsArray))
|
||||
app.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this))
|
||||
app.post('/init', (req, res) => {
|
||||
router.post('/login', this.getLoginRateLimiter(), (req, res) => this.auth.login(req, res, this.rssFeedManager.feedsArray))
|
||||
router.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this))
|
||||
router.post('/init', (req, res) => {
|
||||
if (this.db.hasRootUser) {
|
||||
Logger.error(`[Server] attempt to init server when server already has a root user`)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
this.initializeServer(req, res)
|
||||
})
|
||||
app.get('/status', (req, res) => {
|
||||
router.get('/status', (req, res) => {
|
||||
// status check for client to see if server has been initialized
|
||||
// server has been initialized if a root user exists
|
||||
const payload = {
|
||||
isInit: this.db.hasRootUser
|
||||
isInit: this.db.hasRootUser,
|
||||
language: this.db.serverSettings.language
|
||||
}
|
||||
if (!payload.isInit) {
|
||||
payload.ConfigPath = global.ConfigPath
|
||||
|
|
@ -253,7 +262,7 @@ class Server {
|
|||
}
|
||||
res.json(payload)
|
||||
})
|
||||
app.get('/ping', (req, res) => {
|
||||
router.get('/ping', (req, res) => {
|
||||
Logger.info('Received ping')
|
||||
res.json({ success: true })
|
||||
})
|
||||
|
|
@ -364,21 +373,37 @@ class Server {
|
|||
return purged
|
||||
}
|
||||
|
||||
// Remove user media progress entries that dont have a library item
|
||||
// TODO: Check podcast episode exists still
|
||||
async checkUserMediaProgress() {
|
||||
// Remove user media progress with items that no longer exist & remove seriesHideFrom that no longer exist
|
||||
async cleanUserData() {
|
||||
for (let i = 0; i < this.db.users.length; i++) {
|
||||
var _user = this.db.users[i]
|
||||
if (_user.mediaProgress) {
|
||||
var itemProgressIdsToRemove = _user.mediaProgress.map(lip => lip.id).filter(lipId => !this.db.libraryItems.find(_li => _li.id == lipId))
|
||||
if (itemProgressIdsToRemove.length) {
|
||||
Logger.debug(`[Server] Found ${itemProgressIdsToRemove.length} media progress data to remove from user ${_user.username}`)
|
||||
for (const lipId of itemProgressIdsToRemove) {
|
||||
_user.removeMediaProgress(lipId)
|
||||
}
|
||||
await this.db.updateEntity('user', _user)
|
||||
var hasUpdated = false
|
||||
if (_user.mediaProgress.length) {
|
||||
const lengthBefore = _user.mediaProgress.length
|
||||
_user.mediaProgress = _user.mediaProgress.filter(mp => {
|
||||
const libraryItem = this.db.libraryItems.find(li => li.id === mp.libraryItemId)
|
||||
if (!libraryItem) return false
|
||||
if (mp.episodeId && (libraryItem.mediaType !== 'podcast' || !libraryItem.media.checkHasEpisode(mp.episodeId))) return false // Episode not found
|
||||
return true
|
||||
})
|
||||
|
||||
if (lengthBefore > _user.mediaProgress.length) {
|
||||
Logger.debug(`[Server] Removing ${_user.mediaProgress.length - lengthBefore} media progress data from user ${_user.username}`)
|
||||
hasUpdated = true
|
||||
}
|
||||
}
|
||||
if (_user.seriesHideFromContinueListening.length) {
|
||||
_user.seriesHideFromContinueListening = _user.seriesHideFromContinueListening.filter(seriesId => {
|
||||
if (!this.db.series.some(se => se.id === seriesId)) { // Series removed
|
||||
hasUpdated = true
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
if (hasUpdated) {
|
||||
await this.db.updateEntity('user', _user)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -455,7 +480,7 @@ class Server {
|
|||
backups: (this.backupManager.backups || []).map(b => b.toJSON())
|
||||
}
|
||||
if (user.type === 'root') {
|
||||
initialPayload.usersOnline = this.usersOnline
|
||||
initialPayload.usersOnline = this.getUsersOnline()
|
||||
}
|
||||
client.socket.emit('init', initialPayload)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ class AuthorController {
|
|||
constructor() { }
|
||||
|
||||
async findOne(req, res) {
|
||||
const libraryId = req.query.library
|
||||
const include = (req.query.include || '').split(',')
|
||||
|
||||
const authorJson = req.author.toJSON()
|
||||
|
|
@ -16,6 +17,7 @@ class AuthorController {
|
|||
// Used on author landing page to include library items and items grouped in series
|
||||
if (include.includes('items')) {
|
||||
authorJson.libraryItems = this.db.libraryItems.filter(li => {
|
||||
if (libraryId && li.libraryId !== libraryId) return false
|
||||
if (!req.user.checkCanAccessLibraryItem(li)) return false // filter out library items user cannot access
|
||||
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
const Logger = require('../Logger')
|
||||
const UserCollection = require('../objects/UserCollection')
|
||||
const Collection = require('../objects/Collection')
|
||||
|
||||
class CollectionController {
|
||||
constructor() { }
|
||||
|
||||
async create(req, res) {
|
||||
var newCollection = new UserCollection()
|
||||
var newCollection = new Collection()
|
||||
req.body.userId = req.user.id
|
||||
var success = newCollection.setData(req.body)
|
||||
if (!success) {
|
||||
|
|
@ -18,8 +18,7 @@ class CollectionController {
|
|||
}
|
||||
|
||||
findAll(req, res) {
|
||||
var collections = this.db.collections.filter(c => c.userId === req.user.id)
|
||||
var expandedCollections = collections.map(c => c.toJSONExpanded(this.db.libraryItems))
|
||||
var expandedCollections = this.db.collections.map(c => c.toJSONExpanded(this.db.libraryItems))
|
||||
res.json(expandedCollections)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -131,6 +131,13 @@ class LibraryController {
|
|||
// Remove library watcher
|
||||
this.watcher.removeLibrary(library)
|
||||
|
||||
// Remove collections for library
|
||||
var collections = this.db.collections.filter(c => c.libraryId === library.id)
|
||||
for (const collection of collections) {
|
||||
Logger.info(`[Server] deleting collection "${collection.name}" for library "${library.name}"`)
|
||||
await this.db.removeEntity('collection', collection.id)
|
||||
}
|
||||
|
||||
// Remove items in this library
|
||||
var libraryItems = this.db.libraryItems.filter(li => li.libraryId === library.id)
|
||||
Logger.info(`[Server] deleting library "${library.name}" with ${libraryItems.length} items"`)
|
||||
|
|
@ -160,21 +167,53 @@ class LibraryController {
|
|||
minified: req.query.minified === '1',
|
||||
collapseseries: req.query.collapseseries === '1'
|
||||
}
|
||||
var mediaIsBook = payload.mediaType === 'book'
|
||||
|
||||
// Step 1 - Filter the retrieved library items
|
||||
var filterSeries = null
|
||||
if (payload.filterBy) {
|
||||
// If filtering by series, will include seriesName and seriesSequence on media metadata
|
||||
filterSeries = (payload.mediaType == 'book' && payload.filterBy.startsWith('series.')) ? libraryHelpers.decode(payload.filterBy.replace('series.', '')) : null
|
||||
if (filterSeries === 'No Series') filterSeries = null
|
||||
|
||||
libraryItems = libraryHelpers.getFilteredLibraryItems(libraryItems, payload.filterBy, req.user, this.rssFeedManager.feedsArray)
|
||||
payload.total = libraryItems.length
|
||||
|
||||
// Determining if we are filtering titles by a series, and if so, which series
|
||||
filterSeries = (mediaIsBook && payload.filterBy.startsWith('series.')) ? libraryHelpers.decode(payload.filterBy.replace('series.', '')) : null
|
||||
if (filterSeries === 'No Series') filterSeries = null
|
||||
}
|
||||
|
||||
// Step 2 - If selected, collapse library items by the series they belong to.
|
||||
// If also filtering by series, will not collapse the filtered series as this would lead
|
||||
// to series having a collapsed series that is just that series.
|
||||
if (payload.collapseseries) {
|
||||
let collapsedItems = libraryHelpers.collapseBookSeries(libraryItems, this.db.series, filterSeries)
|
||||
|
||||
if (!(collapsedItems.length == 1 && collapsedItems[0].collapsedSeries)) {
|
||||
libraryItems = collapsedItems
|
||||
|
||||
// Get accurate total entities
|
||||
// let uniqueEntities = new Set()
|
||||
// libraryItems.forEach((item) => {
|
||||
// if (item.collapsedSeries) {
|
||||
// item.collapsedSeries.books.forEach(book => uniqueEntities.add(book.id))
|
||||
// } else {
|
||||
// uniqueEntities.add(item.id)
|
||||
// }
|
||||
// })
|
||||
payload.total = libraryItems.length
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3 - Sort the retrieved library items.
|
||||
var sortArray = []
|
||||
|
||||
// When on the series page, sort by sequence only
|
||||
if (payload.sortBy === 'book.volumeNumber') payload.sortBy = null // TODO: Remove temp fix after mobile release 0.9.60
|
||||
if (filterSeries && !payload.sortBy) {
|
||||
sortArray.push({ asc: (li) => li.media.metadata.getSeries(filterSeries).sequence })
|
||||
}
|
||||
|
||||
if (payload.sortBy) {
|
||||
var sortKey = payload.sortBy
|
||||
|
||||
// old sort key TODO: should be mutated in dbMigration
|
||||
var sortKey = payload.sortBy
|
||||
if (sortKey.startsWith('book.')) {
|
||||
sortKey = sortKey.replace('book.', 'media.metadata.')
|
||||
}
|
||||
|
|
@ -186,29 +225,42 @@ class LibraryController {
|
|||
sortKey += 'IgnorePrefix'
|
||||
}
|
||||
|
||||
// Start sort
|
||||
var direction = payload.sortDesc ? 'desc' : 'asc'
|
||||
var sortArray = [
|
||||
{
|
||||
[direction]: (li) => {
|
||||
// When collapsing by series and sorting by title use the series name instead of the book title
|
||||
if (payload.mediaType === 'book' && payload.collapseseries && li.media.metadata.seriesName) {
|
||||
if (sortByTitle) {
|
||||
return this.db.serverSettings.sortingIgnorePrefix ? li.media.metadata.seriesNameIgnorePrefix : li.media.metadata.seriesName
|
||||
} else {
|
||||
// When not sorting by title always show the collapsed series at the end
|
||||
return direction === 'desc' ? -1 : 'zzzz'
|
||||
}
|
||||
// If series are collapsed and not sorting by title or sequence,
|
||||
// sort all collapsed series to the end in alphabetical order
|
||||
const sortBySequence = filterSeries && (sortKey === 'sequence')
|
||||
if (payload.collapseseries && !(sortByTitle || sortBySequence)) {
|
||||
sortArray.push({
|
||||
asc: (li) => {
|
||||
if (li.collapsedSeries) {
|
||||
return this.db.serverSettings.sortingIgnorePrefix ?
|
||||
li.collapsedSeries.nameIgnorePrefix :
|
||||
li.collapsedSeries.name
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Sort series based on the sortBy attribute
|
||||
var direction = payload.sortDesc ? 'desc' : 'asc'
|
||||
sortArray.push({
|
||||
[direction]: (li) => {
|
||||
if (mediaIsBook && sortBySequence) {
|
||||
return li.media.metadata.getSeries(filterSeries).sequence
|
||||
} else if (mediaIsBook && sortByTitle && li.collapsedSeries) {
|
||||
return this.db.serverSettings.sortingIgnorePrefix ?
|
||||
li.collapsedSeries.nameIgnorePrefix :
|
||||
li.collapsedSeries.name
|
||||
} else {
|
||||
// Supports dot notation strings i.e. "media.metadata.title"
|
||||
return sortKey.split('.').reduce((a, b) => a[b], li)
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// Secondary sort when sorting by book author use series sort title
|
||||
if (payload.mediaType === 'book' && payload.sortBy.includes('author')) {
|
||||
if (mediaIsBook && payload.sortBy.includes('author')) {
|
||||
sortArray.push({
|
||||
asc: (li) => {
|
||||
if (li.media.metadata.series && li.media.metadata.series.length) {
|
||||
|
|
@ -218,30 +270,61 @@ class LibraryController {
|
|||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (sortArray.length) {
|
||||
libraryItems = naturalSort(libraryItems).by(sortArray)
|
||||
}
|
||||
|
||||
if (payload.collapseseries) {
|
||||
libraryItems = libraryHelpers.collapseBookSeries(libraryItems)
|
||||
payload.total = libraryItems.length
|
||||
} else if (filterSeries) {
|
||||
// Book media when filtering series will include series object on media metadata
|
||||
libraryItems = libraryItems.map(li => {
|
||||
var series = li.media.metadata.getSeries(filterSeries)
|
||||
var liJson = payload.minified ? li.toJSONMinified() : li.toJSON()
|
||||
liJson.media.metadata.series = series
|
||||
return liJson
|
||||
})
|
||||
libraryItems = naturalSort(libraryItems).asc(li => li.media.metadata.series.sequence)
|
||||
} else {
|
||||
libraryItems = libraryItems.map(li => payload.minified ? li.toJSONMinified() : li.toJSON())
|
||||
}
|
||||
|
||||
// Step 3.5: Limit items
|
||||
if (payload.limit) {
|
||||
var startIndex = payload.page * payload.limit
|
||||
libraryItems = libraryItems.slice(startIndex, startIndex + payload.limit)
|
||||
}
|
||||
payload.results = libraryItems
|
||||
|
||||
// Step 4 - Transform the items to pass to the client side
|
||||
payload.results = libraryItems.map(li => {
|
||||
let json = payload.minified ? li.toJSONMinified() : li.toJSON()
|
||||
|
||||
if (li.collapsedSeries) {
|
||||
json.collapsedSeries = {
|
||||
id: li.collapsedSeries.id,
|
||||
name: li.collapsedSeries.name,
|
||||
nameIgnorePrefix: li.collapsedSeries.nameIgnorePrefix,
|
||||
libraryItemIds: li.collapsedSeries.books.map(b => b.id),
|
||||
numBooks: li.collapsedSeries.books.length
|
||||
}
|
||||
|
||||
// If collapsing by series and filtering by a series, generate the list of sequences the collapsed
|
||||
// series represents in the filtered series
|
||||
if (filterSeries) {
|
||||
json.collapsedSeries.seriesSequenceList =
|
||||
naturalSort(li.collapsedSeries.books.map(b => b.filterSeriesSequence)).asc()
|
||||
.reduce((ranges, currentSequence) => {
|
||||
let lastRange = ranges.at(-1)
|
||||
let isNumber = /^(\d+|\d+\.\d*|\d*\.\d+)$/.test(currentSequence)
|
||||
if (isNumber) currentSequence = parseFloat(currentSequence)
|
||||
|
||||
if (lastRange && isNumber && lastRange.isNumber && ((lastRange.end + 1) == currentSequence)) {
|
||||
lastRange.end = currentSequence
|
||||
}
|
||||
else {
|
||||
ranges.push({ start: currentSequence, end: currentSequence, isNumber: isNumber })
|
||||
}
|
||||
|
||||
return ranges
|
||||
}, [])
|
||||
.map(r => r.start == r.end ? r.start : `${r.start}-${r.end}`)
|
||||
.join(', ')
|
||||
}
|
||||
} else if (filterSeries) {
|
||||
// If filtering by series, make sure to include the series metadata
|
||||
json.media.metadata.series = li.media.metadata.getSeries(filterSeries)
|
||||
}
|
||||
|
||||
return json
|
||||
})
|
||||
|
||||
res.json(payload)
|
||||
}
|
||||
|
||||
|
|
@ -275,10 +358,25 @@ class LibraryController {
|
|||
minified: req.query.minified === '1'
|
||||
}
|
||||
|
||||
var series = libraryHelpers.getSeriesFromBooks(libraryItems, payload.minified)
|
||||
series = sort(series).asc(s => {
|
||||
return this.db.serverSettings.sortingIgnorePrefix ? s.nameIgnorePrefix : s.name
|
||||
})
|
||||
var series = libraryHelpers.getSeriesFromBooks(libraryItems, this.db.series, null, payload.filterBy, req.user, payload.minified)
|
||||
|
||||
const direction = payload.sortDesc ? 'desc' : 'asc'
|
||||
series = naturalSort(series).by([
|
||||
{
|
||||
[direction]: (se) => {
|
||||
if (payload.sortBy === 'numBooks') {
|
||||
return se.books.length
|
||||
} else if (payload.sortBy === 'totalDuration') {
|
||||
return se.totalDuration
|
||||
} else if (payload.sortBy === 'addedAt') {
|
||||
return se.addedAt
|
||||
} else { // sort by name
|
||||
return this.db.serverSettings.sortingIgnorePrefix ? se.nameIgnorePrefixSort : se.name
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
payload.total = series.length
|
||||
|
||||
if (payload.limit) {
|
||||
|
|
@ -485,7 +583,7 @@ class LibraryController {
|
|||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
// GET: api/scan (Root)
|
||||
// GET: api/libraries/:id/scan
|
||||
async scan(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[LibraryController] Non-root user attempted to scan library`, req.user)
|
||||
|
|
@ -499,6 +597,46 @@ class LibraryController {
|
|||
Logger.info('[LibraryController] Scan complete')
|
||||
}
|
||||
|
||||
// GET: api/libraries/:id/recent-episode
|
||||
async getRecentEpisodes(req, res) {
|
||||
if (!req.library.isPodcast) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
const payload = {
|
||||
episodes: [],
|
||||
total: 0,
|
||||
limit: req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 0,
|
||||
page: req.query.page && !isNaN(req.query.page) ? Number(req.query.page) : 0,
|
||||
}
|
||||
|
||||
var allUnfinishedEpisodes = []
|
||||
for (const libraryItem of req.libraryItems) {
|
||||
const unfinishedEpisodes = libraryItem.media.episodes.filter(ep => {
|
||||
const userProgress = req.user.getMediaProgress(libraryItem.id, ep.id)
|
||||
return !userProgress || !userProgress.isFinished
|
||||
}).map(_ep => {
|
||||
const ep = _ep.toJSONExpanded()
|
||||
ep.podcast = libraryItem.media.toJSONMinified()
|
||||
ep.libraryItemId = libraryItem.id
|
||||
ep.libraryId = libraryItem.libraryId
|
||||
return ep
|
||||
})
|
||||
allUnfinishedEpisodes.push(...unfinishedEpisodes)
|
||||
}
|
||||
|
||||
payload.total = allUnfinishedEpisodes.length
|
||||
|
||||
allUnfinishedEpisodes = sort(allUnfinishedEpisodes).desc(ep => ep.publishedAt)
|
||||
|
||||
if (payload.limit) {
|
||||
var startIndex = payload.page * payload.limit
|
||||
allUnfinishedEpisodes = allUnfinishedEpisodes.slice(startIndex, startIndex + payload.limit)
|
||||
}
|
||||
payload.episodes = allUnfinishedEpisodes
|
||||
res.json(payload)
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
if (!req.user.checkCanAccessLibrary(req.params.id)) {
|
||||
Logger.warn(`[LibraryController] Library ${req.params.id} not accessible to user ${req.user.username}`)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
const Logger = require('../Logger')
|
||||
const { reqSupportsWebp } = require('../utils/index')
|
||||
const { reqSupportsWebp, isNullOrNaN } = require('../utils/index')
|
||||
const { ScanResult } = require('../utils/constants')
|
||||
|
||||
class LibraryItemController {
|
||||
|
|
@ -305,6 +305,42 @@ class LibraryItemController {
|
|||
res.json(libraryItems)
|
||||
}
|
||||
|
||||
// POST: api/items/batch/quickmatch
|
||||
async batchQuickMatch(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.warn('User other than admin attempted to batch quick match library items', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
var itemsUpdated = 0
|
||||
var itemsUnmatched = 0
|
||||
|
||||
var matchData = req.body
|
||||
var options = matchData.options || {}
|
||||
var items = matchData.libraryItemIds
|
||||
if (!items || !items.length) {
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
res.sendStatus(200)
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
var libraryItem = this.db.libraryItems.find(_li => _li.id === items[i])
|
||||
var matchResult = await this.scanner.quickMatchLibraryItem(libraryItem, options)
|
||||
if (matchResult.updated) {
|
||||
itemsUpdated++
|
||||
} else if (matchResult.warning) {
|
||||
itemsUnmatched++
|
||||
}
|
||||
}
|
||||
|
||||
var result = {
|
||||
success: itemsUpdated > 0,
|
||||
updates: itemsUpdated,
|
||||
unmatched: itemsUnmatched
|
||||
}
|
||||
this.clientEmitter(req.user.id, 'batch_quickmatch_complete', result)
|
||||
}
|
||||
|
||||
// DELETE: api/items/all
|
||||
async deleteAll(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
|
|
@ -335,6 +371,20 @@ class LibraryItemController {
|
|||
})
|
||||
}
|
||||
|
||||
getToneMetadataObject(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[LibraryItemController] Non-root user attempted to get tone metadata object`, req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
|
||||
Logger.error(`[LibraryItemController] Invalid library item`)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
res.json(this.audioMetadataManager.getToneMetadataObjectForApi(req.libraryItem))
|
||||
}
|
||||
|
||||
// GET: api/items/:id/audio-metadata
|
||||
async updateAudioFileMetadata(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
|
|
@ -347,7 +397,9 @@ class LibraryItemController {
|
|||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
this.audioMetadataManager.updateAudioFileMetadataForItem(req.user, req.libraryItem)
|
||||
const useTone = req.query.tone === '1'
|
||||
const forceEmbedChapters = req.query.forceEmbedChapters === '1'
|
||||
this.audioMetadataManager.updateMetadataForItem(req.user, req.libraryItem, useTone, forceEmbedChapters)
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
|
|
@ -385,7 +437,7 @@ class LibraryItemController {
|
|||
async openRSSFeed(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[LibraryItemController] Non-admin user attempted to open RSS feed`, req.user.username)
|
||||
return res.sendStatus(500)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const feedData = await this.rssFeedManager.openFeedForItem(req.user, req.libraryItem, req.body)
|
||||
|
|
@ -405,7 +457,7 @@ class LibraryItemController {
|
|||
async closeRSSFeed(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[LibraryItemController] Non-admin user attempted to close RSS feed`, req.user.username)
|
||||
return res.sendStatus(500)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
await this.rssFeedManager.closeFeedForItem(req.params.id)
|
||||
|
|
@ -413,6 +465,22 @@ class LibraryItemController {
|
|||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
async toneScan(req, res) {
|
||||
if (!req.libraryItem.media.audioFiles.length) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
const audioFileIndex = isNullOrNaN(req.params.index) ? 1 : Number(req.params.index)
|
||||
const audioFile = req.libraryItem.media.audioFiles.find(af => af.index === audioFileIndex)
|
||||
if (!audioFile) {
|
||||
Logger.error(`[LibraryItemController] toneScan: Audio file not found with index ${audioFileIndex}`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
const toneData = await this.scanner.probeAudioFileWithTone(audioFile)
|
||||
res.json(toneData)
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
var item = this.db.libraryItems.find(li => li.id === req.params.id)
|
||||
if (!item || !item.media) return res.sendStatus(404)
|
||||
|
|
|
|||
|
|
@ -276,5 +276,47 @@ class MeController {
|
|||
libraryItems: itemsInProgress
|
||||
})
|
||||
}
|
||||
|
||||
// GET: api/me/series/:id/remove-from-continue-listening
|
||||
async removeSeriesFromContinueListening(req, res) {
|
||||
const series = this.db.series.find(se => se.id === req.params.id)
|
||||
if (!series) {
|
||||
Logger.error(`[MeController] removeSeriesFromContinueListening: Series ${req.params.id} not found`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
const hasUpdated = req.user.addSeriesToHideFromContinueListening(req.params.id)
|
||||
if (hasUpdated) {
|
||||
await this.db.updateEntity('user', req.user)
|
||||
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||
}
|
||||
res.json(req.user.toJSONForBrowser())
|
||||
}
|
||||
|
||||
// GET: api/me/series/:id/readd-to-continue-listening
|
||||
async readdSeriesFromContinueListening(req, res) {
|
||||
const series = this.db.series.find(se => se.id === req.params.id)
|
||||
if (!series) {
|
||||
Logger.error(`[MeController] readdSeriesFromContinueListening: Series ${req.params.id} not found`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
const hasUpdated = req.user.removeSeriesFromHideFromContinueListening(req.params.id)
|
||||
if (hasUpdated) {
|
||||
await this.db.updateEntity('user', req.user)
|
||||
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||
}
|
||||
res.json(req.user.toJSONForBrowser())
|
||||
}
|
||||
|
||||
// GET: api/me/progress/:id/remove-from-continue-listening
|
||||
async removeItemFromContinueListening(req, res) {
|
||||
const hasUpdated = req.user.removeProgressFromContinueListening(req.params.id)
|
||||
if (hasUpdated) {
|
||||
await this.db.updateEntity('user', req.user)
|
||||
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||
}
|
||||
res.json(req.user.toJSONForBrowser())
|
||||
}
|
||||
}
|
||||
module.exports = new MeController()
|
||||
|
|
@ -82,26 +82,26 @@ class MiscController {
|
|||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
// GET: api/audiobook-merge/:id
|
||||
async mergeAudiobook(req, res) {
|
||||
if (!req.user.canDownload) {
|
||||
Logger.error('User attempting to download without permission', req.user)
|
||||
// GET: api/encode-m4b/:id
|
||||
async encodeM4b(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error('[MiscController] encodeM4b: Non-admin user attempting to make m4b', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
var libraryItem = this.db.getLibraryItem(req.params.id)
|
||||
if (!libraryItem || libraryItem.isMissing || libraryItem.isInvalid) {
|
||||
Logger.error(`[MiscController] mergeAudiboook: library item not found or invalid ${req.params.id}`)
|
||||
Logger.error(`[MiscController] encodeM4b: library item not found or invalid ${req.params.id}`)
|
||||
return res.status(404).send('Audiobook not found')
|
||||
}
|
||||
|
||||
if (libraryItem.mediaType !== 'book') {
|
||||
Logger.error(`[MiscController] mergeAudiboook: Invalid library item ${req.params.id}: not a book`)
|
||||
Logger.error(`[MiscController] encodeM4b: Invalid library item ${req.params.id}: not a book`)
|
||||
return res.status(500).send('Invalid library item: not a book')
|
||||
}
|
||||
|
||||
if (libraryItem.media.tracks.length <= 0) {
|
||||
Logger.error(`[MiscController] mergeAudiboook: Invalid audiobook ${req.params.id}: no audio tracks`)
|
||||
Logger.error(`[MiscController] encodeM4b: Invalid audiobook ${req.params.id}: no audio tracks`)
|
||||
return res.status(500).send('Invalid audiobook: no audio tracks')
|
||||
}
|
||||
|
||||
|
|
@ -110,53 +110,26 @@ class MiscController {
|
|||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
// GET: api/download/:id
|
||||
async getDownload(req, res) {
|
||||
if (!req.user.canDownload) {
|
||||
Logger.error('User attempting to download without permission', req.user)
|
||||
// POST: api/encode-m4b/:id/cancel
|
||||
async cancelM4bEncode(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error('[MiscController] cancelM4bEncode: Non-admin user attempting to cancel m4b encode', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var downloadId = req.params.id
|
||||
Logger.info('Download Request', downloadId)
|
||||
var download = this.abMergeManager.getDownload(downloadId)
|
||||
if (!download) {
|
||||
Logger.error('Download request not found', downloadId)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
var options = {
|
||||
headers: {
|
||||
'Content-Type': download.mimeType
|
||||
}
|
||||
}
|
||||
res.download(download.path, download.filename, options, (err) => {
|
||||
if (err) {
|
||||
Logger.error('Download Error', err)
|
||||
}
|
||||
})
|
||||
}
|
||||
const workerTask = this.abMergeManager.getPendingTaskByLibraryItemId(req.params.id)
|
||||
if (!workerTask) return res.sendStatus(404)
|
||||
|
||||
this.abMergeManager.cancelEncode(workerTask.task)
|
||||
|
||||
// DELETE: api/download/:id
|
||||
async removeDownload(req, res) {
|
||||
if (!req.user.canDownload || !req.user.canDelete) {
|
||||
Logger.error('User attempting to remove download without permission', req.user.username)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
this.abMergeManager.removeDownloadById(req.params.id)
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
// GET: api/downloads
|
||||
async getDownloads(req, res) {
|
||||
if (!req.user.canDownload) {
|
||||
Logger.error('User attempting to get downloads without permission', req.user.username)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var downloads = {
|
||||
downloads: this.abMergeManager.downloads,
|
||||
pendingDownloads: this.abMergeManager.pendingDownloads
|
||||
}
|
||||
res.json(downloads)
|
||||
// GET: api/tasks
|
||||
getTasks(req, res) {
|
||||
res.json({
|
||||
tasks: this.taskManager.tasks.map(t => t.toJSON())
|
||||
})
|
||||
}
|
||||
|
||||
// PATCH: api/settings (admin)
|
||||
|
|
@ -185,16 +158,26 @@ class MiscController {
|
|||
})
|
||||
}
|
||||
|
||||
// POST: api/purgecache (admin)
|
||||
// POST: api/cache/purge (admin)
|
||||
async purgeCache(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
Logger.info(`[ApiRouter] Purging all cache`)
|
||||
Logger.info(`[MiscController] Purging all cache`)
|
||||
await this.cacheManager.purgeAll()
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
// POST: api/cache/items/purge
|
||||
async purgeItemsCache(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
Logger.info(`[MiscController] Purging items cache`)
|
||||
await this.cacheManager.purgeItems()
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
async findBooks(req, res) {
|
||||
var provider = req.query.provider || 'google'
|
||||
var title = req.query.title || ''
|
||||
|
|
@ -227,7 +210,8 @@ class MiscController {
|
|||
|
||||
async findChapters(req, res) {
|
||||
var asin = req.query.asin
|
||||
var chapterData = await this.bookFinder.findChapters(asin)
|
||||
var region = (req.query.region || 'us').toLowerCase()
|
||||
var chapterData = await this.bookFinder.findChapters(asin, region)
|
||||
if (!chapterData) {
|
||||
return res.json({ error: 'Chapters not found' })
|
||||
}
|
||||
|
|
|
|||
79
server/controllers/NotificationController.js
Normal file
79
server/controllers/NotificationController.js
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
const Logger = require('../Logger')
|
||||
const { version } = require('../../package.json')
|
||||
|
||||
class NotificationController {
|
||||
constructor() { }
|
||||
|
||||
get(req, res) {
|
||||
res.json({
|
||||
data: this.notificationManager.getData(),
|
||||
settings: this.db.notificationSettings
|
||||
})
|
||||
}
|
||||
|
||||
async update(req, res) {
|
||||
const updated = this.db.notificationSettings.update(req.body)
|
||||
if (updated) {
|
||||
await this.db.updateEntity('settings', this.db.notificationSettings)
|
||||
}
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
getData(req, res) {
|
||||
res.json(this.notificationManager.getData())
|
||||
}
|
||||
|
||||
async fireTestEvent(req, res) {
|
||||
await this.notificationManager.triggerNotification('onTest', { version: `v${version}` }, req.query.fail === '1')
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
async createNotification(req, res) {
|
||||
const success = this.db.notificationSettings.createNotification(req.body)
|
||||
|
||||
if (success) {
|
||||
await this.db.updateEntity('settings', this.db.notificationSettings)
|
||||
}
|
||||
res.json(this.db.notificationSettings)
|
||||
}
|
||||
|
||||
async deleteNotification(req, res) {
|
||||
if (this.db.notificationSettings.removeNotification(req.notification.id)) {
|
||||
await this.db.updateEntity('settings', this.db.notificationSettings)
|
||||
}
|
||||
res.json(this.db.notificationSettings)
|
||||
}
|
||||
|
||||
async updateNotification(req, res) {
|
||||
const success = this.db.notificationSettings.updateNotification(req.body)
|
||||
if (success) {
|
||||
await this.db.updateEntity('settings', this.db.notificationSettings)
|
||||
}
|
||||
res.json(this.db.notificationSettings)
|
||||
}
|
||||
|
||||
async sendNotificationTest(req, res) {
|
||||
if (!this.db.notificationSettings.isUseable) return res.status(500).send('Apprise is not configured')
|
||||
|
||||
const success = await this.notificationManager.sendTestNotification(req.notification)
|
||||
if (success) res.sendStatus(200)
|
||||
else res.sendStatus(500)
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
if (req.params.id) {
|
||||
const notification = this.db.notificationSettings.getNotification(req.params.id)
|
||||
if (!notification) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
req.notification = notification
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
module.exports = new NotificationController()
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
const axios = require('axios')
|
||||
const fs = require('../libs/fsExtra')
|
||||
const Path = require('path')
|
||||
const Logger = require('../Logger')
|
||||
const { parsePodcastRssFeedXml } = require('../utils/podcastUtils')
|
||||
const { getPodcastFeed, findMatchingEpisodes } = require('../utils/podcastUtils')
|
||||
const LibraryItem = require('../objects/LibraryItem')
|
||||
const { getFileTimestampsWithIno, sanitizeFilename } = require('../utils/fileUtils')
|
||||
const { getFileTimestampsWithIno } = require('../utils/fileUtils')
|
||||
const filePerms = require('../utils/filePerms')
|
||||
|
||||
class PodcastController {
|
||||
|
|
@ -91,32 +90,17 @@ class PodcastController {
|
|||
}
|
||||
}
|
||||
|
||||
getPodcastFeed(req, res) {
|
||||
async getPodcastFeed(req, res) {
|
||||
var url = req.body.rssFeed
|
||||
if (!url) {
|
||||
return res.status(400).send('Bad request')
|
||||
}
|
||||
var includeRaw = req.query.raw == 1 // Include raw json
|
||||
|
||||
axios.get(url).then(async (data) => {
|
||||
if (!data || !data.data) {
|
||||
Logger.error('Invalid podcast feed request response')
|
||||
return res.status(500).send('Bad response from feed request')
|
||||
}
|
||||
Logger.debug(`[PodcastController] Podcast feed size ${(data.data.length / 1024 / 1024).toFixed(2)}MB`)
|
||||
var payload = await parsePodcastRssFeedXml(data.data, false, includeRaw)
|
||||
if (!payload) {
|
||||
return res.status(500).send('Invalid podcast RSS feed')
|
||||
}
|
||||
|
||||
// RSS feed may be a private RSS feed
|
||||
payload.podcast.metadata.feedUrl = url
|
||||
|
||||
res.json(payload)
|
||||
}).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
res.status(500).send(error)
|
||||
})
|
||||
const podcast = await getPodcastFeed(url)
|
||||
if (!podcast) {
|
||||
return res.status(404).send('Podcast RSS feed request failed or invalid response data')
|
||||
}
|
||||
res.json({ podcast })
|
||||
}
|
||||
|
||||
async getOPMLFeeds(req, res) {
|
||||
|
|
@ -177,9 +161,7 @@ class PodcastController {
|
|||
if (!searchTitle) {
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
searchTitle = searchTitle.toLowerCase().trim()
|
||||
|
||||
const episodes = await this.podcastManager.findEpisode(rssFeedUrl, searchTitle)
|
||||
const episodes = await findMatchingEpisodes(rssFeedUrl, searchTitle)
|
||||
res.json({
|
||||
episodes: episodes || []
|
||||
})
|
||||
|
|
|
|||
|
|
@ -34,6 +34,15 @@ class SeriesController {
|
|||
res.json(series)
|
||||
}
|
||||
|
||||
async update(req, res) {
|
||||
const hasUpdated = req.series.update(req.body)
|
||||
if (hasUpdated) {
|
||||
await this.db.updateEntity('series', req.series)
|
||||
this.emitter('series_updated', req.series)
|
||||
}
|
||||
res.json(req.series)
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
var series = this.db.series.find(se => se.id === req.params.id)
|
||||
if (!series) return res.sendStatus(404)
|
||||
|
|
|
|||
|
|
@ -28,10 +28,6 @@ class UserController {
|
|||
}
|
||||
|
||||
async create(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.warn('Non-admin user attempted to create user', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var account = req.body
|
||||
|
||||
var username = account.username
|
||||
|
|
@ -58,15 +54,7 @@ class UserController {
|
|||
}
|
||||
|
||||
async update(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error('[UserController] User other than admin attempting to update user', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
var user = this.db.users.find(u => u.id === req.params.id)
|
||||
if (!user) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
var user = req.reqUser
|
||||
|
||||
if (user.type === 'root' && !req.user.isRoot) {
|
||||
Logger.error(`[UserController] Admin user attempted to update root user`, req.user.username)
|
||||
|
|
@ -97,9 +85,9 @@ class UserController {
|
|||
Logger.info(`[UserController] User ${user.username} was generated a new api token`)
|
||||
}
|
||||
await this.db.updateEntity('user', user)
|
||||
this.clientEmitter(req.user.id, 'user_updated', user.toJSONForBrowser())
|
||||
}
|
||||
|
||||
this.clientEmitter(req.user.id, 'user_updated', user.toJSONForBrowser())
|
||||
res.json({
|
||||
success: true,
|
||||
user: user.toJSONForBrowser()
|
||||
|
|
@ -107,31 +95,15 @@ class UserController {
|
|||
}
|
||||
|
||||
async delete(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error('User other than admin attempting to delete user', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
if (req.params.id === 'root') {
|
||||
Logger.error('[UserController] Attempt to delete root user. Root user cannot be deleted')
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
if (req.user.id === req.params.id) {
|
||||
Logger.error('Attempting to delete themselves...')
|
||||
Logger.error(`[UserController] ${req.user.username} is attempting to delete themselves... why? WHY?`)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
var user = this.db.users.find(u => u.id === req.params.id)
|
||||
if (!user) {
|
||||
Logger.error('User not found')
|
||||
return res.json({
|
||||
error: 'User not found'
|
||||
})
|
||||
}
|
||||
|
||||
// delete user collections
|
||||
var userCollections = this.db.collections.filter(c => c.userId === user.id)
|
||||
var collectionsToRemove = userCollections.map(uc => uc.id)
|
||||
for (let i = 0; i < collectionsToRemove.length; i++) {
|
||||
await this.db.removeEntity('collection', collectionsToRemove[i])
|
||||
}
|
||||
var user = req.reqUser
|
||||
|
||||
// Todo: check if user is logged in and cancel streams
|
||||
|
||||
|
|
@ -145,10 +117,6 @@ class UserController {
|
|||
|
||||
// GET: api/users/:id/listening-sessions
|
||||
async getListeningSessions(req, res) {
|
||||
if (!req.user.isAdminOrUp && req.user.id !== req.params.id) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
var listeningSessions = await this.getUserListeningSessionsHelper(req.params.id)
|
||||
|
||||
const itemsPerPage = toNumber(req.query.itemsPerPage, 10) || 10
|
||||
|
|
@ -170,11 +138,72 @@ class UserController {
|
|||
|
||||
// GET: api/users/:id/listening-stats
|
||||
async getListeningStats(req, res) {
|
||||
if (!req.user.isAdminOrUp && req.user.id !== req.params.id) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var listeningStats = await this.getUserListeningStatsHelpers(req.params.id)
|
||||
res.json(listeningStats)
|
||||
}
|
||||
|
||||
// POST: api/users/:id/purge-media-progress
|
||||
async purgeMediaProgress(req, res) {
|
||||
const user = req.reqUser
|
||||
|
||||
if (user.type === 'root' && !req.user.isRoot) {
|
||||
Logger.error(`[UserController] Admin user attempted to purge media progress of root user`, req.user.username)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
var progressPurged = 0
|
||||
user.mediaProgress = user.mediaProgress.filter(mp => {
|
||||
const libraryItem = this.db.libraryItems.find(li => li.id === mp.libraryItemId)
|
||||
if (!libraryItem) {
|
||||
progressPurged++
|
||||
return false
|
||||
} else if (mp.episodeId) {
|
||||
const episode = libraryItem.mediaType === 'podcast' ? libraryItem.media.getEpisode(mp.episodeId) : null
|
||||
if (!episode) { // Episode not found
|
||||
progressPurged++
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (progressPurged) {
|
||||
Logger.info(`[UserController] Purged ${progressPurged} media progress for user ${user.username}`)
|
||||
await this.db.updateEntity('user', user)
|
||||
this.clientEmitter(req.user.id, 'user_updated', user.toJSONForBrowser())
|
||||
}
|
||||
|
||||
res.json(this.userJsonWithItemProgressDetails(user, !req.user.isRoot))
|
||||
}
|
||||
|
||||
// POST: api/users/online (admin)
|
||||
async getOnlineUsers(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
const usersOnline = this.getUsersOnline()
|
||||
|
||||
res.json({
|
||||
usersOnline,
|
||||
openSessions: this.playbackSessionManager.sessions
|
||||
})
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
if (!req.user.isAdminOrUp && req.user.id !== req.params.id) {
|
||||
return res.sendStatus(403)
|
||||
} else if ((req.method == 'PATCH' || req.method == 'POST' || req.method == 'DELETE') && !req.user.isAdminOrUp) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
if (req.params.id) {
|
||||
req.reqUser = this.db.users.find(u => u.id === req.params.id)
|
||||
if (!req.reqUser) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
module.exports = new UserController()
|
||||
|
|
@ -150,8 +150,9 @@ class BookFinder {
|
|||
return this.iTunesApi.searchAudiobooks(title)
|
||||
}
|
||||
|
||||
async getAudibleResults(title, author, asin) {
|
||||
var books = await this.audible.search(title, author, asin);
|
||||
async getAudibleResults(title, author, asin, provider) {
|
||||
const region = provider.includes('.') ? provider.split('.').pop() : ''
|
||||
const books = await this.audible.search(title, author, asin, region)
|
||||
if (this.verbose) Logger.debug(`Audible Book Search Results: ${books.length || 0}`)
|
||||
if (!books) return []
|
||||
return books
|
||||
|
|
@ -165,8 +166,8 @@ class BookFinder {
|
|||
|
||||
if (provider === 'google') {
|
||||
books = await this.getGoogleBooksResults(title, author)
|
||||
} else if (provider === 'audible') {
|
||||
books = await this.getAudibleResults(title, author, asin)
|
||||
} else if (provider.startsWith('audible')) {
|
||||
books = await this.getAudibleResults(title, author, asin, provider)
|
||||
} else if (provider === 'itunes') {
|
||||
books = await this.getiTunesAudiobooksResults(title, author)
|
||||
} else if (provider === 'openlibrary') {
|
||||
|
|
@ -208,8 +209,8 @@ class BookFinder {
|
|||
return covers
|
||||
}
|
||||
|
||||
findChapters(asin) {
|
||||
return this.audnexus.getChaptersByASIN(asin)
|
||||
findChapters(asin, region) {
|
||||
return this.audnexus.getChaptersByASIN(asin, region)
|
||||
}
|
||||
}
|
||||
module.exports = BookFinder
|
||||
|
|
@ -20,7 +20,13 @@ module.exports = (function () {
|
|||
|
||||
proc.on('exit', code => { exitCode = code })
|
||||
proc.on('error', err => reject(err))
|
||||
proc.on('close', () => resolve(JSON.parse(probeData.join(''))))
|
||||
proc.on('close', () => {
|
||||
try {
|
||||
resolve(JSON.parse(probeData.join('')))
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,22 +4,30 @@ const fs = require('../libs/fsExtra')
|
|||
|
||||
const workerThreads = require('worker_threads')
|
||||
const Logger = require('../Logger')
|
||||
const Download = require('../objects/Download')
|
||||
const Task = require('../objects/Task')
|
||||
const filePerms = require('../utils/filePerms')
|
||||
const { getId } = require('../utils/index')
|
||||
const { writeConcatFile, writeMetadataFile } = require('../utils/ffmpegHelpers')
|
||||
const { getFileSize } = require('../utils/fileUtils')
|
||||
const { writeConcatFile } = require('../utils/ffmpegHelpers')
|
||||
const toneHelpers = require('../utils/toneHelpers')
|
||||
|
||||
class AbMergeManager {
|
||||
constructor(db, clientEmitter) {
|
||||
constructor(db, taskManager, clientEmitter) {
|
||||
this.db = db
|
||||
this.taskManager = taskManager
|
||||
this.clientEmitter = clientEmitter
|
||||
|
||||
this.itemsCacheDir = Path.join(global.MetadataPath, 'cache/items')
|
||||
this.downloadDirPath = Path.join(global.MetadataPath, 'downloads')
|
||||
this.downloadDirPathExist = false
|
||||
|
||||
this.pendingDownloads = []
|
||||
this.downloads = []
|
||||
this.pendingTasks = []
|
||||
}
|
||||
|
||||
getPendingTaskByLibraryItemId(libraryItemId) {
|
||||
return this.pendingTasks.find(t => t.task.data.libraryItemId === libraryItemId)
|
||||
}
|
||||
|
||||
cancelEncode(task) {
|
||||
return this.removeTask(task, true)
|
||||
}
|
||||
|
||||
async ensureDownloadDirPath() { // Creates download path if necessary and sets owner and permissions
|
||||
|
|
@ -38,85 +46,47 @@ class AbMergeManager {
|
|||
this.downloadDirPathExist = true
|
||||
}
|
||||
|
||||
getDownload(downloadId) {
|
||||
return this.downloads.find(d => d.id === downloadId)
|
||||
}
|
||||
|
||||
removeDownloadById(downloadId) {
|
||||
var download = this.getDownload(downloadId)
|
||||
if (download) {
|
||||
this.removeDownload(download)
|
||||
}
|
||||
}
|
||||
|
||||
async removeOrphanDownloads() {
|
||||
try {
|
||||
var dirs = await fs.readdir(this.downloadDirPath)
|
||||
if (!dirs || !dirs.length) return true
|
||||
|
||||
dirs = dirs.filter(d => d.startsWith('abmerge'))
|
||||
|
||||
await Promise.all(dirs.map(async (dirname) => {
|
||||
var fullPath = Path.join(this.downloadDirPath, dirname)
|
||||
Logger.info(`Removing Orphan Download ${dirname}`)
|
||||
return fs.remove(fullPath)
|
||||
}))
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async startAudiobookMerge(user, libraryItem) {
|
||||
var downloadId = getId('abmerge')
|
||||
var dlpath = Path.join(this.downloadDirPath, downloadId)
|
||||
Logger.info(`Start audiobook merge for ${libraryItem.id} - DownloadId: ${downloadId} - ${dlpath}`)
|
||||
const task = new Task()
|
||||
|
||||
var audiobookDirname = Path.basename(libraryItem.path)
|
||||
var filename = audiobookDirname + '.m4b'
|
||||
var downloadData = {
|
||||
id: downloadId,
|
||||
const audiobookDirname = Path.basename(libraryItem.path)
|
||||
const targetFilename = audiobookDirname + '.m4b'
|
||||
const itemCachePath = Path.join(this.itemsCacheDir, libraryItem.id)
|
||||
const tempFilepath = Path.join(itemCachePath, targetFilename)
|
||||
const taskData = {
|
||||
libraryItemId: libraryItem.id,
|
||||
type: 'abmerge',
|
||||
dirpath: dlpath,
|
||||
path: Path.join(dlpath, filename),
|
||||
filename,
|
||||
ext: '.m4b',
|
||||
userId: user.id
|
||||
libraryItemPath: libraryItem.path,
|
||||
userId: user.id,
|
||||
originalTrackPaths: libraryItem.media.tracks.map(t => t.metadata.path),
|
||||
tempFilepath,
|
||||
targetFilename,
|
||||
targetFilepath: Path.join(libraryItem.path, targetFilename),
|
||||
itemCachePath,
|
||||
toneJsonObject: null
|
||||
}
|
||||
var download = new Download()
|
||||
download.setData(downloadData)
|
||||
download.setTimeoutTimer(this.downloadTimedOut.bind(this))
|
||||
const taskDescription = `Encoding audiobook "${libraryItem.media.metadata.title}" into a single m4b file.`
|
||||
task.setData('encode-m4b', 'Encoding M4b', taskDescription, taskData)
|
||||
this.taskManager.addTask(task)
|
||||
Logger.info(`Start m4b encode for ${libraryItem.id} - TaskId: ${task.id}`)
|
||||
|
||||
|
||||
try {
|
||||
await fs.mkdir(download.dirpath)
|
||||
} catch (error) {
|
||||
Logger.error(`[AbMergeManager] Failed to make directory ${download.dirpath}`)
|
||||
Logger.debug(`[AbMergeManager] Make directory error: ${error}`)
|
||||
var downloadJson = download.toJSON()
|
||||
this.clientEmitter(user.id, 'abmerge_failed', downloadJson)
|
||||
return
|
||||
if (!await fs.pathExists(taskData.itemCachePath)) {
|
||||
await fs.mkdir(taskData.itemCachePath)
|
||||
}
|
||||
|
||||
this.clientEmitter(user.id, 'abmerge_started', download.toJSON())
|
||||
this.runAudiobookMerge(libraryItem, download)
|
||||
this.runAudiobookMerge(libraryItem, task)
|
||||
}
|
||||
|
||||
async runAudiobookMerge(libraryItem, download) {
|
||||
|
||||
async runAudiobookMerge(libraryItem, task) {
|
||||
// If changing audio file type then encoding is needed
|
||||
var audioTracks = libraryItem.media.tracks
|
||||
var audioRequiresEncode = audioTracks[0].metadata.ext !== download.ext
|
||||
var shouldIncludeCover = libraryItem.media.coverPath
|
||||
var audioRequiresEncode = audioTracks[0].metadata.ext !== '.m4b'
|
||||
var firstTrackIsM4b = audioTracks[0].metadata.ext.toLowerCase() === '.m4b'
|
||||
var isOneTrack = audioTracks.length === 1
|
||||
|
||||
const ffmpegInputs = []
|
||||
|
||||
if (!isOneTrack) {
|
||||
var concatFilePath = Path.join(download.dirpath, 'files.txt')
|
||||
console.log('Write files.txt', concatFilePath)
|
||||
var concatFilePath = Path.join(task.data.itemCachePath, 'files.txt')
|
||||
await writeConcatFile(audioTracks, concatFilePath)
|
||||
ffmpegInputs.push({
|
||||
input: concatFilePath,
|
||||
|
|
@ -131,53 +101,44 @@ class AbMergeManager {
|
|||
|
||||
const logLevel = process.env.NODE_ENV === 'production' ? 'error' : 'warning'
|
||||
var ffmpegOptions = [`-loglevel ${logLevel}`]
|
||||
var ffmpegOutputOptions = []
|
||||
var ffmpegOutputOptions = ['-f mp4']
|
||||
|
||||
if (audioRequiresEncode) {
|
||||
ffmpegOptions = ffmpegOptions.concat([
|
||||
'-map 0:a',
|
||||
'-acodec aac',
|
||||
'-ac 2',
|
||||
'-b:a 64k',
|
||||
'-movflags use_metadata_tags'
|
||||
'-b:a 64k'
|
||||
])
|
||||
} else {
|
||||
ffmpegOptions.push('-max_muxing_queue_size 1000')
|
||||
|
||||
if (isOneTrack && firstTrackIsM4b && !shouldIncludeCover) {
|
||||
if (isOneTrack && firstTrackIsM4b) {
|
||||
ffmpegOptions.push('-c copy')
|
||||
} else {
|
||||
ffmpegOptions.push('-c:a copy')
|
||||
}
|
||||
}
|
||||
if (download.ext === '.m4b') {
|
||||
ffmpegOutputOptions.push('-f mp4')
|
||||
|
||||
var toneJsonPath = null
|
||||
try {
|
||||
toneJsonPath = Path.join(task.data.itemCachePath, 'metadata.json')
|
||||
await toneHelpers.writeToneMetadataJsonFile(libraryItem, libraryItem.media.chapters, toneJsonPath, 1)
|
||||
} catch (error) {
|
||||
Logger.error(`[AbMergeManager] Write metadata.json failed`, error)
|
||||
toneJsonPath = null
|
||||
}
|
||||
|
||||
// Create ffmetadata file
|
||||
var metadataFilePath = Path.join(download.dirpath, 'metadata.txt')
|
||||
await writeMetadataFile(libraryItem, metadataFilePath)
|
||||
ffmpegInputs.push({
|
||||
input: metadataFilePath
|
||||
})
|
||||
ffmpegOptions.push('-map_metadata 1')
|
||||
|
||||
// Embed cover art
|
||||
if (shouldIncludeCover) {
|
||||
var coverPath = libraryItem.media.coverPath.replace(/\\/g, '/')
|
||||
ffmpegInputs.push({
|
||||
input: coverPath,
|
||||
options: ['-f image2pipe']
|
||||
})
|
||||
ffmpegOptions.push('-c:v copy')
|
||||
ffmpegOptions.push('-map 2:v')
|
||||
task.data.toneJsonObject = {
|
||||
'ToneJsonFile': toneJsonPath,
|
||||
'TrackNumber': 1,
|
||||
}
|
||||
|
||||
var workerData = {
|
||||
inputs: ffmpegInputs,
|
||||
options: ffmpegOptions,
|
||||
outputOptions: ffmpegOutputOptions,
|
||||
output: download.path,
|
||||
output: task.data.tempFilepath
|
||||
}
|
||||
|
||||
var worker = null
|
||||
|
|
@ -186,117 +147,105 @@ class AbMergeManager {
|
|||
worker = new workerThreads.Worker(workerPath, { workerData })
|
||||
} catch (error) {
|
||||
Logger.error(`[AbMergeManager] Start worker thread failed`, error)
|
||||
if (download.userId) {
|
||||
var downloadJson = download.toJSON()
|
||||
this.clientEmitter(download.userId, 'abmerge_failed', downloadJson)
|
||||
}
|
||||
this.removeDownload(download)
|
||||
task.setFailed('Failed to start worker thread')
|
||||
this.removeTask(task, true)
|
||||
return
|
||||
}
|
||||
|
||||
worker.on('message', (message) => {
|
||||
if (message != null && typeof message === 'object') {
|
||||
if (message.type === 'RESULT') {
|
||||
if (!download.isTimedOut) {
|
||||
this.sendResult(download, message)
|
||||
}
|
||||
this.sendResult(task, message)
|
||||
} else if (message.type === 'FFMPEG') {
|
||||
if (Logger[message.level]) {
|
||||
Logger[message.level](message.log)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Logger.error('Invalid worker message', message)
|
||||
}
|
||||
})
|
||||
this.pendingDownloads.push({
|
||||
id: download.id,
|
||||
download,
|
||||
this.pendingTasks.push({
|
||||
id: task.id,
|
||||
task,
|
||||
worker
|
||||
})
|
||||
}
|
||||
|
||||
async sendResult(download, result) {
|
||||
download.clearTimeoutTimer()
|
||||
|
||||
// Remove pending download
|
||||
this.pendingDownloads = this.pendingDownloads.filter(d => d.id !== download.id)
|
||||
async sendResult(task, result) {
|
||||
// Remove pending task
|
||||
this.pendingTasks = this.pendingTasks.filter(d => d.id !== task.id)
|
||||
|
||||
if (result.isKilled) {
|
||||
if (download.userId) {
|
||||
this.clientEmitter(download.userId, 'abmerge_killed', download.toJSON())
|
||||
}
|
||||
task.setFailed('Ffmpeg task killed')
|
||||
this.removeTask(task, true)
|
||||
return
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
if (download.userId) {
|
||||
this.clientEmitter(download.userId, 'abmerge_failed', download.toJSON())
|
||||
}
|
||||
this.removeDownload(download)
|
||||
task.setFailed('Encoding failed')
|
||||
this.removeTask(task, true)
|
||||
return
|
||||
}
|
||||
|
||||
// Write metadata to merged file
|
||||
const success = await toneHelpers.tagAudioFile(task.data.tempFilepath, task.data.toneJsonObject)
|
||||
if (!success) {
|
||||
Logger.error(`[AbMergeManager] Failed to write metadata to file "${task.data.tempFilepath}"`)
|
||||
task.setFailed('Failed to write metadata to m4b file')
|
||||
this.removeTask(task, true)
|
||||
return
|
||||
}
|
||||
|
||||
// Move library item tracks to cache
|
||||
for (const trackPath of task.data.originalTrackPaths) {
|
||||
const trackFilename = Path.basename(trackPath)
|
||||
const moveToPath = Path.join(task.data.itemCachePath, trackFilename)
|
||||
Logger.debug(`[AbMergeManager] Backing up original track "${trackPath}" to ${moveToPath}`)
|
||||
await fs.move(trackPath, moveToPath, { overwrite: true }).catch((err) => {
|
||||
Logger.error(`[AbMergeManager] Failed to move track "${trackPath}" to "${moveToPath}"`, err)
|
||||
})
|
||||
}
|
||||
|
||||
// Move m4b to target
|
||||
Logger.debug(`[AbMergeManager] Moving m4b from ${task.data.tempFilepath} to ${task.data.targetFilepath}`)
|
||||
await fs.move(task.data.tempFilepath, task.data.targetFilepath)
|
||||
|
||||
// Set file permissions and ownership
|
||||
await filePerms.setDefault(download.path)
|
||||
await filePerms.setDefault(task.data.targetFilepath)
|
||||
await filePerms.setDefault(task.data.itemCachePath)
|
||||
|
||||
var filesize = await getFileSize(download.path)
|
||||
download.setComplete(filesize)
|
||||
if (download.userId) {
|
||||
this.clientEmitter(download.userId, 'abmerge_ready', download.toJSON())
|
||||
}
|
||||
download.setExpirationTimer(this.downloadExpired.bind(this))
|
||||
|
||||
this.downloads.push(download)
|
||||
Logger.info(`[AbMergeManager] Download Ready ${download.id}`)
|
||||
task.setFinished()
|
||||
await this.removeTask(task, false)
|
||||
Logger.info(`[AbMergeManager] Ab task finished ${task.id}`)
|
||||
}
|
||||
|
||||
async downloadExpired(download) {
|
||||
Logger.info(`[AbMergeManager] Download ${download.id} expired`)
|
||||
|
||||
if (download.userId) {
|
||||
this.clientEmitter(download.userId, 'abmerge_expired', download.toJSON())
|
||||
}
|
||||
this.removeDownload(download)
|
||||
}
|
||||
|
||||
async downloadTimedOut(download) {
|
||||
Logger.info(`[AbMergeManager] Download ${download.id} timed out (${download.timeoutTimeMs}ms)`)
|
||||
|
||||
if (download.userId) {
|
||||
var downloadJson = download.toJSON()
|
||||
downloadJson.isTimedOut = true
|
||||
this.clientEmitter(download.userId, 'abmerge_failed', downloadJson)
|
||||
}
|
||||
this.removeDownload(download)
|
||||
}
|
||||
|
||||
async removeDownload(download) {
|
||||
Logger.info('[AbMergeManager] Removing download ' + download.id)
|
||||
|
||||
download.clearTimeoutTimer()
|
||||
download.clearExpirationTimer()
|
||||
|
||||
var pendingDl = this.pendingDownloads.find(d => d.id === download.id)
|
||||
async removeTask(task, removeTempFilepath = false) {
|
||||
Logger.info('[AbMergeManager] Removing task ' + task.id)
|
||||
|
||||
const pendingDl = this.pendingTasks.find(d => d.id === task.id)
|
||||
if (pendingDl) {
|
||||
this.pendingDownloads = this.pendingDownloads.filter(d => d.id !== download.id)
|
||||
this.pendingTasks = this.pendingTasks.filter(d => d.id !== task.id)
|
||||
Logger.warn(`[AbMergeManager] Removing download in progress - stopping worker`)
|
||||
if (pendingDl.worker) {
|
||||
try {
|
||||
pendingDl.worker.postMessage('STOP')
|
||||
return
|
||||
} catch (error) {
|
||||
Logger.error('[AbMergeManager] Error posting stop message to worker', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await fs.remove(download.dirpath).then(() => {
|
||||
Logger.info('[AbMergeManager] Deleted download', download.dirpath)
|
||||
}).catch((err) => {
|
||||
Logger.error('[AbMergeManager] Failed to delete download', err)
|
||||
})
|
||||
this.downloads = this.downloads.filter(d => d.id !== download.id)
|
||||
if (removeTempFilepath) { // On failed tasks remove the bad file if it exists
|
||||
if (await fs.pathExists(task.data.tempFilepath)) {
|
||||
await fs.remove(task.data.tempFilepath).then(() => {
|
||||
Logger.info('[AbMergeManager] Deleted target file', task.data.tempFilepath)
|
||||
}).catch((err) => {
|
||||
Logger.error('[AbMergeManager] Failed to delete target file', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
this.taskManager.taskFinished(task)
|
||||
}
|
||||
}
|
||||
module.exports = AbMergeManager
|
||||
|
|
|
|||
|
|
@ -5,15 +5,107 @@ const Logger = require('../Logger')
|
|||
const filePerms = require('../utils/filePerms')
|
||||
const { secondsToTimestamp } = require('../utils/index')
|
||||
const { writeMetadataFile } = require('../utils/ffmpegHelpers')
|
||||
const toneHelpers = require('../utils/toneHelpers')
|
||||
|
||||
class AudioMetadataMangaer {
|
||||
constructor(db, emitter, clientEmitter) {
|
||||
constructor(db, taskManager, emitter, clientEmitter) {
|
||||
this.db = db
|
||||
this.taskManager = taskManager
|
||||
this.emitter = emitter
|
||||
this.clientEmitter = clientEmitter
|
||||
}
|
||||
|
||||
async updateAudioFileMetadataForItem(user, libraryItem) {
|
||||
updateMetadataForItem(user, libraryItem, useTone, forceEmbedChapters) {
|
||||
if (useTone) {
|
||||
this.updateMetadataForItemWithTone(user, libraryItem, forceEmbedChapters)
|
||||
} else {
|
||||
this.updateMetadataForItemWithFfmpeg(user, libraryItem)
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// TONE
|
||||
//
|
||||
getToneMetadataObjectForApi(libraryItem) {
|
||||
return toneHelpers.getToneMetadataObject(libraryItem)
|
||||
}
|
||||
|
||||
async updateMetadataForItemWithTone(user, libraryItem, forceEmbedChapters) {
|
||||
var audioFiles = libraryItem.media.includedAudioFiles
|
||||
|
||||
const itemAudioMetadataPayload = {
|
||||
userId: user.id,
|
||||
libraryItemId: libraryItem.id,
|
||||
startedAt: Date.now(),
|
||||
audioFiles: audioFiles.map(af => ({ index: af.index, ino: af.ino, filename: af.metadata.filename }))
|
||||
}
|
||||
|
||||
this.emitter('audio_metadata_started', itemAudioMetadataPayload)
|
||||
|
||||
// Write chapters file
|
||||
var toneJsonPath = null
|
||||
const itemCacheDir = Path.join(global.MetadataPath, `cache/items/${libraryItem.id}`)
|
||||
await fs.ensureDir(itemCacheDir)
|
||||
|
||||
try {
|
||||
toneJsonPath = Path.join(itemCacheDir, 'metadata.json')
|
||||
const chapters = (audioFiles.length == 1 || forceEmbedChapters) ? libraryItem.media.chapters : null
|
||||
await toneHelpers.writeToneMetadataJsonFile(libraryItem, chapters, toneJsonPath, audioFiles.length)
|
||||
} catch (error) {
|
||||
Logger.error(`[AudioMetadataManager] Write metadata.json failed`, error)
|
||||
toneJsonPath = null
|
||||
}
|
||||
|
||||
const results = []
|
||||
for (const af of audioFiles) {
|
||||
const result = await this.updateAudioFileMetadataWithTone(libraryItem.id, af, toneJsonPath, itemCacheDir)
|
||||
results.push(result)
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - itemAudioMetadataPayload.startedAt
|
||||
Logger.debug(`[AudioMetadataManager] Elapsed ${secondsToTimestamp(elapsed)}`)
|
||||
itemAudioMetadataPayload.results = results
|
||||
itemAudioMetadataPayload.elapsed = elapsed
|
||||
itemAudioMetadataPayload.finishedAt = Date.now()
|
||||
this.emitter('audio_metadata_finished', itemAudioMetadataPayload)
|
||||
}
|
||||
|
||||
async updateAudioFileMetadataWithTone(libraryItemId, audioFile, toneJsonPath, itemCacheDir) {
|
||||
const resultPayload = {
|
||||
libraryItemId,
|
||||
index: audioFile.index,
|
||||
ino: audioFile.ino,
|
||||
filename: audioFile.metadata.filename
|
||||
}
|
||||
this.emitter('audiofile_metadata_started', resultPayload)
|
||||
|
||||
// Backup audio file
|
||||
try {
|
||||
const backupFilePath = Path.join(itemCacheDir, audioFile.metadata.filename)
|
||||
await fs.copy(audioFile.metadata.path, backupFilePath)
|
||||
Logger.debug(`[AudioMetadataManager] Backed up audio file at "${backupFilePath}"`)
|
||||
} catch (err) {
|
||||
Logger.error(`[AudioMetadataManager] Failed to backup audio file "${audioFile.metadata.path}"`, err)
|
||||
}
|
||||
|
||||
const _toneMetadataObject = {
|
||||
'ToneJsonFile': toneJsonPath,
|
||||
'TrackNumber': audioFile.index,
|
||||
}
|
||||
|
||||
resultPayload.success = await toneHelpers.tagAudioFile(audioFile.metadata.path, _toneMetadataObject)
|
||||
if (resultPayload.success) {
|
||||
Logger.info(`[AudioMetadataManager] Successfully tagged audio file "${audioFile.metadata.path}"`)
|
||||
}
|
||||
|
||||
this.emitter('audiofile_metadata_finished', resultPayload)
|
||||
return resultPayload
|
||||
}
|
||||
|
||||
//
|
||||
// FFMPEG
|
||||
//
|
||||
async updateMetadataForItemWithFfmpeg(user, libraryItem) {
|
||||
var audioFiles = libraryItem.media.audioFiles
|
||||
|
||||
const itemAudioMetadataPayload = {
|
||||
|
|
@ -36,9 +128,8 @@ class AudioMetadataMangaer {
|
|||
var coverPath = libraryItem.media.coverPath.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
// TODO: Split into batches
|
||||
const proms = audioFiles.map(af => {
|
||||
return this.updateAudioFileMetadata(libraryItem.id, af, outputDir, metadataFilePath, coverPath)
|
||||
return this.updateAudioFileMetadataWithFfmpeg(libraryItem.id, af, outputDir, metadataFilePath, coverPath)
|
||||
})
|
||||
|
||||
const results = await Promise.all(proms)
|
||||
|
|
@ -55,7 +146,7 @@ class AudioMetadataMangaer {
|
|||
this.emitter('audio_metadata_finished', itemAudioMetadataPayload)
|
||||
}
|
||||
|
||||
updateAudioFileMetadata(libraryItemId, audioFile, outputDir, metadataFilePath, coverPath = '') {
|
||||
updateAudioFileMetadataWithFfmpeg(libraryItemId, audioFile, outputDir, metadataFilePath, coverPath = '') {
|
||||
return new Promise((resolve) => {
|
||||
const resultPayload = {
|
||||
libraryItemId,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ class CacheManager {
|
|||
this.CachePath = Path.join(global.MetadataPath, 'cache')
|
||||
this.CoverCachePath = Path.join(this.CachePath, 'covers')
|
||||
this.ImageCachePath = Path.join(this.CachePath, 'images')
|
||||
this.ItemCachePath = Path.join(this.CachePath, 'items')
|
||||
}
|
||||
|
||||
async ensureCachePaths() { // Creates cache paths if necessary and sets owner and permissions
|
||||
|
|
@ -29,6 +30,11 @@ class CacheManager {
|
|||
pathsCreated = true
|
||||
}
|
||||
|
||||
if (!(await fs.pathExists(this.ItemCachePath))) {
|
||||
await fs.mkdir(this.ItemCachePath)
|
||||
pathsCreated = true
|
||||
}
|
||||
|
||||
if (pathsCreated) {
|
||||
await filePerms.setDefault(this.CachePath)
|
||||
}
|
||||
|
|
@ -50,18 +56,18 @@ class CacheManager {
|
|||
stream.pipeline(r, ps, (err) => {
|
||||
if (err) {
|
||||
console.log(err)
|
||||
return res.sendStatus(400)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
})
|
||||
return ps.pipe(res)
|
||||
}
|
||||
|
||||
if (!libraryItem.media.coverPath || !await fs.pathExists(libraryItem.media.coverPath)) {
|
||||
return res.sendStatus(404)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
let writtenFile = await resizeImage(libraryItem.media.coverPath, path, width, height)
|
||||
if (!writtenFile) return res.sendStatus(400)
|
||||
if (!writtenFile) return res.sendStatus(500)
|
||||
|
||||
// Set owner and permissions of cache image
|
||||
await filePerms.setDefault(path)
|
||||
|
|
@ -108,6 +114,15 @@ class CacheManager {
|
|||
await this.ensureCachePaths()
|
||||
}
|
||||
|
||||
async purgeItems() {
|
||||
if (await fs.pathExists(this.ItemCachePath)) {
|
||||
await fs.remove(this.ItemCachePath).catch((error) => {
|
||||
Logger.error(`[CacheManager] Failed to remove items cache dir "${this.ItemCachePath}"`, error)
|
||||
})
|
||||
}
|
||||
await this.ensureCachePaths()
|
||||
}
|
||||
|
||||
async handleAuthorCache(res, author, options = {}) {
|
||||
const format = options.format || 'webp'
|
||||
const width = options.width || 400
|
||||
|
|
@ -124,14 +139,14 @@ class CacheManager {
|
|||
stream.pipeline(r, ps, (err) => {
|
||||
if (err) {
|
||||
console.log(err)
|
||||
return res.sendStatus(400)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
})
|
||||
return ps.pipe(res)
|
||||
}
|
||||
|
||||
let writtenFile = await resizeImage(author.imagePath, path, width, height)
|
||||
if (!writtenFile) return res.sendStatus(400)
|
||||
if (!writtenFile) return res.sendStatus(500)
|
||||
|
||||
// Set owner and permissions of cache image
|
||||
await filePerms.setDefault(path)
|
||||
|
|
|
|||
|
|
@ -25,22 +25,6 @@ class DownloadManager {
|
|||
return this.downloads.find(d => d.id === downloadId)
|
||||
}
|
||||
|
||||
async removeOrphanDownloads() {
|
||||
try {
|
||||
var dirs = await fs.readdir(this.downloadDirPath)
|
||||
if (!dirs || !dirs.length) return true
|
||||
|
||||
await Promise.all(dirs.map(async (dirname) => {
|
||||
var fullPath = Path.join(this.downloadDirPath, dirname)
|
||||
Logger.info(`Removing Orphan Download ${dirname}`)
|
||||
return fs.remove(fullPath)
|
||||
}))
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
downloadSocketRequest(socket, payload) {
|
||||
var client = socket.sheepClient
|
||||
var audiobook = this.db.audiobooks.find(a => a.id === payload.audiobookId)
|
||||
|
|
|
|||
113
server/managers/NotificationManager.js
Normal file
113
server/managers/NotificationManager.js
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
const axios = require('axios')
|
||||
const Logger = require("../Logger")
|
||||
const { notificationData } = require('../utils/notifications')
|
||||
|
||||
class NotificationManager {
|
||||
constructor(db, emitter) {
|
||||
this.db = db
|
||||
this.emitter = emitter
|
||||
|
||||
this.sendingNotification = false
|
||||
this.notificationQueue = []
|
||||
}
|
||||
|
||||
getData() {
|
||||
return notificationData
|
||||
}
|
||||
|
||||
onPodcastEpisodeDownloaded(libraryItem, episode) {
|
||||
if (!this.db.notificationSettings.isUseable) return
|
||||
|
||||
Logger.debug(`[NotificationManager] onPodcastEpisodeDownloaded: Episode "${episode.title}" for podcast ${libraryItem.media.metadata.title}`)
|
||||
const library = this.db.libraries.find(lib => lib.id === libraryItem.libraryId)
|
||||
const eventData = {
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryId: libraryItem.libraryId,
|
||||
libraryName: library ? library.name : 'Unknown',
|
||||
podcastTitle: libraryItem.media.metadata.title,
|
||||
episodeId: episode.id,
|
||||
episodeTitle: episode.title
|
||||
}
|
||||
this.triggerNotification('onPodcastEpisodeDownloaded', eventData)
|
||||
}
|
||||
|
||||
onTest() {
|
||||
this.triggerNotification('onTest')
|
||||
}
|
||||
|
||||
async triggerNotification(eventName, eventData, intentionallyFail = false) {
|
||||
if (!this.db.notificationSettings.isUseable) return
|
||||
|
||||
// Will queue the notification if sendingNotification and queue is not full
|
||||
if (!this.checkTriggerNotification(eventName, eventData)) return
|
||||
|
||||
const notifications = this.db.notificationSettings.getActiveNotificationsForEvent(eventName)
|
||||
for (const notification of notifications) {
|
||||
Logger.debug(`[NotificationManager] triggerNotification: Sending ${eventName} notification ${notification.id}`)
|
||||
const success = intentionallyFail ? false : await this.sendNotification(notification, eventData)
|
||||
|
||||
notification.updateNotificationFired(success)
|
||||
if (!success) { // Failed notification
|
||||
if (notification.numConsecutiveFailedAttempts >= this.db.notificationSettings.maxFailedAttempts) {
|
||||
Logger.error(`[NotificationManager] triggerNotification: ${notification.eventName}/${notification.id} reached max failed attempts`)
|
||||
notification.enabled = false
|
||||
} else {
|
||||
Logger.error(`[NotificationManager] triggerNotification: ${notification.eventName}/${notification.id} ${notification.numConsecutiveFailedAttempts} failed attempts`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.db.updateEntity('settings', this.db.notificationSettings)
|
||||
this.emitter('notifications_updated', this.db.notificationSettings)
|
||||
|
||||
this.notificationFinished()
|
||||
}
|
||||
|
||||
// Return TRUE if notification should be triggered now
|
||||
checkTriggerNotification(eventName, eventData) {
|
||||
if (this.sendingNotification) {
|
||||
if (this.notificationQueue.length >= this.db.notificationSettings.maxNotificationQueue) {
|
||||
Logger.warn(`[NotificationManager] Notification queue is full - ignoring event ${eventName}`)
|
||||
} else {
|
||||
Logger.debug(`[NotificationManager] Queueing notification ${eventName} (Queue size: ${this.notificationQueue.length})`)
|
||||
this.notificationQueue.push({ eventName, eventData })
|
||||
}
|
||||
return false
|
||||
}
|
||||
this.sendingNotification = true
|
||||
return true
|
||||
}
|
||||
|
||||
notificationFinished() {
|
||||
// Delay between events then run next notification in queue
|
||||
setTimeout(() => {
|
||||
this.sendingNotification = false
|
||||
if (this.notificationQueue.length) { // Send next notification in queue
|
||||
const nextNotificationEvent = this.notificationQueue.shift()
|
||||
this.triggerNotification(nextNotificationEvent.eventName, nextNotificationEvent.eventData)
|
||||
}
|
||||
}, this.db.notificationSettings.notificationDelay)
|
||||
}
|
||||
|
||||
sendTestNotification(notification) {
|
||||
const eventData = notificationData.events.find(e => e.name === notification.eventName)
|
||||
if (!eventData) {
|
||||
Logger.error(`[NotificationManager] sendTestNotification: Event not found ${notification.eventName}`)
|
||||
return false
|
||||
}
|
||||
|
||||
return this.sendNotification(notification, eventData.testData)
|
||||
}
|
||||
|
||||
sendNotification(notification, eventData) {
|
||||
const payload = notification.getApprisePayload(eventData)
|
||||
return axios.post(this.db.notificationSettings.appriseApiUrl, payload, { timeout: 6000 }).then((response) => {
|
||||
Logger.debug(`[NotificationManager] sendNotification: ${notification.eventName}/${notification.id} response=`, response.data)
|
||||
return true
|
||||
}).catch((error) => {
|
||||
Logger.error(`[NotificationManager] sendNotification: ${notification.eventName}/${notification.id} error=`, error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
}
|
||||
module.exports = NotificationManager
|
||||
|
|
@ -61,13 +61,13 @@ class PlaybackSessionManager {
|
|||
async syncLocalSessionRequest(user, sessionJson, res) {
|
||||
if (this.localSessionLock[sessionJson.id]) {
|
||||
Logger.debug(`[PlaybackSessionManager] syncLocalSessionRequest: Local session is locked and already syncing`)
|
||||
return res.sendStatus(200)
|
||||
return res.status(500).send('Local session is locked and already syncing')
|
||||
}
|
||||
|
||||
var libraryItem = this.db.getLibraryItem(sessionJson.libraryItemId)
|
||||
if (!libraryItem) {
|
||||
Logger.error(`[PlaybackSessionManager] syncLocalSessionRequest: Library item not found for session "${sessionJson.libraryItemId}"`)
|
||||
return res.sendStatus(200)
|
||||
return res.status(500).send('Library item not found')
|
||||
}
|
||||
|
||||
this.localSessionLock[sessionJson.id] = true // Lock local session
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
const fs = require('../libs/fsExtra')
|
||||
const axios = require('axios')
|
||||
|
||||
const { parsePodcastRssFeedXml } = require('../utils/podcastUtils')
|
||||
const { getPodcastFeed } = require('../utils/podcastUtils')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
const { downloadFile, removeFile } = require('../utils/fileUtils')
|
||||
const filePerms = require('../utils/filePerms')
|
||||
const { levenshteinDistance } = require('../utils/index')
|
||||
const opmlParser = require('../utils/parsers/parseOPML')
|
||||
const prober = require('../utils/prober')
|
||||
|
|
@ -14,10 +14,11 @@ const PodcastEpisode = require('../objects/entities/PodcastEpisode')
|
|||
const AudioFile = require('../objects/files/AudioFile')
|
||||
|
||||
class PodcastManager {
|
||||
constructor(db, watcher, emitter) {
|
||||
constructor(db, watcher, emitter, notificationManager) {
|
||||
this.db = db
|
||||
this.watcher = watcher
|
||||
this.emitter = emitter
|
||||
this.notificationManager = notificationManager
|
||||
|
||||
this.downloadQueue = []
|
||||
this.currentDownload = null
|
||||
|
|
@ -72,6 +73,13 @@ class PodcastManager {
|
|||
// Ignores all added files to this dir
|
||||
this.watcher.addIgnoreDir(this.currentDownload.libraryItem.path)
|
||||
|
||||
// Make sure podcast library item folder exists
|
||||
if (!(await fs.pathExists(this.currentDownload.libraryItem.path))) {
|
||||
Logger.warn(`[PodcastManager] Podcast episode download: Podcast folder no longer exists at "${this.currentDownload.libraryItem.path}" - Creating it`)
|
||||
await fs.mkdir(this.currentDownload.libraryItem.path)
|
||||
await filePerms.setDefault(this.currentDownload.libraryItem.path)
|
||||
}
|
||||
|
||||
var success = await downloadFile(this.currentDownload.url, this.currentDownload.targetPath).then(() => true).catch((error) => {
|
||||
Logger.error(`[PodcastManager] Podcast Episode download failed`, error)
|
||||
return false
|
||||
|
|
@ -123,8 +131,8 @@ class PodcastManager {
|
|||
}
|
||||
libraryItem.libraryFiles.push(libraryFile)
|
||||
|
||||
// Check setting maxEpisodesToKeep and remove episode if necessary
|
||||
if (this.currentDownload.isAutoDownload) { // only applies for auto-downloaded episodes
|
||||
if (this.currentDownload.isAutoDownload) {
|
||||
// Check setting maxEpisodesToKeep and remove episode if necessary
|
||||
if (libraryItem.media.maxEpisodesToKeep && libraryItem.media.episodesWithPubDate.length > libraryItem.media.maxEpisodesToKeep) {
|
||||
Logger.info(`[PodcastManager] # of episodes (${libraryItem.media.episodesWithPubDate.length}) exceeds max episodes to keep (${libraryItem.media.maxEpisodesToKeep})`)
|
||||
await this.removeOldestEpisode(libraryItem, podcastEpisode.id)
|
||||
|
|
@ -134,6 +142,11 @@ class PodcastManager {
|
|||
libraryItem.updatedAt = Date.now()
|
||||
await this.db.updateLibraryItem(libraryItem)
|
||||
this.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
|
||||
if (this.currentDownload.isAutoDownload) { // Notifications only for auto downloaded episodes
|
||||
this.notificationManager.onPodcastEpisodeDownloaded(libraryItem, podcastEpisode)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -191,7 +204,7 @@ class PodcastManager {
|
|||
const dateToCheckForEpisodesAfter = latestEpisodePublishedAt || lastEpisodeCheckDate
|
||||
Logger.debug(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.metadata.title}" checking for episodes after ${new Date(dateToCheckForEpisodesAfter)}`)
|
||||
|
||||
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, dateToCheckForEpisodesAfter)
|
||||
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, dateToCheckForEpisodesAfter, libraryItem.media.maxNewEpisodesToDownload)
|
||||
Logger.debug(`[PodcastManager] runEpisodeCheck: ${newEpisodes ? newEpisodes.length : 'N/A'} episodes found`)
|
||||
|
||||
if (!newEpisodes) { // Failed
|
||||
|
|
@ -226,7 +239,7 @@ class PodcastManager {
|
|||
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`)
|
||||
return false
|
||||
}
|
||||
var feed = await this.getPodcastFeed(podcastLibraryItem.media.metadata.feedUrl)
|
||||
var feed = await getPodcastFeed(podcastLibraryItem.media.metadata.feedUrl)
|
||||
if (!feed || !feed.episodes) {
|
||||
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`, feed)
|
||||
return false
|
||||
|
|
@ -262,7 +275,7 @@ class PodcastManager {
|
|||
}
|
||||
|
||||
async findEpisode(rssFeedUrl, searchTitle) {
|
||||
const feed = await this.getPodcastFeed(rssFeedUrl).catch(() => {
|
||||
const feed = await getPodcastFeed(rssFeedUrl).catch(() => {
|
||||
return null
|
||||
})
|
||||
if (!feed || !feed.episodes) {
|
||||
|
|
@ -292,25 +305,6 @@ class PodcastManager {
|
|||
return matches.sort((a, b) => a.levenshtein - b.levenshtein)
|
||||
}
|
||||
|
||||
getPodcastFeed(feedUrl, excludeEpisodeMetadata = false) {
|
||||
Logger.debug(`[PodcastManager] getPodcastFeed for "${feedUrl}"`)
|
||||
return axios.get(feedUrl, { timeout: 5000 }).then(async (data) => {
|
||||
if (!data || !data.data) {
|
||||
Logger.error('Invalid podcast feed request response')
|
||||
return false
|
||||
}
|
||||
Logger.debug(`[PodcastManager] getPodcastFeed for "${feedUrl}" success - parsing xml`)
|
||||
var payload = await parsePodcastRssFeedXml(data.data, excludeEpisodeMetadata)
|
||||
if (!payload) {
|
||||
return false
|
||||
}
|
||||
return payload.podcast
|
||||
}).catch((error) => {
|
||||
Logger.error('[PodcastManager] getPodcastFeed Error', error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
async getOPMLFeeds(opmlText) {
|
||||
var extractedFeeds = opmlParser.parse(opmlText)
|
||||
if (!extractedFeeds || !extractedFeeds.length) {
|
||||
|
|
@ -323,7 +317,7 @@ class PodcastManager {
|
|||
var rssFeedData = []
|
||||
|
||||
for (let feed of extractedFeeds) {
|
||||
var feedData = await this.getPodcastFeed(feed.feedUrl, true)
|
||||
var feedData = await getPodcastFeed(feed.feedUrl, true)
|
||||
if (feedData) {
|
||||
feedData.metadata.feedUrl = feed.feedUrl
|
||||
rssFeedData.push(feedData)
|
||||
|
|
|
|||
20
server/managers/TaskManager.js
Normal file
20
server/managers/TaskManager.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
class TaskManager {
|
||||
constructor(emitter) {
|
||||
this.emitter = emitter
|
||||
|
||||
this.tasks = []
|
||||
}
|
||||
|
||||
addTask(task) {
|
||||
this.tasks.push(task)
|
||||
this.emitter('task_started', task.toJSON())
|
||||
}
|
||||
|
||||
taskFinished(task) {
|
||||
if (this.tasks.some(t => t.id === task.id)) {
|
||||
this.tasks = this.tasks.filter(t => t.id !== task.id)
|
||||
this.emitter('task_finished', task.toJSON())
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = TaskManager
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
const Logger = require('../Logger')
|
||||
const { getId } = require('../utils/index')
|
||||
|
||||
class UserCollection {
|
||||
class Collection {
|
||||
constructor(collection) {
|
||||
this.id = null
|
||||
this.libraryId = null
|
||||
|
|
@ -63,7 +63,7 @@ class UserCollection {
|
|||
if (!data.userId || !data.libraryId || !data.name) {
|
||||
return false
|
||||
}
|
||||
this.id = getId('usr')
|
||||
this.id = getId('col')
|
||||
this.userId = data.userId
|
||||
this.libraryId = data.libraryId
|
||||
this.name = data.name
|
||||
|
|
@ -105,4 +105,4 @@ class UserCollection {
|
|||
return hasUpdates
|
||||
}
|
||||
}
|
||||
module.exports = UserCollection
|
||||
module.exports = Collection
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
const { AudioMimeType } = require('../utils/constants')
|
||||
const { getAudioMimeTypeFromExtname } = require('../utils/fileUtils')
|
||||
const DEFAULT_EXPIRATION = 1000 * 60 * 60 // 60 minutes
|
||||
const DEFAULT_TIMEOUT = 1000 * 60 * 30 // 30 minutes
|
||||
|
||||
class Download {
|
||||
constructor(download) {
|
||||
this.id = null
|
||||
this.libraryItemId = null
|
||||
this.type = null
|
||||
|
||||
this.dirpath = null
|
||||
this.path = null
|
||||
this.ext = null
|
||||
this.filename = null
|
||||
this.size = 0
|
||||
|
||||
this.userId = null
|
||||
this.isReady = false
|
||||
this.isTimedOut = false
|
||||
|
||||
this.startedAt = null
|
||||
this.finishedAt = null
|
||||
this.expiresAt = null
|
||||
|
||||
this.expirationTimeMs = 0
|
||||
this.timeoutTimeMs = 0
|
||||
|
||||
this.timeoutTimer = null
|
||||
this.expirationTimer = null
|
||||
|
||||
if (download) {
|
||||
this.construct(download)
|
||||
}
|
||||
}
|
||||
|
||||
get mimeType() {
|
||||
return getAudioMimeTypeFromExtname(this.ext) || AudioMimeType.MP3
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
libraryItemId: this.libraryItemId,
|
||||
type: this.type,
|
||||
dirpath: this.dirpath,
|
||||
path: this.path,
|
||||
ext: this.ext,
|
||||
filename: this.filename,
|
||||
size: this.size,
|
||||
userId: this.userId,
|
||||
isReady: this.isReady,
|
||||
startedAt: this.startedAt,
|
||||
finishedAt: this.finishedAt,
|
||||
expirationSeconds: this.expirationSeconds
|
||||
}
|
||||
}
|
||||
|
||||
construct(download) {
|
||||
this.id = download.id
|
||||
this.libraryItemId = download.libraryItemId
|
||||
this.type = download.type
|
||||
|
||||
this.dirpath = download.dirpath
|
||||
this.path = download.path
|
||||
this.ext = download.ext
|
||||
this.filename = download.filename
|
||||
this.size = download.size || 0
|
||||
|
||||
this.userId = download.userId
|
||||
this.isReady = !!download.isReady
|
||||
|
||||
this.startedAt = download.startedAt
|
||||
this.finishedAt = download.finishedAt || null
|
||||
|
||||
this.expirationTimeMs = download.expirationTimeMs || DEFAULT_EXPIRATION
|
||||
this.timeoutTimeMs = download.timeoutTimeMs || DEFAULT_TIMEOUT
|
||||
|
||||
this.expiresAt = download.expiresAt || null
|
||||
}
|
||||
|
||||
setData(downloadData) {
|
||||
downloadData.startedAt = Date.now()
|
||||
downloadData.isProcessing = true
|
||||
this.construct(downloadData)
|
||||
}
|
||||
|
||||
setComplete(fileSize) {
|
||||
this.finishedAt = Date.now()
|
||||
this.size = fileSize
|
||||
this.isReady = true
|
||||
this.expiresAt = this.finishedAt + this.expirationTimeMs
|
||||
}
|
||||
|
||||
setExpirationTimer(callback) {
|
||||
this.expirationTimer = setTimeout(() => {
|
||||
if (callback) {
|
||||
callback(this)
|
||||
}
|
||||
}, this.expirationTimeMs)
|
||||
}
|
||||
|
||||
setTimeoutTimer(callback) {
|
||||
this.timeoutTimer = setTimeout(() => {
|
||||
if (callback) {
|
||||
this.isTimedOut = true
|
||||
callback(this)
|
||||
}
|
||||
}, this.timeoutTimeMs)
|
||||
}
|
||||
|
||||
clearTimeoutTimer() {
|
||||
clearTimeout(this.timeoutTimer)
|
||||
}
|
||||
|
||||
clearExpirationTimer() {
|
||||
clearTimeout(this.expirationTimer)
|
||||
}
|
||||
}
|
||||
module.exports = Download
|
||||
|
|
@ -8,7 +8,7 @@ class Library {
|
|||
this.name = null
|
||||
this.folders = []
|
||||
this.displayOrder = 1
|
||||
this.icon = 'database' // database, podcast, book, audiobook, comic
|
||||
this.icon = 'database'
|
||||
this.mediaType = 'book' // book, podcast
|
||||
this.provider = 'google'
|
||||
|
||||
|
|
@ -46,14 +46,15 @@ class Library {
|
|||
|
||||
this.createdAt = library.createdAt
|
||||
this.lastUpdate = library.lastUpdate
|
||||
this.cleanOldValues() // mediaType changed for v2
|
||||
this.cleanOldValues() // mediaType changed for v2 and icon change for v2.2.2
|
||||
}
|
||||
|
||||
cleanOldValues() {
|
||||
var availableIcons = ['database', 'audiobook', 'book', 'comic', 'podcast']
|
||||
const availableIcons = ['database', 'audiobookshelf', 'books-1', 'books-2', 'book-1', 'microphone-1', 'microphone-3', 'radio', 'podcast', 'rss', 'headphones', 'music', 'file-picture', 'rocket', 'power', 'star', 'heart']
|
||||
if (!availableIcons.includes(this.icon)) {
|
||||
if (this.icon === 'default') this.icon = 'database'
|
||||
else if (this.icon.endsWith('s') && availableIcons.includes(this.icon.slice(0, -1))) this.icon = this.icon.slice(0, -1)
|
||||
if (this.icon === 'audiobook') this.icon = 'audiobookshelf'
|
||||
else if (this.icon === 'book') this.icon = 'books-1'
|
||||
else if (this.icon === 'comic') this.icon = 'file-picture'
|
||||
else this.icon = 'database'
|
||||
}
|
||||
if (!this.mediaType || (this.mediaType !== 'podcast' && this.mediaType !== 'book' && this.mediaType !== 'video')) {
|
||||
|
|
|
|||
|
|
@ -246,6 +246,7 @@ class LibraryItem {
|
|||
|
||||
setLastScan() {
|
||||
this.lastScan = Date.now()
|
||||
this.updatedAt = Date.now()
|
||||
this.scanVersion = version
|
||||
}
|
||||
|
||||
|
|
|
|||
133
server/objects/Notification.js
Normal file
133
server/objects/Notification.js
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
const { getId } = require('../utils/index')
|
||||
|
||||
class Notification {
|
||||
constructor(notification = null) {
|
||||
this.id = null
|
||||
this.libraryId = null
|
||||
this.eventName = ''
|
||||
this.urls = []
|
||||
this.titleTemplate = ''
|
||||
this.bodyTemplate = ''
|
||||
this.type = 'info'
|
||||
this.enabled = false
|
||||
|
||||
this.lastFiredAt = null
|
||||
this.lastAttemptFailed = false
|
||||
this.numConsecutiveFailedAttempts = 0
|
||||
this.numTimesFired = 0
|
||||
this.createdAt = null
|
||||
|
||||
if (notification) {
|
||||
this.construct(notification)
|
||||
}
|
||||
}
|
||||
|
||||
construct(notification) {
|
||||
this.id = notification.id
|
||||
this.libraryId = notification.libraryId || null
|
||||
this.eventName = notification.eventName
|
||||
this.urls = notification.urls || []
|
||||
this.titleTemplate = notification.titleTemplate || ''
|
||||
this.bodyTemplate = notification.bodyTemplate || ''
|
||||
this.type = notification.type || 'info'
|
||||
this.enabled = !!notification.enabled
|
||||
this.lastFiredAt = notification.lastFiredAt || null
|
||||
this.lastAttemptFailed = !!notification.lastAttemptFailed
|
||||
this.numConsecutiveFailedAttempts = notification.numConsecutiveFailedAttempts || 0
|
||||
this.numTimesFired = notification.numTimesFired || 0
|
||||
this.createdAt = notification.createdAt
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
libraryId: this.libraryId,
|
||||
eventName: this.eventName,
|
||||
urls: this.urls,
|
||||
titleTemplate: this.titleTemplate,
|
||||
bodyTemplate: this.bodyTemplate,
|
||||
enabled: this.enabled,
|
||||
type: this.type,
|
||||
lastFiredAt: this.lastFiredAt,
|
||||
lastAttemptFailed: this.lastAttemptFailed,
|
||||
numConsecutiveFailedAttempts: this.numConsecutiveFailedAttempts,
|
||||
numTimesFired: this.numTimesFired,
|
||||
createdAt: this.createdAt
|
||||
}
|
||||
}
|
||||
|
||||
setData(payload) {
|
||||
this.id = getId('noti')
|
||||
this.libraryId = payload.libraryId || null
|
||||
this.eventName = payload.eventName
|
||||
this.urls = payload.urls
|
||||
this.titleTemplate = payload.titleTemplate
|
||||
this.bodyTemplate = payload.bodyTemplate
|
||||
this.enabled = !!payload.enabled
|
||||
this.type = payload.type || null
|
||||
this.createdAt = Date.now()
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
if (!this.enabled && payload.enabled) {
|
||||
// Reset
|
||||
this.lastFiredAt = null
|
||||
this.lastAttemptFailed = false
|
||||
this.numConsecutiveFailedAttempts = 0
|
||||
}
|
||||
|
||||
const keysToUpdate = ['libraryId', 'eventName', 'urls', 'titleTemplate', 'bodyTemplate', 'enabled', 'type']
|
||||
var hasUpdated = false
|
||||
for (const key of keysToUpdate) {
|
||||
if (payload[key] !== undefined) {
|
||||
if (key === 'urls') {
|
||||
if (payload[key].join(',') !== this.urls.join(',')) {
|
||||
this.urls = [...payload[key]]
|
||||
hasUpdated = true
|
||||
}
|
||||
} else if (payload[key] !== this[key]) {
|
||||
this[key] = payload[key]
|
||||
hasUpdated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return hasUpdated
|
||||
}
|
||||
|
||||
updateNotificationFired(success) {
|
||||
this.lastFiredAt = Date.now()
|
||||
this.lastAttemptFailed = !success
|
||||
this.numConsecutiveFailedAttempts = success ? 0 : this.numConsecutiveFailedAttempts + 1
|
||||
this.numTimesFired++
|
||||
}
|
||||
|
||||
replaceVariablesInTemplate(templateText, data) {
|
||||
const ptrn = /{{ ?([a-zA-Z]+) ?}}/mg
|
||||
|
||||
var match
|
||||
var updatedTemplate = templateText
|
||||
while ((match = ptrn.exec(templateText)) != null) {
|
||||
if (data[match[1]]) {
|
||||
updatedTemplate = updatedTemplate.replace(match[0], data[match[1]])
|
||||
}
|
||||
}
|
||||
return updatedTemplate
|
||||
}
|
||||
|
||||
parseTitleTemplate(data) {
|
||||
return this.replaceVariablesInTemplate(this.titleTemplate, data)
|
||||
}
|
||||
|
||||
parseBodyTemplate(data) {
|
||||
return this.replaceVariablesInTemplate(this.bodyTemplate, data)
|
||||
}
|
||||
|
||||
getApprisePayload(data) {
|
||||
return {
|
||||
urls: this.urls,
|
||||
title: this.parseTitleTemplate(data),
|
||||
body: this.parseBodyTemplate(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = Notification
|
||||
|
|
@ -22,7 +22,7 @@ class PodcastEpisodeDownload {
|
|||
toJSONForClient() {
|
||||
return {
|
||||
id: this.id,
|
||||
episodeDisplayTitle: this.podcastEpisode ? this.podcastEpisode.bestFilename : null,
|
||||
episodeDisplayTitle: this.podcastEpisode ? this.podcastEpisode.title : null,
|
||||
url: this.url,
|
||||
libraryItemId: this.libraryItem ? this.libraryItem.id : null,
|
||||
isDownloading: this.isDownloading,
|
||||
|
|
@ -35,7 +35,7 @@ class PodcastEpisodeDownload {
|
|||
}
|
||||
|
||||
get targetFilename() {
|
||||
return sanitizeFilename(`${this.podcastEpisode.bestFilename}.mp3`)
|
||||
return sanitizeFilename(`${this.podcastEpisode.title}.mp3`)
|
||||
}
|
||||
get targetPath() {
|
||||
return Path.join(this.libraryItem.path, this.targetFilename)
|
||||
|
|
|
|||
|
|
@ -73,7 +73,9 @@ class Stream extends EventEmitter {
|
|||
AudioMimeType.FLAC,
|
||||
AudioMimeType.OPUS,
|
||||
AudioMimeType.WMA,
|
||||
AudioMimeType.AIFF
|
||||
AudioMimeType.AIFF,
|
||||
AudioMimeType.WEBM,
|
||||
AudioMimeType.WEBMA
|
||||
]
|
||||
}
|
||||
get userToken() {
|
||||
|
|
|
|||
56
server/objects/Task.js
Normal file
56
server/objects/Task.js
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
const { getId } = require('../utils/index')
|
||||
|
||||
class Task {
|
||||
constructor() {
|
||||
this.id = null
|
||||
this.action = null // e.g. embed-metadata, encode-m4b, etc
|
||||
this.data = null // additional info for the action like libraryItemId
|
||||
|
||||
this.title = null
|
||||
this.description = null
|
||||
this.error = null
|
||||
|
||||
this.isFailed = false
|
||||
this.isFinished = false
|
||||
|
||||
this.startedAt = null
|
||||
this.finishedAt = null
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
action: this.action,
|
||||
data: this.data ? { ...this.data } : {},
|
||||
title: this.title,
|
||||
description: this.description,
|
||||
error: this.error,
|
||||
isFailed: this.isFailed,
|
||||
isFinished: this.isFinished,
|
||||
startedAt: this.startedAt,
|
||||
finishedAt: this.finishedAt
|
||||
}
|
||||
}
|
||||
|
||||
setData(action, title, description, data = {}) {
|
||||
this.id = getId(action)
|
||||
this.action = action
|
||||
this.data = { ...data }
|
||||
this.title = title
|
||||
this.description = description
|
||||
this.startedAt = Date.now()
|
||||
}
|
||||
|
||||
setFailed(message) {
|
||||
this.error = message
|
||||
this.isFailed = true
|
||||
this.failedAt = Date.now()
|
||||
this.setFinished()
|
||||
}
|
||||
|
||||
setFinished() {
|
||||
this.isFinished = true
|
||||
this.finishedAt = Date.now()
|
||||
}
|
||||
}
|
||||
module.exports = Task
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
const Logger = require('../../Logger')
|
||||
const { getId } = require('../../utils/index')
|
||||
const { checkNamesAreEqual } = require('../../utils/parsers/parseNameString')
|
||||
|
||||
class Author {
|
||||
constructor(author) {
|
||||
|
|
@ -86,7 +87,7 @@ class Author {
|
|||
Logger.error(`[Author] Author name is null (${this.id})`)
|
||||
return false
|
||||
}
|
||||
return this.name.toLowerCase() == name.toLowerCase().trim()
|
||||
return checkNamesAreEqual(this.name, name)
|
||||
}
|
||||
}
|
||||
module.exports = Author
|
||||
|
|
@ -103,9 +103,8 @@ class PodcastEpisode {
|
|||
return this.audioFile.duration
|
||||
}
|
||||
get size() { return this.audioFile.metadata.size }
|
||||
get bestFilename() {
|
||||
if (this.episode) return `${this.episode} - ${this.title}`
|
||||
return this.title
|
||||
get enclosureUrl() {
|
||||
return this.enclosure ? this.enclosure.url : null
|
||||
}
|
||||
|
||||
setData(data, index = 1) {
|
||||
|
|
|
|||
|
|
@ -47,6 +47,19 @@ class Series {
|
|||
this.updatedAt = Date.now()
|
||||
}
|
||||
|
||||
update(series) {
|
||||
if (!series) return false
|
||||
const keysToUpdate = ['name', 'description']
|
||||
var hasUpdated = false
|
||||
for (const key of keysToUpdate) {
|
||||
if (series[key] !== undefined && series[key] !== this[key]) {
|
||||
this[key] = series[key]
|
||||
hasUpdated = true
|
||||
}
|
||||
}
|
||||
return hasUpdated
|
||||
}
|
||||
|
||||
checkNameEquals(name) {
|
||||
if (!name || !this.name) return false
|
||||
return this.name.toLowerCase() == name.toLowerCase().trim()
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ class AudioTrack {
|
|||
this.startOffset = startOffset
|
||||
this.duration = audioFile.duration
|
||||
this.title = audioFile.metadata.filename || ''
|
||||
this.contentUrl = Path.join(`/s/item/${itemId}`, encodeUriPath(audioFile.metadata.relPath))
|
||||
this.contentUrl = Path.join(`${global.RouterBasePath}/s/item/${itemId}`, encodeUriPath(audioFile.metadata.relPath))
|
||||
this.mimeType = audioFile.mimeType
|
||||
this.metadata = audioFile.metadata.clone()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ class VideoTrack {
|
|||
this.index = videoFile.index
|
||||
this.duration = videoFile.duration
|
||||
this.title = videoFile.metadata.filename || ''
|
||||
this.contentUrl = Path.join(`/s/item/${itemId}`, encodeUriPath(videoFile.metadata.relPath))
|
||||
this.contentUrl = Path.join(`${global.RouterBasePath}/s/item/${itemId}`, encodeUriPath(videoFile.metadata.relPath))
|
||||
this.mimeType = videoFile.mimeType
|
||||
this.metadata = videoFile.metadata.clone()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ const Logger = require('../../Logger')
|
|||
const BookMetadata = require('../metadata/BookMetadata')
|
||||
const { areEquivalent, copyValue, cleanStringForSearch } = require('../../utils/index')
|
||||
const { parseOpfMetadataXML } = require('../../utils/parsers/parseOpfMetadata')
|
||||
const { overdriveMediaMarkersExist, parseOverdriveMediaMarkersAsChapters } = require('../../utils/parsers/parseOverdriveMediaMarkers')
|
||||
const { parseOverdriveMediaMarkersAsChapters } = require('../../utils/parsers/parseOverdriveMediaMarkers')
|
||||
const abmetadataGenerator = require('../../utils/abmetadataGenerator')
|
||||
const { readTextFile } = require('../../utils/fileUtils')
|
||||
const AudioFile = require('../files/AudioFile')
|
||||
|
|
@ -111,12 +111,15 @@ class Book {
|
|||
get invalidAudioFiles() {
|
||||
return this.audioFiles.filter(af => af.invalid)
|
||||
}
|
||||
get includedAudioFiles() {
|
||||
return this.audioFiles.filter(af => !af.exclude && !af.invalid)
|
||||
}
|
||||
get hasIssues() {
|
||||
return this.missingParts.length || this.invalidAudioFiles.length
|
||||
}
|
||||
get tracks() {
|
||||
var startOffset = 0
|
||||
return this.audioFiles.filter(af => !af.exclude && !af.invalid).map((af) => {
|
||||
return this.includedAudioFiles.map((af) => {
|
||||
var audioTrack = new AudioTrack()
|
||||
audioTrack.setData(this.libraryItemId, af, startOffset)
|
||||
startOffset += audioTrack.duration
|
||||
|
|
@ -396,7 +399,7 @@ class Book {
|
|||
var newMissingParts = (this.missingParts || []).join(',') || ''
|
||||
var wasUpdated = newMissingParts !== currMissingParts
|
||||
if (wasUpdated && this.missingParts.length) {
|
||||
Logger.info(`[Audiobook] "${this.name}" has ${missingParts.length} missing parts`)
|
||||
Logger.info(`[Audiobook] "${this.metadata.title}" has ${missingParts.length} missing parts`)
|
||||
}
|
||||
|
||||
return wasUpdated
|
||||
|
|
@ -405,54 +408,29 @@ class Book {
|
|||
setChapters(preferOverdriveMediaMarker = false) {
|
||||
// If 1 audio file without chapters, then no chapters will be set
|
||||
var includedAudioFiles = this.audioFiles.filter(af => !af.exclude)
|
||||
if (!includedAudioFiles.length) return
|
||||
|
||||
// If overdrive media markers are present and preferred, use those instead
|
||||
if (preferOverdriveMediaMarker) {
|
||||
var overdriveChapters = parseOverdriveMediaMarkersAsChapters(includedAudioFiles)
|
||||
if (overdriveChapters) {
|
||||
Logger.info('[Book] Overdrive Media Markers and preference found! Using these for chapter definitions')
|
||||
return this.chapters = overdriveChapters
|
||||
this.chapters = overdriveChapters
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (includedAudioFiles.length === 1) {
|
||||
// 1 audio file with chapters
|
||||
if (includedAudioFiles[0].chapters) {
|
||||
this.chapters = includedAudioFiles[0].chapters.map(c => ({ ...c }))
|
||||
}
|
||||
} else {
|
||||
// IF first audio file has embedded chapters then use embedded chapters
|
||||
if (includedAudioFiles[0].chapters && includedAudioFiles[0].chapters.length) {
|
||||
Logger.debug(`[Book] setChapters: Using embedded chapters in audio file ${includedAudioFiles[0].metadata.path}`)
|
||||
this.chapters = includedAudioFiles[0].chapters.map(c => ({ ...c }))
|
||||
} else if (includedAudioFiles.length > 1) {
|
||||
// Build chapters from audio files
|
||||
this.chapters = []
|
||||
var currChapterId = 0
|
||||
var currStartTime = 0
|
||||
includedAudioFiles.forEach((file) => {
|
||||
// If audio file has chapters use chapters
|
||||
if (file.chapters && file.chapters.length) {
|
||||
file.chapters.forEach((chapter) => {
|
||||
if (currStartTime > this.duration) {
|
||||
Logger.warn(`[Book] Invalid chapter start time > duration`)
|
||||
} else {
|
||||
var chapterAlreadyExists = this.chapters.find(ch => ch.start === currStartTime)
|
||||
if (!chapterAlreadyExists) {
|
||||
var chapterDuration = chapter.end - chapter.start
|
||||
if (chapterDuration > 0) {
|
||||
var title = `Chapter ${currChapterId}`
|
||||
if (chapter.title) {
|
||||
title += ` (${chapter.title})`
|
||||
}
|
||||
var endTime = Math.min(this.duration, currStartTime + chapterDuration)
|
||||
this.chapters.push({
|
||||
id: currChapterId++,
|
||||
start: currStartTime,
|
||||
end: endTime,
|
||||
title
|
||||
})
|
||||
currStartTime += chapterDuration
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
} else if (file.duration) {
|
||||
// Otherwise just use track has chapter
|
||||
if (file.duration) {
|
||||
this.chapters.push({
|
||||
id: currChapterId++,
|
||||
start: currStartTime,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ class Podcast {
|
|||
this.autoDownloadSchedule = null
|
||||
this.lastEpisodeCheck = 0
|
||||
this.maxEpisodesToKeep = 0
|
||||
this.maxNewEpisodesToDownload = 3
|
||||
|
||||
this.lastCoverSearch = null
|
||||
this.lastCoverSearchQuery = null
|
||||
|
|
@ -44,6 +45,7 @@ class Podcast {
|
|||
this.autoDownloadSchedule = podcast.autoDownloadSchedule || '0 * * * *' // Added in 2.1.3 so default to hourly
|
||||
this.lastEpisodeCheck = podcast.lastEpisodeCheck || 0
|
||||
this.maxEpisodesToKeep = podcast.maxEpisodesToKeep || 0
|
||||
this.maxNewEpisodesToDownload = podcast.maxNewEpisodesToDownload || 3
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
|
|
@ -56,7 +58,8 @@ class Podcast {
|
|||
autoDownloadEpisodes: this.autoDownloadEpisodes,
|
||||
autoDownloadSchedule: this.autoDownloadSchedule,
|
||||
lastEpisodeCheck: this.lastEpisodeCheck,
|
||||
maxEpisodesToKeep: this.maxEpisodesToKeep
|
||||
maxEpisodesToKeep: this.maxEpisodesToKeep,
|
||||
maxNewEpisodesToDownload: this.maxNewEpisodesToDownload
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -70,6 +73,7 @@ class Podcast {
|
|||
autoDownloadSchedule: this.autoDownloadSchedule,
|
||||
lastEpisodeCheck: this.lastEpisodeCheck,
|
||||
maxEpisodesToKeep: this.maxEpisodesToKeep,
|
||||
maxNewEpisodesToDownload: this.maxNewEpisodesToDownload,
|
||||
size: this.size
|
||||
}
|
||||
}
|
||||
|
|
@ -85,6 +89,7 @@ class Podcast {
|
|||
autoDownloadSchedule: this.autoDownloadSchedule,
|
||||
lastEpisodeCheck: this.lastEpisodeCheck,
|
||||
maxEpisodesToKeep: this.maxEpisodesToKeep,
|
||||
maxNewEpisodesToDownload: this.maxNewEpisodesToDownload,
|
||||
size: this.size
|
||||
}
|
||||
}
|
||||
|
|
@ -228,6 +233,7 @@ class Podcast {
|
|||
|
||||
addPodcastEpisode(podcastEpisode) {
|
||||
this.episodes.push(podcastEpisode)
|
||||
this.reorderEpisodes()
|
||||
}
|
||||
|
||||
addNewEpisodeFromAudioFile(audioFile, index) {
|
||||
|
|
@ -241,15 +247,13 @@ class Podcast {
|
|||
reorderEpisodes() {
|
||||
var hasUpdates = false
|
||||
|
||||
// TODO: Sort by published date
|
||||
this.episodes = naturalSort(this.episodes).asc((ep) => ep.bestFilename)
|
||||
this.episodes = naturalSort(this.episodes).desc((ep) => ep.publishedAt)
|
||||
for (let i = 0; i < this.episodes.length; i++) {
|
||||
if (this.episodes[i].index !== (i + 1)) {
|
||||
this.episodes[i].index = i + 1
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
this.episodes.sort((a, b) => b.index - a.index)
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -87,6 +87,10 @@ class AudioMetaTags {
|
|||
this.tagOverdriveMediaMarker = payload.file_tag_overdrive_media_marker || null
|
||||
}
|
||||
|
||||
setDataFromTone(tags) {
|
||||
// TODO: Implement
|
||||
}
|
||||
|
||||
updateData(payload) {
|
||||
const dataMap = {
|
||||
tagAlbum: payload.file_tag_album || null,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
const Logger = require('../../Logger')
|
||||
const { areEquivalent, copyValue, cleanStringForSearch, getTitleIgnorePrefix } = require('../../utils/index')
|
||||
const { areEquivalent, copyValue, cleanStringForSearch, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index')
|
||||
const parseNameString = require('../../utils/parsers/parseNameString')
|
||||
class BookMetadata {
|
||||
constructor(metadata) {
|
||||
|
|
@ -62,7 +62,7 @@ class BookMetadata {
|
|||
toJSONMinified() {
|
||||
return {
|
||||
title: this.title,
|
||||
titleIgnorePrefix: this.titleIgnorePrefix,
|
||||
titleIgnorePrefix: this.titlePrefixAtEnd,
|
||||
subtitle: this.subtitle,
|
||||
authorName: this.authorName,
|
||||
authorNameLF: this.authorNameLF,
|
||||
|
|
@ -83,7 +83,7 @@ class BookMetadata {
|
|||
toJSONExpanded() {
|
||||
return {
|
||||
title: this.title,
|
||||
titleIgnorePrefix: this.titleIgnorePrefix,
|
||||
titleIgnorePrefix: this.titlePrefixAtEnd,
|
||||
subtitle: this.subtitle,
|
||||
authors: this.authors.map(a => ({ ...a })), // Author JSONMinimal with name and id
|
||||
narrators: [...this.narrators],
|
||||
|
|
@ -111,6 +111,9 @@ class BookMetadata {
|
|||
get titleIgnorePrefix() {
|
||||
return getTitleIgnorePrefix(this.title)
|
||||
}
|
||||
get titlePrefixAtEnd() {
|
||||
return getTitlePrefixAtEnd(this.title)
|
||||
}
|
||||
get authorName() {
|
||||
if (!this.authors.length) return ''
|
||||
return this.authors.map(au => au.name).join(', ')
|
||||
|
|
@ -133,6 +136,21 @@ class BookMetadata {
|
|||
return `${getTitleIgnorePrefix(se.name)} #${se.sequence}`
|
||||
}).join(', ')
|
||||
}
|
||||
get seriesNamePrefixAtEnd() {
|
||||
if (!this.series.length) return ''
|
||||
return this.series.map(se => {
|
||||
if (!se.sequence) return getTitlePrefixAtEnd(se.name)
|
||||
return `${getTitlePrefixAtEnd(se.name)} #${se.sequence}`
|
||||
}).join(', ')
|
||||
}
|
||||
get firstSeriesName() {
|
||||
if (!this.series.length) return ''
|
||||
return this.series[0].name
|
||||
}
|
||||
get firstSeriesSequence() {
|
||||
if (!this.series.length) return ''
|
||||
return this.series[0].sequence
|
||||
}
|
||||
get narratorName() {
|
||||
return this.narrators.join(', ')
|
||||
}
|
||||
|
|
@ -226,6 +244,7 @@ class BookMetadata {
|
|||
},
|
||||
{
|
||||
tag: 'tagDescription',
|
||||
altTag: 'tagComment',
|
||||
key: 'description'
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
const Logger = require('../../Logger')
|
||||
const { areEquivalent, copyValue, cleanStringForSearch } = require('../../utils/index')
|
||||
const { areEquivalent, copyValue, cleanStringForSearch, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index')
|
||||
|
||||
class PodcastMetadata {
|
||||
constructor(metadata) {
|
||||
|
|
@ -56,7 +56,7 @@ class PodcastMetadata {
|
|||
toJSONMinified() {
|
||||
return {
|
||||
title: this.title,
|
||||
titleIgnorePrefix: this.titleIgnorePrefix,
|
||||
titleIgnorePrefix: this.titlePrefixAtEnd,
|
||||
author: this.author,
|
||||
description: this.description,
|
||||
releaseDate: this.releaseDate,
|
||||
|
|
@ -80,15 +80,11 @@ class PodcastMetadata {
|
|||
}
|
||||
|
||||
get titleIgnorePrefix() {
|
||||
if (!this.title) return ''
|
||||
var prefixesToIgnore = global.ServerSettings.sortingPrefixes || []
|
||||
for (const prefix of prefixesToIgnore) {
|
||||
// e.g. for prefix "the". If title is "The Book Title" return "Book Title, The"
|
||||
if (this.title.toLowerCase().startsWith(`${prefix} `)) {
|
||||
return this.title.substr(prefix.length + 1) + `, ${prefix.substr(0, 1).toUpperCase() + prefix.substr(1)}`
|
||||
}
|
||||
}
|
||||
return this.title
|
||||
return getTitleIgnorePrefix(this.title)
|
||||
}
|
||||
|
||||
get titlePrefixAtEnd() {
|
||||
return getTitlePrefixAtEnd(this.title)
|
||||
}
|
||||
|
||||
searchQuery(query) { // Returns key if match is found
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
const Logger = require('../../Logger')
|
||||
const { areEquivalent, copyValue } = require('../../utils/index')
|
||||
const { areEquivalent, copyValue, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index')
|
||||
|
||||
class VideoMetadata {
|
||||
constructor(metadata) {
|
||||
|
|
@ -32,7 +32,7 @@ class VideoMetadata {
|
|||
toJSONMinified() {
|
||||
return {
|
||||
title: this.title,
|
||||
titleIgnorePrefix: this.titleIgnorePrefix,
|
||||
titleIgnorePrefix: this.titlePrefixAtEnd,
|
||||
description: this.description,
|
||||
explicit: this.explicit,
|
||||
language: this.language
|
||||
|
|
@ -48,15 +48,11 @@ class VideoMetadata {
|
|||
}
|
||||
|
||||
get titleIgnorePrefix() {
|
||||
if (!this.title) return ''
|
||||
var prefixesToIgnore = global.ServerSettings.sortingPrefixes || []
|
||||
for (const prefix of prefixesToIgnore) {
|
||||
// e.g. for prefix "the". If title is "The Book Title" return "Book Title, The"
|
||||
if (this.title.toLowerCase().startsWith(`${prefix} `)) {
|
||||
return this.title.substr(prefix.length + 1) + `, ${prefix.substr(0, 1).toUpperCase() + prefix.substr(1)}`
|
||||
}
|
||||
}
|
||||
return this.title
|
||||
return getTitleIgnorePrefix(this.title)
|
||||
}
|
||||
|
||||
get titlePrefixAtEnd() {
|
||||
return getTitlePrefixAtEnd(this.title)
|
||||
}
|
||||
|
||||
searchQuery(query) { // Returns key if match is found
|
||||
|
|
|
|||
106
server/objects/settings/NotificationSettings.js
Normal file
106
server/objects/settings/NotificationSettings.js
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
const Logger = require('../../Logger')
|
||||
const Notification = require('../Notification')
|
||||
const { isNullOrNaN } = require('../../utils')
|
||||
|
||||
class NotificationSettings {
|
||||
constructor(settings = null) {
|
||||
this.id = 'notification-settings'
|
||||
this.appriseType = 'api'
|
||||
this.appriseApiUrl = null
|
||||
this.notifications = []
|
||||
this.maxFailedAttempts = 5
|
||||
this.maxNotificationQueue = 20 // once reached events will be ignored
|
||||
this.notificationDelay = 1000 // ms delay between firing notifications
|
||||
|
||||
if (settings) {
|
||||
this.construct(settings)
|
||||
}
|
||||
}
|
||||
|
||||
construct(settings) {
|
||||
this.appriseType = settings.appriseType
|
||||
this.appriseApiUrl = settings.appriseApiUrl || null
|
||||
this.notifications = (settings.notifications || []).map(n => new Notification(n))
|
||||
this.maxFailedAttempts = settings.maxFailedAttempts || 5
|
||||
this.maxNotificationQueue = settings.maxNotificationQueue || 20
|
||||
this.notificationDelay = settings.notificationDelay || 1000
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
appriseType: this.appriseType,
|
||||
appriseApiUrl: this.appriseApiUrl,
|
||||
notifications: this.notifications.map(n => n.toJSON()),
|
||||
maxFailedAttempts: this.maxFailedAttempts,
|
||||
maxNotificationQueue: this.maxNotificationQueue,
|
||||
notificationDelay: this.notificationDelay
|
||||
}
|
||||
}
|
||||
|
||||
get isUseable() {
|
||||
return !!this.appriseApiUrl
|
||||
}
|
||||
|
||||
getActiveNotificationsForEvent(eventName) {
|
||||
return this.notifications.filter(n => n.eventName === eventName && n.enabled)
|
||||
}
|
||||
|
||||
getNotification(id) {
|
||||
return this.notifications.find(n => n.id === id)
|
||||
}
|
||||
|
||||
removeNotification(id) {
|
||||
if (this.notifications.some(n => n.id === id)) {
|
||||
this.notifications = this.notifications.filter(n => n.id !== id)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
if (!payload) return false
|
||||
|
||||
var hasUpdates = false
|
||||
if (payload.appriseApiUrl !== this.appriseApiUrl) {
|
||||
this.appriseApiUrl = payload.appriseApiUrl || null
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
const _maxFailedAttempts = isNullOrNaN(payload.maxFailedAttempts) ? 5 : Number(payload.maxFailedAttempts)
|
||||
if (_maxFailedAttempts !== this.maxFailedAttempts) {
|
||||
this.maxFailedAttempts = _maxFailedAttempts
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
const _maxNotificationQueue = isNullOrNaN(payload.maxNotificationQueue) ? 20 : Number(payload.maxNotificationQueue)
|
||||
if (_maxNotificationQueue !== this.maxNotificationQueue) {
|
||||
this.maxNotificationQueue = _maxNotificationQueue
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
createNotification(payload) {
|
||||
if (!payload) return false
|
||||
if (!payload.eventName || !payload.urls.length) return false
|
||||
|
||||
const notification = new Notification()
|
||||
notification.setData(payload)
|
||||
this.notifications.push(notification)
|
||||
return true
|
||||
}
|
||||
|
||||
updateNotification(payload) {
|
||||
if (!payload) return false
|
||||
const notification = this.notifications.find(n => n.id === payload.id)
|
||||
if (!notification) {
|
||||
Logger.error(`[NotificationSettings] updateNotification: Notification not found ${payload.id}`)
|
||||
return false
|
||||
}
|
||||
|
||||
return notification.update(payload)
|
||||
}
|
||||
}
|
||||
module.exports = NotificationSettings
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
const { BookCoverAspectRatio, BookshelfView } = require('../../utils/constants')
|
||||
const { BookshelfView } = require('../../utils/constants')
|
||||
const { isNullOrNaN } = require('../../utils')
|
||||
const Logger = require('../../Logger')
|
||||
|
||||
|
|
@ -17,7 +17,8 @@ class ServerSettings {
|
|||
this.scannerDisableWatcher = false
|
||||
this.scannerPreferOverdriveMediaMarker = false
|
||||
this.scannerUseSingleThreadedProber = true
|
||||
this.scannerMaxThreads = 0 // 0 = defaults to CPUs * 2
|
||||
this.scannerMaxThreads = 0 // Currently not being used
|
||||
this.scannerUseTone = false
|
||||
|
||||
// Metadata - choose to store inside users library item folder
|
||||
this.storeCoverWithItem = false
|
||||
|
|
@ -28,8 +29,7 @@ class ServerSettings {
|
|||
this.rateLimitLoginWindow = 10 * 60 * 1000 // 10 Minutes
|
||||
|
||||
// Backups
|
||||
// this.backupSchedule = '30 1 * * *' // If false then auto-backups are disabled (default every day at 1:30am)
|
||||
this.backupSchedule = false
|
||||
this.backupSchedule = false // If false then auto-backups are disabled
|
||||
this.backupsToKeep = 2
|
||||
this.maxBackupSize = 1
|
||||
this.backupMetadataCovers = true
|
||||
|
|
@ -38,26 +38,23 @@ class ServerSettings {
|
|||
this.loggerDailyLogsToKeep = 7
|
||||
this.loggerScannerLogsToKeep = 2
|
||||
|
||||
// Cover
|
||||
// TODO: Remove after mobile apps are configured to use library server settings
|
||||
this.coverAspectRatio = BookCoverAspectRatio.SQUARE
|
||||
|
||||
// Bookshelf Display
|
||||
this.homeBookshelfView = BookshelfView.STANDARD
|
||||
this.bookshelfView = BookshelfView.STANDARD
|
||||
this.bookshelfView = BookshelfView.DETAIL
|
||||
|
||||
// Podcasts
|
||||
this.podcastEpisodeSchedule = '0 * * * *' // Every hour
|
||||
|
||||
// Sorting
|
||||
this.sortingIgnorePrefix = false
|
||||
this.sortingPrefixes = ['the', 'a']
|
||||
this.sortingPrefixes = ['the']
|
||||
|
||||
// Misc Flags
|
||||
this.chromecastEnabled = false
|
||||
this.sharedListeningStats = false
|
||||
this.enableEReader = false
|
||||
this.dateFormat = 'MM/dd/yyyy'
|
||||
this.language = 'en-us'
|
||||
|
||||
this.logLevel = Logger.logLevel
|
||||
|
||||
|
|
@ -83,6 +80,7 @@ class ServerSettings {
|
|||
this.scannerUseSingleThreadedProber = true
|
||||
}
|
||||
this.scannerMaxThreads = isNullOrNaN(settings.scannerMaxThreads) ? 0 : Number(settings.scannerMaxThreads)
|
||||
this.scannerUseTone = !!settings.scannerUseTone
|
||||
|
||||
this.storeCoverWithItem = !!settings.storeCoverWithItem
|
||||
this.storeMetadataWithItem = !!settings.storeMetadataWithItem
|
||||
|
|
@ -98,16 +96,16 @@ class ServerSettings {
|
|||
this.loggerDailyLogsToKeep = settings.loggerDailyLogsToKeep || 7
|
||||
this.loggerScannerLogsToKeep = settings.loggerScannerLogsToKeep || 2
|
||||
|
||||
this.coverAspectRatio = !isNaN(settings.coverAspectRatio) ? settings.coverAspectRatio : BookCoverAspectRatio.SQUARE
|
||||
this.homeBookshelfView = settings.homeBookshelfView || BookshelfView.STANDARD
|
||||
this.bookshelfView = settings.bookshelfView || BookshelfView.STANDARD
|
||||
|
||||
this.sortingIgnorePrefix = !!settings.sortingIgnorePrefix
|
||||
this.sortingPrefixes = settings.sortingPrefixes || ['the', 'a']
|
||||
this.sortingPrefixes = settings.sortingPrefixes || ['the']
|
||||
this.chromecastEnabled = !!settings.chromecastEnabled
|
||||
this.sharedListeningStats = !!settings.sharedListeningStats
|
||||
this.enableEReader = !!settings.enableEReader
|
||||
this.dateFormat = settings.dateFormat || 'MM/dd/yyyy'
|
||||
this.language = settings.language || 'en-us'
|
||||
this.logLevel = settings.logLevel || Logger.logLevel
|
||||
this.version = settings.version || null
|
||||
|
||||
|
|
@ -141,6 +139,7 @@ class ServerSettings {
|
|||
scannerPreferOverdriveMediaMarker: this.scannerPreferOverdriveMediaMarker,
|
||||
scannerUseSingleThreadedProber: this.scannerUseSingleThreadedProber,
|
||||
scannerMaxThreads: this.scannerMaxThreads,
|
||||
scannerUseTone: this.scannerUseTone,
|
||||
storeCoverWithItem: this.storeCoverWithItem,
|
||||
storeMetadataWithItem: this.storeMetadataWithItem,
|
||||
rateLimitLoginRequests: this.rateLimitLoginRequests,
|
||||
|
|
@ -151,7 +150,6 @@ class ServerSettings {
|
|||
backupMetadataCovers: this.backupMetadataCovers,
|
||||
loggerDailyLogsToKeep: this.loggerDailyLogsToKeep,
|
||||
loggerScannerLogsToKeep: this.loggerScannerLogsToKeep,
|
||||
coverAspectRatio: this.coverAspectRatio,
|
||||
homeBookshelfView: this.homeBookshelfView,
|
||||
bookshelfView: this.bookshelfView,
|
||||
sortingIgnorePrefix: this.sortingIgnorePrefix,
|
||||
|
|
@ -160,6 +158,7 @@ class ServerSettings {
|
|||
sharedListeningStats: this.sharedListeningStats,
|
||||
enableEReader: this.enableEReader,
|
||||
dateFormat: this.dateFormat,
|
||||
language: this.language,
|
||||
logLevel: this.logLevel,
|
||||
version: this.version
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ class MediaProgress {
|
|||
this.progress = null // 0 to 1
|
||||
this.currentTime = null // seconds
|
||||
this.isFinished = false
|
||||
this.hideFromContinueListening = false
|
||||
|
||||
this.lastUpdate = null
|
||||
this.startedAt = null
|
||||
|
|
@ -27,6 +28,7 @@ class MediaProgress {
|
|||
progress: this.progress,
|
||||
currentTime: this.currentTime,
|
||||
isFinished: this.isFinished,
|
||||
hideFromContinueListening: this.hideFromContinueListening,
|
||||
lastUpdate: this.lastUpdate,
|
||||
startedAt: this.startedAt,
|
||||
finishedAt: this.finishedAt
|
||||
|
|
@ -41,6 +43,7 @@ class MediaProgress {
|
|||
this.progress = progress.progress
|
||||
this.currentTime = progress.currentTime
|
||||
this.isFinished = !!progress.isFinished
|
||||
this.hideFromContinueListening = !!progress.hideFromContinueListening
|
||||
this.lastUpdate = progress.lastUpdate
|
||||
this.startedAt = progress.startedAt
|
||||
this.finishedAt = progress.finishedAt || null
|
||||
|
|
@ -58,6 +61,7 @@ class MediaProgress {
|
|||
this.progress = Math.min(1, (progress.progress || 0))
|
||||
this.currentTime = progress.currentTime || 0
|
||||
this.isFinished = !!progress.isFinished || this.progress == 1
|
||||
this.hideFromContinueListening = !!progress.hideFromContinueListening
|
||||
this.lastUpdate = Date.now()
|
||||
this.startedAt = Date.now()
|
||||
this.finishedAt = null
|
||||
|
|
@ -102,9 +106,21 @@ class MediaProgress {
|
|||
this.startedAt = Date.now()
|
||||
}
|
||||
if (hasUpdates) {
|
||||
if (payload.hideFromContinueListening === undefined) {
|
||||
// Reset this flag when the media progress is updated
|
||||
this.hideFromContinueListening = false
|
||||
}
|
||||
|
||||
this.lastUpdate = Date.now()
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
removeFromContinueListening() {
|
||||
if (this.hideFromContinueListening) return false
|
||||
|
||||
this.hideFromContinueListening = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
module.exports = MediaProgress
|
||||
|
|
@ -15,6 +15,7 @@ class User {
|
|||
this.createdAt = null
|
||||
|
||||
this.mediaProgress = []
|
||||
this.seriesHideFromContinueListening = [] // Series IDs that should not show on home page continue listening
|
||||
this.bookmarks = []
|
||||
|
||||
this.settings = {}
|
||||
|
|
@ -93,6 +94,7 @@ class User {
|
|||
type: this.type,
|
||||
token: this.token,
|
||||
mediaProgress: this.mediaProgress ? this.mediaProgress.map(li => li.toJSON()) : [],
|
||||
seriesHideFromContinueListening: [...this.seriesHideFromContinueListening],
|
||||
bookmarks: this.bookmarks ? this.bookmarks.map(b => b.toJSON()) : [],
|
||||
isActive: this.isActive,
|
||||
isLocked: this.isLocked,
|
||||
|
|
@ -112,6 +114,7 @@ class User {
|
|||
type: this.type,
|
||||
token: this.token,
|
||||
mediaProgress: this.mediaProgress ? this.mediaProgress.map(li => li.toJSON()) : [],
|
||||
seriesHideFromContinueListening: [...this.seriesHideFromContinueListening],
|
||||
bookmarks: this.bookmarks ? this.bookmarks.map(b => b.toJSON()) : [],
|
||||
isActive: this.isActive,
|
||||
isLocked: this.isLocked,
|
||||
|
|
@ -128,8 +131,8 @@ class User {
|
|||
toJSONForPublic(sessions, libraryItems) {
|
||||
var userSession = sessions ? sessions.find(s => s.userId === this.id) : null
|
||||
var session = null
|
||||
if (session) {
|
||||
var libraryItem = libraryItems.find(li => li.id === session.libraryItemId)
|
||||
if (userSession) {
|
||||
var libraryItem = libraryItems.find(li => li.id === userSession.libraryItemId)
|
||||
if (libraryItem) {
|
||||
session = userSession.toJSONForClient(libraryItem)
|
||||
}
|
||||
|
|
@ -162,6 +165,9 @@ class User {
|
|||
this.bookmarks = user.bookmarks.filter(bm => typeof bm.libraryItemId == 'string').map(bm => new AudioBookmark(bm))
|
||||
}
|
||||
|
||||
this.seriesHideFromContinueListening = []
|
||||
if (user.seriesHideFromContinueListening) this.seriesHideFromContinueListening = [...user.seriesHideFromContinueListening]
|
||||
|
||||
this.isActive = (user.isActive === undefined || user.type === 'root') ? true : !!user.isActive
|
||||
this.isLocked = user.type === 'root' ? false : !!user.isLocked
|
||||
this.lastSeen = user.lastSeen || null
|
||||
|
|
@ -197,6 +203,13 @@ class User {
|
|||
}
|
||||
})
|
||||
|
||||
if (payload.seriesHideFromContinueListening && Array.isArray(payload.seriesHideFromContinueListening)) {
|
||||
if (this.seriesHideFromContinueListening.join(',') !== payload.seriesHideFromContinueListening.join(',')) {
|
||||
hasUpdates = true
|
||||
this.seriesHideFromContinueListening = [...payload.seriesHideFromContinueListening]
|
||||
}
|
||||
}
|
||||
|
||||
// And update permissions
|
||||
if (payload.permissions) {
|
||||
for (const key in payload.permissions) {
|
||||
|
|
@ -254,16 +267,42 @@ class User {
|
|||
return firstAccessibleLibrary.id
|
||||
}
|
||||
|
||||
// Returns most recent media progress w/ `media` object and optionally an `episode` object
|
||||
getMostRecentItemProgress(libraryItems) {
|
||||
if (!this.mediaProgress.length) return null
|
||||
var lip = this.mediaProgress.map(lip => lip.toJSON())
|
||||
lip.sort((a, b) => b.lastUpdate - a.lastUpdate)
|
||||
var mostRecentWithLip = lip.find(li => libraryItems.find(_li => _li.id === li.id))
|
||||
if (!mostRecentWithLip) return null
|
||||
var libraryItem = libraryItems.find(li => li.id === mostRecentWithLip.id)
|
||||
var mediaProgressObjects = this.mediaProgress.map(lip => lip.toJSON())
|
||||
mediaProgressObjects.sort((a, b) => b.lastUpdate - a.lastUpdate)
|
||||
|
||||
var libraryItemMedia = null
|
||||
var progressEpisode = null
|
||||
// Find the most recent progress that still has a libraryItem and episode
|
||||
var mostRecentProgress = mediaProgressObjects.find((progress) => {
|
||||
const libraryItem = libraryItems.find(li => li.id === progress.libraryItemId)
|
||||
if (!libraryItem) {
|
||||
Logger.warn('[User] Library item not found for users progress ' + progress.libraryItemId)
|
||||
return false
|
||||
} else if (progress.episodeId) {
|
||||
const episode = libraryItem.mediaType === 'podcast' ? libraryItem.media.getEpisode(progress.episodeId) : null
|
||||
if (!episode) {
|
||||
Logger.warn(`[User] Episode ${progress.episodeId} not found for user media progress, podcast: ${libraryItem.media.metadata.title}`)
|
||||
return false
|
||||
} else {
|
||||
libraryItemMedia = libraryItem.media.toJSONExpanded()
|
||||
progressEpisode = episode.toJSON()
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
libraryItemMedia = libraryItem.media.toJSONExpanded()
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
if (!mostRecentProgress) return null
|
||||
|
||||
return {
|
||||
...mostRecentWithLip,
|
||||
media: libraryItem.media.toJSONExpanded()
|
||||
...mostRecentProgress,
|
||||
media: libraryItemMedia,
|
||||
episode: progressEpisode
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -298,7 +337,13 @@ class User {
|
|||
return wasUpdated
|
||||
}
|
||||
|
||||
removeMediaProgress(libraryItemId) {
|
||||
removeMediaProgress(id) {
|
||||
if (!this.mediaProgress.some(mp => mp.id === id)) return false
|
||||
this.mediaProgress = this.mediaProgress.filter(mp => mp.id !== id)
|
||||
return true
|
||||
}
|
||||
|
||||
removeMediaProgressForLibraryItem(libraryItemId) {
|
||||
if (!this.mediaProgress.some(lip => lip.libraryItemId == libraryItemId)) return false
|
||||
this.mediaProgress = this.mediaProgress.filter(lip => lip.libraryItemId != libraryItemId)
|
||||
return true
|
||||
|
|
@ -379,5 +424,27 @@ class User {
|
|||
removeBookmark(libraryItemId, time) {
|
||||
this.bookmarks = this.bookmarks.filter(bm => (bm.libraryItemId !== libraryItemId || bm.time !== time))
|
||||
}
|
||||
|
||||
checkShouldHideSeriesFromContinueListening(seriesId) {
|
||||
return this.seriesHideFromContinueListening.includes(seriesId)
|
||||
}
|
||||
|
||||
addSeriesToHideFromContinueListening(seriesId) {
|
||||
if (this.seriesHideFromContinueListening.includes(seriesId)) return false
|
||||
this.seriesHideFromContinueListening.push(seriesId)
|
||||
return true
|
||||
}
|
||||
|
||||
removeSeriesFromHideFromContinueListening(seriesId) {
|
||||
if (!this.seriesHideFromContinueListening.includes(seriesId)) return false
|
||||
this.seriesHideFromContinueListening = this.seriesHideFromContinueListening.filter(sid => sid !== seriesId)
|
||||
return true
|
||||
}
|
||||
|
||||
removeProgressFromContinueListening(progressId) {
|
||||
const progress = this.mediaProgress.find(mp => mp.id === progressId)
|
||||
if (!progress) return false
|
||||
return progress.removeFromContinueListening()
|
||||
}
|
||||
}
|
||||
module.exports = User
|
||||
|
|
@ -3,7 +3,20 @@ const htmlSanitizer = require('../utils/htmlSanitizer')
|
|||
const Logger = require('../Logger')
|
||||
|
||||
class Audible {
|
||||
constructor() { }
|
||||
constructor() {
|
||||
this.regionMap = {
|
||||
'us': '.com',
|
||||
'ca': '.ca',
|
||||
'uk': '.co.uk',
|
||||
'au': '.co.au',
|
||||
'fr': '.fr',
|
||||
'de': '.de',
|
||||
'jp': '.co.jp',
|
||||
'it': '.it',
|
||||
'in': '.co.in',
|
||||
'es': '.es'
|
||||
}
|
||||
}
|
||||
|
||||
cleanResult(item) {
|
||||
var { title, subtitle, asin, authors, narrators, publisherName, summary, releaseDate, image, genres, seriesPrimary, seriesSecondary, language, runtimeLengthMin } = item
|
||||
|
|
@ -12,8 +25,8 @@ class Audible {
|
|||
if (seriesPrimary) series.push(seriesPrimary)
|
||||
if (seriesSecondary) series.push(seriesSecondary)
|
||||
|
||||
var genresFiltered = genres ? genres.filter(g => g.type == "genre") : []
|
||||
var tagsFiltered = genres ? genres.filter(g => g.type == "tag") : []
|
||||
const genresFiltered = genres ? genres.filter(g => g.type == "genre").map(g => g.name) : []
|
||||
const tagsFiltered = genres ? genres.filter(g => g.type == "tag").map(g => g.name) : []
|
||||
|
||||
return {
|
||||
title,
|
||||
|
|
@ -25,11 +38,13 @@ class Audible {
|
|||
description: summary ? htmlSanitizer.stripAllTags(summary) : null,
|
||||
cover: image,
|
||||
asin,
|
||||
genres: genresFiltered.length > 0 ? genresFiltered.map(({ name }) => name).join(', ') : null,
|
||||
tags: tagsFiltered.length > 0 ? tagsFiltered.map(({ name }) => name).join(', ') : null,
|
||||
series: series != [] ? series.map(({ name, position }) => ({ series: name, volumeNumber: position })) : null,
|
||||
genres: genresFiltered.length ? genresFiltered : null,
|
||||
tags: tagsFiltered.length ? tagsFiltered.join(', ') : null,
|
||||
series: series != [] ? series.map(({ name, position }) => ({ series: name, sequence: position })) : null,
|
||||
language: language ? language.charAt(0).toUpperCase() + language.slice(1) : null,
|
||||
duration: runtimeLengthMin && !isNaN(runtimeLengthMin) ? Number(runtimeLengthMin) : 0
|
||||
duration: runtimeLengthMin && !isNaN(runtimeLengthMin) ? Number(runtimeLengthMin) : 0,
|
||||
region: item.region || null,
|
||||
rating: item.rating || null
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -37,9 +52,10 @@ class Audible {
|
|||
return /^[0-9A-Z]{10}$/.test(title)
|
||||
}
|
||||
|
||||
asinSearch(asin) {
|
||||
asinSearch(asin, region) {
|
||||
asin = encodeURIComponent(asin);
|
||||
var url = `https://api.audnex.us/books/${asin}`
|
||||
var regionQuery = region ? `?region=${region}` : ''
|
||||
var url = `https://api.audnex.us/books/${asin}${regionQuery}`
|
||||
Logger.debug(`[Audible] ASIN url: ${url}`)
|
||||
return axios.get(url).then((res) => {
|
||||
if (!res || !res.data || !res.data.asin) return null
|
||||
|
|
@ -50,14 +66,19 @@ class Audible {
|
|||
})
|
||||
}
|
||||
|
||||
async search(title, author, asin) {
|
||||
async search(title, author, asin, region) {
|
||||
if (region && !this.regionMap[region]) {
|
||||
Logger.error(`[Audible] search: Invalid region ${region}`)
|
||||
region = ''
|
||||
}
|
||||
|
||||
var items
|
||||
if (asin) {
|
||||
items = [await this.asinSearch(asin)]
|
||||
items = [await this.asinSearch(asin, region)]
|
||||
}
|
||||
|
||||
if (!items && this.isProbablyAsin(title)) {
|
||||
items = [await this.asinSearch(title)]
|
||||
items = [await this.asinSearch(title, region)]
|
||||
}
|
||||
|
||||
if (!items) {
|
||||
|
|
@ -65,14 +86,15 @@ class Audible {
|
|||
num_results: '10',
|
||||
products_sort_by: 'Relevance',
|
||||
title: title
|
||||
};
|
||||
}
|
||||
if (author) queryObj.author = author
|
||||
var queryString = (new URLSearchParams(queryObj)).toString();
|
||||
var url = `https://api.audible.com/1.0/catalog/products?${queryString}`
|
||||
const queryString = (new URLSearchParams(queryObj)).toString()
|
||||
const tld = region ? this.regionMap[region] : '.com'
|
||||
const url = `https://api.audible${tld}/1.0/catalog/products?${queryString}`
|
||||
Logger.debug(`[Audible] Search url: ${url}`)
|
||||
items = await axios.get(url).then((res) => {
|
||||
if (!res || !res.data || !res.data.products) return null
|
||||
return Promise.all(res.data.products.map(result => this.asinSearch(result.asin)))
|
||||
return Promise.all(res.data.products.map(result => this.asinSearch(result.asin, region)))
|
||||
}).catch(error => {
|
||||
Logger.error('[Audible] query search error', error)
|
||||
return []
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ class Audnexus {
|
|||
return {
|
||||
asin: author.asin,
|
||||
description: author.description,
|
||||
image: author.image,
|
||||
image: author.image || null,
|
||||
name: author.name
|
||||
}
|
||||
}
|
||||
|
|
@ -54,17 +54,17 @@ class Audnexus {
|
|||
return {
|
||||
asin: author.asin,
|
||||
description: author.description,
|
||||
image: author.image,
|
||||
image: author.image || null,
|
||||
name: author.name
|
||||
}
|
||||
}
|
||||
|
||||
async getChaptersByASIN(asin) {
|
||||
Logger.debug(`[Audnexus] Get chapters for ASIN ${asin}`)
|
||||
return axios.get(`${this.baseUrl}/books/${asin}/chapters`).then((res) => {
|
||||
getChaptersByASIN(asin, region) {
|
||||
Logger.debug(`[Audnexus] Get chapters for ASIN ${asin}/${region}`)
|
||||
return axios.get(`${this.baseUrl}/books/${asin}/chapters?region=${region}`).then((res) => {
|
||||
return res.data
|
||||
}).catch((error) => {
|
||||
Logger.error(`[Audnexus] Chapter ASIN request failed for ${asin}`, error)
|
||||
Logger.error(`[Audnexus] Chapter ASIN request failed for ${asin}/${region}`, error)
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ class GoogleBooks {
|
|||
publishedYear: publisherDate ? publisherDate.split('-')[0] : null,
|
||||
description,
|
||||
cover: imageLinks && imageLinks.thumbnail ? imageLinks.thumbnail : null,
|
||||
genres: categories ? categories.join(', ') : null,
|
||||
genres: categories && Array.isArray(categories) ? [...categories] : null,
|
||||
isbn: this.extractIsbn(industryIdentifiers)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,14 +60,18 @@ class iTunes {
|
|||
}
|
||||
|
||||
cleanAudiobook(data) {
|
||||
// artistName can be "Name1, Name2 & Name3" so we refactor this to "Name1, Name2, Name3"
|
||||
// see: https://github.com/advplyr/audiobookshelf/issues/1022
|
||||
const author = (data.artistName || '').split(' & ').join(', ')
|
||||
|
||||
return {
|
||||
id: data.collectionId,
|
||||
artistId: data.artistId,
|
||||
title: data.collectionName,
|
||||
author: data.artistName,
|
||||
author,
|
||||
description: htmlSanitizer.stripAllTags(data.description || ''),
|
||||
publishedYear: data.releaseDate ? data.releaseDate.split('-')[0] : null,
|
||||
genres: data.primaryGenreName ? [data.primaryGenreName] : [],
|
||||
genres: data.primaryGenreName ? [data.primaryGenreName] : null,
|
||||
cover: this.getCoverArtwork(data)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const SeriesController = require('../controllers/SeriesController')
|
|||
const AuthorController = require('../controllers/AuthorController')
|
||||
const SessionController = require('../controllers/SessionController')
|
||||
const PodcastController = require('../controllers/PodcastController')
|
||||
const NotificationController = require('../controllers/NotificationController')
|
||||
const MiscController = require('../controllers/MiscController')
|
||||
|
||||
const BookFinder = require('../finders/BookFinder')
|
||||
|
|
@ -25,7 +26,7 @@ const Series = require('../objects/entities/Series')
|
|||
const FileSystemController = require('../controllers/FileSystemController')
|
||||
|
||||
class ApiRouter {
|
||||
constructor(db, auth, scanner, playbackSessionManager, abMergeManager, coverManager, backupManager, watcher, cacheManager, podcastManager, audioMetadataManager, rssFeedManager, cronManager, emitter, clientEmitter) {
|
||||
constructor(db, auth, scanner, playbackSessionManager, abMergeManager, coverManager, backupManager, watcher, cacheManager, podcastManager, audioMetadataManager, rssFeedManager, cronManager, notificationManager, taskManager, getUsersOnline, emitter, clientEmitter) {
|
||||
this.db = db
|
||||
this.auth = auth
|
||||
this.scanner = scanner
|
||||
|
|
@ -39,6 +40,9 @@ class ApiRouter {
|
|||
this.audioMetadataManager = audioMetadataManager
|
||||
this.rssFeedManager = rssFeedManager
|
||||
this.cronManager = cronManager
|
||||
this.notificationManager = notificationManager
|
||||
this.taskManager = taskManager
|
||||
this.getUsersOnline = getUsersOnline
|
||||
this.emitter = emitter
|
||||
this.clientEmitter = clientEmitter
|
||||
|
||||
|
|
@ -70,7 +74,8 @@ class ApiRouter {
|
|||
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/matchall', LibraryController.middleware.bind(this), LibraryController.matchAll.bind(this))
|
||||
this.router.get('/libraries/:id/scan', LibraryController.middleware.bind(this), LibraryController.scan.bind(this)) // Root only
|
||||
this.router.get('/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.post('/libraries/order', LibraryController.reorder.bind(this))
|
||||
|
||||
|
|
@ -92,26 +97,31 @@ class ApiRouter {
|
|||
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.get('/items/:id/scan', LibraryItemController.middleware.bind(this), LibraryItemController.scan.bind(this))
|
||||
this.router.get('/items/:id/tone-object', LibraryItemController.middleware.bind(this), LibraryItemController.getToneMetadataObject.bind(this))
|
||||
this.router.get('/items/:id/audio-metadata', LibraryItemController.middleware.bind(this), LibraryItemController.updateAudioFileMetadata.bind(this))
|
||||
this.router.post('/items/:id/chapters', LibraryItemController.middleware.bind(this), LibraryItemController.updateMediaChapters.bind(this))
|
||||
this.router.post('/items/:id/open-feed', LibraryItemController.middleware.bind(this), LibraryItemController.openRSSFeed.bind(this))
|
||||
this.router.post('/items/:id/close-feed', LibraryItemController.middleware.bind(this), LibraryItemController.closeRSSFeed.bind(this))
|
||||
this.router.post('/items/:id/tone-scan/:index?', LibraryItemController.middleware.bind(this), LibraryItemController.toneScan.bind(this))
|
||||
|
||||
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))
|
||||
|
||||
//
|
||||
// User Routes
|
||||
//
|
||||
this.router.post('/users', UserController.create.bind(this))
|
||||
this.router.get('/users', UserController.findAll.bind(this))
|
||||
this.router.get('/users/:id', UserController.findOne.bind(this))
|
||||
this.router.patch('/users/:id', UserController.update.bind(this))
|
||||
this.router.delete('/users/:id', UserController.delete.bind(this))
|
||||
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.get('/users/:id/listening-sessions', UserController.getListeningSessions.bind(this))
|
||||
this.router.get('/users/:id/listening-stats', UserController.getListeningStats.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))
|
||||
this.router.post('/users/:id/purge-media-progress', UserController.middleware.bind(this), UserController.purgeMediaProgress.bind(this))
|
||||
|
||||
//
|
||||
// Collection Routes
|
||||
|
|
@ -132,6 +142,7 @@ class ApiRouter {
|
|||
//
|
||||
this.router.get('/me/listening-sessions', MeController.getListeningSessions.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/:id', MeController.createUpdateMediaProgress.bind(this))
|
||||
|
|
@ -144,6 +155,8 @@ class ApiRouter {
|
|||
this.router.patch('/me/settings', MeController.updateSettings.bind(this))
|
||||
this.router.post('/me/sync-local-progress', MeController.syncLocalMediaProgress.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))
|
||||
|
||||
//
|
||||
// Backup Routes
|
||||
|
|
@ -172,6 +185,7 @@ class ApiRouter {
|
|||
//
|
||||
this.router.get('/series/search', SeriesController.search.bind(this))
|
||||
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
|
||||
|
|
@ -198,16 +212,28 @@ class ApiRouter {
|
|||
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
|
||||
//
|
||||
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))
|
||||
|
||||
//
|
||||
// Misc Routes
|
||||
//
|
||||
this.router.post('/upload', MiscController.handleUpload.bind(this))
|
||||
this.router.get('/audiobook-merge/:id', MiscController.mergeAudiobook.bind(this))
|
||||
this.router.get('/download/:id', MiscController.getDownload.bind(this))
|
||||
this.router.delete('/download/:id', MiscController.removeDownload.bind(this))
|
||||
this.router.get('/downloads', MiscController.getDownloads.bind(this))
|
||||
this.router.patch('/settings', MiscController.updateServerSettings.bind(this)) // Root only
|
||||
this.router.post('/purgecache', MiscController.purgeCache.bind(this)) // Root only
|
||||
this.router.get('/encode-m4b/:id', MiscController.encodeM4b.bind(this))
|
||||
this.router.post('/encode-m4b/:id/cancel', MiscController.cancelM4bEncode.bind(this))
|
||||
this.router.get('/tasks', MiscController.getTasks.bind(this))
|
||||
this.router.patch('/settings', MiscController.updateServerSettings.bind(this))
|
||||
this.router.post('/cache/purge', MiscController.purgeCache.bind(this))
|
||||
this.router.post('/cache/items/purge', MiscController.purgeItemsCache.bind(this))
|
||||
this.router.post('/authorize', MiscController.authorize.bind(this))
|
||||
this.router.get('/search/covers', MiscController.findCovers.bind(this))
|
||||
this.router.get('/search/books', MiscController.findBooks.bind(this))
|
||||
|
|
@ -258,12 +284,24 @@ class ApiRouter {
|
|||
}
|
||||
|
||||
json.mediaProgress = json.mediaProgress.map(lip => {
|
||||
var libraryItem = this.db.libraryItems.find(li => li.id === lip.id)
|
||||
var libraryItem = this.db.libraryItems.find(li => li.id === lip.libraryItemId)
|
||||
if (!libraryItem) {
|
||||
Logger.warn('[ApiRouter] Library item not found for users progress ' + lip.id)
|
||||
return null
|
||||
Logger.warn('[ApiRouter] Library item not found for users progress ' + lip.libraryItemId)
|
||||
lip.media = null
|
||||
} else {
|
||||
if (lip.episodeId) {
|
||||
const episode = libraryItem.mediaType === 'podcast' ? libraryItem.media.getEpisode(lip.episodeId) : null
|
||||
if (!episode) {
|
||||
Logger.warn(`[ApiRouter] Episode ${lip.episodeId} not found for user media progress, podcast: ${libraryItem.media.metadata.title}`)
|
||||
lip.media = null
|
||||
} else {
|
||||
lip.media = libraryItem.media.toJSONExpanded()
|
||||
lip.episode = episode.toJSON()
|
||||
}
|
||||
} else {
|
||||
lip.media = libraryItem.media.toJSONExpanded()
|
||||
}
|
||||
}
|
||||
lip.media = libraryItem.media.toJSONExpanded()
|
||||
return lip
|
||||
}).filter(lip => !!lip)
|
||||
|
||||
|
|
@ -274,7 +312,7 @@ class ApiRouter {
|
|||
// Remove libraryItem from users
|
||||
for (let i = 0; i < this.db.users.length; i++) {
|
||||
var user = this.db.users[i]
|
||||
var madeUpdates = user.removeMediaProgress(libraryItem.id)
|
||||
var madeUpdates = user.removeMediaProgressForLibraryItem(libraryItem.id)
|
||||
if (madeUpdates) {
|
||||
await this.db.updateEntity('user', user)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,8 @@ const Path = require('path')
|
|||
const AudioFile = require('../objects/files/AudioFile')
|
||||
const VideoFile = require('../objects/files/VideoFile')
|
||||
|
||||
const MediaProbePool = require('./MediaProbePool')
|
||||
|
||||
const prober = require('../utils/prober')
|
||||
const toneProber = require('../utils/toneProber')
|
||||
const Logger = require('../Logger')
|
||||
const { LogLevel } = require('../utils/constants')
|
||||
|
||||
|
|
@ -59,7 +58,16 @@ class MediaFileScanner {
|
|||
|
||||
async scan(mediaType, libraryFile, mediaMetadataFromScan, verbose = false) {
|
||||
var probeStart = Date.now()
|
||||
var probeData = await prober.probe(libraryFile.metadata.path, verbose)
|
||||
|
||||
var probeData = null
|
||||
// TODO: Temp not using tone for probing until more testing can be done
|
||||
// if (global.ServerSettings.scannerUseTone) {
|
||||
// Logger.debug(`[MediaFileScanner] using tone to probe audio file "${libraryFile.metadata.path}"`)
|
||||
// probeData = await toneProber.probe(libraryFile.metadata.path, true)
|
||||
// } else {
|
||||
probeData = await prober.probe(libraryFile.metadata.path, verbose)
|
||||
// }
|
||||
|
||||
if (probeData.error) {
|
||||
Logger.error(`[MediaFileScanner] ${probeData.error} : "${libraryFile.metadata.path}"`)
|
||||
return null
|
||||
|
|
@ -105,35 +113,18 @@ class MediaFileScanner {
|
|||
async executeMediaFileScans(libraryItem, mediaLibraryFiles, scanData) {
|
||||
const mediaType = libraryItem.mediaType
|
||||
|
||||
if (!global.ServerSettings.scannerUseSingleThreadedProber) { // New multi-threaded scanner
|
||||
var scanStart = Date.now()
|
||||
const probeResults = await new Promise((resolve) => {
|
||||
// const probePool = new MediaProbePool(mediaType, mediaLibraryFiles, scanData, global.ServerSettings.scannerMaxThreads)
|
||||
const itemBatch = MediaProbePool.initBatch(libraryItem, mediaLibraryFiles, scanData)
|
||||
itemBatch.on('done', resolve)
|
||||
MediaProbePool.runBatch(itemBatch)
|
||||
})
|
||||
|
||||
return {
|
||||
audioFiles: probeResults.audioFiles || [],
|
||||
videoFiles: probeResults.videoFiles || [],
|
||||
elapsed: Date.now() - scanStart,
|
||||
averageScanDuration: probeResults.averageTimePerMb
|
||||
}
|
||||
} else { // Old single threaded scanner
|
||||
var scanStart = Date.now()
|
||||
var mediaMetadataFromScan = scanData.media.metadata || null
|
||||
var proms = []
|
||||
for (let i = 0; i < mediaLibraryFiles.length; i++) {
|
||||
proms.push(this.scan(mediaType, mediaLibraryFiles[i], mediaMetadataFromScan))
|
||||
}
|
||||
var results = await Promise.all(proms).then((scanResults) => scanResults.filter(sr => sr))
|
||||
return {
|
||||
audioFiles: results.filter(r => r.audioFile).map(r => r.audioFile),
|
||||
videoFiles: results.filter(r => r.videoFile).map(r => r.videoFile),
|
||||
elapsed: Date.now() - scanStart,
|
||||
averageScanDuration: this.getAverageScanDurationMs(results)
|
||||
}
|
||||
var scanStart = Date.now()
|
||||
var mediaMetadataFromScan = scanData.media.metadata || null
|
||||
var proms = []
|
||||
for (let i = 0; i < mediaLibraryFiles.length; i++) {
|
||||
proms.push(this.scan(mediaType, mediaLibraryFiles[i], mediaMetadataFromScan))
|
||||
}
|
||||
var results = await Promise.all(proms).then((scanResults) => scanResults.filter(sr => sr))
|
||||
return {
|
||||
audioFiles: results.filter(r => r.audioFile).map(r => r.audioFile),
|
||||
videoFiles: results.filter(r => r.videoFile).map(r => r.videoFile),
|
||||
elapsed: Date.now() - scanStart,
|
||||
averageScanDuration: this.getAverageScanDurationMs(results)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -299,5 +290,10 @@ class MediaFileScanner {
|
|||
|
||||
return hasUpdated
|
||||
}
|
||||
|
||||
probeAudioFileWithTone(audioFile) {
|
||||
Logger.debug(`[MediaFileScanner] using tone to probe audio file "${audioFile.metadata.path}"`)
|
||||
return toneProber.rawProbe(audioFile.metadata.path)
|
||||
}
|
||||
}
|
||||
module.exports = new MediaFileScanner()
|
||||
|
|
@ -66,15 +66,20 @@ class MediaProbeData {
|
|||
this.sampleRate = audioStream.sample_rate
|
||||
this.chapters = data.chapters || []
|
||||
|
||||
var metatags = {}
|
||||
for (const key in data) {
|
||||
if (data[key] && key.startsWith('file_tag')) {
|
||||
metatags[key] = data[key]
|
||||
if (data.tags) { // New for tone library data (toneProber.js)
|
||||
this.audioFileMetadata = new AudioFileMetadata()
|
||||
this.audioFileMetadata.setDataFromTone(data.tags)
|
||||
} else { // Data from ffprobe (prober.js)
|
||||
var metatags = {}
|
||||
for (const key in data) {
|
||||
if (data[key] && key.startsWith('file_tag')) {
|
||||
metatags[key] = data[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.audioFileMetadata = new AudioFileMetadata()
|
||||
this.audioFileMetadata.setData(metatags)
|
||||
this.audioFileMetadata = new AudioFileMetadata()
|
||||
this.audioFileMetadata.setData(metatags)
|
||||
}
|
||||
|
||||
// Track ID3 tag might be "3/10" or just "3"
|
||||
if (this.audioFileMetadata.tagTrack) {
|
||||
|
|
|
|||
|
|
@ -1,209 +0,0 @@
|
|||
const os = require('os')
|
||||
const Path = require('path')
|
||||
const { EventEmitter } = require('events')
|
||||
const { Worker } = require("worker_threads")
|
||||
const Logger = require('../Logger')
|
||||
const AudioFile = require('../objects/files/AudioFile')
|
||||
const VideoFile = require('../objects/files/VideoFile')
|
||||
const MediaProbeData = require('./MediaProbeData')
|
||||
|
||||
class LibraryItemBatch extends EventEmitter {
|
||||
constructor(libraryItem, libraryFiles, scanData) {
|
||||
super()
|
||||
|
||||
this.id = libraryItem.id
|
||||
this.mediaType = libraryItem.mediaType
|
||||
this.mediaMetadataFromScan = scanData.media.metadata || null
|
||||
this.libraryFilesToScan = libraryFiles
|
||||
|
||||
// Results
|
||||
this.totalElapsed = 0
|
||||
this.totalProbed = 0
|
||||
this.audioFiles = []
|
||||
this.videoFiles = []
|
||||
}
|
||||
|
||||
done() {
|
||||
this.emit('done', {
|
||||
videoFiles: this.videoFiles,
|
||||
audioFiles: this.audioFiles,
|
||||
averageTimePerMb: Math.round(this.totalElapsed / this.totalProbed)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
class MediaProbePool {
|
||||
constructor() {
|
||||
this.MaxThreads = 0
|
||||
this.probeWorkerScript = null
|
||||
|
||||
this.itemBatchMap = {}
|
||||
|
||||
this.probesRunning = []
|
||||
this.probeQueue = []
|
||||
}
|
||||
|
||||
tick() {
|
||||
if (this.probesRunning.length < this.MaxThreads) {
|
||||
if (this.probeQueue.length > 0) {
|
||||
const pw = this.probeQueue.shift()
|
||||
// console.log('Unqueued probe - Remaining is', this.probeQueue.length, 'Currently running is', this.probesRunning.length)
|
||||
this.startTask(pw)
|
||||
} else if (!this.probesRunning.length) {
|
||||
// console.log('No more probes to run')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async startTask(task) {
|
||||
this.probesRunning.push(task)
|
||||
|
||||
const itemBatch = this.itemBatchMap[task.batchId]
|
||||
|
||||
await task.start().then((taskResult) => {
|
||||
itemBatch.libraryFilesToScan = itemBatch.libraryFilesToScan.filter(lf => lf.ino !== taskResult.libraryFile.ino)
|
||||
|
||||
var fileSizeMb = taskResult.libraryFile.metadata.size / (1024 * 1024)
|
||||
var elapsedPerMb = Math.round(taskResult.elapsed / fileSizeMb)
|
||||
|
||||
const probeData = new MediaProbeData(taskResult.data)
|
||||
|
||||
if (itemBatch.mediaType === 'video') {
|
||||
if (!probeData.videoStream) {
|
||||
Logger.error('[MediaProbePool] Invalid video file no video stream')
|
||||
} else {
|
||||
itemBatch.totalElapsed += elapsedPerMb
|
||||
itemBatch.totalProbed++
|
||||
|
||||
var videoFile = new VideoFile()
|
||||
videoFile.setDataFromProbe(libraryFile, probeData)
|
||||
itemBatch.videoFiles.push(videoFile)
|
||||
}
|
||||
} else {
|
||||
if (!probeData.audioStream) {
|
||||
Logger.error('[MediaProbePool] Invalid audio file no audio stream')
|
||||
} else {
|
||||
itemBatch.totalElapsed += elapsedPerMb
|
||||
itemBatch.totalProbed++
|
||||
|
||||
var audioFile = new AudioFile()
|
||||
audioFile.trackNumFromMeta = probeData.trackNumber
|
||||
audioFile.discNumFromMeta = probeData.discNumber
|
||||
if (itemBatch.mediaType === 'book') {
|
||||
const { trackNumber, discNumber } = this.getTrackAndDiscNumberFromFilename(itemBatch.mediaMetadataFromScan, taskResult.libraryFile)
|
||||
audioFile.trackNumFromFilename = trackNumber
|
||||
audioFile.discNumFromFilename = discNumber
|
||||
}
|
||||
audioFile.setDataFromProbe(taskResult.libraryFile, probeData)
|
||||
|
||||
itemBatch.audioFiles.push(audioFile)
|
||||
}
|
||||
}
|
||||
|
||||
this.probesRunning = this.probesRunning.filter(tq => tq.mediaPath !== task.mediaPath)
|
||||
this.tick()
|
||||
}).catch((error) => {
|
||||
itemBatch.libraryFilesToScan = itemBatch.libraryFilesToScan.filter(lf => lf.ino !== taskResult.libraryFile.ino)
|
||||
|
||||
Logger.error('[MediaProbePool] Task failed', error)
|
||||
this.probesRunning = this.probesRunning.filter(tq => tq.mediaPath !== task.mediaPath)
|
||||
this.tick()
|
||||
})
|
||||
|
||||
if (!itemBatch.libraryFilesToScan.length) {
|
||||
itemBatch.done()
|
||||
delete this.itemBatchMap[itemBatch.id]
|
||||
}
|
||||
}
|
||||
|
||||
buildTask(libraryFile, batchId) {
|
||||
return {
|
||||
batchId,
|
||||
mediaPath: libraryFile.metadata.path,
|
||||
start: () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startTime = Date.now()
|
||||
|
||||
const worker = new Worker(this.probeWorkerScript)
|
||||
worker.on("message", ({ data }) => {
|
||||
if (data.error) {
|
||||
reject(data.error)
|
||||
} else {
|
||||
resolve({
|
||||
data,
|
||||
elapsed: Date.now() - startTime,
|
||||
libraryFile
|
||||
})
|
||||
}
|
||||
})
|
||||
worker.postMessage({
|
||||
mediaPath: libraryFile.metadata.path
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initBatch(libraryItem, libraryFiles, scanData) {
|
||||
this.MaxThreads = global.ServerSettings.scannerMaxThreads || (os.cpus().length * 2)
|
||||
this.probeWorkerScript = Path.join(global.appRoot, 'server/utils/probeWorker.js')
|
||||
|
||||
Logger.debug(`[MediaProbePool] Run item batch ${libraryItem.id} with`, libraryFiles.length, 'files and max concurrent of', this.MaxThreads)
|
||||
|
||||
const itemBatch = new LibraryItemBatch(libraryItem, libraryFiles, scanData)
|
||||
this.itemBatchMap[itemBatch.id] = itemBatch
|
||||
|
||||
return itemBatch
|
||||
}
|
||||
|
||||
runBatch(itemBatch) {
|
||||
for (const libraryFile of itemBatch.libraryFilesToScan) {
|
||||
const probeTask = this.buildTask(libraryFile, itemBatch.id)
|
||||
|
||||
if (this.probesRunning.length < this.MaxThreads) {
|
||||
this.startTask(probeTask)
|
||||
} else {
|
||||
this.probeQueue.push(probeTask)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getTrackAndDiscNumberFromFilename(mediaMetadataFromScan, audioLibraryFile) {
|
||||
const { title, author, series, publishedYear } = mediaMetadataFromScan
|
||||
const { filename, path } = audioLibraryFile.metadata
|
||||
var partbasename = Path.basename(filename, Path.extname(filename))
|
||||
|
||||
// Remove title, author, series, and publishedYear from filename if there
|
||||
if (title) partbasename = partbasename.replace(title, '')
|
||||
if (author) partbasename = partbasename.replace(author, '')
|
||||
if (series) partbasename = partbasename.replace(series, '')
|
||||
if (publishedYear) partbasename = partbasename.replace(publishedYear)
|
||||
|
||||
// Look for disc number
|
||||
var discNumber = null
|
||||
var discMatch = partbasename.match(/\b(disc|cd) ?(\d\d?)\b/i)
|
||||
if (discMatch && discMatch.length > 2 && discMatch[2]) {
|
||||
if (!isNaN(discMatch[2])) {
|
||||
discNumber = Number(discMatch[2])
|
||||
}
|
||||
|
||||
// Remove disc number from filename
|
||||
partbasename = partbasename.replace(/\b(disc|cd) ?(\d\d?)\b/i, '')
|
||||
}
|
||||
|
||||
// Look for disc number in folder path e.g. /Book Title/CD01/audiofile.mp3
|
||||
var pathdir = Path.dirname(path).split('/').pop()
|
||||
if (pathdir && /^cd\d{1,3}$/i.test(pathdir)) {
|
||||
var discFromFolder = Number(pathdir.replace(/cd/i, ''))
|
||||
if (!isNaN(discFromFolder) && discFromFolder !== null) discNumber = discFromFolder
|
||||
}
|
||||
|
||||
var numbersinpath = partbasename.match(/\d{1,4}/g)
|
||||
var trackNumber = numbersinpath && numbersinpath.length ? parseInt(numbersinpath[0]) : null
|
||||
return {
|
||||
trackNumber,
|
||||
discNumber
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = new MediaProbePool()
|
||||
|
|
@ -7,6 +7,7 @@ const { groupFilesIntoLibraryItemPaths, getLibraryItemFileData, scanFolder } = r
|
|||
const { comparePaths } = require('../utils/index')
|
||||
const { getIno } = require('../utils/fileUtils')
|
||||
const { ScanResult, LogLevel } = require('../utils/constants')
|
||||
const { findMatchingEpisodesInFeed, getPodcastFeed } = require('../utils/podcastUtils')
|
||||
|
||||
const MediaFileScanner = require('./MediaFileScanner')
|
||||
const BookFinder = require('../finders/BookFinder')
|
||||
|
|
@ -674,9 +675,11 @@ class Scanner {
|
|||
var provider = options.provider || 'google'
|
||||
var searchTitle = options.title || libraryItem.media.metadata.title
|
||||
var searchAuthor = options.author || libraryItem.media.metadata.authorName
|
||||
var overrideDefaults = options.overrideDefaults || false
|
||||
|
||||
// Set to override existing metadata if scannerPreferMatchedMetadata setting is true
|
||||
if (this.db.serverSettings.scannerPreferMatchedMetadata) {
|
||||
// Set to override existing metadata if scannerPreferMatchedMetadata setting is true and
|
||||
// the overrideDefaults option is not set or set to false.
|
||||
if ((overrideDefaults == false) && (this.db.serverSettings.scannerPreferMatchedMetadata)) {
|
||||
options.overrideCover = true
|
||||
options.overrideDetails = true
|
||||
}
|
||||
|
|
@ -684,7 +687,7 @@ class Scanner {
|
|||
var updatePayload = {}
|
||||
var hasUpdated = false
|
||||
|
||||
if (libraryItem.mediaType === 'book') {
|
||||
if (libraryItem.isBook) {
|
||||
var searchISBN = options.isbn || libraryItem.media.metadata.isbn
|
||||
var searchASIN = options.asin || libraryItem.media.metadata.asin
|
||||
|
||||
|
|
@ -708,7 +711,7 @@ class Scanner {
|
|||
}
|
||||
|
||||
updatePayload = await this.quickMatchBookBuildUpdatePayload(libraryItem, matchData, options)
|
||||
} else { // Podcast quick match
|
||||
} else if (libraryItem.isPodcast) { // Podcast quick match
|
||||
var results = await this.podcastFinder.search(searchTitle)
|
||||
if (!results.length) {
|
||||
return {
|
||||
|
|
@ -739,6 +742,10 @@ class Scanner {
|
|||
}
|
||||
|
||||
if (hasUpdated) {
|
||||
if (libraryItem.isPodcast && libraryItem.media.metadata.feedUrl) { // Quick match all unmatched podcast episodes
|
||||
await this.quickMatchPodcastEpisodes(libraryItem, options)
|
||||
}
|
||||
|
||||
await this.db.updateLibraryItem(libraryItem)
|
||||
this.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
}
|
||||
|
|
@ -762,16 +769,23 @@ class Scanner {
|
|||
itunesArtistId: matchData.artistId || null,
|
||||
releaseDate: matchData.releaseDate || null,
|
||||
imageUrl: matchData.cover || null,
|
||||
feedUrl: matchData.feedUrl || null,
|
||||
description: matchData.descriptionPlain || null
|
||||
}
|
||||
|
||||
for (const key in matchDataTransformed) {
|
||||
if (matchDataTransformed[key]) {
|
||||
if (key === 'genres') {
|
||||
if ((!libraryItem.media.metadata.genres || options.overrideDetails)) {
|
||||
updatePayload.metadata[key] = matchDataTransformed[key].split(',').map(v => v.trim()).filter(v => !!v)
|
||||
if ((!libraryItem.media.metadata.genres.length || options.overrideDetails)) {
|
||||
var genresArray = []
|
||||
if (Array.isArray(matchDataTransformed[key])) genresArray = [...matchDataTransformed[key]]
|
||||
else { // Genres should always be passed in as an array but just incase handle a string
|
||||
Logger.warn(`[Scanner] quickMatch genres is not an array ${matchDataTransformed[key]}`)
|
||||
genresArray = matchDataTransformed[key].split(',').map(v => v.trim()).filter(v => !!v)
|
||||
}
|
||||
updatePayload.metadata[key] = genresArray
|
||||
}
|
||||
} else if (!libraryItem.media.metadata[key] || options.overrideDetails) {
|
||||
} else if (libraryItem.media.metadata[key] !== matchDataTransformed[key] && (!libraryItem.media.metadata[key] || options.overrideDetails)) {
|
||||
updatePayload.metadata[key] = matchDataTransformed[key]
|
||||
}
|
||||
}
|
||||
|
|
@ -789,6 +803,7 @@ class Scanner {
|
|||
const detailKeysToUpdate = ['title', 'subtitle', 'description', 'narrator', 'publisher', 'publishedYear', 'genres', 'tags', 'language', 'explicit', 'asin', 'isbn']
|
||||
const updatePayload = {}
|
||||
updatePayload.metadata = {}
|
||||
|
||||
for (const key in matchData) {
|
||||
if (matchData[key] && detailKeysToUpdate.includes(key)) {
|
||||
if (key === 'narrator') {
|
||||
|
|
@ -796,12 +811,21 @@ class Scanner {
|
|||
updatePayload.metadata.narrators = matchData[key].split(',').map(v => v.trim()).filter(v => !!v)
|
||||
}
|
||||
} else if (key === 'genres') {
|
||||
if ((!libraryItem.media.metadata.genres || options.overrideDetails)) {
|
||||
updatePayload.metadata[key] = matchData[key].split(',').map(v => v.trim()).filter(v => !!v)
|
||||
if ((!libraryItem.media.metadata.genres.length || options.overrideDetails)) {
|
||||
var genresArray = []
|
||||
if (Array.isArray(matchData[key])) genresArray = [...matchData[key]]
|
||||
else { // Genres should always be passed in as an array but just incase handle a string
|
||||
Logger.warn(`[Scanner] quickMatch genres is not an array ${matchData[key]}`)
|
||||
genresArray = matchData[key].split(',').map(v => v.trim()).filter(v => !!v)
|
||||
}
|
||||
updatePayload.metadata[key] = genresArray
|
||||
}
|
||||
} else if (key === 'tags') {
|
||||
if ((!libraryItem.media.tags || options.overrideDetails)) {
|
||||
updatePayload[key] = matchData[key].split(',').map(v => v.trim()).filter(v => !!v)
|
||||
if ((!libraryItem.media.tags.length || options.overrideDetails)) {
|
||||
var tagsArray = []
|
||||
if (Array.isArray(matchData[key])) tagsArray = [...matchData[key]]
|
||||
else tagsArray = matchData[key].split(',').map(v => v.trim()).filter(v => !!v)
|
||||
updatePayload[key] = tagsArray
|
||||
}
|
||||
} else if ((!libraryItem.media.metadata[key] || options.overrideDetails)) {
|
||||
updatePayload.metadata[key] = matchData[key]
|
||||
|
|
@ -831,7 +855,7 @@ class Scanner {
|
|||
|
||||
// Add or set series if not set
|
||||
if (matchData.series && (!libraryItem.media.metadata.seriesName || options.overrideDetails)) {
|
||||
if (!Array.isArray(matchData.series)) matchData.series = [{ series: matchData.series, volumeNumber: matchData.volumeNumber }]
|
||||
if (!Array.isArray(matchData.series)) matchData.series = [{ series: matchData.series, sequence: matchData.sequence }]
|
||||
const seriesPayload = []
|
||||
for (let index = 0; index < matchData.series.length; index++) {
|
||||
const seriesMatchItem = matchData.series[index]
|
||||
|
|
@ -842,7 +866,7 @@ class Scanner {
|
|||
await this.db.insertEntity('series', seriesItem)
|
||||
this.emitter('series_added', seriesItem)
|
||||
}
|
||||
seriesPayload.push(seriesItem.toJSONMinimal(seriesMatchItem.volumeNumber))
|
||||
seriesPayload.push(seriesItem.toJSONMinimal(seriesMatchItem.sequence))
|
||||
}
|
||||
updatePayload.metadata.series = seriesPayload
|
||||
}
|
||||
|
|
@ -854,6 +878,61 @@ class Scanner {
|
|||
return updatePayload
|
||||
}
|
||||
|
||||
async quickMatchPodcastEpisodes(libraryItem, options = {}) {
|
||||
const episodesToQuickMatch = libraryItem.media.episodes.filter(ep => !ep.enclosureUrl) // Only quick match episodes without enclosure
|
||||
if (!episodesToQuickMatch.length) return false
|
||||
|
||||
const feed = await getPodcastFeed(libraryItem.media.metadata.feedUrl)
|
||||
if (!feed) {
|
||||
Logger.error(`[Scanner] quickMatchPodcastEpisodes: Unable to quick match episodes feed not found for "${libraryItem.media.metadata.feedUrl}"`)
|
||||
return false
|
||||
}
|
||||
|
||||
var episodesWereUpdated = false
|
||||
for (const episode of episodesToQuickMatch) {
|
||||
const episodeMatches = findMatchingEpisodesInFeed(feed, episode.title)
|
||||
if (episodeMatches && episodeMatches.length) {
|
||||
const wasUpdated = this.updateEpisodeWithMatch(libraryItem, episode, episodeMatches[0].episode, options)
|
||||
if (wasUpdated) episodesWereUpdated = true
|
||||
}
|
||||
}
|
||||
return episodesWereUpdated
|
||||
}
|
||||
|
||||
updateEpisodeWithMatch(libraryItem, episode, episodeToMatch, options = {}) {
|
||||
Logger.debug(`[Scanner] quickMatchPodcastEpisodes: Found episode match for "${episode.title}" => ${episodeToMatch.title}`)
|
||||
const matchDataTransformed = {
|
||||
title: episodeToMatch.title || '',
|
||||
subtitle: episodeToMatch.subtitle || '',
|
||||
description: episodeToMatch.description || '',
|
||||
enclosure: episodeToMatch.enclosure || null,
|
||||
episode: episodeToMatch.episode || '',
|
||||
episodeType: episodeToMatch.episodeType || '',
|
||||
season: episodeToMatch.season || '',
|
||||
pubDate: episodeToMatch.pubDate || '',
|
||||
publishedAt: episodeToMatch.publishedAt
|
||||
}
|
||||
const updatePayload = {}
|
||||
for (const key in matchDataTransformed) {
|
||||
if (matchDataTransformed[key]) {
|
||||
if (key === 'enclosure') {
|
||||
if (!episode.enclosure || JSON.stringify(episode.enclosure) !== JSON.stringify(matchDataTransformed.enclosure)) {
|
||||
updatePayload[key] = {
|
||||
...matchDataTransformed.enclosure
|
||||
}
|
||||
}
|
||||
} else if (episode[key] !== matchDataTransformed[key] && (!episode[key] || options.overrideDetails)) {
|
||||
updatePayload[key] = matchDataTransformed[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updatePayload).length) {
|
||||
return libraryItem.media.updateEpisode(episode.id, updatePayload)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async matchLibraryItems(library) {
|
||||
if (library.mediaType === 'podcast') {
|
||||
Logger.error(`[Scanner] matchLibraryItems: Match all not supported for podcasts yet`)
|
||||
|
|
@ -917,5 +996,9 @@ class Scanner {
|
|||
this.librariesScanning = this.librariesScanning.filter(ls => ls.id !== library.id)
|
||||
this.emitter('scan_complete', libraryScan.getScanEmitData)
|
||||
}
|
||||
|
||||
probeAudioFileWithTone(audioFile) {
|
||||
return MediaFileScanner.probeAudioFileWithTone(audioFile)
|
||||
}
|
||||
}
|
||||
module.exports = Scanner
|
||||
|
|
@ -13,7 +13,7 @@ module.exports.BookCoverAspectRatio = {
|
|||
|
||||
module.exports.BookshelfView = {
|
||||
STANDARD: 0,
|
||||
TITLES: 1
|
||||
DETAIL: 1
|
||||
}
|
||||
|
||||
module.exports.LogLevel = {
|
||||
|
|
@ -44,7 +44,9 @@ module.exports.AudioMimeType = {
|
|||
AAC: 'audio/aac',
|
||||
FLAC: 'audio/flac',
|
||||
WMA: 'audio/x-ms-wma',
|
||||
AIFF: 'audio/x-aiff'
|
||||
AIFF: 'audio/x-aiff',
|
||||
WEBM: 'audio/webm',
|
||||
WEBMA: 'audio/webm'
|
||||
}
|
||||
|
||||
module.exports.VideoMimeType = {
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ async function recurseFiles(path, relPathToReplace = null) {
|
|||
return true
|
||||
}).filter(item => {
|
||||
// Filter out items in ignore directories
|
||||
if (directoriesToIgnore.includes(Path.dirname(item.fullname))) {
|
||||
if (directoriesToIgnore.some(dir => item.fullname.startsWith(dir))) {
|
||||
Logger.debug(`[fileUtils] Ignoring path in dir with .ignore "${item.fullname}"`)
|
||||
return false
|
||||
}
|
||||
|
|
@ -168,7 +168,8 @@ module.exports.downloadFile = async (url, filepath) => {
|
|||
const response = await axios({
|
||||
url,
|
||||
method: 'GET',
|
||||
responseType: 'stream'
|
||||
responseType: 'stream',
|
||||
timeout: 30000
|
||||
})
|
||||
response.data.pipe(writer)
|
||||
return new Promise((resolve, reject) => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
const globals = {
|
||||
SupportedImageTypes: ['png', 'jpg', 'jpeg', 'webp'],
|
||||
SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav'],
|
||||
SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma'],
|
||||
SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
|
||||
SupportedVideoTypes: ['mp4'],
|
||||
TextFileTypes: ['txt', 'nfo'],
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ function elapsedPretty(seconds) {
|
|||
}
|
||||
module.exports.elapsedPretty = elapsedPretty
|
||||
|
||||
function secondsToTimestamp(seconds, includeMs = false) {
|
||||
function secondsToTimestamp(seconds, includeMs = false, alwaysIncludeHours = false) {
|
||||
var _seconds = seconds
|
||||
var _minutes = Math.floor(seconds / 60)
|
||||
_seconds -= _minutes * 60
|
||||
|
|
@ -91,6 +91,9 @@ function secondsToTimestamp(seconds, includeMs = false) {
|
|||
_seconds = Math.floor(_seconds)
|
||||
|
||||
var msString = '.' + (includeMs ? ms.toFixed(3) : '0.0').split('.')[1]
|
||||
if (alwaysIncludeHours) {
|
||||
return `${_hours.toString().padStart(2, '0')}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}${msString}`
|
||||
}
|
||||
if (!_hours) {
|
||||
return `${_minutes}:${_seconds.toString().padStart(2, '0')}${msString}`
|
||||
}
|
||||
|
|
@ -137,14 +140,24 @@ module.exports.cleanStringForSearch = (str) => {
|
|||
return str.toLowerCase().replace(/[\'\.\`\",]/g, '').trim()
|
||||
}
|
||||
|
||||
module.exports.getTitleIgnorePrefix = (title) => {
|
||||
if (!title) return ''
|
||||
const getTitleParts = (title) => {
|
||||
if (!title) return ['', null]
|
||||
var prefixesToIgnore = global.ServerSettings.sortingPrefixes || []
|
||||
prefixes = []
|
||||
for (const prefix of prefixesToIgnore) {
|
||||
// e.g. for prefix "the". If title is "The Book" return "Book, The"
|
||||
if (title.toLowerCase().startsWith(`${prefix} `)) {
|
||||
return title.substr(prefix.length + 1) + `, ${prefix.substr(0, 1).toUpperCase() + prefix.substr(1)}`
|
||||
return [title.substr(prefix.length + 1), `${prefix.substr(0, 1).toUpperCase() + prefix.substr(1)}`]
|
||||
}
|
||||
}
|
||||
return title
|
||||
return [title, null]
|
||||
}
|
||||
|
||||
module.exports.getTitleIgnorePrefix = (title) => {
|
||||
return getTitleParts(title)[0]
|
||||
}
|
||||
|
||||
module.exports.getTitlePrefixAtEnd = (title) => {
|
||||
let [sort, prefix] = getTitleParts(title)
|
||||
return prefix ? `${sort}, ${prefix}` : title
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
const { sort, createNewSortInstance } = require('../libs/fastSort')
|
||||
const { getTitleIgnorePrefix } = require('../utils/index')
|
||||
const { getTitlePrefixAtEnd, isNullOrNaN, getTitleIgnorePrefix } = require('../utils/index')
|
||||
const naturalSort = createNewSortInstance({
|
||||
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
|
||||
})
|
||||
|
|
@ -67,6 +67,45 @@ module.exports = {
|
|||
return filtered
|
||||
},
|
||||
|
||||
// Returns false if should be filtered out
|
||||
checkFilterForSeriesLibraryItem(libraryItem, filterBy) {
|
||||
var searchGroups = ['genres', 'tags', 'authors', 'progress', 'narrators', 'languages']
|
||||
var group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
|
||||
if (group) {
|
||||
var filterVal = filterBy.replace(`${group}.`, '')
|
||||
var filter = this.decode(filterVal)
|
||||
|
||||
if (group === 'genres') return libraryItem.media.metadata && libraryItem.media.metadata.genres.includes(filter)
|
||||
else if (group === 'tags') return libraryItem.media.tags.includes(filter)
|
||||
else if (group === 'authors') return libraryItem.mediaType === 'book' && libraryItem.media.metadata.hasAuthor(filter)
|
||||
else if (group === 'narrators') return libraryItem.mediaType === 'book' && libraryItem.media.metadata.hasNarrator(filter)
|
||||
else if (group === 'languages') {
|
||||
return libraryItem.media.metadata && libraryItem.media.metadata.language === filter
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
|
||||
// Return false to filter out series
|
||||
checkSeriesProgressFilter(series, filterBy, user) {
|
||||
const filter = this.decode(filterBy.split('.')[1])
|
||||
|
||||
var numBooksStartedOrFinished = 0
|
||||
for (const libraryItem of series.books) {
|
||||
const itemProgress = user.getMediaProgress(libraryItem.id)
|
||||
if (filter === 'Finished' && (!itemProgress || !itemProgress.isFinished)) return false
|
||||
if (filter === 'Not Started' && itemProgress) return false
|
||||
if (itemProgress) numBooksStartedOrFinished++
|
||||
}
|
||||
|
||||
if (numBooksStartedOrFinished === series.books.length) { // Completely finished series
|
||||
if (filter === 'Not Finished') return false
|
||||
} else if (numBooksStartedOrFinished === 0 && filter === 'In Progress') { // Series not started
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
|
||||
getDistinctFilterDataNew(libraryItems) {
|
||||
var data = {
|
||||
authors: [],
|
||||
|
|
@ -114,27 +153,62 @@ module.exports = {
|
|||
return data
|
||||
},
|
||||
|
||||
getSeriesFromBooks(books, minified = false) {
|
||||
var _series = {}
|
||||
getSeriesFromBooks(books, allSeries, filterSeries, filterBy, user, minified = false) {
|
||||
const _series = {}
|
||||
const seriesToFilterOut = {}
|
||||
books.forEach((libraryItem) => {
|
||||
var bookSeries = libraryItem.media.metadata.series || []
|
||||
bookSeries.forEach((series) => {
|
||||
var abJson = minified ? libraryItem.toJSONMinified() : libraryItem.toJSONExpanded()
|
||||
abJson.sequence = series.sequence
|
||||
if (!_series[series.id]) {
|
||||
_series[series.id] = {
|
||||
id: series.id,
|
||||
name: series.name,
|
||||
nameIgnorePrefix: getTitleIgnorePrefix(series.name),
|
||||
// get all book series for item that is not already filtered out
|
||||
const bookSeries = (libraryItem.media.metadata.series || []).filter(se => !seriesToFilterOut[se.id])
|
||||
if (!bookSeries.length) return
|
||||
|
||||
if (filterBy && user && !filterBy.startsWith('progress.')) { // Series progress filters are evaluated after grouping
|
||||
// If a single book in a series is filtered out then filter out the entire series
|
||||
if (!this.checkFilterForSeriesLibraryItem(libraryItem, filterBy)) {
|
||||
// filter out this library item
|
||||
bookSeries.forEach((bookSeriesObj) => {
|
||||
// flag series to filter it out
|
||||
seriesToFilterOut[bookSeriesObj.id] = true
|
||||
delete _series[bookSeriesObj.id]
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
bookSeries.forEach((bookSeriesObj) => {
|
||||
const series = allSeries.find(se => se.id === bookSeriesObj.id)
|
||||
|
||||
const abJson = minified ? libraryItem.toJSONMinified() : libraryItem.toJSONExpanded()
|
||||
abJson.sequence = bookSeriesObj.sequence
|
||||
if (filterSeries) {
|
||||
abJson.filterSeriesSequence = libraryItem.media.metadata.getSeries(filterSeries).sequence
|
||||
}
|
||||
if (!_series[bookSeriesObj.id]) {
|
||||
_series[bookSeriesObj.id] = {
|
||||
id: bookSeriesObj.id,
|
||||
name: bookSeriesObj.name,
|
||||
nameIgnorePrefix: getTitlePrefixAtEnd(bookSeriesObj.name),
|
||||
nameIgnorePrefixSort: getTitleIgnorePrefix(bookSeriesObj.name),
|
||||
type: 'series',
|
||||
books: [abJson]
|
||||
books: [abJson],
|
||||
addedAt: series ? series.addedAt : 0,
|
||||
totalDuration: isNullOrNaN(abJson.media.duration) ? 0 : Number(abJson.media.duration)
|
||||
}
|
||||
|
||||
} else {
|
||||
_series[series.id].books.push(abJson)
|
||||
_series[bookSeriesObj.id].books.push(abJson)
|
||||
_series[bookSeriesObj.id].totalDuration += isNullOrNaN(abJson.media.duration) ? 0 : Number(abJson.media.duration)
|
||||
}
|
||||
})
|
||||
})
|
||||
return Object.values(_series).map((series) => {
|
||||
|
||||
var seriesItems = Object.values(_series)
|
||||
|
||||
// check progress filter
|
||||
if (filterBy && filterBy.startsWith('progress.') && user) {
|
||||
seriesItems = seriesItems.filter(se => this.checkSeriesProgressFilter(se, filterBy, user))
|
||||
}
|
||||
|
||||
return seriesItems.map((series) => {
|
||||
series.books = naturalSort(series.books).asc(li => li.sequence)
|
||||
return series
|
||||
})
|
||||
|
|
@ -209,34 +283,34 @@ module.exports = {
|
|||
return totalSize
|
||||
},
|
||||
|
||||
collapseBookSeries(libraryItems) {
|
||||
var seriesObjects = this.getSeriesFromBooks(libraryItems, true)
|
||||
var seriesToUse = {}
|
||||
var libraryItemIdsToHide = []
|
||||
seriesObjects.forEach((series) => {
|
||||
series.firstBook = series.books.find(b => !seriesToUse[b.id]) // Find first book not already used
|
||||
if (series.firstBook) {
|
||||
seriesToUse[series.firstBook.id] = series
|
||||
libraryItemIdsToHide = libraryItemIdsToHide.concat(series.books.filter(b => !seriesToUse[b.id]).map(b => b.id))
|
||||
}
|
||||
})
|
||||
|
||||
return libraryItems.map((li) => {
|
||||
collapseBookSeries(libraryItems, series, filterSeries) {
|
||||
// Get series from the library items. If this list is being collapsed after filtering for a series,
|
||||
// don't collapse that series, only books that are in other series.
|
||||
var seriesObjects = this
|
||||
.getSeriesFromBooks(libraryItems, series, filterSeries, null, null, true)
|
||||
.filter(s => s.id != filterSeries)
|
||||
|
||||
var filteredLibraryItems = []
|
||||
|
||||
libraryItems.forEach((li) => {
|
||||
if (li.mediaType != 'book') return
|
||||
var libraryItemJson = li.toJSONMinified()
|
||||
if (libraryItemIdsToHide.includes(li.id)) {
|
||||
return null
|
||||
}
|
||||
if (seriesToUse[li.id]) {
|
||||
libraryItemJson.collapsedSeries = {
|
||||
id: seriesToUse[li.id].id,
|
||||
name: seriesToUse[li.id].name,
|
||||
nameIgnorePrefix: seriesToUse[li.id].nameIgnorePrefix,
|
||||
numBooks: seriesToUse[li.id].books.length
|
||||
}
|
||||
}
|
||||
return libraryItemJson
|
||||
}).filter(li => li)
|
||||
|
||||
// Handle when this is the first book in a series
|
||||
seriesObjects.filter(s => s.books[0].id == li.id).forEach(series => {
|
||||
// Clone the library item as we need to attach data to it, but don't
|
||||
// want to change the global copy of the library item
|
||||
filteredLibraryItems.push(Object.assign(
|
||||
Object.create(Object.getPrototypeOf(li)),
|
||||
li, { collapsedSeries: series }))
|
||||
});
|
||||
|
||||
// Only included books not contained in series
|
||||
if (!seriesObjects.some(s => s.books.some(b => b.id == li.id)))
|
||||
filteredLibraryItems.push(li)
|
||||
});
|
||||
|
||||
return filteredLibraryItems
|
||||
},
|
||||
|
||||
buildPersonalizedShelves(user, libraryItems, mediaType, allSeries, allAuthors, maxEntitiesPerShelf = 10) {
|
||||
|
|
@ -246,6 +320,7 @@ module.exports = {
|
|||
{
|
||||
id: 'continue-listening',
|
||||
label: 'Continue Listening',
|
||||
labelStringKey: 'LabelContinueListening',
|
||||
type: isPodcastLibrary ? 'episode' : mediaType,
|
||||
entities: [],
|
||||
category: 'recentlyListened'
|
||||
|
|
@ -253,6 +328,7 @@ module.exports = {
|
|||
{
|
||||
id: 'continue-series',
|
||||
label: 'Continue Series',
|
||||
labelStringKey: 'LabelContinueSeries',
|
||||
type: mediaType,
|
||||
entities: [],
|
||||
category: 'continueSeries'
|
||||
|
|
@ -260,6 +336,7 @@ module.exports = {
|
|||
{
|
||||
id: 'recently-added',
|
||||
label: 'Recently Added',
|
||||
labelStringKey: 'LabelRecentlyAdded',
|
||||
type: mediaType,
|
||||
entities: [],
|
||||
category: 'newestItems'
|
||||
|
|
@ -267,6 +344,7 @@ module.exports = {
|
|||
{
|
||||
id: 'listen-again',
|
||||
label: 'Listen Again',
|
||||
labelStringKey: 'LabelListenAgain',
|
||||
type: isPodcastLibrary ? 'episode' : mediaType,
|
||||
entities: [],
|
||||
category: 'recentlyFinished'
|
||||
|
|
@ -274,6 +352,7 @@ module.exports = {
|
|||
{
|
||||
id: 'recent-series',
|
||||
label: 'Recent Series',
|
||||
labelStringKey: 'LabelRecentSeries',
|
||||
type: 'series',
|
||||
entities: [],
|
||||
category: 'newestSeries'
|
||||
|
|
@ -281,6 +360,7 @@ module.exports = {
|
|||
{
|
||||
id: 'newest-authors',
|
||||
label: 'Newest Authors',
|
||||
labelStringKey: 'LabelNewestAuthors',
|
||||
type: 'authors',
|
||||
entities: [],
|
||||
category: 'newestAuthors'
|
||||
|
|
@ -288,6 +368,7 @@ module.exports = {
|
|||
{
|
||||
id: 'episodes-recently-added',
|
||||
label: 'Newest Episodes',
|
||||
labelStringKey: 'LabelNewestEpisodes',
|
||||
type: 'episode',
|
||||
entities: [],
|
||||
category: 'newestEpisodes'
|
||||
|
|
@ -378,7 +459,7 @@ module.exports = {
|
|||
}
|
||||
categoryMap.recentlyFinished.biggest = categoryMap.recentlyFinished.items[0].finishedAt
|
||||
}
|
||||
} else if (mediaProgress.progress > 0) { // Handle most recently listened
|
||||
} else if (mediaProgress.inProgress && !mediaProgress.hideFromContinueListening) { // Handle most recently listened
|
||||
if (mediaProgress.lastUpdate > categoryMap.recentlyListened.smallest) { // Item belongs on shelf
|
||||
const libraryItemWithEpisode = {
|
||||
...libraryItem.toJSONMinified(),
|
||||
|
|
@ -415,6 +496,8 @@ module.exports = {
|
|||
const libraryItemJson = libraryItem.toJSONMinified()
|
||||
libraryItemJson.seriesSequence = librarySeries.sequence
|
||||
|
||||
const hideFromContinueListening = user.checkShouldHideSeriesFromContinueListening(librarySeries.id)
|
||||
|
||||
if (!seriesMap[librarySeries.id]) {
|
||||
const seriesObj = allSeries.find(se => se.id === librarySeries.id)
|
||||
if (seriesObj) {
|
||||
|
|
@ -422,6 +505,7 @@ module.exports = {
|
|||
...seriesObj.toJSON(),
|
||||
books: [libraryItemJson],
|
||||
inProgress: bookInProgress,
|
||||
hideFromContinueListening,
|
||||
bookInProgressLastUpdate: bookInProgress ? mediaProgress.lastUpdate : null,
|
||||
firstBookUnread: bookInProgress ? null : libraryItemJson
|
||||
}
|
||||
|
|
@ -528,7 +612,7 @@ module.exports = {
|
|||
}
|
||||
categoryMap.recentlyFinished.biggest = categoryMap.recentlyFinished.items[0].finishedAt
|
||||
}
|
||||
} else if (mediaProgress.inProgress) { // Handle most recently listened
|
||||
} else if (mediaProgress.inProgress && !mediaProgress.hideFromContinueListening) { // Handle most recently listened
|
||||
if (mediaProgress.lastUpdate > categoryMap.recentlyListened.smallest) { // Item belongs on shelf
|
||||
const libraryItemObj = {
|
||||
...libraryItem.toJSONMinified(),
|
||||
|
|
@ -555,7 +639,7 @@ module.exports = {
|
|||
|
||||
// For Continue Series - Find next book in series for series that are in progress
|
||||
for (const seriesId in seriesMap) {
|
||||
if (seriesMap[seriesId].inProgress) {
|
||||
if (seriesMap[seriesId].inProgress && !seriesMap[seriesId].hideFromContinueListening) {
|
||||
seriesMap[seriesId].books = naturalSort(seriesMap[seriesId].books).asc(li => li.seriesSequence)
|
||||
|
||||
// NEW implementation takes the first book unread with the smallest series sequence
|
||||
|
|
|
|||
38
server/utils/notifications.js
Normal file
38
server/utils/notifications.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
const { version } = require('../../package.json')
|
||||
|
||||
module.exports.notificationData = {
|
||||
events: [
|
||||
{
|
||||
name: 'onPodcastEpisodeDownloaded',
|
||||
requiresLibrary: true,
|
||||
libraryMediaType: 'podcast',
|
||||
description: 'Triggered when a podcast episode is auto-downloaded',
|
||||
variables: ['libraryItemId', 'libraryId', 'podcastTitle', 'episodeTitle', 'libraryName', 'episodeId'],
|
||||
defaults: {
|
||||
title: 'New {{podcastTitle}} Episode!',
|
||||
body: '{{episodeTitle}} has been added to {{libraryName}} library.'
|
||||
},
|
||||
testData: {
|
||||
libraryItemId: 'li_notification_test',
|
||||
libraryId: 'lib_test',
|
||||
libraryName: 'Podcasts',
|
||||
podcastTitle: 'Abs Test Podcast',
|
||||
episodeId: 'ep_notification_test',
|
||||
episodeTitle: 'Successful Test'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'onTest',
|
||||
requiresLibrary: false,
|
||||
description: 'Event for testing the notification system',
|
||||
variables: ['version'],
|
||||
defaults: {
|
||||
title: 'Test Notification on Abs {{version}}',
|
||||
body: 'Test notificataion body for abs {{version}}.'
|
||||
},
|
||||
testData: {
|
||||
version: 'v' + version
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -91,4 +91,13 @@ module.exports.parse = (nameString) => {
|
|||
nameLF: lastFirst, // String of comma separated last, first
|
||||
names: namesArray // Array of first last
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.checkNamesAreEqual = (name1, name2) => {
|
||||
if (!name1 || !name2) return false
|
||||
|
||||
// e.g. John H. Smith will be equal to John H Smith
|
||||
name1 = String(name1).toLowerCase().trim().replace(/\./g, '')
|
||||
name2 = String(name2).toLowerCase().trim().replace(/\./g, '')
|
||||
return name1 === name2
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
const Logger = require('../Logger')
|
||||
const { xmlToJSON } = require('./index')
|
||||
const axios = require('axios')
|
||||
const { xmlToJSON, levenshteinDistance } = require('./index')
|
||||
const htmlSanitizer = require('../utils/htmlSanitizer')
|
||||
|
||||
function extractFirstArrayItem(json, key) {
|
||||
|
|
@ -94,7 +95,18 @@ function extractEpisodeData(item) {
|
|||
episode.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription)
|
||||
}
|
||||
|
||||
var arrayFields = ['title', 'pubDate', 'itunes:episodeType', 'itunes:season', 'itunes:episode', 'itunes:author', 'itunes:duration', 'itunes:explicit', 'itunes:subtitle']
|
||||
if (item['pubDate']) {
|
||||
const pubDate = extractFirstArrayItem(item, 'pubDate')
|
||||
if (typeof pubDate === 'string') {
|
||||
episode.pubDate = pubDate
|
||||
} else if (pubDate && typeof pubDate._ === 'string') {
|
||||
episode.pubDate = pubDate._
|
||||
} else {
|
||||
Logger.error(`[podcastUtils] Invalid pubDate ${item['pubDate']} for ${episode.enclosure.url}`)
|
||||
}
|
||||
}
|
||||
|
||||
var arrayFields = ['title', 'itunes:episodeType', 'itunes:season', 'itunes:episode', 'itunes:author', 'itunes:duration', 'itunes:explicit', 'itunes:subtitle']
|
||||
arrayFields.forEach((key) => {
|
||||
var cleanKey = key.split(':').pop()
|
||||
episode[cleanKey] = extractFirstArrayItem(item, key)
|
||||
|
|
@ -103,6 +115,8 @@ function extractEpisodeData(item) {
|
|||
}
|
||||
|
||||
function cleanEpisodeData(data) {
|
||||
const pubJsDate = data.pubDate ? new Date(data.pubDate) : null
|
||||
const publishedAt = pubJsDate && !isNaN(pubJsDate) ? pubJsDate.valueOf() : null
|
||||
return {
|
||||
title: data.title,
|
||||
subtitle: data.subtitle || '',
|
||||
|
|
@ -115,7 +129,7 @@ function cleanEpisodeData(data) {
|
|||
author: data.author || '',
|
||||
duration: data.duration || '',
|
||||
explicit: data.explicit || '',
|
||||
publishedAt: (new Date(data.pubDate)).valueOf(),
|
||||
publishedAt,
|
||||
enclosure: data.enclosure
|
||||
}
|
||||
}
|
||||
|
|
@ -173,4 +187,65 @@ module.exports.parsePodcastRssFeedXml = async (xml, excludeEpisodeMetadata = fal
|
|||
podcast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => {
|
||||
Logger.debug(`[podcastUtils] getPodcastFeed for "${feedUrl}"`)
|
||||
return axios.get(feedUrl, { timeout: 6000 }).then(async (data) => {
|
||||
if (!data || !data.data) {
|
||||
Logger.error(`[podcastUtils] getPodcastFeed: Invalid podcast feed request response (${feedUrl})`)
|
||||
return false
|
||||
}
|
||||
Logger.debug(`[podcastUtils] getPodcastFeed for "${feedUrl}" success - parsing xml`)
|
||||
var payload = await this.parsePodcastRssFeedXml(data.data, excludeEpisodeMetadata)
|
||||
if (!payload) {
|
||||
return false
|
||||
}
|
||||
|
||||
// RSS feed may be a private RSS feed
|
||||
payload.podcast.metadata.feedUrl = feedUrl
|
||||
|
||||
return payload.podcast
|
||||
}).catch((error) => {
|
||||
Logger.error('[podcastUtils] getPodcastFeed Error', error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
// Return array of episodes ordered by closest match (Levenshtein distance of 6 or less)
|
||||
module.exports.findMatchingEpisodes = async (feedUrl, searchTitle) => {
|
||||
const feed = await this.getPodcastFeed(feedUrl).catch(() => {
|
||||
return null
|
||||
})
|
||||
|
||||
return this.findMatchingEpisodesInFeed(feed, searchTitle)
|
||||
}
|
||||
|
||||
module.exports.findMatchingEpisodesInFeed = (feed, searchTitle) => {
|
||||
searchTitle = searchTitle.toLowerCase().trim()
|
||||
if (!feed || !feed.episodes) {
|
||||
return null
|
||||
}
|
||||
|
||||
const matches = []
|
||||
feed.episodes.forEach(ep => {
|
||||
if (!ep.title) return
|
||||
|
||||
const epTitle = ep.title.toLowerCase().trim()
|
||||
if (epTitle === searchTitle) {
|
||||
matches.push({
|
||||
episode: ep,
|
||||
levenshtein: 0
|
||||
})
|
||||
} else {
|
||||
const levenshtein = levenshteinDistance(searchTitle, epTitle, true)
|
||||
if (levenshtein <= 6 && epTitle.length > levenshtein) {
|
||||
matches.push({
|
||||
episode: ep,
|
||||
levenshtein
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
return matches.sort((a, b) => a.levenshtein - b.levenshtein)
|
||||
}
|
||||
|
|
@ -74,7 +74,7 @@ function tryGrabTags(stream, ...tags) {
|
|||
if (!stream.tags) return null
|
||||
for (let i = 0; i < tags.length; i++) {
|
||||
var value = stream.tags[tags[i]] || stream.tags[tags[i].toUpperCase()]
|
||||
if (value) return value
|
||||
if (value && value.trim()) return value.trim()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
|
@ -177,8 +177,8 @@ function parseTags(format, verbose) {
|
|||
file_tag_comment: tryGrabTags(format, 'comment', 'comm', 'com'),
|
||||
file_tag_description: tryGrabTags(format, 'description', 'desc'),
|
||||
file_tag_genre: tryGrabTags(format, 'genre', 'tcon', 'tco'),
|
||||
file_tag_series: tryGrabTags(format, 'series', 'show'),
|
||||
file_tag_seriespart: tryGrabTags(format, 'series-part', 'episode_id'),
|
||||
file_tag_series: tryGrabTags(format, 'series', 'show', 'mvin'),
|
||||
file_tag_seriespart: tryGrabTags(format, 'series-part', 'episode_id', 'mvnm'),
|
||||
file_tag_isbn: tryGrabTags(format, 'isbn'),
|
||||
file_tag_language: tryGrabTags(format, 'language', 'lang'),
|
||||
file_tag_asin: tryGrabTags(format, 'asin'),
|
||||
|
|
@ -200,12 +200,6 @@ function parseTags(format, verbose) {
|
|||
}
|
||||
}
|
||||
|
||||
// var keysToLookOutFor = ['file_tag_genre1', 'file_tag_genre2', 'file_tag_series', 'file_tag_seriespart', 'file_tag_movement', 'file_tag_movementname', 'file_tag_wwwaudiofile', 'file_tag_contentgroup', 'file_tag_releasetime', 'file_tag_isbn']
|
||||
// keysToLookOutFor.forEach((key) => {
|
||||
// if (tags[key]) {
|
||||
// Logger.debug(`Notable! ${key} => ${tags[key]}`)
|
||||
// }
|
||||
// })
|
||||
return tags
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ const globals = require('./globals')
|
|||
const LibraryFile = require('../objects/files/LibraryFile')
|
||||
|
||||
function isMediaFile(mediaType, ext) {
|
||||
// if (!path) return false
|
||||
// var ext = Path.extname(path)
|
||||
if (!ext) return false
|
||||
var extclean = ext.slice(1).toLowerCase()
|
||||
if (mediaType === 'podcast') return globals.SupportedAudioTypes.includes(extclean)
|
||||
|
|
@ -256,6 +254,7 @@ function getBookDataFromDir(folderPath, relPath, parseSubtitle = false) {
|
|||
path: Path.posix.join(folderPath, relPath) // i.e. /audiobook/Author Name/Book Name/..
|
||||
}
|
||||
}
|
||||
module.exports.getBookDataFromDir = getBookDataFromDir
|
||||
|
||||
function getNarrator(folder) {
|
||||
let pattern = /^(?<title>.*) \{(?<narrators>.*)\}$/
|
||||
|
|
@ -279,16 +278,15 @@ function getSequence(folder) {
|
|||
// ]
|
||||
|
||||
// Matches a valid volume string. Also matches a book whose title starts with a 1 to 3 digit number. Will handle that later.
|
||||
let pattern = /^(?<volumeLabel>vol\.? |volume |book )?(?<sequence>\d{1,3}(?:\.\d{1,2})?)(?<trailingDot>\.?)(?: (?<suffix>.*))?$/i
|
||||
let pattern = /^(?<volumeLabel>vol\.? |volume |book )?(?<sequence>\d{0,3}(?:\.\d{1,2})?)(?<trailingDot>\.?)(?: (?<suffix>.*))?$/i
|
||||
|
||||
let volumeNumber = null
|
||||
let parts = folder.split(' - ')
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
let match = parts[i].match(pattern)
|
||||
|
||||
// This excludes '101 Dalmations' but includes '101. Dalmations'
|
||||
if (match && !(match.groups.suffix && !(match.groups.volumeLabel || match.groups.trailingDot))) {
|
||||
volumeNumber = match.groups.sequence
|
||||
volumeNumber = isNaN(match.groups.sequence) ? match.groups.sequence : Number(match.groups.sequence).toString()
|
||||
parts[i] = match.groups.suffix
|
||||
if (!parts[i]) { parts.splice(i, 1) }
|
||||
break
|
||||
|
|
@ -340,7 +338,7 @@ function getDataFromMediaDir(libraryMediaType, folderPath, relPath, serverSettin
|
|||
var parseSubtitle = !!serverSettings.scannerParseSubtitle
|
||||
return getBookDataFromDir(folderPath, relPath, parseSubtitle)
|
||||
} else {
|
||||
return this.getPodcastDataFromDir(folderPath, relPath)
|
||||
return getPodcastDataFromDir(folderPath, relPath)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
151
server/utils/toneHelpers.js
Normal file
151
server/utils/toneHelpers.js
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
const tone = require('node-tone')
|
||||
const fs = require('../libs/fsExtra')
|
||||
const Logger = require('../Logger')
|
||||
const { secondsToTimestamp } = require('./index')
|
||||
|
||||
module.exports.writeToneChaptersFile = (chapters, filePath) => {
|
||||
var chaptersTxt = ''
|
||||
for (const chapter of chapters) {
|
||||
chaptersTxt += `${secondsToTimestamp(chapter.start, true, true)} ${chapter.title}\n`
|
||||
}
|
||||
return fs.writeFile(filePath, chaptersTxt)
|
||||
}
|
||||
|
||||
module.exports.getToneMetadataObject = (libraryItem, chaptersFile) => {
|
||||
const coverPath = libraryItem.media.coverPath
|
||||
const bookMetadata = libraryItem.media.metadata
|
||||
|
||||
const metadataObject = {
|
||||
'Title': bookMetadata.title || '',
|
||||
'Album': bookMetadata.title || '',
|
||||
'TrackTotal': libraryItem.media.tracks.length
|
||||
}
|
||||
const additionalFields = []
|
||||
|
||||
if (bookMetadata.subtitle) {
|
||||
metadataObject['Subtitle'] = bookMetadata.subtitle
|
||||
}
|
||||
if (bookMetadata.authorName) {
|
||||
metadataObject['Artist'] = bookMetadata.authorName
|
||||
metadataObject['AlbumArtist'] = bookMetadata.authorName
|
||||
}
|
||||
if (bookMetadata.description) {
|
||||
metadataObject['Comment'] = bookMetadata.description
|
||||
metadataObject['Description'] = bookMetadata.description
|
||||
}
|
||||
if (bookMetadata.narratorName) {
|
||||
metadataObject['Narrator'] = bookMetadata.narratorName
|
||||
metadataObject['Composer'] = bookMetadata.narratorName
|
||||
}
|
||||
if (bookMetadata.firstSeriesName) {
|
||||
metadataObject['MovementName'] = bookMetadata.firstSeriesName
|
||||
}
|
||||
if (bookMetadata.firstSeriesSequence) {
|
||||
metadataObject['Movement'] = bookMetadata.firstSeriesSequence
|
||||
}
|
||||
if (bookMetadata.genres.length) {
|
||||
metadataObject['Genre'] = bookMetadata.genres.join('/')
|
||||
}
|
||||
if (bookMetadata.publisher) {
|
||||
metadataObject['Publisher'] = bookMetadata.publisher
|
||||
}
|
||||
if (bookMetadata.asin) {
|
||||
additionalFields.push(`ASIN=${bookMetadata.asin}`)
|
||||
}
|
||||
if (bookMetadata.isbn) {
|
||||
additionalFields.push(`ISBN=${bookMetadata.isbn}`)
|
||||
}
|
||||
if (coverPath) {
|
||||
metadataObject['CoverFile'] = coverPath
|
||||
}
|
||||
if (parsePublishedYear(bookMetadata.publishedYear)) {
|
||||
metadataObject['PublishingDate'] = parsePublishedYear(bookMetadata.publishedYear)
|
||||
}
|
||||
if (chaptersFile) {
|
||||
metadataObject['ChaptersFile'] = chaptersFile
|
||||
}
|
||||
|
||||
if (additionalFields.length) {
|
||||
metadataObject['AdditionalFields'] = additionalFields
|
||||
}
|
||||
|
||||
return metadataObject
|
||||
}
|
||||
|
||||
module.exports.writeToneMetadataJsonFile = (libraryItem, chapters, filePath, trackTotal) => {
|
||||
const bookMetadata = libraryItem.media.metadata
|
||||
const coverPath = libraryItem.media.coverPath
|
||||
|
||||
const metadataObject = {
|
||||
'album': bookMetadata.title || '',
|
||||
'title': bookMetadata.title || '',
|
||||
'trackTotal': trackTotal,
|
||||
'additionalFields': {}
|
||||
}
|
||||
if (bookMetadata.subtitle) {
|
||||
metadataObject['subtitle'] = bookMetadata.subtitle
|
||||
}
|
||||
if (bookMetadata.authorName) {
|
||||
metadataObject['artist'] = bookMetadata.authorName
|
||||
metadataObject['albumArtist'] = bookMetadata.authorName
|
||||
}
|
||||
if (bookMetadata.description) {
|
||||
metadataObject['comment'] = bookMetadata.description
|
||||
metadataObject['description'] = bookMetadata.description
|
||||
}
|
||||
if (bookMetadata.narratorName) {
|
||||
metadataObject['narrator'] = bookMetadata.narratorName
|
||||
metadataObject['composer'] = bookMetadata.narratorName
|
||||
}
|
||||
if (bookMetadata.firstSeriesName) {
|
||||
metadataObject['movementName'] = bookMetadata.firstSeriesName
|
||||
}
|
||||
if (bookMetadata.firstSeriesSequence) {
|
||||
metadataObject['movement'] = bookMetadata.firstSeriesSequence
|
||||
}
|
||||
if (bookMetadata.genres.length) {
|
||||
metadataObject['genre'] = bookMetadata.genres.join('/')
|
||||
}
|
||||
if (bookMetadata.publisher) {
|
||||
metadataObject['publisher'] = bookMetadata.publisher
|
||||
}
|
||||
if (bookMetadata.asin) {
|
||||
metadataObject.additionalFields['asin'] = bookMetadata.asin
|
||||
}
|
||||
if (bookMetadata.isbn) {
|
||||
metadataObject.additionalFields['isbn'] = bookMetadata.isbn
|
||||
}
|
||||
if (coverPath) {
|
||||
metadataObject['coverFile'] = coverPath
|
||||
}
|
||||
if (parsePublishedYear(bookMetadata.publishedYear)) {
|
||||
metadataObject['publishingDate'] = parsePublishedYear(bookMetadata.publishedYear)
|
||||
}
|
||||
if (chapters && chapters.length > 0) {
|
||||
let metadataChapters = []
|
||||
for (const chapter of chapters) {
|
||||
metadataChapters.push({
|
||||
start: Math.round(chapter.start * 1000),
|
||||
length: Math.round((chapter.end - chapter.start) * 1000),
|
||||
title: chapter.title,
|
||||
})
|
||||
}
|
||||
metadataObject['chapters'] = metadataChapters
|
||||
}
|
||||
|
||||
return fs.writeFile(filePath, JSON.stringify({ meta: metadataObject }, null, 2))
|
||||
}
|
||||
|
||||
module.exports.tagAudioFile = (filePath, payload) => {
|
||||
return tone.tag(filePath, payload).then((data) => {
|
||||
return true
|
||||
}).catch((error) => {
|
||||
Logger.error(`[toneHelpers] tagAudioFile: Failed for "${filePath}"`, error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
function parsePublishedYear(publishedYear) {
|
||||
if (isNaN(publishedYear) || !publishedYear || Number(publishedYear) <= 0) return null
|
||||
return `01/01/${publishedYear}`
|
||||
}
|
||||
173
server/utils/toneProber.js
Normal file
173
server/utils/toneProber.js
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
const tone = require('node-tone')
|
||||
const MediaProbeData = require('../scanner/MediaProbeData')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
/*
|
||||
Sample dump from tone
|
||||
{
|
||||
"audio": {
|
||||
"bitrate": 17,
|
||||
"format": "MPEG-4 Part 14",
|
||||
"formatShort": "MPEG-4",
|
||||
"sampleRate": 44100.0,
|
||||
"duration": 209284.0,
|
||||
"channels": {
|
||||
"count": 2,
|
||||
"description": "Stereo (2/0.0)"
|
||||
},
|
||||
"frames": {
|
||||
"offset": 42168,
|
||||
"length": 446932
|
||||
"metaFormat": [
|
||||
"mp4"
|
||||
]
|
||||
},
|
||||
"meta": {
|
||||
"album": "node-tone",
|
||||
"albumArtist": "advplyr",
|
||||
"artist": "advplyr",
|
||||
"composer": "Composer 5",
|
||||
"comment": "testing out tone metadata",
|
||||
"encodingTool": "audiobookshelf",
|
||||
"genre": "abs",
|
||||
"itunesCompilation": "no",
|
||||
"itunesMediaType": "audiobook",
|
||||
"itunesPlayGap": "noGap",
|
||||
"narrator": "Narrator 5",
|
||||
"recordingDate": "2022-09-10T00:00:00",
|
||||
"title": "Test 5",
|
||||
"trackNumber": 5,
|
||||
"chapters": [
|
||||
{
|
||||
"start": 0,
|
||||
"length": 500,
|
||||
"title": "chapter 1"
|
||||
},
|
||||
{
|
||||
"start": 500,
|
||||
"length": 500,
|
||||
"title": "chapter 2"
|
||||
},
|
||||
{
|
||||
"start": 1000,
|
||||
"length": 208284,
|
||||
"title": "chapter 3"
|
||||
}
|
||||
],
|
||||
"embeddedPictures": [
|
||||
{
|
||||
"code": 14,
|
||||
"mimetype": "image/png",
|
||||
"data": "..."
|
||||
},
|
||||
"additionalFields": {
|
||||
"test": "Test 5"
|
||||
}
|
||||
},
|
||||
"file": {
|
||||
"size": 530793,
|
||||
"created": "2022-09-10T13:32:51.1942586-05:00",
|
||||
"modified": "2022-09-10T14:09:19.366071-05:00",
|
||||
"accessed": "2022-09-11T13:00:56.5097533-05:00",
|
||||
"path": "C:\\Users\\Coop\\Documents\\NodeProjects\\node-tone\\samples",
|
||||
"name": "m4b.m4b"
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
function bitrateKilobitToBit(bitrate) {
|
||||
if (isNaN(bitrate) || !bitrate) return 0
|
||||
return Number(bitrate) * 1000
|
||||
}
|
||||
|
||||
function msToSeconds(ms) {
|
||||
if (isNaN(ms) || !ms) return 0
|
||||
return Number(ms) / 1000
|
||||
}
|
||||
|
||||
function parseProbeDump(dumpPayload) {
|
||||
const audioMetadata = dumpPayload.audio
|
||||
const audioChannels = audioMetadata.channels || {}
|
||||
const audio_stream = {
|
||||
bit_rate: bitrateKilobitToBit(audioMetadata.bitrate), // tone uses Kbps but ffprobe uses bps so convert to bits
|
||||
codec: null,
|
||||
time_base: null,
|
||||
language: null,
|
||||
channel_layout: audioChannels.description || null,
|
||||
channels: audioChannels.count || null,
|
||||
sample_rate: audioMetadata.sampleRate || null
|
||||
}
|
||||
|
||||
let chapterIndex = 0
|
||||
const chapters = (dumpPayload.meta.chapters || []).map(chap => {
|
||||
return {
|
||||
id: chapterIndex++,
|
||||
start: msToSeconds(chap.start),
|
||||
end: msToSeconds(chap.start + chap.length),
|
||||
title: chap.title || ''
|
||||
}
|
||||
})
|
||||
|
||||
var video_stream = null
|
||||
if (dumpPayload.meta.embeddedPictures && dumpPayload.meta.embeddedPictures.length) {
|
||||
const mimetype = dumpPayload.meta.embeddedPictures[0].mimetype
|
||||
video_stream = {
|
||||
codec: mimetype === 'image/png' ? 'png' : 'jpeg'
|
||||
}
|
||||
}
|
||||
|
||||
const tags = { ...dumpPayload.meta }
|
||||
delete tags.chapters
|
||||
delete tags.embeddedPictures
|
||||
|
||||
const fileMetadata = dumpPayload.file
|
||||
var sizeBytes = !isNaN(fileMetadata.size) ? Number(fileMetadata.size) : null
|
||||
var sizeMb = sizeBytes !== null ? Number((sizeBytes / (1024 * 1024)).toFixed(2)) : null
|
||||
return {
|
||||
format: audioMetadata.format || 'Unknown',
|
||||
duration: msToSeconds(audioMetadata.duration),
|
||||
size: sizeBytes,
|
||||
sizeMb,
|
||||
bit_rate: audio_stream.bit_rate,
|
||||
audio_stream,
|
||||
video_stream,
|
||||
chapters,
|
||||
tags
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.probe = (filepath, verbose = false) => {
|
||||
if (process.env.TONE_PATH) {
|
||||
tone.TONE_PATH = process.env.TONE_PATH
|
||||
}
|
||||
|
||||
return tone.dump(filepath).then((dumpPayload) => {
|
||||
if (verbose) {
|
||||
Logger.debug(`[toneProber] dump for file "${filepath}"`, dumpPayload)
|
||||
}
|
||||
const rawProbeData = parseProbeDump(dumpPayload)
|
||||
const probeData = new MediaProbeData()
|
||||
probeData.setData(rawProbeData)
|
||||
return probeData
|
||||
}).catch((error) => {
|
||||
Logger.error(`[toneProber] Failed to probe file at path "${filepath}"`, error)
|
||||
return {
|
||||
error
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
module.exports.rawProbe = (filepath) => {
|
||||
if (process.env.TONE_PATH) {
|
||||
tone.TONE_PATH = process.env.TONE_PATH
|
||||
}
|
||||
|
||||
return tone.dump(filepath).then((dumpPayload) => {
|
||||
return dumpPayload
|
||||
}).catch((error) => {
|
||||
Logger.error(`[toneProber] Failed to probe file at path "${filepath}"`, error)
|
||||
return {
|
||||
error
|
||||
}
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue