mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-12-15 16:29:37 +00:00
Init
This commit is contained in:
commit
a0c60a93ba
106 changed files with 26925 additions and 0 deletions
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