diff --git a/client/components/app/BookShelfCategorized.vue b/client/components/app/BookShelfCategorized.vue index 13a21c08..17e1327f 100644 --- a/client/components/app/BookShelfCategorized.vue +++ b/client/components/app/BookShelfCategorized.vue @@ -87,7 +87,6 @@ export default { var categories = await this.$axios .$get(`/api/libraries/${this.currentLibraryId}/personalized?minified=1`) .then((data) => { - console.log('Personalized data', data) return data }) .catch((error) => { diff --git a/client/components/controls/FilterSelect.vue b/client/components/controls/FilterSelect.vue index ffca8201..041c465f 100644 --- a/client/components/controls/FilterSelect.vue +++ b/client/components/controls/FilterSelect.vue @@ -168,9 +168,16 @@ export default { }, sublistItems() { return (this[this.sublist] || []).map((item) => { - return { - text: item, - value: this.$encode(item) + if (typeof item === 'string') { + return { + text: item, + value: this.$encode(item) + } + } else { + return { + text: item.name, + value: item.id + } } }) }, diff --git a/client/components/controls/OrderSelect.vue b/client/components/controls/OrderSelect.vue index faf3e8ab..f963038c 100644 --- a/client/components/controls/OrderSelect.vue +++ b/client/components/controls/OrderSelect.vue @@ -86,6 +86,7 @@ export default { }, selectedText() { var _selected = this.selected + if (!_selected) return '' if (this.selected.startsWith('book.')) _selected = _selected.replace('book.', 'media.metadata.') var _sel = this.items.find((i) => i.value === _selected) if (!_sel) return '' diff --git a/client/components/modals/EditModal.vue b/client/components/modals/EditModal.vue index 3d056a93..61fa48f5 100644 --- a/client/components/modals/EditModal.vue +++ b/client/components/modals/EditModal.vue @@ -18,7 +18,7 @@
arrow_forward_ios
-
+
@@ -30,7 +30,7 @@ export default { return { processing: false, libraryItem: null, - fetchOnShow: false, + tabs: [ { id: 'details', @@ -62,11 +62,6 @@ export default { title: 'Match', component: 'modals-edit-tabs-match' } - // { - // id: 'authors', - // title: 'Authors', - // component: 'modals-edit-tabs-authors' - // } ] } }, @@ -84,11 +79,6 @@ export default { this.selectedTab = availableTabIds[0] } - if (this.libraryItem && this.libraryItem.id === this.selectedLibraryItemId) { - if (this.fetchOnShow) this.fetchFull() - return - } - this.fetchOnShow = false this.libraryItem = null this.init() this.registerListeners() @@ -214,14 +204,10 @@ export default { this.selectedTab = tab } }, - audiobookUpdated() { - if (!this.show) this.fetchOnShow = true - else { - this.fetchFull() - } + libraryItemUpdated(expandedLibraryItem) { + this.libraryItem = expandedLibraryItem }, init() { - this.$store.commit('audiobooks/addListener', { meth: this.audiobookUpdated, id: 'edit-modal', audiobookId: this.selectedLibraryItemId }) this.fetchFull() }, async fetchFull() { @@ -244,9 +230,11 @@ export default { }, registerListeners() { this.$eventBus.$on('modal-hotkey', this.hotkey) + this.$eventBus.$on(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated) }, unregisterListeners() { this.$eventBus.$off('modal-hotkey', this.hotkey) + this.$eventBus.$off(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated) } }, mounted() {}, diff --git a/client/components/modals/edit-tabs/Details.vue b/client/components/modals/edit-tabs/Details.vue index abf8c6a0..35a7bd0a 100644 --- a/client/components/modals/edit-tabs/Details.vue +++ b/client/components/modals/edit-tabs/Details.vue @@ -13,9 +13,7 @@
- - - +
@@ -23,12 +21,8 @@
-
-

Series placeholder

- -
- +
@@ -87,6 +81,27 @@
+ +
+
+ close +
+
+
+
+
+ +
+
+ +
+
+
+ Save +
+
+
+
@@ -101,6 +116,8 @@ export default { }, data() { return { + selectedSeries: {}, + showSeriesForm: false, details: { title: null, subtitle: null, @@ -116,7 +133,6 @@ export default { genres: [] }, newTags: [], - authorNames: [], resettingProgress: false, isScrollable: false, savingMetadata: false, @@ -180,9 +196,71 @@ export default { libraryScan() { if (!this.libraryId) return null return this.$store.getters['scanners/getLibraryScan'](this.libraryId) + }, + existingSeriesNames() { + // Only show series names not already selected + var alreadySelectedSeriesIds = this.details.series.map((se) => se.id) + return this.series.filter((se) => !alreadySelectedSeriesIds.includes(se.id)).map((se) => se.name) + }, + seriesItems: { + get() { + return this.details.series.map((se) => { + return { + displayName: se.sequence ? `${se.name} #${se.sequence}` : se.name, + ...se + } + }) + }, + set(val) { + this.details.series = val + } } }, methods: { + cancelSeriesForm() { + this.showSeriesForm = false + }, + editSeriesItem(series) { + var _series = this.details.series.find((se) => se.id === series.id) + if (!_series) return + this.selectedSeries = { + ..._series + } + this.showSeriesForm = true + }, + submitSeriesForm() { + if (!this.selectedSeries.name) { + this.$toast.error('Must enter a series') + return + } + if (this.$refs.newSeriesSelect) { + this.$refs.newSeriesSelect.blur() + } + var existingSeriesIndex = this.details.series.findIndex((se) => se.id === this.selectedSeries.id) + + var seriesSameName = this.series.find((se) => se.name.toLowerCase() === this.selectedSeries.name.toLowerCase()) + if (existingSeriesIndex < 0 && seriesSameName) { + this.selectedSeries.id = seriesSameName.id + } + + if (existingSeriesIndex >= 0) { + this.details.series.splice(existingSeriesIndex, 1, { ...this.selectedSeries }) + } else { + this.details.series.push({ + ...this.selectedSeries + }) + } + + this.showSeriesForm = false + }, + addNewSeries() { + this.selectedSeries = { + id: `new-${Date.now()}`, + name: '', + sequence: '' + } + this.showSeriesForm = true + }, quickMatch() { this.quickMatching = true var matchOptions = { @@ -249,8 +327,8 @@ export default { return } this.isProcessing = true - if (this.$refs.seriesDropdown && this.$refs.seriesDropdown.isFocused) { - this.$refs.seriesDropdown.blur() + if (this.$refs.authorsSelect && this.$refs.authorsSelect.isFocused) { + this.$refs.authorsSelect.forceBlur() } if (this.$refs.genresSelect && this.$refs.genresSelect.isFocused) { this.$refs.genresSelect.forceBlur() @@ -262,11 +340,11 @@ export default { }, async handleForm() { const updatePayload = { - book: this.details, + metadata: this.details, tags: this.newTags } - - var updatedAudiobook = await this.$axios.$patch(`/api/books/${this.libraryItemId}`, updatePayload).catch((error) => { + console.log('Sending update', updatePayload) + var updatedAudiobook = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, updatePayload).catch((error) => { console.error('Failed to update', error) return false }) @@ -280,18 +358,24 @@ export default { this.details.title = this.mediaMetadata.title this.details.subtitle = this.mediaMetadata.subtitle this.details.description = this.mediaMetadata.description - this.details.authors = this.mediaMetadata.authors || [] + this.$set( + this.details, + 'authors', + (this.mediaMetadata.authors || []).map((se) => ({ ...se })) + ) this.details.narrator = this.mediaMetadata.narrator - this.details.genres = this.mediaMetadata.genres || [] - this.details.series = this.mediaMetadata.series + this.details.genres = [...(this.mediaMetadata.genres || [])] + this.$set( + this.details, + 'series', + (this.mediaMetadata.series || []).map((se) => ({ ...se })) + ) this.details.publishYear = this.mediaMetadata.publishYear this.details.publisher = this.mediaMetadata.publisher || null this.details.language = this.mediaMetadata.language || null this.details.isbn = this.mediaMetadata.isbn || null this.details.asin = this.mediaMetadata.asin || null - - this.newTags = this.media.tags || [] - this.authorNames = this.details.authors.map((au) => au.name) + this.newTags = [...(this.media.tags || [])] }, removeItem() { if (confirm(`Are you sure you want to remove this item?\n\n*Does not delete your files, only removes the item from audiobookshelf`)) { diff --git a/client/components/ui/MultiSelect.vue b/client/components/ui/MultiSelect.vue index 4b6eee50..ed7f812c 100644 --- a/client/components/ui/MultiSelect.vue +++ b/client/components/ui/MultiSelect.vue @@ -3,14 +3,15 @@

{{ label }}

-
-
+
+
+ edit close
{{ item }}
- +
@@ -47,7 +48,9 @@ export default { default: () => [] }, label: String, - disabled: Boolean + disabled: Boolean, + readonly: Boolean, + showEdit: Boolean }, data() { return { @@ -76,6 +79,13 @@ export default { showMenu() { return this.isFocused }, + wrapperClass() { + var classes = [] + if (this.disabled) classes.push('bg-black-300') + else classes.push('bg-primary') + if (!this.readonly) classes.push('cursor-text') + return classes.join(' ') + }, itemsToShow() { if (!this.currentSearch || !this.textInput) { return this.items @@ -88,6 +98,9 @@ export default { } }, methods: { + editItem(item) { + this.$emit('edit', item) + }, keydownInput() { clearTimeout(this.typingTimeout) this.typingTimeout = setTimeout(() => { diff --git a/client/components/ui/MultiSelectQueryInput.vue b/client/components/ui/MultiSelectQueryInput.vue index 403ad3ad..93948038 100644 --- a/client/components/ui/MultiSelectQueryInput.vue +++ b/client/components/ui/MultiSelectQueryInput.vue @@ -3,26 +3,30 @@

{{ label }}

-
-
+
+
- close + edit + close
- {{ item }} + {{ item[textKey] }}
- +
+ add +
+
  • @@ -44,7 +48,13 @@ export default { }, endpoint: String, label: String, - disabled: Boolean + disabled: Boolean, + readonly: Boolean, + showEdit: Boolean, + textKey: { + type: String, + default: 'name' + } }, data() { return { @@ -66,20 +76,36 @@ export default { computed: { selected: { get() { - return this.value + return this.value || [] }, set(val) { this.$emit('input', val) } }, + wrapperClass() { + var classes = [] + if (this.disabled) classes.push('bg-black-300') + else classes.push('bg-primary') + if (!this.readonly) classes.push('cursor-text') + return classes.join(' ') + }, showMenu() { - return this.isFocused + return this.isFocused && this.currentSearch }, itemsToShow() { return this.items } }, methods: { + addItem() { + this.$emit('add') + }, + editItem(item) { + this.$emit('edit', item) + }, + getIsSelected(itemValue) { + return !!this.selected.find((i) => i.id === itemValue) + }, async search() { if (this.searching) return this.currentSearch = this.textInput @@ -96,7 +122,7 @@ export default { clearTimeout(this.typingTimeout) this.typingTimeout = setTimeout(() => { this.search() - }, 500) + }, 250) this.setInputWidth() }, setInputWidth() { @@ -165,7 +191,7 @@ export default { if (this.textInput) this.submitForm() if (this.$refs.input) this.$refs.input.blur() }, - clickedOption(e, itemValue) { + clickedOption(e, item) { if (e) { e.stopPropagation() e.preventDefault() @@ -173,11 +199,11 @@ export default { if (this.$refs.input) this.$refs.input.focus() var newSelected = null - if (this.selected.includes(itemValue)) { - newSelected = this.selected.filter((s) => s !== itemValue) - this.$emit('removedItem', itemValue) + if (this.getIsSelected(item.id)) { + newSelected = this.selected.filter((s) => s.id !== item.id) + this.$emit('removedItem', item.id) } else { - newSelected = this.selected.concat([itemValue]) + newSelected = this.selected.concat([item]) } this.textInput = null this.currentSearch = null @@ -193,10 +219,10 @@ export default { } this.focus() }, - removeItem(item) { - var remaining = this.selected.filter((i) => i !== item) + removeItem(itemId) { + var remaining = this.selected.filter((i) => i.id !== itemId) this.$emit('input', remaining) - this.$emit('removedItem', item) + this.$emit('removedItem', itemId) this.$nextTick(() => { this.recalcMenuPos() }) @@ -221,7 +247,10 @@ export default { if (matchesItem) { this.clickedOption(null, matchesItem) } else { - this.insertNewItem(this.textInput) + this.insertNewItem({ + id: `new-${Date.now()}`, + name: this.textInput + }) } }, scroll() { diff --git a/client/components/ui/QueryInput.vue b/client/components/ui/QueryInput.vue new file mode 100644 index 00000000..c9bc337b --- /dev/null +++ b/client/components/ui/QueryInput.vue @@ -0,0 +1,157 @@ + + + diff --git a/client/layouts/default.vue b/client/layouts/default.vue index 8286f913..a417abfa 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -209,6 +209,12 @@ export default { libraryRemoved(library) { this.$store.commit('libraries/remove', library) }, + libraryItemUpdated(libraryItem) { + if (this.$store.state.selectedLibraryItem && this.$store.state.selectedLibraryItem.id === libraryItem.id) { + this.$store.commit('setSelectedLibraryItem', libraryItem) + } + this.$eventBus.$emit(`${libraryItem.id}_updated`, libraryItem) + }, scanComplete(data) { console.log('Scan complete received', data) @@ -395,6 +401,9 @@ export default { this.socket.on('library_added', this.libraryAdded) this.socket.on('library_removed', this.libraryRemoved) + // Library Item Listeners + this.socket.on('item_updated', this.libraryItemUpdated) + // User Listeners this.socket.on('user_updated', this.userUpdated) this.socket.on('user_online', this.userOnline) diff --git a/server/ApiController.js b/server/ApiController.js index f8b14ce2..f757207f 100644 --- a/server/ApiController.js +++ b/server/ApiController.js @@ -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)) diff --git a/server/Db.js b/server/Db.js index a1dcb97a..d3e31096 100644 --- a/server/Db.js +++ b/server/Db.js @@ -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 diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index da6a6349..e18c390b 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -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 diff --git a/server/controllers/SeriesController.js b/server/controllers/SeriesController.js new file mode 100644 index 00000000..734cc6f6 --- /dev/null +++ b/server/controllers/SeriesController.js @@ -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() \ No newline at end of file diff --git a/server/objects/LibraryItem.js b/server/objects/LibraryItem.js index a01e5cc5..53e9a6c5 100644 --- a/server/objects/LibraryItem.js +++ b/server/objects/LibraryItem.js @@ -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 \ No newline at end of file diff --git a/server/objects/entities/Book.js b/server/objects/entities/Book.js index c76b92d7..f560db2b 100644 --- a/server/objects/entities/Book.js +++ b/server/objects/entities/Book.js @@ -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 \ No newline at end of file diff --git a/server/objects/metadata/BookMetadata.js b/server/objects/metadata/BookMetadata.js index bae3c565..38e6f7da 100644 --- a/server/objects/metadata/BookMetadata.js +++ b/server/objects/metadata/BookMetadata.js @@ -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 \ No newline at end of file diff --git a/server/utils/areEquivalent.js b/server/utils/areEquivalent.js new file mode 100644 index 00000000..fb10463c --- /dev/null +++ b/server/utils/areEquivalent.js @@ -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; +} \ No newline at end of file diff --git a/server/utils/index.js b/server/utils/index.js index 377a4164..d13c7573 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -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 + } } \ No newline at end of file diff --git a/server/utils/libraryHelpers.js b/server/utils/libraryHelpers.js index 64843528..52f6de50 100644 --- a/server/utils/libraryHelpers.js +++ b/server/utils/libraryHelpers.js @@ -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) {