This commit is contained in:
advplyr 2021-08-17 17:01:11 -05:00
commit 6930e69b55
106 changed files with 26925 additions and 0 deletions

143
server/ApiController.js Normal file
View file

@ -0,0 +1,143 @@
const express = require('express')
const Logger = require('./Logger')
class ApiController {
constructor(db, scanner, auth, streamManager, emitter) {
this.db = db
this.scanner = scanner
this.auth = auth
this.streamManager = streamManager
this.emitter = emitter
this.router = express()
this.init()
}
init() {
this.router.get('/find/:method', this.find.bind(this))
this.router.get('/audiobooks', this.getAudiobooks.bind(this))
this.router.get('/audiobook/:id', this.getAudiobook.bind(this))
this.router.delete('/audiobook/:id', this.deleteAudiobook.bind(this))
this.router.patch('/audiobook/:id/tracks', this.updateAudiobookTracks.bind(this))
this.router.patch('/audiobook/:id', this.updateAudiobook.bind(this))
this.router.get('/metadata/:id/:trackIndex', this.getMetadata.bind(this))
this.router.patch('/match/:id', this.match.bind(this))
this.router.delete('/user/audiobook/:id', this.resetUserAudiobookProgress.bind(this))
this.router.post('/authorize', this.authorize.bind(this))
}
find(req, res) {
this.scanner.find(req, res)
}
async getMetadata(req, res) {
var metadata = await this.scanner.fetchMetadata(req.params.id, req.params.trackIndex)
res.json(metadata)
}
authorize(req, res) {
if (!req.user) {
Logger.error('Invalid user in authorize')
return res.sendStatus(401)
}
res.json({ user: req.user })
}
getAudiobooks(req, res) {
Logger.info('Get Audiobooks')
var audiobooksMinified = this.db.audiobooks.map(ab => ab.toJSONMinified())
res.json(audiobooksMinified)
}
getAudiobook(req, res) {
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
if (!audiobook) return res.sendStatus(404)
res.json(audiobook.toJSONExpanded())
}
async deleteAudiobook(req, res) {
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
if (!audiobook) return res.sendStatus(404)
// Remove audiobook from users
for (let i = 0; i < this.db.users.length; i++) {
var user = this.db.users[i]
var madeUpdates = user.resetAudiobookProgress(audiobook.id)
if (madeUpdates) {
await this.db.updateEntity('user', user)
}
}
// remove any streams open for this audiobook
var streams = this.streamManager.streams.filter(stream => stream.audiobookId === audiobook.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)
}
}
await this.db.removeEntity('audiobook', audiobook.id)
this.emitter('audiobook_removed', audiobook.toJSONMinified())
res.sendStatus(200)
}
async updateAudiobookTracks(req, res) {
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
if (!audiobook) return res.sendStatus(404)
var files = req.body.files
Logger.info(`Updating audiobook tracks called ${audiobook.id}`)
audiobook.updateAudioTracks(files)
await this.db.updateAudiobook(audiobook)
this.emitter('audiobook_updated', audiobook.toJSONMinified())
res.json(audiobook.toJSON())
}
async updateAudiobook(req, res) {
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
if (!audiobook) return res.sendStatus(404)
var hasUpdates = audiobook.update(req.body)
if (hasUpdates) {
await this.db.updateAudiobook(audiobook)
}
this.emitter('audiobook_updated', audiobook.toJSONMinified())
res.json(audiobook.toJSON())
}
async match(req, res) {
var body = req.body
var audiobookId = req.params.id
var audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId)
var bookData = {
olid: body.id,
publish_year: body.first_publish_year,
description: body.description,
title: body.title,
author: body.author,
cover: body.cover
}
audiobook.setBook(bookData)
await this.db.updateAudiobook(audiobook)
this.emitter('audiobook_updated', audiobook.toJSONMinified())
res.sendStatus(200)
}
async resetUserAudiobookProgress(req, res) {
req.user.resetAudiobookProgress(req.params.id)
await this.db.updateEntity('user', req.user)
this.emitter('user_updated', req.user.toJSONForBrowser())
res.sendStatus(200)
}
}
module.exports = ApiController

96
server/AudioTrack.js Normal file
View file

@ -0,0 +1,96 @@
var { bytesPretty } = require('./utils/fileUtils')
class AudioTrack {
constructor(audioTrack = null) {
this.index = null
this.path = null
this.fullPath = null
this.ext = null
this.filename = null
this.format = null
this.duration = null
this.size = null
this.bitRate = null
this.language = null
this.codec = null
this.timeBase = null
this.channels = null
this.channelLayout = null
this.tagAlbum = null
this.tagArtist = null
this.tagGenre = null
this.tagTitle = null
this.tagTrack = null
if (audioTrack) {
this.construct(audioTrack)
}
}
construct(audioTrack) {
this.index = audioTrack.index
this.path = audioTrack.path
this.fullPath = audioTrack.fullPath
this.ext = audioTrack.ext
this.filename = audioTrack.filename
this.format = audioTrack.format
this.duration = audioTrack.duration
this.size = audioTrack.size
this.bitRate = audioTrack.bitRate
this.language = audioTrack.language
this.codec = audioTrack.codec
this.timeBase = audioTrack.timeBase
this.channels = audioTrack.channels
this.channelLayout = audioTrack.channelLayout
}
get name() {
return `${String(this.index).padStart(3, '0')}: ${this.filename} (${bytesPretty(this.size)}) [${this.duration}]`
}
toJSON() {
return {
index: this.index,
path: this.path,
fullPath: this.fullPath,
ext: this.ext,
filename: this.filename,
format: this.format,
duration: this.duration,
size: this.size,
bitRate: this.bitRate,
language: this.language,
timeBase: this.timeBase,
channels: this.channels,
channelLayout: this.channelLayout
}
}
setData(probeData) {
this.index = probeData.index
this.path = probeData.path
this.fullPath = probeData.fullPath
this.ext = probeData.ext
this.filename = probeData.filename
this.format = probeData.format
this.duration = probeData.duration
this.size = probeData.size
this.bitRate = probeData.bit_rate
this.language = probeData.language
this.codec = probeData.codec
this.timeBase = probeData.time_base
this.channels = probeData.channels
this.channelLayout = probeData.channel_layout
this.tagAlbum = probeData.file_tag_album || null
this.tagArtist = probeData.file_tag_artist || null
this.tagGenre = probeData.file_tag_genre || null
this.tagTitle = probeData.file_tag_title || null
this.tagTrack = probeData.file_tag_track || null
}
}
module.exports = AudioTrack

212
server/Audiobook.js Normal file
View file

@ -0,0 +1,212 @@
const { bytesPretty, elapsedPretty } = require('./utils/fileUtils')
const Book = require('./Book')
const AudioTrack = require('./AudioTrack')
class Audiobook {
constructor(audiobook = null) {
this.id = null
this.path = null
this.fullPath = null
this.addedAt = null
this.tracks = []
this.missingParts = []
this.invalidParts = []
this.audioFiles = []
this.ebookFiles = []
this.otherFiles = []
this.tags = []
this.book = null
if (audiobook) {
this.construct(audiobook)
}
}
construct(audiobook) {
this.id = audiobook.id
this.path = audiobook.path
this.fullPath = audiobook.fullPath
this.addedAt = audiobook.addedAt
this.tracks = audiobook.tracks.map(track => {
return new AudioTrack(track)
})
this.missingParts = audiobook.missingParts
this.invalidParts = audiobook.invalidParts
this.audioFiles = audiobook.audioFiles
this.ebookFiles = audiobook.ebookFiles
this.otherFiles = audiobook.otherFiles
this.tags = audiobook.tags
if (audiobook.book) {
this.book = new Book(audiobook.book)
}
}
get title() {
return this.book ? this.book.title : 'No Title'
}
get cover() {
return this.book ? this.book.cover : ''
}
get author() {
return this.book ? this.book.author : 'Unknown'
}
get totalDuration() {
var total = 0
this.tracks.forEach((track) => total += track.duration)
return total
}
get totalSize() {
var total = 0
this.tracks.forEach((track) => total += track.size)
return total
}
get sizePretty() {
return bytesPretty(this.totalSize)
}
get durationPretty() {
return elapsedPretty(this.totalDuration)
}
bookToJSON() {
return this.book ? this.book.toJSON() : null
}
tracksToJSON() {
if (!this.tracks || !this.tracks.length) return []
return this.tracks.map(t => t.toJSON())
}
toJSON() {
return {
id: this.id,
title: this.title,
author: this.author,
cover: this.cover,
path: this.path,
fullPath: this.fullPath,
addedAt: this.addedAt,
missingParts: this.missingParts,
invalidParts: this.invalidParts,
tags: this.tags,
book: this.bookToJSON(),
tracks: this.tracksToJSON(),
audioFiles: this.audioFiles,
ebookFiles: this.ebookFiles,
otherFiles: this.otherFiles
}
}
toJSONMinified() {
return {
id: this.id,
book: this.bookToJSON(),
tags: this.tags,
path: this.path,
fullPath: this.fullPath,
addedAt: this.addedAt,
duration: this.totalDuration,
size: this.totalSize,
hasBookMatch: !!this.book,
hasMissingParts: this.missingParts ? this.missingParts.length : 0,
hasInvalidParts: this.invalidParts ? this.invalidParts.length : 0,
numTracks: this.tracks.length
}
}
toJSONExpanded() {
return {
id: this.id,
title: this.title,
author: this.author,
cover: this.cover,
path: this.path,
fullPath: this.fullPath,
addedAt: this.addedAt,
duration: this.totalDuration,
durationPretty: this.durationPretty,
size: this.totalSize,
sizePretty: this.sizePretty,
missingParts: this.missingParts,
invalidParts: this.invalidParts,
audioFiles: this.audioFiles,
ebookFiles: this.ebookFiles,
otherFiles: this.otherFiles,
tags: this.tags,
book: this.bookToJSON(),
tracks: this.tracksToJSON()
}
}
setData(data) {
this.id = (Math.trunc(Math.random() * 1000) + Date.now()).toString(36)
this.path = data.path
this.fullPath = data.fullPath
this.addedAt = Date.now()
this.otherFiles = data.otherFiles || []
this.ebookFiles = data.ebooks || []
this.setBook(data)
}
setBook(data) {
this.book = new Book()
this.book.setData(data)
}
addTrack(trackData) {
var track = new AudioTrack()
track.setData(trackData)
this.tracks.push(track)
return track
}
update(payload) {
var hasUpdates = false
if (payload.tags && payload.tags.join(',') !== this.tags.join(',')) {
this.tags = payload.tags
hasUpdates = true
}
if (payload.book) {
if (!this.book) {
this.setBook(payload.book)
hasUpdates = true
} else if (this.book.update(payload.book)) {
hasUpdates = true
}
}
return hasUpdates
}
updateAudioTracks(files) {
var index = 1
this.audioFiles = files.map((file) => {
file.manuallyVerified = true
file.invalid = false
file.error = null
file.index = index++
return file
})
this.tracks = []
this.invalidParts = []
this.missingParts = []
this.audioFiles.forEach((file) => {
this.addTrack(file)
})
}
}
module.exports = Audiobook

