mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-01-01 04:00:45 -05:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bd657f07d | ||
|
|
c3b33ea37a | ||
|
|
36bd6e649a | ||
|
|
4621c78573 | ||
|
|
c88bbf1ce4 | ||
|
|
d37b25a6f6 | ||
|
|
792268f5ee | ||
|
|
5f2d6f4d5e | ||
|
|
acf22ca4fa | ||
|
|
705aac40d7 | ||
|
|
7456052620 |
@@ -147,9 +147,6 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleBookshelfTexture() {
|
||||
this.$store.dispatch('setBookshelfTexture', 'wood2.png')
|
||||
},
|
||||
cancelSelectionMode() {
|
||||
if (this.processingBatchDelete) return
|
||||
this.$store.commit('setSelectedLibraryItems', [])
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
<div id="bookshelf" ref="wrapper" class="w-full max-w-full h-full overflow-y-scroll relative">
|
||||
<!-- Cover size widget -->
|
||||
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
||||
<!-- Experimental Bookshelf Texture -->
|
||||
<div v-show="showExperimentalFeatures && !isAlternativeBookshelfView" class="fixed bottom-4 right-28 z-40">
|
||||
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal"><p class="text-sm py-0.5">Texture</p></div>
|
||||
</div>
|
||||
|
||||
<div v-if="loaded && !shelves.length && !search" class="w-full flex flex-col items-center justify-center py-12">
|
||||
<p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
|
||||
@@ -100,9 +96,6 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
showBookshelfTextureModal() {
|
||||
this.$store.commit('globals/setShowBookshelfTextureModal', true)
|
||||
},
|
||||
async init() {
|
||||
this.wrapperClientWidth = this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0
|
||||
|
||||
|
||||
@@ -22,13 +22,6 @@
|
||||
</div>
|
||||
|
||||
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
||||
|
||||
<!-- Experimental Bookshelf Texture -->
|
||||
<div v-show="showExperimentalFeatures && !isAlternativeBookshelfView" class="fixed bottom-4 right-28 z-40">
|
||||
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal">
|
||||
<p class="text-sm py-0.5">Texture</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -206,9 +199,6 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
showBookshelfTextureModal() {
|
||||
this.$store.commit('globals/setShowBookshelfTextureModal', true)
|
||||
},
|
||||
clearFilter() {
|
||||
this.$store.dispatch('user/updateUserSettings', { filterBy: 'all' })
|
||||
},
|
||||
|
||||
@@ -73,6 +73,12 @@
|
||||
<p class="text-xs font-mono pb-0.5">{{ numIssues }}</p>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
|
||||
<div class="w-full h-12 px-1 py-2 border-t border-black border-opacity-20 absolute left-0" :style="{ bottom: streamLibraryItem ? '240px' : '65px' }">
|
||||
<p class="font-mono text-xs text-center text-gray-300 leading-3 mb-1">v{{ $config.version }}</p>
|
||||
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xxs text-center block leading-3">Update</a>
|
||||
<p v-else class="text-xxs text-gray-400 leading-3 text-center italic">{{ Source }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -82,6 +88,12 @@ export default {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
Source() {
|
||||
return this.$store.state.Source
|
||||
},
|
||||
isMobileLandscape() {
|
||||
return this.$store.state.globals.isMobileLandscape
|
||||
},
|
||||
isShowingBookshelfToolbar() {
|
||||
if (!this.$route.name) return false
|
||||
return this.$route.name.startsWith('library')
|
||||
@@ -131,6 +143,21 @@ export default {
|
||||
},
|
||||
numIssues() {
|
||||
return this.$store.state.libraries.issues || 0
|
||||
},
|
||||
versionData() {
|
||||
return this.$store.state.versionData || {}
|
||||
},
|
||||
hasUpdate() {
|
||||
return !!this.versionData.hasUpdate
|
||||
},
|
||||
latestVersion() {
|
||||
return this.versionData.latestVersion
|
||||
},
|
||||
githubTagUrl() {
|
||||
return this.versionData.githubTagUrl
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<template>
|
||||
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-40 bg-primary px-4 pb-1 md:pb-4 pt-2">
|
||||
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="absolute left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
|
||||
<div id="videoDock" />
|
||||
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
|
||||
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</nuxt-link>
|
||||
<div class="flex items-start pl-24 mb-6 md:mb-0">
|
||||
<div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : 'pl-24'">
|
||||
<div>
|
||||
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-base sm:text-lg">
|
||||
{{ title }}
|
||||
</nuxt-link>
|
||||
<div class="text-gray-400 flex items-center">
|
||||
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center">
|
||||
<span class="material-icons text-sm">person</span>
|
||||
<p v-if="podcastAuthor">{{ podcastAuthor }}</p>
|
||||
<p v-else-if="authors.length" class="pl-1.5 text-sm sm:text-base">
|
||||
@@ -25,7 +26,7 @@
|
||||
<div class="flex-grow" />
|
||||
<span class="material-icons px-2 py-1 md:p-4 cursor-pointer" @click="closePlayer">close</span>
|
||||
</div>
|
||||
<audio-player
|
||||
<player-ui
|
||||
ref="audioPlayer"
|
||||
:chapters="chapters"
|
||||
:paused="!isPlaying"
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="textures" :width="'40vw'" :height="'unset'" :bg-opacity="10" :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">Bookshelf Texture</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="px-4 w-full max-w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300" @mousedown.prevent @mouseup.prevent @mousemove.prevent>
|
||||
<h1 class="text-2xl mb-2">Select a bookshelf texture (For testing only)</h1>
|
||||
<div class="overflow-y-hidden overflow-x-auto">
|
||||
<div class="flex -mx-1">
|
||||
<template v-for="texture in textures">
|
||||
<div :key="texture" class="relative mx-1" style="height: 180px; width: 180px; min-width: 180px" @mousedown.prevent @mouseup.prevent>
|
||||
<img :src="texture" class="h-full object-cover cursor-pointer" @click="setTexture(texture)" />
|
||||
<div v-if="texture === selectedBookshelfTexture" class="absolute top-0 left-0 flex items-center justify-center w-full h-full bg-black bg-opacity-10">
|
||||
<span class="material-icons text-4xl text-success">check</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="flex pt-4">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" type="submit">Submit</ui-btn>
|
||||
</div> -->
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
textures: ['/textures/wood_default.jpg', '/textures/wood1.png', '/textures/wood2.png', '/textures/wood3.png', '/textures/wood4.png', '/textures/leather1.jpg'],
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.$store.state.globals.showBookshelfTextureModal
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit('globals/setShowBookshelfTextureModal', val)
|
||||
}
|
||||
},
|
||||
selectedBookshelfTexture() {
|
||||
return this.$store.state.selectedBookshelfTexture
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
init() {},
|
||||
setTexture(img) {
|
||||
this.$store.dispatch('setBookshelfTexture', img)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -437,7 +437,7 @@ export default {
|
||||
}
|
||||
this.isProcessing = true
|
||||
|
||||
if (updatePayload.cover) {
|
||||
if (updatePayload.metadata.cover) {
|
||||
var coverPayload = {
|
||||
url: updatePayload.metadata.cover
|
||||
}
|
||||
|
||||
65
client/components/player/PlayerPlaybackControls.vue
Normal file
65
client/components/player/PlayerPlaybackControls.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="flex pt-4 pb-2 md:pt-0 md:pb-2">
|
||||
<div class="flex-grow" />
|
||||
<template v-if="!loading">
|
||||
<div class="cursor-pointer flex items-center justify-center text-gray-300 mr-8" @mousedown.prevent @mouseup.prevent @click.stop="restart">
|
||||
<span class="material-icons text-3xl">first_page</span>
|
||||
</div>
|
||||
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
|
||||
<span class="material-icons text-3xl">replay_10</span>
|
||||
</div>
|
||||
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
|
||||
<span class="material-icons">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
|
||||
</div>
|
||||
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
|
||||
<span class="material-icons text-3xl">forward_10</span>
|
||||
</div>
|
||||
<controls-playback-speed-control v-model="playbackRate" @input="playbackRateUpdated" @change="playbackRateChanged" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8 animate-spin">
|
||||
<span class="material-icons">autorenew</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
loading: Boolean,
|
||||
seekLoading: Boolean,
|
||||
playbackRate: Number,
|
||||
paused: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
playPause() {
|
||||
this.$emit('playPause')
|
||||
},
|
||||
restart() {
|
||||
this.$emit('restart')
|
||||
},
|
||||
jumpBackward() {
|
||||
this.$emit('jumpBackward')
|
||||
},
|
||||
jumpForward() {
|
||||
this.$emit('jumpForward')
|
||||
},
|
||||
playbackRateUpdated(playbackRate) {
|
||||
this.$emit('setPlaybackRate', playbackRate)
|
||||
},
|
||||
playbackRateChanged(playbackRate) {
|
||||
this.$emit('setPlaybackRate', playbackRate)
|
||||
this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => {
|
||||
console.error('Failed to update settings', err)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
185
client/components/player/PlayerTrackBar.vue
Normal file
185
client/components/player/PlayerTrackBar.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<!-- Track -->
|
||||
<div ref="track" class="w-full h-2 bg-gray-700 relative cursor-pointer transform duration-100 hover:scale-y-125 overflow-hidden" @mousemove="mousemoveTrack" @mouseleave="mouseleaveTrack" @click.stop="clickTrack">
|
||||
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="bufferTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="trackCursor" class="h-full w-0.5 bg-gray-50 absolute top-0 left-0 opacity-0 pointer-events-none" />
|
||||
<div v-if="loading" class="h-full w-1/4 absolute left-0 top-0 loadingTrack pointer-events-none bg-white bg-opacity-25" />
|
||||
</div>
|
||||
<div ref="track" class="w-full h-2 relative overflow-hidden">
|
||||
<template v-for="(tick, index) in chapterTicks">
|
||||
<div :key="index" :style="{ left: tick.left + 'px' }" class="absolute top-0 w-px bg-white bg-opacity-30 h-1 pointer-events-none" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Hover timestamp -->
|
||||
<div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
|
||||
<p ref="hoverTimestampText" class="text-xs font-mono text-center px-2 py-0.5 truncate whitespace-nowrap">00:00</p>
|
||||
</div>
|
||||
<div ref="hoverTimestampArrow" class="absolute -top-3 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
|
||||
<div class="absolute -bottom-1.5 left-0 right-0 w-full flex justify-center">
|
||||
<div class="arrow-down" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
loading: Boolean,
|
||||
duration: Number,
|
||||
chapters: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
trackWidth: 0,
|
||||
currentTime: 0,
|
||||
percentReady: 0,
|
||||
bufferTime: 0,
|
||||
chapterTicks: [],
|
||||
trackOffsetLeft: 16, // Track is 16px from edge
|
||||
playedTrackWidth: 0,
|
||||
readyTrackWidth: 0,
|
||||
bufferTrackWidth: 0
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
duration: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
this.setChapterTicks()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
clickTrack(e) {
|
||||
if (this.loading) return
|
||||
|
||||
var offsetX = e.offsetX
|
||||
var perc = offsetX / this.trackWidth
|
||||
var time = perc * this.duration
|
||||
if (isNaN(time) || time === null) {
|
||||
console.error('Invalid time', perc, time)
|
||||
return
|
||||
}
|
||||
this.$emit('seek', time)
|
||||
},
|
||||
setBufferTime(time) {
|
||||
this.bufferTime = time
|
||||
this.updateBufferTrack()
|
||||
},
|
||||
updateBufferTrack() {
|
||||
var bufferlen = (this.bufferTime / this.duration) * this.trackWidth
|
||||
bufferlen = Math.round(bufferlen)
|
||||
if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
|
||||
if (this.$refs.bufferTrack) this.$refs.bufferTrack.style.width = bufferlen + 'px'
|
||||
this.bufferTrackWidth = bufferlen
|
||||
},
|
||||
setPercentageReady(percent) {
|
||||
this.percentReady = percent
|
||||
this.updateReadyTrack()
|
||||
},
|
||||
updateReadyTrack() {
|
||||
var widthReady = Math.round(this.trackWidth * this.percentReady)
|
||||
if (this.readyTrackWidth === widthReady) return
|
||||
this.readyTrackWidth = widthReady
|
||||
if (this.$refs.readyTrack) this.$refs.readyTrack.style.width = widthReady + 'px'
|
||||
},
|
||||
setCurrentTime(time) {
|
||||
this.currentTime = time
|
||||
this.updatePlayedTrackWidth()
|
||||
},
|
||||
updatePlayedTrackWidth() {
|
||||
var perc = this.currentTime / this.duration
|
||||
var ptWidth = Math.round(perc * this.trackWidth)
|
||||
if (this.playedTrackWidth === ptWidth) {
|
||||
return
|
||||
}
|
||||
if (this.$refs.playedTrack) this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||
this.playedTrackWidth = ptWidth
|
||||
},
|
||||
setChapterTicks() {
|
||||
this.chapterTicks = this.chapters.map((chap) => {
|
||||
var perc = chap.start / this.duration
|
||||
return {
|
||||
title: chap.title,
|
||||
left: perc * this.trackWidth
|
||||
}
|
||||
})
|
||||
},
|
||||
mousemoveTrack(e) {
|
||||
var offsetX = e.offsetX
|
||||
var time = (offsetX / this.trackWidth) * this.duration
|
||||
|
||||
console.log('Mousemove track', this.trackWidth, this.duration)
|
||||
|
||||
if (this.$refs.hoverTimestamp) {
|
||||
var width = this.$refs.hoverTimestamp.clientWidth
|
||||
this.$refs.hoverTimestamp.style.opacity = 1
|
||||
var posLeft = offsetX - width / 2
|
||||
if (posLeft + width + this.trackOffsetLeft > window.innerWidth) {
|
||||
posLeft = window.innerWidth - width - this.trackOffsetLeft
|
||||
} else if (posLeft < -this.trackOffsetLeft) {
|
||||
posLeft = -this.trackOffsetLeft
|
||||
}
|
||||
this.$refs.hoverTimestamp.style.left = posLeft + 'px'
|
||||
}
|
||||
|
||||
if (this.$refs.hoverTimestampArrow) {
|
||||
var width = this.$refs.hoverTimestampArrow.clientWidth
|
||||
var posLeft = offsetX - width / 2
|
||||
this.$refs.hoverTimestampArrow.style.opacity = 1
|
||||
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
|
||||
}
|
||||
if (this.$refs.hoverTimestampText) {
|
||||
var hoverText = this.$secondsToTimestamp(time)
|
||||
|
||||
var chapter = this.chapters.find((chapter) => chapter.start <= time && time < chapter.end)
|
||||
if (chapter && chapter.title) {
|
||||
hoverText += ` - ${chapter.title}`
|
||||
}
|
||||
this.$refs.hoverTimestampText.innerText = hoverText
|
||||
}
|
||||
if (this.$refs.trackCursor) {
|
||||
this.$refs.trackCursor.style.opacity = 1
|
||||
this.$refs.trackCursor.style.left = offsetX - 1 + 'px'
|
||||
}
|
||||
},
|
||||
mouseleaveTrack() {
|
||||
if (this.$refs.hoverTimestamp) {
|
||||
this.$refs.hoverTimestamp.style.opacity = 0
|
||||
}
|
||||
if (this.$refs.hoverTimestampArrow) {
|
||||
this.$refs.hoverTimestampArrow.style.opacity = 0
|
||||
}
|
||||
if (this.$refs.trackCursor) {
|
||||
this.$refs.trackCursor.style.opacity = 0
|
||||
}
|
||||
},
|
||||
setTrackWidth() {
|
||||
if (this.$refs.track) {
|
||||
this.trackWidth = this.$refs.track.clientWidth
|
||||
} else {
|
||||
console.error('Track not loaded', this.$refs)
|
||||
}
|
||||
},
|
||||
windowResize() {
|
||||
this.setTrackWidth()
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.setTrackWidth()
|
||||
window.addEventListener('resize', this.windowResize)
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.windowResize)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -2,6 +2,8 @@
|
||||
<div class="w-full -mt-6">
|
||||
<div class="w-full relative mb-1">
|
||||
<div class="absolute -top-10 md:top-0 right-0 md:right-2 flex items-center h-full">
|
||||
<span class="material-icons text-2xl cursor-pointer" @click="toggleFullscreen(true)">expand_less</span>
|
||||
|
||||
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" class="mx-2 hidden md:block" />
|
||||
|
||||
<div class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showSleepTimer')">
|
||||
@@ -21,57 +23,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex pt-4 pb-2 md:pt-0 md:pb-2">
|
||||
<div class="flex-grow" />
|
||||
<template v-if="!loading">
|
||||
<div class="cursor-pointer flex items-center justify-center text-gray-300 mr-8" @mousedown.prevent @mouseup.prevent @click.stop="restart">
|
||||
<span class="material-icons text-3xl">first_page</span>
|
||||
</div>
|
||||
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
|
||||
<span class="material-icons text-3xl">replay_10</span>
|
||||
</div>
|
||||
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
|
||||
<span class="material-icons">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
|
||||
</div>
|
||||
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
|
||||
<span class="material-icons text-3xl">forward_10</span>
|
||||
</div>
|
||||
<controls-playback-speed-control v-model="playbackRate" @input="playbackRateUpdated" @change="playbackRateChanged" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8 animate-spin">
|
||||
<span class="material-icons">autorenew</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
<player-playback-controls :loading="loading" :seek-loading="seekLoading" :playback-rate="playbackRate" :paused="paused" @restart="restart" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" />
|
||||
</div>
|
||||
<div class="relative">
|
||||
<!-- Track -->
|
||||
<div ref="track" class="w-full h-2 bg-gray-700 relative cursor-pointer transform duration-100 hover:scale-y-125 overflow-hidden" @mousemove="mousemoveTrack" @mouseleave="mouseleaveTrack" @click.stop="clickTrack">
|
||||
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="bufferTrack" class="h-full bg-gray-400 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="trackCursor" class="h-full w-0.5 bg-gray-50 absolute top-0 left-0 opacity-0 pointer-events-none" />
|
||||
<div v-if="loading" class="h-full w-1/4 absolute left-0 top-0 loadingTrack pointer-events-none bg-white bg-opacity-25" />
|
||||
</div>
|
||||
<div ref="track" class="w-full h-2 relative overflow-hidden">
|
||||
<template v-for="(tick, index) in chapterTicks">
|
||||
<div :key="index" :style="{ left: tick.left + 'px' }" class="absolute top-0 w-px bg-white bg-opacity-30 h-1 pointer-events-none" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Hover timestamp -->
|
||||
<div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
|
||||
<p ref="hoverTimestampText" class="text-xs font-mono text-center px-2 py-0.5 truncate whitespace-nowrap">00:00</p>
|
||||
</div>
|
||||
<div ref="hoverTimestampArrow" class="absolute -top-3 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
|
||||
<div class="absolute -bottom-1.5 left-0 right-0 w-full flex justify-center">
|
||||
<div class="arrow-down" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" @seek="seek" />
|
||||
|
||||
<div class="flex">
|
||||
<p ref="currentTimestamp" class="font-mono text-sm text-gray-100 pointer-events-auto">00:00:00</p>
|
||||
<p class="font-mono text-sm text-gray-100 pointer-events-auto"> / {{ progressPercent }}%</p>
|
||||
@@ -106,17 +62,11 @@ export default {
|
||||
return {
|
||||
volume: 1,
|
||||
playbackRate: 1,
|
||||
trackWidth: 0,
|
||||
playedTrackWidth: 0,
|
||||
bufferTrackWidth: 0,
|
||||
readyTrackWidth: 0,
|
||||
audioEl: null,
|
||||
seekLoading: false,
|
||||
showChaptersModal: false,
|
||||
currentTime: 0,
|
||||
trackOffsetLeft: 16, // Track is 16px from edge
|
||||
duration: 0,
|
||||
chapterTicks: []
|
||||
duration: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -153,24 +103,38 @@ export default {
|
||||
},
|
||||
currentChapterName() {
|
||||
return this.currentChapter ? this.currentChapter.title : ''
|
||||
},
|
||||
isFullscreen() {
|
||||
return this.$store.state.playerIsFullscreen
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleFullscreen(isFullscreen) {
|
||||
this.$store.commit('setPlayerIsFullscreen', isFullscreen)
|
||||
|
||||
var videoPlayerEl = document.getElementById('video-player')
|
||||
if (videoPlayerEl) {
|
||||
if (isFullscreen) {
|
||||
videoPlayerEl.style.width = '100vw'
|
||||
videoPlayerEl.style.height = '100vh'
|
||||
videoPlayerEl.style.top = '0px'
|
||||
videoPlayerEl.style.left = '0px'
|
||||
} else {
|
||||
videoPlayerEl.style.width = '384px'
|
||||
videoPlayerEl.style.height = '216px'
|
||||
videoPlayerEl.style.top = 'unset'
|
||||
videoPlayerEl.style.bottom = '80px'
|
||||
videoPlayerEl.style.left = '16px'
|
||||
}
|
||||
}
|
||||
},
|
||||
setDuration(duration) {
|
||||
this.duration = duration
|
||||
|
||||
this.chapterTicks = this.chapters.map((chap) => {
|
||||
var perc = chap.start / this.duration
|
||||
return {
|
||||
title: chap.title,
|
||||
left: perc * this.trackWidth
|
||||
}
|
||||
})
|
||||
},
|
||||
setCurrentTime(time) {
|
||||
this.currentTime = time
|
||||
this.updateTimestamp()
|
||||
this.updatePlayedTrack()
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setCurrentTime(time)
|
||||
},
|
||||
playPause() {
|
||||
this.$emit('playPause')
|
||||
@@ -223,67 +187,11 @@ export default {
|
||||
seek(time) {
|
||||
this.$emit('seek', time)
|
||||
},
|
||||
playbackRateUpdated(playbackRate) {
|
||||
this.setPlaybackRate(playbackRate)
|
||||
},
|
||||
playbackRateChanged(playbackRate) {
|
||||
this.setPlaybackRate(playbackRate)
|
||||
this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => {
|
||||
console.error('Failed to update settings', err)
|
||||
})
|
||||
},
|
||||
mousemoveTrack(e) {
|
||||
var offsetX = e.offsetX
|
||||
var time = (offsetX / this.trackWidth) * this.duration
|
||||
if (this.$refs.hoverTimestamp) {
|
||||
var width = this.$refs.hoverTimestamp.clientWidth
|
||||
this.$refs.hoverTimestamp.style.opacity = 1
|
||||
var posLeft = offsetX - width / 2
|
||||
if (posLeft + width + this.trackOffsetLeft > window.innerWidth) {
|
||||
posLeft = window.innerWidth - width - this.trackOffsetLeft
|
||||
} else if (posLeft < -this.trackOffsetLeft) {
|
||||
posLeft = -this.trackOffsetLeft
|
||||
}
|
||||
this.$refs.hoverTimestamp.style.left = posLeft + 'px'
|
||||
}
|
||||
|
||||
if (this.$refs.hoverTimestampArrow) {
|
||||
var width = this.$refs.hoverTimestampArrow.clientWidth
|
||||
var posLeft = offsetX - width / 2
|
||||
this.$refs.hoverTimestampArrow.style.opacity = 1
|
||||
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
|
||||
}
|
||||
if (this.$refs.hoverTimestampText) {
|
||||
var hoverText = this.$secondsToTimestamp(time)
|
||||
|
||||
var chapter = this.chapters.find((chapter) => chapter.start <= time && time < chapter.end)
|
||||
if (chapter && chapter.title) {
|
||||
hoverText += ` - ${chapter.title}`
|
||||
}
|
||||
this.$refs.hoverTimestampText.innerText = hoverText
|
||||
}
|
||||
if (this.$refs.trackCursor) {
|
||||
this.$refs.trackCursor.style.opacity = 1
|
||||
this.$refs.trackCursor.style.left = offsetX - 1 + 'px'
|
||||
}
|
||||
},
|
||||
mouseleaveTrack() {
|
||||
if (this.$refs.hoverTimestamp) {
|
||||
this.$refs.hoverTimestamp.style.opacity = 0
|
||||
}
|
||||
if (this.$refs.hoverTimestampArrow) {
|
||||
this.$refs.hoverTimestampArrow.style.opacity = 0
|
||||
}
|
||||
if (this.$refs.trackCursor) {
|
||||
this.$refs.trackCursor.style.opacity = 0
|
||||
}
|
||||
},
|
||||
restart() {
|
||||
this.seek(0)
|
||||
},
|
||||
setStreamReady() {
|
||||
this.readyTrackWidth = this.trackWidth
|
||||
this.$refs.readyTrack.style.width = this.trackWidth + 'px'
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(1)
|
||||
},
|
||||
setChunksReady(chunks, numSegments) {
|
||||
var largestSeg = 0
|
||||
@@ -298,10 +206,7 @@ export default {
|
||||
}
|
||||
}
|
||||
var percentageReady = largestSeg / numSegments
|
||||
var widthReady = Math.round(this.trackWidth * percentageReady)
|
||||
if (this.readyTrackWidth === widthReady) return
|
||||
this.readyTrackWidth = widthReady
|
||||
this.$refs.readyTrack.style.width = widthReady + 'px'
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(percentageReady)
|
||||
},
|
||||
updateTimestamp() {
|
||||
var ts = this.$refs.currentTimestamp
|
||||
@@ -312,36 +217,9 @@ export default {
|
||||
var currTimeClean = this.$secondsToTimestamp(this.currentTime)
|
||||
ts.innerText = currTimeClean
|
||||
},
|
||||
updatePlayedTrack() {
|
||||
var perc = this.currentTime / this.duration
|
||||
var ptWidth = Math.round(perc * this.trackWidth)
|
||||
if (this.playedTrackWidth === ptWidth) {
|
||||
return
|
||||
}
|
||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||
this.playedTrackWidth = ptWidth
|
||||
},
|
||||
clickTrack(e) {
|
||||
if (this.loading) return
|
||||
|
||||
var offsetX = e.offsetX
|
||||
var perc = offsetX / this.trackWidth
|
||||
var time = perc * this.duration
|
||||
if (isNaN(time) || time === null) {
|
||||
console.error('Invalid time', perc, time)
|
||||
return
|
||||
}
|
||||
this.seek(time)
|
||||
},
|
||||
setBufferTime(bufferTime) {
|
||||
if (!this.audioEl) {
|
||||
return
|
||||
}
|
||||
var bufferlen = (bufferTime / this.duration) * this.trackWidth
|
||||
bufferlen = Math.round(bufferlen)
|
||||
if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
|
||||
this.$refs.bufferTrack.style.width = bufferlen + 'px'
|
||||
this.bufferTrackWidth = bufferlen
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime)
|
||||
},
|
||||
showChapters() {
|
||||
if (!this.chapters.length) return
|
||||
@@ -350,14 +228,6 @@ export default {
|
||||
init() {
|
||||
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
|
||||
this.$emit('setPlaybackRate', this.playbackRate)
|
||||
this.setTrackWidth()
|
||||
},
|
||||
setTrackWidth() {
|
||||
if (this.$refs.track) {
|
||||
this.trackWidth = this.$refs.track.clientWidth
|
||||
} else {
|
||||
console.error('Track not loaded', this.$refs)
|
||||
}
|
||||
},
|
||||
settingsUpdated(settings) {
|
||||
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
|
||||
@@ -365,6 +235,11 @@ export default {
|
||||
}
|
||||
},
|
||||
closePlayer() {
|
||||
if (this.isFullscreen) {
|
||||
this.toggleFullscreen(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.loading) return
|
||||
this.$emit('close')
|
||||
},
|
||||
@@ -379,19 +254,14 @@ export default {
|
||||
else if (action === this.$hotkeys.AudioPlayer.INCREASE_PLAYBACK_RATE) this.increasePlaybackRate()
|
||||
else if (action === this.$hotkeys.AudioPlayer.DECREASE_PLAYBACK_RATE) this.decreasePlaybackRate()
|
||||
else if (action === this.$hotkeys.AudioPlayer.CLOSE) this.closePlayer()
|
||||
},
|
||||
windowResize() {
|
||||
this.setTrackWidth()
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener('resize', this.windowResize)
|
||||
this.$store.commit('user/addSettingsListener', { id: 'audioplayer', meth: this.settingsUpdated })
|
||||
this.init()
|
||||
this.$eventBus.$on('player-hotkey', this.hotkey)
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.windowResize)
|
||||
this.$store.commit('user/removeSettingsListener', 'audioplayer')
|
||||
this.$eventBus.$off('player-hotkey', this.hotkey)
|
||||
}
|
||||
@@ -12,7 +12,6 @@
|
||||
<modals-item-edit-modal />
|
||||
<modals-user-collections-modal />
|
||||
<modals-edit-collection-modal />
|
||||
<modals-bookshelf-texture-modal />
|
||||
<modals-podcast-edit-episode />
|
||||
<modals-podcast-view-episode />
|
||||
<modals-authors-edit-modal />
|
||||
@@ -516,23 +515,12 @@ export default {
|
||||
this.$store.commit('globals/updateWindowSize', { width: window.innerWidth, height: window.innerHeight })
|
||||
},
|
||||
checkVersionUpdate() {
|
||||
// Version check is only run if time since last check was 5 minutes
|
||||
const VERSION_CHECK_BUFF = 1000 * 60 * 5 // 5 minutes
|
||||
var lastVerCheck = localStorage.getItem('lastVerCheck') || 0
|
||||
if (Date.now() - Number(lastVerCheck) > VERSION_CHECK_BUFF) {
|
||||
this.$store
|
||||
.dispatch('checkForUpdate')
|
||||
.then((res) => {
|
||||
localStorage.setItem('lastVerCheck', Date.now())
|
||||
if (res && res.hasUpdate) this.showUpdateToast(res)
|
||||
})
|
||||
.catch((err) => console.error(err))
|
||||
|
||||
if (this.$route.query.error) {
|
||||
this.$toast.error(this.$route.query.error)
|
||||
this.$router.replace(this.$route.path)
|
||||
}
|
||||
}
|
||||
this.$store
|
||||
.dispatch('checkForUpdate')
|
||||
.then((res) => {
|
||||
if (res && res.hasUpdate) this.showUpdateToast(res)
|
||||
})
|
||||
.catch((err) => console.error(err))
|
||||
}
|
||||
},
|
||||
beforeMount() {
|
||||
@@ -552,6 +540,11 @@ export default {
|
||||
}
|
||||
|
||||
this.checkVersionUpdate()
|
||||
|
||||
if (this.$route.query.error) {
|
||||
this.$toast.error(this.$route.query.error)
|
||||
this.$router.replace(this.$route.path)
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.resize)
|
||||
|
||||
@@ -31,11 +31,13 @@
|
||||
<p v-if="bookSubtitle" class="sm:ml-4 text-gray-400 text-xl md:text-2xl">{{ bookSubtitle }}</p>
|
||||
</div>
|
||||
|
||||
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">by {{ podcastAuthor || 'Unknown' }}</p>
|
||||
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">
|
||||
by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||
</p>
|
||||
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
|
||||
<template v-if="!isVideo">
|
||||
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">by {{ podcastAuthor || 'Unknown' }}</p>
|
||||
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">
|
||||
by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||
</p>
|
||||
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
|
||||
</template>
|
||||
|
||||
<nuxt-link v-for="_series in seriesList" :key="_series.id" :to="`/library/${libraryId}/series/${_series.id}`" class="hover:underline font-sans text-gray-300 text-lg leading-7"> {{ _series.text }}</nuxt-link>
|
||||
|
||||
@@ -251,6 +253,9 @@ export default {
|
||||
isPodcast() {
|
||||
return this.libraryItem.mediaType === 'podcast'
|
||||
},
|
||||
isVideo() {
|
||||
return this.libraryItem.mediaType === 'video'
|
||||
},
|
||||
isMissing() {
|
||||
return this.libraryItem.isMissing
|
||||
},
|
||||
@@ -258,11 +263,12 @@ export default {
|
||||
return this.libraryItem.isInvalid
|
||||
},
|
||||
invalidAudioFiles() {
|
||||
if (this.isPodcast) return []
|
||||
if (this.isPodcast || this.isVideo) return []
|
||||
return this.libraryItem.media.audioFiles.filter((af) => af.invalid)
|
||||
},
|
||||
showPlayButton() {
|
||||
if (this.isMissing || this.isInvalid) return false
|
||||
if (this.isVideo) return !!this.videoFile
|
||||
if (this.isPodcast) return this.podcastEpisodes.length
|
||||
return this.tracks.length
|
||||
},
|
||||
@@ -348,6 +354,9 @@ export default {
|
||||
ebookFile() {
|
||||
return this.media.ebookFile
|
||||
},
|
||||
videoFile() {
|
||||
return this.media.videoFile
|
||||
},
|
||||
showExperimentalReadAlert() {
|
||||
return !this.tracks.length && this.ebookFile && !this.showExperimentalFeatures && !this.enableEReader
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Hls from 'hls.js'
|
||||
import EventEmitter from 'events'
|
||||
|
||||
export default class LocalPlayer extends EventEmitter {
|
||||
export default class LocalAudioPlayer extends EventEmitter {
|
||||
constructor(ctx) {
|
||||
super()
|
||||
|
||||
260
client/players/LocalVideoPlayer.js
Normal file
260
client/players/LocalVideoPlayer.js
Normal file
@@ -0,0 +1,260 @@
|
||||
import Hls from 'hls.js'
|
||||
import EventEmitter from 'events'
|
||||
|
||||
export default class LocalVideoPlayer extends EventEmitter {
|
||||
constructor(ctx) {
|
||||
super()
|
||||
|
||||
this.ctx = ctx
|
||||
this.player = null
|
||||
|
||||
this.libraryItem = null
|
||||
this.videoTrack = null
|
||||
this.isHlsTranscode = null
|
||||
this.hlsInstance = null
|
||||
this.usingNativeplayer = false
|
||||
this.startTime = 0
|
||||
this.playWhenReady = false
|
||||
this.defaultPlaybackRate = 1
|
||||
|
||||
this.playableMimeTypes = []
|
||||
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
initialize() {
|
||||
if (document.getElementById('video-player')) {
|
||||
document.getElementById('video-player').remove()
|
||||
}
|
||||
var videoEl = document.createElement('video')
|
||||
videoEl.id = 'video-player'
|
||||
// videoEl.style.display = 'none'
|
||||
videoEl.className = 'absolute bg-black z-50'
|
||||
videoEl.style.height = '216px'
|
||||
videoEl.style.width = '384px'
|
||||
videoEl.style.bottom = '80px'
|
||||
videoEl.style.left = '16px'
|
||||
document.body.appendChild(videoEl)
|
||||
this.player = videoEl
|
||||
|
||||
this.player.addEventListener('play', this.evtPlay.bind(this))
|
||||
this.player.addEventListener('pause', this.evtPause.bind(this))
|
||||
this.player.addEventListener('progress', this.evtProgress.bind(this))
|
||||
this.player.addEventListener('ended', this.evtEnded.bind(this))
|
||||
this.player.addEventListener('error', this.evtError.bind(this))
|
||||
this.player.addEventListener('loadedmetadata', this.evtLoadedMetadata.bind(this))
|
||||
this.player.addEventListener('timeupdate', this.evtTimeupdate.bind(this))
|
||||
|
||||
var mimeTypes = ['video/mp4']
|
||||
var mimeTypeCanPlayMap = {}
|
||||
mimeTypes.forEach((mt) => {
|
||||
var canPlay = this.player.canPlayType(mt)
|
||||
mimeTypeCanPlayMap[mt] = canPlay
|
||||
if (canPlay) this.playableMimeTypes.push(mt)
|
||||
})
|
||||
console.log(`[LocalVideoPlayer] Supported mime types`, mimeTypeCanPlayMap, this.playableMimeTypes)
|
||||
}
|
||||
|
||||
evtPlay() {
|
||||
this.emit('stateChange', 'PLAYING')
|
||||
}
|
||||
evtPause() {
|
||||
this.emit('stateChange', 'PAUSED')
|
||||
}
|
||||
evtProgress() {
|
||||
var lastBufferTime = this.getLastBufferedTime()
|
||||
this.emit('buffertimeUpdate', lastBufferTime)
|
||||
}
|
||||
evtEnded() {
|
||||
console.log(`[LocalVideoPlayer] Ended`)
|
||||
this.emit('finished')
|
||||
}
|
||||
evtError(error) {
|
||||
console.error('Player error', error)
|
||||
this.emit('error', error)
|
||||
}
|
||||
evtLoadedMetadata(data) {
|
||||
if (!this.isHlsTranscode) {
|
||||
this.player.currentTime = this.startTime
|
||||
}
|
||||
|
||||
this.emit('stateChange', 'LOADED')
|
||||
if (this.playWhenReady) {
|
||||
this.playWhenReady = false
|
||||
this.play()
|
||||
}
|
||||
}
|
||||
evtTimeupdate() {
|
||||
if (this.player.paused) {
|
||||
this.emit('timeupdate', this.getCurrentTime())
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyHlsInstance()
|
||||
if (this.player) {
|
||||
this.player.remove()
|
||||
}
|
||||
}
|
||||
|
||||
set(libraryItem, videoTrack, isHlsTranscode, startTime, playWhenReady = false) {
|
||||
this.libraryItem = libraryItem
|
||||
this.videoTrack = videoTrack
|
||||
this.isHlsTranscode = isHlsTranscode
|
||||
this.playWhenReady = playWhenReady
|
||||
this.startTime = startTime
|
||||
|
||||
if (this.hlsInstance) {
|
||||
this.destroyHlsInstance()
|
||||
}
|
||||
|
||||
if (this.isHlsTranscode) {
|
||||
this.setHlsStream()
|
||||
} else {
|
||||
this.setDirectPlay()
|
||||
}
|
||||
}
|
||||
|
||||
setHlsStream() {
|
||||
// iOS does not support Media Elements but allows for HLS in the native video player
|
||||
if (!Hls.isSupported()) {
|
||||
console.warn('HLS is not supported - fallback to using video element')
|
||||
this.usingNativeplayer = true
|
||||
this.player.src = this.videoTrack.relativeContentUrl
|
||||
this.player.currentTime = this.startTime
|
||||
return
|
||||
}
|
||||
|
||||
var hlsOptions = {
|
||||
startPosition: this.startTime || -1
|
||||
// No longer needed because token is put in a query string
|
||||
// xhrSetup: (xhr) => {
|
||||
// xhr.setRequestHeader('Authorization', `Bearer ${this.token}`)
|
||||
// }
|
||||
}
|
||||
this.hlsInstance = new Hls(hlsOptions)
|
||||
|
||||
this.hlsInstance.attachMedia(this.player)
|
||||
this.hlsInstance.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||
this.hlsInstance.loadSource(this.videoTrack.relativeContentUrl)
|
||||
|
||||
this.hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
console.log('[HLS] Manifest Parsed')
|
||||
})
|
||||
|
||||
this.hlsInstance.on(Hls.Events.ERROR, (e, data) => {
|
||||
console.error('[HLS] Error', data.type, data.details, data)
|
||||
if (data.details === Hls.ErrorDetails.BUFFER_STALLED_ERROR) {
|
||||
console.error('[HLS] BUFFER STALLED ERROR')
|
||||
}
|
||||
})
|
||||
this.hlsInstance.on(Hls.Events.DESTROYING, () => {
|
||||
console.log('[HLS] Destroying HLS Instance')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
setDirectPlay() {
|
||||
this.player.src = this.videoTrack.relativeContentUrl
|
||||
console.log(`[LocalVideoPlayer] Loading track src ${this.videoTrack.relativeContentUrl}`)
|
||||
this.player.load()
|
||||
}
|
||||
|
||||
destroyHlsInstance() {
|
||||
if (!this.hlsInstance) return
|
||||
if (this.hlsInstance.destroy) {
|
||||
var temp = this.hlsInstance
|
||||
temp.destroy()
|
||||
}
|
||||
this.hlsInstance = null
|
||||
}
|
||||
|
||||
async resetStream(startTime) {
|
||||
this.destroyHlsInstance()
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
this.set(this.libraryItem, this.videoTrack, this.isHlsTranscode, startTime, true)
|
||||
}
|
||||
|
||||
playPause() {
|
||||
if (!this.player) return
|
||||
if (this.player.paused) this.play()
|
||||
else this.pause()
|
||||
}
|
||||
|
||||
play() {
|
||||
if (this.player) this.player.play()
|
||||
}
|
||||
|
||||
pause() {
|
||||
if (this.player) this.player.pause()
|
||||
}
|
||||
|
||||
getCurrentTime() {
|
||||
return this.player ? this.player.currentTime : 0
|
||||
}
|
||||
|
||||
getDuration() {
|
||||
return this.videoTrack.duration
|
||||
}
|
||||
|
||||
setPlaybackRate(playbackRate) {
|
||||
if (!this.player) return
|
||||
this.defaultPlaybackRate = playbackRate
|
||||
this.player.playbackRate = playbackRate
|
||||
}
|
||||
|
||||
seek(time) {
|
||||
if (!this.player) return
|
||||
this.player.currentTime = Math.max(0, time)
|
||||
}
|
||||
|
||||
setVolume(volume) {
|
||||
if (!this.player) return
|
||||
this.player.volume = volume
|
||||
}
|
||||
|
||||
// Utils
|
||||
isValidDuration(duration) {
|
||||
if (duration && !isNaN(duration) && duration !== Number.POSITIVE_INFINITY && duration !== Number.NEGATIVE_INFINITY) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
getBufferedRanges() {
|
||||
if (!this.player) return []
|
||||
const ranges = []
|
||||
const seekable = this.player.buffered || []
|
||||
|
||||
let offset = 0
|
||||
|
||||
for (let i = 0, length = seekable.length; i < length; i++) {
|
||||
let start = seekable.start(i)
|
||||
let end = seekable.end(i)
|
||||
if (!this.isValidDuration(start)) {
|
||||
start = 0
|
||||
}
|
||||
if (!this.isValidDuration(end)) {
|
||||
end = 0
|
||||
continue
|
||||
}
|
||||
|
||||
ranges.push({
|
||||
start: start + offset,
|
||||
end: end + offset
|
||||
})
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
|
||||
getLastBufferedTime() {
|
||||
var bufferedRanges = this.getBufferedRanges()
|
||||
if (!bufferedRanges.length) return 0
|
||||
|
||||
var buff = bufferedRanges.find((buff) => buff.start < this.player.currentTime && buff.end > this.player.currentTime)
|
||||
if (buff) return buff.end
|
||||
|
||||
var last = bufferedRanges[bufferedRanges.length - 1]
|
||||
return last.end
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import LocalPlayer from './LocalPlayer'
|
||||
import LocalAudioPlayer from './LocalAudioPlayer'
|
||||
import LocalVideoPlayer from './LocalVideoPlayer'
|
||||
import CastPlayer from './CastPlayer'
|
||||
import AudioTrack from './AudioTrack'
|
||||
import VideoTrack from './VideoTrack'
|
||||
|
||||
export default class PlayerHandler {
|
||||
constructor(ctx) {
|
||||
@@ -14,6 +16,7 @@ export default class PlayerHandler {
|
||||
this.player = null
|
||||
this.playerState = 'IDLE'
|
||||
this.isHlsTranscode = false
|
||||
this.isVideo = false
|
||||
this.currentSessionId = null
|
||||
this.startTime = 0
|
||||
|
||||
@@ -34,7 +37,7 @@ export default class PlayerHandler {
|
||||
return this.libraryItem && (this.player instanceof CastPlayer)
|
||||
}
|
||||
get isPlayingLocalItem() {
|
||||
return this.libraryItem && (this.player instanceof LocalPlayer)
|
||||
return this.libraryItem && (this.player instanceof LocalAudioPlayer)
|
||||
}
|
||||
get userToken() {
|
||||
return this.ctx.$store.getters['user/getToken']
|
||||
@@ -48,16 +51,17 @@ export default class PlayerHandler {
|
||||
}
|
||||
|
||||
load(libraryItem, episodeId, playWhenReady, playbackRate) {
|
||||
if (!this.player) this.switchPlayer()
|
||||
|
||||
this.libraryItem = libraryItem
|
||||
this.episodeId = episodeId
|
||||
this.playWhenReady = playWhenReady
|
||||
this.initialPlaybackRate = playbackRate
|
||||
this.prepare()
|
||||
this.isVideo = libraryItem.mediaType === 'video'
|
||||
|
||||
if (!this.player) this.switchPlayer(playWhenReady)
|
||||
else this.prepare()
|
||||
}
|
||||
|
||||
switchPlayer() {
|
||||
switchPlayer(playWhenReady) {
|
||||
if (this.isCasting && !(this.player instanceof CastPlayer)) {
|
||||
console.log('[PlayerHandler] Switching to cast player')
|
||||
|
||||
@@ -73,10 +77,10 @@ export default class PlayerHandler {
|
||||
|
||||
if (this.libraryItem) {
|
||||
// libraryItem was already loaded - prepare for cast
|
||||
this.playWhenReady = false
|
||||
this.playWhenReady = playWhenReady
|
||||
this.prepare()
|
||||
}
|
||||
} else if (!this.isCasting && !(this.player instanceof LocalPlayer)) {
|
||||
} else if (!this.isCasting && !(this.player instanceof LocalAudioPlayer) && !(this.player instanceof LocalVideoPlayer)) {
|
||||
console.log('[PlayerHandler] Switching to local player')
|
||||
|
||||
this.stopPlayInterval()
|
||||
@@ -85,12 +89,18 @@ export default class PlayerHandler {
|
||||
if (this.player) {
|
||||
this.player.destroy()
|
||||
}
|
||||
this.player = new LocalPlayer(this.ctx)
|
||||
|
||||
if (this.isVideo) {
|
||||
this.player = new LocalVideoPlayer(this.ctx)
|
||||
} else {
|
||||
this.player = new LocalAudioPlayer(this.ctx)
|
||||
}
|
||||
|
||||
this.setPlayerListeners()
|
||||
|
||||
if (this.libraryItem) {
|
||||
// libraryItem was already loaded - prepare for local play
|
||||
this.playWhenReady = false
|
||||
this.playWhenReady = playWhenReady
|
||||
this.prepare()
|
||||
}
|
||||
}
|
||||
@@ -106,7 +116,7 @@ export default class PlayerHandler {
|
||||
|
||||
playerError() {
|
||||
// Switch to HLS stream on error
|
||||
if (!this.isCasting && !this.currentStreamId && (this.player instanceof LocalPlayer)) {
|
||||
if (!this.isCasting && !this.currentStreamId && (this.player instanceof LocalAudioPlayer)) {
|
||||
console.log(`[PlayerHandler] Audio player error switching to HLS stream`)
|
||||
this.prepare(true)
|
||||
}
|
||||
@@ -155,7 +165,7 @@ export default class PlayerHandler {
|
||||
supportedMimeTypes: this.player.playableMimeTypes,
|
||||
mediaPlayer: this.isCasting ? 'chromecast' : 'html5',
|
||||
forceTranscode,
|
||||
forceDirectPlay: this.isCasting // TODO: add transcode support for chromecast
|
||||
forceDirectPlay: this.isCasting || this.isVideo // TODO: add transcode support for chromecast
|
||||
}
|
||||
|
||||
var path = this.episodeId ? `/api/items/${this.libraryItem.id}/play/${this.episodeId}` : `/api/items/${this.libraryItem.id}/play`
|
||||
@@ -166,11 +176,12 @@ export default class PlayerHandler {
|
||||
}
|
||||
|
||||
prepareOpenSession(session, playbackRate) { // Session opened on init socket
|
||||
if (!this.player) this.switchPlayer()
|
||||
|
||||
this.libraryItem = session.libraryItem
|
||||
this.isVideo = session.libraryItem.mediaType === 'video'
|
||||
this.playWhenReady = false
|
||||
this.initialPlaybackRate = playbackRate
|
||||
|
||||
if (!this.player) this.switchPlayer()
|
||||
this.prepareSession(session)
|
||||
}
|
||||
|
||||
@@ -181,16 +192,29 @@ export default class PlayerHandler {
|
||||
this.displayAuthor = session.displayAuthor
|
||||
|
||||
console.log('[PlayerHandler] Preparing Session', session)
|
||||
var audioTracks = session.audioTracks.map(at => new AudioTrack(at, this.userToken))
|
||||
|
||||
this.ctx.playerLoading = true
|
||||
this.isHlsTranscode = true
|
||||
if (session.playMethod === this.ctx.$constants.PlayMethod.DIRECTPLAY) {
|
||||
this.isHlsTranscode = false
|
||||
if (session.videoTrack) {
|
||||
var videoTrack = new VideoTrack(session.videoTrack, this.userToken)
|
||||
|
||||
this.ctx.playerLoading = true
|
||||
this.isHlsTranscode = true
|
||||
if (session.playMethod === this.ctx.$constants.PlayMethod.DIRECTPLAY) {
|
||||
this.isHlsTranscode = false
|
||||
}
|
||||
|
||||
this.player.set(this.libraryItem, videoTrack, this.isHlsTranscode, this.startTime, this.playWhenReady)
|
||||
} else {
|
||||
var audioTracks = session.audioTracks.map(at => new AudioTrack(at, this.userToken))
|
||||
|
||||
this.ctx.playerLoading = true
|
||||
this.isHlsTranscode = true
|
||||
if (session.playMethod === this.ctx.$constants.PlayMethod.DIRECTPLAY) {
|
||||
this.isHlsTranscode = false
|
||||
}
|
||||
|
||||
this.player.set(this.libraryItem, audioTracks, this.isHlsTranscode, this.startTime, this.playWhenReady)
|
||||
}
|
||||
|
||||
this.player.set(this.libraryItem, audioTracks, this.isHlsTranscode, this.startTime, this.playWhenReady)
|
||||
|
||||
// browser media session api
|
||||
this.ctx.setMediaSession()
|
||||
}
|
||||
|
||||
32
client/players/VideoTrack.js
Normal file
32
client/players/VideoTrack.js
Normal file
@@ -0,0 +1,32 @@
|
||||
export default class VideoTrack {
|
||||
constructor(track, userToken) {
|
||||
this.index = track.index || 0
|
||||
this.startOffset = track.startOffset || 0 // Total time of all previous tracks
|
||||
this.duration = track.duration || 0
|
||||
this.title = track.title || ''
|
||||
this.contentUrl = track.contentUrl || null
|
||||
this.mimeType = track.mimeType
|
||||
this.metadata = track.metadata || {}
|
||||
|
||||
this.userToken = userToken
|
||||
}
|
||||
|
||||
get fullContentUrl() {
|
||||
if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return `${process.env.serverUrl}${this.contentUrl}?token=${this.userToken}`
|
||||
}
|
||||
return `${window.location.origin}${this.contentUrl}?token=${this.userToken}`
|
||||
}
|
||||
|
||||
get relativeContentUrl() {
|
||||
if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return `${process.env.serverUrl}${this.contentUrl}?token=${this.userToken}`
|
||||
}
|
||||
|
||||
return this.contentUrl + `?token=${this.userToken}`
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
const SupportedFileTypes = {
|
||||
image: ['png', 'jpg', 'jpeg', 'webp'],
|
||||
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'mp4', 'aac', 'wma', 'aiff'],
|
||||
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'mp4', 'aac', 'wma', 'aiff', 'wav'],
|
||||
ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
|
||||
info: ['nfo'],
|
||||
text: ['txt'],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Vue from 'vue'
|
||||
import Path from 'path'
|
||||
import vClickOutside from 'v-click-outside'
|
||||
import { formatDistance, format, addDays, isDate } from 'date-fns'
|
||||
|
||||
@@ -119,20 +120,36 @@ Vue.prototype.$sanitizeFilename = (input, colonReplacement = ' - ') => {
|
||||
if (typeof input !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
// Max is actually 255-260 for windows but this leaves padding incase ext wasnt put on yet
|
||||
const MAX_FILENAME_LEN = 240
|
||||
|
||||
var replacement = ''
|
||||
var illegalRe = /[\/\?<>\\:\*\|"]/g;
|
||||
var controlRe = /[\x00-\x1f\x80-\x9f]/g;
|
||||
var reservedRe = /^\.+$/;
|
||||
var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i;
|
||||
var windowsTrailingRe = /[\. ]+$/;
|
||||
var illegalRe = /[\/\?<>\\:\*\|"]/g
|
||||
var controlRe = /[\x00-\x1f\x80-\x9f]/g
|
||||
var reservedRe = /^\.+$/
|
||||
var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i
|
||||
var windowsTrailingRe = /[\. ]+$/
|
||||
var lineBreaks = /[\n\r]/g
|
||||
|
||||
var sanitized = input
|
||||
.replace(':', colonReplacement) // Replace first occurrence of a colon
|
||||
.replace(illegalRe, replacement)
|
||||
.replace(controlRe, replacement)
|
||||
.replace(reservedRe, replacement)
|
||||
.replace(lineBreaks, replacement)
|
||||
.replace(windowsReservedRe, replacement)
|
||||
.replace(windowsTrailingRe, replacement);
|
||||
.replace(windowsTrailingRe, replacement)
|
||||
|
||||
|
||||
if (sanitized.length > MAX_FILENAME_LEN) {
|
||||
var lenToRemove = sanitized.length - MAX_FILENAME_LEN
|
||||
var ext = Path.extname(sanitized)
|
||||
var basename = Path.basename(sanitized, ext)
|
||||
basename = basename.slice(0, basename.length - lenToRemove)
|
||||
sanitized = basename + ext
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
|
||||
@@ -23,14 +23,17 @@ function parseSemver(ver) {
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const currentVersion = packagejson.version
|
||||
|
||||
export async function checkForUpdate() {
|
||||
if (!packagejson.version) {
|
||||
return
|
||||
return null
|
||||
}
|
||||
var currVerObj = parseSemver('v' + packagejson.version)
|
||||
if (!currVerObj) {
|
||||
console.error('Invalid version', packagejson.version)
|
||||
return
|
||||
return null
|
||||
}
|
||||
var largestVer = null
|
||||
await axios.get(`https://api.github.com/repos/advplyr/audiobookshelf/releases`).then((res) => {
|
||||
@@ -49,7 +52,7 @@ export async function checkForUpdate() {
|
||||
})
|
||||
if (!largestVer) {
|
||||
console.error('No valid version tags to compare with')
|
||||
return
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 57 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 141 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 209 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 512 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 132 KiB |
@@ -11,7 +11,6 @@ export const state = () => ({
|
||||
selectedEpisode: null,
|
||||
selectedCollection: null,
|
||||
selectedAuthor: null,
|
||||
showBookshelfTextureModal: false,
|
||||
isCasting: false, // Actively casting
|
||||
isChromecastInitialized: false // Script loaded
|
||||
})
|
||||
@@ -64,9 +63,6 @@ export const mutations = {
|
||||
setSelectedEpisode(state, episode) {
|
||||
state.selectedEpisode = episode
|
||||
},
|
||||
setShowBookshelfTextureModal(state, val) {
|
||||
state.showBookshelfTextureModal = val
|
||||
},
|
||||
showEditAuthorModal(state, author) {
|
||||
state.selectedAuthor = author
|
||||
state.showEditAuthorModal = true
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { checkForUpdate } from '@/plugins/version'
|
||||
import { checkForUpdate, currentVersion } from '@/plugins/version'
|
||||
import Vue from 'vue'
|
||||
|
||||
export const state = () => ({
|
||||
@@ -8,6 +8,7 @@ export const state = () => ({
|
||||
streamLibraryItem: null,
|
||||
streamEpisodeId: null,
|
||||
streamIsPlaying: false,
|
||||
playerIsFullscreen: false,
|
||||
editModalTab: 'details',
|
||||
showEditModal: false,
|
||||
showEReader: false,
|
||||
@@ -21,7 +22,6 @@ export const state = () => ({
|
||||
bookshelfBookIds: [],
|
||||
openModal: null,
|
||||
innerModalOpen: false,
|
||||
selectedBookshelfTexture: '/textures/wood_default.jpg',
|
||||
lastBookshelfScrollData: {}
|
||||
})
|
||||
|
||||
@@ -65,20 +65,44 @@ export const actions = {
|
||||
})
|
||||
},
|
||||
checkForUpdate({ commit }) {
|
||||
return checkForUpdate()
|
||||
.then((res) => {
|
||||
commit('setVersionData', res)
|
||||
return res
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Update check failed', error)
|
||||
return false
|
||||
})
|
||||
},
|
||||
setBookshelfTexture({ commit, state }, img) {
|
||||
let root = document.documentElement;
|
||||
commit('setBookshelfTexture', img)
|
||||
root.style.setProperty('--bookshelf-texture-img', `url(${img})`);
|
||||
const VERSION_CHECK_BUFF = 1000 * 60 * 5 // 5 minutes
|
||||
var lastVerCheck = localStorage.getItem('lastVerCheck') || 0
|
||||
var savedVersionData = localStorage.getItem('versionData')
|
||||
if (savedVersionData) {
|
||||
try {
|
||||
savedVersionData = JSON.parse(localStorage.getItem('versionData'))
|
||||
} catch (error) {
|
||||
console.error('Failed to parse version data', error)
|
||||
savedVersionData = null
|
||||
localStorage.removeItem('versionData')
|
||||
}
|
||||
}
|
||||
|
||||
var shouldCheckForUpdate = Date.now() - Number(lastVerCheck) > VERSION_CHECK_BUFF
|
||||
if (!shouldCheckForUpdate && savedVersionData && savedVersionData.version !== currentVersion) {
|
||||
// Version mismatch between saved data so check for update anyway
|
||||
shouldCheckForUpdate = true
|
||||
}
|
||||
|
||||
if (shouldCheckForUpdate) {
|
||||
return checkForUpdate()
|
||||
.then((res) => {
|
||||
if (res) {
|
||||
localStorage.setItem('lastVerCheck', Date.now())
|
||||
localStorage.setItem('versionData', JSON.stringify(res))
|
||||
|
||||
commit('setVersionData', res)
|
||||
}
|
||||
return res && res.hasUpdate
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Update check failed', error)
|
||||
return false
|
||||
})
|
||||
} else if (savedVersionData) {
|
||||
commit('setVersionData', savedVersionData)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +110,9 @@ export const mutations = {
|
||||
setSource(state, source) {
|
||||
state.Source = source
|
||||
},
|
||||
setPlayerIsFullscreen(state, val) {
|
||||
state.playerIsFullscreen = val
|
||||
},
|
||||
setLastBookshelfScrollData(state, { scrollTop, path, name }) {
|
||||
state.lastBookshelfScrollData[name] = { scrollTop, path }
|
||||
},
|
||||
@@ -180,8 +207,5 @@ export const mutations = {
|
||||
},
|
||||
setInnerModalOpen(state, val) {
|
||||
state.innerModalOpen = val
|
||||
},
|
||||
setBookshelfTexture(state, val) {
|
||||
state.selectedBookshelfTexture = val
|
||||
}
|
||||
}
|
||||
@@ -209,6 +209,7 @@ class Server {
|
||||
const dyanimicRoutes = [
|
||||
'/item/:id',
|
||||
'/item/:id/manage',
|
||||
'/author/:id',
|
||||
'/audiobook/:id/chapters',
|
||||
'/audiobook/:id/edit',
|
||||
'/library/:library',
|
||||
|
||||
@@ -184,7 +184,7 @@ class LibraryItemController {
|
||||
|
||||
// POST: api/items/:id/play
|
||||
startPlaybackSession(req, res) {
|
||||
if (!req.libraryItem.media.numTracks) {
|
||||
if (!req.libraryItem.media.numTracks && req.libraryItem.mediaType !== 'video') {
|
||||
Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${req.libraryItem.id}`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
@@ -219,7 +219,11 @@ class PodcastController {
|
||||
})
|
||||
}
|
||||
|
||||
libraryItem.media.removeEpisode(episodeId)
|
||||
// Remove episode from Podcast and library file
|
||||
const episodeRemoved = libraryItem.media.removeEpisode(episodeId)
|
||||
if (episodeRemoved && episodeRemoved.audioFile) {
|
||||
libraryItem.removeLibraryFile(episodeRemoved.audioFile.ino)
|
||||
}
|
||||
|
||||
await this.db.updateLibraryItem(libraryItem)
|
||||
this.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
|
||||
@@ -119,29 +119,38 @@ class PlaybackSessionManager {
|
||||
const newPlaybackSession = new PlaybackSession()
|
||||
newPlaybackSession.setData(libraryItem, user, mediaPlayer, deviceInfo, userStartTime, episodeId)
|
||||
|
||||
var audioTracks = []
|
||||
if (shouldDirectPlay) {
|
||||
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}"`)
|
||||
audioTracks = libraryItem.getDirectPlayTracklist(episodeId)
|
||||
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
|
||||
if (libraryItem.mediaType === 'video') {
|
||||
if (shouldDirectPlay) {
|
||||
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}"`)
|
||||
newPlaybackSession.videoTrack = libraryItem.media.getVideoTrack()
|
||||
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
|
||||
} else {
|
||||
// HLS not supported for video yet
|
||||
}
|
||||
} else {
|
||||
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting stream session for item "${libraryItem.id}"`)
|
||||
var stream = new Stream(newPlaybackSession.id, this.StreamsPath, user, libraryItem, episodeId, userStartTime, this.clientEmitter.bind(this))
|
||||
await stream.generatePlaylist()
|
||||
stream.start() // Start transcode
|
||||
var audioTracks = []
|
||||
if (shouldDirectPlay) {
|
||||
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}"`)
|
||||
audioTracks = libraryItem.getDirectPlayTracklist(episodeId)
|
||||
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
|
||||
} else {
|
||||
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting stream session for item "${libraryItem.id}"`)
|
||||
var stream = new Stream(newPlaybackSession.id, this.StreamsPath, user, libraryItem, episodeId, userStartTime, this.clientEmitter.bind(this))
|
||||
await stream.generatePlaylist()
|
||||
stream.start() // Start transcode
|
||||
|
||||
audioTracks = [stream.getAudioTrack()]
|
||||
newPlaybackSession.stream = stream
|
||||
newPlaybackSession.playMethod = PlayMethod.TRANSCODE
|
||||
audioTracks = [stream.getAudioTrack()]
|
||||
newPlaybackSession.stream = stream
|
||||
newPlaybackSession.playMethod = PlayMethod.TRANSCODE
|
||||
|
||||
stream.on('closed', () => {
|
||||
Logger.debug(`[PlaybackSessionManager] Stream closed for session "${newPlaybackSession.id}"`)
|
||||
newPlaybackSession.stream = null
|
||||
})
|
||||
stream.on('closed', () => {
|
||||
Logger.debug(`[PlaybackSessionManager] Stream closed for session "${newPlaybackSession.id}"`)
|
||||
newPlaybackSession.stream = null
|
||||
})
|
||||
}
|
||||
newPlaybackSession.audioTracks = audioTracks
|
||||
}
|
||||
|
||||
newPlaybackSession.audioTracks = audioTracks
|
||||
|
||||
// Will save on the first sync
|
||||
user.currentSessionId = newPlaybackSession.id
|
||||
|
||||
|
||||
@@ -143,13 +143,13 @@ class PodcastManager {
|
||||
|
||||
async probeAudioFile(libraryFile) {
|
||||
var path = libraryFile.metadata.path
|
||||
var audioProbeData = await prober.probe(path)
|
||||
if (audioProbeData.error) {
|
||||
Logger.error(`[PodcastManager] Podcast Episode downloaded but failed to probe "${path}"`, audioProbeData.error)
|
||||
var mediaProbeData = await prober.probe(path)
|
||||
if (mediaProbeData.error) {
|
||||
Logger.error(`[PodcastManager] Podcast Episode downloaded but failed to probe "${path}"`, mediaProbeData.error)
|
||||
return false
|
||||
}
|
||||
var newAudioFile = new AudioFile()
|
||||
newAudioFile.setDataFromProbe(libraryFile, audioProbeData)
|
||||
newAudioFile.setDataFromProbe(libraryFile, mediaProbeData)
|
||||
return newAudioFile
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ class Library {
|
||||
else if (this.icon.endsWith('s') && availableIcons.includes(this.icon.slice(0, -1))) this.icon = this.icon.slice(0, -1)
|
||||
else this.icon = 'database'
|
||||
}
|
||||
if (!this.mediaType || (this.mediaType !== 'podcast' && this.mediaType !== 'book')) {
|
||||
if (!this.mediaType || (this.mediaType !== 'podcast' && this.mediaType !== 'book' && this.mediaType !== 'video')) {
|
||||
this.mediaType = 'book'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ const abmetadataGenerator = require('../utils/abmetadataGenerator')
|
||||
const LibraryFile = require('./files/LibraryFile')
|
||||
const Book = require('./mediaTypes/Book')
|
||||
const Podcast = require('./mediaTypes/Podcast')
|
||||
const Video = require('./mediatypes/Video')
|
||||
const { areEquivalent, copyValue, getId } = require('../utils/index')
|
||||
|
||||
class LibraryItem {
|
||||
@@ -67,11 +68,12 @@ class LibraryItem {
|
||||
this.mediaType = libraryItem.mediaType
|
||||
if (this.mediaType === 'book') {
|
||||
this.media = new Book(libraryItem.media)
|
||||
this.media.libraryItemId = this.id
|
||||
} else if (this.mediaType === 'podcast') {
|
||||
this.media = new Podcast(libraryItem.media)
|
||||
this.media.libraryItemId = this.id
|
||||
} else if (this.mediaType === 'video') {
|
||||
this.media = new Video(libraryItem.media)
|
||||
}
|
||||
this.media.libraryItemId = this.id
|
||||
|
||||
this.libraryFiles = libraryItem.libraryFiles.map(f => new LibraryFile(f))
|
||||
}
|
||||
@@ -175,16 +177,15 @@ class LibraryItem {
|
||||
// Data comes from scandir library item data
|
||||
setData(libraryMediaType, payload) {
|
||||
this.id = getId('li')
|
||||
if (libraryMediaType === 'podcast') {
|
||||
this.mediaType = 'podcast'
|
||||
this.mediaType = libraryMediaType
|
||||
if (libraryMediaType === 'video') {
|
||||
this.media = new Video()
|
||||
} else if (libraryMediaType === 'podcast') {
|
||||
this.media = new Podcast()
|
||||
this.media.libraryItemId = this.id
|
||||
} else {
|
||||
this.mediaType = 'book'
|
||||
this.media = new Book()
|
||||
this.media.libraryItemId = this.id
|
||||
}
|
||||
|
||||
this.media.libraryItemId = this.id
|
||||
|
||||
for (const key in payload) {
|
||||
if (key === 'libraryFiles') {
|
||||
@@ -460,6 +461,8 @@ class LibraryItem {
|
||||
|
||||
// Saves metadata.abs file
|
||||
async saveMetadata() {
|
||||
if (this.mediaType === 'video') return
|
||||
|
||||
if (this.isSavingMetadata) return
|
||||
this.isSavingMetadata = true
|
||||
|
||||
@@ -479,5 +482,16 @@ class LibraryItem {
|
||||
return success
|
||||
})
|
||||
}
|
||||
|
||||
removeLibraryFile(ino) {
|
||||
if (!ino) return false
|
||||
var libraryFile = this.libraryFiles.find(lf => lf.ino === ino)
|
||||
if (libraryFile) {
|
||||
this.libraryFiles = this.libraryFiles.filter(lf => lf.ino !== ino)
|
||||
this.updatedAt = Date.now()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
module.exports = LibraryItem
|
||||
@@ -4,6 +4,7 @@ const { PlayMethod } = require('../utils/constants')
|
||||
const BookMetadata = require('./metadata/BookMetadata')
|
||||
const PodcastMetadata = require('./metadata/PodcastMetadata')
|
||||
const DeviceInfo = require('./DeviceInfo')
|
||||
const VideoMetadata = require('./metadata/VideoMetadata')
|
||||
|
||||
class PlaybackSession {
|
||||
constructor(session) {
|
||||
@@ -38,6 +39,7 @@ class PlaybackSession {
|
||||
// Not saved in DB
|
||||
this.lastSave = 0
|
||||
this.audioTracks = []
|
||||
this.videoTrack = null
|
||||
this.stream = null
|
||||
|
||||
if (session) {
|
||||
@@ -97,6 +99,7 @@ class PlaybackSession {
|
||||
startedAt: this.startedAt,
|
||||
updatedAt: this.updatedAt,
|
||||
audioTracks: this.audioTracks.map(at => at.toJSON()),
|
||||
videoTrack: this.videoTrack ? this.videoTrack.toJSON() : null,
|
||||
libraryItem: libraryItem.toJSONExpanded()
|
||||
}
|
||||
}
|
||||
@@ -120,6 +123,8 @@ class PlaybackSession {
|
||||
this.mediaMetadata = new BookMetadata(session.mediaMetadata)
|
||||
} else if (this.mediaType === 'podcast') {
|
||||
this.mediaMetadata = new PodcastMetadata(session.mediaMetadata)
|
||||
} else if (this.mediaType === 'video') {
|
||||
this.mediaMetadata = new VideoMetadata(session.mediaMetadata)
|
||||
}
|
||||
}
|
||||
this.displayTitle = session.displayTitle || ''
|
||||
|
||||
@@ -21,7 +21,6 @@ class PodcastEpisodeDownload {
|
||||
toJSONForClient() {
|
||||
return {
|
||||
id: this.id,
|
||||
// podcastEpisode: this.podcastEpisode ? this.podcastEpisode.toJSON() : null,
|
||||
episodeDisplayTitle: this.podcastEpisode ? this.podcastEpisode.bestFilename : null,
|
||||
url: this.url,
|
||||
libraryItemId: this.libraryItem ? this.libraryItem.id : null,
|
||||
|
||||
@@ -40,13 +40,14 @@ class LibraryFile {
|
||||
if (globals.SupportedImageTypes.includes(this.metadata.format)) return 'image'
|
||||
if (globals.SupportedAudioTypes.includes(this.metadata.format)) return 'audio'
|
||||
if (globals.SupportedEbookTypes.includes(this.metadata.format)) return 'ebook'
|
||||
if (globals.SupportedVideoTypes.includes(this.metadata.format)) return 'video'
|
||||
if (globals.TextFileTypes.includes(this.metadata.format)) return 'text'
|
||||
if (globals.MetadataFileTypes.includes(this.metadata.format)) return 'metadata'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
get isMediaFile() {
|
||||
return this.fileType === 'audio' || this.fileType === 'ebook'
|
||||
return this.fileType === 'audio' || this.fileType === 'ebook' || this.fileType === 'video'
|
||||
}
|
||||
|
||||
get isOPFFile() {
|
||||
|
||||
109
server/objects/files/VideoFile.js
Normal file
109
server/objects/files/VideoFile.js
Normal file
@@ -0,0 +1,109 @@
|
||||
const { VideoMimeType } = require('../../utils/constants')
|
||||
const FileMetadata = require('../metadata/FileMetadata')
|
||||
|
||||
class VideoFile {
|
||||
constructor(data) {
|
||||
this.index = null
|
||||
this.ino = null
|
||||
this.metadata = null
|
||||
this.addedAt = null
|
||||
this.updatedAt = null
|
||||
|
||||
this.format = null
|
||||
this.duration = null
|
||||
this.bitRate = null
|
||||
this.language = null
|
||||
this.codec = null
|
||||
this.timeBase = null
|
||||
this.frameRate = null
|
||||
this.width = null
|
||||
this.height = null
|
||||
this.embeddedCoverArt = null
|
||||
|
||||
this.invalid = false
|
||||
this.error = null
|
||||
|
||||
if (data) {
|
||||
this.construct(data)
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
index: this.index,
|
||||
ino: this.ino,
|
||||
metadata: this.metadata.toJSON(),
|
||||
addedAt: this.addedAt,
|
||||
updatedAt: this.updatedAt,
|
||||
invalid: !!this.invalid,
|
||||
error: this.error || null,
|
||||
format: this.format,
|
||||
duration: this.duration,
|
||||
bitRate: this.bitRate,
|
||||
language: this.language,
|
||||
codec: this.codec,
|
||||
timeBase: this.timeBase,
|
||||
frameRate: this.frameRate,
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
embeddedCoverArt: this.embeddedCoverArt,
|
||||
mimeType: this.mimeType
|
||||
}
|
||||
}
|
||||
|
||||
construct(data) {
|
||||
this.index = data.index
|
||||
this.ino = data.ino
|
||||
this.metadata = new FileMetadata(data.metadata || {})
|
||||
this.addedAt = data.addedAt
|
||||
this.updatedAt = data.updatedAt
|
||||
this.invalid = !!data.invalid
|
||||
this.error = data.error || null
|
||||
|
||||
this.format = data.format
|
||||
this.duration = data.duration
|
||||
this.bitRate = data.bitRate
|
||||
this.language = data.language
|
||||
this.codec = data.codec || null
|
||||
this.timeBase = data.timeBase
|
||||
this.frameRate = data.frameRate
|
||||
this.width = data.width
|
||||
this.height = data.height
|
||||
this.embeddedCoverArt = data.embeddedCoverArt || null
|
||||
}
|
||||
|
||||
get mimeType() {
|
||||
var format = this.metadata.format.toUpperCase()
|
||||
if (VideoMimeType[format]) {
|
||||
return VideoMimeType[format]
|
||||
} else {
|
||||
return VideoMimeType.MP4
|
||||
}
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new VideoFile(this.toJSON())
|
||||
}
|
||||
|
||||
setDataFromProbe(libraryFile, probeData) {
|
||||
this.ino = libraryFile.ino || null
|
||||
|
||||
this.metadata = libraryFile.metadata.clone()
|
||||
this.addedAt = Date.now()
|
||||
this.updatedAt = Date.now()
|
||||
|
||||
const videoStream = probeData.videoStream
|
||||
|
||||
this.format = probeData.format
|
||||
this.duration = probeData.duration
|
||||
this.bitRate = videoStream.bit_rate || probeData.bitRate || null
|
||||
this.language = probeData.language
|
||||
this.codec = videoStream.codec || null
|
||||
this.timeBase = videoStream.time_base
|
||||
this.frameRate = videoStream.frame_rate || null
|
||||
this.width = videoStream.width || null
|
||||
this.height = videoStream.height || null
|
||||
this.embeddedCoverArt = probeData.embeddedCoverArt
|
||||
}
|
||||
}
|
||||
module.exports = VideoFile
|
||||
42
server/objects/files/VideoTrack.js
Normal file
42
server/objects/files/VideoTrack.js
Normal file
@@ -0,0 +1,42 @@
|
||||
const Path = require('path')
|
||||
const { encodeUriPath } = require('../../utils/index')
|
||||
|
||||
class VideoTrack {
|
||||
constructor() {
|
||||
this.index = null
|
||||
this.duration = null
|
||||
this.title = null
|
||||
this.contentUrl = null
|
||||
this.mimeType = null
|
||||
this.metadata = null
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
index: this.index,
|
||||
duration: this.duration,
|
||||
title: this.title,
|
||||
contentUrl: this.contentUrl,
|
||||
mimeType: this.mimeType,
|
||||
metadata: this.metadata ? this.metadata.toJSON() : null
|
||||
}
|
||||
}
|
||||
|
||||
setData(itemId, videoFile) {
|
||||
this.index = videoFile.index
|
||||
this.duration = videoFile.duration
|
||||
this.title = videoFile.metadata.filename || ''
|
||||
this.contentUrl = Path.join(`/s/item/${itemId}`, encodeUriPath(videoFile.metadata.relPath))
|
||||
this.mimeType = videoFile.mimeType
|
||||
this.metadata = videoFile.metadata.clone()
|
||||
}
|
||||
|
||||
setFromStream(title, duration, contentUrl) {
|
||||
this.index = 1
|
||||
this.duration = duration
|
||||
this.title = title
|
||||
this.contentUrl = contentUrl
|
||||
this.mimeType = 'application/vnd.apple.mpegurl'
|
||||
}
|
||||
}
|
||||
module.exports = VideoTrack
|
||||
@@ -240,7 +240,11 @@ class Podcast {
|
||||
}
|
||||
|
||||
removeEpisode(episodeId) {
|
||||
this.episodes = this.episodes.filter(ep => ep.id !== episodeId)
|
||||
const episode = this.episodes.find(ep => ep.id === episodeId)
|
||||
if (episode) {
|
||||
this.episodes = this.episodes.filter(ep => ep.id !== episodeId)
|
||||
}
|
||||
return episode
|
||||
}
|
||||
|
||||
getPlaybackTitle(episodeId) {
|
||||
|
||||
145
server/objects/mediaTypes/Video.js
Normal file
145
server/objects/mediaTypes/Video.js
Normal file
@@ -0,0 +1,145 @@
|
||||
const Logger = require('../../Logger')
|
||||
const VideoFile = require('../files/VideoFile')
|
||||
const VideoTrack = require('../files/VideoTrack')
|
||||
const VideoMetadata = require('../metadata/VideoMetadata')
|
||||
const { areEquivalent, copyValue } = require('../../utils/index')
|
||||
|
||||
class Video {
|
||||
constructor(video) {
|
||||
this.libraryItemId = null
|
||||
this.metadata = null
|
||||
this.coverPath = null
|
||||
this.tags = []
|
||||
this.episodes = []
|
||||
|
||||
this.autoDownloadEpisodes = false
|
||||
this.lastEpisodeCheck = 0
|
||||
|
||||
this.lastCoverSearch = null
|
||||
this.lastCoverSearchQuery = null
|
||||
|
||||
if (video) {
|
||||
this.construct(video)
|
||||
}
|
||||
}
|
||||
|
||||
construct(video) {
|
||||
this.libraryItemId = video.libraryItemId
|
||||
this.metadata = new VideoMetadata(video.metadata)
|
||||
this.coverPath = video.coverPath
|
||||
this.tags = [...video.tags]
|
||||
this.videoFile = new VideoFile(video.videoFile)
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
libraryItemId: this.libraryItemId,
|
||||
metadata: this.metadata.toJSONExpanded(),
|
||||
coverPath: this.coverPath,
|
||||
tags: [...this.tags],
|
||||
videoFile: this.videoFile.toJSON()
|
||||
}
|
||||
}
|
||||
|
||||
toJSONMinified() {
|
||||
return {
|
||||
metadata: this.metadata.toJSONMinified(),
|
||||
coverPath: this.coverPath,
|
||||
tags: [...this.tags],
|
||||
videoFile: this.videoFile.toJSON(),
|
||||
size: this.size
|
||||
}
|
||||
}
|
||||
|
||||
toJSONExpanded() {
|
||||
return {
|
||||
libraryItemId: this.libraryItemId,
|
||||
metadata: this.metadata.toJSONExpanded(),
|
||||
coverPath: this.coverPath,
|
||||
tags: [...this.tags],
|
||||
videoFile: this.videoFile.toJSON(),
|
||||
size: this.size
|
||||
}
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.videoFile.metadata.size
|
||||
}
|
||||
get hasMediaEntities() {
|
||||
return true
|
||||
}
|
||||
get shouldSearchForCover() {
|
||||
return false
|
||||
}
|
||||
get hasEmbeddedCoverArt() {
|
||||
return false
|
||||
}
|
||||
get hasIssues() {
|
||||
return false
|
||||
}
|
||||
get duration() {
|
||||
return 0
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
var json = this.toJSON()
|
||||
var hasUpdates = false
|
||||
for (const key in json) {
|
||||
if (payload[key] !== undefined) {
|
||||
if (key === 'metadata') {
|
||||
if (this.metadata.update(payload.metadata)) {
|
||||
hasUpdates = true
|
||||
}
|
||||
} else if (!areEquivalent(payload[key], json[key])) {
|
||||
this[key] = copyValue(payload[key])
|
||||
Logger.debug('[Video] Key updated', key, this[key])
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
updateCover(coverPath) {
|
||||
coverPath = coverPath.replace(/\\/g, '/')
|
||||
if (this.coverPath === coverPath) return false
|
||||
this.coverPath = coverPath
|
||||
return true
|
||||
}
|
||||
|
||||
removeFileWithInode(inode) {
|
||||
|
||||
}
|
||||
|
||||
findFileWithInode(inode) {
|
||||
return null
|
||||
}
|
||||
|
||||
setVideoFile(videoFile) {
|
||||
this.videoFile = videoFile
|
||||
}
|
||||
|
||||
setData(mediaMetadata) {
|
||||
this.metadata = new VideoMetadata()
|
||||
if (mediaMetadata.metadata) {
|
||||
this.metadata.setData(mediaMetadata.metadata)
|
||||
}
|
||||
|
||||
this.coverPath = mediaMetadata.coverPath || null
|
||||
}
|
||||
|
||||
getPlaybackTitle() {
|
||||
return this.metadata.title
|
||||
}
|
||||
|
||||
getPlaybackAuthor() {
|
||||
return ''
|
||||
}
|
||||
|
||||
getVideoTrack() {
|
||||
var track = new VideoTrack()
|
||||
track.setData(this.libraryItemId, this.videoFile)
|
||||
return track
|
||||
}
|
||||
}
|
||||
module.exports = Video
|
||||
97
server/objects/metadata/VideoMetadata.js
Normal file
97
server/objects/metadata/VideoMetadata.js
Normal file
@@ -0,0 +1,97 @@
|
||||
const Logger = require('../../Logger')
|
||||
const { areEquivalent, copyValue } = require('../../utils/index')
|
||||
|
||||
class VideoMetadata {
|
||||
constructor(metadata) {
|
||||
this.title = null
|
||||
this.description = null
|
||||
this.explicit = false
|
||||
this.language = null
|
||||
|
||||
if (metadata) {
|
||||
this.construct(metadata)
|
||||
}
|
||||
}
|
||||
|
||||
construct(metadata) {
|
||||
this.title = metadata.title
|
||||
this.description = metadata.description
|
||||
this.explicit = metadata.explicit
|
||||
this.language = metadata.language || null
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
title: this.title,
|
||||
description: this.description,
|
||||
explicit: this.explicit,
|
||||
language: this.language
|
||||
}
|
||||
}
|
||||
|
||||
toJSONMinified() {
|
||||
return {
|
||||
title: this.title,
|
||||
titleIgnorePrefix: this.titleIgnorePrefix,
|
||||
description: this.description,
|
||||
explicit: this.explicit,
|
||||
language: this.language
|
||||
}
|
||||
}
|
||||
|
||||
toJSONExpanded() {
|
||||
return this.toJSONMinified()
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new VideoMetadata(this.toJSON())
|
||||
}
|
||||
|
||||
get titleIgnorePrefix() {
|
||||
if (!this.title) return ''
|
||||
var prefixesToIgnore = global.ServerSettings.sortingPrefixes || []
|
||||
for (const prefix of prefixesToIgnore) {
|
||||
// e.g. for prefix "the". If title is "The Book Title" return "Book Title, The"
|
||||
if (this.title.toLowerCase().startsWith(`${prefix} `)) {
|
||||
return this.title.substr(prefix.length + 1) + `, ${prefix.substr(0, 1).toUpperCase() + prefix.substr(1)}`
|
||||
}
|
||||
}
|
||||
return this.title
|
||||
}
|
||||
|
||||
searchQuery(query) { // Returns key if match is found
|
||||
var keysToCheck = ['title']
|
||||
for (var key of keysToCheck) {
|
||||
if (this[key] && String(this[key]).toLowerCase().includes(query)) {
|
||||
return {
|
||||
matchKey: key,
|
||||
matchText: this[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
setData(mediaMetadata = {}) {
|
||||
this.title = mediaMetadata.title || null
|
||||
this.description = mediaMetadata.description || null
|
||||
this.explicit = !!mediaMetadata.explicit
|
||||
this.language = mediaMetadata.language || null
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
var json = this.toJSON()
|
||||
var hasUpdates = false
|
||||
for (const key in json) {
|
||||
if (payload[key] !== undefined) {
|
||||
if (!areEquivalent(payload[key], json[key])) {
|
||||
this[key] = copyValue(payload[key])
|
||||
Logger.debug('[VideoMetadata] Key updated', key, this[key])
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
}
|
||||
module.exports = VideoMetadata
|
||||
@@ -91,7 +91,7 @@ class MediaProgress {
|
||||
|
||||
var timeRemaining = this.duration - this.currentTime
|
||||
// If time remaining is less than 5 seconds then mark as finished
|
||||
if ((this.progress >= 1 || (!isNaN(timeRemaining) && timeRemaining < 5)) && !this.isFinished) {
|
||||
if ((this.progress >= 1 || (!isNaN(timeRemaining) && timeRemaining < 5))) {
|
||||
this.isFinished = true
|
||||
this.finishedAt = Date.now()
|
||||
this.progress = 1
|
||||
|
||||
@@ -343,6 +343,7 @@ class User {
|
||||
|
||||
checkCanAccessLibraryItem(libraryItem) {
|
||||
if (!this.checkCanAccessLibrary(libraryItem.libraryId)) return false
|
||||
|
||||
if (libraryItem.media.metadata.explicit && !this.canAccessExplicitContent) return false
|
||||
return this.checkCanAccessLibraryItemWithTags(libraryItem.media.tags)
|
||||
}
|
||||
|
||||
277
server/scanner/MediaFileScanner.js
Normal file
277
server/scanner/MediaFileScanner.js
Normal file
@@ -0,0 +1,277 @@
|
||||
const Path = require('path')
|
||||
|
||||
const AudioFile = require('../objects/files/AudioFile')
|
||||
const VideoFile = require('../objects/files/VideoFile')
|
||||
|
||||
const prober = require('../utils/prober')
|
||||
const Logger = require('../Logger')
|
||||
const { LogLevel } = require('../utils/constants')
|
||||
|
||||
class MediaFileScanner {
|
||||
constructor() { }
|
||||
|
||||
getTrackAndDiscNumberFromFilename(mediaMetadataFromScan, audioLibraryFile) {
|
||||
const { title, author, series, publishedYear } = mediaMetadataFromScan
|
||||
const { filename, path } = audioLibraryFile.metadata
|
||||
var partbasename = Path.basename(filename, Path.extname(filename))
|
||||
|
||||
// Remove title, author, series, and publishedYear from filename if there
|
||||
if (title) partbasename = partbasename.replace(title, '')
|
||||
if (author) partbasename = partbasename.replace(author, '')
|
||||
if (series) partbasename = partbasename.replace(series, '')
|
||||
if (publishedYear) partbasename = partbasename.replace(publishedYear)
|
||||
|
||||
// Look for disc number
|
||||
var discNumber = null
|
||||
var discMatch = partbasename.match(/\b(disc|cd) ?(\d\d?)\b/i)
|
||||
if (discMatch && discMatch.length > 2 && discMatch[2]) {
|
||||
if (!isNaN(discMatch[2])) {
|
||||
discNumber = Number(discMatch[2])
|
||||
}
|
||||
|
||||
// Remove disc number from filename
|
||||
partbasename = partbasename.replace(/\b(disc|cd) ?(\d\d?)\b/i, '')
|
||||
}
|
||||
|
||||
// Look for disc number in folder path e.g. /Book Title/CD01/audiofile.mp3
|
||||
var pathdir = Path.dirname(path).split('/').pop()
|
||||
if (pathdir && /^cd\d{1,3}$/i.test(pathdir)) {
|
||||
var discFromFolder = Number(pathdir.replace(/cd/i, ''))
|
||||
if (!isNaN(discFromFolder) && discFromFolder !== null) discNumber = discFromFolder
|
||||
}
|
||||
|
||||
var numbersinpath = partbasename.match(/\d{1,4}/g)
|
||||
var trackNumber = numbersinpath && numbersinpath.length ? parseInt(numbersinpath[0]) : null
|
||||
return {
|
||||
trackNumber,
|
||||
discNumber
|
||||
}
|
||||
}
|
||||
|
||||
getAverageScanDurationMs(results) {
|
||||
if (!results.length) return 0
|
||||
var total = 0
|
||||
for (let i = 0; i < results.length; i++) total += results[i].elapsed
|
||||
return Math.floor(total / results.length)
|
||||
}
|
||||
|
||||
async scan(mediaType, libraryFile, mediaMetadataFromScan, verbose = false) {
|
||||
var probeStart = Date.now()
|
||||
var probeData = await prober.probe(libraryFile.metadata.path, verbose)
|
||||
if (probeData.error) {
|
||||
Logger.error(`[MediaFileScanner] ${probeData.error} : "${libraryFile.metadata.path}"`)
|
||||
return null
|
||||
}
|
||||
|
||||
if (mediaType === 'video') {
|
||||
if (!probeData.videoStream) {
|
||||
Logger.error('[MediaFileScanner] Invalid video file no video stream')
|
||||
return null
|
||||
}
|
||||
|
||||
var videoFile = new VideoFile()
|
||||
videoFile.setDataFromProbe(libraryFile, probeData)
|
||||
|
||||
return {
|
||||
videoFile,
|
||||
elapsed: Date.now() - probeStart
|
||||
}
|
||||
} else {
|
||||
if (!probeData.audioStream) {
|
||||
Logger.error('[MediaFileScanner] Invalid audio file no audio stream')
|
||||
return null
|
||||
}
|
||||
|
||||
var audioFile = new AudioFile()
|
||||
audioFile.trackNumFromMeta = probeData.trackNumber
|
||||
audioFile.discNumFromMeta = probeData.discNumber
|
||||
if (mediaType === 'book') {
|
||||
const { trackNumber, discNumber } = this.getTrackAndDiscNumberFromFilename(mediaMetadataFromScan, libraryFile)
|
||||
audioFile.trackNumFromFilename = trackNumber
|
||||
audioFile.discNumFromFilename = discNumber
|
||||
}
|
||||
audioFile.setDataFromProbe(libraryFile, probeData)
|
||||
|
||||
return {
|
||||
audioFile,
|
||||
elapsed: Date.now() - probeStart
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Returns array of { MediaFile, elapsed, averageScanDuration } from audio file scan objects
|
||||
async executeMediaFileScans(mediaType, mediaLibraryFiles, scanData) {
|
||||
var mediaMetadataFromScan = scanData.media.metadata || null
|
||||
var proms = []
|
||||
for (let i = 0; i < mediaLibraryFiles.length; i++) {
|
||||
proms.push(this.scan(mediaType, mediaLibraryFiles[i], mediaMetadataFromScan))
|
||||
}
|
||||
var scanStart = Date.now()
|
||||
var results = await Promise.all(proms).then((scanResults) => scanResults.filter(sr => sr))
|
||||
return {
|
||||
audioFiles: results.filter(r => r.audioFile).map(r => r.audioFile),
|
||||
videoFiles: results.filter(r => r.videoFile).map(r => r.videoFile),
|
||||
elapsed: Date.now() - scanStart,
|
||||
averageScanDuration: this.getAverageScanDurationMs(results)
|
||||
}
|
||||
}
|
||||
|
||||
isSequential(nums) {
|
||||
if (!nums || !nums.length) return false
|
||||
if (nums.length === 1) return true
|
||||
var prev = nums[0]
|
||||
for (let i = 1; i < nums.length; i++) {
|
||||
if (nums[i] - prev > 1) return false
|
||||
prev = nums[i]
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
removeDupes(nums) {
|
||||
if (!nums || !nums.length) return []
|
||||
if (nums.length === 1) return nums
|
||||
|
||||
var nodupes = [nums[0]]
|
||||
nums.forEach((num) => {
|
||||
if (num > nodupes[nodupes.length - 1]) nodupes.push(num)
|
||||
})
|
||||
return nodupes
|
||||
}
|
||||
|
||||
runSmartTrackOrder(libraryItem, audioFiles) {
|
||||
var discsFromFilename = []
|
||||
var tracksFromFilename = []
|
||||
var discsFromMeta = []
|
||||
var tracksFromMeta = []
|
||||
|
||||
audioFiles.forEach((af) => {
|
||||
if (af.discNumFromFilename !== null) discsFromFilename.push(af.discNumFromFilename)
|
||||
if (af.discNumFromMeta !== null) discsFromMeta.push(af.discNumFromMeta)
|
||||
if (af.trackNumFromFilename !== null) tracksFromFilename.push(af.trackNumFromFilename)
|
||||
if (af.trackNumFromMeta !== null) tracksFromMeta.push(af.trackNumFromMeta)
|
||||
af.validateTrackIndex() // Sets error if no valid track number
|
||||
})
|
||||
discsFromFilename.sort((a, b) => a - b)
|
||||
discsFromMeta.sort((a, b) => a - b)
|
||||
tracksFromFilename.sort((a, b) => a - b)
|
||||
tracksFromMeta.sort((a, b) => a - b)
|
||||
|
||||
var discKey = null
|
||||
if (discsFromMeta.length === audioFiles.length && this.isSequential(discsFromMeta)) {
|
||||
discKey = 'discNumFromMeta'
|
||||
} else if (discsFromFilename.length === audioFiles.length && this.isSequential(discsFromFilename)) {
|
||||
discKey = 'discNumFromFilename'
|
||||
}
|
||||
|
||||
var trackKey = null
|
||||
tracksFromFilename = this.removeDupes(tracksFromFilename)
|
||||
tracksFromMeta = this.removeDupes(tracksFromMeta)
|
||||
if (tracksFromFilename.length > tracksFromMeta.length) {
|
||||
trackKey = 'trackNumFromFilename'
|
||||
} else {
|
||||
trackKey = 'trackNumFromMeta'
|
||||
}
|
||||
|
||||
if (discKey !== null) {
|
||||
Logger.debug(`[AudioFileScanner] Smart track order for "${libraryItem.media.metadata.title}" using disc key ${discKey} and track key ${trackKey}`)
|
||||
audioFiles.sort((a, b) => {
|
||||
let Dx = a[discKey] - b[discKey]
|
||||
if (Dx === 0) Dx = a[trackKey] - b[trackKey]
|
||||
return Dx
|
||||
})
|
||||
} else {
|
||||
Logger.debug(`[AudioFileScanner] Smart track order for "${libraryItem.media.metadata.title}" using track key ${trackKey}`)
|
||||
audioFiles.sort((a, b) => a[trackKey] - b[trackKey])
|
||||
}
|
||||
|
||||
for (let i = 0; i < audioFiles.length; i++) {
|
||||
audioFiles[i].index = i + 1
|
||||
var existingAF = libraryItem.media.findFileWithInode(audioFiles[i].ino)
|
||||
if (existingAF) {
|
||||
if (existingAF.updateFromScan) existingAF.updateFromScan(audioFiles[i])
|
||||
} else {
|
||||
libraryItem.media.addAudioFile(audioFiles[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async scanMediaFiles(mediaLibraryFiles, scanData, libraryItem, preferAudioMetadata, libraryScan = null) {
|
||||
var hasUpdated = false
|
||||
|
||||
var mediaScanResult = await this.executeMediaFileScans(libraryItem.mediaType, mediaLibraryFiles, scanData)
|
||||
if (libraryItem.mediaType === 'video') {
|
||||
if (mediaScanResult.videoFiles.length) {
|
||||
// TODO: Check for updates etc
|
||||
hasUpdated = true
|
||||
libraryItem.media.setVideoFile(mediaScanResult.videoFiles[0])
|
||||
}
|
||||
} else if (mediaScanResult.audioFiles.length) {
|
||||
if (libraryScan) {
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Library Item "${scanData.path}" Audio file scan took ${mediaScanResult.elapsed}ms for ${mediaScanResult.audioFiles.length} with average time of ${mediaScanResult.averageScanDuration}ms`)
|
||||
}
|
||||
|
||||
var totalAudioFilesToInclude = mediaScanResult.audioFiles.length
|
||||
var newAudioFiles = mediaScanResult.audioFiles.filter(af => {
|
||||
return !libraryItem.media.findFileWithInode(af.ino)
|
||||
})
|
||||
|
||||
// Book: Adding audio files to book media
|
||||
if (libraryItem.mediaType === 'book') {
|
||||
if (newAudioFiles.length) {
|
||||
// Single Track Audiobooks
|
||||
if (totalAudioFilesToInclude === 1) {
|
||||
var af = mediaScanResult.audioFiles[0]
|
||||
af.index = 1
|
||||
libraryItem.media.addAudioFile(af)
|
||||
hasUpdated = true
|
||||
} else {
|
||||
this.runSmartTrackOrder(libraryItem, mediaScanResult.audioFiles)
|
||||
hasUpdated = true
|
||||
}
|
||||
} else {
|
||||
// Only update metadata not index
|
||||
mediaScanResult.audioFiles.forEach((af) => {
|
||||
var existingAF = libraryItem.media.findFileWithInode(af.ino)
|
||||
if (existingAF) {
|
||||
af.index = existingAF.index
|
||||
if (existingAF.updateFromScan && existingAF.updateFromScan(af)) {
|
||||
hasUpdated = true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Set book details from audio file ID3 tags, optional prefer
|
||||
if (libraryItem.media.setMetadataFromAudioFile(preferAudioMetadata)) {
|
||||
hasUpdated = true
|
||||
}
|
||||
|
||||
if (hasUpdated) {
|
||||
libraryItem.media.rebuildTracks()
|
||||
}
|
||||
} else { // Podcast Media Type
|
||||
var existingAudioFiles = mediaScanResult.audioFiles.filter(af => libraryItem.media.findFileWithInode(af.ino))
|
||||
|
||||
if (newAudioFiles.length) {
|
||||
var newIndex = libraryItem.media.episodes.length + 1
|
||||
newAudioFiles.forEach((newAudioFile) => {
|
||||
libraryItem.media.addNewEpisodeFromAudioFile(newAudioFile, newIndex++)
|
||||
})
|
||||
libraryItem.media.reorderEpisodes()
|
||||
hasUpdated = true
|
||||
}
|
||||
|
||||
// Update audio file metadata for audio files already there
|
||||
existingAudioFiles.forEach((af) => {
|
||||
var peAudioFile = libraryItem.media.findFileWithInode(af.ino)
|
||||
if (peAudioFile.updateFromScan && peAudioFile.updateFromScan(af)) {
|
||||
hasUpdated = true
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return hasUpdated
|
||||
}
|
||||
}
|
||||
module.exports = new MediaFileScanner()
|
||||
@@ -1,11 +1,15 @@
|
||||
const AudioFileMetadata = require('../objects/metadata/AudioMetaTags')
|
||||
|
||||
class AudioProbeData {
|
||||
class MediaProbeData {
|
||||
constructor() {
|
||||
this.embeddedCoverArt = null
|
||||
this.format = null
|
||||
this.duration = null
|
||||
this.size = null
|
||||
|
||||
this.audioStream = null
|
||||
this.videoStream = null
|
||||
|
||||
this.bitRate = null
|
||||
this.codec = null
|
||||
this.timeBase = null
|
||||
@@ -35,6 +39,10 @@ class AudioProbeData {
|
||||
this.format = data.format
|
||||
this.duration = data.duration
|
||||
this.size = data.size
|
||||
|
||||
this.audioStream = audioStream
|
||||
this.videoStream = this.embeddedCoverArt ? null : data.video_stream || null
|
||||
|
||||
this.bitRate = audioStream.bit_rate || data.bit_rate
|
||||
this.codec = audioStream.codec
|
||||
this.timeBase = audioStream.time_base
|
||||
@@ -78,4 +86,4 @@ class AudioProbeData {
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = AudioProbeData
|
||||
module.exports = MediaProbeData
|
||||
@@ -8,7 +8,7 @@ const { comparePaths } = require('../utils/index')
|
||||
const { getIno } = require('../utils/fileUtils')
|
||||
const { ScanResult, LogLevel } = require('../utils/constants')
|
||||
|
||||
const AudioFileScanner = require('./AudioFileScanner')
|
||||
const MediaFileScanner = require('./MediaFileScanner')
|
||||
const BookFinder = require('../finders/BookFinder')
|
||||
const LibraryItem = require('../objects/LibraryItem')
|
||||
const LibraryScan = require('./LibraryScan')
|
||||
@@ -80,7 +80,7 @@ class Scanner {
|
||||
// Scan all audio files
|
||||
if (libraryItem.hasAudioFiles) {
|
||||
var libraryAudioFiles = libraryItem.libraryFiles.filter(lf => lf.fileType === 'audio')
|
||||
if (await AudioFileScanner.scanAudioFiles(libraryAudioFiles, libraryItemData, libraryItem, this.db.serverSettings.scannerPreferAudioMetadata)) {
|
||||
if (await MediaFileScanner.scanMediaFiles(libraryAudioFiles, libraryItemData, libraryItem, this.db.serverSettings.scannerPreferAudioMetadata)) {
|
||||
hasUpdated = true
|
||||
}
|
||||
|
||||
@@ -240,18 +240,18 @@ class Scanner {
|
||||
if (!hasMediaFile) {
|
||||
libraryScan.addLog(LogLevel.WARN, `Item found "${libraryItemDataFound.path}" has no media files`)
|
||||
} else {
|
||||
var audioFileSize = 0
|
||||
dataFound.libraryFiles.filter(lf => lf.fileType == 'audio').forEach(lf => audioFileSize += lf.metadata.size)
|
||||
var mediaFileSize = 0
|
||||
dataFound.libraryFiles.filter(lf => lf.fileType === 'audio' || lf.fileType === 'video').forEach(lf => mediaFileSize += lf.metadata.size)
|
||||
|
||||
// If this item will go over max size then push current chunk
|
||||
if (audioFileSize + newItemDataToScanSize > MaxSizePerChunk && newItemDataToScan.length > 0) {
|
||||
if (mediaFileSize + newItemDataToScanSize > MaxSizePerChunk && newItemDataToScan.length > 0) {
|
||||
newItemDataToScanChunks.push(newItemDataToScan)
|
||||
newItemDataToScanSize = 0
|
||||
newItemDataToScan = []
|
||||
}
|
||||
|
||||
newItemDataToScan.push(dataFound)
|
||||
newItemDataToScanSize += audioFileSize
|
||||
newItemDataToScanSize += mediaFileSize
|
||||
if (newItemDataToScanSize >= MaxSizePerChunk) {
|
||||
newItemDataToScanChunks.push(newItemDataToScan)
|
||||
newItemDataToScanSize = 0
|
||||
@@ -337,7 +337,7 @@ class Scanner {
|
||||
// forceRescan all existing audio files - will probe and update ID3 tag metadata
|
||||
var existingAudioFiles = existingLibraryFiles.filter(lf => lf.fileType === 'audio')
|
||||
if (libraryScan.scanOptions.forceRescan && existingAudioFiles.length) {
|
||||
if (await AudioFileScanner.scanAudioFiles(existingAudioFiles, scanData, libraryItem, libraryScan.preferAudioMetadata, libraryScan)) {
|
||||
if (await MediaFileScanner.scanMediaFiles(existingAudioFiles, scanData, libraryItem, libraryScan.preferAudioMetadata, libraryScan)) {
|
||||
hasUpdated = true
|
||||
}
|
||||
}
|
||||
@@ -345,7 +345,7 @@ class Scanner {
|
||||
var newAudioFiles = newLibraryFiles.filter(lf => lf.fileType === 'audio')
|
||||
var removedAudioFiles = filesRemoved.filter(lf => lf.fileType === 'audio')
|
||||
if (newAudioFiles.length || removedAudioFiles.length) {
|
||||
if (await AudioFileScanner.scanAudioFiles(newAudioFiles, scanData, libraryItem, libraryScan.preferAudioMetadata, libraryScan)) {
|
||||
if (await MediaFileScanner.scanMediaFiles(newAudioFiles, scanData, libraryItem, libraryScan.preferAudioMetadata, libraryScan)) {
|
||||
hasUpdated = true
|
||||
}
|
||||
}
|
||||
@@ -386,9 +386,9 @@ class Scanner {
|
||||
var libraryItem = new LibraryItem()
|
||||
libraryItem.setData(libraryMediaType, libraryItemData)
|
||||
|
||||
var audioFiles = libraryItemData.libraryFiles.filter(lf => lf.fileType === 'audio')
|
||||
if (audioFiles.length) {
|
||||
await AudioFileScanner.scanAudioFiles(audioFiles, libraryItemData, libraryItem, preferAudioMetadata, libraryScan)
|
||||
var mediaFiles = libraryItemData.libraryFiles.filter(lf => lf.fileType === 'audio' || lf.fileType === 'video')
|
||||
if (mediaFiles.length) {
|
||||
await MediaFileScanner.scanMediaFiles(mediaFiles, libraryItemData, libraryItem, preferAudioMetadata, libraryScan)
|
||||
}
|
||||
|
||||
await libraryItem.syncFiles(preferOpfMetadata)
|
||||
@@ -408,7 +408,7 @@ class Scanner {
|
||||
}
|
||||
|
||||
// Scan for cover if enabled and has no cover
|
||||
if (libraryMediaType !== 'podcast') {
|
||||
if (libraryMediaType === 'book') {
|
||||
if (libraryItem && findCovers && !libraryItem.media.coverPath && libraryItem.media.shouldSearchForCover) {
|
||||
var updatedCover = await this.searchForCover(libraryItem, libraryScan)
|
||||
libraryItem.media.updateLastCoverSearch(updatedCover)
|
||||
|
||||
@@ -44,4 +44,8 @@ module.exports.AudioMimeType = {
|
||||
FLAC: 'audio/flac',
|
||||
WMA: 'audio/x-ms-wma',
|
||||
AIFF: 'audio/x-aiff'
|
||||
}
|
||||
|
||||
module.exports.VideoMimeType = {
|
||||
MP4: 'video/mp4'
|
||||
}
|
||||
@@ -181,19 +181,33 @@ module.exports.sanitizeFilename = (filename, colonReplacement = ' - ') => {
|
||||
return false
|
||||
}
|
||||
|
||||
// Max is actually 255-260 for windows but this leaves padding incase ext wasnt put on yet
|
||||
const MAX_FILENAME_LEN = 240
|
||||
|
||||
var replacement = ''
|
||||
var illegalRe = /[\/\?<>\\:\*\|"]/g;
|
||||
var controlRe = /[\x00-\x1f\x80-\x9f]/g;
|
||||
var reservedRe = /^\.+$/;
|
||||
var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i;
|
||||
var windowsTrailingRe = /[\. ]+$/;
|
||||
var illegalRe = /[\/\?<>\\:\*\|"]/g
|
||||
var controlRe = /[\x00-\x1f\x80-\x9f]/g
|
||||
var reservedRe = /^\.+$/
|
||||
var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i
|
||||
var windowsTrailingRe = /[\. ]+$/
|
||||
var lineBreaks = /[\n\r]/g
|
||||
|
||||
sanitized = filename
|
||||
.replace(':', colonReplacement) // Replace first occurrence of a colon
|
||||
.replace(illegalRe, replacement)
|
||||
.replace(controlRe, replacement)
|
||||
.replace(reservedRe, replacement)
|
||||
.replace(lineBreaks, replacement)
|
||||
.replace(windowsReservedRe, replacement)
|
||||
.replace(windowsTrailingRe, replacement);
|
||||
.replace(windowsTrailingRe, replacement)
|
||||
|
||||
if (sanitized.length > MAX_FILENAME_LEN) {
|
||||
var lenToRemove = sanitized.length - MAX_FILENAME_LEN
|
||||
var ext = Path.extname(sanitized)
|
||||
var basename = Path.basename(sanitized, ext)
|
||||
basename = basename.slice(0, basename.length - lenToRemove)
|
||||
sanitized = basename + ext
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
const globals = {
|
||||
SupportedImageTypes: ['png', 'jpg', 'jpeg', 'webp'],
|
||||
SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'mp4', 'aac', 'wma', 'aiff'],
|
||||
SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'mp4', 'aac', 'wma', 'aiff', 'wav'],
|
||||
SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
|
||||
SupportedVideoTypes: ['mp4'],
|
||||
TextFileTypes: ['txt', 'nfo'],
|
||||
MetadataFileTypes: ['opf', 'abs', 'xml']
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ module.exports = {
|
||||
if (series && !data.series.find(se => se.id === series.id)) data.series.push({ id: series.id, name: series.name })
|
||||
})
|
||||
}
|
||||
if (mediaMetadata.genres.length) {
|
||||
if (mediaMetadata.genres && mediaMetadata.genres.length) {
|
||||
mediaMetadata.genres.forEach((genre) => {
|
||||
if (genre && !data.genres.includes(genre)) data.genres.push(genre)
|
||||
})
|
||||
@@ -399,7 +399,7 @@ module.exports = {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} else if (libraryItem.isBook) {
|
||||
// Book categories
|
||||
|
||||
// Newest series
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const ffprobe = require('node-ffprobe')
|
||||
const AudioProbeData = require('../scanner/AudioProbeData')
|
||||
const MediaProbeData = require('../scanner/MediaProbeData')
|
||||
|
||||
const Logger = require('../Logger')
|
||||
|
||||
@@ -274,7 +274,7 @@ function parseProbeData(data, verbose = false) {
|
||||
}
|
||||
}
|
||||
|
||||
// Updated probe returns AudioProbeData object
|
||||
// Updated probe returns MediaProbeData object
|
||||
function probe(filepath, verbose = false) {
|
||||
if (process.env.FFPROBE_PATH) {
|
||||
ffprobe.FFPROBE_PATH = process.env.FFPROBE_PATH
|
||||
@@ -283,12 +283,12 @@ function probe(filepath, verbose = false) {
|
||||
return ffprobe(filepath)
|
||||
.then(raw => {
|
||||
var rawProbeData = parseProbeData(raw, verbose)
|
||||
if (!rawProbeData || !rawProbeData.audio_stream) {
|
||||
if (!rawProbeData || (!rawProbeData.audio_stream && !rawProbeData.video_stream)) {
|
||||
return {
|
||||
error: rawProbeData ? 'Invalid audio file: no audio streams found' : 'Probe Failed'
|
||||
error: rawProbeData ? 'Invalid media file: no audio or video streams found' : 'Probe Failed'
|
||||
}
|
||||
} else {
|
||||
var probeData = new AudioProbeData()
|
||||
var probeData = new MediaProbeData()
|
||||
probeData.setData(rawProbeData)
|
||||
return probeData
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ function isMediaFile(mediaType, ext) {
|
||||
if (!ext) return false
|
||||
var extclean = ext.slice(1).toLowerCase()
|
||||
if (mediaType === 'podcast') return globals.SupportedAudioTypes.includes(extclean)
|
||||
else if (mediaType === 'video') return globals.SupportedVideoTypes.includes(extclean)
|
||||
return globals.SupportedAudioTypes.includes(extclean) || globals.SupportedEbookTypes.includes(extclean)
|
||||
}
|
||||
|
||||
@@ -72,7 +73,7 @@ module.exports.groupFilesIntoLibraryItemPaths = groupFilesIntoLibraryItemPaths
|
||||
function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems) {
|
||||
// Step 1: Filter out non-book-media files in root dir (with depth of 0)
|
||||
var itemsFiltered = fileItems.filter(i => {
|
||||
return i.deep > 0 || (mediaType === 'book' && isMediaFile(mediaType, i.extension))
|
||||
return i.deep > 0 || ((mediaType === 'book' || mediaType === 'video') && isMediaFile(mediaType, i.extension))
|
||||
})
|
||||
|
||||
// Step 2: Seperate media files and other files
|
||||
@@ -136,7 +137,7 @@ function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems) {
|
||||
}
|
||||
|
||||
function cleanFileObjects(libraryItemPath, files) {
|
||||
return Promise.all(files.map(async(file) => {
|
||||
return Promise.all(files.map(async (file) => {
|
||||
var filePath = Path.posix.join(libraryItemPath, file)
|
||||
var newLibraryFile = new LibraryFile()
|
||||
await newLibraryFile.setDataFromPath(filePath, file)
|
||||
@@ -314,9 +315,11 @@ function getPodcastDataFromDir(folderPath, relPath) {
|
||||
function getDataFromMediaDir(libraryMediaType, folderPath, relPath, serverSettings) {
|
||||
if (libraryMediaType === 'podcast') {
|
||||
return getPodcastDataFromDir(folderPath, relPath)
|
||||
} else {
|
||||
} else if (libraryMediaType === 'book') {
|
||||
var parseSubtitle = !!serverSettings.scannerParseSubtitle
|
||||
return getBookDataFromDir(folderPath, relPath, parseSubtitle)
|
||||
} else {
|
||||
return this.getPodcastDataFromDir(folderPath, relPath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,10 +336,10 @@ async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath,
|
||||
if (isSingleMediaItem) { // Single media item in root of folder
|
||||
fileItems = [
|
||||
{
|
||||
fullpath: libraryItemPath,
|
||||
path: libraryItemDir // actually the relPath (only filename here)
|
||||
}
|
||||
]
|
||||
fullpath: libraryItemPath,
|
||||
path: libraryItemDir // actually the relPath (only filename here)
|
||||
}
|
||||
]
|
||||
libraryItemData = {
|
||||
path: libraryItemPath, // full path
|
||||
relPath: libraryItemDir, // only filename
|
||||
|
||||
Reference in New Issue
Block a user