Compare commits

...

12 Commits

33 changed files with 990 additions and 138 deletions
+8
View File
@@ -127,6 +127,14 @@ input[type=number] {
border-top: 6px solid white;
}
.arrow-down-small {
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 4px solid currentColor;
}
.triangle-right {
width: 0;
height: 0;
-6
View File
@@ -150,12 +150,6 @@ export default {
toggleBookshelfTexture() {
this.$store.dispatch('setBookshelfTexture', 'wood2.png')
},
async back() {
var popped = await this.$store.dispatch('popRoute')
if (popped) this.$store.commit('setIsRoutingBack', true)
var backTo = popped || '/'
this.$router.push(backTo)
},
cancelSelectionMode() {
if (this.processingBatchDelete) return
this.$store.commit('setSelectedLibraryItems', [])
+1 -1
View File
@@ -143,7 +143,7 @@ export default {
return this.$store.getters['user/getUserSetting']('filterBy')
},
isIssuesFilter() {
return this.filterBy === 'issues'
return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
}
},
methods: {
+2 -1
View File
@@ -93,12 +93,13 @@ export default {
this.show = false
},
clickBg(ev) {
if (!this.show) return
if (this.preventClickoutside) {
this.preventClickoutside = false
return
}
if (this.processing && this.persistent) return
if (ev.srcElement.classList.contains('modal-bg')) {
if (ev.srcElement && ev.srcElement.classList.contains('modal-bg')) {
this.show = false
}
},
@@ -1,32 +1,11 @@
<template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<div class="w-full mb-4">
<div v-if="chapters.length" class="w-full p-4 bg-primary">
<p>Audiobook Chapters</p>
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" keep-open />
<div v-if="!chapters.length" class="py-4 text-center">
<p class="mb-8 text-xl">No Chapters</p>
<ui-btn v-if="userCanUpdate" :to="`/audiobook/${libraryItem.id}/chapters`">Add Chapters</ui-btn>
</div>
<div v-if="!chapters.length" class="flex my-4 text-center justify-center text-xl">No Chapters</div>
<table v-else class="text-sm tracksTable">
<tr class="font-book">
<th class="text-left w-16"><span class="px-4">Id</span></th>
<th class="text-left">Title</th>
<th class="text-center">Start</th>
<th class="text-center">End</th>
</tr>
<tr v-for="chapter in chapters" :key="chapter.id">
<td class="text-left">
<p class="px-4">{{ chapter.id }}</p>
</td>
<td class="font-book">
{{ chapter.title }}
</td>
<td class="font-mono text-center">
{{ $secondsToTimestamp(chapter.start) }}
</td>
<td class="font-mono text-center">
{{ $secondsToTimestamp(chapter.end) }}
</td>
</tr>
</table>
</div>
</div>
</template>
@@ -48,6 +27,9 @@ export default {
},
chapters() {
return this.media.chapters || []
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
}
},
methods: {}
+14 -1
View File
@@ -18,12 +18,16 @@
<div v-else class="w-full">
<p class="text-lg font-semibold mb-4">Open RSS Feed</p>
<div class="w-full relative">
<div class="w-full relative mb-2">
<ui-text-input-with-label v-model="newFeedSlug" label="RSS Feed Slug" />
<p class="text-xs text-gray-400 py-0.5 px-1">Feed will be {{ demoFeedUrl }}</p>
</div>
<p v-if="isHttp" class="w-full pt-2 text-warning text-xs">Warning: Most podcast apps will require the RSS feed URL is using HTTPS</p>
<p v-if="hasEpisodesWithoutPubDate" class="w-full pt-2 text-warning text-xs">Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.</p>
</div>
<div v-show="userIsAdminOrUp" class="flex items-center pt-6">
<p class="text-xs text-gray-300">Note: RSS feed URLs are not authenticated</p>
<div class="flex-grow" />
<ui-btn v-if="currentFeedUrl" color="error" small @click="closeFeed">Close RSS Feed</ui-btn>
<ui-btn v-else color="success" small @click="openFeed">Open RSS Feed</ui-btn>
@@ -85,6 +89,15 @@ export default {
},
demoFeedUrl() {
return `${window.origin}/feed/${this.newFeedSlug}`
},
isHttp() {
return window.origin.startsWith('http://')
},
episodes() {
return this.media.episodes || []
},
hasEpisodesWithoutPubDate() {
return this.episodes.some((ep) => !ep.pubDate)
}
},
methods: {
+273
View File
@@ -0,0 +1,273 @@
<template>
<div id="heatmap" class="w-full">
<div class="mx-auto" :style="{ height: innerHeight + 160 + 'px', width: innerWidth + 52 + 'px' }" style="background-color: rgba(13, 17, 23, 0)">
<p class="mb-2 px-1 text-sm text-gray-200">{{ Object.values(daysListening).length }} listening sessions in the last year</p>
<div class="border border-opacity-25 rounded py-2 w-full" style="background-color: #232323" :style="{ height: innerHeight + 80 + 'px' }">
<div :style="{ width: innerWidth + 'px', height: innerHeight + 'px' }" class="ml-10 mt-5 absolute" @mouseover="mouseover" @mouseout="mouseout">
<div v-for="dayLabel in dayLabels" :key="dayLabel.label" :style="dayLabel.style" class="absolute top-0 left-0 text-gray-300">{{ dayLabel.label }}</div>
<div v-for="monthLabel in monthLabels" :key="monthLabel.id" :style="monthLabel.style" class="absolute top-0 left-0 text-gray-300">{{ monthLabel.label }}</div>
<div v-for="(block, index) in data" :key="block.dateString" :style="block.style" :data-index="index" class="absolute top-0 left-0 h-2.5 w-2.5 rounded-sm" />
<div class="flex py-2 px-4" :style="{ marginTop: innerHeight + 'px' }">
<div class="flex-grow" />
<p style="font-size: 10px; line-height: 10px" class="text-gray-400 px-1">Less</p>
<div v-for="block in legendBlocks" :key="block.id" :style="block.style" class="h-2.5 w-2.5 rounded-sm" style="margin-left: 1.5px; margin-right: 1.5px" />
<p style="font-size: 10px; line-height: 10px" class="text-gray-400 px-1">More</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
daysListening: {
type: Object,
default: () => {}
}
},
data() {
return {
contentWidth: 0,
maxInnerWidth: 0,
innerHeight: 13 * 7,
blockWidth: 13,
data: [],
monthLabels: [],
tooltipEl: null,
tooltipTextEl: null,
tooltipArrowEl: null,
showingTooltipIndex: -1,
outlineColors: ['rgba(27, 31, 35, 0.06)', 'rgba(255,255,255,0.03)'],
bgColors: ['rgb(45,45,45)', 'rgb(14, 68, 41)', 'rgb(0, 109, 50)', 'rgb(38, 166, 65)', 'rgb(57, 211, 83)']
// GH Colors
// outlineColors: ['rgba(27, 31, 35, 0.06)', 'rgba(255,255,255,0.05)'],
// bgColors: ['rgb(22, 27, 34)', 'rgb(14, 68, 41)', 'rgb(0, 109, 50)', 'rgb(38, 166, 65)', 'rgb(57, 211, 83)']
}
},
computed: {
weeksToShow() {
return Math.min(52, Math.floor(this.maxInnerWidth / this.blockWidth) - 1)
},
innerWidth() {
return (this.weeksToShow + 1) * 13
},
daysToShow() {
return this.weeksToShow * 7 + this.dayOfWeekToday
},
dayOfWeekToday() {
return new Date().getDay()
},
firstWeekStart() {
return this.$addDaysToToday(-this.daysToShow)
},
dayLabels() {
return [
{
label: 'Mon',
style: {
transform: `translate(${-25}px, ${13}px)`,
lineHeight: '10px',
fontSize: '10px'
}
},
{
label: 'Wed',
style: {
transform: `translate(${-25}px, ${13 * 3}px)`,
lineHeight: '10px',
fontSize: '10px'
}
},
{
label: 'Fri',
style: {
transform: `translate(${-25}px, ${13 * 5}px)`,
lineHeight: '10px',
fontSize: '10px'
}
}
]
},
legendBlocks() {
return [
{
id: 'legend-0',
style: `background-color:${this.bgColors[0]};outline:1px solid ${this.outlineColors[0]};outline-offset:-1px;`
},
{
id: 'legend-1',
style: `background-color:${this.bgColors[1]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
},
{
id: 'legend-2',
style: `background-color:${this.bgColors[2]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
},
{
id: 'legend-3',
style: `background-color:${this.bgColors[3]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
},
{
id: 'legend-4',
style: `background-color:${this.bgColors[4]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
}
]
}
},
methods: {
destroyTooltip() {
if (this.tooltipEl) this.tooltipEl.remove()
this.tooltipEl = null
this.showingTooltipIndex = -1
},
createTooltip() {
const tooltip = document.createElement('div')
tooltip.className = 'absolute top-0 left-0 rounded bg-gray-500 text-white p-2 text-white max-w-xs pointer-events-none'
tooltip.style.display = 'none'
tooltip.id = 'heatmap-tooltip'
const tooltipText = document.createElement('p')
tooltipText.innerText = 'Tooltip'
tooltipText.style.fontSize = '10px'
tooltipText.style.lineHeight = '10px'
tooltip.appendChild(tooltipText)
const tooltipArrow = document.createElement('div')
tooltipArrow.className = 'text-gray-500 arrow-down-small absolute -bottom-1 left-0 right-0 mx-auto'
tooltip.appendChild(tooltipArrow)
this.tooltipEl = tooltip
this.tooltipTextEl = tooltipText
this.tooltipArrowEl = tooltipArrow
document.body.appendChild(this.tooltipEl)
},
showTooltip(index, block, rect) {
if (this.tooltipEl && this.showingTooltipIndex === index) return
if (!this.tooltipEl) {
this.createTooltip()
}
this.showingTooltipIndex = index
this.tooltipEl.style.display = 'block'
this.tooltipTextEl.innerHTML = block.value ? `<strong>${this.$elapsedPretty(block.value, true)} listening</strong> on ${block.datePretty}` : `No listening sessions on ${block.datePretty}`
const calculateRect = this.tooltipEl.getBoundingClientRect()
const w = calculateRect.width / 2
var left = rect.x - w
var offsetX = 0
if (left < 0) {
offsetX = Math.abs(left)
left = 0
} else if (rect.x + w > window.innerWidth - 10) {
offsetX = window.innerWidth - 10 - (rect.x + w)
left += offsetX
}
this.tooltipEl.style.transform = `translate(${left}px, ${rect.y - 32}px)`
this.tooltipArrowEl.style.transform = `translate(${5 - offsetX}px, 0px)`
},
hideTooltip() {
if (this.showingTooltipIndex >= 0 && this.tooltipEl) {
this.tooltipEl.style.display = 'none'
this.showingTooltipIndex = -1
}
},
mouseover(e) {
if (isNaN(e.target.dataset.index)) {
this.hideTooltip()
return
}
var block = this.data[e.target.dataset.index]
var rect = e.target.getBoundingClientRect()
this.showTooltip(e.target.dataset.index, block, rect)
},
mouseout(e) {
this.hideTooltip()
},
buildData() {
this.data = []
var maxValue = 0
var minValue = 0
Object.values(this.daysListening).forEach((val) => {
if (val > maxValue) maxValue = val
if (!minValue || val < minValue) minValue = val
})
const range = maxValue - minValue + 0.01
for (let i = 0; i < this.daysToShow + 1; i++) {
const col = Math.floor(i / 7)
const row = i % 7
const date = i === 0 ? this.firstWeekStart : this.$addDaysToDate(this.firstWeekStart, i)
const dateString = this.$formatJsDate(date, 'yyyy-MM-dd')
const datePretty = this.$formatJsDate(date, 'MMM d, yyyy')
const monthString = this.$formatJsDate(date, 'MMM')
const value = this.daysListening[dateString] || 0
const x = col * 13
const y = row * 13
var bgColor = this.bgColors[0]
var outlineColor = this.outlineColors[0]
if (value) {
outlineColor = this.outlineColors[1]
var percentOfAvg = (value - minValue) / range
var bgIndex = Math.floor(percentOfAvg * 4) + 1
bgColor = this.bgColors[bgIndex] || 'red'
}
this.data.push({
date,
dateString,
datePretty,
monthString,
dayOfMonth: Number(dateString.split('-').pop()),
yearString: dateString.split('-').shift(),
value,
col,
row,
style: `transform:translate(${x}px,${y}px);background-color:${bgColor};outline:1px solid ${outlineColor};outline-offset:-1px;`
})
}
console.log('Data', this.data)
this.monthLabels = []
var lastMonth = null
for (let i = 0; i < this.data.length; i++) {
if (this.data[i].monthString !== lastMonth) {
const weekOfMonth = Math.floor(this.data[i].dayOfMonth / 7)
if (weekOfMonth <= 2) {
this.monthLabels.push({
id: this.data[i].dateString + '-ml',
label: this.data[i].monthString,
style: {
transform: `translate(${this.data[i].col * 13}px, -15px)`,
lineHeight: '10px',
fontSize: '10px'
}
})
lastMonth = this.data[i].monthString
}
}
}
},
init() {
const heatmapEl = document.getElementById('heatmap')
this.contentWidth = heatmapEl.clientWidth
this.maxInnerWidth = this.contentWidth - 52
this.buildData()
}
},
updated() {},
mounted() {
this.init()
},
beforeDestroy() {}
}
</script>
@@ -0,0 +1,74 @@
<template>
<div class="w-full my-2">
<div class="w-full bg-primary px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
<p class="pr-4">Chapters</p>
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ chapters.length }}</span>
<div class="flex-grow" />
<ui-btn v-if="userCanUpdate" small :to="`/audiobook/${libraryItemId}/chapters`" color="primary" class="mr-2">Edit Chapters</ui-btn>
<div v-if="!keepOpen" class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="expanded ? 'transform rotate-180' : ''">
<span class="material-icons text-4xl">expand_more</span>
</div>
</div>
<transition name="slide">
<table class="text-sm tracksTable" v-show="expanded || keepOpen">
<tr class="font-book">
<th class="text-left w-16"><span class="px-4">Id</span></th>
<th class="text-left">Title</th>
<th class="text-center">Start</th>
<th class="text-center">End</th>
</tr>
<tr v-for="chapter in chapters" :key="chapter.id">
<td class="text-left">
<p class="px-4">{{ chapter.id }}</p>
</td>
<td class="font-book">
{{ chapter.title }}
</td>
<td class="font-mono text-center">
{{ $secondsToTimestamp(chapter.start) }}
</td>
<td class="font-mono text-center">
{{ $secondsToTimestamp(chapter.end) }}
</td>
</tr>
</table>
</transition>
</div>
</template>
<script>
export default {
props: {
libraryItem: {
type: Object,
default: () => {}
},
keepOpen: Boolean
},
data() {
return {
expanded: false
}
},
computed: {
libraryItemId() {
return this.libraryItem.id
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
chapters() {
return this.media.chapters || []
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
}
},
methods: {
clickBar() {
this.expanded = !this.expanded
}
},
mounted() {}
}
</script>
-30
View File
@@ -1,30 +0,0 @@
export default function (context) {
if (process.client) {
var route = context.route
var from = context.from
var store = context.store
if (route.name === 'login' || from.name === 'login') return
if (!route.name) {
console.warn('No Route name', route)
return
}
if (store.state.isRoutingBack) {
// pressing back button in appbar do not add to route history
store.commit('setIsRoutingBack', false)
return
}
if (route.name.startsWith('config') || route.name === 'upload' || route.name === 'account' || route.name.startsWith('audiobook-id') || route.name.startsWith('collection-id')) {
if (from.name !== route.name && from.name !== 'audiobook-id-edit' && !from.name.startsWith('config') && from.name !== 'upload' && from.name !== 'account') {
var _history = [...store.state.routeHistory]
if (!_history.length || _history[_history.length - 1] !== from.fullPath) {
_history.push(from.fullPath)
store.commit('setRouteHistory', _history)
}
}
}
}
}
+1 -3
View File
@@ -36,9 +36,7 @@ module.exports = {
]
},
router: {
middleware: ['routed']
},
router: {},
// Global CSS: https://go.nuxtjs.dev/config-css
css: [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "2.0.12",
"version": "2.0.13",
"description": "Audiobook manager and player",
"main": "index.js",
"scripts": {
+422
View File
@@ -0,0 +1,422 @@
<template>
<div id="page-wrapper" class="bg-bg page overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
<div class="flex items-center py-4 max-w-7xl mx-auto">
<nuxt-link :to="`/item/${libraryItem.id}`" class="hover:underline">
<h1 class="text-xl">{{ title }}</h1>
</nuxt-link>
<button class="w-7 h-7 flex items-center justify-center mx-4 hover:scale-110 duration-100 transform text-gray-200 hover:text-white" @click="editItem">
<span class="material-icons text-base">edit</span>
</button>
<div class="flex-grow" />
<p class="text-base">Duration:</p>
<p class="text-base font-mono ml-8">{{ mediaDuration }}</p>
</div>
<div class="flex flex-wrap-reverse justify-center py-4">
<div class="w-full max-w-3xl py-4">
<div class="flex items-center">
<p class="text-lg mb-4 font-semibold">Audiobook Chapters</p>
<div class="flex-grow" />
<ui-btn color="primary" small class="mx-2" @click="showFindChaptersModal = true">Lookup</ui-btn>
<ui-btn color="success" small @click="saveChapters">Save</ui-btn>
<div class="w-40" />
</div>
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
<div class="w-12"></div>
<div class="w-32 px-2">Start</div>
<div class="flex-grow px-2">Title</div>
<div class="w-40"></div>
</div>
<template v-for="chapter in newChapters">
<div :key="chapter.id" class="flex py-1">
<div class="w-12">#{{ chapter.id + 1 }}</div>
<div class="w-32 px-1">
<ui-text-input v-model="chapter.start" type="number" class="text-xs" @change="checkChapters" />
</div>
<div class="flex-grow px-1">
<ui-text-input v-model="chapter.title" class="text-xs" />
</div>
<div class="w-40 px-2 py-1">
<div class="flex items-center">
<button v-if="newChapters.length > 1" class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-error transform hover:scale-110 duration-150" @click="removeChapter(chapter)">
<span class="material-icons-outlined text-base">remove</span>
</button>
<ui-tooltip text="Insert chapter below" direction="bottom">
<button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-success transform hover:scale-110 duration-150" @click="addChapter(chapter)">
<span class="material-icons text-lg">add</span>
</button>
</ui-tooltip>
<button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-white transform hover:scale-110 duration-150" @click="playChapter(chapter)">
<widgets-loading-spinner v-if="selectedChapterId === chapter.id && isLoadingChapter" />
<span v-else-if="selectedChapterId === chapter.id && isPlayingChapter" class="material-icons-outlined text-base">pause</span>
<span v-else class="material-icons-outlined text-base">play_arrow</span>
</button>
<ui-tooltip v-if="chapter.error" :text="chapter.error" direction="left">
<button class="w-7 h-7 rounded-full flex items-center justify-center text-error">
<span class="material-icons-outlined text-lg">error_outline</span>
</button>
</ui-tooltip>
</div>
</div>
</div>
</template>
</div>
<div class="w-full max-w-xl py-4">
<p class="text-lg mb-4 font-semibold py-1">Audio Tracks</p>
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
<div class="flex-grow">Filename</div>
<div class="w-20">Duration</div>
<div class="w-20 text-center">Chapters</div>
</div>
<template v-for="track in audioTracks">
<div :key="track.ino" class="flex items-center py-2" :class="currentTrackIndex === track.index && isPlayingChapter ? 'bg-success bg-opacity-10' : ''">
<div class="flex-grow">
<p class="text-xs truncate max-w-sm">{{ track.metadata.filename }}</p>
</div>
<div class="w-20" style="min-width: 80px">
<p class="text-xs font-mono text-gray-200">{{ track.duration }}</p>
</div>
<div class="w-20 flex justify-center" style="min-width: 80px">
<span v-if="(track.chapters || []).length" class="material-icons text-success text-sm">check</span>
</div>
</div>
</template>
</div>
</div>
<div v-if="saving" class="w-full h-full absolute top-0 left-0 bottom-0 right-0 z-30 bg-black bg-opacity-25 flex items-center justify-center">
<ui-loading-indicator />
</div>
<modals-modal v-model="showFindChaptersModal" name="edit-book" :width="500" :processing="findingChapters">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
<p class="font-book text-3xl text-white truncate pointer-events-none">Find Chapters</p>
</div>
</template>
<div class="w-full h-full max-h-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative">
<div v-if="!chapterData" class="flex p-20">
<ui-text-input-with-label v-model="asinInput" label="ASIN" />
<ui-btn small color="primary" class="mt-5 ml-2" @click="findChapters">Find</ui-btn>
</div>
<div v-else class="w-full p-4">
<p class="mb-4">Duration found: {{ chapterData.runtimeLengthSec }}</p>
<div v-if="chapterData.runtimeLengthSec > mediaDuration" class="w-full bg-error bg-opacity-25 p-4 text-center mb-2 rounded border border-white border-opacity-10 text-gray-100 text-sm">
<p>Chapter data invalid duration<br />Your media duration is shorter than duration found</p>
</div>
<div class="flex py-0.5 text-xs font-semibold uppercase text-gray-300 mb-1">
<div class="w-24 px-2">Start</div>
<div class="flex-grow px-2">Title</div>
</div>
<div class="w-full max-h-80 overflow-y-auto my-2">
<div v-for="(chapter, index) in chapterData.chapters" :key="index" class="flex py-0.5 text-xs" :class="chapter.startOffsetSec > mediaDuration ? 'bg-error bg-opacity-20' : chapter.startOffsetSec + chapter.lengthMs / 1000 > mediaDuration ? 'bg-warning bg-opacity-20' : index % 2 === 0 ? 'bg-primary bg-opacity-30' : ''">
<div class="w-24 min-w-24 px-2">
<p class="font-mono">{{ $secondsToTimestamp(chapter.startOffsetSec) }}</p>
</div>
<div class="flex-grow px-2">
<p class="truncate max-w-sm">{{ chapter.title }}</p>
</div>
</div>
</div>
<div class="flex pt-2">
<div class="flex-grow" />
<ui-btn small color="success" @click="applyChapterData">Apply Chapters</ui-btn>
</div>
</div>
</div>
</modals-modal>
</div>
</template>
<script>
export default {
async asyncData({ store, params, app, redirect, route }) {
if (!store.getters['user/getUserCanUpdate']) {
return redirect('/?error=unauthorized')
}
var libraryItem = await app.$axios.$get(`/api/items/${params.id}?expanded=1`).catch((error) => {
console.error('Failed', error)
return false
})
if (!libraryItem) {
console.error('Not found...', params.id)
return redirect('/')
}
if (libraryItem.mediaType != 'book') {
console.error('Invalid media type')
return redirect('/')
}
return {
libraryItem
}
},
data() {
return {
newChapters: [],
selectedChapter: null,
audioEl: null,
isPlayingChapter: false,
isLoadingChapter: false,
currentTrackIndex: 0,
saving: false,
asinInput: null,
findingChapters: false,
showFindChaptersModal: false,
chapterData: null
}
},
computed: {
streamLibraryItem() {
return this.$store.state.streamLibraryItem
},
userToken() {
return this.$store.getters['user/getToken']
},
media() {
return this.libraryItem.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
title() {
return this.mediaMetadata.title
},
mediaDuration() {
return this.media.duration
},
chapters() {
return this.media.chapters || []
},
tracks() {
return this.media.tracks || []
},
audioFiles() {
return this.media.audioFiles || []
},
audioTracks() {
return this.audioFiles.filter((af) => !af.exclude && !af.invalid)
},
selectedChapterId() {
return this.selectedChapter ? this.selectedChapter.id : null
}
},
methods: {
editItem() {
this.$store.commit('showEditModal', this.libraryItem)
},
addChapter(chapter) {
console.log('Add chapter', chapter)
const newChapter = {
id: chapter.id + 1,
start: chapter.start,
end: chapter.end,
title: ''
}
this.newChapters.splice(chapter.id + 1, 0, newChapter)
this.checkChapters()
},
removeChapter(chapter) {
this.newChapters = this.newChapters.filter((ch) => ch.id !== chapter.id)
this.checkChapters()
},
checkChapters() {
var previousStart = 0
for (let i = 0; i < this.newChapters.length; i++) {
this.newChapters[i].id = i
this.newChapters[i].start = Number(this.newChapters[i].start)
if (i === 0 && this.newChapters[i].start !== 0) {
this.newChapters[i].error = 'First chapter must start at 0'
} else if (this.newChapters[i].start <= previousStart && i > 0) {
this.newChapters[i].error = 'Invalid start time must be >= previous chapter start time'
} else if (this.newChapters[i].start >= this.mediaDuration) {
this.newChapters[i].error = 'Invalid start time must be < duration'
} else {
this.newChapters[i].error = null
}
previousStart = this.newChapters[i].start
}
},
playChapter(chapter) {
console.log('Play Chapter', chapter.id)
if (this.selectedChapterId === chapter.id) {
console.log('Chapter already playing', this.isLoadingChapter, this.isPlayingChapter)
if (this.isLoadingChapter) return
if (this.isPlayingChapter) {
console.log('Destroying chapter')
this.destroyAudioEl()
return
}
}
if (this.selectedChapterId) {
this.destroyAudioEl()
}
const audioTrack = this.tracks.find((at) => {
return chapter.start >= at.startOffset && chapter.start < at.startOffset + at.duration
})
console.log('audio track', audioTrack)
this.selectedChapter = chapter
this.isLoadingChapter = true
const trackOffset = chapter.start - audioTrack.startOffset
this.playTrackAtTime(audioTrack, trackOffset)
},
playTrackAtTime(audioTrack, trackOffset) {
this.currentTrackIndex = audioTrack.index
const audioEl = this.audioEl || document.createElement('audio')
var src = audioTrack.contentUrl + `?token=${this.userToken}`
if (this.$isDev) {
src = `http://localhost:3333${src}`
}
console.log('src', src)
audioEl.src = src
audioEl.id = 'chapter-audio'
document.body.appendChild(audioEl)
audioEl.addEventListener('loadeddata', () => {
console.log('Audio loaded data', audioEl.duration)
audioEl.currentTime = trackOffset
audioEl.play()
console.log('Playing audio at current time', trackOffset)
})
audioEl.addEventListener('play', () => {
console.log('Audio playing')
this.isLoadingChapter = false
this.isPlayingChapter = true
})
audioEl.addEventListener('ended', () => {
console.log('Audio ended')
const nextTrack = this.tracks.find((t) => t.index === this.currentTrackIndex + 1)
if (nextTrack) {
console.log('Playing next track', nextTrack.index)
this.currentTrackIndex = nextTrack.index
this.playTrackAtTime(nextTrack, 0)
} else {
console.log('No next track')
this.destroyAudioEl()
}
})
this.audioEl = audioEl
},
destroyAudioEl() {
if (!this.audioEl) return
this.audioEl.remove()
this.audioEl = null
this.selectedChapter = null
this.isPlayingChapter = false
this.isLoadingChapter = false
},
saveChapters() {
this.checkChapters()
for (let i = 0; i < this.newChapters.length; i++) {
if (this.newChapters[i].error) {
this.$toast.error('Chapters have errors')
return
}
if (!this.newChapters[i].title) {
this.$toast.error('Chapters must have titles')
return
}
const nextChapter = this.newChapters[i + 1]
if (nextChapter) {
this.newChapters[i].end = nextChapter.start
} else {
this.newChapters[i].end = this.mediaDuration
}
}
this.saving = true
console.log('udpated chapters', this.newChapters)
const payload = {
chapters: this.newChapters
}
this.$axios
.$post(`/api/items/${this.libraryItem.id}/chapters`, payload)
.then((data) => {
this.saving = false
if (data.updated) {
this.$toast.success('Chapters updated')
this.$router.push(`/item/${this.libraryItem.id}`)
} else {
this.$toast.info('No changes needed updating')
}
})
.catch((error) => {
this.saving = false
console.error('Failed to update chapters', error)
this.$toast.error('Failed to update chapters')
})
},
applyChapterData() {
var index = 0
this.newChapters = this.chapterData.chapters
.filter((chap) => chap.startOffsetSec < this.mediaDuration)
.map((chap) => {
var chapEnd = Math.min(this.mediaDuration, (chap.startOffsetMs + chap.lengthMs) / 1000)
return {
id: index++,
start: chap.startOffsetMs / 1000,
end: chapEnd,
title: chap.title
}
})
this.showFindChaptersModal = false
this.chapterData = null
},
findChapters() {
if (!this.asinInput) {
this.$toast.error('Must input an ASIN')
return
}
this.findingChapters = true
this.chapterData = null
this.$axios
.$get(`/api/search/chapters?asin=${this.asinInput}`)
.then((data) => {
this.findingChapters = false
if (data.error) {
this.$toast.error(data.error)
this.showFindChaptersModal = false
} else {
console.log('Chapter data', data)
this.chapterData = data
}
})
.catch((error) => {
this.findingChapters = false
console.error('Failed to get chapter data', error)
this.$toast.error('Failed to find chapters')
this.showFindChaptersModal = false
})
}
},
mounted() {
this.asinInput = this.mediaMetadata.asin || null
this.newChapters = this.chapters.map((c) => ({ ...c }))
if (!this.newChapters.length) {
this.newChapters = [
{
id: 0,
start: 0,
end: this.mediaDuration,
title: ''
}
]
}
}
}
</script>
-3
View File
@@ -89,9 +89,6 @@ export default {
draggable
},
async asyncData({ store, params, app, redirect, route }) {
if (!store.state.user.user) {
return redirect(`/login?redirect=${route.path}`)
}
if (!store.getters['user/getUserCanUpdate']) {
return redirect('/?error=unauthorized')
}
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<div id="page-wrapper" class="page p-6 overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
<div id="page-wrapper" class="page p-2 md:p-6 overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
<app-config-side-nav :is-open.sync="sideDrawerOpen" />
<div class="configContent" :class="`page-${currentPage}`">
<div v-show="isMobile" class="w-full pb-4 px-2 flex border-b border-white border-opacity-10 mb-2">
+13 -6
View File
@@ -2,7 +2,7 @@
<div>
<div class="flex justify-center">
<div class="flex p-2">
<svg class="h-14 w-14 md:h-18 md:w-18" viewBox="0 0 24 24">
<svg class="hidden sm:block h-14 w-14 lg:h-18 lg:w-18" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M19 1L14 6V17L19 12.5V1M21 5V18.5C19.9 18.15 18.7 18 17.5 18C15.8 18 13.35 18.65 12 19.5V6C10.55 4.9 8.45 4.5 6.5 4.5C4.55 4.5 2.45 4.9 1 6V20.65C1 20.9 1.25 21.15 1.5 21.15C1.6 21.15 1.65 21.1 1.75 21.1C3.1 20.45 5.05 20 6.5 20C8.45 20 10.55 20.4 12 21.5C13.35 20.65 15.8 20 17.5 20C19.15 20 20.85 20.3 22.25 21.05C22.35 21.1 22.4 21.1 22.5 21.1C22.75 21.1 23 20.85 23 20.6V6C22.4 5.55 21.75 5.25 21 5M10 18.41C8.75 18.09 7.5 18 6.5 18C5.44 18 4.18 18.19 3 18.5V7.13C3.91 6.73 5.14 6.5 6.5 6.5C7.86 6.5 9.09 6.73 10 7.13V18.41Z"
@@ -15,7 +15,9 @@
</div>
<div class="flex p-2">
<span class="material-icons-outlined" style="font-size: 4.1rem">event</span>
<div class="hidden sm:block">
<span class="hidden sm:block material-icons-outlined text-5xl lg:text-6xl">event</span>
</div>
<div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ totalDaysListened }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Days Listened</p>
@@ -23,15 +25,17 @@
</div>
<div class="flex p-2">
<span class="material-icons-outlined" style="font-size: 4.1rem">watch_later</span>
<div class="hidden sm:block">
<span class="material-icons-outlined text-5xl lg:text-6xl">watch_later</span>
</div>
<div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ totalMinutesListening }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Minutes Listening</p>
</div>
</div>
</div>
<div class="flex flex-col md:flex-row">
<stats-daily-listening-chart :listening-stats="listeningStats" />
<div class="flex flex-col md:flex-row overflow-hidden max-w-full">
<stats-daily-listening-chart :listening-stats="listeningStats" class="origin-top-left transform scale-75 lg:scale-100" />
<div class="w-80 my-6 mx-auto">
<h1 class="text-2xl mb-4 font-book">Recent Listening Sessions</h1>
<p v-if="!mostRecentListeningSessions.length">No Listening Sessions</p>
@@ -52,6 +56,8 @@
</template>
</div>
</div>
<stats-heatmap v-if="listeningStats" :days-listening="listeningStats.days" class="my-2" />
</div>
</template>
@@ -59,7 +65,8 @@
export default {
data() {
return {
listeningStats: null
listeningStats: null,
windowWidth: 0
}
},
watch: {
+7 -1
View File
@@ -177,6 +177,8 @@
<tables-podcast-episodes-table v-if="isPodcast" :library-item="libraryItem" />
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" class="mt-6" />
<tables-library-files-table v-if="libraryFiles.length" :is-missing="isMissing" :library-item-id="libraryItemId" :files="libraryFiles" class="mt-6" />
</div>
</div>
@@ -275,6 +277,9 @@ export default {
mediaMetadata() {
return this.media.metadata || {}
},
chapters() {
return this.media.chapters || []
},
tracks() {
return this.media.tracks || []
},
@@ -378,7 +383,8 @@ export default {
return this.$store.getters['user/getUserCanDownload']
},
showRssFeedBtn() {
if (!this.showExperimentalFeatures) return false
if (!this.rssFeedUrl && !this.podcastEpisodes.length) return false // Cannot open RSS feed with no episodes
// If rss feed is open then show feed url to users otherwise just show to admins
return this.isPodcast && (this.userIsAdminOrUp || this.rssFeedUrl)
}
-9
View File
@@ -7,9 +7,6 @@
<app-book-shelf-categorized v-if="hasResults" ref="bookshelf" search :results="results" />
<div v-else class="w-full py-16">
<p class="text-xl text-center">No Search results for "{{ query }}"</p>
<div class="flex justify-center">
<ui-btn class="w-52 my-4" @click="back">Back</ui-btn>
</div>
</div>
</div>
</div>
@@ -79,12 +76,6 @@ export default {
this.$refs.bookshelf.setShelvesFromSearch()
}
})
},
async back() {
var popped = await this.$store.dispatch('popRoute')
if (popped) this.$store.commit('setIsRoutingBack', true)
var backTo = popped || '/'
this.$router.push(backTo)
}
},
mounted() {},
+7
View File
@@ -86,6 +86,13 @@ export default {
uploadFinished: false
}
},
watch: {
selectedLibrary(newVal) {
if (newVal && !this.selectedFolderId) {
this.setDefaultFolder()
}
}
},
computed: {
inputAccept() {
var extensions = []
+12 -4
View File
@@ -23,6 +23,11 @@ Vue.prototype.$addDaysToToday = (daysToAdd) => {
if (!date || !isDate(date)) return null
return date
}
Vue.prototype.$addDaysToDate = (jsdate, daysToAdd) => {
var date = addDays(jsdate, daysToAdd)
if (!date || !isDate(date)) return null
return date
}
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
if (isNaN(bytes) || bytes == 0) {
@@ -35,17 +40,20 @@ Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
}
Vue.prototype.$elapsedPretty = (seconds) => {
Vue.prototype.$elapsedPretty = (seconds, useFullNames = false) => {
if (seconds < 60) {
return `${Math.floor(seconds)} sec${useFullNames ? 'onds' : ''}`
}
var minutes = Math.floor(seconds / 60)
if (minutes < 70) {
return `${minutes} min`
return `${minutes} min${useFullNames ? `ute${minutes === 1 ? '' : 's'}` : ''}`
}
var hours = Math.floor(minutes / 60)
minutes -= hours * 60
if (!minutes) {
return `${hours} hr`
return `${hours} ${useFullNames ? 'hours' : 'hr'}`
}
return `${hours} hr ${minutes} min`
return `${hours} ${useFullNames ? `hour${hours === 1 ? '' : 's'}` : 'hr'} ${minutes} ${useFullNames ? `minute${minutes === 1 ? '' : 's'}` : 'min'}`
}
Vue.prototype.$secondsToTimestamp = (seconds) => {
-17
View File
@@ -15,8 +15,6 @@ export const state = () => ({
selectedLibraryItems: [],
processingBatch: false,
previousPath: '/',
routeHistory: [],
isRoutingBack: false,
showExperimentalFeatures: false,
backups: [],
bookshelfBookIds: [],
@@ -74,15 +72,6 @@ export const actions = {
return false
})
},
popRoute({ commit, state }) {
if (!state.routeHistory.length) {
return null
}
var _history = [...state.routeHistory]
var last = _history.pop()
commit('setRouteHistory', _history)
return last
},
setBookshelfTexture({ commit, state }, img) {
let root = document.documentElement;
commit('setBookshelfTexture', img)
@@ -94,12 +83,6 @@ export const mutations = {
setBookshelfBookIds(state, val) {
state.bookshelfBookIds = val || []
},
setRouteHistory(state, val) {
state.routeHistory = val
},
setIsRoutingBack(state, val) {
state.isRoutingBack = val
},
setPreviousPath(state, val) {
state.previousPath = val
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "2.0.12",
"version": "2.0.13",
"description": "Self-hosted audiobook server for managing and playing audiobooks",
"main": "index.js",
"scripts": {
-1
View File
@@ -140,7 +140,6 @@ class FolderWatcher extends EventEmitter {
return
}
Logger.debug(`[Watcher] Rename ${pathFrom} => ${pathTo}`)
this.addFileUpdate(libraryId, pathFrom, 'renamed')
this.addFileUpdate(libraryId, pathTo, 'renamed')
}
+31 -1
View File
@@ -359,7 +359,7 @@ class LibraryItemController {
})
}
// POST: api/items/:id/audio-metadata
// GET: api/items/:id/audio-metadata
async updateAudioFileMetadata(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[LibraryItemController] Non-root user attempted to update audio metadata`, req.user)
@@ -375,6 +375,36 @@ class LibraryItemController {
res.sendStatus(200)
}
// POST: api/items/:id/chapters
async updateMediaChapters(req, res) {
if (!req.user.canUpdate) {
Logger.error(`[LibraryItemController] User attempted to update chapters with invalid permissions`, req.user.username)
return res.sendStatus(403)
}
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
Logger.error(`[LibraryItemController] Invalid library item`)
return res.sendStatus(500)
}
const chapters = req.body.chapters || []
if (!chapters.length) {
Logger.error(`[LibraryItemController] Invalid payload`)
return res.sendStatus(400)
}
const wasUpdated = req.libraryItem.media.updateChapters(chapters)
if (wasUpdated) {
await this.db.updateLibraryItem(req.libraryItem)
this.emitter('item_updated', req.libraryItem.toJSONExpanded())
}
res.json({
success: true,
updated: wasUpdated
})
}
middleware(req, res, next) {
var item = this.db.libraryItems.find(li => li.id === req.params.id)
if (!item || !item.media) return res.sendStatus(404)
+9
View File
@@ -225,6 +225,15 @@ class MiscController {
res.json(author)
}
async findChapters(req, res) {
var asin = req.query.asin
var chapterData = await this.bookFinder.findChapters(asin)
if (!chapterData) {
return res.json({ error: 'Chapters not found' })
}
res.json(chapterData)
}
authorize(req, res) {
if (!req.user) {
Logger.error('Invalid user in authorize')
+6
View File
@@ -3,6 +3,7 @@ const LibGen = require('../providers/LibGen')
const GoogleBooks = require('../providers/GoogleBooks')
const Audible = require('../providers/Audible')
const iTunes = require('../providers/iTunes')
const Audnexus = require('../providers/Audnexus')
const Logger = require('../Logger')
const { levenshteinDistance } = require('../utils/index')
@@ -13,6 +14,7 @@ class BookFinder {
this.googleBooks = new GoogleBooks()
this.audible = new Audible()
this.iTunesApi = new iTunes()
this.audnexus = new Audnexus()
this.verbose = false
}
@@ -226,5 +228,9 @@ class BookFinder {
})
return covers
}
findChapters(asin) {
return this.audnexus.getChaptersByASIN(asin)
}
}
module.exports = BookFinder
@@ -1,4 +1,5 @@
const Path = require('path')
const date = require('date-and-time')
const { PlayMethod } = require('../utils/constants')
const PlaybackSession = require('../objects/PlaybackSession')
const Stream = require('../objects/Stream')
@@ -40,6 +41,10 @@ class PlaybackSessionManager {
async syncLocalSessionRequest(user, sessionJson, res) {
var libraryItem = this.db.getLibraryItem(sessionJson.libraryItemId)
if (!libraryItem) {
Logger.error(`[PlaybackSessionManager] syncLocalSessionRequest: Library item not found for session "${sessionJson.libraryItemId}"`)
return res.sendStatus(200)
}
var session = await this.db.getPlaybackSession(sessionJson.id)
if (!session) {
@@ -49,6 +54,8 @@ class PlaybackSessionManager {
} else {
session.timeListening = sessionJson.timeListening
session.updatedAt = sessionJson.updatedAt
session.date = date.format(new Date(), 'YYYY-MM-DD')
session.dayOfWeek = date.format(new Date(), 'dddd')
await this.db.updateEntity('session', session)
}
+12 -12
View File
@@ -183,8 +183,15 @@ class PodcastManager {
for (const libraryItem of podcastsWithAutoDownload) {
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0)
Logger.info(`[PodcastManager] checkForNewEpisodes Cron for "${libraryItem.media.metadata.title}" - Last episode check: ${lastEpisodeCheckDate}`)
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem)
const latestEpisodePublishedAt = libraryItem.media.latestEpisodePublished
Logger.info(`[PodcastManager] checkForNewEpisodes: "${libraryItem.media.metadata.title}" | Last check: ${lastEpisodeCheckDate} | ${latestEpisodePublishedAt ? `Latest episode pubDate: ${new Date(latestEpisodePublishedAt)}` : 'No latest episode'}`)
// Use latest episode pubDate if exists OR fallback to using lastEpisodeCheckDate
// lastEpisodeCheckDate will be the current time when adding a new podcast
const dateToCheckForEpisodesAfter = latestEpisodePublishedAt || lastEpisodeCheckDate
Logger.debug(`[PodcastManager] checkForNewEpisodes: "${libraryItem.media.metadata.title}" checking for episodes after ${new Date(dateToCheckForEpisodesAfter)}`)
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, dateToCheckForEpisodesAfter)
Logger.debug(`[PodcastManager] checkForNewEpisodes checked result ${newEpisodes ? newEpisodes.length : 'N/A'}`)
if (!newEpisodes) { // Failed
@@ -214,7 +221,7 @@ class PodcastManager {
}
}
async checkPodcastForNewEpisodes(podcastLibraryItem) {
async checkPodcastForNewEpisodes(podcastLibraryItem, dateToCheckForEpisodesAfter) {
if (!podcastLibraryItem.media.metadata.feedUrl) {
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`)
return false
@@ -225,15 +232,8 @@ class PodcastManager {
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
var newEpisodes = feed.episodes.filter(ep => ep.publishedAt > podcastLibraryItem.media.lastEpisodeCheck && !podcastLibraryItem.media.checkHasEpisodeByFeedUrl(ep.enclosure.url))
var newEpisodes = feed.episodes.filter(ep => ep.publishedAt > dateToCheckForEpisodesAfter && !podcastLibraryItem.media.checkHasEpisodeByFeedUrl(ep.enclosure.url))
// Max new episodes for safety = 3
newEpisodes = newEpisodes.slice(0, 3)
return newEpisodes
@@ -242,7 +242,7 @@ class PodcastManager {
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)
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, libraryItem.media.lastEpisodeCheck)
if (newEpisodes.length) {
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.metadata.title}" - starting download`)
this.downloadPodcastEpisodes(libraryItem, newEpisodes)
+44 -12
View File
@@ -153,6 +153,30 @@ class Book {
return hasUpdates
}
updateChapters(chapters) {
var hasUpdates = this.chapters.length !== chapters.length
if (hasUpdates) {
this.chapters = chapters.map(ch => ({
id: ch.id,
start: ch.start,
end: ch.end,
title: ch.title
}))
} else {
for (let i = 0; i < this.chapters.length; i++) {
const currChapter = this.chapters[i]
const newChapter = chapters[i]
if (!hasUpdates && (currChapter.title !== newChapter.title || currChapter.start !== newChapter.start || currChapter.end !== newChapter.end)) {
hasUpdates = true
}
this.chapters[i].title = newChapter.title
this.chapters[i].start = newChapter.start
this.chapters[i].end = newChapter.end
}
}
return hasUpdates
}
updateCover(coverPath) {
coverPath = coverPath.replace(/\\/g, '/')
if (this.coverPath === coverPath) return false
@@ -381,19 +405,27 @@ class Book {
// If audio file has chapters use chapters
if (file.chapters && file.chapters.length) {
file.chapters.forEach((chapter) => {
var chapterDuration = chapter.end - chapter.start
if (chapterDuration > 0) {
var title = `Chapter ${currChapterId}`
if (chapter.title) {
title += ` (${chapter.title})`
if (chapter.start > this.duration) {
Logger.warn(`[Book] Invalid chapter start time > duration`)
} else {
var chapterAlreadyExists = this.chapters.find(ch => ch.start === chapter.start)
if (!chapterAlreadyExists) {
var chapterDuration = chapter.end - chapter.start
if (chapterDuration > 0) {
var title = `Chapter ${currChapterId}`
if (chapter.title) {
title += ` (${chapter.title})`
}
var endTime = Math.min(this.duration, currStartTime + chapterDuration)
this.chapters.push({
id: currChapterId++,
start: currStartTime,
end: endTime,
title
})
currStartTime += chapterDuration
}
}
this.chapters.push({
id: currChapterId++,
start: currStartTime,
end: currStartTime + chapterDuration,
title
})
currStartTime += chapterDuration
}
})
} else if (file.duration) {
+9
View File
@@ -104,6 +104,15 @@ class Podcast {
get numTracks() {
return this.episodes.length
}
get latestEpisodePublished() {
var largestPublishedAt = 0
this.episodes.forEach((ep) => {
if (ep.publishedAt && ep.publishedAt > largestPublishedAt) {
largestPublishedAt = ep.publishedAt
}
})
return largestPublishedAt
}
update(payload) {
var json = this.toJSON()
+10
View File
@@ -45,5 +45,15 @@ class Audnexus {
name: author.name
}
}
async getChaptersByASIN(asin) {
Logger.debug(`[Audnexus] Get chapters for ASIN ${asin}`)
return axios.get(`${this.baseUrl}/books/${asin}/chapters`).then((res) => {
return res.data
}).catch((error) => {
Logger.error(`[Audnexus] Chapter ASIN request failed for ${asin}`, error)
return null
})
}
}
module.exports = Audnexus
+4 -2
View File
@@ -92,8 +92,9 @@ class ApiRouter {
this.router.patch('/items/:id/tracks', LibraryItemController.middleware.bind(this), LibraryItemController.updateTracks.bind(this))
this.router.patch('/items/:id/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.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.get('/items/:id/scan', LibraryItemController.middleware.bind(this), LibraryItemController.scan.bind(this))
this.router.get('/items/:id/audio-metadata', LibraryItemController.middleware.bind(this), LibraryItemController.updateAudioFileMetadata.bind(this))
this.router.post('/items/:id/chapters', LibraryItemController.middleware.bind(this), LibraryItemController.updateMediaChapters.bind(this))
this.router.post('/items/batch/delete', LibraryItemController.batchDelete.bind(this))
this.router.post('/items/batch/update', LibraryItemController.batchUpdate.bind(this))
@@ -204,6 +205,7 @@ class ApiRouter {
this.router.get('/search/books', MiscController.findBooks.bind(this))
this.router.get('/search/podcast', MiscController.findPodcasts.bind(this))
this.router.get('/search/authors', MiscController.findAuthor.bind(this))
this.router.get('/search/chapters', MiscController.findChapters.bind(this))
this.router.get('/tags', MiscController.getAllTags.bind(this))
}
+11
View File
@@ -5,6 +5,7 @@ const Path = require('path')
const Logger = require('../Logger')
const { groupFilesIntoLibraryItemPaths, getLibraryItemFileData, scanFolder } = require('../utils/scandir')
const { comparePaths } = require('../utils/index')
const { getIno } = require('../utils/fileUtils')
const { ScanResult, LogLevel } = require('../utils/constants')
const AudioFileScanner = require('./AudioFileScanner')
@@ -538,9 +539,19 @@ class Scanner {
var itemGroupingResults = {}
for (const itemDir in fileUpdateGroup) {
var fullPath = Path.posix.join(folder.fullPath.replace(/\\/g, '/'), itemDir)
const dirIno = await getIno(fullPath)
// Check if book dir group is already an item
var existingLibraryItem = this.db.libraryItems.find(li => fullPath.startsWith(li.path))
if (!existingLibraryItem) {
existingLibraryItem = this.db.libraryItems.find(li => li.ino === dirIno)
if (existingLibraryItem) {
Logger.debug(`[Scanner] scanFolderUpdates: Library item found by inode value "${existingLibraryItem.relPath} => ${itemDir}"`)
// Update library item paths for scan and all library item paths will get updated in LibraryItem.checkScanData
existingLibraryItem.path = fullPath
existingLibraryItem.relPath = itemDir
}
}
if (existingLibraryItem) {
// Is the item exactly - check if was deleted
if (existingLibraryItem.path === fullPath) {
+3
View File
@@ -64,6 +64,9 @@ module.exports.getId = (prepend = '') => {
}
function elapsedPretty(seconds) {
if (seconds < 60) {
return `${Math.floor(seconds)} sec`
}
var minutes = Math.floor(seconds / 60)
if (minutes < 70) {
return `${minutes} min`