192
server/Auth.js Normal file
View file

@ -0,0 +1,192 @@
const bcrypt = require('bcryptjs')
const jwt = require('jsonwebtoken')
const Logger = require('./Logger')
class Auth {
constructor(db) {
this.db = db
this.user = null
}
get username() {
return this.user ? this.user.username : 'nobody'
}
get users() {
return this.db.users
}
init() {
var root = this.users.find(u => u.type === 'root')
if (!root) {
Logger.fatal('No Root User', this.users)
throw new Error('No Root User')
}
}
cors(req, res, next) {
res.header('Access-Control-Allow-Origin', '*')
res.header("Access-Control-Allow-Methods", 'GET, POST, PATCH, PUT, DELETE, OPTIONS')
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization")
res.header('Access-Control-Allow-Credentials', true)
if (req.method === 'OPTIONS') {
res.sendStatus(200)
} else {
next()
}
}
async authMiddleware(req, res, next) {
const authHeader = req.headers['authorization']
const token = authHeader && authHeader.split(' ')[1]
if (token == null) {
Logger.error('Api called without a token')
return res.sendStatus(401)
}
var user = await this.verifyToken(token)
if (!user) {
Logger.error('Verify Token User Not Found', token)
return res.sendStatus(403)
}
req.user = user
next()
}
hashPass(password) {
return new Promise((resolve) => {
bcrypt.hash(password, 8, (err, hash) => {
if (err) {
Logger.error('Hash failed', err)
resolve(null)
} else {
resolve(hash)
}
})
})
}
async getAuth(req) {
if (req.signedCookies.user) {
var user = this.users.find(u => u.username = req.signedCookies.user)
if (user) {
delete user.pash
}
return user
} else {
return false
}
}
generateAccessToken(payload) {
return jwt.sign(payload, process.env.TOKEN_SECRET, { expiresIn: '1800s' });
}
verifyToken(token) {
return new Promise((resolve) => {
jwt.verify(token, process.env.TOKEN_SECRET, (err, payload) => {
var user = this.users.find(u => u.id === payload.userId)
resolve(user || null)
})
})
}
async login(req, res) {
var username = req.body.username
var password = req.body.password || ''
Logger.debug('Check Auth', username, !!password)
var user = this.users.find(u => u.id === username)
if (!user) {
return res.json({ error: 'User not found' })
}
// Check passwordless root user
if (user.id === 'root' && (!user.pash || user.pash === '')) {
if (password) {
return res.json({ error: 'Invalid root password (hint: there is none)' })
} else {
return res.json({ user: user.toJSONForBrowser() })
}
}
// Check password match
var compare = await bcrypt.compare(password, user.pash)
if (compare) {
res.json({
user: user.toJSONForBrowser()
})
} else {
res.json({
error: 'Invalid Password'
})
}
}
async checkAuth(req, res) {
var username = req.body.username
Logger.debug('Check Auth', username, !!req.body.password)
var matchingUser = this.users.find(u => u.username === username)
if (!matchingUser) {
return res.json({
error: 'User not found'
})
}
var cleanedUser = { ...matchingUser }
delete cleanedUser.pash
// check for empty password (default)
if (!req.body.password) {
if (!matchingUser.pash) {
res.cookie('user', username, { signed: true })
return res.json({
user: cleanedUser
})
} else {
return res.json({
error: 'Invalid Password'
})
}
}
// Set root password first time
if (matchingUser.type === 'root' && !matchingUser.pash && req.body.password && req.body.password.length > 1) {
console.log('Set root pash')
var pw = await this.hashPass(req.body.password)
if (!pw) {
return res.json({
error: 'Hash failed'
})
}
this.users = this.users.map(u => {
if (u.username === matchingUser.username) {
u.pash = pw
}
return u
})
await this.saveAuthDb()
return res.json({
setroot: true,
user: cleanedUser
})
}
var compare = await bcrypt.compare(req.body.password, matchingUser.pash)
if (compare) {
res.cookie('user', username, { signed: true })
res.json({
user: cleanedUser
})
} else {
res.json({
error: 'Invalid Password'
})
}
}
}
module.exports = Auth

72
server/Book.js Normal file
View file

@ -0,0 +1,72 @@
class Book {
constructor(book = null) {
this.olid = null
this.title = null
this.author = null
this.publishYear = null
this.publisher = null
this.description = null
this.cover = null
this.genres = []
if (book) {
this.construct(book)
}
}
construct(book) {
this.olid = book.olid
this.title = book.title
this.author = book.author
this.publishYear = book.publish_year
this.publisher = book.publisher
this.description = book.description
this.cover = book.cover
this.genres = book.genres
}
toJSON() {
return {
olid: this.olid,
title: this.title,
author: this.author,
publishYear: this.publish_year,
publisher: this.publisher,
description: this.description,
cover: this.cover,
genres: this.genres
}
}
setData(data) {
this.olid = data.olid || null
this.title = data.title || null
this.author = data.author || null
this.publishYear = data.publish_year || null
this.description = data.description || null
this.cover = data.cover || null
this.genres = data.genres || []
}
update(payload) {
var hasUpdates = false
for (const key in payload) {
if (payload[key] === undefined) continue;
if (key === 'genres') {
if (payload['genres'] === null && this.genres !== null) {
this.genres = []
hasUpdates = true
} else if (payload['genres'].join(',') !== this.genres.join(',')) {
this.genres = payload['genres']
hasUpdates = true
}
} else if (this[key] !== undefined && payload[key] !== this[key]) {
this[key] = payload[key]
hasUpdates = true
}
}
return true
}
}
module.exports = Book

33
server/BookFinder.js Normal file
View file

@ -0,0 +1,33 @@
const OpenLibrary = require('./providers/OpenLibrary')
const LibGen = require('./providers/LibGen')
class BookFinder {
constructor() {
this.openLibrary = new OpenLibrary()
this.libGen = new LibGen()
}
async findByISBN(isbn) {
var book = await this.openLibrary.isbnLookup(isbn)
if (book.errorCode) {
console.error('Book not found')
}
return book
}
async search(query, provider = 'openlibrary') {
var books = null
if (provider === 'libgen') {
books = await this.libGen.search(query)
return books
}
books = await this.openLibrary.search(query)
if (books.errorCode) {
console.error('Books not found')
}
return books
}
}
module.exports = BookFinder

158
server/Db.js Normal file
View file

