Merge remote-tracking branch 'origin/master' into auth_passportjs

This commit is contained in:
lukeIam 2023-04-14 20:27:43 +02:00
commit 812395b21b
90 changed files with 3469 additions and 1148 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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