mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-12-24 04:39:40 +00:00
Merge remote-tracking branch 'origin/master' into auth_passportjs
This commit is contained in:
commit
812395b21b
90 changed files with 3469 additions and 1148 deletions
|
|
@ -35,7 +35,6 @@ const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
|
|||
const RssFeedManager = require('./managers/RssFeedManager')
|
||||
const CronManager = require('./managers/CronManager')
|
||||
const TaskManager = require('./managers/TaskManager')
|
||||
const EBookManager = require('./managers/EBookManager')
|
||||
|
||||
//Import the main Passport and Express-Session library
|
||||
const passport = require('passport')
|
||||
|
|
@ -80,7 +79,6 @@ class Server {
|
|||
this.podcastManager = new PodcastManager(this.db, this.watcher, this.notificationManager, this.taskManager)
|
||||
this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.taskManager)
|
||||
this.rssFeedManager = new RssFeedManager(this.db)
|
||||
this.eBookManager = new EBookManager(this.db)
|
||||
|
||||
this.scanner = new Scanner(this.db, this.coverManager)
|
||||
this.cronManager = new CronManager(this.db, this.scanner, this.podcastManager)
|
||||
|
|
@ -124,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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -417,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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ 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')
|
||||
|
||||
|
|
@ -69,6 +70,17 @@ class LibraryItemController {
|
|||
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
|
||||
//
|
||||
|
|
@ -162,12 +174,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)
|
||||
}
|
||||
|
|
@ -436,12 +448,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)
|
||||
|
|
@ -470,6 +482,28 @@ class LibraryItemController {
|
|||
res.json(toneData)
|
||||
}
|
||||
|
||||
async deleteLibraryFile(req, res) {
|
||||
const libraryFile = req.libraryItem.libraryFiles.find(lf => lf.ino === req.params.ino)
|
||||
if (!libraryFile) {
|
||||
Logger.error(`[LibraryItemController] Unable to delete library file. Not found. "${req.params.ino}"`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
await fs.remove(libraryFile.metadata.path)
|
||||
req.libraryItem.removeLibraryFile(req.params.ino)
|
||||
|
||||
if (req.libraryItem.media.removeFileWithInode(req.params.ino)) {
|
||||
// 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)
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
const item = this.db.libraryItems.find(li => li.id === req.params.id)
|
||||
if (!item || !item.media) return res.sendStatus(404)
|
||||
|
|
|
|||
|
|
@ -171,23 +171,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 +239,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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -5,18 +5,42 @@ 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)
|
||||
return toneHelpers.getToneMetadataObject(libraryItem, libraryItem.media.chapters, libraryItem.media.tracks.length)
|
||||
}
|
||||
|
||||
handleBatchEmbed(user, libraryItems, options = {}) {
|
||||
libraryItems.forEach((li) => {
|
||||
this.updateMetadataForItem(user, li, options)
|
||||
})
|
||||
}
|
||||
|
||||
async updateMetadataForItem(user, libraryItem, options = {}) {
|
||||
|
|
@ -25,99 +49,144 @@ 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
|
||||
|
||||
// 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),
|
||||
itemCachePath,
|
||||
chapters,
|
||||
options: {
|
||||
forceEmbedChapters,
|
||||
backupFiles
|
||||
}
|
||||
}
|
||||
const taskDescription = `Embedding metadata in audiobook "${libraryItem.media.metadata.title}".`
|
||||
task.setData('embed-metadata', 'Embedding Metadata', taskDescription, 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -138,18 +138,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 +152,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)
|
||||
|
|
@ -268,6 +256,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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,11 +4,12 @@ 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 prober = require('../utils/prober')
|
||||
const ffmpegHelpers = require('../utils/ffmpegHelpers')
|
||||
|
||||
const LibraryFile = require('../objects/files/LibraryFile')
|
||||
const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload')
|
||||
|
|
@ -52,15 +53,15 @@ 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()
|
||||
const newPeDl = new PodcastEpisodeDownload()
|
||||
newPeDl.setData(newPe, libraryItem, isAutoDownload, libraryItem.libraryId)
|
||||
this.startPodcastEpisodeDownload(newPeDl)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async startPodcastEpisodeDownload(podcastEpisodeDownload) {
|
||||
|
|
@ -93,10 +94,21 @@ class PodcastManager {
|
|||
await filePerms.setDefault(this.currentDownload.libraryItem.path)
|
||||
}
|
||||
|
||||
let 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) {
|
||||
|
|
@ -126,23 +138,28 @@ 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
|
||||
|
|
@ -201,13 +218,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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -41,8 +41,12 @@ class PodcastEpisodeDownload {
|
|||
}
|
||||
}
|
||||
|
||||
get urlFileExtension() {
|
||||
const cleanUrl = this.url.split('?')[0] // Remove query string
|
||||
return Path.extname(cleanUrl).substring(1).toLowerCase()
|
||||
}
|
||||
get fileExtension() {
|
||||
const extname = Path.extname(this.url).substring(1).toLowerCase()
|
||||
const extname = this.urlFileExtension
|
||||
if (globals.SupportedAudioTypes.includes(extname)) return extname
|
||||
return 'mp3'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -106,6 +111,10 @@ class PodcastEpisode {
|
|||
get enclosureUrl() {
|
||||
return this.enclosure ? this.enclosure.url : null
|
||||
}
|
||||
get pubYear() {
|
||||
if (!this.publishedAt) return null
|
||||
return new Date(this.publishedAt).getFullYear()
|
||||
}
|
||||
|
||||
setData(data, index = 1) {
|
||||
this.id = getId('ep')
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -166,7 +166,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 +179,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) {
|
||||
|
|
@ -315,5 +323,13 @@ 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)
|
||||
}
|
||||
}
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -136,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
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ class MediaProgress {
|
|||
this.isFinished = false
|
||||
this.hideFromContinueListening = false
|
||||
|
||||
this.ebookLocation = null // current cfi tag
|
||||
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
|
||||
|
|
@ -44,13 +49,15 @@ class MediaProgress {
|
|||
this.currentTime = progress.currentTime
|
||||
this.isFinished = !!progress.isFinished
|
||||
this.hideFromContinueListening = !!progress.hideFromContinueListening
|
||||
this.ebookLocation = progress.ebookLocation || null
|
||||
this.ebookProgress = progress.ebookProgress
|
||||
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)
|
||||
}
|
||||
|
||||
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,7 +18,6 @@ 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
|
||||
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
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
|
||||
|
|
@ -343,33 +335,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
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ 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,7 +51,6 @@ class ApiRouter {
|
|||
this.cronManager = Server.cronManager
|
||||
this.notificationManager = Server.notificationManager
|
||||
this.taskManager = Server.taskManager
|
||||
this.eBookManager = Server.eBookManager
|
||||
|
||||
this.bookFinder = new BookFinder()
|
||||
this.authorFinder = new AuthorFinder()
|
||||
|
|
@ -100,6 +98,7 @@ class ApiRouter {
|
|||
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,6 +112,7 @@ 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.delete('/items/:id/file/:ino', LibraryItemController.middleware.bind(this), LibraryItemController.deleteLibraryFile.bind(this))
|
||||
|
||||
this.router.post('/items/batch/delete', LibraryItemController.batchDelete.bind(this))
|
||||
this.router.post('/items/batch/update', LibraryItemController.batchUpdate.bind(this))
|
||||
|
|
@ -176,7 +176,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))
|
||||
|
|
@ -217,6 +216,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
|
||||
|
|
@ -271,9 +271,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))
|
||||
|
||||
//
|
||||
// RSS Feed Routes (Admin and up)
|
||||
|
|
@ -283,13 +284,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
|
||||
//
|
||||
|
|
@ -339,10 +333,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)
|
||||
|
|
@ -507,11 +498,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])
|
||||
|
|
@ -531,11 +527,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])
|
||||
|
|
|
|||
|
|
@ -221,7 +221,6 @@ class MediaFileScanner {
|
|||
*/
|
||||
async scanMediaFiles(mediaLibraryFiles, libraryItem, libraryScan = null) {
|
||||
const preferAudioMetadata = libraryScan ? !!libraryScan.preferAudioMetadata : !!global.ServerSettings.scannerPreferAudioMetadata
|
||||
const preferOverdriveMediaMarker = !!global.ServerSettings.scannerPreferOverdriveMediaMarker
|
||||
|
||||
let hasUpdated = false
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ class Scanner {
|
|||
|
||||
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
|
||||
}
|
||||
|
|
@ -173,7 +173,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,11 +200,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`)
|
||||
Logger.warn(`[Scanner] Library item "${libraryItem.media.metadata.title}" is missing (inode "${libraryItem.ino}")`)
|
||||
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
|
||||
|
|
@ -632,7 +643,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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,68 @@ 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
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -41,7 +41,7 @@ function extractCategories(channel) {
|
|||
}
|
||||
|
||||
function extractPodcastMetadata(channel) {
|
||||
var metadata = {
|
||||
const metadata = {
|
||||
image: extractImage(channel),
|
||||
categories: extractCategories(channel),
|
||||
feedUrl: null,
|
||||
|
|
@ -62,22 +62,24 @@ function extractPodcastMetadata(channel) {
|
|||
metadata.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription)
|
||||
}
|
||||
|
||||
var arrayFields = ['title', 'language', 'itunes:explicit', 'itunes:author', 'pubDate', 'link', 'itunes:type']
|
||||
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]['$']
|
||||
}
|
||||
|
|
@ -89,6 +91,12 @@ function extractEpisodeData(item) {
|
|||
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') || ''
|
||||
|
|
@ -107,9 +115,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
|
||||
|
|
@ -131,14 +139,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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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_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'),
|
||||
|
|
@ -179,9 +184,12 @@ function parseTags(format, verbose) {
|
|||
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_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,78 +1,8 @@
|
|||
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) {
|
||||
const bookMetadata = libraryItem.media.metadata
|
||||
const coverPath = libraryItem.media.coverPath
|
||||
|
||||
|
|
@ -133,10 +63,20 @@ module.exports.writeToneMetadataJsonFile = (libraryItem, chapters, filePath, tra
|
|||
metadataObject['chapters'] = metadataChapters
|
||||
}
|
||||
|
||||
return metadataObject
|
||||
}
|
||||
module.exports.getToneMetadataObject = getToneMetadataObject
|
||||
|
||||
module.exports.writeToneMetadataJsonFile = (libraryItem, chapters, filePath, trackTotal) => {
|
||||
const metadataObject = getToneMetadataObject(libraryItem, chapters, trackTotal)
|
||||
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