@ -0,0 +1,158 @@
const fs = require('fs-extra')
const Path = require('path')
const njodb = require("njodb")
const jwt = require('jsonwebtoken')
const Logger = require('./Logger')
const Audiobook = require('./Audiobook')
const User = require('./User')
class Db {
constructor(CONFIG_PATH) {
this.ConfigPath = CONFIG_PATH
this.AudiobooksPath = Path.join(CONFIG_PATH, 'audiobooks')
this.UsersPath = Path.join(CONFIG_PATH, 'users')
this.SettingsPath = Path.join(CONFIG_PATH, 'settings')
this.audiobooksDb = new njodb.Database(this.AudiobooksPath)
this.usersDb = new njodb.Database(this.UsersPath)
this.settingsDb = new njodb.Database(this.SettingsPath, { datastores: 2 })
this.users = []
this.audiobooks = []
this.settings = []
}
getEntityDb(entityName) {
if (entityName === 'user') return this.usersDb
else if (entityName === 'audiobook') return this.audiobooksDb
return this.settingsDb
}
getEntityArrayKey(entityName) {
if (entityName === 'user') return 'users'
else if (entityName === 'audiobook') return 'audiobooks'
return 'settings'
}
getDefaultSettings() {
return {
config: {
version: 1,
cardSize: 'md'
}
}
}
getDefaultUser(token) {
return new User({
id: 'root',
type: 'root',
username: 'root',
pash: '',
stream: null,
token,
createdAt: Date.now()
})
}
async init() {
await this.load()
// Insert Defaults
if (!this.users.find(u => u.type === 'root')) {
var token = await jwt.sign({ userId: 'root' }, process.env.TOKEN_SECRET)
Logger.debug('Generated default token', token)
await this.insertUser(this.getDefaultUser(token))
}
}
async load() {
var p1 = this.audiobooksDb.select(() => true).then((results) => {
this.audiobooks = results.data.map(a => new Audiobook(a))
Logger.info(`Audiobooks Loaded ${this.audiobooks.length}`)
})
var p2 = this.usersDb.select(() => true).then((results) => {
this.users = results.data.map(u => new User(u))
Logger.info(`Users Loaded ${this.users.length}`)
})
var p3 = this.settingsDb.select(() => true).then((results) => {
this.settings = results
})
await Promise.all([p1, p2, p3])
}
insertAudiobook(audiobook) {
return this.insertAudiobooks([audiobook])
}
insertAudiobooks(audiobooks) {
return this.audiobooksDb.insert(audiobooks).then((results) => {
Logger.debug(`[DB] Inserted ${results.inserted} audiobooks`)
this.audiobooks = this.audiobooks.concat(audiobooks)
}).catch((error) => {
Logger.error(`[DB] Insert audiobooks Failed ${error}`)
})
}
updateAudiobook(audiobook) {
return this.audiobooksDb.update((record) => record.id === audiobook.id, () => audiobook).then((results) => {
Logger.debug(`[DB] Audiobook updated ${results.updated}`)
}).catch((error) => {
Logger.error(`[DB] Audiobook update failed ${error}`)
})
}
insertUser(user) {
return this.usersDb.insert([user]).then((results) => {
Logger.debug(`[DB] Inserted user ${results.inserted}`)
this.users.push(user)
}).catch((error) => {
Logger.error(`[DB] Insert user Failed ${error}`)
})
}
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}`)
})
}
updateEntity(entityName, entity) {
var entityDb = this.getEntityDb(entityName)
return entityDb.update((record) => record.id === entity.id, () => entity).then((results) => {
Logger.debug(`[DB] Updated entity ${entityName}: ${results.updated}`)
var arrayKey = this.getEntityArrayKey(entityName)
this[arrayKey] = this[arrayKey].map(e => {
return e.id === entity.id ? entity : e
})
}).catch((error) => {
Logger.error(`[DB] Update entity ${entityName} Failed: ${error}`)
})
}
removeEntity(entityName, entityId) {
var entityDb = this.getEntityDb(entityName)
return entityDb.delete((record) => record.id === entityId).then((results) => {
Logger.debug(`[DB] Deleted entity ${entityName}: ${results.deleted}`)
var arrayKey = this.getEntityArrayKey(entityName)
this[arrayKey] = this[arrayKey].filter(e => {
return e.id !== entityId
})
}).catch((error) => {
Logger.error(`[DB] Remove entity ${entityName} Failed: ${error}`)
})
}
}
module.exports = Db

90
server/HlsController.js Normal file
View file

@ -0,0 +1,90 @@
const express = require('express')
const Path = require('path')
const fs = require('fs-extra')
const Logger = require('./Logger')
class HlsController {
constructor(db, scanner, auth, streamManager, emitter, MetadataPath) {
this.db = db
this.scanner = scanner
this.auth = auth
this.streamManager = streamManager
this.emitter = emitter
this.MetadataPath = MetadataPath
this.router = express()
this.init()
}
init() {
this.router.get('/:stream/:file', this.streamFileRequest.bind(this))
}
parseSegmentFilename(filename) {
var basename = Path.basename(filename, '.ts')
var num_part = basename.split('-')[1]
return Number(num_part)
}
async streamFileRequest(req, res) {
var streamId = req.params.stream
// Logger.info('Got hls request', streamId, req.params.file)
var fullFilePath = Path.join(this.MetadataPath, streamId, req.params.file)
var exists = await fs.pathExists(fullFilePath)
if (!exists) {
Logger.error('File path does not exist', fullFilePath)
var fileExt = Path.extname(req.params.file)
if (fileExt === '.ts') {
var segNum = this.parseSegmentFilename(req.params.file)
var stream = this.streamManager.getStream(streamId)
if (!stream) {
Logger.error(`[HLS-CONTROLLER] Stream ${streamId} does not exist`)
return res.sendStatus(500)
}
if (stream.isResetting) {
Logger.info(`[HLS-CONTROLLER] Stream ${streamId} is currently resetting`)
return res.sendStatus(404)
} else {
var startTimeForReset = await stream.checkSegmentNumberRequest(segNum)
if (startTimeForReset) {
// HLS.js should request the file again]
Logger.info(`[HLS-CONTROLLER] Resetting Stream - notify client @${startTimeForReset}s`)
this.emitter('stream_reset', {
startTime: startTimeForReset,
streamId: stream.id
})
return res.sendStatus(500)
// await new Promise((resolve) => {
// setTimeout(() => {
// console.log('Waited 4 seconds')
// resolve()
// }, 4000)
// })
// exists = await fs.pathExists(fullFilePath)
// if (!exists) {
// console.error('Still does not exist')
// return res.sendStatus(404)
// }
}
}
}
// await new Promise(resolve => setTimeout(resolve, 500))
// exists = await fs.pathExists(fullFilePath)
// Logger.info('Waited', exists)
// if (!exists) {
// Logger.error('still does not exist', fullFilePath)
// return res.sendStatus(404)
// }
}
// Logger.info('Sending file', fullFilePath)
res.sendFile(fullFilePath)
}
}
module.exports = HlsController

50
server/Logger.js Normal file
View file

@ -0,0 +1,50 @@
const LOG_LEVEL = {
TRACE: 0,
DEBUG: 1,
INFO: 2,
WARN: 3,
ERROR: 4,
FATAL: 5
}
class Logger {
constructor() {
let env_log_level = process.env.LOG_LEVEL || 'TRACE'
this.LogLevel = LOG_LEVEL[env_log_level] || LOG_LEVEL.TRACE
this.info(`Log Level: ${this.LogLevel}`)
}
get timestamp() {
return (new Date()).toISOString()
}
trace(...args) {
if (this.LogLevel > LOG_LEVEL.TRACE) return
console.trace(`[${this.timestamp}] TRACE:`, ...args)
}
debug(...args) {
if (this.LogLevel > LOG_LEVEL.DEBUG) return
console.debug(`[${this.timestamp}] DEBUG:`, ...args)
}
info(...args) {
if (this.LogLevel > LOG_LEVEL.INFO) return
console.info(`[${this.timestamp}] INFO:`, ...args)
}
warn(...args) {
if (this.LogLevel > LOG_LEVEL.WARN) return
console.warn(`[${this.timestamp}] WARN:`, ...args)
}
error(...args) {
if (this.LogLevel > LOG_LEVEL.ERROR) return
console.error(`[${this.timestamp}] ERROR:`, ...args)
}
fatal(...args) {
console.error(`[${this.timestamp}] FATAL:`, ...args)
}
}
module.exports = new Logger()

90
server/Scanner.js Normal file
View file

@ -0,0 +1,90 @@
const Logger = require('./Logger')
const BookFinder = require('./BookFinder')
const Audiobook = require('./Audiobook')
const audioFileScanner = require('./utils/audioFileScanner')
const { getAllAudiobookFiles } = require('./utils/scandir')
const { secondsToTimestamp } = require('./utils/fileUtils')
class Scanner {
constructor(AUDIOBOOK_PATH, METADATA_PATH, db, emitter) {
this.AudiobookPath = AUDIOBOOK_PATH
this.MetadataPath = METADATA_PATH
this.db = db
this.emitter = emitter
this.bookFinder = new BookFinder()
}
get audiobooks() {
return this.db.audiobooks
}
async scan() {
// console.log('Start scan audiobooks', this.audiobooks.map(a => a.fullPath).join(', '))
const scanStart = Date.now()
var audiobookDataFound = await getAllAudiobookFiles(this.AudiobookPath)
for (let i = 0; i < audiobookDataFound.length; i++) {
var audiobookData = audiobookDataFound[i]
if (!audiobookData.parts.length) {
Logger.error('No Valid Parts for Audiobook', audiobookData)
} else {
var existingAudiobook = this.audiobooks.find(a => a.fullPath === audiobookData.fullPath)
if (existingAudiobook) {
Logger.info('Audiobook already added', audiobookData.title)
// Todo: Update Audiobook here
} else {
// console.log('Audiobook not already there... add new audiobook', audiobookData.fullPath)
var audiobook = new Audiobook()
audiobook.setData(audiobookData)
await audioFileScanner.scanParts(audiobook, audiobookData.parts)
if (!audiobook.tracks.length) {
Logger.warn('Invalid audiobook, no valid tracks', audiobook.title)
} else {
Logger.info('Audiobook Scanned', audiobook.title, `(${audiobook.sizePretty}) [${audiobook.durationPretty}]`)
await this.db.insertAudiobook(audiobook)
this.emitter('audiobook_added', audiobook.toJSONMinified())
}
}
var progress = Math.round(100 * (i + 1) / audiobookDataFound.length)
this.emitter('scan_progress', {
total: audiobookDataFound.length,
done: i + 1,
progress
})
}
}
const scanElapsed = Math.floor((Date.now() - scanStart) / 1000)
Logger.info(`[SCANNER] Finished ${secondsToTimestamp(scanElapsed)}`)
}
async fetchMetadata(id, trackIndex = 0) {
var audiobook = this.audiobooks.find(a => a.id === id)
if (!audiobook) {
return false
}
var tracks = audiobook.tracks
var index = isNaN(trackIndex) ? 0 : Number(trackIndex)
var firstTrack = tracks[index]
var firstTrackFullPath = firstTrack.fullPath
var scanResult = await audioFileScanner.scan(firstTrackFullPath)
return scanResult
}
async find(req, res) {
var method = req.params.method
var query = req.query
var result = null
if (method === 'isbn') {
console.log('Search', query, 'via ISBN')
result = await this.bookFinder.findByISBN(query)
} else if (method === 'search') {
console.log('Search', query, 'via query')
result = await this.bookFinder.search(query)
}
res.json(result)
}
}
module.exports = Scanner

241
server/Server.js Normal file
View file

@ -0,0 +1,241 @@
const Path = require('path')
const express = require('express')
const http = require('http')
const SocketIO = require('socket.io')
const fs = require('fs-extra')
const cookieparser = require('cookie-parser')
const Auth = require('./Auth')
const Watcher = require('./Watcher')
const Scanner = require('./Scanner')
const Db = require('./Db')
const ApiController = require('./ApiController')
const HlsController = require('./HlsController')
const StreamManager = require('./StreamManager')
const Logger = require('./Logger')
const streamTest = require('./streamTest')
class Server {
constructor(PORT, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) {
this.Port = PORT
this.Host = '0.0.0.0'
this.ConfigPath = CONFIG_PATH
this.AudiobookPath = AUDIOBOOK_PATH
this.MetadataPath = METADATA_PATH
fs.ensureDirSync(CONFIG_PATH)
fs.ensureDirSync(METADATA_PATH)
fs.ensureDirSync(AUDIOBOOK_PATH)
this.db = new Db(this.ConfigPath)
this.auth = new Auth(this.db)
this.watcher = new Watcher(this.AudiobookPath)
this.scanner = new Scanner(this.AudiobookPath, this.MetadataPath, this.db, this.emitter.bind(this))
this.streamManager = new StreamManager(this.db, this.MetadataPath)
this.apiController = new ApiController(this.db, this.scanner, this.auth, this.streamManager, this.emitter.bind(this))
this.hlsController = new HlsController(this.db, this.scanner, this.auth, this.streamManager, this.emitter.bind(this), this.MetadataPath)
this.server = null
this.io = null
this.clients = {}
this.isScanning = false
this.isInitialized = false
}
get audiobooks() {
return this.db.audiobooks
}
get settings() {
return this.db.settings
}
emitter(ev, data) {
Logger.debug('EMITTER', ev)
if (!this.io) {
Logger.error('Invalid IO')
return
}
this.io.emit(ev, data)
}
async fileAddedUpdated({ path, fullPath }) {
Logger.info('[SERVER] FileAddedUpdated', path, fullPath)
}
async fileRemoved({ path, fullPath }) { }
async scan() {
Logger.info('[SERVER] Starting Scan')
this.isScanning = true
this.isInitialized = true
this.emitter('scan_start')
await this.scanner.scan()
this.isScanning = false
this.emitter('scan_complete')
Logger.info('[SERVER] Scan complete')
}
async init() {
Logger.info('[SERVER] Init')
await this.streamManager.removeOrphanStreams()
await this.db.init()
this.auth.init()
this.watcher.initWatcher()
this.watcher.on('file_added', this.fileAddedUpdated.bind(this))
this.watcher.on('file_removed', this.fileRemoved.bind(this))
this.watcher.on('file_updated', this.fileAddedUpdated.bind(this))
}
authMiddleware(req, res, next) {
this.auth.authMiddleware(req, res, next)
}
async start() {
Logger.info('=== Starting Server ===')
await this.init()
const app = express()
this.server = http.createServer(app)
app.use(cookieparser('secret_family_recipe'))
app.use(this.auth.cors)
// Static path to generated nuxt
if (process.env.NODE_ENV === 'production') {
const distPath = Path.join(global.appRoot, '/client/dist')
app.use(express.static(distPath))
}
app.use(express.static(this.AudiobookPath))
app.use(express.static(this.MetadataPath))
app.use(express.urlencoded({ extended: true }));
app.use(express.json())
app.use('/api', this.authMiddleware.bind(this), this.apiController.router)
app.use('/hls', this.authMiddleware.bind(this), this.hlsController.router)
app.get('/', (req, res) => {
res.sendFile('/index.html')
})
app.get('/test/:id', (req, res) => {
var audiobook = this.audiobooks.find(a => a.id === req.params.id)
var startTime = !isNaN(req.query.start) ? Number(req.query.start) : 0
Logger.info('/test with audiobook', audiobook.title)
streamTest.start(audiobook, startTime)
res.sendStatus(200)
})
app.post('/stream', (req, res) => this.streamManager.openStreamRequest(req, res))
app.post('/login', (req, res) => this.auth.login(req, res))
app.post('/logout', this.logout.bind(this))
app.get('/ping', (req, res) => {
Logger.info('Recieved ping')
res.json({ success: true })
})
this.server.listen(this.Port, this.Host, () => {
Logger.info(`Running on http://${this.Host}:${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('[SOCKET] Socket Connected', socket.id)
socket.on('auth', (token) => this.authenticateSocket(socket, token))
socket.on('scan', this.scan.bind(this))
socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId))
socket.on('close_stream', () => this.streamManager.closeStreamRequest(socket))
socket.on('stream_update', (payload) => this.streamManager.streamUpdate(socket, payload))
socket.on('test', () => {
console.log('Test Request from', socket.id)
socket.emit('test_received', socket.id)
})
socket.on('disconnect', () => {
var _client = this.clients[socket.id]
if (!_client) {
Logger.warn('[SOCKET] Socket disconnect, no client ' + socket.id)
} else if (!_client.user) {
Logger.info('[SOCKET] Unauth socket disconnected ' + socket.id)
delete this.clients[socket.id]
} else {
const disconnectTime = Date.now() - _client.connected_at
Logger.info(`[SOCKET] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms`)
delete this.clients[socket.id]
}
})
})
}
logout(req, res) {
res.sendStatus(200)
}
async authenticateSocket(socket, token) {
var user = await this.auth.verifyToken(token)
if (!user) {
Logger.error('Cannot validate socket - invalid token')
return socket.emit('invalid_token')
}
var client = this.clients[socket.id]
client.user = user
// Check if user has stream open
if (client.user.stream) {
Logger.info('User has stream open already', client.user.stream)
client.stream = this.streamManager.getStream(client.user.stream)
if (!client.stream) {
Logger.error('Invalid user stream id', client.user.stream)
this.streamManager.removeOrphanStreamFiles(client.user.stream)
await this.db.updateUserStream(client.user.id, null)
}
}
const initialPayload = {
settings: this.settings,
isScanning: this.isScanning,
isInitialized: this.isInitialized,
audiobookPath: this.AudiobookPath,
metadataPath: this.MetadataPath,
configPath: this.ConfigPath,
user: client.user.toJSONForBrowser(),
stream: client.stream || null
}
client.socket.emit('init', initialPayload)
}
async stop() {
await this.watcher.close()
Logger.info('Watcher Closed')
return new Promise((resolve) => {
this.server.close((err) => {
if (err) {
Logger.error('Failed to close server', err)
} else {
Logger.info('Server successfully closed')
}
resolve()
})
})
}
}
module.exports = Server

