Merge branch 'refs/heads/master' into mf/rssInboundManager

# Conflicts:
#	client/components/widgets/PodcastDetailsEdit.vue
#	client/pages/config/rss-feeds.vue
#	client/strings/en-us.json
#	server/controllers/PodcastController.js
#	server/models/Podcast.js
#	server/routers/ApiRouter.js
#	server/utils/queries/libraryItemsPodcastFilters.js
This commit is contained in:
mfcar 2024-11-01 15:17:06 +00:00
commit 2d2c6242dd
No known key found for this signature in database
462 changed files with 33361 additions and 21220 deletions

View file

@ -1,235 +1,289 @@
const Path = require('path')
const fs = require('../libs/fsExtra')
const workerThreads = require('worker_threads')
const Logger = require('../Logger')
const TaskManager = require('./TaskManager')
const Task = require('../objects/Task')
const { writeConcatFile } = require('../utils/ffmpegHelpers')
const toneHelpers = require('../utils/toneHelpers')
const ffmpegHelpers = require('../utils/ffmpegHelpers')
const Ffmpeg = require('../libs/fluentFfmpeg')
const SocketAuthority = require('../SocketAuthority')
const { isWritable, copyToExisting } = require('../utils/fileUtils')
const TrackProgressMonitor = require('../objects/TrackProgressMonitor')
/**
* @typedef AbMergeEncodeOptions
* @property {string} codec
* @property {string} channels
* @property {string} bitrate
*/
class AbMergeManager {
constructor() {
this.itemsCacheDir = Path.join(global.MetadataPath, 'cache/items')
/** @type {Task[]} */
this.pendingTasks = []
}
/**
*
* @param {string} libraryItemId
* @returns {Task|null}
*/
getPendingTaskByLibraryItemId(libraryItemId) {
return this.pendingTasks.find(t => t.task.data.libraryItemId === libraryItemId)
return this.pendingTasks.find((t) => t.task.data.libraryItemId === libraryItemId)
}
/**
* Cancel and fail running task
*
* @param {Task} task
* @returns {Promise<void>}
*/
cancelEncode(task) {
const taskFailedString = {
text: 'Task canceled by user',
key: 'MessageTaskCanceledByUser'
}
task.setFailed(taskFailedString)
return this.removeTask(task, true)
}
async startAudiobookMerge(user, libraryItem, options = {}) {
/**
*
* @param {string} userId
* @param {import('../objects/LibraryItem')} libraryItem
* @param {AbMergeEncodeOptions} [options={}]
*/
async startAudiobookMerge(userId, libraryItem, options = {}) {
const task = new Task()
const audiobookDirname = Path.basename(libraryItem.path)
const targetFilename = audiobookDirname + '.m4b'
const audiobookBaseName = libraryItem.isFile ? Path.basename(libraryItem.path, Path.extname(libraryItem.path)) : Path.basename(libraryItem.path)
const targetFilename = audiobookBaseName + '.m4b'
const itemCachePath = Path.join(this.itemsCacheDir, libraryItem.id)
const tempFilepath = Path.join(itemCachePath, targetFilename)
const ffmetadataPath = Path.join(itemCachePath, 'ffmetadata.txt')
const libraryItemDir = libraryItem.isFile ? Path.dirname(libraryItem.path) : libraryItem.path
const taskData = {
libraryItemId: libraryItem.id,
libraryItemPath: libraryItem.path,
userId: user.id,
originalTrackPaths: libraryItem.media.tracks.map(t => t.metadata.path),
libraryItemDir,
userId,
originalTrackPaths: libraryItem.media.tracks.map((t) => t.metadata.path),
inos: libraryItem.media.includedAudioFiles.map((f) => f.ino),
tempFilepath,
targetFilename,
targetFilepath: Path.join(libraryItem.path, targetFilename),
targetFilepath: Path.join(libraryItemDir, targetFilename),
itemCachePath,
toneJsonObject: null
ffmetadataObject: ffmpegHelpers.getFFMetadataObject(libraryItem, 1),
chapters: libraryItem.media.chapters?.map((c) => ({ ...c })),
coverPath: libraryItem.media.coverPath,
ffmetadataPath,
duration: libraryItem.media.duration,
encodeOptions: options
}
const taskDescription = `Encoding audiobook "${libraryItem.media.metadata.title}" into a single m4b file.`
task.setData('encode-m4b', 'Encoding M4b', taskDescription, false, taskData)
const taskTitleString = {
text: 'Encoding M4b',
key: 'MessageTaskEncodingM4b'
}
const taskDescriptionString = {
text: `Encoding audiobook "${libraryItem.media.metadata.title}" into a single m4b file.`,
key: 'MessageTaskEncodingM4bDescription',
subs: [libraryItem.media.metadata.title]
}
task.setData('encode-m4b', taskTitleString, taskDescriptionString, false, taskData)
TaskManager.addTask(task)
Logger.info(`Start m4b encode for ${libraryItem.id} - TaskId: ${task.id}`)
if (!await fs.pathExists(taskData.itemCachePath)) {
if (!(await fs.pathExists(taskData.itemCachePath))) {
await fs.mkdir(taskData.itemCachePath)
}
this.runAudiobookMerge(libraryItem, task, options || {})
}
/**
*
* @param {import('../objects/LibraryItem')} libraryItem
* @param {Task} task
* @param {AbMergeEncodeOptions} encodingOptions
*/
async runAudiobookMerge(libraryItem, task, encodingOptions) {
const audioBitrate = encodingOptions.bitrate || '128k'
const audioCodec = encodingOptions.codec || 'aac'
const audioChannels = encodingOptions.channels || 2
// If changing audio file type then encoding is needed
const audioTracks = libraryItem.media.tracks
// TODO: Updated in 2.2.11 to always encode even if merging multiple m4b. This is because just using the file extension as was being done before is not enough. This can be an option or do more to check if a concat is possible.
// const audioRequiresEncode = audioTracks[0].metadata.ext !== '.m4b'
const audioRequiresEncode = true
const firstTrackIsM4b = audioTracks[0].metadata.ext.toLowerCase() === '.m4b'
const isOneTrack = audioTracks.length === 1
const ffmpegInputs = []
if (!isOneTrack) {
const concatFilePath = Path.join(task.data.itemCachePath, 'files.txt')
await writeConcatFile(audioTracks, concatFilePath)
ffmpegInputs.push({
input: concatFilePath,
options: ['-safe 0', '-f concat']
})
} else {
ffmpegInputs.push({
input: audioTracks[0].metadata.path,
options: firstTrackIsM4b ? ['-f mp4'] : []
})
}
const logLevel = process.env.NODE_ENV === 'production' ? 'error' : 'warning'
let ffmpegOptions = [`-loglevel ${logLevel}`]
const ffmpegOutputOptions = ['-f mp4']
if (audioRequiresEncode) {
ffmpegOptions = ffmpegOptions.concat([
'-map 0:a',
`-acodec ${audioCodec}`,
`-ac ${audioChannels}`,
`-b:a ${audioBitrate}`
])
} else {
ffmpegOptions.push('-max_muxing_queue_size 1000')
if (isOneTrack && firstTrackIsM4b) {
ffmpegOptions.push('-c copy')
} else {
ffmpegOptions.push('-c:a copy')
// Make sure the target directory is writable
if (!(await isWritable(task.data.libraryItemDir))) {
Logger.error(`[AbMergeManager] Target directory is not writable: ${task.data.libraryItemDir}`)
const taskFailedString = {
text: 'Target directory is not writable',
key: 'MessageTaskTargetDirectoryNotWritable'
}
}
let toneJsonPath = null
try {
toneJsonPath = Path.join(task.data.itemCachePath, 'metadata.json')
await toneHelpers.writeToneMetadataJsonFile(libraryItem, libraryItem.media.chapters, toneJsonPath, 1, 'audio/mp4')
} catch (error) {
Logger.error(`[AbMergeManager] Write metadata.json failed`, error)
toneJsonPath = null
}
task.data.toneJsonObject = {
'ToneJsonFile': toneJsonPath,
'TrackNumber': 1,
}
if (libraryItem.media.coverPath) {
task.data.toneJsonObject['CoverFile'] = libraryItem.media.coverPath
}
const workerData = {
inputs: ffmpegInputs,
options: ffmpegOptions,
outputOptions: ffmpegOutputOptions,
output: task.data.tempFilepath
}
let worker = null
try {
const workerPath = Path.join(global.appRoot, 'server/utils/downloadWorker.js')
worker = new workerThreads.Worker(workerPath, { workerData })
} catch (error) {
Logger.error(`[AbMergeManager] Start worker thread failed`, error)
task.setFailed('Failed to start worker thread')
task.setFailed(taskFailedString)
this.removeTask(task, true)
return
}
worker.on('message', (message) => {
if (message != null && typeof message === 'object') {
if (message.type === 'RESULT') {
this.sendResult(task, message)
} else if (message.type === 'FFMPEG') {
if (Logger[message.level]) {
Logger[message.level](message.log)
}
}
// Create ffmetadata file
if (!(await ffmpegHelpers.writeFFMetadataFile(task.data.ffmetadataObject, task.data.chapters, task.data.ffmetadataPath))) {
Logger.error(`[AudioMetadataManager] Failed to write ffmetadata file for audiobook "${task.data.libraryItemId}"`)
const taskFailedString = {
text: 'Failed to write metadata file',
key: 'MessageTaskFailedToWriteMetadataFile'
}
})
task.setFailed(taskFailedString)
this.removeTask(task, true)
return
}
this.pendingTasks.push({
id: task.id,
task,
worker
task
})
}
async sendResult(task, result) {
// Remove pending task
this.pendingTasks = this.pendingTasks.filter(d => d.id !== task.id)
if (result.isKilled) {
task.setFailed('Ffmpeg task killed')
this.removeTask(task, true)
return
}
if (!result.success) {
task.setFailed('Encoding failed')
this.removeTask(task, true)
const encodeFraction = 0.95
const embedFraction = 1 - encodeFraction
try {
const trackProgressMonitor = new TrackProgressMonitor(
libraryItem.media.tracks.map((t) => t.duration),
(trackIndex) => SocketAuthority.adminEmitter('track_started', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex] }),
(trackIndex, progressInTrack, taskProgress) => {
SocketAuthority.adminEmitter('track_progress', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex], progress: progressInTrack })
SocketAuthority.adminEmitter('task_progress', { libraryItemId: libraryItem.id, progress: taskProgress * encodeFraction })
},
(trackIndex) => SocketAuthority.adminEmitter('track_finished', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex] })
)
task.data.ffmpeg = new Ffmpeg()
await ffmpegHelpers.mergeAudioFiles(libraryItem.media.tracks, task.data.duration, task.data.itemCachePath, task.data.tempFilepath, encodingOptions, (progress) => trackProgressMonitor.update(progress), task.data.ffmpeg)
delete task.data.ffmpeg
trackProgressMonitor.finish()
} catch (error) {
if (error.message === 'FFMPEG_CANCELED') {
Logger.info(`[AbMergeManager] Task cancelled ${task.id}`)
} else {
Logger.error(`[AbMergeManager] mergeAudioFiles failed`, error)
const taskFailedString = {
text: 'Failed to merge audio files',
key: 'MessageTaskFailedToMergeAudioFiles'
}
task.setFailed(taskFailedString)
this.removeTask(task, true)
}
return
}
// Write metadata to merged file
const success = await toneHelpers.tagAudioFile(task.data.tempFilepath, task.data.toneJsonObject)
if (!success) {
Logger.error(`[AbMergeManager] Failed to write metadata to file "${task.data.tempFilepath}"`)
task.setFailed('Failed to write metadata to m4b file')
this.removeTask(task, true)
try {
task.data.ffmpeg = new Ffmpeg()
await ffmpegHelpers.addCoverAndMetadataToFile(
task.data.tempFilepath,
task.data.coverPath,
task.data.ffmetadataPath,
1,
'audio/mp4',
(progress) => {
Logger.debug(`[AbMergeManager] Embedding metadata progress: ${100 * encodeFraction + progress * embedFraction}`)
SocketAuthority.adminEmitter('task_progress', { libraryItemId: libraryItem.id, progress: 100 * encodeFraction + progress * embedFraction })
},
task.data.ffmpeg
)
delete task.data.ffmpeg
} catch (error) {
if (error.message === 'FFMPEG_CANCELED') {
Logger.info(`[AbMergeManager] Task cancelled ${task.id}`)
} else {
Logger.error(`[AbMergeManager] Failed to embed metadata in file "${task.data.tempFilepath}"`)
const taskFailedString = {
text: `Failed to embed metadata in file ${Path.basename(task.data.tempFilepath)}`,
key: 'MessageTaskFailedToEmbedMetadataInFile',
subs: [Path.basename(task.data.tempFilepath)]
}
task.setFailed(taskFailedString)
this.removeTask(task, true)
}
return
}
// Move library item tracks to cache
for (const trackPath of task.data.originalTrackPaths) {
for (const [index, trackPath] of task.data.originalTrackPaths.entries()) {
const trackFilename = Path.basename(trackPath)
const moveToPath = Path.join(task.data.itemCachePath, trackFilename)
Logger.debug(`[AbMergeManager] Backing up original track "${trackPath}" to ${moveToPath}`)
await fs.move(trackPath, moveToPath, { overwrite: true }).catch((err) => {
Logger.error(`[AbMergeManager] Failed to move track "${trackPath}" to "${moveToPath}"`, err)
})
if (index === 0) {
// copy the first track to the cache directory
await fs.copy(trackPath, moveToPath).catch((err) => {
Logger.error(`[AbMergeManager] Failed to copy track "${trackPath}" to "${moveToPath}"`, err)
})
} else {
// move the rest of the tracks to the cache directory
await fs.move(trackPath, moveToPath, { overwrite: true }).catch((err) => {
Logger.error(`[AbMergeManager] Failed to move track "${trackPath}" to "${moveToPath}"`, err)
})
}
}
// Move m4b to target
// Move m4b to target, preserving the original track's permissions
Logger.debug(`[AbMergeManager] Moving m4b from ${task.data.tempFilepath} to ${task.data.targetFilepath}`)
await fs.move(task.data.tempFilepath, task.data.targetFilepath)
try {
await copyToExisting(task.data.tempFilepath, task.data.originalTrackPaths[0])
await fs.rename(task.data.originalTrackPaths[0], task.data.targetFilepath)
await fs.remove(task.data.tempFilepath)
} catch (err) {
Logger.error(`[AbMergeManager] Failed to move m4b from ${task.data.tempFilepath} to ${task.data.targetFilepath}`, err)
const taskFailedString = {
text: 'Failed to move m4b file',
key: 'MessageTaskFailedToMoveM4bFile'
}
task.setFailed(taskFailedString)
this.removeTask(task, true)
return
}
// Remove ffmetadata file
await fs.remove(task.data.ffmetadataPath)
task.setFinished()
await this.removeTask(task, false)
Logger.info(`[AbMergeManager] Ab task finished ${task.id}`)
}
/**
* Remove ab merge task
*
* @param {Task} task
* @param {boolean} [removeTempFilepath=false]
*/
async removeTask(task, removeTempFilepath = false) {
Logger.info('[AbMergeManager] Removing task ' + task.id)
const pendingDl = this.pendingTasks.find(d => d.id === task.id)
if (pendingDl) {
this.pendingTasks = this.pendingTasks.filter(d => d.id !== task.id)
if (pendingDl.worker) {
Logger.warn(`[AbMergeManager] Removing download in progress - stopping worker`)
try {
pendingDl.worker.postMessage('STOP')
return
} catch (error) {
Logger.error('[AbMergeManager] Error posting stop message to worker', error)
}
} else {
Logger.debug(`[AbMergeManager] Removing download in progress - no worker`)
const pendingTask = this.pendingTasks.find((d) => d.id === task.id)
if (pendingTask) {
this.pendingTasks = this.pendingTasks.filter((d) => d.id !== task.id)
if (task.data.ffmpeg) {
Logger.warn(`[AbMergeManager] Killing ffmpeg process for task ${task.id}`)
task.data.ffmpeg.kill()
// wait for ffmpeg to exit, so that the output file is unlocked
await new Promise((resolve) => setTimeout(resolve, 500))
}
}
if (removeTempFilepath) { // On failed tasks remove the bad file if it exists
if (removeTempFilepath) {
// On failed tasks remove the bad file if it exists
if (await fs.pathExists(task.data.tempFilepath)) {
await fs.remove(task.data.tempFilepath).then(() => {
Logger.info('[AbMergeManager] Deleted target file', task.data.tempFilepath)
}).catch((err) => {
Logger.error('[AbMergeManager] Failed to delete target file', err)
})
await fs
.remove(task.data.tempFilepath)
.then(() => {
Logger.info('[AbMergeManager] Deleted target file', task.data.tempFilepath)
})
.catch((err) => {
Logger.error('[AbMergeManager] Failed to delete target file', err)
})
}
if (await fs.pathExists(task.data.ffmetadataPath)) {
await fs
.remove(task.data.ffmetadataPath)
.then(() => {
Logger.info('[AbMergeManager] Deleted ffmetadata file', task.data.ffmetadataPath)
})
.catch((err) => {
Logger.error('[AbMergeManager] Failed to delete ffmetadata file', err)
})
}
}

View file

@ -3,8 +3,7 @@ const Logger = require('../Logger')
const Database = require('../Database')
class ApiCacheManager {
defaultCacheOptions = { max: 1000, maxSize: 10 * 1000 * 1000, sizeCalculation: item => (item.body.length + JSON.stringify(item.headers).length) }
defaultCacheOptions = { max: 1000, maxSize: 10 * 1000 * 1000, sizeCalculation: (item) => item.body.length + JSON.stringify(item.headers).length }
defaultTtlOptions = { ttl: 30 * 60 * 1000 }
constructor(cache = new LRUCache(this.defaultCacheOptions), ttlOptions = this.defaultTtlOptions) {
@ -14,7 +13,7 @@ class ApiCacheManager {
init(database = Database) {
let hooks = ['afterCreate', 'afterUpdate', 'afterDestroy', 'afterBulkCreate', 'afterBulkUpdate', 'afterBulkDestroy', 'afterUpsert']
hooks.forEach(hook => database.sequelize.addHook(hook, (model) => this.clear(model, hook)))
hooks.forEach((hook) => database.sequelize.addHook(hook, (model) => this.clear(model, hook)))
}
clear(model, hook) {
@ -33,7 +32,16 @@ class ApiCacheManager {
}
get middleware() {
/**
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
return (req, res, next) => {
if (req.query.sort === 'random') {
Logger.debug(`[ApiCacheManager] Skipping cache for random sort`)
return next()
}
const key = { user: req.user.username, url: req.url }
const stringifiedKey = JSON.stringify(key)
Logger.debug(`[ApiCacheManager] count: ${this.cache.size} size: ${this.cache.calculatedSize}`)
@ -61,4 +69,4 @@ class ApiCacheManager {
}
}
}
module.exports = ApiCacheManager
module.exports = ApiCacheManager

View file

@ -1,15 +1,17 @@
const Path = require('path')
const SocketAuthority = require('../SocketAuthority')
const Logger = require('../Logger')
const fs = require('../libs/fsExtra')
const toneHelpers = require('../utils/toneHelpers')
const ffmpegHelpers = require('../utils/ffmpegHelpers')
const TaskManager = require('./TaskManager')
const Task = require('../objects/Task')
const fileUtils = require('../utils/fileUtils')
/**
* @typedef UpdateMetadataOptions
* @property {boolean} [forceEmbedChapters=false] - Whether to force embed chapters.
* @property {boolean} [backup=false] - Whether to backup the files.
*/
class AudioMetadataMangaer {
constructor() {
@ -21,31 +23,40 @@ class AudioMetadataMangaer {
}
/**
* Get queued task data
* @return {Array}
*/
* Get queued task data
* @return {Array}
*/
getQueuedTaskData() {
return this.tasksQueued.map(t => t.data)
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)
return this.tasksQueued.some((t) => t.data.libraryItemId === libraryItemId) || this.tasksRunning.some((t) => t.data.libraryItemId === libraryItemId)
}
getToneMetadataObjectForApi(libraryItem) {
const audioFiles = libraryItem.media.includedAudioFiles
let mimeType = audioFiles[0].mimeType
if (audioFiles.some(a => a.mimeType !== mimeType)) mimeType = null
return toneHelpers.getToneMetadataObject(libraryItem, libraryItem.media.chapters, libraryItem.media.tracks.length, mimeType)
getMetadataObjectForApi(libraryItem) {
return ffmpegHelpers.getFFMetadataObject(libraryItem, libraryItem.media.includedAudioFiles.length)
}
handleBatchEmbed(user, libraryItems, options = {}) {
/**
*
* @param {string} userId
* @param {*} libraryItems
* @param {*} options
*/
handleBatchEmbed(userId, libraryItems, options = {}) {
libraryItems.forEach((li) => {
this.updateMetadataForItem(user, li, options)
this.updateMetadataForItem(userId, li, options)
})
}
async updateMetadataForItem(user, libraryItem, options = {}) {
/**
*
* @param {string} userId
* @param {import('../objects/LibraryItem')} libraryItem
* @param {UpdateMetadataOptions} [options={}]
*/
async updateMetadataForItem(userId, libraryItem, options = {}) {
const forceEmbedChapters = !!options.forceEmbedChapters
const backupFiles = !!options.backup
@ -56,36 +67,47 @@ class AudioMetadataMangaer {
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
const chapters = audioFiles.length == 1 || forceEmbedChapters ? libraryItem.media.chapters.map((c) => ({ ...c })) : null
let mimeType = audioFiles[0].mimeType
if (audioFiles.some(a => a.mimeType !== mimeType)) mimeType = null
if (audioFiles.some((a) => a.mimeType !== mimeType)) mimeType = null
// Create task
const libraryItemDir = libraryItem.isFile ? Path.dirname(libraryItem.path) : libraryItem.path
const taskData = {
libraryItemId: libraryItem.id,
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)
}
)),
libraryItemDir,
userId,
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),
duration: af.duration
})),
coverPath: libraryItem.media.coverPath,
metadataObject: toneHelpers.getToneMetadataObject(libraryItem, chapters, audioFiles.length, mimeType),
metadataObject: ffmpegHelpers.getFFMetadataObject(libraryItem, audioFiles.length),
itemCachePath,
chapters,
mimeType,
options: {
forceEmbedChapters,
backupFiles
}
},
duration: libraryItem.media.duration
}
const taskDescription = `Embedding metadata in audiobook "${libraryItem.media.metadata.title}".`
task.setData('embed-metadata', 'Embedding Metadata', taskDescription, false, taskData)
const taskTitleString = {
text: 'Embedding Metadata',
key: 'MessageTaskEmbeddingMetadata'
}
const taskDescriptionString = {
text: `Embedding metadata in audiobook "${libraryItem.media.metadata.title}".`,
key: 'MessageTaskEmbeddingMetadataDescription',
subs: [libraryItem.media.metadata.title]
}
task.setData('embed-metadata', taskTitleString, taskDescriptionString, false, taskData)
if (this.tasksRunning.length >= this.MAX_CONCURRENT_TASKS) {
Logger.info(`[AudioMetadataManager] Queueing embed metadata for audiobook "${libraryItem.media.metadata.title}"`)
@ -99,33 +121,84 @@ class AudioMetadataMangaer {
}
}
/**
*
* @param {import('../objects/Task')} task
*/
async runMetadataEmbed(task) {
this.tasksRunning.push(task)
TaskManager.addTask(task)
Logger.info(`[AudioMetadataManager] Starting metadata embed task`, task.description)
// Ensure item cache dir exists
let cacheDirCreated = false
if (!await fs.pathExists(task.data.itemCachePath)) {
await fs.mkdir(task.data.itemCachePath)
cacheDirCreated = true
// Ensure target directory is writable
const targetDirWritable = await fileUtils.isWritable(task.data.libraryItemDir)
Logger.debug(`[AudioMetadataManager] Target directory ${task.data.libraryItemDir} writable: ${targetDirWritable}`)
if (!targetDirWritable) {
Logger.error(`[AudioMetadataManager] Target directory is not writable: ${task.data.libraryItemDir}`)
const taskFailedString = {
text: 'Target directory is not writable',
key: 'MessageTaskTargetDirectoryNotWritable'
}
task.setFailed(taskFailedString)
this.handleTaskFinished(task)
return
}
// Create metadata json file
const toneJsonPath = Path.join(task.data.itemCachePath, 'metadata.json')
try {
await fs.writeFile(toneJsonPath, JSON.stringify({ meta: task.data.metadataObject }, null, 2))
} catch (error) {
Logger.error(`[AudioMetadataManager] Write metadata.json failed`, error)
task.setFailed('Failed to write metadata.json')
// Ensure target audio files are writable
for (const af of task.data.audioFiles) {
try {
await fs.access(af.path, fs.constants.W_OK)
} catch (err) {
Logger.error(`[AudioMetadataManager] Audio file is not writable: ${af.path}`)
const taskFailedString = {
text: `Audio file "${Path.basename(af.path)}" is not writable`,
key: 'MessageTaskAudioFileNotWritable',
subs: [Path.basename(af.path)]
}
task.setFailed(taskFailedString)
this.handleTaskFinished(task)
return
}
}
// Ensure item cache dir exists
let cacheDirCreated = false
if (!(await fs.pathExists(task.data.itemCachePath))) {
try {
await fs.mkdir(task.data.itemCachePath)
cacheDirCreated = true
} catch (err) {
Logger.error(`[AudioMetadataManager] Failed to create cache directory ${task.data.itemCachePath}`, err)
const taskFailedString = {
text: 'Failed to create cache directory',
key: 'MessageTaskFailedToCreateCacheDirectory'
}
task.setFailed(taskFailedString)
this.handleTaskFinished(task)
return
}
}
// Create ffmetadata file
const ffmetadataPath = Path.join(task.data.itemCachePath, 'ffmetadata.txt')
const success = await ffmpegHelpers.writeFFMetadataFile(task.data.metadataObject, task.data.chapters, ffmetadataPath)
if (!success) {
Logger.error(`[AudioMetadataManager] Failed to write ffmetadata file for audiobook "${task.data.libraryItemId}"`)
const taskFailedString = {
text: 'Failed to write metadata file',
key: 'MessageTaskFailedToWriteMetadataFile'
}
task.setFailed(taskFailedString)
this.handleTaskFinished(task)
return
}
// Tag audio files
let cummulativeProgress = 0
for (const af of task.data.audioFiles) {
SocketAuthority.adminEmitter('audiofile_metadata_started', {
const audioFileRelativeDuration = af.duration / task.data.duration
SocketAuthority.adminEmitter('track_started', {
libraryItemId: task.data.libraryItemId,
ino: af.ino
})
@ -138,27 +211,41 @@ class AudioMetadataMangaer {
Logger.debug(`[AudioMetadataManager] Backed up audio file at "${backupFilePath}"`)
} catch (err) {
Logger.error(`[AudioMetadataManager] Failed to backup audio file "${af.path}"`, err)
const taskFailedString = {
text: `Failed to backup audio file "${Path.basename(af.path)}"`,
key: 'MessageTaskFailedToBackupAudioFile',
subs: [Path.basename(af.path)]
}
task.setFailed(taskFailedString)
this.handleTaskFinished(task)
return
}
}
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) {
try {
await ffmpegHelpers.addCoverAndMetadataToFile(af.path, task.data.coverPath, ffmetadataPath, af.index, task.data.mimeType, (progress) => {
SocketAuthority.adminEmitter('task_progress', { libraryItemId: task.data.libraryItemId, progress: cummulativeProgress + progress * audioFileRelativeDuration })
SocketAuthority.adminEmitter('track_progress', { libraryItemId: task.data.libraryItemId, ino: af.ino, progress })
})
Logger.info(`[AudioMetadataManager] Successfully tagged audio file "${af.path}"`)
} catch (err) {
Logger.error(`[AudioMetadataManager] Failed to tag audio file "${af.path}"`, err)
const taskFailedString = {
text: `Failed to embed metadata in file "${Path.basename(af.path)}"`,
key: 'MessageTaskFailedToEmbedMetadataInFile',
subs: [Path.basename(af.path)]
}
task.setFailed(taskFailedString)
this.handleTaskFinished(task)
return
}
SocketAuthority.adminEmitter('audiofile_metadata_finished', {
SocketAuthority.adminEmitter('track_finished', {
libraryItemId: task.data.libraryItemId,
ino: af.ino
})
cummulativeProgress += audioFileRelativeDuration * 100
}
// Remove temp cache file/folder if not backing up
@ -167,7 +254,7 @@ class AudioMetadataMangaer {
if (cacheDirCreated) {
await fs.remove(task.data.itemCachePath)
} else {
await fs.remove(toneJsonPath)
await fs.remove(ffmetadataPath)
}
}
@ -177,7 +264,7 @@ class AudioMetadataMangaer {
handleTaskFinished(task) {
TaskManager.taskFinished(task)
this.tasksRunning = this.tasksRunning.filter(t => t.id !== task.id)
this.tasksRunning = this.tasksRunning.filter((t) => t.id !== task.id)
if (this.tasksRunning.length < this.MAX_CONCURRENT_TASKS && this.tasksQueued.length) {
Logger.info(`[AudioMetadataManager] Task finished and dequeueing next task. ${this.tasksQueued} tasks queued.`)

View file

@ -14,10 +14,11 @@ const fileUtils = require('../utils/fileUtils')
const { getFileSize } = require('../utils/fileUtils')
const Backup = require('../objects/Backup')
const CacheManager = require('./CacheManager')
const NotificationManager = require('./NotificationManager')
class BackupManager {
constructor() {
this.BackupPath = Path.join(global.MetadataPath, 'backups')
this.ItemsMetadataPath = Path.join(global.MetadataPath, 'items')
this.AuthorsMetadataPath = Path.join(global.MetadataPath, 'authors')
@ -26,8 +27,12 @@ class BackupManager {
this.backups = []
}
get backupLocation() {
return this.BackupPath
get backupPath() {
return global.ServerSettings.backupPath
}
get backupPathEnvSet() {
return !!process.env.BACKUP_PATH
}
get backupSchedule() {
@ -39,19 +44,29 @@ class BackupManager {
}
get maxBackupSize() {
return global.ServerSettings.maxBackupSize || 1
return global.ServerSettings.maxBackupSize || Infinity
}
async init() {
const backupsDirExists = await fs.pathExists(this.BackupPath)
const backupsDirExists = await fs.pathExists(this.backupPath)
if (!backupsDirExists) {
await fs.ensureDir(this.BackupPath)
await fs.ensureDir(this.backupPath)
}
await this.loadBackups()
this.scheduleCron()
}
/**
* Reload backups after updating backup path
*/
async reload() {
Logger.info(`[BackupManager] Reloading backups with backup path "${this.backupPath}"`)
this.backups = []
await this.loadBackups()
this.updateCronSchedule()
}
scheduleCron() {
if (!this.backupSchedule) {
Logger.info(`[BackupManager] Auto Backups are disabled`)
@ -87,11 +102,14 @@ class BackupManager {
return res.status(500).send('Invalid backup file')
}
const tempPath = Path.join(this.BackupPath, fileUtils.sanitizeFilename(backupFile.name))
const success = await backupFile.mv(tempPath).then(() => true).catch((error) => {
Logger.error('[BackupManager] Failed to move backup file', path, error)
return false
})
const tempPath = Path.join(this.backupPath, fileUtils.sanitizeFilename(backupFile.name))
const success = await backupFile
.mv(tempPath)
.then(() => true)
.catch((error) => {
Logger.error('[BackupManager] Failed to move backup file', path, error)
return false
})
if (!success) {
return res.status(500).send('Failed to move backup file into backups directory')
}
@ -122,7 +140,7 @@ class BackupManager {
backup.fileSize = await getFileSize(backup.fullPath)
const existingBackupIndex = this.backups.findIndex(b => b.id === backup.id)
const existingBackupIndex = this.backups.findIndex((b) => b.id === backup.id)
if (existingBackupIndex >= 0) {
Logger.warn(`[BackupManager] Backup already exists with id ${backup.id} - overwriting`)
this.backups.splice(existingBackupIndex, 1, backup)
@ -131,7 +149,7 @@ class BackupManager {
}
res.json({
backups: this.backups.map(b => b.toJSON())
backups: this.backups.map((b) => b.toJSON())
})
}
@ -139,7 +157,7 @@ class BackupManager {
var backupSuccess = await this.runBackup()
if (backupSuccess) {
res.json({
backups: this.backups.map(b => b.toJSON())
backups: this.backups.map((b) => b.toJSON())
})
} else {
res.sendStatus(500)
@ -147,10 +165,10 @@ class BackupManager {
}
/**
*
* @param {import('./ApiCacheManager')} apiCacheManager
* @param {Backup} backup
* @param {import('express').Response} res
*
* @param {import('./ApiCacheManager')} apiCacheManager
* @param {Backup} backup
* @param {import('express').Response} res
*/
async requestApplyBackup(apiCacheManager, backup, res) {
Logger.info(`[BackupManager] Applying backup at "${backup.fullPath}"`)
@ -176,7 +194,7 @@ class BackupManager {
Logger.info(`[BackupManager] Extracted backup sqlite db to temp path ${tempDbPath}`)
// Verify extract - Abandon backup if sqlite file did not extract
if (!await fs.pathExists(tempDbPath)) {
if (!(await fs.pathExists(tempDbPath))) {
Logger.error(`[BackupManager] Sqlite file not found after extract - abandon backup apply and reconnect db`)
await zip.close()
await Database.reconnect()
@ -200,7 +218,9 @@ class BackupManager {
Logger.info(`[BackupManager] Saved backup sqlite file at "${dbPath}"`)
// Extract /metadata/items and /metadata/authors folders
await fs.ensureDir(this.ItemsMetadataPath)
await zip.extract('metadata-items/', this.ItemsMetadataPath)
await fs.ensureDir(this.AuthorsMetadataPath)
await zip.extract('metadata-authors/', this.AuthorsMetadataPath)
await zip.close()
@ -210,6 +230,9 @@ class BackupManager {
// Reset api cache, set hooks again
await apiCacheManager.reset()
// Clear metadata cache
await CacheManager.purgeAll()
res.sendStatus(200)
// Triggers browser refresh for all clients
@ -218,12 +241,12 @@ class BackupManager {
async loadBackups() {
try {
const filesInDir = await fs.readdir(this.BackupPath)
const filesInDir = await fs.readdir(this.backupPath)
for (let i = 0; i < filesInDir.length; i++) {
const filename = filesInDir[i]
if (filename.endsWith('.audiobookshelf')) {
const fullFilePath = Path.join(this.BackupPath, filename)
const fullFilePath = Path.join(this.backupPath, filename)
let zip = null
let data = null
@ -239,14 +262,16 @@ class BackupManager {
const backup = new Backup({ details, fullPath: fullFilePath })
if (!backup.serverVersion) { // Backups before v2
if (!backup.serverVersion) {
// Backups before v2
Logger.error(`[BackupManager] Old unsupported backup was found "${backup.filename}"`)
} else if (!backup.key) { // Backups before sqlite migration
} else if (!backup.key) {
// Backups before sqlite migration
Logger.warn(`[BackupManager] Old unsupported backup was found "${backup.filename}" (pre sqlite migration)`)
}
backup.fileSize = await getFileSize(backup.fullPath)
const existingBackupWithId = this.backups.find(b => b.id === backup.id)
const existingBackupWithId = this.backups.find((b) => b.id === backup.id)
if (existingBackupWithId) {
Logger.warn(`[BackupManager] Backup already loaded with id ${backup.id} - ignoring`)
} else {
@ -267,13 +292,15 @@ class BackupManager {
// Check if Metadata Path is inside Config Path (otherwise there will be an infinite loop as the archiver tries to zip itself)
Logger.info(`[BackupManager] Running Backup`)
const newBackup = new Backup()
newBackup.setData(this.BackupPath)
newBackup.setData(this.backupPath)
await fs.ensureDir(this.AuthorsMetadataPath)
// Create backup sqlite file
const sqliteBackupPath = await this.backupSqliteDb(newBackup).catch((error) => {
Logger.error(`[BackupManager] Failed to backup sqlite db`, error)
const errorMsg = error?.message || error || 'Unknown Error'
NotificationManager.onBackupFailed(errorMsg)
return false
})
@ -284,6 +311,8 @@ class BackupManager {
// Zip sqlite file, /metadata/items, and /metadata/authors folders
const zipResult = await this.zipBackup(sqliteBackupPath, newBackup).catch((error) => {
Logger.error(`[BackupManager] Backup Failed ${error}`)
const errorMsg = error?.message || error || 'Unknown Error'
NotificationManager.onBackupFailed(errorMsg)
return false
})
@ -296,7 +325,7 @@ class BackupManager {
newBackup.fileSize = await getFileSize(newBackup.fullPath)
const existingIndex = this.backups.findIndex(b => b.id === newBackup.id)
const existingIndex = this.backups.findIndex((b) => b.id === newBackup.id)
if (existingIndex >= 0) {
this.backups.splice(existingIndex, 1, newBackup)
} else {
@ -304,13 +333,18 @@ class BackupManager {
}
// Check remove oldest backup
if (this.backups.length > this.backupsToKeep) {
const removeOldest = this.backups.length > this.backupsToKeep
if (removeOldest) {
this.backups.sort((a, b) => a.createdAt - b.createdAt)
const oldBackup = this.backups.shift()
Logger.debug(`[BackupManager] Removing old backup ${oldBackup.id}`)
this.removeBackup(oldBackup)
}
// Notification for backup successfully completed
NotificationManager.onBackupCompleted(newBackup, this.backups.length, removeOldest)
return true
}
@ -318,7 +352,7 @@ class BackupManager {
try {
Logger.debug(`[BackupManager] Removing Backup "${backup.fullPath}"`)
await fs.remove(backup.fullPath)
this.backups = this.backups.filter(b => b.id !== backup.id)
this.backups = this.backups.filter((b) => b.id !== backup.id)
Logger.info(`[BackupManager] Backup "${backup.id}" Removed`)
} catch (error) {
Logger.error(`[BackupManager] Failed to remove backup`, error)
@ -328,7 +362,6 @@ class BackupManager {
/**
* @see https://github.com/TryGhost/node-sqlite3/pull/1116
* @param {Backup} backup
* @promise
*/
backupSqliteDb(backup) {
const db = new sqlite3.Database(Database.dbPath)
@ -401,14 +434,16 @@ class BackupManager {
reject(err)
})
archive.on('progress', ({ fs: fsobj }) => {
const maxBackupSizeInBytes = this.maxBackupSize * 1000 * 1000 * 1000
if (fsobj.processedBytes > maxBackupSizeInBytes) {
Logger.error(`[BackupManager] Archiver is too large - aborting to prevent endless loop, Bytes Processed: ${fsobj.processedBytes}`)
archive.abort()
setTimeout(() => {
this.removeBackup(backup)
output.destroy('Backup too large') // Promise is reject in write stream error evt
}, 500)
if (this.maxBackupSize !== Infinity) {
const maxBackupSizeInBytes = this.maxBackupSize * 1000 * 1000 * 1000
if (fsobj.processedBytes > maxBackupSizeInBytes) {
Logger.error(`[BackupManager] Archiver is too large - aborting to prevent endless loop, Bytes Processed: ${fsobj.processedBytes}`)
archive.abort()
setTimeout(() => {
this.removeBackup(backup)
output.destroy('Backup too large') // Promise is reject in write stream error evt
}, 500)
}
}
})
@ -425,4 +460,4 @@ class BackupManager {
})
}
}
module.exports = BackupManager
module.exports = BackupManager

View file

@ -1,31 +1,348 @@
const child_process = require('child_process')
const { promisify } = require('util')
const exec = promisify(child_process.exec)
const os = require('os')
const axios = require('axios')
const path = require('path')
const which = require('../libs/which')
const fs = require('../libs/fsExtra')
const ffbinaries = require('../libs/ffbinaries')
const Logger = require('../Logger')
const fileUtils = require('../utils/fileUtils')
const StreamZip = require('../libs/nodeStreamZip')
class ZippedAssetDownloader {
constructor() {
this.assetCache = {}
}
getReleaseUrl(releaseTag) {
throw new Error('Not implemented')
}
extractAssetUrl(assets, assetName) {
throw new Error('Not implemented')
}
getAssetName(binaryName, releaseTag) {
throw new Error('Not implemented')
}
getAssetFileName(binaryName) {
throw new Error('Not implemented')
}
async getAssetUrl(releaseTag, assetName) {
// Check if the assets information is already cached for the release tag
if (this.assetCache[releaseTag]) {
Logger.debug(`[ZippedAssetDownloader] release ${releaseTag}: assets found in cache.`)
} else {
// Get the release information
const releaseUrl = this.getReleaseUrl(releaseTag)
const releaseResponse = await axios.get(releaseUrl, { headers: { 'User-Agent': 'axios' } })
// Cache the assets information for the release tag
this.assetCache[releaseTag] = releaseResponse.data
Logger.debug(`[ZippedAssetDownloader] release ${releaseTag}: assets fetched from API.`)
}
const assets = this.assetCache[releaseTag]
const assetUrl = this.extractAssetUrl(assets, assetName)
return assetUrl
}
async downloadAsset(assetUrl, destDir) {
const zipPath = path.join(destDir, 'temp.zip')
const writer = fs.createWriteStream(zipPath)
const assetResponse = await axios({ url: assetUrl, responseType: 'stream' })
assetResponse.data.pipe(writer)
await new Promise((resolve, reject) => {
writer.on('finish', () => {
Logger.debug(`[ZippedAssetDownloader] Downloaded asset ${assetUrl} to ${zipPath}`)
resolve()
})
writer.on('error', (err) => {
Logger.error(`[ZippedAssetDownloader] Error downloading asset ${assetUrl}: ${err.message}`)
reject(err)
})
})
return zipPath
}
async extractFiles(zipPath, filesToExtract, destDir) {
const zip = new StreamZip.async({ file: zipPath })
try {
for (const file of filesToExtract) {
const outputPath = path.join(destDir, file.outputFileName)
if (!(await zip.entry(file.pathInsideZip))) {
Logger.error(`[ZippedAssetDownloader] File ${file.pathInsideZip} not found in zip file ${zipPath}`)
continue
}
await zip.extract(file.pathInsideZip, outputPath)
Logger.debug(`[ZippedAssetDownloader] Extracted file ${file.pathInsideZip} to ${outputPath}`)
// Set executable permission for Linux
if (process.platform !== 'win32') {
await fs.chmod(outputPath, 0o755)
}
}
} catch (error) {
Logger.error('[ZippedAssetDownloader] Error extracting files:', error)
throw error
} finally {
await zip.close()
}
}
async downloadAndExtractFiles(releaseTag, assetName, filesToExtract, destDir) {
let zipPath
try {
await fs.ensureDir(destDir)
const assetUrl = await this.getAssetUrl(releaseTag, assetName)
zipPath = await this.downloadAsset(assetUrl, destDir)
await this.extractFiles(zipPath, filesToExtract, destDir)
} catch (error) {
Logger.error(`[ZippedAssetDownloader] Error downloading or extracting files: ${error.message}`)
} finally {
if (zipPath) await fs.remove(zipPath)
}
}
async downloadBinary(binaryName, releaseTag, destDir) {
const assetName = this.getAssetName(binaryName, releaseTag)
const fileName = this.getAssetFileName(binaryName)
const filesToExtract = [{ pathInsideZip: fileName, outputFileName: fileName }]
await this.downloadAndExtractFiles(releaseTag, assetName, filesToExtract, destDir)
}
}
class FFBinariesDownloader extends ZippedAssetDownloader {
constructor() {
super()
this.platformSuffix = this.getPlatformSuffix()
}
getPlatformSuffix() {
var type = os.type().toLowerCase()
var arch = os.arch().toLowerCase()
if (type === 'darwin') {
return 'osx-64'
}
if (type === 'windows_nt') {
return arch === 'x64' ? 'windows-64' : 'windows-32'
}
if (type === 'linux') {
if (arch === 'arm') return 'linux-armel'
if (arch === 'arm64') return 'linux-arm64'
return arch === 'x64' ? 'linux-64' : 'linux-32'
}
return null
}
getReleaseUrl(releaseTag) {
return `https://ffbinaries.com/api/v1/version/${releaseTag}`
}
extractAssetUrl(assets, assetName) {
const assetUrl = assets?.bin?.[this.platformSuffix]?.[assetName]
if (!assetUrl) {
throw new Error(`[FFBinariesDownloader] Asset ${assetName} not found for platform ${this.platformSuffix}`)
}
return assetUrl
}
getAssetName(binaryName, releaseTag) {
return binaryName
}
getAssetFileName(binaryName) {
return process.platform === 'win32' ? `${binaryName}.exe` : binaryName
}
}
class NunicodeDownloader extends ZippedAssetDownloader {
constructor() {
super()
this.platformSuffix = this.getPlatformSuffix()
}
getPlatformSuffix() {
const platform = process.platform
const arch = process.arch
if (platform === 'win32' && arch === 'x64') {
return 'win-x64'
} else if (platform === 'darwin' && (arch === 'x64' || arch === 'arm64')) {
return 'osx-arm64'
} else if (platform === 'linux' && arch === 'x64') {
return 'linux-x64'
} else if (platform === 'linux' && arch === 'arm64') {
return 'linux-arm64'
}
return null
}
async getAssetUrl(releaseTag, assetName) {
return `https://github.com/mikiher/nunicode-sqlite/releases/download/v${releaseTag}/${assetName}`
}
getAssetName(binaryName, releaseTag) {
if (!this.platformSuffix) {
throw new Error(`[NunicodeDownloader] Platform ${process.platform}-${process.arch} not supported`)
}
return `${binaryName}-${this.platformSuffix}.zip`
}
getAssetFileName(binaryName) {
if (process.platform === 'win32') {
return `${binaryName}.dll`
} else if (process.platform === 'darwin') {
return `${binaryName}.dylib`
} else if (process.platform === 'linux') {
return `${binaryName}.so`
}
throw new Error(`[NunicodeDownloader] Platform ${process.platform} not supported`)
}
}
class Binary {
constructor(name, type, envVariable, validVersions, source, required = true) {
if (!name) throw new Error('Binary name is required')
this.name = name
if (!type) throw new Error('Binary type is required')
this.type = type
if (!envVariable) throw new Error('Binary environment variable name is required')
this.envVariable = envVariable
if (!validVersions || !validVersions.length) throw new Error(`No valid versions specified for ${type} ${name}. At least one version is required.`)
this.validVersions = validVersions
if (!source || !(source instanceof ZippedAssetDownloader)) throw new Error('Binary source is required, and must be an instance of ZippedAssetDownloader')
this.source = source
this.fileName = this.getFileName()
this.required = required
this.exec = exec
}
async find(mainInstallDir, altInstallDir) {
// 1. check path specified in environment variable
const defaultPath = process.env[this.envVariable]
if (await this.isGood(defaultPath)) return defaultPath
// 2. find the first instance of the binary in the PATH environment variable
if (this.type === 'executable') {
const whichPath = which.sync(this.fileName, { nothrow: true })
if (await this.isGood(whichPath)) return whichPath
}
// 3. check main install path (binary root dir)
const mainInstallPath = path.join(mainInstallDir, this.fileName)
if (await this.isGood(mainInstallPath)) return mainInstallPath
// 4. check alt install path (/config)
const altInstallPath = path.join(altInstallDir, this.fileName)
if (await this.isGood(altInstallPath)) return altInstallPath
return null
}
getFileName() {
const platform = process.platform
if (this.type === 'executable') {
return this.name + (platform == 'win32' ? '.exe' : '')
} else if (this.type === 'library') {
return this.name + (platform == 'win32' ? '.dll' : platform == 'darwin' ? '.dylib' : '.so')
} else {
return this.name
}
}
async isLibraryVersionValid(libraryPath) {
try {
const versionFilePath = libraryPath + '.ver'
if (!(await fs.pathExists(versionFilePath))) return false
const version = (await fs.readFile(versionFilePath, 'utf8')).trim()
return this.validVersions.some((validVersion) => version.startsWith(validVersion))
} catch (err) {
Logger.error(`[Binary] Failed to check version of ${libraryPath}`, err)
return false
}
}
async isExecutableVersionValid(executablePath) {
try {
const { stdout } = await this.exec('"' + executablePath + '"' + ' -version')
const version = stdout.match(/version\s([\d\.]+)/)?.[1]
if (!version) return false
return this.validVersions.some((validVersion) => version.startsWith(validVersion))
} catch (err) {
Logger.error(`[Binary] Failed to check version of ${executablePath}`, err)
return false
}
}
async isGood(binaryPath) {
try {
if (!binaryPath || !(await fs.pathExists(binaryPath))) return false
if (this.type === 'library') return await this.isLibraryVersionValid(binaryPath)
else if (this.type === 'executable') return await this.isExecutableVersionValid(binaryPath)
else return true
} catch (err) {
Logger.error(`[Binary] Failed to check ${this.type} ${this.name} at ${binaryPath}`, err)
return false
}
}
async download(destination) {
const version = this.validVersions[0]
try {
await this.source.downloadBinary(this.name, version, destination)
// if it's a library, write the version string to a file
if (this.type === 'library') {
const libraryPath = path.join(destination, this.fileName)
await fs.writeFile(libraryPath + '.ver', version)
}
} catch (err) {
Logger.error(`[Binary] Failed to download ${this.type} ${this.name} version ${version} to ${destination}`, err)
}
}
}
const ffbinaries = new FFBinariesDownloader()
const nunicode = new NunicodeDownloader()
class BinaryManager {
defaultRequiredBinaries = [
{ name: 'ffmpeg', envVariable: 'FFMPEG_PATH', validVersions: ['5.1'] },
{ name: 'ffprobe', envVariable: 'FFPROBE_PATH', validVersions: ['5.1'] }
new Binary('ffmpeg', 'executable', 'FFMPEG_PATH', ['5.1'], ffbinaries), // ffmpeg executable
new Binary('ffprobe', 'executable', 'FFPROBE_PATH', ['5.1'], ffbinaries), // ffprobe executable
new Binary('libnusqlite3', 'library', 'NUSQLITE3_PATH', ['1.2'], nunicode, false) // nunicode sqlite3 extension
]
constructor(requiredBinaries = this.defaultRequiredBinaries) {
this.requiredBinaries = requiredBinaries
this.mainInstallPath = process.pkg ? path.dirname(process.execPath) : global.appRoot
this.altInstallPath = global.ConfigPath
this.mainInstallDir = process.pkg ? path.dirname(process.execPath) : global.appRoot
this.altInstallDir = global.ConfigPath
this.initialized = false
this.exec = exec
}
async init() {
// Optional skip binaries check
if (process.env.SKIP_BINARIES_CHECK === '1') {
for (const binary of this.requiredBinaries) {
if (!process.env[binary.envVariable] && binary.required) {
await Logger.fatal(`[BinaryManager] Environment variable ${binary.envVariable} must be set`)
process.exit(1)
}
}
Logger.info('[BinaryManager] Skipping check for binaries')
return
}
@ -37,124 +354,84 @@ class BinaryManager {
await this.removeOldBinaries(missingBinaries)
await this.install(missingBinaries)
const missingBinariesAfterInstall = await this.findRequiredBinaries()
if (missingBinariesAfterInstall.length) {
Logger.error(`[BinaryManager] Failed to find or install required binaries: ${missingBinariesAfterInstall.join(', ')}`)
const missingRequiredBinryNames = missingBinariesAfterInstall.filter((binary) => binary.required).map((binary) => binary.name)
if (missingRequiredBinryNames.length) {
Logger.error(`[BinaryManager] Failed to find or install required binaries: ${missingRequiredBinryNames.join(', ')}`)
process.exit(1)
}
this.initialized = true
}
/**
* Remove old/invalid binaries in main or alt install path
*
* @param {string[]} binaryNames
* Remove binary
*
* @param {string} destination
* @param {Binary} binary
*/
async removeOldBinaries(binaryNames) {
for (const binaryName of binaryNames) {
const executable = this.getExecutableFileName(binaryName)
const mainInstallPath = path.join(this.mainInstallPath, executable)
if (await fs.pathExists(mainInstallPath)) {
Logger.debug(`[BinaryManager] Removing old binary: ${mainInstallPath}`)
await fs.remove(mainInstallPath)
}
const altInstallPath = path.join(this.altInstallPath, executable)
if (await fs.pathExists(altInstallPath)) {
Logger.debug(`[BinaryManager] Removing old binary: ${altInstallPath}`)
await fs.remove(altInstallPath)
async removeBinary(destination, binary) {
try {
const binaryPath = path.join(destination, binary.fileName)
if (await fs.pathExists(binaryPath)) {
Logger.debug(`[BinaryManager] Removing binary: ${binaryPath}`)
await fs.remove(binaryPath)
}
} catch (err) {
Logger.error(`[BinaryManager] Error removing binary: ${binaryPath}`)
}
}
/**
* Remove old binaries
*
* @param {Binary[]} binaries
*/
async removeOldBinaries(binaries) {
for (const binary of binaries) {
await this.removeBinary(this.mainInstallDir, binary)
await this.removeBinary(this.altInstallDir, binary)
}
}
/**
* Find required binaries and return array of binary names that are missing
*
* @returns {Promise<string[]>}
*
* @returns {Promise<Binary[]>} Array of missing binaries
*/
async findRequiredBinaries() {
const missingBinaries = []
for (const binary of this.requiredBinaries) {
const binaryPath = await this.findBinary(binary.name, binary.envVariable, binary.validVersions)
const binaryPath = await binary.find(this.mainInstallDir, this.altInstallDir)
if (binaryPath) {
Logger.info(`[BinaryManager] Found valid binary ${binary.name} at ${binaryPath}`)
Logger.info(`[BinaryManager] Found valid ${binary.type} ${binary.name} at ${binaryPath}`)
if (process.env[binary.envVariable] !== binaryPath) {
Logger.info(`[BinaryManager] Updating process.env.${binary.envVariable}`)
process.env[binary.envVariable] = binaryPath
}
} else {
Logger.info(`[BinaryManager] ${binary.name} not found or version too old`)
missingBinaries.push(binary.name)
Logger.info(`[BinaryManager] ${binary.name} not found or not a valid version`)
missingBinaries.push(binary)
}
}
return missingBinaries
}
/**
* Find absolute path for binary
*
* @param {string} name
* @param {string} envVariable
* @param {string[]} [validVersions]
* @returns {Promise<string>} Path to binary
*/
async findBinary(name, envVariable, validVersions = []) {
const executable = this.getExecutableFileName(name)
// 1. check path specified in environment variable
const defaultPath = process.env[envVariable]
if (await this.isBinaryGood(defaultPath, validVersions)) return defaultPath
// 2. find the first instance of the binary in the PATH environment variable
const whichPath = which.sync(executable, { nothrow: true })
if (await this.isBinaryGood(whichPath, validVersions)) return whichPath
// 3. check main install path (binary root dir)
const mainInstallPath = path.join(this.mainInstallPath, executable)
if (await this.isBinaryGood(mainInstallPath, validVersions)) return mainInstallPath
// 4. check alt install path (/config)
const altInstallPath = path.join(this.altInstallPath, executable)
if (await this.isBinaryGood(altInstallPath, validVersions)) return altInstallPath
return null
}
/**
* Check binary path exists and optionally check version is valid
*
* @param {string} binaryPath
* @param {string[]} [validVersions]
* @returns {Promise<boolean>}
*/
async isBinaryGood(binaryPath, validVersions = []) {
if (!binaryPath || !await fs.pathExists(binaryPath)) return false
if (!validVersions.length) return true
try {
const { stdout } = await this.exec('"' + binaryPath + '"' + ' -version')
const version = stdout.match(/version\s([\d\.]+)/)?.[1]
if (!version) return false
return validVersions.some(validVersion => version.startsWith(validVersion))
} catch (err) {
Logger.error(`[BinaryManager] Failed to check version of ${binaryPath}`)
return false
}
}
/**
*
* @param {string[]} binaries
* Install missing binaries
*
* @param {Binary[]} binaries
*/
async install(binaries) {
if (!binaries.length) return
Logger.info(`[BinaryManager] Installing binaries: ${binaries.join(', ')}`)
let destination = await fileUtils.isWritable(this.mainInstallPath) ? this.mainInstallPath : this.altInstallPath
await ffbinaries.downloadBinaries(binaries, { destination, version: '5.1', force: true })
Logger.info(`[BinaryManager] Installing binaries: ${binaries.map((binary) => binary.name).join(', ')}`)
let destination = (await fileUtils.isWritable(this.mainInstallDir)) ? this.mainInstallDir : this.altInstallDir
for (const binary of binaries) {
await binary.download(destination)
}
Logger.info(`[BinaryManager] Binaries installed to ${destination}`)
}
/**
* Append .exe to binary name for Windows
*
* @param {string} name
* @returns {string}
*/
getExecutableFileName(name) {
return name + (process.platform == 'win32' ? '.exe' : '')
}
}
module.exports = BinaryManager
module.exports = BinaryManager
module.exports.Binary = Binary // for testing
module.exports.ffbinaries = ffbinaries // for testing
module.exports.nunicode = nunicode // for testing

View file

@ -16,27 +16,17 @@ class CacheManager {
/**
* Create cache directory paths if they dont exist
*/
async ensureCachePaths() { // Creates cache paths if necessary and sets owner and permissions
async ensureCachePaths() {
// Creates cache paths if necessary and sets owner and permissions
this.CachePath = Path.join(global.MetadataPath, 'cache')
this.CoverCachePath = Path.join(this.CachePath, 'covers')
this.ImageCachePath = Path.join(this.CachePath, 'images')
this.ItemCachePath = Path.join(this.CachePath, 'items')
if (!(await fs.pathExists(this.CachePath))) {
await fs.mkdir(this.CachePath)
}
if (!(await fs.pathExists(this.CoverCachePath))) {
await fs.mkdir(this.CoverCachePath)
}
if (!(await fs.pathExists(this.ImageCachePath))) {
await fs.mkdir(this.ImageCachePath)
}
if (!(await fs.pathExists(this.ItemCachePath))) {
await fs.mkdir(this.ItemCachePath)
}
await fs.ensureDir(this.CachePath)
await fs.ensureDir(this.CoverCachePath)
await fs.ensureDir(this.ImageCachePath)
await fs.ensureDir(this.ItemCachePath)
}
async handleCoverCache(res, libraryItemId, coverPath, options = {}) {
@ -89,23 +79,28 @@ class CacheManager {
}
async purgeEntityCache(entityId, cachePath) {
return Promise.all((await fs.readdir(cachePath)).reduce((promises, file) => {
if (file.startsWith(entityId)) {
Logger.debug(`[CacheManager] Going to purge ${file}`);
promises.push(this.removeCache(Path.join(cachePath, file)))
}
return promises
}, []))
return Promise.all(
(await fs.readdir(cachePath)).reduce((promises, file) => {
if (file.startsWith(entityId)) {
Logger.debug(`[CacheManager] Going to purge ${file}`)
promises.push(this.removeCache(Path.join(cachePath, file)))
}
return promises
}, [])
)
}
removeCache(path) {
if (!path) return false
return fs.pathExists(path).then((exists) => {
if (!exists) return false
return fs.unlink(path).then(() => true).catch((err) => {
Logger.error(`[CacheManager] Failed to remove cache "${path}"`, err)
return false
})
return fs
.unlink(path)
.then(() => true)
.catch((err) => {
Logger.error(`[CacheManager] Failed to remove cache "${path}"`, err)
return false
})
})
}
@ -129,6 +124,13 @@ class CacheManager {
await this.ensureCachePaths()
}
/**
*
* @param {import('express').Response} res
* @param {import('../models/Author')} author
* @param {{ format?: string, width?: number, height?: number }} options
* @returns
*/
async handleAuthorCache(res, author, options = {}) {
const format = options.format || 'webp'
const width = options.width || 400
@ -158,4 +160,4 @@ class CacheManager {
readStream.pipe(res)
}
}
module.exports = new CacheManager()
module.exports = new CacheManager()

View file

@ -4,9 +4,14 @@ const Logger = require('../Logger')
const Database = require('../Database')
const LibraryScanner = require('../scanner/LibraryScanner')
const ShareManager = require('./ShareManager')
class CronManager {
constructor(podcastManager) {
constructor(podcastManager, playbackSessionManager) {
/** @type {import('./PodcastManager')} */
this.podcastManager = podcastManager
/** @type {import('./PlaybackSessionManager')} */
this.playbackSessionManager = playbackSessionManager
this.libraryScanCrons = []
this.podcastCrons = []
@ -16,16 +21,33 @@ class CronManager {
/**
* Initialize library scan crons & podcast download crons
* @param {oldLibrary[]} libraries
*
* @param {import('../models/Library')[]} libraries
*/
async init(libraries) {
this.initOpenSessionCleanupCron()
this.initLibraryScanCrons(libraries)
await this.initPodcastCrons()
}
/**
* Initialize open session cleanup cron
* Runs every day at 00:30
* Closes open share sessions that have not been updated in 24 hours
* Closes open playback sessions that have not been updated in 36 hours
* TODO: Clients should re-open the session if it is closed so that stale sessions can be closed sooner
*/
initOpenSessionCleanupCron() {
cron.schedule('30 0 * * *', async () => {
Logger.debug('[CronManager] Open session cleanup cron executing')
ShareManager.closeStaleOpenShareSessions()
await this.playbackSessionManager.closeStaleOpenSessions()
})
}
/**
* Initialize library scan crons
* @param {oldLibrary[]} libraries
* @param {import('../models/Library')[]} libraries
*/
initLibraryScanCrons(libraries) {
for (const library of libraries) {
@ -35,27 +57,45 @@ class CronManager {
}
}
startCronForLibrary(library) {
Logger.debug(`[CronManager] Init library scan cron for ${library.name} on schedule ${library.settings.autoScanCronExpression}`)
const libScanCron = cron.schedule(library.settings.autoScanCronExpression, () => {
Logger.debug(`[CronManager] Library scan cron executing for ${library.name}`)
LibraryScanner.scan(library)
/**
* Start cron schedule for library
*
* @param {import('../models/Library')} _library
*/
startCronForLibrary(_library) {
Logger.debug(`[CronManager] Init library scan cron for ${_library.name} on schedule ${_library.settings.autoScanCronExpression}`)
const libScanCron = cron.schedule(_library.settings.autoScanCronExpression, async () => {
const library = await Database.libraryModel.findByIdWithFolders(_library.id)
if (!library) {
Logger.error(`[CronManager] Library not found for scan cron ${_library.id}`)
} else {
Logger.debug(`[CronManager] Library scan cron executing for ${library.name}`)
LibraryScanner.scan(library)
}
})
this.libraryScanCrons.push({
libraryId: library.id,
expression: library.settings.autoScanCronExpression,
libraryId: _library.id,
expression: _library.settings.autoScanCronExpression,
task: libScanCron
})
}
/**
*
* @param {import('../models/Library')} library
*/
removeCronForLibrary(library) {
Logger.debug(`[CronManager] Removing library scan cron for ${library.name}`)
this.libraryScanCrons = this.libraryScanCrons.filter(lsc => lsc.libraryId !== library.id)
this.libraryScanCrons = this.libraryScanCrons.filter((lsc) => lsc.libraryId !== library.id)
}
/**
*
* @param {import('../models/Library')} library
*/
updateLibraryScanCron(library) {
const expression = library.settings.autoScanCronExpression
const existingCron = this.libraryScanCrons.find(lsc => lsc.libraryId === library.id)
const existingCron = this.libraryScanCrons.find((lsc) => lsc.libraryId === library.id)
if (!expression && existingCron) {
if (existingCron.task.stop) existingCron.task.stop()
@ -128,7 +168,7 @@ class CronManager {
}
async executePodcastCron(expression) {
const podcastCron = this.podcastCrons.find(cron => cron.expression === expression)
const podcastCron = this.podcastCrons.find((cron) => cron.expression === expression)
if (!podcastCron) {
Logger.error(`[CronManager] Podcast cron not found for expression ${expression}`)
return
@ -144,7 +184,7 @@ class CronManager {
const libraryItem = await Database.libraryItemModel.getOldById(libraryItemId)
if (!libraryItem) {
Logger.error(`[CronManager] Library item ${libraryItemId} not found for episode check cron ${expression}`)
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter(lid => lid !== libraryItemId) // Filter it out
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter((lid) => lid !== libraryItemId) // Filter it out
} else {
libraryItems.push(libraryItem)
}
@ -153,8 +193,9 @@ class CronManager {
// Run episode checks
for (const libraryItem of libraryItems) {
const keepAutoDownloading = await this.podcastManager.runEpisodeCheck(libraryItem)
if (!keepAutoDownloading) { // auto download was disabled
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter(lid => lid !== libraryItem.id) // Filter it out
if (!keepAutoDownloading) {
// auto download was disabled
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter((lid) => lid !== libraryItem.id) // Filter it out
}
}
@ -165,20 +206,20 @@ class CronManager {
}
Logger.debug(`[CronManager] Finished executing podcast cron ${expression} for ${libraryItems.length} item(s)`)
this.podcastCronExpressionsExecuting = this.podcastCronExpressionsExecuting.filter(exp => exp !== expression)
this.podcastCronExpressionsExecuting = this.podcastCronExpressionsExecuting.filter((exp) => exp !== expression)
}
removePodcastEpisodeCron(podcastCron) {
Logger.info(`[CronManager] Stopping & removing podcast episode cron for ${podcastCron.expression}`)
if (podcastCron.task) podcastCron.task.stop()
this.podcastCrons = this.podcastCrons.filter(pc => pc.expression !== podcastCron.expression)
this.podcastCrons = this.podcastCrons.filter((pc) => pc.expression !== podcastCron.expression)
}
checkUpdatePodcastCron(libraryItem) {
// Remove from old cron by library item id
const existingCron = this.podcastCrons.find(pc => pc.libraryItemIds.includes(libraryItem.id))
const existingCron = this.podcastCrons.find((pc) => pc.libraryItemIds.includes(libraryItem.id))
if (existingCron) {
existingCron.libraryItemIds = existingCron.libraryItemIds.filter(lid => lid !== libraryItem.id)
existingCron.libraryItemIds = existingCron.libraryItemIds.filter((lid) => lid !== libraryItem.id)
if (!existingCron.libraryItemIds.length) {
this.removePodcastEpisodeCron(existingCron)
}
@ -186,7 +227,7 @@ class CronManager {
// Add to cron or start new cron
if (libraryItem.media.autoDownloadEpisodes && libraryItem.media.autoDownloadSchedule) {
const cronMatchingExpression = this.podcastCrons.find(pc => pc.expression === libraryItem.media.autoDownloadSchedule)
const cronMatchingExpression = this.podcastCrons.find((pc) => pc.expression === libraryItem.media.autoDownloadSchedule)
if (cronMatchingExpression) {
cronMatchingExpression.libraryItemIds.push(libraryItem.id)
Logger.info(`[CronManager] Added podcast "${libraryItem.media.metadata.title}" to auto dl episode cron "${cronMatchingExpression.expression}"`)
@ -196,4 +237,4 @@ class CronManager {
}
}
}
module.exports = CronManager
module.exports = CronManager

View file

@ -0,0 +1,292 @@
const { Umzug, SequelizeStorage } = require('../libs/umzug')
const { Sequelize, DataTypes } = require('sequelize')
const semver = require('semver')
const path = require('path')
const Module = require('module')
const fs = require('../libs/fsExtra')
const Logger = require('../Logger')
class MigrationManager {
static MIGRATIONS_META_TABLE = 'migrationsMeta'
/**
* @param {import('../Database').sequelize} sequelize
* @param {boolean} isDatabaseNew
* @param {string} [configPath]
*/
constructor(sequelize, isDatabaseNew, configPath = global.configPath) {
if (!sequelize || !(sequelize instanceof Sequelize)) throw new Error('Sequelize instance is required for MigrationManager.')
this.sequelize = sequelize
this.isDatabaseNew = isDatabaseNew
if (!configPath) throw new Error('Config path is required for MigrationManager.')
this.configPath = configPath
this.migrationsSourceDir = path.join(__dirname, '..', 'migrations')
this.initialized = false
this.migrationsDir = null
this.maxVersion = null
this.databaseVersion = null
this.serverVersion = null
this.umzug = null
}
/**
* Init version vars and copy migration files to config dir if necessary
*
* @param {string} serverVersion
*/
async init(serverVersion) {
if (!(await fs.pathExists(this.configPath))) throw new Error(`Config path does not exist: ${this.configPath}`)
this.migrationsDir = path.join(this.configPath, 'migrations')
await fs.ensureDir(this.migrationsDir)
this.serverVersion = this.extractVersionFromTag(serverVersion)
if (!this.serverVersion) throw new Error(`Invalid server version: ${serverVersion}. Expected a version tag like v1.2.3.`)
await this.fetchVersionsFromDatabase()
if (!this.maxVersion || !this.databaseVersion) throw new Error('Failed to fetch versions from the database.')
Logger.debug(`[MigrationManager] Database version: ${this.databaseVersion}, Max version: ${this.maxVersion}, Server version: ${this.serverVersion}`)
if (semver.gt(this.serverVersion, this.maxVersion)) {
try {
await this.copyMigrationsToConfigDir()
} catch (error) {
throw new Error('Failed to copy migrations to the config directory.', { cause: error })
}
try {
await this.updateMaxVersion()
} catch (error) {
throw new Error('Failed to update max version in the database.', { cause: error })
}
}
this.initialized = true
}
async runMigrations() {
if (!this.initialized) throw new Error('MigrationManager is not initialized. Call init() first.')
if (this.isDatabaseNew) {
Logger.info('[MigrationManager] Database is new. Skipping migrations.')
return
}
const versionCompare = semver.compare(this.serverVersion, this.databaseVersion)
if (versionCompare == 0) {
Logger.info('[MigrationManager] Database is already up to date.')
return
}
await this.initUmzug()
const migrations = await this.umzug.migrations()
const executedMigrations = (await this.umzug.executed()).map((m) => m.name)
const migrationDirection = versionCompare == 1 ? 'up' : 'down'
let migrationsToRun = []
migrationsToRun = this.findMigrationsToRun(migrations, executedMigrations, migrationDirection)
// Only proceed with migration if there are migrations to run
if (migrationsToRun.length > 0) {
const originalDbPath = path.join(this.configPath, 'absdatabase.sqlite')
const backupDbPath = path.join(this.configPath, 'absdatabase.backup.sqlite')
try {
Logger.info(`[MigrationManager] Migrating database ${migrationDirection} to version ${this.serverVersion}`)
Logger.info(`[MigrationManager] Migrations to run: ${migrationsToRun.join(', ')}`)
// Create a backup copy of the SQLite database before starting migrations
await fs.copy(originalDbPath, backupDbPath)
Logger.info('Created a backup of the original database.')
// Run migrations
await this.umzug[migrationDirection]({ migrations: migrationsToRun, rerun: 'ALLOW' })
// Clean up the backup
await fs.remove(backupDbPath)
Logger.info('[MigrationManager] Migrations successfully applied to the original database.')
} catch (error) {
Logger.error('[MigrationManager] Migration failed:', error)
await this.sequelize.close()
// Step 3: If migration fails, save the failed original and restore the backup
const failedDbPath = path.join(this.configPath, 'absdatabase.failed.sqlite')
await fs.move(originalDbPath, failedDbPath, { overwrite: true })
Logger.info('[MigrationManager] Saved the failed database as absdatabase.failed.sqlite.')
await fs.move(backupDbPath, originalDbPath, { overwrite: true })
Logger.info('[MigrationManager] Restored the original database from the backup.')
Logger.info('[MigrationManager] Migration failed. Exiting Audiobookshelf with code 1.')
process.exit(1)
}
} else {
Logger.info('[MigrationManager] No migrations to run.')
}
await this.updateDatabaseVersion()
}
async initUmzug(umzugStorage = new SequelizeStorage({ sequelize: this.sequelize })) {
// This check is for dependency injection in tests
const files = (await fs.readdir(this.migrationsDir)).filter((file) => !file.startsWith('.')).map((file) => path.join(this.migrationsDir, file))
const parent = new Umzug({
migrations: {
files,
resolve: (params) => {
// make script think it's in migrationsSourceDir
const migrationPath = params.path
const migrationName = params.name
const contents = fs.readFileSync(migrationPath, 'utf8')
const fakePath = path.join(this.migrationsSourceDir, path.basename(migrationPath))
const module = new Module(fakePath)
module.filename = fakePath
module.paths = Module._nodeModulePaths(this.migrationsSourceDir)
module._compile(contents, fakePath)
const script = module.exports
return {
name: migrationName,
path: migrationPath,
up: script.up,
down: script.down
}
}
},
context: { queryInterface: this.sequelize.getQueryInterface(), logger: Logger },
storage: umzugStorage,
logger: Logger
})
// Sort migrations by version
this.umzug = new Umzug({
...parent.options,
migrations: async () =>
(await parent.migrations()).sort((a, b) => {
const versionA = this.extractVersionFromTag(a.name)
const versionB = this.extractVersionFromTag(b.name)
return semver.compare(versionA, versionB)
})
})
}
async fetchVersionsFromDatabase() {
await this.checkOrCreateMigrationsMetaTable()
const [{ version }] = await this.sequelize.query("SELECT value as version FROM :migrationsMeta WHERE key = 'version'", {
replacements: { migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE },
type: Sequelize.QueryTypes.SELECT
})
this.databaseVersion = version
const [{ maxVersion }] = await this.sequelize.query("SELECT value as maxVersion FROM :migrationsMeta WHERE key = 'maxVersion'", {
replacements: { migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE },
type: Sequelize.QueryTypes.SELECT
})
this.maxVersion = maxVersion
}
async checkOrCreateMigrationsMetaTable() {
const queryInterface = this.sequelize.getQueryInterface()
let migrationsMetaTableExists = await queryInterface.tableExists(MigrationManager.MIGRATIONS_META_TABLE)
if (this.isDatabaseNew && migrationsMetaTableExists) {
// This can happen if database was initialized with force: true
await queryInterface.dropTable(MigrationManager.MIGRATIONS_META_TABLE)
migrationsMetaTableExists = false
}
if (!migrationsMetaTableExists) {
await queryInterface.createTable(MigrationManager.MIGRATIONS_META_TABLE, {
key: {
type: DataTypes.STRING,
allowNull: false
},
value: {
type: DataTypes.STRING,
allowNull: false
}
})
await this.sequelize.query("INSERT INTO :migrationsMeta (key, value) VALUES ('version', :version), ('maxVersion', '0.0.0')", {
replacements: { version: this.isDatabaseNew ? this.serverVersion : '0.0.0', migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE },
type: Sequelize.QueryTypes.INSERT
})
Logger.debug(`[MigrationManager] Created migrationsMeta table: "${MigrationManager.MIGRATIONS_META_TABLE}"`)
}
}
extractVersionFromTag(tag) {
if (!tag) return null
const versionMatch = tag.match(/^v?(\d+\.\d+\.\d+)/)
return versionMatch ? versionMatch[1] : null
}
async copyMigrationsToConfigDir() {
if (!(await fs.pathExists(this.migrationsSourceDir))) return
const files = await fs.readdir(this.migrationsSourceDir)
await Promise.all(
files
.filter((file) => path.extname(file) === '.js')
.map(async (file) => {
const sourceFile = path.join(this.migrationsSourceDir, file)
const targetFile = path.join(this.migrationsDir, file)
await fs.copy(sourceFile, targetFile) // Asynchronously copy the files
})
)
Logger.debug(`[MigrationManager] Copied migrations to the config directory: "${this.migrationsDir}"`)
}
/**
*
* @param {{ name: string }[]} migrations
* @param {string[]} executedMigrations - names of executed migrations
* @param {string} direction - 'up' or 'down'
* @returns {string[]} - names of migrations to run
*/
findMigrationsToRun(migrations, executedMigrations, direction) {
const migrationsToRun = migrations
.filter((migration) => {
const migrationVersion = this.extractVersionFromTag(migration.name)
if (direction === 'up') {
return semver.gt(migrationVersion, this.databaseVersion) && semver.lte(migrationVersion, this.serverVersion) && !executedMigrations.includes(migration.name)
} else {
// A down migration should be run even if the associated up migration wasn't executed before
return semver.lte(migrationVersion, this.databaseVersion) && semver.gt(migrationVersion, this.serverVersion)
}
})
.map((migration) => migration.name)
if (direction === 'down') {
return migrationsToRun.reverse()
} else {
return migrationsToRun
}
}
async updateMaxVersion() {
try {
await this.sequelize.query("UPDATE :migrationsMeta SET value = :maxVersion WHERE key = 'maxVersion'", {
replacements: { maxVersion: this.serverVersion, migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE },
type: Sequelize.QueryTypes.UPDATE
})
} catch (error) {
throw new Error('Failed to update maxVersion in the migrationsMeta table.', { cause: error })
}
this.maxVersion = this.serverVersion
}
async updateDatabaseVersion() {
try {
await this.sequelize.query("UPDATE :migrationsMeta SET value = :version WHERE key = 'version'", {
replacements: { version: this.serverVersion, migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE },
type: Sequelize.QueryTypes.UPDATE
})
} catch (error) {
throw new Error('Failed to update version in the migrationsMeta table.', { cause: error })
}
this.databaseVersion = this.serverVersion
}
}
module.exports = MigrationManager

View file

@ -1,5 +1,5 @@
const axios = require('axios')
const Logger = require("../Logger")
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const { notificationData } = require('../utils/notifications')
@ -17,8 +17,13 @@ class NotificationManager {
async onPodcastEpisodeDownloaded(libraryItem, episode) {
if (!Database.notificationSettings.isUseable) return
if (!Database.notificationSettings.getHasActiveNotificationsForEvent('onPodcastEpisodeDownloaded')) {
Logger.debug(`[NotificationManager] onPodcastEpisodeDownloaded: No active notifications`)
return
}
Logger.debug(`[NotificationManager] onPodcastEpisodeDownloaded: Episode "${episode.title}" for podcast ${libraryItem.media.metadata.title}`)
const library = await Database.libraryModel.getOldById(libraryItem.libraryId)
const library = await Database.libraryModel.findByPk(libraryItem.libraryId)
const eventData = {
libraryItemId: libraryItem.id,
libraryId: libraryItem.libraryId,
@ -36,10 +41,60 @@ class NotificationManager {
this.triggerNotification('onPodcastEpisodeDownloaded', eventData)
}
/**
*
* @param {import('../objects/Backup')} backup
* @param {number} totalBackupCount
* @param {boolean} removedOldest - If oldest backup was removed
*/
async onBackupCompleted(backup, totalBackupCount, removedOldest) {
if (!Database.notificationSettings.isUseable) return
if (!Database.notificationSettings.getHasActiveNotificationsForEvent('onBackupCompleted')) {
Logger.debug(`[NotificationManager] onBackupCompleted: No active notifications`)
return
}
Logger.debug(`[NotificationManager] onBackupCompleted: Backup completed`)
const eventData = {
completionTime: backup.createdAt,
backupPath: backup.fullPath,
backupSize: backup.fileSize,
backupCount: totalBackupCount || 'Invalid',
removedOldest: removedOldest || 'false'
}
this.triggerNotification('onBackupCompleted', eventData)
}
/**
*
* @param {string} errorMsg
*/
async onBackupFailed(errorMsg) {
if (!Database.notificationSettings.isUseable) return
if (!Database.notificationSettings.getHasActiveNotificationsForEvent('onBackupFailed')) {
Logger.debug(`[NotificationManager] onBackupFailed: No active notifications`)
return
}
Logger.debug(`[NotificationManager] onBackupFailed: Backup failed (${errorMsg})`)
const eventData = {
errorMsg: errorMsg || 'Backup failed'
}
this.triggerNotification('onBackupFailed', eventData)
}
onTest() {
this.triggerNotification('onTest')
}
/**
*
* @param {string} eventName
* @param {any} eventData
* @param {boolean} [intentionallyFail=false] - If true, will intentionally fail the notification
*/
async triggerNotification(eventName, eventData, intentionallyFail = false) {
if (!Database.notificationSettings.isUseable) return
@ -52,7 +107,8 @@ class NotificationManager {
const success = intentionallyFail ? false : await this.sendNotification(notification, eventData)
notification.updateNotificationFired(success)
if (!success) { // Failed notification
if (!success) {
// Failed notification
if (notification.numConsecutiveFailedAttempts >= Database.notificationSettings.maxFailedAttempts) {
Logger.error(`[NotificationManager] triggerNotification: ${notification.eventName}/${notification.id} reached max failed attempts`)
notification.enabled = false
@ -68,7 +124,12 @@ class NotificationManager {
this.notificationFinished()
}
// Return TRUE if notification should be triggered now
/**
*
* @param {string} eventName
* @param {any} eventData
* @returns {boolean} - TRUE if notification should be triggered now
*/
checkTriggerNotification(eventName, eventData) {
if (this.sendingNotification) {
if (this.notificationQueue.length >= Database.notificationSettings.maxNotificationQueue) {
@ -87,7 +148,8 @@ class NotificationManager {
// Delay between events then run next notification in queue
setTimeout(() => {
this.sendingNotification = false
if (this.notificationQueue.length) { // Send next notification in queue
if (this.notificationQueue.length) {
// Send next notification in queue
const nextNotificationEvent = this.notificationQueue.shift()
this.triggerNotification(nextNotificationEvent.eventName, nextNotificationEvent.eventData)
}
@ -95,7 +157,7 @@ class NotificationManager {
}
sendTestNotification(notification) {
const eventData = notificationData.events.find(e => e.name === notification.eventName)
const eventData = notificationData.events.find((e) => e.name === notification.eventName)
if (!eventData) {
Logger.error(`[NotificationManager] sendTestNotification: Event not found ${notification.eventName}`)
return false
@ -106,13 +168,16 @@ class NotificationManager {
sendNotification(notification, eventData) {
const payload = notification.getApprisePayload(eventData)
return axios.post(Database.notificationSettings.appriseApiUrl, payload, { timeout: 6000 }).then((response) => {
Logger.debug(`[NotificationManager] sendNotification: ${notification.eventName}/${notification.id} response=`, response.data)
return true
}).catch((error) => {
Logger.error(`[NotificationManager] sendNotification: ${notification.eventName}/${notification.id} error=`, error)
return false
})
return axios
.post(Database.notificationSettings.appriseApiUrl, payload, { timeout: 6000 })
.then((response) => {
Logger.debug(`[NotificationManager] sendNotification: ${notification.eventName}/${notification.id} response=`, response.data)
return true
})
.catch((error) => {
Logger.error(`[NotificationManager] sendNotification: ${notification.eventName}/${notification.id} error=`, error)
return false
})
}
}
module.exports = NotificationManager
module.exports = new NotificationManager()

View file

@ -1,4 +1,4 @@
const uuidv4 = require("uuid").v4
const uuidv4 = require('uuid').v4
const Path = require('path')
const serverVersion = require('../../package.json').version
const Logger = require('../Logger')
@ -21,52 +21,71 @@ class PlaybackSessionManager {
this.StreamsPath = Path.join(global.MetadataPath, 'streams')
this.oldPlaybackSessionMap = {} // TODO: Remove after updated mobile versions
/** @type {PlaybackSession[]} */
this.sessions = []
}
getSession(sessionId) {
return this.sessions.find(s => s.id === sessionId)
return this.sessions.find((s) => s.id === sessionId)
}
getUserSession(userId) {
return this.sessions.find(s => s.userId === userId)
return this.sessions.find((s) => s.userId === userId)
}
getStream(sessionId) {
const session = this.getSession(sessionId)
return session?.stream || null
}
async getDeviceInfo(req) {
/**
*
* @param {import('../controllers/SessionController').RequestWithUser} req
* @param {Object} [clientDeviceInfo]
* @returns {Promise<DeviceInfo>}
*/
async getDeviceInfo(req, clientDeviceInfo = null) {
const ua = uaParserJs(req.headers['user-agent'])
const ip = requestIp.getClientIp(req)
const clientDeviceInfo = req.body?.deviceInfo || null
const deviceInfo = new DeviceInfo()
deviceInfo.setData(ip, ua, clientDeviceInfo, serverVersion, req.user.id)
deviceInfo.setData(ip, ua, clientDeviceInfo, serverVersion, req.user?.id)
if (clientDeviceInfo?.deviceId) {
const existingDevice = await Database.getDeviceByDeviceId(clientDeviceInfo.deviceId)
const existingDevice = await Database.deviceModel.getOldDeviceByDeviceId(clientDeviceInfo.deviceId)
if (existingDevice) {
if (existingDevice.update(deviceInfo)) {
await Database.updateDevice(existingDevice)
await Database.deviceModel.updateFromOld(existingDevice)
}
return existingDevice
}
}
await Database.createDevice(deviceInfo)
await Database.deviceModel.createFromOld(deviceInfo)
return deviceInfo
}
/**
*
* @param {import('../controllers/SessionController').RequestWithUser} req
* @param {import('express').Response} res
* @param {string} [episodeId]
*/
async startSessionRequest(req, res, episodeId) {
const deviceInfo = await this.getDeviceInfo(req)
const deviceInfo = await this.getDeviceInfo(req, req.body?.deviceInfo)
Logger.debug(`[PlaybackSessionManager] startSessionRequest for device ${deviceInfo.deviceDescription}`)
const { user, libraryItem, body: options } = req
const session = await this.startSession(user, deviceInfo, libraryItem, episodeId, options)
const { libraryItem, body: options } = req
const session = await this.startSession(req.user, deviceInfo, libraryItem, episodeId, options)
res.json(session.toJSONForClient(libraryItem))
}
/**
*
* @param {import('../models/User')} user
* @param {*} session
* @param {*} payload
* @param {import('express').Response} res
*/
async syncSessionRequest(user, session, payload, res) {
if (await this.syncSession(user, session, payload)) {
res.sendStatus(200)
@ -76,7 +95,7 @@ class PlaybackSessionManager {
}
async syncLocalSessionsRequest(req, res) {
const deviceInfo = await this.getDeviceInfo(req)
const deviceInfo = await this.getDeviceInfo(req, req.body?.deviceInfo)
const user = req.user
const sessions = req.body.sessions || []
@ -92,9 +111,17 @@ class PlaybackSessionManager {
})
}
/**
*
* @param {import('../models/User')} user
* @param {*} sessionJson
* @param {*} deviceInfo
* @returns
*/
async syncLocalSession(user, sessionJson, deviceInfo) {
// TODO: Combine libraryItem query with library query
const libraryItem = await Database.libraryItemModel.getOldById(sessionJson.libraryItemId)
const episode = (sessionJson.episodeId && libraryItem && libraryItem.isPodcast) ? libraryItem.media.getEpisode(sessionJson.episodeId) : null
const episode = sessionJson.episodeId && libraryItem && libraryItem.isPodcast ? libraryItem.media.getEpisode(sessionJson.episodeId) : null
if (!libraryItem || (libraryItem.isPodcast && !episode)) {
Logger.error(`[PlaybackSessionManager] syncLocalSession: Media item not found for session "${sessionJson.displayTitle}" (${sessionJson.id})`)
return {
@ -104,6 +131,16 @@ class PlaybackSessionManager {
}
}
const library = await Database.libraryModel.findByPk(libraryItem.libraryId)
if (!library) {
Logger.error(`[PlaybackSessionManager] syncLocalSession: Library not found for session "${sessionJson.displayTitle}" (${sessionJson.id})`)
return {
id: sessionJson.id,
success: false,
error: 'Library not found'
}
}
sessionJson.userId = user.id
sessionJson.serverVersion = serverVersion
@ -138,6 +175,7 @@ class PlaybackSessionManager {
// New session from local
session = new PlaybackSession(sessionJson)
session.deviceInfo = deviceInfo
session.setDuration(libraryItem, sessionJson.episodeId)
Logger.debug(`[PlaybackSessionManager] Inserting new session for "${session.displayTitle}" (${session.id})`)
await Database.createPlaybackSession(session)
} else {
@ -162,39 +200,62 @@ class PlaybackSessionManager {
progressSynced: false
}
const userProgressForItem = user.getMediaProgress(session.libraryItemId, session.episodeId)
const mediaItemId = session.episodeId || libraryItem.media.id
let userProgressForItem = user.getMediaProgress(mediaItemId)
if (userProgressForItem) {
if (userProgressForItem.lastUpdate > session.updatedAt) {
if (userProgressForItem.updatedAt.valueOf() > session.updatedAt) {
Logger.debug(`[PlaybackSessionManager] Not updating progress for "${session.displayTitle}" because it has been updated more recently`)
} else {
Logger.debug(`[PlaybackSessionManager] Updating progress for "${session.displayTitle}" with current time ${session.currentTime} (previously ${userProgressForItem.currentTime})`)
result.progressSynced = user.createUpdateMediaProgress(libraryItem, session.mediaProgressObject, session.episodeId)
const updateResponse = await user.createUpdateMediaProgressFromPayload({
libraryItemId: libraryItem.id,
episodeId: session.episodeId,
...session.mediaProgressObject,
markAsFinishedPercentComplete: library.librarySettings.markAsFinishedPercentComplete,
markAsFinishedTimeRemaining: library.librarySettings.markAsFinishedTimeRemaining
})
result.progressSynced = !!updateResponse.mediaProgress
if (result.progressSynced) {
userProgressForItem = updateResponse.mediaProgress
}
}
} else {
Logger.debug(`[PlaybackSessionManager] Creating new media progress for media item "${session.displayTitle}"`)
result.progressSynced = user.createUpdateMediaProgress(libraryItem, session.mediaProgressObject, session.episodeId)
const updateResponse = await user.createUpdateMediaProgressFromPayload({
libraryItemId: libraryItem.id,
episodeId: session.episodeId,
...session.mediaProgressObject,
markAsFinishedPercentComplete: library.librarySettings.markAsFinishedPercentComplete,
markAsFinishedTimeRemaining: library.librarySettings.markAsFinishedTimeRemaining
})
result.progressSynced = !!updateResponse.mediaProgress
if (result.progressSynced) {
userProgressForItem = updateResponse.mediaProgress
}
}
// Update user and emit socket event
if (result.progressSynced) {
const itemProgress = user.getMediaProgress(session.libraryItemId, session.episodeId)
if (itemProgress) await Database.upsertMediaProgress(itemProgress)
SocketAuthority.clientEmitter(user.id, 'user_item_progress_updated', {
id: itemProgress.id,
id: userProgressForItem.id,
sessionId: session.id,
deviceDescription: session.deviceDescription,
data: itemProgress.toJSON()
data: userProgressForItem.getOldMediaProgress()
})
}
return result
}
/**
*
* @param {import('../controllers/SessionController').RequestWithUser} req
* @param {*} res
*/
async syncLocalSessionRequest(req, res) {
const deviceInfo = await this.getDeviceInfo(req)
const user = req.user
const deviceInfo = await this.getDeviceInfo(req, req.body?.deviceInfo)
const sessionJson = req.body
const result = await this.syncLocalSession(user, sessionJson, deviceInfo)
const result = await this.syncLocalSession(req.user, sessionJson, deviceInfo)
if (result.error) {
res.status(500).send(result.error)
} else {
@ -202,14 +263,30 @@ class PlaybackSessionManager {
}
}
/**
*
* @param {import('../models/User')} user
* @param {*} session
* @param {*} syncData
* @param {import('express').Response} res
*/
async closeSessionRequest(user, session, syncData, res) {
await this.closeSession(user, session, syncData)
res.sendStatus(200)
}
/**
*
* @param {import('../models/User')} user
* @param {DeviceInfo} deviceInfo
* @param {import('../objects/LibraryItem')} libraryItem
* @param {string|null} episodeId
* @param {{forceDirectPlay?:boolean, forceTranscode?:boolean, mediaPlayer:string, supportedMimeTypes?:string[]}} options
* @returns {Promise<PlaybackSession>}
*/
async startSession(user, deviceInfo, libraryItem, episodeId, options) {
// Close any sessions already open for user and device
const userSessions = this.sessions.filter(playbackSession => playbackSession.userId === user.id && playbackSession.deviceId === deviceInfo.id)
const userSessions = this.sessions.filter((playbackSession) => playbackSession.userId === user.id && playbackSession.deviceId === deviceInfo.id)
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)
@ -218,7 +295,8 @@ class PlaybackSessionManager {
const shouldDirectPlay = options.forceDirectPlay || (!options.forceTranscode && libraryItem.media.checkCanDirectPlay(options, episodeId))
const mediaPlayer = options.mediaPlayer || 'unknown'
const userProgress = libraryItem.isMusic ? null : user.getMediaProgress(libraryItem.id, episodeId)
const mediaItemId = episodeId || libraryItem.media.id
const userProgress = user.getMediaProgress(mediaItemId)
let userStartTime = 0
if (userProgress) {
if (userProgress.isFinished) {
@ -229,39 +307,29 @@ class PlaybackSessionManager {
}
}
const newPlaybackSession = new PlaybackSession()
newPlaybackSession.setData(libraryItem, user, mediaPlayer, deviceInfo, userStartTime, episodeId)
newPlaybackSession.setData(libraryItem, user.id, mediaPlayer, deviceInfo, userStartTime, episodeId)
if (libraryItem.mediaType === 'video') {
if (shouldDirectPlay) {
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}" with id ${newPlaybackSession.id}`)
newPlaybackSession.videoTrack = libraryItem.media.getVideoTrack()
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
} else {
// HLS not supported for video yet
}
let audioTracks = []
if (shouldDirectPlay) {
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}" with id ${newPlaybackSession.id} (Device: ${newPlaybackSession.deviceDescription})`)
audioTracks = libraryItem.getDirectPlayTracklist(episodeId)
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
} else {
let audioTracks = []
if (shouldDirectPlay) {
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}" with id ${newPlaybackSession.id} (Device: ${newPlaybackSession.deviceDescription})`)
audioTracks = libraryItem.getDirectPlayTracklist(episodeId)
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
} else {
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting stream session for item "${libraryItem.id}" (Device: ${newPlaybackSession.deviceDescription})`)
const stream = new Stream(newPlaybackSession.id, this.StreamsPath, user, libraryItem, episodeId, userStartTime)
await stream.generatePlaylist()
stream.start() // Start transcode
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting stream session for item "${libraryItem.id}" (Device: ${newPlaybackSession.deviceDescription})`)
const stream = new Stream(newPlaybackSession.id, this.StreamsPath, user, libraryItem, episodeId, userStartTime)
await stream.generatePlaylist()
stream.start() // Start transcode
audioTracks = [stream.getAudioTrack()]
newPlaybackSession.stream = stream
newPlaybackSession.playMethod = PlayMethod.TRANSCODE
audioTracks = [stream.getAudioTrack()]
newPlaybackSession.stream = stream
newPlaybackSession.playMethod = PlayMethod.TRANSCODE
stream.on('closed', () => {
Logger.debug(`[PlaybackSessionManager] Stream closed for session "${newPlaybackSession.id}" (Device: ${newPlaybackSession.deviceDescription})`)
newPlaybackSession.stream = null
})
}
newPlaybackSession.audioTracks = audioTracks
stream.on('closed', () => {
Logger.debug(`[PlaybackSessionManager] Stream closed for session "${newPlaybackSession.id}" (Device: ${newPlaybackSession.deviceDescription})`)
newPlaybackSession.stream = null
})
}
newPlaybackSession.audioTracks = audioTracks
this.sessions.push(newPlaybackSession)
SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions))
@ -269,31 +337,47 @@ class PlaybackSessionManager {
return newPlaybackSession
}
/**
*
* @param {import('../models/User')} user
* @param {*} session
* @param {*} syncData
* @returns
*/
async syncSession(user, session, syncData) {
// TODO: Combine libraryItem query with library query
const libraryItem = await Database.libraryItemModel.getOldById(session.libraryItemId)
if (!libraryItem) {
Logger.error(`[PlaybackSessionManager] syncSession Library Item not found "${session.libraryItemId}"`)
return null
}
const library = await Database.libraryModel.findByPk(libraryItem.libraryId)
if (!library) {
Logger.error(`[PlaybackSessionManager] syncSession Library not found "${libraryItem.libraryId}"`)
return null
}
session.currentTime = syncData.currentTime
session.addListeningTime(syncData.timeListened)
Logger.debug(`[PlaybackSessionManager] syncSession "${session.id}" (Device: ${session.deviceDescription}) | Total Time Listened: ${session.timeListening}`)
const itemProgressUpdate = {
duration: syncData.duration,
const updateResponse = await user.createUpdateMediaProgressFromPayload({
libraryItemId: libraryItem.id,
episodeId: session.episodeId,
// duration no longer required (v2.15.1) but used if available
duration: syncData.duration || session.duration || 0,
currentTime: syncData.currentTime,
progress: session.progress
}
const wasUpdated = user.createUpdateMediaProgress(libraryItem, itemProgressUpdate, session.episodeId)
if (wasUpdated) {
const itemProgress = user.getMediaProgress(session.libraryItemId, session.episodeId)
if (itemProgress) await Database.upsertMediaProgress(itemProgress)
progress: session.progress,
markAsFinishedTimeRemaining: library.librarySettings.markAsFinishedTimeRemaining,
markAsFinishedPercentComplete: library.librarySettings.markAsFinishedPercentComplete
})
if (updateResponse.mediaProgress) {
SocketAuthority.clientEmitter(user.id, 'user_item_progress_updated', {
id: itemProgress.id,
id: updateResponse.mediaProgress.id,
sessionId: session.id,
deviceDescription: session.deviceDescription,
data: itemProgress.toJSON()
data: updateResponse.mediaProgress.getOldMediaProgress()
})
}
this.saveSession(session)
@ -302,6 +386,13 @@ class PlaybackSessionManager {
}
}
/**
*
* @param {import('../models/User')} user
* @param {*} session
* @param {*} syncData
* @returns
*/
async closeSession(user, session, syncData = null) {
if (syncData) {
await this.syncSession(user, session, syncData)
@ -325,13 +416,17 @@ class PlaybackSessionManager {
}
}
/**
*
* @param {string} sessionId
*/
async removeSession(sessionId) {
const session = this.sessions.find(s => s.id === sessionId)
const session = this.sessions.find((s) => s.id === sessionId)
if (!session) return
if (session.stream) {
await session.stream.close()
}
this.sessions = this.sessions.filter(s => s.id !== sessionId)
this.sessions = this.sessions.filter((s) => s.id !== sessionId)
Logger.debug(`[PlaybackSessionManager] Removed session "${sessionId}"`)
}
@ -343,8 +438,9 @@ class PlaybackSessionManager {
try {
const streamsInPath = await fs.readdir(this.StreamsPath)
for (const streamId of streamsInPath) {
if (/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/.test(streamId)) { // Ensure is uuidv4
const session = this.sessions.find(se => se.id === streamId)
if (/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/.test(streamId)) {
// Ensure is uuidv4
const session = this.sessions.find((se) => se.id === streamId)
if (!session) {
const streamPath = Path.join(this.StreamsPath, streamId)
Logger.debug(`[PlaybackSessionManager] Removing orphan stream "${streamPath}"`)
@ -356,5 +452,18 @@ class PlaybackSessionManager {
Logger.error(`[PlaybackSessionManager] cleanOrphanStreams failed`, error)
}
}
/**
* Close all open sessions that have not been updated in the last 36 hours
*/
async closeStaleOpenSessions() {
const updatedAtTimeCutoff = Date.now() - 1000 * 60 * 60 * 36
const staleSessions = this.sessions.filter((session) => session.updatedAt < updatedAtTimeCutoff)
for (const session of staleSessions) {
const sessionLastUpdate = new Date(session.updatedAt)
Logger.info(`[PlaybackSessionManager] Closing stale session "${session.displayTitle}" (${session.id}) last updated at ${sessionLastUpdate}`)
await this.removeSession(session.id)
}
}
}
module.exports = PlaybackSessionManager

View file

@ -5,7 +5,7 @@ const Database = require('../Database')
const fs = require('../libs/fsExtra')
const { getPodcastFeed } = require('../utils/podcastUtils')
const { removeFile, downloadFile } = require('../utils/fileUtils')
const { removeFile, downloadFile, sanitizeFilename, filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')
const { levenshteinDistance } = require('../utils/index')
const opmlParser = require('../utils/parsers/parseOPML')
const opmlGenerator = require('../utils/generators/opmlGenerator')
@ -13,16 +13,18 @@ const prober = require('../utils/prober')
const ffmpegHelpers = require('../utils/ffmpegHelpers')
const TaskManager = require('./TaskManager')
const CoverManager = require('../managers/CoverManager')
const NotificationManager = require('../managers/NotificationManager')
const LibraryFile = require('../objects/files/LibraryFile')
const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload')
const PodcastEpisode = require('../objects/entities/PodcastEpisode')
const AudioFile = require('../objects/files/AudioFile')
const LibraryItem = require('../objects/LibraryItem')
class PodcastManager {
constructor(watcher, notificationManager) {
constructor(watcher) {
this.watcher = watcher
this.notificationManager = notificationManager
this.downloadQueue = []
this.currentDownload = null
@ -69,12 +71,20 @@ class PodcastManager {
return
}
const taskDescription = `Downloading episode "${podcastEpisodeDownload.podcastEpisode.title}".`
const taskData = {
libraryId: podcastEpisodeDownload.libraryId,
libraryItemId: podcastEpisodeDownload.libraryItemId
}
const task = TaskManager.createAndAddTask('download-podcast-episode', 'Downloading Episode', taskDescription, false, taskData)
const taskTitleString = {
text: 'Downloading episode',
key: 'MessageDownloadingEpisode'
}
const taskDescriptionString = {
text: `Downloading episode "${podcastEpisodeDownload.podcastEpisode.title}".`,
key: 'MessageTaskDownloadingEpisodeDescription',
subs: [podcastEpisodeDownload.podcastEpisode.title]
}
const task = TaskManager.createAndAddTask('download-podcast-episode', taskTitleString, taskDescriptionString, false, taskData)
SocketAuthority.emitter('episode_download_started', podcastEpisodeDownload.toJSONForClient())
this.currentDownload = podcastEpisodeDownload
@ -117,14 +127,22 @@ class PodcastManager {
if (!success) {
await fs.remove(this.currentDownload.targetPath)
this.currentDownload.setFinished(false)
task.setFailed('Failed to download episode')
const taskFailedString = {
text: 'Failed',
key: 'MessageTaskFailed'
}
task.setFailed(taskFailedString)
} else {
Logger.info(`[PodcastManager] Successfully downloaded podcast episode "${this.currentDownload.podcastEpisode.title}"`)
this.currentDownload.setFinished(true)
task.setFinished()
}
} else {
task.setFailed('Failed to download episode')
const taskFailedString = {
text: 'Failed',
key: 'MessageTaskFailed'
}
task.setFailed(taskFailedString)
this.currentDownload.setFinished(false)
}
@ -185,7 +203,7 @@ class PodcastManager {
if (this.currentDownload.isAutoDownload) {
// Notifications only for auto downloaded episodes
this.notificationManager.onPodcastEpisodeDownloaded(libraryItem, podcastEpisode)
NotificationManager.onPodcastEpisodeDownloaded(libraryItem, podcastEpisode)
}
return true
@ -377,19 +395,23 @@ class PodcastManager {
return matches.sort((a, b) => a.levenshtein - b.levenshtein)
}
getParsedOPMLFileFeeds(opmlText) {
return opmlParser.parse(opmlText)
}
async getOPMLFeeds(opmlText) {
var extractedFeeds = opmlParser.parse(opmlText)
if (!extractedFeeds || !extractedFeeds.length) {
const extractedFeeds = opmlParser.parse(opmlText)
if (!extractedFeeds?.length) {
Logger.error('[PodcastManager] getOPMLFeeds: No RSS feeds found in OPML')
return {
error: 'No RSS feeds found in OPML'
}
}
var rssFeedData = []
const rssFeedData = []
for (let feed of extractedFeeds) {
var feedData = await getPodcastFeed(feed.feedUrl, true)
const feedData = await getPodcastFeed(feed.feedUrl, true)
if (feedData) {
feedData.metadata.feedUrl = feed.feedUrl
rssFeedData.push(feedData)
@ -419,5 +441,168 @@ class PodcastManager {
queue: this.downloadQueue.filter((item) => !libraryId || item.libraryId === libraryId).map((item) => item.toJSONForClient())
}
}
/**
*
* @param {string[]} rssFeedUrls
* @param {import('../models/LibraryFolder')} folder
* @param {boolean} autoDownloadEpisodes
* @param {import('../managers/CronManager')} cronManager
*/
async createPodcastsFromFeedUrls(rssFeedUrls, folder, autoDownloadEpisodes, cronManager) {
const taskTitleString = {
text: 'OPML import',
key: 'MessageTaskOpmlImport'
}
const taskDescriptionString = {
text: `Creating podcasts from ${rssFeedUrls.length} RSS feeds`,
key: 'MessageTaskOpmlImportDescription',
subs: [rssFeedUrls.length]
}
const task = TaskManager.createAndAddTask('opml-import', taskTitleString, taskDescriptionString, true, null)
let numPodcastsAdded = 0
Logger.info(`[PodcastManager] createPodcastsFromFeedUrls: Importing ${rssFeedUrls.length} RSS feeds to folder "${folder.path}"`)
for (const feedUrl of rssFeedUrls) {
const feed = await getPodcastFeed(feedUrl).catch(() => null)
if (!feed?.episodes) {
const taskTitleStringFeed = {
text: 'OPML import feed',
key: 'MessageTaskOpmlImportFeed'
}
const taskDescriptionStringFeed = {
text: `Importing RSS feed "${feedUrl}"`,
key: 'MessageTaskOpmlImportFeedDescription',
subs: [feedUrl]
}
const taskErrorString = {
text: 'Failed to get podcast feed',
key: 'MessageTaskOpmlImportFeedFailed'
}
TaskManager.createAndEmitFailedTask('opml-import-feed', taskTitleStringFeed, taskDescriptionStringFeed, taskErrorString)
Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Failed to get podcast feed for "${feedUrl}"`)
continue
}
const podcastFilename = sanitizeFilename(feed.metadata.title)
const podcastPath = filePathToPOSIX(`${folder.path}/${podcastFilename}`)
// Check if a library item with this podcast folder exists already
const existingLibraryItem =
(await Database.libraryItemModel.count({
where: {
path: podcastPath
}
})) > 0
if (existingLibraryItem) {
Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Podcast already exists at path "${podcastPath}"`)
const taskTitleStringFeed = {
text: 'OPML import feed',
key: 'MessageTaskOpmlImportFeed'
}
const taskDescriptionStringPodcast = {
text: `Creating podcast "${feed.metadata.title}"`,
key: 'MessageTaskOpmlImportFeedPodcastDescription',
subs: [feed.metadata.title]
}
const taskErrorString = {
text: 'Podcast already exists at path',
key: 'MessageTaskOpmlImportFeedPodcastExists'
}
TaskManager.createAndEmitFailedTask('opml-import-feed', taskTitleStringFeed, taskDescriptionStringPodcast, taskErrorString)
continue
}
const successCreatingPath = await fs
.ensureDir(podcastPath)
.then(() => true)
.catch((error) => {
Logger.error(`[PodcastManager] Failed to ensure podcast dir "${podcastPath}"`, error)
return false
})
if (!successCreatingPath) {
Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Failed to create podcast folder at "${podcastPath}"`)
const taskTitleStringFeed = {
text: 'OPML import feed',
key: 'MessageTaskOpmlImportFeed'
}
const taskDescriptionStringPodcast = {
text: `Creating podcast "${feed.metadata.title}"`,
key: 'MessageTaskOpmlImportFeedPodcastDescription',
subs: [feed.metadata.title]
}
const taskErrorString = {
text: 'Failed to create podcast folder',
key: 'MessageTaskOpmlImportFeedPodcastFailed'
}
TaskManager.createAndEmitFailedTask('opml-import-feed', taskTitleStringFeed, taskDescriptionStringPodcast, taskErrorString)
continue
}
const newPodcastMetadata = {
title: feed.metadata.title,
author: feed.metadata.author,
description: feed.metadata.description,
releaseDate: '',
genres: [...feed.metadata.categories],
feedUrl: feed.metadata.feedUrl,
imageUrl: feed.metadata.image,
itunesPageUrl: '',
itunesId: '',
itunesArtistId: '',
language: '',
numEpisodes: feed.numEpisodes
}
const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath)
const libraryItemPayload = {
path: podcastPath,
relPath: podcastFilename,
folderId: folder.id,
libraryId: folder.libraryId,
ino: libraryItemFolderStats.ino,
mtimeMs: libraryItemFolderStats.mtimeMs || 0,
ctimeMs: libraryItemFolderStats.ctimeMs || 0,
birthtimeMs: libraryItemFolderStats.birthtimeMs || 0,
media: {
metadata: newPodcastMetadata,
autoDownloadEpisodes
}
}
const libraryItem = new LibraryItem()
libraryItem.setData('podcast', libraryItemPayload)
// Download and save cover image
if (newPodcastMetadata.imageUrl) {
// TODO: Scan cover image to library files
// Podcast cover will always go into library item folder
const coverResponse = await CoverManager.downloadCoverFromUrl(libraryItem, newPodcastMetadata.imageUrl, true)
if (coverResponse) {
if (coverResponse.error) {
Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Download cover error from "${newPodcastMetadata.imageUrl}": ${coverResponse.error}`)
} else if (coverResponse.cover) {
libraryItem.media.coverPath = coverResponse.cover
}
}
}
await Database.createLibraryItem(libraryItem)
SocketAuthority.emitter('item_added', libraryItem.toJSONExpanded())
// Turn on podcast auto download cron if not already on
if (libraryItem.media.autoDownloadEpisodes) {
cronManager.checkUpdatePodcastCron(libraryItem)
}
numPodcastsAdded++
}
const taskFinishedString = {
text: `Added ${numPodcastsAdded} podcasts`,
key: 'MessageTaskOpmlImportFinished',
subs: [numPodcastsAdded]
}
task.setFinished(taskFinishedString)
TaskManager.taskFinished(task)
Logger.info(`[PodcastManager] createPodcastsFromFeedUrls: Finished OPML import. Created ${numPodcastsAdded} podcasts out of ${rssFeedUrls.length} RSS feed URLs`)
}
}
module.exports = PodcastManager

View file

@ -9,7 +9,7 @@ const Feed = require('../objects/Feed')
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
class RssFeedManager {
constructor() { }
constructor() {}
async validateFeedEntity(feedObj) {
if (feedObj.entityType === 'collection') {
@ -25,7 +25,7 @@ class RssFeedManager {
return false
}
} else if (feedObj.entityType === 'series') {
const series = await Database.seriesModel.getOldById(feedObj.entityId)
const series = await Database.seriesModel.findByPk(feedObj.entityId)
if (!series) {
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Series "${feedObj.entityId}" not found`)
return false
@ -44,7 +44,7 @@ class RssFeedManager {
const feeds = await Database.feedModel.getOldFeeds()
for (const feed of feeds) {
// Remove invalid feeds
if (!await this.validateFeedEntity(feed)) {
if (!(await this.validateFeedEntity(feed))) {
await Database.removeFeed(feed.id)
}
}
@ -133,12 +133,12 @@ class RssFeedManager {
}
}
} else if (feed.entityType === 'series') {
const series = await Database.seriesModel.getOldById(feed.entityId)
const series = await Database.seriesModel.findByPk(feed.entityId)
if (series) {
const seriesJson = series.toJSON()
const seriesJson = series.toOldJSON()
// Get books in series that have audio tracks
seriesJson.books = (await libraryItemsBookFilters.getLibraryItemsForSeries(series)).filter(li => li.media.numTracks)
seriesJson.books = (await libraryItemsBookFilters.getLibraryItemsForSeries(series)).filter((li) => li.media.numTracks)
// Find most recently updated item in series
let mostRecentlyUpdatedAt = seriesJson.updatedAt
@ -202,7 +202,14 @@ class RssFeedManager {
readStream.pipe(res)
}
async openFeedForItem(user, libraryItem, options) {
/**
*
* @param {string} userId
* @param {*} libraryItem
* @param {*} options
* @returns
*/
async openFeedForItem(userId, libraryItem, options) {
const serverAddress = options.serverAddress
const slug = options.slug
const preventIndexing = options.metadataDetails?.preventIndexing ?? true
@ -210,7 +217,7 @@ class RssFeedManager {
const ownerEmail = options.metadataDetails?.ownerEmail
const feed = new Feed()
feed.setFromItem(user.id, slug, libraryItem, serverAddress, preventIndexing, ownerName, ownerEmail)
feed.setFromItem(userId, slug, libraryItem, serverAddress, preventIndexing, ownerName, ownerEmail)
Logger.info(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
await Database.createFeed(feed)
@ -218,7 +225,14 @@ class RssFeedManager {
return feed
}
async openFeedForCollection(user, collectionExpanded, options) {
/**
*
* @param {string} userId
* @param {*} collectionExpanded
* @param {*} options
* @returns
*/
async openFeedForCollection(userId, collectionExpanded, options) {
const serverAddress = options.serverAddress
const slug = options.slug
const preventIndexing = options.metadataDetails?.preventIndexing ?? true
@ -226,7 +240,7 @@ class RssFeedManager {
const ownerEmail = options.metadataDetails?.ownerEmail
const feed = new Feed()
feed.setFromCollection(user.id, slug, collectionExpanded, serverAddress, preventIndexing, ownerName, ownerEmail)
feed.setFromCollection(userId, slug, collectionExpanded, serverAddress, preventIndexing, ownerName, ownerEmail)
Logger.info(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
await Database.createFeed(feed)
@ -234,7 +248,14 @@ class RssFeedManager {
return feed
}
async openFeedForSeries(user, seriesExpanded, options) {
/**
*
* @param {string} userId
* @param {*} seriesExpanded
* @param {*} options
* @returns
*/
async openFeedForSeries(userId, seriesExpanded, options) {
const serverAddress = options.serverAddress
const slug = options.slug
const preventIndexing = options.metadataDetails?.preventIndexing ?? true
@ -242,7 +263,7 @@ class RssFeedManager {
const ownerEmail = options.metadataDetails?.ownerEmail
const feed = new Feed()
feed.setFromSeries(user.id, slug, seriesExpanded, serverAddress, preventIndexing, ownerName, ownerEmail)
feed.setFromSeries(userId, slug, seriesExpanded, serverAddress, preventIndexing, ownerName, ownerEmail)
Logger.info(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
await Database.createFeed(feed)

View file

@ -0,0 +1,189 @@
const Database = require('../Database')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const LongTimeout = require('../utils/longTimeout')
const { elapsedPretty } = require('../utils/index')
/**
* @typedef OpenMediaItemShareObject
* @property {string} id
* @property {import('../models/MediaItemShare').MediaItemShareObject} mediaItemShare
* @property {LongTimeout} timeout
*/
class ShareManager {
constructor() {
/** @type {OpenMediaItemShareObject[]} */
this.openMediaItemShares = []
/** @type {import('../objects/PlaybackSession')[]} */
this.openSharePlaybackSessions = []
}
init() {
this.loadMediaItemShares()
}
/**
* @param {import('../objects/PlaybackSession')} playbackSession
*/
addOpenSharePlaybackSession(playbackSession) {
Logger.info(`[ShareManager] Adding new open share playback session "${playbackSession.displayTitle}"`)
this.openSharePlaybackSessions.push(playbackSession)
}
/**
*
* @param {import('../objects/PlaybackSession')} playbackSession
*/
closeSharePlaybackSession(playbackSession) {
Logger.info(`[ShareManager] Closing share playback session "${playbackSession.displayTitle}"`)
this.openSharePlaybackSessions = this.openSharePlaybackSessions.filter((s) => s.id !== playbackSession.id)
}
/**
* Find an open media item share by media item ID
* @param {string} mediaItemId
* @returns {import('../models/MediaItemShare').MediaItemShareForClient}
*/
findByMediaItemId(mediaItemId) {
const mediaItemShareObject = this.openMediaItemShares.find((s) => s.mediaItemShare.mediaItemId === mediaItemId)?.mediaItemShare
if (mediaItemShareObject) {
const mediaItemShareObjectForClient = { ...mediaItemShareObject }
delete mediaItemShareObjectForClient.pash
delete mediaItemShareObjectForClient.userId
delete mediaItemShareObjectForClient.extraData
return mediaItemShareObjectForClient
}
return null
}
/**
* Find an open media item share by slug
* @param {string} slug
* @returns {import('../models/MediaItemShare').MediaItemShareForClient}
*/
findBySlug(slug) {
const mediaItemShareObject = this.openMediaItemShares.find((s) => s.mediaItemShare.slug === slug)?.mediaItemShare
if (mediaItemShareObject) {
const mediaItemShareObjectForClient = { ...mediaItemShareObject }
delete mediaItemShareObjectForClient.pash
delete mediaItemShareObjectForClient.userId
delete mediaItemShareObjectForClient.extraData
return mediaItemShareObjectForClient
}
return null
}
/**
* @param {string} shareSessionId
* @returns {import('../objects/PlaybackSession')}
*/
findPlaybackSessionBySessionId(shareSessionId) {
return this.openSharePlaybackSessions.find((s) => s.shareSessionId === shareSessionId)
}
/**
* Load all media item shares from the database
* Remove expired & schedule active
*/
async loadMediaItemShares() {
/** @type {import('../models/MediaItemShare').MediaItemShareModel[]} */
const mediaItemShares = await Database.models.mediaItemShare.findAll()
for (const mediaItemShare of mediaItemShares) {
if (mediaItemShare.expiresAt && mediaItemShare.expiresAt.valueOf() < Date.now()) {
Logger.info(`[ShareManager] Removing expired media item share "${mediaItemShare.id}"`)
await this.destroyMediaItemShare(mediaItemShare.id)
} else if (mediaItemShare.expiresAt) {
this.scheduleMediaItemShare(mediaItemShare)
} else {
Logger.info(`[ShareManager] Loaded permanent media item share "${mediaItemShare.id}"`)
this.openMediaItemShares.push({
id: mediaItemShare.id,
mediaItemShare: mediaItemShare.toJSON()
})
}
}
}
/**
*
* @param {import('../models/MediaItemShare').MediaItemShareModel} mediaItemShare
*/
scheduleMediaItemShare(mediaItemShare) {
if (!mediaItemShare?.expiresAt) return
const expiresAtDuration = mediaItemShare.expiresAt.valueOf() - Date.now()
if (expiresAtDuration <= 0) {
Logger.warn(`[ShareManager] Attempted to schedule expired media item share "${mediaItemShare.id}"`)
this.destroyMediaItemShare(mediaItemShare.id)
return
}
const timeout = new LongTimeout()
timeout.set(() => {
Logger.info(`[ShareManager] Removing expired media item share "${mediaItemShare.id}"`)
this.removeMediaItemShare(mediaItemShare.id)
}, expiresAtDuration)
this.openMediaItemShares.push({ id: mediaItemShare.id, mediaItemShare: mediaItemShare.toJSON(), timeout })
Logger.info(`[ShareManager] Scheduled media item share "${mediaItemShare.id}" to expire in ${elapsedPretty(expiresAtDuration / 1000)}`)
}
/**
*
* @param {import('../models/MediaItemShare').MediaItemShareModel} mediaItemShare
*/
openMediaItemShare(mediaItemShare) {
if (mediaItemShare.expiresAt) {
this.scheduleMediaItemShare(mediaItemShare)
} else {
this.openMediaItemShares.push({ id: mediaItemShare.id, mediaItemShare: mediaItemShare.toJSON() })
}
SocketAuthority.adminEmitter('share_open', mediaItemShare.toJSONForClient())
}
/**
*
* @param {string} mediaItemShareId
*/
async removeMediaItemShare(mediaItemShareId) {
const mediaItemShare = this.openMediaItemShares.find((s) => s.id === mediaItemShareId)
if (!mediaItemShare) return
if (mediaItemShare.timeout) {
mediaItemShare.timeout.clear()
}
this.openMediaItemShares = this.openMediaItemShares.filter((s) => s.id !== mediaItemShareId)
this.openSharePlaybackSessions = this.openSharePlaybackSessions.filter((s) => s.mediaItemShareId !== mediaItemShareId)
await this.destroyMediaItemShare(mediaItemShareId)
const mediaItemShareObjectForClient = { ...mediaItemShare.mediaItemShare }
delete mediaItemShareObjectForClient.pash
delete mediaItemShareObjectForClient.userId
delete mediaItemShareObjectForClient.extraData
SocketAuthority.adminEmitter('share_closed', mediaItemShareObjectForClient)
}
/**
*
* @param {string} mediaItemShareId
*/
destroyMediaItemShare(mediaItemShareId) {
return Database.models.mediaItemShare.destroy({ where: { id: mediaItemShareId } })
}
/**
* Close open share sessions that have not been updated in the last 24 hours
*/
closeStaleOpenShareSessions() {
const updatedAtTimeCutoff = Date.now() - 1000 * 60 * 60 * 24
const staleSessions = this.openSharePlaybackSessions.filter((session) => session.updatedAt < updatedAtTimeCutoff)
for (const session of staleSessions) {
const sessionLastUpdate = new Date(session.updatedAt)
Logger.info(`[PlaybackSessionManager] Closing stale session "${session.displayTitle}" (${session.id}) last updated at ${sessionLastUpdate}`)
this.closeSharePlaybackSession(session)
}
}
}
module.exports = new ShareManager()

View file

@ -1,6 +1,13 @@
const SocketAuthority = require('../SocketAuthority')
const Task = require('../objects/Task')
/**
* @typedef TaskString
* @property {string} text
* @property {string} key
* @property {string[]} [subs]
*/
class TaskManager {
constructor() {
/** @type {Task[]} */
@ -9,8 +16,8 @@ class TaskManager {
/**
* Add task and emit socket task_started event
*
* @param {Task} task
*
* @param {Task} task
*/
addTask(task) {
this.tasks.push(task)
@ -19,30 +26,46 @@ class TaskManager {
/**
* Remove task and emit task_finished event
*
* @param {Task} task
*
* @param {Task} task
*/
taskFinished(task) {
if (this.tasks.some(t => t.id === task.id)) {
this.tasks = this.tasks.filter(t => t.id !== task.id)
if (this.tasks.some((t) => t.id === task.id)) {
this.tasks = this.tasks.filter((t) => t.id !== task.id)
SocketAuthority.emitter('task_finished', task.toJSON())
}
}
/**
* Create new task and add
*
* @param {string} action
* @param {string} title
* @param {string} description
* @param {boolean} showSuccess
* @param {Object} [data]
*
* @param {string} action
* @param {TaskString} titleString
* @param {TaskString|null} descriptionString
* @param {boolean} showSuccess
* @param {Object} [data]
*/
createAndAddTask(action, title, description, showSuccess, data = {}) {
createAndAddTask(action, titleString, descriptionString, showSuccess, data = {}) {
const task = new Task()
task.setData(action, title, description, showSuccess, data)
task.setData(action, titleString, descriptionString, showSuccess, data)
this.addTask(task)
return task
}
/**
* Create new failed task and add
*
* @param {string} action
* @param {TaskString} titleString
* @param {TaskString|null} descriptionString
* @param {TaskString} errorMessageString
*/
createAndEmitFailedTask(action, titleString, descriptionString, errorMessageString) {
const task = new Task()
task.setData(action, titleString, descriptionString, false)
task.setFailed(errorMessageString)
SocketAuthority.emitter('task_started', task.toJSON())
return task
}
}
module.exports = new TaskManager()
module.exports = new TaskManager()