mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-11-07 16:44:16 +01:00
Series as a dropdown and filter, fix genre list in details modal
This commit is contained in:
parent
f70e1beca1
commit
c3fd9045a8
@ -76,6 +76,11 @@ export default {
|
||||
text: 'Tag',
|
||||
value: 'tags',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Series',
|
||||
value: 'series',
|
||||
sublist: true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -116,6 +121,9 @@ export default {
|
||||
tags() {
|
||||
return this.$store.state.audiobooks.tags
|
||||
},
|
||||
series() {
|
||||
return this.$store.state.audiobooks.series
|
||||
},
|
||||
sublistItems() {
|
||||
return this[this.sublist] || []
|
||||
}
|
||||
|
@ -21,11 +21,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ui-text-input-with-label v-model="details.series" label="Series" class="mt-2" />
|
||||
<!-- <ui-text-input-with-label v-model="details.series" label="Series" class="mt-2" /> -->
|
||||
|
||||
<ui-input-dropdown v-model="details.series" label="Series" class="mt-2" :items="series" />
|
||||
|
||||
<ui-textarea-with-label v-model="details.description" :rows="3" label="Description" class="mt-2" />
|
||||
|
||||
<ui-multi-select v-model="details.genres" label="Genre" :items="genres" class="mt-2" @addOption="addGenre" />
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-multi-select v-model="details.genres" label="Genres" :items="genres" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-multi-select v-model="newTags" label="Tags" :items="tags" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex py-4">
|
||||
<ui-btn color="error" type="button" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
|
||||
@ -55,8 +64,8 @@ export default {
|
||||
publishYear: null,
|
||||
genres: []
|
||||
},
|
||||
resettingProgress: false,
|
||||
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']
|
||||
newTags: [],
|
||||
resettingProgress: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@ -87,21 +96,25 @@ export default {
|
||||
},
|
||||
userProgress() {
|
||||
return this.userAudiobook ? this.userAudiobook.progress : 0
|
||||
},
|
||||
genres() {
|
||||
return this.$store.state.audiobooks.genres
|
||||
},
|
||||
tags() {
|
||||
return this.$store.state.audiobooks.tags
|
||||
},
|
||||
series() {
|
||||
return this.$store.state.audiobooks.series
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addGenre(genre) {
|
||||
this.genres.push({
|
||||
text: genre,
|
||||
value: genre
|
||||
})
|
||||
},
|
||||
async submitForm() {
|
||||
console.log('Submit form', this.details)
|
||||
this.isProcessing = true
|
||||
const updatePayload = {
|
||||
book: this.details
|
||||
book: this.details,
|
||||
tags: this.newTags
|
||||
}
|
||||
|
||||
var updatedAudiobook = await this.$axios.$patch(`/api/audiobook/${this.audiobook.id}`, updatePayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
@ -120,6 +133,8 @@ export default {
|
||||
this.details.genres = this.book.genres || []
|
||||
this.details.series = this.book.series
|
||||
this.details.publishYear = this.book.publishYear
|
||||
|
||||
this.newTags = this.audiobook.tags || []
|
||||
},
|
||||
resetProgress() {
|
||||
if (confirm(`Are you sure you want to reset your progress?`)) {
|
||||
|
112
client/components/ui/InputDropdown.vue
Normal file
112
client/components/ui/InputDropdown.vue
Normal file
@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<p class="px-1 text-sm font-semibold">{{ label }}</p>
|
||||
<div ref="wrapper" class="relative">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div ref="inputWrapper" style="min-height: 40px" class="flex-wrap relative w-full shadow-sm flex items-center bg-primary border border-gray-600 rounded-md px-2 py-1 cursor-text">
|
||||
<input ref="input" v-model="textInput" class="h-full w-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ul ref="menu" v-show="isFocused && items.length && (itemsToShow.length || currentSearch)" class="absolute z-50 mt-0 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in itemsToShow">
|
||||
<li :key="item" class="text-gray-50 select-none relative py-2 pr-3 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate">{{ item }}</span>
|
||||
</div>
|
||||
<span v-if="input === item" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||
<span class="material-icons text-xl">checkmark</span>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
<li v-if="!itemsToShow.length" class="text-gray-50 select-none relative py-2 pr-9" role="option">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal">No items</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: [String, Number],
|
||||
label: String,
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isFocused: false,
|
||||
currentSearch: null,
|
||||
typingTimeout: null,
|
||||
textInput: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
this.textInput = newVal
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
input: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
itemsToShow() {
|
||||
if (!this.currentSearch || !this.textInput || this.textInput === this.input) {
|
||||
return this.items
|
||||
}
|
||||
return this.items.filter((i) => {
|
||||
var iValue = String(i).toLowerCase()
|
||||
return iValue.includes(this.currentSearch.toLowerCase())
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
keydownInput() {
|
||||
clearTimeout(this.typingTimeout)
|
||||
this.typingTimeout = setTimeout(() => {
|
||||
this.currentSearch = this.textInput
|
||||
}, 100)
|
||||
},
|
||||
inputFocus() {
|
||||
this.isFocused = true
|
||||
},
|
||||
inputBlur() {
|
||||
setTimeout(() => {
|
||||
if (document.activeElement === this.$refs.input) {
|
||||
return
|
||||
}
|
||||
this.isFocused = false
|
||||
if (this.input !== this.textInput) {
|
||||
this.input = this.$cleanString(this.textInput) || null
|
||||
}
|
||||
}, 50)
|
||||
},
|
||||
submitForm() {
|
||||
this.input = this.$cleanString(this.textInput) || null
|
||||
this.currentSearch = null
|
||||
},
|
||||
clickedOption(e, item) {
|
||||
var newValue = this.input === item ? null : item
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
this.input = this.$cleanString(newValue) || null
|
||||
if (this.$refs.input) this.$refs.input.blur()
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
@ -4,7 +4,7 @@
|
||||
<div ref="wrapper" class="relative">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div ref="inputWrapper" style="min-height: 40px" class="flex-wrap relative w-full shadow-sm flex items-center bg-primary border border-gray-600 rounded-md px-2 py-1 cursor-text" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 ma-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center">{{ snakeToNormal(item) }}</div>
|
||||
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 ma-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center">{{ $snakeToNormal(item) }}</div>
|
||||
<input ref="input" v-model="textInput" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
|
||||
</div>
|
||||
</form>
|
||||
@ -13,7 +13,7 @@
|
||||
<template v-for="item in itemsToShow">
|
||||
<li :key="item" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate">{{ snakeToNormal(item) }}</span>
|
||||
<span class="font-normal ml-3 block truncate">{{ $snakeToNormal(item) }}</span>
|
||||
</div>
|
||||
<span v-if="selected.includes(item)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||
<span class="material-icons text-xl">checkmark</span>
|
||||
@ -47,7 +47,6 @@ export default {
|
||||
return {
|
||||
textInput: null,
|
||||
currentSearch: null,
|
||||
isTyping: false,
|
||||
typingTimeout: null,
|
||||
isFocused: false,
|
||||
menu: null
|
||||
@ -71,38 +70,15 @@ export default {
|
||||
}
|
||||
|
||||
return this.items.filter((i) => {
|
||||
var normie = this.snakeToNormal(i)
|
||||
var normie = this.$snakeToNormal(i)
|
||||
var iValue = String(normie).toLowerCase()
|
||||
return iValue.includes(this.currentSearch.toLowerCase())
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
snakeToNormal(kebab) {
|
||||
if (!kebab) {
|
||||
return 'err'
|
||||
}
|
||||
return String(kebab)
|
||||
.split('_')
|
||||
.map((t) => t.slice(0, 1).toUpperCase() + t.slice(1))
|
||||
.join(' ')
|
||||
},
|
||||
normalToSnake(normie) {
|
||||
return normie
|
||||
.trim()
|
||||
.split(' ')
|
||||
.map((t) => t.toLowerCase())
|
||||
.join('_')
|
||||
},
|
||||
setMatchingItems() {
|
||||
if (!this.textInput) {
|
||||
return
|
||||
}
|
||||
this.currentSearch = this.textInput
|
||||
},
|
||||
keydownInput() {
|
||||
clearTimeout(this.typingTimeout)
|
||||
this.isTyping = true
|
||||
this.typingTimeout = setTimeout(() => {
|
||||
this.currentSearch = this.textInput
|
||||
}, 100)
|
||||
@ -122,6 +98,7 @@ export default {
|
||||
this.menu.style.top = boundingBox.y + boundingBox.height - 4 + 'px'
|
||||
this.menu.style.left = boundingBox.x + 'px'
|
||||
this.menu.style.width = boundingBox.width + 'px'
|
||||
console.log('Recalc menu pos', boundingBox.height)
|
||||
},
|
||||
unmountMountMenu() {
|
||||
if (!this.$refs.menu) return
|
||||
@ -169,6 +146,9 @@ export default {
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
this.$emit('input', newSelected)
|
||||
this.$nextTick(() => {
|
||||
this.recalcMenuPos()
|
||||
})
|
||||
},
|
||||
clickWrapper() {
|
||||
if (this.showMenu) {
|
||||
@ -177,9 +157,8 @@ export default {
|
||||
this.focus()
|
||||
},
|
||||
insertNewItem(item) {
|
||||
var kebabItem = this.normalToSnake(item)
|
||||
var kebabItem = this.$normalToSnake(item)
|
||||
this.selected.push(kebabItem)
|
||||
this.$emit('addOption', kebabItem)
|
||||
this.$emit('input', this.selected)
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
@ -191,7 +170,7 @@ export default {
|
||||
if (!this.textInput) return
|
||||
|
||||
var cleaned = this.textInput.toLowerCase().trim()
|
||||
var cleanedKebab = this.normalToSnake(cleaned)
|
||||
var cleanedKebab = this.$normalToSnake(cleaned)
|
||||
var matchesItem = this.items.find((i) => {
|
||||
return i === cleaned || cleanedKebab === i
|
||||
})
|
||||
|
@ -145,6 +145,15 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.initializeSocket()
|
||||
// var test1 = 'crab rangoon'
|
||||
// var code = this.$stringToCode(test1)
|
||||
// var str = this.$codeToString(code)
|
||||
// console.log(code, str, test1 === str)
|
||||
|
||||
// var test2 = 'pig~iN.A._BlNan190a Fry em like b**&& A!@#%$&acn()'
|
||||
// var code2 = this.$stringToCode(test2)
|
||||
// var str2 = this.$codeToString(code2)
|
||||
// console.log(code2, code2.length, str2, str2.length, test2 === str2)
|
||||
}
|
||||
}
|
||||
</script>
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "0.9.62-beta",
|
||||
"version": "0.9.64-beta",
|
||||
"description": "Audiobook manager and player",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
@ -38,6 +38,77 @@ Vue.prototype.$secondsToTimestamp = (seconds) => {
|
||||
return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
Vue.prototype.$snakeToNormal = (snake) => {
|
||||
if (!snake) {
|
||||
return ''
|
||||
}
|
||||
return String(snake)
|
||||
.split('_')
|
||||
.map((t) => t.slice(0, 1).toUpperCase() + t.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
Vue.prototype.$normalToSnake = (normie) => {
|
||||
if (!normie) return ''
|
||||
return normie
|
||||
.trim()
|
||||
.split(' ')
|
||||
.map((t) => t.toLowerCase())
|
||||
.join('_')
|
||||
}
|
||||
|
||||
const availableChars = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"
|
||||
const getCharCode = (char) => availableChars.indexOf(char)
|
||||
const getCharFromCode = (code) => availableChars[Number(code)] || -1
|
||||
const cleanChar = (char) => getCharCode(char) < 0 ? '?' : char
|
||||
|
||||
Vue.prototype.$cleanString = (str) => {
|
||||
if (!str) return ''
|
||||
|
||||
// replace accented characters: https://stackoverflow.com/a/49901740/7431543
|
||||
str = str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
|
||||
|
||||
var cleaned = ''
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
cleaned += cleanChar(str[i])
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
Vue.prototype.$stringToCode = (str) => {
|
||||
if (!str) return ''
|
||||
var numcode = [...str].map(s => {
|
||||
return String(getCharCode(s)).padStart(2, '0')
|
||||
}).join('')
|
||||
return BigInt(numcode).toString(36)
|
||||
}
|
||||
|
||||
Vue.prototype.$codeToString = (code) => {
|
||||
if (!code) return ''
|
||||
var numcode = ''
|
||||
try {
|
||||
numcode = [...code].reduce((acc, curr) => {
|
||||
return BigInt(parseInt(curr, 36)) + BigInt(36) * acc
|
||||
}, 0n)
|
||||
} catch (err) {
|
||||
console.error('numcode fialed', code, err)
|
||||
}
|
||||
var numcodestr = String(numcode)
|
||||
|
||||
var remainder = numcodestr.length % 2
|
||||
numcodestr = numcodestr.padStart(numcodestr.length - 1 + remainder, '0')
|
||||
|
||||
var finalform = ''
|
||||
var numChunks = Math.floor(numcodestr.length / 2)
|
||||
var remaining = numcodestr
|
||||
for (let i = 0; i < numChunks; i++) {
|
||||
var chunk = remaining.slice(0, 2)
|
||||
remaining = remaining.slice(2)
|
||||
finalform += getCharFromCode(chunk)
|
||||
}
|
||||
return finalform
|
||||
}
|
||||
|
||||
function loadImageBlob(uri) {
|
||||
return new Promise((resolve) => {
|
||||
const img = document.createElement('img')
|
||||
|
@ -6,7 +6,8 @@ export const state = () => ({
|
||||
audiobooks: [],
|
||||
listeners: [],
|
||||
genres: [...STANDARD_GENRES],
|
||||
tags: []
|
||||
tags: [],
|
||||
series: []
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
@ -14,19 +15,15 @@ export const getters = {
|
||||
var filtered = state.audiobooks
|
||||
var settings = rootState.settings.settings || {}
|
||||
var filterBy = settings.filterBy || ''
|
||||
var filterByParts = filterBy.split('.')
|
||||
if (filterByParts.length > 1) {
|
||||
var primary = filterByParts[0]
|
||||
var secondary = filterByParts[1]
|
||||
if (primary === 'genres') {
|
||||
filtered = filtered.filter(ab => {
|
||||
return ab.book && ab.book.genres.includes(secondary)
|
||||
})
|
||||
} else if (primary === 'tags') {
|
||||
filtered = filtered.filter(ab => ab.tags.includes(secondary))
|
||||
|
||||
var searchGroups = ['genres', 'tags', 'series']
|
||||
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)
|
||||
}
|
||||
}
|
||||
// TODO: Add filters
|
||||
return filtered
|
||||
},
|
||||
getFilteredAndSorted: (state, getters, rootState) => () => {
|
||||
@ -65,6 +62,7 @@ export const mutations = {
|
||||
genres = genres.concat(ab.book.genres)
|
||||
})
|
||||
state.genres = [...new Set(genres)] // Remove Duplicates
|
||||
state.genres.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
|
||||
|
||||
// TAGS
|
||||
var tags = []
|
||||
@ -72,6 +70,16 @@ export const mutations = {
|
||||
tags = tags.concat(ab.tags)
|
||||
})
|
||||
state.tags = [...new Set(tags)] // Remove Duplicates
|
||||
state.tags.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
|
||||
|
||||
// SERIES
|
||||
var series = []
|
||||
audiobooks.forEach((ab) => {
|
||||
if (!ab.book || !ab.book.series || series.includes(ab.book.series)) return
|
||||
series.push(ab.book.series)
|
||||
})
|
||||
state.series = series
|
||||
state.series.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
|
||||
|
||||
state.audiobooks = audiobooks
|
||||
state.listeners.forEach((listener) => {
|
||||
@ -80,19 +88,34 @@ export const mutations = {
|
||||
},
|
||||
addUpdate(state, audiobook) {
|
||||
var index = state.audiobooks.findIndex(a => a.id === audiobook.id)
|
||||
var origAudiobook = null
|
||||
if (index >= 0) {
|
||||
origAudiobook = { ...state.audiobooks[index] }
|
||||
state.audiobooks.splice(index, 1, audiobook)
|
||||
} else {
|
||||
state.audiobooks.push(audiobook)
|
||||
}
|
||||
|
||||
// GENRES
|
||||
if (audiobook.book) {
|
||||
// GENRES
|
||||
var newGenres = []
|
||||
audiobook.book.genres.forEach((genre) => {
|
||||
if (!state.genres.includes(genre)) newGenres.push(genre)
|
||||
})
|
||||
if (newGenres.length) state.genres = state.genres.concat(newGenres)
|
||||
if (newGenres.length) {
|
||||
state.genres = state.genres.concat(newGenres)
|
||||
state.genres.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
|
||||
}
|
||||
|
||||
// SERIES
|
||||
if (audiobook.book.series && !state.series.includes(audiobook.book.series)) {
|
||||
state.series.push(audiobook.book.series)
|
||||
state.series.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
|
||||
}
|
||||
if (origAudiobook && origAudiobook.book && origAudiobook.book.series) {
|
||||
var isInAB = state.audiobooks.find(ab => ab.book && ab.book.series === origAudiobook.book.series)
|
||||
if (!isInAB) state.series = state.series.filter(series => series !== origAudiobook.book.series)
|
||||
}
|
||||
}
|
||||
|
||||
// TAGS
|
||||
@ -100,8 +123,10 @@ export const mutations = {
|
||||
audiobook.tags.forEach((tag) => {
|
||||
if (!state.tags.includes(tag)) newTags.push(tag)
|
||||
})
|
||||
if (newTags.length) state.tags = state.tags.concat(newTags)
|
||||
|
||||
if (newTags.length) {
|
||||
state.tags = state.tags.concat(newTags)
|
||||
state.tags.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
|
||||
}
|
||||
|
||||
state.listeners.forEach((listener) => {
|
||||
if (!listener.audiobookId || listener.audiobookId === audiobook.id) {
|
||||
@ -112,8 +137,8 @@ export const mutations = {
|
||||
remove(state, audiobook) {
|
||||
state.audiobooks = state.audiobooks.filter(a => a.id !== audiobook.id)
|
||||
|
||||
// GENRES
|
||||
if (audiobook.book) {
|
||||
// GENRES
|
||||
audiobook.book.genres.forEach((genre) => {
|
||||
if (!STANDARD_GENRES.includes(genre)) {
|
||||
var isInOtherAB = state.audiobooks.find(ab => {
|
||||
@ -125,6 +150,15 @@ export const mutations = {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// SERIES
|
||||
if (audiobook.book.series) {
|
||||
var isInOtherAB = state.audiobooks.find(ab => ab.book && ab.book.series === audiobook.book.series)
|
||||
if (!isInOtherAB) {
|
||||
// Series not used in any other audiobook - remove it
|
||||
state.series = state.series.filter(s => s !== audiobook.book.series)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TAGS
|
||||
@ -138,7 +172,6 @@ export const mutations = {
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
state.listeners.forEach((listener) => {
|
||||
if (!listener.audiobookId || listener.audiobookId === audiobook.id) {
|
||||
listener.meth()
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "0.9.62-beta",
|
||||
"version": "0.9.64-beta",
|
||||
"description": "Self-hosted audiobook server for managing and playing audiobooks.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
@ -24,3 +24,20 @@ const levenshteinDistance = (str1, str2, caseSensitive = false) => {
|
||||
return track[str2.length][str1.length];
|
||||
}
|
||||
module.exports.levenshteinDistance = levenshteinDistance
|
||||
|
||||
const cleanString = (str) => {
|
||||
if (!str) return ''
|
||||
|
||||
// replace accented characters: https://stackoverflow.com/a/49901740/7431543
|
||||
str = str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
|
||||
|
||||
const availableChars = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"
|
||||
const cleanChar = (char) => availableChars.indexOf(char) < 0 ? '?' : char
|
||||
|
||||
var cleaned = ''
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
cleaned += cleanChar(str[i])
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
module.exports.cleanString = cleanString
|
@ -1,6 +1,7 @@
|
||||
const Path = require('path')
|
||||
const dir = require('node-dir')
|
||||
const Logger = require('../Logger')
|
||||
const { cleanString } = require('./index')
|
||||
|
||||
const AUDIOBOOK_PARTS_FORMATS = ['m4b', 'mp3']
|
||||
const INFO_FORMATS = ['nfo']
|
||||
@ -64,7 +65,7 @@ async function getAllAudiobookFiles(abRootPath) {
|
||||
audiobooks[path] = {
|
||||
author: author,
|
||||
title: title,
|
||||
series: series,
|
||||
series: cleanString(series),
|
||||
publishYear: publishYear,
|
||||
path: relpath,
|
||||
fullPath: Path.join(abRootPath, path),
|
||||
|
Loading…
Reference in New Issue
Block a user