497
server/Stream.js Normal file
View file

@ -0,0 +1,497 @@
const Ffmpeg = require('fluent-ffmpeg')
const EventEmitter = require('events')
const Path = require('path')
const fs = require('fs-extra')
const Logger = require('./Logger')
const { secondsToTimestamp } = require('./utils/fileUtils')
const hlsPlaylistGenerator = require('./utils/hlsPlaylistGenerator')
class Stream extends EventEmitter {
constructor(streamPath, client, audiobook) {
super()
this.id = (Date.now() + Math.trunc(Math.random() * 1000)).toString(36)
this.client = client
this.audiobook = audiobook
this.segmentLength = 6
this.segmentBasename = 'output-%d.ts'
this.streamPath = Path.join(streamPath, this.id)
this.concatFilesPath = Path.join(this.streamPath, 'files.txt')
this.playlistPath = Path.join(this.streamPath, 'output.m3u8')
this.fakePlaylistPath = Path.join(this.streamPath, 'fake-output.m3u8')
this.startTime = 0
this.ffmpeg = null
this.loop = null
this.isResetting = false
this.isClientInitialized = false
this.isTranscodeComplete = false
this.segmentsCreated = new Set()
this.furthestSegmentCreated = 0
this.clientCurrentTime = 0
this.init()
}
get socket() {
return this.client.socket
}
get audiobookId() {
return this.audiobook.id
}
get totalDuration() {
return this.audiobook.totalDuration
}
get segmentStartNumber() {
if (!this.startTime) return 0
return Math.floor(this.startTime / this.segmentLength)
}
get numSegments() {
var numSegs = Math.floor(this.totalDuration / this.segmentLength)
if (this.totalDuration - (numSegs * this.segmentLength) > 0) {
numSegs++
}
return numSegs
}
get tracks() {
return this.audiobook.tracks
}
get clientPlaylistUri() {
return `/hls/${this.id}/output.m3u8`
}
get clientProgress() {
if (!this.clientCurrentTime) return 0
return Number((this.clientCurrentTime / this.totalDuration).toFixed(3))
}
toJSON() {
return {
id: this.id,
clientId: this.client.id,
userId: this.client.user.id,
audiobook: this.audiobook.toJSONMinified(),
segmentLength: this.segmentLength,
playlistPath: this.playlistPath,
clientPlaylistUri: this.clientPlaylistUri,
clientCurrentTime: this.clientCurrentTime,
startTime: this.startTime,
segmentStartNumber: this.segmentStartNumber
}
}
init() {
var clientUserAudiobooks = this.client.user ? this.client.user.audiobooks || {} : {}
var userAudiobook = clientUserAudiobooks[this.audiobookId] || null
if (userAudiobook) {
var timeRemaining = this.totalDuration - userAudiobook.currentTime
Logger.info('[STREAM] User has progress for audiobook', userAudiobook, `Time Remaining: ${timeRemaining}s`)
if (timeRemaining > 15) {
this.startTime = userAudiobook.currentTime
this.clientCurrentTime = this.startTime
}
}
}
async checkSegmentNumberRequest(segNum) {
var segStartTime = segNum * this.segmentLength
if (this.startTime > segStartTime) {
Logger.warn(`[STREAM] Segment #${segNum} Request @${secondsToTimestamp(segStartTime)} is before start time (${secondsToTimestamp(this.startTime)}) - Reset Transcode`)
await this.reset(segStartTime - (this.segmentLength * 2))
return segStartTime
} else if (this.isTranscodeComplete) {
return false
}
var distanceFromFurthestSegment = segNum - this.furthestSegmentCreated
if (distanceFromFurthestSegment > 10) {
Logger.info(`Segment #${segNum} requested is ${distanceFromFurthestSegment} segments from latest (${secondsToTimestamp(segStartTime)}) - Reset Transcode`)
await this.reset(segStartTime - (this.segmentLength * 2))
return segStartTime
}
return false
}
updateClientCurrentTime(currentTime) {
Logger.debug('[Stream] Updated client current time', secondsToTimestamp(currentTime))
this.clientCurrentTime = currentTime
}
async generatePlaylist() {
fs.ensureDirSync(this.streamPath)
await hlsPlaylistGenerator(this.playlistPath, 'output', this.totalDuration, this.segmentLength)
console.log('Playlist generated')
return this.clientPlaylistUri
}
async checkFiles() {
try {
var files = await fs.readdir(this.streamPath)
files.forEach((file) => {
var extname = Path.extname(file)
if (extname === '.ts') {
var basename = Path.basename(file, extname)
var num_part = basename.split('-')[1]
var part_num = Number(num_part)
this.segmentsCreated.add(part_num)
}
})
if (!this.segmentsCreated.size) {
Logger.warn('No Segments')
return
}
if (this.segmentsCreated.size > 6 && !this.isClientInitialized) {
this.isClientInitialized = true
Logger.info(`[STREAM] ${this.id} notifying client that stream is ready`)
this.socket.emit('stream_open', this.toJSON())
}
var chunks = []
var current_chunk = []
var last_seg_in_chunk = -1
var segments = Array.from(this.segmentsCreated).sort((a, b) => a - b);
var lastSegment = segments[segments.length - 1]
if (lastSegment > this.furthestSegmentCreated) {
this.furthestSegmentCreated = lastSegment
}
// console.log('SORT', [...this.segmentsCreated].slice(0, 200).join(', '), segments.slice(0, 200).join(', '))
segments.forEach((seg) => {
if (!current_chunk.length || last_seg_in_chunk + 1 === seg) {
last_seg_in_chunk = seg
current_chunk.push(seg)
} else {
// console.log('Last Seg is not equal to - 1', last_seg_in_chunk, seg)
if (current_chunk.length === 1) chunks.push(current_chunk[0])
else chunks.push(`${current_chunk[0]}-${current_chunk[current_chunk.length - 1]}`)
last_seg_in_chunk = seg
current_chunk = [seg]
}
})
if (current_chunk.length) {
if (current_chunk.length === 1) chunks.push(current_chunk[0])
else chunks.push(`${current_chunk[0]}-${current_chunk[current_chunk.length - 1]}`)
}
var perc = (this.segmentsCreated.size * 100 / this.numSegments).toFixed(2) + '%'
Logger.info('[STREAM-CHECK] Check Files', this.segmentsCreated.size, 'of', this.numSegments, perc, `Furthest Segment: ${this.furthestSegmentCreated}`)
Logger.info('[STREAM-CHECK] Chunks', chunks.join(', '))
this.socket.emit('stream_progress', {
stream: this.id,
percentCreated: perc,
chunks,
numSegments: this.numSegments
})
} catch (error) {
Logger.error('Failed checkign files', error)
}
}
startLoop() {
this.socket.emit('stream_progress', { chunks: [], numSegments: 0 })
this.loop = setInterval(() => {
if (!this.isTranscodeComplete) {
this.checkFiles()
} else {
this.socket.emit('stream_ready')
clearTimeout(this.loop)
}
}, 2000)
}
escapeSingleQuotes(path) {
// return path.replace(/'/g, '\'\\\'\'')
return path.replace(/\\/g, '/').replace(/ /g, '\\ ').replace(/'/g, '\\\'')
}
async start() {
Logger.info(`[STREAM] START STREAM - Num Segments: ${this.numSegments}`)
this.ffmpeg = Ffmpeg()
var currTrackEnd = 0
var startingTrack = this.tracks.find(t => {
currTrackEnd += t.duration
return this.startTime < currTrackEnd
})
var trackStartTime = currTrackEnd - startingTrack.duration
var tracksToInclude = this.tracks.filter(t => t.index >= startingTrack.index)
var trackPaths = tracksToInclude.map(t => {
var line = 'file ' + this.escapeSingleQuotes(t.fullPath) + '\n' + `duration ${t.duration}`
return line
})
var inputstr = trackPaths.join('\n\n')
await fs.writeFile(this.concatFilesPath, inputstr)
this.ffmpeg.addInput(this.concatFilesPath)
this.ffmpeg.inputFormat('concat')
this.ffmpeg.inputOption('-safe 0')
if (this.startTime > 0) {
const shiftedStartTime = this.startTime - trackStartTime
Logger.info(`[STREAM] Starting Stream at startTime ${secondsToTimestamp(this.startTime)} and Segment #${this.segmentStartNumber}`)
this.ffmpeg.inputOption(`-ss ${shiftedStartTime}`)
this.ffmpeg.inputOption('-noaccurate_seek')
}
this.ffmpeg.addOption([
'-loglevel warning',
'-map 0:a',
'-c:a copy'
])
this.ffmpeg.addOption([
'-f hls',
"-copyts",
"-avoid_negative_ts disabled",
"-max_delay 5000000",
"-max_muxing_queue_size 2048",
`-hls_time 6`,
"-hls_segment_type mpegts",
`-start_number ${this.segmentStartNumber}`,
"-hls_playlist_type vod",
"-hls_list_size 0",
"-hls_allow_cache 0"
])
var segmentFilename = Path.join(this.streamPath, this.segmentBasename)
this.ffmpeg.addOption(`-hls_segment_filename ${segmentFilename}`)
this.ffmpeg.output(this.fakePlaylistPath)
this.ffmpeg.on('start', (command) => {
Logger.info('[INFO] FFMPEG transcoding started with command: ' + command)
if (this.isResetting) {
setTimeout(() => {
Logger.info('[STREAM] Clearing isResetting')
this.isResetting = false
}, 500)
}
this.startLoop()
})
this.ffmpeg.on('stderr', (stdErrline) => {
Logger.info(stdErrline)
})
this.ffmpeg.on('error', (err, stdout, stderr) => {
if (err.message && err.message.includes('SIGKILL')) {
// This is an intentional SIGKILL
Logger.info('[FFMPEG] Transcode Killed')
this.ffmpeg = null
} else {
Logger.error('Ffmpeg Err', err.message)
}
})
this.ffmpeg.on('end', (stdout, stderr) => {
Logger.info('[FFMPEG] Transcoding ended')
this.isTranscodeComplete = true
this.ffmpeg = null
})
this.ffmpeg.run()
}
async startConcat() {
Logger.info(`[STREAM] START STREAM - Num Segments: ${this.numSegments}`)
var concatOutput = null
if (this.tracks.length > 1) {
var start = Date.now()
await new Promise(async (resolve) => {
Logger.info('Concatenating here', this.tracks.length)
this.ffmpeg = Ffmpeg()
var trackExt = this.tracks[0].ext
concatOutput = Path.join(this.streamPath, `concat${trackExt}`)
Logger.info('Concat OUTPUT', concatOutput)
var trackPaths = this.tracks.map(t => {
var line = 'file ' + this.escapeSingleQuotes(t.fullPath) + '\n' + `duration ${t.duration}`
return line
})
var inputstr = trackPaths.join('\n\n')
await fs.writeFile(this.concatFilesPath, inputstr)
this.ffmpeg.addInput(this.concatFilesPath)
this.ffmpeg.inputFormat('concat')
this.ffmpeg.inputOption('-safe 0')
this.ffmpeg.addOption([
'-loglevel warning',
'-map 0:a',
'-c:a copy'
])
this.ffmpeg.output(concatOutput)
this.ffmpeg.on('start', (command) => {
Logger.info('[CONCAT] FFMPEG transcoding started with command: ' + command)
})
this.ffmpeg.on('error', (err, stdout, stderr) => {
Logger.info('[CONCAT] ERROR', err, stderr)
})
this.ffmpeg.on('end', (stdout, stderr) => {
Logger.info('[CONCAT] Concat is done')
resolve()
})
this.ffmpeg.run()
})
var elapsed = ((Date.now() - start) / 1000).toFixed(1)
Logger.info(`[CONCAT] Final elapsed is ${elapsed}s`)
} else {
concatOutput = this.tracks[0].fullPath
}
this.ffmpeg = Ffmpeg()
// var currTrackEnd = 0
// var startingTrack = this.tracks.find(t => {
// currTrackEnd += t.duration
// return this.startTime < currTrackEnd
// })
// var trackStartTime = currTrackEnd - startingTrack.duration
// var currInpoint = this.startTime - trackStartTime
// var tracksToInclude = this.tracks.filter(t => t.index >= startingTrack.index)
// var trackPaths = tracksToInclude.map(t => {
// var line = 'file ' + this.escapeSingleQuotes(t.fullPath) + '\n' + `duration ${t.duration}`
// if (t.index === startingTrack.index) {
// line += `\ninpoint ${currInpoint}`
// }
// return line
// })
// var inputstr = trackPaths.join('\n\n')
// await fs.writeFile(this.concatFilesPath, inputstr)
this.ffmpeg.addInput(concatOutput)
// this.ffmpeg.inputFormat('concat')
// this.ffmpeg.inputOption('-safe 0')
if (this.startTime > 0) {
Logger.info(`[STREAM] Starting Stream at startTime ${secondsToTimestamp(this.startTime)} and Segment #${this.segmentStartNumber}`)
this.ffmpeg.inputOption(`-ss ${this.startTime}`)
this.ffmpeg.inputOption('-noaccurate_seek')
}
this.ffmpeg.addOption([
'-loglevel warning',
'-map 0:a',
'-c:a copy'
])
this.ffmpeg.addOption([
'-f hls',
"-copyts",
"-avoid_negative_ts disabled",
"-max_delay 5000000",
"-max_muxing_queue_size 2048",
`-hls_time 6`,
"-hls_segment_type mpegts",
`-start_number ${this.segmentStartNumber}`,
"-hls_playlist_type vod",
"-hls_list_size 0",
"-hls_allow_cache 0"
])
var segmentFilename = Path.join(this.streamPath, this.segmentBasename)
this.ffmpeg.addOption(`-hls_segment_filename ${segmentFilename}`)
this.ffmpeg.output(this.playlistPath)
this.ffmpeg.on('start', (command) => {
Logger.info('[INFO] FFMPEG transcoding started with command: ' + command)
if (this.isResetting) {
setTimeout(() => {
Logger.info('[STREAM] Clearing isResetting')
this.isResetting = false
}, 500)
}
this.startLoop()
})
this.ffmpeg.on('stderr', (stdErrline) => {
Logger.info(stdErrline)
})
this.ffmpeg.on('error', (err, stdout, stderr) => {
if (err.message && err.message.includes('SIGKILL')) {
// This is an intentional SIGKILL
Logger.info('[FFMPEG] Transcode Killed')
this.ffmpeg = null
} else {
Logger.error('Ffmpeg Err', err.message)
}
})
this.ffmpeg.on('end', (stdout, stderr) => {
Logger.info('[FFMPEG] Transcoding ended')
this.isTranscodeComplete = true
this.ffmpeg = null
})
this.ffmpeg.run()
}
async close() {
clearInterval(this.loop)
Logger.info('Closing Stream', this.id)
if (this.ffmpeg) {
this.ffmpeg.kill('SIGKILL')
}
await fs.remove(this.streamPath).then(() => {
Logger.info('Deleted session data', this.streamPath)
}).catch((err) => {
Logger.error('Failed to delete session data', err)
})
this.client.socket.emit('stream_closed', this.id)
this.emit('closed')
}
cancelTranscode() {
clearInterval(this.loop)
if (this.ffmpeg) {
this.ffmpeg.kill('SIGKILL')
}
}
async waitCancelTranscode() {
for (let i = 0; i < 20; i++) {
if (!this.ffmpeg) return true
await new Promise((resolve) => setTimeout(resolve, 500))
}
Logger.error('[STREAM] Transcode never closed...')
return false
}
async reset(time) {
if (this.isResetting) {
return Logger.info(`[STREAM] Stream ${this.id} already resetting`)
}
time = Math.max(0, time)
this.isResetting = true
if (this.ffmpeg) {
this.cancelTranscode()
await this.waitCancelTranscode()
}
this.isTranscodeComplete = false
this.startTime = time
this.clientCurrentTime = this.startTime
Logger.info(`Stream Reset New Start Time ${secondsToTimestamp(this.startTime)}`)
this.start()
}
}
module.exports = Stream

