mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-05 18:22:44 +02:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8a743ccc1 | |||
| 09dc95f560 | |||
| 853858825b | |||
| c962090c3a | |||
| 63a8e2433e | |||
| f78d287b59 | |||
| eaa383b6d8 | |||
| 113026ce13 | |||
| 578a946ca5 | |||
| f31306eda0 | |||
| c62b716a2c | |||
| 97ed20c683 | |||
| d5c46dcbfb | |||
| 30934edd57 | |||
| d06fd1a1b1 | |||
| 6bb36381f1 | |||
| a1331fb3f8 | |||
| 17d15144eb | |||
| 74d26eece4 | |||
| 474a7d08d0 | |||
| 639c930779 | |||
| c6323f8ad9 | |||
| caea6c6371 | |||
| d285845e04 | |||
| 5a6867e98a | |||
| 621444114f | |||
| 5591704aad | |||
| cc1181b301 |
@@ -3,7 +3,6 @@ set -e
|
|||||||
set -o pipefail
|
set -o pipefail
|
||||||
|
|
||||||
FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg/"
|
FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg/"
|
||||||
DEFAULT_AUDIOBOOK_PATH="/usr/share/audiobookshelf/audiobooks"
|
|
||||||
DEFAULT_DATA_PATH="/usr/share/audiobookshelf"
|
DEFAULT_DATA_PATH="/usr/share/audiobookshelf"
|
||||||
DEFAULT_PORT=7331
|
DEFAULT_PORT=7331
|
||||||
DEFAULT_HOST="0.0.0.0"
|
DEFAULT_HOST="0.0.0.0"
|
||||||
@@ -54,14 +53,6 @@ setup_config_interactive() {
|
|||||||
if should_build_config; then
|
if should_build_config; then
|
||||||
echo "Okay, let's setup a new config."
|
echo "Okay, let's setup a new config."
|
||||||
|
|
||||||
AUDIOBOOK_PATH=""
|
|
||||||
read -p "
|
|
||||||
Enter path for your audiobooks [Default: $DEFAULT_AUDIOBOOK_PATH]:" AUDIOBOOK_PATH
|
|
||||||
|
|
||||||
if [[ -z "$AUDIOBOOK_PATH" ]]; then
|
|
||||||
AUDIOBOOK_PATH="$DEFAULT_AUDIOBOOK_PATH"
|
|
||||||
fi
|
|
||||||
|
|
||||||
DATA_PATH=""
|
DATA_PATH=""
|
||||||
read -p "
|
read -p "
|
||||||
Enter path for data files, i.e. streams, downloads, database [Default: $DEFAULT_DATA_PATH]:" DATA_PATH
|
Enter path for data files, i.e. streams, downloads, database [Default: $DEFAULT_DATA_PATH]:" DATA_PATH
|
||||||
@@ -78,8 +69,7 @@ setup_config_interactive() {
|
|||||||
PORT="$DEFAULT_PORT"
|
PORT="$DEFAULT_PORT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
config_text="AUDIOBOOK_PATH=$AUDIOBOOK_PATH
|
config_text="METADATA_PATH=$DATA_PATH/metadata
|
||||||
METADATA_PATH=$DATA_PATH/metadata
|
|
||||||
CONFIG_PATH=$DATA_PATH/config
|
CONFIG_PATH=$DATA_PATH/config
|
||||||
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
|
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
|
||||||
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
|
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
|
||||||
@@ -102,8 +92,7 @@ setup_config() {
|
|||||||
else
|
else
|
||||||
echo "Creating default config."
|
echo "Creating default config."
|
||||||
|
|
||||||
config_text="AUDIOBOOK_PATH=$DEFAULT_AUDIOBOOK_PATH
|
config_text="METADATA_PATH=$DEFAULT_DATA_PATH/metadata
|
||||||
METADATA_PATH=$DEFAULT_DATA_PATH/metadata
|
|
||||||
CONFIG_PATH=$DEFAULT_DATA_PATH/config
|
CONFIG_PATH=$DEFAULT_DATA_PATH/config
|
||||||
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
|
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
|
||||||
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
|
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
|
||||||
|
|||||||
@@ -127,6 +127,14 @@ input[type=number] {
|
|||||||
border-top: 6px solid white;
|
border-top: 6px solid white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.arrow-down-small {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
border-right: 4px solid transparent;
|
||||||
|
border-top: 4px solid currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
.triangle-right {
|
.triangle-right {
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 0;
|
height: 0;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
<ui-libraries-dropdown />
|
<ui-libraries-dropdown />
|
||||||
|
|
||||||
<controls-global-search class="hidden md:block" />
|
<controls-global-search v-if="currentLibrary" class="hidden md:block" />
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
|
|
||||||
<span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span>
|
<span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span>
|
||||||
@@ -24,11 +24,11 @@
|
|||||||
<google-cast-launcher></google-cast-launcher>
|
<google-cast-launcher></google-cast-launcher>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nuxt-link to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
<nuxt-link v-if="currentLibrary" to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||||
<span class="material-icons" aria-label="User Stats" role="button">equalizer</span>
|
<span class="material-icons" aria-label="User Stats" role="button">equalizer</span>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="userCanUpload" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
<nuxt-link v-if="userCanUpload && currentLibrary" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||||
<span class="material-icons" aria-label="Upload Media" role="button">upload</span>
|
<span class="material-icons" aria-label="Upload Media" role="button">upload</span>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
@@ -150,12 +150,6 @@ export default {
|
|||||||
toggleBookshelfTexture() {
|
toggleBookshelfTexture() {
|
||||||
this.$store.dispatch('setBookshelfTexture', 'wood2.png')
|
this.$store.dispatch('setBookshelfTexture', 'wood2.png')
|
||||||
},
|
},
|
||||||
async back() {
|
|
||||||
var popped = await this.$store.dispatch('popRoute')
|
|
||||||
if (popped) this.$store.commit('setIsRoutingBack', true)
|
|
||||||
var backTo = popped || '/'
|
|
||||||
this.$router.push(backTo)
|
|
||||||
},
|
|
||||||
cancelSelectionMode() {
|
cancelSelectionMode() {
|
||||||
if (this.processingBatchDelete) return
|
if (this.processingBatchDelete) return
|
||||||
this.$store.commit('setSelectedLibraryItems', [])
|
this.$store.commit('setSelectedLibraryItems', [])
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export default {
|
|||||||
return this.$store.getters['user/getUserSetting']('filterBy')
|
return this.$store.getters['user/getUserSetting']('filterBy')
|
||||||
},
|
},
|
||||||
isIssuesFilter() {
|
isIssuesFilter() {
|
||||||
return this.filterBy === 'issues'
|
return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export default {
|
|||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
currentLibraryId() {
|
||||||
|
return this.$store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
userIsAdminOrUp() {
|
userIsAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
@@ -38,7 +41,7 @@ export default {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
return [
|
const configRoutes = [
|
||||||
{
|
{
|
||||||
id: 'config',
|
id: 'config',
|
||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
@@ -63,18 +66,23 @@ export default {
|
|||||||
id: 'config-log',
|
id: 'config-log',
|
||||||
title: 'Log',
|
title: 'Log',
|
||||||
path: '/config/log'
|
path: '/config/log'
|
||||||
},
|
}
|
||||||
{
|
]
|
||||||
|
|
||||||
|
if (this.currentLibraryId) {
|
||||||
|
configRoutes.push({
|
||||||
id: 'config-library-stats',
|
id: 'config-library-stats',
|
||||||
title: 'Library Stats',
|
title: 'Library Stats',
|
||||||
path: '/config/library-stats'
|
path: '/config/library-stats'
|
||||||
},
|
})
|
||||||
{
|
configRoutes.push({
|
||||||
id: 'config-stats',
|
id: 'config-stats',
|
||||||
title: 'Your Stats',
|
title: 'Your Stats',
|
||||||
path: '/config/stats'
|
path: '/config/stats'
|
||||||
}
|
})
|
||||||
]
|
}
|
||||||
|
|
||||||
|
return configRoutes
|
||||||
},
|
},
|
||||||
wrapperClass() {
|
wrapperClass() {
|
||||||
var classes = []
|
var classes = []
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-20 bg-bg h-full relative box-shadow-side z-40" style="min-width: 80px">
|
<!-- <div class="w-20 bg-bg h-full relative box-shadow-side z-40" style="min-width: 80px"> -->
|
||||||
<div class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
|
<div class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-40" style="min-width: 80px" :style="{ top: offsetTop + 'px' }">
|
||||||
|
<!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar -->
|
||||||
|
<div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
|
||||||
|
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||||
@@ -79,6 +82,13 @@ export default {
|
|||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
isShowingBookshelfToolbar() {
|
||||||
|
if (!this.$route.name) return false
|
||||||
|
return this.$route.name.startsWith('library')
|
||||||
|
},
|
||||||
|
offsetTop() {
|
||||||
|
return 64
|
||||||
|
},
|
||||||
showExperimentalFeatures() {
|
showExperimentalFeatures() {
|
||||||
return this.$store.state.showExperimentalFeatures
|
return this.$store.state.showExperimentalFeatures
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ export default {
|
|||||||
name() {
|
name() {
|
||||||
return this._author.name || ''
|
return this._author.name || ''
|
||||||
},
|
},
|
||||||
|
asin() {
|
||||||
|
return this._author.asin || ''
|
||||||
|
},
|
||||||
numBooks() {
|
numBooks() {
|
||||||
return this._author.numBooks || 0
|
return this._author.numBooks || 0
|
||||||
},
|
},
|
||||||
@@ -81,7 +84,11 @@ export default {
|
|||||||
},
|
},
|
||||||
async searchAuthor() {
|
async searchAuthor() {
|
||||||
this.searching = true
|
this.searching = true
|
||||||
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, { q: this.name }).catch((error) => {
|
const payload = {}
|
||||||
|
if (this.asin) payload.asin = this.asin
|
||||||
|
else payload.q = this.name
|
||||||
|
|
||||||
|
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -442,7 +442,34 @@ export default {
|
|||||||
this.isSelectionMode = val
|
this.isSelectionMode = val
|
||||||
if (!val) this.selected = false
|
if (!val) this.selected = false
|
||||||
},
|
},
|
||||||
setEntity(libraryItem) {
|
setEntity(_libraryItem) {
|
||||||
|
var libraryItem = _libraryItem
|
||||||
|
|
||||||
|
// this code block is only necessary when showing a selected series with sequence #
|
||||||
|
// it will update the selected series so we get realtime updates for series sequence changes
|
||||||
|
if (this.series) {
|
||||||
|
// i know.. but the libraryItem passed to this func cannot be modified so we need to create a copy
|
||||||
|
libraryItem = {
|
||||||
|
..._libraryItem,
|
||||||
|
media: {
|
||||||
|
..._libraryItem.media,
|
||||||
|
metadata: {
|
||||||
|
..._libraryItem.media.metadata
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var mediaMetadata = libraryItem.media.metadata
|
||||||
|
if (mediaMetadata.series) {
|
||||||
|
var newSeries = mediaMetadata.series.find((se) => se.id === this.series.id)
|
||||||
|
if (newSeries) {
|
||||||
|
// update selected series
|
||||||
|
libraryItem.media.metadata.series = newSeries
|
||||||
|
this.libraryItem = libraryItem
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.libraryItem = libraryItem
|
this.libraryItem = libraryItem
|
||||||
},
|
},
|
||||||
clickCard(e) {
|
clickCard(e) {
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<form @submit.prevent="submitSearch">
|
|
||||||
<div class="flex items-center justify-start -mx-1 h-20">
|
|
||||||
<!-- <div class="w-40 px-1">
|
|
||||||
<ui-dropdown v-model="provider" :items="providers" label="Provider" small />
|
|
||||||
</div> -->
|
|
||||||
<div class="flex-grow px-1">
|
|
||||||
<ui-text-input-with-label v-model="searchAuthor" label="Author" />
|
|
||||||
</div>
|
|
||||||
<ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
authorName: String
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
searchAuthor: null,
|
|
||||||
lastSearch: null,
|
|
||||||
isProcessing: false,
|
|
||||||
provider: 'audnexus',
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
text: 'Audnexus',
|
|
||||||
value: 'audnexus'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
authorName: {
|
|
||||||
immediate: true,
|
|
||||||
handler(newVal) {
|
|
||||||
this.searchAuthor = newVal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {},
|
|
||||||
methods: {
|
|
||||||
getSearchQuery() {
|
|
||||||
return `q=${this.searchAuthor}`
|
|
||||||
},
|
|
||||||
submitSearch() {
|
|
||||||
if (!this.searchAuthor) {
|
|
||||||
this.$toast.warning('Author name is required')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.runSearch()
|
|
||||||
},
|
|
||||||
async runSearch() {
|
|
||||||
var searchQuery = this.getSearchQuery()
|
|
||||||
if (this.lastSearch === searchQuery) return
|
|
||||||
this.isProcessing = true
|
|
||||||
this.lastSearch = searchQuery
|
|
||||||
var result = await this.$axios.$get(`/api/authors/search?${searchQuery}`).catch((error) => {
|
|
||||||
console.error('Failed', error)
|
|
||||||
return []
|
|
||||||
})
|
|
||||||
this.isProcessing = false
|
|
||||||
if (result) {
|
|
||||||
this.$emit('match', result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -93,12 +93,13 @@ export default {
|
|||||||
this.show = false
|
this.show = false
|
||||||
},
|
},
|
||||||
clickBg(ev) {
|
clickBg(ev) {
|
||||||
|
if (!this.show) return
|
||||||
if (this.preventClickoutside) {
|
if (this.preventClickoutside) {
|
||||||
this.preventClickoutside = false
|
this.preventClickoutside = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (this.processing && this.persistent) return
|
if (this.processing && this.persistent) return
|
||||||
if (ev.srcElement.classList.contains('modal-bg')) {
|
if (ev.srcElement && ev.srcElement.classList.contains('modal-bg')) {
|
||||||
this.show = false
|
this.show = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -139,12 +139,17 @@ export default {
|
|||||||
this.processing = false
|
this.processing = false
|
||||||
},
|
},
|
||||||
async searchAuthor() {
|
async searchAuthor() {
|
||||||
if (!this.authorCopy.name) {
|
if (!this.authorCopy.name && !this.authorCopy.asin) {
|
||||||
this.$toast.error('Must enter an author name')
|
this.$toast.error('Must enter an author name')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.processing = true
|
this.processing = true
|
||||||
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, { q: this.authorCopy.name }).catch((error) => {
|
|
||||||
|
const payload = {}
|
||||||
|
if (this.authorCopy.asin) payload.asin = this.authorCopy.asin
|
||||||
|
else payload.q = this.authorCopy.name
|
||||||
|
|
||||||
|
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,32 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||||
<div class="w-full mb-4">
|
<div class="w-full mb-4">
|
||||||
<div v-if="chapters.length" class="w-full p-4 bg-primary">
|
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" keep-open />
|
||||||
<p>Audiobook Chapters</p>
|
<div v-if="!chapters.length" class="py-4 text-center">
|
||||||
|
<p class="mb-8 text-xl">No Chapters</p>
|
||||||
|
<ui-btn v-if="userCanUpdate" :to="`/audiobook/${libraryItem.id}/chapters`">Add Chapters</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!chapters.length" class="flex my-4 text-center justify-center text-xl">No Chapters</div>
|
|
||||||
<table v-else class="text-sm tracksTable">
|
|
||||||
<tr class="font-book">
|
|
||||||
<th class="text-left w-16"><span class="px-4">Id</span></th>
|
|
||||||
<th class="text-left">Title</th>
|
|
||||||
<th class="text-center">Start</th>
|
|
||||||
<th class="text-center">End</th>
|
|
||||||
</tr>
|
|
||||||
<tr v-for="chapter in chapters" :key="chapter.id">
|
|
||||||
<td class="text-left">
|
|
||||||
<p class="px-4">{{ chapter.id }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="font-book">
|
|
||||||
{{ chapter.title }}
|
|
||||||
</td>
|
|
||||||
<td class="font-mono text-center">
|
|
||||||
{{ $secondsToTimestamp(chapter.start) }}
|
|
||||||
</td>
|
|
||||||
<td class="font-mono text-center">
|
|
||||||
{{ $secondsToTimestamp(chapter.end) }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -48,6 +27,9 @@ export default {
|
|||||||
},
|
},
|
||||||
chapters() {
|
chapters() {
|
||||||
return this.media.chapters || []
|
return this.media.chapters || []
|
||||||
|
},
|
||||||
|
userCanUpdate() {
|
||||||
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {}
|
methods: {}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export default {
|
|||||||
settings: {
|
settings: {
|
||||||
disableWatcher: false,
|
disableWatcher: false,
|
||||||
skipMatchingMediaWithAsin: false,
|
skipMatchingMediaWithAsin: false,
|
||||||
skipMatchingMediaWithIsbn: false,
|
skipMatchingMediaWithIsbn: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -193,6 +193,11 @@ export default {
|
|||||||
this.processing = false
|
this.processing = false
|
||||||
this.show = false
|
this.show = false
|
||||||
this.$toast.success(`Library "${res.name}" created successfully`)
|
this.$toast.success(`Library "${res.name}" created successfully`)
|
||||||
|
if (!this.$store.state.libraries.currentLibraryId) {
|
||||||
|
console.log('Setting initially library id', res.id)
|
||||||
|
// First library added
|
||||||
|
this.$store.dispatch('libraries/fetch', res.id)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
|
this.episodes.sort((a, b) => (a.publishedAt < b.publishedAt) ? 1 : -1)
|
||||||
for (let i = 0; i < this.episodes.length; i++) {
|
for (let i = 0; i < this.episodes.length; i++) {
|
||||||
var episode = this.episodes[i]
|
var episode = this.episodes[i]
|
||||||
if (episode.enclosure && !this.itemEpisodeMap[episode.enclosure.url]) {
|
if (episode.enclosure && !this.itemEpisodeMap[episode.enclosure.url]) {
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ export default {
|
|||||||
this.fullPath = ''
|
this.fullPath = ''
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.fullPath = Path.join(this.selectedFolderPath, this.podcast.title)
|
this.fullPath = Path.join(this.selectedFolderPath, this.$sanitizeFilename(this.podcast.title))
|
||||||
},
|
},
|
||||||
submit() {
|
submit() {
|
||||||
const podcastPayload = {
|
const podcastPayload = {
|
||||||
|
|||||||
@@ -18,12 +18,16 @@
|
|||||||
<div v-else class="w-full">
|
<div v-else class="w-full">
|
||||||
<p class="text-lg font-semibold mb-4">Open RSS Feed</p>
|
<p class="text-lg font-semibold mb-4">Open RSS Feed</p>
|
||||||
|
|
||||||
<div class="w-full relative">
|
<div class="w-full relative mb-2">
|
||||||
<ui-text-input-with-label v-model="newFeedSlug" label="RSS Feed Slug" />
|
<ui-text-input-with-label v-model="newFeedSlug" label="RSS Feed Slug" />
|
||||||
<p class="text-xs text-gray-400 py-0.5 px-1">Feed will be {{ demoFeedUrl }}</p>
|
<p class="text-xs text-gray-400 py-0.5 px-1">Feed will be {{ demoFeedUrl }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p v-if="isHttp" class="w-full pt-2 text-warning text-xs">Warning: Most podcast apps will require the RSS feed URL is using HTTPS</p>
|
||||||
|
<p v-if="hasEpisodesWithoutPubDate" class="w-full pt-2 text-warning text-xs">Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="userIsAdminOrUp" class="flex items-center pt-6">
|
<div v-show="userIsAdminOrUp" class="flex items-center pt-6">
|
||||||
|
<p class="text-xs text-gray-300">Note: RSS feed URLs are not authenticated</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn v-if="currentFeedUrl" color="error" small @click="closeFeed">Close RSS Feed</ui-btn>
|
<ui-btn v-if="currentFeedUrl" color="error" small @click="closeFeed">Close RSS Feed</ui-btn>
|
||||||
<ui-btn v-else color="success" small @click="openFeed">Open RSS Feed</ui-btn>
|
<ui-btn v-else color="success" small @click="openFeed">Open RSS Feed</ui-btn>
|
||||||
@@ -85,6 +89,15 @@ export default {
|
|||||||
},
|
},
|
||||||
demoFeedUrl() {
|
demoFeedUrl() {
|
||||||
return `${window.origin}/feed/${this.newFeedSlug}`
|
return `${window.origin}/feed/${this.newFeedSlug}`
|
||||||
|
},
|
||||||
|
isHttp() {
|
||||||
|
return window.origin.startsWith('http://')
|
||||||
|
},
|
||||||
|
episodes() {
|
||||||
|
return this.media.episodes || []
|
||||||
|
},
|
||||||
|
hasEpisodesWithoutPubDate() {
|
||||||
|
return this.episodes.some((ep) => !ep.pubDate)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
<template>
|
||||||
|
<div id="heatmap" class="w-full">
|
||||||
|
<div class="mx-auto" :style="{ height: innerHeight + 160 + 'px', width: innerWidth + 52 + 'px' }" style="background-color: rgba(13, 17, 23, 0)">
|
||||||
|
<p class="mb-2 px-1 text-sm text-gray-200">{{ Object.values(daysListening).length }} listening sessions in the last year</p>
|
||||||
|
<div class="border border-opacity-25 rounded py-2 w-full" style="background-color: #232323" :style="{ height: innerHeight + 80 + 'px' }">
|
||||||
|
<div :style="{ width: innerWidth + 'px', height: innerHeight + 'px' }" class="ml-10 mt-5 absolute" @mouseover="mouseover" @mouseout="mouseout">
|
||||||
|
<div v-for="dayLabel in dayLabels" :key="dayLabel.label" :style="dayLabel.style" class="absolute top-0 left-0 text-gray-300">{{ dayLabel.label }}</div>
|
||||||
|
|
||||||
|
<div v-for="monthLabel in monthLabels" :key="monthLabel.id" :style="monthLabel.style" class="absolute top-0 left-0 text-gray-300">{{ monthLabel.label }}</div>
|
||||||
|
|
||||||
|
<div v-for="(block, index) in data" :key="block.dateString" :style="block.style" :data-index="index" class="absolute top-0 left-0 h-2.5 w-2.5 rounded-sm" />
|
||||||
|
|
||||||
|
<div class="flex py-2 px-4" :style="{ marginTop: innerHeight + 'px' }">
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<p style="font-size: 10px; line-height: 10px" class="text-gray-400 px-1">Less</p>
|
||||||
|
<div v-for="block in legendBlocks" :key="block.id" :style="block.style" class="h-2.5 w-2.5 rounded-sm" style="margin-left: 1.5px; margin-right: 1.5px" />
|
||||||
|
<p style="font-size: 10px; line-height: 10px" class="text-gray-400 px-1">More</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
daysListening: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
contentWidth: 0,
|
||||||
|
maxInnerWidth: 0,
|
||||||
|
innerHeight: 13 * 7,
|
||||||
|
blockWidth: 13,
|
||||||
|
data: [],
|
||||||
|
monthLabels: [],
|
||||||
|
tooltipEl: null,
|
||||||
|
tooltipTextEl: null,
|
||||||
|
tooltipArrowEl: null,
|
||||||
|
showingTooltipIndex: -1,
|
||||||
|
outlineColors: ['rgba(27, 31, 35, 0.06)', 'rgba(255,255,255,0.03)'],
|
||||||
|
bgColors: ['rgb(45,45,45)', 'rgb(14, 68, 41)', 'rgb(0, 109, 50)', 'rgb(38, 166, 65)', 'rgb(57, 211, 83)']
|
||||||
|
// GH Colors
|
||||||
|
// outlineColors: ['rgba(27, 31, 35, 0.06)', 'rgba(255,255,255,0.05)'],
|
||||||
|
// bgColors: ['rgb(22, 27, 34)', 'rgb(14, 68, 41)', 'rgb(0, 109, 50)', 'rgb(38, 166, 65)', 'rgb(57, 211, 83)']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
weeksToShow() {
|
||||||
|
return Math.min(52, Math.floor(this.maxInnerWidth / this.blockWidth) - 1)
|
||||||
|
},
|
||||||
|
innerWidth() {
|
||||||
|
return (this.weeksToShow + 1) * 13
|
||||||
|
},
|
||||||
|
daysToShow() {
|
||||||
|
return this.weeksToShow * 7 + this.dayOfWeekToday
|
||||||
|
},
|
||||||
|
dayOfWeekToday() {
|
||||||
|
return new Date().getDay()
|
||||||
|
},
|
||||||
|
firstWeekStart() {
|
||||||
|
return this.$addDaysToToday(-this.daysToShow)
|
||||||
|
},
|
||||||
|
dayLabels() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Mon',
|
||||||
|
style: {
|
||||||
|
transform: `translate(${-25}px, ${13}px)`,
|
||||||
|
lineHeight: '10px',
|
||||||
|
fontSize: '10px'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Wed',
|
||||||
|
style: {
|
||||||
|
transform: `translate(${-25}px, ${13 * 3}px)`,
|
||||||
|
lineHeight: '10px',
|
||||||
|
fontSize: '10px'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Fri',
|
||||||
|
style: {
|
||||||
|
transform: `translate(${-25}px, ${13 * 5}px)`,
|
||||||
|
lineHeight: '10px',
|
||||||
|
fontSize: '10px'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
legendBlocks() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'legend-0',
|
||||||
|
style: `background-color:${this.bgColors[0]};outline:1px solid ${this.outlineColors[0]};outline-offset:-1px;`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'legend-1',
|
||||||
|
style: `background-color:${this.bgColors[1]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'legend-2',
|
||||||
|
style: `background-color:${this.bgColors[2]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'legend-3',
|
||||||
|
style: `background-color:${this.bgColors[3]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'legend-4',
|
||||||
|
style: `background-color:${this.bgColors[4]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
destroyTooltip() {
|
||||||
|
if (this.tooltipEl) this.tooltipEl.remove()
|
||||||
|
this.tooltipEl = null
|
||||||
|
this.showingTooltipIndex = -1
|
||||||
|
},
|
||||||
|
createTooltip() {
|
||||||
|
const tooltip = document.createElement('div')
|
||||||
|
tooltip.className = 'absolute top-0 left-0 rounded bg-gray-500 text-white p-2 text-white max-w-xs pointer-events-none'
|
||||||
|
tooltip.style.display = 'none'
|
||||||
|
tooltip.id = 'heatmap-tooltip'
|
||||||
|
|
||||||
|
const tooltipText = document.createElement('p')
|
||||||
|
tooltipText.innerText = 'Tooltip'
|
||||||
|
tooltipText.style.fontSize = '10px'
|
||||||
|
tooltipText.style.lineHeight = '10px'
|
||||||
|
tooltip.appendChild(tooltipText)
|
||||||
|
|
||||||
|
const tooltipArrow = document.createElement('div')
|
||||||
|
tooltipArrow.className = 'text-gray-500 arrow-down-small absolute -bottom-1 left-0 right-0 mx-auto'
|
||||||
|
tooltip.appendChild(tooltipArrow)
|
||||||
|
|
||||||
|
this.tooltipEl = tooltip
|
||||||
|
this.tooltipTextEl = tooltipText
|
||||||
|
this.tooltipArrowEl = tooltipArrow
|
||||||
|
|
||||||
|
document.body.appendChild(this.tooltipEl)
|
||||||
|
},
|
||||||
|
showTooltip(index, block, rect) {
|
||||||
|
if (this.tooltipEl && this.showingTooltipIndex === index) return
|
||||||
|
if (!this.tooltipEl) {
|
||||||
|
this.createTooltip()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showingTooltipIndex = index
|
||||||
|
this.tooltipEl.style.display = 'block'
|
||||||
|
this.tooltipTextEl.innerHTML = block.value ? `<strong>${this.$elapsedPretty(block.value, true)} listening</strong> on ${block.datePretty}` : `No listening sessions on ${block.datePretty}`
|
||||||
|
|
||||||
|
const calculateRect = this.tooltipEl.getBoundingClientRect()
|
||||||
|
|
||||||
|
const w = calculateRect.width / 2
|
||||||
|
var left = rect.x - w
|
||||||
|
var offsetX = 0
|
||||||
|
if (left < 0) {
|
||||||
|
offsetX = Math.abs(left)
|
||||||
|
left = 0
|
||||||
|
} else if (rect.x + w > window.innerWidth - 10) {
|
||||||
|
offsetX = window.innerWidth - 10 - (rect.x + w)
|
||||||
|
left += offsetX
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tooltipEl.style.transform = `translate(${left}px, ${rect.y - 32}px)`
|
||||||
|
this.tooltipArrowEl.style.transform = `translate(${5 - offsetX}px, 0px)`
|
||||||
|
},
|
||||||
|
hideTooltip() {
|
||||||
|
if (this.showingTooltipIndex >= 0 && this.tooltipEl) {
|
||||||
|
this.tooltipEl.style.display = 'none'
|
||||||
|
this.showingTooltipIndex = -1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mouseover(e) {
|
||||||
|
if (isNaN(e.target.dataset.index)) {
|
||||||
|
this.hideTooltip()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var block = this.data[e.target.dataset.index]
|
||||||
|
var rect = e.target.getBoundingClientRect()
|
||||||
|
this.showTooltip(e.target.dataset.index, block, rect)
|
||||||
|
},
|
||||||
|
mouseout(e) {
|
||||||
|
this.hideTooltip()
|
||||||
|
},
|
||||||
|
buildData() {
|
||||||
|
this.data = []
|
||||||
|
|
||||||
|
var maxValue = 0
|
||||||
|
var minValue = 0
|
||||||
|
Object.values(this.daysListening).forEach((val) => {
|
||||||
|
if (val > maxValue) maxValue = val
|
||||||
|
if (!minValue || val < minValue) minValue = val
|
||||||
|
})
|
||||||
|
const range = maxValue - minValue + 0.01
|
||||||
|
|
||||||
|
for (let i = 0; i < this.daysToShow + 1; i++) {
|
||||||
|
const col = Math.floor(i / 7)
|
||||||
|
const row = i % 7
|
||||||
|
|
||||||
|
const date = i === 0 ? this.firstWeekStart : this.$addDaysToDate(this.firstWeekStart, i)
|
||||||
|
const dateString = this.$formatJsDate(date, 'yyyy-MM-dd')
|
||||||
|
const datePretty = this.$formatJsDate(date, 'MMM d, yyyy')
|
||||||
|
const monthString = this.$formatJsDate(date, 'MMM')
|
||||||
|
const value = this.daysListening[dateString] || 0
|
||||||
|
const x = col * 13
|
||||||
|
const y = row * 13
|
||||||
|
|
||||||
|
var bgColor = this.bgColors[0]
|
||||||
|
var outlineColor = this.outlineColors[0]
|
||||||
|
if (value) {
|
||||||
|
outlineColor = this.outlineColors[1]
|
||||||
|
var percentOfAvg = (value - minValue) / range
|
||||||
|
var bgIndex = Math.floor(percentOfAvg * 4) + 1
|
||||||
|
bgColor = this.bgColors[bgIndex] || 'red'
|
||||||
|
}
|
||||||
|
|
||||||
|
this.data.push({
|
||||||
|
date,
|
||||||
|
dateString,
|
||||||
|
datePretty,
|
||||||
|
monthString,
|
||||||
|
dayOfMonth: Number(dateString.split('-').pop()),
|
||||||
|
yearString: dateString.split('-').shift(),
|
||||||
|
value,
|
||||||
|
col,
|
||||||
|
row,
|
||||||
|
style: `transform:translate(${x}px,${y}px);background-color:${bgColor};outline:1px solid ${outlineColor};outline-offset:-1px;`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
console.log('Data', this.data)
|
||||||
|
|
||||||
|
this.monthLabels = []
|
||||||
|
var lastMonth = null
|
||||||
|
for (let i = 0; i < this.data.length; i++) {
|
||||||
|
if (this.data[i].monthString !== lastMonth) {
|
||||||
|
const weekOfMonth = Math.floor(this.data[i].dayOfMonth / 7)
|
||||||
|
if (weekOfMonth <= 2) {
|
||||||
|
this.monthLabels.push({
|
||||||
|
id: this.data[i].dateString + '-ml',
|
||||||
|
label: this.data[i].monthString,
|
||||||
|
style: {
|
||||||
|
transform: `translate(${this.data[i].col * 13}px, -15px)`,
|
||||||
|
lineHeight: '10px',
|
||||||
|
fontSize: '10px'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
lastMonth = this.data[i].monthString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
const heatmapEl = document.getElementById('heatmap')
|
||||||
|
this.contentWidth = heatmapEl.clientWidth
|
||||||
|
this.maxInnerWidth = this.contentWidth - 52
|
||||||
|
this.buildData()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updated() {},
|
||||||
|
mounted() {
|
||||||
|
this.init()
|
||||||
|
},
|
||||||
|
beforeDestroy() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full my-2">
|
||||||
|
<div class="w-full bg-primary px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
|
||||||
|
<p class="pr-4">Chapters</p>
|
||||||
|
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ chapters.length }}</span>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn v-if="userCanUpdate" small :to="`/audiobook/${libraryItemId}/chapters`" color="primary" class="mr-2">Edit Chapters</ui-btn>
|
||||||
|
<div v-if="!keepOpen" class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="expanded ? 'transform rotate-180' : ''">
|
||||||
|
<span class="material-icons text-4xl">expand_more</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<transition name="slide">
|
||||||
|
<table class="text-sm tracksTable" v-show="expanded || keepOpen">
|
||||||
|
<tr class="font-book">
|
||||||
|
<th class="text-left w-16"><span class="px-4">Id</span></th>
|
||||||
|
<th class="text-left">Title</th>
|
||||||
|
<th class="text-center">Start</th>
|
||||||
|
<th class="text-center">End</th>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="chapter in chapters" :key="chapter.id">
|
||||||
|
<td class="text-left">
|
||||||
|
<p class="px-4">{{ chapter.id }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="font-book">
|
||||||
|
{{ chapter.title }}
|
||||||
|
</td>
|
||||||
|
<td class="font-mono text-center">
|
||||||
|
{{ $secondsToTimestamp(chapter.start) }}
|
||||||
|
</td>
|
||||||
|
<td class="font-mono text-center">
|
||||||
|
{{ $secondsToTimestamp(chapter.end) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
keepOpen: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
expanded: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
libraryItemId() {
|
||||||
|
return this.libraryItem.id
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||||
|
},
|
||||||
|
chapters() {
|
||||||
|
return this.media.chapters || []
|
||||||
|
},
|
||||||
|
userCanUpdate() {
|
||||||
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
clickBar() {
|
||||||
|
this.expanded = !this.expanded
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -6,18 +6,20 @@
|
|||||||
<span class="material-icons" style="font-size: 1.4rem">add</span>
|
<span class="material-icons" style="font-size: 1.4rem">add</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<draggable :list="libraryCopies" v-bind="dragOptions" class="list-group" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
|
<draggable v-if="libraryCopies.length" :list="libraryCopies" v-bind="dragOptions" class="list-group" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
|
||||||
<template v-for="library in libraryCopies">
|
<template v-for="library in libraryCopies">
|
||||||
<div :key="library.id" class="item">
|
<div :key="library.id" class="item">
|
||||||
<tables-library-item :library="library" :selected="currentLibraryId === library.id" :show-edit="true" :dragging="drag" @edit="editLibrary" @click="setLibrary" />
|
<tables-library-item :library="library" :selected="currentLibraryId === library.id" :show-edit="true" :dragging="drag" @edit="editLibrary" @click="setLibrary" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</draggable>
|
</draggable>
|
||||||
<modals-libraries-edit-modal v-model="showLibraryModal" :library="selectedLibrary" />
|
<div v-if="!libraries.length" class="pb-4">
|
||||||
|
<ui-btn @click="clickAddLibrary">Add your first library</ui-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p class="text-xs mt-4 text-gray-200">*<strong>Force Re-Scan</strong> will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be probed/parsed and used for book details.</p>
|
<p v-if="libraries.length" class="text-xs mt-4 text-gray-200">*<strong>Force Re-Scan</strong> will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be probed/parsed and used for book details.</p>
|
||||||
|
|
||||||
<p class="text-xs mt-4 text-gray-200">**<strong>Match Books</strong> will attempt to match books in library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.</p>
|
<p v-if="libraries.length && libraries.some((li) => li.mediaType === 'book')" class="text-xs mt-4 text-gray-200">**<strong>Match Books</strong> will attempt to match books in library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -32,8 +34,6 @@ export default {
|
|||||||
return {
|
return {
|
||||||
libraryCopies: [],
|
libraryCopies: [],
|
||||||
currentOrder: [],
|
currentOrder: [],
|
||||||
showLibraryModal: false,
|
|
||||||
selectedLibrary: null,
|
|
||||||
drag: false,
|
drag: false,
|
||||||
dragOptions: {
|
dragOptions: {
|
||||||
animation: 200,
|
animation: 200,
|
||||||
@@ -97,12 +97,10 @@ export default {
|
|||||||
this.$router.push(`/library/${library.id}`)
|
this.$router.push(`/library/${library.id}`)
|
||||||
},
|
},
|
||||||
clickAddLibrary() {
|
clickAddLibrary() {
|
||||||
this.selectedLibrary = null
|
this.$emit('showLibraryModal', null)
|
||||||
this.showLibraryModal = true
|
|
||||||
},
|
},
|
||||||
editLibrary(library) {
|
editLibrary(library) {
|
||||||
this.selectedLibrary = library
|
this.$emit('showLibraryModal', library)
|
||||||
this.showLibraryModal = true
|
|
||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.libraryCopies = this.libraries.map((lib) => {
|
this.libraryCopies = this.libraries.map((lib) => {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative">
|
<div ref="wrapper" class="relative">
|
||||||
<input ref="input" v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
|
<input ref="input" v-model="inputValue" :type="actualType" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
|
||||||
<div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center">
|
<div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center">
|
||||||
<span class="material-icons text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
|
<span class="material-icons text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="type === 'password' && isHovering" class="absolute top-0 right-0 h-full px-4 flex items-center justify-center">
|
||||||
|
<span class="material-icons-outlined text-gray-400 cursor-pointer text-lg" @click.stop.prevent="showPassword = !showPassword">{{ !showPassword ? 'visibility' : 'visibility_off' }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -31,7 +34,10 @@ export default {
|
|||||||
clearable: Boolean
|
clearable: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {
|
||||||
|
showPassword: false,
|
||||||
|
isHovering: false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
inputValue: {
|
inputValue: {
|
||||||
@@ -49,6 +55,10 @@ export default {
|
|||||||
if (this.noSpinner) _list.push('no-spinner')
|
if (this.noSpinner) _list.push('no-spinner')
|
||||||
if (this.textCenter) _list.push('text-center')
|
if (this.textCenter) _list.push('text-center')
|
||||||
return _list.join(' ')
|
return _list.join(' ')
|
||||||
|
},
|
||||||
|
actualType() {
|
||||||
|
if (this.type === 'password' && this.showPassword) return 'text'
|
||||||
|
return this.type
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -69,9 +79,20 @@ export default {
|
|||||||
},
|
},
|
||||||
blur() {
|
blur() {
|
||||||
if (this.$refs.input) this.$refs.input.blur()
|
if (this.$refs.input) this.$refs.input.blur()
|
||||||
|
},
|
||||||
|
mouseover() {
|
||||||
|
this.isHovering = true
|
||||||
|
},
|
||||||
|
mouseleave() {
|
||||||
|
this.isHovering = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {
|
||||||
|
if (this.type === 'password' && this.$refs.wrapper) {
|
||||||
|
this.$refs.wrapper.addEventListener('mouseover', this.mouseover)
|
||||||
|
this.$refs.wrapper.addEventListener('mouseleave', this.mouseleave)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">
|
<p class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }">
|
||||||
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
|
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
|
||||||
</p>
|
</p>
|
||||||
<ui-text-input ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" @blur="inputBlurred" />
|
<ui-text-input ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" @blur="inputBlurred" />
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
|
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||||
<div class="flex" :style="{ height: height + 'px' }">
|
<div class="flex" :style="{ height: height + 'px' }">
|
||||||
<template v-for="(item, index) in items">
|
<template v-for="(item, index) in items">
|
||||||
<cards-lazy-book-card :key="item.id" :ref="`slider-episode-${item.recentEpisode.id}`" :index="index" :book-mount="item" :height="cardHeight" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" :bookshelf-view="bookshelfView" class="relative mx-2" @edit="editEpisode" @editPodcast="editPodcast" @select="selectItem" @hook:updated="setScrollVars" />
|
<cards-lazy-book-card :key="item.recentEpisode.id" :ref="`slider-episode-${item.recentEpisode.id}`" :index="index" :book-mount="item" :height="cardHeight" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" :bookshelf-view="bookshelfView" class="relative mx-2" @edit="editEpisode" @editPodcast="editPodcast" @select="selectItem" @hook:updated="setScrollVars" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
<div class="text-white max-h-screen h-screen overflow-hidden bg-bg">
|
<div class="text-white max-h-screen h-screen overflow-hidden bg-bg">
|
||||||
<app-appbar />
|
<app-appbar />
|
||||||
|
|
||||||
<Nuxt />
|
<app-side-rail v-if="isShowingSideRail" class="hidden md:block" />
|
||||||
|
<div id="app-content" class="h-full" :class="{ 'has-siderail': isShowingSideRail }">
|
||||||
|
<Nuxt />
|
||||||
|
</div>
|
||||||
|
|
||||||
<app-stream-container ref="streamContainer" />
|
<app-stream-container ref="streamContainer" />
|
||||||
|
|
||||||
@@ -45,6 +48,13 @@ export default {
|
|||||||
},
|
},
|
||||||
isCasting() {
|
isCasting() {
|
||||||
return this.$store.state.globals.isCasting
|
return this.$store.state.globals.isCasting
|
||||||
|
},
|
||||||
|
isShowingSideRail() {
|
||||||
|
if (!this.$route.name) return false
|
||||||
|
return !this.$route.name.startsWith('config') && this.$store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
|
appContentMarginLeft() {
|
||||||
|
return this.isShowingSideRail ? 80 : 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -163,6 +173,7 @@ export default {
|
|||||||
this.$store.commit('libraries/addUpdate', library)
|
this.$store.commit('libraries/addUpdate', library)
|
||||||
},
|
},
|
||||||
async libraryRemoved(library) {
|
async libraryRemoved(library) {
|
||||||
|
console.log('Library removed', library)
|
||||||
this.$store.commit('libraries/remove', library)
|
this.$store.commit('libraries/remove', library)
|
||||||
|
|
||||||
// When removed currently selected library then set next accessible library
|
// When removed currently selected library then set next accessible library
|
||||||
@@ -181,18 +192,20 @@ export default {
|
|||||||
this.$router.push(`/library/${nextLibrary.id}`)
|
this.$router.push(`/library/${nextLibrary.id}`)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error('User has no accessible libraries')
|
console.error('User has no more accessible libraries')
|
||||||
|
this.$store.commit('libraries/setCurrentLibrary', null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
libraryItemAdded(libraryItem) {
|
libraryItemAdded(libraryItem) {
|
||||||
// this.$store.commit('libraries/updateFilterDataWithAudiobook', libraryItem)
|
this.$store.commit('libraries/updateFilterDataWithItem', libraryItem)
|
||||||
},
|
},
|
||||||
libraryItemUpdated(libraryItem) {
|
libraryItemUpdated(libraryItem) {
|
||||||
if (this.$store.state.selectedLibraryItem && this.$store.state.selectedLibraryItem.id === libraryItem.id) {
|
if (this.$store.state.selectedLibraryItem && this.$store.state.selectedLibraryItem.id === libraryItem.id) {
|
||||||
this.$store.commit('setSelectedLibraryItem', libraryItem)
|
this.$store.commit('setSelectedLibraryItem', libraryItem)
|
||||||
}
|
}
|
||||||
this.$eventBus.$emit(`${libraryItem.id}_updated`, libraryItem)
|
this.$eventBus.$emit(`${libraryItem.id}_updated`, libraryItem)
|
||||||
|
this.$store.commit('libraries/updateFilterDataWithItem', libraryItem)
|
||||||
},
|
},
|
||||||
libraryItemRemoved(item) {
|
libraryItemRemoved(item) {
|
||||||
if (this.$route.name.startsWith('item')) {
|
if (this.$route.name.startsWith('item')) {
|
||||||
@@ -550,4 +563,13 @@ export default {
|
|||||||
.Vue-Toastification__toast-body.custom-class-1 {
|
.Vue-Toastification__toast-body.custom-class-1 {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#app-content {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
#app-content.has-siderail {
|
||||||
|
width: calc(100% - 80px);
|
||||||
|
max-width: calc(100% - 80px);
|
||||||
|
margin-left: 80px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
export default function (context) {
|
|
||||||
if (process.client) {
|
|
||||||
var route = context.route
|
|
||||||
var from = context.from
|
|
||||||
var store = context.store
|
|
||||||
|
|
||||||
if (route.name === 'login' || from.name === 'login') return
|
|
||||||
|
|
||||||
if (!route.name) {
|
|
||||||
console.warn('No Route name', route)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (store.state.isRoutingBack) {
|
|
||||||
// pressing back button in appbar do not add to route history
|
|
||||||
store.commit('setIsRoutingBack', false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (route.name.startsWith('config') || route.name === 'upload' || route.name === 'account' || route.name.startsWith('audiobook-id') || route.name.startsWith('collection-id')) {
|
|
||||||
if (from.name !== route.name && from.name !== 'audiobook-id-edit' && !from.name.startsWith('config') && from.name !== 'upload' && from.name !== 'account') {
|
|
||||||
var _history = [...store.state.routeHistory]
|
|
||||||
if (!_history.length || _history[_history.length - 1] !== from.fullPath) {
|
|
||||||
_history.push(from.fullPath)
|
|
||||||
store.commit('setRouteHistory', _history)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -36,9 +36,7 @@ module.exports = {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
router: {
|
router: {},
|
||||||
middleware: ['routed']
|
|
||||||
},
|
|
||||||
|
|
||||||
// Global CSS: https://go.nuxtjs.dev/config-css
|
// Global CSS: https://go.nuxtjs.dev/config-css
|
||||||
css: [
|
css: [
|
||||||
|
|||||||
Generated
+701
-1604
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.0.12",
|
"version": "2.0.14",
|
||||||
"description": "Audiobook manager and player",
|
"description": "Self-hosted audiobook and podcast client",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nuxt",
|
"dev": "nuxt",
|
||||||
|
|||||||
@@ -0,0 +1,422 @@
|
|||||||
|
<template>
|
||||||
|
<div id="page-wrapper" class="bg-bg page overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
|
||||||
|
<div class="flex items-center py-4 max-w-7xl mx-auto">
|
||||||
|
<nuxt-link :to="`/item/${libraryItem.id}`" class="hover:underline">
|
||||||
|
<h1 class="text-xl">{{ title }}</h1>
|
||||||
|
</nuxt-link>
|
||||||
|
<button class="w-7 h-7 flex items-center justify-center mx-4 hover:scale-110 duration-100 transform text-gray-200 hover:text-white" @click="editItem">
|
||||||
|
<span class="material-icons text-base">edit</span>
|
||||||
|
</button>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<p class="text-base">Duration:</p>
|
||||||
|
<p class="text-base font-mono ml-8">{{ mediaDuration }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap-reverse justify-center py-4">
|
||||||
|
<div class="w-full max-w-3xl py-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<p class="text-lg mb-4 font-semibold">Audiobook Chapters</p>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn color="primary" small class="mx-2" @click="showFindChaptersModal = true">Lookup</ui-btn>
|
||||||
|
<ui-btn color="success" small @click="saveChapters">Save</ui-btn>
|
||||||
|
<div class="w-40" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
|
||||||
|
<div class="w-12"></div>
|
||||||
|
<div class="w-32 px-2">Start</div>
|
||||||
|
<div class="flex-grow px-2">Title</div>
|
||||||
|
<div class="w-40"></div>
|
||||||
|
</div>
|
||||||
|
<template v-for="chapter in newChapters">
|
||||||
|
<div :key="chapter.id" class="flex py-1">
|
||||||
|
<div class="w-12">#{{ chapter.id + 1 }}</div>
|
||||||
|
<div class="w-32 px-1">
|
||||||
|
<ui-text-input v-model="chapter.start" type="number" class="text-xs" @change="checkChapters" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow px-1">
|
||||||
|
<ui-text-input v-model="chapter.title" class="text-xs" />
|
||||||
|
</div>
|
||||||
|
<div class="w-40 px-2 py-1">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<button v-if="newChapters.length > 1" class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-error transform hover:scale-110 duration-150" @click="removeChapter(chapter)">
|
||||||
|
<span class="material-icons-outlined text-base">remove</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ui-tooltip text="Insert chapter below" direction="bottom">
|
||||||
|
<button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-success transform hover:scale-110 duration-150" @click="addChapter(chapter)">
|
||||||
|
<span class="material-icons text-lg">add</span>
|
||||||
|
</button>
|
||||||
|
</ui-tooltip>
|
||||||
|
|
||||||
|
<button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-white transform hover:scale-110 duration-150" @click="playChapter(chapter)">
|
||||||
|
<widgets-loading-spinner v-if="selectedChapterId === chapter.id && isLoadingChapter" />
|
||||||
|
<span v-else-if="selectedChapterId === chapter.id && isPlayingChapter" class="material-icons-outlined text-base">pause</span>
|
||||||
|
<span v-else class="material-icons-outlined text-base">play_arrow</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ui-tooltip v-if="chapter.error" :text="chapter.error" direction="left">
|
||||||
|
<button class="w-7 h-7 rounded-full flex items-center justify-center text-error">
|
||||||
|
<span class="material-icons-outlined text-lg">error_outline</span>
|
||||||
|
</button>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full max-w-xl py-4">
|
||||||
|
<p class="text-lg mb-4 font-semibold py-1">Audio Tracks</p>
|
||||||
|
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
|
||||||
|
<div class="flex-grow">Filename</div>
|
||||||
|
<div class="w-20">Duration</div>
|
||||||
|
<div class="w-20 text-center">Chapters</div>
|
||||||
|
</div>
|
||||||
|
<template v-for="track in audioTracks">
|
||||||
|
<div :key="track.ino" class="flex items-center py-2" :class="currentTrackIndex === track.index && isPlayingChapter ? 'bg-success bg-opacity-10' : ''">
|
||||||
|
<div class="flex-grow">
|
||||||
|
<p class="text-xs truncate max-w-sm">{{ track.metadata.filename }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-20" style="min-width: 80px">
|
||||||
|
<p class="text-xs font-mono text-gray-200">{{ track.duration }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-20 flex justify-center" style="min-width: 80px">
|
||||||
|
<span v-if="(track.chapters || []).length" class="material-icons text-success text-sm">check</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="saving" class="w-full h-full absolute top-0 left-0 bottom-0 right-0 z-30 bg-black bg-opacity-25 flex items-center justify-center">
|
||||||
|
<ui-loading-indicator />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<modals-modal v-model="showFindChaptersModal" name="edit-book" :width="500" :processing="findingChapters">
|
||||||
|
<template #outer>
|
||||||
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
|
||||||
|
<p class="font-book text-3xl text-white truncate pointer-events-none">Find Chapters</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="w-full h-full max-h-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative">
|
||||||
|
<div v-if="!chapterData" class="flex p-20">
|
||||||
|
<ui-text-input-with-label v-model="asinInput" label="ASIN" />
|
||||||
|
<ui-btn small color="primary" class="mt-5 ml-2" @click="findChapters">Find</ui-btn>
|
||||||
|
</div>
|
||||||
|
<div v-else class="w-full p-4">
|
||||||
|
<p class="mb-4">Duration found: {{ chapterData.runtimeLengthSec }}</p>
|
||||||
|
<div v-if="chapterData.runtimeLengthSec > mediaDuration" class="w-full bg-error bg-opacity-25 p-4 text-center mb-2 rounded border border-white border-opacity-10 text-gray-100 text-sm">
|
||||||
|
<p>Chapter data invalid duration<br />Your media duration is shorter than duration found</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex py-0.5 text-xs font-semibold uppercase text-gray-300 mb-1">
|
||||||
|
<div class="w-24 px-2">Start</div>
|
||||||
|
<div class="flex-grow px-2">Title</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full max-h-80 overflow-y-auto my-2">
|
||||||
|
<div v-for="(chapter, index) in chapterData.chapters" :key="index" class="flex py-0.5 text-xs" :class="chapter.startOffsetSec > mediaDuration ? 'bg-error bg-opacity-20' : chapter.startOffsetSec + chapter.lengthMs / 1000 > mediaDuration ? 'bg-warning bg-opacity-20' : index % 2 === 0 ? 'bg-primary bg-opacity-30' : ''">
|
||||||
|
<div class="w-24 min-w-24 px-2">
|
||||||
|
<p class="font-mono">{{ $secondsToTimestamp(chapter.startOffsetSec) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow px-2">
|
||||||
|
<p class="truncate max-w-sm">{{ chapter.title }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex pt-2">
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn small color="success" @click="applyChapterData">Apply Chapters</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
async asyncData({ store, params, app, redirect, route }) {
|
||||||
|
if (!store.getters['user/getUserCanUpdate']) {
|
||||||
|
return redirect('/?error=unauthorized')
|
||||||
|
}
|
||||||
|
var libraryItem = await app.$axios.$get(`/api/items/${params.id}?expanded=1`).catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
if (!libraryItem) {
|
||||||
|
console.error('Not found...', params.id)
|
||||||
|
return redirect('/')
|
||||||
|
}
|
||||||
|
if (libraryItem.mediaType != 'book') {
|
||||||
|
console.error('Invalid media type')
|
||||||
|
return redirect('/')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
libraryItem
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
newChapters: [],
|
||||||
|
selectedChapter: null,
|
||||||
|
audioEl: null,
|
||||||
|
isPlayingChapter: false,
|
||||||
|
isLoadingChapter: false,
|
||||||
|
currentTrackIndex: 0,
|
||||||
|
saving: false,
|
||||||
|
asinInput: null,
|
||||||
|
findingChapters: false,
|
||||||
|
showFindChaptersModal: false,
|
||||||
|
chapterData: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
streamLibraryItem() {
|
||||||
|
return this.$store.state.streamLibraryItem
|
||||||
|
},
|
||||||
|
userToken() {
|
||||||
|
return this.$store.getters['user/getToken']
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.libraryItem.media || {}
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return this.mediaMetadata.title
|
||||||
|
},
|
||||||
|
mediaDuration() {
|
||||||
|
return this.media.duration
|
||||||
|
},
|
||||||
|
chapters() {
|
||||||
|
return this.media.chapters || []
|
||||||
|
},
|
||||||
|
tracks() {
|
||||||
|
return this.media.tracks || []
|
||||||
|
},
|
||||||
|
audioFiles() {
|
||||||
|
return this.media.audioFiles || []
|
||||||
|
},
|
||||||
|
audioTracks() {
|
||||||
|
return this.audioFiles.filter((af) => !af.exclude && !af.invalid)
|
||||||
|
},
|
||||||
|
selectedChapterId() {
|
||||||
|
return this.selectedChapter ? this.selectedChapter.id : null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
editItem() {
|
||||||
|
this.$store.commit('showEditModal', this.libraryItem)
|
||||||
|
},
|
||||||
|
addChapter(chapter) {
|
||||||
|
console.log('Add chapter', chapter)
|
||||||
|
const newChapter = {
|
||||||
|
id: chapter.id + 1,
|
||||||
|
start: chapter.start,
|
||||||
|
end: chapter.end,
|
||||||
|
title: ''
|
||||||
|
}
|
||||||
|
this.newChapters.splice(chapter.id + 1, 0, newChapter)
|
||||||
|
this.checkChapters()
|
||||||
|
},
|
||||||
|
removeChapter(chapter) {
|
||||||
|
this.newChapters = this.newChapters.filter((ch) => ch.id !== chapter.id)
|
||||||
|
this.checkChapters()
|
||||||
|
},
|
||||||
|
checkChapters() {
|
||||||
|
var previousStart = 0
|
||||||
|
for (let i = 0; i < this.newChapters.length; i++) {
|
||||||
|
this.newChapters[i].id = i
|
||||||
|
this.newChapters[i].start = Number(this.newChapters[i].start)
|
||||||
|
|
||||||
|
if (i === 0 && this.newChapters[i].start !== 0) {
|
||||||
|
this.newChapters[i].error = 'First chapter must start at 0'
|
||||||
|
} else if (this.newChapters[i].start <= previousStart && i > 0) {
|
||||||
|
this.newChapters[i].error = 'Invalid start time must be >= previous chapter start time'
|
||||||
|
} else if (this.newChapters[i].start >= this.mediaDuration) {
|
||||||
|
this.newChapters[i].error = 'Invalid start time must be < duration'
|
||||||
|
} else {
|
||||||
|
this.newChapters[i].error = null
|
||||||
|
}
|
||||||
|
previousStart = this.newChapters[i].start
|
||||||
|
}
|
||||||
|
},
|
||||||
|
playChapter(chapter) {
|
||||||
|
console.log('Play Chapter', chapter.id)
|
||||||
|
if (this.selectedChapterId === chapter.id) {
|
||||||
|
console.log('Chapter already playing', this.isLoadingChapter, this.isPlayingChapter)
|
||||||
|
if (this.isLoadingChapter) return
|
||||||
|
if (this.isPlayingChapter) {
|
||||||
|
console.log('Destroying chapter')
|
||||||
|
this.destroyAudioEl()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.selectedChapterId) {
|
||||||
|
this.destroyAudioEl()
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioTrack = this.tracks.find((at) => {
|
||||||
|
return chapter.start >= at.startOffset && chapter.start < at.startOffset + at.duration
|
||||||
|
})
|
||||||
|
console.log('audio track', audioTrack)
|
||||||
|
|
||||||
|
this.selectedChapter = chapter
|
||||||
|
this.isLoadingChapter = true
|
||||||
|
|
||||||
|
const trackOffset = chapter.start - audioTrack.startOffset
|
||||||
|
this.playTrackAtTime(audioTrack, trackOffset)
|
||||||
|
},
|
||||||
|
playTrackAtTime(audioTrack, trackOffset) {
|
||||||
|
this.currentTrackIndex = audioTrack.index
|
||||||
|
|
||||||
|
const audioEl = this.audioEl || document.createElement('audio')
|
||||||
|
var src = audioTrack.contentUrl + `?token=${this.userToken}`
|
||||||
|
if (this.$isDev) {
|
||||||
|
src = `http://localhost:3333${src}`
|
||||||
|
}
|
||||||
|
console.log('src', src)
|
||||||
|
|
||||||
|
audioEl.src = src
|
||||||
|
audioEl.id = 'chapter-audio'
|
||||||
|
document.body.appendChild(audioEl)
|
||||||
|
|
||||||
|
audioEl.addEventListener('loadeddata', () => {
|
||||||
|
console.log('Audio loaded data', audioEl.duration)
|
||||||
|
audioEl.currentTime = trackOffset
|
||||||
|
audioEl.play()
|
||||||
|
console.log('Playing audio at current time', trackOffset)
|
||||||
|
})
|
||||||
|
audioEl.addEventListener('play', () => {
|
||||||
|
console.log('Audio playing')
|
||||||
|
this.isLoadingChapter = false
|
||||||
|
this.isPlayingChapter = true
|
||||||
|
})
|
||||||
|
audioEl.addEventListener('ended', () => {
|
||||||
|
console.log('Audio ended')
|
||||||
|
const nextTrack = this.tracks.find((t) => t.index === this.currentTrackIndex + 1)
|
||||||
|
if (nextTrack) {
|
||||||
|
console.log('Playing next track', nextTrack.index)
|
||||||
|
this.currentTrackIndex = nextTrack.index
|
||||||
|
this.playTrackAtTime(nextTrack, 0)
|
||||||
|
} else {
|
||||||
|
console.log('No next track')
|
||||||
|
this.destroyAudioEl()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.audioEl = audioEl
|
||||||
|
},
|
||||||
|
destroyAudioEl() {
|
||||||
|
if (!this.audioEl) return
|
||||||
|
this.audioEl.remove()
|
||||||
|
this.audioEl = null
|
||||||
|
this.selectedChapter = null
|
||||||
|
this.isPlayingChapter = false
|
||||||
|
this.isLoadingChapter = false
|
||||||
|
},
|
||||||
|
saveChapters() {
|
||||||
|
this.checkChapters()
|
||||||
|
|
||||||
|
for (let i = 0; i < this.newChapters.length; i++) {
|
||||||
|
if (this.newChapters[i].error) {
|
||||||
|
this.$toast.error('Chapters have errors')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!this.newChapters[i].title) {
|
||||||
|
this.$toast.error('Chapters must have titles')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextChapter = this.newChapters[i + 1]
|
||||||
|
if (nextChapter) {
|
||||||
|
this.newChapters[i].end = nextChapter.start
|
||||||
|
} else {
|
||||||
|
this.newChapters[i].end = this.mediaDuration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saving = true
|
||||||
|
|
||||||
|
console.log('udpated chapters', this.newChapters)
|
||||||
|
const payload = {
|
||||||
|
chapters: this.newChapters
|
||||||
|
}
|
||||||
|
this.$axios
|
||||||
|
.$post(`/api/items/${this.libraryItem.id}/chapters`, payload)
|
||||||
|
.then((data) => {
|
||||||
|
this.saving = false
|
||||||
|
if (data.updated) {
|
||||||
|
this.$toast.success('Chapters updated')
|
||||||
|
this.$router.push(`/item/${this.libraryItem.id}`)
|
||||||
|
} else {
|
||||||
|
this.$toast.info('No changes needed updating')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.saving = false
|
||||||
|
console.error('Failed to update chapters', error)
|
||||||
|
this.$toast.error('Failed to update chapters')
|
||||||
|
})
|
||||||
|
},
|
||||||
|
applyChapterData() {
|
||||||
|
var index = 0
|
||||||
|
this.newChapters = this.chapterData.chapters
|
||||||
|
.filter((chap) => chap.startOffsetSec < this.mediaDuration)
|
||||||
|
.map((chap) => {
|
||||||
|
var chapEnd = Math.min(this.mediaDuration, (chap.startOffsetMs + chap.lengthMs) / 1000)
|
||||||
|
return {
|
||||||
|
id: index++,
|
||||||
|
start: chap.startOffsetMs / 1000,
|
||||||
|
end: chapEnd,
|
||||||
|
title: chap.title
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.showFindChaptersModal = false
|
||||||
|
this.chapterData = null
|
||||||
|
},
|
||||||
|
findChapters() {
|
||||||
|
if (!this.asinInput) {
|
||||||
|
this.$toast.error('Must input an ASIN')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.findingChapters = true
|
||||||
|
this.chapterData = null
|
||||||
|
this.$axios
|
||||||
|
.$get(`/api/search/chapters?asin=${this.asinInput}`)
|
||||||
|
.then((data) => {
|
||||||
|
this.findingChapters = false
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
this.$toast.error(data.error)
|
||||||
|
this.showFindChaptersModal = false
|
||||||
|
} else {
|
||||||
|
console.log('Chapter data', data)
|
||||||
|
this.chapterData = data
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.findingChapters = false
|
||||||
|
console.error('Failed to get chapter data', error)
|
||||||
|
this.$toast.error('Failed to find chapters')
|
||||||
|
this.showFindChaptersModal = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.asinInput = this.mediaMetadata.asin || null
|
||||||
|
this.newChapters = this.chapters.map((c) => ({ ...c }))
|
||||||
|
if (!this.newChapters.length) {
|
||||||
|
this.newChapters = [
|
||||||
|
{
|
||||||
|
id: 0,
|
||||||
|
start: 0,
|
||||||
|
end: this.mediaDuration,
|
||||||
|
title: ''
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -89,9 +89,6 @@ export default {
|
|||||||
draggable
|
draggable
|
||||||
},
|
},
|
||||||
async asyncData({ store, params, app, redirect, route }) {
|
async asyncData({ store, params, app, redirect, route }) {
|
||||||
if (!store.state.user.user) {
|
|
||||||
return redirect(`/login?redirect=${route.path}`)
|
|
||||||
}
|
|
||||||
if (!store.getters['user/getUserCanUpdate']) {
|
if (!store.getters['user/getUserCanUpdate']) {
|
||||||
return redirect('/?error=unauthorized')
|
return redirect('/?error=unauthorized')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="page-wrapper" class="page p-6 overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
|
<div id="page-wrapper" class="page p-2 md:p-6 overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
|
||||||
<app-config-side-nav :is-open.sync="sideDrawerOpen" />
|
<app-config-side-nav :is-open.sync="sideDrawerOpen" />
|
||||||
<div class="configContent" :class="`page-${currentPage}`">
|
<div class="configContent" :class="`page-${currentPage}`">
|
||||||
<div v-show="isMobile" class="w-full pb-4 px-2 flex border-b border-white border-opacity-10 mb-2">
|
<div v-show="isMobile" class="w-full pb-4 px-2 flex border-b border-white border-opacity-10 mb-2">
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<tables-library-libraries-table />
|
<tables-library-libraries-table @showLibraryModal="setShowLibraryModal" />
|
||||||
|
|
||||||
|
<modals-libraries-edit-modal v-model="showLibraryModal" :library="selectedLibrary" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {
|
||||||
|
showLibraryModal: false,
|
||||||
|
selectedLibrary: null
|
||||||
|
}
|
||||||
},
|
},
|
||||||
computed: {},
|
computed: {},
|
||||||
methods: {},
|
methods: {
|
||||||
|
setShowLibraryModal(selectedLibrary) {
|
||||||
|
this.selectedLibrary = selectedLibrary
|
||||||
|
this.showLibraryModal = true
|
||||||
|
}
|
||||||
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -67,6 +67,12 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
|
asyncData({ redirect, store }) {
|
||||||
|
if (!store.state.libraries.currentLibraryId) {
|
||||||
|
return redirect('/config')
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
libraryStats: null
|
libraryStats: null
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<div class="flex p-2">
|
<div class="flex p-2">
|
||||||
<svg class="h-14 w-14 md:h-18 md:w-18" viewBox="0 0 24 24">
|
<svg class="hidden sm:block h-14 w-14 lg:h-18 lg:w-18" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
d="M19 1L14 6V17L19 12.5V1M21 5V18.5C19.9 18.15 18.7 18 17.5 18C15.8 18 13.35 18.65 12 19.5V6C10.55 4.9 8.45 4.5 6.5 4.5C4.55 4.5 2.45 4.9 1 6V20.65C1 20.9 1.25 21.15 1.5 21.15C1.6 21.15 1.65 21.1 1.75 21.1C3.1 20.45 5.05 20 6.5 20C8.45 20 10.55 20.4 12 21.5C13.35 20.65 15.8 20 17.5 20C19.15 20 20.85 20.3 22.25 21.05C22.35 21.1 22.4 21.1 22.5 21.1C22.75 21.1 23 20.85 23 20.6V6C22.4 5.55 21.75 5.25 21 5M10 18.41C8.75 18.09 7.5 18 6.5 18C5.44 18 4.18 18.19 3 18.5V7.13C3.91 6.73 5.14 6.5 6.5 6.5C7.86 6.5 9.09 6.73 10 7.13V18.41Z"
|
d="M19 1L14 6V17L19 12.5V1M21 5V18.5C19.9 18.15 18.7 18 17.5 18C15.8 18 13.35 18.65 12 19.5V6C10.55 4.9 8.45 4.5 6.5 4.5C4.55 4.5 2.45 4.9 1 6V20.65C1 20.9 1.25 21.15 1.5 21.15C1.6 21.15 1.65 21.1 1.75 21.1C3.1 20.45 5.05 20 6.5 20C8.45 20 10.55 20.4 12 21.5C13.35 20.65 15.8 20 17.5 20C19.15 20 20.85 20.3 22.25 21.05C22.35 21.1 22.4 21.1 22.5 21.1C22.75 21.1 23 20.85 23 20.6V6C22.4 5.55 21.75 5.25 21 5M10 18.41C8.75 18.09 7.5 18 6.5 18C5.44 18 4.18 18.19 3 18.5V7.13C3.91 6.73 5.14 6.5 6.5 6.5C7.86 6.5 9.09 6.73 10 7.13V18.41Z"
|
||||||
@@ -15,7 +15,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex p-2">
|
<div class="flex p-2">
|
||||||
<span class="material-icons-outlined" style="font-size: 4.1rem">event</span>
|
<div class="hidden sm:block">
|
||||||
|
<span class="hidden sm:block material-icons-outlined text-5xl lg:text-6xl">event</span>
|
||||||
|
</div>
|
||||||
<div class="px-1">
|
<div class="px-1">
|
||||||
<p class="text-4xl md:text-5xl font-bold">{{ totalDaysListened }}</p>
|
<p class="text-4xl md:text-5xl font-bold">{{ totalDaysListened }}</p>
|
||||||
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Days Listened</p>
|
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Days Listened</p>
|
||||||
@@ -23,15 +25,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex p-2">
|
<div class="flex p-2">
|
||||||
<span class="material-icons-outlined" style="font-size: 4.1rem">watch_later</span>
|
<div class="hidden sm:block">
|
||||||
|
<span class="material-icons-outlined text-5xl lg:text-6xl">watch_later</span>
|
||||||
|
</div>
|
||||||
<div class="px-1">
|
<div class="px-1">
|
||||||
<p class="text-4xl md:text-5xl font-bold">{{ totalMinutesListening }}</p>
|
<p class="text-4xl md:text-5xl font-bold">{{ totalMinutesListening }}</p>
|
||||||
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Minutes Listening</p>
|
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Minutes Listening</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col md:flex-row">
|
<div class="flex flex-col md:flex-row overflow-hidden max-w-full">
|
||||||
<stats-daily-listening-chart :listening-stats="listeningStats" />
|
<stats-daily-listening-chart :listening-stats="listeningStats" class="origin-top-left transform scale-75 lg:scale-100" />
|
||||||
<div class="w-80 my-6 mx-auto">
|
<div class="w-80 my-6 mx-auto">
|
||||||
<h1 class="text-2xl mb-4 font-book">Recent Listening Sessions</h1>
|
<h1 class="text-2xl mb-4 font-book">Recent Listening Sessions</h1>
|
||||||
<p v-if="!mostRecentListeningSessions.length">No Listening Sessions</p>
|
<p v-if="!mostRecentListeningSessions.length">No Listening Sessions</p>
|
||||||
@@ -52,6 +56,8 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<stats-heatmap v-if="listeningStats" :days-listening="listeningStats.days" class="my-2" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -59,7 +65,8 @@
|
|||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
listeningStats: null
|
listeningStats: null,
|
||||||
|
windowWidth: 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
asyncData({ redirect, store }) {
|
asyncData({ redirect, store }) {
|
||||||
|
if (!store.state.libraries.currentLibraryId) {
|
||||||
|
return redirect('/oops?message=No libraries')
|
||||||
|
}
|
||||||
redirect(`/library/${store.state.libraries.currentLibraryId}`)
|
redirect(`/library/${store.state.libraries.currentLibraryId}`)
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
|
|||||||
@@ -177,6 +177,8 @@
|
|||||||
|
|
||||||
<tables-podcast-episodes-table v-if="isPodcast" :library-item="libraryItem" />
|
<tables-podcast-episodes-table v-if="isPodcast" :library-item="libraryItem" />
|
||||||
|
|
||||||
|
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" class="mt-6" />
|
||||||
|
|
||||||
<tables-library-files-table v-if="libraryFiles.length" :is-missing="isMissing" :library-item-id="libraryItemId" :files="libraryFiles" class="mt-6" />
|
<tables-library-files-table v-if="libraryFiles.length" :is-missing="isMissing" :library-item-id="libraryItemId" :files="libraryFiles" class="mt-6" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -275,6 +277,9 @@ export default {
|
|||||||
mediaMetadata() {
|
mediaMetadata() {
|
||||||
return this.media.metadata || {}
|
return this.media.metadata || {}
|
||||||
},
|
},
|
||||||
|
chapters() {
|
||||||
|
return this.media.chapters || []
|
||||||
|
},
|
||||||
tracks() {
|
tracks() {
|
||||||
return this.media.tracks || []
|
return this.media.tracks || []
|
||||||
},
|
},
|
||||||
@@ -378,7 +383,8 @@ export default {
|
|||||||
return this.$store.getters['user/getUserCanDownload']
|
return this.$store.getters['user/getUserCanDownload']
|
||||||
},
|
},
|
||||||
showRssFeedBtn() {
|
showRssFeedBtn() {
|
||||||
if (!this.showExperimentalFeatures) return false
|
if (!this.rssFeedUrl && !this.podcastEpisodes.length) return false // Cannot open RSS feed with no episodes
|
||||||
|
|
||||||
// If rss feed is open then show feed url to users otherwise just show to admins
|
// If rss feed is open then show feed url to users otherwise just show to admins
|
||||||
return this.isPodcast && (this.userIsAdminOrUp || this.rssFeedUrl)
|
return this.isPodcast && (this.userIsAdminOrUp || this.rssFeedUrl)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
||||||
<div class="flex h-full">
|
<app-book-shelf-toolbar is-home />
|
||||||
<app-side-rail class="hidden md:block" />
|
<div id="bookshelf" class="w-full h-full p-8 overflow-y-auto">
|
||||||
<div class="flex-grow">
|
<div class="flex flex-wrap justify-center">
|
||||||
<app-book-shelf-toolbar is-home />
|
<template v-for="author in authors">
|
||||||
<div id="bookshelf" class="w-full h-full p-8 overflow-y-auto">
|
<cards-author-card :key="author.id" :author="author" :width="160" :height="200" class="p-3" @edit="editAuthor" />
|
||||||
<div class="flex flex-wrap justify-center">
|
</template>
|
||||||
<template v-for="author in authors">
|
|
||||||
<cards-author-card :key="author.id" :author="author" :width="160" :height="200" class="p-3" @edit="editAuthor" />
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
||||||
<div class="flex h-full">
|
<app-book-shelf-toolbar :page="id || ''" :view-mode.sync="viewMode" />
|
||||||
<app-side-rail class="hidden md:block" />
|
<app-lazy-bookshelf :page="id || ''" :view-mode="viewMode" />
|
||||||
<div class="flex-grow">
|
|
||||||
<app-book-shelf-toolbar :page="id || ''" :view-mode.sync="viewMode" />
|
|
||||||
<app-lazy-bookshelf :page="id || ''" :view-mode="viewMode" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
||||||
<div class="flex h-full">
|
<app-book-shelf-toolbar is-home />
|
||||||
<app-side-rail class="hidden md:block" />
|
<app-book-shelf-categorized />
|
||||||
<div class="flex-grow">
|
|
||||||
<app-book-shelf-toolbar is-home />
|
|
||||||
<app-book-shelf-categorized />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,38 +1,33 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
||||||
<div class="flex h-full">
|
<app-book-shelf-toolbar page="podcast-search" />
|
||||||
<app-side-rail class="hidden md:block" />
|
<div class="w-full h-full overflow-y-auto p-12 relative">
|
||||||
<div class="flex-grow">
|
<div class="w-full max-w-3xl mx-auto">
|
||||||
<app-book-shelf-toolbar page="podcast-search" />
|
<form @submit.prevent="submit" class="flex">
|
||||||
<div class="w-full h-full overflow-y-auto p-12 relative">
|
<ui-text-input v-model="searchInput" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2" />
|
||||||
<div class="w-full max-w-3xl mx-auto">
|
<ui-btn type="submit" :disabled="processing">Submit</ui-btn>
|
||||||
<form @submit.prevent="submit" class="flex">
|
</form>
|
||||||
<ui-text-input v-model="searchInput" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2" />
|
</div>
|
||||||
<ui-btn type="submit" :disabled="processing">Submit</ui-btn>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-full max-w-3xl mx-auto py-4">
|
<div class="w-full max-w-3xl mx-auto py-4">
|
||||||
<p v-if="termSearched && !results.length && !processing" class="text-center text-xl">No podcasts found</p>
|
<p v-if="termSearched && !results.length && !processing" class="text-center text-xl">No podcasts found</p>
|
||||||
<template v-for="podcast in results">
|
<template v-for="podcast in results">
|
||||||
<div :key="podcast.id" class="flex p-1 hover:bg-primary hover:bg-opacity-25 cursor-pointer" @click="selectPodcast(podcast)">
|
<div :key="podcast.id" class="flex p-1 hover:bg-primary hover:bg-opacity-25 cursor-pointer" @click="selectPodcast(podcast)">
|
||||||
<div class="w-24 min-w-24 h-24 bg-primary">
|
<div class="w-24 min-w-24 h-24 bg-primary">
|
||||||
<img v-if="podcast.cover" :src="podcast.cover" class="h-full w-full" />
|
<img v-if="podcast.cover" :src="podcast.cover" class="h-full w-full" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow pl-4 max-w-2xl">
|
<div class="flex-grow pl-4 max-w-2xl">
|
||||||
<a :href="podcast.pageUrl" class="text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a>
|
<a :href="podcast.pageUrl" class="text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a>
|
||||||
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ podcast.artistName }}</p>
|
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ podcast.artistName }}</p>
|
||||||
<p class="text-xs text-gray-400 leading-5">{{ podcast.genres.join(', ') }}</p>
|
<p class="text-xs text-gray-400 leading-5">{{ podcast.genres.join(', ') }}</p>
|
||||||
<p class="text-xs text-gray-400 leading-5">{{ podcast.trackCount }} Episodes</p>
|
<p class="text-xs text-gray-400 leading-5">{{ podcast.trackCount }} Episodes</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-show="processing" class="absolute top-0 left-0 w-full h-full flex items-center justify-center bg-black bg-opacity-25 z-40">
|
<div v-show="processing" class="absolute top-0 left-0 w-full h-full flex items-center justify-center bg-black bg-opacity-25 z-40">
|
||||||
<ui-loading-indicator />
|
<ui-loading-indicator />
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
||||||
<div class="flex h-full">
|
<app-book-shelf-toolbar is-home page="search" :search-query="query" />
|
||||||
<app-side-rail class="hidden md:block" />
|
<app-book-shelf-categorized v-if="hasResults" ref="bookshelf" search :results="results" />
|
||||||
<div class="flex-grow">
|
<div v-else class="w-full py-16">
|
||||||
<app-book-shelf-toolbar is-home page="search" :search-query="query" />
|
<p class="text-xl text-center">No Search results for "{{ query }}"</p>
|
||||||
<app-book-shelf-categorized v-if="hasResults" ref="bookshelf" search :results="results" />
|
|
||||||
<div v-else class="w-full py-16">
|
|
||||||
<p class="text-xl text-center">No Search results for "{{ query }}"</p>
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<ui-btn class="w-52 my-4" @click="back">Back</ui-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -79,12 +71,6 @@ export default {
|
|||||||
this.$refs.bookshelf.setShelvesFromSearch()
|
this.$refs.bookshelf.setShelvesFromSearch()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
|
||||||
async back() {
|
|
||||||
var popped = await this.$store.dispatch('popRoute')
|
|
||||||
if (popped) this.$store.commit('setIsRoutingBack', true)
|
|
||||||
var backTo = popped || '/'
|
|
||||||
this.$router.push(backTo)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {},
|
mounted() {},
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
||||||
<div class="flex h-full">
|
<app-book-shelf-toolbar :selected-series="series" />
|
||||||
<app-side-rail class="hidden md:block" />
|
<app-lazy-bookshelf page="series-books" :series-id="seriesId" />
|
||||||
<div class="flex-grow">
|
|
||||||
<app-book-shelf-toolbar :selected-series="series" />
|
|
||||||
<app-lazy-bookshelf page="series-books" :series-id="seriesId" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
+126
-28
@@ -1,7 +1,29 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-screen bg-bg">
|
<div class="w-full h-screen bg-bg">
|
||||||
<div class="w-full flex h-1/2 items-center justify-center">
|
<div class="w-full flex h-full items-center justify-center">
|
||||||
<div class="w-full max-w-md border border-opacity-0 rounded-xl px-8 pb-8 pt-4">
|
<div v-if="criticalError" class="w-full max-w-md rounded border border-error border-opacity-25 bg-error bg-opacity-10 p-4">
|
||||||
|
<p class="text-center text-lg font-semibold">Server could not be reached</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="showInitScreen" class="w-full max-w-lg px-4 md:px-8 pb-8 pt-4">
|
||||||
|
<p class="text-3xl text-white text-center mb-4">Initial Server Setup</p>
|
||||||
|
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||||
|
|
||||||
|
<form @submit.prevent="submitServerSetup">
|
||||||
|
<p class="text-lg font-semibold mb-2 pl-1 text-center">Create Root User</p>
|
||||||
|
<ui-text-input-with-label v-model="newRoot.username" label="Username" :disabled="processing" class="w-full mb-3 text-sm" />
|
||||||
|
<ui-text-input-with-label v-model="newRoot.password" label="Password" type="password" :disabled="processing" class="w-full mb-3 text-sm" />
|
||||||
|
<ui-text-input-with-label v-model="confirmPassword" label="Confirm Password" type="password" :disabled="processing" class="w-full mb-3 text-sm" />
|
||||||
|
|
||||||
|
<p class="text-lg font-semibold mt-6 mb-2 pl-1 text-center">Directory Paths</p>
|
||||||
|
<ui-text-input-with-label v-model="ConfigPath" label="Config Path" disabled class="w-full mb-3 text-sm" />
|
||||||
|
<ui-text-input-with-label v-model="MetadataPath" label="Metadata Path" disabled class="w-full mb-3 text-sm" />
|
||||||
|
|
||||||
|
<div class="w-full flex justify-end py-3">
|
||||||
|
<ui-btn type="submit" :disabled="processing" color="primary" class="leading-none">{{ processing ? 'Initializing...' : 'Submit' }}</ui-btn>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="isInit" class="w-full max-w-md px-8 pb-8 pt-4 -mt-40">
|
||||||
<p class="text-3xl text-white text-center mb-4">Login</p>
|
<p class="text-3xl text-white text-center mb-4">Login</p>
|
||||||
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||||
<p v-if="error" class="text-error text-center py-2">{{ error }}</p>
|
<p v-if="error" class="text-error text-center py-2">{{ error }}</p>
|
||||||
@@ -11,8 +33,8 @@
|
|||||||
|
|
||||||
<label class="text-xs text-gray-300 uppercase">Password</label>
|
<label class="text-xs text-gray-300 uppercase">Password</label>
|
||||||
<ui-text-input v-model="password" type="password" :disabled="processing" class="w-full mb-3" />
|
<ui-text-input v-model="password" type="password" :disabled="processing" class="w-full mb-3" />
|
||||||
<div class="w-full flex justify-end">
|
<div class="w-full flex justify-end py-3">
|
||||||
<button type="submit" :disabled="processing" class="bg-blue-600 hover:bg-blue-800 px-8 py-1 mt-3 rounded-md text-white text-center transition duration-300 ease-in-out focus:outline-none">{{ processing ? 'Checking...' : 'Submit' }}</button>
|
<ui-btn type="submit" :disabled="processing" color="primary" class="leading-none">{{ processing ? 'Checking...' : 'Submit' }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -26,15 +48,33 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
error: null,
|
error: null,
|
||||||
|
criticalError: null,
|
||||||
processing: false,
|
processing: false,
|
||||||
username: '',
|
username: '',
|
||||||
password: null
|
password: null,
|
||||||
|
showInitScreen: false,
|
||||||
|
isInit: false,
|
||||||
|
newRoot: {
|
||||||
|
username: 'root',
|
||||||
|
password: ''
|
||||||
|
},
|
||||||
|
confirmPassword: '',
|
||||||
|
ConfigPath: '',
|
||||||
|
MetadataPath: ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
user(newVal) {
|
user(newVal) {
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
if (this.$route.query.redirect) {
|
if (!this.$store.state.libraries.currentLibraryId) {
|
||||||
|
// No libraries available to this user
|
||||||
|
if (this.$store.getters['user/getIsRoot']) {
|
||||||
|
// If root user go to config/libraries
|
||||||
|
this.$router.replace('/config/libraries')
|
||||||
|
} else {
|
||||||
|
this.$router.replace('/oops?message=No libraries available')
|
||||||
|
}
|
||||||
|
} else if (this.$route.query.redirect) {
|
||||||
this.$router.replace(this.$route.query.redirect)
|
this.$router.replace(this.$route.query.redirect)
|
||||||
} else {
|
} else {
|
||||||
this.$router.replace(`/library/${this.$store.state.libraries.currentLibraryId}`)
|
this.$router.replace(`/library/${this.$store.state.libraries.currentLibraryId}`)
|
||||||
@@ -48,6 +88,42 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
async submitServerSetup() {
|
||||||
|
if (!this.newRoot.username || !this.newRoot.username.trim()) {
|
||||||
|
this.$toast.error('Must enter a root username')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.newRoot.password !== this.confirmPassword) {
|
||||||
|
this.$toast.error('Password mismatch')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!this.newRoot.password) {
|
||||||
|
if (!confirm('Are you sure you want to create the root user with no password?')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.processing = true
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
newRoot: { ...this.newRoot }
|
||||||
|
}
|
||||||
|
var success = await this.$axios
|
||||||
|
.$post('/init', payload)
|
||||||
|
.then(() => true)
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed', error.response)
|
||||||
|
const errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
||||||
|
this.$toast.error(errorMsg)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
this.processing = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
location.reload()
|
||||||
|
},
|
||||||
setUser({ user, userDefaultLibraryId, serverSettings }) {
|
setUser({ user, userDefaultLibraryId, serverSettings }) {
|
||||||
this.$store.commit('setServerSettings', serverSettings)
|
this.$store.commit('setServerSettings', serverSettings)
|
||||||
|
|
||||||
@@ -81,32 +157,54 @@ export default {
|
|||||||
this.processing = false
|
this.processing = false
|
||||||
},
|
},
|
||||||
checkAuth() {
|
checkAuth() {
|
||||||
if (localStorage.getItem('token')) {
|
var token = localStorage.getItem('token')
|
||||||
var token = localStorage.getItem('token')
|
if (!token) return false
|
||||||
|
|
||||||
if (token) {
|
this.processing = true
|
||||||
this.processing = true
|
|
||||||
|
|
||||||
this.$axios
|
return this.$axios
|
||||||
.$post('/api/authorize', null, {
|
.$post('/api/authorize', null, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`
|
Authorization: `Bearer ${token}`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
this.setUser(res)
|
this.setUser(res)
|
||||||
this.processing = false
|
this.processing = false
|
||||||
})
|
return true
|
||||||
.catch((error) => {
|
})
|
||||||
console.error('Authorize error', error)
|
.catch((error) => {
|
||||||
this.processing = false
|
console.error('Authorize error', error)
|
||||||
})
|
this.processing = false
|
||||||
}
|
return false
|
||||||
}
|
})
|
||||||
|
},
|
||||||
|
checkStatus() {
|
||||||
|
this.processing = true
|
||||||
|
this.$axios
|
||||||
|
.$get('/status')
|
||||||
|
.then((res) => {
|
||||||
|
this.processing = false
|
||||||
|
this.isInit = res.isInit
|
||||||
|
this.showInitScreen = !res.isInit
|
||||||
|
if (this.showInitScreen) {
|
||||||
|
this.ConfigPath = res.ConfigPath || ''
|
||||||
|
this.MetadataPath = res.MetadataPath || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Status check failed', error)
|
||||||
|
this.processing = false
|
||||||
|
this.criticalError = 'Status check failed'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
async mounted() {
|
||||||
this.checkAuth()
|
if (localStorage.getItem('token')) {
|
||||||
|
var userfound = await this.checkAuth()
|
||||||
|
if (userfound) return // if valid user no need to check status
|
||||||
|
}
|
||||||
|
this.checkStatus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -86,6 +86,13 @@ export default {
|
|||||||
uploadFinished: false
|
uploadFinished: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
watch: {
|
||||||
|
selectedLibrary(newVal) {
|
||||||
|
if (newVal && !this.selectedFolderId) {
|
||||||
|
this.setDefaultFolder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
computed: {
|
computed: {
|
||||||
inputAccept() {
|
inputAccept() {
|
||||||
var extensions = []
|
var extensions = []
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ Vue.prototype.$addDaysToToday = (daysToAdd) => {
|
|||||||
if (!date || !isDate(date)) return null
|
if (!date || !isDate(date)) return null
|
||||||
return date
|
return date
|
||||||
}
|
}
|
||||||
|
Vue.prototype.$addDaysToDate = (jsdate, daysToAdd) => {
|
||||||
|
var date = addDays(jsdate, daysToAdd)
|
||||||
|
if (!date || !isDate(date)) return null
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
|
||||||
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
|
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
|
||||||
if (isNaN(bytes) || bytes == 0) {
|
if (isNaN(bytes) || bytes == 0) {
|
||||||
@@ -35,17 +40,20 @@ Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
|
|||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
Vue.prototype.$elapsedPretty = (seconds) => {
|
Vue.prototype.$elapsedPretty = (seconds, useFullNames = false) => {
|
||||||
|
if (seconds < 60) {
|
||||||
|
return `${Math.floor(seconds)} sec${useFullNames ? 'onds' : ''}`
|
||||||
|
}
|
||||||
var minutes = Math.floor(seconds / 60)
|
var minutes = Math.floor(seconds / 60)
|
||||||
if (minutes < 70) {
|
if (minutes < 70) {
|
||||||
return `${minutes} min`
|
return `${minutes} min${useFullNames ? `ute${minutes === 1 ? '' : 's'}` : ''}`
|
||||||
}
|
}
|
||||||
var hours = Math.floor(minutes / 60)
|
var hours = Math.floor(minutes / 60)
|
||||||
minutes -= hours * 60
|
minutes -= hours * 60
|
||||||
if (!minutes) {
|
if (!minutes) {
|
||||||
return `${hours} hr`
|
return `${hours} ${useFullNames ? 'hours' : 'hr'}`
|
||||||
}
|
}
|
||||||
return `${hours} hr ${minutes} min`
|
return `${hours} ${useFullNames ? `hour${hours === 1 ? '' : 's'}` : 'hr'} ${minutes} ${useFullNames ? `minute${minutes === 1 ? '' : 's'}` : 'min'}`
|
||||||
}
|
}
|
||||||
|
|
||||||
Vue.prototype.$secondsToTimestamp = (seconds) => {
|
Vue.prototype.$secondsToTimestamp = (seconds) => {
|
||||||
@@ -106,10 +114,11 @@ Vue.prototype.$calculateTextSize = (text, styles = {}) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Vue.prototype.$sanitizeFilename = (input, replacement = '') => {
|
Vue.prototype.$sanitizeFilename = (input, colonReplacement = ' - ') => {
|
||||||
if (typeof input !== 'string') {
|
if (typeof input !== 'string') {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
var replacement = ''
|
||||||
var illegalRe = /[\/\?<>\\:\*\|"]/g;
|
var illegalRe = /[\/\?<>\\:\*\|"]/g;
|
||||||
var controlRe = /[\x00-\x1f\x80-\x9f]/g;
|
var controlRe = /[\x00-\x1f\x80-\x9f]/g;
|
||||||
var reservedRe = /^\.+$/;
|
var reservedRe = /^\.+$/;
|
||||||
@@ -117,6 +126,7 @@ Vue.prototype.$sanitizeFilename = (input, replacement = '') => {
|
|||||||
var windowsTrailingRe = /[\. ]+$/;
|
var windowsTrailingRe = /[\. ]+$/;
|
||||||
|
|
||||||
var sanitized = input
|
var sanitized = input
|
||||||
|
.replace(':', colonReplacement) // Replace first occurrence of a colon
|
||||||
.replace(illegalRe, replacement)
|
.replace(illegalRe, replacement)
|
||||||
.replace(controlRe, replacement)
|
.replace(controlRe, replacement)
|
||||||
.replace(reservedRe, replacement)
|
.replace(reservedRe, replacement)
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ export const state = () => ({
|
|||||||
selectedLibraryItems: [],
|
selectedLibraryItems: [],
|
||||||
processingBatch: false,
|
processingBatch: false,
|
||||||
previousPath: '/',
|
previousPath: '/',
|
||||||
routeHistory: [],
|
|
||||||
isRoutingBack: false,
|
|
||||||
showExperimentalFeatures: false,
|
showExperimentalFeatures: false,
|
||||||
backups: [],
|
backups: [],
|
||||||
bookshelfBookIds: [],
|
bookshelfBookIds: [],
|
||||||
@@ -74,15 +72,6 @@ export const actions = {
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
popRoute({ commit, state }) {
|
|
||||||
if (!state.routeHistory.length) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
var _history = [...state.routeHistory]
|
|
||||||
var last = _history.pop()
|
|
||||||
commit('setRouteHistory', _history)
|
|
||||||
return last
|
|
||||||
},
|
|
||||||
setBookshelfTexture({ commit, state }, img) {
|
setBookshelfTexture({ commit, state }, img) {
|
||||||
let root = document.documentElement;
|
let root = document.documentElement;
|
||||||
commit('setBookshelfTexture', img)
|
commit('setBookshelfTexture', img)
|
||||||
@@ -94,12 +83,6 @@ export const mutations = {
|
|||||||
setBookshelfBookIds(state, val) {
|
setBookshelfBookIds(state, val) {
|
||||||
state.bookshelfBookIds = val || []
|
state.bookshelfBookIds = val || []
|
||||||
},
|
},
|
||||||
setRouteHistory(state, val) {
|
|
||||||
state.routeHistory = val
|
|
||||||
},
|
|
||||||
setIsRoutingBack(state, val) {
|
|
||||||
state.isRoutingBack = val
|
|
||||||
},
|
|
||||||
setPreviousPath(state, val) {
|
setPreviousPath(state, val) {
|
||||||
state.previousPath = val
|
state.previousPath = val
|
||||||
},
|
},
|
||||||
|
|||||||
+60
-26
@@ -2,7 +2,7 @@ export const state = () => ({
|
|||||||
libraries: [],
|
libraries: [],
|
||||||
lastLoad: 0,
|
lastLoad: 0,
|
||||||
listeners: [],
|
listeners: [],
|
||||||
currentLibraryId: 'main',
|
currentLibraryId: null,
|
||||||
folders: [],
|
folders: [],
|
||||||
issues: 0,
|
issues: 0,
|
||||||
folderLastUpdate: 0,
|
folderLastUpdate: 0,
|
||||||
@@ -206,11 +206,11 @@ export const mutations = {
|
|||||||
setLibraryFilterData(state, filterData) {
|
setLibraryFilterData(state, filterData) {
|
||||||
state.filterData = filterData
|
state.filterData = filterData
|
||||||
},
|
},
|
||||||
updateFilterDataWithAudiobook(state, audiobook) {
|
updateFilterDataWithItem(state, libraryItem) {
|
||||||
if (!audiobook || !audiobook.book || !state.filterData) return
|
if (!libraryItem || !state.filterData) return
|
||||||
if (state.currentLibraryId !== audiobook.libraryId) return
|
if (state.currentLibraryId !== libraryItem.libraryId) return
|
||||||
/*
|
/*
|
||||||
var filterdata = {
|
var data = {
|
||||||
authors: [],
|
authors: [],
|
||||||
genres: [],
|
genres: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
@@ -219,36 +219,70 @@ export const mutations = {
|
|||||||
languages: []
|
languages: []
|
||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
|
var mediaMetadata = libraryItem.media.metadata
|
||||||
|
|
||||||
if (audiobook.book.authorFL) {
|
// Add/update book authors
|
||||||
audiobook.book.authorFL.split(', ').forEach((author) => {
|
if (mediaMetadata.authors && mediaMetadata.authors.length) {
|
||||||
if (author && !state.filterData.authors.includes(author)) {
|
mediaMetadata.authors.forEach((author) => {
|
||||||
|
var indexOf = state.filterData.authors.findIndex(au => au.id === author.id)
|
||||||
|
if (indexOf >= 0) {
|
||||||
|
state.filterData.authors.splice(indexOf, 1, author)
|
||||||
|
} else {
|
||||||
state.filterData.authors.push(author)
|
state.filterData.authors.push(author)
|
||||||
|
state.filterData.authors.sort((a, b) => (a.name || '').localeCompare((b.name || '')))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (audiobook.book.narratorFL) {
|
|
||||||
audiobook.book.narratorFL.split(', ').forEach((narrator) => {
|
// Add/update series
|
||||||
if (narrator && !state.filterData.narrators.includes(narrator)) {
|
if (mediaMetadata.series && mediaMetadata.series.length) {
|
||||||
|
mediaMetadata.series.forEach((series) => {
|
||||||
|
var indexOf = state.filterData.series.findIndex(se => se.id === series.id)
|
||||||
|
if (indexOf >= 0) {
|
||||||
|
state.filterData.series.splice(indexOf, 1, { id: series.id, name: series.name })
|
||||||
|
} else {
|
||||||
|
state.filterData.series.push({ id: series.id, name: series.name })
|
||||||
|
state.filterData.series.sort((a, b) => (a.name || '').localeCompare((b.name || '')))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add genres
|
||||||
|
if (mediaMetadata.genres && mediaMetadata.genres.length) {
|
||||||
|
mediaMetadata.genres.forEach((genre) => {
|
||||||
|
if (!state.filterData.genres.includes(genre)) {
|
||||||
|
state.filterData.genres.push(genre)
|
||||||
|
state.filterData.genres.sort((a, b) => a.localeCompare(b))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tags
|
||||||
|
if (libraryItem.media.tags && libraryItem.media.tags.length) {
|
||||||
|
libraryItem.media.tags.forEach((tag) => {
|
||||||
|
if (!state.filterData.tags.includes(tag)) {
|
||||||
|
state.filterData.tags.push(tag)
|
||||||
|
state.filterData.tags.sort((a, b) => a.localeCompare(b))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add narrators
|
||||||
|
if (mediaMetadata.narrators && mediaMetadata.narrators.length) {
|
||||||
|
mediaMetadata.narrators.forEach((narrator) => {
|
||||||
|
if (!state.filterData.narrators.includes(narrator)) {
|
||||||
state.filterData.narrators.push(narrator)
|
state.filterData.narrators.push(narrator)
|
||||||
|
state.filterData.narrators.sort((a, b) => a.localeCompare(b))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (audiobook.book.series && !state.filterData.series.includes(audiobook.book.series)) {
|
|
||||||
state.filterData.series.push(audiobook.book.series)
|
// Add language
|
||||||
}
|
if (mediaMetadata.language) {
|
||||||
if (audiobook.tags && audiobook.tags.length) {
|
if (!state.filterData.languages.includes(mediaMetadata.language)) {
|
||||||
audiobook.tags.forEach((tag) => {
|
state.filterData.languages.push(mediaMetadata.language)
|
||||||
if (tag && !state.filterData.tags.includes(tag)) state.filterData.tags.push(tag)
|
state.filterData.languages.sort((a, b) => a.localeCompare(b))
|
||||||
})
|
}
|
||||||
}
|
|
||||||
if (audiobook.book.genres && audiobook.book.genres.length) {
|
|
||||||
audiobook.book.genres.forEach((genre) => {
|
|
||||||
if (genre && !state.filterData.genres.includes(genre)) state.filterData.genres.push(genre)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (audiobook.book.language && !state.filterData.languages.includes(audiobook.book.language)) {
|
|
||||||
state.filterData.languages.push(audiobook.book.language)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
if(process.env.TOKEN_SECRET == null) process.env.TOKEN_SECRET = '09f26e402586e2faa8da4c98a35f1b20d6b033c6097befa8be3486a829587fe2f90a832bd3ff9d42710a4da095a2ce285b009f0c3730cd9b8e1af3eb84df6611'
|
if (process.env.TOKEN_SECRET == null) process.env.TOKEN_SECRET = '09f26e402586e2faa8da4c98a35f1b20d6b033c6097befa8be3486a829587fe2f90a832bd3ff9d42710a4da095a2ce285b009f0c3730cd9b8e1af3eb84df6611'
|
||||||
const server = require('./server/Server')
|
const server = require('./server/Server')
|
||||||
global.appRoot = __dirname
|
global.appRoot = __dirname
|
||||||
|
|
||||||
@@ -9,20 +9,20 @@ if (isDev) {
|
|||||||
process.env.PORT = devEnv.Port
|
process.env.PORT = devEnv.Port
|
||||||
process.env.CONFIG_PATH = devEnv.ConfigPath
|
process.env.CONFIG_PATH = devEnv.ConfigPath
|
||||||
process.env.METADATA_PATH = devEnv.MetadataPath
|
process.env.METADATA_PATH = devEnv.MetadataPath
|
||||||
process.env.AUDIOBOOK_PATH = devEnv.AudiobookPath
|
|
||||||
process.env.FFMPEG_PATH = devEnv.FFmpegPath
|
process.env.FFMPEG_PATH = devEnv.FFmpegPath
|
||||||
process.env.FFPROBE_PATH = devEnv.FFProbePath
|
process.env.FFPROBE_PATH = devEnv.FFProbePath
|
||||||
|
process.env.SOURCE = 'local'
|
||||||
}
|
}
|
||||||
|
|
||||||
const PORT = process.env.PORT || 80
|
const PORT = process.env.PORT || 80
|
||||||
const HOST = process.env.HOST || '0.0.0.0'
|
const HOST = process.env.HOST || '0.0.0.0'
|
||||||
const CONFIG_PATH = process.env.CONFIG_PATH || '/config'
|
const CONFIG_PATH = process.env.CONFIG_PATH || '/config'
|
||||||
const AUDIOBOOK_PATH = process.env.AUDIOBOOK_PATH || '/audiobooks'
|
|
||||||
const METADATA_PATH = process.env.METADATA_PATH || '/metadata'
|
const METADATA_PATH = process.env.METADATA_PATH || '/metadata'
|
||||||
const UID = process.env.AUDIOBOOKSHELF_UID || 99
|
const UID = process.env.AUDIOBOOKSHELF_UID || 99
|
||||||
const GID = process.env.AUDIOBOOKSHELF_GID || 100
|
const GID = process.env.AUDIOBOOKSHELF_GID || 100
|
||||||
|
const SOURCE = process.env.SOURCE || 'docker'
|
||||||
|
|
||||||
console.log('Config', CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
|
console.log('Config', CONFIG_PATH, METADATA_PATH)
|
||||||
|
|
||||||
const Server = new server(PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
|
const Server = new server(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH)
|
||||||
Server.start()
|
Server.start()
|
||||||
|
|||||||
Generated
+366
-229
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.0.12",
|
"version": "2.0.14",
|
||||||
"description": "Self-hosted audiobook server for managing and playing audiobooks",
|
"description": "Self-hosted audiobook and podcast server",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node index.js",
|
"dev": "node index.js",
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
const optionDefinitions = [
|
const optionDefinitions = [
|
||||||
{ name: 'config', alias: 'c', type: String },
|
{ name: 'config', alias: 'c', type: String },
|
||||||
{ name: 'audiobooks', alias: 'a', type: String },
|
|
||||||
{ name: 'metadata', alias: 'm', type: String },
|
{ name: 'metadata', alias: 'm', type: String },
|
||||||
{ name: 'port', alias: 'p', type: String },
|
{ name: 'port', alias: 'p', type: String },
|
||||||
{ name: 'host', alias: 'h', type: String }
|
{ name: 'host', alias: 'h', type: String },
|
||||||
|
{ name: 'source', alias: 's', type: String }
|
||||||
]
|
]
|
||||||
|
|
||||||
const commandLineArgs = require('command-line-args')
|
const commandLineArgs = require('command-line-args')
|
||||||
const options = commandLineArgs(optionDefinitions)
|
const options = commandLineArgs(optionDefinitions)
|
||||||
|
|
||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
if(process.env.TOKEN_SECRET == null) process.env.TOKEN_SECRET = '09f26e402586e2faa8da4c98a35f1b20d6b033c6097befa8be3486a829587fe2f90a832bd3ff9d42710a4da095a2ce285b009f0c3730cd9b8e1af3eb84df6611'
|
if (process.env.TOKEN_SECRET == null) process.env.TOKEN_SECRET = '09f26e402586e2faa8da4c98a35f1b20d6b033c6097befa8be3486a829587fe2f90a832bd3ff9d42710a4da095a2ce285b009f0c3730cd9b8e1af3eb84df6611'
|
||||||
process.env.NODE_ENV = 'production'
|
process.env.NODE_ENV = 'production'
|
||||||
|
|
||||||
const server = require('./server/Server')
|
const server = require('./server/Server')
|
||||||
@@ -18,18 +18,17 @@ const server = require('./server/Server')
|
|||||||
global.appRoot = __dirname
|
global.appRoot = __dirname
|
||||||
|
|
||||||
var inputConfig = options.config ? Path.resolve(options.config) : null
|
var inputConfig = options.config ? Path.resolve(options.config) : null
|
||||||
var inputAudiobook = options.audiobooks ? Path.resolve(options.audiobooks) : null
|
|
||||||
var inputMetadata = options.metadata ? Path.resolve(options.metadata) : null
|
var inputMetadata = options.metadata ? Path.resolve(options.metadata) : null
|
||||||
|
|
||||||
const PORT = options.port || process.env.PORT || 3333
|
const PORT = options.port || process.env.PORT || 3333
|
||||||
const HOST = options.host || process.env.HOST || "0.0.0.0"
|
const HOST = options.host || process.env.HOST || "0.0.0.0"
|
||||||
const CONFIG_PATH = inputConfig || process.env.CONFIG_PATH || Path.resolve('config')
|
const CONFIG_PATH = inputConfig || process.env.CONFIG_PATH || Path.resolve('config')
|
||||||
const AUDIOBOOK_PATH = inputAudiobook || process.env.AUDIOBOOK_PATH || Path.resolve('audiobooks')
|
|
||||||
const METADATA_PATH = inputMetadata || process.env.METADATA_PATH || Path.resolve('metadata')
|
const METADATA_PATH = inputMetadata || process.env.METADATA_PATH || Path.resolve('metadata')
|
||||||
const UID = 99
|
const UID = 99
|
||||||
const GID = 100
|
const GID = 100
|
||||||
|
const SOURCE = options.source || 'debian'
|
||||||
|
|
||||||
console.log(process.env.NODE_ENV, 'Config', CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
|
console.log(process.env.NODE_ENV, 'Config', CONFIG_PATH, METADATA_PATH)
|
||||||
|
|
||||||
const Server = new server(PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
|
const Server = new server(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH)
|
||||||
Server.start()
|
Server.start()
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ Available using Test Flight: https://testflight.apple.com/join/wiic7QIW - [Join
|
|||||||
Available in Unraid Community Apps
|
Available in Unraid Community Apps
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull ghcr.io/advplyr/audiobookshelf
|
docker pull ghcr.io/advplyr/audiobookshelf:latest
|
||||||
|
|
||||||
docker run -d \
|
docker run -d \
|
||||||
-e AUDIOBOOKSHELF_UID=99 \
|
-e AUDIOBOOKSHELF_UID=99 \
|
||||||
@@ -75,14 +75,14 @@ docker run -d \
|
|||||||
-v </path/to/config>:/config \
|
-v </path/to/config>:/config \
|
||||||
-v </path/to/metadata>:/metadata \
|
-v </path/to/metadata>:/metadata \
|
||||||
--name audiobookshelf \
|
--name audiobookshelf \
|
||||||
ghcr.io/advplyr/audiobookshelf
|
ghcr.io/advplyr/audiobookshelf:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker Update
|
### Docker Update
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker stop audiobookshelf
|
docker stop audiobookshelf
|
||||||
docker pull ghcr.io/advplyr/audiobookshelf
|
docker pull ghcr.io/advplyr/audiobookshelf:latest
|
||||||
docker start audiobookshelf
|
docker start audiobookshelf
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ docker start audiobookshelf
|
|||||||
### docker-compose.yml ###
|
### docker-compose.yml ###
|
||||||
services:
|
services:
|
||||||
audiobookshelf:
|
audiobookshelf:
|
||||||
image: ghcr.io/advplyr/audiobookshelf
|
image: ghcr.io/advplyr/audiobookshelf:latest
|
||||||
environment:
|
environment:
|
||||||
- AUDIOBOOKSHELF_UID=99
|
- AUDIOBOOKSHELF_UID=99
|
||||||
- AUDIOBOOKSHELF_GID=100
|
- AUDIOBOOKSHELF_GID=100
|
||||||
|
|||||||
@@ -17,14 +17,6 @@ class Auth {
|
|||||||
return this.db.users
|
return this.db.users
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
|
||||||
var root = this.users.find(u => u.type === 'root')
|
|
||||||
if (!root) {
|
|
||||||
Logger.fatal('No Root User', this.users)
|
|
||||||
throw new Error('No Root User')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cors(req, res, next) {
|
cors(req, res, next) {
|
||||||
res.header('Access-Control-Allow-Origin', '*')
|
res.header('Access-Control-Allow-Origin', '*')
|
||||||
res.header("Access-Control-Allow-Methods", 'GET, POST, PATCH, PUT, DELETE, OPTIONS')
|
res.header("Access-Control-Allow-Methods", 'GET, POST, PATCH, PUT, DELETE, OPTIONS')
|
||||||
|
|||||||
+29
-39
@@ -46,6 +46,10 @@ class Db {
|
|||||||
this.previousVersion = null
|
this.previousVersion = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get hasRootUser() {
|
||||||
|
return this.users.some(u => u.id === 'root')
|
||||||
|
}
|
||||||
|
|
||||||
getEntityDb(entityName) {
|
getEntityDb(entityName) {
|
||||||
if (entityName === 'user') return this.usersDb
|
if (entityName === 'user') return this.usersDb
|
||||||
else if (entityName === 'session') return this.sessionsDb
|
else if (entityName === 'session') return this.sessionsDb
|
||||||
@@ -70,33 +74,6 @@ class Db {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
getDefaultUser(token) {
|
|
||||||
return new User({
|
|
||||||
id: 'root',
|
|
||||||
type: 'root',
|
|
||||||
username: 'root',
|
|
||||||
pash: '',
|
|
||||||
stream: null,
|
|
||||||
token,
|
|
||||||
isActive: true,
|
|
||||||
createdAt: Date.now()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
getDefaultLibrary() {
|
|
||||||
var defaultLibrary = new Library()
|
|
||||||
defaultLibrary.setData({
|
|
||||||
id: 'main',
|
|
||||||
name: 'Main',
|
|
||||||
folder: { // Generates default folder
|
|
||||||
id: 'audiobooks',
|
|
||||||
fullPath: global.AudiobookPath,
|
|
||||||
libraryId: 'main'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return defaultLibrary
|
|
||||||
}
|
|
||||||
|
|
||||||
reinit() {
|
reinit() {
|
||||||
this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath)
|
this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath)
|
||||||
this.usersDb = new njodb.Database(this.UsersPath)
|
this.usersDb = new njodb.Database(this.UsersPath)
|
||||||
@@ -123,23 +100,36 @@ class Db {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createRootUser(username, pash, token) {
|
||||||
|
const newRoot = new User({
|
||||||
|
id: 'root',
|
||||||
|
type: 'root',
|
||||||
|
username,
|
||||||
|
pash,
|
||||||
|
token,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: Date.now()
|
||||||
|
})
|
||||||
|
return this.insertEntity('user', newRoot)
|
||||||
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
await this.load()
|
await this.load()
|
||||||
|
|
||||||
// Insert Defaults
|
// Insert Defaults
|
||||||
var rootUser = this.users.find(u => u.type === 'root')
|
// var rootUser = this.users.find(u => u.type === 'root')
|
||||||
if (!rootUser) {
|
// if (!rootUser) {
|
||||||
var token = await jwt.sign({ userId: 'root' }, process.env.TOKEN_SECRET)
|
// var token = await jwt.sign({ userId: 'root' }, process.env.TOKEN_SECRET)
|
||||||
Logger.debug('Generated default token', token)
|
// Logger.debug('Generated default token', token)
|
||||||
Logger.info('[Db] Root user created')
|
// Logger.info('[Db] Root user created')
|
||||||
await this.insertEntity('user', this.getDefaultUser(token))
|
// await this.insertEntity('user', this.getDefaultUser(token))
|
||||||
} else {
|
// } else {
|
||||||
Logger.info(`[Db] Root user exists, pw: ${rootUser.hasPw}`)
|
// Logger.info(`[Db] Root user exists, pw: ${rootUser.hasPw}`)
|
||||||
}
|
// }
|
||||||
|
|
||||||
if (!this.libraries.length) {
|
// if (!this.libraries.length) {
|
||||||
await this.insertEntity('library', this.getDefaultLibrary())
|
// await this.insertEntity('library', this.getDefaultLibrary())
|
||||||
}
|
// }
|
||||||
|
|
||||||
if (!this.serverSettings) {
|
if (!this.serverSettings) {
|
||||||
this.serverSettings = new ServerSettings()
|
this.serverSettings = new ServerSettings()
|
||||||
|
|||||||
+48
-19
@@ -34,18 +34,18 @@ const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
|
|||||||
const RssFeedManager = require('./managers/RssFeedManager')
|
const RssFeedManager = require('./managers/RssFeedManager')
|
||||||
|
|
||||||
class Server {
|
class Server {
|
||||||
constructor(PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) {
|
constructor(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH) {
|
||||||
|
this.Source = SOURCE
|
||||||
this.Port = PORT
|
this.Port = PORT
|
||||||
this.Host = HOST
|
this.Host = HOST
|
||||||
global.Uid = isNaN(UID) ? 0 : Number(UID)
|
global.Uid = isNaN(UID) ? 0 : Number(UID)
|
||||||
global.Gid = isNaN(GID) ? 0 : Number(GID)
|
global.Gid = isNaN(GID) ? 0 : Number(GID)
|
||||||
global.ConfigPath = Path.normalize(CONFIG_PATH)
|
global.ConfigPath = Path.normalize(CONFIG_PATH)
|
||||||
global.AudiobookPath = Path.normalize(AUDIOBOOK_PATH)
|
|
||||||
global.MetadataPath = Path.normalize(METADATA_PATH)
|
global.MetadataPath = Path.normalize(METADATA_PATH)
|
||||||
|
|
||||||
// Fix backslash if not on Windows
|
// Fix backslash if not on Windows
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== 'win32') {
|
||||||
global.ConfigPath = global.ConfigPath.replace(/\\/g, '/')
|
global.ConfigPath = global.ConfigPath.replace(/\\/g, '/')
|
||||||
global.AudiobookPath = global.AudiobookPath.replace(/\\/g, '/')
|
|
||||||
global.MetadataPath = global.MetadataPath.replace(/\\/g, '/')
|
global.MetadataPath = global.MetadataPath.replace(/\\/g, '/')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,10 +57,6 @@ class Server {
|
|||||||
fs.mkdirSync(global.MetadataPath)
|
fs.mkdirSync(global.MetadataPath)
|
||||||
filePerms.setDefaultDirSync(global.MetadataPath, false)
|
filePerms.setDefaultDirSync(global.MetadataPath, false)
|
||||||
}
|
}
|
||||||
if (!fs.pathExistsSync(global.AudiobookPath)) {
|
|
||||||
fs.mkdirSync(global.AudiobookPath)
|
|
||||||
filePerms.setDefaultDirSync(global.AudiobookPath, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.db = new Db()
|
this.db = new Db()
|
||||||
this.watcher = new Watcher()
|
this.watcher = new Watcher()
|
||||||
@@ -140,10 +136,9 @@ class Server {
|
|||||||
await this.db.init()
|
await this.db.init()
|
||||||
}
|
}
|
||||||
|
|
||||||
this.auth.init()
|
|
||||||
|
|
||||||
await this.checkUserMediaProgress() // Remove invalid user item progress
|
await this.checkUserMediaProgress() // Remove invalid user item progress
|
||||||
await this.purgeMetadata() // Remove metadata folders without library item
|
await this.purgeMetadata() // Remove metadata folders without library item
|
||||||
|
await this.cacheManager.ensureCachePaths()
|
||||||
|
|
||||||
await this.backupManager.init()
|
await this.backupManager.init()
|
||||||
await this.logManager.init()
|
await this.logManager.init()
|
||||||
@@ -214,18 +209,42 @@ class Server {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Client dynamic routes
|
// Client dynamic routes
|
||||||
app.get('/item/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
const dyanimicRoutes = [
|
||||||
app.get('/audiobook/:id/edit', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
'/item/:id',
|
||||||
app.get('/library/:library', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
'/item/:id/manage',
|
||||||
app.get('/library/:library/search', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
'/item/:id/chapters',
|
||||||
app.get('/library/:library/bookshelf/:id?', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
'/audiobook/:id/edit',
|
||||||
app.get('/library/:library/authors', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
'/library/:library',
|
||||||
app.get('/library/:library/series/:id?', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
'/library/:library/search',
|
||||||
app.get('/config/users/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
'/library/:library/bookshelf/:id?',
|
||||||
app.get('/collection/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
'/library/:library/authors',
|
||||||
|
'/library/:library/series/:id?',
|
||||||
|
'/config/users/:id',
|
||||||
|
'/collection/:id'
|
||||||
|
]
|
||||||
|
dyanimicRoutes.forEach((route) => app.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))
|
||||||
|
|
||||||
app.post('/login', this.getLoginRateLimiter(), (req, res) => this.auth.login(req, res))
|
app.post('/login', this.getLoginRateLimiter(), (req, res) => this.auth.login(req, res))
|
||||||
app.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this))
|
app.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this))
|
||||||
|
app.post('/init', (req, res) => {
|
||||||
|
if (this.db.hasRootUser) {
|
||||||
|
Logger.error(`[Server] attempt to init server when server already has a root user`)
|
||||||
|
return res.sendStatus(500)
|
||||||
|
}
|
||||||
|
this.initializeServer(req, res)
|
||||||
|
})
|
||||||
|
app.get('/status', (req, res) => {
|
||||||
|
// status check for client to see if server has been initialized
|
||||||
|
// server has been initialized if a root user exists
|
||||||
|
const payload = {
|
||||||
|
isInit: this.db.hasRootUser
|
||||||
|
}
|
||||||
|
if (!payload.isInit) {
|
||||||
|
payload.ConfigPath = global.ConfigPath
|
||||||
|
payload.MetadataPath = global.MetadataPath
|
||||||
|
}
|
||||||
|
res.json(payload)
|
||||||
|
})
|
||||||
app.get('/ping', (req, res) => {
|
app.get('/ping', (req, res) => {
|
||||||
Logger.info('Recieved ping')
|
Logger.info('Recieved ping')
|
||||||
res.json({ success: true })
|
res.json({ success: true })
|
||||||
@@ -288,6 +307,17 @@ class Server {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async initializeServer(req, res) {
|
||||||
|
Logger.info(`[Server] Initializing new server`)
|
||||||
|
const newRoot = req.body.newRoot
|
||||||
|
let rootPash = newRoot.password ? await this.auth.hashPass(newRoot.password) : ''
|
||||||
|
if (!rootPash) Logger.warn(`[Server] Creating root user with no password`)
|
||||||
|
let rootToken = await this.auth.generateAccessToken({ userId: 'root' })
|
||||||
|
await this.db.createRootUser(newRoot.username, rootPash, rootToken)
|
||||||
|
|
||||||
|
res.sendStatus(200)
|
||||||
|
}
|
||||||
|
|
||||||
async filesChanged(fileUpdates) {
|
async filesChanged(fileUpdates) {
|
||||||
Logger.info('[Server]', fileUpdates.length, 'Files Changed')
|
Logger.info('[Server]', fileUpdates.length, 'Files Changed')
|
||||||
await this.scanner.scanFilesChanged(fileUpdates)
|
await this.scanner.scanFilesChanged(fileUpdates)
|
||||||
@@ -428,7 +458,6 @@ class Server {
|
|||||||
const initialPayload = {
|
const initialPayload = {
|
||||||
// TODO: this is sent with user auth now, update mobile app to use that then remove this
|
// TODO: this is sent with user auth now, update mobile app to use that then remove this
|
||||||
serverSettings: this.db.serverSettings.toJSON(),
|
serverSettings: this.db.serverSettings.toJSON(),
|
||||||
audiobookPath: global.AudiobookPath,
|
|
||||||
metadataPath: global.MetadataPath,
|
metadataPath: global.MetadataPath,
|
||||||
configPath: global.ConfigPath,
|
configPath: global.ConfigPath,
|
||||||
user: client.user.toJSONForBrowser(),
|
user: client.user.toJSONForBrowser(),
|
||||||
|
|||||||
@@ -140,7 +140,6 @@ class FolderWatcher extends EventEmitter {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
Logger.debug(`[Watcher] Rename ${pathFrom} => ${pathTo}`)
|
Logger.debug(`[Watcher] Rename ${pathFrom} => ${pathTo}`)
|
||||||
this.addFileUpdate(libraryId, pathFrom, 'renamed')
|
|
||||||
this.addFileUpdate(libraryId, pathTo, 'renamed')
|
this.addFileUpdate(libraryId, pathTo, 'renamed')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -107,11 +107,16 @@ class AuthorController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async match(req, res) {
|
async match(req, res) {
|
||||||
var authorData = await this.authorFinder.findAuthorByName(req.body.q)
|
var authorData = null
|
||||||
|
if (req.body.asin) {
|
||||||
|
authorData = await this.authorFinder.findAuthorByASIN(req.body.asin)
|
||||||
|
} else {
|
||||||
|
authorData = await this.authorFinder.findAuthorByName(req.body.q)
|
||||||
|
}
|
||||||
if (!authorData) {
|
if (!authorData) {
|
||||||
return res.status(404).send('Author not found')
|
return res.status(404).send('Author not found')
|
||||||
}
|
}
|
||||||
Logger.debug(`[AuthorController] match author with "${req.body.q}"`, authorData)
|
Logger.debug(`[AuthorController] match author with "${req.body.q || req.body.asin}"`, authorData)
|
||||||
|
|
||||||
var hasUpdates = false
|
var hasUpdates = false
|
||||||
if (authorData.asin && req.author.asin !== authorData.asin) {
|
if (authorData.asin && req.author.asin !== authorData.asin) {
|
||||||
@@ -121,6 +126,8 @@ class AuthorController {
|
|||||||
|
|
||||||
// Only updates image if there was no image before or the author ASIN was updated
|
// Only updates image if there was no image before or the author ASIN was updated
|
||||||
if (authorData.image && (!req.author.imagePath || hasUpdates)) {
|
if (authorData.image && (!req.author.imagePath || hasUpdates)) {
|
||||||
|
this.cacheManager.purgeImageCache(req.author.id)
|
||||||
|
|
||||||
var imageData = await this.authorFinder.saveAuthorImage(req.author.id, authorData.image)
|
var imageData = await this.authorFinder.saveAuthorImage(req.author.id, authorData.image)
|
||||||
if (imageData) {
|
if (imageData) {
|
||||||
req.author.imagePath = imageData.path
|
req.author.imagePath = imageData.path
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class LibraryController {
|
|||||||
newLibraryPayload.displayOrder = this.db.libraries.length + 1
|
newLibraryPayload.displayOrder = this.db.libraries.length + 1
|
||||||
library.setData(newLibraryPayload)
|
library.setData(newLibraryPayload)
|
||||||
await this.db.insertEntity('library', library)
|
await this.db.insertEntity('library', library)
|
||||||
|
// TODO: Only emit to users that have access
|
||||||
this.emitter('library_added', library.toJSON())
|
this.emitter('library_added', library.toJSON())
|
||||||
|
|
||||||
// Add library watcher
|
// Add library watcher
|
||||||
|
|||||||
@@ -359,7 +359,7 @@ class LibraryItemController {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST: api/items/:id/audio-metadata
|
// GET: api/items/:id/audio-metadata
|
||||||
async updateAudioFileMetadata(req, res) {
|
async updateAudioFileMetadata(req, res) {
|
||||||
if (!req.user.isAdminOrUp) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error(`[LibraryItemController] Non-root user attempted to update audio metadata`, req.user)
|
Logger.error(`[LibraryItemController] Non-root user attempted to update audio metadata`, req.user)
|
||||||
@@ -375,6 +375,36 @@ class LibraryItemController {
|
|||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST: api/items/:id/chapters
|
||||||
|
async updateMediaChapters(req, res) {
|
||||||
|
if (!req.user.canUpdate) {
|
||||||
|
Logger.error(`[LibraryItemController] User attempted to update chapters with invalid permissions`, req.user.username)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
|
||||||
|
Logger.error(`[LibraryItemController] Invalid library item`)
|
||||||
|
return res.sendStatus(500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const chapters = req.body.chapters || []
|
||||||
|
if (!chapters.length) {
|
||||||
|
Logger.error(`[LibraryItemController] Invalid payload`)
|
||||||
|
return res.sendStatus(400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasUpdated = req.libraryItem.media.updateChapters(chapters)
|
||||||
|
if (wasUpdated) {
|
||||||
|
await this.db.updateLibraryItem(req.libraryItem)
|
||||||
|
this.emitter('item_updated', req.libraryItem.toJSONExpanded())
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
updated: wasUpdated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
middleware(req, res, next) {
|
middleware(req, res, next) {
|
||||||
var item = this.db.libraryItems.find(li => li.id === req.params.id)
|
var item = this.db.libraryItems.find(li => li.id === req.params.id)
|
||||||
if (!item || !item.media) return res.sendStatus(404)
|
if (!item || !item.media) return res.sendStatus(404)
|
||||||
|
|||||||
@@ -225,6 +225,15 @@ class MiscController {
|
|||||||
res.json(author)
|
res.json(author)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findChapters(req, res) {
|
||||||
|
var asin = req.query.asin
|
||||||
|
var chapterData = await this.bookFinder.findChapters(asin)
|
||||||
|
if (!chapterData) {
|
||||||
|
return res.json({ error: 'Chapters not found' })
|
||||||
|
}
|
||||||
|
res.json(chapterData)
|
||||||
|
}
|
||||||
|
|
||||||
authorize(req, res) {
|
authorize(req, res) {
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
Logger.error('Invalid user in authorize')
|
Logger.error('Invalid user in authorize')
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
const axios = require('axios')
|
const axios = require('axios')
|
||||||
const fs = require('fs-extra')
|
const fs = require('fs-extra')
|
||||||
|
const Path = require('path')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const { parsePodcastRssFeedXml } = require('../utils/podcastUtils')
|
const { parsePodcastRssFeedXml } = require('../utils/podcastUtils')
|
||||||
const LibraryItem = require('../objects/LibraryItem')
|
const LibraryItem = require('../objects/LibraryItem')
|
||||||
const { getFileTimestampsWithIno } = require('../utils/fileUtils')
|
const { getFileTimestampsWithIno, sanitizeFilename } = require('../utils/fileUtils')
|
||||||
const filePerms = require('../utils/filePerms')
|
const filePerms = require('../utils/filePerms')
|
||||||
|
|
||||||
class PodcastController {
|
class PodcastController {
|
||||||
@@ -107,6 +108,12 @@ class PodcastController {
|
|||||||
if (!payload) {
|
if (!payload) {
|
||||||
return res.status(500).send('Invalid podcast RSS feed')
|
return res.status(500).send('Invalid podcast RSS feed')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!payload.podcast.metadata.feedUrl) {
|
||||||
|
// Not every RSS feed will put the feed url in their metadata
|
||||||
|
payload.podcast.metadata.feedUrl = url
|
||||||
|
}
|
||||||
|
|
||||||
res.json(payload)
|
res.json(payload)
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ class AuthorFinder {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
findAuthorByASIN(asin) {
|
||||||
|
if (!asin) return null
|
||||||
|
return this.audnexus.findAuthorByASIN(asin)
|
||||||
|
}
|
||||||
|
|
||||||
async findAuthorByName(name, options = {}) {
|
async findAuthorByName(name, options = {}) {
|
||||||
if (!name) return null
|
if (!name) return null
|
||||||
const maxLevenshtein = !isNaN(options.maxLevenshtein) ? Number(options.maxLevenshtein) : 3
|
const maxLevenshtein = !isNaN(options.maxLevenshtein) ? Number(options.maxLevenshtein) : 3
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const LibGen = require('../providers/LibGen')
|
|||||||
const GoogleBooks = require('../providers/GoogleBooks')
|
const GoogleBooks = require('../providers/GoogleBooks')
|
||||||
const Audible = require('../providers/Audible')
|
const Audible = require('../providers/Audible')
|
||||||
const iTunes = require('../providers/iTunes')
|
const iTunes = require('../providers/iTunes')
|
||||||
|
const Audnexus = require('../providers/Audnexus')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const { levenshteinDistance } = require('../utils/index')
|
const { levenshteinDistance } = require('../utils/index')
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ class BookFinder {
|
|||||||
this.googleBooks = new GoogleBooks()
|
this.googleBooks = new GoogleBooks()
|
||||||
this.audible = new Audible()
|
this.audible = new Audible()
|
||||||
this.iTunesApi = new iTunes()
|
this.iTunesApi = new iTunes()
|
||||||
|
this.audnexus = new Audnexus()
|
||||||
|
|
||||||
this.verbose = false
|
this.verbose = false
|
||||||
}
|
}
|
||||||
@@ -226,5 +228,9 @@ class BookFinder {
|
|||||||
})
|
})
|
||||||
return covers
|
return covers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
findChapters(asin) {
|
||||||
|
return this.audnexus.getChaptersByASIN(asin)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
module.exports = BookFinder
|
module.exports = BookFinder
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
const fs = require('fs-extra')
|
const fs = require('fs-extra')
|
||||||
const stream = require('stream')
|
const stream = require('stream')
|
||||||
|
const filePerms = require('../utils/filePerms')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const { resizeImage } = require('../utils/ffmpegHelpers')
|
const { resizeImage } = require('../utils/ffmpegHelpers')
|
||||||
|
|
||||||
@@ -9,6 +10,34 @@ class CacheManager {
|
|||||||
this.CachePath = Path.join(global.MetadataPath, 'cache')
|
this.CachePath = Path.join(global.MetadataPath, 'cache')
|
||||||
this.CoverCachePath = Path.join(this.CachePath, 'covers')
|
this.CoverCachePath = Path.join(this.CachePath, 'covers')
|
||||||
this.ImageCachePath = Path.join(this.CachePath, 'images')
|
this.ImageCachePath = Path.join(this.CachePath, 'images')
|
||||||
|
|
||||||
|
this.cachePathsExist = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureCachePaths() { // Creates cache paths if necessary and sets owner and permissions
|
||||||
|
if (this.cachePathsExist) return
|
||||||
|
|
||||||
|
var pathsCreated = false
|
||||||
|
if (!(await fs.pathExists(this.CachePath))) {
|
||||||
|
await fs.mkdir(this.CachePath)
|
||||||
|
pathsCreated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await fs.pathExists(this.CoverCachePath))) {
|
||||||
|
await fs.mkdir(this.CoverCachePath)
|
||||||
|
pathsCreated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await fs.pathExists(this.ImageCachePath))) {
|
||||||
|
await fs.mkdir(this.ImageCachePath)
|
||||||
|
pathsCreated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathsCreated) {
|
||||||
|
await filePerms.setDefault(this.CachePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cachePathsExist = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleCoverCache(res, libraryItem, options = {}) {
|
async handleCoverCache(res, libraryItem, options = {}) {
|
||||||
@@ -33,9 +62,6 @@ class CacheManager {
|
|||||||
return ps.pipe(res)
|
return ps.pipe(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write cache
|
|
||||||
await fs.ensureDir(this.CoverCachePath)
|
|
||||||
|
|
||||||
if (!libraryItem.media.coverPath || !await fs.pathExists(libraryItem.media.coverPath)) {
|
if (!libraryItem.media.coverPath || !await fs.pathExists(libraryItem.media.coverPath)) {
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
@@ -43,6 +69,9 @@ class CacheManager {
|
|||||||
let writtenFile = await resizeImage(libraryItem.media.coverPath, path, width, height)
|
let writtenFile = await resizeImage(libraryItem.media.coverPath, path, width, height)
|
||||||
if (!writtenFile) return res.sendStatus(400)
|
if (!writtenFile) return res.sendStatus(400)
|
||||||
|
|
||||||
|
// Set owner and permissions of cache image
|
||||||
|
await filePerms.setDefault(path)
|
||||||
|
|
||||||
var readStream = fs.createReadStream(writtenFile)
|
var readStream = fs.createReadStream(writtenFile)
|
||||||
readStream.pipe(res)
|
readStream.pipe(res)
|
||||||
}
|
}
|
||||||
@@ -56,8 +85,6 @@ class CacheManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async purgeEntityCache(entityId, cachePath) {
|
async purgeEntityCache(entityId, cachePath) {
|
||||||
// If purgeAll has been called... The cover cache directory no longer exists
|
|
||||||
await fs.ensureDir(cachePath)
|
|
||||||
return Promise.all((await fs.readdir(cachePath)).reduce((promises, file) => {
|
return Promise.all((await fs.readdir(cachePath)).reduce((promises, file) => {
|
||||||
if (file.startsWith(entityId)) {
|
if (file.startsWith(entityId)) {
|
||||||
Logger.debug(`[CacheManager] Going to purge ${file}`);
|
Logger.debug(`[CacheManager] Going to purge ${file}`);
|
||||||
@@ -84,6 +111,7 @@ class CacheManager {
|
|||||||
Logger.error(`[CacheManager] Failed to remove cache dir "${this.CachePath}"`, error)
|
Logger.error(`[CacheManager] Failed to remove cache dir "${this.CachePath}"`, error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
await this.ensureCachePaths()
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleAuthorCache(res, author, options = {}) {
|
async handleAuthorCache(res, author, options = {}) {
|
||||||
@@ -108,12 +136,12 @@ class CacheManager {
|
|||||||
return ps.pipe(res)
|
return ps.pipe(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write cache
|
|
||||||
await fs.ensureDir(this.ImageCachePath)
|
|
||||||
|
|
||||||
let writtenFile = await resizeImage(author.imagePath, path, width, height)
|
let writtenFile = await resizeImage(author.imagePath, path, width, height)
|
||||||
if (!writtenFile) return res.sendStatus(400)
|
if (!writtenFile) return res.sendStatus(400)
|
||||||
|
|
||||||
|
// Set owner and permissions of cache image
|
||||||
|
await filePerms.setDefault(path)
|
||||||
|
|
||||||
var readStream = fs.createReadStream(writtenFile)
|
var readStream = fs.createReadStream(writtenFile)
|
||||||
readStream.pipe(res)
|
readStream.pipe(res)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,13 +242,6 @@ class DownloadManager {
|
|||||||
if (shouldIncludeCover) {
|
if (shouldIncludeCover) {
|
||||||
var _cover = audiobook.book.coverFullPath.replace(/\\/g, '/')
|
var _cover = audiobook.book.coverFullPath.replace(/\\/g, '/')
|
||||||
|
|
||||||
// Supporting old local file prefix
|
|
||||||
var bookCoverPath = audiobook.book.cover ? audiobook.book.cover.replace(/\\/g, '/') : null
|
|
||||||
if (!_cover && bookCoverPath && bookCoverPath.startsWith('/local')) {
|
|
||||||
_cover = Path.posix.join(global.AudiobookPath, _cover.replace('/local', ''))
|
|
||||||
Logger.debug('Local cover url', _cover)
|
|
||||||
}
|
|
||||||
|
|
||||||
ffmpegInputs.push({
|
ffmpegInputs.push({
|
||||||
input: _cover,
|
input: _cover,
|
||||||
options: ['-f image2pipe']
|
options: ['-f image2pipe']
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
|
const date = require('date-and-time')
|
||||||
const { PlayMethod } = require('../utils/constants')
|
const { PlayMethod } = require('../utils/constants')
|
||||||
const PlaybackSession = require('../objects/PlaybackSession')
|
const PlaybackSession = require('../objects/PlaybackSession')
|
||||||
const Stream = require('../objects/Stream')
|
const Stream = require('../objects/Stream')
|
||||||
@@ -40,6 +41,10 @@ class PlaybackSessionManager {
|
|||||||
|
|
||||||
async syncLocalSessionRequest(user, sessionJson, res) {
|
async syncLocalSessionRequest(user, sessionJson, res) {
|
||||||
var libraryItem = this.db.getLibraryItem(sessionJson.libraryItemId)
|
var libraryItem = this.db.getLibraryItem(sessionJson.libraryItemId)
|
||||||
|
if (!libraryItem) {
|
||||||
|
Logger.error(`[PlaybackSessionManager] syncLocalSessionRequest: Library item not found for session "${sessionJson.libraryItemId}"`)
|
||||||
|
return res.sendStatus(200)
|
||||||
|
}
|
||||||
|
|
||||||
var session = await this.db.getPlaybackSession(sessionJson.id)
|
var session = await this.db.getPlaybackSession(sessionJson.id)
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -49,6 +54,8 @@ class PlaybackSessionManager {
|
|||||||
} else {
|
} else {
|
||||||
session.timeListening = sessionJson.timeListening
|
session.timeListening = sessionJson.timeListening
|
||||||
session.updatedAt = sessionJson.updatedAt
|
session.updatedAt = sessionJson.updatedAt
|
||||||
|
session.date = date.format(new Date(), 'YYYY-MM-DD')
|
||||||
|
session.dayOfWeek = date.format(new Date(), 'dddd')
|
||||||
await this.db.updateEntity('session', session)
|
await this.db.updateEntity('session', session)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -183,8 +183,15 @@ class PodcastManager {
|
|||||||
|
|
||||||
for (const libraryItem of podcastsWithAutoDownload) {
|
for (const libraryItem of podcastsWithAutoDownload) {
|
||||||
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0)
|
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0)
|
||||||
Logger.info(`[PodcastManager] checkForNewEpisodes Cron for "${libraryItem.media.metadata.title}" - Last episode check: ${lastEpisodeCheckDate}`)
|
const latestEpisodePublishedAt = libraryItem.media.latestEpisodePublished
|
||||||
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem)
|
Logger.info(`[PodcastManager] checkForNewEpisodes: "${libraryItem.media.metadata.title}" | Last check: ${lastEpisodeCheckDate} | ${latestEpisodePublishedAt ? `Latest episode pubDate: ${new Date(latestEpisodePublishedAt)}` : 'No latest episode'}`)
|
||||||
|
|
||||||
|
// Use latest episode pubDate if exists OR fallback to using lastEpisodeCheckDate
|
||||||
|
// lastEpisodeCheckDate will be the current time when adding a new podcast
|
||||||
|
const dateToCheckForEpisodesAfter = latestEpisodePublishedAt || lastEpisodeCheckDate
|
||||||
|
Logger.debug(`[PodcastManager] checkForNewEpisodes: "${libraryItem.media.metadata.title}" checking for episodes after ${new Date(dateToCheckForEpisodesAfter)}`)
|
||||||
|
|
||||||
|
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, dateToCheckForEpisodesAfter)
|
||||||
Logger.debug(`[PodcastManager] checkForNewEpisodes checked result ${newEpisodes ? newEpisodes.length : 'N/A'}`)
|
Logger.debug(`[PodcastManager] checkForNewEpisodes checked result ${newEpisodes ? newEpisodes.length : 'N/A'}`)
|
||||||
|
|
||||||
if (!newEpisodes) { // Failed
|
if (!newEpisodes) { // Failed
|
||||||
@@ -214,7 +221,7 @@ class PodcastManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkPodcastForNewEpisodes(podcastLibraryItem) {
|
async checkPodcastForNewEpisodes(podcastLibraryItem, dateToCheckForEpisodesAfter) {
|
||||||
if (!podcastLibraryItem.media.metadata.feedUrl) {
|
if (!podcastLibraryItem.media.metadata.feedUrl) {
|
||||||
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`)
|
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`)
|
||||||
return false
|
return false
|
||||||
@@ -225,15 +232,8 @@ class PodcastManager {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Added for testing
|
|
||||||
Logger.debug(`[PodcastManager] checkPodcastForNewEpisodes: ${feed.episodes.length} episodes in feed for "${podcastLibraryItem.media.metadata.title}"`)
|
|
||||||
const latestEpisodes = feed.episodes.slice(0, 3)
|
|
||||||
latestEpisodes.forEach((ep) => {
|
|
||||||
Logger.debug(`[PodcastManager] checkPodcastForNewEpisodes: Recent episode "${ep.title}", pubDate=${ep.pubDate}, publishedAt=${ep.publishedAt}/${new Date(ep.publishedAt)} for "${podcastLibraryItem.media.metadata.title}"`)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Filter new and not already has
|
// Filter new and not already has
|
||||||
var newEpisodes = feed.episodes.filter(ep => ep.publishedAt > podcastLibraryItem.media.lastEpisodeCheck && !podcastLibraryItem.media.checkHasEpisodeByFeedUrl(ep.enclosure.url))
|
var newEpisodes = feed.episodes.filter(ep => ep.publishedAt > dateToCheckForEpisodesAfter && !podcastLibraryItem.media.checkHasEpisodeByFeedUrl(ep.enclosure.url))
|
||||||
// Max new episodes for safety = 3
|
// Max new episodes for safety = 3
|
||||||
newEpisodes = newEpisodes.slice(0, 3)
|
newEpisodes = newEpisodes.slice(0, 3)
|
||||||
return newEpisodes
|
return newEpisodes
|
||||||
@@ -242,7 +242,7 @@ class PodcastManager {
|
|||||||
async checkAndDownloadNewEpisodes(libraryItem) {
|
async checkAndDownloadNewEpisodes(libraryItem) {
|
||||||
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0)
|
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0)
|
||||||
Logger.info(`[PodcastManager] checkAndDownloadNewEpisodes for "${libraryItem.media.metadata.title}" - Last episode check: ${lastEpisodeCheckDate}`)
|
Logger.info(`[PodcastManager] checkAndDownloadNewEpisodes for "${libraryItem.media.metadata.title}" - Last episode check: ${lastEpisodeCheckDate}`)
|
||||||
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem)
|
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, libraryItem.media.lastEpisodeCheck)
|
||||||
if (newEpisodes.length) {
|
if (newEpisodes.length) {
|
||||||
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.metadata.title}" - starting download`)
|
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.metadata.title}" - starting download`)
|
||||||
this.downloadPodcastEpisodes(libraryItem, newEpisodes)
|
this.downloadPodcastEpisodes(libraryItem, newEpisodes)
|
||||||
|
|||||||
+10
-18
@@ -480,7 +480,6 @@ const updateStoreData = async (store, match, update, tempstore, lockoptions) =>
|
|||||||
|
|
||||||
release = await lock(store, lockoptions);
|
release = await lock(store, lockoptions);
|
||||||
|
|
||||||
// console.log('Start updateStoreData for tempstore', tempstore, 'real store', store)
|
|
||||||
const handlerResults = await new Promise((resolve, reject) => {
|
const handlerResults = await new Promise((resolve, reject) => {
|
||||||
|
|
||||||
const writer = createWriteStream(tempstore);
|
const writer = createWriteStream(tempstore);
|
||||||
@@ -490,14 +489,11 @@ const updateStoreData = async (store, match, update, tempstore, lockoptions) =>
|
|||||||
// Reader was opening and closing before writer ever opened
|
// Reader was opening and closing before writer ever opened
|
||||||
const reader = createInterface({ input: createReadStream(store), crlfDelay: Infinity });
|
const reader = createInterface({ input: createReadStream(store), crlfDelay: Infinity });
|
||||||
|
|
||||||
// console.log('Writer opened for tempstore', tempstore)
|
|
||||||
reader.on("line", record => {
|
reader.on("line", record => {
|
||||||
handler.next(record, writer)
|
handler.next(record, writer)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
reader.on("close", () => {
|
reader.on("close", () => {
|
||||||
// console.log('Closing reader for store', store)
|
|
||||||
writer.end();
|
writer.end();
|
||||||
resolve(handler.return());
|
resolve(handler.return());
|
||||||
});
|
});
|
||||||
@@ -505,12 +501,7 @@ const updateStoreData = async (store, match, update, tempstore, lockoptions) =>
|
|||||||
reader.on("error", error => reject(error));
|
reader.on("error", error => reject(error));
|
||||||
});
|
});
|
||||||
|
|
||||||
// writer.on('close', () => {
|
|
||||||
// console.log('Writer closed for tempstore', tempstore)
|
|
||||||
// })
|
|
||||||
|
|
||||||
writer.on("error", error => reject(error));
|
writer.on("error", error => reject(error));
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
results = Object.assign({ store: store, tempstore: tempstore }, handlerResults);
|
results = Object.assign({ store: store, tempstore: tempstore }, handlerResults);
|
||||||
@@ -579,26 +570,27 @@ const updateStoreDataSync = (store, match, update, tempstore) => {
|
|||||||
|
|
||||||
const deleteStoreData = async (store, match, tempstore, lockoptions) => {
|
const deleteStoreData = async (store, match, tempstore, lockoptions) => {
|
||||||
let release, results;
|
let release, results;
|
||||||
|
|
||||||
release = await lock(store, lockoptions);
|
release = await lock(store, lockoptions);
|
||||||
|
|
||||||
const handlerResults = await new Promise((resolve, reject) => {
|
const handlerResults = await new Promise((resolve, reject) => {
|
||||||
const reader = createInterface({ input: createReadStream(store), crlfDelay: Infinity });
|
|
||||||
const writer = createWriteStream(tempstore);
|
const writer = createWriteStream(tempstore);
|
||||||
const handler = Handler("delete", match);
|
const handler = Handler("delete", match);
|
||||||
|
|
||||||
writer.on("open", () => {
|
writer.on("open", () => {
|
||||||
|
// Create reader after writer opens otherwise the reader can sometimes close before the writer opens
|
||||||
|
const reader = createInterface({ input: createReadStream(store), crlfDelay: Infinity });
|
||||||
|
|
||||||
reader.on("line", record => handler.next(record, writer));
|
reader.on("line", record => handler.next(record, writer));
|
||||||
|
|
||||||
|
reader.on("close", () => {
|
||||||
|
writer.end();
|
||||||
|
resolve(handler.return());
|
||||||
|
});
|
||||||
|
|
||||||
|
reader.on("error", error => reject(error));
|
||||||
});
|
});
|
||||||
|
|
||||||
writer.on("error", error => reject(error));
|
writer.on("error", error => reject(error));
|
||||||
|
|
||||||
reader.on("close", () => {
|
|
||||||
writer.end();
|
|
||||||
resolve(handler.return());
|
|
||||||
});
|
|
||||||
|
|
||||||
reader.on("error", error => reject(error));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
results = Object.assign({ store: store, tempstore: tempstore }, handlerResults);
|
results = Object.assign({ store: store, tempstore: tempstore }, handlerResults);
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class Author {
|
|||||||
imagePath: this.imagePath,
|
imagePath: this.imagePath,
|
||||||
relImagePath: this.relImagePath,
|
relImagePath: this.relImagePath,
|
||||||
addedAt: this.addedAt,
|
addedAt: this.addedAt,
|
||||||
lastUpdate: this.updatedAt
|
updatedAt: this.updatedAt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -153,6 +153,30 @@ class Book {
|
|||||||
return hasUpdates
|
return hasUpdates
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateChapters(chapters) {
|
||||||
|
var hasUpdates = this.chapters.length !== chapters.length
|
||||||
|
if (hasUpdates) {
|
||||||
|
this.chapters = chapters.map(ch => ({
|
||||||
|
id: ch.id,
|
||||||
|
start: ch.start,
|
||||||
|
end: ch.end,
|
||||||
|
title: ch.title
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < this.chapters.length; i++) {
|
||||||
|
const currChapter = this.chapters[i]
|
||||||
|
const newChapter = chapters[i]
|
||||||
|
if (!hasUpdates && (currChapter.title !== newChapter.title || currChapter.start !== newChapter.start || currChapter.end !== newChapter.end)) {
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
this.chapters[i].title = newChapter.title
|
||||||
|
this.chapters[i].start = newChapter.start
|
||||||
|
this.chapters[i].end = newChapter.end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hasUpdates
|
||||||
|
}
|
||||||
|
|
||||||
updateCover(coverPath) {
|
updateCover(coverPath) {
|
||||||
coverPath = coverPath.replace(/\\/g, '/')
|
coverPath = coverPath.replace(/\\/g, '/')
|
||||||
if (this.coverPath === coverPath) return false
|
if (this.coverPath === coverPath) return false
|
||||||
@@ -381,19 +405,27 @@ class Book {
|
|||||||
// If audio file has chapters use chapters
|
// If audio file has chapters use chapters
|
||||||
if (file.chapters && file.chapters.length) {
|
if (file.chapters && file.chapters.length) {
|
||||||
file.chapters.forEach((chapter) => {
|
file.chapters.forEach((chapter) => {
|
||||||
var chapterDuration = chapter.end - chapter.start
|
if (chapter.start > this.duration) {
|
||||||
if (chapterDuration > 0) {
|
Logger.warn(`[Book] Invalid chapter start time > duration`)
|
||||||
var title = `Chapter ${currChapterId}`
|
} else {
|
||||||
if (chapter.title) {
|
var chapterAlreadyExists = this.chapters.find(ch => ch.start === chapter.start)
|
||||||
title += ` (${chapter.title})`
|
if (!chapterAlreadyExists) {
|
||||||
|
var chapterDuration = chapter.end - chapter.start
|
||||||
|
if (chapterDuration > 0) {
|
||||||
|
var title = `Chapter ${currChapterId}`
|
||||||
|
if (chapter.title) {
|
||||||
|
title += ` (${chapter.title})`
|
||||||
|
}
|
||||||
|
var endTime = Math.min(this.duration, currStartTime + chapterDuration)
|
||||||
|
this.chapters.push({
|
||||||
|
id: currChapterId++,
|
||||||
|
start: currStartTime,
|
||||||
|
end: endTime,
|
||||||
|
title
|
||||||
|
})
|
||||||
|
currStartTime += chapterDuration
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.chapters.push({
|
|
||||||
id: currChapterId++,
|
|
||||||
start: currStartTime,
|
|
||||||
end: currStartTime + chapterDuration,
|
|
||||||
title
|
|
||||||
})
|
|
||||||
currStartTime += chapterDuration
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else if (file.duration) {
|
} else if (file.duration) {
|
||||||
|
|||||||
@@ -104,6 +104,15 @@ class Podcast {
|
|||||||
get numTracks() {
|
get numTracks() {
|
||||||
return this.episodes.length
|
return this.episodes.length
|
||||||
}
|
}
|
||||||
|
get latestEpisodePublished() {
|
||||||
|
var largestPublishedAt = 0
|
||||||
|
this.episodes.forEach((ep) => {
|
||||||
|
if (ep.publishedAt && ep.publishedAt > largestPublishedAt) {
|
||||||
|
largestPublishedAt = ep.publishedAt
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return largestPublishedAt
|
||||||
|
}
|
||||||
|
|
||||||
update(payload) {
|
update(payload) {
|
||||||
var json = this.toJSON()
|
var json = this.toJSON()
|
||||||
|
|||||||
@@ -27,6 +27,19 @@ class Audnexus {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findAuthorByASIN(asin) {
|
||||||
|
var author = await this.authorRequest(asin)
|
||||||
|
if (!author) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
asin: author.asin,
|
||||||
|
description: author.description,
|
||||||
|
image: author.image,
|
||||||
|
name: author.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async findAuthorByName(name, maxLevenshtein = 3) {
|
async findAuthorByName(name, maxLevenshtein = 3) {
|
||||||
Logger.debug(`[Audnexus] Looking up author by name ${name}`)
|
Logger.debug(`[Audnexus] Looking up author by name ${name}`)
|
||||||
var asins = await this.authorASINsRequest(name)
|
var asins = await this.authorASINsRequest(name)
|
||||||
@@ -45,5 +58,15 @@ class Audnexus {
|
|||||||
name: author.name
|
name: author.name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getChaptersByASIN(asin) {
|
||||||
|
Logger.debug(`[Audnexus] Get chapters for ASIN ${asin}`)
|
||||||
|
return axios.get(`${this.baseUrl}/books/${asin}/chapters`).then((res) => {
|
||||||
|
return res.data
|
||||||
|
}).catch((error) => {
|
||||||
|
Logger.error(`[Audnexus] Chapter ASIN request failed for ${asin}`, error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
module.exports = Audnexus
|
module.exports = Audnexus
|
||||||
@@ -92,8 +92,9 @@ class ApiRouter {
|
|||||||
this.router.patch('/items/:id/tracks', LibraryItemController.middleware.bind(this), LibraryItemController.updateTracks.bind(this))
|
this.router.patch('/items/:id/tracks', LibraryItemController.middleware.bind(this), LibraryItemController.updateTracks.bind(this))
|
||||||
this.router.patch('/items/:id/episodes', LibraryItemController.middleware.bind(this), LibraryItemController.updateEpisodes.bind(this))
|
this.router.patch('/items/:id/episodes', LibraryItemController.middleware.bind(this), LibraryItemController.updateEpisodes.bind(this))
|
||||||
this.router.delete('/items/:id/episode/:episodeId', LibraryItemController.middleware.bind(this), LibraryItemController.removeEpisode.bind(this))
|
this.router.delete('/items/:id/episode/:episodeId', LibraryItemController.middleware.bind(this), LibraryItemController.removeEpisode.bind(this))
|
||||||
this.router.get('/items/:id/scan', LibraryItemController.middleware.bind(this), LibraryItemController.scan.bind(this)) // Root only
|
this.router.get('/items/:id/scan', LibraryItemController.middleware.bind(this), LibraryItemController.scan.bind(this))
|
||||||
this.router.get('/items/:id/audio-metadata', LibraryItemController.middleware.bind(this), LibraryItemController.updateAudioFileMetadata.bind(this)) // Root only
|
this.router.get('/items/:id/audio-metadata', LibraryItemController.middleware.bind(this), LibraryItemController.updateAudioFileMetadata.bind(this))
|
||||||
|
this.router.post('/items/:id/chapters', LibraryItemController.middleware.bind(this), LibraryItemController.updateMediaChapters.bind(this))
|
||||||
|
|
||||||
this.router.post('/items/batch/delete', LibraryItemController.batchDelete.bind(this))
|
this.router.post('/items/batch/delete', LibraryItemController.batchDelete.bind(this))
|
||||||
this.router.post('/items/batch/update', LibraryItemController.batchUpdate.bind(this))
|
this.router.post('/items/batch/update', LibraryItemController.batchUpdate.bind(this))
|
||||||
@@ -204,6 +205,7 @@ class ApiRouter {
|
|||||||
this.router.get('/search/books', MiscController.findBooks.bind(this))
|
this.router.get('/search/books', MiscController.findBooks.bind(this))
|
||||||
this.router.get('/search/podcast', MiscController.findPodcasts.bind(this))
|
this.router.get('/search/podcast', MiscController.findPodcasts.bind(this))
|
||||||
this.router.get('/search/authors', MiscController.findAuthor.bind(this))
|
this.router.get('/search/authors', MiscController.findAuthor.bind(this))
|
||||||
|
this.router.get('/search/chapters', MiscController.findChapters.bind(this))
|
||||||
this.router.get('/tags', MiscController.getAllTags.bind(this))
|
this.router.get('/tags', MiscController.getAllTags.bind(this))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const Path = require('path')
|
|||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const { groupFilesIntoLibraryItemPaths, getLibraryItemFileData, scanFolder } = require('../utils/scandir')
|
const { groupFilesIntoLibraryItemPaths, getLibraryItemFileData, scanFolder } = require('../utils/scandir')
|
||||||
const { comparePaths } = require('../utils/index')
|
const { comparePaths } = require('../utils/index')
|
||||||
|
const { getIno } = require('../utils/fileUtils')
|
||||||
const { ScanResult, LogLevel } = require('../utils/constants')
|
const { ScanResult, LogLevel } = require('../utils/constants')
|
||||||
|
|
||||||
const AudioFileScanner = require('./AudioFileScanner')
|
const AudioFileScanner = require('./AudioFileScanner')
|
||||||
@@ -538,9 +539,19 @@ class Scanner {
|
|||||||
var itemGroupingResults = {}
|
var itemGroupingResults = {}
|
||||||
for (const itemDir in fileUpdateGroup) {
|
for (const itemDir in fileUpdateGroup) {
|
||||||
var fullPath = Path.posix.join(folder.fullPath.replace(/\\/g, '/'), itemDir)
|
var fullPath = Path.posix.join(folder.fullPath.replace(/\\/g, '/'), itemDir)
|
||||||
|
const dirIno = await getIno(fullPath)
|
||||||
|
|
||||||
// Check if book dir group is already an item
|
// Check if book dir group is already an item
|
||||||
var existingLibraryItem = this.db.libraryItems.find(li => fullPath.startsWith(li.path))
|
var existingLibraryItem = this.db.libraryItems.find(li => fullPath.startsWith(li.path))
|
||||||
|
if (!existingLibraryItem) {
|
||||||
|
existingLibraryItem = this.db.libraryItems.find(li => li.ino === dirIno)
|
||||||
|
if (existingLibraryItem) {
|
||||||
|
Logger.debug(`[Scanner] scanFolderUpdates: Library item found by inode value "${existingLibraryItem.relPath} => ${itemDir}"`)
|
||||||
|
// Update library item paths for scan and all library item paths will get updated in LibraryItem.checkScanData
|
||||||
|
existingLibraryItem.path = fullPath
|
||||||
|
existingLibraryItem.relPath = itemDir
|
||||||
|
}
|
||||||
|
}
|
||||||
if (existingLibraryItem) {
|
if (existingLibraryItem) {
|
||||||
// Is the item exactly - check if was deleted
|
// Is the item exactly - check if was deleted
|
||||||
if (existingLibraryItem.path === fullPath) {
|
if (existingLibraryItem.path === fullPath) {
|
||||||
|
|||||||
@@ -120,9 +120,6 @@ module.exports.extractCoverArt = extractCoverArt
|
|||||||
|
|
||||||
//This should convert based on the output file extension as well
|
//This should convert based on the output file extension as well
|
||||||
async function resizeImage(filePath, outputPath, width, height) {
|
async function resizeImage(filePath, outputPath, width, height) {
|
||||||
var dirname = Path.dirname(outputPath);
|
|
||||||
await fs.ensureDir(dirname);
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
var ffmpeg = Ffmpeg(filePath)
|
var ffmpeg = Ffmpeg(filePath)
|
||||||
ffmpeg.addOption(['-vf', `scale=${width || -1}:${height || -1}`])
|
ffmpeg.addOption(['-vf', `scale=${width || -1}:${height || -1}`])
|
||||||
|
|||||||
@@ -176,17 +176,20 @@ module.exports.downloadFile = async (url, filepath) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports.sanitizeFilename = (filename, replacement = '') => {
|
module.exports.sanitizeFilename = (filename, colonReplacement = ' - ') => {
|
||||||
if (typeof filename !== 'string') {
|
if (typeof filename !== 'string') {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var replacement = ''
|
||||||
var illegalRe = /[\/\?<>\\:\*\|"]/g;
|
var illegalRe = /[\/\?<>\\:\*\|"]/g;
|
||||||
var controlRe = /[\x00-\x1f\x80-\x9f]/g;
|
var controlRe = /[\x00-\x1f\x80-\x9f]/g;
|
||||||
var reservedRe = /^\.+$/;
|
var reservedRe = /^\.+$/;
|
||||||
var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i;
|
var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i;
|
||||||
var windowsTrailingRe = /[\. ]+$/;
|
var windowsTrailingRe = /[\. ]+$/;
|
||||||
|
|
||||||
var sanitized = filename
|
sanitized = filename
|
||||||
|
.replace(':', colonReplacement) // Replace first occurrence of a colon
|
||||||
.replace(illegalRe, replacement)
|
.replace(illegalRe, replacement)
|
||||||
.replace(controlRe, replacement)
|
.replace(controlRe, replacement)
|
||||||
.replace(reservedRe, replacement)
|
.replace(reservedRe, replacement)
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ module.exports.getId = (prepend = '') => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function elapsedPretty(seconds) {
|
function elapsedPretty(seconds) {
|
||||||
|
if (seconds < 60) {
|
||||||
|
return `${Math.floor(seconds)} sec`
|
||||||
|
}
|
||||||
var minutes = Math.floor(seconds / 60)
|
var minutes = Math.floor(seconds / 60)
|
||||||
if (minutes < 70) {
|
if (minutes < 70) {
|
||||||
return `${minutes} min`
|
return `${minutes} min`
|
||||||
|
|||||||
Reference in New Issue
Block a user