-
Scanner
-
-
Scan
+
+
+
Scanner
+
+
+ Scan
+ Scan for Covers
+
+
@@ -68,6 +73,12 @@ export default {
computed: {
streamAudiobook() {
return this.$store.state.streamAudiobook
+ },
+ isScanning() {
+ return this.$store.state.isScanning
+ },
+ isScanningCovers() {
+ return this.$store.state.isScanningCovers
}
},
methods: {
@@ -79,6 +90,9 @@ export default {
scan() {
this.$root.socket.emit('scan')
},
+ scanCovers() {
+ this.$root.socket.emit('scan_covers')
+ },
clickAddUser() {
this.$toast.info('Under Construction: User management coming soon.')
},
diff --git a/client/plugins/init.client.js b/client/plugins/init.client.js
index 15d70bac..1ee7a95a 100644
--- a/client/plugins/init.client.js
+++ b/client/plugins/init.client.js
@@ -109,6 +109,21 @@ Vue.prototype.$codeToString = (code) => {
return finalform
}
+function cleanString(str, availableChars) {
+ var _str = str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
+ var cleaned = ''
+ for (let i = 0; i < _str.length; i++) {
+ cleaned += availableChars.indexOf(str[i]) < 0 ? '' : str[i]
+ }
+ return cleaned
+}
+
+export const cleanFilterString = (str) => {
+ var _str = str.toLowerCase().replace(/ /g, '_')
+ _str = cleanString(_str, "0123456789abcdefghijklmnopqrstuvwxyz")
+ return _str
+}
+
function loadImageBlob(uri) {
return new Promise((resolve) => {
const img = document.createElement('img')
diff --git a/client/store/audiobooks.js b/client/store/audiobooks.js
index 53ff5a4d..bc8fd908 100644
--- a/client/store/audiobooks.js
+++ b/client/store/audiobooks.js
@@ -1,4 +1,5 @@
import { sort } from '@/assets/fastSort'
+import { cleanFilterString } from '@/plugins/init.client'
const STANDARD_GENRES = ['adventure', 'autobiography', 'biography', 'childrens', 'comedy', 'crime', 'dystopian', 'fantasy', 'fiction', 'health', 'history', 'horror', 'mystery', 'new_adult', 'nonfiction', 'philosophy', 'politics', 'religion', 'romance', 'sci-fi', 'self-help', 'short_story', 'technology', 'thriller', 'true_crime', 'western', 'young_adult']
@@ -16,13 +17,14 @@ export const getters = {
var settings = rootState.user.settings || {}
var filterBy = settings.filterBy || ''
- var searchGroups = ['genres', 'tags', 'series']
+ var searchGroups = ['genres', 'tags', 'series', 'authors']
var group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
if (group) {
var filter = filterBy.replace(`${group}.`, '')
if (group === 'genres') filtered = filtered.filter(ab => ab.book && ab.book.genres.includes(filter))
else if (group === 'tags') filtered = filtered.filter(ab => ab.tags.includes(filter))
else if (group === 'series') filtered = filtered.filter(ab => ab.book && ab.book.series === filter)
+ else if (group === 'authors') filtered = filtered.filter(ab => ab.book && ab.book.author === filter)
}
return filtered
},
@@ -35,6 +37,10 @@ export const getters = {
// Supports dot notation strings i.e. "book.title"
return settings.orderBy.split('.').reduce((a, b) => a[b], ab)
})
+ },
+ getUniqueAuthors: (state) => {
+ var _authors = state.audiobooks.filter(ab => !!(ab.book && ab.book.author)).map(ab => ab.book.author)
+ return [...new Set(_authors)]
}
}
diff --git a/client/store/index.js b/client/store/index.js
index 67c912f7..8bcc2fe0 100644
--- a/client/store/index.js
+++ b/client/store/index.js
@@ -5,7 +5,9 @@ export const state = () => ({
selectedAudiobook: null,
playOnLoad: false,
isScanning: false,
+ isScanningCovers: false,
scanProgress: null,
+ coverScanProgress: null,
developerMode: false
})
@@ -41,9 +43,16 @@ export const mutations = {
setIsScanning(state, isScanning) {
state.isScanning = isScanning
},
- setScanProgress(state, progress) {
- if (progress > 0) state.isScanning = true
- state.scanProgress = progress
+ setScanProgress(state, scanProgress) {
+ if (scanProgress && scanProgress.progress > 0) state.isScanning = true
+ state.scanProgress = scanProgress
+ },
+ setIsScanningCovers(state, isScanningCovers) {
+ state.isScanningCovers = isScanningCovers
+ },
+ setCoverScanProgress(state, coverScanProgress) {
+ if (coverScanProgress && coverScanProgress.progress > 0) state.isScanningCovers = true
+ state.coverScanProgress = coverScanProgress
},
setDeveloperMode(state, val) {
state.developerMode = val
diff --git a/client/tailwind.config.js b/client/tailwind.config.js
index 570f53ae..a5272496 100644
--- a/client/tailwind.config.js
+++ b/client/tailwind.config.js
@@ -16,12 +16,11 @@ module.exports = {
},
colors: {
bg: '#373838',
- primary: '#262626',
+ primary: '#232323',
accent: '#1ad691',
error: '#FF5252',
info: '#2196F3',
success: '#4CAF50',
- successDark: '#3b8a3e',
warning: '#FB8C00',
'black-50': '#bbbbbb',
'black-100': '#666666',
diff --git a/package.json b/package.json
index 5b28c1db..9a743080 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
- "version": "0.9.72-beta",
+ "version": "0.9.73-beta",
"description": "Self-hosted audiobook server for managing and playing audiobooks.",
"main": "index.js",
"scripts": {
@@ -26,4 +26,4 @@
"socket.io": "^4.1.3"
},
"devDependencies": {}
-}
+}
\ No newline at end of file
diff --git a/server/Audiobook.js b/server/Audiobook.js
index 61ec2824..1908c55e 100644
--- a/server/Audiobook.js
+++ b/server/Audiobook.js
@@ -62,6 +62,10 @@ class Audiobook {
return this.book ? this.book.author : 'Unknown'
}
+ get authorLF() {
+ return this.book ? this.book.authorLF : null
+ }
+
get genres() {
return this.book ? this.book.genres || [] : []
}
@@ -136,9 +140,9 @@ class Audiobook {
toJSONExpanded() {
return {
id: this.id,
- title: this.title,
- author: this.author,
- cover: this.cover,
+ // title: this.title,
+ // author: this.author,
+ // cover: this.cover,
path: this.path,
fullPath: this.fullPath,
addedAt: this.addedAt,
@@ -306,6 +310,10 @@ class Audiobook {
return hasUpdates
}
+ syncAuthorNames(audiobookData) {
+ return this.book.syncAuthorNames(audiobookData.authorFL, audiobookData.authorLF)
+ }
+
isSearchMatch(search) {
return this.book.isSearchMatch(search.toLowerCase().trim())
}
diff --git a/server/Book.js b/server/Book.js
index 4e3849ab..b5275c6e 100644
--- a/server/Book.js
+++ b/server/Book.js
@@ -4,7 +4,10 @@ class Book {
this.olid = null
this.title = null
this.author = null
+ this.authorFL = null
+ this.authorLF = null
this.series = null
+ this.volumeNumber = null
this.publishYear = null
this.publisher = null
this.description = null
@@ -24,7 +27,10 @@ class Book {
this.olid = book.olid
this.title = book.title
this.author = book.author
+ this.authorFL = book.authorFL || null
+ this.authorLF = book.authorLF || null
this.series = book.series
+ this.volumeNumber = book.volumeNumber || null
this.publishYear = book.publishYear
this.publisher = book.publisher
this.description = book.description
@@ -37,7 +43,10 @@ class Book {
olid: this.olid,
title: this.title,
author: this.author,
+ authorFL: this.authorFL,
+ authorLF: this.authorLF,
series: this.series,
+ volumeNumber: this.volumeNumber,
publishYear: this.publishYear,
publisher: this.publisher,
description: this.description,
@@ -50,7 +59,10 @@ class Book {
this.olid = data.olid || null
this.title = data.title || null
this.author = data.author || null
+ this.authorLF = data.authorLF || null
+ this.authorFL = data.authorFL || null
this.series = data.series || null
+ this.volumeNumber = data.volumeNumber || null
this.publishYear = data.publishYear || null
this.description = data.description || null
this.cover = data.cover || null
@@ -83,7 +95,20 @@ class Book {
hasUpdates = true
}
}
- return true
+ return hasUpdates
+ }
+
+ syncAuthorNames(authorFL, authorLF) {
+ var hasUpdates = false
+ if (authorFL !== this.authorFL) {
+ this.authorFL = authorFL
+ hasUpdates = true
+ }
+ if (authorLF !== this.authorLF) {
+ this.authorLF = authorLF
+ hasUpdates = true
+ }
+ return hasUpdates
}
isSearchMatch(search) {
diff --git a/server/BookFinder.js b/server/BookFinder.js
index a3245918..5e05cd09 100644
--- a/server/BookFinder.js
+++ b/server/BookFinder.js
@@ -26,7 +26,17 @@ class BookFinder {
return title
}
+ replaceAccentedChars(str) {
+ try {
+ return str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
+ } catch (error) {
+ Logger.error('[BookFinder] str normalize error', error)
+ return str
+ }
+ }
+
cleanTitleForCompares(title) {
+ if (!title) return ''
// Remove subtitle if there (i.e. "Cool Book: Coolest Ever" becomes "Cool Book")
var stripped = this.stripSubtitle(title)
@@ -35,16 +45,34 @@ class BookFinder {
// Remove single quotes (i.e. "Ender's Game" becomes "Enders Game")
cleaned = cleaned.replace(/'/g, '')
+ cleaned = this.replaceAccentedChars(cleaned)
+ return cleaned.toLowerCase()
+ }
+
+ cleanAuthorForCompares(author) {
+ if (!author) return ''
+ var cleaned = this.replaceAccentedChars(author)
return cleaned.toLowerCase()
}
filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance) {
var searchTitle = this.cleanTitleForCompares(title)
+ var searchAuthor = this.cleanAuthorForCompares(author)
return books.map(b => {
b.cleanedTitle = this.cleanTitleForCompares(b.title)
b.titleDistance = levenshteinDistance(b.cleanedTitle, title)
if (author) {
- b.authorDistance = levenshteinDistance(b.author || '', author)
+ if (!b.author) {
+ b.authorDistance = author.length
+ } else {
+ b.cleanedAuthor = this.cleanAuthorForCompares(b.author)
+
+ var cleanedAuthorDistance = levenshteinDistance(b.cleanedAuthor, searchAuthor)
+ var authorDistance = levenshteinDistance(b.author || '', author)
+ // Use best distance
+ if (cleanedAuthorDistance > authorDistance) b.authorDistance = authorDistance
+ else b.authorDistance = cleanedAuthorDistance
+ }
}
b.totalDistance = b.titleDistance + (b.authorDistance || 0)
b.totalPossibleDistance = b.title.length
@@ -142,7 +170,8 @@ class BookFinder {
async findCovers(provider, title, author, options = {}) {
var searchResults = await this.search(provider, title, author, options)
- console.log('Find Covers search results', searchResults)
+ Logger.info(`[BookFinder] FindCovers search results: ${searchResults.length}`)
+
var covers = []
searchResults.forEach((result) => {
if (result.covers && result.covers.length) {
diff --git a/server/Scanner.js b/server/Scanner.js
index 80338f51..dda4a7ea 100644
--- a/server/Scanner.js
+++ b/server/Scanner.js
@@ -13,6 +13,8 @@ class Scanner {
this.db = db
this.emitter = emitter
+ this.cancelScan = false
+
this.bookFinder = new BookFinder()
}
@@ -34,6 +36,11 @@ class Scanner {
const scanStart = Date.now()
var audiobookDataFound = await getAllAudiobookFiles(this.AudiobookPath)
+ if (this.cancelScan) {
+ this.cancelScan = false
+ return null
+ }
+
var scanResults = {
removed: 0,
updated: 0,
@@ -54,6 +61,10 @@ class Scanner {
scanResults.removed++
this.emitter('audiobook_removed', this.audiobooks[i].toJSONMinified())
}
+ if (this.cancelScan) {
+ this.cancelScan = false
+ return null
+ }
}
for (let i = 0; i < audiobookDataFound.length; i++) {
@@ -109,6 +120,11 @@ class Scanner {
hasUpdates = true
}
+ if (audiobookData.author && existingAudiobook.syncAuthorNames(audiobookData)) {
+ Logger.info(`[Scanner] "${existingAudiobook.title}" author names updated, "${existingAudiobook.authorLF}"`)
+ hasUpdates = true
+ }
+
if (hasUpdates) {
Logger.info(`[Scanner] "${existingAudiobook.title}" was updated - saving`)
existingAudiobook.lastUpdate = Date.now()
@@ -138,10 +154,17 @@ class Scanner {
}
var progress = Math.round(100 * (i + 1) / audiobookDataFound.length)
this.emitter('scan_progress', {
- total: audiobookDataFound.length,
- done: i + 1,
- progress
+ scanType: 'files',
+ progress: {
+ total: audiobookDataFound.length,
+ done: i + 1,
+ progress
+ }
})
+ if (this.cancelScan) {
+ this.cancelScan = false
+ break
+ }
}
const scanElapsed = Math.floor((Date.now() - scanStart) / 1000)
Logger.info(`[Scanned] Finished | ${scanResults.added} added | ${scanResults.updated} updated | ${scanResults.removed} removed | elapsed: ${secondsToTimestamp(scanElapsed)}`)
@@ -161,6 +184,47 @@ class Scanner {
return scanResult
}
+ async scanCovers() {
+ var audiobooksNeedingCover = this.audiobooks.filter(ab => !ab.cover && ab.author)
+ var found = 0
+ var notFound = 0
+ for (let i = 0; i < audiobooksNeedingCover.length; i++) {
+ var audiobook = audiobooksNeedingCover[i]
+ var options = {
+ titleDistance: 2,
+ authorDistance: 2
+ }
+ var results = await this.bookFinder.findCovers('openlibrary', audiobook.title, audiobook.author, options)
+ if (results.length) {
+ Logger.info(`[Scanner] Found best cover for "${audiobook.title}"`)
+ audiobook.book.cover = results[0]
+ await this.db.updateAudiobook(audiobook)
+ found++
+ } else {
+ notFound++
+ }
+
+ var progress = Math.round(100 * (i + 1) / audiobooksNeedingCover.length)
+ this.emitter('scan_progress', {
+ scanType: 'covers',
+ progress: {
+ total: audiobooksNeedingCover.length,
+ done: i + 1,
+ progress
+ }
+ })
+
+ if (this.cancelScan) {
+ this.cancelScan = false
+ break
+ }
+ }
+ return {
+ found,
+ notFound
+ }
+ }
+
async find(req, res) {
var method = req.params.method
var query = req.query
diff --git a/server/Server.js b/server/Server.js
index 628220ec..d2a91e65 100644
--- a/server/Server.js
+++ b/server/Server.js
@@ -42,6 +42,7 @@ class Server {
this.clients = {}
this.isScanning = false
+ this.isScanningCovers = false
this.isInitialized = false
}
@@ -64,13 +65,28 @@ class Server {
Logger.info('[Server] Starting Scan')
this.isScanning = true
this.isInitialized = true
- this.emitter('scan_start')
+ this.emitter('scan_start', 'files')
var results = await this.scanner.scan()
this.isScanning = false
- this.emitter('scan_complete', results)
+ this.emitter('scan_complete', { scanType: 'files', results })
Logger.info('[Server] Scan complete')
}
+ async scanCovers() {
+ Logger.info('[Server] Start cover scan')
+ this.isScanningCovers = true
+ this.emitter('scan_start', 'covers')
+ var results = await this.scanner.scanCovers()
+ this.isScanningCovers = false
+ this.emitter('scan_complete', { scanType: 'covers', results })
+ Logger.info('[Server] Cover scan complete')
+ }
+
+ cancelScan() {
+ if (!this.isScanningCovers && !this.isScanning) return
+ this.scanner.cancelScan = true
+ }
+
async init() {
Logger.info('[Server] Init')
await this.streamManager.removeOrphanStreams()
@@ -149,6 +165,8 @@ class Server {
socket.on('auth', (token) => this.authenticateSocket(socket, token))
socket.on('scan', this.scan.bind(this))
+ socket.on('scan_covers', this.scanCovers.bind(this))
+ socket.on('cancel_scan', this.cancelScan.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))
diff --git a/server/utils/parseAuthors.js b/server/utils/parseAuthors.js
new file mode 100644
index 00000000..1d326254
--- /dev/null
+++ b/server/utils/parseAuthors.js
@@ -0,0 +1,67 @@
+const parseFullName = require('./parseFullName')
+
+function parseName(name) {
+ var parts = parseFullName(name)
+ var firstName = parts.first
+ if (firstName && parts.middle) firstName += ' ' + parts.middle
+
+ return {
+ first_name: firstName,
+ last_name: parts.last
+ }
+}
+
+// Check if this name segment is of the format "Last, First" or "First Last"
+// return true is "Last, First"
+function checkIsALastName(name) {
+ if (!name.includes(' ')) return true // No spaces must be a Last name
+
+ var parsed = parseFullName(name)
+ if (!parsed.first) return true // had spaces but not a first name i.e. "von Mises", must be last name only
+
+ return false
+}
+
+module.exports = (author) => {
+ if (!author) return null
+ var splitByComma = author.split(', ')
+
+ var authors = []
+
+ // 1 author FIRST LAST
+ if (splitByComma.length === 1) {
+ authors.push(parseName(author))
+ } else {
+ var firstChunkIsALastName = checkIsALastName(splitByComma[0])
+ var isEvenNum = splitByComma.length % 2 === 0
+
+ if (!isEvenNum && firstChunkIsALastName) {
+ // console.error('Multi-author LAST,FIRST entry has a straggler (could be roman numerals or a suffix), ignore it', splitByComma[splitByComma.length - 1])
+ splitByComma = splitByComma.slice(0, splitByComma.length - 1)
+ }
+
+ if (firstChunkIsALastName) {
+ var numAuthors = splitByComma.length / 2
+ for (let i = 0; i < numAuthors; i++) {
+ var last = splitByComma.shift()
+ var first = splitByComma.shift()
+ authors.push({
+ first_name: first,
+ last_name: last
+ })
+ }
+ } else {
+ splitByComma.forEach((segment) => {
+ authors.push(parseName(segment))
+ })
+ }
+ }
+
+ var firstLast = authors.length ? authors.map(a => a.first_name ? `${a.first_name} ${a.last_name}` : a.last_name).join(', ') : ''
+ var lastFirst = authors.length ? authors.map(a => a.first_name ? `${a.last_name}, ${a.first_name}` : a.last_name).join(', ') : ''
+ return {
+ authorFL: firstLast,
+ authorLF: lastFirst,
+ authorsParsed: authors
+ }
+}
\ No newline at end of file
diff --git a/server/utils/parseFullName.js b/server/utils/parseFullName.js
new file mode 100644
index 00000000..daf2de27
--- /dev/null
+++ b/server/utils/parseFullName.js
@@ -0,0 +1,346 @@
+
+
+// https://github.com/RateGravity/parse-full-name/blob/master/index.js
+module.exports = (nameToParse, partToReturn, fixCase, stopOnError, useLongLists) => {
+
+ var i, j, k, l, m, n, part, comma, titleList, suffixList, prefixList, regex,
+ partToCheck, partFound, partsFoundCount, firstComma, remainingCommas,
+ nameParts = [], nameCommas = [null], partsFound = [],
+ conjunctionList = ['&', 'and', 'et', 'e', 'of', 'the', 'und', 'y'],
+ parsedName = {
+ title: '', first: '', middle: '', last: '', nick: '', suffix: '', error: []
+ };
+
+ // Validate inputs, or set to defaults
+ partToReturn = partToReturn && ['title', 'first', 'middle', 'last', 'nick',
+ 'suffix', 'error'].indexOf(partToReturn.toLowerCase()) > -1 ?
+ partToReturn.toLowerCase() : 'all';
+ // 'all' = return object with all parts, others return single part
+ if (fixCase === false) fixCase = 0;
+ if (fixCase === true) fixCase = 1;
+ fixCase = fixCase !== 'undefined' && (fixCase === 0 || fixCase === 1) ?
+ fixCase : -1; // -1 = fix case only if input is all upper or lowercase
+ if (stopOnError === true) stopOnError = 1;
+ stopOnError = stopOnError && stopOnError === 1 ? 1 : 0;
+ // false = output warnings on parse error, but don't stop
+ if (useLongLists === true) useLongLists = 1;
+ useLongLists = useLongLists && useLongLists === 1 ? 1 : 0; // 0 = short lists
+
+ // If stopOnError = 1, throw error, otherwise return error messages in array
+ function handleError(errorMessage) {
+ if (stopOnError) {
+ throw 'Error: ' + errorMessage;
+ } else {
+ parsedName.error.push('Error: ' + errorMessage);
+ }
+ }
+
+ // If fixCase = 1, fix case of parsedName parts before returning
+ function fixParsedNameCase(fixedCaseName, fixCaseNow) {
+ var forceCaseList = ['e', 'y', 'av', 'af', 'da', 'dal', 'de', 'del', 'der', 'di',
+ 'la', 'le', 'van', 'der', 'den', 'vel', 'von', 'II', 'III', 'IV', 'J.D.', 'LL.M.',
+ 'M.D.', 'D.O.', 'D.C.', 'Ph.D.'];
+ var forceCaseListIndex;
+ var namePartLabels = [];
+ var namePartWords;
+ if (fixCaseNow) {
+ namePartLabels = Object.keys(parsedName)
+ .filter(function (v) { return v !== 'error'; });
+ for (i = 0, l = namePartLabels.length; i < l; i++) {
+ if (fixedCaseName[namePartLabels[i]]) {
+ namePartWords = (fixedCaseName[namePartLabels[i]] + '').split(' ');
+ for (j = 0, m = namePartWords.length; j < m; j++) {
+ forceCaseListIndex = forceCaseList
+ .map(function (v) { return v.toLowerCase(); })
+ .indexOf(namePartWords[j].toLowerCase());
+ if (forceCaseListIndex > -1) { // Set case of words in forceCaseList
+ namePartWords[j] = forceCaseList[forceCaseListIndex];
+ } else if (namePartWords[j].length === 1) { // Uppercase initials
+ namePartWords[j] = namePartWords[j].toUpperCase();
+ } else if (
+ namePartWords[j].length > 2 &&
+ namePartWords[j].slice(0, 1) ===
+ namePartWords[j].slice(0, 1).toUpperCase() &&
+ namePartWords[j].slice(1, 2) ===
+ namePartWords[j].slice(1, 2).toLowerCase() &&
+ namePartWords[j].slice(2) ===
+ namePartWords[j].slice(2).toUpperCase()
+ ) { // Detect McCASE and convert to McCase
+ namePartWords[j] = namePartWords[j].slice(0, 3) +
+ namePartWords[j].slice(3).toLowerCase();
+ } else if (
+ namePartLabels[j] === 'suffix' &&
+ nameParts[j].slice(-1) !== '.' &&
+ !suffixList.indexOf(nameParts[j].toLowerCase())
+ ) { // Convert suffix abbreviations to UPPER CASE
+ if (namePartWords[j] === namePartWords[j].toLowerCase()) {
+ namePartWords[j] = namePartWords[j].toUpperCase();
+ }
+ } else { // Convert to Title Case
+ namePartWords[j] = namePartWords[j].slice(0, 1).toUpperCase() +
+ namePartWords[j].slice(1).toLowerCase();
+ }
+ }
+ fixedCaseName[namePartLabels[i]] = namePartWords.join(' ');
+ }
+ }
+ }
+ return fixedCaseName;
+ }
+
+ // If no input name, or input name is not a string, abort
+ if (!nameToParse || typeof nameToParse !== 'string') {
+ handleError('No input');
+ parsedName = fixParsedNameCase(parsedName, fixCase);
+ return partToReturn === 'all' ? parsedName : parsedName[partToReturn];
+ } else {
+ nameToParse = nameToParse.trim();
+ }
+
+ // Auto-detect fixCase: fix if nameToParse is all upper or all lowercase
+ if (fixCase === -1) {
+ fixCase = (
+ nameToParse === nameToParse.toUpperCase() ||
+ nameToParse === nameToParse.toLowerCase() ? 1 : 0
+ );
+ }
+
+ // Initilize lists of prefixs, suffixs, and titles to detect
+ // Note: These list entries must be all lowercase
+ if (useLongLists) {
+ suffixList = ['esq', 'esquire', 'jr', 'jnr', 'sr', 'snr', '2', 'ii', 'iii', 'iv',
+ 'v', 'clu', 'chfc', 'cfp', 'md', 'phd', 'j.d.', 'll.m.', 'm.d.', 'd.o.', 'd.c.',
+ 'p.c.', 'ph.d.'];
+ prefixList = ['a', 'ab', 'antune', 'ap', 'abu', 'al', 'alm', 'alt', 'bab', 'bäck',
+ 'bar', 'bath', 'bat', 'beau', 'beck', 'ben', 'berg', 'bet', 'bin', 'bint', 'birch',
+ 'björk', 'björn', 'bjur', 'da', 'dahl', 'dal', 'de', 'degli', 'dele', 'del',
+ 'della', 'der', 'di', 'dos', 'du', 'e', 'ek', 'el', 'escob', 'esch', 'fleisch',
+ 'fitz', 'fors', 'gott', 'griff', 'haj', 'haug', 'holm', 'ibn', 'kauf', 'kil',
+ 'koop', 'kvarn', 'la', 'le', 'lind', 'lönn', 'lund', 'mac', 'mhic', 'mic', 'mir',
+ 'na', 'naka', 'neder', 'nic', 'ni', 'nin', 'nord', 'norr', 'ny', 'o', 'ua', 'ui\'',
+ 'öfver', 'ost', 'över', 'öz', 'papa', 'pour', 'quarn', 'skog', 'skoog', 'sten',
+ 'stor', 'ström', 'söder', 'ter', 'ter', 'tre', 'türk', 'van', 'väst', 'väster',
+ 'vest', 'von'];
+ titleList = ['mr', 'mrs', 'ms', 'miss', 'dr', 'herr', 'monsieur', 'hr', 'frau',
+ 'a v m', 'admiraal', 'admiral', 'air cdre', 'air commodore', 'air marshal',
+ 'air vice marshal', 'alderman', 'alhaji', 'ambassador', 'baron', 'barones',
+ 'brig', 'brig gen', 'brig general', 'brigadier', 'brigadier general',
+ 'brother', 'canon', 'capt', 'captain', 'cardinal', 'cdr', 'chief', 'cik', 'cmdr',
+ 'coach', 'col', 'col dr', 'colonel', 'commandant', 'commander', 'commissioner',
+ 'commodore', 'comte', 'comtessa', 'congressman', 'conseiller', 'consul',
+ 'conte', 'contessa', 'corporal', 'councillor', 'count', 'countess',
+ 'crown prince', 'crown princess', 'dame', 'datin', 'dato', 'datuk',
+ 'datuk seri', 'deacon', 'deaconess', 'dean', 'dhr', 'dipl ing', 'doctor',
+ 'dott', 'dott sa', 'dr', 'dr ing', 'dra', 'drs', 'embajador', 'embajadora', 'en',
+ 'encik', 'eng', 'eur ing', 'exma sra', 'exmo sr', 'f o', 'father',
+ 'first lieutient', 'first officer', 'flt lieut', 'flying officer', 'fr',
+ 'frau', 'fraulein', 'fru', 'gen', 'generaal', 'general', 'governor', 'graaf',
+ 'gravin', 'group captain', 'grp capt', 'h e dr', 'h h', 'h m', 'h r h', 'hajah',
+ 'haji', 'hajim', 'her highness', 'her majesty', 'herr', 'high chief',
+ 'his highness', 'his holiness', 'his majesty', 'hon', 'hr', 'hra', 'ing', 'ir',
+ 'jonkheer', 'judge', 'justice', 'khun ying', 'kolonel', 'lady', 'lcda', 'lic',
+ 'lieut', 'lieut cdr', 'lieut col', 'lieut gen', 'lord', 'm', 'm l', 'm r',
+ 'madame', 'mademoiselle', 'maj gen', 'major', 'master', 'mevrouw', 'miss',
+ 'mlle', 'mme', 'monsieur', 'monsignor', 'mr', 'mrs', 'ms', 'mstr', 'nti', 'pastor',
+ 'president', 'prince', 'princess', 'princesse', 'prinses', 'prof', 'prof dr',
+ 'prof sir', 'professor', 'puan', 'puan sri', 'rabbi', 'rear admiral', 'rev',
+ 'rev canon', 'rev dr', 'rev mother', 'reverend', 'rva', 'senator', 'sergeant',
+ 'sheikh', 'sheikha', 'sig', 'sig na', 'sig ra', 'sir', 'sister', 'sqn ldr', 'sr',
+ 'sr d', 'sra', 'srta', 'sultan', 'tan sri', 'tan sri dato', 'tengku', 'teuku',
+ 'than puying', 'the hon dr', 'the hon justice', 'the hon miss', 'the hon mr',
+ 'the hon mrs', 'the hon ms', 'the hon sir', 'the very rev', 'toh puan', 'tun',
+ 'vice admiral', 'viscount', 'viscountess', 'wg cdr', 'ind', 'misc', 'mx'];
+ } else {
+ suffixList = ['esq', 'esquire', 'jr', 'jnr', 'sr', 'snr', '2', 'ii', 'iii', 'iv',
+ 'md', 'phd', 'j.d.', 'll.m.', 'm.d.', 'd.o.', 'd.c.', 'p.c.', 'ph.d.'];
+ prefixList = ['ab', 'bar', 'bin', 'da', 'dal', 'de', 'de la', 'del', 'della', 'der',
+ 'di', 'du', 'ibn', 'l\'', 'la', 'le', 'san', 'st', 'st.', 'ste', 'ter', 'van',
+ 'van de', 'van der', 'van den', 'vel', 'ver', 'vere', 'von'];
+ titleList = ['dr', 'miss', 'mr', 'mrs', 'ms', 'prof', 'sir', 'frau', 'herr', 'hr',
+ 'monsieur', 'captain', 'doctor', 'judge', 'officer', 'professor', 'ind', 'misc',
+ 'mx'];
+ }
+
+ // Nickname: remove and store parts with surrounding punctuation as nicknames
+ regex = /\s(?:[‘’']([^‘’']+)[‘’']|[“”"]([^“”"]+)[“”"]|\[([^\]]+)\]|\(([^\)]+)\)),?\s/g;
+ partFound = (' ' + nameToParse + ' ').match(regex);
+ if (partFound) partsFound = partsFound.concat(partFound);
+ partsFoundCount = partsFound.length;
+ if (partsFoundCount === 1) {
+ parsedName.nick = partsFound[0].slice(2).slice(0, -2);
+ if (parsedName.nick.slice(-1) === ',') {
+ parsedName.nick = parsedName.nick.slice(0, -1);
+ }
+ nameToParse = (' ' + nameToParse + ' ').replace(partsFound[0], ' ').trim();
+ partsFound = [];
+ } else if (partsFoundCount > 1) {
+ handleError(partsFoundCount + ' nicknames found');
+ for (i = 0; i < partsFoundCount; i++) {
+ nameToParse = (' ' + nameToParse + ' ')
+ .replace(partsFound[i], ' ').trim();
+ partsFound[i] = partsFound[i].slice(2).slice(0, -2);
+ if (partsFound[i].slice(-1) === ',') {
+ partsFound[i] = partsFound[i].slice(0, -1);
+ }
+ }
+ parsedName.nick = partsFound.join(', ');
+ partsFound = [];
+ }
+ if (!nameToParse.trim().length) {
+ parsedName = fixParsedNameCase(parsedName, fixCase);
+ return partToReturn === 'all' ? parsedName : parsedName[partToReturn];
+ }
+
+ // Split remaining nameToParse into parts, remove and store preceding commas
+ for (i = 0, n = nameToParse.split(' '), l = n.length; i < l; i++) {
+ part = n[i];
+ comma = null;
+ if (part.slice(-1) === ',') {
+ comma = ',';
+ part = part.slice(0, -1);
+ }
+ nameParts.push(part);
+ nameCommas.push(comma);
+ }
+
+ // Suffix: remove and store matching parts as suffixes
+ for (l = nameParts.length, i = l - 1; i > 0; i--) {
+ partToCheck = (nameParts[i].slice(-1) === '.' ?
+ nameParts[i].slice(0, -1).toLowerCase() : nameParts[i].toLowerCase());
+ if (
+ suffixList.indexOf(partToCheck) > -1 ||
+ suffixList.indexOf(partToCheck + '.') > -1
+ ) {
+ partsFound = nameParts.splice(i, 1).concat(partsFound);
+ if (nameCommas[i] === ',') { // Keep comma, either before or after
+ nameCommas.splice(i + 1, 1);
+ } else {
+ nameCommas.splice(i, 1);
+ }
+ }
+ }
+ partsFoundCount = partsFound.length;
+ if (partsFoundCount === 1) {
+ parsedName.suffix = partsFound[0];
+ partsFound = [];
+ } else if (partsFoundCount > 1) {
+ handleError(partsFoundCount + ' suffixes found');
+ parsedName.suffix = partsFound.join(', ');
+ partsFound = [];
+ }
+ if (!nameParts.length) {
+ parsedName = fixParsedNameCase(parsedName, fixCase);
+ return partToReturn === 'all' ? parsedName : parsedName[partToReturn];
+ }
+
+ // Title: remove and store matching parts as titles
+ for (l = nameParts.length, i = l - 1; i >= 0; i--) {
+ partToCheck = (nameParts[i].slice(-1) === '.' ?
+ nameParts[i].slice(0, -1).toLowerCase() : nameParts[i].toLowerCase());
+ if (
+ titleList.indexOf(partToCheck) > -1 ||
+ titleList.indexOf(partToCheck + '.') > -1
+ ) {
+ partsFound = nameParts.splice(i, 1).concat(partsFound);
+ if (nameCommas[i] === ',') { // Keep comma, either before or after
+ nameCommas.splice(i + 1, 1);
+ } else {
+ nameCommas.splice(i, 1);
+ }
+ }
+ }
+ partsFoundCount = partsFound.length;
+ if (partsFoundCount === 1) {
+ parsedName.title = partsFound[0];
+ partsFound = [];
+ } else if (partsFoundCount > 1) {
+ handleError(partsFoundCount + ' titles found');
+ parsedName.title = partsFound.join(', ');
+ partsFound = [];
+ }
+ if (!nameParts.length) {
+ parsedName = fixParsedNameCase(parsedName, fixCase);
+ return partToReturn === 'all' ? parsedName : parsedName[partToReturn];
+ }
+
+ // Join name prefixes to following names
+ if (nameParts.length > 1) {
+ for (i = nameParts.length - 2; i >= 0; i--) {
+ if (prefixList.indexOf(nameParts[i].toLowerCase()) > -1) {
+ nameParts[i] = nameParts[i] + ' ' + nameParts[i + 1];
+ nameParts.splice(i + 1, 1);
+ nameCommas.splice(i + 1, 1);
+ }
+ }
+ }
+
+ // Join conjunctions to surrounding names
+ if (nameParts.length > 2) {
+ for (i = nameParts.length - 3; i >= 0; i--) {
+ if (conjunctionList.indexOf(nameParts[i + 1].toLowerCase()) > -1) {
+ nameParts[i] = nameParts[i] + ' ' + nameParts[i + 1] + ' ' + nameParts[i + 2];
+ nameParts.splice(i + 1, 2);
+ nameCommas.splice(i + 1, 2);
+ i--;
+ }
+ }
+ }
+
+ // Suffix: remove and store items after extra commas as suffixes
+ nameCommas.pop();
+ firstComma = nameCommas.indexOf(',');
+ remainingCommas = nameCommas.filter(function (v) { return v !== null; }).length;
+ if (firstComma > 1 || remainingCommas > 1) {
+ for (i = nameParts.length - 1; i >= 2; i--) {
+ if (nameCommas[i] === ',') {
+ partsFound = nameParts.splice(i, 1).concat(partsFound);
+ nameCommas.splice(i, 1);
+ remainingCommas--;
+ } else {
+ break;
+ }
+ }
+ }
+ if (partsFound.length) {
+ if (parsedName.suffix) {
+ partsFound = [parsedName.suffix].concat(partsFound);
+ }
+ parsedName.suffix = partsFound.join(', ');
+ partsFound = [];
+ }
+
+ // Last name: remove and store last name
+ if (remainingCommas > 0) {
+ if (remainingCommas > 1) {
+ handleError((remainingCommas - 1) + ' extra commas found');
+ }
+ // Remove and store all parts before first comma as last name
+ if (nameCommas.indexOf(',')) {
+ parsedName.last = nameParts.splice(0, nameCommas.indexOf(',')).join(' ');
+ nameCommas.splice(0, nameCommas.indexOf(','));
+ }
+ } else {
+ // Remove and store last part as last name
+ parsedName.last = nameParts.pop();
+ }
+ if (!nameParts.length) {
+ parsedName = fixParsedNameCase(parsedName, fixCase);
+ return partToReturn === 'all' ? parsedName : parsedName[partToReturn];
+ }
+
+ // First name: remove and store first part as first name
+ parsedName.first = nameParts.shift();
+ if (!nameParts.length) {
+ parsedName = fixParsedNameCase(parsedName, fixCase);
+ return partToReturn === 'all' ? parsedName : parsedName[partToReturn];
+ }
+
+ // Middle name: store all remaining parts as middle name
+ if (nameParts.length > 2) {
+ handleError(nameParts.length + ' middle names');
+ }
+ parsedName.middle = nameParts.join(' ');
+
+ parsedName = fixParsedNameCase(parsedName, fixCase);
+ return partToReturn === 'all' ? parsedName : parsedName[partToReturn];
+};
\ No newline at end of file
diff --git a/server/utils/scandir.js b/server/utils/scandir.js
index 4cb1537b..0439d571 100644
--- a/server/utils/scandir.js
+++ b/server/utils/scandir.js
@@ -1,6 +1,7 @@
const Path = require('path')
const dir = require('node-dir')
const Logger = require('../Logger')
+const parseAuthors = require('./parseAuthors')
const { cleanString } = require('./index')
const AUDIOBOOK_PARTS_FORMATS = ['m4b', 'mp3']
@@ -74,6 +75,14 @@ async function getAllAudiobookFiles(abRootPath) {
parts: [],
otherFiles: []
}
+ if (author) {
+ var parsedAuthors = parseAuthors(author)
+ if (parsedAuthors) {
+ var { authorLF, authorFL } = parsedAuthors
+ audiobooks[path].authorLF = authorLF || null
+ audiobooks[path].authorFL = authorFL || null
+ }
+ }
}
var filetype = getFileType(pathformat.ext)