118
server/StreamManager.js Normal file
View file

@ -0,0 +1,118 @@
const Stream = require('./Stream')
const Logger = require('./Logger')
const fs = require('fs-extra')
const Path = require('path')
class StreamManager {
constructor(db, STREAM_PATH) {
this.db = db
this.streams = []
this.streamPath = STREAM_PATH
}
get audiobooks() {
return this.db.audiobooks
}
getStream(streamId) {
return this.streams.find(s => s.id === streamId)
}
removeStream(stream) {
this.streams = this.streams.filter(s => s.id !== stream.id)
}
async openStream(client, audiobook) {
var stream = new Stream(this.streamPath, client, audiobook)
stream.on('closed', () => {
this.removeStream(stream)
})
this.streams.push(stream)
await stream.generatePlaylist()
stream.start()
Logger.info('Stream Opened for client', client.user.username, 'for audiobook', audiobook.title, 'with streamId', stream.id)
client.stream = stream
client.user.stream = stream.id
return stream
}
removeOrphanStreamFiles(streamId) {
try {
var streamPath = Path.join(this.streamPath, streamId)
return fs.remove(streamPath)
} catch (error) {
Logger.debug('No orphan stream', streamId)
return false
}
}
async removeOrphanStreams() {
try {
var dirs = await fs.readdir(this.streamPath)
if (!dirs || !dirs.length) return true
await Promise.all(dirs.map(async (dirname) => {
var fullPath = Path.join(this.streamPath, dirname)
Logger.info(`Removing Orphan Stream ${dirname}`)
return fs.remove(fullPath)
}))
return true
} catch (error) {
Logger.debug('No orphan stream', streamId)
return false
}
}
async openStreamSocketRequest(socket, audiobookId) {
Logger.info('Open Stream Request', socket.id, audiobookId)
var audiobook = this.audiobooks.find(ab => ab.id === audiobookId)
var client = socket.sheepClient
if (client.stream) {
Logger.info('Closing client stream first', client.stream.id)
await client.stream.close()
client.user.stream = null
client.stream = null
}
var stream = await this.openStream(client, audiobook)
this.db.updateUserStream(client.user.id, stream.id)
}
async closeStreamRequest(socket) {
Logger.info('Close Stream Request', socket.id)
var client = socket.sheepClient
if (!client || !client.stream) {
Logger.error('No stream for client', client.user.id)
return
}
// var streamId = client.stream.id
await client.stream.close()
client.user.stream = null
client.stream = null
this.db.updateUserStream(client.user.id, null)
}
streamUpdate(socket, { currentTime, streamId }) {
var client = socket.sheepClient
if (!client || !client.stream) {
Logger.error('No stream for client', client.user.id)
return
}
if (client.stream.id !== streamId) {
Logger.error('Stream id mismatch on stream update', streamId, client.stream.id)
return
}
client.stream.updateClientCurrentTime(currentTime)
client.user.updateAudiobookProgress(client.stream)
this.db.updateEntity('user', client.user.toJSON())
}
}
module.exports = StreamManager

