mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-12-23 12:19:38 +00:00
Init
This commit is contained in:
commit
6930e69b55
106 changed files with 26925 additions and 0 deletions
143
server/ApiController.js
Normal file
143
server/ApiController.js
Normal 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
96
server/AudioTrack.js
Normal 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
212
server/Audiobook.js
Normal 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
192
server/Auth.js
Normal 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
72
server/Book.js
Normal 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
33
server/BookFinder.js
Normal 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
158
server/Db.js
Normal 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
90
server/HlsController.js
Normal 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
50
server/Logger.js
Normal 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
90
server/Scanner.js
Normal 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
241
server/Server.js
Normal 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
497
server/Stream.js
Normal 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
118
server/StreamManager.js
Normal 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
75
server/User.js
Normal 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
71
server/Watcher.js
Normal 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
|
||||
44
server/providers/LibGen.js
Normal file
44
server/providers/LibGen.js
Normal 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
|
||||
72
server/providers/OpenLibrary.js
Normal file
72
server/providers/OpenLibrary.js
Normal 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
112
server/streamTest.js
Normal 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
|
||||
180
server/utils/audioFileScanner.js
Normal file
180
server/utils/audioFileScanner.js
Normal 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
58
server/utils/fileUtils.js
Normal 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
|
||||
30
server/utils/hlsPlaylistGenerator.js
Normal file
30
server/utils/hlsPlaylistGenerator.js
Normal 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
168
server/utils/prober.js
Normal 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
72
server/utils/scandir.js
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue