{{ name }}
+{{ description }}
+Loading...
+Update Author Details
+Audio Tracks
+Files
+All Files
{{ allFiles.length }} diff --git a/server/ApiController.js b/server/ApiController.js index 6b7fc4a1..0a5eef0d 100644 --- a/server/ApiController.js +++ b/server/ApiController.js @@ -8,6 +8,7 @@ const { isObject, getId } = require('./utils/index') const audioFileScanner = require('./utils/audioFileScanner') const BookFinder = require('./BookFinder') +const AuthorController = require('./AuthorController') const Library = require('./objects/Library') const User = require('./objects/User') @@ -29,6 +30,7 @@ class ApiController { this.MetadataPath = MetadataPath this.bookFinder = new BookFinder() + this.authorController = new AuthorController(this.MetadataPath) this.router = express() this.init() @@ -88,6 +90,13 @@ class ApiController { this.router.patch('/collection/:id', this.updateUserCollection.bind(this)) this.router.delete('/collection/:id', this.deleteUserCollection.bind(this)) + this.router.get('/authors', this.getAuthors.bind(this)) + this.router.get('/authors/search', this.searchAuthor.bind(this)) + this.router.get('/authors/:id', this.getAuthor.bind(this)) + this.router.post('/authors', this.createAuthor.bind(this)) + this.router.patch('/authors/:id', this.updateAuthor.bind(this)) + this.router.delete('/authors/:id', this.deleteAuthor.bind(this)) + this.router.patch('/serverSettings', this.updateServerSettings.bind(this)) this.router.delete('/backup/:id', this.deleteBackup.bind(this)) @@ -897,6 +906,63 @@ class ApiController { res.sendStatus(200) } + async getAuthors(req, res) { + var authors = this.db.authors.filter(p => p.isAuthor) + res.json(authors) + } + + async getAuthor(req, res) { + var author = this.db.authors.find(p => p.id === req.params.id) + if (!author) { + return res.status(404).send('Author not found') + } + res.json(author.toJSON()) + } + + async searchAuthor(req, res) { + var query = req.query.q + var author = await this.authorController.findAuthorByName(query) + res.json(author) + } + + async createAuthor(req, res) { + var author = await this.authorController.createAuthor(req.body) + if (!author) { + return res.status(500).send('Failed to create author') + } + + await this.db.insertEntity('author', author) + this.emitter('author_added', author.toJSON()) + res.json(author) + } + + async updateAuthor(req, res) { + var author = this.db.authors.find(p => p.id === req.params.id) + if (!author) { + return res.status(404).send('Author not found') + } + + var wasUpdated = author.update(req.body) + if (wasUpdated) { + await this.db.updateEntity('author', author) + this.emitter('author_updated', author.toJSON()) + } + res.json(author) + } + + async deleteAuthor(req, res) { + var author = this.db.authors.find(p => p.id === req.params.id) + if (!author) { + return res.status(404).send('Author not found') + } + + var authorJson = author.toJSON() + + await this.db.removeEntity('author', author.id) + this.emitter('author_removed', authorJson) + res.sendStatus(200) + } + async updateServerSettings(req, res) { if (!req.user.isRoot) { Logger.error('User other than root attempting to update server settings', req.user) diff --git a/server/AuthorController.js b/server/AuthorController.js new file mode 100644 index 00000000..d6bf398f --- /dev/null +++ b/server/AuthorController.js @@ -0,0 +1,110 @@ +const fs = require('fs-extra') +const Logger = require('./Logger') +const Path = require('path') +const Author = require('./objects/Author') +const Audnexus = require('./providers/Audnexus') + +const { downloadFile } = require('./utils/fileUtils') + +class AuthorController { + constructor(MetadataPath) { + this.MetadataPath = MetadataPath + this.AuthorPath = Path.join(MetadataPath, 'authors') + + this.audnexus = new Audnexus() + } + + async downloadImage(url, outputPath) { + return downloadFile(url, outputPath).then(() => true).catch((error) => { + Logger.error('[AuthorController] Failed to download author image', error) + return null + }) + } + + async findAuthorByName(name, options = {}) { + if (!name) return null + const maxLevenshtein = !isNaN(options.maxLevenshtein) ? Number(options.maxLevenshtein) : 2 + + var author = await this.audnexus.findAuthorByName(name, maxLevenshtein) + if (!author || !author.name) { + return null + } + return author + } + + async createAuthor(payload) { + if (!payload || !payload.name) return null + + var authorDir = Path.posix.join(this.AuthorPath, payload.name) + var relAuthorDir = Path.posix.join('/metadata', 'authors', payload.name) + + if (payload.image && payload.image.startsWith('http')) { + await fs.ensureDir(authorDir) + + var imageExtension = payload.image.toLowerCase().split('.').pop() + var ext = imageExtension === 'png' ? 'png' : 'jpg' + var filename = 'photo.' + ext + var outputPath = Path.posix.join(authorDir, filename) + var relPath = Path.posix.join(relAuthorDir, filename) + + var success = await this.downloadImage(payload.image, outputPath) + if (!success) { + await fs.rmdir(authorDir).catch((error) => { + Logger.error(`[AuthorController] Failed to remove author dir`, authorDir, error) + }) + payload.image = null + payload.imageFullPath = null + } else { + payload.image = relPath + payload.imageFullPath = outputPath + } + } else { + payload.image = null + payload.imageFullPath = null + } + + var author = new Author() + author.setData(payload) + + return author + } + + async getAuthorByName(name, options = {}) { + var authorData = await this.findAuthorByName(name, options) + if (!authorData) return null + + var authorDir = Path.posix.join(this.AuthorPath, authorData.name) + var relAuthorDir = Path.posix.join('/metadata', 'authors', authorData.name) + + if (authorData.image) { + await fs.ensureDir(authorDir) + + var imageExtension = authorData.image.toLowerCase().split('.').pop() + var ext = imageExtension === 'png' ? 'png' : 'jpg' + var filename = 'photo.' + ext + var outputPath = Path.posix.join(authorDir, filename) + var relPath = Path.posix.join(relAuthorDir, filename) + + var success = await this.downloadImage(authorData.image, outputPath) + if (!success) { + await fs.rmdir(authorDir).catch((error) => { + Logger.error(`[AuthorController] Failed to remove author dir`, authorDir, error) + }) + authorData.image = null + authorData.imageFullPath = null + } else { + authorData.image = relPath + authorData.imageFullPath = outputPath + } + } else { + authorData.image = null + authorData.imageFullPath = null + } + + var author = new Author() + author.setData(authorData) + + return author + } +} +module.exports = AuthorController \ No newline at end of file diff --git a/server/CoverController.js b/server/CoverController.js index 1532546f..1b02de2e 100644 --- a/server/CoverController.js +++ b/server/CoverController.js @@ -7,6 +7,7 @@ const imageType = require('image-type') const globals = require('./utils/globals') const { CoverDestination } = require('./utils/constants') +const { downloadFile } = require('./utils/fileUtils') class CoverController { constructor(db, MetadataPath, AudiobookPath) { @@ -123,28 +124,13 @@ class CoverController { } } - async downloadFile(url, filepath) { - Logger.debug(`[CoverController] Starting file download to ${filepath}`) - const writer = fs.createWriteStream(filepath) - const response = await axios({ - url, - method: 'GET', - responseType: 'stream' - }) - response.data.pipe(writer) - return new Promise((resolve, reject) => { - writer.on('finish', resolve) - writer.on('error', reject) - }) - } - async downloadCoverFromUrl(audiobook, url) { try { var { fullPath, relPath } = this.getCoverDirectory(audiobook) await fs.ensureDir(fullPath) var temppath = Path.posix.join(fullPath, 'cover') - var success = await this.downloadFile(url, temppath).then(() => true).catch((err) => { + var success = await downloadFile(url, temppath).then(() => true).catch((err) => { Logger.error(`[CoverController] Download image file failed for "${url}"`, err) return false }) diff --git a/server/Db.js b/server/Db.js index e2c5f450..95122b09 100644 --- a/server/Db.js +++ b/server/Db.js @@ -8,6 +8,7 @@ const Audiobook = require('./objects/Audiobook') const User = require('./objects/User') const UserCollection = require('./objects/UserCollection') const Library = require('./objects/Library') +const Author = require('./objects/Author') const ServerSettings = require('./objects/ServerSettings') class Db { @@ -21,6 +22,7 @@ class Db { this.LibrariesPath = Path.join(ConfigPath, 'libraries') this.SettingsPath = Path.join(ConfigPath, 'settings') this.CollectionsPath = Path.join(ConfigPath, 'collections') + this.AuthorsPath = Path.join(ConfigPath, 'authors') this.audiobooksDb = new njodb.Database(this.AudiobooksPath) this.usersDb = new njodb.Database(this.UsersPath) @@ -28,6 +30,7 @@ class Db { this.librariesDb = new njodb.Database(this.LibrariesPath, { datastores: 2 }) this.settingsDb = new njodb.Database(this.SettingsPath, { datastores: 2 }) this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2 }) + this.authorsDb = new njodb.Database(this.AuthorsPath) this.users = [] this.sessions = [] @@ -35,6 +38,7 @@ class Db { this.audiobooks = [] this.settings = [] this.collections = [] + this.authors = [] this.serverSettings = null @@ -49,6 +53,7 @@ class Db { else if (entityName === 'library') return this.librariesDb else if (entityName === 'settings') return this.settingsDb else if (entityName === 'collection') return this.collectionsDb + else if (entityName === 'author') return this.authorsDb return null } @@ -59,6 +64,7 @@ class Db { else if (entityName === 'library') return 'libraries' else if (entityName === 'settings') return 'settings' else if (entityName === 'collection') return 'collections' + else if (entityName === 'author') return 'authors' return null } @@ -96,6 +102,7 @@ class Db { this.librariesDb = new njodb.Database(this.LibrariesPath, { datastores: 2 }) this.settingsDb = new njodb.Database(this.SettingsPath, { datastores: 2 }) this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2 }) + this.authorsDb = new njodb.Database(this.AuthorsPath) return this.init() } @@ -154,7 +161,11 @@ class Db { this.collections = results.data.map(l => new UserCollection(l)) Logger.info(`[DB] ${this.collections.length} Collections Loaded`) }) - await Promise.all([p1, p2, p3, p4, p5]) + var p6 = this.authorsDb.select(() => true).then((results) => { + this.authors = results.data.map(l => new Author(l)) + Logger.info(`[DB] ${this.authors.length} Authors Loaded`) + }) + await Promise.all([p1, p2, p3, p4, p5, p6]) // Update server version in server settings if (this.previousVersion) { diff --git a/server/objects/Author.js b/server/objects/Author.js new file mode 100644 index 00000000..b0aca398 --- /dev/null +++ b/server/objects/Author.js @@ -0,0 +1,72 @@ +const { getId } = require('../utils/index') +const Logger = require('../Logger') + +class Author { + constructor(author = null) { + this.id = null + this.name = null + this.description = null + this.asin = null + this.image = null + this.imageFullPath = null + + this.createdAt = null + this.lastUpdate = null + + if (author) { + this.construct(author) + } + } + + construct(author) { + this.id = author.id + this.name = author.name + this.description = author.description + this.asin = author.asin + this.image = author.image + this.imageFullPath = author.imageFullPath + + this.createdAt = author.createdAt + this.lastUpdate = author.lastUpdate + } + + toJSON() { + return { + id: this.id, + name: this.name, + description: this.description, + asin: this.asin, + image: this.image, + imageFullPath: this.imageFullPath, + createdAt: this.createdAt, + lastUpdate: this.lastUpdate + } + } + + setData(data) { + this.id = data.id ? data.id : getId('per') + this.name = data.name + this.description = data.description + this.asin = data.asin || null + this.image = data.image || null + this.imageFullPath = data.imageFullPath || null + this.createdAt = Date.now() + this.lastUpdate = Date.now() + } + + update(payload) { + var hasUpdates = false + for (const key in payload) { + if (this[key] === undefined) continue; + if (this[key] !== payload[key]) { + hasUpdates = true + this[key] = payload[key] + } + } + if (hasUpdates) { + this.lastUpdate = Date.now() + } + return hasUpdates + } +} +module.exports = Author \ No newline at end of file diff --git a/server/objects/Book.js b/server/objects/Book.js index 39c5adfc..63fbf2e9 100644 --- a/server/objects/Book.js +++ b/server/objects/Book.js @@ -9,6 +9,7 @@ class Book { this.author = null this.authorFL = null this.authorLF = null + this.authors = [] this.narrator = null this.series = null this.volumeNumber = null @@ -51,6 +52,7 @@ class Book { this.title = book.title this.subtitle = book.subtitle || null this.author = book.author + this.authors = (book.authors || []).map(a => ({ ...a })) this.authorFL = book.authorFL || null this.authorLF = book.authorLF || null this.narrator = book.narrator || book.narrarator || null // Mispelled initially... need to catch those @@ -81,6 +83,7 @@ class Book { title: this.title, subtitle: this.subtitle, author: this.author, + authors: this.authors, authorFL: this.authorFL, authorLF: this.authorLF, narrator: this.narrator, @@ -142,6 +145,7 @@ class Book { this.title = data.title || null this.subtitle = data.subtitle || null this.author = data.author || null + this.authors = data.authors || [] this.narrator = data.narrator || data.narrarator || null this.series = data.series || null this.volumeNumber = data.volumeNumber || null diff --git a/server/providers/Audnexus.js b/server/providers/Audnexus.js new file mode 100644 index 00000000..a7d76f67 --- /dev/null +++ b/server/providers/Audnexus.js @@ -0,0 +1,47 @@ +const axios = require('axios') +const { levenshteinDistance } = require('../utils/index') +const Logger = require('../Logger') + +class Audnexus { + constructor() { + this.baseUrl = 'https://api.audnex.us' + } + + authorASINsRequest(name) { + return axios.get(`${this.baseUrl}/authors?name=${name}`).then((res) => { + return res.data || [] + }).catch((error) => { + Logger.error(`[Audnexus] Author ASIN request failed for ${name}`, error) + return [] + }) + } + + authorRequest(asin) { + return axios.get(`${this.baseUrl}/authors/${asin}`).then((res) => { + return res.data + }).catch((error) => { + Logger.error(`[Audnexus] Author request failed for ${asin}`, error) + return null + }) + } + + async findAuthorByName(name, maxLevenshtein = 2) { + Logger.debug(`[Audnexus] Looking up author by name ${name}`) + var asins = await this.authorASINsRequest(name) + var matchingAsin = asins.find(obj => levenshteinDistance(obj.name, name) <= maxLevenshtein) + if (!matchingAsin) { + return null + } + var author = await this.authorRequest(matchingAsin.asin) + if (!author) { + return null + } + return { + asin: author.asin, + description: author.description, + image: author.image, + name: author.name + } + } +} +module.exports = Audnexus \ No newline at end of file diff --git a/server/utils/constants.js b/server/utils/constants.js index b01e2163..d36ce925 100644 --- a/server/utils/constants.js +++ b/server/utils/constants.js @@ -19,4 +19,4 @@ module.exports.LogLevel = { ERROR: 4, FATAL: 5, NOTE: 6 -} \ No newline at end of file +} diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js index fa8a4927..acb6f9b1 100644 --- a/server/utils/fileUtils.js +++ b/server/utils/fileUtils.js @@ -141,4 +141,20 @@ async function recurseFiles(path) { // }) return list } -module.exports.recurseFiles = recurseFiles \ No newline at end of file +module.exports.recurseFiles = recurseFiles + +module.exports.downloadFile = async (url, filepath) => { + Logger.debug(`[fileUtils] Downloading file to ${filepath}`) + + const writer = fs.createWriteStream(filepath) + const response = await axios({ + url, + method: 'GET', + responseType: 'stream' + }) + response.data.pipe(writer) + return new Promise((resolve, reject) => { + writer.on('finish', resolve) + writer.on('error', reject) + }) +} \ No newline at end of file