75
server/User.js Normal file
View file

@ -0,0 +1,75 @@
class User {
constructor(user) {
this.id = null
this.username = null
this.pash = null
this.type = null
this.stream = null
this.token = null
this.createdAt = null
this.audiobooks = null
if (user) {
this.construct(user)
}
}
toJSON() {
return {
id: this.id,
username: this.username,
pash: this.pash,
type: this.type,
stream: this.stream,
token: this.token,
audiobooks: this.audiobooks,
createdAt: this.createdAt
}
}
toJSONForBrowser() {
return {
id: this.id,
username: this.username,
type: this.type,
stream: this.stream,
token: this.token,
audiobooks: this.audiobooks,
createdAt: this.createdAt
}
}
construct(user) {
this.id = user.id
this.username = user.username
this.pash = user.pash
this.type = user.type
this.stream = user.stream
this.token = user.token
this.audiobooks = user.audiobooks || null
this.createdAt = user.createdAt
}
updateAudiobookProgress(stream) {
if (!this.audiobooks) this.audiobooks = {}
if (!this.audiobooks[stream.audiobookId]) {
this.audiobooks[stream.audiobookId] = {
audiobookId: stream.audiobookId,
totalDuration: stream.totalDuration,
startedAt: Date.now()
}
}
this.audiobooks[stream.audiobookId].lastUpdate = Date.now()
this.audiobooks[stream.audiobookId].progress = stream.clientProgress
this.audiobooks[stream.audiobookId].currentTime = stream.clientCurrentTime
}
resetAudiobookProgress(audiobookId) {
if (!this.audiobooks || !this.audiobooks[audiobookId]) {
return false
}
delete this.audiobooks[audiobookId]
return true
}
}
module.exports = User

71
server/Watcher.js Normal file
View file

@ -0,0 +1,71 @@
var EventEmitter = require('events')
var Logger = require('./Logger')
var chokidar = require('chokidar')
class FolderWatcher extends EventEmitter {
constructor(audiobookPath) {
super()
this.AudiobookPath = audiobookPath
this.folderMap = {}
this.watcher = null
}
initWatcher() {
try {
Logger.info('[WATCHER] Initializing..')
this.watcher = chokidar.watch(this.AudiobookPath, {
ignoreInitial: true,
ignored: /(^|[\/\\])\../, // ignore dotfiles
persistent: true,
awaitWriteFinish: {
stabilityThreshold: 2500,
pollInterval: 500
}
})
this.watcher
.on('add', (path) => {
this.onNewFile(path)
}).on('change', (path) => {
this.onFileUpdated(path)
}).on('unlink', path => {
this.onFileRemoved(path)
}).on('error', (error) => {
Logger.error(`Watcher error: ${error}`)
}).on('ready', () => {
Logger.info('[WATCHER] Ready')
})
} catch (error) {
Logger.error('Chokidar watcher failed', error)
}
}
close() {
return this.watcher.close()
}
onNewFile(path) {
Logger.info('FolderWatcher: New File', path)
this.emit('file_added', {
path: path.replace(this.AudiobookPath, ''),
fullPath: path
})
}
onFileRemoved(path) {
Logger.info('FolderWatcher: File Removed', path)
this.emit('file_removed', {
path: path.replace(this.AudiobookPath, ''),
fullPath: path
})
}
onFileUpdated(path) {
Logger.info('FolderWatcher: Updated File', path)
this.emit('file_updated', {
path: path.replace(this.AudiobookPath, ''),
fullPath: path
})
}
}
module.exports = FolderWatcher

View file

@ -0,0 +1,44 @@
var libgen = require('libgen')
class LibGen {
constructor() {
this.mirror = null
}
async init() {
this.mirror = await libgen.mirror()
console.log(`${this.mirror} is currently fastest`)
}
async search(query) {
if (!this.mirror) {
await this.init()
}
var options = {
mirror: this.mirror,
query: query,
search_in: 'title'
}
try {
const data = await libgen.search(options)
let n = data.length
console.log(`${n} results for "${options.query}"`)
while (n--) {
console.log('');
console.log('Title: ' + data[n].title)
console.log('Author: ' + data[n].author)
console.log('Download: ' +
'http://gen.lib.rus.ec/book/index.php?md5=' +
data[n].md5.toLowerCase())
}
return data
} catch (err) {
console.error(err)
return {
errorCode: 500
}
}
}
}
module.exports = LibGen

View file

@ -0,0 +1,72 @@
var axios = require('axios')
class OpenLibrary {
constructor() {
this.baseUrl = 'https://openlibrary.org'
}
get(uri) {
return axios.get(`${this.baseUrl}/${uri}`).then((res) => {
return res.data
}).catch((error) => {
console.error('Failed', error)
return false
})
}
async isbnLookup(isbn) {
var lookupData = await this.get(`/isbn/${isbn}`)
if (!lookupData) {
return {
errorCode: 404
}
}
return lookupData
}
async getWorksData(worksKey) {
var worksData = await this.get(`${worksKey}.json`)
if (!worksData.covers) worksData.covers = []
var coverImages = worksData.covers.filter(c => c > 0).map(c => `https://covers.openlibrary.org/b/id/${c}-L.jpg`)
var description = null
if (worksData.description) {
if (typeof worksData.description === 'string') {
description = worksData.description
} else {
description = worksData.description.value || null
}
}
return {
id: worksKey.split('/').pop(),
key: worksKey,
covers: coverImages,
first_publish_date: worksData.first_publish_date,
description: description
}
}
async cleanSearchDoc(doc) {
var worksData = await this.getWorksData(doc.key)
return {
title: doc.title,
author: doc.author_name ? doc.author_name.join(', ') : null,
first_publish_year: doc.first_publish_year,
edition: doc.cover_edition_key,
cover: doc.cover_edition_key ? `https://covers.openlibrary.org/b/OLID/${doc.cover_edition_key}-L.jpg` : null,
...worksData
}
}
async search(query) {
var queryString = Object.keys(query).map(key => key + '=' + query[key]).join('&')
var lookupData = await this.get(`/search.json?${queryString}`)
if (!lookupData) {
return {
errorCode: 404
}
}
var searchDocs = await Promise.all(lookupData.docs.map(d => this.cleanSearchDoc(d)))
return searchDocs
}
}
module.exports = OpenLibrary

112
server/streamTest.js Normal file
View file

