This commit is contained in:
Mark Cooper 2021-08-17 17:01:11 -05:00
commit a0c60a93ba
106 changed files with 26925 additions and 0 deletions

View file

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

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

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

View file

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

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

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

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

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