mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-16 06:11:38 +00:00
Merge remote-tracking branch 'upstream/master'
This commit is contained in:
commit
b3f9a40f18
227 changed files with 11338 additions and 3382 deletions
|
|
@ -120,18 +120,19 @@ class Auth {
|
|||
user: user.toJSONForBrowser(),
|
||||
userDefaultLibraryId: user.getDefaultLibraryId(this.db.libraries),
|
||||
serverSettings: this.db.serverSettings.toJSONForBrowser(),
|
||||
ereaderDevices: this.db.emailSettings.getEReaderDevices(user),
|
||||
Source: global.Source
|
||||
}
|
||||
}
|
||||
|
||||
async login(req, res) {
|
||||
const ipAddress = requestIp.getClientIp(req)
|
||||
var username = (req.body.username || '').toLowerCase()
|
||||
var password = req.body.password || ''
|
||||
const username = (req.body.username || '').toLowerCase()
|
||||
const password = req.body.password || ''
|
||||
|
||||
var user = this.users.find(u => u.username.toLowerCase() === username)
|
||||
const user = this.users.find(u => u.username.toLowerCase() === username)
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
if (!user?.isActive) {
|
||||
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}`)
|
||||
|
|
@ -145,13 +146,15 @@ class Auth {
|
|||
if (password) {
|
||||
return res.status(401).send('Invalid root password (hint: there is none)')
|
||||
} else {
|
||||
Logger.info(`[Auth] ${user.username} logged in from ${ipAddress}`)
|
||||
return res.json(this.getUserLoginResponsePayload(user))
|
||||
}
|
||||
}
|
||||
|
||||
// Check password match
|
||||
var compare = await bcrypt.compare(password, user.pash)
|
||||
const compare = await bcrypt.compare(password, user.pash)
|
||||
if (compare) {
|
||||
Logger.info(`[Auth] ${user.username} logged in from ${ipAddress}`)
|
||||
res.json(this.getUserLoginResponsePayload(user))
|
||||
} else {
|
||||
Logger.warn(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit} from ${ipAddress}`)
|
||||
|
|
|
|||
68
server/Db.js
68
server/Db.js
|
|
@ -12,6 +12,7 @@ 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 EmailSettings = require('./objects/settings/EmailSettings')
|
||||
const PlaybackSession = require('./objects/PlaybackSession')
|
||||
|
||||
class Db {
|
||||
|
|
@ -27,17 +28,16 @@ class Db {
|
|||
this.SeriesPath = Path.join(global.ConfigPath, 'series')
|
||||
this.FeedsPath = Path.join(global.ConfigPath, 'feeds')
|
||||
|
||||
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.playlistsDb = new njodb.Database(this.PlaylistsPath, { 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.libraryItemsDb = new njodb.Database(this.LibraryItemsPath, this.getNjodbOptions())
|
||||
this.usersDb = new njodb.Database(this.UsersPath, this.getNjodbOptions())
|
||||
this.sessionsDb = new njodb.Database(this.SessionsPath, this.getNjodbOptions())
|
||||
this.librariesDb = new njodb.Database(this.LibrariesPath, this.getNjodbOptions())
|
||||
this.settingsDb = new njodb.Database(this.SettingsPath, this.getNjodbOptions())
|
||||
this.collectionsDb = new njodb.Database(this.CollectionsPath, this.getNjodbOptions())
|
||||
this.playlistsDb = new njodb.Database(this.PlaylistsPath, this.getNjodbOptions())
|
||||
this.authorsDb = new njodb.Database(this.AuthorsPath, this.getNjodbOptions())
|
||||
this.seriesDb = new njodb.Database(this.SeriesPath, this.getNjodbOptions())
|
||||
this.feedsDb = new njodb.Database(this.FeedsPath, this.getNjodbOptions())
|
||||
|
||||
this.libraryItems = []
|
||||
this.users = []
|
||||
|
|
@ -50,6 +50,7 @@ class Db {
|
|||
|
||||
this.serverSettings = null
|
||||
this.notificationSettings = null
|
||||
this.emailSettings = null
|
||||
|
||||
// Stores previous version only if upgraded
|
||||
this.previousVersion = null
|
||||
|
|
@ -59,6 +60,21 @@ class Db {
|
|||
return this.users.some(u => u.id === 'root')
|
||||
}
|
||||
|
||||
getNjodbOptions() {
|
||||
return {
|
||||
lockoptions: {
|
||||
stale: 1000 * 20, // 20 seconds
|
||||
update: 2500,
|
||||
retries: {
|
||||
retries: 20,
|
||||
minTimeout: 250,
|
||||
maxTimeout: 5000,
|
||||
factor: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getEntityDb(entityName) {
|
||||
if (entityName === 'user') return this.usersDb
|
||||
else if (entityName === 'session') return this.sessionsDb
|
||||
|
|
@ -88,17 +104,16 @@ class Db {
|
|||
}
|
||||
|
||||
reinit() {
|
||||
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.playlistsDb = new njodb.Database(this.PlaylistsPath, { 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.libraryItemsDb = new njodb.Database(this.LibraryItemsPath, this.getNjodbOptions())
|
||||
this.usersDb = new njodb.Database(this.UsersPath, this.getNjodbOptions())
|
||||
this.sessionsDb = new njodb.Database(this.SessionsPath, this.getNjodbOptions())
|
||||
this.librariesDb = new njodb.Database(this.LibrariesPath, this.getNjodbOptions())
|
||||
this.settingsDb = new njodb.Database(this.SettingsPath, this.getNjodbOptions())
|
||||
this.collectionsDb = new njodb.Database(this.CollectionsPath, this.getNjodbOptions())
|
||||
this.playlistsDb = new njodb.Database(this.PlaylistsPath, this.getNjodbOptions())
|
||||
this.authorsDb = new njodb.Database(this.AuthorsPath, this.getNjodbOptions())
|
||||
this.seriesDb = new njodb.Database(this.SeriesPath, this.getNjodbOptions())
|
||||
this.feedsDb = new njodb.Database(this.FeedsPath, this.getNjodbOptions())
|
||||
return this.init()
|
||||
}
|
||||
|
||||
|
|
@ -143,6 +158,10 @@ class Db {
|
|||
this.notificationSettings = new NotificationSettings()
|
||||
await this.insertEntity('settings', this.notificationSettings)
|
||||
}
|
||||
if (!this.emailSettings) {
|
||||
this.emailSettings = new EmailSettings()
|
||||
await this.insertEntity('settings', this.emailSettings)
|
||||
}
|
||||
global.ServerSettings = this.serverSettings.toJSON()
|
||||
}
|
||||
|
||||
|
|
@ -189,6 +208,11 @@ class Db {
|
|||
if (notificationSettings) {
|
||||
this.notificationSettings = new NotificationSettings(notificationSettings)
|
||||
}
|
||||
|
||||
const emailSettings = this.settings.find(s => s.id === 'email-settings')
|
||||
if (emailSettings) {
|
||||
this.emailSettings = new EmailSettings(emailSettings)
|
||||
}
|
||||
}
|
||||
})
|
||||
const p5 = this.collectionsDb.select(() => true).then((results) => {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const { version } = require('../package.json')
|
|||
const dbMigration = require('./utils/dbMigration')
|
||||
const filePerms = require('./utils/filePerms')
|
||||
const fileUtils = require('./utils/fileUtils')
|
||||
const globals = require('./utils/globals')
|
||||
const Logger = require('./Logger')
|
||||
|
||||
const Auth = require('./Auth')
|
||||
|
|
@ -24,6 +25,7 @@ const HlsRouter = require('./routers/HlsRouter')
|
|||
const StaticRouter = require('./routers/StaticRouter')
|
||||
|
||||
const NotificationManager = require('./managers/NotificationManager')
|
||||
const EmailManager = require('./managers/EmailManager')
|
||||
const CoverManager = require('./managers/CoverManager')
|
||||
const AbMergeManager = require('./managers/AbMergeManager')
|
||||
const CacheManager = require('./managers/CacheManager')
|
||||
|
|
@ -36,7 +38,6 @@ const LibraryItemFolderManager = require('./managers/LibraryItemFolderManager')
|
|||
const RssFeedManager = require('./managers/RssFeedManager')
|
||||
const CronManager = require('./managers/CronManager')
|
||||
const TaskManager = require('./managers/TaskManager')
|
||||
const EBookManager = require('./managers/EBookManager')
|
||||
|
||||
class Server {
|
||||
constructor(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) {
|
||||
|
|
@ -67,19 +68,19 @@ class Server {
|
|||
// Managers
|
||||
this.taskManager = new TaskManager()
|
||||
this.notificationManager = new NotificationManager(this.db)
|
||||
this.emailManager = new EmailManager(this.db)
|
||||
this.backupManager = new BackupManager(this.db)
|
||||
this.logManager = new LogManager(this.db)
|
||||
this.cacheManager = new CacheManager()
|
||||
this.abMergeManager = new AbMergeManager(this.db, this.taskManager)
|
||||
this.playbackSessionManager = new PlaybackSessionManager(this.db)
|
||||
this.coverManager = new CoverManager(this.db, this.cacheManager)
|
||||
this.podcastManager = new PodcastManager(this.db, this.watcher, this.notificationManager)
|
||||
this.podcastManager = new PodcastManager(this.db, this.watcher, this.notificationManager, this.taskManager)
|
||||
this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.taskManager)
|
||||
this.folderRenameManager = new LibraryItemFolderManager(this.db, this.watcher)
|
||||
this.rssFeedManager = new RssFeedManager(this.db)
|
||||
this.eBookManager = new EBookManager(this.db)
|
||||
|
||||
this.scanner = new Scanner(this.db, this.coverManager)
|
||||
this.scanner = new Scanner(this.db, this.coverManager, this.taskManager)
|
||||
this.cronManager = new CronManager(this.db, this.scanner, this.podcastManager)
|
||||
|
||||
// Routers
|
||||
|
|
@ -121,7 +122,6 @@ class Server {
|
|||
await this.purgeMetadata() // Remove metadata folders without library item
|
||||
await this.playbackSessionManager.removeInvalidSessions()
|
||||
await this.cacheManager.ensureCachePaths()
|
||||
await this.abMergeManager.ensureDownloadDirPath()
|
||||
|
||||
await this.backupManager.init()
|
||||
await this.logManager.init()
|
||||
|
|
@ -150,7 +150,12 @@ class Server {
|
|||
this.server = http.createServer(app)
|
||||
|
||||
router.use(this.auth.cors)
|
||||
router.use(fileUpload())
|
||||
router.use(fileUpload({
|
||||
defCharset: 'utf8',
|
||||
defParamCharset: 'utf8',
|
||||
useTempFiles: true,
|
||||
tempFileDir: Path.join(global.MetadataPath, 'tmp')
|
||||
}))
|
||||
router.use(express.urlencoded({ extended: true, limit: "5mb" }));
|
||||
router.use(express.json({ limit: "5mb" }))
|
||||
|
||||
|
|
@ -166,16 +171,35 @@ class Server {
|
|||
|
||||
router.use('/api', this.authMiddleware.bind(this), this.apiRouter.router)
|
||||
router.use('/hls', this.authMiddleware.bind(this), this.hlsRouter.router)
|
||||
|
||||
// TODO: Deprecated as of 2.2.21 edge
|
||||
router.use('/s', this.authMiddleware.bind(this), this.staticRouter.router)
|
||||
|
||||
// EBook static file routes
|
||||
// TODO: Deprecated as of 2.2.21 edge
|
||||
router.get('/ebook/:library/:folder/*', (req, res) => {
|
||||
const library = this.db.libraries.find(lib => lib.id === req.params.library)
|
||||
if (!library) return res.sendStatus(404)
|
||||
const folder = library.folders.find(fol => fol.id === req.params.folder)
|
||||
if (!folder) return res.status(404).send('Folder not found')
|
||||
|
||||
const remainingPath = req.params['0']
|
||||
// Replace backslashes with forward slashes
|
||||
const remainingPath = req.params['0'].replace(/\\/g, '/')
|
||||
|
||||
// Prevent path traversal
|
||||
// e.g. ../../etc/passwd
|
||||
if (/\/?\.?\.\//.test(remainingPath)) {
|
||||
Logger.error(`[Server] Invalid path to get ebook "${remainingPath}"`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
// Check file ext is a valid ebook file
|
||||
const filext = (Path.extname(remainingPath) || '').slice(1).toLowerCase()
|
||||
if (!globals.SupportedEbookTypes.includes(filext)) {
|
||||
Logger.error(`[Server] Invalid ebook file ext requested "${remainingPath}"`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const fullPath = Path.join(folder.fullPath, remainingPath)
|
||||
res.sendFile(fullPath)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -167,18 +167,19 @@ class AuthorController {
|
|||
}
|
||||
|
||||
async match(req, res) {
|
||||
var authorData = null
|
||||
let authorData = null
|
||||
const region = req.body.region || 'us'
|
||||
if (req.body.asin) {
|
||||
authorData = await this.authorFinder.findAuthorByASIN(req.body.asin)
|
||||
authorData = await this.authorFinder.findAuthorByASIN(req.body.asin, region)
|
||||
} else {
|
||||
authorData = await this.authorFinder.findAuthorByName(req.body.q)
|
||||
authorData = await this.authorFinder.findAuthorByName(req.body.q, region)
|
||||
}
|
||||
if (!authorData) {
|
||||
return res.status(404).send('Author not found')
|
||||
}
|
||||
Logger.debug(`[AuthorController] match author with "${req.body.q || req.body.asin}"`, authorData)
|
||||
|
||||
var hasUpdates = false
|
||||
let hasUpdates = false
|
||||
if (authorData.asin && req.author.asin !== authorData.asin) {
|
||||
req.author.asin = authorData.asin
|
||||
hasUpdates = true
|
||||
|
|
@ -188,7 +189,7 @@ class AuthorController {
|
|||
if (authorData.image && (!req.author.imagePath || hasUpdates)) {
|
||||
this.cacheManager.purgeImageCache(req.author.id)
|
||||
|
||||
var imageData = await this.authorFinder.saveAuthorImage(req.author.id, authorData.image)
|
||||
const imageData = await this.authorFinder.saveAuthorImage(req.author.id, authorData.image)
|
||||
if (imageData) {
|
||||
req.author.imagePath = imageData.path
|
||||
hasUpdates = true
|
||||
|
|
@ -204,7 +205,7 @@ class AuthorController {
|
|||
req.author.updatedAt = Date.now()
|
||||
|
||||
await this.db.updateEntity('author', req.author)
|
||||
var numBooks = this.db.libraryItems.filter(li => {
|
||||
const numBooks = this.db.libraryItems.filter(li => {
|
||||
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id)
|
||||
}).length
|
||||
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))
|
||||
|
|
|
|||
|
|
@ -1,52 +0,0 @@
|
|||
const Logger = require('../Logger')
|
||||
const { isNullOrNaN } = require('../utils/index')
|
||||
|
||||
class EBookController {
|
||||
constructor() { }
|
||||
|
||||
async getEbookInfo(req, res) {
|
||||
const isDev = req.query.dev == 1
|
||||
const json = await this.eBookManager.getBookInfo(req.libraryItem, req.user, isDev)
|
||||
res.json(json)
|
||||
}
|
||||
|
||||
async getEbookPage(req, res) {
|
||||
if (isNullOrNaN(req.params.page)) {
|
||||
return res.status(400).send('Invalid page params')
|
||||
}
|
||||
const isDev = req.query.dev == 1
|
||||
const pageIndex = Number(req.params.page)
|
||||
const page = await this.eBookManager.getBookPage(req.libraryItem, req.user, pageIndex, isDev)
|
||||
if (!page) {
|
||||
return res.status(500).send('Failed to get page')
|
||||
}
|
||||
|
||||
res.send(page)
|
||||
}
|
||||
|
||||
async getEbookResource(req, res) {
|
||||
if (!req.query.path) {
|
||||
return res.status(400).send('Invalid query path')
|
||||
}
|
||||
const isDev = req.query.dev == 1
|
||||
this.eBookManager.getBookResource(req.libraryItem, req.user, req.query.path, isDev, res)
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
const item = this.db.libraryItems.find(li => li.id === req.params.id)
|
||||
if (!item || !item.media) return res.sendStatus(404)
|
||||
|
||||
// Check user can access this library item
|
||||
if (!req.user.checkCanAccessLibraryItem(item)) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
if (!item.isBook || !item.media.ebookFile) {
|
||||
return res.status(400).send('Invalid ebook library item')
|
||||
}
|
||||
|
||||
req.libraryItem = item
|
||||
next()
|
||||
}
|
||||
}
|
||||
module.exports = new EBookController()
|
||||
86
server/controllers/EmailController.js
Normal file
86
server/controllers/EmailController.js
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
const Logger = require('../Logger')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
|
||||
class EmailController {
|
||||
constructor() { }
|
||||
|
||||
getSettings(req, res) {
|
||||
res.json({
|
||||
settings: this.db.emailSettings
|
||||
})
|
||||
}
|
||||
|
||||
async updateSettings(req, res) {
|
||||
const updated = this.db.emailSettings.update(req.body)
|
||||
if (updated) {
|
||||
await this.db.updateEntity('settings', this.db.emailSettings)
|
||||
}
|
||||
res.json({
|
||||
settings: this.db.emailSettings
|
||||
})
|
||||
}
|
||||
|
||||
async sendTest(req, res) {
|
||||
this.emailManager.sendTest(res)
|
||||
}
|
||||
|
||||
async updateEReaderDevices(req, res) {
|
||||
if (!req.body.ereaderDevices || !Array.isArray(req.body.ereaderDevices)) {
|
||||
return res.status(400).send('Invalid payload. ereaderDevices array required')
|
||||
}
|
||||
|
||||
const ereaderDevices = req.body.ereaderDevices
|
||||
for (const device of ereaderDevices) {
|
||||
if (!device.name || !device.email) {
|
||||
return res.status(400).send('Invalid payload. ereaderDevices array items must have name and email')
|
||||
}
|
||||
}
|
||||
|
||||
const updated = this.db.emailSettings.update({
|
||||
ereaderDevices
|
||||
})
|
||||
if (updated) {
|
||||
await this.db.updateEntity('settings', this.db.emailSettings)
|
||||
SocketAuthority.adminEmitter('ereader-devices-updated', {
|
||||
ereaderDevices: this.db.emailSettings.ereaderDevices
|
||||
})
|
||||
}
|
||||
res.json({
|
||||
ereaderDevices: this.db.emailSettings.ereaderDevices
|
||||
})
|
||||
}
|
||||
|
||||
async sendEBookToDevice(req, res) {
|
||||
Logger.debug(`[EmailController] Send ebook to device request for libraryItemId=${req.body.libraryItemId}, deviceName=${req.body.deviceName}`)
|
||||
|
||||
const libraryItem = this.db.getLibraryItem(req.body.libraryItemId)
|
||||
if (!libraryItem) {
|
||||
return res.status(404).send('Library item not found')
|
||||
}
|
||||
|
||||
if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const ebookFile = libraryItem.media.ebookFile
|
||||
if (!ebookFile) {
|
||||
return res.status(404).send('EBook file not found')
|
||||
}
|
||||
|
||||
const device = this.db.emailSettings.getEReaderDevice(req.body.deviceName)
|
||||
if (!device) {
|
||||
return res.status(404).send('E-reader device not found')
|
||||
}
|
||||
|
||||
this.emailManager.sendEBookToDevice(ebookFile, device, res)
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
module.exports = new EmailController()
|
||||
|
|
@ -1,27 +1,50 @@
|
|||
const Logger = require('../Logger')
|
||||
const Path = require('path')
|
||||
const Logger = require('../Logger')
|
||||
const fs = require('../libs/fsExtra')
|
||||
|
||||
class FileSystemController {
|
||||
constructor() { }
|
||||
|
||||
async getPaths(req, res) {
|
||||
var excludedDirs = ['node_modules', 'client', 'server', '.git', 'static', 'build', 'dist', 'metadata', 'config', 'sys', 'proc'].map(dirname => {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[FileSystemController] Non-admin user attempting to get filesystem paths`, req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const excludedDirs = ['node_modules', 'client', 'server', '.git', 'static', 'build', 'dist', 'metadata', 'config', 'sys', 'proc'].map(dirname => {
|
||||
return Path.sep + dirname
|
||||
})
|
||||
|
||||
// Do not include existing mapped library paths in response
|
||||
this.db.libraries.forEach(lib => {
|
||||
lib.folders.forEach((folder) => {
|
||||
var dir = folder.fullPath
|
||||
let dir = folder.fullPath
|
||||
if (dir.includes(global.appRoot)) dir = dir.replace(global.appRoot, '')
|
||||
excludedDirs.push(dir)
|
||||
})
|
||||
})
|
||||
|
||||
Logger.debug(`[Server] get file system paths, excluded: ${excludedDirs.join(', ')}`)
|
||||
res.json({
|
||||
directories: await this.getDirectories(global.appRoot, '/', excludedDirs)
|
||||
})
|
||||
}
|
||||
|
||||
// POST: api/filesystem/pathexists
|
||||
async checkPathExists(req, res) {
|
||||
if (!req.user.canUpload) {
|
||||
Logger.error(`[FileSystemController] Non-admin user attempting to check path exists`, req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const filepath = req.body.filepath
|
||||
if (!filepath?.length) {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
const exists = await fs.pathExists(filepath)
|
||||
res.json({
|
||||
exists
|
||||
})
|
||||
}
|
||||
}
|
||||
module.exports = new FileSystemController()
|
||||
|
|
@ -82,6 +82,11 @@ class LibraryController {
|
|||
return res.json(req.library)
|
||||
}
|
||||
|
||||
async getEpisodeDownloadQueue(req, res) {
|
||||
const libraryDownloadQueueDetails = this.podcastManager.getDownloadQueueDetails(req.library.id)
|
||||
return res.json(libraryDownloadQueueDetails)
|
||||
}
|
||||
|
||||
async update(req, res) {
|
||||
const library = req.library
|
||||
|
||||
|
|
@ -229,6 +234,16 @@ class LibraryController {
|
|||
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 no series sequence then fallback to sorting by title (or collapsed series name for sub-series)
|
||||
sortArray.push({
|
||||
asc: (li) => {
|
||||
if (this.db.serverSettings.sortingIgnorePrefix) {
|
||||
return li.collapsedSeries?.nameIgnorePrefix || li.media.metadata.titleIgnorePrefix
|
||||
} else {
|
||||
return li.collapsedSeries?.name || li.media.metadata.title
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (payload.sortBy) {
|
||||
|
|
@ -402,6 +417,10 @@ class LibraryController {
|
|||
return se.totalDuration
|
||||
} else if (payload.sortBy === 'addedAt') {
|
||||
return se.addedAt
|
||||
} else if (payload.sortBy === 'lastBookUpdated') {
|
||||
return Math.max(...(se.books).map(x => x.updatedAt), 0)
|
||||
} else if (payload.sortBy === 'lastBookAdded') {
|
||||
return Math.max(...(se.books).map(x => x.addedAt), 0)
|
||||
} else { // sort by name
|
||||
return this.db.serverSettings.sortingIgnorePrefix ? se.nameIgnorePrefixSort : se.name
|
||||
}
|
||||
|
|
@ -577,6 +596,7 @@ class LibraryController {
|
|||
|
||||
const itemMatches = []
|
||||
const authorMatches = {}
|
||||
const narratorMatches = {}
|
||||
const seriesMatches = {}
|
||||
const tagMatches = {}
|
||||
|
||||
|
|
@ -589,7 +609,7 @@ class LibraryController {
|
|||
matchText: queryResult.matchText
|
||||
})
|
||||
}
|
||||
if (queryResult.series && queryResult.series.length) {
|
||||
if (queryResult.series?.length) {
|
||||
queryResult.series.forEach((se) => {
|
||||
if (!seriesMatches[se.id]) {
|
||||
const _series = this.db.series.find(_se => _se.id === se.id)
|
||||
|
|
@ -599,7 +619,7 @@ class LibraryController {
|
|||
}
|
||||
})
|
||||
}
|
||||
if (queryResult.authors && queryResult.authors.length) {
|
||||
if (queryResult.authors?.length) {
|
||||
queryResult.authors.forEach((au) => {
|
||||
if (!authorMatches[au.id]) {
|
||||
const _author = this.db.authors.find(_au => _au.id === au.id)
|
||||
|
|
@ -612,7 +632,7 @@ class LibraryController {
|
|||
}
|
||||
})
|
||||
}
|
||||
if (queryResult.tags && queryResult.tags.length) {
|
||||
if (queryResult.tags?.length) {
|
||||
queryResult.tags.forEach((tag) => {
|
||||
if (!tagMatches[tag]) {
|
||||
tagMatches[tag] = { name: tag, books: [li.toJSON()] }
|
||||
|
|
@ -621,13 +641,23 @@ class LibraryController {
|
|||
}
|
||||
})
|
||||
}
|
||||
if (queryResult.narrators?.length) {
|
||||
queryResult.narrators.forEach((narrator) => {
|
||||
if (!narratorMatches[narrator]) {
|
||||
narratorMatches[narrator] = { name: narrator, books: [li.toJSON()] }
|
||||
} else {
|
||||
narratorMatches[narrator].books.push(li.toJSON())
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
const itemKey = req.library.mediaType
|
||||
const results = {
|
||||
[itemKey]: itemMatches.slice(0, maxResults),
|
||||
tags: Object.values(tagMatches).slice(0, maxResults),
|
||||
authors: Object.values(authorMatches).slice(0, maxResults),
|
||||
series: Object.values(seriesMatches).slice(0, maxResults)
|
||||
series: Object.values(seriesMatches).slice(0, maxResults),
|
||||
narrators: Object.values(narratorMatches).slice(0, maxResults)
|
||||
}
|
||||
res.json(results)
|
||||
}
|
||||
|
|
@ -637,6 +667,7 @@ class LibraryController {
|
|||
var authorsWithCount = libraryHelpers.getAuthorsWithCount(libraryItems)
|
||||
var genresWithCount = libraryHelpers.getGenresWithCount(libraryItems)
|
||||
var durationStats = libraryHelpers.getItemDurationStats(libraryItems)
|
||||
var sizeStats = libraryHelpers.getItemSizeStats(libraryItems)
|
||||
var stats = {
|
||||
totalItems: libraryItems.length,
|
||||
totalAuthors: Object.keys(authorsWithCount).length,
|
||||
|
|
@ -645,6 +676,7 @@ class LibraryController {
|
|||
longestItems: durationStats.longestItems,
|
||||
numAudioTracks: durationStats.numAudioTracks,
|
||||
totalSize: libraryHelpers.getLibraryItemsTotalSize(libraryItems),
|
||||
largestItems: sizeStats.largestItems,
|
||||
authorsWithCount,
|
||||
genresWithCount
|
||||
}
|
||||
|
|
@ -652,13 +684,12 @@ class LibraryController {
|
|||
}
|
||||
|
||||
async getAuthors(req, res) {
|
||||
var libraryItems = req.libraryItems
|
||||
var authors = {}
|
||||
libraryItems.forEach((li) => {
|
||||
const authors = {}
|
||||
req.libraryItems.forEach((li) => {
|
||||
if (li.media.metadata.authors && li.media.metadata.authors.length) {
|
||||
li.media.metadata.authors.forEach((au) => {
|
||||
if (!authors[au.id]) {
|
||||
var _author = this.db.authors.find(_au => _au.id === au.id)
|
||||
const _author = this.db.authors.find(_au => _au.id === au.id)
|
||||
if (_author) {
|
||||
authors[au.id] = _author.toJSON()
|
||||
authors[au.id].numBooks = 1
|
||||
|
|
@ -675,6 +706,85 @@ class LibraryController {
|
|||
})
|
||||
}
|
||||
|
||||
async getNarrators(req, res) {
|
||||
const narrators = {}
|
||||
req.libraryItems.forEach((li) => {
|
||||
if (li.media.metadata.narrators?.length) {
|
||||
li.media.metadata.narrators.forEach((n) => {
|
||||
if (typeof n !== 'string') {
|
||||
Logger.error(`[LibraryController] getNarrators: Invalid narrator "${n}" on book "${li.media.metadata.title}"`)
|
||||
} else if (!narrators[n]) {
|
||||
narrators[n] = {
|
||||
id: encodeURIComponent(Buffer.from(n).toString('base64')),
|
||||
name: n,
|
||||
numBooks: 1
|
||||
}
|
||||
} else {
|
||||
narrators[n].numBooks++
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
res.json({
|
||||
narrators: naturalSort(Object.values(narrators)).asc(n => n.name)
|
||||
})
|
||||
}
|
||||
|
||||
async updateNarrator(req, res) {
|
||||
if (!req.user.canUpdate) {
|
||||
Logger.error(`[LibraryController] Unauthorized user "${req.user.username}" attempted to update narrator`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const narratorName = libraryHelpers.decode(req.params.narratorId)
|
||||
const updatedName = req.body.name
|
||||
if (!updatedName) {
|
||||
return res.status(400).send('Invalid request payload. Name not specified.')
|
||||
}
|
||||
|
||||
const itemsUpdated = []
|
||||
for (const libraryItem of req.libraryItems) {
|
||||
if (libraryItem.media.metadata.updateNarrator(narratorName, updatedName)) {
|
||||
itemsUpdated.push(libraryItem)
|
||||
}
|
||||
}
|
||||
|
||||
if (itemsUpdated.length) {
|
||||
await this.db.updateLibraryItems(itemsUpdated)
|
||||
SocketAuthority.emitter('items_updated', itemsUpdated.map(li => li.toJSONExpanded()))
|
||||
}
|
||||
|
||||
res.json({
|
||||
updated: itemsUpdated.length
|
||||
})
|
||||
}
|
||||
|
||||
async removeNarrator(req, res) {
|
||||
if (!req.user.canUpdate) {
|
||||
Logger.error(`[LibraryController] Unauthorized user "${req.user.username}" attempted to remove narrator`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const narratorName = libraryHelpers.decode(req.params.narratorId)
|
||||
|
||||
const itemsUpdated = []
|
||||
for (const libraryItem of req.libraryItems) {
|
||||
if (libraryItem.media.metadata.removeNarrator(narratorName)) {
|
||||
itemsUpdated.push(libraryItem)
|
||||
}
|
||||
}
|
||||
|
||||
if (itemsUpdated.length) {
|
||||
await this.db.updateLibraryItems(itemsUpdated)
|
||||
SocketAuthority.emitter('items_updated', itemsUpdated.map(li => li.toJSONExpanded()))
|
||||
}
|
||||
|
||||
res.json({
|
||||
updated: itemsUpdated.length
|
||||
})
|
||||
}
|
||||
|
||||
async matchAll(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[LibraryController] Non-root user attempted to match library items`, req.user)
|
||||
|
|
@ -738,13 +848,19 @@ class LibraryController {
|
|||
res.json(payload)
|
||||
}
|
||||
|
||||
getOPMLFile(req, res) {
|
||||
const opmlText = this.podcastManager.generateOPMLFileText(req.libraryItems)
|
||||
res.type('application/xml')
|
||||
res.send(opmlText)
|
||||
}
|
||||
|
||||
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}`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
var library = this.db.libraries.find(lib => lib.id === req.params.id)
|
||||
const library = this.db.libraries.find(lib => lib.id === req.params.id)
|
||||
if (!library) {
|
||||
return res.status(404).send('Library not found')
|
||||
}
|
||||
|
|
@ -755,4 +871,4 @@ class LibraryController {
|
|||
next()
|
||||
}
|
||||
}
|
||||
module.exports = new LibraryController()
|
||||
module.exports = new LibraryController()
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
const Path = require('path')
|
||||
const fs = require('../libs/fsExtra')
|
||||
const Logger = require('../Logger')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
|
||||
const zipHelpers = require('../utils/zipHelpers')
|
||||
const { reqSupportsWebp, isNullOrNaN } = require('../utils/index')
|
||||
const { ScanResult } = require('../utils/constants')
|
||||
const { getAudioMimeTypeFromExtname } = require('../utils/fileUtils')
|
||||
|
||||
class LibraryItemController {
|
||||
constructor() { }
|
||||
|
|
@ -36,8 +39,11 @@ class LibraryItemController {
|
|||
}).filter(au => au)
|
||||
}
|
||||
} else if (includeEntities.includes('downloads')) {
|
||||
var downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id)
|
||||
item.episodesDownloading = downloadsInQueue.map(d => d.toJSONForClient())
|
||||
const downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id)
|
||||
item.episodeDownloadsQueued = downloadsInQueue.map(d => d.toJSONForClient())
|
||||
if (this.podcastManager.currentDownload?.libraryItemId === req.libraryItem.id) {
|
||||
item.episodesDownloading = [this.podcastManager.currentDownload.toJSONForClient()]
|
||||
}
|
||||
}
|
||||
|
||||
return res.json(item)
|
||||
|
|
@ -62,10 +68,29 @@ class LibraryItemController {
|
|||
}
|
||||
|
||||
async delete(req, res) {
|
||||
const hardDelete = req.query.hard == 1 // Delete from file system
|
||||
const libraryItemPath = req.libraryItem.path
|
||||
await this.handleDeleteLibraryItem(req.libraryItem)
|
||||
if (hardDelete) {
|
||||
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
|
||||
await fs.remove(libraryItemPath).catch((error) => {
|
||||
Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
|
||||
})
|
||||
}
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
download(req, res) {
|
||||
if (!req.user.canDownload) {
|
||||
Logger.warn('User attempted to download without permission', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const libraryItemPath = req.libraryItem.path
|
||||
const filename = `${req.libraryItem.media.metadata.title}.zip`
|
||||
zipHelpers.zipDirectoryPipe(libraryItemPath, filename, res)
|
||||
}
|
||||
|
||||
//
|
||||
// PATCH: will create new authors & series if in payload
|
||||
//
|
||||
|
|
@ -159,12 +184,12 @@ class LibraryItemController {
|
|||
|
||||
// PATCH: api/items/:id/cover
|
||||
async updateCover(req, res) {
|
||||
var libraryItem = req.libraryItem
|
||||
const libraryItem = req.libraryItem
|
||||
if (!req.body.cover) {
|
||||
return res.status(400).error('Invalid request no cover path')
|
||||
return res.status(400).send('Invalid request no cover path')
|
||||
}
|
||||
|
||||
var validationResult = await this.coverManager.validateCoverPath(req.body.cover, libraryItem)
|
||||
const validationResult = await this.coverManager.validateCoverPath(req.body.cover, libraryItem)
|
||||
if (validationResult.error) {
|
||||
return res.status(500).send(validationResult.error)
|
||||
}
|
||||
|
|
@ -277,19 +302,27 @@ class LibraryItemController {
|
|||
Logger.warn(`[LibraryItemController] User attempted to delete without permission`, req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
const hardDelete = req.query.hard == 1 // Delete files from filesystem
|
||||
|
||||
var { libraryItemIds } = req.body
|
||||
const { libraryItemIds } = req.body
|
||||
if (!libraryItemIds || !libraryItemIds.length) {
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
var itemsToDelete = this.db.libraryItems.filter(li => libraryItemIds.includes(li.id))
|
||||
const itemsToDelete = this.db.libraryItems.filter(li => libraryItemIds.includes(li.id))
|
||||
if (!itemsToDelete.length) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
for (let i = 0; i < itemsToDelete.length; i++) {
|
||||
const libraryItemPath = itemsToDelete[i].path
|
||||
Logger.info(`[LibraryItemController] Deleting Library Item "${itemsToDelete[i].media.metadata.title}"`)
|
||||
await this.handleDeleteLibraryItem(itemsToDelete[i])
|
||||
if (hardDelete) {
|
||||
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
|
||||
await fs.remove(libraryItemPath).catch((error) => {
|
||||
Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
|
||||
})
|
||||
}
|
||||
}
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
|
@ -348,20 +381,23 @@ class LibraryItemController {
|
|||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
var itemsUpdated = 0
|
||||
var itemsUnmatched = 0
|
||||
let itemsUpdated = 0
|
||||
let itemsUnmatched = 0
|
||||
|
||||
var matchData = req.body
|
||||
var options = matchData.options || {}
|
||||
var items = matchData.libraryItemIds
|
||||
if (!items || !items.length) {
|
||||
return res.sendStatus(500)
|
||||
const options = req.body.options || {}
|
||||
if (!req.body.libraryItemIds?.length) {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
const libraryItems = req.body.libraryItemIds.map(lid => this.db.getLibraryItem(lid)).filter(li => li)
|
||||
if (!libraryItems?.length) {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
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)
|
||||
for (const libraryItem of libraryItems) {
|
||||
const matchResult = await this.scanner.quickMatchLibraryItem(libraryItem, options)
|
||||
if (matchResult.updated) {
|
||||
itemsUpdated++
|
||||
} else if (matchResult.warning) {
|
||||
|
|
@ -369,7 +405,7 @@ class LibraryItemController {
|
|||
}
|
||||
}
|
||||
|
||||
var result = {
|
||||
const result = {
|
||||
success: itemsUpdated > 0,
|
||||
updates: itemsUpdated,
|
||||
unmatched: itemsUnmatched
|
||||
|
|
@ -377,6 +413,33 @@ class LibraryItemController {
|
|||
SocketAuthority.clientEmitter(req.user.id, 'batch_quickmatch_complete', result)
|
||||
}
|
||||
|
||||
// POST: api/items/batch/scan
|
||||
async batchScan(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.warn('User other than admin attempted to batch scan library items', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
if (!req.body.libraryItemIds?.length) {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
const libraryItems = req.body.libraryItemIds.map(lid => this.db.getLibraryItem(lid)).filter(li => li)
|
||||
if (!libraryItems?.length) {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
res.sendStatus(200)
|
||||
|
||||
for (const libraryItem of libraryItems) {
|
||||
if (libraryItem.isFile) {
|
||||
Logger.warn(`[LibraryItemController] Re-scanning file library items not yet supported`)
|
||||
} else {
|
||||
await this.scanner.scanLibraryItemByRequest(libraryItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE: api/items/all
|
||||
async deleteAll(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
|
|
@ -401,7 +464,7 @@ class LibraryItemController {
|
|||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
var result = await this.scanner.scanLibraryItemById(req.libraryItem.id)
|
||||
const result = await this.scanner.scanLibraryItemByRequest(req.libraryItem)
|
||||
res.json({
|
||||
result: Object.keys(ScanResult).find(key => ScanResult[key] == result)
|
||||
})
|
||||
|
|
@ -433,12 +496,12 @@ class LibraryItemController {
|
|||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
const chapters = req.body.chapters || []
|
||||
if (!chapters.length) {
|
||||
if (!req.body.chapters) {
|
||||
Logger.error(`[LibraryItemController] Invalid payload`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
const chapters = req.body.chapters || []
|
||||
const wasUpdated = req.libraryItem.media.updateChapters(chapters)
|
||||
if (wasUpdated) {
|
||||
await this.db.updateLibraryItem(req.libraryItem)
|
||||
|
|
@ -467,15 +530,126 @@ class LibraryItemController {
|
|||
res.json(toneData)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/items/:id/file/:fileid
|
||||
*
|
||||
* @param {express.Request} req
|
||||
* @param {express.Response} res
|
||||
*/
|
||||
async getLibraryFile(req, res) {
|
||||
const libraryFile = req.libraryFile
|
||||
|
||||
if (global.XAccel) {
|
||||
Logger.debug(`Use X-Accel to serve static file ${libraryFile.metadata.path}`)
|
||||
return res.status(204).header({ 'X-Accel-Redirect': global.XAccel + libraryFile.metadata.path }).send()
|
||||
}
|
||||
|
||||
// Express does not set the correct mimetype for m4b files so use our defined mimetypes if available
|
||||
const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(libraryFile.metadata.path))
|
||||
if (audioMimeType) {
|
||||
res.setHeader('Content-Type', audioMimeType)
|
||||
}
|
||||
res.sendFile(libraryFile.metadata.path)
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE api/items/:id/file/:fileid
|
||||
*
|
||||
* @param {express.Request} req
|
||||
* @param {express.Response} res
|
||||
*/
|
||||
async deleteLibraryFile(req, res) {
|
||||
const libraryFile = req.libraryFile
|
||||
|
||||
Logger.info(`[LibraryItemController] User "${req.user.username}" requested file delete at "${libraryFile.metadata.path}"`)
|
||||
|
||||
await fs.remove(libraryFile.metadata.path).catch((error) => {
|
||||
Logger.error(`[LibraryItemController] Failed to delete library file at "${libraryFile.metadata.path}"`, error)
|
||||
})
|
||||
req.libraryItem.removeLibraryFile(req.params.fileid)
|
||||
|
||||
if (req.libraryItem.media.removeFileWithInode(req.params.fileid)) {
|
||||
// If book has no more media files then mark it as missing
|
||||
if (req.libraryItem.mediaType === 'book' && !req.libraryItem.media.hasMediaEntities) {
|
||||
req.libraryItem.setMissing()
|
||||
}
|
||||
}
|
||||
req.libraryItem.updatedAt = Date.now()
|
||||
await this.db.updateLibraryItem(req.libraryItem)
|
||||
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/items/:id/file/:fileid/download
|
||||
* Same as GET api/items/:id/file/:fileid but allows logging and restricting downloads
|
||||
* @param {express.Request} req
|
||||
* @param {express.Response} res
|
||||
*/
|
||||
async downloadLibraryFile(req, res) {
|
||||
const libraryFile = req.libraryFile
|
||||
|
||||
if (!req.user.canDownload) {
|
||||
Logger.error(`[LibraryItemController] User without download permission attempted to download file "${libraryFile.metadata.path}"`, req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
Logger.info(`[LibraryItemController] User "${req.user.username}" requested file download at "${libraryFile.metadata.path}"`)
|
||||
|
||||
if (global.XAccel) {
|
||||
Logger.debug(`Use X-Accel to serve static file ${libraryFile.metadata.path}`)
|
||||
return res.status(204).header({ 'X-Accel-Redirect': global.XAccel + libraryFile.metadata.path }).send()
|
||||
}
|
||||
|
||||
// Express does not set the correct mimetype for m4b files so use our defined mimetypes if available
|
||||
const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(libraryFile.metadata.path))
|
||||
if (audioMimeType) {
|
||||
res.setHeader('Content-Type', audioMimeType)
|
||||
}
|
||||
|
||||
res.download(libraryFile.metadata.path, libraryFile.metadata.filename)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/items/:id/ebook
|
||||
*
|
||||
* @param {express.Request} req
|
||||
* @param {express.Response} res
|
||||
*/
|
||||
async getEBookFile(req, res) {
|
||||
const ebookFile = req.libraryItem.media.ebookFile
|
||||
if (!ebookFile) {
|
||||
Logger.error(`[LibraryItemController] No ebookFile for library item "${req.libraryItem.media.metadata.title}"`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
const ebookFilePath = ebookFile.metadata.path
|
||||
|
||||
if (global.XAccel) {
|
||||
Logger.debug(`Use X-Accel to serve static file ${ebookFilePath}`)
|
||||
return res.status(204).header({ 'X-Accel-Redirect': global.XAccel + ebookFilePath }).send()
|
||||
}
|
||||
|
||||
res.sendFile(ebookFilePath)
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
const item = this.db.libraryItems.find(li => li.id === req.params.id)
|
||||
if (!item || !item.media) return res.sendStatus(404)
|
||||
req.libraryItem = this.db.libraryItems.find(li => li.id === req.params.id)
|
||||
if (!req.libraryItem?.media) return res.sendStatus(404)
|
||||
|
||||
// Check user can access this library item
|
||||
if (!req.user.checkCanAccessLibraryItem(item)) {
|
||||
if (!req.user.checkCanAccessLibraryItem(req.libraryItem)) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
// For library file routes, get the library file
|
||||
if (req.params.fileid) {
|
||||
req.libraryFile = req.libraryItem.libraryFiles.find(lf => lf.ino === req.params.fileid)
|
||||
if (!req.libraryFile) {
|
||||
Logger.error(`[LibraryItemController] Library file "${req.params.fileid}" does not exist for library item`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
}
|
||||
|
||||
if (req.path.includes('/play')) {
|
||||
// allow POST requests using /play and /play/:episodeId
|
||||
} else if (req.method == 'DELETE' && !req.user.canDelete) {
|
||||
|
|
@ -486,7 +660,6 @@ class LibraryItemController {
|
|||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
req.libraryItem = item
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,8 +48,7 @@ class MeController {
|
|||
|
||||
// DELETE: api/me/progress/:id
|
||||
async removeMediaProgress(req, res) {
|
||||
var wasRemoved = req.user.removeMediaProgress(req.params.id)
|
||||
if (!wasRemoved) {
|
||||
if (!req.user.removeMediaProgress(req.params.id)) {
|
||||
return res.sendStatus(200)
|
||||
}
|
||||
await this.db.updateEntity('user', req.user)
|
||||
|
|
@ -171,23 +170,6 @@ class MeController {
|
|||
this.auth.userChangePassword(req, res)
|
||||
}
|
||||
|
||||
// TODO: Remove after mobile release v0.9.61-beta
|
||||
// PATCH: api/me/settings
|
||||
async updateSettings(req, res) {
|
||||
var settingsUpdate = req.body
|
||||
if (!settingsUpdate || !isObject(settingsUpdate)) {
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
var madeUpdates = req.user.updateSettings(settingsUpdate)
|
||||
if (madeUpdates) {
|
||||
await this.db.updateEntity('user', req.user)
|
||||
}
|
||||
return res.json({
|
||||
success: true,
|
||||
settings: req.user.settings
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: Deprecated. Removed from Android. Only used in iOS app now.
|
||||
// POST: api/me/sync-local-progress
|
||||
async syncLocalMediaProgress(req, res) {
|
||||
|
|
@ -256,13 +238,13 @@ class MeController {
|
|||
}
|
||||
|
||||
// GET: api/me/items-in-progress
|
||||
async getAllLibraryItemsInProgress(req, res) {
|
||||
getAllLibraryItemsInProgress(req, res) {
|
||||
const limit = !isNaN(req.query.limit) ? Number(req.query.limit) || 25 : 25
|
||||
|
||||
var itemsInProgress = []
|
||||
let itemsInProgress = []
|
||||
for (const mediaProgress of req.user.mediaProgress) {
|
||||
if (!mediaProgress.isFinished && mediaProgress.progress > 0) {
|
||||
const libraryItem = await this.db.getLibraryItem(mediaProgress.libraryItemId)
|
||||
if (!mediaProgress.isFinished && (mediaProgress.progress > 0 || mediaProgress.ebookProgress > 0)) {
|
||||
const libraryItem = this.db.getLibraryItem(mediaProgress.libraryItemId)
|
||||
if (libraryItem) {
|
||||
if (mediaProgress.episodeId && libraryItem.mediaType === 'podcast') {
|
||||
const episode = libraryItem.media.episodes.find(ep => ep.id === mediaProgress.episodeId)
|
||||
|
|
|
|||
|
|
@ -90,9 +90,19 @@ class MiscController {
|
|||
|
||||
// GET: api/tasks
|
||||
getTasks(req, res) {
|
||||
res.json({
|
||||
const includeArray = (req.query.include || '').split(',')
|
||||
|
||||
const data = {
|
||||
tasks: this.taskManager.tasks.map(t => t.toJSON())
|
||||
})
|
||||
}
|
||||
|
||||
if (includeArray.includes('queue')) {
|
||||
data.queuedTaskData = {
|
||||
embedMetadata: this.audioMetadataManager.getQueuedTaskData()
|
||||
}
|
||||
}
|
||||
|
||||
res.json(data)
|
||||
}
|
||||
|
||||
// PATCH: api/settings (admin)
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ class PodcastController {
|
|||
res.json({ podcast })
|
||||
}
|
||||
|
||||
async getOPMLFeeds(req, res) {
|
||||
async getFeedsFromOPMLText(req, res) {
|
||||
if (!req.body.opmlText) {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
|
@ -225,6 +225,20 @@ class PodcastController {
|
|||
res.json(libraryItem.toJSONExpanded())
|
||||
}
|
||||
|
||||
// GET: api/podcasts/:id/episode/:episodeId
|
||||
async getEpisode(req, res) {
|
||||
const episodeId = req.params.episodeId
|
||||
const libraryItem = req.libraryItem
|
||||
|
||||
const episode = libraryItem.media.episodes.find(ep => ep.id === episodeId)
|
||||
if (!episode) {
|
||||
Logger.error(`[PodcastController] getEpisode episode ${episodeId} not found for item ${libraryItem.id}`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
res.json(episode)
|
||||
}
|
||||
|
||||
// DELETE: api/podcasts/:id/episode/:episodeId
|
||||
async removeEpisode(req, res) {
|
||||
var episodeId = req.params.episodeId
|
||||
|
|
@ -283,4 +297,4 @@ class PodcastController {
|
|||
next()
|
||||
}
|
||||
}
|
||||
module.exports = new PodcastController()
|
||||
module.exports = new PodcastController()
|
||||
|
|
|
|||
|
|
@ -134,4 +134,4 @@ class RSSFeedController {
|
|||
next()
|
||||
}
|
||||
}
|
||||
module.exports = new RSSFeedController()
|
||||
module.exports = new RSSFeedController()
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ class SeriesController {
|
|||
|
||||
// Add progress map with isFinished flag
|
||||
if (include.includes('progress')) {
|
||||
const libraryItemsInSeries = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(seriesJson.id))
|
||||
const libraryItemsInSeries = req.libraryItemsInSeries
|
||||
const libraryItemsFinished = libraryItemsInSeries.filter(li => {
|
||||
const mediaProgress = req.user.getMediaProgress(li.id)
|
||||
return mediaProgress && mediaProgress.isFinished
|
||||
|
|
@ -55,6 +55,12 @@ class SeriesController {
|
|||
const series = this.db.series.find(se => se.id === req.params.id)
|
||||
if (!series) return res.sendStatus(404)
|
||||
|
||||
const libraryItemsInSeries = this.db.libraryItems.filter(li => li.media.metadata.hasSeries?.(series.id))
|
||||
if (libraryItemsInSeries.some(li => !req.user.checkCanAccessLibrary(li.libraryId))) {
|
||||
Logger.warn(`[SeriesController] User attempted to access series "${series.id}" without access to the library`, req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
if (req.method == 'DELETE' && !req.user.canDelete) {
|
||||
Logger.warn(`[SeriesController] User attempted to delete without permission`, req.user)
|
||||
return res.sendStatus(403)
|
||||
|
|
@ -64,6 +70,7 @@ class SeriesController {
|
|||
}
|
||||
|
||||
req.series = series
|
||||
req.libraryItemsInSeries = libraryItemsInSeries
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ class SessionController {
|
|||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
var listeningSessions = []
|
||||
let listeningSessions = []
|
||||
if (req.query.user) {
|
||||
listeningSessions = await this.getUserListeningSessionsHelper(req.query.user)
|
||||
} else {
|
||||
|
|
@ -42,6 +42,25 @@ class SessionController {
|
|||
res.json(payload)
|
||||
}
|
||||
|
||||
getOpenSessions(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[SessionController] getOpenSessions: Non-admin user requested open session data ${req.user.id}/"${req.user.username}"`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
const openSessions = this.playbackSessionManager.sessions.map(se => {
|
||||
const user = this.db.users.find(u => u.id === se.userId) || null
|
||||
return {
|
||||
...se.toJSON(),
|
||||
user: user ? { id: user.id, username: user.username } : null
|
||||
}
|
||||
})
|
||||
|
||||
res.json({
|
||||
sessions: openSessions
|
||||
})
|
||||
}
|
||||
|
||||
getOpenSession(req, res) {
|
||||
var libraryItem = this.db.getLibraryItem(req.session.libraryItemId)
|
||||
var sessionForClient = req.session.toJSONForClient(libraryItem)
|
||||
|
|
@ -55,7 +74,9 @@ class SessionController {
|
|||
|
||||
// POST: api/session/:id/close
|
||||
close(req, res) {
|
||||
this.playbackSessionManager.closeSessionRequest(req.user, req.session, req.body, res)
|
||||
let syncData = req.body
|
||||
if (syncData && !Object.keys(syncData).length) syncData = null
|
||||
this.playbackSessionManager.closeSessionRequest(req.user, req.session, syncData, res)
|
||||
}
|
||||
|
||||
// DELETE: api/session/:id
|
||||
|
|
|
|||
|
|
@ -3,14 +3,8 @@ const Logger = require('../Logger')
|
|||
class ToolsController {
|
||||
constructor() { }
|
||||
|
||||
|
||||
// POST: api/tools/item/:id/encode-m4b
|
||||
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)
|
||||
}
|
||||
|
||||
if (req.libraryItem.isMissing || req.libraryItem.isInvalid) {
|
||||
Logger.error(`[MiscController] encodeM4b: library item not found or invalid ${req.params.id}`)
|
||||
return res.status(404).send('Audiobook not found')
|
||||
|
|
@ -34,11 +28,6 @@ class ToolsController {
|
|||
|
||||
// DELETE: api/tools/item/:id/encode-m4b
|
||||
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)
|
||||
}
|
||||
|
||||
const workerTask = this.abMergeManager.getPendingTaskByLibraryItemId(req.params.id)
|
||||
if (!workerTask) return res.sendStatus(404)
|
||||
|
||||
|
|
@ -49,14 +38,14 @@ class ToolsController {
|
|||
|
||||
// POST: api/tools/item/:id/embed-metadata
|
||||
async embedAudioFileMetadata(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[LibraryItemController] Non-root user attempted to update audio metadata`, req.user)
|
||||
return res.sendStatus(403)
|
||||
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
|
||||
Logger.error(`[ToolsController] Invalid library item`)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
|
||||
Logger.error(`[LibraryItemController] Invalid library item`)
|
||||
return res.sendStatus(500)
|
||||
if (this.audioMetadataManager.getIsLibraryItemQueuedOrProcessing(req.libraryItem.id)) {
|
||||
Logger.error(`[ToolsController] Library item (${req.libraryItem.id}) is already in queue or processing`)
|
||||
return res.status(500).send('Library item is already in queue or processing')
|
||||
}
|
||||
|
||||
const options = {
|
||||
|
|
@ -67,16 +56,66 @@ class ToolsController {
|
|||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
itemMiddleware(req, res, next) {
|
||||
var item = this.db.libraryItems.find(li => li.id === req.params.id)
|
||||
if (!item || !item.media) return res.sendStatus(404)
|
||||
// POST: api/tools/batch/embed-metadata
|
||||
async batchEmbedMetadata(req, res) {
|
||||
const libraryItemIds = req.body.libraryItemIds || []
|
||||
if (!libraryItemIds.length) {
|
||||
return res.status(400).send('Invalid request payload')
|
||||
}
|
||||
|
||||
// Check user can access this library item
|
||||
if (!req.user.checkCanAccessLibraryItem(item)) {
|
||||
const libraryItems = []
|
||||
for (const libraryItemId of libraryItemIds) {
|
||||
const libraryItem = this.db.getLibraryItem(libraryItemId)
|
||||
if (!libraryItem) {
|
||||
Logger.error(`[ToolsController] Batch embed metadata library item (${libraryItemId}) not found`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
// Check user can access this library item
|
||||
if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
|
||||
Logger.error(`[ToolsController] Batch embed metadata library item (${libraryItemId}) not accessible to user`, req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
if (libraryItem.isMissing || !libraryItem.hasAudioFiles || !libraryItem.isBook) {
|
||||
Logger.error(`[ToolsController] Batch embed invalid library item (${libraryItemId})`)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
if (this.audioMetadataManager.getIsLibraryItemQueuedOrProcessing(libraryItemId)) {
|
||||
Logger.error(`[ToolsController] Batch embed library item (${libraryItemId}) is already in queue or processing`)
|
||||
return res.status(500).send('Library item is already in queue or processing')
|
||||
}
|
||||
|
||||
libraryItems.push(libraryItem)
|
||||
}
|
||||
|
||||
const options = {
|
||||
forceEmbedChapters: req.query.forceEmbedChapters === '1',
|
||||
backup: req.query.backup === '1'
|
||||
}
|
||||
this.audioMetadataManager.handleBatchEmbed(req.user, libraryItems, options)
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[LibraryItemController] Non-root user attempted to access tools route`, req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
req.libraryItem = item
|
||||
if (req.params.id) {
|
||||
const item = this.db.libraryItems.find(li => li.id === req.params.id)
|
||||
if (!item || !item.media) return res.sendStatus(404)
|
||||
|
||||
// Check user can access this library item
|
||||
if (!req.user.checkCanAccessLibraryItem(item)) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
req.libraryItem = item
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
// POST: api/tools/item/:id/renameFolder
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@ class UserController {
|
|||
findAll(req, res) {
|
||||
if (!req.user.isAdminOrUp) return res.sendStatus(403)
|
||||
const hideRootToken = !req.user.isRoot
|
||||
const users = this.db.users.map(u => this.userJsonWithItemProgressDetails(u, hideRootToken))
|
||||
res.json({
|
||||
users: users
|
||||
// Minimal toJSONForBrowser does not include mediaProgress and bookmarks
|
||||
users: this.db.users.map(u => u.toJSONForBrowser(hideRootToken, true))
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,16 +20,16 @@ class AuthorFinder {
|
|||
})
|
||||
}
|
||||
|
||||
findAuthorByASIN(asin) {
|
||||
findAuthorByASIN(asin, region) {
|
||||
if (!asin) return null
|
||||
return this.audnexus.findAuthorByASIN(asin)
|
||||
return this.audnexus.findAuthorByASIN(asin, region)
|
||||
}
|
||||
|
||||
async findAuthorByName(name, options = {}) {
|
||||
async findAuthorByName(name, region, options = {}) {
|
||||
if (!name) return null
|
||||
const maxLevenshtein = !isNaN(options.maxLevenshtein) ? Number(options.maxLevenshtein) : 3
|
||||
|
||||
var author = await this.audnexus.findAuthorByName(name, maxLevenshtein)
|
||||
const author = await this.audnexus.findAuthorByName(name, region, maxLevenshtein)
|
||||
if (!author || !author.name) {
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ const Audible = require('../providers/Audible')
|
|||
const iTunes = require('../providers/iTunes')
|
||||
const Audnexus = require('../providers/Audnexus')
|
||||
const FantLab = require('../providers/FantLab')
|
||||
const AudiobookCovers = require('../providers/AudiobookCovers')
|
||||
const Logger = require('../Logger')
|
||||
const { levenshteinDistance } = require('../utils/index')
|
||||
|
||||
|
|
@ -15,6 +16,9 @@ class BookFinder {
|
|||
this.iTunesApi = new iTunes()
|
||||
this.audnexus = new Audnexus()
|
||||
this.fantLab = new FantLab()
|
||||
this.audiobookCovers = new AudiobookCovers()
|
||||
|
||||
this.providers = ['google', 'itunes', 'openlibrary', 'fantlab', 'audiobookcovers', 'audible', 'audible.ca', 'audible.uk', 'audible.au', 'audible.fr', 'audible.de', 'audible.jp', 'audible.it', 'audible.in', 'audible.es']
|
||||
|
||||
this.verbose = false
|
||||
}
|
||||
|
|
@ -159,6 +163,12 @@ class BookFinder {
|
|||
return books
|
||||
}
|
||||
|
||||
async getAudiobookCoversResults(search) {
|
||||
const covers = await this.audiobookCovers.search(search)
|
||||
if (this.verbose) Logger.debug(`AudiobookCovers Search Results: ${covers.length || 0}`)
|
||||
return covers || []
|
||||
}
|
||||
|
||||
async getiTunesAudiobooksResults(title, author) {
|
||||
return this.iTunesApi.searchAudiobooks(title)
|
||||
}
|
||||
|
|
@ -175,7 +185,7 @@ class BookFinder {
|
|||
var books = []
|
||||
var maxTitleDistance = !isNaN(options.titleDistance) ? Number(options.titleDistance) : 4
|
||||
var maxAuthorDistance = !isNaN(options.authorDistance) ? Number(options.authorDistance) : 4
|
||||
Logger.debug(`Book Search: title: "${title}", author: "${author}", provider: ${provider}`)
|
||||
Logger.debug(`Book Search: title: "${title}", author: "${author || ''}", provider: ${provider}`)
|
||||
|
||||
if (provider === 'google') {
|
||||
books = await this.getGoogleBooksResults(title, author)
|
||||
|
|
@ -187,6 +197,8 @@ class BookFinder {
|
|||
books = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
|
||||
} else if (provider === 'fantlab') {
|
||||
books = await this.getFantLabResults(title, author)
|
||||
} else if (provider === 'audiobookcovers') {
|
||||
books = await this.getAudiobookCoversResults(title)
|
||||
}
|
||||
else {
|
||||
books = await this.getGoogleBooksResults(title, author)
|
||||
|
|
@ -202,27 +214,39 @@ class BookFinder {
|
|||
return this.search(provider, cleanedTitle, cleanedAuthor, isbn, asin, options)
|
||||
}
|
||||
|
||||
if (["google", "audible", "itunes", 'fantlab'].includes(provider)) return books
|
||||
if (provider === 'openlibrary') {
|
||||
books.sort((a, b) => {
|
||||
return a.totalDistance - b.totalDistance
|
||||
})
|
||||
}
|
||||
|
||||
return books.sort((a, b) => {
|
||||
return a.totalDistance - b.totalDistance
|
||||
})
|
||||
return books
|
||||
}
|
||||
|
||||
async findCovers(provider, title, author, options = {}) {
|
||||
var searchResults = await this.search(provider, title, author, options)
|
||||
let searchResults = []
|
||||
|
||||
if (provider === 'all') {
|
||||
for (const providerString of this.providers) {
|
||||
const providerResults = await this.search(providerString, title, author, options)
|
||||
Logger.debug(`[BookFinder] Found ${providerResults.length} covers from ${providerString}`)
|
||||
searchResults.push(...providerResults)
|
||||
}
|
||||
} else {
|
||||
searchResults = await this.search(provider, title, author, options)
|
||||
}
|
||||
Logger.debug(`[BookFinder] FindCovers search results: ${searchResults.length}`)
|
||||
|
||||
var covers = []
|
||||
const covers = []
|
||||
searchResults.forEach((result) => {
|
||||
if (result.covers && result.covers.length) {
|
||||
covers = covers.concat(result.covers)
|
||||
covers.push(...result.covers)
|
||||
}
|
||||
if (result.cover) {
|
||||
covers.push(result.cover)
|
||||
}
|
||||
})
|
||||
return covers
|
||||
return [...(new Set(covers))]
|
||||
}
|
||||
|
||||
findChapters(asin, region) {
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ function updateLock(file, options) {
|
|||
// the lockfile was deleted or we are over the threshold
|
||||
if (err) {
|
||||
if (err.code === 'ENOENT' || isOverThreshold) {
|
||||
console.error(`lockfile "${file}" compromised. stat code=${err.code}, isOverThreshold=${isOverThreshold}`)
|
||||
return setLockAsCompromised(file, lock, Object.assign(err, { code: 'ECOMPROMISED' }));
|
||||
}
|
||||
|
||||
|
|
@ -129,6 +130,7 @@ function updateLock(file, options) {
|
|||
const isMtimeOurs = lock.mtime.getTime() === stat.mtime.getTime();
|
||||
|
||||
if (!isMtimeOurs) {
|
||||
console.error(`lockfile "${file}" compromised. mtime is not ours`)
|
||||
return setLockAsCompromised(
|
||||
file,
|
||||
lock,
|
||||
|
|
@ -152,6 +154,7 @@ function updateLock(file, options) {
|
|||
// the lockfile was deleted or we are over the threshold
|
||||
if (err) {
|
||||
if (err.code === 'ENOENT' || isOverThreshold) {
|
||||
console.error(`lockfile "${file}" compromised. utimes code=${err.code}, isOverThreshold=${isOverThreshold}`)
|
||||
return setLockAsCompromised(file, lock, Object.assign(err, { code: 'ECOMPROMISED' }));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,8 +15,6 @@ class AbMergeManager {
|
|||
this.taskManager = taskManager
|
||||
|
||||
this.itemsCacheDir = Path.join(global.MetadataPath, 'cache/items')
|
||||
this.downloadDirPath = Path.join(global.MetadataPath, 'downloads')
|
||||
this.downloadDirPathExist = false
|
||||
|
||||
this.pendingTasks = []
|
||||
}
|
||||
|
|
@ -29,22 +27,6 @@ class AbMergeManager {
|
|||
return this.removeTask(task, true)
|
||||
}
|
||||
|
||||
async ensureDownloadDirPath() { // Creates download path if necessary and sets owner and permissions
|
||||
if (this.downloadDirPathExist) return
|
||||
|
||||
var pathCreated = false
|
||||
if (!(await fs.pathExists(this.downloadDirPath))) {
|
||||
await fs.mkdir(this.downloadDirPath)
|
||||
pathCreated = true
|
||||
}
|
||||
|
||||
if (pathCreated) {
|
||||
await filePerms.setDefault(this.downloadDirPath)
|
||||
}
|
||||
|
||||
this.downloadDirPathExist = true
|
||||
}
|
||||
|
||||
async startAudiobookMerge(user, libraryItem, options = {}) {
|
||||
const task = new Task()
|
||||
|
||||
|
|
@ -64,7 +46,7 @@ class AbMergeManager {
|
|||
toneJsonObject: null
|
||||
}
|
||||
const taskDescription = `Encoding audiobook "${libraryItem.media.metadata.title}" into a single m4b file.`
|
||||
task.setData('encode-m4b', 'Encoding M4b', taskDescription, taskData)
|
||||
task.setData('encode-m4b', 'Encoding M4b', taskDescription, false, taskData)
|
||||
this.taskManager.addTask(task)
|
||||
Logger.info(`Start m4b encode for ${libraryItem.id} - TaskId: ${task.id}`)
|
||||
|
||||
|
|
@ -130,7 +112,7 @@ class AbMergeManager {
|
|||
let toneJsonPath = null
|
||||
try {
|
||||
toneJsonPath = Path.join(task.data.itemCachePath, 'metadata.json')
|
||||
await toneHelpers.writeToneMetadataJsonFile(libraryItem, libraryItem.media.chapters, toneJsonPath, 1)
|
||||
await toneHelpers.writeToneMetadataJsonFile(libraryItem, libraryItem.media.chapters, toneJsonPath, 1, 'audio/mp4')
|
||||
} catch (error) {
|
||||
Logger.error(`[AbMergeManager] Write metadata.json failed`, error)
|
||||
toneJsonPath = null
|
||||
|
|
|
|||
|
|
@ -5,18 +5,45 @@ const Logger = require('../Logger')
|
|||
|
||||
const fs = require('../libs/fsExtra')
|
||||
|
||||
const { secondsToTimestamp } = require('../utils/index')
|
||||
const toneHelpers = require('../utils/toneHelpers')
|
||||
const filePerms = require('../utils/filePerms')
|
||||
|
||||
const Task = require('../objects/Task')
|
||||
|
||||
class AudioMetadataMangaer {
|
||||
constructor(db, taskManager) {
|
||||
this.db = db
|
||||
this.taskManager = taskManager
|
||||
|
||||
this.itemsCacheDir = Path.join(global.MetadataPath, 'cache/items')
|
||||
|
||||
this.MAX_CONCURRENT_TASKS = 1
|
||||
this.tasksRunning = []
|
||||
this.tasksQueued = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queued task data
|
||||
* @return {Array}
|
||||
*/
|
||||
getQueuedTaskData() {
|
||||
return this.tasksQueued.map(t => t.data)
|
||||
}
|
||||
|
||||
getIsLibraryItemQueuedOrProcessing(libraryItemId) {
|
||||
return this.tasksQueued.some(t => t.data.libraryItemId === libraryItemId) || this.tasksRunning.some(t => t.data.libraryItemId === libraryItemId)
|
||||
}
|
||||
|
||||
getToneMetadataObjectForApi(libraryItem) {
|
||||
return toneHelpers.getToneMetadataObject(libraryItem)
|
||||
const audioFiles = libraryItem.media.includedAudioFiles
|
||||
let mimeType = audioFiles[0].mimeType
|
||||
if (audioFiles.some(a => a.mimeType !== mimeType)) mimeType = null
|
||||
return toneHelpers.getToneMetadataObject(libraryItem, libraryItem.media.chapters, libraryItem.media.tracks.length, mimeType)
|
||||
}
|
||||
|
||||
handleBatchEmbed(user, libraryItems, options = {}) {
|
||||
libraryItems.forEach((li) => {
|
||||
this.updateMetadataForItem(user, li, options)
|
||||
})
|
||||
}
|
||||
|
||||
async updateMetadataForItem(user, libraryItem, options = {}) {
|
||||
|
|
@ -25,99 +52,147 @@ class AudioMetadataMangaer {
|
|||
|
||||
const audioFiles = libraryItem.media.includedAudioFiles
|
||||
|
||||
const itemAudioMetadataPayload = {
|
||||
userId: user.id,
|
||||
const task = new Task()
|
||||
|
||||
const itemCachePath = Path.join(this.itemsCacheDir, libraryItem.id)
|
||||
|
||||
// Only writing chapters for single file audiobooks
|
||||
const chapters = (audioFiles.length == 1 || forceEmbedChapters) ? libraryItem.media.chapters.map(c => ({ ...c })) : null
|
||||
|
||||
let mimeType = audioFiles[0].mimeType
|
||||
if (audioFiles.some(a => a.mimeType !== mimeType)) mimeType = null
|
||||
|
||||
// Create task
|
||||
const taskData = {
|
||||
libraryItemId: libraryItem.id,
|
||||
startedAt: Date.now(),
|
||||
audioFiles: audioFiles.map(af => ({ index: af.index, ino: af.ino, filename: af.metadata.filename }))
|
||||
libraryItemPath: libraryItem.path,
|
||||
userId: user.id,
|
||||
audioFiles: audioFiles.map(af => (
|
||||
{
|
||||
index: af.index,
|
||||
ino: af.ino,
|
||||
filename: af.metadata.filename,
|
||||
path: af.metadata.path,
|
||||
cachePath: Path.join(itemCachePath, af.metadata.filename)
|
||||
}
|
||||
)),
|
||||
coverPath: libraryItem.media.coverPath,
|
||||
metadataObject: toneHelpers.getToneMetadataObject(libraryItem, chapters, audioFiles.length, mimeType),
|
||||
itemCachePath,
|
||||
chapters,
|
||||
options: {
|
||||
forceEmbedChapters,
|
||||
backupFiles
|
||||
}
|
||||
}
|
||||
const taskDescription = `Embedding metadata in audiobook "${libraryItem.media.metadata.title}".`
|
||||
task.setData('embed-metadata', 'Embedding Metadata', taskDescription, false, taskData)
|
||||
|
||||
SocketAuthority.emitter('audio_metadata_started', itemAudioMetadataPayload)
|
||||
if (this.tasksRunning.length >= this.MAX_CONCURRENT_TASKS) {
|
||||
Logger.info(`[AudioMetadataManager] Queueing embed metadata for audiobook "${libraryItem.media.metadata.title}"`)
|
||||
SocketAuthority.adminEmitter('metadata_embed_queue_update', {
|
||||
libraryItemId: libraryItem.id,
|
||||
queued: true
|
||||
})
|
||||
this.tasksQueued.push(task)
|
||||
} else {
|
||||
this.runMetadataEmbed(task)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure folder for backup files
|
||||
const itemCacheDir = Path.join(global.MetadataPath, `cache/items/${libraryItem.id}`)
|
||||
async runMetadataEmbed(task) {
|
||||
this.tasksRunning.push(task)
|
||||
this.taskManager.addTask(task)
|
||||
|
||||
Logger.info(`[AudioMetadataManager] Starting metadata embed task`, task.description)
|
||||
|
||||
// Ensure item cache dir exists
|
||||
let cacheDirCreated = false
|
||||
if (!await fs.pathExists(itemCacheDir)) {
|
||||
await fs.mkdir(itemCacheDir)
|
||||
await filePerms.setDefault(itemCacheDir, true)
|
||||
if (!await fs.pathExists(task.data.itemCachePath)) {
|
||||
await fs.mkdir(task.data.itemCachePath)
|
||||
cacheDirCreated = true
|
||||
}
|
||||
|
||||
// Write chapters file
|
||||
const toneJsonPath = Path.join(itemCacheDir, 'metadata.json')
|
||||
|
||||
// Create metadata json file
|
||||
const toneJsonPath = Path.join(task.data.itemCachePath, 'metadata.json')
|
||||
try {
|
||||
const chapters = (audioFiles.length == 1 || forceEmbedChapters) ? libraryItem.media.chapters : null
|
||||
await toneHelpers.writeToneMetadataJsonFile(libraryItem, chapters, toneJsonPath, audioFiles.length)
|
||||
await fs.writeFile(toneJsonPath, JSON.stringify({ meta: task.data.metadataObject }, null, 2))
|
||||
} catch (error) {
|
||||
Logger.error(`[AudioMetadataManager] Write metadata.json failed`, error)
|
||||
|
||||
itemAudioMetadataPayload.failed = true
|
||||
itemAudioMetadataPayload.error = 'Failed to write metadata.json'
|
||||
SocketAuthority.emitter('audio_metadata_finished', itemAudioMetadataPayload)
|
||||
task.setFailed('Failed to write metadata.json')
|
||||
this.handleTaskFinished(task)
|
||||
return
|
||||
}
|
||||
|
||||
const results = []
|
||||
for (const af of audioFiles) {
|
||||
const result = await this.updateAudioFileMetadataWithTone(libraryItem, af, toneJsonPath, itemCacheDir, backupFiles)
|
||||
results.push(result)
|
||||
// Tag audio files
|
||||
for (const af of task.data.audioFiles) {
|
||||
SocketAuthority.adminEmitter('audiofile_metadata_started', {
|
||||
libraryItemId: task.data.libraryItemId,
|
||||
ino: af.ino
|
||||
})
|
||||
|
||||
// Backup audio file
|
||||
if (task.data.options.backupFiles) {
|
||||
try {
|
||||
const backupFilePath = Path.join(task.data.itemCachePath, af.filename)
|
||||
await fs.copy(af.path, backupFilePath)
|
||||
Logger.debug(`[AudioMetadataManager] Backed up audio file at "${backupFilePath}"`)
|
||||
} catch (err) {
|
||||
Logger.error(`[AudioMetadataManager] Failed to backup audio file "${af.path}"`, err)
|
||||
}
|
||||
}
|
||||
|
||||
const _toneMetadataObject = {
|
||||
'ToneJsonFile': toneJsonPath,
|
||||
'TrackNumber': af.index,
|
||||
}
|
||||
|
||||
if (task.data.coverPath) {
|
||||
_toneMetadataObject['CoverFile'] = task.data.coverPath
|
||||
}
|
||||
|
||||
const success = await toneHelpers.tagAudioFile(af.path, _toneMetadataObject)
|
||||
if (success) {
|
||||
Logger.info(`[AudioMetadataManager] Successfully tagged audio file "${af.path}"`)
|
||||
}
|
||||
|
||||
SocketAuthority.adminEmitter('audiofile_metadata_finished', {
|
||||
libraryItemId: task.data.libraryItemId,
|
||||
ino: af.ino
|
||||
})
|
||||
}
|
||||
|
||||
// Remove temp cache file/folder if not backing up
|
||||
if (!backupFiles) {
|
||||
if (!task.data.options.backupFiles) {
|
||||
// If cache dir was created from this then remove it
|
||||
if (cacheDirCreated) {
|
||||
await fs.remove(itemCacheDir)
|
||||
await fs.remove(task.data.itemCachePath)
|
||||
} else {
|
||||
await fs.remove(toneJsonPath)
|
||||
}
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - itemAudioMetadataPayload.startedAt
|
||||
Logger.debug(`[AudioMetadataManager] Elapsed ${secondsToTimestamp(elapsed / 1000, true)}`)
|
||||
itemAudioMetadataPayload.results = results
|
||||
itemAudioMetadataPayload.elapsed = elapsed
|
||||
itemAudioMetadataPayload.finishedAt = Date.now()
|
||||
SocketAuthority.emitter('audio_metadata_finished', itemAudioMetadataPayload)
|
||||
task.setFinished()
|
||||
this.handleTaskFinished(task)
|
||||
}
|
||||
|
||||
async updateAudioFileMetadataWithTone(libraryItem, audioFile, toneJsonPath, itemCacheDir, backupFiles) {
|
||||
const resultPayload = {
|
||||
libraryItemId: libraryItem.id,
|
||||
index: audioFile.index,
|
||||
ino: audioFile.ino,
|
||||
filename: audioFile.metadata.filename
|
||||
}
|
||||
SocketAuthority.emitter('audiofile_metadata_started', resultPayload)
|
||||
handleTaskFinished(task) {
|
||||
this.taskManager.taskFinished(task)
|
||||
this.tasksRunning = this.tasksRunning.filter(t => t.id !== task.id)
|
||||
|
||||
// Backup audio file
|
||||
if (backupFiles) {
|
||||
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)
|
||||
}
|
||||
if (this.tasksRunning.length < this.MAX_CONCURRENT_TASKS && this.tasksQueued.length) {
|
||||
Logger.info(`[AudioMetadataManager] Task finished and dequeueing next task. ${this.tasksQueued} tasks queued.`)
|
||||
const nextTask = this.tasksQueued.shift()
|
||||
SocketAuthority.emitter('metadata_embed_queue_update', {
|
||||
libraryItemId: nextTask.data.libraryItemId,
|
||||
queued: false
|
||||
})
|
||||
this.runMetadataEmbed(nextTask)
|
||||
} else if (this.tasksRunning.length > 0) {
|
||||
Logger.debug(`[AudioMetadataManager] Task finished but not dequeueing. Currently running ${this.tasksRunning.length} tasks. ${this.tasksQueued.length} tasks queued.`)
|
||||
} else {
|
||||
Logger.debug(`[AudioMetadataManager] Task finished and no tasks remain in queue`)
|
||||
}
|
||||
|
||||
const _toneMetadataObject = {
|
||||
'ToneJsonFile': toneJsonPath,
|
||||
'TrackNumber': audioFile.index,
|
||||
}
|
||||
|
||||
if (libraryItem.media.coverPath) {
|
||||
_toneMetadataObject['CoverFile'] = libraryItem.media.coverPath
|
||||
}
|
||||
|
||||
resultPayload.success = await toneHelpers.tagAudioFile(audioFile.metadata.path, _toneMetadataObject)
|
||||
if (resultPayload.success) {
|
||||
Logger.info(`[AudioMetadataManager] Successfully tagged audio file "${audioFile.metadata.path}"`)
|
||||
}
|
||||
|
||||
SocketAuthority.emitter('audiofile_metadata_finished', resultPayload)
|
||||
return resultPayload
|
||||
}
|
||||
}
|
||||
module.exports = AudioMetadataMangaer
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ class CronManager {
|
|||
for (const libraryItem of libraryItems) {
|
||||
const keepAutoDownloading = await this.podcastManager.runEpisodeCheck(libraryItem)
|
||||
if (!keepAutoDownloading) { // auto download was disabled
|
||||
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter(lid => lid !== libraryItemId) // Filter it out
|
||||
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter(lid => lid !== libraryItem.id) // Filter it out
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,80 +0,0 @@
|
|||
const Logger = require('../Logger')
|
||||
const StreamZip = require('../libs/nodeStreamZip')
|
||||
|
||||
const parseEpub = require('../utils/parsers/parseEpub')
|
||||
|
||||
class EBookManager {
|
||||
constructor() {
|
||||
this.extractedEpubs = {}
|
||||
}
|
||||
|
||||
async extractBookData(libraryItem, user, isDev = false) {
|
||||
if (!libraryItem || !libraryItem.isBook || !libraryItem.media.ebookFile) return null
|
||||
|
||||
if (this.extractedEpubs[libraryItem.id]) return this.extractedEpubs[libraryItem.id]
|
||||
|
||||
const ebookFile = libraryItem.media.ebookFile
|
||||
if (!ebookFile.isEpub) {
|
||||
Logger.error(`[EBookManager] get book data is not supported for format ${ebookFile.ebookFormat}`)
|
||||
return null
|
||||
}
|
||||
|
||||
this.extractedEpubs[libraryItem.id] = await parseEpub.parse(ebookFile, libraryItem.id, user.token, isDev)
|
||||
|
||||
return this.extractedEpubs[libraryItem.id]
|
||||
}
|
||||
|
||||
async getBookInfo(libraryItem, user, isDev = false) {
|
||||
if (!libraryItem || !libraryItem.isBook || !libraryItem.media.ebookFile) return null
|
||||
|
||||
const bookData = await this.extractBookData(libraryItem, user, isDev)
|
||||
|
||||
return {
|
||||
title: libraryItem.media.metadata.title,
|
||||
pages: bookData.pages.length
|
||||
}
|
||||
}
|
||||
|
||||
async getBookPage(libraryItem, user, pageIndex, isDev = false) {
|
||||
if (!libraryItem || !libraryItem.isBook || !libraryItem.media.ebookFile) return null
|
||||
|
||||
const bookData = await this.extractBookData(libraryItem, user, isDev)
|
||||
|
||||
const pageObj = bookData.pages[pageIndex]
|
||||
|
||||
if (!pageObj) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = await parseEpub.parsePage(pageObj.path, bookData, libraryItem.id, user.token, isDev)
|
||||
|
||||
if (parsed.error) {
|
||||
Logger.error(`[EBookManager] Failed to parse epub page at "${pageObj.path}"`, parsed.error)
|
||||
return null
|
||||
}
|
||||
|
||||
return parsed.html
|
||||
}
|
||||
|
||||
async getBookResource(libraryItem, user, resourcePath, isDev = false, res) {
|
||||
if (!libraryItem || !libraryItem.isBook || !libraryItem.media.ebookFile) return res.sendStatus(500)
|
||||
const bookData = await this.extractBookData(libraryItem, user, isDev)
|
||||
const resourceItem = bookData.resources.find(r => r.path === resourcePath)
|
||||
|
||||
if (!resourceItem) {
|
||||
return res.status(404).send('Resource not found')
|
||||
}
|
||||
|
||||
const zip = new StreamZip.async({ file: bookData.filepath })
|
||||
const stm = await zip.stream(resourceItem.path)
|
||||
|
||||
res.set('content-type', resourceItem['media-type'])
|
||||
|
||||
stm.pipe(res)
|
||||
stm.on('end', () => {
|
||||
zip.close()
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
module.exports = EBookManager
|
||||
73
server/managers/EmailManager.js
Normal file
73
server/managers/EmailManager.js
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
const nodemailer = require('nodemailer')
|
||||
const Logger = require("../Logger")
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
|
||||
class EmailManager {
|
||||
constructor(db) {
|
||||
this.db = db
|
||||
}
|
||||
|
||||
getTransporter() {
|
||||
return nodemailer.createTransport(this.db.emailSettings.getTransportObject())
|
||||
}
|
||||
|
||||
async sendTest(res) {
|
||||
Logger.info(`[EmailManager] Sending test email`)
|
||||
const transporter = this.getTransporter()
|
||||
|
||||
const success = await transporter.verify().catch((error) => {
|
||||
Logger.error(`[EmailManager] Failed to verify SMTP connection config`, error)
|
||||
return false
|
||||
})
|
||||
|
||||
if (!success) {
|
||||
return res.status(400).send('Failed to verify SMTP connection configuration')
|
||||
}
|
||||
|
||||
transporter.sendMail({
|
||||
from: this.db.emailSettings.fromAddress,
|
||||
to: this.db.emailSettings.fromAddress,
|
||||
subject: 'Test email from Audiobookshelf',
|
||||
text: 'Success!'
|
||||
}).then((result) => {
|
||||
Logger.info(`[EmailManager] Test email sent successfully`, result)
|
||||
res.sendStatus(200)
|
||||
}).catch((error) => {
|
||||
Logger.error(`[EmailManager] Failed to send test email`, error)
|
||||
res.status(400).send(error.message || 'Failed to send test email')
|
||||
})
|
||||
}
|
||||
|
||||
async sendEBookToDevice(ebookFile, device, res) {
|
||||
Logger.info(`[EmailManager] Sending ebook "${ebookFile.metadata.filename}" to device "${device.name}"/"${device.email}"`)
|
||||
const transporter = this.getTransporter()
|
||||
|
||||
const success = await transporter.verify().catch((error) => {
|
||||
Logger.error(`[EmailManager] Failed to verify SMTP connection config`, error)
|
||||
return false
|
||||
})
|
||||
|
||||
if (!success) {
|
||||
return res.status(400).send('Failed to verify SMTP connection configuration')
|
||||
}
|
||||
|
||||
transporter.sendMail({
|
||||
from: this.db.emailSettings.fromAddress,
|
||||
to: device.email,
|
||||
html: '<div dir="auto"></div>',
|
||||
attachments: [
|
||||
{
|
||||
filename: ebookFile.metadata.filename,
|
||||
path: ebookFile.metadata.path,
|
||||
}
|
||||
]
|
||||
}).then((result) => {
|
||||
Logger.info(`[EmailManager] Ebook sent to device successfully`, result)
|
||||
res.sendStatus(200)
|
||||
}).catch((error) => {
|
||||
Logger.error(`[EmailManager] Failed to send ebook to device`, error)
|
||||
res.status(400).send(error.message || 'Failed to send ebook to device')
|
||||
})
|
||||
}
|
||||
}
|
||||
module.exports = EmailManager
|
||||
|
|
@ -14,7 +14,6 @@ const PlaybackSession = require('../objects/PlaybackSession')
|
|||
const DeviceInfo = require('../objects/DeviceInfo')
|
||||
const Stream = require('../objects/Stream')
|
||||
|
||||
|
||||
class PlaybackSessionManager {
|
||||
constructor(db) {
|
||||
this.db = db
|
||||
|
|
@ -31,13 +30,14 @@ class PlaybackSessionManager {
|
|||
}
|
||||
getStream(sessionId) {
|
||||
const session = this.getSession(sessionId)
|
||||
return session ? session.stream : null
|
||||
return session?.stream || null
|
||||
}
|
||||
|
||||
getDeviceInfo(req) {
|
||||
const ua = uaParserJs(req.headers['user-agent'])
|
||||
const ip = requestIp.getClientIp(req)
|
||||
const clientDeviceInfo = req.body ? req.body.deviceInfo || null : null // From mobile client
|
||||
|
||||
const clientDeviceInfo = req.body?.deviceInfo || null
|
||||
|
||||
const deviceInfo = new DeviceInfo()
|
||||
deviceInfo.setData(ip, ua, clientDeviceInfo, serverVersion)
|
||||
|
|
@ -130,6 +130,8 @@ class PlaybackSessionManager {
|
|||
const itemProgress = user.getMediaProgress(session.libraryItemId, session.episodeId)
|
||||
SocketAuthority.clientEmitter(user.id, 'user_item_progress_updated', {
|
||||
id: itemProgress.id,
|
||||
sessionId: session.id,
|
||||
deviceDescription: session.deviceDescription,
|
||||
data: itemProgress.toJSON()
|
||||
})
|
||||
}
|
||||
|
|
@ -138,18 +140,6 @@ class PlaybackSessionManager {
|
|||
}
|
||||
|
||||
async syncLocalSessionRequest(user, sessionJson, res) {
|
||||
// If server session is open for this same media item then close it
|
||||
const userSessionForThisItem = this.sessions.find(playbackSession => {
|
||||
if (playbackSession.userId !== user.id) return false
|
||||
if (sessionJson.episodeId) return playbackSession.episodeId !== sessionJson.episodeId
|
||||
return playbackSession.libraryItemId === sessionJson.libraryItemId
|
||||
})
|
||||
if (userSessionForThisItem) {
|
||||
Logger.info(`[PlaybackSessionManager] syncLocalSessionRequest: Closing open session "${userSessionForThisItem.displayTitle}" for user "${user.username}"`)
|
||||
await this.closeSession(user, userSessionForThisItem, null)
|
||||
}
|
||||
|
||||
// Sync
|
||||
const result = await this.syncLocalSession(user, sessionJson)
|
||||
if (result.error) {
|
||||
res.status(500).send(result.error)
|
||||
|
|
@ -164,8 +154,8 @@ class PlaybackSessionManager {
|
|||
}
|
||||
|
||||
async startSession(user, deviceInfo, libraryItem, episodeId, options) {
|
||||
// Close any sessions already open for user
|
||||
const userSessions = this.sessions.filter(playbackSession => playbackSession.userId === user.id)
|
||||
// Close any sessions already open for user and device
|
||||
const userSessions = this.sessions.filter(playbackSession => playbackSession.userId === user.id && playbackSession.deviceId === deviceInfo.deviceId)
|
||||
for (const session of userSessions) {
|
||||
Logger.info(`[PlaybackSessionManager] startSession: Closing open session "${session.displayTitle}" for user "${user.username}" (Device: ${session.deviceDescription})`)
|
||||
await this.closeSession(user, session, null)
|
||||
|
|
@ -251,6 +241,8 @@ class PlaybackSessionManager {
|
|||
const itemProgress = user.getMediaProgress(session.libraryItemId, session.episodeId)
|
||||
SocketAuthority.clientEmitter(user.id, 'user_item_progress_updated', {
|
||||
id: itemProgress.id,
|
||||
sessionId: session.id,
|
||||
deviceDescription: session.deviceDescription,
|
||||
data: itemProgress.toJSON()
|
||||
})
|
||||
}
|
||||
|
|
@ -268,6 +260,7 @@ class PlaybackSessionManager {
|
|||
}
|
||||
Logger.debug(`[PlaybackSessionManager] closeSession "${session.id}"`)
|
||||
SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions, this.db.libraryItems))
|
||||
SocketAuthority.clientEmitter(session.userId, 'user_session_closed', session.id)
|
||||
return this.removeSession(session.id)
|
||||
}
|
||||
|
||||
|
|
@ -317,7 +310,7 @@ class PlaybackSessionManager {
|
|||
// See https://github.com/advplyr/audiobookshelf/issues/868
|
||||
// Remove playback sessions with listening time too high
|
||||
async removeInvalidSessions() {
|
||||
const selectFunc = (session) => isNaN(session.timeListening) || Number(session.timeListening) > 3600000000
|
||||
const selectFunc = (session) => isNaN(session.timeListening) || Number(session.timeListening) > 36000000
|
||||
const numSessionsRemoved = await this.db.removeEntities('session', selectFunc, true)
|
||||
if (numSessionsRemoved) {
|
||||
Logger.info(`[PlaybackSessionManager] Removed ${numSessionsRemoved} invalid playback sessions`)
|
||||
|
|
|
|||
|
|
@ -4,22 +4,26 @@ const SocketAuthority = require('../SocketAuthority')
|
|||
const fs = require('../libs/fsExtra')
|
||||
|
||||
const { getPodcastFeed } = require('../utils/podcastUtils')
|
||||
const { downloadFile, removeFile } = require('../utils/fileUtils')
|
||||
const { removeFile, downloadFile } = require('../utils/fileUtils')
|
||||
const filePerms = require('../utils/filePerms')
|
||||
const { levenshteinDistance } = require('../utils/index')
|
||||
const opmlParser = require('../utils/parsers/parseOPML')
|
||||
const opmlGenerator = require('../utils/generators/opmlGenerator')
|
||||
const prober = require('../utils/prober')
|
||||
const ffmpegHelpers = require('../utils/ffmpegHelpers')
|
||||
|
||||
const LibraryFile = require('../objects/files/LibraryFile')
|
||||
const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload')
|
||||
const PodcastEpisode = require('../objects/entities/PodcastEpisode')
|
||||
const AudioFile = require('../objects/files/AudioFile')
|
||||
const Task = require("../objects/Task")
|
||||
|
||||
class PodcastManager {
|
||||
constructor(db, watcher, notificationManager) {
|
||||
constructor(db, watcher, notificationManager, taskManager) {
|
||||
this.db = db
|
||||
this.watcher = watcher
|
||||
this.notificationManager = notificationManager
|
||||
this.taskManager = taskManager
|
||||
|
||||
this.downloadQueue = []
|
||||
this.currentDownload = null
|
||||
|
|
@ -50,27 +54,44 @@ class PodcastManager {
|
|||
}
|
||||
|
||||
async downloadPodcastEpisodes(libraryItem, episodesToDownload, isAutoDownload) {
|
||||
var index = libraryItem.media.episodes.length + 1
|
||||
episodesToDownload.forEach((ep) => {
|
||||
var newPe = new PodcastEpisode()
|
||||
let index = libraryItem.media.episodes.length + 1
|
||||
for (const ep of episodesToDownload) {
|
||||
const newPe = new PodcastEpisode()
|
||||
newPe.setData(ep, index++)
|
||||
newPe.libraryItemId = libraryItem.id
|
||||
var newPeDl = new PodcastEpisodeDownload()
|
||||
newPeDl.setData(newPe, libraryItem, isAutoDownload)
|
||||
const newPeDl = new PodcastEpisodeDownload()
|
||||
newPeDl.setData(newPe, libraryItem, isAutoDownload, libraryItem.libraryId)
|
||||
this.startPodcastEpisodeDownload(newPeDl)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async startPodcastEpisodeDownload(podcastEpisodeDownload) {
|
||||
SocketAuthority.emitter('episode_download_queue_updated', this.getDownloadQueueDetails())
|
||||
if (this.currentDownload) {
|
||||
this.downloadQueue.push(podcastEpisodeDownload)
|
||||
SocketAuthority.emitter('episode_download_queued', podcastEpisodeDownload.toJSONForClient())
|
||||
return
|
||||
}
|
||||
|
||||
const task = new Task()
|
||||
const taskDescription = `Downloading episode "${podcastEpisodeDownload.podcastEpisode.title}".`
|
||||
const taskData = {
|
||||
libraryId: podcastEpisodeDownload.libraryId,
|
||||
libraryItemId: podcastEpisodeDownload.libraryItemId,
|
||||
}
|
||||
task.setData('download-podcast-episode', 'Downloading Episode', taskDescription, false, taskData)
|
||||
this.taskManager.addTask(task)
|
||||
|
||||
SocketAuthority.emitter('episode_download_started', podcastEpisodeDownload.toJSONForClient())
|
||||
this.currentDownload = podcastEpisodeDownload
|
||||
|
||||
// If this file already exists then append the episode id to the filename
|
||||
// e.g. "/tagesschau 20 Uhr.mp3" becomes "/tagesschau 20 Uhr (ep_asdfasdf).mp3"
|
||||
// this handles podcasts where every title is the same (ref https://github.com/advplyr/audiobookshelf/issues/1802)
|
||||
if (await fs.pathExists(this.currentDownload.targetPath)) {
|
||||
this.currentDownload.appendEpisodeId = true
|
||||
}
|
||||
|
||||
// Ignores all added files to this dir
|
||||
this.watcher.addIgnoreDir(this.currentDownload.libraryItem.path)
|
||||
|
||||
|
|
@ -81,24 +102,41 @@ class PodcastManager {
|
|||
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
|
||||
})
|
||||
let success = false
|
||||
if (this.currentDownload.urlFileExtension === 'mp3') {
|
||||
// Download episode and tag it
|
||||
success = await ffmpegHelpers.downloadPodcastEpisode(this.currentDownload).catch((error) => {
|
||||
Logger.error(`[PodcastManager] Podcast Episode download failed`, error)
|
||||
return false
|
||||
})
|
||||
} else {
|
||||
// Download episode only
|
||||
success = await downloadFile(this.currentDownload.url, this.currentDownload.targetPath).then(() => true).catch((error) => {
|
||||
Logger.error(`[PodcastManager] Podcast Episode download failed`, error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
if (success) {
|
||||
success = await this.scanAddPodcastEpisodeAudioFile()
|
||||
if (!success) {
|
||||
await fs.remove(this.currentDownload.targetPath)
|
||||
this.currentDownload.setFinished(false)
|
||||
task.setFailed('Failed to download episode')
|
||||
} else {
|
||||
Logger.info(`[PodcastManager] Successfully downloaded podcast episode "${this.currentDownload.podcastEpisode.title}"`)
|
||||
this.currentDownload.setFinished(true)
|
||||
task.setFinished()
|
||||
}
|
||||
} else {
|
||||
task.setFailed('Failed to download episode')
|
||||
this.currentDownload.setFinished(false)
|
||||
}
|
||||
|
||||
this.taskManager.taskFinished(task)
|
||||
|
||||
SocketAuthority.emitter('episode_download_finished', this.currentDownload.toJSONForClient())
|
||||
SocketAuthority.emitter('episode_download_queue_updated', this.getDownloadQueueDetails())
|
||||
|
||||
this.watcher.removeIgnoreDir(this.currentDownload.libraryItem.path)
|
||||
this.currentDownload = null
|
||||
|
|
@ -108,23 +146,26 @@ class PodcastManager {
|
|||
}
|
||||
|
||||
async scanAddPodcastEpisodeAudioFile() {
|
||||
var libraryFile = await this.getLibraryFile(this.currentDownload.targetPath, this.currentDownload.targetRelPath)
|
||||
const libraryFile = await this.getLibraryFile(this.currentDownload.targetPath, this.currentDownload.targetRelPath)
|
||||
|
||||
// TODO: Set meta tags on new audio file
|
||||
|
||||
var audioFile = await this.probeAudioFile(libraryFile)
|
||||
const audioFile = await this.probeAudioFile(libraryFile)
|
||||
if (!audioFile) {
|
||||
return false
|
||||
}
|
||||
|
||||
var libraryItem = this.db.libraryItems.find(li => li.id === this.currentDownload.libraryItem.id)
|
||||
const libraryItem = this.db.libraryItems.find(li => li.id === this.currentDownload.libraryItem.id)
|
||||
if (!libraryItem) {
|
||||
Logger.error(`[PodcastManager] Podcast Episode finished but library item was not found ${this.currentDownload.libraryItem.id}`)
|
||||
return false
|
||||
}
|
||||
|
||||
var podcastEpisode = this.currentDownload.podcastEpisode
|
||||
const podcastEpisode = this.currentDownload.podcastEpisode
|
||||
podcastEpisode.audioFile = audioFile
|
||||
|
||||
if (audioFile.chapters?.length) {
|
||||
podcastEpisode.chapters = audioFile.chapters.map(ch => ({ ...ch }))
|
||||
}
|
||||
|
||||
libraryItem.media.addPodcastEpisode(podcastEpisode)
|
||||
if (libraryItem.isInvalid) {
|
||||
// First episode added to an empty podcast
|
||||
|
|
@ -143,6 +184,9 @@ class PodcastManager {
|
|||
libraryItem.updatedAt = Date.now()
|
||||
await this.db.updateLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
const podcastEpisodeExpanded = podcastEpisode.toJSONExpanded()
|
||||
podcastEpisodeExpanded.libraryItem = libraryItem.toJSONExpanded()
|
||||
SocketAuthority.emitter('episode_added', podcastEpisodeExpanded)
|
||||
|
||||
if (this.currentDownload.isAutoDownload) { // Notifications only for auto downloaded episodes
|
||||
this.notificationManager.onPodcastEpisodeDownloaded(libraryItem, podcastEpisode)
|
||||
|
|
@ -183,13 +227,13 @@ class PodcastManager {
|
|||
}
|
||||
|
||||
async probeAudioFile(libraryFile) {
|
||||
var path = libraryFile.metadata.path
|
||||
var mediaProbeData = await prober.probe(path)
|
||||
const path = libraryFile.metadata.path
|
||||
const mediaProbeData = await prober.probe(path)
|
||||
if (mediaProbeData.error) {
|
||||
Logger.error(`[PodcastManager] Podcast Episode downloaded but failed to probe "${path}"`, mediaProbeData.error)
|
||||
return false
|
||||
}
|
||||
var newAudioFile = new AudioFile()
|
||||
const newAudioFile = new AudioFile()
|
||||
newAudioFile.setDataFromProbe(libraryFile, mediaProbeData)
|
||||
return newAudioFile
|
||||
}
|
||||
|
|
@ -329,5 +373,19 @@ class PodcastManager {
|
|||
feeds: rssFeedData
|
||||
}
|
||||
}
|
||||
|
||||
generateOPMLFileText(libraryItems) {
|
||||
return opmlGenerator.generate(libraryItems)
|
||||
}
|
||||
|
||||
getDownloadQueueDetails(libraryId = null) {
|
||||
let _currentDownload = this.currentDownload
|
||||
if (libraryId && _currentDownload?.libraryId !== libraryId) _currentDownload = null
|
||||
|
||||
return {
|
||||
currentDownload: _currentDownload?.toJSONForClient(),
|
||||
queue: this.downloadQueue.filter(item => !libraryId || item.libraryId === libraryId).map(item => item.toJSONForClient())
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = PodcastManager
|
||||
module.exports = PodcastManager
|
||||
|
|
|
|||
|
|
@ -188,9 +188,12 @@ class RssFeedManager {
|
|||
async openFeedForItem(user, libraryItem, options) {
|
||||
const serverAddress = options.serverAddress
|
||||
const slug = options.slug
|
||||
const preventIndexing = options.metadataDetails?.preventIndexing ?? true
|
||||
const ownerName = options.metadataDetails?.ownerName
|
||||
const ownerEmail = options.metadataDetails?.ownerEmail
|
||||
|
||||
const feed = new Feed()
|
||||
feed.setFromItem(user.id, slug, libraryItem, serverAddress)
|
||||
feed.setFromItem(user.id, slug, libraryItem, serverAddress, preventIndexing, ownerName, ownerEmail)
|
||||
this.feeds[feed.id] = feed
|
||||
|
||||
Logger.debug(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
|
||||
|
|
@ -202,9 +205,12 @@ class RssFeedManager {
|
|||
async openFeedForCollection(user, collectionExpanded, options) {
|
||||
const serverAddress = options.serverAddress
|
||||
const slug = options.slug
|
||||
const preventIndexing = options.metadataDetails?.preventIndexing ?? true
|
||||
const ownerName = options.metadataDetails?.ownerName
|
||||
const ownerEmail = options.metadataDetails?.ownerEmail
|
||||
|
||||
const feed = new Feed()
|
||||
feed.setFromCollection(user.id, slug, collectionExpanded, serverAddress)
|
||||
feed.setFromCollection(user.id, slug, collectionExpanded, serverAddress, preventIndexing, ownerName, ownerEmail)
|
||||
this.feeds[feed.id] = feed
|
||||
|
||||
Logger.debug(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
|
||||
|
|
@ -216,9 +222,12 @@ class RssFeedManager {
|
|||
async openFeedForSeries(user, seriesExpanded, options) {
|
||||
const serverAddress = options.serverAddress
|
||||
const slug = options.slug
|
||||
const preventIndexing = options.metadataDetails?.preventIndexing ?? true
|
||||
const ownerName = options.metadataDetails?.ownerName
|
||||
const ownerEmail = options.metadataDetails?.ownerEmail
|
||||
|
||||
const feed = new Feed()
|
||||
feed.setFromSeries(user.id, slug, seriesExpanded, serverAddress)
|
||||
feed.setFromSeries(user.id, slug, seriesExpanded, serverAddress, preventIndexing, ownerName, ownerEmail)
|
||||
this.feeds[feed.id] = feed
|
||||
|
||||
Logger.debug(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
|
||||
|
|
@ -246,4 +255,4 @@ class RssFeedManager {
|
|||
return this.handleCloseFeed(feed)
|
||||
}
|
||||
}
|
||||
module.exports = RssFeedManager
|
||||
module.exports = RssFeedManager
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
class DeviceInfo {
|
||||
constructor(deviceInfo = null) {
|
||||
this.deviceId = null
|
||||
this.ipAddress = null
|
||||
|
||||
// From User Agent (see: https://www.npmjs.com/package/ua-parser-js)
|
||||
|
|
@ -32,6 +33,7 @@ class DeviceInfo {
|
|||
|
||||
toJSON() {
|
||||
const obj = {
|
||||
deviceId: this.deviceId,
|
||||
ipAddress: this.ipAddress,
|
||||
browserName: this.browserName,
|
||||
browserVersion: this.browserVersion,
|
||||
|
|
@ -60,23 +62,42 @@ class DeviceInfo {
|
|||
return `${this.osName} ${this.osVersion} / ${this.browserName}`
|
||||
}
|
||||
|
||||
// When client doesn't send a device id
|
||||
getTempDeviceId() {
|
||||
const keys = [
|
||||
this.browserName,
|
||||
this.browserVersion,
|
||||
this.osName,
|
||||
this.osVersion,
|
||||
this.clientVersion,
|
||||
this.manufacturer,
|
||||
this.model,
|
||||
this.sdkVersion,
|
||||
this.ipAddress
|
||||
].map(k => k || '')
|
||||
return 'temp-' + Buffer.from(keys.join('-'), 'utf-8').toString('base64')
|
||||
}
|
||||
|
||||
setData(ip, ua, clientDeviceInfo, serverVersion) {
|
||||
this.deviceId = clientDeviceInfo?.deviceId || null
|
||||
this.ipAddress = ip || null
|
||||
|
||||
const uaObj = ua || {}
|
||||
this.browserName = uaObj.browser.name || null
|
||||
this.browserVersion = uaObj.browser.version || null
|
||||
this.osName = uaObj.os.name || null
|
||||
this.osVersion = uaObj.os.version || null
|
||||
this.deviceType = uaObj.device.type || null
|
||||
this.browserName = ua?.browser.name || null
|
||||
this.browserVersion = ua?.browser.version || null
|
||||
this.osName = ua?.os.name || null
|
||||
this.osVersion = ua?.os.version || null
|
||||
this.deviceType = ua?.device.type || null
|
||||
|
||||
const cdi = clientDeviceInfo || {}
|
||||
this.clientVersion = cdi.clientVersion || null
|
||||
this.manufacturer = cdi.manufacturer || null
|
||||
this.model = cdi.model || null
|
||||
this.sdkVersion = cdi.sdkVersion || null
|
||||
this.clientVersion = clientDeviceInfo?.clientVersion || null
|
||||
this.manufacturer = clientDeviceInfo?.manufacturer || null
|
||||
this.model = clientDeviceInfo?.model || null
|
||||
this.sdkVersion = clientDeviceInfo?.sdkVersion || null
|
||||
|
||||
this.serverVersion = serverVersion || null
|
||||
|
||||
if (!this.deviceId) {
|
||||
this.deviceId = this.getTempDeviceId()
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = DeviceInfo
|
||||
|
|
@ -70,17 +70,19 @@ class Feed {
|
|||
id: this.id,
|
||||
entityType: this.entityType,
|
||||
entityId: this.entityId,
|
||||
feedUrl: this.feedUrl
|
||||
feedUrl: this.feedUrl,
|
||||
meta: this.meta.toJSONMinified(),
|
||||
}
|
||||
}
|
||||
|
||||
getEpisodePath(id) {
|
||||
var episode = this.episodes.find(ep => ep.id === id)
|
||||
console.log('getEpisodePath=', id, episode)
|
||||
if (!episode) return null
|
||||
return episode.fullPath
|
||||
}
|
||||
|
||||
setFromItem(userId, slug, libraryItem, serverAddress) {
|
||||
setFromItem(userId, slug, libraryItem, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) {
|
||||
const media = libraryItem.media
|
||||
const mediaMetadata = media.metadata
|
||||
const isPodcast = libraryItem.mediaType === 'podcast'
|
||||
|
|
@ -106,6 +108,11 @@ class Feed {
|
|||
this.meta.feedUrl = feedUrl
|
||||
this.meta.link = `${serverAddress}/item/${libraryItem.id}`
|
||||
this.meta.explicit = !!mediaMetadata.explicit
|
||||
this.meta.type = mediaMetadata.type
|
||||
this.meta.language = mediaMetadata.language
|
||||
this.meta.preventIndexing = preventIndexing
|
||||
this.meta.ownerName = ownerName
|
||||
this.meta.ownerEmail = ownerEmail
|
||||
|
||||
this.episodes = []
|
||||
if (isPodcast) { // PODCAST EPISODES
|
||||
|
|
@ -142,6 +149,8 @@ class Feed {
|
|||
this.meta.author = author
|
||||
this.meta.imageUrl = media.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover` : `${this.serverAddress}/Logo.png`
|
||||
this.meta.explicit = !!mediaMetadata.explicit
|
||||
this.meta.type = mediaMetadata.type
|
||||
this.meta.language = mediaMetadata.language
|
||||
|
||||
this.episodes = []
|
||||
if (isPodcast) { // PODCAST EPISODES
|
||||
|
|
@ -333,4 +342,4 @@ class Feed {
|
|||
return author
|
||||
}
|
||||
}
|
||||
module.exports = Feed
|
||||
module.exports = Feed
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ class FeedEpisode {
|
|||
this.author = null
|
||||
this.explicit = null
|
||||
this.duration = null
|
||||
this.season = null
|
||||
this.episode = null
|
||||
this.episodeType = null
|
||||
|
||||
this.libraryItemId = null
|
||||
this.episodeId = null
|
||||
|
|
@ -35,6 +38,9 @@ class FeedEpisode {
|
|||
this.author = episode.author
|
||||
this.explicit = episode.explicit
|
||||
this.duration = episode.duration
|
||||
this.season = episode.season
|
||||
this.episode = episode.episode
|
||||
this.episodeType = episode.episodeType
|
||||
this.libraryItemId = episode.libraryItemId
|
||||
this.episodeId = episode.episodeId || null
|
||||
this.trackIndex = episode.trackIndex || 0
|
||||
|
|
@ -52,6 +58,9 @@ class FeedEpisode {
|
|||
author: this.author,
|
||||
explicit: this.explicit,
|
||||
duration: this.duration,
|
||||
season: this.season,
|
||||
episode: this.episode,
|
||||
episodeType: this.episodeType,
|
||||
libraryItemId: this.libraryItemId,
|
||||
episodeId: this.episodeId,
|
||||
trackIndex: this.trackIndex,
|
||||
|
|
@ -77,25 +86,31 @@ class FeedEpisode {
|
|||
this.author = meta.author
|
||||
this.explicit = mediaMetadata.explicit
|
||||
this.duration = episode.duration
|
||||
this.season = episode.season
|
||||
this.episode = episode.episode
|
||||
this.episodeType = episode.episodeType
|
||||
this.libraryItemId = libraryItem.id
|
||||
this.episodeId = episode.id
|
||||
this.trackIndex = 0
|
||||
this.fullPath = episode.audioFile.metadata.path
|
||||
}
|
||||
|
||||
setFromAudiobookTrack(libraryItem, serverAddress, slug, audioTrack, meta, additionalOffset = 0) {
|
||||
setFromAudiobookTrack(libraryItem, serverAddress, slug, audioTrack, meta, additionalOffset = null) {
|
||||
// Example: <pubDate>Fri, 04 Feb 2015 00:00:00 GMT</pubDate>
|
||||
let timeOffset = isNaN(audioTrack.index) ? 0 : (Number(audioTrack.index) * 1000) // Offset pubdate to ensure correct order
|
||||
let episodeId = String(audioTrack.index)
|
||||
|
||||
// Additional offset can be used for collections/series
|
||||
if (additionalOffset && !isNaN(additionalOffset)) {
|
||||
if (additionalOffset !== null && !isNaN(additionalOffset)) {
|
||||
timeOffset += Number(additionalOffset) * 1000
|
||||
|
||||
episodeId = String(additionalOffset) + '-' + episodeId
|
||||
}
|
||||
|
||||
// e.g. Track 1 will have a pub date before Track 2
|
||||
const audiobookPubDate = date.format(new Date(libraryItem.addedAt + timeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
|
||||
|
||||
const contentUrl = `/feed/${slug}/item/${audioTrack.index}/${audioTrack.metadata.filename}`
|
||||
const contentUrl = `/feed/${slug}/item/${episodeId}/${audioTrack.metadata.filename}`
|
||||
const media = libraryItem.media
|
||||
const mediaMetadata = media.metadata
|
||||
|
||||
|
|
@ -110,7 +125,7 @@ class FeedEpisode {
|
|||
}
|
||||
}
|
||||
|
||||
this.id = String(audioTrack.index)
|
||||
this.id = episodeId
|
||||
this.title = title
|
||||
this.description = mediaMetadata.description || ''
|
||||
this.enclosure = {
|
||||
|
|
@ -144,9 +159,12 @@ class FeedEpisode {
|
|||
{ 'itunes:summary': this.description || '' },
|
||||
{
|
||||
"itunes:explicit": !!this.explicit
|
||||
}
|
||||
},
|
||||
{ "itunes:episodeType": this.episodeType },
|
||||
{ "itunes:season": this.season },
|
||||
{ "itunes:episode": this.episode }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = FeedEpisode
|
||||
module.exports = FeedEpisode
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@ class FeedMeta {
|
|||
this.feedUrl = null
|
||||
this.link = null
|
||||
this.explicit = null
|
||||
this.type = null
|
||||
this.language = null
|
||||
this.preventIndexing = null
|
||||
this.ownerName = null
|
||||
this.ownerEmail = null
|
||||
|
||||
if (meta) {
|
||||
this.construct(meta)
|
||||
|
|
@ -21,6 +26,11 @@ class FeedMeta {
|
|||
this.feedUrl = meta.feedUrl
|
||||
this.link = meta.link
|
||||
this.explicit = meta.explicit
|
||||
this.type = meta.type
|
||||
this.language = meta.language
|
||||
this.preventIndexing = meta.preventIndexing
|
||||
this.ownerName = meta.ownerName
|
||||
this.ownerEmail = meta.ownerEmail
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
|
|
@ -31,7 +41,22 @@ class FeedMeta {
|
|||
imageUrl: this.imageUrl,
|
||||
feedUrl: this.feedUrl,
|
||||
link: this.link,
|
||||
explicit: this.explicit
|
||||
explicit: this.explicit,
|
||||
type: this.type,
|
||||
language: this.language,
|
||||
preventIndexing: this.preventIndexing,
|
||||
ownerName: this.ownerName,
|
||||
ownerEmail: this.ownerEmail
|
||||
}
|
||||
}
|
||||
|
||||
toJSONMinified() {
|
||||
return {
|
||||
title: this.title,
|
||||
description: this.description,
|
||||
preventIndexing: this.preventIndexing,
|
||||
ownerName: this.ownerName,
|
||||
ownerEmail: this.ownerEmail
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -43,16 +68,18 @@ class FeedMeta {
|
|||
feed_url: this.feedUrl,
|
||||
site_url: this.link,
|
||||
image_url: this.imageUrl,
|
||||
language: 'en',
|
||||
custom_namespaces: {
|
||||
'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd',
|
||||
'psc': 'http://podlove.org/simple-chapters',
|
||||
'podcast': 'https://podcastindex.org/namespace/1.0'
|
||||
'podcast': 'https://podcastindex.org/namespace/1.0',
|
||||
'googleplay': 'http://www.google.com/schemas/play-podcasts/1.0'
|
||||
},
|
||||
custom_elements: [
|
||||
{ 'language': this.language || 'en' },
|
||||
{ 'author': this.author || 'advplyr' },
|
||||
{ 'itunes:author': this.author || 'advplyr' },
|
||||
{ 'itunes:summary': this.description || '' },
|
||||
{ 'itunes:type': this.type },
|
||||
{
|
||||
'itunes:image': {
|
||||
_attr: {
|
||||
|
|
@ -62,15 +89,15 @@ class FeedMeta {
|
|||
},
|
||||
{
|
||||
'itunes:owner': [
|
||||
{ 'itunes:name': this.author || '' },
|
||||
{ 'itunes:email': '' }
|
||||
{ 'itunes:name': this.ownerName || this.author || '' },
|
||||
{ 'itunes:email': this.ownerEmail || '' }
|
||||
]
|
||||
},
|
||||
{
|
||||
"itunes:explicit": !!this.explicit
|
||||
}
|
||||
{ 'itunes:explicit': !!this.explicit },
|
||||
{ 'itunes:block': this.preventIndexing?"Yes":"No" },
|
||||
{ 'googleplay:block': this.preventIndexing?"yes":"no" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = FeedMeta
|
||||
module.exports = FeedMeta
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@ const fs = require('../libs/fsExtra')
|
|||
const Path = require('path')
|
||||
const { version } = require('../../package.json')
|
||||
const Logger = require('../Logger')
|
||||
const abmetadataGenerator = require('../utils/abmetadataGenerator')
|
||||
const abmetadataGenerator = require('../utils/generators/abmetadataGenerator')
|
||||
const LibraryFile = require('./files/LibraryFile')
|
||||
const Book = require('./mediaTypes/Book')
|
||||
const Podcast = require('./mediaTypes/Podcast')
|
||||
const Video = require('./mediaTypes/Video')
|
||||
const Music = require('./mediaTypes/Music')
|
||||
const { areEquivalent, copyValue, getId, cleanStringForSearch } = require('../utils/index')
|
||||
const { filePathToPOSIX } = require('../utils/fileUtils')
|
||||
|
||||
class LibraryItem {
|
||||
constructor(libraryItem = null) {
|
||||
|
|
@ -197,9 +198,15 @@ class LibraryItem {
|
|||
if (key === 'libraryFiles') {
|
||||
this.libraryFiles = payload.libraryFiles.map(lf => lf.clone())
|
||||
|
||||
// Use first image library file as cover
|
||||
const firstImageFile = this.libraryFiles.find(lf => lf.fileType === 'image')
|
||||
if (firstImageFile) this.media.coverPath = firstImageFile.metadata.path
|
||||
// Set cover image
|
||||
const imageFiles = this.libraryFiles.filter(lf => lf.fileType === 'image')
|
||||
const coverMatch = imageFiles.find(iFile => /\/cover\.[^.\/]*$/.test(iFile.metadata.path))
|
||||
if (coverMatch) {
|
||||
this.media.coverPath = coverMatch.metadata.path
|
||||
} else if (imageFiles.length) {
|
||||
this.media.coverPath = imageFiles[0].metadata.path
|
||||
}
|
||||
|
||||
} else if (this[key] !== undefined && key !== 'media') {
|
||||
this[key] = payload[key]
|
||||
}
|
||||
|
|
@ -330,6 +337,7 @@ class LibraryItem {
|
|||
}
|
||||
|
||||
if (dataFound.ino !== this.ino) {
|
||||
Logger.warn(`[LibraryItem] Check scan item changed inode "${this.ino}" -> "${dataFound.ino}"`)
|
||||
this.ino = dataFound.ino
|
||||
hasUpdated = true
|
||||
}
|
||||
|
|
@ -341,7 +349,7 @@ class LibraryItem {
|
|||
}
|
||||
|
||||
if (dataFound.path !== this.path) {
|
||||
Logger.warn(`[LibraryItem] Check scan item changed path "${this.path}" -> "${dataFound.path}"`)
|
||||
Logger.warn(`[LibraryItem] Check scan item changed path "${this.path}" -> "${dataFound.path}" (inode ${this.ino})`)
|
||||
this.path = dataFound.path
|
||||
this.relPath = dataFound.relPath
|
||||
hasUpdated = true
|
||||
|
|
@ -361,7 +369,7 @@ class LibraryItem {
|
|||
const fileFoundCheck = this.checkFileFound(lf, true)
|
||||
if (fileFoundCheck === null) {
|
||||
newLibraryFiles.push(lf)
|
||||
} else if (fileFoundCheck && lf.metadata.format !== 'abs') { // Ignore abs file updates
|
||||
} else if (fileFoundCheck && lf.metadata.format !== 'abs' && lf.metadata.filename !== 'metadata.json') { // Ignore abs file updates
|
||||
hasUpdated = true
|
||||
existingLibraryFiles.push(lf)
|
||||
} else {
|
||||
|
|
@ -426,23 +434,32 @@ class LibraryItem {
|
|||
|
||||
if (this.mediaType === 'book') {
|
||||
// Add/update ebook file (ebooks that were removed are removed in checkScanData)
|
||||
this.libraryFiles.forEach((lf) => {
|
||||
if (lf.fileType === 'ebook') {
|
||||
if (!this.media.ebookFile) {
|
||||
this.media.setEbookFile(lf)
|
||||
hasUpdated = true
|
||||
} else if (this.media.ebookFile.ino == lf.ino && this.media.ebookFile.updateFromLibraryFile(lf)) { // Update existing ebookFile
|
||||
hasUpdated = true
|
||||
}
|
||||
if (this.media.ebookFile) {
|
||||
const matchingLibraryFile = this.libraryFiles.find(lf => lf.ino === this.media.ebookFile.ino)
|
||||
if (matchingLibraryFile && this.media.ebookFile.updateFromLibraryFile(matchingLibraryFile)) {
|
||||
hasUpdated = true
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// Prefer epub ebook then fallback to first other ebook file
|
||||
const ebookLibraryFile = this.libraryFiles.find(lf => lf.isEBookFile && lf.metadata.format === 'epub') || this.libraryFiles.find(lf => lf.isEBookFile)
|
||||
if (ebookLibraryFile) {
|
||||
this.media.setEbookFile(ebookLibraryFile)
|
||||
hasUpdated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set cover image if not set
|
||||
const imageFiles = this.libraryFiles.filter(lf => lf.fileType === 'image')
|
||||
if (imageFiles.length && !this.media.coverPath) {
|
||||
this.media.coverPath = imageFiles[0].metadata.path
|
||||
Logger.debug('[LibraryItem] Set media cover path', this.media.coverPath)
|
||||
// attempt to find a file called cover.<ext> otherwise just fall back to the first image found
|
||||
const coverMatch = imageFiles.find(iFile => /\/cover\.[^.\/]*$/.test(iFile.metadata.path))
|
||||
if (coverMatch) {
|
||||
this.media.coverPath = coverMatch.metadata.path
|
||||
} else {
|
||||
this.media.coverPath = imageFiles[0].metadata.path
|
||||
}
|
||||
Logger.info('[LibraryItem] Set media cover path', this.media.coverPath)
|
||||
hasUpdated = true
|
||||
}
|
||||
|
||||
|
|
@ -483,14 +500,56 @@ class LibraryItem {
|
|||
// Make sure metadata book dir exists
|
||||
await fs.ensureDir(metadataPath)
|
||||
}
|
||||
metadataPath = Path.join(metadataPath, 'metadata.abs')
|
||||
|
||||
return abmetadataGenerator.generate(this, metadataPath).then((success) => {
|
||||
this.isSavingMetadata = false
|
||||
if (!success) Logger.error(`[LibraryItem] Failed saving abmetadata to "${metadataPath}"`)
|
||||
else Logger.debug(`[LibraryItem] Success saving abmetadata to "${metadataPath}"`)
|
||||
return success
|
||||
})
|
||||
const metadataFileFormat = global.ServerSettings.metadataFileFormat
|
||||
const metadataFilePath = Path.join(metadataPath, `metadata.${metadataFileFormat}`)
|
||||
if (metadataFileFormat === 'json') {
|
||||
// Remove metadata.abs if it exists
|
||||
if (await fs.pathExists(Path.join(metadataPath, `metadata.abs`))) {
|
||||
Logger.debug(`[LibraryItem] Removing metadata.abs for item "${this.media.metadata.title}"`)
|
||||
await fs.remove(Path.join(metadataPath, `metadata.abs`))
|
||||
this.libraryFiles = this.libraryFiles.filter(lf => lf.metadata.path !== filePathToPOSIX(Path.join(metadataPath, `metadata.abs`)))
|
||||
}
|
||||
|
||||
return fs.writeFile(metadataFilePath, JSON.stringify(this.media.toJSONForMetadataFile(), null, 2)).then(async () => {
|
||||
this.isSavingMetadata = false
|
||||
// Add metadata.json to libraryFiles array if it is new
|
||||
if (global.ServerSettings.storeMetadataWithItem && !this.libraryFiles.some(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath))) {
|
||||
const newLibraryFile = new LibraryFile()
|
||||
await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`)
|
||||
this.libraryFiles.push(newLibraryFile)
|
||||
}
|
||||
|
||||
return true
|
||||
}).catch((error) => {
|
||||
this.isSavingMetadata = false
|
||||
Logger.error(`[LibraryItem] Failed to save json file at "${metadataFilePath}"`, error)
|
||||
return false
|
||||
})
|
||||
} else {
|
||||
// Remove metadata.json if it exists
|
||||
if (await fs.pathExists(Path.join(metadataPath, `metadata.json`))) {
|
||||
Logger.debug(`[LibraryItem] Removing metadata.json for item "${this.media.metadata.title}"`)
|
||||
await fs.remove(Path.join(metadataPath, `metadata.json`))
|
||||
this.libraryFiles = this.libraryFiles.filter(lf => lf.metadata.path !== filePathToPOSIX(Path.join(metadataPath, `metadata.json`)))
|
||||
}
|
||||
|
||||
return abmetadataGenerator.generate(this, metadataFilePath).then(async (success) => {
|
||||
this.isSavingMetadata = false
|
||||
if (!success) Logger.error(`[LibraryItem] Failed saving abmetadata to "${metadataFilePath}"`)
|
||||
else {
|
||||
// Add metadata.abs to libraryFiles array if it is new
|
||||
if (global.ServerSettings.storeMetadataWithItem && !this.libraryFiles.some(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath))) {
|
||||
const newLibraryFile = new LibraryFile()
|
||||
await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.abs`)
|
||||
this.libraryFiles.push(newLibraryFile)
|
||||
}
|
||||
|
||||
Logger.debug(`[LibraryItem] Success saving abmetadata to "${metadataFilePath}"`)
|
||||
}
|
||||
return success
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
removeLibraryFile(ino) {
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ class PlaybackSession {
|
|||
libraryItemId: this.libraryItemId,
|
||||
episodeId: this.episodeId,
|
||||
mediaType: this.mediaType,
|
||||
mediaMetadata: this.mediaMetadata ? this.mediaMetadata.toJSON() : null,
|
||||
mediaMetadata: this.mediaMetadata?.toJSON() || null,
|
||||
chapters: (this.chapters || []).map(c => ({ ...c })),
|
||||
displayTitle: this.displayTitle,
|
||||
displayAuthor: this.displayAuthor,
|
||||
|
|
@ -63,7 +63,7 @@ class PlaybackSession {
|
|||
duration: this.duration,
|
||||
playMethod: this.playMethod,
|
||||
mediaPlayer: this.mediaPlayer,
|
||||
deviceInfo: this.deviceInfo ? this.deviceInfo.toJSON() : null,
|
||||
deviceInfo: this.deviceInfo?.toJSON() || null,
|
||||
date: this.date,
|
||||
dayOfWeek: this.dayOfWeek,
|
||||
timeListening: this.timeListening,
|
||||
|
|
@ -82,7 +82,7 @@ class PlaybackSession {
|
|||
libraryItemId: this.libraryItemId,
|
||||
episodeId: this.episodeId,
|
||||
mediaType: this.mediaType,
|
||||
mediaMetadata: this.mediaMetadata ? this.mediaMetadata.toJSON() : null,
|
||||
mediaMetadata: this.mediaMetadata?.toJSON() || null,
|
||||
chapters: (this.chapters || []).map(c => ({ ...c })),
|
||||
displayTitle: this.displayTitle,
|
||||
displayAuthor: this.displayAuthor,
|
||||
|
|
@ -90,7 +90,7 @@ class PlaybackSession {
|
|||
duration: this.duration,
|
||||
playMethod: this.playMethod,
|
||||
mediaPlayer: this.mediaPlayer,
|
||||
deviceInfo: this.deviceInfo ? this.deviceInfo.toJSON() : null,
|
||||
deviceInfo: this.deviceInfo?.toJSON() || null,
|
||||
date: this.date,
|
||||
dayOfWeek: this.dayOfWeek,
|
||||
timeListening: this.timeListening,
|
||||
|
|
@ -151,6 +151,10 @@ class PlaybackSession {
|
|||
return Math.max(0, Math.min(this.currentTime / this.duration, 1))
|
||||
}
|
||||
|
||||
get deviceId() {
|
||||
return this.deviceInfo?.deviceId
|
||||
}
|
||||
|
||||
get deviceDescription() {
|
||||
if (!this.deviceInfo) return 'No Device Info'
|
||||
return this.deviceInfo.deviceDescription
|
||||
|
|
@ -173,7 +177,7 @@ class PlaybackSession {
|
|||
this.episodeId = episodeId
|
||||
this.mediaType = libraryItem.mediaType
|
||||
this.mediaMetadata = libraryItem.media.metadata.clone()
|
||||
this.chapters = (libraryItem.media.chapters || []).map(c => ({ ...c })) // Only book mediaType has chapters
|
||||
this.chapters = libraryItem.media.getChapters(episodeId)
|
||||
this.displayTitle = libraryItem.media.getPlaybackTitle(episodeId)
|
||||
this.displayAuthor = libraryItem.media.getPlaybackAuthor()
|
||||
this.coverPath = libraryItem.media.coverPath
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
const Path = require('path')
|
||||
const { getId } = require('../utils/index')
|
||||
const { sanitizeFilename } = require('../utils/fileUtils')
|
||||
const globals = require('../utils/globals')
|
||||
|
||||
class PodcastEpisodeDownload {
|
||||
constructor() {
|
||||
|
|
@ -8,12 +9,14 @@ class PodcastEpisodeDownload {
|
|||
this.podcastEpisode = null
|
||||
this.url = null
|
||||
this.libraryItem = null
|
||||
this.libraryId = null
|
||||
|
||||
this.isAutoDownload = false
|
||||
this.isDownloading = false
|
||||
this.isFinished = false
|
||||
this.failed = false
|
||||
|
||||
this.appendEpisodeId = false
|
||||
|
||||
this.startedAt = null
|
||||
this.createdAt = null
|
||||
this.finishedAt = null
|
||||
|
|
@ -22,20 +25,39 @@ class PodcastEpisodeDownload {
|
|||
toJSONForClient() {
|
||||
return {
|
||||
id: this.id,
|
||||
episodeDisplayTitle: this.podcastEpisode ? this.podcastEpisode.title : null,
|
||||
episodeDisplayTitle: this.podcastEpisode?.title ?? null,
|
||||
url: this.url,
|
||||
libraryItemId: this.libraryItem ? this.libraryItem.id : null,
|
||||
isDownloading: this.isDownloading,
|
||||
libraryItemId: this.libraryItem?.id || null,
|
||||
libraryId: this.libraryId || null,
|
||||
isFinished: this.isFinished,
|
||||
failed: this.failed,
|
||||
appendEpisodeId: this.appendEpisodeId,
|
||||
startedAt: this.startedAt,
|
||||
createdAt: this.createdAt,
|
||||
finishedAt: this.finishedAt
|
||||
finishedAt: this.finishedAt,
|
||||
podcastTitle: this.libraryItem?.media.metadata.title ?? null,
|
||||
podcastExplicit: !!this.libraryItem?.media.metadata.explicit,
|
||||
season: this.podcastEpisode?.season ?? null,
|
||||
episode: this.podcastEpisode?.episode ?? null,
|
||||
episodeType: this.podcastEpisode?.episodeType ?? 'full',
|
||||
publishedAt: this.podcastEpisode?.publishedAt ?? null
|
||||
}
|
||||
}
|
||||
|
||||
get urlFileExtension() {
|
||||
const cleanUrl = this.url.split('?')[0] // Remove query string
|
||||
return Path.extname(cleanUrl).substring(1).toLowerCase()
|
||||
}
|
||||
get fileExtension() {
|
||||
const extname = this.urlFileExtension
|
||||
if (globals.SupportedAudioTypes.includes(extname)) return extname
|
||||
return 'mp3'
|
||||
}
|
||||
|
||||
get targetFilename() {
|
||||
return sanitizeFilename(`${this.podcastEpisode.title}.mp3`)
|
||||
const appendage = this.appendEpisodeId ? ` (${this.podcastEpisode.id})` : ''
|
||||
const filename = `${this.podcastEpisode.title}${appendage}.${this.fileExtension}`
|
||||
return sanitizeFilename(filename)
|
||||
}
|
||||
get targetPath() {
|
||||
return Path.join(this.libraryItem.path, this.targetFilename)
|
||||
|
|
@ -47,13 +69,21 @@ class PodcastEpisodeDownload {
|
|||
return this.libraryItem ? this.libraryItem.id : null
|
||||
}
|
||||
|
||||
setData(podcastEpisode, libraryItem, isAutoDownload) {
|
||||
setData(podcastEpisode, libraryItem, isAutoDownload, libraryId) {
|
||||
this.id = getId('epdl')
|
||||
this.podcastEpisode = podcastEpisode
|
||||
this.url = podcastEpisode.enclosure.url
|
||||
|
||||
const url = podcastEpisode.enclosure.url
|
||||
if (decodeURIComponent(url) !== url) { // Already encoded
|
||||
this.url = url
|
||||
} else {
|
||||
this.url = encodeURI(url)
|
||||
}
|
||||
|
||||
this.libraryItem = libraryItem
|
||||
this.isAutoDownload = isAutoDownload
|
||||
this.createdAt = Date.now()
|
||||
this.libraryId = libraryId
|
||||
}
|
||||
|
||||
setFinished(success) {
|
||||
|
|
@ -62,4 +92,4 @@ class PodcastEpisodeDownload {
|
|||
this.failed = !success
|
||||
}
|
||||
}
|
||||
module.exports = PodcastEpisodeDownload
|
||||
module.exports = PodcastEpisodeDownload
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const Ffmpeg = require('../libs/fluentFfmpeg')
|
|||
const { secondsToTimestamp } = require('../utils/index')
|
||||
const { writeConcatFile } = require('../utils/ffmpegHelpers')
|
||||
const { AudioMimeType } = require('../utils/constants')
|
||||
const hlsPlaylistGenerator = require('../utils/hlsPlaylistGenerator')
|
||||
const hlsPlaylistGenerator = require('../utils/generators/hlsPlaylistGenerator')
|
||||
const AudioTrack = require('./files/AudioTrack')
|
||||
|
||||
class Stream extends EventEmitter {
|
||||
|
|
@ -82,7 +82,9 @@ class Stream extends EventEmitter {
|
|||
AudioMimeType.WMA,
|
||||
AudioMimeType.AIFF,
|
||||
AudioMimeType.WEBM,
|
||||
AudioMimeType.WEBMA
|
||||
AudioMimeType.WEBMA,
|
||||
AudioMimeType.AWB,
|
||||
AudioMimeType.CAF
|
||||
]
|
||||
}
|
||||
get codecsToForceAAC() {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ class Task {
|
|||
this.title = null
|
||||
this.description = null
|
||||
this.error = null
|
||||
this.showSuccess = false // If true client side should keep the task visible after success
|
||||
|
||||
this.isFailed = false
|
||||
this.isFinished = false
|
||||
|
|
@ -25,6 +26,7 @@ class Task {
|
|||
title: this.title,
|
||||
description: this.description,
|
||||
error: this.error,
|
||||
showSuccess: this.showSuccess,
|
||||
isFailed: this.isFailed,
|
||||
isFinished: this.isFinished,
|
||||
startedAt: this.startedAt,
|
||||
|
|
@ -32,12 +34,13 @@ class Task {
|
|||
}
|
||||
}
|
||||
|
||||
setData(action, title, description, data = {}) {
|
||||
setData(action, title, description, showSuccess, data = {}) {
|
||||
this.id = getId(action)
|
||||
this.action = action
|
||||
this.data = { ...data }
|
||||
this.title = title
|
||||
this.description = description
|
||||
this.showSuccess = showSuccess
|
||||
this.startedAt = Date.now()
|
||||
}
|
||||
|
||||
|
|
@ -48,7 +51,10 @@ class Task {
|
|||
this.setFinished()
|
||||
}
|
||||
|
||||
setFinished() {
|
||||
setFinished(newDescription = null) {
|
||||
if (newDescription) {
|
||||
this.description = newDescription
|
||||
}
|
||||
this.isFinished = true
|
||||
this.finishedAt = Date.now()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
const Path = require('path')
|
||||
const { getId, cleanStringForSearch } = require('../../utils/index')
|
||||
const Logger = require('../../Logger')
|
||||
const { getId, cleanStringForSearch, areEquivalent, copyValue } = require('../../utils/index')
|
||||
const AudioFile = require('../files/AudioFile')
|
||||
const AudioTrack = require('../files/AudioTrack')
|
||||
|
||||
|
|
@ -17,6 +18,7 @@ class PodcastEpisode {
|
|||
this.description = null
|
||||
this.enclosure = null
|
||||
this.pubDate = null
|
||||
this.chapters = []
|
||||
|
||||
this.audioFile = null
|
||||
this.publishedAt = null
|
||||
|
|
@ -40,6 +42,7 @@ class PodcastEpisode {
|
|||
this.description = episode.description
|
||||
this.enclosure = episode.enclosure ? { ...episode.enclosure } : null
|
||||
this.pubDate = episode.pubDate
|
||||
this.chapters = episode.chapters?.map(ch => ({ ...ch })) || []
|
||||
this.audioFile = new AudioFile(episode.audioFile)
|
||||
this.publishedAt = episode.publishedAt
|
||||
this.addedAt = episode.addedAt
|
||||
|
|
@ -61,6 +64,7 @@ class PodcastEpisode {
|
|||
description: this.description,
|
||||
enclosure: this.enclosure ? { ...this.enclosure } : null,
|
||||
pubDate: this.pubDate,
|
||||
chapters: this.chapters.map(ch => ({ ...ch })),
|
||||
audioFile: this.audioFile.toJSON(),
|
||||
publishedAt: this.publishedAt,
|
||||
addedAt: this.addedAt,
|
||||
|
|
@ -81,6 +85,7 @@ class PodcastEpisode {
|
|||
description: this.description,
|
||||
enclosure: this.enclosure ? { ...this.enclosure } : null,
|
||||
pubDate: this.pubDate,
|
||||
chapters: this.chapters.map(ch => ({ ...ch })),
|
||||
audioFile: this.audioFile.toJSON(),
|
||||
audioTrack: this.audioTrack.toJSON(),
|
||||
publishedAt: this.publishedAt,
|
||||
|
|
@ -104,7 +109,11 @@ class PodcastEpisode {
|
|||
}
|
||||
get size() { return this.audioFile.metadata.size }
|
||||
get enclosureUrl() {
|
||||
return this.enclosure ? this.enclosure.url : null
|
||||
return this.enclosure?.url || null
|
||||
}
|
||||
get pubYear() {
|
||||
if (!this.publishedAt) return null
|
||||
return new Date(this.publishedAt).getFullYear()
|
||||
}
|
||||
|
||||
setData(data, index = 1) {
|
||||
|
|
@ -117,7 +126,7 @@ class PodcastEpisode {
|
|||
this.enclosure = data.enclosure ? { ...data.enclosure } : null
|
||||
this.season = data.season || ''
|
||||
this.episode = data.episode || ''
|
||||
this.episodeType = data.episodeType || ''
|
||||
this.episodeType = data.episodeType || 'full'
|
||||
this.publishedAt = data.publishedAt || 0
|
||||
this.addedAt = Date.now()
|
||||
this.updatedAt = Date.now()
|
||||
|
|
@ -128,6 +137,10 @@ class PodcastEpisode {
|
|||
this.audioFile = audioFile
|
||||
this.title = Path.basename(audioFile.metadata.filename, Path.extname(audioFile.metadata.filename))
|
||||
this.index = index
|
||||
|
||||
this.setDataFromAudioMetaTags(audioFile.metaTags, true)
|
||||
|
||||
this.chapters = audioFile.chapters?.map((c) => ({ ...c }))
|
||||
this.addedAt = Date.now()
|
||||
this.updatedAt = Date.now()
|
||||
}
|
||||
|
|
@ -135,8 +148,8 @@ class PodcastEpisode {
|
|||
update(payload) {
|
||||
let hasUpdates = false
|
||||
for (const key in this.toJSON()) {
|
||||
if (payload[key] != undefined && payload[key] != this[key]) {
|
||||
this[key] = payload[key]
|
||||
if (payload[key] != undefined && !areEquivalent(payload[key], this[key])) {
|
||||
this[key] = copyValue(payload[key])
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
|
@ -164,5 +177,76 @@ class PodcastEpisode {
|
|||
searchQuery(query) {
|
||||
return cleanStringForSearch(this.title).includes(query)
|
||||
}
|
||||
|
||||
setDataFromAudioMetaTags(audioFileMetaTags, overrideExistingDetails = false) {
|
||||
if (!audioFileMetaTags) return false
|
||||
|
||||
const MetadataMapArray = [
|
||||
{
|
||||
tag: 'tagComment',
|
||||
altTag: 'tagSubtitle',
|
||||
key: 'description'
|
||||
},
|
||||
{
|
||||
tag: 'tagSubtitle',
|
||||
key: 'subtitle'
|
||||
},
|
||||
{
|
||||
tag: 'tagDate',
|
||||
key: 'pubDate'
|
||||
},
|
||||
{
|
||||
tag: 'tagDisc',
|
||||
key: 'season',
|
||||
},
|
||||
{
|
||||
tag: 'tagTrack',
|
||||
altTag: 'tagSeriesPart',
|
||||
key: 'episode'
|
||||
},
|
||||
{
|
||||
tag: 'tagTitle',
|
||||
key: 'title'
|
||||
},
|
||||
{
|
||||
tag: 'tagEpisodeType',
|
||||
key: 'episodeType'
|
||||
}
|
||||
]
|
||||
|
||||
MetadataMapArray.forEach((mapping) => {
|
||||
let value = audioFileMetaTags[mapping.tag]
|
||||
let tagToUse = mapping.tag
|
||||
if (!value && mapping.altTag) {
|
||||
tagToUse = mapping.altTag
|
||||
value = audioFileMetaTags[mapping.altTag]
|
||||
}
|
||||
|
||||
if (value && typeof value === 'string') {
|
||||
value = value.trim() // Trim whitespace
|
||||
|
||||
if (mapping.key === 'pubDate' && (!this.pubDate || overrideExistingDetails)) {
|
||||
const pubJsDate = new Date(value)
|
||||
if (pubJsDate && !isNaN(pubJsDate)) {
|
||||
this.publishedAt = pubJsDate.valueOf()
|
||||
this.pubDate = value
|
||||
Logger.debug(`[PodcastEpisode] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${this[mapping.key]}`)
|
||||
} else {
|
||||
Logger.warn(`[PodcastEpisode] Mapping pubDate with tag ${tagToUse} has invalid date "${value}"`)
|
||||
}
|
||||
} else if (mapping.key === 'episodeType' && (!this.episodeType || overrideExistingDetails)) {
|
||||
if (['full', 'trailer', 'bonus'].includes(value)) {
|
||||
this.episodeType = value
|
||||
Logger.debug(`[PodcastEpisode] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${this[mapping.key]}`)
|
||||
} else {
|
||||
Logger.warn(`[PodcastEpisode] Mapping episodeType with invalid value "${value}". Must be one of [full, trailer, bonus].`)
|
||||
}
|
||||
} else if (!this[mapping.key] || overrideExistingDetails) {
|
||||
this[mapping.key] = value
|
||||
Logger.debug(`[PodcastEpisode] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${this[mapping.key]}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
module.exports = PodcastEpisode
|
||||
module.exports = PodcastEpisode
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ class AudioTrack {
|
|||
this.startOffset = startOffset
|
||||
this.duration = audioFile.duration
|
||||
this.title = audioFile.metadata.filename || ''
|
||||
// TODO: Switch to /api/items/:id/file/:fileid
|
||||
this.contentUrl = Path.join(`${global.RouterBasePath}/s/item/${itemId}`, encodeUriPath(audioFile.metadata.relPath))
|
||||
this.mimeType = audioFile.mimeType
|
||||
this.codec = audioFile.codec || null
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
const Path = require('path')
|
||||
const { getFileTimestampsWithIno } = require('../../utils/fileUtils')
|
||||
const { getFileTimestampsWithIno, filePathToPOSIX } = require('../../utils/fileUtils')
|
||||
const globals = require('../../utils/globals')
|
||||
const FileMetadata = require('../metadata/FileMetadata')
|
||||
|
||||
|
|
@ -50,6 +50,10 @@ class LibraryFile {
|
|||
return this.fileType === 'audio' || this.fileType === 'ebook' || this.fileType === 'video'
|
||||
}
|
||||
|
||||
get isEBookFile() {
|
||||
return this.fileType === 'ebook'
|
||||
}
|
||||
|
||||
get isOPFFile() {
|
||||
return this.metadata.ext === '.opf'
|
||||
}
|
||||
|
|
@ -59,8 +63,8 @@ class LibraryFile {
|
|||
var fileMetadata = new FileMetadata()
|
||||
fileMetadata.setData(fileTsData)
|
||||
fileMetadata.filename = Path.basename(relPath)
|
||||
fileMetadata.path = path
|
||||
fileMetadata.relPath = relPath
|
||||
fileMetadata.path = filePathToPOSIX(path)
|
||||
fileMetadata.relPath = filePathToPOSIX(relPath)
|
||||
fileMetadata.ext = Path.extname(relPath)
|
||||
this.ino = fileTsData.ino
|
||||
this.metadata = fileMetadata
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ class VideoTrack {
|
|||
this.index = videoFile.index
|
||||
this.duration = videoFile.duration
|
||||
this.title = videoFile.metadata.filename || ''
|
||||
// TODO: Switch to /api/items/:id/file/:fileid
|
||||
this.contentUrl = Path.join(`${global.RouterBasePath}/s/item/${itemId}`, encodeUriPath(videoFile.metadata.relPath))
|
||||
this.mimeType = videoFile.mimeType
|
||||
this.codec = videoFile.codec
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ const BookMetadata = require('../metadata/BookMetadata')
|
|||
const { areEquivalent, copyValue, cleanStringForSearch } = require('../../utils/index')
|
||||
const { parseOpfMetadataXML } = require('../../utils/parsers/parseOpfMetadata')
|
||||
const { parseOverdriveMediaMarkersAsChapters } = require('../../utils/parsers/parseOverdriveMediaMarkers')
|
||||
const abmetadataGenerator = require('../../utils/abmetadataGenerator')
|
||||
const abmetadataGenerator = require('../../utils/generators/abmetadataGenerator')
|
||||
const { readTextFile, filePathToPOSIX } = require('../../utils/fileUtils')
|
||||
const AudioFile = require('../files/AudioFile')
|
||||
const AudioTrack = require('../files/AudioTrack')
|
||||
|
|
@ -89,6 +89,14 @@ class Book {
|
|||
}
|
||||
}
|
||||
|
||||
toJSONForMetadataFile() {
|
||||
return {
|
||||
tags: [...this.tags],
|
||||
chapters: this.chapters.map(c => ({ ...c })),
|
||||
metadata: this.metadata.toJSONForMetadataFile()
|
||||
}
|
||||
}
|
||||
|
||||
get size() {
|
||||
var total = 0
|
||||
this.audioFiles.forEach((af) => total += af.metadata.size)
|
||||
|
|
@ -134,6 +142,9 @@ class Book {
|
|||
get numTracks() {
|
||||
return this.tracks.length
|
||||
}
|
||||
get isEBookOnly() {
|
||||
return this.ebookFile && !this.numTracks
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
const json = this.toJSON()
|
||||
|
|
@ -229,7 +240,7 @@ class Book {
|
|||
// Look for desc.txt, reader.txt, metadata.abs and opf file then update details if found
|
||||
async syncMetadataFiles(textMetadataFiles, opfMetadataOverrideDetails) {
|
||||
let metadataUpdatePayload = {}
|
||||
let tagsUpdated = false
|
||||
let hasUpdated = false
|
||||
|
||||
const descTxt = textMetadataFiles.find(lf => lf.metadata.filename === 'desc.txt')
|
||||
if (descTxt) {
|
||||
|
|
@ -248,17 +259,25 @@ class Book {
|
|||
}
|
||||
}
|
||||
|
||||
const metadataIsJSON = global.ServerSettings.metadataFileFormat === 'json'
|
||||
const metadataAbs = textMetadataFiles.find(lf => lf.metadata.filename === 'metadata.abs')
|
||||
if (metadataAbs) {
|
||||
Logger.debug(`[Book] Found metadata.abs file for "${this.metadata.title}"`)
|
||||
const metadataText = await readTextFile(metadataAbs.metadata.path)
|
||||
const abmetadataUpdates = abmetadataGenerator.parseAndCheckForUpdates(metadataText, this, 'book')
|
||||
const metadataJson = textMetadataFiles.find(lf => lf.metadata.filename === 'metadata.json')
|
||||
|
||||
const metadataFile = metadataIsJSON ? metadataJson : metadataAbs
|
||||
if (metadataFile) {
|
||||
Logger.debug(`[Book] Found ${metadataFile.metadata.filename} file for "${this.metadata.title}"`)
|
||||
const metadataText = await readTextFile(metadataFile.metadata.path)
|
||||
const abmetadataUpdates = abmetadataGenerator.parseAndCheckForUpdates(metadataText, this, 'book', metadataIsJSON)
|
||||
if (abmetadataUpdates && Object.keys(abmetadataUpdates).length) {
|
||||
Logger.debug(`[Book] "${this.metadata.title}" changes found in metadata.abs file`, abmetadataUpdates)
|
||||
|
||||
if (abmetadataUpdates.tags) { // Set media tags if updated
|
||||
this.tags = abmetadataUpdates.tags
|
||||
tagsUpdated = true
|
||||
hasUpdated = true
|
||||
}
|
||||
if (abmetadataUpdates.chapters) { // Set chapters if updated
|
||||
this.chapters = abmetadataUpdates.chapters
|
||||
hasUpdated = true
|
||||
}
|
||||
if (abmetadataUpdates.metadata) {
|
||||
metadataUpdatePayload = {
|
||||
|
|
@ -267,6 +286,9 @@ class Book {
|
|||
}
|
||||
}
|
||||
}
|
||||
} else if (metadataAbs || metadataJson) { // Has different metadata file format so mark as updated
|
||||
Logger.debug(`[Book] Found different format metadata file ${(metadataAbs || metadataJson).metadata.filename}, expecting .${global.ServerSettings.metadataFileFormat} for "${this.metadata.title}"`)
|
||||
hasUpdated = true
|
||||
}
|
||||
|
||||
const metadataOpf = textMetadataFiles.find(lf => lf.isOPFFile || lf.metadata.filename === 'metadata.xml')
|
||||
|
|
@ -280,7 +302,7 @@ class Book {
|
|||
if (key === 'tags') { // Add tags only if tags are empty
|
||||
if (opfMetadata.tags.length && (!this.tags.length || opfMetadataOverrideDetails)) {
|
||||
this.tags = opfMetadata.tags
|
||||
tagsUpdated = true
|
||||
hasUpdated = true
|
||||
}
|
||||
} else if (key === 'genres') { // Add genres only if genres are empty
|
||||
if (opfMetadata.genres.length && (!this.metadata.genres.length || opfMetadataOverrideDetails)) {
|
||||
|
|
@ -296,7 +318,7 @@ class Book {
|
|||
})
|
||||
}
|
||||
} else if (key === 'narrators') {
|
||||
if (opfMetadata.narrators && opfMetadata.narrators.length && (!this.metadata.narrators.length || opfMetadataOverrideDetails)) {
|
||||
if (opfMetadata.narrators?.length && (!this.metadata.narrators.length || opfMetadataOverrideDetails)) {
|
||||
metadataUpdatePayload.narrators = opfMetadata.narrators
|
||||
}
|
||||
} else if (key === 'series') {
|
||||
|
|
@ -312,9 +334,9 @@ class Book {
|
|||
}
|
||||
|
||||
if (Object.keys(metadataUpdatePayload).length) {
|
||||
return this.metadata.update(metadataUpdatePayload) || tagsUpdated
|
||||
return this.metadata.update(metadataUpdatePayload) || hasUpdated
|
||||
}
|
||||
return tagsUpdated
|
||||
return hasUpdated
|
||||
}
|
||||
|
||||
searchQuery(query) {
|
||||
|
|
@ -322,6 +344,7 @@ class Book {
|
|||
tags: this.tags.filter(t => cleanStringForSearch(t).includes(query)),
|
||||
series: this.metadata.searchSeries(query),
|
||||
authors: this.metadata.searchAuthors(query),
|
||||
narrators: this.metadata.searchNarrators(query),
|
||||
matchKey: null,
|
||||
matchText: null
|
||||
}
|
||||
|
|
@ -336,10 +359,12 @@ class Book {
|
|||
} else if (payload.series.length) {
|
||||
payload.matchKey = 'series'
|
||||
payload.matchText = this.metadata.seriesName
|
||||
}
|
||||
else if (payload.tags.length) {
|
||||
} else if (payload.tags.length) {
|
||||
payload.matchKey = 'tags'
|
||||
payload.matchText = this.tags.join(', ')
|
||||
} else if (payload.narrators.length) {
|
||||
payload.matchKey = 'narrators'
|
||||
payload.matchText = this.metadata.narratorName
|
||||
}
|
||||
}
|
||||
return payload
|
||||
|
|
@ -356,9 +381,9 @@ class Book {
|
|||
}
|
||||
|
||||
updateAudioTracks(orderedFileData) {
|
||||
var index = 1
|
||||
let index = 1
|
||||
this.audioFiles = orderedFileData.map((fileData) => {
|
||||
var audioFile = this.audioFiles.find(af => af.ino === fileData.ino)
|
||||
const audioFile = this.audioFiles.find(af => af.ino === fileData.ino)
|
||||
audioFile.manuallyVerified = true
|
||||
audioFile.invalid = false
|
||||
audioFile.error = null
|
||||
|
|
@ -376,11 +401,11 @@ class Book {
|
|||
this.rebuildTracks()
|
||||
}
|
||||
|
||||
rebuildTracks(preferOverdriveMediaMarker) {
|
||||
rebuildTracks() {
|
||||
Logger.debug(`[Book] Tracks being rebuilt...!`)
|
||||
this.audioFiles.sort((a, b) => a.index - b.index)
|
||||
this.missingParts = []
|
||||
this.setChapters(preferOverdriveMediaMarker)
|
||||
this.setChapters()
|
||||
this.checkUpdateMissingTracks()
|
||||
}
|
||||
|
||||
|
|
@ -412,14 +437,16 @@ class Book {
|
|||
return wasUpdated
|
||||
}
|
||||
|
||||
setChapters(preferOverdriveMediaMarker = false) {
|
||||
setChapters() {
|
||||
const preferOverdriveMediaMarker = !!global.ServerSettings.scannerPreferOverdriveMediaMarker
|
||||
|
||||
// If 1 audio file without chapters, then no chapters will be set
|
||||
var includedAudioFiles = this.audioFiles.filter(af => !af.exclude)
|
||||
const 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)
|
||||
const overdriveChapters = parseOverdriveMediaMarkersAsChapters(includedAudioFiles)
|
||||
if (overdriveChapters) {
|
||||
Logger.info('[Book] Overdrive Media Markers and preference found! Using these for chapter definitions')
|
||||
this.chapters = overdriveChapters
|
||||
|
|
@ -460,17 +487,26 @@ class Book {
|
|||
})
|
||||
}
|
||||
} else if (includedAudioFiles.length > 1) {
|
||||
const preferAudioMetadata = !!global.ServerSettings.scannerPreferAudioMetadata
|
||||
|
||||
// Build chapters from audio files
|
||||
this.chapters = []
|
||||
var currChapterId = 0
|
||||
var currStartTime = 0
|
||||
let currChapterId = 0
|
||||
let currStartTime = 0
|
||||
includedAudioFiles.forEach((file) => {
|
||||
if (file.duration) {
|
||||
let title = file.metadata.filename ? Path.basename(file.metadata.filename, Path.extname(file.metadata.filename)) : `Chapter ${currChapterId}`
|
||||
|
||||
// When prefer audio metadata server setting is set then use ID3 title tag as long as it is not the same as the book title
|
||||
if (preferAudioMetadata && file.metaTags?.tagTitle && file.metaTags?.tagTitle !== this.metadata.title) {
|
||||
title = file.metaTags.tagTitle
|
||||
}
|
||||
|
||||
this.chapters.push({
|
||||
id: currChapterId++,
|
||||
start: currStartTime,
|
||||
end: currStartTime + file.duration,
|
||||
title: file.metadata.filename ? Path.basename(file.metadata.filename, Path.extname(file.metadata.filename)) : `Chapter ${currChapterId}`
|
||||
title
|
||||
})
|
||||
currStartTime += file.duration
|
||||
}
|
||||
|
|
@ -495,5 +531,9 @@ class Book {
|
|||
getPlaybackAuthor() {
|
||||
return this.metadata.authorName
|
||||
}
|
||||
|
||||
getChapters() {
|
||||
return this.chapters?.map(ch => ({ ...ch })) || []
|
||||
}
|
||||
}
|
||||
module.exports = Book
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ const Logger = require('../../Logger')
|
|||
const PodcastEpisode = require('../entities/PodcastEpisode')
|
||||
const PodcastMetadata = require('../metadata/PodcastMetadata')
|
||||
const { areEquivalent, copyValue, cleanStringForSearch } = require('../../utils/index')
|
||||
const abmetadataGenerator = require('../../utils/abmetadataGenerator')
|
||||
const abmetadataGenerator = require('../../utils/generators/abmetadataGenerator')
|
||||
const { readTextFile, filePathToPOSIX } = require('../../utils/fileUtils')
|
||||
const { createNewSortInstance } = require('../../libs/fastSort')
|
||||
const naturalSort = createNewSortInstance({
|
||||
|
|
@ -94,6 +94,13 @@ class Podcast {
|
|||
}
|
||||
}
|
||||
|
||||
toJSONForMetadataFile() {
|
||||
return {
|
||||
tags: [...this.tags],
|
||||
metadata: this.metadata.toJSON()
|
||||
}
|
||||
}
|
||||
|
||||
get size() {
|
||||
var total = 0
|
||||
this.episodes.forEach((ep) => total += ep.size)
|
||||
|
|
@ -166,7 +173,11 @@ class Podcast {
|
|||
}
|
||||
|
||||
removeFileWithInode(inode) {
|
||||
this.episodes = this.episodes.filter(ep => ep.ino !== inode)
|
||||
const hasEpisode = this.episodes.some(ep => ep.audioFile.ino === inode)
|
||||
if (hasEpisode) {
|
||||
this.episodes = this.episodes.filter(ep => ep.audioFile.ino !== inode)
|
||||
}
|
||||
return hasEpisode
|
||||
}
|
||||
|
||||
findFileWithInode(inode) {
|
||||
|
|
@ -175,6 +186,10 @@ class Podcast {
|
|||
return null
|
||||
}
|
||||
|
||||
findEpisodeWithInode(inode) {
|
||||
return this.episodes.find(ep => ep.audioFile.ino === inode)
|
||||
}
|
||||
|
||||
setData(mediaData) {
|
||||
this.metadata = new PodcastMetadata()
|
||||
if (mediaData.metadata) {
|
||||
|
|
@ -191,10 +206,11 @@ class Podcast {
|
|||
let metadataUpdatePayload = {}
|
||||
let tagsUpdated = false
|
||||
|
||||
const metadataAbs = textMetadataFiles.find(lf => lf.metadata.filename === 'metadata.abs')
|
||||
const metadataAbs = textMetadataFiles.find(lf => lf.metadata.filename === 'metadata.abs' || lf.metadata.filename === 'metadata.json')
|
||||
if (metadataAbs) {
|
||||
const isJSON = metadataAbs.metadata.filename === 'metadata.json'
|
||||
const metadataText = await readTextFile(metadataAbs.metadata.path)
|
||||
const abmetadataUpdates = abmetadataGenerator.parseAndCheckForUpdates(metadataText, this, 'podcast')
|
||||
const abmetadataUpdates = abmetadataGenerator.parseAndCheckForUpdates(metadataText, this, 'podcast', isJSON)
|
||||
if (abmetadataUpdates && Object.keys(abmetadataUpdates).length) {
|
||||
Logger.debug(`[Podcast] "${this.metadata.title}" changes found in metadata.abs file`, abmetadataUpdates)
|
||||
|
||||
|
|
@ -315,5 +331,17 @@ class Podcast {
|
|||
getEpisode(episodeId) {
|
||||
return this.episodes.find(ep => ep.id == episodeId)
|
||||
}
|
||||
|
||||
// Audio file metadata tags map to podcast details
|
||||
setMetadataFromAudioFile(overrideExistingDetails = false) {
|
||||
if (!this.episodes.length) return false
|
||||
const audioFile = this.episodes[0].audioFile
|
||||
if (!audioFile?.metaTags) return false
|
||||
return this.metadata.setDataFromAudioMetaTags(audioFile.metaTags, overrideExistingDetails)
|
||||
}
|
||||
|
||||
getChapters(episodeId) {
|
||||
return this.getEpisode(episodeId)?.chapters?.map(ch => ({ ...ch })) || []
|
||||
}
|
||||
}
|
||||
module.exports = Podcast
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
class AudioMetaTags {
|
||||
constructor(metadata) {
|
||||
this.tagAlbum = null
|
||||
this.tagAlbumSort = null
|
||||
this.tagArtist = null
|
||||
this.tagArtistSort = null
|
||||
this.tagGenre = null
|
||||
this.tagTitle = null
|
||||
this.tagTitleSort = null
|
||||
this.tagSeries = null
|
||||
this.tagSeriesPart = null
|
||||
this.tagTrack = null
|
||||
|
|
@ -20,6 +23,9 @@ class AudioMetaTags {
|
|||
this.tagIsbn = null
|
||||
this.tagLanguage = null
|
||||
this.tagASIN = null
|
||||
this.tagItunesId = null
|
||||
this.tagPodcastType = null
|
||||
this.tagEpisodeType = null
|
||||
this.tagOverdriveMediaMarker = null
|
||||
this.tagOriginalYear = null
|
||||
this.tagReleaseCountry = null
|
||||
|
|
@ -94,9 +100,12 @@ class AudioMetaTags {
|
|||
|
||||
construct(metadata) {
|
||||
this.tagAlbum = metadata.tagAlbum || null
|
||||
this.tagAlbumSort = metadata.tagAlbumSort || null
|
||||
this.tagArtist = metadata.tagArtist || null
|
||||
this.tagArtistSort = metadata.tagArtistSort || null
|
||||
this.tagGenre = metadata.tagGenre || null
|
||||
this.tagTitle = metadata.tagTitle || null
|
||||
this.tagTitleSort = metadata.tagTitleSort || null
|
||||
this.tagSeries = metadata.tagSeries || null
|
||||
this.tagSeriesPart = metadata.tagSeriesPart || null
|
||||
this.tagTrack = metadata.tagTrack || null
|
||||
|
|
@ -113,6 +122,9 @@ class AudioMetaTags {
|
|||
this.tagIsbn = metadata.tagIsbn || null
|
||||
this.tagLanguage = metadata.tagLanguage || null
|
||||
this.tagASIN = metadata.tagASIN || null
|
||||
this.tagItunesId = metadata.tagItunesId || null
|
||||
this.tagPodcastType = metadata.tagPodcastType || null
|
||||
this.tagEpisodeType = metadata.tagEpisodeType || null
|
||||
this.tagOverdriveMediaMarker = metadata.tagOverdriveMediaMarker || null
|
||||
this.tagOriginalYear = metadata.tagOriginalYear || null
|
||||
this.tagReleaseCountry = metadata.tagReleaseCountry || null
|
||||
|
|
@ -128,9 +140,12 @@ class AudioMetaTags {
|
|||
// Data parsed in prober.js
|
||||
setData(payload) {
|
||||
this.tagAlbum = payload.file_tag_album || null
|
||||
this.tagAlbumSort = payload.file_tag_albumsort || null
|
||||
this.tagArtist = payload.file_tag_artist || null
|
||||
this.tagArtistSort = payload.file_tag_artistsort || null
|
||||
this.tagGenre = payload.file_tag_genre || null
|
||||
this.tagTitle = payload.file_tag_title || null
|
||||
this.tagTitleSort = payload.file_tag_titlesort || null
|
||||
this.tagSeries = payload.file_tag_series || null
|
||||
this.tagSeriesPart = payload.file_tag_seriespart || null
|
||||
this.tagTrack = payload.file_tag_track || null
|
||||
|
|
@ -147,6 +162,9 @@ class AudioMetaTags {
|
|||
this.tagIsbn = payload.file_tag_isbn || null
|
||||
this.tagLanguage = payload.file_tag_language || null
|
||||
this.tagASIN = payload.file_tag_asin || null
|
||||
this.tagItunesId = payload.file_tag_itunesid || null
|
||||
this.tagPodcastType = payload.file_tag_podcasttype || null
|
||||
this.tagEpisodeType = payload.file_tag_episodetype || null
|
||||
this.tagOverdriveMediaMarker = payload.file_tag_overdrive_media_marker || null
|
||||
this.tagOriginalYear = payload.file_tag_originalyear || null
|
||||
this.tagReleaseCountry = payload.file_tag_releasecountry || null
|
||||
|
|
@ -166,9 +184,12 @@ class AudioMetaTags {
|
|||
updateData(payload) {
|
||||
const dataMap = {
|
||||
tagAlbum: payload.file_tag_album || null,
|
||||
tagAlbumSort: payload.file_tag_albumsort || null,
|
||||
tagArtist: payload.file_tag_artist || null,
|
||||
tagArtistSort: payload.file_tag_artistsort || null,
|
||||
tagGenre: payload.file_tag_genre || null,
|
||||
tagTitle: payload.file_tag_title || null,
|
||||
tagTitleSort: payload.file_tag_titlesort || null,
|
||||
tagSeries: payload.file_tag_series || null,
|
||||
tagSeriesPart: payload.file_tag_seriespart || null,
|
||||
tagTrack: payload.file_tag_track || null,
|
||||
|
|
@ -185,6 +206,9 @@ class AudioMetaTags {
|
|||
tagIsbn: payload.file_tag_isbn || null,
|
||||
tagLanguage: payload.file_tag_language || null,
|
||||
tagASIN: payload.file_tag_asin || null,
|
||||
tagItunesId: payload.file_tag_itunesid || null,
|
||||
tagPodcastType: payload.file_tag_podcasttype || null,
|
||||
tagEpisodeType: payload.file_tag_episodetype || null,
|
||||
tagOverdriveMediaMarker: payload.file_tag_overdrive_media_marker || null,
|
||||
tagOriginalYear: payload.file_tag_originalyear || null,
|
||||
tagReleaseCountry: payload.file_tag_releasecountry || null,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ class BookMetadata {
|
|||
this.asin = null
|
||||
this.language = null
|
||||
this.explicit = false
|
||||
this.abridged = false
|
||||
|
||||
if (metadata) {
|
||||
this.construct(metadata)
|
||||
|
|
@ -26,9 +27,9 @@ class BookMetadata {
|
|||
construct(metadata) {
|
||||
this.title = metadata.title
|
||||
this.subtitle = metadata.subtitle
|
||||
this.authors = (metadata.authors && metadata.authors.map) ? metadata.authors.map(a => ({ ...a })) : []
|
||||
this.narrators = metadata.narrators ? [...metadata.narrators] : []
|
||||
this.series = (metadata.series && metadata.series.map) ? metadata.series.map(s => ({ ...s })) : []
|
||||
this.authors = (metadata.authors?.map) ? metadata.authors.map(a => ({ ...a })) : []
|
||||
this.narrators = metadata.narrators ? [...metadata.narrators].filter(n => n) : []
|
||||
this.series = (metadata.series?.map) ? metadata.series.map(s => ({ ...s })) : []
|
||||
this.genres = metadata.genres ? [...metadata.genres] : []
|
||||
this.publishedYear = metadata.publishedYear || null
|
||||
this.publishedDate = metadata.publishedDate || null
|
||||
|
|
@ -38,6 +39,7 @@ class BookMetadata {
|
|||
this.asin = metadata.asin
|
||||
this.language = metadata.language
|
||||
this.explicit = !!metadata.explicit
|
||||
this.abridged = !!metadata.abridged
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
|
|
@ -55,7 +57,8 @@ class BookMetadata {
|
|||
isbn: this.isbn,
|
||||
asin: this.asin,
|
||||
language: this.language,
|
||||
explicit: this.explicit
|
||||
explicit: this.explicit,
|
||||
abridged: this.abridged
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -76,7 +79,8 @@ class BookMetadata {
|
|||
isbn: this.isbn,
|
||||
asin: this.asin,
|
||||
language: this.language,
|
||||
explicit: this.explicit
|
||||
explicit: this.explicit,
|
||||
abridged: this.abridged
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -100,10 +104,21 @@ class BookMetadata {
|
|||
authorName: this.authorName,
|
||||
authorNameLF: this.authorNameLF,
|
||||
narratorName: this.narratorName,
|
||||
seriesName: this.seriesName
|
||||
seriesName: this.seriesName,
|
||||
abridged: this.abridged
|
||||
}
|
||||
}
|
||||
|
||||
toJSONForMetadataFile() {
|
||||
const json = this.toJSON()
|
||||
json.authors = json.authors.map(au => au.name)
|
||||
json.series = json.series.map(se => {
|
||||
if (!se.sequence) return se.name
|
||||
return `${se.name} #${se.sequence}`
|
||||
})
|
||||
return json
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new BookMetadata(this.toJSON())
|
||||
}
|
||||
|
|
@ -209,8 +224,9 @@ class BookMetadata {
|
|||
}
|
||||
}
|
||||
update(payload) {
|
||||
var json = this.toJSON()
|
||||
var hasUpdates = false
|
||||
const json = this.toJSON()
|
||||
let hasUpdates = false
|
||||
|
||||
for (const key in json) {
|
||||
if (payload[key] !== undefined) {
|
||||
if (!areEquivalent(payload[key], json[key])) {
|
||||
|
|
@ -239,6 +255,32 @@ class BookMetadata {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update narrator name if narrator is in book
|
||||
* @param {String} oldNarratorName - Narrator name to get updated
|
||||
* @param {String} newNarratorName - Updated narrator name
|
||||
* @return {Boolean} True if narrator was updated
|
||||
*/
|
||||
updateNarrator(oldNarratorName, newNarratorName) {
|
||||
if (!this.hasNarrator(oldNarratorName)) return false
|
||||
this.narrators = this.narrators.filter(n => n !== oldNarratorName)
|
||||
if (newNarratorName && !this.hasNarrator(newNarratorName)) {
|
||||
this.narrators.push(newNarratorName)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove narrator name if narrator is in book
|
||||
* @param {String} narratorName - Narrator name to remove
|
||||
* @return {Boolean} True if narrator was updated
|
||||
*/
|
||||
removeNarrator(narratorName) {
|
||||
if (!this.hasNarrator(narratorName)) return false
|
||||
this.narrators = this.narrators.filter(n => n !== narratorName)
|
||||
return true
|
||||
}
|
||||
|
||||
setData(scanMediaData = {}) {
|
||||
this.title = scanMediaData.title || null
|
||||
this.subtitle = scanMediaData.subtitle || null
|
||||
|
|
@ -365,8 +407,10 @@ class BookMetadata {
|
|||
const parsed = parseNameString.parse(authorsTag)
|
||||
if (!parsed) return []
|
||||
return (parsed.names || []).map((au) => {
|
||||
const findAuthor = this.authors.find(_au => _au.name == au)
|
||||
|
||||
return {
|
||||
id: `new-${Math.floor(Math.random() * 1000000)}`,
|
||||
id: findAuthor?.id || `new-${Math.floor(Math.random() * 1000000)}`,
|
||||
name: au
|
||||
}
|
||||
})
|
||||
|
|
@ -399,8 +443,11 @@ class BookMetadata {
|
|||
searchAuthors(query) {
|
||||
return this.authors.filter(au => cleanStringForSearch(au.name).includes(query))
|
||||
}
|
||||
searchNarrators(query) {
|
||||
return this.narrators.filter(n => cleanStringForSearch(n).includes(query))
|
||||
}
|
||||
searchQuery(query) { // Returns key if match is found
|
||||
const keysToCheck = ['title', 'asin', 'isbn']
|
||||
const keysToCheck = ['title', 'asin', 'isbn', 'subtitle']
|
||||
for (const key of keysToCheck) {
|
||||
if (this[key] && cleanStringForSearch(String(this[key])).includes(query)) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ class PodcastMetadata {
|
|||
this.itunesArtistId = null
|
||||
this.explicit = false
|
||||
this.language = null
|
||||
this.type = null
|
||||
|
||||
if (metadata) {
|
||||
this.construct(metadata)
|
||||
|
|
@ -34,6 +35,7 @@ class PodcastMetadata {
|
|||
this.itunesArtistId = metadata.itunesArtistId
|
||||
this.explicit = metadata.explicit
|
||||
this.language = metadata.language || null
|
||||
this.type = metadata.type || 'episodic'
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
|
|
@ -49,7 +51,8 @@ class PodcastMetadata {
|
|||
itunesId: this.itunesId,
|
||||
itunesArtistId: this.itunesArtistId,
|
||||
explicit: this.explicit,
|
||||
language: this.language
|
||||
language: this.language,
|
||||
type: this.type
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -67,7 +70,8 @@ class PodcastMetadata {
|
|||
itunesId: this.itunesId,
|
||||
itunesArtistId: this.itunesArtistId,
|
||||
explicit: this.explicit,
|
||||
language: this.language
|
||||
language: this.language,
|
||||
type: this.type
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -112,6 +116,7 @@ class PodcastMetadata {
|
|||
this.itunesArtistId = mediaMetadata.itunesArtistId || null
|
||||
this.explicit = !!mediaMetadata.explicit
|
||||
this.language = mediaMetadata.language || null
|
||||
this.type = mediaMetadata.type || null
|
||||
if (mediaMetadata.genres && mediaMetadata.genres.length) {
|
||||
this.genres = [...mediaMetadata.genres]
|
||||
}
|
||||
|
|
@ -131,5 +136,74 @@ class PodcastMetadata {
|
|||
}
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
setDataFromAudioMetaTags(audioFileMetaTags, overrideExistingDetails = false) {
|
||||
const MetadataMapArray = [
|
||||
{
|
||||
tag: 'tagAlbum',
|
||||
altTag: 'tagSeries',
|
||||
key: 'title'
|
||||
},
|
||||
{
|
||||
tag: 'tagArtist',
|
||||
key: 'author'
|
||||
},
|
||||
{
|
||||
tag: 'tagGenre',
|
||||
key: 'genres'
|
||||
},
|
||||
{
|
||||
tag: 'tagLanguage',
|
||||
key: 'language'
|
||||
},
|
||||
{
|
||||
tag: 'tagItunesId',
|
||||
key: 'itunesId'
|
||||
},
|
||||
{
|
||||
tag: 'tagPodcastType',
|
||||
key: 'type',
|
||||
}
|
||||
]
|
||||
|
||||
const updatePayload = {}
|
||||
|
||||
MetadataMapArray.forEach((mapping) => {
|
||||
let value = audioFileMetaTags[mapping.tag]
|
||||
let tagToUse = mapping.tag
|
||||
if (!value && mapping.altTag) {
|
||||
value = audioFileMetaTags[mapping.altTag]
|
||||
tagToUse = mapping.altTag
|
||||
}
|
||||
|
||||
if (value && typeof value === 'string') {
|
||||
value = value.trim() // Trim whitespace
|
||||
|
||||
if (mapping.key === 'genres' && (!this.genres.length || overrideExistingDetails)) {
|
||||
updatePayload.genres = this.parseGenresTag(value)
|
||||
Logger.debug(`[Podcast] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${updatePayload.genres.join(', ')}`)
|
||||
} else if (!this[mapping.key] || overrideExistingDetails) {
|
||||
updatePayload[mapping.key] = value
|
||||
Logger.debug(`[Podcast] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${updatePayload[mapping.key]}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (Object.keys(updatePayload).length) {
|
||||
return this.update(updatePayload)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
parseGenresTag(genreTag) {
|
||||
if (!genreTag || !genreTag.length) return []
|
||||
const separators = ['/', '//', ';']
|
||||
for (let i = 0; i < separators.length; i++) {
|
||||
if (genreTag.includes(separators[i])) {
|
||||
return genreTag.split(separators[i]).map(genre => genre.trim()).filter(g => !!g)
|
||||
}
|
||||
}
|
||||
return [genreTag]
|
||||
}
|
||||
}
|
||||
module.exports = PodcastMetadata
|
||||
module.exports = PodcastMetadata
|
||||
|
|
|
|||
101
server/objects/settings/EmailSettings.js
Normal file
101
server/objects/settings/EmailSettings.js
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
const Logger = require('../../Logger')
|
||||
const { areEquivalent, copyValue, isNullOrNaN } = require('../../utils')
|
||||
|
||||
// REF: https://nodemailer.com/smtp/
|
||||
class EmailSettings {
|
||||
constructor(settings = null) {
|
||||
this.id = 'email-settings'
|
||||
this.host = null
|
||||
this.port = 465
|
||||
this.secure = true
|
||||
this.user = null
|
||||
this.pass = null
|
||||
this.fromAddress = null
|
||||
|
||||
// Array of { name:String, email:String }
|
||||
this.ereaderDevices = []
|
||||
|
||||
if (settings) {
|
||||
this.construct(settings)
|
||||
}
|
||||
}
|
||||
|
||||
construct(settings) {
|
||||
this.host = settings.host
|
||||
this.port = settings.port
|
||||
this.secure = !!settings.secure
|
||||
this.user = settings.user
|
||||
this.pass = settings.pass
|
||||
this.fromAddress = settings.fromAddress
|
||||
this.ereaderDevices = settings.ereaderDevices?.map(d => ({ ...d })) || []
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
host: this.host,
|
||||
port: this.port,
|
||||
secure: this.secure,
|
||||
user: this.user,
|
||||
pass: this.pass,
|
||||
fromAddress: this.fromAddress,
|
||||
ereaderDevices: this.ereaderDevices.map(d => ({ ...d }))
|
||||
}
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
if (!payload) return false
|
||||
|
||||
if (payload.port !== undefined) {
|
||||
if (isNullOrNaN(payload.port)) payload.port = 465
|
||||
else payload.port = Number(payload.port)
|
||||
}
|
||||
if (payload.secure !== undefined) payload.secure = !!payload.secure
|
||||
|
||||
if (payload.ereaderDevices !== undefined && !Array.isArray(payload.ereaderDevices)) payload.ereaderDevices = undefined
|
||||
|
||||
let hasUpdates = false
|
||||
|
||||
const json = this.toJSON()
|
||||
for (const key in json) {
|
||||
if (key === 'id') continue
|
||||
|
||||
if (payload[key] !== undefined && !areEquivalent(payload[key], json[key])) {
|
||||
this[key] = copyValue(payload[key])
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
getTransportObject() {
|
||||
const payload = {
|
||||
host: this.host,
|
||||
secure: this.secure
|
||||
}
|
||||
if (this.port) payload.port = this.port
|
||||
if (this.user && this.pass !== undefined) {
|
||||
payload.auth = {
|
||||
user: this.user,
|
||||
pass: this.pass
|
||||
}
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
getEReaderDevices(user) {
|
||||
// Only accessible to admin or up
|
||||
if (!user.isAdminOrUp) {
|
||||
return []
|
||||
}
|
||||
|
||||
return this.ereaderDevices.map(d => ({ ...d }))
|
||||
}
|
||||
|
||||
getEReaderDevice(deviceName) {
|
||||
return this.ereaderDevices.find(d => d.name === deviceName)
|
||||
}
|
||||
}
|
||||
module.exports = EmailSettings
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
const { BookshelfView } = require('../../utils/constants')
|
||||
const { isNullOrNaN } = require('../../utils')
|
||||
const Logger = require('../../Logger')
|
||||
|
||||
class ServerSettings {
|
||||
|
|
@ -22,6 +21,7 @@ class ServerSettings {
|
|||
this.storeCoverWithItem = false
|
||||
this.storeMetadataWithItem = false
|
||||
this.defaultRenameString = "$bookAuthor/$bookTitle"
|
||||
this.metadataFileFormat = 'json'
|
||||
|
||||
// Security/Rate limits
|
||||
this.rateLimitLoginRequests = 10
|
||||
|
|
@ -52,6 +52,7 @@ class ServerSettings {
|
|||
this.chromecastEnabled = false
|
||||
this.enableEReader = false
|
||||
this.dateFormat = 'MM/dd/yyyy'
|
||||
this.timeFormat = 'HH:mm'
|
||||
this.language = 'en-us'
|
||||
|
||||
this.logLevel = Logger.logLevel
|
||||
|
|
@ -78,6 +79,7 @@ class ServerSettings {
|
|||
this.storeCoverWithItem = !!settings.storeCoverWithItem
|
||||
this.storeMetadataWithItem = !!settings.storeMetadataWithItem
|
||||
this.defaultRenameString = settings.defaultRenameString || "$bookAuthor/$bookTitle"
|
||||
this.metadataFileFormat = settings.metadataFileFormat || 'json'
|
||||
|
||||
this.rateLimitLoginRequests = !isNaN(settings.rateLimitLoginRequests) ? Number(settings.rateLimitLoginRequests) : 10
|
||||
this.rateLimitLoginWindow = !isNaN(settings.rateLimitLoginWindow) ? Number(settings.rateLimitLoginWindow) : 10 * 60 * 1000 // 10 Minutes
|
||||
|
|
@ -98,6 +100,7 @@ class ServerSettings {
|
|||
this.chromecastEnabled = !!settings.chromecastEnabled
|
||||
this.enableEReader = !!settings.enableEReader
|
||||
this.dateFormat = settings.dateFormat || 'MM/dd/yyyy'
|
||||
this.timeFormat = settings.timeFormat || 'HH:mm'
|
||||
this.language = settings.language || 'en-us'
|
||||
this.logLevel = settings.logLevel || Logger.logLevel
|
||||
this.version = settings.version || null
|
||||
|
|
@ -112,6 +115,16 @@ class ServerSettings {
|
|||
if (settings.homeBookshelfView == undefined) { // homeBookshelfView was added in 2.1.3
|
||||
this.homeBookshelfView = settings.bookshelfView
|
||||
}
|
||||
if (settings.metadataFileFormat == undefined) { // metadataFileFormat was added in 2.2.21
|
||||
// All users using old settings will stay abs until changed
|
||||
this.metadataFileFormat = 'abs'
|
||||
}
|
||||
|
||||
// Validation
|
||||
if (!['abs', 'json'].includes(this.metadataFileFormat)) {
|
||||
Logger.error(`[ServerSettings] construct: Invalid metadataFileFormat ${this.metadataFileFormat}`)
|
||||
this.metadataFileFormat = 'json'
|
||||
}
|
||||
|
||||
if (this.logLevel !== Logger.logLevel) {
|
||||
Logger.setLogLevel(this.logLevel)
|
||||
|
|
@ -134,6 +147,7 @@ class ServerSettings {
|
|||
storeCoverWithItem: this.storeCoverWithItem,
|
||||
storeMetadataWithItem: this.storeMetadataWithItem,
|
||||
defaultRenameString: this.defaultRenameString,
|
||||
metadataFileFormat: this.metadataFileFormat,
|
||||
rateLimitLoginRequests: this.rateLimitLoginRequests,
|
||||
rateLimitLoginWindow: this.rateLimitLoginWindow,
|
||||
backupSchedule: this.backupSchedule,
|
||||
|
|
@ -149,6 +163,7 @@ class ServerSettings {
|
|||
chromecastEnabled: this.chromecastEnabled,
|
||||
enableEReader: this.enableEReader,
|
||||
dateFormat: this.dateFormat,
|
||||
timeFormat: this.timeFormat,
|
||||
language: this.language,
|
||||
logLevel: this.logLevel,
|
||||
version: this.version
|
||||
|
|
@ -181,4 +196,4 @@ class ServerSettings {
|
|||
return hasUpdates
|
||||
}
|
||||
}
|
||||
module.exports = ServerSettings
|
||||
module.exports = ServerSettings
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ class MediaProgress {
|
|||
this.isFinished = false
|
||||
this.hideFromContinueListening = false
|
||||
|
||||
this.ebookLocation = null // cfi tag for epub, page number for pdf
|
||||
this.ebookProgress = null // 0 to 1
|
||||
|
||||
this.lastUpdate = null
|
||||
this.startedAt = null
|
||||
this.finishedAt = null
|
||||
|
|
@ -29,6 +32,8 @@ class MediaProgress {
|
|||
currentTime: this.currentTime,
|
||||
isFinished: this.isFinished,
|
||||
hideFromContinueListening: this.hideFromContinueListening,
|
||||
ebookLocation: this.ebookLocation,
|
||||
ebookProgress: this.ebookProgress,
|
||||
lastUpdate: this.lastUpdate,
|
||||
startedAt: this.startedAt,
|
||||
finishedAt: this.finishedAt
|
||||
|
|
@ -41,16 +46,18 @@ class MediaProgress {
|
|||
this.episodeId = progress.episodeId
|
||||
this.duration = progress.duration || 0
|
||||
this.progress = progress.progress
|
||||
this.currentTime = progress.currentTime
|
||||
this.currentTime = progress.currentTime || 0
|
||||
this.isFinished = !!progress.isFinished
|
||||
this.hideFromContinueListening = !!progress.hideFromContinueListening
|
||||
this.ebookLocation = progress.ebookLocation || null
|
||||
this.ebookProgress = progress.ebookProgress || null
|
||||
this.lastUpdate = progress.lastUpdate
|
||||
this.startedAt = progress.startedAt
|
||||
this.finishedAt = progress.finishedAt || null
|
||||
}
|
||||
|
||||
get inProgress() {
|
||||
return !this.isFinished && this.progress > 0
|
||||
return !this.isFinished && (this.progress > 0 || (this.ebookLocation != null && this.ebookProgress > 0))
|
||||
}
|
||||
|
||||
setData(libraryItemId, progress, episodeId = null) {
|
||||
|
|
@ -62,6 +69,8 @@ class MediaProgress {
|
|||
this.currentTime = progress.currentTime || 0
|
||||
this.isFinished = !!progress.isFinished || this.progress == 1
|
||||
this.hideFromContinueListening = !!progress.hideFromContinueListening
|
||||
this.ebookLocation = progress.ebookLocation
|
||||
this.ebookProgress = Math.min(1, (progress.ebookProgress || 0))
|
||||
this.lastUpdate = Date.now()
|
||||
this.finishedAt = null
|
||||
if (this.isFinished) {
|
||||
|
|
|
|||
|
|
@ -18,10 +18,9 @@ class User {
|
|||
this.seriesHideFromContinueListening = [] // Series IDs that should not show on home page continue listening
|
||||
this.bookmarks = []
|
||||
|
||||
this.settings = {} // TODO: Remove after mobile release v0.9.61-beta
|
||||
this.permissions = {}
|
||||
this.librariesAccessible = [] // Library IDs (Empty if ALL libraries)
|
||||
this.itemTagsAccessible = [] // Empty if ALL item tags accessible
|
||||
this.itemTagsSelected = [] // Empty if ALL item tags accessible
|
||||
|
||||
if (user) {
|
||||
this.construct(user)
|
||||
|
|
@ -59,15 +58,6 @@ class User {
|
|||
return !!this.pash && !!this.pash.length
|
||||
}
|
||||
|
||||
// TODO: Remove after mobile release v0.9.61-beta
|
||||
getDefaultUserSettings() {
|
||||
return {
|
||||
mobileOrderBy: 'recent',
|
||||
mobileOrderDesc: true,
|
||||
mobileFilterBy: 'all'
|
||||
}
|
||||
}
|
||||
|
||||
getDefaultUserPermissions() {
|
||||
return {
|
||||
download: true,
|
||||
|
|
@ -94,19 +84,18 @@ class User {
|
|||
isLocked: this.isLocked,
|
||||
lastSeen: this.lastSeen,
|
||||
createdAt: this.createdAt,
|
||||
settings: this.settings, // TODO: Remove after mobile release v0.9.61-beta
|
||||
permissions: this.permissions,
|
||||
librariesAccessible: [...this.librariesAccessible],
|
||||
itemTagsAccessible: [...this.itemTagsAccessible]
|
||||
itemTagsSelected: [...this.itemTagsSelected]
|
||||
}
|
||||
}
|
||||
|
||||
toJSONForBrowser() {
|
||||
return {
|
||||
toJSONForBrowser(hideRootToken = false, minimal = false) {
|
||||
const json = {
|
||||
id: this.id,
|
||||
username: this.username,
|
||||
type: this.type,
|
||||
token: this.token,
|
||||
token: (this.type === 'root' && hideRootToken) ? '' : this.token,
|
||||
mediaProgress: this.mediaProgress ? this.mediaProgress.map(li => li.toJSON()) : [],
|
||||
seriesHideFromContinueListening: [...this.seriesHideFromContinueListening],
|
||||
bookmarks: this.bookmarks ? this.bookmarks.map(b => b.toJSON()) : [],
|
||||
|
|
@ -114,11 +103,15 @@ class User {
|
|||
isLocked: this.isLocked,
|
||||
lastSeen: this.lastSeen,
|
||||
createdAt: this.createdAt,
|
||||
settings: this.settings, // TODO: Remove after mobile release v0.9.61-beta
|
||||
permissions: this.permissions,
|
||||
librariesAccessible: [...this.librariesAccessible],
|
||||
itemTagsAccessible: [...this.itemTagsAccessible]
|
||||
itemTagsSelected: [...this.itemTagsSelected]
|
||||
}
|
||||
if (minimal) {
|
||||
delete json.mediaProgress
|
||||
delete json.bookmarks
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
// Data broadcasted
|
||||
|
|
@ -166,7 +159,6 @@ class User {
|
|||
this.isLocked = user.type === 'root' ? false : !!user.isLocked
|
||||
this.lastSeen = user.lastSeen || null
|
||||
this.createdAt = user.createdAt || Date.now()
|
||||
this.settings = user.settings || this.getDefaultUserSettings() // TODO: Remove after mobile release v0.9.61-beta
|
||||
this.permissions = user.permissions || this.getDefaultUserPermissions()
|
||||
// Upload permission added v1.1.13, make sure root user has upload permissions
|
||||
if (this.type === 'root' && !this.permissions.upload) this.permissions.upload = true
|
||||
|
|
@ -177,9 +169,14 @@ class User {
|
|||
if (this.permissions.accessAllTags === undefined) this.permissions.accessAllTags = true
|
||||
// Explicit content restriction permission added v2.0.18
|
||||
if (this.permissions.accessExplicitContent === undefined) this.permissions.accessExplicitContent = true
|
||||
// itemTagsAccessible was renamed to itemTagsSelected in version v2.2.20
|
||||
if (user.itemTagsAccessible?.length) {
|
||||
this.permissions.selectedTagsNotAccessible = false
|
||||
user.itemTagsSelected = user.itemTagsAccessible
|
||||
}
|
||||
|
||||
this.librariesAccessible = [...(user.librariesAccessible || [])]
|
||||
this.itemTagsAccessible = [...(user.itemTagsAccessible || [])]
|
||||
this.itemTagsSelected = [...(user.itemTagsSelected || [])]
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
|
|
@ -236,19 +233,21 @@ class User {
|
|||
// Update accessible tags
|
||||
if (this.permissions.accessAllTags) {
|
||||
// Access all tags
|
||||
if (this.itemTagsAccessible.length) {
|
||||
this.itemTagsAccessible = []
|
||||
if (this.itemTagsSelected.length) {
|
||||
this.itemTagsSelected = []
|
||||
this.permissions.selectedTagsNotAccessible = false
|
||||
hasUpdates = true
|
||||
}
|
||||
} else if (payload.itemTagsAccessible !== undefined) {
|
||||
if (payload.itemTagsAccessible.length) {
|
||||
if (payload.itemTagsAccessible.join(',') !== this.itemTagsAccessible.join(',')) {
|
||||
} else if (payload.itemTagsSelected !== undefined) {
|
||||
if (payload.itemTagsSelected.length) {
|
||||
if (payload.itemTagsSelected.join(',') !== this.itemTagsSelected.join(',')) {
|
||||
hasUpdates = true
|
||||
this.itemTagsAccessible = [...payload.itemTagsAccessible]
|
||||
this.itemTagsSelected = [...payload.itemTagsSelected]
|
||||
}
|
||||
} else if (this.itemTagsAccessible.length > 0) {
|
||||
} else if (this.itemTagsSelected.length > 0) {
|
||||
hasUpdates = true
|
||||
this.itemTagsAccessible = []
|
||||
this.itemTagsSelected = []
|
||||
this.permissions.selectedTagsNotAccessible = false
|
||||
}
|
||||
}
|
||||
return hasUpdates
|
||||
|
|
@ -343,33 +342,6 @@ class User {
|
|||
return true
|
||||
}
|
||||
|
||||
// TODO: Remove after mobile release v0.9.61-beta
|
||||
// Returns Boolean If update was made
|
||||
updateSettings(settings) {
|
||||
if (!this.settings) {
|
||||
this.settings = { ...settings }
|
||||
return true
|
||||
}
|
||||
var madeUpdates = false
|
||||
|
||||
for (const key in this.settings) {
|
||||
if (settings[key] !== undefined && this.settings[key] !== settings[key]) {
|
||||
this.settings[key] = settings[key]
|
||||
madeUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check if new settings update has keys not currently in user settings
|
||||
for (const key in settings) {
|
||||
if (settings[key] !== undefined && this.settings[key] === undefined) {
|
||||
this.settings[key] = settings[key]
|
||||
madeUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
return madeUpdates
|
||||
}
|
||||
|
||||
checkCanAccessLibrary(libraryId) {
|
||||
if (this.permissions.accessAllLibraries) return true
|
||||
if (!this.librariesAccessible) return false
|
||||
|
|
@ -378,8 +350,12 @@ class User {
|
|||
|
||||
checkCanAccessLibraryItemWithTags(tags) {
|
||||
if (this.permissions.accessAllTags) return true
|
||||
if (!tags || !tags.length) return false
|
||||
return this.itemTagsAccessible.some(tag => tags.includes(tag))
|
||||
if (this.permissions.selectedTagsNotAccessible) {
|
||||
if (!tags?.length) return true
|
||||
return tags.every(tag => !this.itemTagsSelected.includes(tag))
|
||||
}
|
||||
if (!tags?.length) return false
|
||||
return this.itemTagsSelected.some(tag => tags.includes(tag))
|
||||
}
|
||||
|
||||
checkCanAccessLibraryItem(libraryItem) {
|
||||
|
|
|
|||
|
|
@ -13,13 +13,13 @@ class Audible {
|
|||
'de': '.de',
|
||||
'jp': '.co.jp',
|
||||
'it': '.it',
|
||||
'in': '.co.in',
|
||||
'in': '.in',
|
||||
'es': '.es'
|
||||
}
|
||||
}
|
||||
|
||||
cleanResult(item) {
|
||||
const { title, subtitle, asin, authors, narrators, publisherName, summary, releaseDate, image, genres, seriesPrimary, seriesSecondary, language, runtimeLengthMin } = item
|
||||
const { title, subtitle, asin, authors, narrators, publisherName, summary, releaseDate, image, genres, seriesPrimary, seriesSecondary, language, runtimeLengthMin, formatType } = item
|
||||
|
||||
const series = []
|
||||
if (seriesPrimary) {
|
||||
|
|
@ -54,7 +54,8 @@ class Audible {
|
|||
language: language ? language.charAt(0).toUpperCase() + language.slice(1) : null,
|
||||
duration: runtimeLengthMin && !isNaN(runtimeLengthMin) ? Number(runtimeLengthMin) : 0,
|
||||
region: item.region || null,
|
||||
rating: item.rating || null
|
||||
rating: item.rating || null,
|
||||
abridged: formatType === 'abridged'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
23
server/providers/AudiobookCovers.js
Normal file
23
server/providers/AudiobookCovers.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
const axios = require('axios')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
class AudiobookCovers {
|
||||
constructor() { }
|
||||
|
||||
async search(search) {
|
||||
const url = `https://api.audiobookcovers.com/cover/bytext/`
|
||||
const params = new URLSearchParams([['q', search]])
|
||||
const items = await axios.get(url, { params }).then((res) => {
|
||||
if (!res || !res.data) return []
|
||||
return res.data
|
||||
}).catch(error => {
|
||||
Logger.error('[AudiobookCovers] Cover search error', error)
|
||||
return []
|
||||
})
|
||||
return items.map(item => ({ cover: item.filename }))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
module.exports = AudiobookCovers
|
||||
|
|
@ -7,9 +7,12 @@ class Audnexus {
|
|||
this.baseUrl = 'https://api.audnex.us'
|
||||
}
|
||||
|
||||
authorASINsRequest(name) {
|
||||
name = encodeURIComponent(name);
|
||||
return axios.get(`${this.baseUrl}/authors?name=${name}`).then((res) => {
|
||||
authorASINsRequest(name, region) {
|
||||
name = encodeURIComponent(name)
|
||||
const regionQuery = region ? `®ion=${region}` : ''
|
||||
const authorRequestUrl = `${this.baseUrl}/authors?name=${name}${regionQuery}`
|
||||
Logger.info(`[Audnexus] Searching for author "${authorRequestUrl}"`)
|
||||
return axios.get(authorRequestUrl).then((res) => {
|
||||
return res.data || []
|
||||
}).catch((error) => {
|
||||
Logger.error(`[Audnexus] Author ASIN request failed for ${name}`, error)
|
||||
|
|
@ -17,9 +20,12 @@ class Audnexus {
|
|||
})
|
||||
}
|
||||
|
||||
authorRequest(asin) {
|
||||
asin = encodeURIComponent(asin);
|
||||
return axios.get(`${this.baseUrl}/authors/${asin}`).then((res) => {
|
||||
authorRequest(asin, region) {
|
||||
asin = encodeURIComponent(asin)
|
||||
const regionQuery = region ? `?region=${region}` : ''
|
||||
const authorRequestUrl = `${this.baseUrl}/authors/${asin}${regionQuery}`
|
||||
Logger.info(`[Audnexus] Searching for author "${authorRequestUrl}"`)
|
||||
return axios.get(authorRequestUrl).then((res) => {
|
||||
return res.data
|
||||
}).catch((error) => {
|
||||
Logger.error(`[Audnexus] Author request failed for ${asin}`, error)
|
||||
|
|
@ -27,8 +33,8 @@ class Audnexus {
|
|||
})
|
||||
}
|
||||
|
||||
async findAuthorByASIN(asin) {
|
||||
var author = await this.authorRequest(asin)
|
||||
async findAuthorByASIN(asin, region) {
|
||||
const author = await this.authorRequest(asin, region)
|
||||
if (!author) {
|
||||
return null
|
||||
}
|
||||
|
|
@ -40,14 +46,14 @@ class Audnexus {
|
|||
}
|
||||
}
|
||||
|
||||
async findAuthorByName(name, maxLevenshtein = 3) {
|
||||
async findAuthorByName(name, region, maxLevenshtein = 3) {
|
||||
Logger.debug(`[Audnexus] Looking up author by name ${name}`)
|
||||
var asins = await this.authorASINsRequest(name)
|
||||
var matchingAsin = asins.find(obj => levenshteinDistance(obj.name, name) <= maxLevenshtein)
|
||||
const asins = await this.authorASINsRequest(name, region)
|
||||
const matchingAsin = asins.find(obj => levenshteinDistance(obj.name, name) <= maxLevenshtein)
|
||||
if (!matchingAsin) {
|
||||
return null
|
||||
}
|
||||
var author = await this.authorRequest(matchingAsin.asin)
|
||||
const author = await this.authorRequest(matchingAsin.asin)
|
||||
if (!author) {
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,7 +95,8 @@ class iTunes {
|
|||
cover: this.getCoverArtwork(data),
|
||||
trackCount: data.trackCount,
|
||||
feedUrl: data.feedUrl,
|
||||
pageUrl: data.collectionViewUrl
|
||||
pageUrl: data.collectionViewUrl,
|
||||
explicit: data.trackExplicitness === 'explicit'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -105,4 +106,4 @@ class iTunes {
|
|||
})
|
||||
}
|
||||
}
|
||||
module.exports = iTunes
|
||||
module.exports = iTunes
|
||||
|
|
|
|||
|
|
@ -20,11 +20,11 @@ const AuthorController = require('../controllers/AuthorController')
|
|||
const SessionController = require('../controllers/SessionController')
|
||||
const PodcastController = require('../controllers/PodcastController')
|
||||
const NotificationController = require('../controllers/NotificationController')
|
||||
const EmailController = require('../controllers/EmailController')
|
||||
const SearchController = require('../controllers/SearchController')
|
||||
const CacheController = require('../controllers/CacheController')
|
||||
const ToolsController = require('../controllers/ToolsController')
|
||||
const RSSFeedController = require('../controllers/RSSFeedController')
|
||||
const EBookController = require('../controllers/EBookController')
|
||||
const MiscController = require('../controllers/MiscController')
|
||||
|
||||
const BookFinder = require('../finders/BookFinder')
|
||||
|
|
@ -52,8 +52,8 @@ class ApiRouter {
|
|||
this.rssFeedManager = Server.rssFeedManager
|
||||
this.cronManager = Server.cronManager
|
||||
this.notificationManager = Server.notificationManager
|
||||
this.emailManager = Server.emailManager
|
||||
this.taskManager = Server.taskManager
|
||||
this.eBookManager = Server.eBookManager
|
||||
|
||||
this.bookFinder = new BookFinder()
|
||||
this.authorFinder = new AuthorFinder()
|
||||
|
|
@ -77,6 +77,7 @@ class ApiRouter {
|
|||
|
||||
this.router.get('/libraries/:id/items', LibraryController.middleware.bind(this), LibraryController.getLibraryItems.bind(this))
|
||||
this.router.delete('/libraries/:id/issues', LibraryController.middleware.bind(this), LibraryController.removeLibraryItemsWithIssues.bind(this))
|
||||
this.router.get('/libraries/:id/episode-downloads', LibraryController.middleware.bind(this), LibraryController.getEpisodeDownloadQueue.bind(this))
|
||||
this.router.get('/libraries/:id/series', LibraryController.middleware.bind(this), LibraryController.getAllSeriesForLibrary.bind(this))
|
||||
this.router.get('/libraries/:id/collections', LibraryController.middleware.bind(this), LibraryController.getCollectionsForLibrary.bind(this))
|
||||
this.router.get('/libraries/:id/playlists', LibraryController.middleware.bind(this), LibraryController.getUserPlaylistsForLibrary.bind(this))
|
||||
|
|
@ -86,20 +87,29 @@ class ApiRouter {
|
|||
this.router.get('/libraries/:id/search', LibraryController.middleware.bind(this), LibraryController.search.bind(this))
|
||||
this.router.get('/libraries/:id/stats', LibraryController.middleware.bind(this), LibraryController.stats.bind(this))
|
||||
this.router.get('/libraries/:id/authors', LibraryController.middleware.bind(this), LibraryController.getAuthors.bind(this))
|
||||
this.router.get('/libraries/:id/narrators', LibraryController.middleware.bind(this), LibraryController.getNarrators.bind(this))
|
||||
this.router.patch('/libraries/:id/narrators/:narratorId', LibraryController.middleware.bind(this), LibraryController.updateNarrator.bind(this))
|
||||
this.router.delete('/libraries/:id/narrators/:narratorId', LibraryController.middleware.bind(this), LibraryController.removeNarrator.bind(this))
|
||||
this.router.get('/libraries/:id/matchall', LibraryController.middleware.bind(this), LibraryController.matchAll.bind(this))
|
||||
this.router.post('/libraries/:id/scan', LibraryController.middleware.bind(this), LibraryController.scan.bind(this))
|
||||
this.router.get('/libraries/:id/recent-episodes', LibraryController.middleware.bind(this), LibraryController.getRecentEpisodes.bind(this))
|
||||
|
||||
this.router.get('/libraries/:id/opml', LibraryController.middleware.bind(this), LibraryController.getOPMLFile.bind(this))
|
||||
this.router.post('/libraries/order', LibraryController.reorder.bind(this))
|
||||
|
||||
//
|
||||
// Item Routes
|
||||
//
|
||||
this.router.post('/items/batch/delete', LibraryItemController.batchDelete.bind(this))
|
||||
this.router.post('/items/batch/update', LibraryItemController.batchUpdate.bind(this))
|
||||
this.router.post('/items/batch/get', LibraryItemController.batchGet.bind(this))
|
||||
this.router.post('/items/batch/quickmatch', LibraryItemController.batchQuickMatch.bind(this))
|
||||
this.router.post('/items/batch/scan', LibraryItemController.batchScan.bind(this))
|
||||
this.router.delete('/items/all', LibraryItemController.deleteAll.bind(this))
|
||||
|
||||
this.router.get('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.findOne.bind(this))
|
||||
this.router.patch('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.update.bind(this))
|
||||
this.router.delete('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.delete.bind(this))
|
||||
this.router.get('/items/:id/download', LibraryItemController.middleware.bind(this), LibraryItemController.download.bind(this))
|
||||
this.router.patch('/items/:id/media', LibraryItemController.middleware.bind(this), LibraryItemController.updateMedia.bind(this))
|
||||
this.router.get('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.getCover.bind(this))
|
||||
this.router.post('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.uploadCover.bind(this))
|
||||
|
|
@ -113,11 +123,10 @@ class ApiRouter {
|
|||
this.router.get('/items/:id/tone-object', LibraryItemController.middleware.bind(this), LibraryItemController.getToneMetadataObject.bind(this))
|
||||
this.router.post('/items/:id/chapters', LibraryItemController.middleware.bind(this), LibraryItemController.updateMediaChapters.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))
|
||||
this.router.get('/items/:id/file/:fileid', LibraryItemController.middleware.bind(this), LibraryItemController.getLibraryFile.bind(this))
|
||||
this.router.delete('/items/:id/file/:fileid', LibraryItemController.middleware.bind(this), LibraryItemController.deleteLibraryFile.bind(this))
|
||||
this.router.get('/items/:id/file/:fileid/download', LibraryItemController.middleware.bind(this), LibraryItemController.downloadLibraryFile.bind(this))
|
||||
this.router.get('/items/:id/ebook', LibraryItemController.middleware.bind(this), LibraryItemController.getEBookFile.bind(this))
|
||||
|
||||
//
|
||||
// User Routes
|
||||
|
|
@ -176,7 +185,6 @@ class ApiRouter {
|
|||
this.router.patch('/me/item/:id/bookmark', MeController.updateBookmark.bind(this))
|
||||
this.router.delete('/me/item/:id/bookmark/:time', MeController.removeBookmark.bind(this))
|
||||
this.router.patch('/me/password', MeController.updatePassword.bind(this))
|
||||
this.router.patch('/me/settings', MeController.updateSettings.bind(this)) // TODO: Deprecated. Remove after mobile release v0.9.61-beta
|
||||
this.router.post('/me/sync-local-progress', MeController.syncLocalMediaProgress.bind(this)) // TODO: Deprecated. Removed from Android. Only used in iOS app now.
|
||||
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))
|
||||
|
|
@ -195,6 +203,7 @@ class ApiRouter {
|
|||
// File System Routes
|
||||
//
|
||||
this.router.get('/filesystem', FileSystemController.getPaths.bind(this))
|
||||
this.router.post('/filesystem/pathexists', FileSystemController.checkPathExists.bind(this))
|
||||
|
||||
//
|
||||
// Author Routes
|
||||
|
|
@ -217,6 +226,7 @@ class ApiRouter {
|
|||
//
|
||||
this.router.get('/sessions', SessionController.getAllWithUserData.bind(this))
|
||||
this.router.delete('/sessions/:id', SessionController.middleware.bind(this), SessionController.delete.bind(this))
|
||||
this.router.get('/sessions/open', SessionController.getOpenSessions.bind(this))
|
||||
this.router.post('/session/local', SessionController.syncLocal.bind(this))
|
||||
this.router.post('/session/local-all', SessionController.syncLocalSessions.bind(this))
|
||||
// TODO: Update these endpoints because they are only for open playback sessions
|
||||
|
|
@ -229,13 +239,14 @@ class ApiRouter {
|
|||
//
|
||||
this.router.post('/podcasts', PodcastController.create.bind(this))
|
||||
this.router.post('/podcasts/feed', PodcastController.getPodcastFeed.bind(this))
|
||||
this.router.post('/podcasts/opml', PodcastController.getOPMLFeeds.bind(this))
|
||||
this.router.post('/podcasts/opml', PodcastController.getFeedsFromOPMLText.bind(this))
|
||||
this.router.get('/podcasts/:id/checknew', PodcastController.middleware.bind(this), PodcastController.checkNewEpisodes.bind(this))
|
||||
this.router.get('/podcasts/:id/downloads', PodcastController.middleware.bind(this), PodcastController.getEpisodeDownloads.bind(this))
|
||||
this.router.get('/podcasts/:id/clear-queue', PodcastController.middleware.bind(this), PodcastController.clearEpisodeDownloadQueue.bind(this))
|
||||
this.router.get('/podcasts/:id/search-episode', PodcastController.middleware.bind(this), PodcastController.findEpisode.bind(this))
|
||||
this.router.post('/podcasts/:id/download-episodes', PodcastController.middleware.bind(this), PodcastController.downloadEpisodes.bind(this))
|
||||
this.router.post('/podcasts/:id/match-episodes', PodcastController.middleware.bind(this), PodcastController.quickMatchEpisodes.bind(this))
|
||||
this.router.get('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.getEpisode.bind(this))
|
||||
this.router.patch('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.updateEpisode.bind(this))
|
||||
this.router.delete('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.removeEpisode.bind(this))
|
||||
|
||||
|
|
@ -251,6 +262,15 @@ class ApiRouter {
|
|||
this.router.patch('/notifications/:id', NotificationController.middleware.bind(this), NotificationController.updateNotification.bind(this))
|
||||
this.router.get('/notifications/:id/test', NotificationController.middleware.bind(this), NotificationController.sendNotificationTest.bind(this))
|
||||
|
||||
//
|
||||
// Email Routes (Admin and up)
|
||||
//
|
||||
this.router.get('/emails/settings', EmailController.middleware.bind(this), EmailController.getSettings.bind(this))
|
||||
this.router.patch('/emails/settings', EmailController.middleware.bind(this), EmailController.updateSettings.bind(this))
|
||||
this.router.post('/emails/test', EmailController.middleware.bind(this), EmailController.sendTest.bind(this))
|
||||
this.router.post('/emails/ereader-devices', EmailController.middleware.bind(this), EmailController.updateEReaderDevices.bind(this))
|
||||
this.router.post('/emails/send-ebook-to-device', EmailController.middleware.bind(this), EmailController.sendEBookToDevice.bind(this))
|
||||
|
||||
//
|
||||
// Search Routes
|
||||
//
|
||||
|
|
@ -270,9 +290,10 @@ class ApiRouter {
|
|||
//
|
||||
// Tools Routes (Admin and up)
|
||||
//
|
||||
this.router.post('/tools/item/:id/encode-m4b', ToolsController.itemMiddleware.bind(this), ToolsController.encodeM4b.bind(this))
|
||||
this.router.delete('/tools/item/:id/encode-m4b', ToolsController.itemMiddleware.bind(this), ToolsController.cancelM4bEncode.bind(this))
|
||||
this.router.post('/tools/item/:id/embed-metadata', ToolsController.itemMiddleware.bind(this), ToolsController.embedAudioFileMetadata.bind(this))
|
||||
this.router.post('/tools/item/:id/encode-m4b', ToolsController.middleware.bind(this), ToolsController.encodeM4b.bind(this))
|
||||
this.router.delete('/tools/item/:id/encode-m4b', ToolsController.middleware.bind(this), ToolsController.cancelM4bEncode.bind(this))
|
||||
this.router.post('/tools/item/:id/embed-metadata', ToolsController.middleware.bind(this), ToolsController.embedAudioFileMetadata.bind(this))
|
||||
this.router.post('/tools/batch/embed-metadata', ToolsController.middleware.bind(this), ToolsController.batchEmbedMetadata.bind(this))
|
||||
this.router.post('/tools/item/:id/renameFolder', ToolsController.itemMiddleware.bind(this), ToolsController.renameBookFolder.bind(this))
|
||||
this.router.get('/tools/item/:id/renameFolder', ToolsController.itemMiddleware.bind(this), ToolsController.testRenameBookFolder.bind(this))
|
||||
|
||||
|
|
@ -284,13 +305,6 @@ class ApiRouter {
|
|||
this.router.post('/feeds/series/:seriesId/open', RSSFeedController.middleware.bind(this), RSSFeedController.openRSSFeedForSeries.bind(this))
|
||||
this.router.post('/feeds/:id/close', RSSFeedController.middleware.bind(this), RSSFeedController.closeRSSFeed.bind(this))
|
||||
|
||||
//
|
||||
// EBook Routes
|
||||
//
|
||||
this.router.get('/ebooks/:id/info', EBookController.middleware.bind(this), EBookController.getEbookInfo.bind(this))
|
||||
this.router.get('/ebooks/:id/page/:page', EBookController.middleware.bind(this), EBookController.getEbookPage.bind(this))
|
||||
this.router.get('/ebooks/:id/resource', EBookController.middleware.bind(this), EBookController.getEbookResource.bind(this))
|
||||
|
||||
//
|
||||
// Misc Routes
|
||||
//
|
||||
|
|
@ -340,10 +354,7 @@ class ApiRouter {
|
|||
// Helper Methods
|
||||
//
|
||||
userJsonWithItemProgressDetails(user, hideRootToken = false) {
|
||||
const json = user.toJSONForBrowser()
|
||||
if (json.type === 'root' && hideRootToken) {
|
||||
json.token = ''
|
||||
}
|
||||
const json = user.toJSONForBrowser(hideRootToken)
|
||||
|
||||
json.mediaProgress = json.mediaProgress.map(lip => {
|
||||
const libraryItem = this.db.libraryItems.find(li => li.id === lip.libraryItemId)
|
||||
|
|
@ -420,6 +431,12 @@ class ApiRouter {
|
|||
await this.cacheManager.purgeCoverCache(libraryItem.id)
|
||||
}
|
||||
|
||||
const itemMetadataPath = Path.join(global.MetadataPath, 'items', libraryItem.id)
|
||||
if (await fs.pathExists(itemMetadataPath)) {
|
||||
Logger.debug(`[ApiRouter] Removing item metadata path "${itemMetadataPath}"`)
|
||||
await fs.remove(itemMetadataPath)
|
||||
}
|
||||
|
||||
await this.db.removeLibraryItem(libraryItem.id)
|
||||
SocketAuthority.emitter('item_removed', libraryItem.toJSONExpanded())
|
||||
}
|
||||
|
|
@ -508,11 +525,16 @@ class ApiRouter {
|
|||
|
||||
// Create new authors if in payload
|
||||
if (mediaMetadata.authors && mediaMetadata.authors.length) {
|
||||
// TODO: validate authors
|
||||
const newAuthors = []
|
||||
for (let i = 0; i < mediaMetadata.authors.length; i++) {
|
||||
if (mediaMetadata.authors[i].id.startsWith('new')) {
|
||||
let author = this.db.authors.find(au => au.checkNameEquals(mediaMetadata.authors[i].name))
|
||||
const authorName = (mediaMetadata.authors[i].name || '').trim()
|
||||
if (!authorName) {
|
||||
Logger.error(`[ApiRouter] Invalid author object, no name`, mediaMetadata.authors[i])
|
||||
continue
|
||||
}
|
||||
|
||||
if (!mediaMetadata.authors[i].id || mediaMetadata.authors[i].id.startsWith('new')) {
|
||||
let author = this.db.authors.find(au => au.checkNameEquals(authorName))
|
||||
if (!author) {
|
||||
author = new Author()
|
||||
author.setData(mediaMetadata.authors[i])
|
||||
|
|
@ -532,11 +554,16 @@ class ApiRouter {
|
|||
|
||||
// Create new series if in payload
|
||||
if (mediaMetadata.series && mediaMetadata.series.length) {
|
||||
// TODO: validate series
|
||||
const newSeries = []
|
||||
for (let i = 0; i < mediaMetadata.series.length; i++) {
|
||||
if (mediaMetadata.series[i].id.startsWith('new')) {
|
||||
let seriesItem = this.db.series.find(se => se.checkNameEquals(mediaMetadata.series[i].name))
|
||||
const seriesName = (mediaMetadata.series[i].name || '').trim()
|
||||
if (!seriesName) {
|
||||
Logger.error(`[ApiRouter] Invalid series object, no name`, mediaMetadata.series[i])
|
||||
continue
|
||||
}
|
||||
|
||||
if (!mediaMetadata.series[i].id || mediaMetadata.series[i].id.startsWith('new')) {
|
||||
let seriesItem = this.db.series.find(se => se.checkNameEquals(seriesName))
|
||||
if (!seriesItem) {
|
||||
seriesItem = new Series()
|
||||
seriesItem.setData(mediaMetadata.series[i])
|
||||
|
|
@ -556,4 +583,4 @@ class ApiRouter {
|
|||
}
|
||||
}
|
||||
}
|
||||
module.exports = ApiRouter
|
||||
module.exports = ApiRouter
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ const Path = require('path')
|
|||
const Logger = require('../Logger')
|
||||
const { getAudioMimeTypeFromExtname } = require('../utils/fileUtils')
|
||||
|
||||
// TODO: Deprecated as of 2.2.21 edge
|
||||
class StaticRouter {
|
||||
constructor(db) {
|
||||
this.db = db
|
||||
|
|
@ -18,7 +19,22 @@ class StaticRouter {
|
|||
const item = this.db.libraryItems.find(ab => ab.id === req.params.id)
|
||||
if (!item) return res.status(404).send('Item not found with id ' + req.params.id)
|
||||
|
||||
const remainingPath = req.params['0']
|
||||
// Replace backslashes with forward slashes
|
||||
const remainingPath = req.params['0'].replace(/\\/g, '/')
|
||||
|
||||
// Check user has access to this library item
|
||||
if (!req.user.checkCanAccessLibraryItem(item)) {
|
||||
Logger.error(`[StaticRouter] User attempted to access library item file without access ${remainingPath}`, req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
// Prevent path traversal
|
||||
// e.g. ../../etc/passwd
|
||||
if (/\/?\.?\.\//.test(remainingPath)) {
|
||||
Logger.error(`[StaticRouter] Invalid path to get library item file "${remainingPath}"`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const fullPath = item.isFile ? item.path : Path.join(item.path, remainingPath)
|
||||
|
||||
// Allow reverse proxy to serve files directly
|
||||
|
|
@ -28,7 +44,7 @@ class StaticRouter {
|
|||
return res.status(204).header({ 'X-Accel-Redirect': global.XAccel + fullPath }).send()
|
||||
}
|
||||
|
||||
var opts = {}
|
||||
let opts = {}
|
||||
|
||||
// Express does not set the correct mimetype for m4b files so use our defined mimetypes if available
|
||||
const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(fullPath))
|
||||
|
|
|
|||
|
|
@ -221,7 +221,6 @@ class MediaFileScanner {
|
|||
*/
|
||||
async scanMediaFiles(mediaLibraryFiles, libraryItem, libraryScan = null) {
|
||||
const preferAudioMetadata = libraryScan ? !!libraryScan.preferAudioMetadata : !!global.ServerSettings.scannerPreferAudioMetadata
|
||||
const preferOverdriveMediaMarker = libraryScan ? !!libraryScan.preferOverdriveMediaMarker : !!global.ServerSettings.scannerPreferOverdriveMediaMarker
|
||||
|
||||
let hasUpdated = false
|
||||
|
||||
|
|
@ -280,7 +279,7 @@ class MediaFileScanner {
|
|||
}
|
||||
|
||||
if (hasUpdated) {
|
||||
libraryItem.media.rebuildTracks(preferOverdriveMediaMarker)
|
||||
libraryItem.media.rebuildTracks()
|
||||
}
|
||||
} else if (libraryItem.mediaType === 'podcast') { // Podcast Media Type
|
||||
const existingAudioFiles = mediaScanResult.audioFiles.filter(af => libraryItem.media.findFileWithInode(af.ino))
|
||||
|
|
@ -296,11 +295,17 @@ class MediaFileScanner {
|
|||
|
||||
// Update audio file metadata for audio files already there
|
||||
existingAudioFiles.forEach((af) => {
|
||||
const peAudioFile = libraryItem.media.findFileWithInode(af.ino)
|
||||
if (peAudioFile.updateFromScan && peAudioFile.updateFromScan(af)) {
|
||||
const podcastEpisode = libraryItem.media.findEpisodeWithInode(af.ino)
|
||||
if (podcastEpisode?.audioFile.updateFromScan(af)) {
|
||||
hasUpdated = true
|
||||
|
||||
podcastEpisode.setDataFromAudioMetaTags(podcastEpisode.audioFile.metaTags, false)
|
||||
}
|
||||
})
|
||||
|
||||
if (libraryItem.media.setMetadataFromAudioFile(preferAudioMetadata)) {
|
||||
hasUpdated = true
|
||||
}
|
||||
} else if (libraryItem.mediaType === 'music') { // Music
|
||||
// Only one audio file in library item
|
||||
if (newAudioFiles.length) { // New audio file
|
||||
|
|
|
|||
|
|
@ -19,11 +19,13 @@ const ScanOptions = require('./ScanOptions')
|
|||
|
||||
const Author = require('../objects/entities/Author')
|
||||
const Series = require('../objects/entities/Series')
|
||||
const Task = require('../objects/Task')
|
||||
|
||||
class Scanner {
|
||||
constructor(db, coverManager) {
|
||||
constructor(db, coverManager, taskManager) {
|
||||
this.db = db
|
||||
this.coverManager = coverManager
|
||||
this.taskManager = taskManager
|
||||
|
||||
this.cancelLibraryScan = {}
|
||||
this.librariesScanning = []
|
||||
|
|
@ -46,12 +48,24 @@ class Scanner {
|
|||
this.cancelLibraryScan[libraryId] = true
|
||||
}
|
||||
|
||||
async scanLibraryItemById(libraryItemId) {
|
||||
const libraryItem = this.db.libraryItems.find(li => li.id === libraryItemId)
|
||||
if (!libraryItem) {
|
||||
Logger.error(`[Scanner] Scan libraryItem by id not found ${libraryItemId}`)
|
||||
return ScanResult.NOTHING
|
||||
getScanResultDescription(result) {
|
||||
switch (result) {
|
||||
case ScanResult.ADDED:
|
||||
return 'Added to library'
|
||||
case ScanResult.NOTHING:
|
||||
return 'No updates necessary'
|
||||
case ScanResult.REMOVED:
|
||||
return 'Removed from library'
|
||||
case ScanResult.UPDATED:
|
||||
return 'Item was updated'
|
||||
case ScanResult.UPTODATE:
|
||||
return 'No updates necessary'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
async scanLibraryItemByRequest(libraryItem) {
|
||||
const library = this.db.libraries.find(lib => lib.id === libraryItem.libraryId)
|
||||
if (!library) {
|
||||
Logger.error(`[Scanner] Scan libraryItem by id library not found "${libraryItem.libraryId}"`)
|
||||
|
|
@ -63,12 +77,26 @@ class Scanner {
|
|||
return ScanResult.NOTHING
|
||||
}
|
||||
Logger.info(`[Scanner] Scanning Library Item "${libraryItem.media.metadata.title}"`)
|
||||
return this.scanLibraryItem(library.mediaType, folder, libraryItem)
|
||||
|
||||
const task = new Task()
|
||||
task.setData('scan-item', `Scan ${libraryItem.media.metadata.title}`, '', true, {
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryId: library.id,
|
||||
mediaType: library.mediaType
|
||||
})
|
||||
this.taskManager.addTask(task)
|
||||
|
||||
const result = await this.scanLibraryItem(library.mediaType, folder, libraryItem)
|
||||
|
||||
task.setFinished(this.getScanResultDescription(result))
|
||||
this.taskManager.taskFinished(task)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async scanLibraryItem(libraryMediaType, folder, libraryItem) {
|
||||
// TODO: Support for single media item
|
||||
const libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, libraryItem.path, false, this.db.serverSettings)
|
||||
const libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, libraryItem.path, false)
|
||||
if (!libraryItemData) {
|
||||
return ScanResult.NOTHING
|
||||
}
|
||||
|
|
@ -111,8 +139,8 @@ class Scanner {
|
|||
}
|
||||
|
||||
if (hasUpdated) {
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
await this.db.updateLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
return ScanResult.UPDATED
|
||||
}
|
||||
return ScanResult.UPTODATE
|
||||
|
|
@ -173,7 +201,7 @@ class Scanner {
|
|||
// Scan each library
|
||||
for (let i = 0; i < libraryScan.folders.length; i++) {
|
||||
const folder = libraryScan.folders[i]
|
||||
const itemDataFoundInFolder = await scanFolder(libraryScan.libraryMediaType, folder, this.db.serverSettings)
|
||||
const itemDataFoundInFolder = await scanFolder(libraryScan.libraryMediaType, folder)
|
||||
libraryScan.addLog(LogLevel.INFO, `${itemDataFoundInFolder.length} item data found in folder "${folder.fullPath}"`)
|
||||
libraryItemDataFound = libraryItemDataFound.concat(itemDataFoundInFolder)
|
||||
}
|
||||
|
|
@ -200,10 +228,22 @@ class Scanner {
|
|||
// Find library item folder with matching inode or matching path
|
||||
const dataFound = libraryItemDataFound.find(lid => lid.ino === libraryItem.ino || comparePaths(lid.relPath, libraryItem.relPath))
|
||||
if (!dataFound) {
|
||||
libraryScan.addLog(LogLevel.WARN, `Library Item "${libraryItem.media.metadata.title}" is missing`)
|
||||
libraryScan.resultsMissing++
|
||||
libraryItem.setMissing()
|
||||
itemsToUpdate.push(libraryItem)
|
||||
// Podcast folder can have no episodes and still be valid
|
||||
if (libraryScan.libraryMediaType === 'podcast' && await fs.pathExists(libraryItem.path)) {
|
||||
Logger.info(`[Scanner] Library item "${libraryItem.media.metadata.title}" folder exists but has no episodes`)
|
||||
if (libraryItem.isMissing) {
|
||||
libraryScan.resultsUpdated++
|
||||
libraryItem.isMissing = false
|
||||
libraryItem.setLastScan()
|
||||
itemsToUpdate.push(libraryItem)
|
||||
}
|
||||
} else {
|
||||
libraryScan.addLog(LogLevel.WARN, `Library Item "${libraryItem.media.metadata.title}" is missing`)
|
||||
Logger.warn(`[Scanner] Library item "${libraryItem.media.metadata.title}" is missing (inode "${libraryItem.ino}")`)
|
||||
libraryScan.resultsMissing++
|
||||
libraryItem.setMissing()
|
||||
itemsToUpdate.push(libraryItem)
|
||||
}
|
||||
} else {
|
||||
const checkRes = libraryItem.checkScanData(dataFound)
|
||||
if (checkRes.newLibraryFiles.length || libraryScan.scanOptions.forceRescan) { // Item has new files
|
||||
|
|
@ -631,7 +671,7 @@ class Scanner {
|
|||
}
|
||||
|
||||
async scanPotentialNewLibraryItem(libraryMediaType, folder, fullPath, isSingleMediaItem = false) {
|
||||
const libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, fullPath, isSingleMediaItem, this.db.serverSettings)
|
||||
const libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, fullPath, isSingleMediaItem)
|
||||
if (!libraryItemData) return null
|
||||
return this.scanNewLibraryItem(libraryItemData, libraryMediaType)
|
||||
}
|
||||
|
|
@ -792,7 +832,7 @@ class Scanner {
|
|||
|
||||
async quickMatchBookBuildUpdatePayload(libraryItem, matchData, options) {
|
||||
// Update media metadata if not set OR overrideDetails flag
|
||||
const detailKeysToUpdate = ['title', 'subtitle', 'description', 'narrator', 'publisher', 'publishedYear', 'genres', 'tags', 'language', 'explicit', 'asin', 'isbn']
|
||||
const detailKeysToUpdate = ['title', 'subtitle', 'description', 'narrator', 'publisher', 'publishedYear', 'genres', 'tags', 'language', 'explicit', 'abridged', 'asin', 'isbn']
|
||||
const updatePayload = {}
|
||||
updatePayload.metadata = {}
|
||||
|
||||
|
|
@ -899,7 +939,7 @@ class Scanner {
|
|||
description: episodeToMatch.description || '',
|
||||
enclosure: episodeToMatch.enclosure || null,
|
||||
episode: episodeToMatch.episode || '',
|
||||
episodeType: episodeToMatch.episodeType || '',
|
||||
episodeType: episodeToMatch.episodeType || 'full',
|
||||
season: episodeToMatch.season || '',
|
||||
pubDate: episodeToMatch.pubDate || '',
|
||||
publishedAt: episodeToMatch.publishedAt
|
||||
|
|
@ -993,4 +1033,4 @@ class Scanner {
|
|||
return MediaFileScanner.probeAudioFileWithTone(audioFile)
|
||||
}
|
||||
}
|
||||
module.exports = Scanner
|
||||
module.exports = Scanner
|
||||
|
|
|
|||
|
|
@ -25,6 +25,11 @@ module.exports = function areEquivalent(value1, value2, stack = []) {
|
|||
return true;
|
||||
}
|
||||
|
||||
// Truthy check to handle value1=null, value2=Object
|
||||
if ((value1 && !value2) || (!value1 && value2)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const type1 = typeof value1;
|
||||
|
||||
// Ensure types match
|
||||
|
|
|
|||
|
|
@ -46,7 +46,10 @@ module.exports.AudioMimeType = {
|
|||
WMA: 'audio/x-ms-wma',
|
||||
AIFF: 'audio/x-aiff',
|
||||
WEBM: 'audio/webm',
|
||||
WEBMA: 'audio/webm'
|
||||
WEBMA: 'audio/webm',
|
||||
MKA: 'audio/x-matroska',
|
||||
AWB: 'audio/amr-wb',
|
||||
CAF: 'audio/x-caf'
|
||||
}
|
||||
|
||||
module.exports.VideoMimeType = {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
const axios = require('axios')
|
||||
const Ffmpeg = require('../libs/fluentFfmpeg')
|
||||
const fs = require('../libs/fsExtra')
|
||||
const Path = require('path')
|
||||
|
|
@ -86,3 +87,73 @@ async function resizeImage(filePath, outputPath, width, height) {
|
|||
})
|
||||
}
|
||||
module.exports.resizeImage = resizeImage
|
||||
|
||||
module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
|
||||
return new Promise(async (resolve) => {
|
||||
const response = await axios({
|
||||
url: podcastEpisodeDownload.url,
|
||||
method: 'GET',
|
||||
responseType: 'stream',
|
||||
timeout: 30000
|
||||
}).catch((error) => {
|
||||
Logger.error(`[ffmpegHelpers] Failed to download podcast episode with url "${podcastEpisodeDownload.url}"`, error)
|
||||
return null
|
||||
})
|
||||
if (!response) return resolve(false)
|
||||
|
||||
|
||||
const ffmpeg = Ffmpeg(response.data)
|
||||
ffmpeg.outputOptions(
|
||||
'-c', 'copy',
|
||||
'-metadata', 'podcast=1'
|
||||
)
|
||||
|
||||
const podcastMetadata = podcastEpisodeDownload.libraryItem.media.metadata
|
||||
const podcastEpisode = podcastEpisodeDownload.podcastEpisode
|
||||
|
||||
const taggings = {
|
||||
'album': podcastMetadata.title,
|
||||
'album-sort': podcastMetadata.title,
|
||||
'artist': podcastMetadata.author,
|
||||
'artist-sort': podcastMetadata.author,
|
||||
'comment': podcastEpisode.description,
|
||||
'subtitle': podcastEpisode.subtitle,
|
||||
'disc': podcastEpisode.season,
|
||||
'genre': podcastMetadata.genres.length ? podcastMetadata.genres.join(';') : null,
|
||||
'language': podcastMetadata.language,
|
||||
'MVNM': podcastMetadata.title,
|
||||
'MVIN': podcastEpisode.episode,
|
||||
'track': podcastEpisode.episode,
|
||||
'series-part': podcastEpisode.episode,
|
||||
'title': podcastEpisode.title,
|
||||
'title-sort': podcastEpisode.title,
|
||||
'year': podcastEpisode.pubYear,
|
||||
'date': podcastEpisode.pubDate,
|
||||
'releasedate': podcastEpisode.pubDate,
|
||||
'itunes-id': podcastMetadata.itunesId,
|
||||
'podcast-type': podcastMetadata.type,
|
||||
'episode-type': podcastMetadata.episodeType
|
||||
}
|
||||
|
||||
for (const tag in taggings) {
|
||||
if (taggings[tag]) {
|
||||
ffmpeg.addOption('-metadata', `${tag}=${taggings[tag]}`)
|
||||
}
|
||||
}
|
||||
|
||||
ffmpeg.addOutput(podcastEpisodeDownload.targetPath)
|
||||
|
||||
ffmpeg.on('start', (cmd) => {
|
||||
Logger.debug(`[FfmpegHelpers] downloadPodcastEpisode: Cmd: ${cmd}`)
|
||||
})
|
||||
ffmpeg.on('error', (err, stdout, stderr) => {
|
||||
Logger.error(`[FfmpegHelpers] downloadPodcastEpisode: Error ${err} ${stdout} ${stderr}`)
|
||||
resolve(false)
|
||||
})
|
||||
ffmpeg.on('end', () => {
|
||||
Logger.debug(`[FfmpegHelpers] downloadPodcastEpisode: Complete`)
|
||||
resolve(podcastEpisodeDownload.targetPath)
|
||||
})
|
||||
ffmpeg.run()
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,7 +92,6 @@ module.exports.setDefault = (path, silent = false) => {
|
|||
const gid = global.Gid
|
||||
return new Promise((resolve) => {
|
||||
if (isNaN(uid) || isNaN(gid)) {
|
||||
if (!silent) Logger.debug('Not modifying permissions since no uid/gid is specified')
|
||||
return resolve()
|
||||
}
|
||||
if (!silent) Logger.debug(`Setting permission "${mode}" for uid ${uid} and gid ${gid} | "${path}"`)
|
||||
|
|
@ -107,7 +106,6 @@ module.exports.setDefaultDirSync = (path, silent = false) => {
|
|||
const uid = global.Uid
|
||||
const gid = global.Gid
|
||||
if (isNaN(uid) || isNaN(gid)) {
|
||||
if (!silent) Logger.debug('Not modifying permissions since no uid/gid is specified')
|
||||
return true
|
||||
}
|
||||
if (!silent) Logger.debug(`[FilePerms] Setting dir permission "${mode}" for uid ${uid} and gid ${gid} | "${path}"`)
|
||||
|
|
|
|||
|
|
@ -174,20 +174,24 @@ async function recurseFiles(path, relPathToReplace = null) {
|
|||
}
|
||||
module.exports.recurseFiles = recurseFiles
|
||||
|
||||
module.exports.downloadFile = async (url, filepath) => {
|
||||
Logger.debug(`[fileUtils] Downloading file to ${filepath}`)
|
||||
module.exports.downloadFile = (url, filepath) => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
Logger.debug(`[fileUtils] Downloading file to ${filepath}`)
|
||||
axios({
|
||||
url,
|
||||
method: 'GET',
|
||||
responseType: 'stream',
|
||||
timeout: 30000
|
||||
}).then((response) => {
|
||||
const writer = fs.createWriteStream(filepath)
|
||||
response.data.pipe(writer)
|
||||
|
||||
const writer = fs.createWriteStream(filepath)
|
||||
const response = await axios({
|
||||
url,
|
||||
method: 'GET',
|
||||
responseType: 'stream',
|
||||
timeout: 30000
|
||||
})
|
||||
response.data.pipe(writer)
|
||||
return new Promise((resolve, reject) => {
|
||||
writer.on('finish', resolve)
|
||||
writer.on('error', reject)
|
||||
writer.on('finish', resolve)
|
||||
writer.on('error', reject)
|
||||
}).catch((err) => {
|
||||
Logger.error(`[fileUtils] Failed to download file "${filepath}"`, err)
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
const fs = require('../libs/fsExtra')
|
||||
const filePerms = require('./filePerms')
|
||||
const package = require('../../package.json')
|
||||
const Logger = require('../Logger')
|
||||
const { getId, copyValue } = require('./index')
|
||||
const fs = require('../../libs/fsExtra')
|
||||
const filePerms = require('../filePerms')
|
||||
const package = require('../../../package.json')
|
||||
const Logger = require('../../Logger')
|
||||
const { getId } = require('../index')
|
||||
const areEquivalent = require('../areEquivalent')
|
||||
|
||||
|
||||
const CurrentAbMetadataVersion = 2
|
||||
|
|
@ -121,6 +122,10 @@ const bookMetadataMapper = {
|
|||
explicit: {
|
||||
to: (m) => m.explicit ? 'Y' : 'N',
|
||||
from: (v) => v && v.toLowerCase() == 'y'
|
||||
},
|
||||
abridged: {
|
||||
to: (m) => m.abridged ? 'Y' : 'N',
|
||||
from: (v) => v && v.toLowerCase() == 'y'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -324,11 +329,11 @@ function parseAbMetadataText(text, mediaType) {
|
|||
module.exports.parse = parseAbMetadataText
|
||||
|
||||
function checkUpdatedBookAuthors(abmetadataAuthors, authors) {
|
||||
var finalAuthors = []
|
||||
var hasUpdates = false
|
||||
const finalAuthors = []
|
||||
let hasUpdates = false
|
||||
|
||||
abmetadataAuthors.forEach((authorName) => {
|
||||
var findAuthor = authors.find(au => au.name.toLowerCase() == authorName.toLowerCase())
|
||||
const findAuthor = authors.find(au => au.name.toLowerCase() == authorName.toLowerCase())
|
||||
if (!findAuthor) {
|
||||
hasUpdates = true
|
||||
finalAuthors.push({
|
||||
|
|
@ -393,18 +398,81 @@ function checkArraysChanged(abmetadataArray, mediaArray) {
|
|||
return abmetadataArray.join(',') != mediaArray.join(',')
|
||||
}
|
||||
|
||||
function parseJsonMetadataText(text) {
|
||||
try {
|
||||
const abmetadataData = JSON.parse(text)
|
||||
if (abmetadataData.metadata?.series?.length) {
|
||||
abmetadataData.metadata.series = abmetadataData.metadata.series.map(series => {
|
||||
let sequence = null
|
||||
let name = series
|
||||
// Series sequence match any characters after " #" other than whitespace and another #
|
||||
// e.g. "Name #1a" is valid. "Name #1#a" or "Name #1 a" is not valid.
|
||||
const matchResults = series.match(/ #([^#\s]+)$/) // Pull out sequence #
|
||||
if (matchResults && matchResults.length && matchResults.length > 1) {
|
||||
sequence = matchResults[1] // Group 1
|
||||
name = series.replace(matchResults[0], '')
|
||||
}
|
||||
return {
|
||||
name,
|
||||
sequence
|
||||
}
|
||||
})
|
||||
}
|
||||
return abmetadataData
|
||||
} catch (error) {
|
||||
Logger.error(`[abmetadataGenerator] Invalid metadata.json JSON`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function cleanChaptersArray(chaptersArray, mediaTitle) {
|
||||
const chapters = []
|
||||
let index = 0
|
||||
for (const chap of chaptersArray) {
|
||||
if (chap.start === null || isNaN(chap.start)) {
|
||||
Logger.error(`[abmetadataGenerator] Invalid chapter start time ${chap.start} for "${mediaTitle}" metadata file`)
|
||||
return null
|
||||
}
|
||||
if (chap.end === null || isNaN(chap.end)) {
|
||||
Logger.error(`[abmetadataGenerator] Invalid chapter end time ${chap.end} for "${mediaTitle}" metadata file`)
|
||||
return null
|
||||
}
|
||||
if (!chap.title || typeof chap.title !== 'string') {
|
||||
Logger.error(`[abmetadataGenerator] Invalid chapter title ${chap.title} for "${mediaTitle}" metadata file`)
|
||||
return null
|
||||
}
|
||||
|
||||
chapters.push({
|
||||
id: index++,
|
||||
start: chap.start,
|
||||
end: chap.end,
|
||||
title: chap.title
|
||||
})
|
||||
}
|
||||
return chapters
|
||||
}
|
||||
|
||||
// Input text from abmetadata file and return object of media changes
|
||||
// only returns object of changes. empty object means no changes
|
||||
function parseAndCheckForUpdates(text, media, mediaType) {
|
||||
function parseAndCheckForUpdates(text, media, mediaType, isJSON) {
|
||||
if (!text || !media || !media.metadata || !mediaType) {
|
||||
Logger.error(`Invalid inputs to parseAndCheckForUpdates`)
|
||||
return null
|
||||
}
|
||||
|
||||
const mediaMetadata = media.metadata
|
||||
const metadataUpdatePayload = {} // Only updated key/values
|
||||
|
||||
const abmetadataData = parseAbMetadataText(text, mediaType)
|
||||
let abmetadataData = null
|
||||
|
||||
if (isJSON) {
|
||||
abmetadataData = parseJsonMetadataText(text)
|
||||
} else {
|
||||
abmetadataData = parseAbMetadataText(text, mediaType)
|
||||
}
|
||||
|
||||
if (!abmetadataData || !abmetadataData.metadata) {
|
||||
Logger.error(`[abmetadataGenerator] Invalid metadata file`)
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
@ -437,6 +505,15 @@ function parseAndCheckForUpdates(text, media, mediaType) {
|
|||
}
|
||||
}
|
||||
|
||||
if (abmetadataData.chapters && mediaType === 'book') {
|
||||
const abmetadataChaptersCleaned = cleanChaptersArray(abmetadataData.chapters)
|
||||
if (abmetadataChaptersCleaned) {
|
||||
if (!areEquivalent(abmetadataChaptersCleaned, media.chapters)) {
|
||||
updatePayload.chapters = abmetadataChaptersCleaned
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(metadataUpdatePayload).length) {
|
||||
updatePayload.metadata = metadataUpdatePayload
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
const fs = require('../libs/fsExtra')
|
||||
const fs = require('../../libs/fsExtra')
|
||||
|
||||
function getPlaylistStr(segmentName, duration, segmentLength, hlsSegmentType, token) {
|
||||
var ext = hlsSegmentType === 'fmp4' ? 'm4s' : 'ts'
|
||||
52
server/utils/generators/opmlGenerator.js
Normal file
52
server/utils/generators/opmlGenerator.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
const xml = require('../../libs/xml')
|
||||
|
||||
module.exports.generate = (libraryItems, indent = true) => {
|
||||
const bodyItems = []
|
||||
libraryItems.forEach((item) => {
|
||||
if (!item.media.metadata.feedUrl) return
|
||||
const feedAttributes = {
|
||||
type: 'rss',
|
||||
text: item.media.metadata.title,
|
||||
title: item.media.metadata.title,
|
||||
xmlUrl: item.media.metadata.feedUrl
|
||||
}
|
||||
if (item.media.metadata.description) {
|
||||
feedAttributes.description = item.media.metadata.description
|
||||
}
|
||||
if (item.media.metadata.itunesPageUrl) {
|
||||
feedAttributes.htmlUrl = item.media.metadata.itunesPageUrl
|
||||
}
|
||||
if (item.media.metadata.language) {
|
||||
feedAttributes.language = item.media.metadata.language
|
||||
}
|
||||
bodyItems.push({
|
||||
outline: {
|
||||
_attr: feedAttributes
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const data = [
|
||||
{
|
||||
opml: [
|
||||
{
|
||||
_attr: {
|
||||
version: '1.0'
|
||||
}
|
||||
},
|
||||
{
|
||||
head: [
|
||||
{
|
||||
title: 'Audiobookshelf Podcast Subscriptions'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
body: bodyItems
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
return '<?xml version="1.0" encoding="UTF-8"?>\n' + xml(data, indent)
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
const globals = {
|
||||
SupportedImageTypes: ['png', 'jpg', 'jpeg', 'webp'],
|
||||
SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma'],
|
||||
SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb', 'caf'],
|
||||
SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
|
||||
SupportedVideoTypes: ['mp4'],
|
||||
TextFileTypes: ['txt', 'nfo'],
|
||||
MetadataFileTypes: ['opf', 'abs', 'xml']
|
||||
MetadataFileTypes: ['opf', 'abs', 'xml', 'json']
|
||||
}
|
||||
|
||||
module.exports = globals
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
const sanitizeHtml = require('../libs/sanitizeHtml')
|
||||
const {entities} = require("./htmlEntities");
|
||||
const { entities } = require("./htmlEntities");
|
||||
|
||||
function sanitize(html) {
|
||||
const sanitizerOptions = {
|
||||
allowedTags: [
|
||||
'p', 'ol', 'ul', 'li', 'a', 'strong', 'em', 'del'
|
||||
'p', 'ol', 'ul', 'li', 'a', 'strong', 'em', 'del', 'br'
|
||||
],
|
||||
disallowedTagsMode: 'discard',
|
||||
allowedAttributes: {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ const { parseString } = require("xml2js")
|
|||
const areEquivalent = require('./areEquivalent')
|
||||
|
||||
const levenshteinDistance = (str1, str2, caseSensitive = false) => {
|
||||
str1 = String(str1)
|
||||
str2 = String(str2)
|
||||
if (!caseSensitive) {
|
||||
str1 = str1.toLowerCase()
|
||||
str2 = str2.toLowerCase()
|
||||
|
|
@ -108,7 +110,7 @@ module.exports.reqSupportsWebp = (req) => {
|
|||
module.exports.areEquivalent = areEquivalent
|
||||
|
||||
module.exports.copyValue = (val) => {
|
||||
if (!val) return null
|
||||
if (!val) return val === false ? false : null
|
||||
if (!this.isObject(val)) return val
|
||||
|
||||
if (Array.isArray(val)) {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ module.exports = {
|
|||
if (group) {
|
||||
const filterVal = filterBy.replace(`${group}.`, '')
|
||||
const filter = this.decode(filterVal)
|
||||
if (group === 'genres') filtered = filtered.filter(li => li.media.metadata && li.media.metadata.genres.includes(filter))
|
||||
if (group === 'genres') filtered = filtered.filter(li => li.media.metadata.genres?.includes(filter))
|
||||
else if (group === 'tags') filtered = filtered.filter(li => li.media.tags.includes(filter))
|
||||
else if (group === 'series') {
|
||||
if (filter === 'no-series') filtered = filtered.filter(li => li.isBook && !li.media.metadata.series.length)
|
||||
|
|
@ -58,7 +58,7 @@ module.exports = {
|
|||
}
|
||||
})
|
||||
} else if (group === 'languages') {
|
||||
filtered = filtered.filter(li => li.media.metadata && li.media.metadata.language === filter)
|
||||
filtered = filtered.filter(li => li.media.metadata.language === filter)
|
||||
} else if (group === 'tracks') {
|
||||
if (filter === 'single') filtered = filtered.filter(li => li.isBook && li.media.numTracks === 1)
|
||||
else if (filter === 'multi') filtered = filtered.filter(li => li.isBook && li.media.numTracks > 1)
|
||||
|
|
@ -67,6 +67,10 @@ module.exports = {
|
|||
filtered = filtered.filter(li => li.hasIssues)
|
||||
} else if (filterBy === 'feed-open') {
|
||||
filtered = filtered.filter(li => feedsArray.some(feed => feed.entityId === li.id))
|
||||
} else if (filterBy === 'abridged') {
|
||||
filtered = filtered.filter(li => !!li.media.metadata?.abridged)
|
||||
} else if (filterBy === 'ebook') {
|
||||
filtered = filtered.filter(li => li.media.ebookFile)
|
||||
}
|
||||
|
||||
return filtered
|
||||
|
|
@ -80,12 +84,12 @@ module.exports = {
|
|||
var filterVal = filterBy.replace(`${group}.`, '')
|
||||
var filter = this.decode(filterVal)
|
||||
|
||||
if (group === 'genres') return libraryItem.media.metadata && libraryItem.media.metadata.genres.includes(filter)
|
||||
if (group === 'genres') return 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 === 'authors') return libraryItem.isBook && libraryItem.media.metadata.hasAuthor(filter)
|
||||
else if (group === 'narrators') return libraryItem.isBook && libraryItem.media.metadata.hasNarrator(filter)
|
||||
else if (group === 'languages') {
|
||||
return libraryItem.media.metadata && libraryItem.media.metadata.language === filter
|
||||
return libraryItem.media.metadata.language === filter
|
||||
}
|
||||
}
|
||||
return true
|
||||
|
|
@ -95,17 +99,20 @@ module.exports = {
|
|||
checkSeriesProgressFilter(series, filterBy, user) {
|
||||
const filter = this.decode(filterBy.split('.')[1])
|
||||
|
||||
var numBooksStartedOrFinished = 0
|
||||
let someBookHasProgress = false
|
||||
let someBookIsUnfinished = false
|
||||
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 (!itemProgress || !itemProgress.isFinished) someBookIsUnfinished = true
|
||||
if (itemProgress && itemProgress.progress > 0) someBookHasProgress = true
|
||||
|
||||
if (filter === 'finished' && (!itemProgress || !itemProgress.isFinished)) return false
|
||||
if (filter === 'not-started' && itemProgress) return false
|
||||
}
|
||||
|
||||
if (numBooksStartedOrFinished === series.books.length) { // Completely finished series
|
||||
if (filter === 'Not Finished') return false
|
||||
} else if (numBooksStartedOrFinished === 0 && filter === 'In Progress') { // Series not started
|
||||
if (!someBookIsUnfinished && (filter === 'not-finished' || filter === 'in-progress')) { // Completely finished series
|
||||
return false
|
||||
} else if (!someBookHasProgress && filter === 'in-progress') { // Series not started
|
||||
return false
|
||||
}
|
||||
return true
|
||||
|
|
@ -280,6 +287,19 @@ module.exports = {
|
|||
}
|
||||
},
|
||||
|
||||
getItemSizeStats(libraryItems) {
|
||||
var sorted = sort(libraryItems).desc(li => li.media.size)
|
||||
var top10 = sorted.slice(0, 10).map(li => ({ id: li.id, title: li.media.metadata.title, size: li.media.size })).filter(i => i.size > 0)
|
||||
var totalSize = 0
|
||||
libraryItems.forEach((li) => {
|
||||
totalSize += li.media.size
|
||||
})
|
||||
return {
|
||||
totalSize,
|
||||
largestItems: top10
|
||||
}
|
||||
},
|
||||
|
||||
getLibraryItemsTotalSize(libraryItems) {
|
||||
var totalSize = 0
|
||||
libraryItems.forEach((li) => {
|
||||
|
|
@ -328,71 +348,77 @@ module.exports = {
|
|||
label: 'Continue Listening',
|
||||
labelStringKey: 'LabelContinueListening',
|
||||
type: isPodcastLibrary ? 'episode' : mediaType,
|
||||
entities: [],
|
||||
category: 'recentlyListened'
|
||||
entities: []
|
||||
},
|
||||
{
|
||||
id: 'continue-reading',
|
||||
label: 'Continue Reading',
|
||||
labelStringKey: 'LabelContinueReading',
|
||||
type: 'book',
|
||||
entities: []
|
||||
},
|
||||
{
|
||||
id: 'continue-series',
|
||||
label: 'Continue Series',
|
||||
labelStringKey: 'LabelContinueSeries',
|
||||
type: mediaType,
|
||||
entities: [],
|
||||
category: 'continueSeries'
|
||||
entities: []
|
||||
},
|
||||
{
|
||||
id: 'episodes-recently-added',
|
||||
label: 'Newest Episodes',
|
||||
labelStringKey: 'LabelNewestEpisodes',
|
||||
type: 'episode',
|
||||
entities: [],
|
||||
category: 'newestEpisodes'
|
||||
entities: []
|
||||
},
|
||||
{
|
||||
id: 'recently-added',
|
||||
label: 'Recently Added',
|
||||
labelStringKey: 'LabelRecentlyAdded',
|
||||
type: mediaType,
|
||||
entities: [],
|
||||
category: 'newestItems'
|
||||
entities: []
|
||||
},
|
||||
{
|
||||
id: 'recent-series',
|
||||
label: 'Recent Series',
|
||||
labelStringKey: 'LabelRecentSeries',
|
||||
type: 'series',
|
||||
entities: [],
|
||||
category: 'newestSeries'
|
||||
entities: []
|
||||
},
|
||||
{
|
||||
id: 'recommended',
|
||||
label: 'Recommended',
|
||||
labelStringKey: 'LabelRecommended',
|
||||
type: mediaType,
|
||||
entities: [],
|
||||
category: 'recommended'
|
||||
entities: []
|
||||
},
|
||||
{
|
||||
id: 'listen-again',
|
||||
label: 'Listen Again',
|
||||
labelStringKey: 'LabelListenAgain',
|
||||
type: isPodcastLibrary ? 'episode' : mediaType,
|
||||
entities: [],
|
||||
category: 'recentlyFinished'
|
||||
entities: []
|
||||
},
|
||||
{
|
||||
id: 'read-again',
|
||||
label: 'Read Again',
|
||||
labelStringKey: 'LabelReadAgain',
|
||||
type: 'book',
|
||||
entities: []
|
||||
},
|
||||
{
|
||||
id: 'newest-authors',
|
||||
label: 'Newest Authors',
|
||||
labelStringKey: 'LabelNewestAuthors',
|
||||
type: 'authors',
|
||||
entities: [],
|
||||
category: 'newestAuthors'
|
||||
entities: []
|
||||
}
|
||||
]
|
||||
|
||||
const categoryMap = {}
|
||||
shelves.forEach((shelf) => {
|
||||
categoryMap[shelf.category] = {
|
||||
category: shelf.category,
|
||||
categoryMap[shelf.id] = {
|
||||
id: shelf.id,
|
||||
biggest: 0,
|
||||
smallest: 0,
|
||||
items: []
|
||||
|
|
@ -409,21 +435,21 @@ module.exports = {
|
|||
const notStartedBooks = []
|
||||
|
||||
for (const libraryItem of libraryItems) {
|
||||
if (libraryItem.addedAt > categoryMap.newestItems.smallest) {
|
||||
if (libraryItem.addedAt > categoryMap['recently-added'].smallest) {
|
||||
|
||||
const indexToPut = categoryMap.newestItems.items.findIndex(i => libraryItem.addedAt > i.addedAt)
|
||||
const indexToPut = categoryMap['recently-added'].items.findIndex(i => libraryItem.addedAt > i.addedAt)
|
||||
if (indexToPut >= 0) {
|
||||
categoryMap.newestItems.items.splice(indexToPut, 0, libraryItem.toJSONMinified())
|
||||
categoryMap['recently-added'].items.splice(indexToPut, 0, libraryItem.toJSONMinified())
|
||||
} else {
|
||||
categoryMap.newestItems.items.push(libraryItem.toJSONMinified())
|
||||
categoryMap['recently-added'].items.push(libraryItem.toJSONMinified())
|
||||
}
|
||||
|
||||
if (categoryMap.newestItems.items.length > maxEntitiesPerShelf) {
|
||||
if (categoryMap['recently-added'].items.length > maxEntitiesPerShelf) {
|
||||
// Remove last item
|
||||
categoryMap.newestItems.items.pop()
|
||||
categoryMap.newestItems.smallest = categoryMap.newestItems.items[categoryMap.newestItems.items.length - 1].addedAt
|
||||
categoryMap['recently-added'].items.pop()
|
||||
categoryMap['recently-added'].smallest = categoryMap['recently-added'].items[categoryMap['recently-added'].items.length - 1].addedAt
|
||||
}
|
||||
categoryMap.newestItems.biggest = categoryMap.newestItems.items[0].addedAt
|
||||
categoryMap['recently-added'].biggest = categoryMap['recently-added'].items[0].addedAt
|
||||
}
|
||||
|
||||
const allItemProgress = user.getAllMediaProgressForLibraryItem(libraryItem.id)
|
||||
|
|
@ -432,74 +458,74 @@ module.exports = {
|
|||
const podcastEpisodes = libraryItem.media.episodes || []
|
||||
for (const episode of podcastEpisodes) {
|
||||
// Newest episodes
|
||||
if (episode.addedAt > categoryMap.newestEpisodes.smallest) {
|
||||
if (episode.addedAt > categoryMap['episodes-recently-added'].smallest) {
|
||||
const libraryItemWithEpisode = {
|
||||
...libraryItem.toJSONMinified(),
|
||||
recentEpisode: episode.toJSON()
|
||||
}
|
||||
|
||||
const indexToPut = categoryMap.newestEpisodes.items.findIndex(i => episode.addedAt > i.recentEpisode.addedAt)
|
||||
const indexToPut = categoryMap['episodes-recently-added'].items.findIndex(i => episode.addedAt > i.recentEpisode.addedAt)
|
||||
if (indexToPut >= 0) {
|
||||
categoryMap.newestEpisodes.items.splice(indexToPut, 0, libraryItemWithEpisode)
|
||||
categoryMap['episodes-recently-added'].items.splice(indexToPut, 0, libraryItemWithEpisode)
|
||||
} else {
|
||||
categoryMap.newestEpisodes.items.push(libraryItemWithEpisode)
|
||||
categoryMap['episodes-recently-added'].items.push(libraryItemWithEpisode)
|
||||
}
|
||||
|
||||
if (categoryMap.newestEpisodes.items.length > maxEntitiesPerShelf) {
|
||||
if (categoryMap['episodes-recently-added'].items.length > maxEntitiesPerShelf) {
|
||||
// Remove last item
|
||||
categoryMap.newestEpisodes.items.pop()
|
||||
categoryMap.newestEpisodes.smallest = categoryMap.newestEpisodes.items[categoryMap.newestEpisodes.items.length - 1].recentEpisode.addedAt
|
||||
categoryMap['episodes-recently-added'].items.pop()
|
||||
categoryMap['episodes-recently-added'].smallest = categoryMap['episodes-recently-added'].items[categoryMap['episodes-recently-added'].items.length - 1].recentEpisode.addedAt
|
||||
}
|
||||
categoryMap.newestEpisodes.biggest = categoryMap.newestEpisodes.items[0].recentEpisode.addedAt
|
||||
categoryMap['episodes-recently-added'].biggest = categoryMap['episodes-recently-added'].items[0].recentEpisode.addedAt
|
||||
}
|
||||
|
||||
// Episode recently listened and finished
|
||||
const mediaProgress = allItemProgress.find(mp => mp.episodeId === episode.id)
|
||||
if (mediaProgress) {
|
||||
if (mediaProgress.isFinished) {
|
||||
if (mediaProgress.finishedAt > categoryMap.recentlyFinished.smallest) { // Item belongs on shelf
|
||||
if (mediaProgress.finishedAt > categoryMap['listen-again'].smallest) { // Item belongs on shelf
|
||||
const libraryItemWithEpisode = {
|
||||
...libraryItem.toJSONMinified(),
|
||||
recentEpisode: episode.toJSON(),
|
||||
finishedAt: mediaProgress.finishedAt
|
||||
}
|
||||
|
||||
const indexToPut = categoryMap.recentlyFinished.items.findIndex(i => mediaProgress.finishedAt > i.finishedAt)
|
||||
const indexToPut = categoryMap['listen-again'].items.findIndex(i => mediaProgress.finishedAt > i.finishedAt)
|
||||
if (indexToPut >= 0) {
|
||||
categoryMap.recentlyFinished.items.splice(indexToPut, 0, libraryItemWithEpisode)
|
||||
categoryMap['listen-again'].items.splice(indexToPut, 0, libraryItemWithEpisode)
|
||||
} else {
|
||||
categoryMap.recentlyFinished.items.push(libraryItemWithEpisode)
|
||||
categoryMap['listen-again'].items.push(libraryItemWithEpisode)
|
||||
}
|
||||
|
||||
if (categoryMap.recentlyFinished.items.length > maxEntitiesPerShelf) {
|
||||
if (categoryMap['listen-again'].items.length > maxEntitiesPerShelf) {
|
||||
// Remove last item
|
||||
categoryMap.recentlyFinished.items.pop()
|
||||
categoryMap.recentlyFinished.smallest = categoryMap.recentlyFinished.items[categoryMap.recentlyFinished.items.length - 1].finishedAt
|
||||
categoryMap['listen-again'].items.pop()
|
||||
categoryMap['listen-again'].smallest = categoryMap['listen-again'].items[categoryMap['listen-again'].items.length - 1].finishedAt
|
||||
}
|
||||
categoryMap.recentlyFinished.biggest = categoryMap.recentlyFinished.items[0].finishedAt
|
||||
categoryMap['listen-again'].biggest = categoryMap['listen-again'].items[0].finishedAt
|
||||
}
|
||||
} else if (mediaProgress.inProgress && !mediaProgress.hideFromContinueListening) { // Handle most recently listened
|
||||
if (mediaProgress.lastUpdate > categoryMap.recentlyListened.smallest) { // Item belongs on shelf
|
||||
if (mediaProgress.lastUpdate > categoryMap['continue-listening'].smallest) { // Item belongs on shelf
|
||||
const libraryItemWithEpisode = {
|
||||
...libraryItem.toJSONMinified(),
|
||||
recentEpisode: episode.toJSON(),
|
||||
progressLastUpdate: mediaProgress.lastUpdate
|
||||
}
|
||||
|
||||
const indexToPut = categoryMap.recentlyListened.items.findIndex(i => mediaProgress.lastUpdate > i.progressLastUpdate)
|
||||
const indexToPut = categoryMap['continue-listening'].items.findIndex(i => mediaProgress.lastUpdate > i.progressLastUpdate)
|
||||
if (indexToPut >= 0) {
|
||||
categoryMap.recentlyListened.items.splice(indexToPut, 0, libraryItemWithEpisode)
|
||||
categoryMap['continue-listening'].items.splice(indexToPut, 0, libraryItemWithEpisode)
|
||||
} else {
|
||||
categoryMap.recentlyListened.items.push(libraryItemWithEpisode)
|
||||
categoryMap['continue-listening'].items.push(libraryItemWithEpisode)
|
||||
}
|
||||
|
||||
if (categoryMap.recentlyListened.items.length > maxEntitiesPerShelf) {
|
||||
if (categoryMap['continue-listening'].items.length > maxEntitiesPerShelf) {
|
||||
// Remove last item
|
||||
categoryMap.recentlyListened.items.pop()
|
||||
categoryMap.recentlyListened.smallest = categoryMap.recentlyListened.items[categoryMap.recentlyListened.items.length - 1].progressLastUpdate
|
||||
categoryMap['continue-listening'].items.pop()
|
||||
categoryMap['continue-listening'].smallest = categoryMap['continue-listening'].items[categoryMap['continue-listening'].items.length - 1].progressLastUpdate
|
||||
}
|
||||
|
||||
categoryMap.recentlyListened.biggest = categoryMap.recentlyListened.items[0].progressLastUpdate
|
||||
categoryMap['continue-listening'].biggest = categoryMap['continue-listening'].items[0].progressLastUpdate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -550,21 +576,21 @@ module.exports = {
|
|||
}
|
||||
seriesMap[librarySeries.id] = series
|
||||
|
||||
if (series.addedAt > categoryMap.newestSeries.smallest) {
|
||||
const indexToPut = categoryMap.newestSeries.items.findIndex(i => series.addedAt > i.addedAt)
|
||||
if (series.addedAt > categoryMap['recent-series'].smallest) {
|
||||
const indexToPut = categoryMap['recent-series'].items.findIndex(i => series.addedAt > i.addedAt)
|
||||
if (indexToPut >= 0) {
|
||||
categoryMap.newestSeries.items.splice(indexToPut, 0, series)
|
||||
categoryMap['recent-series'].items.splice(indexToPut, 0, series)
|
||||
} else {
|
||||
categoryMap.newestSeries.items.push(series)
|
||||
categoryMap['recent-series'].items.push(series)
|
||||
}
|
||||
|
||||
// Max series is 5
|
||||
if (categoryMap.newestSeries.items.length > 5) {
|
||||
categoryMap.newestSeries.items.pop()
|
||||
categoryMap.newestSeries.smallest = categoryMap.newestSeries.items[categoryMap.newestSeries.items.length - 1].addedAt
|
||||
if (categoryMap['recent-series'].items.length > 5) {
|
||||
categoryMap['recent-series'].items.pop()
|
||||
categoryMap['recent-series'].smallest = categoryMap['recent-series'].items[categoryMap['recent-series'].items.length - 1].addedAt
|
||||
}
|
||||
|
||||
categoryMap.newestSeries.biggest = categoryMap.newestSeries.items[0].addedAt
|
||||
categoryMap['recent-series'].biggest = categoryMap['recent-series'].items[0].addedAt
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -606,22 +632,22 @@ module.exports = {
|
|||
numBooks: 1
|
||||
}
|
||||
|
||||
if (author.addedAt > categoryMap.newestAuthors.smallest) {
|
||||
if (author.addedAt > categoryMap['newest-authors'].smallest) {
|
||||
|
||||
const indexToPut = categoryMap.newestAuthors.items.findIndex(i => author.addedAt > i.addedAt)
|
||||
const indexToPut = categoryMap['newest-authors'].items.findIndex(i => author.addedAt > i.addedAt)
|
||||
if (indexToPut >= 0) {
|
||||
categoryMap.newestAuthors.items.splice(indexToPut, 0, author)
|
||||
categoryMap['newest-authors'].items.splice(indexToPut, 0, author)
|
||||
} else {
|
||||
categoryMap.newestAuthors.items.push(author)
|
||||
categoryMap['newest-authors'].items.push(author)
|
||||
}
|
||||
|
||||
// Max authors is 10
|
||||
if (categoryMap.newestAuthors.items.length > 10) {
|
||||
categoryMap.newestAuthors.items.pop()
|
||||
categoryMap.newestAuthors.smallest = categoryMap.newestAuthors.items[categoryMap.newestAuthors.items.length - 1].addedAt
|
||||
if (categoryMap['newest-authors'].items.length > 10) {
|
||||
categoryMap['newest-authors'].items.pop()
|
||||
categoryMap['newest-authors'].smallest = categoryMap['newest-authors'].items[categoryMap['newest-authors'].items.length - 1].addedAt
|
||||
}
|
||||
|
||||
categoryMap.newestAuthors.biggest = categoryMap.newestAuthors.items[0].addedAt
|
||||
categoryMap['newest-authors'].biggest = categoryMap['newest-authors'].items[0].addedAt
|
||||
}
|
||||
|
||||
authorMap[libraryAuthor.id] = author
|
||||
|
|
@ -634,46 +660,50 @@ module.exports = {
|
|||
|
||||
// Book listening and finished
|
||||
if (mediaProgress) {
|
||||
const categoryId = libraryItem.media.isEBookOnly ? 'read-again' : 'listen-again'
|
||||
|
||||
// Handle most recently finished
|
||||
if (mediaProgress.isFinished) {
|
||||
if (mediaProgress.finishedAt > categoryMap.recentlyFinished.smallest) { // Item belongs on shelf
|
||||
if (mediaProgress.finishedAt > categoryMap[categoryId].smallest) { // Item belongs on shelf
|
||||
const libraryItemObj = {
|
||||
...libraryItem.toJSONMinified(),
|
||||
finishedAt: mediaProgress.finishedAt
|
||||
}
|
||||
|
||||
const indexToPut = categoryMap.recentlyFinished.items.findIndex(i => mediaProgress.finishedAt > i.finishedAt)
|
||||
const indexToPut = categoryMap[categoryId].items.findIndex(i => mediaProgress.finishedAt > i.finishedAt)
|
||||
if (indexToPut >= 0) {
|
||||
categoryMap.recentlyFinished.items.splice(indexToPut, 0, libraryItemObj)
|
||||
categoryMap[categoryId].items.splice(indexToPut, 0, libraryItemObj)
|
||||
} else {
|
||||
categoryMap.recentlyFinished.items.push(libraryItemObj)
|
||||
categoryMap[categoryId].items.push(libraryItemObj)
|
||||
}
|
||||
if (categoryMap.recentlyFinished.items.length > maxEntitiesPerShelf) {
|
||||
if (categoryMap[categoryId].items.length > maxEntitiesPerShelf) {
|
||||
// Remove last item
|
||||
categoryMap.recentlyFinished.items.pop()
|
||||
categoryMap.recentlyFinished.smallest = categoryMap.recentlyFinished.items[categoryMap.recentlyFinished.items.length - 1].finishedAt
|
||||
categoryMap[categoryId].items.pop()
|
||||
categoryMap[categoryId].smallest = categoryMap[categoryId].items[categoryMap[categoryId].items.length - 1].finishedAt
|
||||
}
|
||||
categoryMap.recentlyFinished.biggest = categoryMap.recentlyFinished.items[0].finishedAt
|
||||
categoryMap[categoryId].biggest = categoryMap[categoryId].items[0].finishedAt
|
||||
}
|
||||
} else if (mediaProgress.inProgress && !mediaProgress.hideFromContinueListening) { // Handle most recently listened
|
||||
if (mediaProgress.lastUpdate > categoryMap.recentlyListened.smallest) { // Item belongs on shelf
|
||||
const categoryId = libraryItem.media.isEBookOnly ? 'continue-reading' : 'continue-listening'
|
||||
|
||||
if (mediaProgress.lastUpdate > categoryMap[categoryId].smallest) { // Item belongs on shelf
|
||||
const libraryItemObj = {
|
||||
...libraryItem.toJSONMinified(),
|
||||
progressLastUpdate: mediaProgress.lastUpdate
|
||||
}
|
||||
|
||||
const indexToPut = categoryMap.recentlyListened.items.findIndex(i => mediaProgress.lastUpdate > i.progressLastUpdate)
|
||||
const indexToPut = categoryMap[categoryId].items.findIndex(i => mediaProgress.lastUpdate > i.progressLastUpdate)
|
||||
if (indexToPut >= 0) {
|
||||
categoryMap.recentlyListened.items.splice(indexToPut, 0, libraryItemObj)
|
||||
categoryMap[categoryId].items.splice(indexToPut, 0, libraryItemObj)
|
||||
} else { // Should only happen when array is < max
|
||||
categoryMap.recentlyListened.items.push(libraryItemObj)
|
||||
categoryMap[categoryId].items.push(libraryItemObj)
|
||||
}
|
||||
if (categoryMap.recentlyListened.items.length > maxEntitiesPerShelf) {
|
||||
if (categoryMap[categoryId].items.length > maxEntitiesPerShelf) {
|
||||
// Remove last item
|
||||
categoryMap.recentlyListened.items.pop()
|
||||
categoryMap.recentlyListened.smallest = categoryMap.recentlyListened.items[categoryMap.recentlyListened.items.length - 1].progressLastUpdate
|
||||
categoryMap[categoryId].items.pop()
|
||||
categoryMap[categoryId].smallest = categoryMap[categoryId].items[categoryMap[categoryId].items.length - 1].progressLastUpdate
|
||||
}
|
||||
categoryMap.recentlyListened.biggest = categoryMap.recentlyListened.items[0].progressLastUpdate
|
||||
categoryMap[categoryId].biggest = categoryMap[categoryId].items[0].progressLastUpdate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -701,12 +731,12 @@ module.exports = {
|
|||
sequence: nextBookInSeries.seriesSequence
|
||||
}
|
||||
|
||||
const indexToPut = categoryMap.continueSeries.items.findIndex(i => i.prevBookInProgressLastUpdate < bookForContinueSeries.prevBookInProgressLastUpdate)
|
||||
if (!categoryMap.continueSeries.items.find(book => book.id === bookForContinueSeries.id)) {
|
||||
const indexToPut = categoryMap['continue-series'].items.findIndex(i => i.prevBookInProgressLastUpdate < bookForContinueSeries.prevBookInProgressLastUpdate)
|
||||
if (!categoryMap['continue-series'].items.find(book => book.id === bookForContinueSeries.id)) {
|
||||
if (indexToPut >= 0) {
|
||||
categoryMap.continueSeries.items.splice(indexToPut, 0, bookForContinueSeries)
|
||||
} else if (categoryMap.continueSeries.items.length < 10) { // Max 10 books
|
||||
categoryMap.continueSeries.items.push(bookForContinueSeries)
|
||||
categoryMap['continue-series'].items.splice(indexToPut, 0, bookForContinueSeries)
|
||||
} else if (categoryMap['continue-series'].items.length < 10) { // Max 10 books
|
||||
categoryMap['continue-series'].items.push(bookForContinueSeries)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -784,8 +814,8 @@ module.exports = {
|
|||
}
|
||||
|
||||
// Sort series books by sequence
|
||||
if (categoryMap.newestSeries.items.length) {
|
||||
for (const seriesItem of categoryMap.newestSeries.items) {
|
||||
if (categoryMap['recent-series'].items.length) {
|
||||
for (const seriesItem of categoryMap['recent-series'].items) {
|
||||
seriesItem.books = naturalSort(seriesItem.books).asc(li => li.seriesSequence)
|
||||
}
|
||||
}
|
||||
|
|
@ -793,7 +823,7 @@ module.exports = {
|
|||
const categoriesWithItems = Object.values(categoryMap).filter(cat => cat.items.length)
|
||||
|
||||
return categoriesWithItems.map(cat => {
|
||||
const shelf = shelves.find(s => s.category === cat.category)
|
||||
const shelf = shelves.find(s => s.id === cat.id)
|
||||
shelf.entities = cat.items
|
||||
|
||||
// Add rssFeed to entities if query string "include=rssfeed" was on request
|
||||
|
|
@ -843,4 +873,4 @@ module.exports = {
|
|||
|
||||
return Object.values(albums)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,226 +0,0 @@
|
|||
|
||||
const Path = require('path')
|
||||
const h = require('htmlparser2')
|
||||
const ds = require('dom-serializer')
|
||||
|
||||
const Logger = require('../../Logger')
|
||||
const StreamZip = require('../../libs/nodeStreamZip')
|
||||
const css = require('../../libs/css')
|
||||
|
||||
const { xmlToJSON } = require('../index.js')
|
||||
|
||||
module.exports.parse = async (ebookFile, libraryItemId, token, isDev) => {
|
||||
const zip = new StreamZip.async({ file: ebookFile.metadata.path })
|
||||
const containerXml = await zip.entryData('META-INF/container.xml')
|
||||
const containerJson = await xmlToJSON(containerXml.toString('utf8'))
|
||||
|
||||
const packageOpfPath = containerJson.container.rootfiles[0].rootfile[0].$['full-path']
|
||||
const packageOpfDir = Path.dirname(packageOpfPath)
|
||||
|
||||
const packageDoc = await zip.entryData(packageOpfPath)
|
||||
const packageJson = await xmlToJSON(packageDoc.toString('utf8'))
|
||||
|
||||
const pages = []
|
||||
|
||||
let manifestItems = packageJson.package.manifest[0].item.map(item => item.$)
|
||||
const spineItems = packageJson.package.spine[0].itemref.map(ref => ref.$.idref)
|
||||
for (const spineItem of spineItems) {
|
||||
const mi = manifestItems.find(i => i.id === spineItem)
|
||||
if (mi) {
|
||||
manifestItems = manifestItems.filter(_mi => _mi.id !== mi.id) // Remove from manifest items
|
||||
|
||||
mi.path = Path.posix.join(packageOpfDir, mi.href)
|
||||
pages.push(mi)
|
||||
} else {
|
||||
Logger.error('[parseEpub] Invalid spine item', spineItem)
|
||||
}
|
||||
}
|
||||
|
||||
const stylesheets = []
|
||||
const resources = []
|
||||
|
||||
for (const manifestItem of manifestItems) {
|
||||
manifestItem.path = Path.posix.join(packageOpfDir, manifestItem.href)
|
||||
|
||||
if (manifestItem['media-type'] === 'text/css') {
|
||||
const stylesheetData = await zip.entryData(manifestItem.path)
|
||||
const modifiedCss = this.parseStylesheet(stylesheetData.toString('utf8'), manifestItem.path, libraryItemId, token, isDev)
|
||||
if (modifiedCss) {
|
||||
manifestItem.style = modifiedCss
|
||||
stylesheets.push(manifestItem)
|
||||
} else {
|
||||
Logger.error(`[parseEpub] Invalid stylesheet "${manifestItem.path}"`)
|
||||
}
|
||||
} else {
|
||||
resources.push(manifestItem)
|
||||
}
|
||||
}
|
||||
|
||||
await zip.close()
|
||||
|
||||
return {
|
||||
filepath: ebookFile.metadata.path,
|
||||
epubVersion: packageJson.package.$.version,
|
||||
packageDir: packageOpfDir,
|
||||
resources,
|
||||
stylesheets,
|
||||
pages
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.parsePage = async (pagePath, bookData, libraryItemId, token, isDev) => {
|
||||
const pageDir = Path.dirname(pagePath)
|
||||
|
||||
const zip = new StreamZip.async({ file: bookData.filepath })
|
||||
const pageData = await zip.entryData(pagePath)
|
||||
await zip.close()
|
||||
const rawHtml = pageData.toString('utf8')
|
||||
|
||||
const results = {}
|
||||
|
||||
const dh = new h.DomHandler((err, dom) => {
|
||||
if (err) return results.error = err
|
||||
|
||||
// Get stylesheets
|
||||
const isStylesheetLink = (elem) => elem.type == 'tag' && elem.name.toLowerCase() === 'link' && elem.attribs.rel === 'stylesheet' && elem.attribs.type === 'text/css'
|
||||
const stylesheets = h.DomUtils.findAll(isStylesheetLink, dom)
|
||||
|
||||
// Get body tag
|
||||
const isBodyTag = (elem) => elem.type == 'tag' && elem.name.toLowerCase() == 'body'
|
||||
const body = h.DomUtils.findOne(isBodyTag, dom)
|
||||
|
||||
// Get all svg elements
|
||||
const isSvgTag = (name) => ['svg'].includes((name || '').toLowerCase())
|
||||
const svgElements = h.DomUtils.getElementsByTagName(isSvgTag, body.children)
|
||||
svgElements.forEach((el) => {
|
||||
if (el.attribs.class) el.attribs.class += ' abs-svg-scale'
|
||||
else el.attribs.class = 'abs-svg-scale'
|
||||
})
|
||||
|
||||
// Get all img elements
|
||||
const isImageTag = (name) => ['img', 'image'].includes((name || '').toLowerCase())
|
||||
const imgElements = h.DomUtils.getElementsByTagName(isImageTag, body.children)
|
||||
|
||||
imgElements.forEach(el => {
|
||||
if (!el.attribs.src && !el.attribs['xlink:href']) {
|
||||
Logger.warn('[parseEpub] parsePage: Invalid img element attribs', el.attribs)
|
||||
return
|
||||
}
|
||||
|
||||
if (el.attribs.class) el.attribs.class += ' abs-image-scale'
|
||||
else el.attribs.class = 'abs-image-scale'
|
||||
|
||||
const srcKey = el.attribs.src ? 'src' : 'xlink:href'
|
||||
const src = encodeURIComponent(Path.posix.join(pageDir, el.attribs[srcKey]))
|
||||
|
||||
const basePath = isDev ? 'http://localhost:3333' : ''
|
||||
el.attribs[srcKey] = `${basePath}/api/ebooks/${libraryItemId}/resource?path=${src}&token=${token}`
|
||||
})
|
||||
|
||||
let finalHtml = '<div class="abs-page-content" style="max-height: unset; margin-left: 15% !important; margin-right: 15% !important;">'
|
||||
|
||||
stylesheets.forEach((el) => {
|
||||
const href = Path.posix.join(pageDir, el.attribs.href)
|
||||
const ssObj = bookData.stylesheets.find(sso => sso.path === href)
|
||||
|
||||
// find @import css and add it
|
||||
const importSheets = getStylesheetImports(ssObj.style, bookData.stylesheets)
|
||||
if (importSheets) {
|
||||
importSheets.forEach((sheet) => {
|
||||
finalHtml += `<style>${sheet.style}</style>\n`
|
||||
})
|
||||
}
|
||||
|
||||
if (!ssObj) {
|
||||
Logger.warn('[parseEpub] parsePage: Stylesheet object not found for href', href)
|
||||
} else {
|
||||
finalHtml += `<style>${ssObj.style}</style>\n`
|
||||
}
|
||||
})
|
||||
|
||||
finalHtml += `<style>
|
||||
.abs-image-scale { max-width: 100%; object-fit: contain; object-position: top center; max-height: 100vh; }
|
||||
.abs-svg-scale { width: auto; max-height: 80vh; }
|
||||
</style>\n`
|
||||
|
||||
finalHtml += ds.render(body.children)
|
||||
|
||||
finalHtml += '\n</div>'
|
||||
|
||||
results.html = finalHtml
|
||||
})
|
||||
|
||||
const parser = new h.Parser(dh)
|
||||
parser.write(rawHtml)
|
||||
parser.end()
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
module.exports.parseStylesheet = (rawCss, stylesheetPath, libraryItemId, token, isDev) => {
|
||||
try {
|
||||
const stylesheetDir = Path.dirname(stylesheetPath)
|
||||
|
||||
const res = css.parse(rawCss)
|
||||
|
||||
res.stylesheet.rules.forEach((rule) => {
|
||||
if (rule.type === 'rule') {
|
||||
rule.selectors = rule.selectors.map(s => s === 'body' ? '.abs-page-content' : `.abs-page-content ${s}`)
|
||||
} else if (rule.type === 'font-face' && rule.declarations) {
|
||||
rule.declarations = rule.declarations.map(dec => {
|
||||
if (dec.property === 'src') {
|
||||
const match = dec.value.trim().split(' ').shift().match(/url\((.+)\)/)
|
||||
if (match && match[1]) {
|
||||
const fontPath = Path.posix.join(stylesheetDir, match[1])
|
||||
const newSrc = encodeURIComponent(fontPath)
|
||||
|
||||
const basePath = isDev ? 'http://localhost:3333' : ''
|
||||
dec.value = dec.value.replace(match[1], `"${basePath}/api/ebooks/${libraryItemId}/resource?path=${newSrc}&token=${token}"`)
|
||||
}
|
||||
}
|
||||
return dec
|
||||
})
|
||||
} else if (rule.type === 'import') {
|
||||
const importUrl = rule.import
|
||||
const match = importUrl.match(/\"(.*)\"/)
|
||||
const path = match ? match[1] || '' : ''
|
||||
if (path) {
|
||||
// const newSrc = encodeURIComponent(Path.posix.join(stylesheetDir, path))
|
||||
// const basePath = isDev ? 'http://localhost:3333' : ''
|
||||
// const newPath = `"${basePath}/api/ebooks/${libraryItemId}/resource?path=${newSrc}&token=${token}"`
|
||||
// rule.import = rule.import.replace(path, newPath)
|
||||
|
||||
rule.import = Path.posix.join(stylesheetDir, path)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return css.stringify(res)
|
||||
} catch (error) {
|
||||
Logger.error('[parseEpub] parseStylesheet: Failed', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getStylesheetImports(rawCss, stylesheets) {
|
||||
try {
|
||||
const res = css.parse(rawCss)
|
||||
|
||||
const imports = []
|
||||
res.stylesheet.rules.forEach((rule) => {
|
||||
if (rule.type === 'import') {
|
||||
const importUrl = rule.import.replaceAll('"', '')
|
||||
const sheet = stylesheets.find(s => s.path === importUrl)
|
||||
if (sheet) imports.push(sheet)
|
||||
else {
|
||||
Logger.error('[parseEpub] getStylesheetImports: Sheet not found', stylesheets)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return imports
|
||||
} catch (error) {
|
||||
Logger.error('[parseEpub] getStylesheetImports: Failed', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -43,6 +43,8 @@ module.exports.parse = (nameString) => {
|
|||
// Example &LF: Friedman, Milton & Friedman, Rose
|
||||
if (nameString.includes('&')) {
|
||||
nameString.split('&').forEach((asa) => splitNames = splitNames.concat(asa.split(',')))
|
||||
} else if (nameString.includes(';')) {
|
||||
nameString.split(';').forEach((asa) => splitNames = splitNames.concat(asa.split(',')))
|
||||
} else {
|
||||
splitNames = nameString.split(',')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ function fetchVolumeNumber(metadataMeta) {
|
|||
|
||||
function fetchNarrators(creators, metadata) {
|
||||
const narrators = fetchCreators(creators, 'nrt')
|
||||
if (typeof metadata.meta == "undefined" || narrators.length) return narrators
|
||||
if (narrators?.length) return narrators
|
||||
try {
|
||||
const narratorsJSON = JSON.parse(fetchTagString(metadata.meta, "calibre:user_metadata:#narrators").replace(/"/g, '"'))
|
||||
return narratorsJSON["#value#"]
|
||||
|
|
@ -150,7 +150,7 @@ module.exports.parseOpfMetadataXML = async (xml) => {
|
|||
const metadataMeta = prefix ? metadata[`${prefix}:meta`] || metadata.meta : metadata.meta
|
||||
|
||||
metadata.meta = {}
|
||||
if (metadataMeta && metadataMeta.length) {
|
||||
if (metadataMeta?.length) {
|
||||
metadataMeta.forEach((meta) => {
|
||||
if (meta && meta['$'] && meta['$'].name) {
|
||||
metadata.meta[meta['$'].name] = [meta['$'].content || '']
|
||||
|
|
|
|||
|
|
@ -41,12 +41,13 @@ function extractCategories(channel) {
|
|||
}
|
||||
|
||||
function extractPodcastMetadata(channel) {
|
||||
var metadata = {
|
||||
const metadata = {
|
||||
image: extractImage(channel),
|
||||
categories: extractCategories(channel),
|
||||
feedUrl: null,
|
||||
description: null,
|
||||
descriptionPlain: null
|
||||
descriptionPlain: null,
|
||||
type: null
|
||||
}
|
||||
|
||||
if (channel['itunes:new-feed-url']) {
|
||||
|
|
@ -61,33 +62,43 @@ function extractPodcastMetadata(channel) {
|
|||
metadata.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription)
|
||||
}
|
||||
|
||||
var arrayFields = ['title', 'language', 'itunes:explicit', 'itunes:author', 'pubDate', 'link']
|
||||
const arrayFields = ['title', 'language', 'itunes:explicit', 'itunes:author', 'pubDate', 'link', 'itunes:type']
|
||||
arrayFields.forEach((key) => {
|
||||
var cleanKey = key.split(':').pop()
|
||||
metadata[cleanKey] = extractFirstArrayItem(channel, key)
|
||||
const cleanKey = key.split(':').pop()
|
||||
let value = extractFirstArrayItem(channel, key)
|
||||
if (value && value['_']) value = value['_']
|
||||
metadata[cleanKey] = value
|
||||
})
|
||||
return metadata
|
||||
}
|
||||
|
||||
function extractEpisodeData(item) {
|
||||
// Episode must have url
|
||||
if (!item.enclosure || !item.enclosure.length || !item.enclosure[0]['$'] || !item.enclosure[0]['$'].url) {
|
||||
if (!item.enclosure?.[0]?.['$']?.url) {
|
||||
Logger.error(`[podcastUtils] Invalid podcast episode data`)
|
||||
return null
|
||||
}
|
||||
|
||||
var episode = {
|
||||
const episode = {
|
||||
enclosure: {
|
||||
...item.enclosure[0]['$']
|
||||
}
|
||||
}
|
||||
|
||||
episode.enclosure.url = episode.enclosure.url.trim()
|
||||
|
||||
// Full description with html
|
||||
if (item['content:encoded']) {
|
||||
const rawDescription = (extractFirstArrayItem(item, 'content:encoded') || '').trim()
|
||||
episode.description = htmlSanitizer.sanitize(rawDescription)
|
||||
}
|
||||
|
||||
// Extract chapters
|
||||
if (item['podcast:chapters']?.[0]?.['$']?.url) {
|
||||
episode.chaptersUrl = item['podcast:chapters'][0]['$'].url
|
||||
episode.chaptersType = item['podcast:chapters'][0]['$'].type || 'application/json'
|
||||
}
|
||||
|
||||
// Supposed to be the plaintext description but not always followed
|
||||
if (item['description']) {
|
||||
const rawDescription = extractFirstArrayItem(item, 'description') || ''
|
||||
|
|
@ -106,9 +117,9 @@ function extractEpisodeData(item) {
|
|||
}
|
||||
}
|
||||
|
||||
var arrayFields = ['title', 'itunes:episodeType', 'itunes:season', 'itunes:episode', 'itunes:author', 'itunes:duration', 'itunes:explicit', 'itunes:subtitle']
|
||||
const arrayFields = ['title', 'itunes:episodeType', 'itunes:season', 'itunes:episode', 'itunes:author', 'itunes:duration', 'itunes:explicit', 'itunes:subtitle']
|
||||
arrayFields.forEach((key) => {
|
||||
var cleanKey = key.split(':').pop()
|
||||
const cleanKey = key.split(':').pop()
|
||||
episode[cleanKey] = extractFirstArrayItem(item, key)
|
||||
})
|
||||
return episode
|
||||
|
|
@ -130,14 +141,16 @@ function cleanEpisodeData(data) {
|
|||
duration: data.duration || '',
|
||||
explicit: data.explicit || '',
|
||||
publishedAt,
|
||||
enclosure: data.enclosure
|
||||
enclosure: data.enclosure,
|
||||
chaptersUrl: data.chaptersUrl || null,
|
||||
chaptersType: data.chaptersType || null
|
||||
}
|
||||
}
|
||||
|
||||
function extractPodcastEpisodes(items) {
|
||||
var episodes = []
|
||||
const episodes = []
|
||||
items.forEach((item) => {
|
||||
var extracted = extractEpisodeData(item)
|
||||
const extracted = extractEpisodeData(item)
|
||||
if (extracted) {
|
||||
episodes.push(cleanEpisodeData(extracted))
|
||||
}
|
||||
|
|
@ -258,4 +271,4 @@ module.exports.findMatchingEpisodesInFeed = (feed, searchTitle) => {
|
|||
}
|
||||
})
|
||||
return matches.sort((a, b) => a.levenshtein - b.levenshtein)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,7 +73,8 @@ function tryGrabChannelLayout(stream) {
|
|||
function tryGrabTags(stream, ...tags) {
|
||||
if (!stream.tags) return null
|
||||
for (let i = 0; i < tags.length; i++) {
|
||||
const value = stream.tags[tags[i]] || stream.tags[tags[i].toUpperCase()]
|
||||
const tagKey = Object.keys(stream.tags).find(t => t.toLowerCase() === tags[i].toLowerCase())
|
||||
const value = stream.tags[tagKey]
|
||||
if (value && value.trim()) return value.trim()
|
||||
}
|
||||
return null
|
||||
|
|
@ -161,15 +162,19 @@ function parseTags(format, verbose) {
|
|||
if (verbose) {
|
||||
Logger.debug('Tags', format.tags)
|
||||
}
|
||||
|
||||
const tags = {
|
||||
file_tag_encoder: tryGrabTags(format, 'encoder', 'tsse', 'tss'),
|
||||
file_tag_encodedby: tryGrabTags(format, 'encoded_by', 'tenc', 'ten'),
|
||||
file_tag_title: tryGrabTags(format, 'title', 'tit2', 'tt2'),
|
||||
file_tag_titlesort: tryGrabTags(format, 'title-sort', 'tsot'),
|
||||
file_tag_subtitle: tryGrabTags(format, 'subtitle', 'tit3', 'tt3'),
|
||||
file_tag_track: tryGrabTags(format, 'track', 'trck', 'trk'),
|
||||
file_tag_disc: tryGrabTags(format, 'discnumber', 'disc', 'disk', 'tpos'),
|
||||
file_tag_disc: tryGrabTags(format, 'discnumber', 'disc', 'disk', 'tpos', 'tpa'),
|
||||
file_tag_album: tryGrabTags(format, 'album', 'talb', 'tal'),
|
||||
file_tag_albumsort: tryGrabTags(format, 'album-sort', 'tsoa'),
|
||||
file_tag_artist: tryGrabTags(format, 'artist', 'tpe1', 'tp1'),
|
||||
file_tag_artistsort: tryGrabTags(format, 'artist-sort', 'tsop'),
|
||||
file_tag_albumartist: tryGrabTags(format, 'albumartist', 'album_artist', 'tpe2'),
|
||||
file_tag_date: tryGrabTags(format, 'date', 'tyer', 'tye'),
|
||||
file_tag_composer: tryGrabTags(format, 'composer', 'tcom', 'tcm'),
|
||||
|
|
@ -178,10 +183,13 @@ function parseTags(format, verbose) {
|
|||
file_tag_description: tryGrabTags(format, 'description', 'desc'),
|
||||
file_tag_genre: tryGrabTags(format, 'genre', 'tcon', 'tco'),
|
||||
file_tag_series: tryGrabTags(format, 'series', 'show', 'mvnm'),
|
||||
file_tag_seriespart: tryGrabTags(format, 'series-part', 'episode_id', 'mvin'),
|
||||
file_tag_isbn: tryGrabTags(format, 'isbn'),
|
||||
file_tag_seriespart: tryGrabTags(format, 'series-part', 'episode_id', 'mvin', 'part'),
|
||||
file_tag_isbn: tryGrabTags(format, 'isbn'), // custom
|
||||
file_tag_language: tryGrabTags(format, 'language', 'lang'),
|
||||
file_tag_asin: tryGrabTags(format, 'asin'),
|
||||
file_tag_asin: tryGrabTags(format, 'asin', 'audible_asin'), // custom
|
||||
file_tag_itunesid: tryGrabTags(format, 'itunes-id'), // custom
|
||||
file_tag_podcasttype: tryGrabTags(format, 'podcast-type'), // custom
|
||||
file_tag_episodetype: tryGrabTags(format, 'episode-type'), // custom
|
||||
file_tag_originalyear: tryGrabTags(format, 'originalyear'),
|
||||
file_tag_releasecountry: tryGrabTags(format, 'MusicBrainz Album Release Country', 'releasecountry'),
|
||||
file_tag_releasestatus: tryGrabTags(format, 'MusicBrainz Album Status', 'releasestatus', 'musicbrainz_albumstatus'),
|
||||
|
|
|
|||
|
|
@ -175,7 +175,7 @@ function cleanFileObjects(libraryItemPath, files) {
|
|||
}
|
||||
|
||||
// Scan folder
|
||||
async function scanFolder(libraryMediaType, folder, serverSettings = {}) {
|
||||
async function scanFolder(libraryMediaType, folder) {
|
||||
const folderPath = filePathToPOSIX(folder.fullPath)
|
||||
|
||||
const pathExists = await fs.pathExists(folderPath)
|
||||
|
|
@ -216,7 +216,7 @@ async function scanFolder(libraryMediaType, folder, serverSettings = {}) {
|
|||
fileObjs = await cleanFileObjects(folderPath, [libraryItemPath])
|
||||
isFile = true
|
||||
} else {
|
||||
libraryItemData = getDataFromMediaDir(libraryMediaType, folderPath, libraryItemPath, serverSettings, libraryItemGrouping[libraryItemPath])
|
||||
libraryItemData = getDataFromMediaDir(libraryMediaType, folderPath, libraryItemPath)
|
||||
fileObjs = await cleanFileObjects(libraryItemData.path, libraryItemGrouping[libraryItemPath])
|
||||
}
|
||||
|
||||
|
|
@ -347,19 +347,18 @@ function getPodcastDataFromDir(folderPath, relPath) {
|
|||
}
|
||||
}
|
||||
|
||||
function getDataFromMediaDir(libraryMediaType, folderPath, relPath, serverSettings, fileNames) {
|
||||
function getDataFromMediaDir(libraryMediaType, folderPath, relPath) {
|
||||
if (libraryMediaType === 'podcast') {
|
||||
return getPodcastDataFromDir(folderPath, relPath)
|
||||
} else if (libraryMediaType === 'book') {
|
||||
var parseSubtitle = !!serverSettings.scannerParseSubtitle
|
||||
return getBookDataFromDir(folderPath, relPath, parseSubtitle)
|
||||
return getBookDataFromDir(folderPath, relPath, !!global.ServerSettings.scannerParseSubtitle)
|
||||
} else {
|
||||
return getPodcastDataFromDir(folderPath, relPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Called from Scanner.js
|
||||
async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath, isSingleMediaItem, serverSettings = {}) {
|
||||
async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath, isSingleMediaItem) {
|
||||
libraryItemPath = filePathToPOSIX(libraryItemPath)
|
||||
const folderFullPath = filePathToPOSIX(folder.fullPath)
|
||||
|
||||
|
|
@ -384,8 +383,7 @@ async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath,
|
|||
}
|
||||
} else {
|
||||
fileItems = await recurseFiles(libraryItemPath)
|
||||
const fileNames = fileItems.map(i => i.name)
|
||||
libraryItemData = getDataFromMediaDir(libraryMediaType, folderFullPath, libraryItemDir, serverSettings, fileNames)
|
||||
libraryItemData = getDataFromMediaDir(libraryMediaType, folderFullPath, libraryItemDir)
|
||||
}
|
||||
|
||||
const libraryItemDirStats = await getFileTimestampsWithIno(libraryItemData.path)
|
||||
|
|
|
|||
|
|
@ -1,81 +1,14 @@
|
|||
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) => {
|
||||
function getToneMetadataObject(libraryItem, chapters, trackTotal, mimeType = null) {
|
||||
const bookMetadata = libraryItem.media.metadata
|
||||
const coverPath = libraryItem.media.coverPath
|
||||
|
||||
const isMp4 = mimeType === 'audio/mp4'
|
||||
const isMp3 = mimeType === 'audio/mpeg'
|
||||
|
||||
const metadataObject = {
|
||||
'album': bookMetadata.title || '',
|
||||
'title': bookMetadata.title || '',
|
||||
|
|
@ -98,10 +31,24 @@ module.exports.writeToneMetadataJsonFile = (libraryItem, chapters, filePath, tra
|
|||
metadataObject['composer'] = bookMetadata.narratorName
|
||||
}
|
||||
if (bookMetadata.firstSeriesName) {
|
||||
if (!isMp3) {
|
||||
metadataObject.additionalFields['----:com.pilabor.tone:SERIES'] = bookMetadata.firstSeriesName
|
||||
}
|
||||
metadataObject['movementName'] = bookMetadata.firstSeriesName
|
||||
}
|
||||
if (bookMetadata.firstSeriesSequence) {
|
||||
metadataObject['movement'] = bookMetadata.firstSeriesSequence
|
||||
// Non-mp3
|
||||
if (!isMp3) {
|
||||
metadataObject.additionalFields['----:com.pilabor.tone:PART'] = bookMetadata.firstSeriesSequence
|
||||
}
|
||||
// MP3 Files with non-integer sequence
|
||||
const isNonIntegerSequence = String(bookMetadata.firstSeriesSequence).includes('.') || isNaN(bookMetadata.firstSeriesSequence)
|
||||
if (isMp3 && isNonIntegerSequence) {
|
||||
metadataObject.additionalFields['PART'] = bookMetadata.firstSeriesSequence
|
||||
}
|
||||
if (!isNonIntegerSequence) {
|
||||
metadataObject['movement'] = bookMetadata.firstSeriesSequence
|
||||
}
|
||||
}
|
||||
if (bookMetadata.genres.length) {
|
||||
metadataObject['genre'] = bookMetadata.genres.join('/')
|
||||
|
|
@ -110,7 +57,12 @@ module.exports.writeToneMetadataJsonFile = (libraryItem, chapters, filePath, tra
|
|||
metadataObject['publisher'] = bookMetadata.publisher
|
||||
}
|
||||
if (bookMetadata.asin) {
|
||||
metadataObject.additionalFields['asin'] = bookMetadata.asin
|
||||
if (!isMp3) {
|
||||
metadataObject.additionalFields['----:com.pilabor.tone:AUDIBLE_ASIN'] = bookMetadata.asin
|
||||
}
|
||||
if (!isMp4) {
|
||||
metadataObject.additionalFields['asin'] = bookMetadata.asin
|
||||
}
|
||||
}
|
||||
if (bookMetadata.isbn) {
|
||||
metadataObject.additionalFields['isbn'] = bookMetadata.isbn
|
||||
|
|
@ -133,10 +85,20 @@ module.exports.writeToneMetadataJsonFile = (libraryItem, chapters, filePath, tra
|
|||
metadataObject['chapters'] = metadataChapters
|
||||
}
|
||||
|
||||
return metadataObject
|
||||
}
|
||||
module.exports.getToneMetadataObject = getToneMetadataObject
|
||||
|
||||
module.exports.writeToneMetadataJsonFile = (libraryItem, chapters, filePath, trackTotal, mimeType) => {
|
||||
const metadataObject = getToneMetadataObject(libraryItem, chapters, trackTotal, mimeType)
|
||||
return fs.writeFile(filePath, JSON.stringify({ meta: metadataObject }, null, 2))
|
||||
}
|
||||
|
||||
module.exports.tagAudioFile = (filePath, payload) => {
|
||||
if (process.env.TONE_PATH) {
|
||||
tone.TONE_PATH = process.env.TONE_PATH
|
||||
}
|
||||
|
||||
return tone.tag(filePath, payload).then((data) => {
|
||||
return true
|
||||
}).catch((error) => {
|
||||
|
|
|
|||
52
server/utils/zipHelpers.js
Normal file
52
server/utils/zipHelpers.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
const Logger = require('../Logger')
|
||||
const archiver = require('../libs/archiver')
|
||||
|
||||
module.exports.zipDirectoryPipe = (path, filename, res) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// create a file to stream archive data to
|
||||
res.attachment(filename)
|
||||
|
||||
const archive = archiver('zip', {
|
||||
zlib: { level: 9 } // Sets the compression level.
|
||||
})
|
||||
|
||||
// listen for all archive data to be written
|
||||
// 'close' event is fired only when a file descriptor is involved
|
||||
res.on('close', () => {
|
||||
Logger.info(archive.pointer() + ' total bytes')
|
||||
Logger.debug('archiver has been finalized and the output file descriptor has closed.')
|
||||
resolve()
|
||||
})
|
||||
|
||||
// This event is fired when the data source is drained no matter what was the data source.
|
||||
// It is not part of this library but rather from the NodeJS Stream API.
|
||||
// @see: https://nodejs.org/api/stream.html#stream_event_end
|
||||
res.on('end', () => {
|
||||
Logger.debug('Data has been drained')
|
||||
})
|
||||
|
||||
// good practice to catch warnings (ie stat failures and other non-blocking errors)
|
||||
archive.on('warning', function (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
// log warning
|
||||
Logger.warn(`[DownloadManager] Archiver warning: ${err.message}`)
|
||||
} else {
|
||||
// throw error
|
||||
Logger.error(`[DownloadManager] Archiver error: ${err.message}`)
|
||||
// throw err
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
archive.on('error', function (err) {
|
||||
Logger.error(`[DownloadManager] Archiver error: ${err.message}`)
|
||||
reject(err)
|
||||
})
|
||||
|
||||
// pipe archive data to the file
|
||||
archive.pipe(res)
|
||||
|
||||
archive.directory(path, false)
|
||||
|
||||
archive.finalize()
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue