Merge branch 'master' into social

This commit is contained in:
Ben 2022-11-17 21:06:18 -05:00 committed by GitHub
commit 12b7976ef3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
245 changed files with 11554 additions and 3676 deletions

View file

@ -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')

View file

@ -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) => {

View file

@ -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)
}

View file

@ -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)
})

View file

@ -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)
}

View file

@ -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}`)

View file

@ -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)

View file

@ -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()

View file

@ -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' })
}

View 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()

View file

@ -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 || []
})

View file

@ -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)

View file

@ -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()

View file

@ -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

View file

@ -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);
}
})
})
}

View file

@ -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

View file

@ -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,

View file

@ -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)

View file

@ -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)

View 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

View file

@ -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

View file

@ -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)

View 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

View file

@ -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

View file

@ -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

View file

@ -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')) {

View file

@ -246,6 +246,7 @@ class LibraryItem {
setLastScan() {
this.lastScan = Date.now()
this.updatedAt = Date.now()
this.scanVersion = version
}

View 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

View file

@ -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)

View file

@ -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
View 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

View file

@ -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

View file

@ -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) {

View file

@ -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()

View file

@ -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()
}

View file

@ -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()
}

View file

@ -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,

View file

@ -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
}

View file

@ -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,

View file

@ -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'
},
{

View file

@ -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

View file

@ -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

View 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

View file

@ -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
}

View file

@ -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

View file

@ -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

View file

@ -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 []

View file

@ -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
})
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}

View file

@ -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()

View file

@ -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) {

View file

@ -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()

View file

@ -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

View file

@ -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 = {

View file

@ -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) => {

View file

@ -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'],

View file

@ -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
}

View file

@ -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

View 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
}
}
]
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
View 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
View 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
}
})
}