@ -0,0 +1,112 @@
const Ffmpeg = require('fluent-ffmpeg')
const Path = require('path')
const fs = require('fs-extra')
const Logger = require('./Logger')
const { secondsToTimestamp } = require('./utils/fileUtils')
function escapeSingleQuotes(path) {
return path.replace(/\\/g, '/').replace(/ /g, '\\ ').replace(/'/g, '\\\'')
}
function getNumSegments(audiobook, segmentLength) {
var numSegments = Math.floor(audiobook.totalDuration / segmentLength)
var remainingTime = audiobook.totalDuration - (numSegments * segmentLength)
if (remainingTime > 0) numSegments++
return numSegments
}
async function start(audiobook, startTime = 0, segmentLength = 6) {
var testDir = Path.join(global.appRoot, 'test', audiobook.id)
var existsAlready = await fs.pathExists(testDir)
if (existsAlready) {
await fs.remove(testDir).then(() => {
Logger.info('Deleted test dir data', testDir)
}).catch((err) => {
Logger.error('Failed to delete test dir', err)
})
}
fs.ensureDirSync(testDir)
var concatFilePath = Path.join(testDir, 'concat.txt')
var playlistPath = Path.join(testDir, 'output.m3u8')
const numSegments = getNumSegments(audiobook, segmentLength)
const segmentStartNumber = Math.floor(startTime / segmentLength)
Logger.info(`[STREAM] START STREAM - Num Segments: ${numSegments} - Segment Start: ${segmentStartNumber}`)
const tracks = audiobook.tracks
const ffmpeg = Ffmpeg()
var currTrackEnd = 0
var startingTrack = tracks.find(t => {
currTrackEnd += t.duration
return startTime < currTrackEnd
})
var trackStartTime = currTrackEnd - startingTrack.duration
var currInpoint = startTime - trackStartTime
Logger.info('Starting Track Index', startingTrack.index)
var tracksToInclude = tracks.filter(t => t.index >= startingTrack.index)
var trackPaths = tracksToInclude.map(t => {
var line = 'file ' + escapeSingleQuotes(t.fullPath) + '\n' + `duration ${t.duration}`
// if (t.index === startingTrack.index) {
// currInpoint = 60 * 5 + 4
// line += `\ninpoint ${currInpoint}`
// }
return line
})
var inputstr = trackPaths.join('\n\n')
await fs.writeFile(concatFilePath, inputstr)
ffmpeg.addInput(concatFilePath)
ffmpeg.inputFormat('concat')
ffmpeg.inputOption('-safe 0')
var shiftedStartTime = startTime - trackStartTime
if (startTime > 0) {
Logger.info(`[STREAM] Starting Stream at startTime ${secondsToTimestamp(startTime)} and Segment #${segmentStartNumber}`)
ffmpeg.inputOption(`-ss ${shiftedStartTime}`)
ffmpeg.inputOption('-noaccurate_seek')
}
ffmpeg.addOption([
'-loglevel warning',
'-map 0:a',
'-c:a copy'
])
ffmpeg.addOption([
'-f hls',
"-copyts",
"-avoid_negative_ts disabled",
"-max_delay 5000000",
"-max_muxing_queue_size 2048",
`-hls_time 6`,
"-hls_segment_type mpegts",
`-start_number ${segmentStartNumber}`,
"-hls_playlist_type vod",
"-hls_list_size 0",
"-hls_allow_cache 0"
])
var segmentFilename = Path.join(testDir, 'output-%d.ts')
ffmpeg.addOption(`-hls_segment_filename ${segmentFilename}`)
ffmpeg.output(playlistPath)
ffmpeg.on('start', (command) => {
Logger.info('[FFMPEG-START] FFMPEG transcoding started with command: ' + command)
})
ffmpeg.on('stderr', (stdErrline) => {
Logger.info('[FFMPEG-STDERR]', stdErrline)
})
ffmpeg.on('error', (err, stdout, stderr) => {
Logger.info('[FFMPEG-ERROR]', err)
})
ffmpeg.on('end', (stdout, stderr) => {
Logger.info('[FFMPEG] Transcode ended')
})
ffmpeg.run()
}
module.exports.start = start

View file

@ -0,0 +1,180 @@
const Path = require('path')
const Logger = require('../Logger')
var prober = require('./prober')
function getDefaultAudioStream(audioStreams) {
if (audioStreams.length === 1) return audioStreams[0]
var defaultStream = audioStreams.find(a => a.is_default)
if (!defaultStream) return audioStreams[0]
return defaultStream
}
async function scan(path) {
var probeData = await prober(path)
if (!probeData || !probeData.audio_streams || !probeData.audio_streams.length) {
return {
error: 'Invalid audio file'
}
}
if (!probeData.duration || !probeData.size) {
return {
error: 'Invalid duration or size'
}
}
var audioStream = getDefaultAudioStream(probeData.audio_streams)
const finalData = {
format: probeData.format,
duration: probeData.duration,
size: probeData.size,
bit_rate: audioStream.bit_rate || probeData.bit_rate,
codec: audioStream.codec,
time_base: audioStream.time_base,
language: audioStream.language,
channel_layout: audioStream.channel_layout,
channels: audioStream.channels,
sample_rate: audioStream.sample_rate
}
for (const key in probeData) {
if (probeData[key] && key.startsWith('file_tag')) {
finalData[key] = probeData[key]
}
}
if (finalData.file_tag_track) {
var track = finalData.file_tag_track
var trackParts = track.split('/').map(part => Number(part))
if (trackParts.length > 0) {
finalData.trackNumber = trackParts[0]
}
if (trackParts.length > 1) {
finalData.trackTotal = trackParts[1]
}
}
return finalData
}
module.exports.scan = scan
function isNumber(val) {
return !isNaN(val) && val !== null
}
function getTrackNumberFromMeta(scanData) {
return !isNaN(scanData.trackNumber) && scanData.trackNumber !== null ? Number(scanData.trackNumber) : null
}
function getTrackNumberFromFilename(filename) {
var partbasename = Path.basename(filename, Path.extname(filename))
var numbersinpath = partbasename.match(/\d+/g)
if (!numbersinpath) return null
var number = numbersinpath.length ? parseInt(numbersinpath[0]) : null
return number
}
async function scanParts(audiobook, parts) {
if (!parts || !parts.length) {
Logger.error('Scan Parts', audiobook.title, 'No Parts', parts)
return
}
var tracks = []
for (let i = 0; i < parts.length; i++) {
var fullPath = Path.join(audiobook.fullPath, parts[i])
var scanData = await scan(fullPath)
if (!scanData || scanData.error) {
Logger.error('Scan failed for', parts[i])
audiobook.invalidParts.push(parts[i])
continue;
}
var audioFileObj = {
path: parts[i],
filename: Path.basename(parts[i]),
fullPath: fullPath
}
var trackNumFromMeta = getTrackNumberFromMeta(scanData)
var trackNumFromFilename = getTrackNumberFromFilename(parts[i])
audioFileObj = {
...audioFileObj,
...scanData,
trackNumFromMeta,
trackNumFromFilename
}
audiobook.audioFiles.push(audioFileObj)
var trackNumber = isNumber(trackNumFromMeta) ? trackNumFromMeta : trackNumFromFilename
if (trackNumber === null) {
if (parts.length === 1) {
// Only 1 track
trackNumber = 1
} else {
Logger.error('Invalid track number for', parts[i])
audioFileObj.invalid = true
audioFileObj.error = 'Failed to get track number'
continue;
}
}
if (tracks.find(t => t.index === trackNumber)) {
Logger.error('Duplicate track number for', parts[i])
audioFileObj.invalid = true
audioFileObj.error = 'Duplicate track number'
continue;
}
var track = {
index: trackNumber,
filename: parts[i],
ext: Path.extname(parts[i]),
path: Path.join(audiobook.path, parts[i]),
fullPath: Path.join(audiobook.fullPath, parts[i]),
...scanData
}
tracks.push(track)
}
if (!tracks.length) {
Logger.warn('No Tracks for audiobook', audiobook.id)
return
}
tracks.sort((a, b) => a.index - b.index)
// If first index is 0, increment all by 1
if (tracks[0].index === 0) {
tracks = tracks.map(t => {
t.index += 1
return t
})
}
var parts_copy = tracks.map(p => ({ ...p }))
var current_index = 1
for (let i = 0; i < parts_copy.length; i++) {
var cleaned_part = parts_copy[i]
if (cleaned_part.index > current_index) {
var num_parts_missing = cleaned_part.index - current_index
for (let x = 0; x < num_parts_missing; x++) {
audiobook.missingParts.push(current_index + x)
}
}
current_index = cleaned_part.index + 1
}
if (audiobook.missingParts.length) {
Logger.info('Audiobook', audiobook.title, 'Has missing parts', audiobook.missingParts)
}
tracks.forEach((track) => {
audiobook.addTrack(track)
})
}
module.exports.scanParts = scanParts

58
server/utils/fileUtils.js Normal file
View file

@ -0,0 +1,58 @@
const fs = require('fs-extra')
async function getFileStat(path) {
try {
var stat = await fs.stat(path)
return {
size: stat.size,
atime: stat.atime,
mtime: stat.mtime,
ctime: stat.ctime,
birthtime: stat.birthtime
}
} catch (err) {
console.error('Failed to stat', err)
return false
}
}
module.exports.getFileStat = getFileStat
function bytesPretty(bytes, decimals = 0) {
if (bytes === 0) {
return '0 Bytes'
}
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
}
module.exports.bytesPretty = bytesPretty
function elapsedPretty(seconds) {
var minutes = Math.floor(seconds / 60)
if (minutes < 70) {
return `${minutes} min`
}
var hours = Math.floor(minutes / 60)
minutes -= hours * 60
if (!minutes) {
return `${hours} hr`
}
return `${hours} hr ${minutes} min`
}
module.exports.elapsedPretty = elapsedPretty
function secondsToTimestamp(seconds) {
var _seconds = seconds
var _minutes = Math.floor(seconds / 60)
_seconds -= _minutes * 60
var _hours = Math.floor(_minutes / 60)
_minutes -= _hours * 60
_seconds = Math.round(_seconds)
if (!_hours) {
return `${_minutes}:${_seconds.toString().padStart(2, '0')}`
}
return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}`
}
module.exports.secondsToTimestamp = secondsToTimestamp

View file

@ -0,0 +1,30 @@
const fs = require('fs-extra')
function getPlaylistStr(segmentName, duration, segmentLength) {
var lines = [
'#EXTM3U',
'#EXT-X-VERSION:3',
'#EXT-X-ALLOW-CACHE:NO',
'#EXT-X-TARGETDURATION:6',
'#EXT-X-MEDIA-SEQUENCE:0',
'#EXT-X-PLAYLIST-TYPE:VOD'
]
var numSegments = Math.floor(duration / segmentLength)
var lastSegment = duration - (numSegments * segmentLength)
for (let i = 0; i < numSegments; i++) {
lines.push(`#EXTINF:6,`)
lines.push(`${segmentName}-${i}.ts`)
}
if (lastSegment > 0) {
lines.push(`#EXTINF:${lastSegment},`)
lines.push(`${segmentName}-${numSegments}.ts`)
}
lines.push('#EXT-X-ENDLIST')
return lines.join('\n')
}
function generatePlaylist(outputPath, segmentName, duration, segmentLength) {
var playlistStr = getPlaylistStr(segmentName, duration, segmentLength)
return fs.writeFile(outputPath, playlistStr)
}
module.exports = generatePlaylist

