Merge branch 'master' into feat-forwardauth

This commit is contained in:
advplyr 2022-12-18 09:49:49 -06:00
commit 8558baf30a
214 changed files with 14835 additions and 6306 deletions

View file

@ -1,5 +1,6 @@
const bcrypt = require('./libs/bcryptjs')
const jwt = require('./libs/jsonwebtoken')
const requestIp = require('./libs/requestIp')
const Logger = require('./Logger')
const User = require('./objects/user/User')
const { getId } = require('./utils/index')
@ -111,7 +112,7 @@ class Auth {
Logger.error('JWT Verify Token Failed', err)
return resolve(null)
}
var user = this.users.find(u => u.id === payload.userId && u.username === payload.username)
const user = this.users.find(u => u.id === payload.userId && u.username === payload.username)
resolve(user || null)
})
})
@ -128,14 +129,16 @@ class Auth {
}
async login(req, res, feeds) {
const ipAddress = requestIp.getClientIp(req)
var username = (req.body.username || '').toLowerCase()
var password = req.body.password || ''
var user = this.users.find(u => u.username.toLowerCase() === username)
if (!user || !user.isActive) {
Logger.debug(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit}`)
Logger.warn(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit} from ${ipAddress}`)
if (req.rateLimit.remaining <= 2) {
Logger.error(`[Auth] Failed login attempt for username ${username} from ip ${ipAddress}. Attempts: ${req.rateLimit.current}`)
return res.status(401).send(`Invalid user or password (${req.rateLimit.remaining === 0 ? '1 attempt remaining' : `${req.rateLimit.remaining + 1} attempts remaining`})`)
}
return res.status(401).send('Invalid user or password')
@ -155,9 +158,9 @@ class Auth {
if (compare) {
res.json(this.getUserLoginResponsePayload(user, feeds))
} else {
Logger.debug(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit}`)
Logger.warn(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit} from ${ipAddress}`)
if (req.rateLimit.remaining <= 2) {
Logger.error(`[Auth] Failed login attempt for user ${user.username}. Attempts: ${req.rateLimit.current}`)
Logger.error(`[Auth] Failed login attempt for user ${user.username} from ip ${ipAddress}. Attempts: ${req.rateLimit.current}`)
return res.status(401).send(`Invalid user or password (${req.rateLimit.remaining === 0 ? '1 attempt remaining' : `${req.rateLimit.remaining + 1} attempts remaining`})`)
}
return res.status(401).send('Invalid user or password')

View file

@ -2,9 +2,11 @@ const Path = require('path')
const njodb = require('./libs/njodb')
const Logger = require('./Logger')
const { version } = require('../package.json')
const filePerms = require('./utils/filePerms')
const LibraryItem = require('./objects/LibraryItem')
const User = require('./objects/user/User')
const UserCollection = require('./objects/UserCollection')
const Collection = require('./objects/Collection')
const Playlist = require('./objects/Playlist')
const Library = require('./objects/Library')
const Author = require('./objects/entities/Author')
const Series = require('./objects/entities/Series')
@ -20,6 +22,7 @@ class Db {
this.LibrariesPath = Path.join(global.ConfigPath, 'libraries')
this.SettingsPath = Path.join(global.ConfigPath, 'settings')
this.CollectionsPath = Path.join(global.ConfigPath, 'collections')
this.PlaylistsPath = Path.join(global.ConfigPath, 'playlists')
this.AuthorsPath = Path.join(global.ConfigPath, 'authors')
this.SeriesPath = Path.join(global.ConfigPath, 'series')
this.FeedsPath = Path.join(global.ConfigPath, 'feeds')
@ -31,6 +34,7 @@ class Db {
this.librariesDb = new njodb.Database(this.LibrariesPath, { datastores: 2, lockoptions: { stale: staleTime } })
this.settingsDb = new njodb.Database(this.SettingsPath, { datastores: 2, lockoptions: { stale: staleTime } })
this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2, lockoptions: { stale: staleTime } })
this.playlistsDb = new njodb.Database(this.PlaylistsPath, { datastores: 2, lockoptions: { stale: staleTime } })
this.authorsDb = new njodb.Database(this.AuthorsPath, { lockoptions: { stale: staleTime } })
this.seriesDb = new njodb.Database(this.SeriesPath, { datastores: 2, lockoptions: { stale: staleTime } })
this.feedsDb = new njodb.Database(this.FeedsPath, { datastores: 2, lockoptions: { stale: staleTime } })
@ -40,6 +44,7 @@ class Db {
this.libraries = []
this.settings = []
this.collections = []
this.playlists = []
this.authors = []
this.series = []
@ -61,6 +66,7 @@ class Db {
else if (entityName === 'library') return this.librariesDb
else if (entityName === 'settings') return this.settingsDb
else if (entityName === 'collection') return this.collectionsDb
else if (entityName === 'playlist') return this.playlistsDb
else if (entityName === 'author') return this.authorsDb
else if (entityName === 'series') return this.seriesDb
else if (entityName === 'feed') return this.feedsDb
@ -74,6 +80,7 @@ class Db {
else if (entityName === 'library') return 'libraries'
else if (entityName === 'settings') return 'settings'
else if (entityName === 'collection') return 'collections'
else if (entityName === 'playlist') return 'playlists'
else if (entityName === 'author') return 'authors'
else if (entityName === 'series') return 'series'
else if (entityName === 'feed') return 'feeds'
@ -81,15 +88,17 @@ class Db {
}
reinit() {
this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath)
this.usersDb = new njodb.Database(this.UsersPath)
this.sessionsDb = new njodb.Database(this.SessionsPath)
this.librariesDb = new njodb.Database(this.LibrariesPath, { datastores: 2 })
this.settingsDb = new njodb.Database(this.SettingsPath, { datastores: 2 })
this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2 })
this.authorsDb = new njodb.Database(this.AuthorsPath)
this.seriesDb = new njodb.Database(this.SeriesPath, { datastores: 2 })
this.feedsDb = new njodb.Database(this.FeedsPath, { datastores: 2 })
const staleTime = 1000 * 60 * 2
this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath, { lockoptions: { stale: staleTime } })
this.usersDb = new njodb.Database(this.UsersPath, { lockoptions: { stale: staleTime } })
this.sessionsDb = new njodb.Database(this.SessionsPath, { lockoptions: { stale: staleTime } })
this.librariesDb = new njodb.Database(this.LibrariesPath, { datastores: 2, lockoptions: { stale: staleTime } })
this.settingsDb = new njodb.Database(this.SettingsPath, { datastores: 2, lockoptions: { stale: staleTime } })
this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2, lockoptions: { stale: staleTime } })
this.playlistsDb = new njodb.Database(this.PlaylistsPath, { datastores: 2, lockoptions: { stale: staleTime } })
this.authorsDb = new njodb.Database(this.AuthorsPath, { lockoptions: { stale: staleTime } })
this.seriesDb = new njodb.Database(this.SeriesPath, { datastores: 2, lockoptions: { stale: staleTime } })
this.feedsDb = new njodb.Database(this.FeedsPath, { datastores: 2, lockoptions: { stale: staleTime } })
return this.init()
}
@ -123,6 +132,9 @@ class Db {
async init() {
await this.load()
// Set file ownership for all files created by db
await filePerms.setDefault(global.ConfigPath, true)
if (!this.serverSettings) { // Create first load server settings
this.serverSettings = new ServerSettings()
await this.insertEntity('settings', this.serverSettings)
@ -135,20 +147,20 @@ class Db {
}
async load() {
var p1 = this.libraryItemsDb.select(() => true).then((results) => {
const p1 = this.libraryItemsDb.select(() => true).then((results) => {
this.libraryItems = results.data.map(a => new LibraryItem(a))
Logger.info(`[DB] ${this.libraryItems.length} Library Items Loaded`)
})
var p2 = this.usersDb.select(() => true).then((results) => {
const p2 = this.usersDb.select(() => true).then((results) => {
this.users = results.data.map(u => new User(u))
Logger.info(`[DB] ${this.users.length} Users Loaded`)
})
var p3 = this.librariesDb.select(() => true).then((results) => {
const p3 = this.librariesDb.select(() => true).then((results) => {
this.libraries = results.data.map(l => new Library(l))
this.libraries.sort((a, b) => a.displayOrder - b.displayOrder)
Logger.info(`[DB] ${this.libraries.length} Libraries Loaded`)
})
var p4 = this.settingsDb.select(() => true).then(async (results) => {
const p4 = this.settingsDb.select(() => true).then(async (results) => {
if (results.data && results.data.length) {
this.settings = results.data
var serverSettings = this.settings.find(s => s.id === 'server-settings')
@ -179,19 +191,23 @@ class Db {
}
}
})
var p5 = this.collectionsDb.select(() => true).then((results) => {
this.collections = results.data.map(l => new UserCollection(l))
const p5 = this.collectionsDb.select(() => true).then((results) => {
this.collections = results.data.map(l => new Collection(l))
Logger.info(`[DB] ${this.collections.length} Collections Loaded`)
})
var p6 = this.authorsDb.select(() => true).then((results) => {
const p6 = this.playlistsDb.select(() => true).then((results) => {
this.playlists = results.data.map(l => new Playlist(l))
Logger.info(`[DB] ${this.playlists.length} Playlists Loaded`)
})
const p7 = this.authorsDb.select(() => true).then((results) => {
this.authors = results.data.map(l => new Author(l))
Logger.info(`[DB] ${this.authors.length} Authors Loaded`)
})
var p7 = this.seriesDb.select(() => true).then((results) => {
const p8 = this.seriesDb.select(() => true).then((results) => {
this.series = results.data.map(l => new Series(l))
Logger.info(`[DB] ${this.series.length} Series Loaded`)
})
await Promise.all([p1, p2, p3, p4, p5, p6, p7])
await Promise.all([p1, p2, p3, p4, p5, p6, p7, p8])
// Update server version in server settings
if (this.previousVersion) {
@ -258,23 +274,6 @@ class Db {
})
}
updateUserStream(userId, streamId) {
return this.usersDb.update((record) => record.id === userId, (user) => {
user.stream = streamId
return user
}).then((results) => {
Logger.debug(`[DB] Updated user ${results.updated}`)
this.users = this.users.map(u => {
if (u.id === userId) {
u.stream = streamId
}
return u
})
}).catch((error) => {
Logger.error(`[DB] Update user Failed ${error}`)
})
}
updateServerSettings() {
global.ServerSettings = this.serverSettings.toJSON()
return this.updateEntity('settings', this.serverSettings)

View file

@ -1,7 +1,6 @@
const Path = require('path')
const express = require('express')
const http = require('http')
const SocketIO = require('socket.io')
const fs = require('./libs/fsExtra')
const fileUpload = require('./libs/expressFileupload')
const rateLimit = require('./libs/expressRateLimit')
@ -13,11 +12,11 @@ const dbMigration = require('./utils/dbMigration')
const filePerms = require('./utils/filePerms')
const Logger = require('./Logger')
// Classes
const Auth = require('./Auth')
const Watcher = require('./Watcher')
const Scanner = require('./scanner/Scanner')
const Db = require('./Db')
const SocketAuthority = require('./SocketAuthority')
const ApiRouter = require('./routers/ApiRouter')
const HlsRouter = require('./routers/HlsRouter')
@ -67,60 +66,30 @@ class Server {
this.auth = new Auth(this.db)
// Managers
this.taskManager = new TaskManager(this.emitter.bind(this))
this.notificationManager = new NotificationManager(this.db, this.emitter.bind(this))
this.backupManager = new BackupManager(this.db, this.emitter.bind(this))
this.taskManager = new TaskManager()
this.notificationManager = new NotificationManager(this.db)
this.backupManager = new BackupManager(this.db)
this.logManager = new LogManager(this.db)
this.cacheManager = new CacheManager()
this.abMergeManager = new AbMergeManager(this.db, this.taskManager, this.clientEmitter.bind(this))
this.playbackSessionManager = new PlaybackSessionManager(this.db, this.emitter.bind(this), this.clientEmitter.bind(this))
this.abMergeManager = new AbMergeManager(this.db, this.taskManager)
this.playbackSessionManager = new PlaybackSessionManager(this.db)
this.coverManager = new CoverManager(this.db, this.cacheManager)
this.podcastManager = new PodcastManager(this.db, this.watcher, this.emitter.bind(this), this.notificationManager)
this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.taskManager, this.emitter.bind(this), this.clientEmitter.bind(this))
this.rssFeedManager = new RssFeedManager(this.db, this.emitter.bind(this))
this.podcastManager = new PodcastManager(this.db, this.watcher, this.notificationManager)
this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.taskManager)
this.rssFeedManager = new RssFeedManager(this.db)
this.scanner = new Scanner(this.db, this.coverManager, this.emitter.bind(this))
this.scanner = new Scanner(this.db, this.coverManager)
this.cronManager = new CronManager(this.db, this.scanner, this.podcastManager)
// Routers
this.apiRouter = new ApiRouter(this.db, this.auth, this.scanner, this.playbackSessionManager, this.abMergeManager, this.coverManager, this.backupManager, this.watcher, this.cacheManager, this.podcastManager, this.audioMetadataManager, this.rssFeedManager, this.cronManager, this.notificationManager, this.taskManager, this.emitter.bind(this), this.clientEmitter.bind(this))
this.hlsRouter = new HlsRouter(this.db, this.auth, this.playbackSessionManager, this.emitter.bind(this))
this.apiRouter = new ApiRouter(this)
this.hlsRouter = new HlsRouter(this.db, this.auth, this.playbackSessionManager)
this.staticRouter = new StaticRouter(this.db)
Logger.logManager = this.logManager
this.server = null
this.io = null
this.clients = {}
}
get usersOnline() {
// TODO: Map open user sessions
return Object.values(this.clients).filter(c => c.user).map(client => {
return client.user.toJSONForPublic(this.playbackSessionManager.sessions, this.db.libraryItems)
})
}
getClientsForUser(userId) {
return Object.values(this.clients).filter(c => c.user && c.user.id === userId)
}
emitter(ev, data) {
// Logger.debug('EMITTER', ev)
this.io.emit(ev, data)
}
clientEmitter(userId, ev, data) {
var clients = this.getClientsForUser(userId)
if (!clients.length) {
return Logger.debug(`[Server] clientEmitter - no clients found for user ${userId}`)
}
clients.forEach((client) => {
if (client.socket) {
client.socket.emit(ev, data)
}
})
}
authMiddleware(req, res, next) {
@ -131,7 +100,7 @@ class Server {
Logger.info('[Server] Init v' + version)
await this.playbackSessionManager.removeOrphanStreams()
var previousVersion = await this.db.checkPreviousVersion() // Returns null if same server version
const previousVersion = await this.db.checkPreviousVersion() // Returns null if same server version
if (previousVersion) {
Logger.debug(`[Server] Upgraded from previous version ${previousVersion}`)
}
@ -198,13 +167,13 @@ class Server {
// EBook static file routes
router.get('/ebook/:library/:folder/*', (req, res) => {
var library = this.db.libraries.find(lib => lib.id === req.params.library)
const library = this.db.libraries.find(lib => lib.id === req.params.library)
if (!library) return res.sendStatus(404)
var folder = library.folders.find(fol => fol.id === req.params.folder)
const folder = library.folders.find(fol => fol.id === req.params.folder)
if (!folder) return res.status(404).send('Folder not found')
var remainingPath = req.params['0']
var fullPath = Path.join(folder.fullPath, remainingPath)
const remainingPath = req.params['0']
const fullPath = Path.join(folder.fullPath, remainingPath)
res.sendFile(fullPath)
})
@ -237,7 +206,8 @@ class Server {
'/library/:library/podcast/latest',
'/config/users/:id',
'/config/users/:id/sessions',
'/collection/:id'
'/collection/:id',
'/playlist/:id'
]
dyanimicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))
@ -255,7 +225,8 @@ class Server {
// status check for client to see if server has been initialized
// server has been initialized if a root user exists
const payload = {
isInit: this.db.hasRootUser
isInit: this.db.hasRootUser,
language: this.db.serverSettings.language
}
if (!payload.isInit) {
payload.ConfigPath = global.ConfigPath
@ -270,61 +241,12 @@ class Server {
app.get('/healthcheck', (req, res) => res.sendStatus(200))
this.server.listen(this.Port, this.Host, () => {
Logger.info(`Listening on http://${this.Host}:${this.Port}`)
if (this.Host) Logger.info(`Listening on http://${this.Host}:${this.Port}`)
else Logger.info(`Listening on port :${this.Port}`)
})
this.io = new SocketIO.Server(this.server, {
cors: {
origin: '*',
methods: ["GET", "POST"]
}
})
this.io.on('connection', (socket) => {
this.clients[socket.id] = {
id: socket.id,
socket,
connected_at: Date.now()
}
socket.sheepClient = this.clients[socket.id]
Logger.info('[Server] Socket Connected', socket.id)
socket.on('auth', (token) => this.authenticateSocket(socket, token))
// Scanning
socket.on('cancel_scan', this.cancelScan.bind(this))
// Logs
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))
socket.on('fetch_daily_logs', () => this.logManager.socketRequestDailyLogs(socket))
socket.on('ping', () => {
var client = this.clients[socket.id] || {}
var user = client.user || {}
Logger.debug(`[Server] Received ping from socket ${user.username || 'No User'}`)
socket.emit('pong')
})
socket.on('disconnect', (reason) => {
Logger.removeSocketListener(socket.id)
var _client = this.clients[socket.id]
if (!_client) {
Logger.warn(`[Server] Socket ${socket.id} disconnect, no client (Reason: ${reason})`)
} else if (!_client.user) {
Logger.info(`[Server] Unauth socket ${socket.id} disconnected (Reason: ${reason})`)
delete this.clients[socket.id]
} else {
Logger.debug('[Server] User Offline ' + _client.user.username)
this.io.emit('user_offline', _client.user.toJSONForPublic(this.playbackSessionManager.sessions, this.db.libraryItems))
const disconnectTime = Date.now() - _client.connected_at
Logger.info(`[Server] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`)
delete this.clients[socket.id]
}
})
})
// Start listening for socket connections
SocketAuthority.initialize(this)
}
async initializeServer(req, res) {
@ -343,22 +265,17 @@ class Server {
await this.scanner.scanFilesChanged(fileUpdates)
}
cancelScan(id) {
Logger.debug('[Server] Cancel scan', id)
this.scanner.setCancelLibraryScan(id)
}
// Remove unused /metadata/items/{id} folders
async purgeMetadata() {
var itemsMetadata = Path.join(global.MetadataPath, 'items')
const itemsMetadata = Path.join(global.MetadataPath, 'items')
if (!(await fs.pathExists(itemsMetadata))) return
var foldersInItemsMetadata = await fs.readdir(itemsMetadata)
const foldersInItemsMetadata = await fs.readdir(itemsMetadata)
var purged = 0
let purged = 0
await Promise.all(foldersInItemsMetadata.map(async foldername => {
var hasMatchingItem = this.db.libraryItems.find(ab => ab.id === foldername)
const hasMatchingItem = this.db.libraryItems.find(ab => ab.id === foldername)
if (!hasMatchingItem) {
var folderPath = Path.join(itemsMetadata, foldername)
const folderPath = Path.join(itemsMetadata, foldername)
Logger.debug(`[Server] Purging unused metadata ${folderPath}`)
await fs.remove(folderPath).then(() => {
@ -377,8 +294,8 @@ class Server {
// Remove user media progress with items that no longer exist & remove seriesHideFrom that no longer exist
async cleanUserData() {
for (let i = 0; i < this.db.users.length; i++) {
var _user = this.db.users[i]
var hasUpdated = false
const _user = this.db.users[i]
let hasUpdated = false
if (_user.mediaProgress.length) {
const lengthBefore = _user.mediaProgress.length
_user.mediaProgress = _user.mediaProgress.filter(mp => {
@ -424,68 +341,14 @@ class Server {
}
logout(req, res) {
var { socketId } = req.body
Logger.info(`[Server] User ${req.user ? req.user.username : 'Unknown'} is logging out with socket ${socketId}`)
// Strip user and client from client and client socket
if (socketId && this.clients[socketId]) {
var client = this.clients[socketId]
var clientSocket = client.socket
Logger.debug(`[Server] Found user client ${clientSocket.id}, Has user: ${!!client.user}, Socket has client: ${!!clientSocket.sheepClient}`)
if (client.user) {
Logger.debug('[Server] User Offline ' + client.user.username)
this.io.emit('user_offline', client.user.toJSONForPublic(null, this.db.libraryItems))
}
delete this.clients[socketId].user
if (clientSocket && clientSocket.sheepClient) delete this.clients[socketId].socket.sheepClient
} else if (socketId) {
Logger.warn(`[Server] No client for socket ${socketId}`)
if (req.body.socketId) {
Logger.info(`[Server] User ${req.user ? req.user.username : 'Unknown'} is logging out with socket ${req.body.socketId}`)
SocketAuthority.logout(req.body.socketId)
}
res.sendStatus(200)
}
async authenticateSocket(socket, token) {
var user = await this.auth.authenticateUser(token)
if (!user) {
Logger.error('Cannot validate socket - invalid token')
return socket.emit('invalid_token')
}
var client = this.clients[socket.id]
if (client.user !== undefined) {
Logger.debug(`[Server] Authenticating socket client already has user`, client.user.username)
}
client.user = user
if (!client.user.toJSONForBrowser) {
Logger.error('Invalid user...', client.user)
return
}
Logger.debug(`[Server] User Online ${client.user.username}`)
this.io.emit('user_online', client.user.toJSONForPublic(this.playbackSessionManager.sessions, this.db.libraryItems))
user.lastSeen = Date.now()
await this.db.updateEntity('user', user)
const initialPayload = {
metadataPath: global.MetadataPath,
configPath: global.ConfigPath,
user: client.user.toJSONForBrowser(),
librariesScanning: this.scanner.librariesScanning,
backups: (this.backupManager.backups || []).map(b => b.toJSON())
}
if (user.type === 'root') {
initialPayload.usersOnline = this.usersOnline
}
client.socket.emit('init', initialPayload)
}
async stop() {
await this.watcher.close()
Logger.info('Watcher Closed')

204
server/SocketAuthority.js Normal file
View file

@ -0,0 +1,204 @@
const SocketIO = require('socket.io')
const Logger = require('./Logger')
class SocketAuthority {
constructor() {
this.Server = null
this.io = null
this.clients = {}
}
// returns an array of User.toJSONForPublic with `connections` for the # of socket connections
// a user can have many socket connections
getUsersOnline() {
const onlineUsersMap = {}
Object.values(this.clients).filter(c => c.user).forEach(client => {
if (onlineUsersMap[client.user.id]) {
onlineUsersMap[client.user.id].connections++
} else {
onlineUsersMap[client.user.id] = {
...client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, this.Server.db.libraryItems),
connections: 1
}
}
})
return Object.values(onlineUsersMap)
}
getClientsForUser(userId) {
return Object.values(this.clients).filter(c => c.user && c.user.id === userId)
}
// Emits event to all authorized clients
// optional filter function to only send event to specific users
// TODO: validate that filter is actually a function
emitter(evt, data, filter = null) {
for (const socketId in this.clients) {
if (this.clients[socketId].user) {
if (filter && !filter(this.clients[socketId].user)) continue
this.clients[socketId].socket.emit(evt, data)
}
}
}
// Emits event to all clients for a specific user
clientEmitter(userId, evt, data) {
const clients = this.getClientsForUser(userId)
if (!clients.length) {
return Logger.debug(`[Server] clientEmitter - no clients found for user ${userId}`)
}
clients.forEach((client) => {
if (client.socket) {
client.socket.emit(evt, data)
}
})
}
// Emits event to all admin user clients
adminEmitter(evt, data) {
for (const socketId in this.clients) {
if (this.clients[socketId].user && this.clients[socketId].user.isAdminOrUp) {
this.clients[socketId].socket.emit(evt, data)
}
}
}
initialize(Server) {
this.Server = Server
this.io = new SocketIO.Server(this.Server.server, {
cors: {
origin: '*',
methods: ["GET", "POST"]
}
})
this.io.on('connection', (socket) => {
this.clients[socket.id] = {
id: socket.id,
socket,
connected_at: Date.now()
}
socket.sheepClient = this.clients[socket.id]
Logger.info('[Server] Socket Connected', socket.id)
// Required for associating a User with a socket
socket.on('auth', (token) => this.authenticateSocket(socket, token))
// Scanning
socket.on('cancel_scan', this.cancelScan.bind(this))
// Logs
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))
socket.on('fetch_daily_logs', () => this.Server.logManager.socketRequestDailyLogs(socket))
// Sent automatically from socket.io clients
socket.on('disconnect', (reason) => {
Logger.removeSocketListener(socket.id)
const _client = this.clients[socket.id]
if (!_client) {
Logger.warn(`[Server] Socket ${socket.id} disconnect, no client (Reason: ${reason})`)
} else if (!_client.user) {
Logger.info(`[Server] Unauth socket ${socket.id} disconnected (Reason: ${reason})`)
delete this.clients[socket.id]
} else {
Logger.debug('[Server] User Offline ' + _client.user.username)
this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, this.Server.db.libraryItems))
const disconnectTime = Date.now() - _client.connected_at
Logger.info(`[Server] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`)
delete this.clients[socket.id]
}
})
//
// Events for testing
//
socket.on('message_all_users', (payload) => {
// admin user can send a message to all authenticated users
// displays on the web app as a toast
const client = this.clients[socket.id] || {}
if (client.user && client.user.isAdminOrUp) {
this.emitter('admin_message', payload.message || '')
} else {
Logger.error(`[Server] Non-admin user sent the message_all_users event`)
}
})
socket.on('ping', () => {
const client = this.clients[socket.id] || {}
const user = client.user || {}
Logger.debug(`[Server] Received ping from socket ${user.username || 'No User'}`)
socket.emit('pong')
})
})
}
// When setting up a socket connection the user needs to be associated with a socket id
// for this the client will send a 'auth' event that includes the users API token
async authenticateSocket(socket, token) {
const user = await this.Server.auth.authenticateUser(token)
if (!user) {
Logger.error('Cannot validate socket - invalid token')
return socket.emit('invalid_token')
}
const client = this.clients[socket.id]
if (client.user !== undefined) {
Logger.debug(`[Server] Authenticating socket client already has user`, client.user.username)
}
client.user = user
if (!client.user.toJSONForBrowser) {
Logger.error('Invalid user...', client.user)
return
}
Logger.debug(`[Server] User Online ${client.user.username}`)
this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, this.Server.db.libraryItems))
// Update user lastSeen
user.lastSeen = Date.now()
await this.Server.db.updateEntity('user', user)
const initialPayload = {
userId: client.user.id,
username: client.user.username,
librariesScanning: this.Server.scanner.librariesScanning
}
if (user.isAdminOrUp) {
initialPayload.usersOnline = this.getUsersOnline()
}
client.socket.emit('init', initialPayload)
}
logout(socketId) {
// Strip user and client from client and client socket
if (socketId && this.clients[socketId]) {
const client = this.clients[socketId]
const clientSocket = client.socket
Logger.debug(`[Server] Found user client ${clientSocket.id}, Has user: ${!!client.user}, Socket has client: ${!!clientSocket.sheepClient}`)
if (client.user) {
Logger.debug('[Server] User Offline ' + client.user.username)
this.adminEmitter('user_offline', client.user.toJSONForPublic(null, this.Server.db.libraryItems))
}
delete this.clients[socketId].user
if (clientSocket && clientSocket.sheepClient) delete this.clients[socketId].socket.sheepClient
} else if (socketId) {
Logger.warn(`[Server] No client for socket ${socketId}`)
}
}
cancelScan(id) {
Logger.debug('[Server] Cancel scan', id)
this.Server.scanner.setCancelLibraryScan(id)
}
}
module.exports = new SocketAuthority()

View file

@ -1,4 +1,6 @@
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const { reqSupportsWebp } = require('../utils/index')
const { createNewSortInstance } = require('../libs/fastSort')
@ -60,76 +62,77 @@ class AuthorController {
}
async update(req, res) {
var payload = req.body
const payload = req.body
let hasUpdated = false
// If updating or removing cover image then clear cache
if (payload.imagePath !== undefined && req.author.imagePath && payload.imagePath !== req.author.imagePath) {
this.cacheManager.purgeImageCache(req.author.id)
if (!payload.imagePath) { // If removing image then remove file
var currentImagePath = req.author.imagePath
await this.coverManager.removeFile(currentImagePath)
// Updating/removing cover image
if (payload.imagePath !== undefined && payload.imagePath !== req.author.imagePath) {
if (!payload.imagePath && req.author.imagePath) { // If removing image then remove file
await this.cacheManager.purgeImageCache(req.author.id) // Purge cache
await this.coverManager.removeFile(req.author.imagePath)
} else if (payload.imagePath.startsWith('http')) { // Check if image path is a url
var imageData = await this.authorFinder.saveAuthorImage(req.author.id, payload.imagePath)
const imageData = await this.authorFinder.saveAuthorImage(req.author.id, payload.imagePath)
if (imageData) {
req.author.imagePath = imageData.path
req.author.relImagePath = imageData.relPath
hasUpdated = hasUpdated || true;
} else {
req.author.imagePath = null
req.author.relImagePath = null
if (req.author.imagePath) {
await this.cacheManager.purgeImageCache(req.author.id) // Purge cache
}
payload.imagePath = imageData.path
payload.relImagePath = imageData.relPath
hasUpdated = true
}
}
}
var authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name
const authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name
// Check if author name matches another author and merge the authors
var existingAuthor = authorNameUpdate ? this.db.authors.find(au => au.id !== req.author.id && payload.name === au.name) : false
const existingAuthor = authorNameUpdate ? this.db.authors.find(au => au.id !== req.author.id && payload.name === au.name) : false
if (existingAuthor) {
var itemsWithAuthor = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(req.author.id))
const itemsWithAuthor = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(req.author.id))
itemsWithAuthor.forEach(libraryItem => { // Replace old author with merging author for each book
libraryItem.media.metadata.replaceAuthor(req.author, existingAuthor)
})
if (itemsWithAuthor.length) {
await this.db.updateLibraryItems(itemsWithAuthor)
this.emitter('items_updated', itemsWithAuthor.map(li => li.toJSONExpanded()))
SocketAuthority.emitter('items_updated', itemsWithAuthor.map(li => li.toJSONExpanded()))
}
// Remove old author
await this.db.removeEntity('author', req.author.id)
this.emitter('author_removed', req.author.toJSON())
SocketAuthority.emitter('author_removed', req.author.toJSON())
// Send updated num books for merged author
var numBooks = this.db.libraryItems.filter(li => {
const numBooks = this.db.libraryItems.filter(li => {
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(existingAuthor.id)
}).length
this.emitter('author_updated', existingAuthor.toJSONExpanded(numBooks))
SocketAuthority.emitter('author_updated', existingAuthor.toJSONExpanded(numBooks))
res.json({
author: existingAuthor.toJSON(),
merged: true
})
} else { // Regular author update
var hasUpdated = req.author.update(payload)
if (req.author.update(payload)) {
hasUpdated = true
}
if (hasUpdated) {
if (authorNameUpdate) { // Update author name on all books
var itemsWithAuthor = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(req.author.id))
const itemsWithAuthor = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(req.author.id))
itemsWithAuthor.forEach(libraryItem => {
libraryItem.media.metadata.updateAuthor(req.author)
})
if (itemsWithAuthor.length) {
await this.db.updateLibraryItems(itemsWithAuthor)
this.emitter('items_updated', itemsWithAuthor.map(li => li.toJSONExpanded()))
SocketAuthority.emitter('items_updated', itemsWithAuthor.map(li => li.toJSONExpanded()))
}
}
await this.db.updateEntity('author', req.author)
var numBooks = this.db.libraryItems.filter(li => {
const numBooks = this.db.libraryItems.filter(li => {
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id)
}).length
this.emitter('author_updated', req.author.toJSONExpanded(numBooks))
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))
}
res.json({
@ -145,7 +148,9 @@ class AuthorController {
var limit = (req.query.limit && !isNaN(req.query.limit)) ? Number(req.query.limit) : 25
var authors = this.db.authors.filter(au => au.name.toLowerCase().includes(q))
authors = authors.slice(0, limit)
res.json(authors)
res.json({
results: authors
})
}
async match(req, res) {
@ -190,7 +195,7 @@ class AuthorController {
var numBooks = this.db.libraryItems.filter(li => {
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id)
}).length
this.emitter('author_updated', req.author.toJSONExpanded(numBooks))
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))
}
res.json({

View file

@ -3,32 +3,29 @@ const Logger = require('../Logger')
class BackupController {
constructor() { }
async create(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[BackupController] Non-admin user attempting to craete backup`, req.user)
return res.sendStatus(403)
}
getAll(req, res) {
res.json({
backups: this.backupManager.backups.map(b => b.toJSON())
})
}
create(req, res) {
this.backupManager.requestCreateBackup(res)
}
async delete(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[BackupController] Non-admin user attempting to delete backup`, req.user)
return res.sendStatus(403)
}
var backup = this.backupManager.backups.find(b => b.id === req.params.id)
if (!backup) {
return res.sendStatus(404)
}
await this.backupManager.removeBackup(backup)
res.json(this.backupManager.backups.map(b => b.toJSON()))
res.json({
backups: this.backupManager.backups.map(b => b.toJSON())
})
}
async upload(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[BackupController] Non-admin user attempting to upload backup`, req.user)
return res.sendStatus(403)
}
if (!req.files.file) {
Logger.error('[BackupController] Upload backup invalid')
return res.sendStatus(500)
@ -37,10 +34,6 @@ class BackupController {
}
async apply(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[BackupController] Non-admin user attempting to apply backup`, req.user)
return res.sendStatus(403)
}
var backup = this.backupManager.backups.find(b => b.id === req.params.id)
if (!backup) {
return res.sendStatus(404)
@ -48,5 +41,13 @@ class BackupController {
await this.backupManager.requestApplyBackup(backup)
res.sendStatus(200)
}
middleware(req, res, next) {
if (!req.user.isAdminOrUp) {
Logger.error(`[BackupController] Non-admin user attempting to access backups`, req.user)
return res.sendStatus(403)
}
next()
}
}
module.exports = new BackupController()

View file

@ -0,0 +1,26 @@
const Logger = require('../Logger')
class CacheController {
constructor() { }
// POST: api/cache/purge
async purgeCache(req, res) {
if (!req.user.isAdminOrUp) {
return res.sendStatus(403)
}
Logger.info(`[MiscController] Purging all cache`)
await this.cacheManager.purgeAll()
res.sendStatus(200)
}
// POST: api/cache/items/purge
async purgeItemsCache(req, res) {
if (!req.user.isAdminOrUp) {
return res.sendStatus(403)
}
Logger.info(`[MiscController] Purging items cache`)
await this.cacheManager.purgeItems()
res.sendStatus(200)
}
}
module.exports = new CacheController()

View file

@ -1,11 +1,13 @@
const Logger = require('../Logger')
const UserCollection = require('../objects/UserCollection')
const SocketAuthority = require('../SocketAuthority')
const Collection = require('../objects/Collection')
class CollectionController {
constructor() { }
async create(req, res) {
var newCollection = new UserCollection()
var newCollection = new Collection()
req.body.userId = req.user.id
var success = newCollection.setData(req.body)
if (!success) {
@ -13,14 +15,14 @@ class CollectionController {
}
var jsonExpanded = newCollection.toJSONExpanded(this.db.libraryItems)
await this.db.insertEntity('collection', newCollection)
this.emitter('collection_added', jsonExpanded)
SocketAuthority.emitter('collection_added', jsonExpanded)
res.json(jsonExpanded)
}
findAll(req, res) {
var collections = this.db.collections.filter(c => c.userId === req.user.id)
var expandedCollections = collections.map(c => c.toJSONExpanded(this.db.libraryItems))
res.json(expandedCollections)
res.json({
collections: this.db.collections.map(c => c.toJSONExpanded(this.db.libraryItems))
})
}
findOne(req, res) {
@ -33,7 +35,7 @@ class CollectionController {
var jsonExpanded = collection.toJSONExpanded(this.db.libraryItems)
if (wasUpdated) {
await this.db.updateEntity('collection', collection)
this.emitter('collection_updated', jsonExpanded)
SocketAuthority.emitter('collection_updated', jsonExpanded)
}
res.json(jsonExpanded)
}
@ -42,7 +44,7 @@ class CollectionController {
const collection = req.collection
var jsonExpanded = collection.toJSONExpanded(this.db.libraryItems)
await this.db.removeEntity('collection', collection.id)
this.emitter('collection_removed', jsonExpanded)
SocketAuthority.emitter('collection_removed', jsonExpanded)
res.sendStatus(200)
}
@ -61,7 +63,7 @@ class CollectionController {
collection.addBook(req.body.id)
var jsonExpanded = collection.toJSONExpanded(this.db.libraryItems)
await this.db.updateEntity('collection', collection)
this.emitter('collection_updated', jsonExpanded)
SocketAuthority.emitter('collection_updated', jsonExpanded)
res.json(jsonExpanded)
}
@ -72,7 +74,7 @@ class CollectionController {
collection.removeBook(req.params.bookId)
var jsonExpanded = collection.toJSONExpanded(this.db.libraryItems)
await this.db.updateEntity('collection', collection)
this.emitter('collection_updated', jsonExpanded)
SocketAuthority.emitter('collection_updated', jsonExpanded)
}
res.json(collection.toJSONExpanded(this.db.libraryItems))
}
@ -93,7 +95,7 @@ class CollectionController {
}
if (hasUpdated) {
await this.db.updateEntity('collection', collection)
this.emitter('collection_updated', collection.toJSONExpanded(this.db.libraryItems))
SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(this.db.libraryItems))
}
res.json(collection.toJSONExpanded(this.db.libraryItems))
}
@ -114,14 +116,14 @@ class CollectionController {
}
if (hasUpdated) {
await this.db.updateEntity('collection', collection)
this.emitter('collection_updated', collection.toJSONExpanded(this.db.libraryItems))
SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(this.db.libraryItems))
}
res.json(collection.toJSONExpanded(this.db.libraryItems))
}
middleware(req, res, next) {
if (req.params.id) {
var collection = this.db.collections.find(c => c.id === req.params.id)
const collection = this.db.collections.find(c => c.id === req.params.id)
if (!collection) {
return res.status(404).send('Collection not found')
}

View file

@ -19,8 +19,9 @@ class FileSystemController {
})
Logger.debug(`[Server] get file system paths, excluded: ${excludedDirs.join(', ')}`)
var dirs = await this.getDirectories(global.appRoot, '/', excludedDirs)
res.json(dirs)
res.json({
directories: await this.getDirectories(global.appRoot, '/', excludedDirs)
})
}
}
module.exports = new FileSystemController()

View file

@ -2,6 +2,7 @@ const Path = require('path')
const fs = require('../libs/fsExtra')
const filePerms = require('../utils/filePerms')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Library = require('../objects/Library')
const libraryHelpers = require('../utils/libraryHelpers')
const { sort, createNewSortInstance } = require('../libs/fastSort')
@ -12,7 +13,7 @@ class LibraryController {
constructor() { }
async create(req, res) {
var newLibraryPayload = {
const newLibraryPayload = {
...req.body
}
if (!newLibraryPayload.name || !newLibraryPayload.folders || !newLibraryPayload.folders.length) {
@ -25,9 +26,9 @@ class LibraryController {
f.fullPath = Path.resolve(f.fullPath)
return f
})
for (var folder of newLibraryPayload.folders) {
for (const folder of newLibraryPayload.folders) {
try {
var direxists = await fs.pathExists(folder.fullPath)
const direxists = await fs.pathExists(folder.fullPath)
if (!direxists) { // If folder does not exist try to make it and set file permissions/owner
await fs.mkdir(folder.fullPath)
await filePerms.setDefault(folder.fullPath)
@ -38,12 +39,16 @@ class LibraryController {
}
}
var library = new Library()
const library = new Library()
newLibraryPayload.displayOrder = this.db.libraries.length + 1
library.setData(newLibraryPayload)
await this.db.insertEntity('library', library)
// TODO: Only emit to users that have access
this.emitter('library_added', library.toJSON())
// Only emit to users with access to library
const userFilter = (user) => {
return user.checkCanAccessLibrary && user.checkCanAccessLibrary(library.id)
}
SocketAuthority.emitter('library_added', library.toJSON(), userFilter)
// Add library watcher
this.watcher.addLibrary(library)
@ -52,19 +57,23 @@ class LibraryController {
}
findAll(req, res) {
var librariesAccessible = req.user.librariesAccessible || []
const librariesAccessible = req.user.librariesAccessible || []
if (librariesAccessible && librariesAccessible.length) {
return res.json(this.db.libraries.filter(lib => librariesAccessible.includes(lib.id)).map(lib => lib.toJSON()))
}
res.json(this.db.libraries.map(lib => lib.toJSON()))
res.json({
libraries: this.db.libraries.map(lib => lib.toJSON())
})
}
async findOne(req, res) {
if (req.query.include && req.query.include === 'filterdata') {
const includeArray = (req.query.include || '').split(',')
if (includeArray.includes('filterdata')) {
return res.json({
filterdata: libraryHelpers.getDistinctFilterDataNew(req.libraryItems),
issues: req.libraryItems.filter(li => li.hasIssues).length,
numUserPlaylists: this.db.playlists.filter(p => p.userId === req.user.id && p.libraryId === req.library.id).length,
library: req.library
})
}
@ -72,12 +81,12 @@ class LibraryController {
}
async update(req, res) {
var library = req.library
const library = req.library
// Validate new folder paths exist or can be created & resolve rel paths
// returns 400 if a new folder fails to access
if (req.body.folders) {
var newFolderPaths = []
const newFolderPaths = []
req.body.folders = req.body.folders.map(f => {
if (!f.id) {
f.fullPath = Path.resolve(f.fullPath)
@ -85,11 +94,11 @@ class LibraryController {
}
return f
})
for (var path of newFolderPaths) {
var pathExists = await fs.pathExists(path)
for (const path of newFolderPaths) {
const pathExists = await fs.pathExists(path)
if (!pathExists) {
// Ensure dir will recursively create directories which might be preferred over mkdir
var success = await fs.ensureDir(path).then(() => true).catch((error) => {
const success = await fs.ensureDir(path).then(() => true).catch((error) => {
Logger.error(`[LibraryController] Failed to ensure folder dir "${path}"`, error)
return false
})
@ -102,7 +111,7 @@ class LibraryController {
}
}
var hasUpdates = library.update(req.body)
const hasUpdates = library.update(req.body)
// TODO: Should check if this is an update to folder paths or name only
if (hasUpdates) {
// Update watcher
@ -112,7 +121,7 @@ class LibraryController {
this.cronManager.updateLibraryScanCron(library)
// Remove libraryItems no longer in library
var itemsToRemove = this.db.libraryItems.filter(li => li.libraryId === library.id && !library.checkFullPathInLibrary(li.path))
const itemsToRemove = this.db.libraryItems.filter(li => li.libraryId === library.id && !library.checkFullPathInLibrary(li.path))
if (itemsToRemove.length) {
Logger.info(`[Scanner] Updating library, removing ${itemsToRemove.length} items`)
for (let i = 0; i < itemsToRemove.length; i++) {
@ -120,27 +129,39 @@ class LibraryController {
}
}
await this.db.updateEntity('library', library)
this.emitter('library_updated', library.toJSON())
// Only emit to users with access to library
const userFilter = (user) => {
return user.checkCanAccessLibrary && user.checkCanAccessLibrary(library.id)
}
SocketAuthority.emitter('library_updated', library.toJSON(), userFilter)
}
return res.json(library.toJSON())
}
async delete(req, res) {
var library = req.library
const library = req.library
// Remove library watcher
this.watcher.removeLibrary(library)
// Remove collections for library
const collections = this.db.collections.filter(c => c.libraryId === library.id)
for (const collection of collections) {
Logger.info(`[Server] deleting collection "${collection.name}" for library "${library.name}"`)
await this.db.removeEntity('collection', collection.id)
}
// Remove items in this library
var libraryItems = this.db.libraryItems.filter(li => li.libraryId === library.id)
const libraryItems = this.db.libraryItems.filter(li => li.libraryId === library.id)
Logger.info(`[Server] deleting library "${library.name}" with ${libraryItems.length} items"`)
for (let i = 0; i < libraryItems.length; i++) {
await this.handleDeleteLibraryItem(libraryItems[i])
}
var libraryJson = library.toJSON()
const libraryJson = library.toJSON()
await this.db.removeEntity('library', library.id)
this.emitter('library_removed', libraryJson)
SocketAuthority.emitter('library_removed', libraryJson)
return res.json(libraryJson)
}
@ -160,21 +181,53 @@ class LibraryController {
minified: req.query.minified === '1',
collapseseries: req.query.collapseseries === '1'
}
const mediaIsBook = payload.mediaType === 'book'
var filterSeries = null
// Step 1 - Filter the retrieved library items
let filterSeries = null
if (payload.filterBy) {
// If filtering by series, will include seriesName and seriesSequence on media metadata
filterSeries = (payload.mediaType == 'book' && payload.filterBy.startsWith('series.')) ? libraryHelpers.decode(payload.filterBy.replace('series.', '')) : null
if (filterSeries === 'No Series') filterSeries = null
libraryItems = libraryHelpers.getFilteredLibraryItems(libraryItems, payload.filterBy, req.user, this.rssFeedManager.feedsArray)
payload.total = libraryItems.length
// Determining if we are filtering titles by a series, and if so, which series
filterSeries = (mediaIsBook && payload.filterBy.startsWith('series.')) ? libraryHelpers.decode(payload.filterBy.replace('series.', '')) : null
if (filterSeries === 'no-series') filterSeries = null
}
// Step 2 - If selected, collapse library items by the series they belong to.
// If also filtering by series, will not collapse the filtered series as this would lead
// to series having a collapsed series that is just that series.
if (payload.collapseseries) {
let collapsedItems = libraryHelpers.collapseBookSeries(libraryItems, this.db.series, filterSeries)
if (!(collapsedItems.length == 1 && collapsedItems[0].collapsedSeries)) {
libraryItems = collapsedItems
// Get accurate total entities
// let uniqueEntities = new Set()
// libraryItems.forEach((item) => {
// if (item.collapsedSeries) {
// item.collapsedSeries.books.forEach(book => uniqueEntities.add(book.id))
// } else {
// uniqueEntities.add(item.id)
// }
// })
payload.total = libraryItems.length
}
}
// Step 3 - Sort the retrieved library items.
var sortArray = []
// When on the series page, sort by sequence only
if (payload.sortBy === 'book.volumeNumber') payload.sortBy = null // TODO: Remove temp fix after mobile release 0.9.60
if (filterSeries && !payload.sortBy) {
sortArray.push({ asc: (li) => li.media.metadata.getSeries(filterSeries).sequence })
}
if (payload.sortBy) {
var sortKey = payload.sortBy
// old sort key TODO: should be mutated in dbMigration
let sortKey = payload.sortBy
if (sortKey.startsWith('book.')) {
sortKey = sortKey.replace('book.', 'media.metadata.')
}
@ -186,29 +239,42 @@ class LibraryController {
sortKey += 'IgnorePrefix'
}
// Start sort
var direction = payload.sortDesc ? 'desc' : 'asc'
var sortArray = [
{
[direction]: (li) => {
// When collapsing by series and sorting by title use the series name instead of the book title
if (payload.mediaType === 'book' && payload.collapseseries && li.media.metadata.seriesName) {
if (sortByTitle) {
return this.db.serverSettings.sortingIgnorePrefix ? li.media.metadata.seriesNameIgnorePrefix : li.media.metadata.seriesName
} else {
// When not sorting by title always show the collapsed series at the end
return direction === 'desc' ? -1 : 'zzzz'
}
// If series are collapsed and not sorting by title or sequence,
// sort all collapsed series to the end in alphabetical order
const sortBySequence = filterSeries && (sortKey === 'sequence')
if (payload.collapseseries && !(sortByTitle || sortBySequence)) {
sortArray.push({
asc: (li) => {
if (li.collapsedSeries) {
return this.db.serverSettings.sortingIgnorePrefix ?
li.collapsedSeries.nameIgnorePrefix :
li.collapsedSeries.name
} else {
return ''
}
}
})
}
// Sort series based on the sortBy attribute
const direction = payload.sortDesc ? 'desc' : 'asc'
sortArray.push({
[direction]: (li) => {
if (mediaIsBook && sortBySequence) {
return li.media.metadata.getSeries(filterSeries).sequence
} else if (mediaIsBook && sortByTitle && li.collapsedSeries) {
return this.db.serverSettings.sortingIgnorePrefix ?
li.collapsedSeries.nameIgnorePrefix :
li.collapsedSeries.name
} else {
// Supports dot notation strings i.e. "media.metadata.title"
return sortKey.split('.').reduce((a, b) => a[b], li)
}
}
]
})
// Secondary sort when sorting by book author use series sort title
if (payload.mediaType === 'book' && payload.sortBy.includes('author')) {
if (mediaIsBook && payload.sortBy.includes('author')) {
sortArray.push({
asc: (li) => {
if (li.media.metadata.series && li.media.metadata.series.length) {
@ -218,35 +284,66 @@ class LibraryController {
}
})
}
}
if (sortArray.length) {
libraryItems = naturalSort(libraryItems).by(sortArray)
}
if (payload.collapseseries) {
libraryItems = libraryHelpers.collapseBookSeries(libraryItems, this.db.series)
payload.total = libraryItems.length
} else if (filterSeries) {
// Book media when filtering series will include series object on media metadata
libraryItems = libraryItems.map(li => {
var series = li.media.metadata.getSeries(filterSeries)
var liJson = payload.minified ? li.toJSONMinified() : li.toJSON()
liJson.media.metadata.series = series
return liJson
})
libraryItems = naturalSort(libraryItems).asc(li => li.media.metadata.series.sequence)
} else {
libraryItems = libraryItems.map(li => payload.minified ? li.toJSONMinified() : li.toJSON())
}
// Step 3.5: Limit items
if (payload.limit) {
var startIndex = payload.page * payload.limit
libraryItems = libraryItems.slice(startIndex, startIndex + payload.limit)
}
payload.results = libraryItems
// Step 4 - Transform the items to pass to the client side
payload.results = libraryItems.map(li => {
let json = payload.minified ? li.toJSONMinified() : li.toJSON()
if (li.collapsedSeries) {
json.collapsedSeries = {
id: li.collapsedSeries.id,
name: li.collapsedSeries.name,
nameIgnorePrefix: li.collapsedSeries.nameIgnorePrefix,
libraryItemIds: li.collapsedSeries.books.map(b => b.id),
numBooks: li.collapsedSeries.books.length
}
// If collapsing by series and filtering by a series, generate the list of sequences the collapsed
// series represents in the filtered series
if (filterSeries) {
json.collapsedSeries.seriesSequenceList =
naturalSort(li.collapsedSeries.books.map(b => b.filterSeriesSequence)).asc()
.reduce((ranges, currentSequence) => {
let lastRange = ranges.at(-1)
let isNumber = /^(\d+|\d+\.\d*|\d*\.\d+)$/.test(currentSequence)
if (isNumber) currentSequence = parseFloat(currentSequence)
if (lastRange && isNumber && lastRange.isNumber && ((lastRange.end + 1) == currentSequence)) {
lastRange.end = currentSequence
}
else {
ranges.push({ start: currentSequence, end: currentSequence, isNumber: isNumber })
}
return ranges
}, [])
.map(r => r.start == r.end ? r.start : `${r.start}-${r.end}`)
.join(', ')
}
} else if (filterSeries) {
// If filtering by series, make sure to include the series metadata
json.media.metadata.series = li.media.metadata.getSeries(filterSeries)
}
return json
})
res.json(payload)
}
async removeLibraryItemsWithIssues(req, res) {
var libraryItemsWithIssues = req.libraryItems.filter(li => li.hasIssues)
const libraryItemsWithIssues = req.libraryItems.filter(li => li.hasIssues)
if (!libraryItemsWithIssues.length) {
Logger.warn(`[LibraryController] No library items have issues`)
return res.sendStatus(200)
@ -263,8 +360,8 @@ class LibraryController {
// api/libraries/:id/series
async getAllSeriesForLibrary(req, res) {
var libraryItems = req.libraryItems
var payload = {
const libraryItems = req.libraryItems
const payload = {
results: [],
total: 0,
limit: req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 0,
@ -275,7 +372,7 @@ class LibraryController {
minified: req.query.minified === '1'
}
var series = libraryHelpers.getSeriesFromBooks(libraryItems, this.db.series, payload.filterBy, req.user, payload.minified)
let series = libraryHelpers.getSeriesFromBooks(libraryItems, this.db.series, null, payload.filterBy, req.user, payload.minified)
const direction = payload.sortDesc ? 'desc' : 'asc'
series = naturalSort(series).by([
@ -288,7 +385,7 @@ class LibraryController {
} else if (payload.sortBy === 'addedAt') {
return se.addedAt
} else { // sort by name
return this.db.serverSettings.sortingIgnorePrefix ? se.nameIgnorePrefix : se.name
return this.db.serverSettings.sortingIgnorePrefix ? se.nameIgnorePrefixSort : se.name
}
}
}
@ -338,6 +435,26 @@ class LibraryController {
res.json(payload)
}
// api/libraries/:id/playlists
async getUserPlaylistsForLibrary(req, res) {
let playlistsForUser = this.db.playlists.filter(p => p.userId === req.user.id && p.libraryId === req.library.id).map(p => p.toJSONExpanded(this.db.libraryItems))
const payload = {
results: [],
total: playlistsForUser.length,
limit: req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 0,
page: req.query.page && !isNaN(req.query.page) ? Number(req.query.page) : 0
}
if (payload.limit) {
const startIndex = payload.page * payload.limit
playlistsForUser = playlistsForUser.slice(startIndex, startIndex + payload.limit)
}
payload.results = playlistsForUser
res.json(payload)
}
async getLibraryFilterData(req, res) {
res.json(libraryHelpers.getDistinctFilterDataNew(req.libraryItems))
}
@ -381,8 +498,9 @@ class LibraryController {
Logger.debug(`[LibraryController] Library orders were up to date`)
}
var libraries = this.db.libraries.map(lib => lib.toJSON())
res.json(libraries)
res.json({
libraries: this.db.libraries.map(lib => lib.toJSON())
})
}
// GET: Global library search
@ -488,7 +606,9 @@ class LibraryController {
}
})
res.json(naturalSort(Object.values(authors)).asc(au => au.name))
res.json({
authors: naturalSort(Object.values(authors)).asc(au => au.name)
})
}
async matchAll(req, res) {
@ -536,6 +656,7 @@ class LibraryController {
const ep = _ep.toJSONExpanded()
ep.podcast = libraryItem.media.toJSONMinified()
ep.libraryItemId = libraryItem.id
ep.libraryId = libraryItem.libraryId
return ep
})
allUnfinishedEpisodes.push(...unfinishedEpisodes)

View file

@ -1,4 +1,7 @@
const fs = require('../libs/fsExtra')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const { reqSupportsWebp, isNullOrNaN } = require('../utils/index')
const { ScanResult } = require('../utils/constants')
@ -53,7 +56,7 @@ class LibraryItemController {
if (hasUpdates) {
Logger.debug(`[LibraryItemController] Updated now saving`)
await this.db.updateLibraryItem(libraryItem)
this.emitter('item_updated', libraryItem.toJSONExpanded())
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
}
res.json(libraryItem.toJSON())
}
@ -97,7 +100,7 @@ class LibraryItemController {
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
await this.db.updateLibraryItem(libraryItem)
this.emitter('item_updated', libraryItem.toJSONExpanded())
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
}
res.json({
updated: hasUpdates,
@ -132,7 +135,7 @@ class LibraryItemController {
}
await this.db.updateLibraryItem(libraryItem)
this.emitter('item_updated', libraryItem.toJSONExpanded())
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
res.json({
success: true,
cover: result.cover
@ -152,7 +155,7 @@ class LibraryItemController {
}
if (validationResult.updated) {
await this.db.updateLibraryItem(libraryItem)
this.emitter('item_updated', libraryItem.toJSONExpanded())
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
}
res.json({
success: true,
@ -168,7 +171,7 @@ class LibraryItemController {
libraryItem.updateMediaCover('')
await this.cacheManager.purgeCoverCache(libraryItem.id)
await this.db.updateLibraryItem(libraryItem)
this.emitter('item_updated', libraryItem.toJSONExpanded())
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
}
res.sendStatus(200)
@ -176,7 +179,15 @@ class LibraryItemController {
// GET api/items/:id/cover
async getCover(req, res) {
let { query: { width, height, format }, libraryItem } = req
const { query: { width, height, format, raw }, libraryItem } = req
if (raw) { // any value
if (!libraryItem.media.coverPath || !await fs.pathExists(libraryItem.media.coverPath)) {
return res.sendStatus(404)
}
return res.sendFile(libraryItem.media.coverPath)
}
const options = {
format: format || (reqSupportsWebp(req) ? 'webp' : 'jpeg'),
@ -228,7 +239,7 @@ class LibraryItemController {
}
libraryItem.media.updateAudioTracks(orderedFileData)
await this.db.updateLibraryItem(libraryItem)
this.emitter('item_updated', libraryItem.toJSONExpanded())
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
res.json(libraryItem.toJSON())
}
@ -284,7 +295,7 @@ class LibraryItemController {
if (hasUpdates) {
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
await this.db.updateLibraryItem(libraryItem)
this.emitter('item_updated', libraryItem.toJSONExpanded())
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
itemsUpdated++
}
}
@ -297,12 +308,18 @@ class LibraryItemController {
// POST: api/items/batch/get
async batchGet(req, res) {
var libraryItemIds = req.body.libraryItemIds || []
const libraryItemIds = req.body.libraryItemIds || []
if (!libraryItemIds.length) {
return res.status(403).send('Invalid payload')
}
var libraryItems = this.db.libraryItems.filter(li => libraryItemIds.includes(li.id)).map((li) => li.toJSONExpanded())
res.json(libraryItems)
const libraryItems = []
libraryItemIds.forEach((lid) => {
const li = this.db.libraryItems.find(_li => _li.id === lid)
if (li) libraryItems.push(li.toJSONExpanded())
})
res.json({
libraryItems
})
}
// POST: api/items/batch/quickmatch
@ -338,7 +355,7 @@ class LibraryItemController {
updates: itemsUpdated,
unmatched: itemsUnmatched
}
this.clientEmitter(req.user.id, 'batch_quickmatch_complete', result)
SocketAuthority.clientEmitter(req.user.id, 'batch_quickmatch_complete', result)
}
// DELETE: api/items/all
@ -385,23 +402,6 @@ class LibraryItemController {
res.json(this.audioMetadataManager.getToneMetadataObjectForApi(req.libraryItem))
}
// GET: api/items/:id/audio-metadata
async updateAudioFileMetadata(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[LibraryItemController] Non-root user attempted to update audio metadata`, req.user)
return res.sendStatus(403)
}
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
Logger.error(`[LibraryItemController] Invalid library item`)
return res.sendStatus(500)
}
const useTone = req.query.tone === '1'
this.audioMetadataManager.updateMetadataForItem(req.user, req.libraryItem, useTone)
res.sendStatus(200)
}
// POST: api/items/:id/chapters
async updateMediaChapters(req, res) {
if (!req.user.canUpdate) {
@ -423,7 +423,7 @@ class LibraryItemController {
const wasUpdated = req.libraryItem.media.updateChapters(chapters)
if (wasUpdated) {
await this.db.updateLibraryItem(req.libraryItem)
this.emitter('item_updated', req.libraryItem.toJSONExpanded())
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
}
res.json({
@ -436,7 +436,7 @@ class LibraryItemController {
async openRSSFeed(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[LibraryItemController] Non-admin user attempted to open RSS feed`, req.user.username)
return res.sendStatus(500)
return res.sendStatus(403)
}
const feedData = await this.rssFeedManager.openFeedForItem(req.user, req.libraryItem, req.body)
@ -456,7 +456,7 @@ class LibraryItemController {
async closeRSSFeed(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[LibraryItemController] Non-admin user attempted to close RSS feed`, req.user.username)
return res.sendStatus(500)
return res.sendStatus(403)
}
await this.rssFeedManager.closeFeedForItem(req.params.id)

View file

@ -1,4 +1,5 @@
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const { sort } = require('../libs/fastSort')
const { isObject, toNumber } = require('../utils/index')
@ -48,7 +49,7 @@ class MeController {
return res.sendStatus(200)
}
await this.db.updateEntity('user', req.user)
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
res.sendStatus(200)
}
@ -62,7 +63,7 @@ class MeController {
var wasUpdated = req.user.createUpdateMediaProgress(libraryItem, req.body)
if (wasUpdated) {
await this.db.updateEntity('user', req.user)
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
}
res.sendStatus(200)
}
@ -82,7 +83,7 @@ class MeController {
var wasUpdated = req.user.createUpdateMediaProgress(libraryItem, req.body, episodeId)
if (wasUpdated) {
await this.db.updateEntity('user', req.user)
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
}
res.sendStatus(200)
}
@ -91,7 +92,7 @@ class MeController {
async batchUpdateMediaProgress(req, res) {
var itemProgressPayloads = req.body
if (!itemProgressPayloads || !itemProgressPayloads.length) {
return res.sendStatus(500)
return res.status(400).send('Missing request payload')
}
var shouldUpdate = false
@ -107,7 +108,7 @@ class MeController {
if (shouldUpdate) {
await this.db.updateEntity('user', req.user)
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
}
res.sendStatus(200)
@ -120,7 +121,7 @@ class MeController {
const { time, title } = req.body
var bookmark = req.user.createBookmark(libraryItem.id, time, title)
await this.db.updateEntity('user', req.user)
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
res.json(bookmark)
}
@ -136,7 +137,7 @@ class MeController {
var bookmark = req.user.updateBookmark(libraryItem.id, time, title)
if (!bookmark) return res.sendStatus(500)
await this.db.updateEntity('user', req.user)
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
res.json(bookmark)
}
@ -153,7 +154,7 @@ class MeController {
}
req.user.removeBookmark(libraryItem.id, time)
await this.db.updateEntity('user', req.user)
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
res.sendStatus(200)
}
@ -166,6 +167,7 @@ class MeController {
this.auth.userChangePassword(req, res)
}
// TODO: Remove after mobile release v0.9.61-beta
// PATCH: api/me/settings
async updateSettings(req, res) {
var settingsUpdate = req.body
@ -233,7 +235,7 @@ class MeController {
Logger.debug(`[MeController] syncLocalMediaProgress server updates = ${numServerProgressUpdates}, local updates = ${updatedLocalMediaProgress.length}`)
if (numServerProgressUpdates > 0) {
await this.db.updateEntity('user', req.user)
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
}
res.json({
@ -288,7 +290,23 @@ class MeController {
const hasUpdated = req.user.addSeriesToHideFromContinueListening(req.params.id)
if (hasUpdated) {
await this.db.updateEntity('user', req.user)
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
}
res.json(req.user.toJSONForBrowser())
}
// GET: api/me/series/:id/readd-to-continue-listening
async readdSeriesFromContinueListening(req, res) {
const series = this.db.series.find(se => se.id === req.params.id)
if (!series) {
Logger.error(`[MeController] readdSeriesFromContinueListening: Series ${req.params.id} not found`)
return res.sendStatus(404)
}
const hasUpdated = req.user.removeSeriesFromHideFromContinueListening(req.params.id)
if (hasUpdated) {
await this.db.updateEntity('user', req.user)
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
}
res.json(req.user.toJSONForBrowser())
}
@ -298,7 +316,7 @@ class MeController {
const hasUpdated = req.user.removeProgressFromContinueListening(req.params.id)
if (hasUpdated) {
await this.db.updateEntity('user', req.user)
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
}
res.json(req.user.toJSONForBrowser())
}

View file

@ -26,11 +26,11 @@ class MiscController {
var library = this.db.libraries.find(lib => lib.id === libraryId)
if (!library) {
return res.status(500).send(`Library not found with id ${libraryId}`)
return res.status(404).send(`Library not found with id ${libraryId}`)
}
var folder = library.folders.find(fold => fold.id === folderId)
if (!folder) {
return res.status(500).send(`Folder not found with id ${folderId} in library ${library.name}`)
return res.status(404).send(`Folder not found with id ${folderId} in library ${library.name}`)
}
if (!files.length || !title) {
@ -82,49 +82,6 @@ class MiscController {
res.sendStatus(200)
}
// GET: api/encode-m4b/:id
async encodeM4b(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error('[MiscController] encodeM4b: Non-admin user attempting to make m4b', req.user)
return res.sendStatus(403)
}
var libraryItem = this.db.getLibraryItem(req.params.id)
if (!libraryItem || libraryItem.isMissing || libraryItem.isInvalid) {
Logger.error(`[MiscController] encodeM4b: library item not found or invalid ${req.params.id}`)
return res.status(404).send('Audiobook not found')
}
if (libraryItem.mediaType !== 'book') {
Logger.error(`[MiscController] encodeM4b: Invalid library item ${req.params.id}: not a book`)
return res.status(500).send('Invalid library item: not a book')
}
if (libraryItem.media.tracks.length <= 0) {
Logger.error(`[MiscController] encodeM4b: Invalid audiobook ${req.params.id}: no audio tracks`)
return res.status(500).send('Invalid audiobook: no audio tracks')
}
this.abMergeManager.startAudiobookMerge(req.user, libraryItem)
res.sendStatus(200)
}
// POST: api/encode-m4b/:id/cancel
async cancelM4bEncode(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error('[MiscController] cancelM4bEncode: Non-admin user attempting to cancel m4b encode', req.user)
return res.sendStatus(403)
}
const workerTask = this.abMergeManager.getPendingTaskByLibraryItemId(req.params.id)
if (!workerTask) return res.sendStatus(404)
this.abMergeManager.cancelEncode(workerTask.task)
res.sendStatus(200)
}
// GET: api/tasks
getTasks(req, res) {
res.json({
@ -154,70 +111,10 @@ class MiscController {
}
return res.json({
success: true,
serverSettings: this.db.serverSettings
serverSettings: this.db.serverSettings.toJSONForBrowser()
})
}
// POST: api/cache/purge (admin)
async purgeCache(req, res) {
if (!req.user.isAdminOrUp) {
return res.sendStatus(403)
}
Logger.info(`[MiscController] Purging all cache`)
await this.cacheManager.purgeAll()
res.sendStatus(200)
}
// POST: api/cache/items/purge
async purgeItemsCache(req, res) {
if (!req.user.isAdminOrUp) {
return res.sendStatus(403)
}
Logger.info(`[MiscController] Purging items cache`)
await this.cacheManager.purgeItems()
res.sendStatus(200)
}
async findBooks(req, res) {
var provider = req.query.provider || 'google'
var title = req.query.title || ''
var author = req.query.author || ''
var results = await this.bookFinder.search(provider, title, author)
res.json(results)
}
async findCovers(req, res) {
var query = req.query
var podcast = query.podcast == 1
var result = null
if (podcast) result = await this.podcastFinder.findCovers(query.title)
else result = await this.bookFinder.findCovers(query.provider, query.title, query.author || null)
res.json(result)
}
async findPodcasts(req, res) {
var term = req.query.term
var results = await this.podcastFinder.search(term)
res.json(results)
}
async findAuthor(req, res) {
var query = req.query.q
var author = await this.authorFinder.findAuthorByName(query)
res.json(author)
}
async findChapters(req, res) {
var asin = req.query.asin
var region = (req.query.region || 'us').toLowerCase()
var chapterData = await this.bookFinder.findChapters(asin, region)
if (!chapterData) {
return res.json({ error: 'Chapters not found' })
}
res.json(chapterData)
}
authorize(req, res) {
if (!req.user) {
Logger.error('Invalid user in authorize')
@ -240,7 +137,9 @@ class MiscController {
})
}
})
res.json(tags)
res.json({
tags: tags
})
}
validateCronExpression(req, res) {

View file

@ -0,0 +1,226 @@
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Playlist = require('../objects/Playlist')
class PlaylistController {
constructor() { }
// POST: api/playlists
async create(req, res) {
const newPlaylist = new Playlist()
req.body.userId = req.user.id
const success = newPlaylist.setData(req.body)
if (!success) {
return res.status(400).send('Invalid playlist request data')
}
const jsonExpanded = newPlaylist.toJSONExpanded(this.db.libraryItems)
await this.db.insertEntity('playlist', newPlaylist)
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
res.json(jsonExpanded)
}
// GET: api/playlists
findAllForUser(req, res) {
res.json({
playlists: this.db.playlists.filter(p => p.userId === req.user.id).map(p => p.toJSONExpanded(this.db.libraryItems))
})
}
// GET: api/playlists/:id
findOne(req, res) {
res.json(req.playlist.toJSONExpanded(this.db.libraryItems))
}
// PATCH: api/playlists/:id
async update(req, res) {
const playlist = req.playlist
let wasUpdated = playlist.update(req.body)
const jsonExpanded = playlist.toJSONExpanded(this.db.libraryItems)
if (wasUpdated) {
await this.db.updateEntity('playlist', playlist)
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
}
res.json(jsonExpanded)
}
// DELETE: api/playlists/:id
async delete(req, res) {
const playlist = req.playlist
const jsonExpanded = playlist.toJSONExpanded(this.db.libraryItems)
await this.db.removeEntity('playlist', playlist.id)
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
res.sendStatus(200)
}
// POST: api/playlists/:id/item
async addItem(req, res) {
const playlist = req.playlist
const itemToAdd = req.body
if (!itemToAdd.libraryItemId) {
return res.status(400).send('Request body has no libraryItemId')
}
const libraryItem = this.db.libraryItems.find(li => li.id === itemToAdd.libraryItemId)
if (!libraryItem) {
return res.status(400).send('Library item not found')
}
if (libraryItem.libraryId !== playlist.libraryId) {
return res.status(400).send('Library item in different library')
}
if (playlist.containsItem(itemToAdd)) {
return res.status(400).send('Item already in playlist')
}
if ((itemToAdd.episodeId && !libraryItem.isPodcast) || (libraryItem.isPodcast && !itemToAdd.episodeId)) {
return res.status(400).send('Invalid item to add for this library type')
}
if (itemToAdd.episodeId && !libraryItem.media.checkHasEpisode(itemToAdd.episodeId)) {
return res.status(400).send('Episode not found in library item')
}
playlist.addItem(itemToAdd.libraryItemId, itemToAdd.episodeId)
const jsonExpanded = playlist.toJSONExpanded(this.db.libraryItems)
await this.db.updateEntity('playlist', playlist)
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
res.json(jsonExpanded)
}
// DELETE: api/playlists/:id/item/:libraryItemId/:episodeId?
async removeItem(req, res) {
const playlist = req.playlist
const itemToRemove = {
libraryItemId: req.params.libraryItemId,
episodeId: req.params.episodeId || null
}
if (!playlist.containsItem(itemToRemove)) {
return res.sendStatus(404)
}
playlist.removeItem(itemToRemove.libraryItemId, itemToRemove.episodeId)
const jsonExpanded = playlist.toJSONExpanded(this.db.libraryItems)
// Playlist is removed when there are no items
if (!playlist.items.length) {
Logger.info(`[PlaylistController] Playlist "${playlist.name}" has no more items - removing it`)
await this.db.removeEntity('playlist', playlist.id)
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
} else {
await this.db.updateEntity('playlist', playlist)
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
}
res.json(jsonExpanded)
}
// POST: api/playlists/:id/batch/add
async addBatch(req, res) {
const playlist = req.playlist
if (!req.body.items || !req.body.items.length) {
return res.status(500).send('Invalid request body')
}
const itemsToAdd = req.body.items
let hasUpdated = false
for (const item of itemsToAdd) {
if (!item.libraryItemId) {
return res.status(400).send('Item does not have libraryItemId')
}
if (!playlist.containsItem(item)) {
playlist.addItem(item.libraryItemId, item.episodeId)
hasUpdated = true
}
}
const jsonExpanded = playlist.toJSONExpanded(this.db.libraryItems)
if (hasUpdated) {
await this.db.updateEntity('playlist', playlist)
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
}
res.json(jsonExpanded)
}
// POST: api/playlists/:id/batch/remove
async removeBatch(req, res) {
const playlist = req.playlist
if (!req.body.items || !req.body.items.length) {
return res.status(500).send('Invalid request body')
}
const itemsToRemove = req.body.items
let hasUpdated = false
for (const item of itemsToRemove) {
if (!item.libraryItemId) {
return res.status(400).send('Item does not have libraryItemId')
}
if (playlist.containsItem(item)) {
playlist.removeItem(item.libraryItemId, item.episodeId)
hasUpdated = true
}
}
const jsonExpanded = playlist.toJSONExpanded(this.db.libraryItems)
if (hasUpdated) {
// Playlist is removed when there are no items
if (!playlist.items.length) {
Logger.info(`[PlaylistController] Playlist "${playlist.name}" has no more items - removing it`)
await this.db.removeEntity('playlist', playlist.id)
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
} else {
await this.db.updateEntity('playlist', playlist)
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
}
}
res.json(jsonExpanded)
}
// POST: api/playlists/collection/:collectionId
async createFromCollection(req, res) {
let collection = this.db.collections.find(c => c.id === req.params.collectionId)
if (!collection) {
return res.status(404).send('Collection not found')
}
// Expand collection to get library items
collection = collection.toJSONExpanded(this.db.libraryItems)
// Filter out library items not accessible to user
const libraryItems = collection.books.filter(item => req.user.checkCanAccessLibraryItem(item))
if (!libraryItems.length) {
return res.status(400).send('Collection has no books accessible to user')
}
const newPlaylist = new Playlist()
const newPlaylistData = {
userId: req.user.id,
libraryId: collection.libraryId,
name: collection.name,
description: collection.description || null,
items: libraryItems.map(li => ({ libraryItemId: li.id }))
}
newPlaylist.setData(newPlaylistData)
const jsonExpanded = newPlaylist.toJSONExpanded(this.db.libraryItems)
await this.db.insertEntity('playlist', newPlaylist)
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
res.json(jsonExpanded)
}
middleware(req, res, next) {
if (req.params.id) {
const playlist = this.db.playlists.find(p => p.id === req.params.id)
if (!playlist) {
return res.status(404).send('Playlist not found')
}
if (playlist.userId !== req.user.id) {
Logger.warn(`[PlaylistController] Playlist ${req.params.id} requested by user ${req.user.id} that is not the owner`)
return res.sendStatus(403)
}
req.playlist = playlist
}
next()
}
}
module.exports = new PlaylistController()

View file

@ -1,30 +1,33 @@
const axios = require('axios')
const fs = require('../libs/fsExtra')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const fs = require('../libs/fsExtra')
const { getPodcastFeed, findMatchingEpisodes } = require('../utils/podcastUtils')
const LibraryItem = require('../objects/LibraryItem')
const { getFileTimestampsWithIno } = require('../utils/fileUtils')
const filePerms = require('../utils/filePerms')
const LibraryItem = require('../objects/LibraryItem')
class PodcastController {
async create(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[PodcastController] Non-admin user attempted to create podcast`, req.user)
return res.sendStatus(500)
return res.sendStatus(403)
}
const payload = req.body
const library = this.db.libraries.find(lib => lib.id === payload.libraryId)
if (!library) {
Logger.error(`[PodcastController] Create: Library not found "${payload.libraryId}"`)
return res.status(400).send('Library not found')
return res.status(404).send('Library not found')
}
const folder = library.folders.find(fold => fold.id === payload.folderId)
if (!folder) {
Logger.error(`[PodcastController] Create: Folder not found "${payload.folderId}"`)
return res.status(400).send('Folder not found')
return res.status(404).send('Folder not found')
}
var podcastPath = payload.path.replace(/\\/g, '/')
@ -75,7 +78,7 @@ class PodcastController {
}
await this.db.insertLibraryItem(libraryItem)
this.emitter('item_added', libraryItem.toJSONExpanded())
SocketAuthority.emitter('item_added', libraryItem.toJSONExpanded())
res.json(libraryItem.toJSONExpanded())
@ -115,7 +118,7 @@ class PodcastController {
async checkNewEpisodes(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[PodcastController] Non-admin user attempted to check/download episodes`, req.user)
return res.sendStatus(500)
return res.sendStatus(403)
}
var libraryItem = req.libraryItem
@ -135,7 +138,7 @@ class PodcastController {
clearEpisodeDownloadQueue(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[PodcastController] Non-admin user attempting to clear download queue "${req.user.username}"`)
return res.sendStatus(500)
return res.sendStatus(403)
}
this.podcastManager.clearDownloadQueue(req.params.id)
res.sendStatus(200)
@ -194,7 +197,7 @@ class PodcastController {
var wasUpdated = libraryItem.media.updateEpisode(episodeId, req.body)
if (wasUpdated) {
await this.db.updateLibraryItem(libraryItem)
this.emitter('item_updated', libraryItem.toJSONExpanded())
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
}
res.json(libraryItem.toJSONExpanded())
@ -229,7 +232,7 @@ class PodcastController {
}
await this.db.updateLibraryItem(libraryItem)
this.emitter('item_updated', libraryItem.toJSONExpanded())
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
res.json(libraryItem.toJSON())
}

View file

@ -0,0 +1,53 @@
const Logger = require("../Logger")
class SearchController {
constructor() { }
async findBooks(req, res) {
const provider = req.query.provider || 'google'
const title = req.query.title || ''
const author = req.query.author || ''
const results = await this.bookFinder.search(provider, title, author)
res.json(results)
}
async findCovers(req, res) {
const query = req.query
const podcast = query.podcast == 1
if (!query.title) {
Logger.error(`[SearchController] findCovers: No title sent in query`)
return res.sendStatus(400)
}
let results = null
if (podcast) results = await this.podcastFinder.findCovers(query.title)
else results = await this.bookFinder.findCovers(query.provider || 'google', query.title, query.author || null)
res.json({
results
})
}
async findPodcasts(req, res) {
const term = req.query.term
const results = await this.podcastFinder.search(term)
res.json(results)
}
async findAuthor(req, res) {
const query = req.query.q
const author = await this.authorFinder.findAuthorByName(query)
res.json(author)
}
async findChapters(req, res) {
const asin = req.query.asin
const region = (req.query.region || 'us').toLowerCase()
const chapterData = await this.bookFinder.findChapters(asin, region)
if (!chapterData) {
return res.json({ error: 'Chapters not found' })
}
res.json(chapterData)
}
}
module.exports = new SearchController()

View file

@ -1,4 +1,5 @@
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
class SeriesController {
constructor() { }
@ -31,14 +32,16 @@ class SeriesController {
var limit = (req.query.limit && !isNaN(req.query.limit)) ? Number(req.query.limit) : 25
var series = this.db.series.filter(se => se.name.toLowerCase().includes(q))
series = series.slice(0, limit)
res.json(series)
res.json({
results: series
})
}
async update(req, res) {
const hasUpdated = req.series.update(req.body)
if (hasUpdated) {
await this.db.updateEntity('series', req.series)
this.emitter('series_updated', req.series)
SocketAuthority.emitter('series_updated', req.series)
}
res.json(req.series)
}

View file

@ -0,0 +1,81 @@
const Logger = require('../Logger')
class ToolsController {
constructor() { }
// POST: api/tools/item/:id/encode-m4b
async encodeM4b(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error('[MiscController] encodeM4b: Non-admin user attempting to make m4b', req.user)
return res.sendStatus(403)
}
if (req.libraryItem.isMissing || req.libraryItem.isInvalid) {
Logger.error(`[MiscController] encodeM4b: library item not found or invalid ${req.params.id}`)
return res.status(404).send('Audiobook not found')
}
if (req.libraryItem.mediaType !== 'book') {
Logger.error(`[MiscController] encodeM4b: Invalid library item ${req.params.id}: not a book`)
return res.status(500).send('Invalid library item: not a book')
}
if (req.libraryItem.media.tracks.length <= 0) {
Logger.error(`[MiscController] encodeM4b: Invalid audiobook ${req.params.id}: no audio tracks`)
return res.status(500).send('Invalid audiobook: no audio tracks')
}
this.abMergeManager.startAudiobookMerge(req.user, req.libraryItem)
res.sendStatus(200)
}
// DELETE: api/tools/item/:id/encode-m4b
async cancelM4bEncode(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error('[MiscController] cancelM4bEncode: Non-admin user attempting to cancel m4b encode', req.user)
return res.sendStatus(403)
}
const workerTask = this.abMergeManager.getPendingTaskByLibraryItemId(req.params.id)
if (!workerTask) return res.sendStatus(404)
this.abMergeManager.cancelEncode(workerTask.task)
res.sendStatus(200)
}
// POST: api/tools/item/:id/embed-metadata
async embedAudioFileMetadata(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[LibraryItemController] Non-root user attempted to update audio metadata`, req.user)
return res.sendStatus(403)
}
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
Logger.error(`[LibraryItemController] Invalid library item`)
return res.sendStatus(500)
}
const useTone = req.query.tone === '1'
const forceEmbedChapters = req.query.forceEmbedChapters === '1'
this.audioMetadataManager.updateMetadataForItem(req.user, req.libraryItem, useTone, forceEmbedChapters)
res.sendStatus(200)
}
itemMiddleware(req, res, next) {
var item = this.db.libraryItems.find(li => li.id === req.params.id)
if (!item || !item.media) return res.sendStatus(404)
// Check user can access this library item
if (!req.user.checkCanAccessLibraryItem(item)) {
return res.sendStatus(403)
}
req.libraryItem = item
next()
}
}
module.exports = new ToolsController()

View file

@ -1,4 +1,6 @@
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const User = require('../objects/user/User')
const { getId, toNumber } = require('../utils/index')
@ -9,8 +11,10 @@ class UserController {
findAll(req, res) {
if (!req.user.isAdminOrUp) return res.sendStatus(403)
const hideRootToken = !req.user.isRoot
var users = this.db.users.map(u => this.userJsonWithItemProgressDetails(u, hideRootToken))
res.json(users)
const users = this.db.users.map(u => this.userJsonWithItemProgressDetails(u, hideRootToken))
res.json({
users: users
})
}
findOne(req, res) {
@ -19,7 +23,7 @@ class UserController {
return res.sendStatus(403)
}
var user = this.db.users.find(u => u.id === req.params.id)
const user = this.db.users.find(u => u.id === req.params.id)
if (!user) {
return res.sendStatus(404)
}
@ -44,7 +48,7 @@ class UserController {
var newUser = new User(account)
var success = await this.db.insertEntity('user', newUser)
if (success) {
this.clientEmitter(req.user.id, 'user_added', newUser)
SocketAuthority.adminEmitter('user_added', newUser)
res.json({
user: newUser.toJSONForBrowser()
})
@ -85,7 +89,7 @@ class UserController {
Logger.info(`[UserController] User ${user.username} was generated a new api token`)
}
await this.db.updateEntity('user', user)
this.clientEmitter(req.user.id, 'user_updated', user.toJSONForBrowser())
SocketAuthority.clientEmitter(req.user.id, 'user_updated', user.toJSONForBrowser())
}
res.json({
@ -103,20 +107,19 @@ class UserController {
Logger.error(`[UserController] ${req.user.username} is attempting to delete themselves... why? WHY?`)
return res.sendStatus(500)
}
var user = req.reqUser
// delete user collections
var userCollections = this.db.collections.filter(c => c.userId === user.id)
var collectionsToRemove = userCollections.map(uc => uc.id)
for (let i = 0; i < collectionsToRemove.length; i++) {
await this.db.removeEntity('collection', collectionsToRemove[i])
}
const user = req.reqUser
// Todo: check if user is logged in and cancel streams
var userJson = user.toJSONForBrowser()
// Remove user playlists
const userPlaylists = this.db.playlists.filter(p => p.userId === user.id)
for (const playlist of userPlaylists) {
await this.db.removeEntity('playlist', playlist.id)
}
const userJson = user.toJSONForBrowser()
await this.db.removeEntity('user', user.id)
this.clientEmitter(req.user.id, 'user_removed', userJson)
SocketAuthority.adminEmitter('user_removed', userJson)
res.json({
success: true
})
@ -177,12 +180,24 @@ class UserController {
if (progressPurged) {
Logger.info(`[UserController] Purged ${progressPurged} media progress for user ${user.username}`)
await this.db.updateEntity('user', user)
this.clientEmitter(req.user.id, 'user_updated', user.toJSONForBrowser())
SocketAuthority.adminEmitter('user_updated', user.toJSONForBrowser())
}
res.json(this.userJsonWithItemProgressDetails(user, !req.user.isRoot))
}
// POST: api/users/online (admin)
async getOnlineUsers(req, res) {
if (!req.user.isAdminOrUp) {
return res.sendStatus(403)
}
res.json({
usersOnline: SocketAuthority.getUsersOnline(),
openSessions: this.playbackSessionManager.sessions
})
}
middleware(req, res, next) {
if (!req.user.isAdminOrUp && req.user.id !== req.params.id) {
return res.sendStatus(403)

View file

@ -10,10 +10,9 @@ const { writeConcatFile } = require('../utils/ffmpegHelpers')
const toneHelpers = require('../utils/toneHelpers')
class AbMergeManager {
constructor(db, taskManager, clientEmitter) {
constructor(db, taskManager) {
this.db = db
this.taskManager = taskManager
this.clientEmitter = clientEmitter
this.itemsCacheDir = Path.join(global.MetadataPath, 'cache/items')
this.downloadDirPath = Path.join(global.MetadataPath, 'downloads')
@ -62,7 +61,7 @@ class AbMergeManager {
targetFilename,
targetFilepath: Path.join(libraryItem.path, targetFilename),
itemCachePath,
toneMetadataObject: null
toneJsonObject: null
}
const taskDescription = `Encoding audiobook "${libraryItem.media.metadata.title}" into a single m4b file.`
task.setData('encode-m4b', 'Encoding M4b', taskDescription, taskData)
@ -120,22 +119,19 @@ class AbMergeManager {
}
}
var chaptersFilePath = null
if (libraryItem.media.chapters.length) {
chaptersFilePath = Path.join(task.data.itemCachePath, 'chapters.txt')
try {
await toneHelpers.writeToneChaptersFile(libraryItem.media.chapters, chaptersFilePath)
} catch (error) {
Logger.error(`[AbMergeManager] Write chapters.txt failed`, error)
chaptersFilePath = null
}
var toneJsonPath = null
try {
toneJsonPath = Path.join(task.data.itemCachePath, 'metadata.json')
await toneHelpers.writeToneMetadataJsonFile(libraryItem, libraryItem.media.chapters, toneJsonPath, 1)
} catch (error) {
Logger.error(`[AbMergeManager] Write metadata.json failed`, error)
toneJsonPath = null
}
const toneMetadataObject = toneHelpers.getToneMetadataObject(libraryItem, chaptersFilePath)
toneMetadataObject.TrackNumber = 1
task.data.toneMetadataObject = toneMetadataObject
Logger.debug(`[AbMergeManager] Book "${libraryItem.media.metadata.title}" tone metadata object=`, toneMetadataObject)
task.data.toneJsonObject = {
'ToneJsonFile': toneJsonPath,
'TrackNumber': 1,
}
var workerData = {
inputs: ffmpegInputs,
@ -190,7 +186,7 @@ class AbMergeManager {
}
// Write metadata to merged file
const success = await toneHelpers.tagAudioFile(task.data.tempFilepath, task.data.toneMetadataObject)
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')
@ -227,14 +223,16 @@ class AbMergeManager {
const pendingDl = this.pendingTasks.find(d => d.id === task.id)
if (pendingDl) {
this.pendingTasks = this.pendingTasks.filter(d => d.id !== task.id)
Logger.warn(`[AbMergeManager] Removing download in progress - stopping worker`)
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`)
}
}

View file

@ -1,23 +1,25 @@
const Path = require('path')
const fs = require('../libs/fsExtra')
const workerThreads = require('worker_threads')
const SocketAuthority = require('../SocketAuthority')
const Logger = require('../Logger')
const fs = require('../libs/fsExtra')
const filePerms = require('../utils/filePerms')
const { secondsToTimestamp } = require('../utils/index')
const { writeMetadataFile } = require('../utils/ffmpegHelpers')
const toneHelpers = require('../utils/toneHelpers')
class AudioMetadataMangaer {
constructor(db, taskManager, emitter, clientEmitter) {
constructor(db, taskManager) {
this.db = db
this.taskManager = taskManager
this.emitter = emitter
this.clientEmitter = clientEmitter
}
updateMetadataForItem(user, libraryItem, useTone = true) {
updateMetadataForItem(user, libraryItem, useTone, forceEmbedChapters) {
if (useTone) {
this.updateMetadataForItemWithTone(user, libraryItem)
this.updateMetadataForItemWithTone(user, libraryItem, forceEmbedChapters)
} else {
this.updateMetadataForItemWithFfmpeg(user, libraryItem)
}
@ -30,7 +32,7 @@ class AudioMetadataMangaer {
return toneHelpers.getToneMetadataObject(libraryItem)
}
async updateMetadataForItemWithTone(user, libraryItem) {
async updateMetadataForItemWithTone(user, libraryItem, forceEmbedChapters) {
var audioFiles = libraryItem.media.includedAudioFiles
const itemAudioMetadataPayload = {
@ -40,29 +42,25 @@ class AudioMetadataMangaer {
audioFiles: audioFiles.map(af => ({ index: af.index, ino: af.ino, filename: af.metadata.filename }))
}
this.emitter('audio_metadata_started', itemAudioMetadataPayload)
SocketAuthority.emitter('audio_metadata_started', itemAudioMetadataPayload)
// Write chapters file
var chaptersFilePath = null
var toneJsonPath = null
const itemCacheDir = Path.join(global.MetadataPath, `cache/items/${libraryItem.id}`)
await fs.ensureDir(itemCacheDir)
if (libraryItem.media.chapters.length) {
chaptersFilePath = Path.join(itemCacheDir, 'chapters.txt')
try {
await toneHelpers.writeToneChaptersFile(libraryItem.media.chapters, chaptersFilePath)
} catch (error) {
Logger.error(`[AudioMetadataManager] Write chapters.txt failed`, error)
chaptersFilePath = null
}
try {
toneJsonPath = Path.join(itemCacheDir, 'metadata.json')
const chapters = (audioFiles.length == 1 || forceEmbedChapters) ? libraryItem.media.chapters : null
await toneHelpers.writeToneMetadataJsonFile(libraryItem, chapters, toneJsonPath, audioFiles.length)
} catch (error) {
Logger.error(`[AudioMetadataManager] Write metadata.json failed`, error)
toneJsonPath = null
}
const toneMetadataObject = toneHelpers.getToneMetadataObject(libraryItem, chaptersFilePath)
Logger.debug(`[AudioMetadataManager] Book "${libraryItem.media.metadata.title}" tone metadata object=`, toneMetadataObject)
const results = []
for (const af of audioFiles) {
const result = await this.updateAudioFileMetadataWithTone(libraryItem.id, af, toneMetadataObject, itemCacheDir)
const result = await this.updateAudioFileMetadataWithTone(libraryItem.id, af, toneJsonPath, itemCacheDir)
results.push(result)
}
@ -71,17 +69,17 @@ class AudioMetadataMangaer {
itemAudioMetadataPayload.results = results
itemAudioMetadataPayload.elapsed = elapsed
itemAudioMetadataPayload.finishedAt = Date.now()
this.emitter('audio_metadata_finished', itemAudioMetadataPayload)
SocketAuthority.emitter('audio_metadata_finished', itemAudioMetadataPayload)
}
async updateAudioFileMetadataWithTone(libraryItemId, audioFile, toneMetadataObject, itemCacheDir) {
async updateAudioFileMetadataWithTone(libraryItemId, audioFile, toneJsonPath, itemCacheDir) {
const resultPayload = {
libraryItemId,
index: audioFile.index,
ino: audioFile.ino,
filename: audioFile.metadata.filename
}
this.emitter('audiofile_metadata_started', resultPayload)
SocketAuthority.emitter('audiofile_metadata_started', resultPayload)
// Backup audio file
try {
@ -93,8 +91,8 @@ class AudioMetadataMangaer {
}
const _toneMetadataObject = {
...toneMetadataObject,
'TrackNumber': audioFile.index
'ToneJsonFile': toneJsonPath,
'TrackNumber': audioFile.index,
}
resultPayload.success = await toneHelpers.tagAudioFile(audioFile.metadata.path, _toneMetadataObject)
@ -102,7 +100,7 @@ class AudioMetadataMangaer {
Logger.info(`[AudioMetadataManager] Successfully tagged audio file "${audioFile.metadata.path}"`)
}
this.emitter('audiofile_metadata_finished', resultPayload)
SocketAuthority.emitter('audiofile_metadata_finished', resultPayload)
return resultPayload
}
@ -119,7 +117,7 @@ class AudioMetadataMangaer {
audioFiles: audioFiles.map(af => ({ index: af.index, ino: af.ino, filename: af.metadata.filename }))
}
this.emitter('audio_metadata_started', itemAudioMetadataPayload)
SocketAuthority.emitter('audio_metadata_started', itemAudioMetadataPayload)
var downloadsPath = Path.join(global.MetadataPath, 'downloads')
var outputDir = Path.join(downloadsPath, libraryItem.id)
@ -147,7 +145,7 @@ class AudioMetadataMangaer {
itemAudioMetadataPayload.results = results
itemAudioMetadataPayload.elapsed = elapsed
itemAudioMetadataPayload.finishedAt = Date.now()
this.emitter('audio_metadata_finished', itemAudioMetadataPayload)
SocketAuthority.emitter('audio_metadata_finished', itemAudioMetadataPayload)
}
updateAudioFileMetadataWithFfmpeg(libraryItemId, audioFile, outputDir, metadataFilePath, coverPath = '') {
@ -158,7 +156,7 @@ class AudioMetadataMangaer {
ino: audioFile.ino,
filename: audioFile.metadata.filename
}
this.emitter('audiofile_metadata_started', resultPayload)
SocketAuthority.emitter('audiofile_metadata_started', resultPayload)
Logger.debug(`[AudioFileMetadataManager] Starting audio file metadata encode for "${audioFile.metadata.filename}"`)
@ -233,19 +231,19 @@ class AudioMetadataMangaer {
Logger.debug(`[AudioFileMetadataManager] Audio file replaced successfully "${inputPath}"`)
resultPayload.success = true
this.emitter('audiofile_metadata_finished', resultPayload)
SocketAuthority.emitter('audiofile_metadata_finished', resultPayload)
resolve(resultPayload)
}).catch((error) => {
Logger.error(`[AudioFileMetadataManager] Audio file failed to move "${inputPath}"`, error)
resultPayload.success = false
this.emitter('audiofile_metadata_finished', resultPayload)
SocketAuthority.emitter('audiofile_metadata_finished', resultPayload)
resolve(resultPayload)
})
} else {
Logger.debug(`[AudioFileMetadataManager] Metadata encode FAILED for "${audioFile.metadata.filename}"`)
resultPayload.success = false
this.emitter('audiofile_metadata_finished', resultPayload)
SocketAuthority.emitter('audiofile_metadata_finished', resultPayload)
resolve(resultPayload)
}
} else if (message.type === 'FFMPEG') {

View file

@ -1,4 +1,6 @@
const Path = require('path')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const cron = require('../libs/nodeCron')
const fs = require('../libs/fsExtra')
@ -8,18 +10,16 @@ const StreamZip = require('../libs/nodeStreamZip')
// Utils
const { getFileSize } = require('../utils/fileUtils')
const filePerms = require('../utils/filePerms')
const Logger = require('../Logger')
const Backup = require('../objects/Backup')
class BackupManager {
constructor(db, emitter) {
constructor(db) {
this.BackupPath = Path.join(global.MetadataPath, 'backups')
this.ItemsMetadataPath = Path.join(global.MetadataPath, 'items')
this.AuthorsMetadataPath = Path.join(global.MetadataPath, 'authors')
this.db = db
this.emitter = emitter
this.scheduleTask = null
@ -106,13 +106,20 @@ class BackupManager {
this.backups.push(backup)
}
return res.json(this.backups.map(b => b.toJSON()))
res.json({
backups: this.backups.map(b => b.toJSON())
})
}
async requestCreateBackup(res) {
var backupSuccess = await this.runBackup()
if (backupSuccess) res.json(this.backups.map(b => b.toJSON()))
else res.sendStatus(500)
if (backupSuccess) {
res.json({
backups: this.backups.map(b => b.toJSON())
})
} else {
res.sendStatus(500)
}
}
async requestApplyBackup(backup) {
@ -123,7 +130,7 @@ class BackupManager {
await zip.extract('metadata-authors/', this.AuthorsMetadataPath)
}
await this.db.reinit()
this.emitter('backup_applied')
SocketAuthority.emitter('backup_applied')
}
async loadBackups() {

View file

@ -47,7 +47,7 @@ class CacheManager {
res.type(`image/${format}`)
var path = Path.join(this.CoverCachePath, `${libraryItem.id}_${width}${height ? `x${height}` : ''}`) + '.' + format
const path = Path.join(this.CoverCachePath, `${libraryItem.id}_${width}${height ? `x${height}` : ''}`) + '.' + format
// Cache exists
if (await fs.pathExists(path)) {
@ -56,18 +56,18 @@ class CacheManager {
stream.pipeline(r, ps, (err) => {
if (err) {
console.log(err)
return res.sendStatus(400)
return res.sendStatus(500)
}
})
return ps.pipe(res)
}
if (!libraryItem.media.coverPath || !await fs.pathExists(libraryItem.media.coverPath)) {
return res.sendStatus(404)
return res.sendStatus(500)
}
let writtenFile = await resizeImage(libraryItem.media.coverPath, path, width, height)
if (!writtenFile) return res.sendStatus(400)
const writtenFile = await resizeImage(libraryItem.media.coverPath, path, width, height)
if (!writtenFile) return res.sendStatus(500)
// Set owner and permissions of cache image
await filePerms.setDefault(path)
@ -139,14 +139,14 @@ class CacheManager {
stream.pipeline(r, ps, (err) => {
if (err) {
console.log(err)
return res.sendStatus(400)
return res.sendStatus(500)
}
})
return ps.pipe(res)
}
let writtenFile = await resizeImage(author.imagePath, path, width, height)
if (!writtenFile) return res.sendStatus(400)
if (!writtenFile) return res.sendStatus(500)
// Set owner and permissions of cache image
await filePerms.setDefault(path)

View file

@ -1,5 +1,6 @@
const Path = require('path')
const fs = require('../libs/fsExtra')
const filePerms = require('../utils/filePerms')
const DailyLog = require('../objects/DailyLog')
@ -11,8 +12,8 @@ class LogManager {
constructor(db) {
this.db = db
this.logDirPath = Path.join(global.MetadataPath, 'logs')
this.dailyLogDirPath = Path.join(this.logDirPath, 'daily')
this.DailyLogPath = Path.posix.join(global.MetadataPath, 'logs', 'daily')
this.ScanLogPath = Path.posix.join(global.MetadataPath, 'logs', 'scans')
this.currentDailyLog = null
this.dailyLogBuffer = []
@ -27,24 +28,38 @@ class LogManager {
return this.serverSettings.loggerDailyLogsToKeep || 7
}
async ensureLogDirs() {
await fs.ensureDir(this.DailyLogPath)
await fs.ensureDir(this.ScanLogPath)
await filePerms.setDefault(Path.posix.join(global.MetadataPath, 'logs'), true)
}
async ensureScanLogDir() {
if (!(await fs.pathExists(this.ScanLogPath))) {
await fs.mkdir(this.ScanLogPath)
await filePerms.setDefault(this.ScanLogPath)
}
}
async init() {
await this.ensureLogDirs()
// Load daily logs
await this.scanLogFiles()
// Check remove extra daily logs
if (this.dailyLogFiles.length > this.loggerDailyLogsToKeep) {
var dailyLogFilesCopy = [...this.dailyLogFiles]
const dailyLogFilesCopy = [...this.dailyLogFiles]
for (let i = 0; i < dailyLogFilesCopy.length - this.loggerDailyLogsToKeep; i++) {
var logFileToRemove = dailyLogFilesCopy[i]
await this.removeLogFile(logFileToRemove)
await this.removeLogFile(dailyLogFilesCopy[i])
}
}
var currentDailyLogFilename = DailyLog.getCurrentDailyLogFilename()
const currentDailyLogFilename = DailyLog.getCurrentDailyLogFilename()
Logger.info(TAG, `Init current daily log filename: ${currentDailyLogFilename}`)
this.currentDailyLog = new DailyLog()
this.currentDailyLog.setData({ dailyLogDirPath: this.dailyLogDirPath })
this.currentDailyLog.setData({ dailyLogDirPath: this.DailyLogPath })
if (this.dailyLogFiles.includes(currentDailyLogFilename)) {
Logger.debug(TAG, `Daily log file already exists - set in Logger`)
@ -63,8 +78,7 @@ class LogManager {
}
async scanLogFiles() {
await fs.ensureDir(this.dailyLogDirPath)
var dailyFiles = await fs.readdir(this.dailyLogDirPath)
const dailyFiles = await fs.readdir(this.DailyLogPath)
if (dailyFiles && dailyFiles.length) {
dailyFiles.forEach((logFile) => {
if (Path.extname(logFile) === '.txt') {
@ -80,13 +94,13 @@ class LogManager {
async removeOldestLog() {
if (!this.dailyLogFiles.length) return
var oldestLog = this.dailyLogFiles[0]
const oldestLog = this.dailyLogFiles[0]
return this.removeLogFile(oldestLog)
}
async removeLogFile(filename) {
var fullPath = Path.join(this.dailyLogDirPath, filename)
var exists = await fs.pathExists(fullPath)
const fullPath = Path.join(this.DailyLogPath, filename)
const exists = await fs.pathExists(fullPath)
if (!exists) {
Logger.error(TAG, 'Invalid log dne ' + fullPath)
this.dailyLogFiles = this.dailyLogFiles.filter(dlf => dlf.filename !== filename)
@ -109,8 +123,8 @@ class LogManager {
// Check log rolls to next day
if (this.currentDailyLog.id !== DailyLog.getCurrentDateString()) {
var newDailyLog = new DailyLog()
newDailyLog.setData({ dailyLogDirPath: this.dailyLogDirPath })
const newDailyLog = new DailyLog()
newDailyLog.setData({ dailyLogDirPath: this.DailyLogPath })
this.currentDailyLog = newDailyLog
if (this.dailyLogFiles.length > this.loggerDailyLogsToKeep) {
this.removeOldestLog()
@ -126,7 +140,7 @@ class LogManager {
return
}
var lastLogs = this.currentDailyLog.logs.slice(-5000)
const lastLogs = this.currentDailyLog.logs.slice(-5000)
socket.emit('daily_logs', lastLogs)
}
}

View file

@ -1,11 +1,11 @@
const axios = require('axios')
const Logger = require("../Logger")
const SocketAuthority = require('../SocketAuthority')
const { notificationData } = require('../utils/notifications')
class NotificationManager {
constructor(db, emitter) {
constructor(db) {
this.db = db
this.emitter = emitter
this.sendingNotification = false
this.notificationQueue = []
@ -58,7 +58,7 @@ class NotificationManager {
}
await this.db.updateEntity('settings', this.db.notificationSettings)
this.emitter('notifications_updated', this.db.notificationSettings)
SocketAuthority.emitter('notifications_updated', this.db.notificationSettings)
this.notificationFinished()
}

View file

@ -1,22 +1,24 @@
const Path = require('path')
const date = require('../libs/dateAndTime')
const serverVersion = require('../../package.json').version
const { PlayMethod } = require('../utils/constants')
const PlaybackSession = require('../objects/PlaybackSession')
const DeviceInfo = require('../objects/DeviceInfo')
const Stream = require('../objects/Stream')
const Logger = require('../Logger')
const fs = require('../libs/fsExtra')
const SocketAuthority = require('../SocketAuthority')
const date = require('../libs/dateAndTime')
const fs = require('../libs/fsExtra')
const uaParserJs = require('../libs/uaParser')
const requestIp = require('../libs/requestIp')
const { PlayMethod } = require('../utils/constants')
const PlaybackSession = require('../objects/PlaybackSession')
const DeviceInfo = require('../objects/DeviceInfo')
const Stream = require('../objects/Stream')
class PlaybackSessionManager {
constructor(db, emitter, clientEmitter) {
constructor(db) {
this.db = db
this.StreamsPath = Path.join(global.MetadataPath, 'streams')
this.emitter = emitter
this.clientEmitter = clientEmitter
this.sessions = []
this.localSessionLock = {}
@ -29,7 +31,7 @@ class PlaybackSessionManager {
return this.sessions.find(s => s.userId === userId)
}
getStream(sessionId) {
var session = this.getSession(sessionId)
const session = this.getSession(sessionId)
return session ? session.stream : null
}
@ -52,7 +54,7 @@ class PlaybackSessionManager {
}
async syncSessionRequest(user, session, payload, res) {
var result = await this.syncSession(user, session, payload)
const result = await this.syncSession(user, session, payload)
if (result) {
res.json(session.toJSONForClient(result.libraryItem))
}
@ -61,18 +63,18 @@ class PlaybackSessionManager {
async syncLocalSessionRequest(user, sessionJson, res) {
if (this.localSessionLock[sessionJson.id]) {
Logger.debug(`[PlaybackSessionManager] syncLocalSessionRequest: Local session is locked and already syncing`)
return res.sendStatus(200)
return res.status(500).send('Local session is locked and already syncing')
}
var libraryItem = this.db.getLibraryItem(sessionJson.libraryItemId)
const libraryItem = this.db.getLibraryItem(sessionJson.libraryItemId)
if (!libraryItem) {
Logger.error(`[PlaybackSessionManager] syncLocalSessionRequest: Library item not found for session "${sessionJson.libraryItemId}"`)
return res.sendStatus(200)
return res.status(500).send('Library item not found')
}
this.localSessionLock[sessionJson.id] = true // Lock local session
var session = await this.db.getPlaybackSession(sessionJson.id)
let session = await this.db.getPlaybackSession(sessionJson.id)
if (!session) {
// New session from local
session = new PlaybackSession(sessionJson)
@ -94,11 +96,11 @@ class PlaybackSessionManager {
progress: session.progress,
lastUpdate: session.updatedAt // Keep media progress update times the same as local
}
var wasUpdated = user.createUpdateMediaProgress(libraryItem, itemProgressUpdate, session.episodeId)
const wasUpdated = user.createUpdateMediaProgress(libraryItem, itemProgressUpdate, session.episodeId)
if (wasUpdated) {
await this.db.updateEntity('user', user)
var itemProgress = user.getMediaProgress(session.libraryItemId, session.episodeId)
this.clientEmitter(user.id, 'user_item_progress_updated', {
const itemProgress = user.getMediaProgress(session.libraryItemId, session.episodeId)
SocketAuthority.clientEmitter(user.id, 'user_item_progress_updated', {
id: itemProgress.id,
data: itemProgress.toJSON()
})
@ -116,18 +118,25 @@ class PlaybackSessionManager {
async startSession(user, deviceInfo, libraryItem, episodeId, options) {
// Close any sessions already open for user
var userSessions = this.sessions.filter(playbackSession => playbackSession.userId === user.id)
const userSessions = this.sessions.filter(playbackSession => playbackSession.userId === user.id)
for (const session of userSessions) {
Logger.info(`[PlaybackSessionManager] startSession: Closing open session "${session.displayTitle}" for user "${user.username}"`)
await this.closeSession(user, session, null)
}
var shouldDirectPlay = options.forceDirectPlay || (!options.forceTranscode && libraryItem.media.checkCanDirectPlay(options, episodeId))
var mediaPlayer = options.mediaPlayer || 'unknown'
const shouldDirectPlay = options.forceDirectPlay || (!options.forceTranscode && libraryItem.media.checkCanDirectPlay(options, episodeId))
const mediaPlayer = options.mediaPlayer || 'unknown'
const userProgress = user.getMediaProgress(libraryItem.id, episodeId)
var userStartTime = 0
if (userProgress) userStartTime = Number.parseFloat(userProgress.currentTime) || 0
let userStartTime = 0
if (userProgress) {
if (userProgress.isFinished) {
Logger.info(`[PlaybackSessionManager] Starting session for user "${user.username}" and resetting progress for finished item "${libraryItem.media.metadata.title}"`)
// Keep userStartTime as 0 so the client restarts the media
} else {
userStartTime = Number.parseFloat(userProgress.currentTime) || 0
}
}
const newPlaybackSession = new PlaybackSession()
newPlaybackSession.setData(libraryItem, user, mediaPlayer, deviceInfo, userStartTime, episodeId)
@ -140,14 +149,14 @@ class PlaybackSessionManager {
// HLS not supported for video yet
}
} else {
var audioTracks = []
let audioTracks = []
if (shouldDirectPlay) {
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}"`)
audioTracks = libraryItem.getDirectPlayTracklist(episodeId)
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
} else {
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting stream session for item "${libraryItem.id}"`)
var stream = new Stream(newPlaybackSession.id, this.StreamsPath, user, libraryItem, episodeId, userStartTime, this.clientEmitter.bind(this))
const stream = new Stream(newPlaybackSession.id, this.StreamsPath, user, libraryItem, episodeId, userStartTime)
await stream.generatePlaylist()
stream.start() // Start transcode
@ -167,13 +176,13 @@ class PlaybackSessionManager {
user.currentSessionId = newPlaybackSession.id
this.sessions.push(newPlaybackSession)
this.emitter('user_stream_update', user.toJSONForPublic(this.sessions, this.db.libraryItems))
SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions, this.db.libraryItems))
return newPlaybackSession
}
async syncSession(user, session, syncData) {
var libraryItem = this.db.libraryItems.find(li => li.id === session.libraryItemId)
const libraryItem = this.db.libraryItems.find(li => li.id === session.libraryItemId)
if (!libraryItem) {
Logger.error(`[PlaybackSessionManager] syncSession Library Item not found "${session.libraryItemId}"`)
return null
@ -188,12 +197,12 @@ class PlaybackSessionManager {
currentTime: syncData.currentTime,
progress: session.progress
}
var wasUpdated = user.createUpdateMediaProgress(libraryItem, itemProgressUpdate, session.episodeId)
const wasUpdated = user.createUpdateMediaProgress(libraryItem, itemProgressUpdate, session.episodeId)
if (wasUpdated) {
await this.db.updateEntity('user', user)
var itemProgress = user.getMediaProgress(session.libraryItemId, session.episodeId)
this.clientEmitter(user.id, 'user_item_progress_updated', {
const itemProgress = user.getMediaProgress(session.libraryItemId, session.episodeId)
SocketAuthority.clientEmitter(user.id, 'user_item_progress_updated', {
id: itemProgress.id,
data: itemProgress.toJSON()
})
@ -211,7 +220,7 @@ class PlaybackSessionManager {
await this.saveSession(session)
}
Logger.debug(`[PlaybackSessionManager] closeSession "${session.id}"`)
this.emitter('user_stream_update', user.toJSONForPublic(this.sessions, this.db.libraryItems))
SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions, this.db.libraryItems))
return this.removeSession(session.id)
}
@ -227,7 +236,7 @@ class PlaybackSessionManager {
}
async removeSession(sessionId) {
var 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()
@ -240,13 +249,13 @@ class PlaybackSessionManager {
async removeOrphanStreams() {
await fs.ensureDir(this.StreamsPath)
try {
var streamsInPath = await fs.readdir(this.StreamsPath)
const streamsInPath = await fs.readdir(this.StreamsPath)
for (let i = 0; i < streamsInPath.length; i++) {
var streamId = streamsInPath[i]
const streamId = streamsInPath[i]
if (streamId.startsWith('play_')) { // Make sure to only remove folders that are a stream
var session = this.sessions.find(se => se.id === streamId)
const session = this.sessions.find(se => se.id === streamId)
if (!session) {
var streamPath = Path.join(this.StreamsPath, streamId)
const streamPath = Path.join(this.StreamsPath, streamId)
Logger.debug(`[PlaybackSessionManager] Removing orphan stream "${streamPath}"`)
await fs.remove(streamPath)
}

View file

@ -1,23 +1,24 @@
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const fs = require('../libs/fsExtra')
const { getPodcastFeed } = require('../utils/podcastUtils')
const Logger = require('../Logger')
const { downloadFile, removeFile } = require('../utils/fileUtils')
const filePerms = require('../utils/filePerms')
const { levenshteinDistance } = require('../utils/index')
const opmlParser = require('../utils/parsers/parseOPML')
const prober = require('../utils/prober')
const LibraryFile = require('../objects/files/LibraryFile')
const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload')
const PodcastEpisode = require('../objects/entities/PodcastEpisode')
const AudioFile = require('../objects/files/AudioFile')
class PodcastManager {
constructor(db, watcher, emitter, notificationManager) {
constructor(db, watcher, notificationManager) {
this.db = db
this.watcher = watcher
this.emitter = emitter
this.notificationManager = notificationManager
this.downloadQueue = []
@ -63,11 +64,11 @@ class PodcastManager {
async startPodcastEpisodeDownload(podcastEpisodeDownload) {
if (this.currentDownload) {
this.downloadQueue.push(podcastEpisodeDownload)
this.emitter('episode_download_queued', podcastEpisodeDownload.toJSONForClient())
SocketAuthority.emitter('episode_download_queued', podcastEpisodeDownload.toJSONForClient())
return
}
this.emitter('episode_download_started', podcastEpisodeDownload.toJSONForClient())
SocketAuthority.emitter('episode_download_started', podcastEpisodeDownload.toJSONForClient())
this.currentDownload = podcastEpisodeDownload
// Ignores all added files to this dir
@ -97,7 +98,7 @@ class PodcastManager {
this.currentDownload.setFinished(false)
}
this.emitter('episode_download_finished', this.currentDownload.toJSONForClient())
SocketAuthority.emitter('episode_download_finished', this.currentDownload.toJSONForClient())
this.watcher.removeIgnoreDir(this.currentDownload.libraryItem.path)
this.currentDownload = null
@ -141,7 +142,7 @@ class PodcastManager {
libraryItem.updatedAt = Date.now()
await this.db.updateLibraryItem(libraryItem)
this.emitter('item_updated', libraryItem.toJSONExpanded())
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
if (this.currentDownload.isAutoDownload) { // Notifications only for auto downloaded episodes
this.notificationManager.onPodcastEpisodeDownloaded(libraryItem, podcastEpisode)
@ -230,7 +231,7 @@ class PodcastManager {
libraryItem.media.lastEpisodeCheck = Date.now()
libraryItem.updatedAt = Date.now()
await this.db.updateLibraryItem(libraryItem)
this.emitter('item_updated', libraryItem.toJSONExpanded())
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
return libraryItem.media.autoDownloadEpisodes
}
@ -269,7 +270,7 @@ class PodcastManager {
libraryItem.media.lastEpisodeCheck = Date.now()
libraryItem.updatedAt = Date.now()
await this.db.updateLibraryItem(libraryItem)
this.emitter('item_updated', libraryItem.toJSONExpanded())
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
return newEpisodes
}

View file

@ -1,13 +1,15 @@
const Path = require('path')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const fs = require('../libs/fsExtra')
const Feed = require('../objects/Feed')
const Logger = require('../Logger')
// Not functional at the moment
class RssFeedManager {
constructor(db, emitter) {
constructor(db) {
this.db = db
this.emitter = emitter
this.feeds = {}
}
@ -33,7 +35,7 @@ class RssFeedManager {
async getFeed(req, res) {
var feed = this.feeds[req.params.id]
if (!feed) {
Logger.error(`[RssFeedManager] Feed not found ${req.params.id}`)
Logger.debug(`[RssFeedManager] Feed not found ${req.params.id}`)
res.sendStatus(404)
return
}
@ -55,7 +57,7 @@ class RssFeedManager {
getFeedItem(req, res) {
var feed = this.feeds[req.params.id]
if (!feed) {
Logger.error(`[RssFeedManager] Feed not found ${req.params.id}`)
Logger.debug(`[RssFeedManager] Feed not found ${req.params.id}`)
res.sendStatus(404)
return
}
@ -71,7 +73,7 @@ class RssFeedManager {
getFeedCover(req, res) {
var feed = this.feeds[req.params.id]
if (!feed) {
Logger.error(`[RssFeedManager] Feed not found ${req.params.id}`)
Logger.debug(`[RssFeedManager] Feed not found ${req.params.id}`)
res.sendStatus(404)
return
}
@ -104,7 +106,7 @@ class RssFeedManager {
Logger.debug(`[RssFeedManager] Opened RSS feed ${feed.feedUrl}`)
await this.db.insertEntity('feed', feed)
this.emitter('rss_feed_open', { id: feed.id, entityType: feed.entityType, entityId: feed.entityId, feedUrl: feed.feedUrl })
SocketAuthority.emitter('rss_feed_open', { id: feed.id, entityType: feed.entityType, entityId: feed.entityId, feedUrl: feed.feedUrl })
return feed
}
@ -118,7 +120,7 @@ class RssFeedManager {
if (!this.feeds[id]) return
var feed = this.feeds[id]
await this.db.removeEntity('feed', id)
this.emitter('rss_feed_closed', { id: feed.id, entityType: feed.entityType, entityId: feed.entityId, feedUrl: feed.feedUrl })
SocketAuthority.emitter('rss_feed_closed', { id: feed.id, entityType: feed.entityType, entityId: feed.entityId, feedUrl: feed.feedUrl })
delete this.feeds[id]
Logger.info(`[RssFeedManager] Closed RSS feed "${feed.feedUrl}"`)
}

View file

@ -1,19 +1,19 @@
class TaskManager {
constructor(emitter) {
this.emitter = emitter
const SocketAuthority = require('../SocketAuthority')
class TaskManager {
constructor() {
this.tasks = []
}
addTask(task) {
this.tasks.push(task)
this.emitter('task_started', task.toJSON())
SocketAuthority.emitter('task_started', task.toJSON())
}
taskFinished(task) {
if (this.tasks.some(t => t.id === task.id)) {
this.tasks = this.tasks.filter(t => t.id !== task.id)
this.emitter('task_finished', task.toJSON())
SocketAuthority.emitter('task_finished', task.toJSON())
}
}
}

View file

@ -1,7 +1,7 @@
const Logger = require('../Logger')
const { getId } = require('../utils/index')
class UserCollection {
class Collection {
constructor(collection) {
this.id = null
this.libraryId = null
@ -38,10 +38,10 @@ class UserCollection {
}
toJSONExpanded(libraryItems, minifiedBooks = false) {
var json = this.toJSON()
const json = this.toJSON()
json.books = json.books.map(bookId => {
var _ab = libraryItems.find(li => li.id === bookId)
return _ab ? minifiedBooks ? _ab.toJSONMinified() : _ab.toJSONExpanded() : null
const book = libraryItems.find(li => li.id === bookId)
return book ? minifiedBooks ? book.toJSONMinified() : book.toJSONExpanded() : null
}).filter(b => !!b)
return json
}
@ -63,7 +63,7 @@ class UserCollection {
if (!data.userId || !data.libraryId || !data.name) {
return false
}
this.id = getId('usr')
this.id = getId('col')
this.userId = data.userId
this.libraryId = data.libraryId
this.name = data.name
@ -105,4 +105,4 @@ class UserCollection {
return hasUpdates
}
}
module.exports = UserCollection
module.exports = Collection

View file

@ -86,17 +86,22 @@ class FeedEpisode {
setFromAudiobookTrack(libraryItem, serverAddress, slug, audioTrack, meta) {
// Example: <pubDate>Fri, 04 Feb 2015 00:00:00 GMT</pubDate>
const timeOffset = isNaN(audioTrack.index) ? 0 : (Number(audioTrack.index) * 1000) // Offset pubdate to ensure correct order
const audiobookPubDate = date.format(new Date(libraryItem.addedAt - timeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
// e.g. Track 1 will have a pub date before Track 2
const audiobookPubDate = date.format(new Date(libraryItem.addedAt + timeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
const contentUrl = `/feed/${slug}/item/${audioTrack.index}/${audioTrack.metadata.filename}`
const media = libraryItem.media
const mediaMetadata = media.metadata
var title = audioTrack.title
if (libraryItem.media.chapters.length) {
// If audio track start and chapter start are within 1 seconds of eachother then use the chapter title
var matchingChapter = libraryItem.media.chapters.find(ch => Math.abs(ch.start - audioTrack.startOffset) < 1)
if (matchingChapter && matchingChapter.title) title = matchingChapter.title
let title = audioTrack.title
if (libraryItem.media.tracks.length == 1) { // If audiobook is a single file, use book title instead of chapter/file title
title = libraryItem.media.metadata.title
} else {
if (libraryItem.media.chapters.length) {
// If audio track start and chapter start are within 1 seconds of eachother then use the chapter title
var matchingChapter = libraryItem.media.chapters.find(ch => Math.abs(ch.start - audioTrack.startOffset) < 1)
if (matchingChapter && matchingChapter.title) title = matchingChapter.title
}
}
this.id = String(audioTrack.index)

View file

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

149
server/objects/Playlist.js Normal file
View file

@ -0,0 +1,149 @@
const Logger = require('../Logger')
const { getId } = require('../utils/index')
class Playlist {
constructor(playlist) {
this.id = null
this.libraryId = null
this.userId = null
this.name = null
this.description = null
this.coverPath = null
// Array of objects like { libraryItemId: "", episodeId: "" } (episodeId optional)
this.items = []
this.lastUpdate = null
this.createdAt = null
if (playlist) {
this.construct(playlist)
}
}
toJSON() {
return {
id: this.id,
libraryId: this.libraryId,
userId: this.userId,
name: this.name,
description: this.description,
coverPath: this.coverPath,
items: [...this.items],
lastUpdate: this.lastUpdate,
createdAt: this.createdAt
}
}
// Expands the items array
toJSONExpanded(libraryItems) {
var json = this.toJSON()
json.items = json.items.map(item => {
const libraryItem = libraryItems.find(li => li.id === item.libraryItemId)
if (!libraryItem) {
// Not found
return null
}
if (item.episodeId) {
if (!libraryItem.isPodcast) {
// Invalid
return null
}
const episode = libraryItem.media.episodes.find(ep => ep.id === item.episodeId)
if (!episode) {
// Not found
return null
}
return {
...item,
episode: episode.toJSONExpanded(),
libraryItem: libraryItem.toJSONMinified()
}
} else {
return {
...item,
libraryItem: libraryItem.toJSONExpanded()
}
}
}).filter(i => i)
return json
}
construct(playlist) {
this.id = playlist.id
this.libraryId = playlist.libraryId
this.userId = playlist.userId
this.name = playlist.name
this.description = playlist.description || null
this.coverPath = playlist.coverPath || null
this.items = playlist.items ? playlist.items.map(i => ({ ...i })) : []
this.lastUpdate = playlist.lastUpdate || null
this.createdAt = playlist.createdAt || null
}
setData(data) {
if (!data.userId || !data.libraryId || !data.name) {
return false
}
this.id = getId('pl')
this.userId = data.userId
this.libraryId = data.libraryId
this.name = data.name
this.description = data.description || null
this.coverPath = data.coverPath || null
this.items = data.items ? data.items.map(i => ({ ...i })) : []
this.lastUpdate = Date.now()
this.createdAt = Date.now()
return true
}
addItem(libraryItemId, episodeId = null) {
this.items.push({
libraryItemId,
episodeId: episodeId || null
})
this.lastUpdate = Date.now()
}
removeItem(libraryItemId, episodeId = null) {
if (episodeId) this.items = this.items.filter(i => i.libraryItemId !== libraryItemId || i.episodeId !== episodeId)
else this.items = this.items.filter(i => i.libraryItemId !== libraryItemId)
this.lastUpdate = Date.now()
}
update(payload) {
let hasUpdates = false
for (const key in payload) {
if (key === 'items') {
if (payload.items && JSON.stringify(payload.items) !== JSON.stringify(this.items)) {
this.items = payload.items.map(i => ({ ...i }))
hasUpdates = true
}
} else if (this[key] !== undefined && this[key] !== payload[key]) {
hasUpdates = true
this[key] = payload[key]
}
}
if (hasUpdates) {
this.lastUpdate = Date.now()
}
return hasUpdates
}
containsItem(item) {
if (item.episodeId) return this.items.some(i => i.libraryItemId === item.libraryItemId && i.episodeId === item.episodeId)
return this.items.some(i => i.libraryItemId === item.libraryItemId)
}
hasItemsForLibraryItem(libraryItemId) {
return this.items.some(i => i.libraryItemId === libraryItemId)
}
removeItemsForLibraryItem(libraryItemId) {
this.items = this.items.filter(i => i.libraryItemId !== libraryItemId)
}
}
module.exports = Playlist

View file

@ -1,8 +1,12 @@
const Ffmpeg = require('../libs/fluentFfmpeg')
const EventEmitter = require('events')
const Path = require('path')
const fs = require('../libs/fsExtra')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const fs = require('../libs/fsExtra')
const Ffmpeg = require('../libs/fluentFfmpeg')
const { secondsToTimestamp } = require('../utils/index')
const { writeConcatFile } = require('../utils/ffmpegHelpers')
const { AudioMimeType } = require('../utils/constants')
@ -10,14 +14,13 @@ const hlsPlaylistGenerator = require('../utils/hlsPlaylistGenerator')
const AudioTrack = require('./files/AudioTrack')
class Stream extends EventEmitter {
constructor(sessionId, streamPath, user, libraryItem, episodeId, startTime, clientEmitter, transcodeOptions = {}) {
constructor(sessionId, streamPath, user, libraryItem, episodeId, startTime, transcodeOptions = {}) {
super()
this.id = sessionId
this.user = user
this.libraryItem = libraryItem
this.episodeId = episodeId
this.clientEmitter = clientEmitter
this.transcodeOptions = transcodeOptions
@ -408,7 +411,7 @@ class Stream extends EventEmitter {
}
clientEmit(evtName, data) {
if (this.clientEmitter) this.clientEmitter(this.user.id, evtName, data)
SocketAuthority.clientEmitter(this.user.id, evtName, data)
}
getAudioTrack() {

View file

@ -1,5 +1,5 @@
const Logger = require('../../Logger')
const { areEquivalent, copyValue, cleanStringForSearch, getTitleIgnorePrefix } = require('../../utils/index')
const { areEquivalent, copyValue, cleanStringForSearch, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index')
const parseNameString = require('../../utils/parsers/parseNameString')
class BookMetadata {
constructor(metadata) {
@ -62,7 +62,7 @@ class BookMetadata {
toJSONMinified() {
return {
title: this.title,
titleIgnorePrefix: this.titleIgnorePrefix,
titleIgnorePrefix: this.titlePrefixAtEnd,
subtitle: this.subtitle,
authorName: this.authorName,
authorNameLF: this.authorNameLF,
@ -83,7 +83,7 @@ class BookMetadata {
toJSONExpanded() {
return {
title: this.title,
titleIgnorePrefix: this.titleIgnorePrefix,
titleIgnorePrefix: this.titlePrefixAtEnd,
subtitle: this.subtitle,
authors: this.authors.map(a => ({ ...a })), // Author JSONMinimal with name and id
narrators: [...this.narrators],
@ -111,6 +111,9 @@ class BookMetadata {
get titleIgnorePrefix() {
return getTitleIgnorePrefix(this.title)
}
get titlePrefixAtEnd() {
return getTitlePrefixAtEnd(this.title)
}
get authorName() {
if (!this.authors.length) return ''
return this.authors.map(au => au.name).join(', ')
@ -133,6 +136,13 @@ class BookMetadata {
return `${getTitleIgnorePrefix(se.name)} #${se.sequence}`
}).join(', ')
}
get seriesNamePrefixAtEnd() {
if (!this.series.length) return ''
return this.series.map(se => {
if (!se.sequence) return getTitlePrefixAtEnd(se.name)
return `${getTitlePrefixAtEnd(se.name)} #${se.sequence}`
}).join(', ')
}
get firstSeriesName() {
if (!this.series.length) return ''
return this.series[0].name

View file

@ -1,5 +1,5 @@
const Logger = require('../../Logger')
const { areEquivalent, copyValue, cleanStringForSearch } = require('../../utils/index')
const { areEquivalent, copyValue, cleanStringForSearch, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index')
class PodcastMetadata {
constructor(metadata) {
@ -56,7 +56,7 @@ class PodcastMetadata {
toJSONMinified() {
return {
title: this.title,
titleIgnorePrefix: this.titleIgnorePrefix,
titleIgnorePrefix: this.titlePrefixAtEnd,
author: this.author,
description: this.description,
releaseDate: this.releaseDate,
@ -80,15 +80,11 @@ class PodcastMetadata {
}
get titleIgnorePrefix() {
if (!this.title) return ''
var prefixesToIgnore = global.ServerSettings.sortingPrefixes || []
for (const prefix of prefixesToIgnore) {
// e.g. for prefix "the". If title is "The Book Title" return "Book Title, The"
if (this.title.toLowerCase().startsWith(`${prefix} `)) {
return this.title.substr(prefix.length + 1) + `, ${prefix.substr(0, 1).toUpperCase() + prefix.substr(1)}`
}
}
return this.title
return getTitleIgnorePrefix(this.title)
}
get titlePrefixAtEnd() {
return getTitlePrefixAtEnd(this.title)
}
searchQuery(query) { // Returns key if match is found

View file

@ -1,5 +1,5 @@
const Logger = require('../../Logger')
const { areEquivalent, copyValue } = require('../../utils/index')
const { areEquivalent, copyValue, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index')
class VideoMetadata {
constructor(metadata) {
@ -32,7 +32,7 @@ class VideoMetadata {
toJSONMinified() {
return {
title: this.title,
titleIgnorePrefix: this.titleIgnorePrefix,
titleIgnorePrefix: this.titlePrefixAtEnd,
description: this.description,
explicit: this.explicit,
language: this.language
@ -48,15 +48,11 @@ class VideoMetadata {
}
get titleIgnorePrefix() {
if (!this.title) return ''
var prefixesToIgnore = global.ServerSettings.sortingPrefixes || []
for (const prefix of prefixesToIgnore) {
// e.g. for prefix "the". If title is "The Book Title" return "Book Title, The"
if (this.title.toLowerCase().startsWith(`${prefix} `)) {
return this.title.substr(prefix.length + 1) + `, ${prefix.substr(0, 1).toUpperCase() + prefix.substr(1)}`
}
}
return this.title
return getTitleIgnorePrefix(this.title)
}
get titlePrefixAtEnd() {
return getTitlePrefixAtEnd(this.title)
}
searchQuery(query) { // Returns key if match is found

View file

@ -53,6 +53,7 @@ class ServerSettings {
this.chromecastEnabled = false
this.enableEReader = false
this.dateFormat = 'MM/dd/yyyy'
this.language = 'en-us'
this.logLevel = Logger.logLevel
@ -102,6 +103,7 @@ class ServerSettings {
this.chromecastEnabled = !!settings.chromecastEnabled
this.enableEReader = !!settings.enableEReader
this.dateFormat = settings.dateFormat || 'MM/dd/yyyy'
this.language = settings.language || 'en-us'
this.logLevel = settings.logLevel || Logger.logLevel
this.version = settings.version || null
@ -153,6 +155,7 @@ class ServerSettings {
chromecastEnabled: this.chromecastEnabled,
enableEReader: this.enableEReader,
dateFormat: this.dateFormat,
language: this.language,
logLevel: this.logLevel,
version: this.version
}

View file

@ -63,12 +63,12 @@ class MediaProgress {
this.isFinished = !!progress.isFinished || this.progress == 1
this.hideFromContinueListening = !!progress.hideFromContinueListening
this.lastUpdate = Date.now()
this.startedAt = Date.now()
this.finishedAt = null
if (this.isFinished) {
this.finishedAt = Date.now()
this.finishedAt = progress.finishedAt || Date.now()
this.progress = 1
}
this.startedAt = progress.startedAt || this.finishedAt || Date.now()
}
update(payload) {
@ -95,7 +95,7 @@ class MediaProgress {
// If time remaining is less than 5 seconds then mark as finished
if ((this.progress >= 1 || (this.duration && !isNaN(timeRemaining) && timeRemaining < 5))) {
this.isFinished = true
this.finishedAt = Date.now()
this.finishedAt = payload.finishedAt || Date.now()
this.progress = 1
} else if (this.progress < 1 && this.isFinished) {
this.isFinished = false
@ -103,7 +103,7 @@ class MediaProgress {
}
if (!this.startedAt) {
this.startedAt = Date.now()
this.startedAt = this.finishedAt || Date.now()
}
if (hasUpdates) {
if (payload.hideFromContinueListening === undefined) {

View file

@ -18,7 +18,7 @@ class User {
this.seriesHideFromContinueListening = [] // Series IDs that should not show on home page continue listening
this.bookmarks = []
this.settings = {}
this.settings = {} // TODO: Remove after mobile release v0.9.61-beta
this.permissions = {}
this.librariesAccessible = [] // Library IDs (Empty if ALL libraries)
this.itemTagsAccessible = [] // Empty if ALL item tags accessible
@ -59,17 +59,12 @@ class User {
return !!this.pash && !!this.pash.length
}
// TODO: Remove after mobile release v0.9.61-beta
getDefaultUserSettings() {
return {
mobileOrderBy: 'recent',
mobileOrderDesc: true,
mobileFilterBy: 'all',
orderBy: 'media.metadata.title',
orderDesc: false,
filterBy: 'all',
playbackRate: 1,
bookshelfCoverSize: 120,
collapseSeries: false
mobileFilterBy: 'all'
}
}
@ -99,7 +94,7 @@ class User {
isLocked: this.isLocked,
lastSeen: this.lastSeen,
createdAt: this.createdAt,
settings: this.settings,
settings: this.settings, // TODO: Remove after mobile release v0.9.61-beta
permissions: this.permissions,
librariesAccessible: [...this.librariesAccessible],
itemTagsAccessible: [...this.itemTagsAccessible]
@ -119,7 +114,7 @@ class User {
isLocked: this.isLocked,
lastSeen: this.lastSeen,
createdAt: this.createdAt,
settings: this.settings,
settings: this.settings, // TODO: Remove after mobile release v0.9.61-beta
permissions: this.permissions,
librariesAccessible: [...this.librariesAccessible],
itemTagsAccessible: [...this.itemTagsAccessible]
@ -130,8 +125,8 @@ class User {
toJSONForPublic(sessions, libraryItems) {
var userSession = sessions ? sessions.find(s => s.userId === this.id) : null
var session = null
if (session) {
var libraryItem = libraryItems.find(li => li.id === session.libraryItemId)
if (userSession) {
var libraryItem = libraryItems.find(li => li.id === userSession.libraryItemId)
if (libraryItem) {
session = userSession.toJSONForClient(libraryItem)
}
@ -171,7 +166,7 @@ class User {
this.isLocked = user.type === 'root' ? false : !!user.isLocked
this.lastSeen = user.lastSeen || null
this.createdAt = user.createdAt || Date.now()
this.settings = user.settings || this.getDefaultUserSettings()
this.settings = user.settings || this.getDefaultUserSettings() // TODO: Remove after mobile release v0.9.61-beta
this.permissions = user.permissions || this.getDefaultUserPermissions()
// Upload permission added v1.1.13, make sure root user has upload permissions
if (this.type === 'root' && !this.permissions.upload) this.permissions.upload = true
@ -266,16 +261,42 @@ class User {
return firstAccessibleLibrary.id
}
// Returns most recent media progress w/ `media` object and optionally an `episode` object
getMostRecentItemProgress(libraryItems) {
if (!this.mediaProgress.length) return null
var lip = this.mediaProgress.map(lip => lip.toJSON())
lip.sort((a, b) => b.lastUpdate - a.lastUpdate)
var mostRecentWithLip = lip.find(li => libraryItems.find(_li => _li.id === li.id))
if (!mostRecentWithLip) return null
var libraryItem = libraryItems.find(li => li.id === mostRecentWithLip.id)
var mediaProgressObjects = this.mediaProgress.map(lip => lip.toJSON())
mediaProgressObjects.sort((a, b) => b.lastUpdate - a.lastUpdate)
var libraryItemMedia = null
var progressEpisode = null
// Find the most recent progress that still has a libraryItem and episode
var mostRecentProgress = mediaProgressObjects.find((progress) => {
const libraryItem = libraryItems.find(li => li.id === progress.libraryItemId)
if (!libraryItem) {
Logger.warn('[User] Library item not found for users progress ' + progress.libraryItemId)
return false
} else if (progress.episodeId) {
const episode = libraryItem.mediaType === 'podcast' ? libraryItem.media.getEpisode(progress.episodeId) : null
if (!episode) {
Logger.warn(`[User] Episode ${progress.episodeId} not found for user media progress, podcast: ${libraryItem.media.metadata.title}`)
return false
} else {
libraryItemMedia = libraryItem.media.toJSONExpanded()
progressEpisode = episode.toJSON()
return true
}
} else {
libraryItemMedia = libraryItem.media.toJSONExpanded()
return true
}
})
if (!mostRecentProgress) return null
return {
...mostRecentWithLip,
media: libraryItem.media.toJSONExpanded()
...mostRecentProgress,
media: libraryItemMedia,
episode: progressEpisode
}
}
@ -322,6 +343,7 @@ class User {
return true
}
// TODO: Remove after mobile release v0.9.61-beta
// Returns Boolean If update was made
updateSettings(settings) {
if (!this.settings) {
@ -408,6 +430,12 @@ class User {
return true
}
removeSeriesFromHideFromContinueListening(seriesId) {
if (!this.seriesHideFromContinueListening.includes(seriesId)) return false
this.seriesHideFromContinueListening = this.seriesHideFromContinueListening.filter(sid => sid !== seriesId)
return true
}
removeProgressFromContinueListening(progressId) {
const progress = this.mediaProgress.find(mp => mp.id === progressId)
if (!progress) return false

View file

@ -8,7 +8,7 @@ class Audible {
'us': '.com',
'ca': '.ca',
'uk': '.co.uk',
'au': '.co.au',
'au': '.com.au',
'fr': '.fr',
'de': '.de',
'jp': '.co.jp',

View file

@ -15,7 +15,14 @@ class GoogleBooks {
cleanResult(item) {
var { id, volumeInfo } = item
if (!volumeInfo) return null
var { title, subtitle, authors, publisher, publisherDate, description, industryIdentifiers, categories, imageLinks } = volumeInfo
const { title, subtitle, authors, publisher, publisherDate, description, industryIdentifiers, categories, imageLinks } = volumeInfo
let cover = null
// Selects the largest cover assuming the largest is the last key in the object
if (imageLinks && Object.keys(imageLinks).length) {
cover = imageLinks[Object.keys(imageLinks).pop()]
cover = cover?.replace(/^http:/, 'https:') || null
}
return {
id,
@ -25,7 +32,7 @@ class GoogleBooks {
publisher,
publishedYear: publisherDate ? publisherDate.split('-')[0] : null,
description,
cover: imageLinks && imageLinks.thumbnail ? imageLinks.thumbnail : null,
cover,
genres: categories && Array.isArray(categories) ? [...categories] : null,
isbn: this.extractIsbn(industryIdentifiers)
}

View file

@ -1,20 +1,28 @@
const express = require('express')
const Path = require('path')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const fs = require('../libs/fsExtra')
const date = require('../libs/dateAndTime')
const Logger = require('../Logger')
const LibraryController = require('../controllers/LibraryController')
const UserController = require('../controllers/UserController')
const CollectionController = require('../controllers/CollectionController')
const PlaylistController = require('../controllers/PlaylistController')
const MeController = require('../controllers/MeController')
const BackupController = require('../controllers/BackupController')
const LibraryItemController = require('../controllers/LibraryItemController')
const SeriesController = require('../controllers/SeriesController')
const FileSystemController = require('../controllers/FileSystemController')
const AuthorController = require('../controllers/AuthorController')
const SessionController = require('../controllers/SessionController')
const PodcastController = require('../controllers/PodcastController')
const NotificationController = require('../controllers/NotificationController')
const SearchController = require('../controllers/SearchController')
const CacheController = require('../controllers/CacheController')
const ToolsController = require('../controllers/ToolsController')
const MiscController = require('../controllers/MiscController')
const BookFinder = require('../finders/BookFinder')
@ -23,27 +31,24 @@ const PodcastFinder = require('../finders/PodcastFinder')
const Author = require('../objects/entities/Author')
const Series = require('../objects/entities/Series')
const FileSystemController = require('../controllers/FileSystemController')
class ApiRouter {
constructor(db, auth, scanner, playbackSessionManager, abMergeManager, coverManager, backupManager, watcher, cacheManager, podcastManager, audioMetadataManager, rssFeedManager, cronManager, notificationManager, taskManager, emitter, clientEmitter) {
this.db = db
this.auth = auth
this.scanner = scanner
this.playbackSessionManager = playbackSessionManager
this.abMergeManager = abMergeManager
this.backupManager = backupManager
this.coverManager = coverManager
this.watcher = watcher
this.cacheManager = cacheManager
this.podcastManager = podcastManager
this.audioMetadataManager = audioMetadataManager
this.rssFeedManager = rssFeedManager
this.cronManager = cronManager
this.notificationManager = notificationManager
this.taskManager = taskManager
this.emitter = emitter
this.clientEmitter = clientEmitter
constructor(Server) {
this.db = Server.db
this.auth = Server.auth
this.scanner = Server.scanner
this.playbackSessionManager = Server.playbackSessionManager
this.abMergeManager = Server.abMergeManager
this.backupManager = Server.backupManager
this.coverManager = Server.coverManager
this.watcher = Server.watcher
this.cacheManager = Server.cacheManager
this.podcastManager = Server.podcastManager
this.audioMetadataManager = Server.audioMetadataManager
this.rssFeedManager = Server.rssFeedManager
this.cronManager = Server.cronManager
this.notificationManager = Server.notificationManager
this.taskManager = Server.taskManager
this.bookFinder = new BookFinder()
this.authorFinder = new AuthorFinder()
@ -67,6 +72,7 @@ class ApiRouter {
this.router.delete('/libraries/:id/issues', LibraryController.middleware.bind(this), LibraryController.removeLibraryItemsWithIssues.bind(this))
this.router.get('/libraries/:id/series', LibraryController.middleware.bind(this), LibraryController.getAllSeriesForLibrary.bind(this))
this.router.get('/libraries/:id/collections', LibraryController.middleware.bind(this), LibraryController.getCollectionsForLibrary.bind(this))
this.router.get('/libraries/:id/playlists', LibraryController.middleware.bind(this), LibraryController.getUserPlaylistsForLibrary.bind(this))
this.router.get('/libraries/:id/personalized', LibraryController.middleware.bind(this), LibraryController.getLibraryUserPersonalizedOptimal.bind(this))
this.router.get('/libraries/:id/filterdata', LibraryController.middleware.bind(this), LibraryController.getLibraryFilterData.bind(this))
this.router.get('/libraries/:id/search', LibraryController.middleware.bind(this), LibraryController.search.bind(this))
@ -97,7 +103,6 @@ class ApiRouter {
this.router.patch('/items/:id/tracks', LibraryItemController.middleware.bind(this), LibraryItemController.updateTracks.bind(this))
this.router.get('/items/:id/scan', LibraryItemController.middleware.bind(this), LibraryItemController.scan.bind(this))
this.router.get('/items/:id/tone-object', LibraryItemController.middleware.bind(this), LibraryItemController.getToneMetadataObject.bind(this))
this.router.get('/items/:id/audio-metadata', LibraryItemController.middleware.bind(this), LibraryItemController.updateAudioFileMetadata.bind(this))
this.router.post('/items/:id/chapters', LibraryItemController.middleware.bind(this), LibraryItemController.updateMediaChapters.bind(this))
this.router.post('/items/:id/open-feed', LibraryItemController.middleware.bind(this), LibraryItemController.openRSSFeed.bind(this))
this.router.post('/items/:id/close-feed', LibraryItemController.middleware.bind(this), LibraryItemController.closeRSSFeed.bind(this))
@ -113,6 +118,7 @@ class ApiRouter {
//
this.router.post('/users', UserController.middleware.bind(this), UserController.create.bind(this))
this.router.get('/users', UserController.middleware.bind(this), UserController.findAll.bind(this))
this.router.get('/users/online', UserController.getOnlineUsers.bind(this))
this.router.get('/users/:id', UserController.middleware.bind(this), UserController.findOne.bind(this))
this.router.patch('/users/:id', UserController.middleware.bind(this), UserController.update.bind(this))
this.router.delete('/users/:id', UserController.middleware.bind(this), UserController.delete.bind(this))
@ -129,12 +135,25 @@ class ApiRouter {
this.router.get('/collections/:id', CollectionController.middleware.bind(this), CollectionController.findOne.bind(this))
this.router.patch('/collections/:id', CollectionController.middleware.bind(this), CollectionController.update.bind(this))
this.router.delete('/collections/:id', CollectionController.middleware.bind(this), CollectionController.delete.bind(this))
this.router.post('/collections/:id/book', CollectionController.middleware.bind(this), CollectionController.addBook.bind(this))
this.router.delete('/collections/:id/book/:bookId', CollectionController.middleware.bind(this), CollectionController.removeBook.bind(this))
this.router.post('/collections/:id/batch/add', CollectionController.middleware.bind(this), CollectionController.addBatch.bind(this))
this.router.post('/collections/:id/batch/remove', CollectionController.middleware.bind(this), CollectionController.removeBatch.bind(this))
//
// Playlist Routes
//
this.router.post('/playlists', PlaylistController.create.bind(this))
this.router.get('/playlists', PlaylistController.findAllForUser.bind(this))
this.router.get('/playlists/:id', PlaylistController.middleware.bind(this), PlaylistController.findOne.bind(this))
this.router.patch('/playlists/:id', PlaylistController.middleware.bind(this), PlaylistController.update.bind(this))
this.router.delete('/playlists/:id', PlaylistController.middleware.bind(this), PlaylistController.delete.bind(this))
this.router.post('/playlists/:id/item', PlaylistController.middleware.bind(this), PlaylistController.addItem.bind(this))
this.router.delete('/playlists/:id/item/:libraryItemId/:episodeId?', PlaylistController.middleware.bind(this), PlaylistController.removeItem.bind(this))
this.router.post('/playlists/:id/batch/add', PlaylistController.middleware.bind(this), PlaylistController.addBatch.bind(this))
this.router.post('/playlists/:id/batch/remove', PlaylistController.middleware.bind(this), PlaylistController.removeBatch.bind(this))
this.router.post('/playlists/collection/:collectionId', PlaylistController.createFromCollection.bind(this))
//
// Current User Routes (Me)
//
@ -150,18 +169,20 @@ class ApiRouter {
this.router.patch('/me/item/:id/bookmark', MeController.updateBookmark.bind(this))
this.router.delete('/me/item/:id/bookmark/:time', MeController.removeBookmark.bind(this))
this.router.patch('/me/password', MeController.updatePassword.bind(this))
this.router.patch('/me/settings', MeController.updateSettings.bind(this))
this.router.patch('/me/settings', MeController.updateSettings.bind(this)) // TODO: Remove after mobile release v0.9.61-beta
this.router.post('/me/sync-local-progress', MeController.syncLocalMediaProgress.bind(this))
this.router.get('/me/items-in-progress', MeController.getAllLibraryItemsInProgress.bind(this))
this.router.get('/me/series/:id/remove-from-continue-listening', MeController.removeSeriesFromContinueListening.bind(this))
this.router.get('/me/series/:id/readd-to-continue-listening', MeController.readdSeriesFromContinueListening.bind(this))
//
// Backup Routes
//
this.router.post('/backups', BackupController.create.bind(this))
this.router.delete('/backups/:id', BackupController.delete.bind(this))
this.router.get('/backups/:id/apply', BackupController.apply.bind(this))
this.router.post('/backups/upload', BackupController.upload.bind(this))
this.router.get('/backups', BackupController.middleware.bind(this), BackupController.getAll.bind(this))
this.router.post('/backups', BackupController.middleware.bind(this), BackupController.create.bind(this))
this.router.delete('/backups/:id', BackupController.middleware.bind(this), BackupController.delete.bind(this))
this.router.get('/backups/:id/apply', BackupController.middleware.bind(this), BackupController.apply.bind(this))
this.router.post('/backups/upload', BackupController.middleware.bind(this), BackupController.upload.bind(this))
//
// File System Routes
@ -221,35 +242,48 @@ class ApiRouter {
this.router.patch('/notifications/:id', NotificationController.middleware.bind(this), NotificationController.updateNotification.bind(this))
this.router.get('/notifications/:id/test', NotificationController.middleware.bind(this), NotificationController.sendNotificationTest.bind(this))
//
// Search Routes
//
this.router.get('/search/covers', SearchController.findCovers.bind(this))
this.router.get('/search/books', SearchController.findBooks.bind(this))
this.router.get('/search/podcast', SearchController.findPodcasts.bind(this))
this.router.get('/search/authors', SearchController.findAuthor.bind(this))
this.router.get('/search/chapters', SearchController.findChapters.bind(this))
//
// Cache Routes
//
this.router.post('/cache/purge', CacheController.purgeCache.bind(this))
this.router.post('/cache/items/purge', CacheController.purgeItemsCache.bind(this))
//
// Tools Routes
//
this.router.post('/tools/item/:id/encode-m4b', ToolsController.itemMiddleware.bind(this), ToolsController.encodeM4b.bind(this))
this.router.delete('/tools/item/:id/encode-m4b', ToolsController.itemMiddleware.bind(this), ToolsController.cancelM4bEncode.bind(this))
this.router.post('/tools/item/:id/embed-metadata', ToolsController.itemMiddleware.bind(this), ToolsController.embedAudioFileMetadata.bind(this))
//
// Misc Routes
//
this.router.post('/upload', MiscController.handleUpload.bind(this))
this.router.get('/encode-m4b/:id', MiscController.encodeM4b.bind(this))
this.router.post('/encode-m4b/:id/cancel', MiscController.cancelM4bEncode.bind(this))
this.router.get('/tasks', MiscController.getTasks.bind(this))
this.router.patch('/settings', MiscController.updateServerSettings.bind(this))
this.router.post('/cache/purge', MiscController.purgeCache.bind(this))
this.router.post('/cache/items/purge', MiscController.purgeItemsCache.bind(this))
this.router.post('/authorize', MiscController.authorize.bind(this))
this.router.get('/search/covers', MiscController.findCovers.bind(this))
this.router.get('/search/books', MiscController.findBooks.bind(this))
this.router.get('/search/podcast', MiscController.findPodcasts.bind(this))
this.router.get('/search/authors', MiscController.findAuthor.bind(this))
this.router.get('/search/chapters', MiscController.findChapters.bind(this))
this.router.get('/tags', MiscController.getAllTags.bind(this))
this.router.post('/validate-cron', MiscController.validateCronExpression.bind(this))
}
async getDirectories(dir, relpath, excludedDirs, level = 0) {
try {
var paths = await fs.readdir(dir)
const paths = await fs.readdir(dir)
var dirs = await Promise.all(paths.map(async dirname => {
var fullPath = Path.join(dir, dirname)
var path = Path.join(relpath, dirname)
let dirs = await Promise.all(paths.map(async dirname => {
const fullPath = Path.join(dir, dirname)
const path = Path.join(relpath, dirname)
var isDir = (await fs.lstat(fullPath)).isDirectory()
const isDir = (await fs.lstat(fullPath)).isDirectory()
if (isDir && !excludedDirs.includes(path) && dirname !== 'node_modules') {
return {
path,
@ -274,13 +308,13 @@ class ApiRouter {
// Helper Methods
//
userJsonWithItemProgressDetails(user, hideRootToken = false) {
var json = user.toJSONForBrowser()
const json = user.toJSONForBrowser()
if (json.type === 'root' && hideRootToken) {
json.token = ''
}
json.mediaProgress = json.mediaProgress.map(lip => {
var libraryItem = this.db.libraryItems.find(li => li.id === lip.libraryItemId)
const libraryItem = this.db.libraryItems.find(li => li.id === lip.libraryItemId)
if (!libraryItem) {
Logger.warn('[ApiRouter] Library item not found for users progress ' + lip.libraryItemId)
lip.media = null
@ -307,34 +341,38 @@ class ApiRouter {
async handleDeleteLibraryItem(libraryItem) {
// Remove libraryItem from users
for (let i = 0; i < this.db.users.length; i++) {
var user = this.db.users[i]
var madeUpdates = user.removeMediaProgressForLibraryItem(libraryItem.id)
if (madeUpdates) {
const user = this.db.users[i]
if (user.removeMediaProgressForLibraryItem(libraryItem.id)) {
await this.db.updateEntity('user', user)
}
}
// remove any streams open for this audiobook
// TODO: Change to PlaybackSessionManager to remove open sessions for user
// var streams = this.streamManager.streams.filter(stream => stream.audiobookId === libraryItem.id)
// for (let i = 0; i < streams.length; i++) {
// var stream = streams[i]
// var client = stream.client
// await stream.close()
// if (client && client.user) {
// client.user.stream = null
// client.stream = null
// this.db.updateUserStream(client.user.id, null)
// }
// }
// TODO: Remove open sessions for library item
// remove book from collections
var collectionsWithBook = this.db.collections.filter(c => c.books.includes(libraryItem.id))
const collectionsWithBook = this.db.collections.filter(c => c.books.includes(libraryItem.id))
for (let i = 0; i < collectionsWithBook.length; i++) {
var collection = collectionsWithBook[i]
const collection = collectionsWithBook[i]
collection.removeBook(libraryItem.id)
await this.db.updateEntity('collection', collection)
this.clientEmitter(collection.userId, 'collection_updated', collection.toJSONExpanded(this.db.libraryItems))
SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(this.db.libraryItems))
}
// remove item from playlists
const playlistsWithItem = this.db.playlists.filter(p => p.hasItemsForLibraryItem(libraryItem.id))
for (let i = 0; i < playlistsWithItem.length; i++) {
const playlist = playlistsWithItem[i]
playlist.removeItemsForLibraryItem(libraryItem.id)
// If playlist is now empty then remove it
if (!playlist.items.length) {
Logger.info(`[ApiRouter] Playlist "${playlist.name}" has no more items - removing it`)
await this.db.removeEntity('playlist', playlist.id)
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', playlist.toJSONExpanded(this.db.libraryItems))
} else {
await this.db.updateEntity('playlist', playlist)
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', playlist.toJSONExpanded(this.db.libraryItems))
}
}
// purge cover cache
@ -342,34 +380,32 @@ class ApiRouter {
await this.cacheManager.purgeCoverCache(libraryItem.id)
}
var json = libraryItem.toJSONExpanded()
await this.db.removeLibraryItem(libraryItem.id)
this.emitter('item_removed', json)
SocketAuthority.emitter('item_removed', libraryItem.toJSONExpanded())
}
async getUserListeningSessionsHelper(userId) {
var userSessions = await this.db.selectUserSessions(userId)
const userSessions = await this.db.selectUserSessions(userId)
return userSessions.sort((a, b) => b.updatedAt - a.updatedAt)
}
async getAllSessionsWithUserData() {
var sessions = await this.db.getAllSessions()
const sessions = await this.db.getAllSessions()
sessions.sort((a, b) => b.updatedAt - a.updatedAt)
return sessions.map(se => {
var user = this.db.users.find(u => u.id === se.userId)
var _se = {
const user = this.db.users.find(u => u.id === se.userId)
return {
...se,
user: user ? { id: user.id, username: user.username } : null
}
return _se
})
}
async getUserListeningStatsHelpers(userId) {
const today = date.format(new Date(), 'YYYY-MM-DD')
var listeningSessions = await this.getUserListeningSessionsHelper(userId)
var listeningStats = {
const listeningSessions = await this.getUserListeningSessionsHelper(userId)
const listeningStats = {
totalTime: 0,
items: {},
days: {},
@ -378,7 +414,7 @@ class ApiRouter {
recentSessions: listeningSessions.slice(0, 10)
}
listeningSessions.forEach((s) => {
var sessionTimeListening = s.timeListening
let sessionTimeListening = s.timeListening
if (typeof sessionTimeListening == 'string') {
sessionTimeListening = Number(sessionTimeListening)
}
@ -413,15 +449,15 @@ class ApiRouter {
async createAuthorsAndSeriesForItemUpdate(mediaPayload) {
if (mediaPayload.metadata) {
var mediaMetadata = mediaPayload.metadata
const mediaMetadata = mediaPayload.metadata
// Create new authors if in payload
if (mediaMetadata.authors && mediaMetadata.authors.length) {
// TODO: validate authors
var newAuthors = []
const newAuthors = []
for (let i = 0; i < mediaMetadata.authors.length; i++) {
if (mediaMetadata.authors[i].id.startsWith('new')) {
var author = this.db.authors.find(au => au.checkNameEquals(mediaMetadata.authors[i].name))
let author = this.db.authors.find(au => au.checkNameEquals(mediaMetadata.authors[i].name))
if (!author) {
author = new Author()
author.setData(mediaMetadata.authors[i])
@ -435,17 +471,17 @@ class ApiRouter {
}
if (newAuthors.length) {
await this.db.insertEntities('author', newAuthors)
this.emitter('authors_added', newAuthors)
SocketAuthority.emitter('authors_added', newAuthors)
}
}
// Create new series if in payload
if (mediaMetadata.series && mediaMetadata.series.length) {
// TODO: validate series
var newSeries = []
const newSeries = []
for (let i = 0; i < mediaMetadata.series.length; i++) {
if (mediaMetadata.series[i].id.startsWith('new')) {
var seriesItem = this.db.series.find(se => se.checkNameEquals(mediaMetadata.series[i].name))
let seriesItem = this.db.series.find(se => se.checkNameEquals(mediaMetadata.series[i].name))
if (!seriesItem) {
seriesItem = new Series()
seriesItem.setData(mediaMetadata.series[i])
@ -459,7 +495,7 @@ class ApiRouter {
}
if (newSeries.length) {
await this.db.insertEntities('series', newSeries)
this.emitter('authors_added', newSeries)
SocketAuthority.emitter('authors_added', newSeries)
}
}
}

View file

@ -1,14 +1,17 @@
const express = require('express')
const Path = require('path')
const fs = require('../libs/fsExtra')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const fs = require('../libs/fsExtra')
class HlsRouter {
constructor(db, auth, playbackSessionManager, emitter) {
constructor(db, auth, playbackSessionManager) {
this.db = db
this.auth = auth
this.playbackSessionManager = playbackSessionManager
this.emitter = emitter
this.router = express()
this.init()
@ -49,7 +52,7 @@ class HlsRouter {
if (startTimeForReset) {
// HLS.js will restart the stream at the new time
Logger.info(`[HlsRouter] Resetting Stream - notify client @${startTimeForReset}s`)
this.emitter('stream_reset', {
SocketAuthority.emitter('stream_reset', {
startTime: startTimeForReset,
streamId: stream.id
})

View file

@ -5,6 +5,7 @@ const date = require('../libs/dateAndTime')
const Logger = require('../Logger')
const Folder = require('../objects/Folder')
const { LogLevel } = require('../utils/constants')
const filePerms = require('../utils/filePerms')
const { getId, secondsToTimestamp } = require('../utils/index')
class LibraryScan {
@ -61,7 +62,7 @@ class LibraryScan {
get totalResults() {
return this.resultsAdded + this.resultsUpdated + this.resultsMissing
}
get getLogFilename() {
get logFilename() {
return date.format(new Date(), 'YYYY-MM-DD') + '_' + this.id + '.txt'
}
@ -124,14 +125,17 @@ class LibraryScan {
this.logs.push(logObj)
}
async saveLog(logDir) {
await fs.ensureDir(logDir)
var outputPath = Path.join(logDir, this.getLogFilename)
var logLines = [JSON.stringify(this.toJSON())]
async saveLog() {
await Logger.logManager.ensureScanLogDir()
const outputPath = Path.join(logDir, this.logFilename)
const logLines = [JSON.stringify(this.toJSON())]
this.logs.forEach(l => {
logLines.push(JSON.stringify(l))
})
await fs.writeFile(outputPath, logLines.join('\n') + '\n')
await filePerms.setDefault(outputPath)
Logger.info(`[LibraryScan] Scan log saved "${outputPath}"`)
}
}

View file

@ -14,7 +14,7 @@ class MediaFileScanner {
getTrackAndDiscNumberFromFilename(mediaMetadataFromScan, audioLibraryFile) {
const { title, author, series, publishedYear } = mediaMetadataFromScan
const { filename, path } = audioLibraryFile.metadata
var partbasename = Path.basename(filename, Path.extname(filename))
let partbasename = Path.basename(filename, Path.extname(filename))
// Remove title, author, series, and publishedYear from filename if there
if (title) partbasename = partbasename.replace(title, '')
@ -23,8 +23,8 @@ class MediaFileScanner {
if (publishedYear) partbasename = partbasename.replace(publishedYear)
// Look for disc number
var discNumber = null
var discMatch = partbasename.match(/\b(disc|cd) ?(\d\d?)\b/i)
let discNumber = null
const discMatch = partbasename.match(/\b(disc|cd) ?(\d\d?)\b/i)
if (discMatch && discMatch.length > 2 && discMatch[2]) {
if (!isNaN(discMatch[2])) {
discNumber = Number(discMatch[2])
@ -35,14 +35,14 @@ class MediaFileScanner {
}
// Look for disc number in folder path e.g. /Book Title/CD01/audiofile.mp3
var pathdir = Path.dirname(path).split('/').pop()
const pathdir = Path.dirname(path).split('/').pop()
if (pathdir && /^cd\d{1,3}$/i.test(pathdir)) {
var discFromFolder = Number(pathdir.replace(/cd/i, ''))
const discFromFolder = Number(pathdir.replace(/cd/i, ''))
if (!isNaN(discFromFolder) && discFromFolder !== null) discNumber = discFromFolder
}
var numbersinpath = partbasename.match(/\d{1,4}/g)
var trackNumber = numbersinpath && numbersinpath.length ? parseInt(numbersinpath[0]) : null
const numbersinpath = partbasename.match(/\d{1,4}/g)
const trackNumber = numbersinpath && numbersinpath.length ? parseInt(numbersinpath[0]) : null
return {
trackNumber,
discNumber
@ -51,7 +51,7 @@ class MediaFileScanner {
getAverageScanDurationMs(results) {
if (!results.length) return 0
var total = 0
let total = 0
for (let i = 0; i < results.length; i++) total += results[i].elapsed
return Math.floor(total / results.length)
}

View file

@ -1,8 +1,9 @@
const fs = require('../libs/fsExtra')
const Path = require('path')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
// Utils
const Logger = require('../Logger')
const { groupFilesIntoLibraryItemPaths, getLibraryItemFileData, scanFolder } = require('../utils/scandir')
const { comparePaths } = require('../utils/index')
const { getIno } = require('../utils/fileUtils')
@ -20,12 +21,9 @@ const Author = require('../objects/entities/Author')
const Series = require('../objects/entities/Series')
class Scanner {
constructor(db, coverManager, emitter) {
this.ScanLogPath = Path.posix.join(global.MetadataPath, 'logs', 'scans')
constructor(db, coverManager) {
this.db = db
this.coverManager = coverManager
this.emitter = emitter
this.cancelLibraryScan = {}
this.librariesScanning = []
@ -113,7 +111,7 @@ class Scanner {
}
if (hasUpdated) {
this.emitter('item_updated', libraryItem.toJSONExpanded())
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
await this.db.updateLibraryItem(libraryItem)
return ScanResult.UPDATED
}
@ -139,7 +137,7 @@ class Scanner {
libraryScan.verbose = false
this.librariesScanning.push(libraryScan.getScanEmitData)
this.emitter('scan_start', libraryScan.getScanEmitData)
SocketAuthority.emitter('scan_start', libraryScan.getScanEmitData)
Logger.info(`[Scanner] Starting library scan ${libraryScan.id} for ${libraryScan.libraryName}`)
@ -158,14 +156,14 @@ class Scanner {
if (canceled && !libraryScan.totalResults) {
var emitData = libraryScan.getScanEmitData
emitData.results = null
this.emitter('scan_complete', emitData)
SocketAuthority.emitter('scan_complete', emitData)
return
}
this.emitter('scan_complete', libraryScan.getScanEmitData)
SocketAuthority.emitter('scan_complete', libraryScan.getScanEmitData)
if (libraryScan.totalResults) {
libraryScan.saveLog(this.ScanLogPath)
libraryScan.saveLog()
}
}
@ -302,7 +300,7 @@ class Scanner {
async updateLibraryItemChunk(itemsToUpdate) {
await this.db.updateLibraryItems(itemsToUpdate)
this.emitter('items_updated', itemsToUpdate.map(li => li.toJSONExpanded()))
SocketAuthority.emitter('items_updated', itemsToUpdate.map(li => li.toJSONExpanded()))
}
async rescanLibraryItemDataChunk(itemDataToRescan, libraryScan) {
@ -320,7 +318,7 @@ class Scanner {
if (itemsUpdated.length) {
libraryScan.resultsUpdated += itemsUpdated.length
await this.db.updateLibraryItems(itemsUpdated)
this.emitter('items_updated', itemsUpdated.map(li => li.toJSONExpanded()))
SocketAuthority.emitter('items_updated', itemsUpdated.map(li => li.toJSONExpanded()))
}
}
@ -337,7 +335,7 @@ class Scanner {
libraryScan.resultsAdded += newLibraryItems.length
await this.db.insertLibraryItems(newLibraryItems)
this.emitter('items_added', newLibraryItems.map(li => li.toJSONExpanded()))
SocketAuthority.emitter('items_added', newLibraryItems.map(li => li.toJSONExpanded()))
}
async rescanLibraryItem(libraryItemCheckData, libraryScan) {
@ -458,7 +456,7 @@ class Scanner {
})
if (newAuthors.length) {
await this.db.insertEntities('author', newAuthors)
this.emitter('authors_added', newAuthors.map(au => au.toJSON()))
SocketAuthority.emitter('authors_added', newAuthors.map(au => au.toJSON()))
}
}
if (libraryItem.media.metadata.series.some(se => se.id.startsWith('new'))) {
@ -479,7 +477,7 @@ class Scanner {
})
if (newSeries.length) {
await this.db.insertEntities('series', newSeries)
this.emitter('series_added', newSeries.map(se => se.toJSON()))
SocketAuthority.emitter('series_added', newSeries.map(se => se.toJSON()))
}
}
}
@ -602,7 +600,7 @@ class Scanner {
Logger.info(`[Scanner] Scanning file update group and library item was deleted "${existingLibraryItem.media.metadata.title}" - marking as missing`)
existingLibraryItem.setMissing()
await this.db.updateLibraryItem(existingLibraryItem)
this.emitter('item_updated', existingLibraryItem.toJSONExpanded())
SocketAuthority.emitter('item_updated', existingLibraryItem.toJSONExpanded())
itemGroupingResults[itemDir] = ScanResult.REMOVED
continue;
@ -629,7 +627,7 @@ class Scanner {
if (newLibraryItem) {
await this.createNewAuthorsAndSeries(newLibraryItem)
await this.db.insertLibraryItem(newLibraryItem)
this.emitter('item_added', newLibraryItem.toJSONExpanded())
SocketAuthority.emitter('item_added', newLibraryItem.toJSONExpanded())
}
itemGroupingResults[itemDir] = newLibraryItem ? ScanResult.ADDED : ScanResult.NOTHING
}
@ -747,7 +745,7 @@ class Scanner {
}
await this.db.updateLibraryItem(libraryItem)
this.emitter('item_updated', libraryItem.toJSONExpanded())
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
}
return {
@ -776,9 +774,14 @@ class Scanner {
for (const key in matchDataTransformed) {
if (matchDataTransformed[key]) {
if (key === 'genres') {
if ((!libraryItem.media.metadata.genres || options.overrideDetails)) {
// TODO: Genres array or string?
updatePayload.metadata[key] = matchDataTransformed[key].split(',').map(v => v.trim()).filter(v => !!v)
if ((!libraryItem.media.metadata.genres.length || options.overrideDetails)) {
var genresArray = []
if (Array.isArray(matchDataTransformed[key])) genresArray = [...matchDataTransformed[key]]
else { // Genres should always be passed in as an array but just incase handle a string
Logger.warn(`[Scanner] quickMatch genres is not an array ${matchDataTransformed[key]}`)
genresArray = matchDataTransformed[key].split(',').map(v => v.trim()).filter(v => !!v)
}
updatePayload.metadata[key] = genresArray
}
} else if (libraryItem.media.metadata[key] !== matchDataTransformed[key] && (!libraryItem.media.metadata[key] || options.overrideDetails)) {
updatePayload.metadata[key] = matchDataTransformed[key]
@ -841,7 +844,7 @@ class Scanner {
author = new Author()
author.setData({ name: authorName })
await this.db.insertEntity('author', author)
this.emitter('author_added', author)
SocketAuthority.emitter('author_added', author)
}
authorPayload.push(author.toJSONMinimal())
}
@ -859,7 +862,7 @@ class Scanner {
seriesItem = new Series()
seriesItem.setData({ name: seriesMatchItem.series })
await this.db.insertEntity('series', seriesItem)
this.emitter('series_added', seriesItem)
SocketAuthority.emitter('series_added', seriesItem)
}
seriesPayload.push(seriesItem.toJSONMinimal(seriesMatchItem.sequence))
}
@ -950,7 +953,7 @@ class Scanner {
var libraryScan = new LibraryScan()
libraryScan.setData(library, null, 'match')
this.librariesScanning.push(libraryScan.getScanEmitData)
this.emitter('scan_start', libraryScan.getScanEmitData)
SocketAuthority.emitter('scan_start', libraryScan.getScanEmitData)
Logger.info(`[Scanner] matchLibraryItems: Starting library match scan ${libraryScan.id} for ${libraryScan.libraryName}`)
@ -982,14 +985,14 @@ class Scanner {
delete this.cancelLibraryScan[libraryScan.libraryId]
var scanData = libraryScan.getScanEmitData
scanData.results = false
this.emitter('scan_complete', scanData)
SocketAuthority.emitter('scan_complete', scanData)
this.librariesScanning = this.librariesScanning.filter(ls => ls.id !== library.id)
return
}
}
this.librariesScanning = this.librariesScanning.filter(ls => ls.id !== library.id)
this.emitter('scan_complete', libraryScan.getScanEmitData)
SocketAuthority.emitter('scan_complete', libraryScan.getScanEmitData)
}
probeAudioFileWithTone(audioFile) {

View file

@ -183,16 +183,19 @@ module.exports.sanitizeFilename = (filename, colonReplacement = ' - ') => {
return false
}
// Max is actually 255-260 for windows but this leaves padding incase ext wasnt put on yet
const MAX_FILENAME_LEN = 240
// Most file systems use number of bytes for max filename
// to support most filesystems we will use max of 255 bytes in utf-16
// Ref: https://doc.owncloud.com/server/next/admin_manual/troubleshooting/path_filename_length.html
// Issue: https://github.com/advplyr/audiobookshelf/issues/1261
const MAX_FILENAME_BYTES = 255
var replacement = ''
var illegalRe = /[\/\?<>\\:\*\|"]/g
var controlRe = /[\x00-\x1f\x80-\x9f]/g
var reservedRe = /^\.+$/
var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i
var windowsTrailingRe = /[\. ]+$/
var lineBreaks = /[\n\r]/g
const replacement = ''
const illegalRe = /[\/\?<>\\:\*\|"]/g
const controlRe = /[\x00-\x1f\x80-\x9f]/g
const reservedRe = /^\.+$/
const windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i
const windowsTrailingRe = /[\. ]+$/
const lineBreaks = /[\n\r]/g
sanitized = filename
.replace(':', colonReplacement) // Replace first occurrence of a colon
@ -203,12 +206,25 @@ module.exports.sanitizeFilename = (filename, colonReplacement = ' - ') => {
.replace(windowsReservedRe, replacement)
.replace(windowsTrailingRe, replacement)
if (sanitized.length > MAX_FILENAME_LEN) {
var lenToRemove = sanitized.length - MAX_FILENAME_LEN
var ext = Path.extname(sanitized)
var basename = Path.basename(sanitized, ext)
basename = basename.slice(0, basename.length - lenToRemove)
sanitized = basename + ext
// Check if basename is too many bytes
const ext = Path.extname(sanitized) // separate out file extension
const basename = Path.basename(sanitized, ext)
const extByteLength = Buffer.byteLength(ext, 'utf16le')
const basenameByteLength = Buffer.byteLength(basename, 'utf16le')
if (basenameByteLength + extByteLength > MAX_FILENAME_BYTES) {
const MaxBytesForBasename = MAX_FILENAME_BYTES - extByteLength
let totalBytes = 0
let trimmedBasename = ''
// Add chars until max bytes is reached
for (const char of basename) {
totalBytes += Buffer.byteLength(char, 'utf16le')
if (totalBytes > MaxBytesForBasename) break
else trimmedBasename += char
}
trimmedBasename = trimmedBasename.trim()
sanitized = trimmedBasename + ext
}
return sanitized

View file

@ -140,14 +140,24 @@ module.exports.cleanStringForSearch = (str) => {
return str.toLowerCase().replace(/[\'\.\`\",]/g, '').trim()
}
module.exports.getTitleIgnorePrefix = (title) => {
if (!title) return ''
const getTitleParts = (title) => {
if (!title) return ['', null]
var prefixesToIgnore = global.ServerSettings.sortingPrefixes || []
prefixes = []
for (const prefix of prefixesToIgnore) {
// e.g. for prefix "the". If title is "The Book" return "Book, The"
if (title.toLowerCase().startsWith(`${prefix} `)) {
return title.substr(prefix.length + 1) + `, ${prefix.substr(0, 1).toUpperCase() + prefix.substr(1)}`
return [title.substr(prefix.length + 1), `${prefix.substr(0, 1).toUpperCase() + prefix.substr(1)}`]
}
}
return title
return [title, null]
}
module.exports.getTitleIgnorePrefix = (title) => {
return getTitleParts(title)[0]
}
module.exports.getTitlePrefixAtEnd = (title) => {
let [sort, prefix] = getTitleParts(title)
return prefix ? `${sort}, ${prefix}` : title
}

View file

@ -1,5 +1,5 @@
const { sort, createNewSortInstance } = require('../libs/fastSort')
const { getTitleIgnorePrefix, isNullOrNaN } = require('../utils/index')
const { getTitlePrefixAtEnd, isNullOrNaN, getTitleIgnorePrefix } = require('../utils/index')
const naturalSort = createNewSortInstance({
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
})
@ -10,53 +10,57 @@ module.exports = {
},
getFilteredLibraryItems(libraryItems, filterBy, user, feedsArray) {
var filtered = libraryItems
let filtered = libraryItems
var searchGroups = ['genres', 'tags', 'series', 'authors', 'progress', 'narrators', 'missing', 'languages']
var group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
const searchGroups = ['genres', 'tags', 'series', 'authors', 'progress', 'narrators', 'missing', 'languages', 'tracks']
const group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
if (group) {
var filterVal = filterBy.replace(`${group}.`, '')
var filter = this.decode(filterVal)
const filterVal = filterBy.replace(`${group}.`, '')
const filter = this.decode(filterVal)
if (group === 'genres') filtered = filtered.filter(li => li.media.metadata && li.media.metadata.genres.includes(filter))
else if (group === 'tags') filtered = filtered.filter(li => li.media.tags.includes(filter))
else if (group === 'series') {
if (filter === 'No Series') filtered = filtered.filter(li => li.mediaType === 'book' && !li.media.metadata.series.length)
if (filter === 'no-series') filtered = filtered.filter(li => li.isBook && !li.media.metadata.series.length)
else {
filtered = filtered.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(filter))
filtered = filtered.filter(li => li.isBook && li.media.metadata.hasSeries(filter))
}
}
else if (group === 'authors') filtered = filtered.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(filter))
else if (group === 'narrators') filtered = filtered.filter(li => li.mediaType === 'book' && li.media.metadata.hasNarrator(filter))
else if (group === 'authors') filtered = filtered.filter(li => li.isBook && li.media.metadata.hasAuthor(filter))
else if (group === 'narrators') filtered = filtered.filter(li => li.isBook && li.media.metadata.hasNarrator(filter))
else if (group === 'progress') {
filtered = filtered.filter(li => {
var itemProgress = user.getMediaProgress(li.id)
if (filter === 'Finished' && (itemProgress && itemProgress.isFinished)) return true
if (filter === 'Not Started' && !itemProgress) return true
if (filter === 'Not Finished' && (!itemProgress || !itemProgress.isFinished)) return true
if (filter === 'In Progress' && (itemProgress && itemProgress.inProgress)) return true
const itemProgress = user.getMediaProgress(li.id)
if (filter === 'finished' && (itemProgress && itemProgress.isFinished)) return true
if (filter === 'not-started' && !itemProgress) return true
if (filter === 'not-finished' && (!itemProgress || !itemProgress.isFinished)) return true
if (filter === 'in-progress' && (itemProgress && itemProgress.inProgress)) return true
return false
})
} else if (group == 'missing') {
filtered = filtered.filter(li => {
if (li.mediaType === 'book') {
if (filter === 'ASIN' && li.media.metadata.asin === null) return true;
if (filter === 'ISBN' && li.media.metadata.isbn === null) return true;
if (filter === 'Subtitle' && li.media.metadata.subtitle === null) return true;
if (filter === 'Author' && li.media.metadata.authors.length === 0) return true;
if (filter === 'Publish Year' && li.media.metadata.publishedYear === null) return true;
if (filter === 'Series' && li.media.metadata.series.length === 0) return true;
if (filter === 'Description' && li.media.metadata.description === null) return true;
if (filter === 'Genres' && li.media.metadata.genres.length === 0) return true;
if (filter === 'Tags' && li.media.tags.length === 0) return true;
if (filter === 'Narrator' && li.media.metadata.narrators.length === 0) return true;
if (filter === 'Publisher' && li.media.metadata.publisher === null) return true;
if (filter === 'Language' && li.media.metadata.language === null) return true;
if (li.isBook) {
if (filter === 'asin' && !li.media.metadata.asin) return true
if (filter === 'isbn' && !li.media.metadata.isbn) return true
if (filter === 'subtitle' && !li.media.metadata.subtitle) return true
if (filter === 'authors' && !li.media.metadata.authors.length) return true
if (filter === 'publishedYear' && !li.media.metadata.publishedYear) return true
if (filter === 'series' && !li.media.metadata.series.length) return true
if (filter === 'description' && !li.media.metadata.description) return true
if (filter === 'genres' && !li.media.metadata.genres.length) return true
if (filter === 'tags' && !li.media.tags.length) return true
if (filter === 'narrators' && !li.media.metadata.narrators.length) return true
if (filter === 'publisher' && !li.media.metadata.publisher) return true
if (filter === 'language' && !li.media.metadata.language) return true
if (filter === 'cover' && !li.media.coverPath) return true
} else {
return false
}
})
} else if (group === 'languages') {
filtered = filtered.filter(li => li.media.metadata && li.media.metadata.language === filter)
} else if (group === 'tracks') {
if (filter === 'single') filtered = filtered.filter(li => li.isBook && li.media.numTracks === 1)
else if (filter === 'multi') filtered = filtered.filter(li => li.isBook && li.media.numTracks > 1)
}
} else if (filterBy === 'issues') {
filtered = filtered.filter(li => li.hasIssues)
@ -153,7 +157,7 @@ module.exports = {
return data
},
getSeriesFromBooks(books, allSeries, filterBy, user, minified = false) {
getSeriesFromBooks(books, allSeries, filterSeries, filterBy, user, minified = false) {
const _series = {}
const seriesToFilterOut = {}
books.forEach((libraryItem) => {
@ -179,11 +183,15 @@ module.exports = {
const abJson = minified ? libraryItem.toJSONMinified() : libraryItem.toJSONExpanded()
abJson.sequence = bookSeriesObj.sequence
if (filterSeries) {
abJson.filterSeriesSequence = libraryItem.media.metadata.getSeries(filterSeries).sequence
}
if (!_series[bookSeriesObj.id]) {
_series[bookSeriesObj.id] = {
id: bookSeriesObj.id,
name: bookSeriesObj.name,
nameIgnorePrefix: getTitleIgnorePrefix(bookSeriesObj.name),
nameIgnorePrefix: getTitlePrefixAtEnd(bookSeriesObj.name),
nameIgnorePrefixSort: getTitleIgnorePrefix(bookSeriesObj.name),
type: 'series',
books: [abJson],
addedAt: series ? series.addedAt : 0,
@ -279,35 +287,34 @@ module.exports = {
return totalSize
},
collapseBookSeries(libraryItems, series) {
var seriesObjects = this.getSeriesFromBooks(libraryItems, series, null, null, true)
var seriesToUse = {}
var libraryItemIdsToHide = []
seriesObjects.forEach((series) => {
series.firstBook = series.books.find(b => !seriesToUse[b.id]) // Find first book not already used
if (series.firstBook) {
seriesToUse[series.firstBook.id] = series
libraryItemIdsToHide = libraryItemIdsToHide.concat(series.books.filter(b => !seriesToUse[b.id]).map(b => b.id))
}
})
return libraryItems.map((li) => {
collapseBookSeries(libraryItems, series, filterSeries) {
// Get series from the library items. If this list is being collapsed after filtering for a series,
// don't collapse that series, only books that are in other series.
var seriesObjects = this
.getSeriesFromBooks(libraryItems, series, filterSeries, null, null, true)
.filter(s => s.id != filterSeries)
var filteredLibraryItems = []
libraryItems.forEach((li) => {
if (li.mediaType != 'book') return
var libraryItemJson = li.toJSONMinified()
if (libraryItemIdsToHide.includes(li.id)) {
return null
}
if (seriesToUse[li.id]) {
libraryItemJson.collapsedSeries = {
id: seriesToUse[li.id].id,
name: seriesToUse[li.id].name,
nameIgnorePrefix: seriesToUse[li.id].nameIgnorePrefix,
libraryItemIds: seriesToUse[li.id].books.map(b => b.id),
numBooks: seriesToUse[li.id].books.length
}
}
return libraryItemJson
}).filter(li => li)
// Handle when this is the first book in a series
seriesObjects.filter(s => s.books[0].id == li.id).forEach(series => {
// Clone the library item as we need to attach data to it, but don't
// want to change the global copy of the library item
filteredLibraryItems.push(Object.assign(
Object.create(Object.getPrototypeOf(li)),
li, { collapsedSeries: series }))
});
// Only included books not contained in series
if (!seriesObjects.some(s => s.books.some(b => b.id == li.id)))
filteredLibraryItems.push(li)
});
return filteredLibraryItems
},
buildPersonalizedShelves(user, libraryItems, mediaType, allSeries, allAuthors, maxEntitiesPerShelf = 10) {
@ -317,6 +324,7 @@ module.exports = {
{
id: 'continue-listening',
label: 'Continue Listening',
labelStringKey: 'LabelContinueListening',
type: isPodcastLibrary ? 'episode' : mediaType,
entities: [],
category: 'recentlyListened'
@ -324,6 +332,7 @@ module.exports = {
{
id: 'continue-series',
label: 'Continue Series',
labelStringKey: 'LabelContinueSeries',
type: mediaType,
entities: [],
category: 'continueSeries'
@ -331,6 +340,7 @@ module.exports = {
{
id: 'recently-added',
label: 'Recently Added',
labelStringKey: 'LabelRecentlyAdded',
type: mediaType,
entities: [],
category: 'newestItems'
@ -338,6 +348,7 @@ module.exports = {
{
id: 'listen-again',
label: 'Listen Again',
labelStringKey: 'LabelListenAgain',
type: isPodcastLibrary ? 'episode' : mediaType,
entities: [],
category: 'recentlyFinished'
@ -345,6 +356,7 @@ module.exports = {
{
id: 'recent-series',
label: 'Recent Series',
labelStringKey: 'LabelRecentSeries',
type: 'series',
entities: [],
category: 'newestSeries'
@ -352,6 +364,7 @@ module.exports = {
{
id: 'newest-authors',
label: 'Newest Authors',
labelStringKey: 'LabelNewestAuthors',
type: 'authors',
entities: [],
category: 'newestAuthors'
@ -359,6 +372,7 @@ module.exports = {
{
id: 'episodes-recently-added',
label: 'Newest Episodes',
labelStringKey: 'LabelNewestEpisodes',
type: 'episode',
entities: [],
category: 'newestEpisodes'

View file

@ -96,13 +96,11 @@ function extractEpisodeData(item) {
}
if (item['pubDate']) {
if (typeof item['pubDate'] === 'string') {
episode.pubDate = item['pubDate']
} else if (Array.isArray(item['pubDate'])) {
const pubDateObj = extractFirstArrayItem(item, 'pubDate')
if (pubDateObj && typeof pubDateObj._ === 'string') {
episode.pubDate = pubDateObj._
}
const pubDate = extractFirstArrayItem(item, 'pubDate')
if (typeof pubDate === 'string') {
episode.pubDate = pubDate
} else if (pubDate && typeof pubDate._ === 'string') {
episode.pubDate = pubDate._
} else {
Logger.error(`[podcastUtils] Invalid pubDate ${item['pubDate']} for ${episode.enclosure.url}`)
}

View file

@ -72,6 +72,70 @@ module.exports.getToneMetadataObject = (libraryItem, chaptersFile) => {
return metadataObject
}
module.exports.writeToneMetadataJsonFile = (libraryItem, chapters, filePath, trackTotal) => {
const bookMetadata = libraryItem.media.metadata
const coverPath = libraryItem.media.coverPath
const metadataObject = {
'album': bookMetadata.title || '',
'title': bookMetadata.title || '',
'trackTotal': trackTotal,
'additionalFields': {}
}
if (bookMetadata.subtitle) {
metadataObject['subtitle'] = bookMetadata.subtitle
}
if (bookMetadata.authorName) {
metadataObject['artist'] = bookMetadata.authorName
metadataObject['albumArtist'] = bookMetadata.authorName
}
if (bookMetadata.description) {
metadataObject['comment'] = bookMetadata.description
metadataObject['description'] = bookMetadata.description
}
if (bookMetadata.narratorName) {
metadataObject['narrator'] = bookMetadata.narratorName
metadataObject['composer'] = bookMetadata.narratorName
}
if (bookMetadata.firstSeriesName) {
metadataObject['movementName'] = bookMetadata.firstSeriesName
}
if (bookMetadata.firstSeriesSequence) {
metadataObject['movement'] = bookMetadata.firstSeriesSequence
}
if (bookMetadata.genres.length) {
metadataObject['genre'] = bookMetadata.genres.join('/')
}
if (bookMetadata.publisher) {
metadataObject['publisher'] = bookMetadata.publisher
}
if (bookMetadata.asin) {
metadataObject.additionalFields['asin'] = bookMetadata.asin
}
if (bookMetadata.isbn) {
metadataObject.additionalFields['isbn'] = bookMetadata.isbn
}
if (coverPath) {
metadataObject['coverFile'] = coverPath
}
if (parsePublishedYear(bookMetadata.publishedYear)) {
metadataObject['publishingDate'] = parsePublishedYear(bookMetadata.publishedYear)
}
if (chapters && chapters.length > 0) {
let metadataChapters = []
for (const chapter of chapters) {
metadataChapters.push({
start: Math.round(chapter.start * 1000),
length: Math.round((chapter.end - chapter.start) * 1000),
title: chapter.title,
})
}
metadataObject['chapters'] = metadataChapters
}
return fs.writeFile(filePath, JSON.stringify({ meta: metadataObject }, null, 2))
}
module.exports.tagAudioFile = (filePath, payload) => {
return tone.tag(filePath, payload).then((data) => {
return true