mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-03-28 00:21:47 +01:00
Testing video media type
This commit is contained in:
parent
705aac40d7
commit
acf22ca4fa
@ -1,14 +1,15 @@
|
|||||||
<template>
|
<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">
|
<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" />
|
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</nuxt-link>
|
</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>
|
<div>
|
||||||
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-base sm:text-lg">
|
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-base sm:text-lg">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</nuxt-link>
|
</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>
|
<span class="material-icons text-sm">person</span>
|
||||||
<p v-if="podcastAuthor">{{ podcastAuthor }}</p>
|
<p v-if="podcastAuthor">{{ podcastAuthor }}</p>
|
||||||
<p v-else-if="authors.length" class="pl-1.5 text-sm sm:text-base">
|
<p v-else-if="authors.length" class="pl-1.5 text-sm sm:text-base">
|
||||||
|
@ -62,6 +62,10 @@ export default {
|
|||||||
{
|
{
|
||||||
value: 'podcast',
|
value: 'podcast',
|
||||||
text: 'Podcasts'
|
text: 'Podcasts'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'video',
|
||||||
|
text: 'Videos'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -31,11 +31,13 @@
|
|||||||
<p v-if="bookSubtitle" class="sm:ml-4 text-gray-400 text-xl md:text-2xl">{{ bookSubtitle }}</p>
|
<p v-if="bookSubtitle" class="sm:ml-4 text-gray-400 text-xl md:text-2xl">{{ bookSubtitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">by {{ podcastAuthor || 'Unknown' }}</p>
|
<template v-if="!isVideo">
|
||||||
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">
|
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">by {{ podcastAuthor || 'Unknown' }}</p>
|
||||||
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 v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">
|
||||||
</p>
|
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 v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
|
</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>
|
<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() {
|
isPodcast() {
|
||||||
return this.libraryItem.mediaType === 'podcast'
|
return this.libraryItem.mediaType === 'podcast'
|
||||||
},
|
},
|
||||||
|
isVideo() {
|
||||||
|
return this.libraryItem.mediaType === 'video'
|
||||||
|
},
|
||||||
isMissing() {
|
isMissing() {
|
||||||
return this.libraryItem.isMissing
|
return this.libraryItem.isMissing
|
||||||
},
|
},
|
||||||
@ -258,11 +263,12 @@ export default {
|
|||||||
return this.libraryItem.isInvalid
|
return this.libraryItem.isInvalid
|
||||||
},
|
},
|
||||||
invalidAudioFiles() {
|
invalidAudioFiles() {
|
||||||
if (this.isPodcast) return []
|
if (this.isPodcast || this.isVideo) return []
|
||||||
return this.libraryItem.media.audioFiles.filter((af) => af.invalid)
|
return this.libraryItem.media.audioFiles.filter((af) => af.invalid)
|
||||||
},
|
},
|
||||||
showPlayButton() {
|
showPlayButton() {
|
||||||
if (this.isMissing || this.isInvalid) return false
|
if (this.isMissing || this.isInvalid) return false
|
||||||
|
if (this.isVideo) return !!this.videoFile
|
||||||
if (this.isPodcast) return this.podcastEpisodes.length
|
if (this.isPodcast) return this.podcastEpisodes.length
|
||||||
return this.tracks.length
|
return this.tracks.length
|
||||||
},
|
},
|
||||||
@ -348,6 +354,9 @@ export default {
|
|||||||
ebookFile() {
|
ebookFile() {
|
||||||
return this.media.ebookFile
|
return this.media.ebookFile
|
||||||
},
|
},
|
||||||
|
videoFile() {
|
||||||
|
return this.media.videoFile
|
||||||
|
},
|
||||||
showExperimentalReadAlert() {
|
showExperimentalReadAlert() {
|
||||||
return !this.tracks.length && this.ebookFile && !this.showExperimentalFeatures && !this.enableEReader
|
return !this.tracks.length && this.ebookFile && !this.showExperimentalFeatures && !this.enableEReader
|
||||||
},
|
},
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import Hls from 'hls.js'
|
import Hls from 'hls.js'
|
||||||
import EventEmitter from 'events'
|
import EventEmitter from 'events'
|
||||||
|
|
||||||
export default class LocalPlayer extends EventEmitter {
|
export default class LocalAudioPlayer extends EventEmitter {
|
||||||
constructor(ctx) {
|
constructor(ctx) {
|
||||||
super()
|
super()
|
||||||
|
|
257
client/players/LocalVideoPlayer.js
Normal file
257
client/players/LocalVideoPlayer.js
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
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 bottom-20 left-4 w-96 bg-black z-50'
|
||||||
|
videoEl.style.height = '216px'
|
||||||
|
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 CastPlayer from './CastPlayer'
|
||||||
import AudioTrack from './AudioTrack'
|
import AudioTrack from './AudioTrack'
|
||||||
|
import VideoTrack from './VideoTrack'
|
||||||
|
|
||||||
export default class PlayerHandler {
|
export default class PlayerHandler {
|
||||||
constructor(ctx) {
|
constructor(ctx) {
|
||||||
@ -14,6 +16,7 @@ export default class PlayerHandler {
|
|||||||
this.player = null
|
this.player = null
|
||||||
this.playerState = 'IDLE'
|
this.playerState = 'IDLE'
|
||||||
this.isHlsTranscode = false
|
this.isHlsTranscode = false
|
||||||
|
this.isVideo = false
|
||||||
this.currentSessionId = null
|
this.currentSessionId = null
|
||||||
this.startTime = 0
|
this.startTime = 0
|
||||||
|
|
||||||
@ -34,7 +37,7 @@ export default class PlayerHandler {
|
|||||||
return this.libraryItem && (this.player instanceof CastPlayer)
|
return this.libraryItem && (this.player instanceof CastPlayer)
|
||||||
}
|
}
|
||||||
get isPlayingLocalItem() {
|
get isPlayingLocalItem() {
|
||||||
return this.libraryItem && (this.player instanceof LocalPlayer)
|
return this.libraryItem && (this.player instanceof LocalAudioPlayer)
|
||||||
}
|
}
|
||||||
get userToken() {
|
get userToken() {
|
||||||
return this.ctx.$store.getters['user/getToken']
|
return this.ctx.$store.getters['user/getToken']
|
||||||
@ -48,16 +51,17 @@ export default class PlayerHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
load(libraryItem, episodeId, playWhenReady, playbackRate) {
|
load(libraryItem, episodeId, playWhenReady, playbackRate) {
|
||||||
if (!this.player) this.switchPlayer()
|
|
||||||
|
|
||||||
this.libraryItem = libraryItem
|
this.libraryItem = libraryItem
|
||||||
this.episodeId = episodeId
|
this.episodeId = episodeId
|
||||||
this.playWhenReady = playWhenReady
|
this.playWhenReady = playWhenReady
|
||||||
this.initialPlaybackRate = playbackRate
|
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)) {
|
if (this.isCasting && !(this.player instanceof CastPlayer)) {
|
||||||
console.log('[PlayerHandler] Switching to cast player')
|
console.log('[PlayerHandler] Switching to cast player')
|
||||||
|
|
||||||
@ -73,10 +77,10 @@ export default class PlayerHandler {
|
|||||||
|
|
||||||
if (this.libraryItem) {
|
if (this.libraryItem) {
|
||||||
// libraryItem was already loaded - prepare for cast
|
// libraryItem was already loaded - prepare for cast
|
||||||
this.playWhenReady = false
|
this.playWhenReady = playWhenReady
|
||||||
this.prepare()
|
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')
|
console.log('[PlayerHandler] Switching to local player')
|
||||||
|
|
||||||
this.stopPlayInterval()
|
this.stopPlayInterval()
|
||||||
@ -85,12 +89,18 @@ export default class PlayerHandler {
|
|||||||
if (this.player) {
|
if (this.player) {
|
||||||
this.player.destroy()
|
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()
|
this.setPlayerListeners()
|
||||||
|
|
||||||
if (this.libraryItem) {
|
if (this.libraryItem) {
|
||||||
// libraryItem was already loaded - prepare for local play
|
// libraryItem was already loaded - prepare for local play
|
||||||
this.playWhenReady = false
|
this.playWhenReady = playWhenReady
|
||||||
this.prepare()
|
this.prepare()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -106,7 +116,7 @@ export default class PlayerHandler {
|
|||||||
|
|
||||||
playerError() {
|
playerError() {
|
||||||
// Switch to HLS stream on error
|
// 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`)
|
console.log(`[PlayerHandler] Audio player error switching to HLS stream`)
|
||||||
this.prepare(true)
|
this.prepare(true)
|
||||||
}
|
}
|
||||||
@ -155,7 +165,7 @@ export default class PlayerHandler {
|
|||||||
supportedMimeTypes: this.player.playableMimeTypes,
|
supportedMimeTypes: this.player.playableMimeTypes,
|
||||||
mediaPlayer: this.isCasting ? 'chromecast' : 'html5',
|
mediaPlayer: this.isCasting ? 'chromecast' : 'html5',
|
||||||
forceTranscode,
|
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`
|
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
|
prepareOpenSession(session, playbackRate) { // Session opened on init socket
|
||||||
if (!this.player) this.switchPlayer()
|
|
||||||
|
|
||||||
this.libraryItem = session.libraryItem
|
this.libraryItem = session.libraryItem
|
||||||
|
this.isVideo = session.libraryItem.mediaType === 'video'
|
||||||
this.playWhenReady = false
|
this.playWhenReady = false
|
||||||
this.initialPlaybackRate = playbackRate
|
this.initialPlaybackRate = playbackRate
|
||||||
|
|
||||||
|
if (!this.player) this.switchPlayer()
|
||||||
this.prepareSession(session)
|
this.prepareSession(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -181,16 +192,29 @@ export default class PlayerHandler {
|
|||||||
this.displayAuthor = session.displayAuthor
|
this.displayAuthor = session.displayAuthor
|
||||||
|
|
||||||
console.log('[PlayerHandler] Preparing Session', session)
|
console.log('[PlayerHandler] Preparing Session', session)
|
||||||
var audioTracks = session.audioTracks.map(at => new AudioTrack(at, this.userToken))
|
|
||||||
|
|
||||||
this.ctx.playerLoading = true
|
if (session.videoTrack) {
|
||||||
this.isHlsTranscode = true
|
var videoTrack = new VideoTrack(session.videoTrack, this.userToken)
|
||||||
if (session.playMethod === this.ctx.$constants.PlayMethod.DIRECTPLAY) {
|
|
||||||
this.isHlsTranscode = false
|
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
|
// browser media session api
|
||||||
this.ctx.setMediaSession()
|
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}`
|
||||||
|
}
|
||||||
|
}
|
@ -184,7 +184,7 @@ class LibraryItemController {
|
|||||||
|
|
||||||
// POST: api/items/:id/play
|
// POST: api/items/:id/play
|
||||||
startPlaybackSession(req, res) {
|
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}`)
|
Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${req.libraryItem.id}`)
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
|
@ -119,29 +119,38 @@ class PlaybackSessionManager {
|
|||||||
const newPlaybackSession = new PlaybackSession()
|
const newPlaybackSession = new PlaybackSession()
|
||||||
newPlaybackSession.setData(libraryItem, user, mediaPlayer, deviceInfo, userStartTime, episodeId)
|
newPlaybackSession.setData(libraryItem, user, mediaPlayer, deviceInfo, userStartTime, episodeId)
|
||||||
|
|
||||||
var audioTracks = []
|
if (libraryItem.mediaType === 'video') {
|
||||||
if (shouldDirectPlay) {
|
if (shouldDirectPlay) {
|
||||||
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}"`)
|
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}"`)
|
||||||
audioTracks = libraryItem.getDirectPlayTracklist(episodeId)
|
newPlaybackSession.videoTrack = libraryItem.media.getVideoTrack()
|
||||||
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
|
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
|
||||||
|
} else {
|
||||||
|
// HLS not supported for video yet
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting stream session for item "${libraryItem.id}"`)
|
var audioTracks = []
|
||||||
var stream = new Stream(newPlaybackSession.id, this.StreamsPath, user, libraryItem, episodeId, userStartTime, this.clientEmitter.bind(this))
|
if (shouldDirectPlay) {
|
||||||
await stream.generatePlaylist()
|
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}"`)
|
||||||
stream.start() // Start transcode
|
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()]
|
audioTracks = [stream.getAudioTrack()]
|
||||||
newPlaybackSession.stream = stream
|
newPlaybackSession.stream = stream
|
||||||
newPlaybackSession.playMethod = PlayMethod.TRANSCODE
|
newPlaybackSession.playMethod = PlayMethod.TRANSCODE
|
||||||
|
|
||||||
stream.on('closed', () => {
|
stream.on('closed', () => {
|
||||||
Logger.debug(`[PlaybackSessionManager] Stream closed for session "${newPlaybackSession.id}"`)
|
Logger.debug(`[PlaybackSessionManager] Stream closed for session "${newPlaybackSession.id}"`)
|
||||||
newPlaybackSession.stream = null
|
newPlaybackSession.stream = null
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
newPlaybackSession.audioTracks = audioTracks
|
||||||
}
|
}
|
||||||
|
|
||||||
newPlaybackSession.audioTracks = audioTracks
|
|
||||||
|
|
||||||
// Will save on the first sync
|
// Will save on the first sync
|
||||||
user.currentSessionId = newPlaybackSession.id
|
user.currentSessionId = newPlaybackSession.id
|
||||||
|
|
||||||
|
@ -143,13 +143,13 @@ class PodcastManager {
|
|||||||
|
|
||||||
async probeAudioFile(libraryFile) {
|
async probeAudioFile(libraryFile) {
|
||||||
var path = libraryFile.metadata.path
|
var path = libraryFile.metadata.path
|
||||||
var audioProbeData = await prober.probe(path)
|
var mediaProbeData = await prober.probe(path)
|
||||||
if (audioProbeData.error) {
|
if (mediaProbeData.error) {
|
||||||
Logger.error(`[PodcastManager] Podcast Episode downloaded but failed to probe "${path}"`, audioProbeData.error)
|
Logger.error(`[PodcastManager] Podcast Episode downloaded but failed to probe "${path}"`, mediaProbeData.error)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
var newAudioFile = new AudioFile()
|
var newAudioFile = new AudioFile()
|
||||||
newAudioFile.setDataFromProbe(libraryFile, audioProbeData)
|
newAudioFile.setDataFromProbe(libraryFile, mediaProbeData)
|
||||||
return newAudioFile
|
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 if (this.icon.endsWith('s') && availableIcons.includes(this.icon.slice(0, -1))) this.icon = this.icon.slice(0, -1)
|
||||||
else this.icon = 'database'
|
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'
|
this.mediaType = 'book'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,7 @@ const abmetadataGenerator = require('../utils/abmetadataGenerator')
|
|||||||
const LibraryFile = require('./files/LibraryFile')
|
const LibraryFile = require('./files/LibraryFile')
|
||||||
const Book = require('./mediaTypes/Book')
|
const Book = require('./mediaTypes/Book')
|
||||||
const Podcast = require('./mediaTypes/Podcast')
|
const Podcast = require('./mediaTypes/Podcast')
|
||||||
|
const Video = require('./mediatypes/Video')
|
||||||
const { areEquivalent, copyValue, getId } = require('../utils/index')
|
const { areEquivalent, copyValue, getId } = require('../utils/index')
|
||||||
|
|
||||||
class LibraryItem {
|
class LibraryItem {
|
||||||
@ -67,11 +68,12 @@ class LibraryItem {
|
|||||||
this.mediaType = libraryItem.mediaType
|
this.mediaType = libraryItem.mediaType
|
||||||
if (this.mediaType === 'book') {
|
if (this.mediaType === 'book') {
|
||||||
this.media = new Book(libraryItem.media)
|
this.media = new Book(libraryItem.media)
|
||||||
this.media.libraryItemId = this.id
|
|
||||||
} else if (this.mediaType === 'podcast') {
|
} else if (this.mediaType === 'podcast') {
|
||||||
this.media = new Podcast(libraryItem.media)
|
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))
|
this.libraryFiles = libraryItem.libraryFiles.map(f => new LibraryFile(f))
|
||||||
}
|
}
|
||||||
@ -175,16 +177,15 @@ class LibraryItem {
|
|||||||
// Data comes from scandir library item data
|
// Data comes from scandir library item data
|
||||||
setData(libraryMediaType, payload) {
|
setData(libraryMediaType, payload) {
|
||||||
this.id = getId('li')
|
this.id = getId('li')
|
||||||
if (libraryMediaType === 'podcast') {
|
this.mediaType = libraryMediaType
|
||||||
this.mediaType = 'podcast'
|
if (libraryMediaType === 'video') {
|
||||||
|
this.media = new Video()
|
||||||
|
} else if (libraryMediaType === 'podcast') {
|
||||||
this.media = new Podcast()
|
this.media = new Podcast()
|
||||||
this.media.libraryItemId = this.id
|
|
||||||
} else {
|
} else {
|
||||||
this.mediaType = 'book'
|
|
||||||
this.media = new Book()
|
this.media = new Book()
|
||||||
this.media.libraryItemId = this.id
|
|
||||||
}
|
}
|
||||||
|
this.media.libraryItemId = this.id
|
||||||
|
|
||||||
for (const key in payload) {
|
for (const key in payload) {
|
||||||
if (key === 'libraryFiles') {
|
if (key === 'libraryFiles') {
|
||||||
@ -460,6 +461,8 @@ class LibraryItem {
|
|||||||
|
|
||||||
// Saves metadata.abs file
|
// Saves metadata.abs file
|
||||||
async saveMetadata() {
|
async saveMetadata() {
|
||||||
|
if (this.mediaType === 'video') return
|
||||||
|
|
||||||
if (this.isSavingMetadata) return
|
if (this.isSavingMetadata) return
|
||||||
this.isSavingMetadata = true
|
this.isSavingMetadata = true
|
||||||
|
|
||||||
|
@ -4,6 +4,7 @@ const { PlayMethod } = require('../utils/constants')
|
|||||||
const BookMetadata = require('./metadata/BookMetadata')
|
const BookMetadata = require('./metadata/BookMetadata')
|
||||||
const PodcastMetadata = require('./metadata/PodcastMetadata')
|
const PodcastMetadata = require('./metadata/PodcastMetadata')
|
||||||
const DeviceInfo = require('./DeviceInfo')
|
const DeviceInfo = require('./DeviceInfo')
|
||||||
|
const VideoMetadata = require('./metadata/VideoMetadata')
|
||||||
|
|
||||||
class PlaybackSession {
|
class PlaybackSession {
|
||||||
constructor(session) {
|
constructor(session) {
|
||||||
@ -38,6 +39,7 @@ class PlaybackSession {
|
|||||||
// Not saved in DB
|
// Not saved in DB
|
||||||
this.lastSave = 0
|
this.lastSave = 0
|
||||||
this.audioTracks = []
|
this.audioTracks = []
|
||||||
|
this.videoTrack = null
|
||||||
this.stream = null
|
this.stream = null
|
||||||
|
|
||||||
if (session) {
|
if (session) {
|
||||||
@ -97,6 +99,7 @@ class PlaybackSession {
|
|||||||
startedAt: this.startedAt,
|
startedAt: this.startedAt,
|
||||||
updatedAt: this.updatedAt,
|
updatedAt: this.updatedAt,
|
||||||
audioTracks: this.audioTracks.map(at => at.toJSON()),
|
audioTracks: this.audioTracks.map(at => at.toJSON()),
|
||||||
|
videoTrack: this.videoTrack ? this.videoTrack.toJSON() : null,
|
||||||
libraryItem: libraryItem.toJSONExpanded()
|
libraryItem: libraryItem.toJSONExpanded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -120,6 +123,8 @@ class PlaybackSession {
|
|||||||
this.mediaMetadata = new BookMetadata(session.mediaMetadata)
|
this.mediaMetadata = new BookMetadata(session.mediaMetadata)
|
||||||
} else if (this.mediaType === 'podcast') {
|
} else if (this.mediaType === 'podcast') {
|
||||||
this.mediaMetadata = new PodcastMetadata(session.mediaMetadata)
|
this.mediaMetadata = new PodcastMetadata(session.mediaMetadata)
|
||||||
|
} else if (this.mediaType === 'video') {
|
||||||
|
this.mediaMetadata = new VideoMetadata(session.mediaMetadata)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.displayTitle = session.displayTitle || ''
|
this.displayTitle = session.displayTitle || ''
|
||||||
|
@ -40,13 +40,14 @@ class LibraryFile {
|
|||||||
if (globals.SupportedImageTypes.includes(this.metadata.format)) return 'image'
|
if (globals.SupportedImageTypes.includes(this.metadata.format)) return 'image'
|
||||||
if (globals.SupportedAudioTypes.includes(this.metadata.format)) return 'audio'
|
if (globals.SupportedAudioTypes.includes(this.metadata.format)) return 'audio'
|
||||||
if (globals.SupportedEbookTypes.includes(this.metadata.format)) return 'ebook'
|
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.TextFileTypes.includes(this.metadata.format)) return 'text'
|
||||||
if (globals.MetadataFileTypes.includes(this.metadata.format)) return 'metadata'
|
if (globals.MetadataFileTypes.includes(this.metadata.format)) return 'metadata'
|
||||||
return 'unknown'
|
return 'unknown'
|
||||||
}
|
}
|
||||||
|
|
||||||
get isMediaFile() {
|
get isMediaFile() {
|
||||||
return this.fileType === 'audio' || this.fileType === 'ebook'
|
return this.fileType === 'audio' || this.fileType === 'ebook' || this.fileType === 'video'
|
||||||
}
|
}
|
||||||
|
|
||||||
get isOPFFile() {
|
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
|
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
|
@ -343,6 +343,7 @@ class User {
|
|||||||
|
|
||||||
checkCanAccessLibraryItem(libraryItem) {
|
checkCanAccessLibraryItem(libraryItem) {
|
||||||
if (!this.checkCanAccessLibrary(libraryItem.libraryId)) return false
|
if (!this.checkCanAccessLibrary(libraryItem.libraryId)) return false
|
||||||
|
|
||||||
if (libraryItem.media.metadata.explicit && !this.canAccessExplicitContent) return false
|
if (libraryItem.media.metadata.explicit && !this.canAccessExplicitContent) return false
|
||||||
return this.checkCanAccessLibraryItemWithTags(libraryItem.media.tags)
|
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')
|
const AudioFileMetadata = require('../objects/metadata/AudioMetaTags')
|
||||||
|
|
||||||
class AudioProbeData {
|
class MediaProbeData {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.embeddedCoverArt = null
|
this.embeddedCoverArt = null
|
||||||
this.format = null
|
this.format = null
|
||||||
this.duration = null
|
this.duration = null
|
||||||
this.size = null
|
this.size = null
|
||||||
|
|
||||||
|
this.audioStream = null
|
||||||
|
this.videoStream = null
|
||||||
|
|
||||||
this.bitRate = null
|
this.bitRate = null
|
||||||
this.codec = null
|
this.codec = null
|
||||||
this.timeBase = null
|
this.timeBase = null
|
||||||
@ -35,6 +39,10 @@ class AudioProbeData {
|
|||||||
this.format = data.format
|
this.format = data.format
|
||||||
this.duration = data.duration
|
this.duration = data.duration
|
||||||
this.size = data.size
|
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.bitRate = audioStream.bit_rate || data.bit_rate
|
||||||
this.codec = audioStream.codec
|
this.codec = audioStream.codec
|
||||||
this.timeBase = audioStream.time_base
|
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 { getIno } = require('../utils/fileUtils')
|
||||||
const { ScanResult, LogLevel } = require('../utils/constants')
|
const { ScanResult, LogLevel } = require('../utils/constants')
|
||||||
|
|
||||||
const AudioFileScanner = require('./AudioFileScanner')
|
const MediaFileScanner = require('./MediaFileScanner')
|
||||||
const BookFinder = require('../finders/BookFinder')
|
const BookFinder = require('../finders/BookFinder')
|
||||||
const LibraryItem = require('../objects/LibraryItem')
|
const LibraryItem = require('../objects/LibraryItem')
|
||||||
const LibraryScan = require('./LibraryScan')
|
const LibraryScan = require('./LibraryScan')
|
||||||
@ -80,7 +80,7 @@ class Scanner {
|
|||||||
// Scan all audio files
|
// Scan all audio files
|
||||||
if (libraryItem.hasAudioFiles) {
|
if (libraryItem.hasAudioFiles) {
|
||||||
var libraryAudioFiles = libraryItem.libraryFiles.filter(lf => lf.fileType === 'audio')
|
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
|
hasUpdated = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -240,18 +240,18 @@ class Scanner {
|
|||||||
if (!hasMediaFile) {
|
if (!hasMediaFile) {
|
||||||
libraryScan.addLog(LogLevel.WARN, `Item found "${libraryItemDataFound.path}" has no media files`)
|
libraryScan.addLog(LogLevel.WARN, `Item found "${libraryItemDataFound.path}" has no media files`)
|
||||||
} else {
|
} else {
|
||||||
var audioFileSize = 0
|
var mediaFileSize = 0
|
||||||
dataFound.libraryFiles.filter(lf => lf.fileType == 'audio').forEach(lf => audioFileSize += lf.metadata.size)
|
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 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)
|
newItemDataToScanChunks.push(newItemDataToScan)
|
||||||
newItemDataToScanSize = 0
|
newItemDataToScanSize = 0
|
||||||
newItemDataToScan = []
|
newItemDataToScan = []
|
||||||
}
|
}
|
||||||
|
|
||||||
newItemDataToScan.push(dataFound)
|
newItemDataToScan.push(dataFound)
|
||||||
newItemDataToScanSize += audioFileSize
|
newItemDataToScanSize += mediaFileSize
|
||||||
if (newItemDataToScanSize >= MaxSizePerChunk) {
|
if (newItemDataToScanSize >= MaxSizePerChunk) {
|
||||||
newItemDataToScanChunks.push(newItemDataToScan)
|
newItemDataToScanChunks.push(newItemDataToScan)
|
||||||
newItemDataToScanSize = 0
|
newItemDataToScanSize = 0
|
||||||
@ -337,7 +337,7 @@ class Scanner {
|
|||||||
// forceRescan all existing audio files - will probe and update ID3 tag metadata
|
// forceRescan all existing audio files - will probe and update ID3 tag metadata
|
||||||
var existingAudioFiles = existingLibraryFiles.filter(lf => lf.fileType === 'audio')
|
var existingAudioFiles = existingLibraryFiles.filter(lf => lf.fileType === 'audio')
|
||||||
if (libraryScan.scanOptions.forceRescan && existingAudioFiles.length) {
|
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
|
hasUpdated = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -345,7 +345,7 @@ class Scanner {
|
|||||||
var newAudioFiles = newLibraryFiles.filter(lf => lf.fileType === 'audio')
|
var newAudioFiles = newLibraryFiles.filter(lf => lf.fileType === 'audio')
|
||||||
var removedAudioFiles = filesRemoved.filter(lf => lf.fileType === 'audio')
|
var removedAudioFiles = filesRemoved.filter(lf => lf.fileType === 'audio')
|
||||||
if (newAudioFiles.length || removedAudioFiles.length) {
|
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
|
hasUpdated = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -386,9 +386,9 @@ class Scanner {
|
|||||||
var libraryItem = new LibraryItem()
|
var libraryItem = new LibraryItem()
|
||||||
libraryItem.setData(libraryMediaType, libraryItemData)
|
libraryItem.setData(libraryMediaType, libraryItemData)
|
||||||
|
|
||||||
var audioFiles = libraryItemData.libraryFiles.filter(lf => lf.fileType === 'audio')
|
var mediaFiles = libraryItemData.libraryFiles.filter(lf => lf.fileType === 'audio' || lf.fileType === 'video')
|
||||||
if (audioFiles.length) {
|
if (mediaFiles.length) {
|
||||||
await AudioFileScanner.scanAudioFiles(audioFiles, libraryItemData, libraryItem, preferAudioMetadata, libraryScan)
|
await MediaFileScanner.scanMediaFiles(mediaFiles, libraryItemData, libraryItem, preferAudioMetadata, libraryScan)
|
||||||
}
|
}
|
||||||
|
|
||||||
await libraryItem.syncFiles(preferOpfMetadata)
|
await libraryItem.syncFiles(preferOpfMetadata)
|
||||||
@ -408,7 +408,7 @@ class Scanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Scan for cover if enabled and has no cover
|
// Scan for cover if enabled and has no cover
|
||||||
if (libraryMediaType !== 'podcast') {
|
if (libraryMediaType === 'book') {
|
||||||
if (libraryItem && findCovers && !libraryItem.media.coverPath && libraryItem.media.shouldSearchForCover) {
|
if (libraryItem && findCovers && !libraryItem.media.coverPath && libraryItem.media.shouldSearchForCover) {
|
||||||
var updatedCover = await this.searchForCover(libraryItem, libraryScan)
|
var updatedCover = await this.searchForCover(libraryItem, libraryScan)
|
||||||
libraryItem.media.updateLastCoverSearch(updatedCover)
|
libraryItem.media.updateLastCoverSearch(updatedCover)
|
||||||
|
@ -45,3 +45,7 @@ module.exports.AudioMimeType = {
|
|||||||
WMA: 'audio/x-ms-wma',
|
WMA: 'audio/x-ms-wma',
|
||||||
AIFF: 'audio/x-aiff'
|
AIFF: 'audio/x-aiff'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
module.exports.VideoMimeType = {
|
||||||
|
MP4: 'video/mp4'
|
||||||
|
}
|
@ -2,6 +2,7 @@ const globals = {
|
|||||||
SupportedImageTypes: ['png', 'jpg', 'jpeg', 'webp'],
|
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'],
|
||||||
SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
|
SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
|
||||||
|
SupportedVideoTypes: ['mp4'],
|
||||||
TextFileTypes: ['txt', 'nfo'],
|
TextFileTypes: ['txt', 'nfo'],
|
||||||
MetadataFileTypes: ['opf', 'abs', 'xml']
|
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 (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) => {
|
mediaMetadata.genres.forEach((genre) => {
|
||||||
if (genre && !data.genres.includes(genre)) data.genres.push(genre)
|
if (genre && !data.genres.includes(genre)) data.genres.push(genre)
|
||||||
})
|
})
|
||||||
@ -399,7 +399,7 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else if (libraryItem.isBook) {
|
||||||
// Book categories
|
// Book categories
|
||||||
|
|
||||||
// Newest series
|
// Newest series
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
const ffprobe = require('node-ffprobe')
|
const ffprobe = require('node-ffprobe')
|
||||||
const AudioProbeData = require('../scanner/AudioProbeData')
|
const MediaProbeData = require('../scanner/MediaProbeData')
|
||||||
|
|
||||||
const Logger = require('../Logger')
|
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) {
|
function probe(filepath, verbose = false) {
|
||||||
if (process.env.FFPROBE_PATH) {
|
if (process.env.FFPROBE_PATH) {
|
||||||
ffprobe.FFPROBE_PATH = process.env.FFPROBE_PATH
|
ffprobe.FFPROBE_PATH = process.env.FFPROBE_PATH
|
||||||
@ -283,12 +283,12 @@ function probe(filepath, verbose = false) {
|
|||||||
return ffprobe(filepath)
|
return ffprobe(filepath)
|
||||||
.then(raw => {
|
.then(raw => {
|
||||||
var rawProbeData = parseProbeData(raw, verbose)
|
var rawProbeData = parseProbeData(raw, verbose)
|
||||||
if (!rawProbeData || !rawProbeData.audio_stream) {
|
if (!rawProbeData || (!rawProbeData.audio_stream && !rawProbeData.video_stream)) {
|
||||||
return {
|
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 {
|
} else {
|
||||||
var probeData = new AudioProbeData()
|
var probeData = new MediaProbeData()
|
||||||
probeData.setData(rawProbeData)
|
probeData.setData(rawProbeData)
|
||||||
return probeData
|
return probeData
|
||||||
}
|
}
|
||||||
|
@ -11,6 +11,7 @@ function isMediaFile(mediaType, ext) {
|
|||||||
if (!ext) return false
|
if (!ext) return false
|
||||||
var extclean = ext.slice(1).toLowerCase()
|
var extclean = ext.slice(1).toLowerCase()
|
||||||
if (mediaType === 'podcast') return globals.SupportedAudioTypes.includes(extclean)
|
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)
|
return globals.SupportedAudioTypes.includes(extclean) || globals.SupportedEbookTypes.includes(extclean)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -72,7 +73,7 @@ module.exports.groupFilesIntoLibraryItemPaths = groupFilesIntoLibraryItemPaths
|
|||||||
function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems) {
|
function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems) {
|
||||||
// Step 1: Filter out non-book-media files in root dir (with depth of 0)
|
// Step 1: Filter out non-book-media files in root dir (with depth of 0)
|
||||||
var itemsFiltered = fileItems.filter(i => {
|
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
|
// Step 2: Seperate media files and other files
|
||||||
@ -136,7 +137,7 @@ function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cleanFileObjects(libraryItemPath, files) {
|
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 filePath = Path.posix.join(libraryItemPath, file)
|
||||||
var newLibraryFile = new LibraryFile()
|
var newLibraryFile = new LibraryFile()
|
||||||
await newLibraryFile.setDataFromPath(filePath, file)
|
await newLibraryFile.setDataFromPath(filePath, file)
|
||||||
@ -314,9 +315,11 @@ function getPodcastDataFromDir(folderPath, relPath) {
|
|||||||
function getDataFromMediaDir(libraryMediaType, folderPath, relPath, serverSettings) {
|
function getDataFromMediaDir(libraryMediaType, folderPath, relPath, serverSettings) {
|
||||||
if (libraryMediaType === 'podcast') {
|
if (libraryMediaType === 'podcast') {
|
||||||
return getPodcastDataFromDir(folderPath, relPath)
|
return getPodcastDataFromDir(folderPath, relPath)
|
||||||
} else {
|
} else if (libraryMediaType === 'book') {
|
||||||
var parseSubtitle = !!serverSettings.scannerParseSubtitle
|
var parseSubtitle = !!serverSettings.scannerParseSubtitle
|
||||||
return getBookDataFromDir(folderPath, relPath, parseSubtitle)
|
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
|
if (isSingleMediaItem) { // Single media item in root of folder
|
||||||
fileItems = [
|
fileItems = [
|
||||||
{
|
{
|
||||||
fullpath: libraryItemPath,
|
fullpath: libraryItemPath,
|
||||||
path: libraryItemDir // actually the relPath (only filename here)
|
path: libraryItemDir // actually the relPath (only filename here)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
libraryItemData = {
|
libraryItemData = {
|
||||||
path: libraryItemPath, // full path
|
path: libraryItemPath, // full path
|
||||||
relPath: libraryItemDir, // only filename
|
relPath: libraryItemDir, // only filename
|
||||||
|
Loading…
Reference in New Issue
Block a user