168
server/utils/prober.js Normal file
View file

@ -0,0 +1,168 @@
var Ffmpeg = require('fluent-ffmpeg')
function tryGrabBitRate(stream, all_streams, total_bit_rate) {
if (!isNaN(stream.bit_rate) && stream.bit_rate) {
return Number(stream.bit_rate)
}
if (!stream.tags) {
return null
}
// Attempt to get bitrate from bps tags
var bps = stream.tags.BPS || stream.tags['BPS-eng'] || stream.tags['BPS_eng']
if (bps && !isNaN(bps)) {
return Number(bps)
}
var tagDuration = stream.tags.DURATION || stream.tags['DURATION-eng'] || stream.tags['DURATION_eng']
var tagBytes = stream.tags.NUMBER_OF_BYTES || stream.tags['NUMBER_OF_BYTES-eng'] || stream.tags['NUMBER_OF_BYTES_eng']
if (tagDuration && tagBytes && !isNaN(tagDuration) && !isNaN(tagBytes)) {
var bps = Math.floor(Number(tagBytes) * 8 / Number(tagDuration))
if (bps && !isNaN(bps)) {
return bps
}
}
if (total_bit_rate && stream.codec_type === 'video') {
var estimated_bit_rate = total_bit_rate
all_streams.forEach((stream) => {
if (stream.bit_rate && !isNaN(stream.bit_rate)) {
estimated_bit_rate -= Number(stream.bit_rate)
}
})
if (!all_streams.find(s => s.codec_type === 'audio' && s.bit_rate && Number(s.bit_rate) > estimated_bit_rate)) {
return estimated_bit_rate
} else {
return total_bit_rate
}
} else if (stream.codec_type === 'audio') {
return 112000
} else {
return 0
}
}
function tryGrabFrameRate(stream) {
var avgFrameRate = stream.avg_frame_rate || stream.r_frame_rate
if (!avgFrameRate) return null
var parts = avgFrameRate.split('/')
if (parts.length === 2) {
avgFrameRate = Number(parts[0]) / Number(parts[1])
} else {
avgFrameRate = Number(parts[0])
}
if (!isNaN(avgFrameRate)) return avgFrameRate
return null
}
function tryGrabSampleRate(stream) {
var sample_rate = stream.sample_rate
if (!isNaN(sample_rate)) return Number(sample_rate)
return null
}
function tryGrabChannelLayout(stream) {
var layout = stream.channel_layout
if (!layout) return null
return String(layout).split('(').shift()
}
function tryGrabTag(stream, tag) {
if (!stream.tags) return null
return stream.tags[tag] || stream.tags[tag.toUpperCase()] || null
}
function parseMediaStreamInfo(stream, all_streams, total_bit_rate) {
var info = {
index: stream.index,
type: stream.codec_type,
codec: stream.codec_name || null,
codec_long: stream.codec_long_name || null,
codec_time_base: stream.codec_time_base || null,
time_base: stream.time_base || null,
bit_rate: tryGrabBitRate(stream, all_streams, total_bit_rate),
language: tryGrabTag(stream, 'language'),
title: tryGrabTag(stream, 'title')
}
if (info.type === 'audio' || info.type === 'subtitle') {
var disposition = stream.disposition || {}
info.is_default = disposition.default === 1 || disposition.default === '1'
}
if (info.type === 'video') {
info.profile = stream.profile || null
info.is_avc = (stream.is_avc !== '0' && stream.is_avc !== 'false')
info.pix_fmt = stream.pix_fmt || null
info.frame_rate = tryGrabFrameRate(stream)
info.width = !isNaN(stream.width) ? Number(stream.width) : null
info.height = !isNaN(stream.height) ? Number(stream.height) : null
info.color_range = stream.color_range || null
info.color_space = stream.color_space || null
info.color_transfer = stream.color_transfer || null
info.color_primaries = stream.color_primaries || null
} else if (stream.codec_type === 'audio') {
info.channels = stream.channels || null
info.sample_rate = tryGrabSampleRate(stream)
info.channel_layout = tryGrabChannelLayout(stream)
}
return info
}
function parseProbeData(data) {
try {
var { format, streams } = data
var { format_long_name, duration, size, bit_rate } = format
var sizeBytes = !isNaN(size) ? Number(size) : null
var sizeMb = sizeBytes !== null ? Number((sizeBytes / (1024 * 1024)).toFixed(2)) : null
var cleanedData = {
format: format_long_name,
duration: !isNaN(duration) ? Number(duration) : null,
size: sizeBytes,
sizeMb,
bit_rate: !isNaN(bit_rate) ? Number(bit_rate) : null,
file_tag_encoder: tryGrabTag(format, 'encoder') || tryGrabTag(format, 'encoded_by'),
file_tag_title: tryGrabTag(format, 'title'),
file_tag_track: tryGrabTag(format, 'track') || tryGrabTag(format, 'trk'),
file_tag_album: tryGrabTag(format, 'album') || tryGrabTag(format, 'tal'),
file_tag_artist: tryGrabTag(format, 'artist') || tryGrabTag(format, 'tp1'),
file_tag_date: tryGrabTag(format, 'date') || tryGrabTag(format, 'tye'),
file_tag_genre: tryGrabTag(format, 'genre'),
file_tag_creation_time: tryGrabTag(format, 'creation_time')
}
const cleaned_streams = streams.map(s => parseMediaStreamInfo(s, streams, cleanedData.bit_rate))
cleanedData.video_stream = cleaned_streams.find(s => s.type === 'video')
cleanedData.audio_streams = cleaned_streams.filter(s => s.type === 'audio')
cleanedData.subtitle_streams = cleaned_streams.filter(s => s.type === 'subtitle')
if (cleanedData.audio_streams.length && cleanedData.video_stream) {
var videoBitrate = cleanedData.video_stream.bit_rate
// If audio stream bitrate larger then video, most likely incorrect
if (cleanedData.audio_streams.find(astream => astream.bit_rate > videoBitrate)) {
cleanedData.video_stream.bit_rate = cleanedData.bit_rate
}
}
return cleanedData
} catch (error) {
console.error('Parse failed', error)
return null
}
}
function probe(filepath) {
return new Promise((resolve) => {
Ffmpeg.ffprobe(filepath, (err, raw) => {
if (err) {
console.error(err)
resolve(null)
} else {
resolve(parseProbeData(raw))
}
})
})
}
module.exports = probe

72
server/utils/scandir.js Normal file
View file

@ -0,0 +1,72 @@
const Path = require('path')
const dir = require('node-dir')
const Logger = require('../Logger')
const AUDIOBOOK_PARTS_FORMATS = ['m4b', 'mp3']
const INFO_FORMATS = ['nfo']
const IMAGE_FORMATS = ['png', 'jpg', 'jpeg', 'webp']
const EBOOK_FORMATS = ['epub', 'pdf']
function getPaths(path) {
return new Promise((resolve) => {
dir.paths(path, function (err, res) {
if (err) {
console.error(err)
resolve(false)
}
resolve(res)
})
})
}
function getFileType(ext) {
var ext_cleaned = ext.toLowerCase()
if (ext_cleaned.startsWith('.')) ext_cleaned = ext_cleaned.slice(1)
if (AUDIOBOOK_PARTS_FORMATS.includes(ext_cleaned)) return 'abpart'
if (INFO_FORMATS.includes(ext_cleaned)) return 'info'
if (IMAGE_FORMATS.includes(ext_cleaned)) return 'image'
if (EBOOK_FORMATS.includes(ext_cleaned)) return 'ebook'
return null
}
async function getAllAudiobookFiles(path) {
console.log('getAllAudiobooks', path)
var paths = await getPaths(path)
var books = {}
paths.files.forEach((filepath) => {
var relpath = filepath.replace(path, '').slice(1)
var pathformat = Path.parse(relpath)
var authordir = Path.dirname(pathformat.dir)
var bookdir = Path.basename(pathformat.dir)
if (!books[bookdir]) {
books[bookdir] = {
author: authordir,
title: bookdir,
path: pathformat.dir,
fullPath: Path.join(path, pathformat.dir),
parts: [],
infos: [],
images: [],
ebooks: [],
otherFiles: []
}
}
var filetype = getFileType(pathformat.ext)
if (filetype === 'abpart') {
books[bookdir].parts.push(`${pathformat.name}${pathformat.ext}`)
} else if (filetype === 'info') {
books[bookdir].infos.push(`${pathformat.name}${pathformat.ext}`)
} else if (filetype === 'image') {
books[bookdir].images.push(`${pathformat.name}${pathformat.ext}`)
} else if (filetype === 'ebook') {
books[bookdir].ebooks.push(`${pathformat.name}${pathformat.ext}`)
} else {
Logger.warn('Invalid file type', pathformat.name, pathformat.ext)
books[bookdir].otherFiles.push(`${pathformat.name}${pathformat.ext}`)
}
})
return Object.values(books)
}
module.exports.getAllAudiobookFiles = getAllAudiobookFiles