New model update details, author and series inputs with create new, compare & copy utils

This commit is contained in:
advplyr 2022-03-11 19:46:32 -06:00
parent f2be3bc95e
commit 5f4e5cd3d8
19 changed files with 707 additions and 70 deletions

View file

@ -15,6 +15,7 @@ const CollectionController = require('./controllers/CollectionController')
const MeController = require('./controllers/MeController')
const BackupController = require('./controllers/BackupController')
const LibraryItemController = require('./controllers/LibraryItemController')
const SeriesController = require('./controllers/SeriesController')
const BookFinder = require('./finders/BookFinder')
const AuthorFinder = require('./finders/AuthorFinder')
@ -74,6 +75,8 @@ class ApiController {
// Item Routes
//
this.router.get('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.findOne.bind(this))
this.router.patch('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.update.bind(this))
this.router.patch('/items/:id/media', LibraryItemController.middleware.bind(this), LibraryItemController.updateMedia.bind(this))
this.router.get('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.getCover.bind(this))
//
@ -152,7 +155,7 @@ class ApiController {
this.router.get('/filesystem', FileSystemController.getPaths.bind(this))
//
// Others
// Author Routes
//
this.router.get('/authors', this.getAuthors.bind(this))
this.router.get('/authors/search', this.searchAuthors.bind(this))
@ -161,6 +164,15 @@ class ApiController {
this.router.patch('/authors/:id', this.updateAuthor.bind(this))
this.router.delete('/authors/:id', this.deleteAuthor.bind(this))
//
// Series Routes
//
this.router.get('/series/search', SeriesController.search.bind(this))
//
// Misc Routes
//
this.router.patch('/serverSettings', this.updateServerSettings.bind(this))
this.router.post('/authorize', this.authorize.bind(this))

View file

@ -184,6 +184,20 @@ class Db {
}
}
async updateLibraryItem(libraryItem) {
if (libraryItem && libraryItem.saveMetadata) {
await libraryItem.saveMetadata()
}
return this.libraryItemsDb.update((record) => record.id === libraryItem.id, () => libraryItem).then((results) => {
Logger.debug(`[DB] Library Item updated ${results.updated}`)
return true
}).catch((error) => {
Logger.error(`[DB] Library Item update failed ${error}`)
return false
})
}
async updateAudiobook(audiobook) {
if (audiobook && audiobook.saveAbMetadata) {
// TODO: Book may have updates where this save is not necessary

View file

@ -1,4 +1,6 @@
const Logger = require('../Logger')
const Author = require('../objects/entities/Author')
const Series = require('../objects/entities/Series')
const { reqSupportsWebp } = require('../utils/index')
class LibraryItemController {
@ -9,6 +11,95 @@ class LibraryItemController {
res.json(req.libraryItem)
}
async update(req, res) {
if (!req.user.canUpdate) {
Logger.warn('User attempted to update without permission', req.user)
return res.sendStatus(403)
}
var libraryItem = req.libraryItem
// Item has cover and update is removing cover so purge it from cache
if (libraryItem.media.coverPath && req.body.media && (req.body.media.coverPath === '' || req.body.media.coverPath === null)) {
await this.cacheManager.purgeCoverCache(libraryItem.id)
}
var hasUpdates = libraryItem.update(req.body)
if (hasUpdates) {
Logger.debug(`[LibraryItemController] Updated now saving`)
await this.db.updateLibraryItem(libraryItem)
this.emitter('item_updated', libraryItem.toJSONExpanded())
}
res.json(libraryItem.toJSON())
}
//
// PATCH: will create new authors & series if in payload
//
async updateMedia(req, res) {
if (!req.user.canUpdate) {
Logger.warn('User attempted to update without permission', req.user)
return res.sendStatus(403)
}
var libraryItem = req.libraryItem
var mediaPayload = req.body
// Item has cover and update is removing cover so purge it from cache
if (libraryItem.media.coverPath && (mediaPayload.coverPath === '' || mediaPayload.coverPath === null)) {
await this.cacheManager.purgeCoverCache(libraryItem.id)
}
if (mediaPayload.metadata) {
var mediaMetadata = mediaPayload.metadata
// Create new authors if in payload
if (mediaMetadata.authors && mediaMetadata.authors.length) {
// TODO: validate authors
var newAuthors = []
for (let i = 0; i < mediaMetadata.authors.length; i++) {
if (mediaMetadata.authors[i].id.startsWith('new')) {
var newAuthor = new Author()
newAuthor.setData(mediaMetadata.authors[i])
Logger.debug(`[LibraryItemController] Created new author "${newAuthor.name}"`)
newAuthors.push(newAuthor)
// Update ID in original payload
mediaMetadata.authors[i].id = newAuthor.id
}
}
if (newAuthors.length) {
await this.db.insertEntities('author', newAuthors)
this.emitter('authors_added', newAuthors)
}
}
// Create new series if in payload
if (mediaMetadata.series && mediaMetadata.series.length) {
// TODO: validate series
var newSeries = []
for (let i = 0; i < mediaMetadata.series.length; i++) {
if (mediaMetadata.series[i].id.startsWith('new')) {
var newSeriesItem = new Series()
newSeriesItem.setData(mediaMetadata.series[i])
Logger.debug(`[LibraryItemController] Created new series "${newSeriesItem.name}"`)
newSeries.push(newSeriesItem)
// Update ID in original payload
mediaMetadata.series[i].id = newSeriesItem.id
}
}
if (newSeries.length) {
await this.db.insertEntities('series', newSeries)
this.emitter('authors_added', newSeries)
}
}
}
var hasUpdates = libraryItem.media.update(mediaPayload)
if (hasUpdates) {
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
await this.db.updateLibraryItem(libraryItem)
this.emitter('item_updated', libraryItem.toJSONExpanded())
}
res.json(libraryItem)
}
// GET api/items/:id/cover
async getCover(req, res) {
let { query: { width, height, format }, libraryItem } = req

View file

@ -0,0 +1,15 @@
const Logger = require('../Logger')
class SeriesController {
constructor() { }
async search(req, res) {
var q = (req.query.q || '').toLowerCase()
if (!q) return res.json([])
var limit = (req.query.limit && !isNaN(req.query.limit)) ? Number(req.query.limit) : 25
var series = this.db.series.filter(se => se.name.toLowerCase().includes(q))
series = series.slice(0, limit)
res.json(series)
}
}
module.exports = new SeriesController()

View file

@ -2,6 +2,7 @@ const Logger = require('../Logger')
const LibraryFile = require('./files/LibraryFile')
const Book = require('./entities/Book')
const Podcast = require('./entities/Podcast')
const { areEquivalent, copyValue } = require('../utils/index')
class LibraryItem {
constructor(libraryItem = null) {
@ -132,5 +133,23 @@ class LibraryItem {
this.libraryFiles.forEach((lf) => total += lf.metadata.size)
return total
}
update(payload) {
var json = this.toJSON()
var hasUpdates = false
for (const key in json) {
if (payload[key] !== undefined) {
if (key === 'media') {
if (this.media.update(payload[key])) {
hasUpdates = true
}
} else if (!areEquivalent(payload[key], json[key])) {
this[key] = copyValue(payload[key])
hasUpdates = true
}
}
}
return hasUpdates
}
}
module.exports = LibraryItem

View file

@ -1,6 +1,8 @@
const Logger = require('../../Logger')
const BookMetadata = require('../metadata/BookMetadata')
const AudioFile = require('../files/AudioFile')
const EBookFile = require('../files/EBookFile')
const { areEquivalent, copyValue } = require('../../utils/index')
class Book {
constructor(book) {
@ -78,5 +80,24 @@ class Book {
this.audioFiles.forEach((af) => total += af.metadata.size)
return total
}
update(payload) {
var json = this.toJSON()
var hasUpdates = false
for (const key in json) {
if (payload[key] !== undefined) {
if (key === 'metadata') {
if (this.metadata.update(payload.metadata)) {
hasUpdates = true
}
} else if (!areEquivalent(payload[key], json[key])) {
this[key] = copyValue(payload[key])
Logger.debug('[Book] Key updated', key, this[key])
hasUpdates = true
}
}
}
return hasUpdates
}
}
module.exports = Book

View file

@ -1,3 +1,6 @@
const Logger = require('../../Logger')
const { areEquivalent, copyValue } = require('../../utils/index')
class BookMetadata {
constructor(metadata) {
this.title = null
@ -100,5 +103,20 @@ class BookMetadata {
hasNarrator(narratorName) {
return this.narrators.includes(narratorName)
}
update(payload) {
var json = this.toJSON()
var hasUpdates = false
for (const key in json) {
if (payload[key] !== undefined) {
if (!areEquivalent(payload[key], json[key])) {
this[key] = copyValue(payload[key])
Logger.debug('[BookMetadata] Key updated', key, this[key])
hasUpdates = true
}
}
}
return hasUpdates
}
}
module.exports = BookMetadata

View file

@ -0,0 +1,142 @@
/**
* https://gist.github.com/DLiblik/96801665f9b6c935f12c1071d37eae95
Compares two items (values or references) for nested equivalency, meaning that
at root and at each key or index they are equivalent as follows:
- If a value type, values are either hard equal (===) or are both NaN
(different than JS where NaN !== NaN)
- If functions, they are the same function instance or have the same value
when converted to string via `toString()`
- If Date objects, both have the same getTime() or are both NaN (invalid)
- If arrays, both are same length, and all contained values areEquivalent
recursively - only contents by numeric key are checked
- If other object types, enumerable keys are the same (the keys themselves)
and values at every key areEquivalent recursively
Author: Dathan Liblik
License: Free to use anywhere by anyone, as-is, no guarantees of any kind.
@param value1 First item to compare
@param value2 Other item to compare
@param stack Used internally to track circular refs - don't set it
*/
module.exports = function areEquivalent(value1, value2, stack = []) {
// Numbers, strings, null, undefined, symbols, functions, booleans.
// Also: objects (incl. arrays) that are actually the same instance
if (value1 === value2) {
// Fast and done
return true;
}
const type1 = typeof value1;
// Ensure types match
if (type1 !== typeof value2) {
return false;
}
// Special case for number: check for NaN on both sides
// (only way they can still be equivalent but not equal)
if (type1 === 'number') {
// Failed initial equals test, but could still both be NaN
return (isNaN(value1) && isNaN(value2));
}
// Special case for function: check for toString() equivalence
if (type1 === 'function') {
// Failed initial equals test, but could still have equivalent
// implementations - note, will match on functions that have same name
// and are native code: `function abc() { [native code] }`
return value1.toString() === value2.toString();
}
// For these types, cannot still be equal at this point, so fast-fail
if (type1 === 'bigint' || type1 === 'boolean' ||
type1 === 'function' || type1 === 'string' ||
type1 === 'symbol') {
return false;
}
// For dates, cast to number and ensure equal or both NaN (note, if same
// exact instance then we're not here - that was checked above)
if (value1 instanceof Date) {
if (!(value2 instanceof Date)) {
return false;
}
// Convert to number to compare
const asNum1 = +value1, asNum2 = +value2;
// Check if both invalid (NaN) or are same value
return asNum1 === asNum2 || (isNaN(asNum1) && isNaN(asNum2));
}
// At this point, it's a reference type and could be circular, so
// make sure we haven't been here before... note we only need to track value1
// since value1 being un-circular means value2 will either be equal (and not
// circular too) or unequal whether circular or not.
if (stack.includes(value1)) {
throw new Error(`areEquivalent value1 is circular`);
}
// breadcrumb
stack.push(value1);
// Handle arrays
if (Array.isArray(value1)) {
if (!Array.isArray(value2)) {
return false;
}
const length = value1.length;
if (length !== value2.length) {
return false;
}
for (let i = 0; i < length; i++) {
if (!areEquivalent(value1[i], value2[i], stack)) {
return false;
}
}
return true;
}
// Final case: object
// get both key lists and check length
const keys1 = Object.keys(value1);
const keys2 = Object.keys(value2);
const numKeys = keys1.length;
if (keys2.length !== numKeys) {
return false;
}
// Empty object on both sides?
if (numKeys === 0) {
return true;
}
// sort is a native call so it's very fast - much faster than comparing the
// values at each key if it can be avoided, so do the sort and then
// ensure every key matches at every index
keys1.sort();
keys2.sort();
// Ensure perfect match across all keys
for (let i = 0; i < numKeys; i++) {
if (keys1[i] !== keys2[i]) {
return false;
}
}
// Ensure perfect match across all values
for (let i = 0; i < numKeys; i++) {
if (!areEquivalent(value1[keys1[i]], value2[keys1[i]], stack)) {
return false;
}
}
// back up
stack.pop();
// Walk the same, talk the same - matching ducks. Quack.
// 🦆🦆
return true;
}

View file

@ -2,6 +2,7 @@ const Path = require('path')
const fs = require('fs')
const Logger = require('../Logger')
const { parseString } = require("xml2js")
const areEquivalent = require('./areEquivalent')
const levenshteinDistance = (str1, str2, caseSensitive = false) => {
if (!caseSensitive) {
@ -99,4 +100,21 @@ module.exports.msToTimestamp = (ms, includeMs) => secondsToTimestamp(ms / 1000,
module.exports.reqSupportsWebp = (req) => {
if (!req || !req.headers || !req.headers.accept) return false
return req.headers.accept.includes('image/webp') || req.headers.accept === '*/*'
}
module.exports.areEquivalent = areEquivalent
module.exports.copyValue = (val) => {
if (!val) return null
if (!this.isObject(val)) return val
if (Array.isArray(val)) {
return val.map(this.copyValue)
} else {
var final = {}
for (const key in val) {
final[key] = this.copyValue(val[key])
}
return final
}
}

View file

@ -97,12 +97,12 @@ module.exports = {
var mediaMetadata = li.media.metadata
if (mediaMetadata.authors.length) {
mediaMetadata.authors.forEach((author) => {
if (author && !data.authors.includes(author.name)) data.authors.push(author.name)
if (author && !data.authors.find(au => au.id === author.id)) data.authors.push({ id: author.id, name: author.name })
})
}
if (mediaMetadata.series.length) {
mediaMetadata.series.forEach((series) => {
if (series && !data.series.includes(series.name)) data.series.push(series.name)
if (series && !data.series.find(se => se.id === series.id)) data.series.push({ id: series.id, name: series.name })
})
}
if (mediaMetadata.genres.length) {