mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-02 00:40:39 +02:00
Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 944a5b3e92 | |||
| 9b9de84740 | |||
| 2746e61cb3 | |||
| 7f1d797fb2 | |||
| 2059c9f14a | |||
| 0e16a9c8de | |||
| b6a33bf7bb | |||
| ce88ac9f33 | |||
| 678dceefed | |||
| 8b38dda229 | |||
| 7373c7159b | |||
| e34a39dde4 | |||
| d4cd8c6db9 | |||
| 9e93a3c7e6 | |||
| 4a8bcc90ea | |||
| 84c12a6e7e | |||
| 2a513ac8b8 | |||
| 97687c96cd | |||
| a42c13aec2 | |||
| 5f0f8b92d1 | |||
| 78ca6aa679 | |||
| 22e3d4a150 | |||
| e3fba1fb2b | |||
| 4d95250990 | |||
| 4776368501 | |||
| 8b0ed2bf29 | |||
| 54389e3c25 | |||
| bf0da1c6ec | |||
| 591a866f8c | |||
| fc8473ed84 | |||
| b19442e440 | |||
| 7a51e0693d | |||
| 21785c8e72 | |||
| bdf6ccbd2d | |||
| ceb163570f | |||
| 049ae73d74 | |||
| 729fdd5c9f | |||
| 4dac8ac16c | |||
| 220bbc3d2d | |||
| c2a4b32192 | |||
| 09d0d47549 | |||
| 4185807da4 | |||
| 620bf7990f |
@@ -13,8 +13,6 @@ on:
|
|||||||
- server/**
|
- server/**
|
||||||
- index.js
|
- index.js
|
||||||
- package.json
|
- package.json
|
||||||
release:
|
|
||||||
types: [published, edited]
|
|
||||||
# Allows you to run workflow manually from Actions tab
|
# Allows you to run workflow manually from Actions tab
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
|||||||
@@ -54,10 +54,16 @@
|
|||||||
<ui-icon-btn :disabled="processingBatch" icon="collections_bookmark" @click="batchAddToCollectionClick" class="mx-1.5" />
|
<ui-icon-btn :disabled="processingBatch" icon="collections_bookmark" @click="batchAddToCollectionClick" class="mx-1.5" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
<template v-if="userCanUpdate && numLibraryItemsSelected < 50">
|
<template v-if="userCanUpdate && numLibraryItemsSelected < 50">
|
||||||
<ui-icon-btn v-show="!processingBatchDelete" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
|
<ui-tooltip text="Edit" direction="bottom">
|
||||||
|
<ui-icon-btn v-show="!processingBatchDelete" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
|
||||||
|
</ui-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<ui-icon-btn v-show="userCanDelete" :disabled="processingBatchDelete" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
|
<ui-tooltip v-if="userCanDelete" text="Delete" direction="bottom">
|
||||||
<span class="material-icons text-4xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatchDelete ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
|
<ui-icon-btn :disabled="processingBatchDelete" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
|
||||||
|
</ui-tooltip>
|
||||||
|
<ui-tooltip text="Deselect All" direction="bottom">
|
||||||
|
<span class="material-icons text-4xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatchDelete ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
|
||||||
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -229,4 +235,4 @@ export default {
|
|||||||
#appbar {
|
#appbar {
|
||||||
box-shadow: 0px 5px 5px #11111155;
|
box-shadow: 0px 5px 5px #11111155;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -128,8 +128,7 @@ export default {
|
|||||||
type: 'series',
|
type: 'series',
|
||||||
entities: this.results.series.map((seriesObj) => {
|
entities: this.results.series.map((seriesObj) => {
|
||||||
return {
|
return {
|
||||||
name: seriesObj.series.name,
|
...seriesObj.series,
|
||||||
series: seriesObj.series,
|
|
||||||
books: seriesObj.books,
|
books: seriesObj.books,
|
||||||
type: 'series'
|
type: 'series'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
<div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
<icons-podcast-svg class="w-6 h-6" />
|
<icons-podcast-svg class="w-6 h-6" />
|
||||||
|
|
||||||
<p class="font-book pt-1.5" style="font-size: 0.9rem">Search</p>
|
<p class="font-book pt-1.5" style="font-size: 0.9rem">Search</p>
|
||||||
@@ -82,6 +82,9 @@ export default {
|
|||||||
showExperimentalFeatures() {
|
showExperimentalFeatures() {
|
||||||
return this.$store.state.showExperimentalFeatures
|
return this.$store.state.showExperimentalFeatures
|
||||||
},
|
},
|
||||||
|
userIsAdminOrUp() {
|
||||||
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
|
},
|
||||||
paramId() {
|
paramId() {
|
||||||
return this.$route.params ? this.$route.params.id || '' : ''
|
return this.$route.params ? this.$route.params.id || '' : ''
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,10 +11,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search icon btn -->
|
<!-- Search icon btn -->
|
||||||
<div v-show="!searching && isHovering" class="absolute top-0 left-0 p-2 cursor-pointer hover:text-white text-gray-200 transform transition-transform hover:scale-125" @click.prevent.stop="searchAuthor">
|
<div v-show="!searching && isHovering && userCanUpdate" class="absolute top-0 left-0 p-2 cursor-pointer hover:text-white text-gray-200 transform transition-transform hover:scale-125" @click.prevent.stop="searchAuthor">
|
||||||
<span class="material-icons text-lg">search</span>
|
<span class="material-icons text-lg">search</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="isHovering && !searching" class="absolute top-0 right-0 p-2 cursor-pointer hover:text-white text-gray-200 transform transition-transform hover:scale-125" @click.prevent.stop="$emit('edit', author)">
|
<div v-show="isHovering && !searching && userCanUpdate" class="absolute top-0 right-0 p-2 cursor-pointer hover:text-white text-gray-200 transform transition-transform hover:scale-125" @click.prevent.stop="$emit('edit', author)">
|
||||||
<span class="material-icons text-lg">edit</span>
|
<span class="material-icons text-lg">edit</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -65,6 +65,9 @@ export default {
|
|||||||
},
|
},
|
||||||
numBooks() {
|
numBooks() {
|
||||||
return this._author.numBooks || 0
|
return this._author.numBooks || 0
|
||||||
|
},
|
||||||
|
userCanUpdate() {
|
||||||
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
<!-- Series sequence -->
|
<!-- Series sequence -->
|
||||||
<div v-if="seriesSequence && showSequence && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
<div v-if="seriesSequence && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequence }}</p>
|
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequence }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -110,7 +110,6 @@ export default {
|
|||||||
default: 192
|
default: 192
|
||||||
},
|
},
|
||||||
bookCoverAspectRatio: Number,
|
bookCoverAspectRatio: Number,
|
||||||
showSequence: Boolean,
|
|
||||||
bookshelfView: Number,
|
bookshelfView: Number,
|
||||||
bookMount: {
|
bookMount: {
|
||||||
// Book can be passed as prop or set with setEntity()
|
// Book can be passed as prop or set with setEntity()
|
||||||
@@ -176,7 +175,7 @@ export default {
|
|||||||
return this._libraryItem.id
|
return this._libraryItem.id
|
||||||
},
|
},
|
||||||
series() {
|
series() {
|
||||||
// Only included when filtering by series or collapse series
|
// Only included when filtering by series or collapse series or Continue Series shelf on home page
|
||||||
return this.mediaMetadata.series
|
return this.mediaMetadata.series
|
||||||
},
|
},
|
||||||
seriesSequence() {
|
seriesSequence() {
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ export default {
|
|||||||
this.$nextTick(this.init)
|
this.$nextTick(this.init)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
width: {
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.isInit = false
|
||||||
|
this.$nextTick(this.init)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" name="account" :width="800" :height="'unset'" :processing="processing">
|
<modals-modal ref="modal" v-model="show" name="account" :width="800" :height="'unset'" :processing="processing">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||||
@@ -8,20 +8,20 @@
|
|||||||
<form @submit.prevent="submitForm">
|
<form @submit.prevent="submitForm">
|
||||||
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
|
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
|
||||||
<div class="w-full p-8">
|
<div class="w-full p-8">
|
||||||
<div class="flex py-2 -mx-2">
|
<div class="flex py-2">
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
<ui-text-input-with-label v-model="newUser.username" label="Username" class="mx-2" />
|
<ui-text-input-with-label v-model="newUser.username" label="Username" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
<ui-text-input-with-label v-if="!isEditingRoot" v-model="newUser.password" :label="isNew ? 'Password' : 'Change Password'" type="password" class="mx-2" />
|
<ui-text-input-with-label v-if="!isEditingRoot" v-model="newUser.password" :label="isNew ? 'Password' : 'Change Password'" type="password" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex py-2">
|
<div v-show="!isEditingRoot" class="flex py-2">
|
||||||
<div class="px-2">
|
<div class="px-2 w-52">
|
||||||
<ui-input-dropdown v-model="newUser.type" label="Account Type" :disabled="isEditingRoot" :editable="false" :items="accountTypes" @input="userTypeUpdated" />
|
<ui-dropdown v-model="newUser.type" label="Account Type" :disabled="isEditingRoot" :items="accountTypes" @input="userTypeUpdated" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<div v-show="!isEditingRoot" class="flex items-center pt-4 px-2">
|
<div class="flex items-center pt-4 px-2">
|
||||||
<p class="px-3 font-semibold" :class="isEditingRoot ? 'text-gray-300' : ''">Is Active</p>
|
<p class="px-3 font-semibold" :class="isEditingRoot ? 'text-gray-300' : ''">Is Active</p>
|
||||||
<ui-toggle-switch v-model="newUser.isActive" :disabled="isEditingRoot" />
|
<ui-toggle-switch v-model="newUser.isActive" :disabled="isEditingRoot" />
|
||||||
</div>
|
</div>
|
||||||
@@ -86,13 +86,13 @@
|
|||||||
<ui-toggle-switch v-model="newUser.permissions.accessAllTags" @input="accessAllTagsToggled" />
|
<ui-toggle-switch v-model="newUser.permissions.accessAllTags" @input="accessAllTagsToggled" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!newUser.permissions.accessAllTags" class="my-4">
|
<div v-if="!newUser.permissions.accessAllTags" class="my-4">
|
||||||
<ui-multi-select-dropdown v-model="newUser.itemTagsAccessible" :items="itemTags" label="Tags Accessible to User" />
|
<ui-multi-select-dropdown v-model="newUser.itemTagsAccessible" :items="itemTags" label="Tags Accessible to User" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex pt-4">
|
<div class="flex pt-4 px-2">
|
||||||
|
<ui-btn v-if="isEditingRoot" to="/account">Change Root Password</ui-btn>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn color="success" type="submit">Submit</ui-btn>
|
<ui-btn color="success" type="submit">Submit</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,7 +116,20 @@ export default {
|
|||||||
processing: false,
|
processing: false,
|
||||||
newUser: {},
|
newUser: {},
|
||||||
isNew: true,
|
isNew: true,
|
||||||
accountTypes: ['guest', 'user', 'admin'],
|
accountTypes: [
|
||||||
|
{
|
||||||
|
text: 'Guest',
|
||||||
|
value: 'guest'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'User',
|
||||||
|
value: 'user'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Admin',
|
||||||
|
value: 'admin'
|
||||||
|
}
|
||||||
|
],
|
||||||
tags: [],
|
tags: [],
|
||||||
loadingTags: false
|
loadingTags: false
|
||||||
}
|
}
|
||||||
@@ -124,6 +137,7 @@ export default {
|
|||||||
watch: {
|
watch: {
|
||||||
show: {
|
show: {
|
||||||
handler(newVal) {
|
handler(newVal) {
|
||||||
|
console.log('accoutn modal show change', newVal)
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
this.init()
|
this.init()
|
||||||
}
|
}
|
||||||
@@ -140,7 +154,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.isNew ? 'Add New Account' : `Update Account: ${(this.account || {}).username}`
|
return this.isNew ? 'Add New Account' : `Update ${(this.account || {}).username}`
|
||||||
},
|
},
|
||||||
isEditingRoot() {
|
isEditingRoot() {
|
||||||
return this.account && this.account.type === 'root'
|
return this.account && this.account.type === 'root'
|
||||||
@@ -161,10 +175,12 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
close() {
|
||||||
|
// Force close when navigating - used in UsersTable
|
||||||
|
if (this.$refs.modal) this.$refs.modal.setHide()
|
||||||
|
},
|
||||||
accessAllTagsToggled(val) {
|
accessAllTagsToggled(val) {
|
||||||
if (!val && !this.newUser.itemTagsAccessible.length) {
|
if (val && this.newUser.itemTagsAccessible.length) {
|
||||||
this.newUser.itemTagsAccessible = this.libraries.map((l) => l.id)
|
|
||||||
} else if (val && this.newUser.itemTagsAccessible.length) {
|
|
||||||
this.newUser.itemTagsAccessible = []
|
this.newUser.itemTagsAccessible = []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -197,6 +213,10 @@ export default {
|
|||||||
this.$toast.error('Must select at least one library')
|
this.$toast.error('Must select at least one library')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!this.newUser.permissions.accessAllTags && !this.newUser.itemTagsAccessible.length) {
|
||||||
|
this.$toast.error('Must select at least one tag')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (this.isNew) {
|
if (this.isNew) {
|
||||||
this.submitCreateAccount()
|
this.submitCreateAccount()
|
||||||
|
|||||||
@@ -62,9 +62,9 @@ export default {
|
|||||||
component: 'modals-item-tabs-match'
|
component: 'modals-item-tabs-match'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'merge',
|
id: 'manage',
|
||||||
title: 'Merge',
|
title: 'Manage',
|
||||||
component: 'modals-item-tabs-merge',
|
component: 'modals-item-tabs-manage',
|
||||||
experimental: true
|
experimental: true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -123,12 +123,12 @@ export default {
|
|||||||
if (!this.userCanUpdate && !this.userCanDownload) return []
|
if (!this.userCanUpdate && !this.userCanDownload) return []
|
||||||
return this.tabs.filter((tab) => {
|
return this.tabs.filter((tab) => {
|
||||||
if (tab.experimental && !this.showExperimentalFeatures) return false
|
if (tab.experimental && !this.showExperimentalFeatures) return false
|
||||||
if (tab.id === 'merge' && (this.isMissing || this.mediaType !== 'book')) return false
|
if (tab.id === 'manage' && (this.isMissing || this.mediaType !== 'book')) return false
|
||||||
if (this.mediaType == 'podcast' && tab.id == 'chapters') return false
|
if (this.mediaType == 'podcast' && tab.id == 'chapters') return false
|
||||||
if (this.mediaType == 'book' && tab.id == 'episodes') return false
|
if (this.mediaType == 'book' && tab.id == 'episodes') return false
|
||||||
|
|
||||||
if ((tab.id === 'merge' || tab.id === 'files') && this.userCanDownload) return true
|
if ((tab.id === 'manage' || tab.id === 'files') && this.userCanDownload) return true
|
||||||
if (tab.id !== 'merge' && tab.id !== 'files' && this.userCanUpdate) return true
|
if (tab.id !== 'manage' && tab.id !== 'files' && this.userCanUpdate) return true
|
||||||
if (tab.id === 'match' && this.userCanUpdate) return true
|
if (tab.id === 'match' && this.userCanUpdate) return true
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ export default {
|
|||||||
if (updateResult) {
|
if (updateResult) {
|
||||||
if (updateResult.updated) {
|
if (updateResult.updated) {
|
||||||
this.$toast.success('Item details updated')
|
this.$toast.success('Item details updated')
|
||||||
// this.$emit('close')
|
this.$emit('close')
|
||||||
} else {
|
} else {
|
||||||
this.$toast.info('No updates were necessary')
|
this.$toast.info('No updates were necessary')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +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 class="flex items-center mb-4">
|
<div v-if="userIsAdminOrUp" class="flex items-end justify-end mb-4">
|
||||||
<p v-if="autoDownloadEpisodes">Last new episode check {{ $formatDate(lastEpisodeCheck) }}</p>
|
<!-- <p v-if="autoDownloadEpisodes">Last new episode check {{ $formatDate(lastEpisodeCheck) }}</p> -->
|
||||||
<div class="flex-grow" />
|
<ui-text-input-with-label ref="lastCheckInput" v-model="lastEpisodeCheckInput" :disabled="checkingNewEpisodes" type="datetime-local" label="Look for new episodes after this date" class="max-w-xs mr-2" />
|
||||||
<ui-btn :loading="checkingNewEpisodes" @click="checkForNewEpisodes">Check for new episodes</ui-btn>
|
<ui-btn :loading="checkingNewEpisodes" @click="checkForNewEpisodes">Check & Download New Episodes</ui-btn>
|
||||||
</div> -->
|
</div>
|
||||||
|
|
||||||
<div v-if="episodes.length" class="w-full p-4 bg-primary">
|
<div v-if="episodes.length" class="w-full p-4 bg-primary">
|
||||||
<p>Podcast Episodes</p>
|
<p>Podcast Episodes</p>
|
||||||
@@ -51,10 +51,23 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
checkingNewEpisodes: false
|
checkingNewEpisodes: false,
|
||||||
|
lastEpisodeCheckInput: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
lastEpisodeCheck: {
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.setLastEpisodeCheckInput()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
userIsAdminOrUp() {
|
||||||
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
|
},
|
||||||
autoDownloadEpisodes() {
|
autoDownloadEpisodes() {
|
||||||
return !!this.media.autoDownloadEpisodes
|
return !!this.media.autoDownloadEpisodes
|
||||||
},
|
},
|
||||||
@@ -72,8 +85,22 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
checkForNewEpisodes() {
|
async checkForNewEpisodes() {
|
||||||
|
if (this.$refs.lastCheckInput) {
|
||||||
|
this.$refs.lastCheckInput.blur()
|
||||||
|
}
|
||||||
this.checkingNewEpisodes = true
|
this.checkingNewEpisodes = true
|
||||||
|
const lastEpisodeCheck = new Date(this.lastEpisodeCheckInput).valueOf()
|
||||||
|
|
||||||
|
// If last episode check changed then update it first
|
||||||
|
if (lastEpisodeCheck && lastEpisodeCheck !== this.lastEpisodeCheck) {
|
||||||
|
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, { lastEpisodeCheck }).catch((error) => {
|
||||||
|
console.error('Failed to update', error)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
console.log('updateResult', updateResult)
|
||||||
|
}
|
||||||
|
|
||||||
this.$axios
|
this.$axios
|
||||||
.$get(`/api/podcasts/${this.libraryItemId}/checknew`)
|
.$get(`/api/podcasts/${this.libraryItemId}/checknew`)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
@@ -91,7 +118,13 @@ export default {
|
|||||||
this.$toast.error(errorMsg)
|
this.$toast.error(errorMsg)
|
||||||
this.checkingNewEpisodes = false
|
this.checkingNewEpisodes = false
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
setLastEpisodeCheckInput() {
|
||||||
|
this.lastEpisodeCheckInput = this.lastEpisodeCheck ? this.$formatDate(this.lastEpisodeCheck, "yyyy-MM-dd'T'HH:mm") : null
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.setLastEpisodeCheckInput()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
+57
-7
@@ -1,10 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
|
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
|
||||||
|
<!-- Merge to m4b -->
|
||||||
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-8">
|
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-8">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-lg">M4B Audiobook File <span class="text-error">*</span></p>
|
<p class="text-lg">Make M4B Audiobook File <span class="text-error">*</span></p>
|
||||||
<p class="max-w-xs text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded cover image and chapters.</p>
|
<p class="max-w-sm text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded metadata, cover image, and chapters. <br /><span class="text-warning">*</span> Does not delete existing audio files.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<div>
|
<div>
|
||||||
@@ -24,13 +25,55 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-left text-base mb-4 py-4">
|
<!-- Split to mp3 -->
|
||||||
|
<div v-if="showMp3Split" class="w-full border border-black-200 p-4 my-8">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div>
|
||||||
|
<p class="text-lg">Split M4B to MP3's</p>
|
||||||
|
<p class="max-w-sm text-sm pt-2 text-gray-300">Generate multiple MP3's split by chapters with embedded metadata, cover image, and chapters. <br /><span class="text-warning">*</span> Does not delete existing audio files.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<div>
|
||||||
|
<p v-if="abmergeStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
|
||||||
|
<p v-if="abmergeStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
|
||||||
|
<p v-if="abmergeStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
|
||||||
|
|
||||||
|
<ui-btn v-if="abmergeStatus !== $constants.DownloadStatus.READY" :loading="abmergeStatus === $constants.DownloadStatus.PENDING" :disabled="true" @click="startAudiobookMerge">Not yet implemented</ui-btn>
|
||||||
|
<div v-else>
|
||||||
|
<div class="flex">
|
||||||
|
<ui-btn @click="downloadWithProgress(abmergeDownload)">Download</ui-btn>
|
||||||
|
<ui-icon-btn small icon="delete" bg-color="error" class="ml-2" @click="removeDownload" />
|
||||||
|
</div>
|
||||||
|
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(abmergeDownload.size) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Embed Metadata -->
|
||||||
|
<div v-if="mediaTracks.length" class="w-full border border-black-200 p-4 my-8">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div>
|
||||||
|
<p class="text-lg">Embed Metadata</p>
|
||||||
|
<p class="max-w-sm text-sm pt-2 text-gray-300">Embed metadata into audio files including cover image and chapters. <br /><span class="text-warning">*</span> Modifies audio files.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<div>
|
||||||
|
<ui-btn :to="`/item/${libraryItemId}/manage`" class="flex items-center"
|
||||||
|
>Open Manager
|
||||||
|
<span class="material-icons text-lg ml-2">launch</span>
|
||||||
|
</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="showM4bDownload" class="text-left text-base mb-4 py-4">
|
||||||
<span class="text-error">* <strong>Experimental</strong></span
|
<span class="text-error">* <strong>Experimental</strong></span
|
||||||
> - M4b merge can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted. Download will timeout after 20 minutes.
|
> - M4b merge can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted. Download will timeout after 20 minutes.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p v-if="isSingleM4b" class="text-lg text-center my-8">Audiobook is already a single m4b!</p>
|
<!-- <p v-if="isSingleM4b" class="text-lg text-center my-8">Audiobook is already a single m4b!</p> -->
|
||||||
<p v-else-if="!mediaTracks.length" class="text-lg text-center my-8">No audio tracks to merge</p>
|
<p v-if="!mediaTracks.length" class="text-lg text-center my-8">No audio tracks to merge</p>
|
||||||
|
|
||||||
<div v-if="isDownloading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
<div v-if="isDownloading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||||
<div class="w-80 border border-black-400 bg-bg rounded-xl h-20">
|
<div class="w-80 border border-black-400 bg-bg rounded-xl h-20">
|
||||||
@@ -97,9 +140,16 @@ export default {
|
|||||||
isSingleM4b() {
|
isSingleM4b() {
|
||||||
return this.mediaTracks.length === 1 && this.mediaTracks[0].metadata.ext.toLowerCase() === '.m4b'
|
return this.mediaTracks.length === 1 && this.mediaTracks[0].metadata.ext.toLowerCase() === '.m4b'
|
||||||
},
|
},
|
||||||
|
chapters() {
|
||||||
|
return this.media.chapters || []
|
||||||
|
},
|
||||||
showM4bDownload() {
|
showM4bDownload() {
|
||||||
if (this.libraryItem.isMissing || !this.mediaTracks.length) return false
|
if (!this.mediaTracks.length) return false
|
||||||
return !this.isSingleM4b && this.mediaTracks.length > 0
|
return !this.isSingleM4b
|
||||||
|
},
|
||||||
|
showMp3Split() {
|
||||||
|
if (!this.mediaTracks.length) return false
|
||||||
|
return this.isSingleM4b && this.chapters.length
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" name="rss-feed-modal" :width="600" :height="'unset'" :processing="processing">
|
||||||
|
<template #outer>
|
||||||
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
|
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div ref="wrapper" class="px-8 py-6 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
||||||
|
<div class="w-full">
|
||||||
|
<p class="text-lg font-semibold mb-4">Podcast RSS Feed is Open</p>
|
||||||
|
|
||||||
|
<div class="w-full relative">
|
||||||
|
<ui-text-input v-model="feedUrl" readonly />
|
||||||
|
|
||||||
|
<span class="material-icons absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(feedUrl)">content_copy</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-show="userIsAdminOrUp" class="flex items-center pt-6">
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn color="error" small @click="closeFeed">Close RSS Feed</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
},
|
||||||
|
feedUrl: String
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
processing: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.libraryItem.media || {}
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return this.mediaMetadata.title
|
||||||
|
},
|
||||||
|
userIsAdminOrUp() {
|
||||||
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
copyToClipboard(str) {
|
||||||
|
this.$copyToClipboard(str, this)
|
||||||
|
},
|
||||||
|
closeFeed() {
|
||||||
|
this.processing = true
|
||||||
|
this.$axios
|
||||||
|
.$post(`/api/podcasts/${this.libraryItem.id}/close-feed`)
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success('RSS Feed Closed')
|
||||||
|
this.show = false
|
||||||
|
this.processing = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to close RSS feed', error)
|
||||||
|
this.processing = false
|
||||||
|
this.$toast.error()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
init() {}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<modals-account-modal v-model="showAccountModal" :account="selectedAccount" />
|
<modals-account-modal ref="accountModal" v-model="showAccountModal" :account="selectedAccount" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -156,6 +156,10 @@ export default {
|
|||||||
this.init()
|
this.init()
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
|
if (this.$refs.accountModal) {
|
||||||
|
this.$refs.accountModal.close()
|
||||||
|
}
|
||||||
|
|
||||||
if (this.$root.socket) {
|
if (this.$root.socket) {
|
||||||
this.$root.socket.off('user_added', this.newUserAdded)
|
this.$root.socket.off('user_added', this.newUserAdded)
|
||||||
this.$root.socket.off('user_updated', this.userUpdated)
|
this.$root.socket.off('user_updated', this.userUpdated)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative w-full" v-click-outside="clickOutsideObj">
|
<div class="relative w-full" v-click-outside="clickOutsideObj">
|
||||||
<p class="text-sm font-semibold" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
<p class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
||||||
<button type="button" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
<button type="button" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText }}</span>
|
<span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText }}</span>
|
||||||
|
|||||||
@@ -106,12 +106,6 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (payload.serverSettings) {
|
if (payload.serverSettings) {
|
||||||
this.$store.commit('setServerSettings', payload.serverSettings)
|
|
||||||
|
|
||||||
if (payload.serverSettings.chromecastEnabled) {
|
|
||||||
console.log('Chromecast enabled import script')
|
|
||||||
require('@/plugins/chromecast.js').default(this)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start scans currently running
|
// Start scans currently running
|
||||||
@@ -167,8 +161,28 @@ export default {
|
|||||||
libraryUpdated(library) {
|
libraryUpdated(library) {
|
||||||
this.$store.commit('libraries/addUpdate', library)
|
this.$store.commit('libraries/addUpdate', library)
|
||||||
},
|
},
|
||||||
libraryRemoved(library) {
|
async libraryRemoved(library) {
|
||||||
this.$store.commit('libraries/remove', library)
|
this.$store.commit('libraries/remove', library)
|
||||||
|
|
||||||
|
// When removed currently selected library then set next accessible library
|
||||||
|
const currLibraryId = this.$store.state.libraries.currentLibraryId
|
||||||
|
if (currLibraryId === library.id) {
|
||||||
|
var nextLibrary = this.$store.getters['libraries/getNextAccessibleLibrary']
|
||||||
|
if (nextLibrary) {
|
||||||
|
await this.$store.dispatch('libraries/fetch', nextLibrary.id)
|
||||||
|
|
||||||
|
if (this.$route.name.startsWith('config')) {
|
||||||
|
// No need to refresh
|
||||||
|
} else if (this.$route.name.startsWith('library')) {
|
||||||
|
var newRoute = this.$route.path.replace(currLibraryId, nextLibrary.id)
|
||||||
|
this.$router.push(newRoute)
|
||||||
|
} else {
|
||||||
|
this.$router.push(`/library/${nextLibrary.id}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('User has no accessible libraries')
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
libraryItemAdded(libraryItem) {
|
libraryItemAdded(libraryItem) {
|
||||||
// this.$store.commit('libraries/updateFilterDataWithAudiobook', libraryItem)
|
// this.$store.commit('libraries/updateFilterDataWithAudiobook', libraryItem)
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export default {
|
|||||||
bookCoverAspectRatio: this.bookCoverAspectRatio,
|
bookCoverAspectRatio: this.bookCoverAspectRatio,
|
||||||
bookshelfView: this.bookshelfView
|
bookshelfView: this.bookshelfView
|
||||||
}
|
}
|
||||||
if (this.entityName === 'series-books') props.showSequence = true
|
|
||||||
if (this.entityName === 'books') {
|
if (this.entityName === 'books') {
|
||||||
props.filterBy = this.filterBy
|
props.filterBy = this.filterBy
|
||||||
props.orderBy = this.orderBy
|
props.orderBy = this.orderBy
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.0.7",
|
"version": "2.0.9",
|
||||||
"description": "Audiobook manager and player",
|
"description": "Audiobook manager and player",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -15,8 +15,8 @@
|
|||||||
|
|
||||||
<div class="w-full h-px bg-primary my-4" />
|
<div class="w-full h-px bg-primary my-4" />
|
||||||
|
|
||||||
<p class="mb-4 text-lg">Change Password</p>
|
<p v-if="!isGuest" class="mb-4 text-lg">Change Password</p>
|
||||||
<form @submit.prevent="submitChangePassword">
|
<form v-if="!isGuest" @submit.prevent="submitChangePassword">
|
||||||
<ui-text-input-with-label v-model="password" :disabled="changingPassword" type="password" label="Password" class="my-2" />
|
<ui-text-input-with-label v-model="password" :disabled="changingPassword" type="password" label="Password" class="my-2" />
|
||||||
<ui-text-input-with-label v-model="newPassword" :disabled="changingPassword" type="password" label="New Password" class="my-2" />
|
<ui-text-input-with-label v-model="newPassword" :disabled="changingPassword" type="password" label="New Password" class="my-2" />
|
||||||
<ui-text-input-with-label v-model="confirmPassword" :disabled="changingPassword" type="password" label="Confirm Password" class="my-2" />
|
<ui-text-input-with-label v-model="confirmPassword" :disabled="changingPassword" type="password" label="Confirm Password" class="my-2" />
|
||||||
@@ -60,6 +60,9 @@ export default {
|
|||||||
},
|
},
|
||||||
isRoot() {
|
isRoot() {
|
||||||
return this.usertype === 'root'
|
return this.usertype === 'root'
|
||||||
|
},
|
||||||
|
isGuest() {
|
||||||
|
return this.usertype === 'guest'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -38,7 +38,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<draggable v-model="files" v-bind="dragOptions" class="list-group border border-gray-600" draggable=".item" tag="ul" @start="drag = true" @end="drag = false" @update="draggableUpdate">
|
<draggable v-model="files" v-bind="dragOptions" class="list-group border border-gray-600" draggable=".item" tag="ul" @start="drag = true" @end="drag = false" @update="draggableUpdate">
|
||||||
<transition-group type="transition" :name="!drag ? 'flip-list' : null">
|
<transition-group type="transition" :name="!drag ? 'flip-list' : null">
|
||||||
<li v-for="(audio, index) in files" :key="audio.ino" :class="audio.include ? 'item' : 'exclude'" class="w-full list-group-item flex items-center">
|
<li v-for="(audio, index) in files" :key="audio.ino" :class="audio.include ? 'item' : 'exclude'" class="w-full list-group-item flex items-center relative">
|
||||||
|
<div v-if="audiofilesEncoding[audio.ino]" class="absolute top-0 left-0 w-full h-full bg-success bg-opacity-25" />
|
||||||
|
|
||||||
<div class="font-book text-center px-4 py-1 w-12">
|
<div class="font-book text-center px-4 py-1 w-12">
|
||||||
{{ audio.include ? index - numExcluded + 1 : -1 }}
|
{{ audio.include ? index - numExcluded + 1 : -1 }}
|
||||||
</div>
|
</div>
|
||||||
@@ -71,7 +73,7 @@
|
|||||||
<div class="font-sans text-xs font-normal w-56">
|
<div class="font-sans text-xs font-normal w-56">
|
||||||
{{ audio.error }}
|
{{ audio.error }}
|
||||||
</div>
|
</div>
|
||||||
<div class="font-sans text-xs font-normal w-40 flex justify-center">
|
<div class="font-sans text-xs font-normal w-40 flex items-center justify-center">
|
||||||
<ui-toggle-switch v-model="audio.include" :off-color="'error'" @input="includeToggled(audio)" />
|
<ui-toggle-switch v-model="audio.include" :off-color="'error'" @input="includeToggled(audio)" />
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -129,6 +131,12 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
showExperimentalFeatures() {
|
||||||
|
return this.$store.state.showExperimentalFeatures
|
||||||
|
},
|
||||||
|
isRootUser() {
|
||||||
|
return this.$store.getters['user/getIsRoot']
|
||||||
|
},
|
||||||
media() {
|
media() {
|
||||||
return this.libraryItem.media || {}
|
return this.libraryItem.media || {}
|
||||||
},
|
},
|
||||||
@@ -162,9 +170,6 @@ export default {
|
|||||||
},
|
},
|
||||||
streamLibraryItem() {
|
streamLibraryItem() {
|
||||||
return this.$store.state.streamLibraryItem
|
return this.$store.state.streamLibraryItem
|
||||||
},
|
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.$store.state.showExperimentalFeatures
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -95,14 +95,16 @@
|
|||||||
<p class="ml-4">Book has no audio tracks but has valid ebook files. The e-reader is experimental and can be turned on in config.</p>
|
<p class="ml-4">Book has no audio tracks but has valid ebook files. The e-reader is experimental and can be turned on in config.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Podcast episode downloads queue -->
|
||||||
<div v-if="episodeDownloadsQueued.length" class="px-4 py-2 mt-4 bg-info bg-opacity-40 text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0">
|
<div v-if="episodeDownloadsQueued.length" class="px-4 py-2 mt-4 bg-info bg-opacity-40 text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<p class="text-sm py-1">{{ episodeDownloadsQueued.length }} Episode{{ episodeDownloadsQueued.length === 1 ? '' : 's' }} queued for download</p>
|
<p class="text-sm py-1">{{ episodeDownloadsQueued.length }} Episode{{ episodeDownloadsQueued.length === 1 ? '' : 's' }} queued for download</p>
|
||||||
|
|
||||||
<span class="material-icons hover:text-error text-xl ml-3 cursor-pointer" @click="clearDownloadQueue">close</span>
|
<span v-if="userIsAdminOrUp" class="material-icons hover:text-error text-xl ml-3 cursor-pointer" @click="clearDownloadQueue">close</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Podcast episodes currently downloading -->
|
||||||
<div v-if="episodesDownloading.length" class="px-4 py-2 mt-4 bg-success bg-opacity-20 text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0">
|
<div v-if="episodesDownloading.length" class="px-4 py-2 mt-4 bg-success bg-opacity-20 text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0">
|
||||||
<div v-for="episode in episodesDownloading" :key="episode.id" class="flex items-center">
|
<div v-for="episode in episodesDownloading" :key="episode.id" class="flex items-center">
|
||||||
<widgets-loading-spinner />
|
<widgets-loading-spinner />
|
||||||
@@ -150,9 +152,15 @@
|
|||||||
<ui-icon-btn icon="collections_bookmark" class="mx-0.5" outlined @click="collectionsClick" />
|
<ui-icon-btn icon="collections_bookmark" class="mx-0.5" outlined @click="collectionsClick" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
<ui-tooltip v-if="isPodcast" text="Find Episodes" direction="top">
|
<!-- Only admin or root user can download new episodes -->
|
||||||
|
<ui-tooltip v-if="isPodcast && userIsAdminOrUp" text="Find Episodes" direction="top">
|
||||||
<ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
|
<ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
|
<!-- Experimental RSS feed open -->
|
||||||
|
<ui-tooltip v-if="showRssFeedBtn" text="Open RSS Feed" direction="top">
|
||||||
|
<ui-icon-btn icon="rss_feed" class="mx-0.5" :bg-color="rssFeedUrl ? 'success' : 'primary'" outlined @click="clickRSSFeed" />
|
||||||
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="my-4 max-w-2xl">
|
<div class="my-4 max-w-2xl">
|
||||||
@@ -175,6 +183,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" />
|
<modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" />
|
||||||
|
<modals-rssfeed-view-modal v-model="showRssFeedModal" :library-item="libraryItem" :feed-url="rssFeedUrl" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -186,7 +195,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Include episode downloads for podcasts
|
// Include episode downloads for podcasts
|
||||||
var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1&include=authors,downloads`).catch((error) => {
|
var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1&include=authors,downloads,rssfeed`).catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
@@ -195,7 +204,8 @@ export default {
|
|||||||
return redirect('/')
|
return redirect('/')
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
libraryItem: item
|
libraryItem: item,
|
||||||
|
rssFeedUrl: item.rssFeedUrl || null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -206,10 +216,14 @@ export default {
|
|||||||
showPodcastEpisodeFeed: false,
|
showPodcastEpisodeFeed: false,
|
||||||
podcastFeedEpisodes: [],
|
podcastFeedEpisodes: [],
|
||||||
episodesDownloading: [],
|
episodesDownloading: [],
|
||||||
episodeDownloadsQueued: []
|
episodeDownloadsQueued: [],
|
||||||
|
showRssFeedModal: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
userIsAdminOrUp() {
|
||||||
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
|
},
|
||||||
isFile() {
|
isFile() {
|
||||||
return this.libraryItem.isFile
|
return this.libraryItem.isFile
|
||||||
},
|
},
|
||||||
@@ -362,6 +376,11 @@ export default {
|
|||||||
},
|
},
|
||||||
userCanDownload() {
|
userCanDownload() {
|
||||||
return this.$store.getters['user/getUserCanDownload']
|
return this.$store.getters['user/getUserCanDownload']
|
||||||
|
},
|
||||||
|
showRssFeedBtn() {
|
||||||
|
if (!this.showExperimentalFeatures) return false
|
||||||
|
// If rss feed is open then show feed url to users otherwise just show to admins
|
||||||
|
return this.isPodcast && (this.userIsAdminOrUp || this.rssFeedUrl)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -472,6 +491,35 @@ export default {
|
|||||||
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||||
this.$store.commit('globals/setShowUserCollectionsModal', true)
|
this.$store.commit('globals/setShowUserCollectionsModal', true)
|
||||||
},
|
},
|
||||||
|
clickRSSFeed() {
|
||||||
|
if (!this.rssFeedUrl) {
|
||||||
|
if (confirm(`Are you sure you want to open an RSS Feed for this podcast?`)) {
|
||||||
|
this.openRSSFeed()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.showRssFeedModal = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openRSSFeed() {
|
||||||
|
const payload = {
|
||||||
|
serverAddress: window.origin
|
||||||
|
}
|
||||||
|
if (this.$isDev) payload.serverAddress = 'http://localhost:3333'
|
||||||
|
|
||||||
|
console.log('Payload', payload)
|
||||||
|
this.$axios
|
||||||
|
.$post(`/api/podcasts/${this.libraryItemId}/open-feed`, payload)
|
||||||
|
.then((data) => {
|
||||||
|
if (data.success) {
|
||||||
|
console.log('Opened RSS Feed', data)
|
||||||
|
this.rssFeedUrl = data.feedUrl
|
||||||
|
this.showRssFeedModal = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to open RSS Feed', error)
|
||||||
|
})
|
||||||
|
},
|
||||||
episodeDownloadQueued(episodeDownload) {
|
episodeDownloadQueued(episodeDownload) {
|
||||||
if (episodeDownload.libraryItemId === this.libraryItemId) {
|
if (episodeDownload.libraryItemId === this.libraryItemId) {
|
||||||
this.episodeDownloadsQueued.push(episodeDownload)
|
this.episodeDownloadsQueued.push(episodeDownload)
|
||||||
@@ -488,6 +536,18 @@ export default {
|
|||||||
this.episodeDownloadsQueued = this.episodeDownloadsQueued.filter((d) => d.id !== episodeDownload.id)
|
this.episodeDownloadsQueued = this.episodeDownloadsQueued.filter((d) => d.id !== episodeDownload.id)
|
||||||
this.episodesDownloading = this.episodesDownloading.filter((d) => d.id !== episodeDownload.id)
|
this.episodesDownloading = this.episodesDownloading.filter((d) => d.id !== episodeDownload.id)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
rssFeedOpen(data) {
|
||||||
|
if (data.libraryItemId === this.libraryItemId) {
|
||||||
|
console.log('RSS Feed Opened', data)
|
||||||
|
this.rssFeedUrl = data.feedUrl
|
||||||
|
}
|
||||||
|
},
|
||||||
|
rssFeedClosed(data) {
|
||||||
|
if (data.libraryItemId === this.libraryItemId) {
|
||||||
|
console.log('RSS Feed Closed', data)
|
||||||
|
this.rssFeedUrl = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
@@ -500,12 +560,16 @@ export default {
|
|||||||
this.$store.commit('libraries/setCurrentLibrary', this.libraryId)
|
this.$store.commit('libraries/setCurrentLibrary', this.libraryId)
|
||||||
}
|
}
|
||||||
this.$root.socket.on('item_updated', this.libraryItemUpdated)
|
this.$root.socket.on('item_updated', this.libraryItemUpdated)
|
||||||
|
this.$root.socket.on('rss_feed_open', this.rssFeedOpen)
|
||||||
|
this.$root.socket.on('rss_feed_closed', this.rssFeedClosed)
|
||||||
this.$root.socket.on('episode_download_queued', this.episodeDownloadQueued)
|
this.$root.socket.on('episode_download_queued', this.episodeDownloadQueued)
|
||||||
this.$root.socket.on('episode_download_started', this.episodeDownloadStarted)
|
this.$root.socket.on('episode_download_started', this.episodeDownloadStarted)
|
||||||
this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished)
|
this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished)
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
||||||
|
this.$root.socket.off('rss_feed_open', this.rssFeedOpen)
|
||||||
|
this.$root.socket.off('rss_feed_closed', this.rssFeedClosed)
|
||||||
this.$root.socket.off('episode_download_queued', this.episodeDownloadQueued)
|
this.$root.socket.off('episode_download_queued', this.episodeDownloadQueued)
|
||||||
this.$root.socket.off('episode_download_started', this.episodeDownloadStarted)
|
this.$root.socket.off('episode_download_started', this.episodeDownloadStarted)
|
||||||
this.$root.socket.off('episode_download_finished', this.episodeDownloadFinished)
|
this.$root.socket.off('episode_download_finished', this.episodeDownloadFinished)
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
<template>
|
||||||
|
<div id="page-wrapper" class="bg-bg page p-8 overflow-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
|
||||||
|
<div class="flex justify-center mb-2">
|
||||||
|
<div class="w-full max-w-2xl">
|
||||||
|
<p class="text-xl">Metadata to embed</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-full max-w-2xl"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-center flex-wrap">
|
||||||
|
<div class="w-full max-w-2xl border border-opacity-10 bg-bg mx-2">
|
||||||
|
<div class="flex py-2 px-4">
|
||||||
|
<div class="w-1/3 text-xs font-semibold uppercase text-gray-200">Meta Tag</div>
|
||||||
|
<div class="w-2/3 text-xs font-semibold uppercase text-gray-200">Value</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full max-h-72 overflow-auto">
|
||||||
|
<template v-for="(keyValue, index) in metadataKeyValues">
|
||||||
|
<div :key="keyValue.key" class="flex py-1 px-4 text-sm" :class="index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''">
|
||||||
|
<div class="w-1/3 font-semibold">{{ keyValue.key }}</div>
|
||||||
|
<div class="w-2/3">
|
||||||
|
{{ keyValue.value }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full max-w-2xl border border-opacity-10 bg-bg mx-2">
|
||||||
|
<div class="flex py-2 px-4">
|
||||||
|
<div class="flex-grow text-xs font-semibold uppercase text-gray-200">Chapter Title</div>
|
||||||
|
<div class="w-24 text-xs font-semibold uppercase text-gray-200">Start</div>
|
||||||
|
<div class="w-24 text-xs font-semibold uppercase text-gray-200">End</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full max-h-72 overflow-auto">
|
||||||
|
<template v-for="(chapter, index) in metadataChapters">
|
||||||
|
<div :key="index" class="flex py-1 px-4 text-sm" :class="index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''">
|
||||||
|
<div class="flex-grow font-semibold">{{ chapter.title }}</div>
|
||||||
|
<div class="w-24">
|
||||||
|
{{ chapter.start.toFixed(2) }}
|
||||||
|
</div>
|
||||||
|
<div class="w-24">
|
||||||
|
{{ chapter.end.toFixed(2) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full h-px bg-white bg-opacity-10 my-8" />
|
||||||
|
|
||||||
|
<div class="w-full max-w-4xl mx-auto">
|
||||||
|
<div class="w-full flex justify-between items-center mb-4">
|
||||||
|
<p class="text-warning text-lg font-semibold">Warning: Modifies your audio files</p>
|
||||||
|
<ui-btn v-if="!embedFinished" color="primary" :loading="updatingMetadata" @click="updateAudioFileMetadata">Embed Metadata</ui-btn>
|
||||||
|
<p v-else class="text-success text-lg font-semibold">Embed Finished!</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-full mx-auto border border-opacity-10 bg-bg">
|
||||||
|
<div class="flex py-2 px-4">
|
||||||
|
<div class="w-10 text-xs font-semibold text-gray-200">#</div>
|
||||||
|
<div class="flex-grow text-xs font-semibold uppercase text-gray-200">Filename</div>
|
||||||
|
<div class="w-16 text-xs font-semibold uppercase text-gray-200">Size</div>
|
||||||
|
<div class="w-24"></div>
|
||||||
|
</div>
|
||||||
|
<template v-for="file in audioFiles">
|
||||||
|
<div :key="file.index" class="flex py-2 px-4 text-sm" :class="file.index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''">
|
||||||
|
<div class="w-10">{{ file.index }}</div>
|
||||||
|
<div class="flex-grow">
|
||||||
|
{{ file.metadata.filename }}
|
||||||
|
</div>
|
||||||
|
<div class="w-16 font-mono text-gray-200">
|
||||||
|
{{ $bytesPretty(file.metadata.size) }}
|
||||||
|
</div>
|
||||||
|
<div class="w-24">
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<span v-if="audiofilesFinished[file.ino]" class="material-icons text-xl text-success leading-none">check_circle</span>
|
||||||
|
<div v-else-if="audiofilesEncoding[file.ino]">
|
||||||
|
<widgets-loading-spinner />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
async asyncData({ store, params, app, redirect, route }) {
|
||||||
|
if (!store.state.user.user) {
|
||||||
|
return redirect(`/login?redirect=${route.path}`)
|
||||||
|
}
|
||||||
|
if (!store.getters['user/getIsAdminOrUp']) {
|
||||||
|
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('/?error=not found')
|
||||||
|
}
|
||||||
|
if (libraryItem.mediaType !== 'book') {
|
||||||
|
console.error('Invalid media type')
|
||||||
|
return redirect('/?error=invalid media type')
|
||||||
|
}
|
||||||
|
if (!libraryItem.media.audioFiles.length) {
|
||||||
|
cnosole.error('No audio files')
|
||||||
|
return redirect('/?error=no audio files')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
libraryItem
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
audiofilesEncoding: {},
|
||||||
|
audiofilesFinished: {},
|
||||||
|
updatingMetadata: false,
|
||||||
|
embedFinished: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
libraryItemId() {
|
||||||
|
return this.libraryItem.id
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.libraryItem.media || {}
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
|
},
|
||||||
|
audioFiles() {
|
||||||
|
return this.media.audioFiles || []
|
||||||
|
},
|
||||||
|
streamLibraryItem() {
|
||||||
|
return this.$store.state.streamLibraryItem
|
||||||
|
},
|
||||||
|
metadataKeyValues() {
|
||||||
|
const keyValues = [
|
||||||
|
{
|
||||||
|
key: 'title',
|
||||||
|
value: this.mediaMetadata.title
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'artist',
|
||||||
|
value: this.mediaMetadata.authorName
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'album_artist',
|
||||||
|
value: this.mediaMetadata.authorName
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'date',
|
||||||
|
value: this.mediaMetadata.publishedYear
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'description',
|
||||||
|
value: this.mediaMetadata.description
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'genre',
|
||||||
|
value: this.mediaMetadata.genres.join(';')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'performer',
|
||||||
|
value: this.mediaMetadata.narratorName
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
if (this.mediaMetadata.subtitle) {
|
||||||
|
keyValues.push({
|
||||||
|
key: 'subtitle',
|
||||||
|
value: this.mediaMetadata.subtitle
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.mediaMetadata.asin) {
|
||||||
|
keyValues.push({
|
||||||
|
key: 'asin',
|
||||||
|
value: this.mediaMetadata.asin
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (this.mediaMetadata.isbn) {
|
||||||
|
keyValues.push({
|
||||||
|
key: 'isbn',
|
||||||
|
value: this.mediaMetadata.isbn
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (this.mediaMetadata.language) {
|
||||||
|
keyValues.push({
|
||||||
|
key: 'language',
|
||||||
|
value: this.mediaMetadata.language
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (this.mediaMetadata.series.length) {
|
||||||
|
var firstSeries = this.mediaMetadata.series[0]
|
||||||
|
keyValues.push({
|
||||||
|
key: 'series',
|
||||||
|
value: firstSeries.name
|
||||||
|
})
|
||||||
|
if (firstSeries.sequence) {
|
||||||
|
keyValues.push({
|
||||||
|
key: 'series-part',
|
||||||
|
value: firstSeries.sequence
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return keyValues
|
||||||
|
},
|
||||||
|
metadataChapters() {
|
||||||
|
var chapters = this.media.chapters || []
|
||||||
|
return chapters.concat(chapters)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
updateAudioFileMetadata() {
|
||||||
|
if (confirm(`Warning!\n\nThis will modify the audio files for this audiobook.\nMake sure your audio files are backed up before using this feature.`)) {
|
||||||
|
this.updatingMetadata = true
|
||||||
|
this.$axios
|
||||||
|
.$get(`/api/items/${this.libraryItemId}/audio-metadata`)
|
||||||
|
.then(() => {
|
||||||
|
console.log('Audio metadata encode started')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Audio metadata encode failed', error)
|
||||||
|
this.updatingMetadata = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
audioMetadataStarted(data) {
|
||||||
|
console.log('audio metadata started', data)
|
||||||
|
if (data.libraryItemId !== this.libraryItemId) return
|
||||||
|
this.audiofilesFinished = {}
|
||||||
|
this.updatingMetadata = true
|
||||||
|
},
|
||||||
|
audioMetadataFinished(data) {
|
||||||
|
console.log('audio metadata finished', data)
|
||||||
|
if (data.libraryItemId !== this.libraryItemId) return
|
||||||
|
this.updatingMetadata = false
|
||||||
|
this.embedFinished = true
|
||||||
|
this.audiofilesEncoding = {}
|
||||||
|
this.$toast.success('Audio file metadata updated')
|
||||||
|
},
|
||||||
|
audiofileMetadataStarted(data) {
|
||||||
|
if (data.libraryItemId !== this.libraryItemId) return
|
||||||
|
this.$set(this.audiofilesEncoding, data.ino, true)
|
||||||
|
},
|
||||||
|
audiofileMetadataFinished(data) {
|
||||||
|
if (data.libraryItemId !== this.libraryItemId) return
|
||||||
|
this.$set(this.audiofilesEncoding, data.ino, false)
|
||||||
|
this.$set(this.audiofilesFinished, data.ino, true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$root.socket.on('audio_metadata_started', this.audioMetadataStarted)
|
||||||
|
this.$root.socket.on('audio_metadata_finished', this.audioMetadataFinished)
|
||||||
|
this.$root.socket.on('audiofile_metadata_started', this.audiofileMetadataStarted)
|
||||||
|
this.$root.socket.on('audiofile_metadata_finished', this.audiofileMetadataFinished)
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.$root.socket.off('audio_metadata_started', this.audioMetadataStarted)
|
||||||
|
this.$root.socket.off('audio_metadata_finished', this.audioMetadataFinished)
|
||||||
|
this.$root.socket.off('audiofile_metadata_started', this.audiofileMetadataStarted)
|
||||||
|
this.$root.socket.off('audiofile_metadata_finished', this.audiofileMetadataFinished)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
+11
-4
@@ -48,8 +48,15 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
setUser(user, defaultLibraryId) {
|
setUser({ user, userDefaultLibraryId, serverSettings }) {
|
||||||
this.$store.commit('libraries/setCurrentLibrary', defaultLibraryId)
|
this.$store.commit('setServerSettings', serverSettings)
|
||||||
|
|
||||||
|
if (serverSettings.chromecastEnabled) {
|
||||||
|
console.log('Chromecast enabled import script')
|
||||||
|
require('@/plugins/chromecast.js').default(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId)
|
||||||
this.$store.commit('user/setUser', user)
|
this.$store.commit('user/setUser', user)
|
||||||
},
|
},
|
||||||
async submitForm() {
|
async submitForm() {
|
||||||
@@ -69,7 +76,7 @@ export default {
|
|||||||
if (authRes && authRes.error) {
|
if (authRes && authRes.error) {
|
||||||
this.error = authRes.error
|
this.error = authRes.error
|
||||||
} else if (authRes) {
|
} else if (authRes) {
|
||||||
this.setUser(authRes.user, authRes.userDefaultLibraryId)
|
this.setUser(authRes)
|
||||||
}
|
}
|
||||||
this.processing = false
|
this.processing = false
|
||||||
},
|
},
|
||||||
@@ -87,7 +94,7 @@ export default {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
this.setUser(res.user, res.userDefaultLibraryId)
|
this.setUser(res)
|
||||||
this.processing = false
|
this.processing = false
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
|||||||
@@ -53,8 +53,8 @@
|
|||||||
</widgets-alert>
|
</widgets-alert>
|
||||||
|
|
||||||
<!-- Item Upload cards -->
|
<!-- Item Upload cards -->
|
||||||
<template v-for="(item, index) in items">
|
<template v-for="item in items">
|
||||||
<cards-item-upload-card :ref="`itemCard-${item.index}`" :key="index" :media-type="selectedLibraryMediaType" :item="item" :processing="processing" @remove="removeItem(item)" />
|
<cards-item-upload-card :ref="`itemCard-${item.index}`" :key="item.index" :media-type="selectedLibraryMediaType" :item="item" :processing="processing" @remove="removeItem(item)" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Upload/Reset btns -->
|
<!-- Upload/Reset btns -->
|
||||||
|
|||||||
@@ -29,6 +29,19 @@ export const getters = {
|
|||||||
var library = state.libraries.find(l => l.id === libraryId)
|
var library = state.libraries.find(l => l.id === libraryId)
|
||||||
if (!library) return null
|
if (!library) return null
|
||||||
return library.provider
|
return library.provider
|
||||||
|
},
|
||||||
|
getNextAccessibleLibrary: (state, getters, rootState, rootGetters) => {
|
||||||
|
var librariesSorted = getters['getSortedLibraries']()
|
||||||
|
if (!librariesSorted.length) return null
|
||||||
|
|
||||||
|
var canAccessAllLibraries = rootGetters['user/getUserCanAccessAllLibraries']
|
||||||
|
var userAccessibleLibraries = rootGetters['user/getLibrariesAccessible']
|
||||||
|
if (canAccessAllLibraries) return librariesSorted[0]
|
||||||
|
librariesSorted = librariesSorted.filter((lib) => {
|
||||||
|
return userAccessibleLibraries.includes(lib.id)
|
||||||
|
})
|
||||||
|
if (!librariesSorted.length) return null
|
||||||
|
return librariesSorted[0]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const state = () => ({
|
|||||||
|
|
||||||
export const getters = {
|
export const getters = {
|
||||||
getIsRoot: (state) => state.user && state.user.type === 'root',
|
getIsRoot: (state) => state.user && state.user.type === 'root',
|
||||||
|
getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'),
|
||||||
getToken: (state) => {
|
getToken: (state) => {
|
||||||
return state.user ? state.user.token : null
|
return state.user ? state.user.token : null
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+81
-3
@@ -1,12 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "1.7.3",
|
"version": "2.0.8",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf",
|
"version": "2.0.8",
|
||||||
"version": "1.7.3",
|
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver": "^5.3.0",
|
"archiver": "^5.3.0",
|
||||||
@@ -26,6 +25,7 @@
|
|||||||
"node-cron": "^3.0.0",
|
"node-cron": "^3.0.0",
|
||||||
"node-ffprobe": "^3.0.0",
|
"node-ffprobe": "^3.0.0",
|
||||||
"node-stream-zip": "^1.15.0",
|
"node-stream-zip": "^1.15.0",
|
||||||
|
"podcast": "^2.0.0",
|
||||||
"proper-lockfile": "^4.1.2",
|
"proper-lockfile": "^4.1.2",
|
||||||
"read-chunk": "^3.1.0",
|
"read-chunk": "^3.1.0",
|
||||||
"recursive-readdir-async": "^1.1.8",
|
"recursive-readdir-async": "^1.1.8",
|
||||||
@@ -1477,6 +1477,14 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/podcast": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/podcast/-/podcast-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-1NZe7cVabfkcMe39yOOhBMJrXC6OROLOIBkfEPayiJL59ncyJmOeO5bxolSCSGroho8jQ0zURMczyICI1U/xbw==",
|
||||||
|
"dependencies": {
|
||||||
|
"rss": "^1.2.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/process-nextick-args": {
|
"node_modules/process-nextick-args": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
@@ -1675,6 +1683,34 @@
|
|||||||
"atomically": "^1.7.0"
|
"atomically": "^1.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rss": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/rss/-/rss-1.2.2.tgz",
|
||||||
|
"integrity": "sha1-UKFpiHYTgTOnT5oF0r3I240nqSE=",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-types": "2.1.13",
|
||||||
|
"xml": "1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/rss/node_modules/mime-db": {
|
||||||
|
"version": "1.25.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.25.0.tgz",
|
||||||
|
"integrity": "sha1-wY29fHOl2/b0SgJNwNFloeexw5I=",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/rss/node_modules/mime-types": {
|
||||||
|
"version": "2.1.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.13.tgz",
|
||||||
|
"integrity": "sha1-4HqqnGxrmnyjASxpADrSWjnpKog=",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "~1.25.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/safe-buffer": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
@@ -2071,6 +2107,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xml": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
|
||||||
|
"integrity": "sha1-eLpyAgApxbyHuKgaPPzXS0ovweU="
|
||||||
|
},
|
||||||
"node_modules/xml2js": {
|
"node_modules/xml2js": {
|
||||||
"version": "0.4.23",
|
"version": "0.4.23",
|
||||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
|
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
|
||||||
@@ -3222,6 +3263,14 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
|
||||||
"integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="
|
"integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="
|
||||||
},
|
},
|
||||||
|
"podcast": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/podcast/-/podcast-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-1NZe7cVabfkcMe39yOOhBMJrXC6OROLOIBkfEPayiJL59ncyJmOeO5bxolSCSGroho8jQ0zURMczyICI1U/xbw==",
|
||||||
|
"requires": {
|
||||||
|
"rss": "^1.2.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"process-nextick-args": {
|
"process-nextick-args": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
@@ -3387,6 +3436,30 @@
|
|||||||
"atomically": "^1.7.0"
|
"atomically": "^1.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"rss": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/rss/-/rss-1.2.2.tgz",
|
||||||
|
"integrity": "sha1-UKFpiHYTgTOnT5oF0r3I240nqSE=",
|
||||||
|
"requires": {
|
||||||
|
"mime-types": "2.1.13",
|
||||||
|
"xml": "1.0.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": {
|
||||||
|
"version": "1.25.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.25.0.tgz",
|
||||||
|
"integrity": "sha1-wY29fHOl2/b0SgJNwNFloeexw5I="
|
||||||
|
},
|
||||||
|
"mime-types": {
|
||||||
|
"version": "2.1.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.13.tgz",
|
||||||
|
"integrity": "sha1-4HqqnGxrmnyjASxpADrSWjnpKog=",
|
||||||
|
"requires": {
|
||||||
|
"mime-db": "~1.25.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"safe-buffer": {
|
"safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
@@ -3690,6 +3763,11 @@
|
|||||||
"integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==",
|
"integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==",
|
||||||
"requires": {}
|
"requires": {}
|
||||||
},
|
},
|
||||||
|
"xml": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
|
||||||
|
"integrity": "sha1-eLpyAgApxbyHuKgaPPzXS0ovweU="
|
||||||
|
},
|
||||||
"xml2js": {
|
"xml2js": {
|
||||||
"version": "0.4.23",
|
"version": "0.4.23",
|
||||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
|
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.0.7",
|
"version": "2.0.9",
|
||||||
"description": "Self-hosted audiobook server for managing and playing audiobooks",
|
"description": "Self-hosted audiobook server for managing and playing audiobooks",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -44,6 +44,7 @@
|
|||||||
"node-cron": "^3.0.0",
|
"node-cron": "^3.0.0",
|
||||||
"node-ffprobe": "^3.0.0",
|
"node-ffprobe": "^3.0.0",
|
||||||
"node-stream-zip": "^1.15.0",
|
"node-stream-zip": "^1.15.0",
|
||||||
|
"podcast": "^2.0.0",
|
||||||
"proper-lockfile": "^4.1.2",
|
"proper-lockfile": "^4.1.2",
|
||||||
"read-chunk": "^3.1.0",
|
"read-chunk": "^3.1.0",
|
||||||
"recursive-readdir-async": "^1.1.8",
|
"recursive-readdir-async": "^1.1.8",
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ docker run -d \
|
|||||||
-e AUDIOBOOKSHELF_GID=100 \
|
-e AUDIOBOOKSHELF_GID=100 \
|
||||||
-p 13378:80 \
|
-p 13378:80 \
|
||||||
-v </path/to/audiobooks>:/audiobooks \
|
-v </path/to/audiobooks>:/audiobooks \
|
||||||
|
-v </path/to/your/podcasts>:/podcasts \
|
||||||
-v </path/to/config>:/config \
|
-v </path/to/config>:/config \
|
||||||
-v </path/to/metadata>:/metadata \
|
-v </path/to/metadata>:/metadata \
|
||||||
--name audiobookshelf \
|
--name audiobookshelf \
|
||||||
@@ -87,19 +88,45 @@ docker start audiobookshelf
|
|||||||
|
|
||||||
### Running with Docker Compose
|
### Running with Docker Compose
|
||||||
|
|
||||||
```bash
|
```yaml
|
||||||
### docker-compose.yml ###
|
### docker-compose.yml ###
|
||||||
services:
|
services:
|
||||||
audiobookshelf:
|
audiobookshelf:
|
||||||
image: ghcr.io/advplyr/audiobookshelf
|
image: ghcr.io/advplyr/audiobookshelf
|
||||||
|
environment:
|
||||||
|
- AUDIOBOOKSHELF_UID=99
|
||||||
|
- AUDIOBOOKSHELF_GID=100
|
||||||
ports:
|
ports:
|
||||||
- 13378:80
|
- 13378:80
|
||||||
volumes:
|
volumes:
|
||||||
- <path/to/your/audiobooks>:/audiobooks
|
- </path/to/your/audiobooks>:/audiobooks
|
||||||
- <path/to/metadata>:/metadata
|
- </path/to/your/podcasts>:/podcasts
|
||||||
- <path/to/config>:/config
|
- </path/to/config>:/config
|
||||||
|
- </path/to/metadata>:/metadata
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Docker Compose Update
|
||||||
|
|
||||||
|
Depending on the version of Docker Compose please run one of the two commands. If not sure on which version you are running you can run the following command and check.
|
||||||
|
|
||||||
|
#### Version Check
|
||||||
|
|
||||||
|
docker-compose --version or docker compose version
|
||||||
|
|
||||||
|
#### v2 Update
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --file <path/to/config>/docker-compose.yml pull
|
||||||
|
docker compose --file <path/to/config>/docker-compose.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
#### V1 Update
|
||||||
|
```bash
|
||||||
|
docker-compose --file <path/to/config>/docker-compose.yml pull
|
||||||
|
docker-compose --file <path/to/config>/docker-compose.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
** We recommend updating the the latest version of Docker Compose
|
||||||
|
|
||||||
### Linux (amd64) Install
|
### Linux (amd64) Install
|
||||||
|
|
||||||
@@ -107,29 +134,15 @@ Debian package will use this config file `/etc/default/audiobookshelf` if exists
|
|||||||
|
|
||||||
### Ubuntu Install via PPA
|
### Ubuntu Install via PPA
|
||||||
|
|
||||||
A PPA is hosted on [github](https://github.com/advplyr/audiobookshelf-ppa), add and install:
|
A PPA is hosted on [github](https://github.com/advplyr/audiobookshelf-ppa)
|
||||||
|
|
||||||
```bash
|
See [install docs](https://www.audiobookshelf.org/install/#ubuntu)
|
||||||
curl -s --compressed "https://advplyr.github.io/audiobookshelf-ppa/KEY.gpg" | sudo apt-key add -
|
|
||||||
|
|
||||||
sudo curl -s --compressed -o /etc/apt/sources.list.d/audiobookshelf.list "https://advplyr.github.io/audiobookshelf-ppa/audiobookshelf.list"
|
|
||||||
|
|
||||||
sudo apt update
|
|
||||||
|
|
||||||
sudo apt install audiobookshelf
|
|
||||||
```
|
|
||||||
|
|
||||||
or use a single command
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s --compressed "https://advplyr.github.io/audiobookshelf-ppa/KEY.gpg" | sudo apt-key add - && sudo curl -s --compressed -o /etc/apt/sources.list.d/audiobookshelf.list "https://advplyr.github.io/audiobookshelf-ppa/audiobookshelf.list" && sudo apt update && sudo apt install audiobookshelf
|
|
||||||
```
|
|
||||||
|
|
||||||
### Install via debian package
|
### Install via debian package
|
||||||
|
|
||||||
Get the `deb` file from the [github repo](https://github.com/advplyr/audiobookshelf-ppa).
|
Get the `deb` file from the [github repo](https://github.com/advplyr/audiobookshelf-ppa).
|
||||||
|
|
||||||
See [instructions](https://www.audiobookshelf.org/install#debian)
|
See [install docs](https://www.audiobookshelf.org/install#debian)
|
||||||
|
|
||||||
|
|
||||||
#### Linux file locations
|
#### Linux file locations
|
||||||
@@ -235,6 +248,17 @@ For this to work you must enable at least the following mods using `a2enmod`:
|
|||||||
|
|
||||||
[from @silentArtifact](https://github.com/advplyr/audiobookshelf/issues/241#issuecomment-1036732329)
|
[from @silentArtifact](https://github.com/advplyr/audiobookshelf/issues/241#issuecomment-1036732329)
|
||||||
|
|
||||||
|
### [Traefik Reverse Proxy](https://doc.traefik.io/traefik/)
|
||||||
|
|
||||||
|
Middleware relating to CORS will cause the app to report Unknown Error when logging in. To prevent this don't apply any of the following headers to the router for this site:
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>accessControlAllowMethods</li>
|
||||||
|
<li>accessControlAllowOriginList</li>
|
||||||
|
<li>accessControlMaxAge</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
From [@Dondochaka](https://discord.com/channels/942908292873723984/942914154254176257/945074590374318170) and [@BeastleeUK](https://discord.com/channels/942908292873723984/942914154254176257/970366039294611506)
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
# Run from source
|
# Run from source
|
||||||
|
|||||||
+10
-5
@@ -100,6 +100,14 @@ class Auth {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getUserLoginResponsePayload(user) {
|
||||||
|
return {
|
||||||
|
user: user.toJSONForBrowser(),
|
||||||
|
userDefaultLibraryId: user.getDefaultLibraryId(this.db.libraries),
|
||||||
|
serverSettings: this.db.serverSettings.toJSON()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async login(req, res) {
|
async login(req, res) {
|
||||||
var username = (req.body.username || '').toLowerCase()
|
var username = (req.body.username || '').toLowerCase()
|
||||||
var password = req.body.password || ''
|
var password = req.body.password || ''
|
||||||
@@ -120,17 +128,14 @@ class Auth {
|
|||||||
if (password) {
|
if (password) {
|
||||||
return res.status(401).send('Invalid root password (hint: there is none)')
|
return res.status(401).send('Invalid root password (hint: there is none)')
|
||||||
} else {
|
} else {
|
||||||
return res.json({ user: user.toJSONForBrowser(), userDefaultLibraryId: user.getDefaultLibraryId(this.db.libraries) })
|
return res.json(this.getUserLoginResponsePayload(user))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check password match
|
// Check password match
|
||||||
var compare = await bcrypt.compare(password, user.pash)
|
var compare = await bcrypt.compare(password, user.pash)
|
||||||
if (compare) {
|
if (compare) {
|
||||||
res.json({
|
res.json(this.getUserLoginResponsePayload(user))
|
||||||
user: user.toJSONForBrowser(),
|
|
||||||
userDefaultLibraryId: user.getDefaultLibraryId(this.db.libraries)
|
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
Logger.debug(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit}`)
|
Logger.debug(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit}`)
|
||||||
if (req.rateLimit.remaining <= 2) {
|
if (req.rateLimit.remaining <= 2) {
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
// const Podcast = require('podcast')
|
|
||||||
const express = require('express')
|
|
||||||
// const ip = require('ip')
|
|
||||||
const Logger = require('./Logger')
|
|
||||||
|
|
||||||
// Not functional at the moment - just an idea
|
|
||||||
class RssFeeds {
|
|
||||||
constructor(Port, db) {
|
|
||||||
this.Port = Port
|
|
||||||
this.db = db
|
|
||||||
this.feeds = {}
|
|
||||||
|
|
||||||
this.router = express()
|
|
||||||
this.init()
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
this.router.get('/:id', this.getFeed.bind(this))
|
|
||||||
}
|
|
||||||
|
|
||||||
getFeed(req, res) {
|
|
||||||
Logger.info('Get Feed', req.params.id, this.feeds[req.params.id])
|
|
||||||
|
|
||||||
var feed = this.feeds[req.params.id]
|
|
||||||
if (!feed) return null
|
|
||||||
var xml = feed.buildXml()
|
|
||||||
res.set('Content-Type', 'text/xml')
|
|
||||||
res.send(xml)
|
|
||||||
}
|
|
||||||
|
|
||||||
openFeed(audiobook) {
|
|
||||||
// Removed Podcast npm package and ip package
|
|
||||||
return null
|
|
||||||
// var ipAddress = ip.address('public', 'ipv4')
|
|
||||||
// var serverAddress = 'http://' + ipAddress + ':' + this.Port
|
|
||||||
// Logger.info('Open RSS Feed', 'Server address', serverAddress)
|
|
||||||
|
|
||||||
// var feedId = (Date.now() + Math.floor(Math.random() * 1000)).toString(36)
|
|
||||||
// const feed = new Podcast({
|
|
||||||
// title: audiobook.title,
|
|
||||||
// description: 'AudioBookshelf RSS Feed',
|
|
||||||
// feed_url: `${serverAddress}/feeds/${feedId}`,
|
|
||||||
// image_url: `${serverAddress}/Logo.png`,
|
|
||||||
// author: 'advplyr',
|
|
||||||
// language: 'en'
|
|
||||||
// })
|
|
||||||
// audiobook.tracks.forEach((track) => {
|
|
||||||
// feed.addItem({
|
|
||||||
// title: `Track ${track.index}`,
|
|
||||||
// description: `AudioBookshelf Audiobook Track #${track.index}`,
|
|
||||||
// url: `${serverAddress}/feeds/${feedId}?track=${track.index}`,
|
|
||||||
// author: 'advplyr'
|
|
||||||
// })
|
|
||||||
// })
|
|
||||||
// this.feeds[feedId] = feed
|
|
||||||
// return feed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
module.exports = RssFeeds
|
|
||||||
+19
-1
@@ -30,6 +30,8 @@ const LogManager = require('./managers/LogManager')
|
|||||||
const BackupManager = require('./managers/BackupManager')
|
const BackupManager = require('./managers/BackupManager')
|
||||||
const PlaybackSessionManager = require('./managers/PlaybackSessionManager')
|
const PlaybackSessionManager = require('./managers/PlaybackSessionManager')
|
||||||
const PodcastManager = require('./managers/PodcastManager')
|
const PodcastManager = require('./managers/PodcastManager')
|
||||||
|
const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
|
||||||
|
const RssFeedManager = require('./managers/RssFeedManager')
|
||||||
|
|
||||||
class Server {
|
class Server {
|
||||||
constructor(PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) {
|
constructor(PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) {
|
||||||
@@ -72,11 +74,13 @@ class Server {
|
|||||||
this.playbackSessionManager = new PlaybackSessionManager(this.db, this.emitter.bind(this), this.clientEmitter.bind(this))
|
this.playbackSessionManager = new PlaybackSessionManager(this.db, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||||
this.coverManager = new CoverManager(this.db, this.cacheManager)
|
this.coverManager = new CoverManager(this.db, this.cacheManager)
|
||||||
this.podcastManager = new PodcastManager(this.db, this.watcher, this.emitter.bind(this))
|
this.podcastManager = new PodcastManager(this.db, this.watcher, this.emitter.bind(this))
|
||||||
|
this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||||
|
this.rssFeedManager = new RssFeedManager(this.db, this.emitter.bind(this))
|
||||||
|
|
||||||
this.scanner = new Scanner(this.db, this.coverManager, this.emitter.bind(this))
|
this.scanner = new Scanner(this.db, this.coverManager, this.emitter.bind(this))
|
||||||
|
|
||||||
// Routers
|
// Routers
|
||||||
this.apiRouter = new ApiRouter(this.db, this.auth, this.scanner, this.playbackSessionManager, this.abMergeManager, this.coverManager, this.backupManager, this.watcher, this.cacheManager, this.podcastManager, this.emitter.bind(this), this.clientEmitter.bind(this))
|
this.apiRouter = new ApiRouter(this.db, this.auth, this.scanner, this.playbackSessionManager, this.abMergeManager, this.coverManager, this.backupManager, this.watcher, this.cacheManager, this.podcastManager, this.audioMetadataManager, this.rssFeedManager, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||||
this.hlsRouter = new HlsRouter(this.db, this.auth, this.playbackSessionManager, this.emitter.bind(this))
|
this.hlsRouter = new HlsRouter(this.db, this.auth, this.playbackSessionManager, this.emitter.bind(this))
|
||||||
this.staticRouter = new StaticRouter(this.db)
|
this.staticRouter = new StaticRouter(this.db)
|
||||||
|
|
||||||
@@ -196,6 +200,19 @@ class Server {
|
|||||||
res.sendFile(fullPath)
|
res.sendFile(fullPath)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// RSS Feed temp route
|
||||||
|
app.get('/feed/:id', (req, res) => {
|
||||||
|
Logger.info(`[Server] requesting rss feed ${req.params.id}`)
|
||||||
|
this.rssFeedManager.getFeed(req, res)
|
||||||
|
})
|
||||||
|
app.get('/feed/:id/cover', (req, res) => {
|
||||||
|
this.rssFeedManager.getFeedCover(req, res)
|
||||||
|
})
|
||||||
|
app.get('/feed/:id/item/*', (req, res) => {
|
||||||
|
Logger.info(`[Server] requesting rss feed ${req.params.id}`)
|
||||||
|
this.rssFeedManager.getFeedItem(req, res)
|
||||||
|
})
|
||||||
|
|
||||||
// Client dynamic routes
|
// Client dynamic routes
|
||||||
app.get('/item/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
app.get('/item/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
||||||
app.get('/audiobook/:id/edit', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
app.get('/audiobook/:id/edit', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
||||||
@@ -409,6 +426,7 @@ class Server {
|
|||||||
await this.db.updateEntity('user', user)
|
await this.db.updateEntity('user', user)
|
||||||
|
|
||||||
const initialPayload = {
|
const initialPayload = {
|
||||||
|
// 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,
|
audiobookPath: global.AudiobookPath,
|
||||||
metadataPath: global.MetadataPath,
|
metadataPath: global.MetadataPath,
|
||||||
|
|||||||
@@ -201,7 +201,6 @@ class LibraryController {
|
|||||||
libraryItems = naturalSort(libraryItems).by(sortArray)
|
libraryItems = naturalSort(libraryItems).by(sortArray)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Potentially implement collapse series again
|
|
||||||
if (payload.collapseseries) {
|
if (payload.collapseseries) {
|
||||||
libraryItems = libraryHelpers.collapseBookSeries(libraryItems)
|
libraryItems = libraryHelpers.collapseBookSeries(libraryItems)
|
||||||
payload.total = libraryItems.length
|
payload.total = libraryItems.length
|
||||||
@@ -319,102 +318,6 @@ class LibraryController {
|
|||||||
res.json(categories)
|
res.json(categories)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Remove old personalized function with all its helper functions
|
|
||||||
// old personalized function looped through the library items many times
|
|
||||||
// api/libraries/:id/personalized-old
|
|
||||||
async getLibraryUserPersonalized(req, res) {
|
|
||||||
var mediaType = req.library.mediaType
|
|
||||||
var isPodcastLibrary = mediaType == 'podcast'
|
|
||||||
var libraryItems = req.libraryItems
|
|
||||||
var limitPerShelf = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12
|
|
||||||
var minified = req.query.minified == '1'
|
|
||||||
|
|
||||||
var itemsWithUserProgress = libraryHelpers.getMediaProgressWithItems(req.user, libraryItems)
|
|
||||||
var categories = [
|
|
||||||
{
|
|
||||||
id: 'continue-listening',
|
|
||||||
label: 'Continue Listening',
|
|
||||||
type: isPodcastLibrary ? 'episode' : req.library.mediaType,
|
|
||||||
entities: libraryHelpers.getItemsMostRecentlyListened(itemsWithUserProgress, limitPerShelf, minified)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'recently-added',
|
|
||||||
label: 'Recently Added',
|
|
||||||
type: req.library.mediaType,
|
|
||||||
entities: libraryHelpers.getItemsMostRecentlyAdded(libraryItems, limitPerShelf, minified)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'listen-again',
|
|
||||||
label: 'Listen Again',
|
|
||||||
type: isPodcastLibrary ? 'episode' : req.library.mediaType,
|
|
||||||
entities: libraryHelpers.getItemsMostRecentlyFinished(itemsWithUserProgress, limitPerShelf, minified)
|
|
||||||
}
|
|
||||||
].filter(cats => { // Remove categories with no items
|
|
||||||
return cats.entities.length
|
|
||||||
})
|
|
||||||
|
|
||||||
// New Series section
|
|
||||||
// TODO: optimize and move to libraryHelpers
|
|
||||||
if (!isPodcastLibrary) {
|
|
||||||
var series = this.db.series.map(se => {
|
|
||||||
var books = libraryItems.filter(li => li.media.metadata.hasSeries(se.id))
|
|
||||||
if (!books.length) return null
|
|
||||||
books = books.map(b => {
|
|
||||||
var json = b.toJSONMinified()
|
|
||||||
json.sequence = b.media.metadata.getSeriesSequence(se.id)
|
|
||||||
return json
|
|
||||||
})
|
|
||||||
books = naturalSort(books).asc(b => b.sequence)
|
|
||||||
return {
|
|
||||||
id: se.id,
|
|
||||||
name: se.name,
|
|
||||||
type: 'series',
|
|
||||||
addedAt: se.addedAt,
|
|
||||||
books
|
|
||||||
}
|
|
||||||
}).filter(se => se).sort((a, b) => a.addedAt - b.addedAt).slice(0, 5)
|
|
||||||
|
|
||||||
if (series.length) {
|
|
||||||
categories.push({
|
|
||||||
id: 'recent-series',
|
|
||||||
label: 'Recent Series',
|
|
||||||
type: 'series',
|
|
||||||
entities: series
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var authors = this.db.authors.map(author => {
|
|
||||||
var books = libraryItems.filter(li => li.media.metadata.hasAuthor(author.id))
|
|
||||||
if (!books.length) return null
|
|
||||||
// books = books.map(b => b.toJSONMinified())
|
|
||||||
return {
|
|
||||||
...author.toJSON(),
|
|
||||||
numBooks: books.length
|
|
||||||
}
|
|
||||||
}).filter(au => au).sort((a, b) => a.addedAt - b.addedAt).slice(0, 10)
|
|
||||||
if (authors.length) {
|
|
||||||
categories.push({
|
|
||||||
id: 'newest-authors',
|
|
||||||
label: 'Newest Authors',
|
|
||||||
type: 'authors',
|
|
||||||
entities: authors
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
var episodesRecentlyAdded = libraryHelpers.getEpisodesRecentlyAdded(libraryItems, limitPerShelf, minified)
|
|
||||||
if (episodesRecentlyAdded.length) {
|
|
||||||
categories.splice(1, 0, {
|
|
||||||
id: 'episodes-recently-added',
|
|
||||||
label: 'Newest Episodes',
|
|
||||||
type: 'episode',
|
|
||||||
entities: episodesRecentlyAdded
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(categories)
|
|
||||||
}
|
|
||||||
|
|
||||||
// PATCH: Change the order of libraries
|
// PATCH: Change the order of libraries
|
||||||
async reorder(req, res) {
|
async reorder(req, res) {
|
||||||
if (!req.user.isRoot) {
|
if (!req.user.isRoot) {
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ class LibraryItemController {
|
|||||||
item.userMediaProgress = req.user.getMediaProgress(item.id, episodeId)
|
item.userMediaProgress = req.user.getMediaProgress(item.id, episodeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (includeEntities.includes('rssfeed')) {
|
||||||
|
var feedData = this.rssFeedManager.findFeedForItem(item.id)
|
||||||
|
item.rssFeedUrl = feedData ? feedData.feedUrl : null
|
||||||
|
}
|
||||||
|
|
||||||
if (item.mediaType == 'book') {
|
if (item.mediaType == 'book') {
|
||||||
if (includeEntities.includes('authors')) {
|
if (includeEntities.includes('authors')) {
|
||||||
item.media.metadata.authors = item.media.metadata.authors.map(au => {
|
item.media.metadata.authors = item.media.metadata.authors.map(au => {
|
||||||
@@ -354,6 +359,22 @@ class LibraryItemController {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST: api/items/:id/audio-metadata
|
||||||
|
async updateAudioFileMetadata(req, res) {
|
||||||
|
if (!req.user.isRoot) {
|
||||||
|
Logger.error(`[LibraryItemController] Non-root user attempted to update audio metadata`, req.user)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
|
||||||
|
Logger.error(`[LibraryItemController] Invalid library item`)
|
||||||
|
return res.sendStatus(500)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.audioMetadataManager.updateAudioFileMetadataForItem(req.user, req.libraryItem)
|
||||||
|
res.sendStatus(200)
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@@ -133,6 +133,10 @@ class MeController {
|
|||||||
|
|
||||||
// PATCH: api/me/password
|
// PATCH: api/me/password
|
||||||
updatePassword(req, res) {
|
updatePassword(req, res) {
|
||||||
|
if (req.user.isGuest) {
|
||||||
|
Logger.error(`[MeController] Guest user attempted to change password`, req.user.username)
|
||||||
|
return res.sendStatus(500)
|
||||||
|
}
|
||||||
this.auth.userChangePassword(req, res)
|
this.auth.userChangePassword(req, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -230,7 +230,12 @@ class MiscController {
|
|||||||
Logger.error('Invalid user in authorize')
|
Logger.error('Invalid user in authorize')
|
||||||
return res.sendStatus(401)
|
return res.sendStatus(401)
|
||||||
}
|
}
|
||||||
res.json({ user: req.user, userDefaultLibraryId: req.user.getDefaultLibraryId(this.db.libraries) })
|
const userResponse = {
|
||||||
|
user: req.user,
|
||||||
|
userDefaultLibraryId: req.user.getDefaultLibraryId(this.db.libraries),
|
||||||
|
serverSettings: this.db.serverSettings.toJSON()
|
||||||
|
}
|
||||||
|
res.json(userResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
getAllTags(req, res) {
|
getAllTags(req, res) {
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ const filePerms = require('../utils/filePerms')
|
|||||||
class PodcastController {
|
class PodcastController {
|
||||||
|
|
||||||
async create(req, res) {
|
async create(req, res) {
|
||||||
if (!req.user.isRoot) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error(`[PodcastController] Non-root user attempted to create podcast`, req.user)
|
Logger.error(`[PodcastController] Non-admin user attempted to create podcast`, req.user)
|
||||||
return res.sendStatus(500)
|
return res.sendStatus(500)
|
||||||
}
|
}
|
||||||
const payload = req.body
|
const payload = req.body
|
||||||
@@ -115,24 +115,26 @@ class PodcastController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async checkNewEpisodes(req, res) {
|
async checkNewEpisodes(req, res) {
|
||||||
var libraryItem = this.db.getLibraryItem(req.params.id)
|
if (!req.user.isAdminOrUp) {
|
||||||
if (!libraryItem || libraryItem.mediaType !== 'podcast') {
|
Logger.error(`[PodcastController] Non-admin user attempted to check/download episodes`, req.user)
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(500)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var libraryItem = req.libraryItem
|
||||||
if (!libraryItem.media.metadata.feedUrl) {
|
if (!libraryItem.media.metadata.feedUrl) {
|
||||||
Logger.error(`[PodcastController] checkNewEpisodes no feed url for item ${libraryItem.id}`)
|
Logger.error(`[PodcastController] checkNewEpisodes no feed url for item ${libraryItem.id}`)
|
||||||
return res.status(500).send('Podcast has no rss feed url')
|
return res.status(500).send('Podcast has no rss feed url')
|
||||||
}
|
}
|
||||||
|
|
||||||
var newEpisodes = await this.podcastManager.checkPodcastForNewEpisodes(libraryItem)
|
var newEpisodes = await this.podcastManager.checkAndDownloadNewEpisodes(libraryItem)
|
||||||
res.json({
|
res.json({
|
||||||
episodes: newEpisodes || []
|
episodes: newEpisodes || []
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
clearEpisodeDownloadQueue(req, res) {
|
clearEpisodeDownloadQueue(req, res) {
|
||||||
if (!req.user.canUpdate) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error(`[PodcastController] User attempting to clear download queue without permission "${req.user.username}"`)
|
Logger.error(`[PodcastController] Non-admin user attempting to clear download queue "${req.user.username}"`)
|
||||||
return res.sendStatus(500)
|
return res.sendStatus(500)
|
||||||
}
|
}
|
||||||
this.podcastManager.clearDownloadQueue(req.params.id)
|
this.podcastManager.clearDownloadQueue(req.params.id)
|
||||||
@@ -140,10 +142,8 @@ class PodcastController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getEpisodeDownloads(req, res) {
|
getEpisodeDownloads(req, res) {
|
||||||
var libraryItem = this.db.getLibraryItem(req.params.id)
|
var libraryItem = req.libraryItem
|
||||||
if (!libraryItem || libraryItem.mediaType !== 'podcast') {
|
|
||||||
return res.sendStatus(404)
|
|
||||||
}
|
|
||||||
var downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(libraryItem.id)
|
var downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(libraryItem.id)
|
||||||
res.json({
|
res.json({
|
||||||
downloads: downloadsInQueue.map(d => d.toJSONForClient())
|
downloads: downloadsInQueue.map(d => d.toJSONForClient())
|
||||||
@@ -151,13 +151,11 @@ class PodcastController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async downloadEpisodes(req, res) {
|
async downloadEpisodes(req, res) {
|
||||||
var libraryItem = this.db.getLibraryItem(req.params.id)
|
if (!req.user.isAdminOrUp) {
|
||||||
if (!libraryItem || libraryItem.mediaType !== 'podcast') {
|
Logger.error(`[PodcastController] Non-admin user attempted to download episodes`, req.user)
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(500)
|
||||||
}
|
|
||||||
if (!req.user.canUpload || !req.user.checkCanAccessLibrary(libraryItem.libraryId)) {
|
|
||||||
return res.sendStatus(404)
|
|
||||||
}
|
}
|
||||||
|
var libraryItem = req.libraryItem
|
||||||
|
|
||||||
var episodes = req.body
|
var episodes = req.body
|
||||||
if (!episodes || !episodes.length) {
|
if (!episodes || !episodes.length) {
|
||||||
@@ -168,14 +166,33 @@ class PodcastController {
|
|||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async openPodcastFeed(req, res) {
|
||||||
|
if (!req.user.isAdminOrUp) {
|
||||||
|
Logger.error(`[PodcastController] Non-admin user attempted to open podcast feed`, req.user.username)
|
||||||
|
return res.sendStatus(500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const feedData = this.rssFeedManager.openPodcastFeed(req.user, req.libraryItem, req.body)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
feedUrl: feedData.feedUrl
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async closePodcastFeed(req, res) {
|
||||||
|
if (!req.user.isAdminOrUp) {
|
||||||
|
Logger.error(`[PodcastController] Non-admin user attempted to close podcast feed`, req.user.username)
|
||||||
|
return res.sendStatus(500)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.rssFeedManager.closePodcastFeedForItem(req.params.id)
|
||||||
|
|
||||||
|
res.sendStatus(200)
|
||||||
|
}
|
||||||
|
|
||||||
async updateEpisode(req, res) {
|
async updateEpisode(req, res) {
|
||||||
var libraryItem = this.db.getLibraryItem(req.params.id)
|
var libraryItem = req.libraryItem
|
||||||
if (!libraryItem || libraryItem.mediaType !== 'podcast') {
|
|
||||||
return res.sendStatus(404)
|
|
||||||
}
|
|
||||||
if (!req.user.canUpload || !req.user.checkCanAccessLibrary(libraryItem.libraryId)) {
|
|
||||||
return res.sendStatus(404)
|
|
||||||
}
|
|
||||||
|
|
||||||
var episodeId = req.params.episodeId
|
var episodeId = req.params.episodeId
|
||||||
if (!libraryItem.media.checkHasEpisode(episodeId)) {
|
if (!libraryItem.media.checkHasEpisode(episodeId)) {
|
||||||
@@ -190,5 +207,35 @@ class PodcastController {
|
|||||||
|
|
||||||
res.json(libraryItem.toJSONExpanded())
|
res.json(libraryItem.toJSONExpanded())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
middleware(req, res, next) {
|
||||||
|
var item = this.db.libraryItems.find(li => li.id === req.params.id)
|
||||||
|
if (!item || !item.media) return res.sendStatus(404)
|
||||||
|
|
||||||
|
if (!item.isPodcast) {
|
||||||
|
return res.sendStatus(500)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check user can access this library
|
||||||
|
if (!req.user.checkCanAccessLibrary(item.libraryId)) {
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check user can access this library item
|
||||||
|
if (!req.user.checkCanAccessLibraryItemWithTags(item.media.tags)) {
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method == 'DELETE' && !req.user.canDelete) {
|
||||||
|
Logger.warn(`[PodcastController] User attempted to delete without permission`, req.user.username)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
} else if ((req.method == 'PATCH' || req.method == 'POST') && !req.user.canUpdate) {
|
||||||
|
Logger.warn('[PodcastController] User attempted to update without permission', req.user.username)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.libraryItem = item
|
||||||
|
next()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
module.exports = new PodcastController()
|
module.exports = new PodcastController()
|
||||||
@@ -121,7 +121,7 @@ class AbMergeManager {
|
|||||||
'-acodec aac',
|
'-acodec aac',
|
||||||
'-ac 2',
|
'-ac 2',
|
||||||
'-b:a 64k',
|
'-b:a 64k',
|
||||||
'-id3v2_version 3'
|
'-movflags use_metadata_tags'
|
||||||
])
|
])
|
||||||
} else {
|
} else {
|
||||||
ffmpegOptions.push('-max_muxing_queue_size 1000')
|
ffmpegOptions.push('-max_muxing_queue_size 1000')
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
const Path = require('path')
|
||||||
|
const fs = require('fs-extra')
|
||||||
|
const workerThreads = require('worker_threads')
|
||||||
|
const Logger = require('../Logger')
|
||||||
|
const filePerms = require('../utils/filePerms')
|
||||||
|
const { secondsToTimestamp } = require('../utils/index')
|
||||||
|
const { writeMetadataFile } = require('../utils/ffmpegHelpers')
|
||||||
|
|
||||||
|
class AudioMetadataMangaer {
|
||||||
|
constructor(db, emitter, clientEmitter) {
|
||||||
|
this.db = db
|
||||||
|
this.emitter = emitter
|
||||||
|
this.clientEmitter = clientEmitter
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAudioFileMetadataForItem(user, libraryItem) {
|
||||||
|
var audioFiles = libraryItem.media.audioFiles
|
||||||
|
|
||||||
|
const itemAudioMetadataPayload = {
|
||||||
|
userId: user.id,
|
||||||
|
libraryItemId: libraryItem.id,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
audioFiles: audioFiles.map(af => ({ index: af.index, ino: af.ino, filename: af.metadata.filename }))
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emitter('audio_metadata_started', itemAudioMetadataPayload)
|
||||||
|
|
||||||
|
var downloadsPath = Path.join(global.MetadataPath, 'downloads')
|
||||||
|
var outputDir = Path.join(downloadsPath, libraryItem.id)
|
||||||
|
await fs.ensureDir(outputDir)
|
||||||
|
|
||||||
|
var metadataFilePath = Path.join(outputDir, 'metadata.txt')
|
||||||
|
await writeMetadataFile(libraryItem, metadataFilePath)
|
||||||
|
|
||||||
|
// TODO: Split into batches
|
||||||
|
const proms = audioFiles.map(af => {
|
||||||
|
return this.updateAudioFileMetadata(libraryItem.id, af, outputDir, metadataFilePath)
|
||||||
|
})
|
||||||
|
|
||||||
|
const results = await Promise.all(proms)
|
||||||
|
|
||||||
|
Logger.debug(`[AudioMetadataManager] Finished`)
|
||||||
|
|
||||||
|
await fs.remove(outputDir)
|
||||||
|
|
||||||
|
const elapsed = Date.now() - itemAudioMetadataPayload.startedAt
|
||||||
|
Logger.debug(`[AudioMetadataManager] Elapsed ${secondsToTimestamp(elapsed)}`)
|
||||||
|
itemAudioMetadataPayload.results = results
|
||||||
|
itemAudioMetadataPayload.elapsed = elapsed
|
||||||
|
itemAudioMetadataPayload.finishedAt = Date.now()
|
||||||
|
this.emitter('audio_metadata_finished', itemAudioMetadataPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAudioFileMetadata(libraryItemId, audioFile, outputDir, metadataFilePath) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const resultPayload = {
|
||||||
|
libraryItemId,
|
||||||
|
index: audioFile.index,
|
||||||
|
ino: audioFile.ino,
|
||||||
|
filename: audioFile.metadata.filename
|
||||||
|
}
|
||||||
|
this.emitter('audiofile_metadata_started', resultPayload)
|
||||||
|
|
||||||
|
Logger.debug(`[AudioFileMetadataManager] Starting audio file metadata encode for "${audioFile.metadata.filename}"`)
|
||||||
|
|
||||||
|
var outputPath = Path.join(outputDir, audioFile.metadata.filename)
|
||||||
|
var inputPath = audioFile.metadata.path
|
||||||
|
const isM4b = audioFile.metadata.format === 'm4b'
|
||||||
|
const ffmpegInputs = [
|
||||||
|
{
|
||||||
|
input: inputPath,
|
||||||
|
options: isM4b ? ['-f mp4'] : []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: metadataFilePath
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
/*
|
||||||
|
Mp4 doesnt support writing custom tags by default. Supported tags are itunes tags: https://git.videolan.org/?p=ffmpeg.git;a=blob;f=libavformat/movenc.c;h=b6821d447c92183101086cb67099b2f4804293de;hb=HEAD#l2905
|
||||||
|
|
||||||
|
Workaround -movflags use_metadata_tags found here: https://superuser.com/a/1208277
|
||||||
|
|
||||||
|
Ffmpeg premapped id3 tags: https://wiki.multimedia.cx/index.php/FFmpeg_Metadata
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ffmpegOptions = ['-c copy', '-map_metadata 1', `-metadata track=${audioFile.index}`, '-write_id3v2 1', '-movflags use_metadata_tags']
|
||||||
|
var workerData = {
|
||||||
|
inputs: ffmpegInputs,
|
||||||
|
options: ffmpegOptions,
|
||||||
|
outputOptions: isM4b ? ['-f mp4'] : [],
|
||||||
|
output: outputPath,
|
||||||
|
}
|
||||||
|
var workerPath = Path.join(global.appRoot, 'server/utils/downloadWorker.js')
|
||||||
|
var worker = new workerThreads.Worker(workerPath, { workerData })
|
||||||
|
|
||||||
|
worker.on('message', async (message) => {
|
||||||
|
if (message != null && typeof message === 'object') {
|
||||||
|
if (message.type === 'RESULT') {
|
||||||
|
Logger.debug(message)
|
||||||
|
|
||||||
|
if (message.success) {
|
||||||
|
Logger.debug(`[AudioFileMetadataManager] Metadata encode SUCCESS for "${audioFile.metadata.filename}"`)
|
||||||
|
|
||||||
|
await filePerms.setDefault(outputPath, true)
|
||||||
|
|
||||||
|
fs.move(outputPath, inputPath, { overwrite: true }).then(() => {
|
||||||
|
Logger.debug(`[AudioFileMetadataManager] Audio file replaced successfully "${inputPath}"`)
|
||||||
|
|
||||||
|
resultPayload.success = true
|
||||||
|
this.emitter('audiofile_metadata_finished', resultPayload)
|
||||||
|
resolve(resultPayload)
|
||||||
|
}).catch((error) => {
|
||||||
|
Logger.error(`[AudioFileMetadataManager] Audio file failed to move "${inputPath}"`, error)
|
||||||
|
resultPayload.success = false
|
||||||
|
this.emitter('audiofile_metadata_finished', resultPayload)
|
||||||
|
resolve(resultPayload)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Logger.debug(`[AudioFileMetadataManager] Metadata encode FAILED for "${audioFile.metadata.filename}"`)
|
||||||
|
|
||||||
|
resultPayload.success = false
|
||||||
|
this.emitter('audiofile_metadata_finished', resultPayload)
|
||||||
|
resolve(resultPayload)
|
||||||
|
}
|
||||||
|
} else if (message.type === 'FFMPEG') {
|
||||||
|
if (message.level === 'debug' && process.env.NODE_ENV === 'production') {
|
||||||
|
// stderr is not necessary in production
|
||||||
|
} else if (Logger[message.level]) {
|
||||||
|
Logger[message.level](message.log)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Logger.error('Invalid worker message', message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = AudioMetadataMangaer
|
||||||
@@ -22,6 +22,7 @@ class PodcastManager {
|
|||||||
this.currentDownload = null
|
this.currentDownload = null
|
||||||
|
|
||||||
this.episodeScheduleTask = null
|
this.episodeScheduleTask = null
|
||||||
|
this.failedCheckMap = {}
|
||||||
}
|
}
|
||||||
|
|
||||||
get serverSettings() {
|
get serverSettings() {
|
||||||
@@ -154,7 +155,10 @@ class PodcastManager {
|
|||||||
schedulePodcastEpisodeCron() {
|
schedulePodcastEpisodeCron() {
|
||||||
try {
|
try {
|
||||||
Logger.debug(`[PodcastManager] Scheduled podcast episode check cron "${this.serverSettings.podcastEpisodeSchedule}"`)
|
Logger.debug(`[PodcastManager] Scheduled podcast episode check cron "${this.serverSettings.podcastEpisodeSchedule}"`)
|
||||||
this.episodeScheduleTask = cron.schedule(this.serverSettings.podcastEpisodeSchedule, this.checkForNewEpisodes.bind(this))
|
this.episodeScheduleTask = cron.schedule(this.serverSettings.podcastEpisodeSchedule, () => {
|
||||||
|
Logger.debug(`[PodcastManager] Running cron`)
|
||||||
|
this.checkForNewEpisodes()
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(`[PodcastManager] Failed to schedule podcast cron ${this.serverSettings.podcastEpisodeSchedule}`, error)
|
Logger.error(`[PodcastManager] Failed to schedule podcast cron ${this.serverSettings.podcastEpisodeSchedule}`, error)
|
||||||
}
|
}
|
||||||
@@ -171,21 +175,35 @@ class PodcastManager {
|
|||||||
async checkForNewEpisodes() {
|
async checkForNewEpisodes() {
|
||||||
var podcastsWithAutoDownload = this.db.libraryItems.filter(li => li.mediaType === 'podcast' && li.media.autoDownloadEpisodes)
|
var podcastsWithAutoDownload = this.db.libraryItems.filter(li => li.mediaType === 'podcast' && li.media.autoDownloadEpisodes)
|
||||||
if (!podcastsWithAutoDownload.length) {
|
if (!podcastsWithAutoDownload.length) {
|
||||||
|
Logger.info(`[PodcastManager] checkForNewEpisodes - No podcasts with auto download set`)
|
||||||
this.cancelCron()
|
this.cancelCron()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
Logger.debug(`[PodcastManager] checkForNewEpisodes - Checking ${podcastsWithAutoDownload.length} Podcasts`)
|
||||||
|
|
||||||
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}`)
|
Logger.info(`[PodcastManager] checkForNewEpisodes Cron for "${libraryItem.media.metadata.title}" - Last episode check: ${lastEpisodeCheckDate}`)
|
||||||
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem)
|
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem)
|
||||||
|
Logger.debug(`[PodcastManager] checkForNewEpisodes checked result ${newEpisodes ? newEpisodes.length : 'N/A'}`)
|
||||||
|
|
||||||
if (!newEpisodes) { // Failed
|
if (!newEpisodes) { // Failed
|
||||||
libraryItem.media.autoDownloadEpisodes = false
|
// Allow up to 3 failed attempts before disabling auto download
|
||||||
|
if (!this.failedCheckMap[libraryItem.id]) this.failedCheckMap[libraryItem.id] = 0
|
||||||
|
this.failedCheckMap[libraryItem.id]++
|
||||||
|
if (this.failedCheckMap[libraryItem.id] > 2) {
|
||||||
|
Logger.error(`[PodcastManager] checkForNewEpisodes 3 failed attempts at checking episodes for "${libraryItem.media.metadata.title}" - disabling auto download`)
|
||||||
|
libraryItem.media.autoDownloadEpisodes = false
|
||||||
|
delete this.failedCheckMap[libraryItem.id]
|
||||||
|
} else {
|
||||||
|
Logger.warn(`[PodcastManager] checkForNewEpisodes ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.metadata.title}"`)
|
||||||
|
}
|
||||||
} else if (newEpisodes.length) {
|
} else if (newEpisodes.length) {
|
||||||
|
delete this.failedCheckMap[libraryItem.id]
|
||||||
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)
|
||||||
} else {
|
} else {
|
||||||
|
delete this.failedCheckMap[libraryItem.id]
|
||||||
Logger.debug(`[PodcastManager] No new episodes for "${libraryItem.media.metadata.title}"`)
|
Logger.debug(`[PodcastManager] No new episodes for "${libraryItem.media.metadata.title}"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,27 +216,56 @@ class PodcastManager {
|
|||||||
|
|
||||||
async checkPodcastForNewEpisodes(podcastLibraryItem) {
|
async checkPodcastForNewEpisodes(podcastLibraryItem) {
|
||||||
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}) - disabling auto download`)
|
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
var feed = await this.getPodcastFeed(podcastLibraryItem.media.metadata.feedUrl)
|
var feed = await this.getPodcastFeed(podcastLibraryItem.media.metadata.feedUrl)
|
||||||
if (!feed || !feed.episodes) {
|
if (!feed || !feed.episodes) {
|
||||||
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id}) - disabling auto download`)
|
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`, feed)
|
||||||
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 > podcastLibraryItem.media.lastEpisodeCheck && !podcastLibraryItem.media.checkHasEpisodeByFeedUrl(ep.enclosure.url))
|
||||||
// Max new episodes for safety = 2
|
// Max new episodes for safety = 3
|
||||||
newEpisodes = newEpisodes.slice(0, 2)
|
newEpisodes = newEpisodes.slice(0, 3)
|
||||||
|
return newEpisodes
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkAndDownloadNewEpisodes(libraryItem) {
|
||||||
|
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0)
|
||||||
|
Logger.info(`[PodcastManager] checkAndDownloadNewEpisodes for "${libraryItem.media.metadata.title}" - Last episode check: ${lastEpisodeCheckDate}`)
|
||||||
|
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem)
|
||||||
|
if (newEpisodes.length) {
|
||||||
|
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.metadata.title}" - starting download`)
|
||||||
|
this.downloadPodcastEpisodes(libraryItem, newEpisodes)
|
||||||
|
} else {
|
||||||
|
Logger.info(`[PodcastManager] No new episodes found for podcast "${libraryItem.media.metadata.title}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
libraryItem.media.lastEpisodeCheck = Date.now()
|
||||||
|
libraryItem.updatedAt = Date.now()
|
||||||
|
await this.db.updateLibraryItem(libraryItem)
|
||||||
|
this.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||||
|
|
||||||
return newEpisodes
|
return newEpisodes
|
||||||
}
|
}
|
||||||
|
|
||||||
getPodcastFeed(feedUrl) {
|
getPodcastFeed(feedUrl) {
|
||||||
return axios.get(feedUrl).then(async (data) => {
|
Logger.debug(`[PodcastManager] getPodcastFeed for "${feedUrl}"`)
|
||||||
|
return axios.get(feedUrl, { timeout: 5000 }).then(async (data) => {
|
||||||
if (!data || !data.data) {
|
if (!data || !data.data) {
|
||||||
Logger.error('Invalid podcast feed request response')
|
Logger.error('Invalid podcast feed request response')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
Logger.debug(`[PodcastManager] getPodcastFeed for "${feedUrl}" success - parsing xml`)
|
||||||
var payload = await parsePodcastRssFeedXml(data.data)
|
var payload = await parsePodcastRssFeedXml(data.data)
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
const Path = require('path')
|
||||||
|
const fs = require('fs-extra')
|
||||||
|
const { Podcast } = require('podcast')
|
||||||
|
const { getId } = require('../utils/index')
|
||||||
|
const Logger = require('../Logger')
|
||||||
|
|
||||||
|
// Not functional at the moment
|
||||||
|
class RssFeedManager {
|
||||||
|
constructor(db, emitter) {
|
||||||
|
this.db = db
|
||||||
|
this.emitter = emitter
|
||||||
|
this.feeds = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
findFeedForItem(libraryItemId) {
|
||||||
|
return Object.values(this.feeds).find(feed => feed.libraryItemId === libraryItemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
getFeed(req, res) {
|
||||||
|
var feedData = this.feeds[req.params.id]
|
||||||
|
if (!feedData) {
|
||||||
|
Logger.error(`[RssFeedManager] Feed not found ${req.params.id}`)
|
||||||
|
res.sendStatus(404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var xml = feedData.feed.buildXml()
|
||||||
|
res.set('Content-Type', 'text/xml')
|
||||||
|
res.send(xml)
|
||||||
|
}
|
||||||
|
|
||||||
|
getFeedItem(req, res) {
|
||||||
|
var feedData = this.feeds[req.params.id]
|
||||||
|
if (!feedData) {
|
||||||
|
Logger.error(`[RssFeedManager] Feed not found ${req.params.id}`)
|
||||||
|
res.sendStatus(404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var remainingPath = req.params['0']
|
||||||
|
var fullPath = Path.join(feedData.libraryItemPath, remainingPath)
|
||||||
|
res.sendFile(fullPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
getFeedCover(req, res) {
|
||||||
|
var feedData = this.feeds[req.params.id]
|
||||||
|
if (!feedData) {
|
||||||
|
Logger.error(`[RssFeedManager] Feed not found ${req.params.id}`)
|
||||||
|
res.sendStatus(404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!feedData.mediaCoverPath) {
|
||||||
|
res.sendStatus(404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const extname = Path.extname(feedData.mediaCoverPath).toLowerCase().slice(1)
|
||||||
|
res.type(`image/${extname}`)
|
||||||
|
var readStream = fs.createReadStream(feedData.mediaCoverPath)
|
||||||
|
readStream.pipe(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
openFeed(userId, feedId, libraryItem, serverAddress) {
|
||||||
|
const podcast = libraryItem.media
|
||||||
|
|
||||||
|
const feedUrl = `${serverAddress}/feed/${feedId}`
|
||||||
|
// Removed Podcast npm package and ip package
|
||||||
|
const feed = new Podcast({
|
||||||
|
title: podcast.metadata.title,
|
||||||
|
description: podcast.metadata.description,
|
||||||
|
feedUrl,
|
||||||
|
siteUrl: serverAddress,
|
||||||
|
imageUrl: podcast.coverPath ? `${serverAddress}/feed/${feedId}/cover` : `${serverAddress}/Logo.png`,
|
||||||
|
author: podcast.metadata.author || 'advplyr',
|
||||||
|
language: 'en'
|
||||||
|
})
|
||||||
|
podcast.episodes.forEach((episode) => {
|
||||||
|
var contentUrl = episode.audioTrack.contentUrl.replace(/\\/g, '/')
|
||||||
|
contentUrl = contentUrl.replace(`/s/item/${libraryItem.id}`, `/feed/${feedId}/item`)
|
||||||
|
|
||||||
|
feed.addItem({
|
||||||
|
title: episode.title,
|
||||||
|
description: episode.description || '',
|
||||||
|
enclosure: {
|
||||||
|
url: `${serverAddress}${contentUrl}`,
|
||||||
|
type: episode.audioTrack.mimeType,
|
||||||
|
size: episode.size
|
||||||
|
},
|
||||||
|
date: episode.pubDate || '',
|
||||||
|
url: `${serverAddress}${contentUrl}`,
|
||||||
|
author: podcast.metadata.author || 'advplyr'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const feedData = {
|
||||||
|
id: feedId,
|
||||||
|
userId,
|
||||||
|
libraryItemId: libraryItem.id,
|
||||||
|
libraryItemPath: libraryItem.path,
|
||||||
|
mediaCoverPath: podcast.coverPath,
|
||||||
|
serverAddress: serverAddress,
|
||||||
|
feedUrl,
|
||||||
|
feed
|
||||||
|
}
|
||||||
|
this.feeds[feedId] = feedData
|
||||||
|
return feedData
|
||||||
|
}
|
||||||
|
|
||||||
|
openPodcastFeed(user, libraryItem, options) {
|
||||||
|
const serverAddress = options.serverAddress
|
||||||
|
const feedId = getId('feed')
|
||||||
|
const feedData = this.openFeed(user.id, feedId, libraryItem, serverAddress)
|
||||||
|
Logger.debug(`[RssFeedManager] Opened podcast feed ${feedData.feedUrl}`)
|
||||||
|
this.emitter('rss_feed_open', { libraryItemId: libraryItem.id, feedUrl: feedData.feedUrl })
|
||||||
|
return feedData
|
||||||
|
}
|
||||||
|
|
||||||
|
closePodcastFeedForItem(libraryItemId) {
|
||||||
|
var feed = this.findFeedForItem(libraryItemId)
|
||||||
|
if (!feed) return
|
||||||
|
this.closeRssFeed(feed.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
closeRssFeed(id) {
|
||||||
|
if (!this.feeds[id]) return
|
||||||
|
var feedData = this.feeds[id]
|
||||||
|
this.emitter('rss_feed_closed', { libraryItemId: feedData.libraryItemId, feedUrl: feedData.feedUrl })
|
||||||
|
delete this.feeds[id]
|
||||||
|
Logger.info(`[RssFeedManager] Closed RSS feed "${feedData.feedUrl}"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = RssFeedManager
|
||||||
@@ -79,7 +79,7 @@ class ServerSettings {
|
|||||||
|
|
||||||
this.backupSchedule = settings.backupSchedule || false
|
this.backupSchedule = settings.backupSchedule || false
|
||||||
this.backupsToKeep = settings.backupsToKeep || 2
|
this.backupsToKeep = settings.backupsToKeep || 2
|
||||||
this.maxBackupSize = settings.maxBackupSize || 1
|
this.maxBackupSize = settings.maxBackupSize || 1
|
||||||
this.backupMetadataCovers = settings.backupMetadataCovers !== false
|
this.backupMetadataCovers = settings.backupMetadataCovers !== false
|
||||||
|
|
||||||
this.loggerDailyLogsToKeep = settings.loggerDailyLogsToKeep || 7
|
this.loggerDailyLogsToKeep = settings.loggerDailyLogsToKeep || 7
|
||||||
|
|||||||
@@ -30,6 +30,15 @@ class User {
|
|||||||
get isRoot() {
|
get isRoot() {
|
||||||
return this.type === 'root'
|
return this.type === 'root'
|
||||||
}
|
}
|
||||||
|
get isAdmin() {
|
||||||
|
return this.type === 'admin'
|
||||||
|
}
|
||||||
|
get isGuest() {
|
||||||
|
return this.type === 'guest'
|
||||||
|
}
|
||||||
|
get isAdminOrUp() {
|
||||||
|
return this.isAdmin || this.isRoot
|
||||||
|
}
|
||||||
get canDelete() {
|
get canDelete() {
|
||||||
return !!this.permissions.delete && this.isActive
|
return !!this.permissions.delete && this.isActive
|
||||||
}
|
}
|
||||||
@@ -186,6 +195,7 @@ class User {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// And update permissions
|
// And update permissions
|
||||||
if (payload.permissions) {
|
if (payload.permissions) {
|
||||||
for (const key in payload.permissions) {
|
for (const key in payload.permissions) {
|
||||||
@@ -195,8 +205,15 @@ class User {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update accessible libraries
|
// Update accessible libraries
|
||||||
if (payload.librariesAccessible !== undefined) {
|
if (this.permissions.accessAllLibraries) {
|
||||||
|
// Access all libraries
|
||||||
|
if (this.librariesAccessible.length) {
|
||||||
|
this.librariesAccessible = []
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
} else if (payload.librariesAccessible !== undefined) {
|
||||||
if (payload.librariesAccessible.length) {
|
if (payload.librariesAccessible.length) {
|
||||||
if (payload.librariesAccessible.join(',') !== this.librariesAccessible.join(',')) {
|
if (payload.librariesAccessible.join(',') !== this.librariesAccessible.join(',')) {
|
||||||
hasUpdates = true
|
hasUpdates = true
|
||||||
@@ -208,8 +225,14 @@ class User {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update accessible libraries
|
// Update accessible tags
|
||||||
if (payload.itemTagsAccessible !== undefined) {
|
if (this.permissions.accessAllTags) {
|
||||||
|
// Access all tags
|
||||||
|
if (this.itemTagsAccessible.length) {
|
||||||
|
this.itemTagsAccessible = []
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
} else if (payload.itemTagsAccessible !== undefined) {
|
||||||
if (payload.itemTagsAccessible.length) {
|
if (payload.itemTagsAccessible.length) {
|
||||||
if (payload.itemTagsAccessible.join(',') !== this.itemTagsAccessible.join(',')) {
|
if (payload.itemTagsAccessible.join(',') !== this.itemTagsAccessible.join(',')) {
|
||||||
hasUpdates = true
|
hasUpdates = true
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const Series = require('../objects/entities/Series')
|
|||||||
const FileSystemController = require('../controllers/FileSystemController')
|
const FileSystemController = require('../controllers/FileSystemController')
|
||||||
|
|
||||||
class ApiRouter {
|
class ApiRouter {
|
||||||
constructor(db, auth, scanner, playbackSessionManager, abMergeManager, coverManager, backupManager, watcher, cacheManager, podcastManager, emitter, clientEmitter) {
|
constructor(db, auth, scanner, playbackSessionManager, abMergeManager, coverManager, backupManager, watcher, cacheManager, podcastManager, audioMetadataManager, rssFeedManager, emitter, clientEmitter) {
|
||||||
this.db = db
|
this.db = db
|
||||||
this.auth = auth
|
this.auth = auth
|
||||||
this.scanner = scanner
|
this.scanner = scanner
|
||||||
@@ -36,6 +36,8 @@ class ApiRouter {
|
|||||||
this.watcher = watcher
|
this.watcher = watcher
|
||||||
this.cacheManager = cacheManager
|
this.cacheManager = cacheManager
|
||||||
this.podcastManager = podcastManager
|
this.podcastManager = podcastManager
|
||||||
|
this.audioMetadataManager = audioMetadataManager
|
||||||
|
this.rssFeedManager = rssFeedManager
|
||||||
this.emitter = emitter
|
this.emitter = emitter
|
||||||
this.clientEmitter = clientEmitter
|
this.clientEmitter = clientEmitter
|
||||||
|
|
||||||
@@ -61,7 +63,6 @@ class ApiRouter {
|
|||||||
this.router.delete('/libraries/:id/issues', LibraryController.middleware.bind(this), LibraryController.removeLibraryItemsWithIssues.bind(this))
|
this.router.delete('/libraries/:id/issues', LibraryController.middleware.bind(this), LibraryController.removeLibraryItemsWithIssues.bind(this))
|
||||||
this.router.get('/libraries/:id/series', LibraryController.middleware.bind(this), LibraryController.getAllSeriesForLibrary.bind(this))
|
this.router.get('/libraries/:id/series', LibraryController.middleware.bind(this), LibraryController.getAllSeriesForLibrary.bind(this))
|
||||||
this.router.get('/libraries/:id/collections', LibraryController.middleware.bind(this), LibraryController.getCollectionsForLibrary.bind(this))
|
this.router.get('/libraries/:id/collections', LibraryController.middleware.bind(this), LibraryController.getCollectionsForLibrary.bind(this))
|
||||||
this.router.get('/libraries/:id/personalized-old', LibraryController.middleware.bind(this), LibraryController.getLibraryUserPersonalized.bind(this))
|
|
||||||
this.router.get('/libraries/:id/personalized', LibraryController.middleware.bind(this), LibraryController.getLibraryUserPersonalizedOptimal.bind(this))
|
this.router.get('/libraries/:id/personalized', LibraryController.middleware.bind(this), LibraryController.getLibraryUserPersonalizedOptimal.bind(this))
|
||||||
this.router.get('/libraries/:id/filterdata', LibraryController.middleware.bind(this), LibraryController.getLibraryFilterData.bind(this))
|
this.router.get('/libraries/:id/filterdata', LibraryController.middleware.bind(this), LibraryController.getLibraryFilterData.bind(this))
|
||||||
this.router.get('/libraries/:id/search', LibraryController.middleware.bind(this), LibraryController.search.bind(this))
|
this.router.get('/libraries/:id/search', LibraryController.middleware.bind(this), LibraryController.search.bind(this))
|
||||||
@@ -92,6 +93,7 @@ class ApiRouter {
|
|||||||
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)) // Root only
|
||||||
|
this.router.get('/items/:id/audio-metadata', LibraryItemController.middleware.bind(this), LibraryItemController.updateAudioFileMetadata.bind(this)) // Root only
|
||||||
|
|
||||||
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))
|
||||||
@@ -179,11 +181,13 @@ class ApiRouter {
|
|||||||
//
|
//
|
||||||
this.router.post('/podcasts', PodcastController.create.bind(this))
|
this.router.post('/podcasts', PodcastController.create.bind(this))
|
||||||
this.router.post('/podcasts/feed', PodcastController.getPodcastFeed.bind(this))
|
this.router.post('/podcasts/feed', PodcastController.getPodcastFeed.bind(this))
|
||||||
this.router.get('/podcasts/:id/checknew', PodcastController.checkNewEpisodes.bind(this))
|
this.router.get('/podcasts/:id/checknew', PodcastController.middleware.bind(this), PodcastController.checkNewEpisodes.bind(this))
|
||||||
this.router.get('/podcasts/:id/downloads', PodcastController.getEpisodeDownloads.bind(this))
|
this.router.get('/podcasts/:id/downloads', PodcastController.middleware.bind(this), PodcastController.getEpisodeDownloads.bind(this))
|
||||||
this.router.get('/podcasts/:id/clear-queue', PodcastController.clearEpisodeDownloadQueue.bind(this))
|
this.router.get('/podcasts/:id/clear-queue', PodcastController.middleware.bind(this), PodcastController.clearEpisodeDownloadQueue.bind(this))
|
||||||
this.router.post('/podcasts/:id/download-episodes', PodcastController.downloadEpisodes.bind(this))
|
this.router.post('/podcasts/:id/download-episodes', PodcastController.middleware.bind(this), PodcastController.downloadEpisodes.bind(this))
|
||||||
this.router.patch('/podcasts/:id/episode/:episodeId', PodcastController.updateEpisode.bind(this))
|
this.router.post('/podcasts/:id/open-feed', PodcastController.middleware.bind(this), PodcastController.openPodcastFeed.bind(this))
|
||||||
|
this.router.post('/podcasts/:id/close-feed', PodcastController.middleware.bind(this), PodcastController.closePodcastFeed.bind(this))
|
||||||
|
this.router.patch('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.updateEpisode.bind(this))
|
||||||
|
|
||||||
//
|
//
|
||||||
// Misc Routes
|
// Misc Routes
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ async function runFfmpeg() {
|
|||||||
ffmpegCommand.on('stderr', (stdErrline) => {
|
ffmpegCommand.on('stderr', (stdErrline) => {
|
||||||
parentPort.postMessage({
|
parentPort.postMessage({
|
||||||
type: 'FFMPEG',
|
type: 'FFMPEG',
|
||||||
level: 'error',
|
level: 'debug',
|
||||||
log: '[DownloadWorker] Ffmpeg Stderr: ' + stdErrline
|
log: '[DownloadWorker] Ffmpeg Stderr: ' + stdErrline
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -48,10 +48,33 @@ async function writeMetadataFile(libraryItem, outputPath) {
|
|||||||
`artist=${libraryItem.media.metadata.authorName}`,
|
`artist=${libraryItem.media.metadata.authorName}`,
|
||||||
`album_artist=${libraryItem.media.metadata.authorName}`,
|
`album_artist=${libraryItem.media.metadata.authorName}`,
|
||||||
`date=${libraryItem.media.metadata.publishedYear || ''}`,
|
`date=${libraryItem.media.metadata.publishedYear || ''}`,
|
||||||
`description=${libraryItem.media.metadata.description}`,
|
`description=${libraryItem.media.metadata.description || ''}`,
|
||||||
`genre=${libraryItem.media.metadata.genres.join(';')}`
|
`genre=${libraryItem.media.metadata.genres.join(';')}`,
|
||||||
|
`performer=${libraryItem.media.metadata.narratorName || ''}`,
|
||||||
|
`encoded_by=audiobookshelf:${package.version}`
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if (libraryItem.media.metadata.asin) {
|
||||||
|
inputstrs.push(`ASIN=${libraryItem.media.metadata.asin}`)
|
||||||
|
}
|
||||||
|
if (libraryItem.media.metadata.isbn) {
|
||||||
|
inputstrs.push(`ISBN=${libraryItem.media.metadata.isbn}`)
|
||||||
|
}
|
||||||
|
if (libraryItem.media.metadata.language) {
|
||||||
|
inputstrs.push(`language=${libraryItem.media.metadata.language}`)
|
||||||
|
}
|
||||||
|
if (libraryItem.media.metadata.series.length) {
|
||||||
|
// Only uses first series
|
||||||
|
var firstSeries = libraryItem.media.metadata.series[0]
|
||||||
|
inputstrs.push(`series=${firstSeries.name}`)
|
||||||
|
if (firstSeries.sequence) {
|
||||||
|
inputstrs.push(`series-part=${firstSeries.sequence}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (libraryItem.media.metadata.subtitle) {
|
||||||
|
inputstrs.push(`subtitle=${libraryItem.media.metadata.subtitle}`)
|
||||||
|
}
|
||||||
|
|
||||||
if (libraryItem.media.chapters) {
|
if (libraryItem.media.chapters) {
|
||||||
libraryItem.media.chapters.forEach((chap) => {
|
libraryItem.media.chapters.forEach((chap) => {
|
||||||
const chapterstrs = [
|
const chapterstrs = [
|
||||||
|
|||||||
+57
-128
@@ -136,80 +136,6 @@ module.exports = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
getSeriesWithProgressFromBooks(user, books) {
|
|
||||||
return []
|
|
||||||
// var _series = {}
|
|
||||||
// books.forEach((audiobook) => {
|
|
||||||
// if (audiobook.book.series) {
|
|
||||||
// var bookWithUserAb = { userAudiobook: user.getMediaProgress(audiobook.id), book: audiobook }
|
|
||||||
// if (!_series[audiobook.book.series]) {
|
|
||||||
// _series[audiobook.book.series] = {
|
|
||||||
// id: audiobook.book.series,
|
|
||||||
// name: audiobook.book.series,
|
|
||||||
// type: 'series',
|
|
||||||
// books: [bookWithUserAb]
|
|
||||||
// }
|
|
||||||
// } else {
|
|
||||||
// _series[audiobook.book.series].books.push(bookWithUserAb)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
// return Object.values(_series).map((series) => {
|
|
||||||
// series.books = naturalSort(series.books).asc(ab => ab.book.book.volumeNumber)
|
|
||||||
// return series
|
|
||||||
// }).filter((series) => series.books.some((book) => book.userAudiobook && book.userAudiobook.isRead))
|
|
||||||
},
|
|
||||||
|
|
||||||
sortSeriesBooks(books, seriesId, minified = false) {
|
|
||||||
return naturalSort(books).asc(li => {
|
|
||||||
if (!li.media.metadata.series) return null
|
|
||||||
var series = li.media.metadata.series.find(se => se.id === seriesId)
|
|
||||||
if (!series) return null
|
|
||||||
return series.sequence
|
|
||||||
}).map(li => {
|
|
||||||
if (minified) return li.toJSONMinified()
|
|
||||||
return li.toJSONExpanded()
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
getMediaProgressWithItems(user, libraryItems) {
|
|
||||||
var mediaProgress = []
|
|
||||||
libraryItems.forEach(li => {
|
|
||||||
var itemProgress = user.getAllMediaProgressForLibraryItem(li.id).map(mp => {
|
|
||||||
var episode = null
|
|
||||||
if (mp.episodeId) {
|
|
||||||
episode = li.media.getEpisode(mp.episodeId)
|
|
||||||
if (!episode) {
|
|
||||||
// Episode not found for library item
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
userProgress: mp.toJSON(),
|
|
||||||
libraryItem: li,
|
|
||||||
episode
|
|
||||||
}
|
|
||||||
}).filter(mp => !!mp)
|
|
||||||
|
|
||||||
mediaProgress = mediaProgress.concat(itemProgress)
|
|
||||||
})
|
|
||||||
return mediaProgress
|
|
||||||
},
|
|
||||||
|
|
||||||
getItemsMostRecentlyListened(itemsWithUserProgress, limit, minified = false) {
|
|
||||||
var itemsInProgress = itemsWithUserProgress.filter((data) => data.userProgress && data.userProgress.progress > 0 && !data.userProgress.isFinished)
|
|
||||||
itemsInProgress.sort((a, b) => {
|
|
||||||
return b.userProgress.lastUpdate - a.userProgress.lastUpdate
|
|
||||||
})
|
|
||||||
return itemsInProgress.map(b => {
|
|
||||||
var libjson = minified ? b.libraryItem.toJSONMinified() : b.libraryItem.toJSONExpanded()
|
|
||||||
if (b.episode) {
|
|
||||||
libjson.recentEpisode = b.episode
|
|
||||||
}
|
|
||||||
return libjson
|
|
||||||
}).slice(0, limit)
|
|
||||||
},
|
|
||||||
|
|
||||||
getBooksNextInSeries(seriesWithUserAb, limit, minified = false) {
|
getBooksNextInSeries(seriesWithUserAb, limit, minified = false) {
|
||||||
var incompleteSeires = seriesWithUserAb.filter((series) => series.books.some((book) => !book.userAudiobook || (!book.userAudiobook.isRead && book.userAudiobook.progress == 0)))
|
var incompleteSeires = seriesWithUserAb.filter((series) => series.books.some((book) => !book.userAudiobook || (!book.userAudiobook.isRead && book.userAudiobook.progress == 0)))
|
||||||
var booksNextInSeries = []
|
var booksNextInSeries = []
|
||||||
@@ -222,49 +148,6 @@ module.exports = {
|
|||||||
return booksNextInSeries.sort((a, b) => { return b.DateLastReadSeries - a.DateLastReadSeries }).map(b => minified ? b.book.toJSONMinified() : b.book.toJSONExpanded()).slice(0, limit)
|
return booksNextInSeries.sort((a, b) => { return b.DateLastReadSeries - a.DateLastReadSeries }).map(b => minified ? b.book.toJSONMinified() : b.book.toJSONExpanded()).slice(0, limit)
|
||||||
},
|
},
|
||||||
|
|
||||||
getItemsMostRecentlyFinished(itemsWithUserProgress, limit, minified = false) {
|
|
||||||
var itemsFinished = itemsWithUserProgress.filter((data) => data.userProgress && data.userProgress.isFinished)
|
|
||||||
itemsFinished.sort((a, b) => {
|
|
||||||
return b.userProgress.finishedAt - a.userProgress.finishedAt
|
|
||||||
})
|
|
||||||
return itemsFinished.map(i => {
|
|
||||||
var libjson = minified ? i.libraryItem.toJSONMinified() : i.libraryItem.toJSONExpanded()
|
|
||||||
if (i.episode) {
|
|
||||||
libjson.recentEpisode = i.episode
|
|
||||||
}
|
|
||||||
return libjson
|
|
||||||
}).slice(0, limit)
|
|
||||||
},
|
|
||||||
|
|
||||||
getItemsMostRecentlyAdded(libraryItems, limit, minified = false) {
|
|
||||||
var itemsSortedByAddedAt = sort(libraryItems).desc(li => li.addedAt)
|
|
||||||
return itemsSortedByAddedAt.map(b => minified ? b.toJSONMinified() : b.toJSONExpanded()).slice(0, limit)
|
|
||||||
},
|
|
||||||
|
|
||||||
getEpisodesRecentlyAdded(libraryItems, limit, minified = false) {
|
|
||||||
var libraryItemsWithEpisode = []
|
|
||||||
libraryItems.forEach((li) => {
|
|
||||||
if (li.mediaType !== 'podcast' || !li.media.hasMediaEntities) return
|
|
||||||
var libjson = minified ? li.toJSONMinified() : li.toJSONExpanded()
|
|
||||||
var episodes = sort(li.media.episodes || []).desc(ep => ep.addedAt)
|
|
||||||
episodes.forEach((ep) => {
|
|
||||||
var lie = { ...libjson }
|
|
||||||
lie.recentEpisode = ep
|
|
||||||
libraryItemsWithEpisode.push(lie)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
libraryItemsWithEpisode = sort(libraryItemsWithEpisode).desc(lie => lie.recentEpisode.addedAt)
|
|
||||||
return libraryItemsWithEpisode.slice(0, limit)
|
|
||||||
},
|
|
||||||
|
|
||||||
getSeriesMostRecentlyAdded(series, limit) {
|
|
||||||
var seriesSortedByAddedAt = sort(series).desc(_series => {
|
|
||||||
var booksSortedByMostRecent = sort(_series.books).desc(b => b.addedAt)
|
|
||||||
return booksSortedByMostRecent[0].addedAt
|
|
||||||
})
|
|
||||||
return seriesSortedByAddedAt.slice(0, limit)
|
|
||||||
},
|
|
||||||
|
|
||||||
getGenresWithCount(libraryItems) {
|
getGenresWithCount(libraryItems) {
|
||||||
var genresMap = {}
|
var genresMap = {}
|
||||||
libraryItems.forEach((li) => {
|
libraryItems.forEach((li) => {
|
||||||
@@ -354,7 +237,6 @@ module.exports = {
|
|||||||
buildPersonalizedShelves(user, libraryItems, mediaType, allSeries, allAuthors, maxEntitiesPerShelf = 10) {
|
buildPersonalizedShelves(user, libraryItems, mediaType, allSeries, allAuthors, maxEntitiesPerShelf = 10) {
|
||||||
const isPodcastLibrary = mediaType === 'podcast'
|
const isPodcastLibrary = mediaType === 'podcast'
|
||||||
|
|
||||||
|
|
||||||
const shelves = [
|
const shelves = [
|
||||||
{
|
{
|
||||||
id: 'continue-listening',
|
id: 'continue-listening',
|
||||||
@@ -363,6 +245,13 @@ module.exports = {
|
|||||||
entities: [],
|
entities: [],
|
||||||
category: 'recentlyListened'
|
category: 'recentlyListened'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'continue-series',
|
||||||
|
label: 'Continue Series',
|
||||||
|
type: mediaType,
|
||||||
|
entities: [],
|
||||||
|
category: 'continueSeries'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'recently-added',
|
id: 'recently-added',
|
||||||
label: 'Recently Added',
|
label: 'Recently Added',
|
||||||
@@ -400,7 +289,7 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
const categories = ['recentlyListened', 'newestEpisodes', 'newestItems', 'newestSeries', 'recentlyFinished', 'newestAuthors']
|
const categories = ['recentlyListened', 'continueSeries', 'newestEpisodes', 'newestItems', 'newestSeries', 'recentlyFinished', 'newestAuthors']
|
||||||
const categoryMap = {}
|
const categoryMap = {}
|
||||||
categories.forEach((cat) => {
|
categories.forEach((cat) => {
|
||||||
categoryMap[cat] = {
|
categoryMap[cat] = {
|
||||||
@@ -516,20 +405,24 @@ module.exports = {
|
|||||||
// Newest series
|
// Newest series
|
||||||
if (libraryItem.media.metadata.series.length) {
|
if (libraryItem.media.metadata.series.length) {
|
||||||
for (const librarySeries of libraryItem.media.metadata.series) {
|
for (const librarySeries of libraryItem.media.metadata.series) {
|
||||||
|
const mediaProgress = allItemProgress.length ? allItemProgress[0] : null
|
||||||
|
const bookInProgress = mediaProgress && (mediaProgress.inProgress || mediaProgress.isFinished)
|
||||||
|
const libraryItemJson = libraryItem.toJSONMinified()
|
||||||
|
libraryItemJson.seriesSequence = librarySeries.sequence
|
||||||
|
|
||||||
if (!seriesMap[librarySeries.id]) {
|
if (!seriesMap[librarySeries.id]) {
|
||||||
const seriesObj = allSeries.find(se => se.id === librarySeries.id)
|
const seriesObj = allSeries.find(se => se.id === librarySeries.id)
|
||||||
if (seriesObj) {
|
if (seriesObj) {
|
||||||
var series = {
|
var series = {
|
||||||
...seriesObj.toJSON(),
|
...seriesObj.toJSON(),
|
||||||
books: []
|
books: [libraryItemJson],
|
||||||
|
inProgress: bookInProgress,
|
||||||
|
bookInProgressLastUpdate: bookInProgress ? mediaProgress.lastUpdate : null,
|
||||||
|
sequenceInProgress: bookInProgress ? libraryItemJson.seriesSequence : null
|
||||||
}
|
}
|
||||||
|
seriesMap[librarySeries.id] = series
|
||||||
|
|
||||||
if (series.addedAt > categoryMap.newestSeries.smallest) {
|
if (series.addedAt > categoryMap.newestSeries.smallest) {
|
||||||
const libraryItemJson = libraryItem.toJSONMinified()
|
|
||||||
libraryItemJson.seriesSequence = librarySeries.sequence
|
|
||||||
series.books.push(libraryItemJson)
|
|
||||||
|
|
||||||
var indexToPut = categoryMap.newestSeries.items.findIndex(i => series.addedAt > i.addedAt)
|
var indexToPut = categoryMap.newestSeries.items.findIndex(i => series.addedAt > i.addedAt)
|
||||||
if (indexToPut >= 0) {
|
if (indexToPut >= 0) {
|
||||||
categoryMap.newestSeries.items.splice(indexToPut, 0, series)
|
categoryMap.newestSeries.items.splice(indexToPut, 0, series)
|
||||||
@@ -544,15 +437,19 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
categoryMap.newestSeries.biggest = categoryMap.newestSeries.items[0].addedAt
|
categoryMap.newestSeries.biggest = categoryMap.newestSeries.items[0].addedAt
|
||||||
|
|
||||||
seriesMap[librarySeries.id] = series
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// series already in map - add book
|
// series already in map - add book
|
||||||
const libraryItemJson = libraryItem.toJSONMinified()
|
|
||||||
libraryItemJson.seriesSequence = librarySeries.sequence
|
|
||||||
seriesMap[librarySeries.id].books.push(libraryItemJson)
|
seriesMap[librarySeries.id].books.push(libraryItemJson)
|
||||||
|
|
||||||
|
if (bookInProgress) { // Update if this series is in progress
|
||||||
|
seriesMap[librarySeries.id].inProgress = true
|
||||||
|
if (!seriesMap[librarySeries.id].sequenceInProgress || (librarySeries.sequence && String(librarySeries.sequence).localeCompare(String(seriesMap[librarySeries.id].sequenceInProgress), undefined, { sensitivity: 'base', numeric: true }) > 0)) {
|
||||||
|
seriesMap[librarySeries.id].sequenceInProgress = librarySeries.sequence
|
||||||
|
seriesMap[librarySeries.id].bookInProgressLastUpdate = mediaProgress.lastUpdate
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -643,6 +540,38 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For Continue Series - Find next book in series for series that are in progress
|
||||||
|
for (const seriesId in seriesMap) {
|
||||||
|
if (seriesMap[seriesId].inProgress) {
|
||||||
|
seriesMap[seriesId].books = naturalSort(seriesMap[seriesId].books).asc(li => li.seriesSequence)
|
||||||
|
|
||||||
|
const nextBookInSeries = seriesMap[seriesId].books.find(li => {
|
||||||
|
if (!seriesMap[seriesId].sequenceInProgress) return true
|
||||||
|
// True if book series sequence is greater than the current book sequence in progress
|
||||||
|
return String(li.seriesSequence).localeCompare(String(seriesMap[seriesId].sequenceInProgress), undefined, { sensitivity: 'base', numeric: true }) > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
if (nextBookInSeries) {
|
||||||
|
const bookForContinueSeries = {
|
||||||
|
...nextBookInSeries,
|
||||||
|
prevBookInProgressLastUpdate: seriesMap[seriesId].bookInProgressLastUpdate
|
||||||
|
}
|
||||||
|
bookForContinueSeries.media.metadata.series = {
|
||||||
|
id: seriesId,
|
||||||
|
name: seriesMap[seriesId].name,
|
||||||
|
sequence: nextBookInSeries.seriesSequence
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexToPut = categoryMap.continueSeries.items.findIndex(i => i.prevBookInProgressLastUpdate < bookForContinueSeries.prevBookInProgressLastUpdate)
|
||||||
|
if (indexToPut >= 0) {
|
||||||
|
categoryMap.continueSeries.items.splice(indexToPut, 0, bookForContinueSeries)
|
||||||
|
} else if (categoryMap.continueSeries.items.length < 10) { // Max 10 books
|
||||||
|
categoryMap.continueSeries.items.push(bookForContinueSeries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Sort series books by sequence
|
// Sort series books by sequence
|
||||||
if (categoryMap.newestSeries.items.length) {
|
if (categoryMap.newestSeries.items.length) {
|
||||||
for (const seriesItem of categoryMap.newestSeries.items) {
|
for (const seriesItem of categoryMap.newestSeries.items) {
|
||||||
|
|||||||
@@ -204,12 +204,12 @@ function parseTags(format, verbose) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var keysToLookOutFor = ['file_tag_genre1', 'file_tag_genre2', 'file_tag_series', 'file_tag_seriespart', 'file_tag_movement', 'file_tag_movementname', 'file_tag_wwwaudiofile', 'file_tag_contentgroup', 'file_tag_releasetime', 'file_tag_isbn']
|
// var keysToLookOutFor = ['file_tag_genre1', 'file_tag_genre2', 'file_tag_series', 'file_tag_seriespart', 'file_tag_movement', 'file_tag_movementname', 'file_tag_wwwaudiofile', 'file_tag_contentgroup', 'file_tag_releasetime', 'file_tag_isbn']
|
||||||
keysToLookOutFor.forEach((key) => {
|
// keysToLookOutFor.forEach((key) => {
|
||||||
if (tags[key]) {
|
// if (tags[key]) {
|
||||||
Logger.debug(`Notable! ${key} => ${tags[key]}`)
|
// Logger.debug(`Notable! ${key} => ${tags[key]}`)
|
||||||
}
|
// }
|
||||||
})
|
// })
|
||||||
return tags
|
return tags
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user