Add:Podcast iTunes search api and iTunes provider

This commit is contained in:
advplyr 2022-03-06 16:32:04 -06:00
parent 2a4cbd48b8
commit 43f48b65f8
7 changed files with 81 additions and 14 deletions

View file

@ -0,0 +1,109 @@
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 AuthorFinder {
constructor() {
this.AuthorPath = Path.join(global.MetadataPath, 'authors')
this.audnexus = new Audnexus()
}
async downloadImage(url, outputPath) {
return downloadFile(url, outputPath).then(() => true).catch((error) => {
Logger.error('[AuthorFinder] 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(`[AuthorFinder] 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(`[AuthorFinder] 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 = AuthorFinder

View file

@ -0,0 +1,222 @@
const OpenLibrary = require('../providers/OpenLibrary')
const LibGen = require('../providers/LibGen')
const GoogleBooks = require('../providers/GoogleBooks')
const Audible = require('../providers/Audible')
const Logger = require('../Logger')
const { levenshteinDistance } = require('../utils/index')
class BookFinder {
constructor() {
this.openLibrary = new OpenLibrary()
this.libGen = new LibGen()
this.googleBooks = new GoogleBooks()
this.audible = new Audible()
this.verbose = false
}
async findByISBN(isbn) {
var book = await this.openLibrary.isbnLookup(isbn)
if (book.errorCode) {
Logger.error('Book not found')
}
return book
}
stripSubtitle(title) {
if (title.includes(':')) {
return title.split(':')[0].trim()
} else if (title.includes(' - ')) {
return title.split(' - ')[0].trim()
}
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)
// Remove text in paranthesis (i.e. "Ender's Game (Ender's Saga)" becomes "Ender's Game")
var cleaned = stripped.replace(/ *\([^)]*\) */g, "")
// 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)
// Total length of search (title or both title & author)
b.totalPossibleDistance = b.title.length
if (author) {
if (!b.author) {
b.authorDistance = author.length
} else {
b.totalPossibleDistance += b.author.length
b.cleanedAuthor = this.cleanAuthorForCompares(b.author)
var cleanedAuthorDistance = levenshteinDistance(b.cleanedAuthor, searchAuthor)
var authorDistance = levenshteinDistance(b.author || '', author)
// Use best distance
b.authorDistance = Math.min(cleanedAuthorDistance, authorDistance)
// Check book author contains searchAuthor
if (searchAuthor.length > 4 && b.cleanedAuthor.includes(searchAuthor)) b.includesAuthor = searchAuthor
else if (author.length > 4 && b.author.includes(author)) b.includesAuthor = author
}
}
b.totalDistance = b.titleDistance + (b.authorDistance || 0)
// Check book title contains the searchTitle
if (searchTitle.length > 4 && b.cleanedTitle.includes(searchTitle)) b.includesTitle = searchTitle
else if (title.length > 4 && b.title.includes(title)) b.includesTitle = title
return b
}).filter(b => {
if (b.includesTitle) { // If search title was found in result title then skip over leven distance check
if (this.verbose) Logger.debug(`Exact title was included in "${b.title}", Search: "${b.includesTitle}"`)
} else if (b.titleDistance > maxTitleDistance) {
if (this.verbose) Logger.debug(`Filtering out search result title distance = ${b.titleDistance}: "${b.cleanedTitle}"/"${searchTitle}"`)
return false
}
if (author) {
if (b.includesAuthor) { // If search author was found in result author then skip over leven distance check
if (this.verbose) Logger.debug(`Exact author was included in "${b.author}", Search: "${b.includesAuthor}"`)
} else if (b.authorDistance > maxAuthorDistance) {
if (this.verbose) Logger.debug(`Filtering out search result "${b.author}", author distance = ${b.authorDistance}: "${b.author}"/"${author}"`)
return false
}
}
// If book total search length < 5 and was not exact match, then filter out
if (b.totalPossibleDistance < 5 && b.totalDistance > 0) return false
return true
})
}
async getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance) {
var books = await this.libGen.search(title)
if (this.verbose) Logger.debug(`LibGen Book Search Results: ${books.length || 0}`)
if (books.errorCode) {
Logger.error(`LibGen Search Error ${books.errorCode}`)
return []
}
var booksFiltered = this.filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance)
if (!booksFiltered.length && books.length) {
if (this.verbose) Logger.debug(`Search has ${books.length} matches, but no close title matches`)
}
return booksFiltered
}
async getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance) {
var books = await this.openLibrary.searchTitle(title)
if (this.verbose) Logger.debug(`OpenLib Book Search Results: ${books.length || 0}`)
if (books.errorCode) {
Logger.error(`OpenLib Search Error ${books.errorCode}`)
return []
}
var booksFiltered = this.filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance)
if (!booksFiltered.length && books.length) {
if (this.verbose) Logger.debug(`Search has ${books.length} matches, but no close title matches`)
}
return booksFiltered
}
async getGoogleBooksResults(title, author) {
var books = await this.googleBooks.search(title, author)
if (this.verbose) Logger.debug(`GoogleBooks Book Search Results: ${books.length || 0}`)
if (books.errorCode) {
Logger.error(`GoogleBooks Search Error ${books.errorCode}`)
return []
}
// Google has good sort
return books
}
async getAudibleResults(title, author) {
var books = await this.audible.search(title, author);
if (this.verbose) Logger.debug(`Audible Book Search Results: ${books.length || 0}`)
if (!books) return []
return books
}
async search(provider, title, author, options = {}) {
var books = []
var maxTitleDistance = !isNaN(options.titleDistance) ? Number(options.titleDistance) : 4
var maxAuthorDistance = !isNaN(options.authorDistance) ? Number(options.authorDistance) : 4
Logger.debug(`Book Search: title: "${title}", author: "${author}", provider: ${provider}`)
if (provider === 'google') {
return this.getGoogleBooksResults(title, author)
} else if (provider === 'audible') {
return this.getAudibleResults(title, author)
} else if (provider === 'libgen') {
books = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance)
} else if (provider === 'openlibrary') {
books = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
} else if (provider === 'all') {
var lbBooks = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance)
var olBooks = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
books = books.concat(lbBooks, olBooks)
} else {
books = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
var hasCloseMatch = books.find(b => (b.totalDistance < 2 && b.totalPossibleDistance > 6))
if (!hasCloseMatch) {
Logger.debug(`Book Search, openlib has no super close matches - get libgen results also`)
var lbBooks = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance)
books = books.concat(lbBooks)
}
if (!books.length && author && options.fallbackTitleOnly) {
Logger.debug(`Book Search, no matches for title and author.. check title only`)
return this.search(provider, title, null, options)
}
}
return books.sort((a, b) => {
return a.totalDistance - b.totalDistance
})
}
async findCovers(provider, title, author, options = {}) {
var searchResults = await this.search(provider, title, author, options)
Logger.debug(`[BookFinder] FindCovers search results: ${searchResults.length}`)
var covers = []
searchResults.forEach((result) => {
if (result.covers && result.covers.length) {
covers = covers.concat(result.covers)
}
if (result.cover) {
covers.push(result.cover)
}
})
return covers
}
}
module.exports = BookFinder

View file

@ -0,0 +1,25 @@
const Logger = require('../Logger')
const iTunes = require('../providers/iTunes')
class PodcastFinder {
constructor() {
this.iTunesApi = new iTunes()
}
async search(term, options = {}) {
if (!term) return null
Logger.debug(`[iTunes] Searching for podcast with term "${term}"`)
var searchOptions = {
term,
media: 'podcast',
entity: 'podcast',
...options
}
var results = await this.iTunesApi.search(searchOptions)
Logger.debug(`[iTunes] Podcast search for "${term}" returned ${results.length} results`)
return results
}
}
module.exports = PodcastFinder