mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-27 03:31:46 +00:00
merge upstream
This commit is contained in:
commit
61bf30e08a
89 changed files with 2963 additions and 1805 deletions
102
server/utils/abmetadataGenerator.js
Normal file
102
server/utils/abmetadataGenerator.js
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
const fs = require('fs-extra')
|
||||
const filePerms = require('./filePerms')
|
||||
const package = require('../../package.json')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
const bookKeyMap = {
|
||||
title: 'title',
|
||||
subtitle: 'subtitle',
|
||||
author: 'authorFL',
|
||||
narrator: 'narratorFL',
|
||||
series: 'series',
|
||||
volumeNumber: 'volumeNumber',
|
||||
publishYear: 'publishYear',
|
||||
publisher: 'publisher',
|
||||
description: 'description',
|
||||
isbn: 'isbn',
|
||||
asin: 'asin',
|
||||
language: 'language',
|
||||
genres: 'genresCommaSeparated'
|
||||
}
|
||||
|
||||
function generate(audiobook, outputPath) {
|
||||
var fileString = ';ABMETADATA1\n'
|
||||
fileString += `#audiobookshelf v${package.version}\n\n`
|
||||
|
||||
for (const key in bookKeyMap) {
|
||||
const value = audiobook.book[bookKeyMap[key]] || ''
|
||||
fileString += `${key}=${value}\n`
|
||||
}
|
||||
|
||||
if (audiobook.chapters.length) {
|
||||
fileString += '\n'
|
||||
audiobook.chapters.forEach((chapter) => {
|
||||
fileString += `[CHAPTER]\n`
|
||||
fileString += `start=${chapter.start}\n`
|
||||
fileString += `end=${chapter.end}\n`
|
||||
fileString += `title=${chapter.title}\n`
|
||||
})
|
||||
}
|
||||
|
||||
return fs.writeFile(outputPath, fileString).then(() => {
|
||||
return filePerms(outputPath, 0o774, global.Uid, global.Gid, true).then((data) => true)
|
||||
}).catch((error) => {
|
||||
Logger.error(`[absMetaFileGenerator] Failed to save abs file`, error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
module.exports.generate = generate
|
||||
|
||||
function parseAbMetadataText(text) {
|
||||
if (!text) return null
|
||||
var lines = text.split(/\r?\n/)
|
||||
|
||||
// Check first line and get abmetadata version number
|
||||
var firstLine = lines.shift().toLowerCase()
|
||||
if (!firstLine.startsWith(';abmetadata')) {
|
||||
Logger.error(`Invalid abmetadata file first line is not ;abmetadata "${firstLine}"`)
|
||||
return null
|
||||
}
|
||||
var abmetadataVersion = Number(firstLine.replace(';abmetadata', '').trim())
|
||||
if (isNaN(abmetadataVersion)) {
|
||||
Logger.warn(`Invalid abmetadata version ${abmetadataVersion} - using 1`)
|
||||
abmetadataVersion = 1
|
||||
}
|
||||
|
||||
// Remove comments and empty lines
|
||||
const ignoreFirstChars = [' ', '#', ';'] // Ignore any line starting with the following
|
||||
lines = lines.filter(line => !!line.trim() && !ignoreFirstChars.includes(line[0]))
|
||||
|
||||
// Get lines that map to book details (all lines before the first chapter section)
|
||||
var firstSectionLine = lines.findIndex(l => l.startsWith('['))
|
||||
var detailLines = firstSectionLine > 0 ? lines.slice(0, firstSectionLine) : lines
|
||||
|
||||
// Put valid book detail values into map
|
||||
const bookDetails = {}
|
||||
for (let i = 0; i < detailLines.length; i++) {
|
||||
var line = detailLines[i]
|
||||
var keyValue = line.split('=')
|
||||
if (keyValue.length < 2) {
|
||||
Logger.warn('abmetadata invalid line has no =', line)
|
||||
} else if (!bookKeyMap[keyValue[0].trim()]) {
|
||||
Logger.warn(`abmetadata key "${keyValue[0].trim()}" is not a valid book detail key`)
|
||||
} else {
|
||||
var key = keyValue[0].trim()
|
||||
bookDetails[key] = keyValue[1].trim()
|
||||
|
||||
// Genres convert to array of strings
|
||||
if (key === 'genres') {
|
||||
bookDetails[key] = bookDetails[key] ? bookDetails[key].split(',').map(genre => genre.trim()) : []
|
||||
} else if (!bookDetails[key]) { // Use null for empty details
|
||||
bookDetails[key] = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Chapter support
|
||||
|
||||
return {
|
||||
book: bookDetails
|
||||
}
|
||||
}
|
||||
module.exports.parse = parseAbMetadataText
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
const fs = require('fs-extra')
|
||||
const filePerms = require('./filePerms')
|
||||
const package = require('../../package.json')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
const bookKeyMap = {
|
||||
title: 'title',
|
||||
subtitle: 'subtitle',
|
||||
author: 'authorFL',
|
||||
narrator: 'narratorFL',
|
||||
series: 'series',
|
||||
volumeNumber: 'volumeNumber',
|
||||
publishYear: 'publishYear',
|
||||
publisher: 'publisher',
|
||||
description: 'description',
|
||||
isbn: 'isbn',
|
||||
asin: 'asin',
|
||||
language: 'language',
|
||||
genres: 'genresCommaSeparated'
|
||||
}
|
||||
|
||||
function generate(audiobook, outputPath, uid, gid) {
|
||||
var fileString = `[audiobookshelf v${package.version}]\n`
|
||||
|
||||
for (const key in bookKeyMap) {
|
||||
const value = audiobook.book[bookKeyMap[key]] || ''
|
||||
fileString += `${key}=${value}\n`
|
||||
}
|
||||
|
||||
return fs.writeFile(outputPath, fileString).then(() => {
|
||||
return filePerms(outputPath, 0o774, uid, gid).then(() => true)
|
||||
}).catch((error) => {
|
||||
Logger.error(`[absMetaFileGenerator] Failed to save abs file`, error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
module.exports.generate = generate
|
||||
|
|
@ -6,11 +6,6 @@ module.exports.ScanResult = {
|
|||
UPTODATE: 4
|
||||
}
|
||||
|
||||
module.exports.CoverDestination = {
|
||||
METADATA: 0,
|
||||
AUDIOBOOK: 1
|
||||
}
|
||||
|
||||
module.exports.BookCoverAspectRatio = {
|
||||
STANDARD: 0, // 1.6:1
|
||||
SQUARE: 1
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ const chmodr = (p, mode, uid, gid, cb) => {
|
|||
// any error other than ENOTDIR means it's not readable, or
|
||||
// doesn't exist. give up.
|
||||
if (er && er.code !== 'ENOTDIR') return cb(er)
|
||||
if (er) {
|
||||
if (er) { // Is a file
|
||||
return fs.chmod(p, mode).then(() => {
|
||||
fs.chown(p, uid, gid, cb)
|
||||
})
|
||||
|
|
@ -77,9 +77,9 @@ const chmodr = (p, mode, uid, gid, cb) => {
|
|||
})
|
||||
}
|
||||
|
||||
module.exports = (path, mode, uid, gid) => {
|
||||
module.exports = (path, mode, uid, gid, silent = false) => {
|
||||
return new Promise((resolve) => {
|
||||
Logger.debug(`[FilePerms] Setting permission "${mode}" for uid ${uid} and gid ${gid} | "${path}"`)
|
||||
if (!silent) Logger.debug(`[FilePerms] Setting permission "${mode}" for uid ${uid} and gid ${gid} | "${path}"`)
|
||||
chmodr(path, mode, uid, gid, resolve)
|
||||
})
|
||||
}
|
||||
|
|
@ -20,6 +20,23 @@ async function getFileStat(path) {
|
|||
}
|
||||
module.exports.getFileStat = getFileStat
|
||||
|
||||
async function getFileTimestampsWithIno(path) {
|
||||
try {
|
||||
var stat = await fs.stat(path, { bigint: true })
|
||||
return {
|
||||
size: Number(stat.size),
|
||||
mtimeMs: Number(stat.mtimeMs),
|
||||
ctimeMs: Number(stat.ctimeMs),
|
||||
birthtimeMs: Number(stat.birthtimeMs),
|
||||
ino: String(stat.ino)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to getFileTimestampsWithIno', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
module.exports.getFileTimestampsWithIno = getFileTimestampsWithIno
|
||||
|
||||
async function getFileSize(path) {
|
||||
var stat = await getFileStat(path)
|
||||
if (!stat) return 0
|
||||
|
|
@ -27,6 +44,15 @@ async function getFileSize(path) {
|
|||
}
|
||||
module.exports.getFileSize = getFileSize
|
||||
|
||||
|
||||
function getIno(path) {
|
||||
return fs.stat(path, { bigint: true }).then((data => String(data.ino))).catch((err) => {
|
||||
Logger.error('[Utils] Failed to get ino for path', path, err)
|
||||
return null
|
||||
})
|
||||
}
|
||||
module.exports.getIno = getIno
|
||||
|
||||
async function readTextFile(path) {
|
||||
try {
|
||||
var data = await fs.readFile(path)
|
||||
|
|
|
|||
|
|
@ -38,13 +38,6 @@ module.exports.comparePaths = (path1, path2) => {
|
|||
return path1 === path2 || Path.normalize(path1) === Path.normalize(path2)
|
||||
}
|
||||
|
||||
module.exports.getIno = (path) => {
|
||||
return fs.promises.stat(path, { bigint: true }).then((data => String(data.ino))).catch((err) => {
|
||||
Logger.error('[Utils] Failed to get ino for path', path, err)
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
module.exports.isNullOrNaN = (num) => {
|
||||
return num === null || isNaN(num)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,4 @@
|
|||
const ffprobe = require('node-ffprobe')
|
||||
|
||||
if (process.env.FFPROBE_PATH) {
|
||||
ffprobe.FFPROBE_PATH = process.env.FFPROBE_PATH
|
||||
}
|
||||
|
||||
const AudioProbeData = require('../scanner/AudioProbeData')
|
||||
|
||||
const Logger = require('../Logger')
|
||||
|
|
@ -281,6 +276,10 @@ function parseProbeData(data, verbose = false) {
|
|||
|
||||
// Updated probe returns AudioProbeData object
|
||||
function probe(filepath, verbose = false) {
|
||||
if (process.env.FFPROBE_PATH) {
|
||||
ffprobe.FFPROBE_PATH = process.env.FFPROBE_PATH
|
||||
}
|
||||
|
||||
return ffprobe(filepath)
|
||||
.then(raw => {
|
||||
var rawProbeData = parseProbeData(raw, verbose)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
const Path = require('path')
|
||||
const fs = require('fs-extra')
|
||||
const Logger = require('../Logger')
|
||||
const { getIno } = require('./index')
|
||||
const { recurseFiles } = require('./fileUtils')
|
||||
const { recurseFiles, getFileTimestampsWithIno } = require('./fileUtils')
|
||||
const globals = require('./globals')
|
||||
|
||||
function isBookFile(path) {
|
||||
|
|
@ -114,16 +113,20 @@ function groupFileItemsIntoBooks(fileItems) {
|
|||
}
|
||||
|
||||
function cleanFileObjects(basepath, abrelpath, files) {
|
||||
return files.map((file) => {
|
||||
return Promise.all(files.map(async (file) => {
|
||||
var fullPath = Path.posix.join(basepath, file)
|
||||
var fileTsData = await getFileTimestampsWithIno(fullPath)
|
||||
|
||||
var ext = Path.extname(file)
|
||||
return {
|
||||
filetype: getFileType(ext),
|
||||
filename: Path.basename(file),
|
||||
path: Path.posix.join(abrelpath, file), // /AUDIOBOOK/PATH/filename.mp3
|
||||
fullPath: Path.posix.join(basepath, file), // /audiobooks/AUDIOBOOK/PATH/filename.mp3
|
||||
ext: ext
|
||||
fullPath, // /audiobooks/AUDIOBOOK/PATH/filename.mp3
|
||||
ext: ext,
|
||||
...fileTsData
|
||||
}
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
function getFileType(ext) {
|
||||
|
|
@ -162,15 +165,15 @@ async function scanRootDir(folder, serverSettings = {}) {
|
|||
for (const audiobookPath in audiobookGrouping) {
|
||||
var audiobookData = getAudiobookDataFromDir(folderPath, audiobookPath, parseSubtitle)
|
||||
|
||||
var fileObjs = cleanFileObjects(audiobookData.fullPath, audiobookPath, audiobookGrouping[audiobookPath])
|
||||
for (let i = 0; i < fileObjs.length; i++) {
|
||||
fileObjs[i].ino = await getIno(fileObjs[i].fullPath)
|
||||
}
|
||||
var audiobookIno = await getIno(audiobookData.fullPath)
|
||||
var fileObjs = await cleanFileObjects(audiobookData.fullPath, audiobookPath, audiobookGrouping[audiobookPath])
|
||||
var audiobookFolderStats = await getFileTimestampsWithIno(audiobookData.fullPath)
|
||||
audiobooks.push({
|
||||
folderId: folder.id,
|
||||
libraryId: folder.libraryId,
|
||||
ino: audiobookIno,
|
||||
ino: audiobookFolderStats.ino,
|
||||
mtimeMs: audiobookFolderStats.mtimeMs || 0,
|
||||
ctimeMs: audiobookFolderStats.ctimeMs || 0,
|
||||
birthtimeMs: audiobookFolderStats.birthtimeMs || 0,
|
||||
...audiobookData,
|
||||
audioFiles: fileObjs.filter(f => f.filetype === 'audio'),
|
||||
otherFiles: fileObjs.filter(f => f.filetype !== 'audio')
|
||||
|
|
@ -196,32 +199,42 @@ function getAudiobookDataFromDir(folderPath, dir, parseSubtitle = false) {
|
|||
|
||||
|
||||
// If in a series directory check for volume number match
|
||||
/* ACCEPTS:
|
||||
/* ACCEPTS
|
||||
Book 2 - Title Here - Subtitle Here
|
||||
Title Here - Subtitle Here - Vol 12
|
||||
Title Here - volume 9 - Subtitle Here
|
||||
Vol. 3 Title Here - Subtitle Here
|
||||
1980 - Book 2-Title Here
|
||||
Title Here-Volume 999-Subtitle Here
|
||||
2 - Book Title
|
||||
100 - Book Title
|
||||
0.5 - Book Title
|
||||
*/
|
||||
var volumeNumber = null
|
||||
if (series) {
|
||||
// New volume regex to match volumes with decimal (OLD: /(-? ?)\b((?:Book|Vol.?|Volume) (\d{1,3}))\b( ?-?)/i)
|
||||
var volumeMatch = title.match(/(-? ?)\b((?:Book|Vol.?|Volume) (\d{0,3}(?:\.\d{1,2})?))\b( ?-?)/i)
|
||||
if (volumeMatch && volumeMatch.length > 3 && volumeMatch[2] && volumeMatch[3]) {
|
||||
volumeNumber = volumeMatch[3]
|
||||
var replaceChunk = volumeMatch[2]
|
||||
// Added 1.7.1: If title starts with a # that is 3 digits or less (or w/ 2 decimal), then use as volume number
|
||||
var volumeMatch = title.match(/^(\d{1,3}(?:\.\d{1,2})?) - ./)
|
||||
if (volumeMatch && volumeMatch.length > 1) {
|
||||
volumeNumber = volumeMatch[1]
|
||||
title = title.replace(`${volumeNumber} - `, '')
|
||||
} else {
|
||||
// Match volumes with decimal (OLD: /(-? ?)\b((?:Book|Vol.?|Volume) (\d{1,3}))\b( ?-?)/i)
|
||||
var volumeMatch = title.match(/(-? ?)\b((?:Book|Vol.?|Volume) (\d{0,3}(?:\.\d{1,2})?))\b( ?-?)/i)
|
||||
if (volumeMatch && volumeMatch.length > 3 && volumeMatch[2] && volumeMatch[3]) {
|
||||
volumeNumber = volumeMatch[3]
|
||||
var replaceChunk = volumeMatch[2]
|
||||
|
||||
// "1980 - Book 2-Title Here"
|
||||
// Group 1 would be "- "
|
||||
// Group 3 would be "-"
|
||||
// Only remove the first group
|
||||
if (volumeMatch[1]) {
|
||||
replaceChunk = volumeMatch[1] + replaceChunk
|
||||
} else if (volumeMatch[4]) {
|
||||
replaceChunk += volumeMatch[4]
|
||||
// "1980 - Book 2-Title Here"
|
||||
// Group 1 would be "- "
|
||||
// Group 3 would be "-"
|
||||
// Only remove the first group
|
||||
if (volumeMatch[1]) {
|
||||
replaceChunk = volumeMatch[1] + replaceChunk
|
||||
} else if (volumeMatch[4]) {
|
||||
replaceChunk += volumeMatch[4]
|
||||
}
|
||||
title = title.replace(replaceChunk, '').trim()
|
||||
}
|
||||
title = title.replace(replaceChunk, '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -272,8 +285,12 @@ async function getAudiobookFileData(folder, audiobookPath, serverSettings = {})
|
|||
|
||||
var audiobookDir = audiobookPath.replace(folderFullPath, '').slice(1)
|
||||
var audiobookData = getAudiobookDataFromDir(folderFullPath, audiobookDir, parseSubtitle)
|
||||
var audiobookFolderStats = await getFileTimestampsWithIno(audiobookData.fullPath)
|
||||
var audiobook = {
|
||||
ino: await getIno(audiobookData.fullPath),
|
||||
ino: audiobookFolderStats.ino,
|
||||
mtimeMs: audiobookFolderStats.mtimeMs || 0,
|
||||
ctimeMs: audiobookFolderStats.ctimeMs || 0,
|
||||
birthtimeMs: audiobookFolderStats.birthtimeMs || 0,
|
||||
folderId: folder.id,
|
||||
libraryId: folder.libraryId,
|
||||
...audiobookData,
|
||||
|
|
@ -284,14 +301,14 @@ async function getAudiobookFileData(folder, audiobookPath, serverSettings = {})
|
|||
for (let i = 0; i < fileItems.length; i++) {
|
||||
var fileItem = fileItems[i]
|
||||
|
||||
var ino = await getIno(fileItem.fullpath)
|
||||
var fileStatData = await getFileTimestampsWithIno(fileItem.fullpath)
|
||||
var fileObj = {
|
||||
ino,
|
||||
filetype: getFileType(fileItem.extension),
|
||||
filename: fileItem.name,
|
||||
path: fileItem.path,
|
||||
fullPath: fileItem.fullpath,
|
||||
ext: fileItem.extension
|
||||
ext: fileItem.extension,
|
||||
...fileStatData
|
||||
}
|
||||
if (fileObj.filetype === 'audio') {
|
||||
audiobook.audioFiles.push(fileObj)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue